Merge remote-tracking branch 'origin/main' into feat/voice-aurora-stage

This commit is contained in:
Claude 2026-10-02 19:04:06 +00:00
commit 3b5d0d2b95
No known key found for this signature in database
16 changed files with 368 additions and 60 deletions

View File

@ -1,9 +1,11 @@
name: evo-deploy 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). # Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow).
on: on:
push: workflow_run:
workflows: [ci]
types: [completed]
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@ -29,7 +31,7 @@ concurrency:
jobs: jobs:
deploy: 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] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@ -37,12 +39,12 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
persist-credentials: false 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 - name: Deploy evOo-local on Evo
env: env:
EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }} 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_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }} EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }}
EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }} EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }}
@ -72,7 +74,7 @@ jobs:
{ {
echo "### Evo deploy" echo "### Evo deploy"
echo 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\`" echo "- Dir: \`$EVO_DEPLOY_DIR\`"
if [[ -f "$EVO_STATE_FILE" ]]; then if [[ -f "$EVO_STATE_FILE" ]]; then
echo "- State:" echo "- State:"

View File

@ -111,6 +111,24 @@ jobs:
env: env:
BOX_API_KEY: ${{ secrets.BOX_API_KEY }} BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
run: test -n "$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 - name: Free stale E2E ports
run: bash scripts/free-e2e-ports.sh run: bash scripts/free-e2e-ports.sh
- name: Run Playwright tests - name: Run Playwright tests

View File

@ -71,10 +71,14 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - 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 - id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
images: ghcr.io/${{ github.repository }}/${{ matrix.name }} images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha- tags: type=sha,prefix=sha-
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
@ -93,8 +97,8 @@ jobs:
name: Build ${{ matrix.name }} ${{ matrix.arch }} name: Build ${{ matrix.name }} ${{ matrix.arch }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
# Each architecture builds natively on its own runner and pushes by digest only. # 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). # amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
# No QEMU: emulated arm64 took hours for the desktop-sized computer image. # and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags). # See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 90 timeout-minutes: 90
@ -105,7 +109,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
name: [app, updater, computer] name: [app, updater, computer]
arch: [amd64, arm64] arch: [amd64]
include: include:
# Only the updater image contains the Docker CLI; the application image stays unprivileged. # 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. # 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 context: infra/sandboxes/computer
- arch: amd64 - arch: amd64
runner: [self-hosted, evoo-ci, Linux] runner: [self-hosted, evoo-ci, Linux]
- arch: arm64
runner: [self-hosted, evoo-ci, macOS]
steps: steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: with:
@ -133,11 +135,15 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} 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 - id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
# Labels only; the tagged manifest is assembled in the publish job. # 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- tags: type=sha,prefix=sha-
- id: build - id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
@ -147,7 +153,7 @@ jobs:
platforms: linux/${{ matrix.arch }} platforms: linux/${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
# A fork can publish only to its own namespace; no credential can redirect this value. # 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. # GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }} cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
@ -199,11 +205,15 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} 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 - id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
# A fork can publish only to its own namespace; no credential can redirect this value. # 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: | tags: |
type=semver,pattern=v{{version}} type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}} type=semver,pattern=v{{major}}.{{minor}}
@ -211,11 +221,11 @@ jobs:
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} 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. # 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, '-') }} 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 id: manifest
working-directory: /tmp/digests working-directory: /tmp/digests
env: env:
IMAGE: ghcr.io/${{ github.repository }}/${{ matrix.name }} IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
TAGS: ${{ steps.meta.outputs.tags }} TAGS: ${{ steps.meta.outputs.tags }}
run: | run: |
set -euo pipefail set -euo pipefail
@ -227,7 +237,7 @@ jobs:
for digest in *; do for digest in *; do
sources+=("${IMAGE}@sha256:${digest}") sources+=("${IMAGE}@sha256:${digest}")
done done
if [[ "${#sources[@]}" -ne 2 ]]; then if [[ "${#sources[@]}" -ne 1 ]]; then
echo "Expected one digest per architecture, found ${#sources[@]}." >&2 echo "Expected one digest per architecture, found ${#sources[@]}." >&2
exit 1 exit 1
fi fi
@ -236,13 +246,13 @@ jobs:
first_tag="$(head -n 1 <<< "$TAGS")" first_tag="$(head -n 1 <<< "$TAGS")"
platforms="$(docker buildx imagetools inspect "$first_tag" \ platforms="$(docker buildx imagetools inspect "$first_tag" \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')" --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; } grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
done done
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT" echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
- name: Attest the published image - name: Attest the published image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with: 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 }} subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true push-to-registry: true

View File

@ -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();
});

View File

@ -29,6 +29,26 @@
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
})(); })();
</script> </script>
<script>
// Reload once when the browser aborts part of the module graph while the app
// boots (a network interface changed mid-request). main.tsx clears the flag
// after a successful boot; a second failure in a row is left to the browser.
addEventListener(
"error",
(event) => {
const target = event.target;
if (!(target instanceof HTMLScriptElement) || target.type !== "module") return;
try {
if (sessionStorage.getItem("rakazo:boot-reload")) return;
sessionStorage.setItem("rakazo:boot-reload", "1");
} catch {
return;
}
location.reload();
},
true,
);
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,10 +1,11 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts"; import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
import { Button, Skeleton } from "@rakazo/ui-web"; 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 { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
import { LoadingState } from "./components/ai/primitives"; import { LoadingState } from "./components/ai/primitives";
import { authClient } from "./lib/auth"; import { authClient } from "./lib/auth";
import { lazyWithRecovery } from "./lib/lazy-recovery";
import { markAfterPaint, markOnce } from "./lib/performance"; import { markAfterPaint, markOnce } from "./lib/performance";
import { import {
holdUnreachableGate, holdUnreachableGate,
@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback"; import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell"; import { ShellPage } from "./pages/Shell";
const AuthPage = lazy(() => const AuthPage = lazyWithRecovery("pages/Auth", () =>
import("./pages/Auth").then((module) => ({ default: module.AuthPage })), import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
); );
const PasswordResetPage = lazy(() => const PasswordResetPage = lazyWithRecovery("pages/Auth", () =>
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })), import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
); );
const OnboardingPage = lazy(() => const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })), import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
); );
const WelcomePage = lazy(() => const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })), import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
); );

View File

@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { loadWithRecovery } from "./lazy-recovery.js";
function fakeStorage(initial: Record<string, string> = {}) {
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<string>>()
.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<string>>().mockRejectedValue(fetchFailure());
const pending = loadWithRecovery("pages/Onboarding", load, {
storage,
reload,
retryDelayMs: 0,
});
const outcome = await Promise.race([
pending.then(() => "resolved"),
new Promise<string>((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<string>>().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");
});
});

View File

@ -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<Storage, "getItem" | "setItem" | "removeItem">;
reload?: () => void;
}
const STORAGE_PREFIX = "rakazo:module-reload:";
function delay(ms: number) {
return new Promise<void>((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<T>(
key: string,
load: () => Promise<T>,
options: RecoveryOptions = {},
): Promise<T> {
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<never>(() => undefined);
}
}
/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */
export function lazyWithRecovery<T extends ComponentType<any>>(
key: string,
load: () => Promise<{ default: T }>,
) {
return lazy(() => loadWithRecovery(key, load));
}

View File

@ -12,6 +12,10 @@ import { resolveUiLocale } from "./lib/ui-locale";
import "./styles.css"; import "./styles.css";
markOnce("rk:renderer:module-evaluated"); 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(); installPreloadRecovery();
applyUiDirection(resolveUiLocale()); applyUiDirection(resolveUiLocale());
applyUiAppearance(); applyUiAppearance();

View File

@ -139,7 +139,6 @@ import {
import { import {
type ClipboardEvent, type ClipboardEvent,
type DragEvent, type DragEvent,
lazy,
type MutableRefObject, type MutableRefObject,
memo, memo,
type RefObject, type RefObject,
@ -202,6 +201,7 @@ import {
resolveWorkingGroupBots, resolveWorkingGroupBots,
} from "../lib/group-stage"; } from "../lib/group-stage";
import { buildHeuteBrief } from "../lib/heute-brief"; import { buildHeuteBrief } from "../lib/heute-brief";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone"; import { localTimezone } from "../lib/local-timezone";
import { copyableMessageText } from "../lib/message-text"; import { copyableMessageText } from "../lib/message-text";
import { messageProviderLabel } from "../lib/messaging"; import { messageProviderLabel } from "../lib/messaging";
@ -283,27 +283,29 @@ import {
} from "./shell/message-cards"; } from "./shell/message-cards";
import { WindowChrome } from "./WindowChrome"; import { WindowChrome } from "./WindowChrome";
const BotContextMenu = lazy(() => const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })), import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
); );
const MessagingSettingsOverlay = lazy(() => const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
import("./MessagingSettingsOverlay").then((module) => ({ import("./MessagingSettingsOverlay").then((module) => ({
default: module.MessagingSettingsOverlay, default: module.MessagingSettingsOverlay,
})), })),
); );
const SettingsOverlay = lazy(() => const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })), import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
); );
const PeerMessagesOverlay = lazy(() => const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })), import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
); );
const PluginsOverlay = lazy(() => const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })), import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
); );
const McpServersOverlay = lazy(() => const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
import("./McpServersOverlay").then((module) => ({ default: module.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 = type Panel =
| "computer" | "computer"

View File

@ -25,15 +25,16 @@ import {
Toggle, Toggle,
} from "@rakazo/ui-web"; } from "@rakazo/ui-web";
import { X } from "lucide-react"; 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 { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover"; import { AvatarStudioPopover } from "./avatar-studio-popover";
const ScratchpadSection = lazy(() => const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
); );
const KnowledgeSection = lazy(() => const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })), import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
); );

View File

@ -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. 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 The example defaults to `edge` (main builds). Published images are `linux/amd64` only for now;
arm64 hosts need no special tag. Do not assume `latest` is present until a stable release exists. 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 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 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-<full-commit>` | every push and manual run | source-addressed; used by the updater sidecar | | `sha-<full-commit>` | every push and manual run | source-addressed; used by the updater sidecar |
| `edge` | pushes to main | yes, to the newest main build | | `edge` | pushes to main | yes, to the newest main build |
Every publish, including `edge` from main merges, is multi-arch (`amd64` + `arm64`): each Every publish, including `edge` from main merges, is `linux/amd64` only for now: the image builds
architecture builds natively on its own runner and one manifest is assembled per image. Until a 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 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. `latest` unless that tag exists in the registry.

View File

@ -19,31 +19,38 @@ const workflow = parse(workflowText) as {
}; };
describe("server image publish workflow", () => { 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).not.toContain("setup-qemu-action");
expect(workflowText).toContain("arm64 is paused");
const build = workflow.jobs.build; const build = workflow.jobs.build;
expect(build["runs-on"]).toContain("matrix.runner"); 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( const runners = Object.fromEntries(
(build.strategy?.matrix?.include ?? []) (build.strategy?.matrix?.include ?? [])
.filter((entry) => entry.arch !== undefined) .filter((entry) => entry.arch !== undefined)
.map((entry) => [entry.arch, entry.runner]), .map((entry) => [entry.arch, entry.runner]),
); );
expect(runners).toEqual({ expect(runners).toEqual({ amd64: ["self-hosted", "evoo-ci", "Linux"] });
amd64: ["self-hosted", "evoo-ci", "Linux"],
arm64: ["self-hosted", "evoo-ci", "macOS"],
});
}); });
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; const publish = workflow.jobs.publish;
expect(publish.needs).toBe("build"); expect(publish.needs).toBe("build");
expect(workflowText).toContain("push-by-digest=true"); expect(workflowText).toContain("push-by-digest=true");
expect(workflowText).toContain("docker buildx imagetools create"); 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@"); 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", () => { it("keeps pull requests read-only and every action pinned to a commit", () => {
const validate = workflow.jobs.validate; const validate = workflow.jobs.validate;
const build = workflow.jobs.build; const build = workflow.jobs.build;

View File

@ -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 path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(import.meta.dirname, "../../.."); const repoRoot = path.resolve(import.meta.dirname, "../../..");
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh"); const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
const script = readFileSync(scriptPath, "utf8");
const playwrightWorkflow = readFileSync( const playwrightWorkflow = readFileSync(
path.join(repoRoot, ".github/workflows/playwright.yml"), path.join(repoRoot, ".github/workflows/playwright.yml"),
"utf8", "utf8",
@ -15,20 +18,62 @@ function portsFromShellArray(source: string, name: string): number[] {
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port)); 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<string, string>): { 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", () => { describe("free-e2e-ports.sh", () => {
const script = readFileSync(scriptPath, "utf8"); it("keeps the live dev ports apart from the default E2E ports", () => {
it("declares dedicated E2E ports separate from live dev ports", () => {
const livePorts = portsFromShellArray(script, "LIVE_PORTS"); const livePorts = portsFromShellArray(script, "LIVE_PORTS");
const e2ePorts = portsFromShellArray(script, "E2E_PORTS");
expect(livePorts).toEqual([3100, 5173]); 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]); expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
for (const livePort of livePorts) { for (const livePort of livePorts) {
expect(e2ePorts).not.toContain(livePort); 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", () => { it("uses fuser and skips non-Linux hosts", () => {
expect(script).toContain('fuser -k "$spec"'); expect(script).toContain('fuser -k "$spec"');
expect(script).toContain('uname -s)" != "Linux"'); expect(script).toContain('uname -s)" != "Linux"');
@ -44,4 +89,13 @@ describe("free-e2e-ports.sh", () => {
playwrightWorkflow.indexOf("Run marketing homepage E2E"), 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);
}
});
}); });

View File

@ -1,11 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Free stale listeners on dedicated Playwright/E2E ports before harness bind. # Free stale listeners on dedicated Playwright/E2E ports before harness bind.
# Never touches live dev stack ports 3100 (API) or 5173 (web). # 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 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 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 if [[ "$(uname -s)" != "Linux" ]]; then
echo "free-e2e-ports: skip (not Linux)" echo "free-e2e-ports: skip (not Linux)"

View File

@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}" # Publishing is optional: without the S3 settings the gallery is skipped and the job stays green.
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}" for setting in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY S3_BUCKET S3_ENDPOINT PLAYWRIGHT_PUBLIC_BASE_URL; do
: "${S3_BUCKET:?S3_BUCKET is required}" if [[ -z "${!setting:-}" ]]; then
: "${S3_ENDPOINT:?S3_ENDPOINT is required}" echo "::notice::Skipping the Playwright report publish: $setting is not configured."
: "${PLAYWRIGHT_PUBLIC_BASE_URL:?PLAYWRIGHT_PUBLIC_BASE_URL is required}" exit 0
fi
done
: "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}" : "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}"
: "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}" : "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}"
: "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}" : "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"