diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index ae12e47..eac7522 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -6,8 +6,12 @@ on:
branches: [main, master]
paths:
- "apps/site/**"
+ - "apps/web/**"
+ - "packages/**"
- "scripts/check-release-publication.mjs"
- "scripts/deploy-site.mjs"
+ - "scripts/deploy-demo.mjs"
+ - "scripts/deploy-site-bundle.mjs"
- "scripts/dispatch-site-deployment.mjs"
- "scripts/generate-release-manifest.mjs"
- "scripts/inspect-linux-update.mjs"
@@ -153,9 +157,18 @@ jobs:
ref: ${{ steps.immutable_source.outputs.source_sha }}
persist-credentials: false
+ - name: Check out trusted demo source
+ if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ ref: ${{ steps.source.outputs.trusted_sha }}
+ path: .trusted-demo-source
+ persist-credentials: false
- name: Install dependencies
if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
- run: pnpm install --frozen-lockfile
+ run: |
+ pnpm install --frozen-lockfile
+ pnpm --dir .trusted-demo-source install --frozen-lockfile
- name: Authenticate to AWS with GitHub OIDC
if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
@@ -169,5 +182,6 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }}
+ T4_IMMUTABLE_SITE_SOURCE: ${{ github.workspace }}
T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }}
- run: pnpm deploy:site
+ run: pnpm --dir .trusted-demo-source deploy:site-bundle
diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx
index 28721bb..2e0a544 100644
--- a/apps/web/src/components/AppShell.tsx
+++ b/apps/web/src/components/AppShell.tsx
@@ -17,7 +17,7 @@ import { getShellData, useShellData } from "../state/shell-data.ts";
import { RAIL_OVERLAY_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts";
import { isEditableTarget, resolveShortcut } from "../keyboard/shortcuts.ts";
import { buildProjectGroups, listVisibleSessionIds } from "../lib/session-tree.ts";
-import { useWorkspace, workspaceStore } from "../state/store-instance.ts";
+import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts";
import { RAIL_COLLAPSED_WIDTH, RAIL_WIDTH, selectSessionView } from "../state/workspace-store.ts";
import { CommandPalette } from "./CommandPalette.tsx";
import { CollapsedRail, Rail } from "./Rail.tsx";
@@ -195,6 +195,20 @@ export function AppShell() {
}}
railToggle={railToggle}
/>
+ {rendererPlatform.demo && (
+
+ Sample data
+
+ ·
+
+
+ Explore freely. No live hosts, accounts, or files are connected.
+
+
+ )}
{!railOverlaid && !focusMode && (
<>
diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx
index 6059f73..63f10c3 100644
--- a/apps/web/src/components/SessionScreen.tsx
+++ b/apps/web/src/components/SessionScreen.tsx
@@ -38,7 +38,7 @@ import { PaneContent } from "../features/panes/PaneContent.tsx";
import { TerminalDrawer } from "../features/terminal/TerminalDrawer.tsx";
import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx";
import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts";
-import { useWorkspace, workspaceStore } from "../state/store-instance.ts";
+import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts";
import { useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts";
import { resolveLiveSession } from "../platform/live-workspace.ts";
import { useShellData } from "../state/shell-data.ts";
@@ -275,12 +275,13 @@ export function SessionScreen({
const runtimeSnapshot = useDesktopRuntimeSnapshot();
const previewAddress =
runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, session.id);
- const previewCount =
- previewAddress === null
+ const previewCount = rendererPlatform.demo
+ ? 1
+ : previewAddress === null
? 0
- : (runtimeSnapshot?.projection.sessions
- .get(`${previewAddress.hostId}\u0000${previewAddress.sessionId}`)
- ?.previews.size ?? 0);
+ : (runtimeSnapshot?.projection.sessions.get(
+ `${previewAddress.hostId}\u0000${previewAddress.sessionId}`,
+ )?.previews.size ?? 0);
const [panePreviewWidth, setPanePreviewWidth] = useState
(null);
// Transcript scroll ownership lives in TranscriptTimeline (virtualized
diff --git a/apps/web/src/features/agent-view/AgentViewScreen.tsx b/apps/web/src/features/agent-view/AgentViewScreen.tsx
index 52f5d0c..ee650b9 100644
--- a/apps/web/src/features/agent-view/AgentViewScreen.tsx
+++ b/apps/web/src/features/agent-view/AgentViewScreen.tsx
@@ -57,18 +57,23 @@ function AgentCard({
group,
nowMs,
row,
+ sampleMode,
snapshot,
onCancel,
}: {
readonly group: AgentViewGroup;
readonly nowMs: number;
readonly row: AgentViewRow;
- readonly snapshot: DesktopRuntimeSnapshot;
+ readonly sampleMode: boolean;
+ readonly snapshot: DesktopRuntimeSnapshot | null;
readonly onCancel: () => void;
}) {
const { node } = row;
const style = AGENT_STATE_STYLES[node.state];
- const availability = agentCancelAvailability(snapshot, group.viewId, node);
+ const availability =
+ sampleMode || snapshot === null
+ ? { enabled: false, reason: "Sample data is local and cannot stop an agent." }
+ : agentCancelAvailability(snapshot, group.viewId, node);
const elapsed = node.state === "running" ? formatElapsed(node.startedAt, nowMs) : "";
const contextPercent =
node.contextUsed === null || node.contextLimit === null || node.contextLimit === 0
@@ -143,28 +148,43 @@ function AgentCard({
);
}
+type AgentViewFixtureProps =
+ | {
+ readonly fixtureGroups?: never;
+ readonly fixtureNowMs?: never;
+ }
+ | {
+ readonly fixtureGroups: readonly AgentViewGroup[];
+ readonly fixtureNowMs: number;
+ };
+
+interface AgentViewScreenProps {
+ readonly controller: AgentViewRuntime | null;
+ readonly snapshot: DesktopRuntimeSnapshot | null;
+ readonly onBack: () => void;
+ readonly onOpenSession: (sessionId: string) => void;
+}
+
export function AgentViewScreen({
controller,
+ fixtureGroups,
+ fixtureNowMs,
snapshot,
onBack,
onOpenSession,
-}: {
- readonly controller: AgentViewRuntime | null;
- readonly snapshot: DesktopRuntimeSnapshot | null;
- readonly onBack: () => void;
- readonly onOpenSession: (sessionId: string) => void;
-}) {
+}: AgentViewScreenProps & AgentViewFixtureProps) {
const groups = useMemo(
- () => (snapshot === null ? [] : deriveAgentViewGroups(snapshot)),
- [snapshot],
+ () => (snapshot === null ? (fixtureGroups ?? []) : deriveAgentViewGroups(snapshot)),
+ [fixtureGroups, snapshot],
);
+ const sampleMode = snapshot === null && fixtureGroups !== undefined;
const agentCount = groups.reduce((sum, group) => sum + group.agents.length, 0);
const runningCount = groups.reduce(
- (sum, group) =>
- sum + group.agents.filter(({ node }) => node.state === "running").length,
+ (sum, group) => sum + group.agents.filter(({ node }) => node.state === "running").length,
0,
);
- const nowMs = useNowTick(runningCount > 0);
+ const liveNowMs = useNowTick(runningCount > 0 && !sampleMode);
+ const nowMs = sampleMode && fixtureNowMs !== undefined ? fixtureNowMs : liveNowMs;
const [pending, setPending] = useState(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
@@ -211,7 +231,7 @@ export function AgentViewScreen({
{announcement}
- {snapshot === null ? (
+ {snapshot === null && fixtureGroups === undefined ? (
Agent View requires the desktop runtime
@@ -282,6 +302,7 @@ export function AgentViewScreen({
setPending({ group, row });
}}
row={row}
+ sampleMode={sampleMode}
snapshot={snapshot}
/>
))}
diff --git a/apps/web/src/features/agent-view/fixtures.ts b/apps/web/src/features/agent-view/fixtures.ts
new file mode 100644
index 0000000..f25007a
--- /dev/null
+++ b/apps/web/src/features/agent-view/fixtures.ts
@@ -0,0 +1,33 @@
+import { SHELL_FIXTURE } from "../../fixture/data.ts";
+import { FIXTURE_EPOCH_MS, fixtureAgentsForSession } from "../panes/fixtures.ts";
+import type { AgentViewGroup } from "./model.ts";
+
+const VISIBLE_SESSION_IDS: Readonly> = {
+ "sess-stream": true,
+ "sess-bundle": true,
+};
+
+export const AGENT_VIEW_FIXTURE_NOW_MS = FIXTURE_EPOCH_MS;
+
+/** Global Agent View sample built from the same agents shown in session panes. */
+export const AGENT_VIEW_FIXTURE_GROUPS: readonly AgentViewGroup[] = SHELL_FIXTURE.sessions
+ .filter((session) => VISIBLE_SESSION_IDS[session.id] === true)
+ .map((session) => ({
+ viewId: session.id,
+ session,
+ projectName:
+ SHELL_FIXTURE.projects.find((project) => project.id === session.projectId)?.name ??
+ "Sample project",
+ agents: fixtureAgentsForSession(session.id).map((node) => ({
+ node,
+ task:
+ node.kind === "main"
+ ? "Coordinate the reconnect investigation and consolidate verified findings."
+ : node.kind === "batch"
+ ? "Inspect the replay boundary, soak behavior, and documentation in parallel."
+ : node.path === null
+ ? "Report a bounded finding back to the parent session."
+ : `Inspect ${node.path} and report evidence.`,
+ resumable: node.state === "parked",
+ })),
+ }));
diff --git a/apps/web/src/features/panes/fixtures.ts b/apps/web/src/features/panes/fixtures.ts
index fc66747..8073c31 100644
--- a/apps/web/src/features/panes/fixtures.ts
+++ b/apps/web/src/features/panes/fixtures.ts
@@ -793,6 +793,11 @@ function agentsForSession(sessionId: string): readonly AgentNode[] {
);
}
+/** Shared deterministic agent corpus for fixture-only surfaces such as Agent View. */
+export function fixtureAgentsForSession(sessionId: string): readonly AgentNode[] {
+ return agentsForSession(sessionId);
+}
+
function fixtureController(api: InspectorStoreApi, clock: () => number): InspectorController {
const editedFiles = new Map();
return {
diff --git a/apps/web/src/features/preview/FixturePreviewWorkspace.tsx b/apps/web/src/features/preview/FixturePreviewWorkspace.tsx
new file mode 100644
index 0000000..c767836
--- /dev/null
+++ b/apps/web/src/features/preview/FixturePreviewWorkspace.tsx
@@ -0,0 +1,189 @@
+import { Badge, Button, cn } from "@t4-code/ui";
+import {
+ ArrowLeft,
+ ChevronLeft,
+ ChevronRight,
+ LockKeyhole,
+ RotateCw,
+ type LucideIcon,
+} from "lucide-react";
+import { useState } from "react";
+import { useNavigate } from "@tanstack/react-router";
+
+import type { WorkspaceProject, WorkspaceSession } from "../../lib/workspace-data.ts";
+
+const SAMPLE_PREVIEW_URL = "https://preview.example.test/reconnect";
+const SAMPLE_NAVIGATION_ACTIONS: readonly {
+ readonly label: string;
+ readonly Icon: LucideIcon;
+}[] = [
+ { label: "Back", Icon: ChevronLeft },
+ { label: "Forward", Icon: ChevronRight },
+ { label: "Reload", Icon: RotateCw },
+];
+
+export function FixturePreviewWorkspace({
+ session,
+ project,
+}: {
+ readonly session: WorkspaceSession;
+ readonly project: WorkspaceProject;
+}) {
+ const navigate = useNavigate();
+ const [scale, setScale] = useState<"fit" | "actual">("fit");
+
+ return (
+
+
+
+
+
+ Browser preview
+ {project.name}
+
+
+ Sample data
+
+
+
+
+
+
+
+
+ {SAMPLE_NAVIGATION_ACTIONS.map(({ label, Icon }) => (
+
+ ))}
+
+
+
+
+ This deterministic preview is rendered locally. It opens no browser, sends no input,
+ and uses no account or network connection.
+
+
+
+
+
+ Snapshot
+ Read-only fixture
+
+
+
+
+
+
+
+
+ {[
+ ["Active sessions", "12", "All replay cursors current"],
+ ["Buffered frames", "0", "No duplicate sequence IDs"],
+ ["Reconnect p95", "184 ms", "Within the 250 ms target"],
+ ].map(([label, value, detail]) => (
+
+ {label}
+ {value}
+ {detail}
+
+ ))}
+
+
+
+ Recent reconnect
+ Result
+
+ {[
+ ["dev-server · epoch 8", "Recovered"],
+ ["This machine · epoch 21", "No gap"],
+ ["test-runner · epoch 5", "Recovered"],
+ ].map(([name, result]) => (
+
+ {name}
+ {result}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/platform/bridge.ts b/apps/web/src/platform/bridge.ts
index 274f3ab..18aa807 100644
--- a/apps/web/src/platform/bridge.ts
+++ b/apps/web/src/platform/bridge.ts
@@ -11,9 +11,15 @@ import { WORKSPACE_STORAGE_KEY } from "../state/workspace-store.ts";
export type ShellPlatform = "linux" | "darwin";
+export interface RendererPlatformOptions {
+ readonly forceFixture?: boolean;
+}
+
export interface RendererPlatform {
/** "desktop" when the Electron preload injected the shell port. */
readonly mode: "desktop" | "browser";
+ /** True only for the explicit, read-only public demo build. */
+ readonly demo: boolean;
readonly platform: ShellPlatform;
/** Workspace view-state persistence; always renderer-local. */
readonly persistence: WorkspacePersistence;
@@ -33,23 +39,27 @@ function injectedShell(): DesktopShellPort | null {
return shell !== undefined && shell.kind === "desktop" ? shell : null;
}
-export function resolveRendererPlatform(platformOverride?: ShellPlatform): RendererPlatform {
- const shell = injectedShell();
+export function resolveRendererPlatform(
+ platformOverride?: ShellPlatform,
+ options: RendererPlatformOptions = {},
+): RendererPlatform {
+ const forceFixture = options.forceFixture === true;
+ const shell = forceFixture ? null : injectedShell();
const platform =
shell?.platform ??
platformOverride ??
(typeof navigator !== "undefined" && /mac/i.test(navigator.platform) ? "darwin" : "linux");
- // Browser mode: try to create a browser-direct shell port that connects
- // to the OMP appserver over WebSocket. If no backend config is detected,
- // fall through to the original browser mode (fixture/demo data).
+ // The public demo must stay on deterministic fixtures even if a URL or
+ // injected global tries to supply a live backend.
let resolvedShell: DesktopShellPort | null = shell;
- if (resolvedShell === null) {
+ if (resolvedShell === null && !forceFixture) {
resolvedShell = createBrowserShellPort();
}
return {
mode: resolvedShell === null ? "browser" : "desktop",
+ demo: forceFixture,
platform,
persistence: createLocalStoragePersistence(WORKSPACE_STORAGE_KEY),
shell: resolvedShell,
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 3e4cf4d..6ec3c39 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -25,7 +25,12 @@ import { AppShell } from "./components/AppShell.tsx";
import { HomePane } from "./components/HomePane.tsx";
import { SessionScreen } from "./components/SessionScreen.tsx";
import { AgentViewScreen } from "./features/agent-view/AgentViewScreen.tsx";
+import {
+ AGENT_VIEW_FIXTURE_GROUPS,
+ AGENT_VIEW_FIXTURE_NOW_MS,
+} from "./features/agent-view/fixtures.ts";
import { PreviewWorkspace } from "./features/preview/PreviewWorkspace.tsx";
+import { FixturePreviewWorkspace } from "./features/preview/FixturePreviewWorkspace.tsx";
import { LiveAttentionInbox } from "./features/attention/index.ts";
import { LiveTranscriptSearch } from "./features/transcript-search/index.ts";
import { TRANSCRIPT_SEARCH_ROUTE } from "./features/transcript-search/route.ts";
@@ -263,7 +268,13 @@ function PreviewRoute() {
const { sessionId } = useParams({ from: "/sessions/$sessionId/preview" });
return (
- {(session, project) => }
+ {(session, project) =>
+ rendererPlatform.demo ? (
+
+ ) : (
+
+ )
+ }
);
}
@@ -287,6 +298,12 @@ function AgentViewRoute() {
return (
{
if (activeSessionId === null) void navigate({ to: "/" });
else void navigate({ params: { sessionId: activeSessionId }, to: "/sessions/$sessionId" });
diff --git a/apps/web/src/state/store-instance.ts b/apps/web/src/state/store-instance.ts
index a2b0001..f48d789 100644
--- a/apps/web/src/state/store-instance.ts
+++ b/apps/web/src/state/store-instance.ts
@@ -24,8 +24,11 @@ const bootOptions = parseFixtureBootOptions(
typeof window === "undefined" ? "" : window.location.search,
);
+const demoMode = import.meta.env.MODE === "demo";
+
export const rendererPlatform: RendererPlatform = resolveRendererPlatform(
bootOptions.platform ?? undefined,
+ { forceFixture: demoMode },
);
const browserMode = rendererPlatform.mode === "browser";
diff --git a/apps/web/test/browser-platform.test.ts b/apps/web/test/browser-platform.test.ts
index c2d6b1c..90aace0 100644
--- a/apps/web/test/browser-platform.test.ts
+++ b/apps/web/test/browser-platform.test.ts
@@ -83,6 +83,24 @@ describe("browser platform boundary", () => {
Object.defineProperty(globalThis, "window", { configurable: true, value: undefined });
const platform = resolveRendererPlatform("linux");
expect(platform.mode).toBe("browser");
+ expect(platform.demo).toBe(false);
+ expect(platform.shell).toBeNull();
+ });
+
+ it("forces the public demo onto fixtures despite live backend inputs", () => {
+ Object.defineProperty(globalThis, "document", { configurable: true, value: undefined });
+ Object.defineProperty(globalThis, "window", {
+ configurable: true,
+ value: {
+ location: { search: "?backend=wss%3A%2F%2Fomp.example%2Fv1%2Fws" },
+ ompShell: { kind: "desktop", platform: "darwin" },
+ },
+ });
+
+ const platform = resolveRendererPlatform("linux", { forceFixture: true });
+ expect(platform.mode).toBe("browser");
+ expect(platform.platform).toBe("linux");
+ expect(platform.demo).toBe(true);
expect(platform.shell).toBeNull();
});
diff --git a/apps/web/test/panes-fixtures.test.ts b/apps/web/test/panes-fixtures.test.ts
index 2b17b2f..456e0fd 100644
--- a/apps/web/test/panes-fixtures.test.ts
+++ b/apps/web/test/panes-fixtures.test.ts
@@ -3,6 +3,10 @@
// reproducible instants — no module-load wall clock anywhere.
import { describe, expect, it } from "vite-plus/test";
+import {
+ AGENT_VIEW_FIXTURE_GROUPS,
+ AGENT_VIEW_FIXTURE_NOW_MS,
+} from "../src/features/agent-view/fixtures.ts";
import {
FIXTURE_EPOCH_MS,
installFixtureInspector,
@@ -75,3 +79,21 @@ describe("fixture determinism", () => {
expect(store.getState().files.draftsByPath[path]?.baseRevision).toBe("fixture-revision-1");
});
});
+
+describe("global Agent View fixtures", () => {
+ it("exposes deterministic multi-session lifecycle samples", () => {
+ expect(AGENT_VIEW_FIXTURE_NOW_MS).toBe(FIXTURE_EPOCH_MS);
+ expect(AGENT_VIEW_FIXTURE_GROUPS.map(({ viewId }) => viewId)).toEqual([
+ "sess-stream",
+ "sess-bundle",
+ ]);
+
+ const agents = AGENT_VIEW_FIXTURE_GROUPS.flatMap(({ agents }) => agents);
+ expect(agents).toHaveLength(11);
+ expect(new Set(agents.map(({ node }) => node.state))).toEqual(
+ new Set(["running", "waiting", "parked", "completed", "failed", "queued"]),
+ );
+ expect(agents.some(({ node }) => node.path === "packages/client/src/replay.ts")).toBe(true);
+ expect(agents.find(({ node }) => node.state === "parked")?.resumable).toBe(true);
+ });
+});
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index ce27c72..6c71db6 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -2,7 +2,18 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
-export default defineConfig({
- base: "./",
- plugins: [react(), tailwindcss()],
-});
+export default defineConfig(({ mode }) => ({
+ base: mode === "demo" ? "/demo/" : "./",
+ plugins: [
+ react(),
+ tailwindcss(),
+ ...(mode === "demo"
+ ? [
+ {
+ name: "t4-demo-document-root",
+ transformIndexHtml: (html: string) => html.replaceAll('="./', '="/demo/'),
+ },
+ ]
+ : []),
+ ],
+}));
diff --git a/package.json b/package.json
index 0cd0284..078a1ce 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,10 @@
"build:web": "pnpm --filter @t4-code/web build",
"build:desktop": "pnpm --filter @t4-code/desktop build",
"build:site": "pnpm --filter @t4-code/site build",
+ "build:demo": "pnpm --filter @t4-code/web exec vp build --mode demo --base /demo/ --outDir ../site/dist/demo --emptyOutDir",
"deploy:site": "node scripts/deploy-site.mjs",
+ "deploy:demo": "node scripts/deploy-demo.mjs",
+ "deploy:site-bundle": "node scripts/deploy-site-bundle.mjs",
"prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && node scripts/package-preflight.mjs",
"package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64",
"package:mac:unsigned": "node scripts/package-mac-unsigned.mjs",
@@ -23,7 +26,7 @@
"inspect:package": "node scripts/inspect-package.mjs",
"inspect:dmg": "node scripts/inspect-macos-dmg.mjs",
"test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs scripts/inspect-macos-release.test.mjs",
- "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs",
+ "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-demo.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs",
"check:release": "node scripts/check-release-consistency.mjs",
"check:provenance": "node scripts/check-provenance.mjs",
"lint": "vp lint --deny-warnings",
diff --git a/scripts/deploy-demo.mjs b/scripts/deploy-demo.mjs
new file mode 100644
index 0000000..3041825
--- /dev/null
+++ b/scripts/deploy-demo.mjs
@@ -0,0 +1,100 @@
+import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { resolveDeployConfig } from "./deploy-site.mjs";
+
+function run(command, args, cwd) {
+ const result = spawnSync(command, args, { cwd, env: process.env, stdio: "inherit" });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ throw new Error(`${command} exited with status ${result.status ?? "unknown"}`);
+ }
+}
+
+const DOCUMENT_URL_PATTERN = /\b(?:href|src)="([^"]+)"/gu;
+
+export function assertDemoDocumentPaths(document) {
+ const urls = [...document.matchAll(DOCUMENT_URL_PATTERN)].map((match) => match[1]);
+ const localUrls = urls.filter(
+ (url) =>
+ url !== undefined &&
+ !url.startsWith("data:") &&
+ !url.startsWith("http:") &&
+ !url.startsWith("https:") &&
+ !url.startsWith("#"),
+ );
+ if (localUrls.length === 0) throw new Error("demo index does not reference local assets");
+ const escaped = localUrls.find((url) => !url.startsWith("/demo/"));
+ if (escaped !== undefined) throw new Error(`demo asset escapes /demo/: ${escaped}`);
+}
+
+export function validateDemoBuild(repoRoot) {
+ const document = readFileSync(resolve(repoRoot, "apps/site/dist/demo/index.html"), "utf8");
+ assertDemoDocumentPaths(document);
+}
+
+export function deployDemo(
+ config,
+ repoRoot = resolve(import.meta.dirname, ".."),
+ runCommand = run,
+ validateBuild = validateDemoBuild,
+) {
+ const destination = `s3://${config.bucket}/demo`;
+ runCommand("pnpm", ["build:demo"], repoRoot);
+ validateBuild(repoRoot);
+ runCommand(
+ "aws",
+ [
+ "s3",
+ "sync",
+ "apps/site/dist/demo/assets",
+ `${destination}/assets`,
+ "--cache-control",
+ "public,max-age=31536000,immutable",
+ "--only-show-errors",
+ ],
+ repoRoot,
+ );
+ runCommand(
+ "aws",
+ [
+ "s3",
+ "sync",
+ "apps/site/dist/demo",
+ destination,
+ "--delete",
+ "--exclude",
+ "assets/*",
+ "--cache-control",
+ "public,max-age=0,must-revalidate",
+ "--only-show-errors",
+ ],
+ repoRoot,
+ );
+ runCommand(
+ "aws",
+ [
+ "cloudfront",
+ "create-invalidation",
+ "--distribution-id",
+ config.distributionId,
+ "--paths",
+ "/demo",
+ "/demo/*",
+ ],
+ repoRoot,
+ );
+}
+
+const isMain =
+ process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
+if (isMain) {
+ try {
+ deployDemo(resolveDeployConfig());
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/deploy-demo.test.mjs b/scripts/deploy-demo.test.mjs
new file mode 100644
index 0000000..a619a40
--- /dev/null
+++ b/scripts/deploy-demo.test.mjs
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { assertDemoDocumentPaths, deployDemo } from "./deploy-demo.mjs";
+import { deploySiteBundle } from "./deploy-site-bundle.mjs";
+
+test("demo deploy replaces only the demo prefix after immutable assets", () => {
+ const calls = [];
+ deployDemo(
+ { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" },
+ "/repo",
+ (command, args, cwd) => calls.push({ command, args, cwd }),
+ () => undefined,
+ );
+
+ assert.equal(calls.length, 4);
+ assert.deepEqual(calls[0], { command: "pnpm", args: ["build:demo"], cwd: "/repo" });
+ assert.equal(calls[1].args[2], "apps/site/dist/demo/assets");
+ assert.equal(calls[1].args[3], "s3://t4code-net-site-595529182031/demo/assets");
+ assert.equal(calls[1].args.includes("--delete"), false);
+ assert.equal(calls[2].args[2], "apps/site/dist/demo");
+ assert.equal(calls[2].args[3], "s3://t4code-net-site-595529182031/demo");
+ assert.equal(calls[2].args.includes("--delete"), true);
+ assert.deepEqual(calls[3].args.slice(-3), ["--paths", "/demo", "/demo/*"]);
+ assert.deepEqual(
+ calls.map(({ cwd }) => cwd),
+ ["/repo", "/repo", "/repo", "/repo"],
+ );
+});
+
+test("demo build keeps every local document URL under /demo", () => {
+ assert.doesNotThrow(() =>
+ assertDemoDocumentPaths(
+ '',
+ ),
+ );
+ assert.throws(
+ () => assertDemoDocumentPaths(''),
+ /demo asset escapes/u,
+ );
+ assert.throws(() => assertDemoDocumentPaths("No assets"), /does not reference/u);
+});
+
+test("site bundle preserves the demo while deploying immutable release content", () => {
+ const calls = [];
+ const config = { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" };
+ deploySiteBundle(
+ config,
+ "/release-source",
+ "/trusted-demo-source",
+ (receivedConfig, root) => calls.push({ kind: "site", config: receivedConfig, root }),
+ (receivedConfig, root) => calls.push({ kind: "demo", config: receivedConfig, root }),
+ );
+
+ assert.deepEqual(calls, [
+ { kind: "site", config, root: "/release-source" },
+ { kind: "demo", config, root: "/trusted-demo-source" },
+ ]);
+ assert.throws(
+ () => deploySiteBundle(config, "relative-release-source"),
+ /T4_IMMUTABLE_SITE_SOURCE must be an absolute path/u,
+ );
+});
diff --git a/scripts/deploy-site-bundle.mjs b/scripts/deploy-site-bundle.mjs
new file mode 100644
index 0000000..df0dc5e
--- /dev/null
+++ b/scripts/deploy-site-bundle.mjs
@@ -0,0 +1,31 @@
+import { isAbsolute, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { deployDemo } from "./deploy-demo.mjs";
+import { deploySite, resolveDeployConfig } from "./deploy-site.mjs";
+
+export function deploySiteBundle(
+ config,
+ immutableSiteRoot,
+ demoRoot = resolve(import.meta.dirname, ".."),
+ deploySiteCommand = deploySite,
+ deployDemoCommand = deployDemo,
+) {
+ if (!isAbsolute(immutableSiteRoot)) {
+ throw new Error("T4_IMMUTABLE_SITE_SOURCE must be an absolute path");
+ }
+ deploySiteCommand(config, immutableSiteRoot);
+ deployDemoCommand(config, demoRoot);
+}
+
+const isMain =
+ process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
+if (isMain) {
+ try {
+ const immutableSiteRoot = process.env.T4_IMMUTABLE_SITE_SOURCE?.trim() ?? "";
+ deploySiteBundle(resolveDeployConfig(), immutableSiteRoot);
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/deploy-site.mjs b/scripts/deploy-site.mjs
index efd3cb7..fe903fd 100644
--- a/scripts/deploy-site.mjs
+++ b/scripts/deploy-site.mjs
@@ -69,6 +69,8 @@ export function deploySite(
"--delete",
"--exclude",
"assets/*",
+ "--exclude",
+ "demo/*",
"--cache-control",
"public,max-age=0,must-revalidate",
"--only-show-errors",
@@ -89,7 +91,8 @@ export function deploySite(
);
}
-const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
+const isMain =
+ process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
deploySite(resolveDeployConfig());
diff --git a/scripts/deploy-site.test.mjs b/scripts/deploy-site.test.mjs
index 58f1260..3468b02 100644
--- a/scripts/deploy-site.test.mjs
+++ b/scripts/deploy-site.test.mjs
@@ -46,7 +46,10 @@ test("site deploy uploads immutable assets before switching entry documents", ()
);
assert.equal(calls.length, 5);
- assert.deepEqual(calls.map(({ command }) => command), ["pnpm", "node", "aws", "aws", "aws"]);
+ assert.deepEqual(
+ calls.map(({ command }) => command),
+ ["pnpm", "node", "aws", "aws", "aws"],
+ );
assert.deepEqual(calls[1].args, [
"scripts/generate-release-manifest.mjs",
"--version",
@@ -58,5 +61,14 @@ test("site deploy uploads immutable assets before switching entry documents", ()
assert.equal(calls[3].args[2], "apps/site/dist");
assert.equal(calls[2].args.includes("--delete"), false);
assert.equal(calls[3].args.includes("--delete"), true);
- assert.deepEqual(calls.map(({ cwd }) => cwd), ["/repo", "/repo", "/repo", "/repo", "/repo"]);
+ assert.deepEqual(
+ calls[3].args.flatMap((argument, index) =>
+ argument === "--exclude" ? [calls[3].args[index + 1]] : [],
+ ),
+ ["assets/*", "demo/*"],
+ );
+ assert.deepEqual(
+ calls.map(({ cwd }) => cwd),
+ ["/repo", "/repo", "/repo", "/repo", "/repo"],
+ );
});