diff --git a/examples/think-compare-runtimes/.dev.vars.example b/examples/think-compare-runtimes/.dev.vars.example new file mode 100644 index 00000000..edd885ed --- /dev/null +++ b/examples/think-compare-runtimes/.dev.vars.example @@ -0,0 +1,7 @@ +# Copy to .dev.vars for local development. +# +# wrangler dev / vite dev under colima or Docker Desktop does not +# expose /dev/fuse to Workers Containers. This tells the Workspace +# container to run wsd with its userspace shim locally. Production +# leaves this unset so wsd uses real kernel FUSE when available. +FUSE_MOUNT=shim diff --git a/examples/think-compare-runtimes/.gitignore b/examples/think-compare-runtimes/.gitignore new file mode 100644 index 00000000..ae2b0309 --- /dev/null +++ b/examples/think-compare-runtimes/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# wrangler files +.wrangler +.dev.vars* +!.dev.vars.example +.env* +!.env.example diff --git a/examples/think-compare-runtimes/Dockerfile.sandbox b/examples/think-compare-runtimes/Dockerfile.sandbox new file mode 100644 index 00000000..f51abff9 --- /dev/null +++ b/examples/think-compare-runtimes/Dockerfile.sandbox @@ -0,0 +1,6 @@ +# Sandbox runtime image for the comparison example. This extends +# the Sandbox SDK base image used by getSandbox(env.Sandbox, ...). + +FROM docker.io/cloudflare/sandbox:0.11.0 + +EXPOSE 8080 diff --git a/examples/think-compare-runtimes/Dockerfile.workspace b/examples/think-compare-runtimes/Dockerfile.workspace new file mode 100644 index 00000000..e8669e10 --- /dev/null +++ b/examples/think-compare-runtimes/Dockerfile.workspace @@ -0,0 +1,30 @@ +# Workspace runtime image for the comparison example. It runs the +# wsd daemon so shell commands can see the same /workspace tree that +# Workspace.fs stores in Durable Object storage. +# +# Base image is node:22-trixie-slim so the agent has node + npm +# available out of the box for in-container verification (e.g. npm +# install / npm test). It's also Debian-glibc, which is what the wsd +# SEA binary is built against. +# +# FUSE_MOUNT=auto lets wsd use real kernel FUSE when available and +# fall back to the userspace shim in local development environments +# that do not expose /dev/fuse. + +FROM ghcr.io/cloudflare/workspace-wsd-linux-x64:0.0.0-alpha.7 AS wsd + +FROM --platform=linux/amd64 node:22-trixie-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates git fuse3 libfuse2t64 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=wsd /usr/local/bin/wsd /usr/local/bin/wsd + +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/wsd"] diff --git a/examples/think-compare-runtimes/index.html b/examples/think-compare-runtimes/index.html new file mode 100644 index 00000000..e6f7f640 --- /dev/null +++ b/examples/think-compare-runtimes/index.html @@ -0,0 +1,12 @@ + + + + + + Think Runtime Comparison + + +
+ + + diff --git a/examples/think-compare-runtimes/package.json b/examples/think-compare-runtimes/package.json new file mode 100644 index 00000000..9708e7e2 --- /dev/null +++ b/examples/think-compare-runtimes/package.json @@ -0,0 +1,56 @@ +{ + "name": "@cloudflare/example-think-compare-runtimes", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build:wsd": "npm run build:docker --workspace @cloudflare/workspace-wsd", + "predev": "npm run build:wsd", + "predeploy": "npm run build:wsd", + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run", + "typecheck": "tsc -b --pretty false", + "preview": "npm run build && vite preview", + "deploy": "npm run build && wrangler deploy", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "@cloudflare/kumo": "^2.4.1", + "@cloudflare/sandbox": "^0.11.0", + "@cloudflare/think": "^0.8.2", + "@cloudflare/workspace": "*", + "@cloudflare/workspace-rpc": "*", + "@phosphor-icons/react": "^2.1.10", + "@shikijs/core": "^4.2.0", + "@shikijs/engine-javascript": "^4.2.0", + "@shikijs/langs": "^4.2.0", + "@shikijs/themes": "^4.2.0", + "agents": "^0.14.1", + "ai": "^6.0.196", + "partyserver": "^0.5.6", + "partysocket": "^1.1.19", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "shiki": "^4.2.0", + "workers-ai-provider": "^3.1.14", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.39.2", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/react": "^16.3.2", + "@types/node": "^25.9.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "vite": "^8.0.14", + "vitest": "^4.1.7", + "wrangler": "^4.95.0" + } +} diff --git a/examples/think-compare-runtimes/shared/events.ts b/examples/think-compare-runtimes/shared/events.ts new file mode 100644 index 00000000..20a531b9 --- /dev/null +++ b/examples/think-compare-runtimes/shared/events.ts @@ -0,0 +1,35 @@ +export type RuntimeId = "workspace" | "sandbox"; +export type EventRuntime = RuntimeId | "both"; +export type ExecutionTarget = "worker-shell" | "workspace-container" | "sandbox-container"; + +export type RunEventKind = + | "run_started" + | "run_completed" + | "runtime_started" + | "runtime_completed" + | "runtime_failed" + | "runtime_note" + | "container_acquired" + | "container_released" + | "container_release_scheduled" + | "agent_message" + | "agent_message_delta" + | "agent_thinking_delta" + | "agent_step" + | "agent_tool_call" + | "agent_tool_result" + | "agent_tool_error" + | "tool_call" + | "tool_result" + | "tool_error"; + +export interface RunEvent { + id: string; + runId: string; + sequence: number; + runtime: EventRuntime; + kind: RunEventKind; + title: string; + detail: string; + timestamp: string; +} diff --git a/examples/think-compare-runtimes/shared/fixture.test.ts b/examples/think-compare-runtimes/shared/fixture.test.ts new file mode 100644 index 00000000..d855ea00 --- /dev/null +++ b/examples/think-compare-runtimes/shared/fixture.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "vitest"; +import { comparisonFixture } from "./fixture"; + +describe("comparisonFixture", () => { + test("defines a docs feature task for both runtimes", () => { + expect(comparisonFixture.root).toBe("/workspace/repo"); + expect(comparisonFixture.files.map((file) => file.path)).toEqual([ + "package.json", + "README.md", + "style-guide.md", + "docs-nav.json", + "feature-briefs/smart-request-policies.md", + "docs/workers/index.md", + "docs/workers/routing.md", + "docs/workers/security.md", + "docs/workers/examples/authenticated-api.md", + "docs/workers/examples/rate-limit.md", + "docs/_partials/beta-note.md", + "scripts/check-docs.mjs", + ]); + expect(comparisonFixture.task).toContain("Add documentation for Smart Request Policies"); + expect(comparisonFixture.task).toContain("create a new Workers docs page"); + expect(comparisonFixture.task).toContain("update the docs navigation"); + }); + + test("provides source material for a file-first docs workflow", () => { + const brief = fileContents("feature-briefs/smart-request-policies.md"); + const styleGuide = fileContents("style-guide.md"); + const nav = fileContents("docs-nav.json"); + const checker = fileContents("scripts/check-docs.mjs"); + + expect(brief).toContain("Smart Request Policies"); + expect(brief).toContain("Enterprise report exports"); + expect(styleGuide).toContain("Frontmatter"); + expect(styleGuide).toContain("Workers docs style"); + expect(nav).toContain("Workers"); + expect(checker).toContain("docs/workers/smart-request-policies.md"); + expect(checker).toContain("docs-nav.json"); + }); + + test("reports all docs validation failures as repair instructions", () => { + const files = createFixtureFiles(); + files.set( + `${comparisonFixture.root}/docs/workers/smart-request-policies.md`, + [ + "---", + "title: Smart Request Policies", + "description: Configure Smart Request Policies.", + "lastUpdated: 2026-06-05", + "---", + "", + "# Smart Request Policies", + "", + "This draft intentionally misses several validation requirements.", + "", + "~~~ts", + "export default { async fetch() { return new Response('ok'); } };", + "~~~", + "", + ].join("\n"), + ); + + const result = runDocsCheck(files); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain("docs validation failed:"); + expect(result.output).toContain('exact header name "x-bypass-token"'); + expect(result.output).toContain('exact phrase "Enterprise report exports"'); + expect(result.output).toContain('path "/workers/smart-request-policies/"'); + expect(result.output).toContain('README.md must include "smart-request-policies"'); + }); + + test("accepts docs that satisfy the validation contract", () => { + const files = createFixtureFiles(); + files.set( + `${comparisonFixture.root}/docs/workers/smart-request-policies.md`, + [ + "---", + "title: Smart Request Policies", + "description: Configure Smart Request Policies for Workers requests.", + "lastUpdated: 2026-06-05", + "---", + "", + "# Smart Request Policies", + "", + "Smart Request Policies evaluate method, path, header, and risk-signal rules before a Worker handler runs.", + "", + "Enterprise report exports can use a route-specific bypass token for scheduled jobs.", + "The Worker can check the `x-bypass-token` header before allowing sensitive export routes.", + "", + "~~~ts", + "export default { async fetch(request: Request, env: Env): Promise {", + " const url = new URL(request.url);", + " if (url.pathname === '/reports/export' && request.method !== 'GET') {", + " if (request.headers.get('x-bypass-token') !== env.EXPORT_BYPASS_TOKEN) {", + " return new Response('Policy denied', { status: 403 });", + " }", + " }", + " return fetch(request);", + "} };", + "~~~", + "", + ].join("\n"), + ); + files.set( + `${comparisonFixture.root}/docs-nav.json`, + JSON.stringify( + { + sections: [ + { + title: "Workers", + items: [ + { title: "Smart Request Policies", path: "/workers/smart-request-policies/" }, + ], + }, + ], + }, + null, + 2, + ), + ); + files.set( + `${comparisonFixture.root}/README.md`, + "See docs/workers/smart-request-policies.md for Smart Request Policies.\n", + ); + + const result = runDocsCheck(files); + + expect(result).toEqual({ exitCode: 0, output: "docs check passed" }); + }); +}); + +function createFixtureFiles(): Map { + return new Map( + comparisonFixture.files.map((file) => [ + `${comparisonFixture.root}/${file.path}`, + file.contents, + ]), + ); +} + +function runDocsCheck(files: Map): { exitCode: number; output: string } { + const output: string[] = []; + const script = fileContents("scripts/check-docs.mjs").replace( + 'import { readFileSync } from "node:fs";', + "", + ); + const readFileSync = (path: string): string => { + const contents = files.get(`${comparisonFixture.root}/${path}`); + if (contents === undefined) throw new Error(`ENOENT: ${path}`); + return contents; + }; + const consoleLike = { + error: (message: string) => output.push(message), + log: (message: string) => output.push(message), + }; + const processLike = { + exit(code: number) { + throw new DocsCheckExit(code); + }, + }; + + try { + new Function("readFileSync", "console", "process", script)( + readFileSync, + consoleLike, + processLike, + ); + return { exitCode: 0, output: output.join("\n") }; + } catch (error) { + if (error instanceof DocsCheckExit) { + return { exitCode: error.code, output: output.join("\n") }; + } + throw error; + } +} + +class DocsCheckExit extends Error { + readonly code: number; + + constructor(code: number) { + super(`docs check exited with ${code}`); + this.code = code; + } +} + +function fileContents(path: string): string { + const file = comparisonFixture.files.find((candidate) => candidate.path === path); + expect(file, `missing fixture file ${path}`).toBeTruthy(); + return file?.contents ?? ""; +} diff --git a/examples/think-compare-runtimes/shared/fixture.ts b/examples/think-compare-runtimes/shared/fixture.ts new file mode 100644 index 00000000..6c3785d4 --- /dev/null +++ b/examples/think-compare-runtimes/shared/fixture.ts @@ -0,0 +1,166 @@ +export interface FixtureFile { + path: string; + contents: string; +} + +export interface ComparisonFixture { + root: string; + task: string; + files: FixtureFile[]; +} + +export const comparisonFixture: ComparisonFixture = { + root: "/workspace/repo", + task: [ + "Add documentation for Smart Request Policies.", + "Use the feature brief, style guide, existing Workers docs, and examples to create a new Workers docs page, update the docs navigation, add a Worker example, and update the README.", + "Write the docs changes first; when the content is complete, run the available docs validation command and summarize what changed and how you verified it.", + ].join(" "), + files: [ + { + path: "package.json", + contents: `${JSON.stringify( + { + scripts: { + check: "node scripts/check-docs.mjs", + }, + dependencies: {}, + devDependencies: {}, + }, + null, + 2, + )}\n`, + }, + { + path: "README.md", + contents: `# Workers docs fixture\n\nThis repository is a small stand-in for Cloudflare Workers documentation. It contains existing docs pages, examples, navigation metadata, and feature briefs that should be used together when adding a new feature page.\n\n## Current sections\n\n- Workers overview\n- Routing\n- Security\n- Examples\n\nRun \`npm run check\` after documentation changes are complete to verify the new page, navigation entry, and example requirements.\n`, + }, + { + path: "style-guide.md", + contents: `# Workers docs style guide\n\n## Voice\n\nWrite in a direct, helpful style for developers building on Cloudflare Workers. Prefer concrete examples over abstract platform language.\n\n## Frontmatter\n\nEvery docs page must begin with YAML frontmatter containing \`title\`, \`description\`, and \`lastUpdated\`. Use an ISO date for \`lastUpdated\`.\n\n## Workers docs style\n\n- Start with a short explanation of what the feature does.\n- Include a small Worker example when the feature affects request handling.\n- Mention beta limitations in a clearly labeled section.\n- Link to related Workers docs using relative links.\n- Avoid marketing claims such as "best", "magic", or "instant".\n`, + }, + { + path: "docs-nav.json", + contents: `${JSON.stringify( + { + sections: [ + { + title: "Workers", + items: [ + { title: "Overview", path: "/workers/" }, + { title: "Routing", path: "/workers/routing/" }, + { title: "Security", path: "/workers/security/" }, + ], + }, + { + title: "Examples", + items: [ + { + title: "Authenticated API", + path: "/workers/examples/authenticated-api/", + }, + { title: "Rate limit", path: "/workers/examples/rate-limit/" }, + ], + }, + ], + }, + null, + 2, + )}\n`, + }, + { + path: "feature-briefs/smart-request-policies.md", + contents: `# Smart Request Policies\n\nSmart Request Policies let Workers evaluate incoming requests against declarative method, path, header, and risk-signal rules before application handlers run. The feature is in beta for Enterprise customers.\n\n## Audience\n\nDevelopers who maintain API Workers, internal tooling, and report export endpoints.\n\n## Core behavior\n\n- Policies run at the start of a Worker request.\n- Safe methods such as \`GET\` and \`HEAD\` can be allowed without a bypass token.\n- Mutating methods such as \`POST\`, \`PUT\`, \`PATCH\`, and \`DELETE\` should require an explicit bypass token when the route handles sensitive exports.\n- Enterprise report exports may use a route-specific bypass token for scheduled jobs.\n- Denied requests should return a short reason string suitable for logs.\n\n## Docs requirements\n\n- Create \`docs/workers/smart-request-policies.md\`.\n- Add the new page to the Workers section in \`docs-nav.json\`.\n- Include a Worker example that checks method, pathname, and an \`x-bypass-token\` header.\n- Mention that beta policies do not replace application authorization.\n- Update the repository README so maintainers can find the new page.\n\n## Related topics\n\n- Routing rules are documented in \`docs/workers/routing.md\`.\n- Security recommendations are documented in \`docs/workers/security.md\`.\n`, + }, + { + path: "docs/workers/index.md", + contents: `---\ntitle: Workers overview\ndescription: Build serverless applications on Cloudflare's global network.\nlastUpdated: 2026-05-20\n---\n\n# Workers overview\n\nCloudflare Workers run JavaScript and TypeScript close to users. Workers can inspect requests, route traffic, call storage services, and generate responses without managing servers.\n\n## Common tasks\n\n- Route requests to different origins.\n- Protect APIs with request checks.\n- Transform responses at the edge.\n- Connect to Cloudflare storage and AI services.\n\nFor request routing details, see [Routing](./routing.md). For security patterns, see [Security](./security.md).\n`, + }, + { + path: "docs/workers/routing.md", + contents: `---\ntitle: Workers routing\ndescription: Route requests in Workers using URL and method checks.\nlastUpdated: 2026-05-21\n---\n\n# Workers routing\n\nWorkers receive a \`Request\` object and can branch on method, pathname, headers, and other request metadata.\n\n~~~ts\nexport default {\n async fetch(request: Request): Promise {\n const url = new URL(request.url);\n\n if (request.method === "GET" && url.pathname === "/health") {\n return Response.json({ ok: true });\n }\n\n return new Response("Not found", { status: 404 });\n },\n};\n~~~\n\nKeep routing checks close to the code that handles the matching request.\n`, + }, + { + path: "docs/workers/security.md", + contents: `---\ntitle: Workers security\ndescription: Apply request validation and authorization checks in Workers.\nlastUpdated: 2026-05-22\n---\n\n# Workers security\n\nWorkers can enforce lightweight request checks before calling application code. Use these checks together with application authorization and origin-side controls.\n\n## Recommendations\n\n- Validate methods before handling mutating routes.\n- Treat headers as untrusted input unless they are set by trusted infrastructure.\n- Return short denial reasons for logs without exposing sensitive policy details to callers.\n- Keep security checks explicit and easy to review.\n`, + }, + { + path: "docs/workers/examples/authenticated-api.md", + contents: `---\ntitle: Authenticated API example\ndescription: Check an authorization header before proxying an API request.\nlastUpdated: 2026-05-23\n---\n\n# Authenticated API example\n\nThis Worker checks a bearer token before forwarding traffic to an API origin.\n\n~~~ts\nexport default {\n async fetch(request: Request, env: Env): Promise {\n const token = request.headers.get("authorization");\n\n if (token !== "Bearer " + env.API_TOKEN) {\n return new Response("Unauthorized", { status: 401 });\n }\n\n return fetch(request);\n },\n};\n~~~\n\nUse application-specific authorization for user and tenant decisions.\n`, + }, + { + path: "docs/workers/examples/rate-limit.md", + contents: `---\ntitle: Rate limit example\ndescription: Apply a simple path-specific request limit in a Worker.\nlastUpdated: 2026-05-24\n---\n\n# Rate limit example\n\nThis example shows where request protection logic can run before application handlers.\n\n~~~ts\nexport default {\n async fetch(request: Request): Promise {\n const url = new URL(request.url);\n\n if (url.pathname.startsWith("/api/") && request.method !== "GET") {\n return new Response("Limited", { status: 429 });\n }\n\n return new Response("OK");\n },\n};\n~~~\n`, + }, + { + path: "docs/_partials/beta-note.md", + contents: `> Beta features can change before general availability. Test policies in a staging environment before using them for production request handling.\n`, + }, + { + path: "scripts/check-docs.mjs", + contents: `import { readFileSync } from "node:fs"; + +const failures = []; + +function readRequired(path, purpose) { + try { + return readFileSync(path, "utf8"); + } catch (error) { + failures.push(path + " is required for " + purpose + ". Create or repair this file, then rerun npm run check."); + return ""; + } +} + +function parseJson(path, source) { + try { + return JSON.parse(source); + } catch (error) { + failures.push(path + " must contain valid JSON. Repair the JSON syntax, then rerun npm run check."); + return {}; + } +} + +function assert(condition, message) { + if (!condition) { + failures.push(message); + } +} + +const pagePath = "docs/workers/smart-request-policies.md"; +const page = readRequired(pagePath, "the Smart Request Policies docs page"); +const nav = parseJson("docs-nav.json", readRequired("docs-nav.json", "the Workers navigation entry")); +const readme = readRequired("README.md", "the maintainer-facing page link"); + +assert(page.startsWith("---\\n"), pagePath + " must start with YAML frontmatter containing title, description, and lastUpdated."); +assert(page.includes("title:"), pagePath + " frontmatter must include title."); +assert(page.includes("description:"), pagePath + " frontmatter must include description."); +assert(page.includes("lastUpdated:"), pagePath + " frontmatter must include lastUpdated."); +assert(page.includes("Smart Request Policies"), pagePath + " must describe Smart Request Policies by name."); +assert(page.includes("x-bypass-token"), pagePath + ' must include the exact header name "x-bypass-token". Add it to the Worker example or policy explanation.'); +assert(page.includes("Enterprise report exports"), pagePath + ' must include the exact phrase "Enterprise report exports". Explain how scheduled Enterprise report exports can use a route-specific bypass token.'); +assert(page.includes("~~~ts") || page.includes("\`\`\`ts"), pagePath + ' must include a fenced TypeScript Worker example using ~~~ts or "three-backtick ts".'); +assert(!page.includes("TODO"), pagePath + " must not contain TODO placeholders. Replace placeholders with final docs content."); + +const sections = Array.isArray(nav.sections) ? nav.sections : []; +const workers = sections.find((section) => section && section.title === "Workers"); +assert(workers, "docs-nav.json must contain the Workers section."); +const workerItems = Array.isArray(workers?.items) ? workers.items : []; +assert( + workerItems.some((item) => item && item.path === "/workers/smart-request-policies/"), + 'docs-nav.json must include a Workers item with path "/workers/smart-request-policies/".', +); +assert( + readme.includes("smart-request-policies"), + 'README.md must include "smart-request-policies" so maintainers can find the new page.', +); + +if (failures.length > 0) { + console.error(["docs validation failed:", "", ...failures.map((failure, index) => String(index + 1) + ". " + failure)].join("\\n")); + process.exit(1); +} + +console.log("docs check passed"); +`, + }, + ], +}; diff --git a/examples/think-compare-runtimes/src/App.test.tsx b/examples/think-compare-runtimes/src/App.test.tsx new file mode 100644 index 00000000..27693f70 --- /dev/null +++ b/examples/think-compare-runtimes/src/App.test.tsx @@ -0,0 +1,368 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { App } from "./App"; + +vi.mock("partysocket/react", () => ({ + usePartySocket: vi.fn(), +})); + +describe("App", () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("renders a clean idle substrate instrument", () => { + vi.stubGlobal("fetch", vi.fn()); + + render(); + + expect(screen.getByText("Workspace / Sandbox")).toBeTruthy(); + expect(screen.queryByText("Workspace vs Sandbox · same task · same model")).toBeNull(); + expect(screen.queryByText("TASK")).toBeNull(); + expect(screen.queryByText(/run-/)).toBeNull(); + expect(screen.getByRole("button", { name: "START RUN" })).toBeTruthy(); + + const workspace = screen.getByLabelText("Workspace runtime wing"); + const sandbox = screen.getByLabelText("Sandbox runtime wing"); + + expect(within(workspace).getByText("Workspace")).toBeTruthy(); + expect(within(workspace).queryByText("VFS · dynamic worker · container escalation")).toBeNull(); + expect(within(workspace).getAllByText("VFS").length).toBeGreaterThanOrEqual(1); + expect(within(workspace).getAllByText("Dynamic worker").length).toBeGreaterThanOrEqual(1); + expect(within(workspace).getAllByText("Container").length).toBeGreaterThanOrEqual(1); + expect(within(workspace).queryByText("Routing summary")).toBeNull(); + expect(within(workspace).queryByText("Details")).toBeNull(); + expect(within(workspace).queryByText("Check")).toBeNull(); + + expect(within(sandbox).getAllByText("Sandbox").length).toBeGreaterThanOrEqual(1); + expect(within(sandbox).queryByText("Container-native files and commands")).toBeNull(); + }); + + test("starts a comparison run without exposing debug run IDs", async () => { + const fetchMock = vi.fn(async () => + Response.json( + { + runId: "run-123", + socketPath: "/parties/compare-run/run-123", + events: [ + event({ + id: "run-123:0", + runId: "run-123", + sequence: 0, + runtime: "both", + kind: "run_started", + title: "Comparison run started", + detail: "Both agents are starting.", + timestamp: new Date().toISOString(), + }), + ], + }, + { status: 201 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + fireEvent.click(screen.getByRole("button", { name: "START RUN" })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/runs", { method: "POST" })); + expect(screen.queryByText("run-123")).toBeNull(); + expect((screen.getByRole("button", { name: "STOP RUN" }) as HTMLButtonElement).disabled).toBe( + false, + ); + }); + + test("stops and discards the visible run so a new run can start", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === "/api/runs/run-123/stop") { + return new Response(null, { status: 204 }); + } + return Response.json( + { + runId: "run-123", + socketPath: "/parties/compare-run/run-123", + events: [ + event({ + id: "run-123:0", + runId: "run-123", + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: new Date().toISOString(), + }), + ], + }, + { status: 201 }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + fireEvent.click(screen.getByRole("button", { name: "START RUN" })); + const stopButton = await screen.findByRole("button", { name: "STOP RUN" }); + + fireEvent.click(stopButton); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith("/api/runs/run-123/stop", { method: "POST" }), + ); + expect(screen.getByRole("button", { name: "START RUN" })).toBeTruthy(); + expect(screen.queryByText(/Running ·/)).toBeNull(); + }); + + test("updates running elapsed time before agents finish", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-04T00:00:00.000Z")); + vi.stubGlobal( + "fetch", + sessionWithEvents([ + event({ + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + event({ + sequence: 1, + runtime: "workspace", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + ]), + ); + + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "START RUN" })); + await Promise.resolve(); + }); + + act(() => { + vi.setSystemTime(new Date("2026-06-04T00:00:01.000Z")); + vi.advanceTimersByTime(1000); + }); + + expect( + within(screen.getByLabelText("Workspace runtime wing")).getByText(/Running · 00:02/i), + ).toBeTruthy(); + }); + + test("renders substrate lanes and streamed thinking without raw event details", async () => { + vi.stubGlobal( + "fetch", + sessionWithEvents([ + event({ + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + event({ + sequence: 1, + runtime: "workspace", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 2, + runtime: "workspace", + kind: "agent_tool_call", + title: "Think requested read", + detail: JSON.stringify({ path: "/workspace/repo/docs/workers/security.md" }), + timestamp: "2026-06-04T00:00:02.000Z", + }), + event({ + sequence: 3, + runtime: "workspace", + kind: "agent_message_delta", + title: "Think response stream", + detail: "Reading the related docs before editing.", + timestamp: "2026-06-04T00:00:03.000Z", + }), + event({ + sequence: 4, + runtime: "workspace", + kind: "agent_tool_result", + title: "Think exec result", + detail: JSON.stringify({ + command: "grep -R Smart docs", + cwd: "/workspace/repo", + executionTarget: "worker-shell", + exitCode: 0, + stdout: "docs/workers/security.md:Smart Request Policies", + stderr: "", + }), + timestamp: "2026-06-04T00:00:05.000Z", + }), + event({ + sequence: 5, + runtime: "sandbox", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 6, + runtime: "sandbox", + kind: "agent_tool_result", + title: "Think exec result", + detail: JSON.stringify({ + command: "npm run check", + cwd: "/workspace/repo", + executionTarget: "sandbox-container", + exitCode: 0, + stdout: "docs check passed", + stderr: "", + }), + timestamp: "2026-06-04T00:00:06.000Z", + }), + ]), + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "START RUN" })); + + const workspace = await screen.findByLabelText("Workspace runtime wing"); + const sandbox = screen.getByLabelText("Sandbox runtime wing"); + + expect(within(workspace).getByLabelText("workspace substrate timeline")).toBeTruthy(); + expect( + await within(workspace).findByText("Reading the related docs before editing."), + ).toBeTruthy(); + const workspaceStream = within(workspace).getByLabelText("workspace agent work stream"); + expect(workspaceStream.className).toContain("overflow-y-auto"); + expect(within(workspace).getAllByText("Dynamic worker").length).toBeGreaterThanOrEqual(1); + expect(within(workspace).getByText("$ grep -R Smart docs")).toBeTruthy(); + expect(within(workspace).getAllByText("dynamic worker").length).toBeGreaterThanOrEqual(1); + expect(within(workspace).getByText("exit 0")).toBeTruthy(); + expect(within(workspace).queryByText("message")).toBeNull(); + expect(within(workspace).queryByText("Command requested")).toBeNull(); + expect(within(workspace).getAllByText("1").length).toBeGreaterThanOrEqual(2); + expect(within(workspace).queryByText("Routing summary")).toBeNull(); + expect(within(workspace).queryByText("agent · tool · result")).toBeNull(); + + expect(within(sandbox).getByLabelText("sandbox substrate timeline")).toBeTruthy(); + expect(within(sandbox).getAllByText("Sandbox").length).toBeGreaterThanOrEqual(1); + expect(within(sandbox).getByText("exit 0")).toBeTruthy(); + expect(within(sandbox).queryByText("Check")).toBeNull(); + expect(within(sandbox).queryByText("State")).toBeNull(); + }); + + test("renders assistant response details as Markdown in the secondary transcript", async () => { + vi.stubGlobal( + "fetch", + sessionWithEvents([ + event({ + sequence: 0, + runtime: "workspace", + kind: "agent_message", + title: "Think turn complete", + detail: + "## Summary of Changes\n\nI modified `docs/workers/smart-request-policies.md`.\n\n- `npm run check` passed.", + timestamp: "2026-06-04T00:00:03.000Z", + }), + ]), + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "START RUN" })); + + const workspace = await screen.findByLabelText("Workspace runtime wing"); + expect(within(workspace).getByRole("heading", { name: "Summary of Changes" })).toBeTruthy(); + expect(within(workspace).getByText("docs/workers/smart-request-policies.md")).toBeTruthy(); + expect(within(workspace).getByText("npm run check")).toBeTruthy(); + }); + + test("renders completed run status and capacity failure hints", async () => { + vi.stubGlobal( + "fetch", + sessionWithEvents([ + event({ + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + event({ + sequence: 1, + runtime: "workspace", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 2, + runtime: "workspace", + kind: "runtime_completed", + title: "Workspace runtime completed", + timestamp: "2026-06-04T00:02:51.000Z", + }), + event({ + sequence: 3, + runtime: "sandbox", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:02.000Z", + }), + event({ + sequence: 4, + runtime: "sandbox", + kind: "runtime_failed", + title: "Sandbox runtime failed", + detail: "3040: Capacity temporarily exceeded, please try again.", + timestamp: "2026-06-04T00:03:42.000Z", + }), + event({ + sequence: 5, + runtime: "both", + kind: "run_completed", + title: "Comparison run complete", + timestamp: "2026-06-04T00:03:42.000Z", + }), + ]), + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "START RUN" })); + + expect( + await within(screen.getByLabelText("Workspace runtime wing")).findByText( + /Completed · 02:50/i, + ), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "RUN AGAIN" })).toBeTruthy(); + + const sandbox = screen.getByLabelText("Sandbox runtime wing"); + + expect(within(sandbox).getByText(/Failed · 03:40/i)).toBeTruthy(); + expect(within(sandbox).getByText("Upstream model capacity; retry later.")).toBeTruthy(); + }); +}); + +function sessionWithEvents(events: ReturnType[]) { + return vi.fn(async () => + Response.json( + { + runId: "run-456", + socketPath: "/parties/compare-run/run-456", + events, + }, + { status: 201 }, + ), + ); +} + +function event(overrides: Partial) { + return { + id: `run-1:${overrides.sequence ?? 0}`, + runId: "run-1", + sequence: overrides.sequence ?? 0, + runtime: overrides.runtime ?? "both", + kind: overrides.kind ?? "run_started", + title: overrides.title ?? "Event", + detail: overrides.detail ?? "Detail", + timestamp: overrides.timestamp ?? "1970-01-01T00:00:00.000Z", + ...overrides, + } as import("../shared/events").RunEvent; +} diff --git a/examples/think-compare-runtimes/src/App.tsx b/examples/think-compare-runtimes/src/App.tsx new file mode 100644 index 00000000..b9188d73 --- /dev/null +++ b/examples/think-compare-runtimes/src/App.tsx @@ -0,0 +1,138 @@ +import { usePartySocket } from "partysocket/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { RunEvent } from "../shared/events"; +import { buildDashboardModel } from "./dashboard-model"; +import { applyRunMessage, type RunMessage } from "./run-state"; +import { RuntimeWing } from "./runtime-wing"; +import { TopBar } from "./top-bar"; + +interface RunSessionResponse { + runId: string; + socketPath: string; + events: RunEvent[]; +} + +type StartState = "idle" | "starting" | "running" | "failed"; + +export function App() { + const [runId, setRunId] = useState(null); + const [events, setEvents] = useState([]); + const [startState, setStartState] = useState("idle"); + const [error, setError] = useState(null); + const [nowIso, setNowIso] = useState(() => new Date().toISOString()); + const activeRunIdRef = useRef(null); + + usePartySocket({ + party: "compare-run", + room: runId ?? "idle", + enabled: runId !== null, + onMessage(message) { + const parsed = JSON.parse(String(message.data)) as RunMessage; + const activeRunId = activeRunIdRef.current; + if (!activeRunId || !messageBelongsToRun(parsed, activeRunId)) return; + setEvents((current) => applyRunMessage(current, parsed)); + }, + }); + + const dashboard = useMemo(() => buildDashboardModel(events, nowIso), [events, nowIso]); + const runLabel = runStatusLabel(startState, dashboard.run.status, dashboard.run.elapsedLabel); + const actionLabel = + startState === "starting" + ? "STARTING" + : startState === "running" && dashboard.run.status === "running" + ? "STOP RUN" + : runId + ? dashboard.run.actionLabel + : "START RUN"; + const startDisabled = startState === "starting"; + + useEffect(() => { + if (dashboard.run.status !== "running" && startState !== "running") return; + + setNowIso(new Date().toISOString()); + const timer = setInterval(() => { + setNowIso(new Date().toISOString()); + }, 1000); + + return () => clearInterval(timer); + }, [dashboard.run.status, startState]); + + async function handleRunAction() { + if (startState === "running" && dashboard.run.status === "running") { + stopRun(); + return; + } + await startRun(); + } + + async function startRun() { + activeRunIdRef.current = null; + setRunId(null); + setEvents([]); + setStartState("starting"); + setError(null); + setNowIso(new Date().toISOString()); + + try { + const response = await fetch("/api/runs", { method: "POST" }); + + if (!response.ok) { + throw new Error(`Run request failed with ${response.status}`); + } + + const session = (await response.json()) as RunSessionResponse; + activeRunIdRef.current = session.runId; + setRunId(session.runId); + setEvents(session.events); + setStartState("running"); + } catch (cause) { + setStartState("failed"); + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + function stopRun() { + const stoppedRunId = activeRunIdRef.current ?? runId; + activeRunIdRef.current = null; + setRunId(null); + setEvents([]); + setStartState("idle"); + setError(null); + setNowIso(new Date().toISOString()); + + if (stoppedRunId) { + void fetch(`/api/runs/${encodeURIComponent(stoppedRunId)}/stop`, { method: "POST" }); + } + } + + return ( +
+ + +
+ + +
+
+ ); +} + +function runStatusLabel(startState: StartState, status: string, elapsedLabel: string): string { + if (startState === "starting") return "STARTING"; + if (startState === "failed" || status === "failed") return `FAILED · ${elapsedLabel}`; + if (status === "completed") return `DONE · ${elapsedLabel}`; + if (status === "running") return `RUN · ${elapsedLabel}`; + return "IDLE"; +} + +function messageBelongsToRun(message: RunMessage, runId: string): boolean { + if (message.type === "event") return message.event.runId === runId; + return message.events.every((event) => event.runId === runId); +} diff --git a/examples/think-compare-runtimes/src/auto-scroll-list.test.tsx b/examples/think-compare-runtimes/src/auto-scroll-list.test.tsx new file mode 100644 index 00000000..29b2fc2c --- /dev/null +++ b/examples/think-compare-runtimes/src/auto-scroll-list.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { AutoScrollList } from "./auto-scroll-list"; + +describe("AutoScrollList", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + test("scrolls to the bottom when its watch key changes", () => { + const scrollTo = vi.fn(); + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(480); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + value: scrollTo, + }); + + const { rerender } = render( + +
  • first event
  • +
    , + ); + + expect(scrollTo).toHaveBeenLastCalledWith({ top: 480, behavior: "smooth" }); + + rerender( + +
  • first event
  • +
  • second event
  • +
    , + ); + + expect(scrollTo).toHaveBeenCalledTimes(2); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 480, behavior: "smooth" }); + }); + + test("keeps separate lists independently scrollable", () => { + const scrollTo = vi.fn(); + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(320); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + value: scrollTo, + }); + + render( +
    + +
  • workspace event
  • +
    + +
  • sandbox event
  • +
    +
    , + ); + + expect(scrollTo).toHaveBeenCalledTimes(2); + expect(scrollTo.mock.contexts[0]).not.toBe(scrollTo.mock.contexts[1]); + }); +}); diff --git a/examples/think-compare-runtimes/src/auto-scroll-list.tsx b/examples/think-compare-runtimes/src/auto-scroll-list.tsx new file mode 100644 index 00000000..c279bce5 --- /dev/null +++ b/examples/think-compare-runtimes/src/auto-scroll-list.tsx @@ -0,0 +1,34 @@ +import { type ReactNode, useEffect, useRef } from "react"; + +export function AutoScrollList({ + ariaLabel, + children, + className, + watchKey, +}: { + ariaLabel: string; + children: ReactNode; + className?: string; + watchKey: string | number; +}) { + const listRef = useRef(null); + + useEffect(() => { + void watchKey; + const list = listRef.current; + if (!list) return; + + if (typeof list.scrollTo === "function") { + list.scrollTo({ top: list.scrollHeight, behavior: "smooth" }); + return; + } + + list.scrollTop = list.scrollHeight; + }, [watchKey]); + + return ( +
      + {children} +
    + ); +} diff --git a/examples/think-compare-runtimes/src/dashboard-model.test.ts b/examples/think-compare-runtimes/src/dashboard-model.test.ts new file mode 100644 index 00000000..95b7146b --- /dev/null +++ b/examples/think-compare-runtimes/src/dashboard-model.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "vitest"; +import type { EventRuntime, RunEvent, RunEventKind } from "../shared/events"; +import { buildDashboardModel } from "./dashboard-model"; + +describe("buildDashboardModel", () => { + test("derives idle telemetry before a run starts", () => { + const model = buildDashboardModel([], null); + + expect(model.run.status).toBe("idle"); + expect(model.run.actionLabel).toBe("START RUN"); + expect(model.runtimes.workspace.container).toBe("asleep"); + expect(model.runtimes.sandbox.container).toBe("off"); + expect(model.runtimes.workspace.toolCalls).toBe(0); + expect(model.runtimes.sandbox.execCalls).toBe(0); + }); + + test("shows Sandbox cold boot while Workspace is already active", () => { + const model = buildDashboardModel( + [ + event({ + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + event({ + sequence: 1, + runtime: "workspace", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 2, + runtime: "workspace", + kind: "tool_call", + title: "read called", + detail: JSON.stringify({ name: "read", path: "/workspace/repo/src/index.ts" }), + timestamp: "2026-06-04T00:00:02.000Z", + }), + event({ + sequence: 3, + runtime: "sandbox", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:03.000Z", + }), + ], + "2026-06-04T00:00:04.000Z", + ); + + expect(model.run.status).toBe("running"); + expect(model.run.elapsedLabel).toBe("00:04"); + expect(model.runtimes.workspace.status).toBe("running"); + expect(model.runtimes.workspace.elapsedLabel).toBe("00:03"); + expect(model.runtimes.workspace.toolCalls).toBe(1); + expect(model.runtimes.workspace.fileOps).toBe(1); + expect(model.runtimes.workspace.execCalls).toBe(0); + expect(model.runtimes.workspace.workerShellExecs).toBe(0); + expect(model.runtimes.workspace.containerExecs).toBe(0); + expect(model.runtimes.workspace.container).toBe("asleep"); + expect(model.runtimes.sandbox.container).toBe("booting"); + }); + + test("counts Workspace exec routing by execution target", () => { + const model = buildDashboardModel( + [ + event({ + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + event({ + sequence: 1, + runtime: "workspace", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 2, + runtime: "sandbox", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 3, + runtime: "workspace", + kind: "tool_call", + title: "exec called", + detail: JSON.stringify({ + command: "grep -R Smart docs", + executionTarget: "worker-shell", + cwd: "/workspace/repo", + }), + timestamp: "2026-06-04T00:00:05.000Z", + }), + event({ + sequence: 4, + runtime: "workspace", + kind: "tool_call", + title: "exec called", + detail: JSON.stringify({ + command: "npm run check", + executionTarget: "workspace-container", + cwd: "/workspace/repo", + }), + timestamp: "2026-06-04T00:00:06.000Z", + }), + event({ + sequence: 5, + runtime: "sandbox", + kind: "agent_tool_call", + title: "Think requested exec", + detail: JSON.stringify({ command: "npm test", cwd: "/workspace/repo" }), + timestamp: "2026-06-04T00:00:07.000Z", + }), + ], + "2026-06-04T00:00:08.000Z", + ); + + expect(model.runtimes.workspace.toolCalls).toBe(2); + expect(model.runtimes.workspace.execCalls).toBe(2); + expect(model.runtimes.workspace.workerShellExecs).toBe(1); + expect(model.runtimes.workspace.containerExecs).toBe(1); + expect(model.runtimes.workspace.validationStatus).toBe("passed"); + expect(model.runtimes.workspace.container).toBe("awake"); + expect(model.runtimes.sandbox.toolCalls).toBe(1); + expect(model.runtimes.sandbox.execCalls).toBe(1); + expect(model.runtimes.sandbox.containerExecs).toBe(1); + expect(model.runtimes.sandbox.container).toBe("awake"); + }); + + test("uses the latest validation result", () => { + const model = buildDashboardModel( + [ + event({ + sequence: 0, + runtime: "sandbox", + kind: "agent_tool_result", + title: "Think exec result", + detail: JSON.stringify({ + command: "npm run check", + executionTarget: "sandbox-container", + exitCode: 1, + }), + }), + event({ + sequence: 1, + runtime: "sandbox", + kind: "agent_tool_result", + title: "Think exec result", + detail: JSON.stringify({ + command: "npm run check", + executionTarget: "sandbox-container", + exitCode: 0, + }), + }), + ], + "2026-06-04T00:01:00.000Z", + ); + + expect(model.runtimes.sandbox.validationStatus).toBe("passed"); + }); + + test("uses terminal timestamps for completed runs", () => { + const model = buildDashboardModel( + [ + event({ + sequence: 0, + runtime: "both", + kind: "run_started", + timestamp: "2026-06-04T00:00:00.000Z", + }), + event({ + sequence: 1, + runtime: "workspace", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:02.000Z", + }), + event({ + sequence: 2, + runtime: "workspace", + kind: "runtime_completed", + timestamp: "2026-06-04T00:02:51.000Z", + }), + event({ + sequence: 3, + runtime: "sandbox", + kind: "runtime_started", + timestamp: "2026-06-04T00:00:01.000Z", + }), + event({ + sequence: 4, + runtime: "sandbox", + kind: "runtime_completed", + timestamp: "2026-06-04T00:03:42.000Z", + }), + event({ + sequence: 5, + runtime: "both", + kind: "run_completed", + timestamp: "2026-06-04T00:03:42.000Z", + }), + ], + "2026-06-04T00:10:00.000Z", + ); + + expect(model.run.status).toBe("completed"); + expect(model.run.actionLabel).toBe("RUN AGAIN"); + expect(model.run.elapsedLabel).toBe("03:42"); + expect(model.runtimes.workspace.elapsedLabel).toBe("02:49"); + expect(model.runtimes.sandbox.elapsedLabel).toBe("03:41"); + }); +}); + +function event(overrides: Partial & { sequence: number }): RunEvent { + return { + id: `run-1:${overrides.sequence}`, + runId: "run-1", + sequence: overrides.sequence, + runtime: (overrides.runtime ?? "both") as EventRuntime, + kind: (overrides.kind ?? "run_started") as RunEventKind, + title: overrides.title ?? "Event", + detail: overrides.detail ?? "Detail", + timestamp: overrides.timestamp ?? "1970-01-01T00:00:00.000Z", + }; +} diff --git a/examples/think-compare-runtimes/src/dashboard-model.ts b/examples/think-compare-runtimes/src/dashboard-model.ts new file mode 100644 index 00000000..25172760 --- /dev/null +++ b/examples/think-compare-runtimes/src/dashboard-model.ts @@ -0,0 +1,139 @@ +import type { RunEvent, RuntimeId } from "../shared/events"; +import { execObservationFacts, factsForRuntime, type RunEventFact } from "./run-event-facts"; +import { deriveRunSummary, type OverallRunStatus, type RuntimeRunStatus } from "./run-state"; + +export type ContainerState = "off" | "booting" | "asleep" | "awake"; + +export type ValidationStatus = "not-run" | "passed" | "failed"; + +export interface RuntimeDashboardModel { + id: RuntimeId; + status: RuntimeRunStatus; + elapsedLabel: string; + toolCalls: number; + fileOps: number; + execCalls: number; + workerShellExecs: number; + containerExecs: number; + validationStatus: ValidationStatus; + container: ContainerState; + error: string | null; + events: RunEvent[]; +} + +export interface DashboardModel { + run: { + status: OverallRunStatus; + elapsedLabel: string; + actionLabel: "START RUN" | "RUN AGAIN"; + }; + runtimes: Record; +} + +const runtimeIds: RuntimeId[] = ["workspace", "sandbox"]; + +export function buildDashboardModel(events: RunEvent[], nowIso: string | null): DashboardModel { + const summary = deriveRunSummary(events); + + return { + run: { + status: summary.status, + elapsedLabel: formatDuration( + summary.elapsedMs ?? runningElapsedMs(summary.startedAt, summary.completedAt, nowIso), + ), + actionLabel: + summary.status === "completed" || summary.status === "failed" ? "RUN AGAIN" : "START RUN", + }, + runtimes: Object.fromEntries( + runtimeIds.map((runtime) => { + const runtimeSummary = summary.runtimes[runtime]; + const facts = factsForRuntime(events, runtime, "runtimeOnly"); + const execs = execObservationFacts(facts); + const workerShellExecs = execs.filter( + (fact) => fact.executionTarget === "worker-shell", + ).length; + const workspaceContainerExecs = execs.filter( + (fact) => fact.executionTarget === "workspace-container", + ).length; + const sandboxContainerExecs = execs.filter( + (fact) => fact.executionTarget === "sandbox-container", + ).length; + const containerExecs = + runtime === "workspace" ? workspaceContainerExecs : sandboxContainerExecs; + + return [ + runtime, + { + id: runtime, + status: runtimeSummary.status, + elapsedLabel: formatDuration( + runtimeSummary.elapsedMs ?? + runningElapsedMs(runtimeSummary.startedAt, runtimeSummary.completedAt, nowIso), + ), + toolCalls: facts.filter((fact) => fact.phase === "call" && fact.tool !== null).length, + fileOps: facts.filter(isFileCall).length, + execCalls: execs.length, + workerShellExecs, + containerExecs, + validationStatus: validationStatus(facts), + container: containerState(runtime, runtimeSummary.status, facts, containerExecs), + error: runtimeSummary.error, + events: facts.map((fact) => fact.event), + }, + ]; + }), + ) as Record, + }; +} + +function runningElapsedMs( + startedAt: string | null, + completedAt: string | null, + nowIso: string | null, +): number | null { + if (!startedAt || completedAt || !nowIso) return null; + const elapsed = Date.parse(nowIso) - Date.parse(startedAt); + return Number.isNaN(elapsed) ? null : Math.max(0, elapsed); +} + +export function formatDuration(elapsedMs: number | null): string { + if (elapsedMs === null) return "--:--"; + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +function isFileCall(fact: RunEventFact): boolean { + return ( + fact.phase === "call" && (fact.tool === "read" || fact.tool === "write" || fact.tool === "edit") + ); +} + +function validationStatus(facts: RunEventFact[]): ValidationStatus { + const latestValidation = execObservationFacts(facts) + .filter((fact) => fact.validationCommand) + .at(-1); + if (!latestValidation) return "not-run"; + return latestValidation.failed ? "failed" : "passed"; +} + +function containerState( + runtime: RuntimeId, + status: RuntimeRunStatus, + facts: RunEventFact[], + containerExecs: number, +): ContainerState { + if (runtime === "workspace") { + return containerExecs > 0 ? "awake" : "asleep"; + } + + if (status === "idle") return "off"; + if ( + facts.some((fact) => fact.phase === "call" || fact.phase === "result") || + containerExecs > 0 + ) { + return "awake"; + } + return "booting"; +} diff --git a/examples/think-compare-runtimes/src/main.tsx b/examples/think-compare-runtimes/src/main.tsx new file mode 100644 index 00000000..07abc654 --- /dev/null +++ b/examples/think-compare-runtimes/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Root element not found"); +} + +createRoot(root).render( + + + , +); diff --git a/examples/think-compare-runtimes/src/markdown-text.tsx b/examples/think-compare-runtimes/src/markdown-text.tsx new file mode 100644 index 00000000..bee5aa26 --- /dev/null +++ b/examples/think-compare-runtimes/src/markdown-text.tsx @@ -0,0 +1,242 @@ +import { type ComponentProps, useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { HighlighterCore } from "shiki/core"; + +export function MarkdownText({ text }: { text: string }) { + return ( + + {text} + + ); +} + +function Heading2(props: ComponentProps<"h2"> & { node?: unknown }) { + const { node: _node, ...rest } = props; + return ( +

    + ); +} + +function Heading3(props: ComponentProps<"h3"> & { node?: unknown }) { + const { node: _node, ...rest } = props; + return

    ; +} + +function Paragraph(props: ComponentProps<"p"> & { node?: unknown }) { + const { node: _node, ...rest } = props; + return

    ; +} + +function OrderedList(props: ComponentProps<"ol"> & { node?: unknown }) { + const { node: _node, ...rest } = props; + return ( +

      + ); +} + +function UnorderedList(props: ComponentProps<"ul"> & { node?: unknown }) { + const { node: _node, ...rest } = props; + return ( +