diff --git a/.github/workflows/evo-deploy.yml b/.github/workflows/evo-deploy.yml
index 697afc9..7aad6e8 100644
--- a/.github/workflows/evo-deploy.yml
+++ b/.github/workflows/evo-deploy.yml
@@ -1,9 +1,11 @@
name: evo-deploy
-# Deploy evOo-local on the Evo host when main advances, or on manual dispatch.
+# Deploy evOo-local on the Evo host once Product CI has passed on main, or on manual dispatch.
# Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow).
on:
- push:
+ workflow_run:
+ workflows: [ci]
+ types: [completed]
branches: [main]
workflow_dispatch:
inputs:
@@ -29,7 +31,7 @@ concurrency:
jobs:
deploy:
- if: vars.EVO_DEPLOY_ENABLED == 'true'
+ if: vars.EVO_DEPLOY_ENABLED == 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
steps:
@@ -37,12 +39,12 @@ jobs:
uses: actions/checkout@v5
with:
persist-credentials: false
- ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
+ ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}
- name: Deploy evOo-local on Evo
env:
EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }}
- EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
+ EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}
EVO_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }}
EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }}
@@ -72,7 +74,7 @@ jobs:
{
echo "### Evo deploy"
echo
- echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}\`"
+ echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}\`"
echo "- Dir: \`$EVO_DEPLOY_DIR\`"
if [[ -f "$EVO_STATE_FILE" ]]; then
echo "- State:"
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 00cd53b..25756e7 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -111,6 +111,24 @@ jobs:
env:
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
run: test -n "$BOX_API_KEY"
+ - name: Pick E2E ports for this runner
+ # Several runners share one host. Each runner gets its own port set so the
+ # port-freeing steps and the harness never touch another job's servers. No runner
+ # uses the harness defaults: jobs from branches that still free the fixed ports
+ # cannot kill ours, and we cannot kill theirs.
+ run: |
+ set -euo pipefail
+ suffix="${RUNNER_NAME##*-}"
+ offset=100
+ if [[ "$suffix" =~ ^[0-9]+$ ]]; then
+ offset=$((suffix * 100))
+ fi
+ {
+ echo "API_PORT=$((3110 + offset))"
+ echo "WEB_PORT=$((5180 + offset))"
+ echo "WWW_PORT=$((4321 + offset))"
+ } >> "$GITHUB_ENV"
+ echo "E2E ports for ${RUNNER_NAME}: api $((3110 + offset)), web $((5180 + offset)), www $((4321 + offset))"
- name: Free stale E2E ports
run: bash scripts/free-e2e-ports.sh
- name: Run Playwright tests
diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml
index 3c58ad7..d1904b5 100644
--- a/.github/workflows/publish-server-image.yml
+++ b/.github/workflows/publish-server-image.yml
@@ -71,10 +71,14 @@ jobs:
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ - name: Resolve the lowercase image namespace
+ id: ns
+ # GHCR rejects repository names with capitals; the GitHub repository slug has them.
+ run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
- images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
+ images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
@@ -93,8 +97,8 @@ jobs:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
if: github.event_name != 'pull_request'
# Each architecture builds natively on its own runner and pushes by digest only.
- # amd64 on evo Linux runners; arm64 on Apple Silicon macOS runners (linux/arm64 via buildx).
- # No QEMU: emulated arm64 took hours for the desktop-sized computer image.
+ # amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
+ # and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
@@ -105,7 +109,7 @@ jobs:
fail-fast: false
matrix:
name: [app, updater, computer]
- arch: [amd64, arm64]
+ arch: [amd64]
include:
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
# The supervisor is not a separate published image: it runs from `app` on the internal network.
@@ -120,8 +124,6 @@ jobs:
context: infra/sandboxes/computer
- arch: amd64
runner: [self-hosted, evoo-ci, Linux]
- - arch: arm64
- runner: [self-hosted, evoo-ci, macOS]
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
@@ -133,11 +135,15 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Resolve the lowercase image namespace
+ id: ns
+ # GHCR rejects repository names with capitals; the GitHub repository slug has them.
+ run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# Labels only; the tagged manifest is assembled in the publish job.
- images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
+ images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
@@ -147,7 +153,7 @@ jobs:
platforms: linux/${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
# A fork can publish only to its own namespace; no credential can redirect this value.
- outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
+ outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
# GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
@@ -199,11 +205,15 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Resolve the lowercase image namespace
+ id: ns
+ # GHCR rejects repository names with capitals; the GitHub repository slug has them.
+ run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# A fork can publish only to its own namespace; no credential can redirect this value.
- images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
+ images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
@@ -211,11 +221,11 @@ jobs:
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
# Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest.
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
- - name: Assemble and verify the multi-arch manifest
+ - name: Assemble and verify the manifest
id: manifest
working-directory: /tmp/digests
env:
- IMAGE: ghcr.io/${{ github.repository }}/${{ matrix.name }}
+ IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
@@ -227,7 +237,7 @@ jobs:
for digest in *; do
sources+=("${IMAGE}@sha256:${digest}")
done
- if [[ "${#sources[@]}" -ne 2 ]]; then
+ if [[ "${#sources[@]}" -ne 1 ]]; then
echo "Expected one digest per architecture, found ${#sources[@]}." >&2
exit 1
fi
@@ -236,13 +246,13 @@ jobs:
first_tag="$(head -n 1 <<< "$TAGS")"
platforms="$(docker buildx imagetools inspect "$first_tag" \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')"
- for want in linux/amd64 linux/arm64; do
+ for want in linux/amd64; do
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
done
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
- name: Attest the published image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
- subject-name: ghcr.io/${{ github.repository }}/${{ matrix.name }}
+ subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
diff --git a/apps/web/e2e/module-recovery.spec.ts b/apps/web/e2e/module-recovery.spec.ts
new file mode 100644
index 0000000..f557942
--- /dev/null
+++ b/apps/web/e2e/module-recovery.spec.ts
@@ -0,0 +1,38 @@
+import type { Page } from "@playwright/test";
+import { expect, test } from "@playwright/test";
+
+/** Abort the first request for a module once, then let every later request through. */
+async function abortFirstRequest(page: Page, pattern: string) {
+ const counter = { aborted: 0 };
+ await page.route(pattern, async (route) => {
+ if (counter.aborted === 0) {
+ counter.aborted += 1;
+ await route.abort("failed");
+ return;
+ }
+ await route.continue();
+ });
+ return counter;
+}
+
+test("the app reloads itself once when a module request aborts while booting", async ({ page }) => {
+ const counter = await abortFirstRequest(page, "**/src/App.tsx");
+
+ await page.goto("/sign-in", { waitUntil: "commit" });
+
+ await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
+ expect(counter.aborted).toBe(1);
+ expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull();
+});
+
+test("a lazy route recovers when its module request aborts", async ({ page }) => {
+ const counter = await abortFirstRequest(page, "**/src/pages/Auth.tsx");
+
+ await page.goto("/sign-in");
+
+ await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
+ expect(counter.aborted).toBe(1);
+ expect(
+ await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")),
+ ).toBeNull();
+});
diff --git a/apps/web/index.html b/apps/web/index.html
index 80df422..c626fbd 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -29,6 +29,26 @@
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
})();
+
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 979d4d6..12dd355 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,10 +1,11 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
import { Button, Skeleton } from "@rakazo/ui-web";
-import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
import { LoadingState } from "./components/ai/primitives";
import { authClient } from "./lib/auth";
+import { lazyWithRecovery } from "./lib/lazy-recovery";
import { markAfterPaint, markOnce } from "./lib/performance";
import {
holdUnreachableGate,
@@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell";
-const AuthPage = lazy(() =>
+const AuthPage = lazyWithRecovery("pages/Auth", () =>
import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
);
-const PasswordResetPage = lazy(() =>
+const PasswordResetPage = lazyWithRecovery("pages/Auth", () =>
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
);
-const OnboardingPage = lazy(() =>
+const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
);
-const WelcomePage = lazy(() =>
+const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
);
diff --git a/apps/web/src/lib/lazy-recovery.test.ts b/apps/web/src/lib/lazy-recovery.test.ts
new file mode 100644
index 0000000..d75d128
--- /dev/null
+++ b/apps/web/src/lib/lazy-recovery.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, vi } from "vitest";
+import { loadWithRecovery } from "./lazy-recovery.js";
+
+function fakeStorage(initial: Record = {}) {
+ const values = new Map(Object.entries(initial));
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => void values.set(key, value),
+ removeItem: (key: string) => void values.delete(key),
+ values,
+ };
+}
+
+const fetchFailure = () => new TypeError("Failed to fetch dynamically imported module");
+const flag = "rakazo:module-reload:pages/Onboarding";
+
+describe("loadWithRecovery", () => {
+ it("returns the module and clears the reload flag when the import succeeds", async () => {
+ const storage = fakeStorage({ [flag]: "1" });
+ const reload = vi.fn();
+ const loaded = await loadWithRecovery("pages/Onboarding", async () => "module", {
+ storage,
+ reload,
+ });
+ expect(loaded).toBe("module");
+ expect(storage.values.has(flag)).toBe(false);
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ it("retries the import in place before touching the page", async () => {
+ const storage = fakeStorage();
+ const reload = vi.fn();
+ const load = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(fetchFailure())
+ .mockResolvedValueOnce("module");
+ const loaded = await loadWithRecovery("pages/Onboarding", load, {
+ storage,
+ reload,
+ retryDelayMs: 0,
+ });
+ expect(loaded).toBe("module");
+ expect(load).toHaveBeenCalledTimes(2);
+ expect(reload).not.toHaveBeenCalled();
+ expect(storage.values.has(flag)).toBe(false);
+ });
+
+ it("reloads the page once when the retry fails as well", async () => {
+ const storage = fakeStorage();
+ const reload = vi.fn();
+ const load = vi.fn<() => Promise>().mockRejectedValue(fetchFailure());
+ const pending = loadWithRecovery("pages/Onboarding", load, {
+ storage,
+ reload,
+ retryDelayMs: 0,
+ });
+ const outcome = await Promise.race([
+ pending.then(() => "resolved"),
+ new Promise((resolve) => setTimeout(() => resolve("still pending"), 20)),
+ ]);
+ expect(outcome).toBe("still pending");
+ expect(load).toHaveBeenCalledTimes(2);
+ expect(reload).toHaveBeenCalledTimes(1);
+ expect(storage.values.get(flag)).toBe("1");
+ });
+
+ it("surfaces the failure instead of reloading again after a reload did not help", async () => {
+ const storage = fakeStorage({ [flag]: "1" });
+ const reload = vi.fn();
+ const failure = fetchFailure();
+ const load = vi.fn<() => Promise>().mockRejectedValue(failure);
+ await expect(
+ loadWithRecovery("pages/Onboarding", load, { storage, reload, retryDelayMs: 0 }),
+ ).rejects.toBe(failure);
+ expect(reload).not.toHaveBeenCalled();
+ expect(storage.values.get(flag)).toBe("1");
+ });
+});
diff --git a/apps/web/src/lib/lazy-recovery.ts b/apps/web/src/lib/lazy-recovery.ts
new file mode 100644
index 0000000..6a5a651
--- /dev/null
+++ b/apps/web/src/lib/lazy-recovery.ts
@@ -0,0 +1,58 @@
+import type { ComponentType } from "react";
+import { lazy } from "react";
+
+export interface RecoveryOptions {
+ /** Pause before the in-place retry; the default covers a brief interface change. */
+ retryDelayMs?: number;
+ storage?: Pick;
+ reload?: () => void;
+}
+
+const STORAGE_PREFIX = "rakazo:module-reload:";
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Load a lazily imported module, recovering from a fetch the browser aborted
+ * (a network interface changed, the deploy behind the URL moved on). The first
+ * recovery is a plain retry; when that fails too, the page reloads once, because
+ * browsers keep a failed module in their module map and keep rejecting the same
+ * import without touching the network. A second failure after the reload is
+ * surfaced to the caller.
+ */
+export async function loadWithRecovery(
+ key: string,
+ load: () => Promise,
+ options: RecoveryOptions = {},
+): Promise {
+ const storage = options.storage ?? window.sessionStorage;
+ const reload = options.reload ?? (() => window.location.reload());
+ const storageKey = `${STORAGE_PREFIX}${key}`;
+ try {
+ const loaded = await load();
+ storage.removeItem(storageKey);
+ return loaded;
+ } catch {
+ await delay(options.retryDelayMs ?? 300);
+ }
+ try {
+ const loaded = await load();
+ storage.removeItem(storageKey);
+ return loaded;
+ } catch (error) {
+ if (storage.getItem(storageKey)) throw error;
+ storage.setItem(storageKey, "1");
+ reload();
+ return new Promise(() => undefined);
+ }
+}
+
+/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */
+export function lazyWithRecovery>(
+ key: string,
+ load: () => Promise<{ default: T }>,
+) {
+ return lazy(() => loadWithRecovery(key, load));
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index a0bbd2c..1b03bf7 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -12,6 +12,10 @@ import { resolveUiLocale } from "./lib/ui-locale";
import "./styles.css";
markOnce("rk:renderer:module-evaluated");
+try {
+ // Set by the inline boot script in index.html before it reloads once.
+ sessionStorage.removeItem("rakazo:boot-reload");
+} catch {}
installPreloadRecovery();
applyUiDirection(resolveUiLocale());
applyUiAppearance();
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx
index ee2d117..6d0ba02 100644
--- a/apps/web/src/pages/Shell.tsx
+++ b/apps/web/src/pages/Shell.tsx
@@ -139,7 +139,6 @@ import {
import {
type ClipboardEvent,
type DragEvent,
- lazy,
type MutableRefObject,
memo,
type RefObject,
@@ -201,6 +200,7 @@ import {
resolveWorkingGroupBots,
} from "../lib/group-stage";
import { buildHeuteBrief } from "../lib/heute-brief";
+import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone";
import { copyableMessageText } from "../lib/message-text";
import { messageProviderLabel } from "../lib/messaging";
@@ -282,27 +282,29 @@ import {
} from "./shell/message-cards";
import { WindowChrome } from "./WindowChrome";
-const BotContextMenu = lazy(() =>
+const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
);
-const MessagingSettingsOverlay = lazy(() =>
+const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
import("./MessagingSettingsOverlay").then((module) => ({
default: module.MessagingSettingsOverlay,
})),
);
-const SettingsOverlay = lazy(() =>
+const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
);
-const PeerMessagesOverlay = lazy(() =>
+const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
);
-const PluginsOverlay = lazy(() =>
+const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
);
-const McpServersOverlay = lazy(() =>
+const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
);
-const CallView = lazy(() => import("./CallView").then((module) => ({ default: module.CallView })));
+const CallView = lazyWithRecovery("CallView", () =>
+ import("./CallView").then((module) => ({ default: module.CallView })),
+);
type Panel =
| "computer"
diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx
index fcbede4..74c336c 100644
--- a/apps/web/src/pages/shell/bot-panel.tsx
+++ b/apps/web/src/pages/shell/bot-panel.tsx
@@ -25,15 +25,16 @@ import {
Toggle,
} from "@rakazo/ui-web";
import { X } from "lucide-react";
-import { lazy, Suspense, useEffect, useId, useRef, useState } from "react";
+import { Suspense, useEffect, useId, useRef, useState } from "react";
+import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover";
-const ScratchpadSection = lazy(() =>
+const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
);
-const KnowledgeSection = lazy(() =>
+const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
);
diff --git a/docs/self-host.md b/docs/self-host.md
index ce91160..8510b82 100644
--- a/docs/self-host.md
+++ b/docs/self-host.md
@@ -36,8 +36,9 @@ Compose stack requires `SANDBOX_SUPERVISOR_TOKEN` for every provider; leave it e
Optional: set `OPENROUTER_API_KEY` or connect a model in the UI after signup.
-The example defaults to `edge` (main builds). Every publish is multi-arch (`amd64` + `arm64`), so
-arm64 hosts need no special tag. Do not assume `latest` is present until a stable release exists.
+The example defaults to `edge` (main builds). Published images are `linux/amd64` only for now;
+arm64 publishing is paused until an arm64 runner with Docker buildx exists. Do not assume `latest`
+is present until a stable release exists.
Open [http://127.0.0.1:5173](http://127.0.0.1:5173). The first registered user becomes the
deployment owner. Put TLS in front of `:5173` for a public host and set the three public origins to
@@ -483,8 +484,9 @@ your CI cannot publish into someone else's.
| `sha-` | every push and manual run | source-addressed; used by the updater sidecar |
| `edge` | pushes to main | yes, to the newest main build |
-Every publish, including `edge` from main merges, is multi-arch (`amd64` + `arm64`): each
-architecture builds natively on its own runner and one manifest is assembled per image. Until a
+Every publish, including `edge` from main merges, is `linux/amd64` only for now: the image builds
+natively on the Linux runner and one manifest is assembled per image. arm64 publishing is paused
+until an arm64 runner with Docker buildx exists. Until a
stable `vX.Y.Z` has been published, GHCR may only have `edge` and `sha-*` tags; do not pin
`latest` unless that tag exists in the registry.
diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts
index b4654c4..67e992a 100644
--- a/infra/updater/src/publish-workflow.test.ts
+++ b/infra/updater/src/publish-workflow.test.ts
@@ -19,31 +19,38 @@ const workflow = parse(workflowText) as {
};
describe("server image publish workflow", () => {
- it("builds every architecture natively instead of emulating arm64", () => {
+ it("builds amd64 natively on the Linux runner and keeps arm64 paused, not emulated", () => {
expect(workflowText).not.toContain("setup-qemu-action");
+ expect(workflowText).toContain("arm64 is paused");
const build = workflow.jobs.build;
expect(build["runs-on"]).toContain("matrix.runner");
- expect(build.strategy?.matrix?.arch).toEqual(["amd64", "arm64"]);
+ expect(build.strategy?.matrix?.arch).toEqual(["amd64"]);
const runners = Object.fromEntries(
(build.strategy?.matrix?.include ?? [])
.filter((entry) => entry.arch !== undefined)
.map((entry) => [entry.arch, entry.runner]),
);
- expect(runners).toEqual({
- amd64: ["self-hosted", "evoo-ci", "Linux"],
- arm64: ["self-hosted", "evoo-ci", "macOS"],
- });
+ expect(runners).toEqual({ amd64: ["self-hosted", "evoo-ci", "Linux"] });
});
- it("publishes one verified multi-arch manifest per image after both builds", () => {
+ it("publishes one verified manifest per image after the build", () => {
const publish = workflow.jobs.publish;
expect(publish.needs).toBe("build");
expect(workflowText).toContain("push-by-digest=true");
expect(workflowText).toContain("docker buildx imagetools create");
- expect(workflowText).toContain("for want in linux/amd64 linux/arm64");
+ expect(workflowText).toContain('if [[ "${#sources[@]}" -ne 1 ]]');
+ expect(workflowText).toContain("for want in linux/amd64; do");
expect(workflowText).toContain("actions/attest-build-provenance@");
});
+ it("names every image under the lowercase repository namespace", () => {
+ expect(workflowText).not.toContain("ghcr.io/${{ github.repository }}");
+ const references =
+ workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? [];
+ expect(references.length).toBeGreaterThanOrEqual(6);
+ expect(workflowText).toContain("repo=${GITHUB_REPOSITORY,,}");
+ });
+
it("keeps pull requests read-only and every action pinned to a commit", () => {
const validate = workflow.jobs.validate;
const build = workflow.jobs.build;
diff --git a/packages/testkit/src/e2e-ports.test.ts b/packages/testkit/src/e2e-ports.test.ts
index eac409a..247407c 100644
--- a/packages/testkit/src/e2e-ports.test.ts
+++ b/packages/testkit/src/e2e-ports.test.ts
@@ -1,9 +1,12 @@
-import { readFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
+const script = readFileSync(scriptPath, "utf8");
const playwrightWorkflow = readFileSync(
path.join(repoRoot, ".github/workflows/playwright.yml"),
"utf8",
@@ -15,20 +18,62 @@ function portsFromShellArray(source: string, name: string): number[] {
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port));
}
+/** Runs the script on a fake Linux host whose fuser never finds a listener, so nothing is killed. */
+function runScript(env: Record): { status: number; output: string } {
+ const fakeBin = mkdtempSync(path.join(tmpdir(), "evoo-e2e-ports-"));
+ for (const [name, body] of [
+ ["uname", "echo Linux"],
+ ["fuser", "exit 1"],
+ ] as const) {
+ const file = path.join(fakeBin, name);
+ writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`);
+ chmodSync(file, 0o755);
+ }
+ try {
+ const output = execFileSync("bash", [scriptPath], {
+ env: { PATH: `${fakeBin}:${process.env.PATH}`, ...env },
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ return { status: 0, output };
+ } catch (error) {
+ const failure = error as { status: number; stdout: string; stderr: string };
+ return { status: failure.status, output: `${failure.stdout}${failure.stderr}` };
+ }
+}
+
+function announcedPorts(output: string): number[] {
+ const match = output.match(/E2E ports \(([^)]*)\)/);
+ expect(match, "announced E2E ports").toBeTruthy();
+ return match![1].split(" ").map(Number);
+}
+
describe("free-e2e-ports.sh", () => {
- const script = readFileSync(scriptPath, "utf8");
-
- it("declares dedicated E2E ports separate from live dev ports", () => {
+ it("keeps the live dev ports apart from the default E2E ports", () => {
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
- const e2ePorts = portsFromShellArray(script, "E2E_PORTS");
-
expect(livePorts).toEqual([3100, 5173]);
+
+ const run = runScript({});
+ expect(run.status).toBe(0);
+ const e2ePorts = announcedPorts(run.output);
expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
for (const livePort of livePorts) {
expect(e2ePorts).not.toContain(livePort);
}
});
+ it("follows the harness port settings so runners on one host never touch each other", () => {
+ const run = runScript({ API_PORT: "3310", WEB_PORT: "5380", WWW_PORT: "4521" });
+ expect(run.status).toBe(0);
+ expect(announcedPorts(run.output)).toEqual([3310, 3311, 5380, 4521]);
+ });
+
+ it("refuses a port setting that is not a number", () => {
+ const run = runScript({ API_PORT: "abc" });
+ expect(run.status).toBe(1);
+ expect(run.output).toContain("API_PORT must be a port number");
+ });
+
it("uses fuser and skips non-Linux hosts", () => {
expect(script).toContain('fuser -k "$spec"');
expect(script).toContain('uname -s)" != "Linux"');
@@ -44,4 +89,13 @@ describe("free-e2e-ports.sh", () => {
playwrightWorkflow.indexOf("Run marketing homepage E2E"),
);
});
+
+ it("gets its ports from the workflow step that picks them per runner", () => {
+ const pickStep = playwrightWorkflow.indexOf("Pick E2E ports for this runner");
+ expect(pickStep).toBeGreaterThan(-1);
+ expect(pickStep).toBeLessThan(playwrightWorkflow.indexOf("free-e2e-ports.sh"));
+ for (const setting of ["API_PORT=", "WEB_PORT=", "WWW_PORT="]) {
+ expect(playwrightWorkflow).toContain(setting);
+ }
+ });
});
diff --git a/scripts/free-e2e-ports.sh b/scripts/free-e2e-ports.sh
index 754bcf8..32c29d8 100755
--- a/scripts/free-e2e-ports.sh
+++ b/scripts/free-e2e-ports.sh
@@ -1,11 +1,22 @@
#!/usr/bin/env bash
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
# Never touches live dev stack ports 3100 (API) or 5173 (web).
+# The ports follow API_PORT, WEB_PORT and WWW_PORT so that runners sharing one host,
+# each with its own port set, never kill another job's servers.
set -euo pipefail
+for setting in API_PORT WEB_PORT WWW_PORT; do
+ value="${!setting:-}"
+ if [[ -n "$value" && ! "$value" =~ ^[0-9]+$ ]]; then
+ echo "free-e2e-ports: $setting must be a port number, got: $value" >&2
+ exit 1
+ fi
+done
+
readonly LIVE_PORTS=(3100 5173)
-readonly E2E_PORTS=(3110 3111 5180 4321)
+api_port="${API_PORT:-3110}"
+readonly E2E_PORTS=("$api_port" "$((api_port + 1))" "${WEB_PORT:-5180}" "${WWW_PORT:-4321}")
if [[ "$(uname -s)" != "Linux" ]]; then
echo "free-e2e-ports: skip (not Linux)"
diff --git a/scripts/publish-playwright-report.sh b/scripts/publish-playwright-report.sh
index ffdbdcf..afbc04a 100755
--- a/scripts/publish-playwright-report.sh
+++ b/scripts/publish-playwright-report.sh
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
-: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
-: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
-: "${S3_BUCKET:?S3_BUCKET is required}"
-: "${S3_ENDPOINT:?S3_ENDPOINT is required}"
-: "${PLAYWRIGHT_PUBLIC_BASE_URL:?PLAYWRIGHT_PUBLIC_BASE_URL is required}"
+# Publishing is optional: without the S3 settings the gallery is skipped and the job stays green.
+for setting in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY S3_BUCKET S3_ENDPOINT PLAYWRIGHT_PUBLIC_BASE_URL; do
+ if [[ -z "${!setting:-}" ]]; then
+ echo "::notice::Skipping the Playwright report publish: $setting is not configured."
+ exit 0
+ fi
+done
: "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}"
: "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}"
: "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"