Compare commits
7 Commits
claude/ama
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b74ae3cc2 | ||
|
|
99f868914a | ||
|
|
ddf5febdb3 | ||
|
|
bc980032e9 | ||
|
|
cd0aedafcd | ||
|
|
2f05ab6a81 | ||
|
|
9c42cbcc17 |
14
.github/workflows/evo-deploy.yml
vendored
14
.github/workflows/evo-deploy.yml
vendored
@ -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:"
|
||||
|
||||
18
.github/workflows/playwright.yml
vendored
18
.github/workflows/playwright.yml
vendored
@ -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
|
||||
|
||||
38
.github/workflows/publish-server-image.yml
vendored
38
.github/workflows/publish-server-image.yml
vendored
@ -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
|
||||
|
||||
38
apps/web/e2e/module-recovery.spec.ts
Normal file
38
apps/web/e2e/module-recovery.spec.ts
Normal 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();
|
||||
});
|
||||
@ -29,6 +29,26 @@
|
||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
||||
})();
|
||||
</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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -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 })),
|
||||
);
|
||||
|
||||
|
||||
78
apps/web/src/lib/lazy-recovery.test.ts
Normal file
78
apps/web/src/lib/lazy-recovery.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
58
apps/web/src/lib/lazy-recovery.ts
Normal file
58
apps/web/src/lib/lazy-recovery.ts
Normal 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));
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 })),
|
||||
);
|
||||
|
||||
|
||||
@ -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-<full-commit>` | 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.
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<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", () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user