diff --git a/.changeset/ponytail-dead-code.md b/.changeset/ponytail-dead-code.md new file mode 100644 index 000000000..15bba3199 --- /dev/null +++ b/.changeset/ponytail-dead-code.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": patch +"@agent-bundle/runtime": patch +--- + +Drop the unused `open` dependency from `agent-bundle` and remove internal helpers that no package entry point exported and no code reached (the MCP App sandbox bridge, `durable-fs` pinned-file readers, unused Effect boundary helpers, `boundRenderEventStream`). Installs get one fewer package; no exported API changes. (#654) diff --git a/agent-patterns/effect-errors.md b/agent-patterns/effect-errors.md index a0f318996..a78f73b26 100644 --- a/agent-patterns/effect-errors.md +++ b/agent-patterns/effect-errors.md @@ -105,7 +105,7 @@ Callers of `runPromise` see the same types they see today. - Swallowing interruption as a typed success. Cancellation is `AbortError`. `Stream.toReadableStream`'s `Cause.squash` is not that mapping — use the boundary helper. -- `Effect.runPromise` in a test to "see the error" when `runPromiseExit` + +- `Effect.runPromise` in a test to "see the error" when `Effect.exit` + `Cause` is the assertion you want — still only through the boundary. - New public error codes without updating the authoring docs and the mapping table in `docs/effect-conventions.md`. diff --git a/docs/canvases/agent-bundle-walkthrough.canvas.tsx b/docs/canvases/agent-bundle-walkthrough.canvas.tsx deleted file mode 100644 index 1a196c4cf..000000000 --- a/docs/canvases/agent-bundle-walkthrough.canvas.tsx +++ /dev/null @@ -1,975 +0,0 @@ -import { - BarChart, - Callout, - Card, - CardBody, - CardHeader, - CollapsibleSection, - Divider, - Grid, - H1, - H2, - H3, - Pill, - Row, - Stack, - Stat, - Table, - Text, - useHostTheme, - type CSSProperties, -} from "cursor/canvas"; - -/* ---------------------------------------------------------------- helpers */ - -const MONO = - 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace'; - -function Kicker({ children }: { children: string }) { - const t = useHostTheme(); - return ( - - {children} - - ); -} - -function MonoBlock({ text, dim }: { text: string; dim?: boolean }) { - const t = useHostTheme(); - const style: CSSProperties = { - background: t.fill.quaternary, - border: `1px solid ${t.stroke.tertiary}`, - borderRadius: 6, - color: dim ? t.text.secondary : t.text.primary, - fontFamily: MONO, - fontSize: 12, - lineHeight: "18px", - margin: 0, - overflowX: "auto", - padding: "10px 12px", - whiteSpace: "pre", - }; - return
{text}
; -} - -function StageBox({ - title, - detail, - emphasized, -}: { - title: string; - detail: string; - emphasized?: boolean; -}) { - const t = useHostTheme(); - return ( -
- - {title} - - - {detail} - -
- ); -} - -function FlowArrow() { - const t = useHostTheme(); - return ( -
- {"\u2192"} -
- ); -} - -function StepMarker({ n }: { n: number }) { - const t = useHostTheme(); - return ( -
- {n} -
- ); -} - -function WireStep({ - n, - title, - channel, - payload, - note, - last, -}: { - n: number; - title: string; - channel: string; - payload?: string; - note?: string; - last?: boolean; -}) { - const t = useHostTheme(); - return ( -
-
- - {!last && ( -
- )} -
-
- - {title} - - {channel} - - - {note !== undefined && ( - - {note} - - )} - {payload !== undefined && ( -
- -
- )} -
-
- ); -} - -/* ------------------------------------------------------------ wire bodies */ - -const AUTHORED_TREE = `my-plugin/ - agent-bundle.config.ts defineConfig({ plugin, targets, ... }) - src/ - mcp/curator/tools/search.tsx MCP tool (id: tool:curator/search) - mcp/curator/resources/catalog.tsx - mcp/curator/prompts/curate.tsx - mcp/curator/apps/panel.tsx MCP App (config.resourceUri) - events/tool/before.tsx event route (id: event:tool/before) - events/stop.tsx event route (id: event:stop) - providers/build-info.ts context provider factory - cli/library/audit.ts CLI command "library audit" - scripts/verify-release.ts plain script -> scripts/*.mjs - state.ts defineState({ id, lifetime, budgets }) - skills/curate/SKILL.md agent skill (or rendered SKILL.tsx) - rules/style.mdc Cursor rules component - commands/triage.md chat command (Claude + Cursor)`; - -const TOOL_ROUTE_SNIPPET = `// src/mcp/curator/tools/search_audible.tsx (examples/audiobook-curator) -export const config = { - annotations: { openWorldHint: true, readOnlyHint: false }, - description: "Search Audible regions and return ranked identity evidence...", -}; // statically extracted, never executed -export const inputSchema = operation.inputSchema; // zod, runtime boundary -export const resultSchema = operation.resultSchema; - -export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }); - return ; // renders Agent.* elements -}`; - -const EVENT_ROUTE_SNIPPET = `// src/events/tool/after.tsx (examples/rsc-agent-runtime) -export const config = { - runtime: 'standalone', // 'shared' (warm IPC runtime) | 'standalone' - targets: ['claude', 'codex'], - timeoutMs: 30_000, // budget inside the host's native deadline - tools: ['file.write'], // canonical selector -> per-host matcher regex -}; - -export default async function AfterFileEdit({ canonical, native, signal }: AgentEventRouteProps) { - // canonical.provenance = { host, hostContractRevision, nativeEvent, source: 'native' } - const snapshot = await kernel.recordEdit({ idempotencyKey: canonical.idempotencyKey, ... }); - return ( - - {\`Recorded \${path} from \${canonical.provenance.host}.\`} - - ); -}`; - -const CLAUDE_HOOKS_JSON = `// hooks/hooks.json - real emitted bytes (examples/hooks-and-scripts) -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "command": "node \\"\${CLAUDE_PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.mjs\\"", - "type": "command" - } - ] - } - ] - } -} -// Cursor keeps a flat document instead: -// { "version": 1, "hooks": { "preToolUse": [ { "command": "node \\"\${CURSOR_PLUGIN_ROOT}/...\\"", "matcher": "^Write$" } ] } }`; - -const WIRE_STDIN = `{ - "hook_event_name": "PreToolUse", - "session_id": "6f9a2c1e-4d15-4a44-9be2-6b8f0f6f2a10", - "transcript_path": "/home/dev/.claude/projects/acme/transcript.jsonl", - "cwd": "/home/dev/acme", - "permission_mode": "default", - "tool_name": "Write", - "tool_input": { "file_path": "src/payments.ts", "content": "..." }, - "tool_use_id": "toolu_01H8KQ2ZC4" -}`; - -const WIRE_IPC_REQUEST = `{ - "protocolVersion": 1, - "artifactEpoch": "17903a885df8d142e2fc4457e61bb34479e81a8f4cc64e24cb92db49eaabe3f1", - "event": "tool/before", - "hostContractRevision": "2.1.260", - "target": "claude", - "native": { ...the validated stdin envelope, value-preserving (re-serialized JSON, not raw bytes)... } -} -// socket: /tmp/agent-bundle-/event-.sock (mode 0600) -// endpointId = "::" - two installs never share a runtime`; - -const WIRE_RENDER_PROPS = `props = { - canonical: { - event: "tool/before", - idempotencyKey: sha256({ event, native, target }), - observedAt: "2026-09-01T23:41:07.512Z", - provenance: { host: "claude", hostContractRevision: "2.1.260", - nativeEvent: "PreToolUse", source: "native" }, - sequence: 1, - }, - native: { ...frozen structuredClone of the envelope... }, - signal, // aborted when the socket drops or the client times out -} -// runAgentRequest installs host/session/workspace axes read from the envelope, -// then the react-server Flight worker renders the route's default component.`; - -const WIRE_IPC_RESPONSE = `{ - "protocolVersion": 1, - "artifactEpoch": "17903a88...eaabe3f1", - "status": "ok", - "output": { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": "payments.ts is frozen during release week" - } - } -} -// on failure: { "status": "error", "code": "epoch-mismatch" | "invalid-message" | "runtime-failed", ... }`; - -const WIRE_STDOUT = `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", - "permissionDecisionReason":"payments.ts is frozen during release week"}}`; - -const STATE_SNIPPET = `// src/state.ts - one direct defineState call (AB4818 otherwise) -export default defineState({ - id: 'library-index', - lifetime: 'workspace-durable', // request | process | workspace-durable | external - budgets: { maxStateBytes: 262144, maxRevisions: 64 }, // fail closed: budget-exceeded -});`; - -/* ------------------------------------------------------------------ page */ - -export default function AgentBundleWalkthrough() { - const t = useHostTheme(); - - return ( - - {/* ============================================================ hero */} - - Framework walkthrough -

agent-bundle, end to end

- - Author agent behavior as React Server Components; compile it into a - route-graph IR; emit native, independently distributable artifacts - for each host; and execute hooks and MCP tools as Flight renders - against a warm runtime. Flight is an internal transport only — the - hosts see their own native JSON contracts, pinned by revision. - - - - - - - - -
- - {/* ======================================================== pipeline */} - -

The pipeline: author → compile → emit → install → execute

- - - - - - - - - - - - - - - Source: packages/agent-bundle/src — routes/graph.ts, config/validate.ts, - build/entries.ts + entry-shell.ts, adapters/*, install/install.ts. - -
- - - - {/* ======================================================= authoring */} - -

1 · Authoring model: files are the app

- - One flat config owns identity and policy; everything executable is a - conventional file whose path is its identity. A generated MCP tool, - resource, or prompt route module is one async default Server Component - plus statically extractable - config, - inputSchema, and - resultSchema exports — there is no - execute/render split (exporting either is the AB4811 error). Other - route kinds (events, CLI commands) carry their own export - contracts; skills are Markdown documents parsed from frontmatter - unless the rendered SKILL.tsx form is used. - - - - Conventional source tree - - - - - - real route}> - src/mcp/curator/tools/search_audible.tsx - - - - - - -

Route kinds compiled into the graph

- src/mcp/<server>/{tools,resources,prompts,apps}/*.tsx, tool:curator/search, "Flight render in the generated MCP server"], - ["event-route", src/events/<family>/*.tsx, src/events/stop.tsx, event:tool/before, "Hook wrapper → IPC → warm runtime (or standalone)"], - ["cli", src/cli/**/*.ts(x) — nesting is the command path, cli:library/audit, "Generated package bin; .tsx renders via dispatcher"], - ["script", src/scripts/*.ts(x) — direct children only, script:verify-release, "scripts/.mjs (+ -flight.mjs worker when rendered)"], - ["provider", src/providers/*.ts — factory, not addressable, provider:build-info, "Wraps every render with request-scoped context values"], - ]} - /> - - Source: routes/graph.ts routeGlobs + classifyModule; routes/types.ts. Config always - wins: modules claimed by explicit scripts/hooks/bin/lib/mcp config never become routes. - - - - - - {/* ===================================================== compile+val */} - -

2 · Compile and validate: one immutable IR, structured diagnostics

- - Discovery globs the conventional roots, rejects unsafe identity - segments, and refuses to choose sides on any collision — an MCP - server with both discovered routes and an explicit entry is a hard - AB4800 error until routes.servers.<id> picks - a mode. The graph digest covers only project-relative identity, so equal - trees hash equally on every machine. Every failure is one diagnostic: - stable AB code, severity, message, and usually a recovery hint. - - -
AB30xx, "Skill documents: Markdown parsing and rendered-skill compilation"], - [AB40xx, "Plugin metadata, skill fields, package identity (AB4008 version mismatch)"], - [AB41xx / AB42xx, "Normalized-model invariants; hook configuration and native hook sources"], - [AB43xx / AB44xx / AB46xx, "MCP servers and Apps; scripts; assets and the generated-runtime floor"], - [AB47xx, "Package build (bin/lib, AB4716 declaration replay), migration nudges, prebuilt payloads"], - [AB48xx, "Route graph: collisions AB4800–AB4803, static config grammar AB4805/AB4806, route contract AB4810/AB4811, event vocabulary + CLI command-graph collisions AB4813, CLI argv grammar AB4814, shared-runtime placement AB4817, state AB4818–AB4821"], - [AB494x, "Providers: default-factory contract, key uniqueness, reserved processLifetime"], - [AB5000 / AB60xx, "CLI and adapter failures; built-artifact validation against pinned schemas"], - [AB70xx, "Host installation; read-only Doctor probes AB7300–AB7316; dev rebuilds (AB7103)"], - [AB8xxx / AB9xxx, "Dev server configuration; eval selection, harnesses, persisted runs"], - ]} - /> - - Source: docs/diagnostics.md. Only error severity gates a build; warnings and infos never block. - - - - A route's config export is parsed with the TypeScript compiler from a bounded literal - grammar (object/array/string/number literals, as/satisfies casts). Anything dynamic - compiles with an empty config beside a named AB4806 error. The same static approach - projects each CLI route's zod inputSchema onto kebab-case argv options (AB4814 when a - construct leaves the grammar) — the module's real zod schema still validates at run time. - - - - - - {/* ======================================================== emission */} - -

3 · Per-host emission: adapters, pinned contracts, provenance

- - Each adapter projects the normalized model against a pinned host - capability table (a JSON file with observed versions, evidence - strings, and per-event support states) and validates its output - against pinned host schemas whose sha256 hashes ship in the artifact - manifest. Every capability is supported or{" "} - unavailable with a written reason — never a - silent guess. - -
.claude-plugin/plugin.json, - .codex-plugin/plugin.json, - .cursor-plugin/plugin.json, - plugin.json], - ["Marketplace", - .claude-plugin/marketplace.json, - .agents/plugins/marketplace.json, - .cursor-plugin/marketplace.json, - "—"], - ["Hooks wiring", - "hooks/hooks.json, grouped { matcher, hooks: [{ type: 'command', command }] }", - "same grouped shape; hook processes get PLUGIN_ROOT / PLUGIN_DATA", - "hooks/hooks.json, flat { command, matcher?, timeout? } entries", - "none — Agent Plugins 1.0.0 defines no hooks"], - ["MCP registration", - .mcp.json (stdio + streamable HTTP), - .mcp.json, - mcp.json at plugin root, - "plugin.json mcp block, ${PLUGIN_ROOT} tokens"], - ["Skills / rules / commands", - "skills + commands", - "skills", - "skills + rules + commands", - "skills"], - ["Path token", - {"${CLAUDE_PLUGIN_ROOT}"}, - "relative paths + PLUGIN_ROOT cwd", - {"${CURSOR_PLUGIN_ROOT}"}, - {"${PLUGIN_ROOT}"}], - ["Install", - "claude plugin marketplace add ./ + install --scope user|project|local", - "codex plugin add @ (user scope)", - "copy to ~/.cursor/plugins/local/ (no non-interactive verb)", - "distribution profile — no single host install location"], - ]} - /> - - Source: src/adapters/capabilities/*.json (observed 2026-08-28…09-01) and src/adapters/{claude,codex,cursor,portable}.ts. - A composite "plugin" target emits one directory loadable by both Claude and Codex, with a - universal hook wrapper that discriminates the host at run time via PLUGIN_ROOT. - - - - Emitted artifact size by target and file kind (bytes) - - - X: target directory · Y: total bytes, summed per emitted-file kind. Portable has no - hook wrapper — its spec defines no hooks. Source: examples/hooks-and-scripts - dist/agent-bundle.manifest.json. - - - - Provenance in every artifact - - agent-bundle.manifest.json records, per emitted file, its sha256 and the exact - source inputs that produced it, plus the project revision (the artifact epoch), - each projection's adapter revision, and the sha256 of every - pinned host schema it was validated against. Its executables.hooks[] rows are the - canonical hook inventory across hosts (no separate hook index file). - - - Source: dist/agent-bundle.manifest.json fields files[].sha256, files[].sourceInputs, - project.revision, projections[].schemas[], executables.hooks[]. - - - - - - - - {/* ================================================== HOOKS DEEP DIVE */} - - Deep dive -

4 · The hooks pipeline: what is on the wire at every step

- - Two authoring shapes share the emitted hooks.json wiring. A{" "} - plain handler hook{" "} - (config hooks: { sessionStart: { handler } }) - compiles into a self-contained wrapper that decodes the native envelope, calls the - default-export function, and encodes the result — no IPC. An{" "} - event route{" "} - (src/events/**) compiles into a thin - client that forwards the validated envelope over a per-user Unix socket (a named pipe on - Windows) to the warm runtime living inside the generated MCP server process, where the - route component renders as a Flight request. The walkthrough below is the event-route - path for a Claude PreToolUse on a{" "} - tool/before route. - - - - generated wiring}> - What the host actually invokes - - - - - - - - - - - - - - - - -

Failure semantics: fail closed, one narrow fallback

-
runtime-unavailable, "No live socket (connect refused / missing)", "Standalone fallback if the route declares runtime or fallback 'standalone'; otherwise nonzero exit"], - [runtime-timeout, "No reply within timeoutMs (default 5 s)", "Nonzero exit — never a fabricated response"], - [epoch-mismatch, "Wrapper and runtime built from different artifact epochs", "Nonzero exit; no fallback — stale code never answers"], - [invalid-message, "Payload over 1 MiB, non-JSON, or off-schema", "Nonzero exit"], - [runtime-failed, "Render threw, socket error, endpoint contention", "Nonzero exit"], - ]} - /> - - Source: src/events/ipc.ts (EventRuntimeTransportError, requestEventRuntime) and the - generated wrapper in src/adapters/hook-contract.ts — fallback fires only on - runtime-unavailable with fallback: 'standalone'. - - - -

The standalone path

- - A route with runtime: 'standalone' (or - as fallback) bundles the route module into the wrapper itself: the same - createCanonicalEventProps builds identity, the component resolves in-process - (Server Components and Agent protocol elements only), and the same - projectEventDocument lowers the output. No shared state, no warm process — - identical wire contract to the host. - - - - AB4817 guards placement: an event route requiring the shared runtime on a target - with no generated MCP entry hosting it — and no standalone fallback — fails the build. - -
- - - - - - The event runtime server claims its endpoint with an exclusive{" "} - .lock file recording pid and (on - Linux) the /proc start time. A stale claim is reclaimed only when its owner is - provably dead — signal-0 probe plus start-time comparison — and on Linux the - reclamation itself is serialized through a kernel-released abstract-socket gate, so - a namespace squatter can force bounded retries (100 × 10 ms) but never steal - ownership. Live endpoints are never stolen; unverifiable claims stay fail-closed. - Reads use StringDecoder for chunk-safe UTF-8; each connection gets an - AbortController wired to close/end/error so orphaned renders cancel; teardown - removes the socket only when its device and inode still match the one it created. - - - Source: src/events/ipc.ts — claimEndpoint, reclaimOrphanedEndpointClaim, - tryAcquireEndpointRecoveryGate, readOneMessage, closeServer. - - - - - - - - {/* ==================================================== event matrix */} - -

5 · Event families × hosts

- - The v1 vocabulary is seven canonical families. Six are supported on - all three interactive hosts under their native names;{" "} - workspace/open is supported on - Cursor as a fire-and-forget observation (the optional native pluginPaths - return is deliberately not modeled) and stays unavailable on Claude and - Codex — with a written reason per host, not a silent gap. - -
session/start, "SessionStart", "SessionStart", "sessionStart", "—"], - [tool/before, "PreToolUse", "PreToolUse", "preToolUse", "—"], - [tool/after, "PostToolUse", "PostToolUse", "postToolUse", "—"], - [stop, "Stop", "Stop", "stop", "—"], - [agent/start, "SubagentStart", "SubagentStart (adds turn_id, model, permission_mode)", "subagentStart (subagent_id/subagent_type envelope; permission deny)", "—"], - [agent/stop, "SubagentStop", "SubagentStop", "subagentStop (status/loop_count envelope; followup_message)", "—"], - [workspace/open, "unavailable: no such event", "unavailable: no such event", "workspaceOpen (observe-only; optional pluginPaths return not modeled)", "unavailable: spec defines no hooks"], - ]} - /> - - -

What each event may answer

-
- - Source: events/projection.ts projectEventDocument and the wrapper validateResult in - adapters/hook-contract.ts — illegal combinations throw before anything reaches the host. - - - -

Canonical tool selectors → native matchers

-
file.read, ^Read$, "—", ^Read$], - [file.write, ^(?:Write|Edit)$, ^(?:apply_patch|Edit|Write)$, ^Write$], - [shell, ^Bash$, ^Bash$, ^Shell$], - [mcp, ^mcp__, ^mcp__, ^MCP:], - [agent, "—", "—", ^Task$], - ]} - /> - - A hook can also pin host-scoped native tool names; a selector no target can map is a - per-target diagnostic, never a silently empty matcher. - - - - - standalone example}> - src/events/tool/after.tsx — a real event route - - - - - - - - - - {/* ======================================================== MCP flow */} - -

6 · MCP tools: the same architecture, request-shaped

- - - - - - - - - - - The generated entry carries the compiled route table as data and hands a warm worker to - the shared server runtime (createGeneratedRouteMcpServer). Each tools/call validates - input with the route's real zod schema, renders the async default component inside - runAgentRequest — with providers, the state kernel, and the notice ledger bound — - streams Flight bytes back, and projects them onto MCP: progress reports become - notifications, Agent.Result becomes structured content validated by resultSchema. MCP - Apps embed their built HTML into the bundle as resources keyed by resourceUri. When the - bundle has event routes, exactly one generated server also hosts the event runtime IPC - socket — that is the warm runtime hooks talk to, so hooks share process state with tools. - - - Source: build/entry-shell.ts (generatedRouteMcpEntrySource, generatedRouteFlightWorkerSource), - src/mcp-server-runtime.ts (startEventRuntime, projectMcpRenderStream), build/entries.ts. - -
- - - - {/* ==================================================== supporting */} - -

7 · Supporting systems

- - - State - - - - - Drivers supply storage for exactly one lifetime — memory for request/process, - SQLite under the plugin root's state/ for workspace-durable; external needs - embedder wiring and is rejected for generated mounting (AB4820). Budgets - (bytes, revisions, commit latency) fail closed with budget-exceeded. Doctor - inventories durable stores by filesystem metadata only — it never opens a database. - - - - - - Notices - - - - An append-only ledger co-mounted with state (reserved id, AB4821). A notice - targets a recipient — the conjunction of observed identity axes — and moves - through evidenced states only: pending, attempted, expired, unavailable, - withdrawn. Delivery is attempted on the next event render; a recipient-scoped - MCP inbox resource (agent-bundle://notices/inbox) exposes pending notices with - exposure receipts. No host claims are fabricated. - - - pending - attempted - expired - unavailable - withdrawn - - - - - - Context axes - - - - Inside any route, await agent() returns the request context: host, session, - actor, and workspace each as an Observed value — either - { state: 'available', value, source } or - { state: 'unavailable', reason }. Honest absence is the contract: a CLI - render reports host unavailable ('unsupported-surface') rather than inventing - one. Capabilities (command, filesystem, network, projectRoot) follow the same - shape; src/providers/* factories add request-scoped values beside the - framework-owned processLifetime. - - - - - - -
- - - - {/* ======================================================== dev loop */} - -

8 · The dev loop: epochs, Workbench, doctor, install

- - agent-bundle dev watches the project, serializes rebuilds, and commits each successful - build as an immutable epoch under .agent-bundle/epochs/<uuid>/ with an - active-epoch.json pointer — the same epoch identity that fences hook IPC, so a stale - wrapper can never talk to a newer runtime. A package-build failure never invalidates a - committed artifact epoch (it surfaces as the AB7103 warning and retries). Generated - route declarations publish atomically to .agent-bundle/routes.d.ts. - - - -

Workbench (desktop web UI over the dev server)

-
- - -

Doctor and install

- - agent-bundle doctor is strictly read-only (AB7300–AB7316): host CLI probes, - installed-bundle inventory, bundle-to-source comparison, registration proof, event - runtime endpoint health, and durable-state inventory. It never repairs anything. - - - - Every target directory is independently distributable with a generated INSTALL.md; - cursor/portable/plugin targets also ship a standalone install.mjs whose staged copy - is idempotent for identical content, refuses version or content collisions, and - never touches sudo or PATH. The packed proof level (agent-bundle/test) runs the - same mcp-server-runtime in memory, and deleted-source proofs verify artifacts stay - self-contained after the source tree is gone. - -
- - - - - - {/* ========================================================== footer */} - - - Verified against /fast/projects/agent-bundle source: routes/{graph,types,contract,public}.ts · - config/validate.ts · adapters/hook-contract.ts + capabilities/*.json · - events/{ipc,project,projection}.ts · build/{entries,entry-shell}.ts · - mcp-server-runtime.ts · rsc-runtime state/notices/agent-request · docs/diagnostics.md + - docs/framework-mode.md · examples/{hooks-and-scripts,audiobook-curator,rsc-agent-runtime}. - Wire payloads in section 4 are illustrative values over verified shapes. Reflects the - post-PR-#280 split of React rendering (events/project.ts) from envelope projection - (events/projection.ts). Host capability facts pinned at Claude Code 2.1.260, Codex - 0.147.0, Cursor 2026-08-28, Agent Plugins 1.0.0 (observed 2026-08-28 … 2026-09-01). - - - Generated 2026-09-01 · colors follow the host theme ({t.kind}) - - - - ); -} diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 8b5196698..b8979cbe5 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -28,7 +28,7 @@ Each Effect-consuming package has exactly one `src/effect/boundary.ts`: The boundary owns: -- `runPromise` / `runSync` (plus `runPromiseExit` where a caller branches on `Exit`) +- `runPromise` / `runSync` (a caller that branches on `Exit` wraps its program in `Effect.exit`) - `AbortSignal` ↔ interruption (`interruptWhenAborted`, `scopedAbortSignal`, `signal` on `runPromise`) - mapping the Effect error channel onto the existing typed Error contracts @@ -207,7 +207,7 @@ keep boundary-runner assertions on the package boundary they are testing. Stage 2 uses Effect `Stream` for the #145 dispatcher: Flight bytes via `Stream.unfold` that waits for event-stream demand *before* `reader.read()`, pending boundaries via `Stream.paginate`, contract bounds as the emit stage -(`boundRenderEventStream` / `createAgentRenderEventSequence`), and progress +(`emitBoundRenderEvent` / `createAgentRenderEventSequence`), and progress via `Stream.merge` + `takeUntil(complete)` (not `Stream.callback` — a failed callback producer does not fail the stream). A `Latch` opened from the public event-stream pull gates Flight bytes after the shell. Host diff --git a/package.json b/package.json index a298b7ae7..b469a868c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:watch": "rstest --config rstest.config.ts --watch", "lint": "rslint .", "bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs", - "bench:preflight-cold-start": "node scripts/measure-preflight-cold-start.mjs", + "record:claude-hooks-fixtures": "node scripts/record-claude-hooks-schema-fixtures.mjs", "typecheck": "node scripts/check-dist-fresh.mjs && tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json && tsc --project packages/agent-bundle/tsconfig.web-host.json && pnpm --filter @agent-bundle/docs typecheck", "check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck", "check:local-ci": "node scripts/local-ci.mjs", @@ -41,7 +41,6 @@ "test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts packages/agent-bundle/tests/dev-host-install.test.ts packages/agent-bundle/tests/dev-live-host.test.ts packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts", "test:host-install:build": "pnpm build && pnpm test:host-install", "test:host-install:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-host-install-proof.test.ts", - "test:host-install:packed:build": "pnpm build && pnpm test:host-install:packed", "test:host-install:session": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-session.test.ts", "test:host-install:session:claude": "pnpm build && AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION=1 pnpm test:host-install:session", "changeset": "changeset", @@ -75,7 +74,6 @@ "@rstest/playwright": "0.11.12", "@types/node": "26.4.1", "agent-bundle": "workspace:*", - "commander": "15.0.0", "pkg-pr-new": "0.0.88", "playwright": "1.62.1", "playwright-core": "1.62.1", diff --git a/packages/agent-bundle/fixtures/contracts/epoch-atomicity/local-linux.json b/packages/agent-bundle/fixtures/contracts/epoch-atomicity/local-linux.json deleted file mode 100644 index 2fc2a9bb0..000000000 --- a/packages/agent-bundle/fixtures/contracts/epoch-atomicity/local-linux.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "environment": { - "architecture": "x64", - "nodeVersion": "v22.23.1", - "platform": "linux", - "runtime": "node" - }, - "operations": [ - { - "id": "failed-publication-retention", - "mechanism": "stage-write then atomic rename", - "observed": { - "activeEpochIdAfterFailure": "epoch-1", - "candidateEpochId": "epoch-2", - "retainedPriorActive": true - }, - "steps": [ - { - "action": "seed-active", - "result": { - "epochId": "epoch-1" - } - }, - { - "action": "stage-candidate", - "result": { - "epochId": "epoch-2" - } - }, - { - "action": "inject-failure-before-rename", - "result": { - "error": "simulated publication failure before atomic rename" - } - }, - { - "action": "read-active-after-failure", - "result": { - "epochId": "epoch-1" - } - } - ] - }, - { - "id": "live-lock-second-writer-rejection", - "observed": { - "firstWriter": { - "status": "acquired" - }, - "liveOwner": { - "status": "running" - }, - "secondWriter": { - "observedOwner": "writer-a", - "reason": "lock-exists", - "status": "rejected" - } - }, - "steps": [ - { - "action": "acquire-exclusive-lock", - "actor": "writer-a", - "result": { - "status": "acquired" - } - }, - { - "action": "probe-live-owner", - "actor": "writer-a", - "result": { - "status": "running" - } - }, - { - "action": "acquire-exclusive-lock", - "actor": "writer-b", - "result": { - "observedOwner": "writer-a", - "reason": "lock-exists", - "status": "rejected" - } - } - ] - }, - { - "id": "dead-pid-lock-recovery", - "observed": { - "recoveredWriter": { - "status": "acquired" - }, - "staleOwner": { - "status": "not-running", - "systemCode": "ESRCH" - } - }, - "steps": [ - { - "action": "seed-stale-lock", - "actor": "dead-writer", - "result": { - "stalePid": 2147483647 - } - }, - { - "action": "probe-owner-pid", - "actor": "dead-writer", - "result": { - "status": "not-running", - "systemCode": "ESRCH" - } - }, - { - "action": "remove-stale-lock-after-esrch", - "result": { - "removed": true - } - }, - { - "action": "acquire-exclusive-lock", - "actor": "writer-c", - "result": { - "status": "acquired" - } - } - ] - } - ], - "spike": { - "name": "atomic-epoch-publication-and-lock-ownership", - "scope": "disposable local filesystem probe; evidence only" - } -} diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 4382f8999..630c95912 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -125,7 +125,6 @@ "ignore": "7.0.8", "jiti": "2.7.0", "npm-package-arg": "14.0.0", - "open": "11.0.2", "ws": "8.21.3", "yaml": "2.9.0" }, diff --git a/packages/agent-bundle/src/contracts/playground.ts b/packages/agent-bundle/src/contracts/playground.ts deleted file mode 100644 index ff4d8e78a..000000000 --- a/packages/agent-bundle/src/contracts/playground.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Browser-consumable contract surface for Playground sessions, operation - * requests, and native host catalogs. The host vocabulary is the only - * runtime export; everything else is type-only because the playground - * services behind these shapes touch Node builtins. - */ -export { NATIVE_HOSTS, NATIVE_HOST_LABELS } from '../host-contracts/native-hosts.ts'; -export type { PlaygroundOperationRequest, PlaygroundRun } from '../dev/playground/playground-contract.ts'; -export type { NativePlaygroundCatalog } from '../dev/playground/native-playground-service.ts'; -export type { NativePlaygroundHost } from '../dev/playground/native-playground-types.ts'; -export type { - DraftEvalCase, - PlaygroundEpochIdentity, - PlaygroundExport, - PlaygroundJsonObject, - PlaygroundJsonValue, - PlaygroundReplay, - PlaygroundSession, - PlaygroundTarget, - PlaygroundTraceEvent, - PlaygroundTraceSource, -} from '../dev/playground/playground-store.ts'; diff --git a/packages/agent-bundle/src/core/durability-test-hook.ts b/packages/agent-bundle/src/core/durability-test-hook.ts deleted file mode 100644 index 74de9ca78..000000000 --- a/packages/agent-bundle/src/core/durability-test-hook.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared reader for the stores' symbol-keyed durability test seams. - * - * Each filesystem-backed store publishes a `Symbol.for` key (the key strings - * are part of the test contract in tests/support/durability.ts and must not - * change). A hook installed at that key on `globalThis` is visible only while - * the process explicitly runs with NODE_ENV=test; outside test mode the seam - * reads as absent, so production behavior cannot be altered through it. - */ -export const testModeGlobalValue = (key: symbol): Value | undefined => { - if (process.env.NODE_ENV !== 'test') return undefined; - const globals = globalThis as typeof globalThis & Record; - return globals[key]; -}; diff --git a/packages/agent-bundle/src/core/durable-fs.ts b/packages/agent-bundle/src/core/durable-fs.ts index 8a8f179b5..cb5f88322 100644 --- a/packages/agent-bundle/src/core/durable-fs.ts +++ b/packages/agent-bundle/src/core/durable-fs.ts @@ -1,21 +1,12 @@ -import { Buffer } from 'node:buffer'; -import { closeSync, constants, fsyncSync, openSync } from 'node:fs'; -import { link, lstat, open, realpath, rename, rm, writeFile, type FileHandle } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; +import { link, open, rm } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { isErrno, isTolerableWin32SyncError } from './errors.ts'; -import { isInsideOrEqual, sameFile } from './paths.ts'; -import { parseJsonWithoutDuplicateKeys } from './strict-json.ts'; /** - * Shared filesystem durability toolkit for the playground, eval run, and - * epoch stores. Every primitive here parameterizes only the differences the - * stores genuinely have (error vocabularies, test-hook phases, injectable - * handle opens) while keeping one implementation of each durability - * algorithm: atomic temp+rename JSON publication, staged hard-link - * publication, file and directory fsync with the documented Windows - * directory tolerance, pinned single-link file verification, and - * torn-tail-tolerant JSONL decoding. + * Filesystem durability primitives behind the dev lock's link-published + * owner document: file and directory fsync with the documented Windows + * directory tolerance, and staged hard-link publication. */ /** Minimal handle surface the async sync primitives need; `FileHandle` satisfies it. */ @@ -53,60 +44,6 @@ export const syncPath = async (path: string, options: SyncPathOptions = {}): Pro } }; -export interface SyncDirectorySyncOptions { - /** Runs after the descriptor opens, immediately before the fsync attempt. */ - readonly beforeFsync?: () => void; - /** Runs immediately before the directory descriptor opens. */ - readonly beforeOpen?: () => void; - /** Defaults to `process.platform`. */ - readonly platform?: NodeJS.Platform; -} - -/** Synchronous directory fsync with the same Windows tolerance as {@link syncPath}. */ -export const syncDirectorySync = (path: string, options: SyncDirectorySyncOptions = {}): void => { - options.beforeOpen?.(); - const descriptor = openSync(path, constants.O_RDONLY); - try { - options.beforeFsync?.(); - fsyncSync(descriptor); - } catch (error) { - // See syncPath: only documented Windows directory FlushFileBuffers - // capability failures are tolerated. - if (isTolerableWin32SyncError(options.platform ?? process.platform, error)) return; - throw error; - } finally { - closeSync(descriptor); - } -}; - -export interface AtomicJsonWriteOptions { - /** Caller-named staging path; it must share a directory with the destination. */ - readonly temporaryPath: string; - readonly open?: DurableHandleOpen; - readonly platform?: NodeJS.Platform; -} - -/** - * Atomically publishes a serialized JSON document: write to the staging path, - * fsync it, rename it over the destination, then fsync the directory so the - * rename itself is durable. The staging file never survives, even on failure. - */ -export const writeJsonFileAtomically = async ( - path: string, - serialized: string, - options: AtomicJsonWriteOptions, -): Promise => { - const sync = { open: options.open, platform: options.platform }; - try { - await writeFile(options.temporaryPath, serialized, 'utf8'); - await syncPath(options.temporaryPath, sync); - await rename(options.temporaryPath, path); - await syncPath(dirname(path), { ...sync, directory: true }); - } finally { - await rm(options.temporaryPath, { force: true }); - } -}; - /** Staging handle surface the link publication needs; `FileHandle` satisfies it. */ export interface DurableStagingHandle extends DurableHandle { writeFile(data: string, encoding: 'utf8'): Promise; @@ -195,170 +132,3 @@ export const publishFileByLink = async ( } return created; }; - -export interface WriteNewPinnedFileOptions { - /** Runs after the fsync succeeds so callers can mark the file recoverable. */ - readonly afterFsync?: () => void; - readonly beforeFsync?: () => void | Promise; - readonly beforeWrite?: () => void | Promise; - /** Store-specific error for a create that did not pin a singly linked regular file. */ - readonly invalid: () => Error; -} - -/** - * Exclusively creates a new durable file (O_EXCL, no symlink following), - * proves the created handle pins a singly linked regular file, then writes - * and fsyncs its contents. - */ -export const writeNewPinnedFile = async ( - path: string, - contents: string, - options: WriteNewPinnedFileOptions, -): Promise => { - const handle = await open( - path, - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o600, - ); - try { - const stat = await handle.stat(); - if (!stat.isFile() || stat.nlink !== 1) throw options.invalid(); - await options.beforeWrite?.(); - await handle.writeFile(contents, 'utf8'); - await options.beforeFsync?.(); - await handle.sync(); - options.afterFsync?.(); - } finally { - await handle.close(); - } -}; - -export interface OpenPinnedContainedFileOptions { - readonly flags: number; - /** Store-specific error for a path that is not a singly linked contained regular file. */ - readonly invalid: () => Error; - readonly name: string; - readonly root: string; -} - -/** - * Opens `name` inside `root` and proves that the handle and the path name the - * same singly linked regular file (matching dev/ino, nlink 1, no symlink) - * whose real path stays contained under `root`. The handle is closed when any - * check fails. - */ -export const openPinnedContainedFile = async (options: OpenPinnedContainedFileOptions): Promise => { - const path = join(options.root, options.name); - const handle = await open(path, options.flags | constants.O_NOFOLLOW); - try { - const [fileStat, pathStat] = await Promise.all([handle.stat(), lstat(path)]); - const resolved = await realpath(path); - if (!fileStat.isFile() - || !pathStat.isFile() - || pathStat.isSymbolicLink() - || fileStat.nlink !== 1 - || pathStat.nlink !== 1 - || fileStat.dev !== pathStat.dev - || fileStat.ino !== pathStat.ino - || !isInsideOrEqual(options.root, resolved) - || basename(resolved) !== options.name) { - throw options.invalid(); - } - return handle; - } catch (error) { - await handle.close(); - throw error; - } -}; - -export interface ReadPinnedFileOptions { - /** Store-specific error for a file that changed between lstat and the opened handle. */ - readonly changedWhileOpening: () => Error; - /** Store-specific error for a file whose identity or size drifted across the read. */ - readonly changedWhileReading: () => Error; - /** Upper bound on the pinned file's size in bytes. */ - readonly maximumBytes: number; - /** Store-specific error for a symlinked, multiply linked, or oversized path. */ - readonly unsafe: () => Error; - /** Re-verifies caller-tracked ancestor directory identities before and after the read. */ - readonly verifyAncestry?: () => Promise; -} - -/** - * Reads a whole pinned file while proving the path, the open descriptor, and - * the post-read stats all describe the same singly linked regular file whose - * size never changed — the shared TOCTOU defense for record reads. - */ -export const readPinnedFile = async (path: string, options: ReadPinnedFileOptions): Promise => { - await options.verifyAncestry?.(); - const before = await lstat(path); - if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size > options.maximumBytes) { - throw options.unsafe(); - } - const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const descriptor = await file.stat(); - if (!descriptor.isFile() || descriptor.nlink !== 1 || descriptor.size > options.maximumBytes || !sameFile(before, descriptor)) { - throw options.changedWhileOpening(); - } - const bytes = await file.readFile({ encoding: 'utf8' }); - const [after, final] = await Promise.all([lstat(path), file.stat()]); - await options.verifyAncestry?.(); - if (after.isSymbolicLink() || !after.isFile() || after.nlink !== 1 || !sameFile(before, after) || !sameFile(descriptor, final) || - final.size !== descriptor.size || Buffer.byteLength(bytes, 'utf8') !== descriptor.size) { - throw options.changedWhileReading(); - } - return bytes; - } finally { - await file.close(); - } -}; - -export interface TornTailJsonlOptions { - /** Decodes one complete strict-parsed line, throwing the store's error for invalid records. */ - readonly decode: (value: unknown, index: number) => Record; - /** Store-specific error for an empty complete line. */ - readonly emptyRecord: () => Error; - /** Store-specific error for a complete line that is not strict JSON. */ - readonly malformedRecord: () => Error; - /** Store-specific error when a decoded record's sequence is not exactly index + 1. */ - readonly sequenceViolation: () => Error; -} - -export interface TornTailJsonlRead { - /** Present when the log ends in a torn, incomplete final append. */ - readonly incompleteTrailingRecord?: string; - readonly records: readonly Record[]; -} - -/** - * Decodes a JSONL journal tolerating exactly one torn trailing append: split - * on newlines, drop the trailing fragment (or the empty string after a final - * newline), reject empty complete lines, strict-parse each record without - * duplicate keys, and verify sequences are exactly 1 through N. - */ -export const readTornTailJsonl = ( - contents: string, - options: TornTailJsonlOptions, -): TornTailJsonlRead => { - const lines = contents.split('\n'); - // The final element is the empty string after a trailing newline or a torn tail append; both are dropped. - const trailing = lines.pop() ?? ''; - const records: Record[] = []; - for (const [index, line] of lines.entries()) { - if (line.length === 0) throw options.emptyRecord(); - let parsed: unknown; - try { - parsed = parseJsonWithoutDuplicateKeys(line); - } catch { - throw options.malformedRecord(); - } - const record = options.decode(parsed, index); - if (record.sequence !== index + 1) throw options.sequenceViolation(); - records.push(record); - } - return Object.freeze({ - records: Object.freeze(records), - ...(trailing.length === 0 ? {} : { incompleteTrailingRecord: trailing }), - }); -}; diff --git a/packages/agent-bundle/src/core/owner-lock.ts b/packages/agent-bundle/src/core/owner-lock.ts index 2e480ac23..4540e9277 100644 --- a/packages/agent-bundle/src/core/owner-lock.ts +++ b/packages/agent-bundle/src/core/owner-lock.ts @@ -68,32 +68,3 @@ export const acquireOwnerLockFile = async (options: AcquireOwnerLockOpti } throw options.exhausted(); }; - -/** - * Process-wide serializer for owner-lock mutations, keyed by lock path. - * - * Sharing across store instances is intentional: two services recovering or - * releasing the same on-disk lock must serialize their read-verify-unlink - * windows even though each service holds its own instance. Entries are - * removed as soon as the last queued mutation for a path settles, so the - * registry never grows past the set of locks currently being mutated. - */ -export class OwnerMutationSerializer { - readonly #tails = new Map>(); - - async run(path: string, operation: () => Promise): Promise { - const previous = this.#tails.get(path) ?? Promise.resolve(); - const boundary = Promise.withResolvers(); - this.#tails.set(path, boundary.promise); - await previous; - try { - return await operation(); - } finally { - boundary.resolve(); - if (this.#tails.get(path) === boundary.promise) this.#tails.delete(path); - } - } -} - -/** The one process-wide owner-mutation serializer every store instance shares. */ -export const sharedOwnerMutationSerializer = new OwnerMutationSerializer(); diff --git a/packages/agent-bundle/src/core/strict-json.ts b/packages/agent-bundle/src/core/strict-json.ts index 22dd2fc36..44108b4d2 100644 --- a/packages/agent-bundle/src/core/strict-json.ts +++ b/packages/agent-bundle/src/core/strict-json.ts @@ -245,12 +245,6 @@ const snapshotJsonValue = (value: unknown, ancestors: Set, nullPrototype export const snapshotStrictJsonValue = (value: unknown, options: SnapshotStrictJsonOptions = {}): JsonValue => snapshotJsonValue(value, new Set(), options.nullPrototype === true); -/** Boolean acceptance form of `snapshotStrictJsonValue` for validation-only callers. */ -export const isStrictJsonValue = (value: unknown): boolean => { - try { snapshotStrictJsonValue(value); return true; } - catch { return false; } -}; - /** Parses JSON only after rejecting duplicate object keys at every depth. */ export const parseJsonWithoutDuplicateKeys = (bytes: string): unknown => { const end = skipWhitespace(bytes, scanJsonValue(bytes, 0)); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-json.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-json.ts index 5d504da5f..909573aad 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-json.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-json.ts @@ -34,9 +34,3 @@ export const requireMcpAppJson = (value: unknown, message: string): McpAppJsonVa throw error; } }; - -export const requireMcpAppJsonRecord = (value: unknown, message: string): McpAppJsonRecord => { - const snapshot = requireMcpAppJson(value, message); - if (!isJsonRecord(snapshot)) throw new TypeError(message); - return snapshot; -}; diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts index 606705c76..e8649e101 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts @@ -11,7 +11,6 @@ import { deepFreeze } from '../../core/freeze.ts'; const JSON_RPC_VERSION = '2.0'; -const SANDBOX_NOTIFICATION_PREFIX = 'ui/notifications/sandbox-'; const PROXY_READY_METHOD = 'ui/notifications/sandbox-proxy-ready'; const RESOURCE_READY_METHOD = 'ui/notifications/sandbox-resource-ready'; const INITIALIZED_METHOD = 'ui/notifications/initialized'; @@ -261,50 +260,6 @@ export interface McpAppSandboxFrame { readonly targetOrigin: string; } -export type McpAppSandboxLifecycle = 'created' | 'proxy-ready' | 'resource-ready' | 'initializing' | 'initialize-responded' | 'initialized' | 'closed'; - -export type McpAppSandboxRequestId = string | number | null; - -export interface McpAppSandboxMessage { - readonly error?: unknown; - readonly id?: McpAppSandboxRequestId; - readonly jsonrpc: '2.0'; - readonly method?: string; - readonly params?: unknown; - readonly result?: unknown; -} - -export interface McpAppSandboxMessageEvent { - readonly data: unknown; - readonly origin: string; - readonly source: unknown; -} - -export interface McpAppSandboxWindow { - postMessage(message: unknown, targetOrigin: string): void; -} - -export interface McpAppSandboxResource { - readonly csp?: McpAppSandboxCsp; - readonly html: string; - readonly permissions?: McpAppSandboxPermissions; - readonly sandbox?: string; -} - -export interface CreateMcpAppSandboxBridgeOptions { - readonly frame: McpAppSandboxFrame; - readonly onMessage?: (message: McpAppSandboxMessage) => void; - readonly proxyWindow: McpAppSandboxWindow; -} - -export interface McpAppSandboxBridge { - readonly lifecycle: McpAppSandboxLifecycle; - close(): void; - provideResource(resource: McpAppSandboxResource): boolean; - receive(event: McpAppSandboxMessageEvent): boolean; - send(message: McpAppSandboxMessage): boolean; -} - const finiteJson = (value: unknown): value is McpAppJsonValue => value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) || Array.isArray(value) && value.every(finiteJson) @@ -607,40 +562,6 @@ const relayOf = (maxMessageBytes: number | undefined, maxQueuedMessages: number maxQueuedMessages: maximum(maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES, 'maxQueuedMessages'), }); -const messageSize = (message: unknown): number | undefined => { - try { - const serialized = JSON.stringify(message); - return typeof serialized === 'string' ? Buffer.byteLength(serialized) : undefined; - } catch { - return undefined; - } -}; - -const isRequestId = (value: unknown): value is McpAppSandboxRequestId => value === null || typeof value === 'string' || typeof value === 'number'; - -const isMessage = (value: unknown, maxMessageBytes: number): value is McpAppSandboxMessage => { - if (!isRecord(value) || value.jsonrpc !== JSON_RPC_VERSION) return false; - const size = messageSize(value); - if (size === undefined || size > maxMessageBytes) return false; - const hasMethod = typeof value.method === 'string' && value.method.length > 0; - const hasId = Object.hasOwn(value, 'id') && isRequestId(value.id); - return hasMethod || (hasId && (Object.hasOwn(value, 'result') || Object.hasOwn(value, 'error'))); -}; - -const isNotification = (message: McpAppSandboxMessage, method: string): boolean => message.method === method && !Object.hasOwn(message, 'id'); - -const isSandboxNotification = (message: McpAppSandboxMessage): boolean => typeof message.method === 'string' && message.method.startsWith(SANDBOX_NOTIFICATION_PREFIX); - -const isInitializeRequest = (message: McpAppSandboxMessage): message is McpAppSandboxMessage & { readonly id: McpAppSandboxRequestId } => ( - message.method === INITIALIZE_METHOD && Object.hasOwn(message, 'id') && isRequestId(message.id) -); - -const isInitializeResponse = (message: McpAppSandboxMessage, id: McpAppSandboxRequestId | undefined): boolean => ( - !Object.hasOwn(message, 'method') && Object.hasOwn(message, 'id') && message.id === id && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error')) -); - -const notification = (method: string, params: unknown = {}): McpAppSandboxMessage => ({ jsonrpc: JSON_RPC_VERSION, method, params }); - export const createMcpAppSandboxFrame = ( options: CreateMcpAppSandboxFrameOptions, ): McpAppSandboxFrame => { @@ -666,93 +587,6 @@ export const createMcpAppSandboxFrame = ( }); }; -export const createMcpAppSandboxBridge = ( - options: CreateMcpAppSandboxBridgeOptions, -): McpAppSandboxBridge => { - const proxyOrigin = originOf(options.frame.targetOrigin); - const relay = relayOf(options.frame.relay.maxMessageBytes, options.frame.relay.maxQueuedMessages); - const queuedMessages: McpAppSandboxMessage[] = []; - let lifecycle: McpAppSandboxLifecycle = 'created'; - let initializeId: McpAppSandboxRequestId | undefined; - - const post = (message: McpAppSandboxMessage): void => options.proxyWindow.postMessage(message, proxyOrigin); - - const flush = (): void => { - while (queuedMessages.length > 0) { - const message = queuedMessages.shift(); - if (message) post(message); - } - }; - - return Object.freeze({ - get lifecycle(): McpAppSandboxLifecycle { - return lifecycle; - }, - close(): void { - lifecycle = 'closed'; - queuedMessages.length = 0; - initializeId = undefined; - }, - provideResource(resource: McpAppSandboxResource): boolean { - if (lifecycle !== 'proxy-ready' || typeof resource.html !== 'string' || (resource.sandbox !== undefined && typeof resource.sandbox !== 'string')) return false; - const message = notification(RESOURCE_READY_METHOD, { - allow: options.frame.policy.iframeAllow, - contentSecurityPolicy: options.frame.policy.contentSecurityPolicy, - html: resource.html, - ...(resource.sandbox === undefined ? {} : { sandbox: resource.sandbox }), - }); - lifecycle = 'resource-ready'; - post(message); - return true; - }, - receive(event: McpAppSandboxMessageEvent): boolean { - if (lifecycle === 'closed' || event.source !== options.proxyWindow || event.origin !== proxyOrigin) return false; - if (!isMessage(event.data, relay.maxMessageBytes)) return false; - const message = event.data; - if (isNotification(message, PROXY_READY_METHOD)) { - if (lifecycle !== 'created') return false; - lifecycle = 'proxy-ready'; - return true; - } - if (isNotification(message, INITIALIZED_METHOD)) { - if (lifecycle !== 'initialize-responded') return false; - lifecycle = 'initialized'; - flush(); - return true; - } - if (isSandboxNotification(message)) return false; - if (isInitializeRequest(message)) { - if (lifecycle !== 'resource-ready') return false; - initializeId = message.id; - lifecycle = 'initializing'; - options.onMessage?.(message); - return true; - } - if (lifecycle !== 'initialized') return false; - options.onMessage?.(message); - return true; - }, - send(message: McpAppSandboxMessage): boolean { - if (lifecycle === 'closed' || !isMessage(message, relay.maxMessageBytes) || isSandboxNotification(message)) return false; - if (lifecycle === 'initializing') { - if (!isInitializeResponse(message, initializeId)) return false; - lifecycle = 'initialize-responded'; - post(message); - return true; - } - if (lifecycle === 'initialized') { - post(message); - return true; - } - if (lifecycle !== 'resource-ready' || queuedMessages.length >= relay.maxQueuedMessages) { - return false; - } - queuedMessages.push(message); - return true; - }, - }); -}; - const listen = async (server: Server, port: number): Promise => new Promise((resolve, reject) => { server.once('error', reject); server.listen({ host: '127.0.0.1', port }, () => { diff --git a/packages/agent-bundle/src/effect/boundary.ts b/packages/agent-bundle/src/effect/boundary.ts index 56e3bedd3..e95315a4e 100644 --- a/packages/agent-bundle/src/effect/boundary.ts +++ b/packages/agent-bundle/src/effect/boundary.ts @@ -36,9 +36,6 @@ export interface RunPromiseOptions { readonly signal?: AbortSignal; } -const interruptAs = (): Effect.Effect => - Effect.interrupt as Effect.Effect; - export const abortError = (cause?: unknown): DOMException => { const error = new DOMException('The operation was aborted', 'AbortError'); if (cause !== undefined) { @@ -92,12 +89,6 @@ export const runPromise = async ( options?: RunPromiseOptions, ): Promise => throwExitFailure(await Effect.runPromiseExit(effect, runOptions(options))); -/** Promise edge that preserves the Effect `Exit` for callers that branch on cause. */ -export const runPromiseExit = async ( - effect: Effect.Effect, - options?: RunPromiseOptions, -): Promise> => Effect.runPromiseExit(effect, runOptions(options)); - /** * Sync edge for effects that cannot suspend. Do not use for I/O, streams, * or anything that waits on a fiber. @@ -133,41 +124,6 @@ export const makeScopedEffectRuntime = ( }); }; -/** - * Host AbortSignal → Effect interruption. Re-checks `signal.aborted` when - * the effect starts (not only when this helper is constructed) so a signal - * that aborts between construction and run still interrupts. The listener - * is registered first; aborted signals do not replay `abort`, so the - * callback rechecks immediately after `addEventListener`. - */ -export const abortToInterrupt = (signal: AbortSignal): Effect.Effect => - Effect.suspend(() => { - if (signal.aborted) return interruptAs(); - return Effect.callback((resume) => { - let settled = false; - const onAbort = (): void => { - if (settled) return; - settled = true; - resume(Effect.interrupt); - }; - signal.addEventListener('abort', onAbort, { once: true }); - if (signal.aborted) onAbort(); - return Effect.sync(() => { - signal.removeEventListener('abort', onAbort); - }); - }); - }); - -/** - * AbortSignal → Effect interruption, for programs that still run inside - * Effect and receive a host signal. The Promise edge also accepts `signal` - * directly via {@link runPromise}. - */ -export const interruptWhenAborted = ( - effect: Effect.Effect, - signal: AbortSignal, -): Effect.Effect => Effect.raceFirst(effect, abortToInterrupt(signal)); - /** * Effect interruption → AbortSignal, for Promise/fetch APIs that take a * signal. Requires `Scope`; close the scope to abort. This is the stage-2 diff --git a/packages/agent-bundle/src/schemas/skill-hosts/contract.ts b/packages/agent-bundle/src/schemas/skill-hosts/contract.ts index 45839ec88..e37838ff7 100644 --- a/packages/agent-bundle/src/schemas/skill-hosts/contract.ts +++ b/packages/agent-bundle/src/schemas/skill-hosts/contract.ts @@ -1,6 +1,5 @@ import type { ErrorObject } from 'ajv/dist/2020.js'; -import { validateAgentSkillsFrontmatter } from '../agent-skills/contract.ts'; import { createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; import claudeSchema from './claude-skill-frontmatter.schema.json' with { type: 'json' }; import codexSchema from './codex-openai-yaml.schema.json' with { type: 'json' }; @@ -43,5 +42,3 @@ export const validateCursorSkillFrontmatter = (value: unknown): readonly SkillHo export const validateCodexOpenaiYaml = (value: unknown): readonly SkillHostDocumentIssue[] => issuesFrom(validateCodex(value), validateCodex.errors); - -export const validatePortableSkillFrontmatter = validateAgentSkillsFrontmatter; diff --git a/packages/agent-bundle/src/test/registry.ts b/packages/agent-bundle/src/test/registry.ts index b440364c4..d5591be14 100644 --- a/packages/agent-bundle/src/test/registry.ts +++ b/packages/agent-bundle/src/test/registry.ts @@ -169,5 +169,3 @@ export const registeredManifestIdentity = (): { readonly digest: string; readonl ? undefined : { digest: registry.manifest.digest, projectRoot: registry.manifest.projectRoot }; }; - -export const hasRegisteredRoutes = (): boolean => registered() !== undefined; diff --git a/packages/agent-bundle/tests/durable-fs.test.ts b/packages/agent-bundle/tests/durable-fs.test.ts index c67d618c3..0ca214c7e 100644 --- a/packages/agent-bundle/tests/durable-fs.test.ts +++ b/packages/agent-bundle/tests/durable-fs.test.ts @@ -1,131 +1,23 @@ -import { constants } from 'node:fs'; -import { link, mkdtemp, open, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, open, readdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { - openPinnedContainedFile, publishFileByLink, - readPinnedFile, - readTornTailJsonl, - syncDirectorySync, syncPath, - writeJsonFileAtomically, - writeNewPinnedFile, type DurableHandleOpen, type DurableStagingOpen, } from '../src/core/durable-fs.ts'; -import { acquireOwnerLockFile, isProcessAlive, OwnerMutationSerializer } from '../src/core/owner-lock.ts'; +import { acquireOwnerLockFile, isProcessAlive } from '../src/core/owner-lock.ts'; import { errnoFailure } from './support/errors.ts'; -const tornOptions = Object.freeze({ - decode: (value: unknown): { readonly sequence: number; readonly value: string } => { - const record = value as { readonly sequence: number; readonly value: string }; - return Object.freeze({ sequence: record.sequence, value: record.value }); - }, - emptyRecord: () => new Error('empty record'), - malformedRecord: () => new Error('malformed record'), - sequenceViolation: () => new Error('sequence violation'), -}); - const publicationMessages = Object.freeze({ publicationCleanupFailed: 'publication and cleanup both failed', stagingCleanupFailed: 'staging cleanup failed', }); -it('decodes complete JSONL journals and tolerates exactly one torn trailing append', () => { - const complete = readTornTailJsonl('{"sequence":1,"value":"a"}\n{"sequence":2,"value":"b"}\n', tornOptions); - expect(complete.records).toEqual([ - { sequence: 1, value: 'a' }, - { sequence: 2, value: 'b' }, - ]); - expect(complete.incompleteTrailingRecord).toBeUndefined(); - expect(Object.isFrozen(complete.records)).toBe(true); - - const torn = readTornTailJsonl('{"sequence":1,"value":"a"}\n{"sequence":2,"va', tornOptions); - expect(torn.records).toEqual([{ sequence: 1, value: 'a' }]); - expect(torn.incompleteTrailingRecord).toBe('{"sequence":2,"va'); - - const empty = readTornTailJsonl('', tornOptions); - expect(empty.records).toEqual([]); - expect(empty.incompleteTrailingRecord).toBeUndefined(); - - const tornFirstAppend = readTornTailJsonl('{"sequence":1', tornOptions); - expect(tornFirstAppend.records).toEqual([]); - expect(tornFirstAppend.incompleteTrailingRecord).toBe('{"sequence":1'); -}); - -it('rejects empty lines, malformed or duplicate-key records, and sequence gaps in JSONL journals', () => { - expect(() => readTornTailJsonl('\n{"sequence":1,"value":"a"}\n', tornOptions)).toThrow('empty record'); - expect(() => readTornTailJsonl('not json\n', tornOptions)).toThrow('malformed record'); - expect(() => readTornTailJsonl('{"sequence":1,"sequence":1,"value":"a"}\n', tornOptions)).toThrow('malformed record'); - expect(() => readTornTailJsonl('{"sequence":2,"value":"a"}\n', tornOptions)).toThrow('sequence violation'); - expect(() => readTornTailJsonl('{"sequence":1,"value":"a"}\n{"sequence":3,"value":"b"}\n', tornOptions)) - .toThrow('sequence violation'); -}); - -it('publishes JSON atomically, fsyncing the staging file and then the directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-durable-fs-atomic-')); - try { - const path = join(root, 'value.json'); - const temporaryPath = join(root, '.value.json.tmp'); - const synced: string[] = []; - const openHandle: DurableHandleOpen = async (target, flags) => { - const handle = await open(target, flags); - return { - close: async () => { await handle.close(); }, - sync: async () => { - synced.push(target); - await handle.sync(); - }, - }; - }; - await writeJsonFileAtomically(path, '{"ok":true}\n', { open: openHandle, temporaryPath }); - await expect(readFile(path, 'utf8')).resolves.toBe('{"ok":true}\n'); - expect(synced).toEqual([temporaryPath, root]); - await expect(readdir(root)).resolves.toEqual(['value.json']); - } finally { - await rm(root, { force: true, recursive: true }); - } -}); - -it('removes the atomic-write staging file when publication fails before or after the rename', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-durable-fs-atomic-failure-')); - try { - const path = join(root, 'value.json'); - const temporaryPath = join(root, '.value.json.tmp'); - const failingSync = (failingTarget: string, failure: Error): DurableHandleOpen => async (target, flags) => { - const handle = await open(target, flags); - return { - close: async () => { await handle.close(); }, - sync: async () => { - if (target === failingTarget) throw failure; - await handle.sync(); - }, - }; - }; - - const stagingFailure = new Error('staging fsync failed'); - await expect(writeJsonFileAtomically(path, '{"ok":true}\n', { - open: failingSync(temporaryPath, stagingFailure), - temporaryPath, - })).rejects.toBe(stagingFailure); - await expect(readdir(root)).resolves.toEqual([]); - - const directoryFailure = new Error('directory fsync failed'); - await expect(writeJsonFileAtomically(path, '{"ok":true}\n', { - open: failingSync(root, directoryFailure), - temporaryPath, - })).rejects.toBe(directoryFailure); - // The rename already landed; only the staging path is guaranteed gone. - await expect(readdir(root)).resolves.toEqual(['value.json']); - } finally { - await rm(root, { force: true, recursive: true }); - } -}); - it('tolerates only documented Windows directory fsync capability failures', async () => { const closed: string[] = []; const failingOpen = (code: string): DurableHandleOpen => async (target) => ({ @@ -147,102 +39,6 @@ it('tolerates only documented Windows directory fsync capability failures', asyn expect(closed).toHaveLength(5); }); -it('applies the same Windows tolerance to the synchronous directory fsync', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-durable-fs-sync-directory-')); - try { - const phases: string[] = []; - syncDirectorySync(root, { - beforeFsync: () => { phases.push('fsync'); }, - beforeOpen: () => { phases.push('open'); }, - }); - expect(phases).toEqual(['open', 'fsync']); - expect(() => syncDirectorySync(root, { - beforeFsync: () => { throw errnoFailure('EACCES', 'denied'); }, - platform: 'win32', - })).not.toThrow(); - expect(() => syncDirectorySync(root, { - beforeFsync: () => { throw errnoFailure('EACCES', 'denied'); }, - platform: 'linux', - })).toThrow('denied'); - } finally { - await rm(root, { force: true, recursive: true }); - } -}); - -it('creates new pinned files exclusively and never follows an existing symlink', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-durable-fs-new-pinned-')); - try { - const phases: string[] = []; - const target = join(root, 'created.json'); - await writeNewPinnedFile(target, '{"created":true}\n', Object.freeze({ - afterFsync: () => { phases.push('after-fsync'); }, - beforeFsync: () => { phases.push('before-fsync'); }, - beforeWrite: () => { phases.push('before-write'); }, - invalid: () => new Error('not pinned'), - })); - expect(phases).toEqual(['before-write', 'before-fsync', 'after-fsync']); - await expect(readFile(target, 'utf8')).resolves.toBe('{"created":true}\n'); - - await expect(writeNewPinnedFile(target, 'again', { invalid: () => new Error('not pinned') })) - .rejects.toMatchObject({ code: 'EEXIST' }); - - // A dangling symlink at the path is never followed into a create. - await symlink(join(root, 'elsewhere.json'), join(root, 'aliased.json')); - await expect(writeNewPinnedFile(join(root, 'aliased.json'), 'aliased', { invalid: () => new Error('not pinned') })) - .rejects.toMatchObject({ code: 'EEXIST' }); - await expect(readFile(join(root, 'elsewhere.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); - } finally { - await rm(root, { force: true, recursive: true }); - } -}); - -it('rejects symlinked and multiply linked paths for pinned opens and pinned reads', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-durable-fs-pinned-')); - try { - const invalid = (): Error => new Error('pin rejected'); - await writeFile(join(root, 'real.json'), '{"pinned":true}\n', 'utf8'); - await symlink(join(root, 'real.json'), join(root, 'linked.json')); - await link(join(root, 'real.json'), join(root, 'hard.json')); - - // Symlinks never open (O_NOFOLLOW) ... - await expect(openPinnedContainedFile({ flags: constants.O_RDONLY, invalid, name: 'linked.json', root })) - .rejects.toMatchObject({ code: 'ELOOP' }); - // ... and hardlinked files open but fail the single-link pin. - await expect(openPinnedContainedFile({ flags: constants.O_RDONLY, invalid, name: 'hard.json', root })) - .rejects.toThrow('pin rejected'); - - const readOptions = Object.freeze({ - changedWhileOpening: () => new Error('changed while opening'), - changedWhileReading: () => new Error('changed while reading'), - maximumBytes: 1024, - unsafe: () => new Error('unsafe read'), - }); - await expect(readPinnedFile(join(root, 'linked.json'), readOptions)).rejects.toThrow('unsafe read'); - await expect(readPinnedFile(join(root, 'hard.json'), readOptions)).rejects.toThrow('unsafe read'); - // The original name is also multiply linked while the hardlink survives. - await expect(readPinnedFile(join(root, 'real.json'), readOptions)).rejects.toThrow('unsafe read'); - - await rm(join(root, 'hard.json')); - const ancestry: string[] = []; - await expect(readPinnedFile(join(root, 'real.json'), { - ...readOptions, - verifyAncestry: async () => { ancestry.push('verified'); }, - })).resolves.toBe('{"pinned":true}\n'); - expect(ancestry).toEqual(['verified', 'verified']); - await expect(readPinnedFile(join(root, 'real.json'), { ...readOptions, maximumBytes: 4 })) - .rejects.toThrow('unsafe read'); - - const handle = await openPinnedContainedFile({ flags: constants.O_RDONLY, invalid, name: 'real.json', root }); - try { - await expect(handle.readFile({ encoding: 'utf8' })).resolves.toBe('{"pinned":true}\n'); - } finally { - await handle.close(); - } - } finally { - await rm(root, { force: true, recursive: true }); - } -}); - it('publishes files by hard link, adopts raced winners, and never leaves staging behind', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-durable-fs-publish-')); try { @@ -420,21 +216,3 @@ it('drives owner acquisition through creation, adoption, stale clearing, and exh onContention: async () => 'adopted', })).rejects.toBe(denied); }); - -it('serializes owner mutations per path while distinct paths proceed concurrently', async () => { - const serializer = new OwnerMutationSerializer(); - const order: string[] = []; - const gate = Promise.withResolvers(); - const first = serializer.run('/lock-a', async () => { - order.push('a1-start'); - await gate.promise; - order.push('a1-end'); - }); - const second = serializer.run('/lock-a', async () => { order.push('a2'); }); - const other = serializer.run('/lock-b', async () => { order.push('b'); }); - await other; - expect(order).toEqual(['a1-start', 'b']); - gate.resolve(); - await Promise.all([first, second]); - expect(order).toEqual(['a1-start', 'b', 'a1-end', 'a2']); -}); diff --git a/packages/agent-bundle/tests/effect-boundary.test.ts b/packages/agent-bundle/tests/effect-boundary.test.ts index 40a7427fc..1445c28b4 100644 --- a/packages/agent-bundle/tests/effect-boundary.test.ts +++ b/packages/agent-bundle/tests/effect-boundary.test.ts @@ -8,13 +8,10 @@ import { DevLockError } from '../src/dev/dev-lock.ts'; import { ProjectEventHubError } from '../src/dev/events.ts'; import { abortError, - abortToInterrupt, - interruptWhenAborted, isAbortError, isTypedDevError, mapCause, runPromise, - runPromiseExit, runSync, toDevError, } from '../src/effect/boundary.ts'; @@ -26,7 +23,6 @@ describe('effect boundary (agent-bundle dev seam)', () => { expect('runPromise' in rootApi).toBe(false); expect('runSync' in rootApi).toBe(false); expect('runPromise' in devApi).toBe(false); - expect('interruptWhenAborted' in devApi).toBe(false); }); it('resolves a successful effect', async () => { @@ -57,50 +53,21 @@ describe('effect boundary (agent-bundle dev seam)', () => { await expect(runPromise(Effect.never, { signal: controller.signal })).rejects.toSatisfy(isAbortError); }); - it('interrupts an in-flight effect when the host signal aborts', async () => { - const controller = new AbortController(); - const pending = runPromise(interruptWhenAborted(Effect.never, controller.signal)); - await Promise.resolve(); - controller.abort(); - await expect(pending).rejects.toSatisfy(isAbortError); - }); - - it('preserves Exit on runPromiseExit', async () => { - const success = await runPromiseExit(Effect.succeed(7)); - expect(Exit.isSuccess(success)).toBe(true); - if (Exit.isSuccess(success)) expect(success.value).toBe(7); - - const failure = await runPromiseExit(Effect.fail('nope')); - expect(Exit.isFailure(failure)).toBe(true); - }); - it('wraps non-Error fail values', () => { expect(toDevError('plain')).toEqual(new Error('plain')); expect(abortError().name).toBe('AbortError'); }); - - it('interrupts when the host signal aborts between construction and run', async () => { - const controller = new AbortController(); - const program = interruptWhenAborted(Effect.never, controller.signal); - controller.abort(); - const pending = runPromise(program); - const hung = new Promise((_, reject) => { - setTimeout(() => reject(new Error('interruptWhenAborted hung after abort-before-start')), 250); - }); - await expect(Promise.race([pending, hung])).rejects.toSatisfy(isAbortError); - await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError); - }); }); describe('effect lifts (src/effect/lift.ts)', () => { it('keeps the rejected or thrown value identity-preserved on the fail channel', async () => { const typed = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.'); const reason = { code: 'ECUSTOM', message: 'not an Error' }; - const rejected = await runPromiseExit(liftPromise(() => Promise.reject(typed))); + const rejected = await runPromise(Effect.exit(liftPromise(() => Promise.reject(typed)))); expect(Exit.isFailure(rejected) && Cause.squash(rejected.cause)).toBe(typed); - const rawReason = await runPromiseExit(liftPromise(() => Promise.reject(reason))); + const rawReason = await runPromise(Effect.exit(liftPromise(() => Promise.reject(reason)))); expect(Exit.isFailure(rawReason) && Cause.squash(rawReason.cause)).toBe(reason); - const thrown = await runPromiseExit(liftTry((): never => { throw typed; })); + const thrown = await runPromise(Effect.exit(liftTry((): never => { throw typed; }))); expect(Exit.isFailure(thrown) && Cause.squash(thrown.cause)).toBe(typed); expect(runSync(liftTry(() => 7))).toBe(7); // The Promise edge rethrows typed errors as-is and wraps non-Error values, diff --git a/packages/agent-bundle/tests/effect-errors.test.ts b/packages/agent-bundle/tests/effect-errors.test.ts index 93c50584c..1626abb9c 100644 --- a/packages/agent-bundle/tests/effect-errors.test.ts +++ b/packages/agent-bundle/tests/effect-errors.test.ts @@ -8,7 +8,7 @@ import { CodedError } from '../src/core/errors.ts'; import { DevCoordinatorCloseError } from '../src/dev/coordinator.ts'; import { RuntimeMcpRegistryError } from '../src/dev/runtime-mcp-registry.ts'; import { ScriptPlaygroundFailure } from '../src/dev/playground/script-playground-service.ts'; -import { isTypedDevError, runPromise, runPromiseExit } from '../src/effect/boundary.ts'; +import { isTypedDevError, runPromise } from '../src/effect/boundary.ts'; import { YieldableCodedError, YieldableFrameworkError } from '../src/effect/errors.ts'; /** The plain-`Error` twin the yieldable bases replace; the serialization pins compare against it. */ @@ -36,7 +36,7 @@ describe('yieldable framework error bases (src/effect/errors.ts)', () => { const program = Effect.gen(function* () { return yield* error; }); - const exit = await runPromiseExit(program); + const exit = await runPromise(Effect.exit(program)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toBe(error); diff --git a/packages/agent-bundle/tests/mcp-app-sandbox.test.ts b/packages/agent-bundle/tests/mcp-app-sandbox.test.ts index dbe837768..a6ddd5410 100644 --- a/packages/agent-bundle/tests/mcp-app-sandbox.test.ts +++ b/packages/agent-bundle/tests/mcp-app-sandbox.test.ts @@ -5,7 +5,6 @@ import { expect, it } from '@rstest/core'; import { createMcpAppDocumentPolicySnapshot, - createMcpAppSandboxBridge, createMcpAppSandboxFrame, createMcpAppSandboxProxy, deriveMcpAppSandboxPolicy, @@ -25,12 +24,6 @@ const frameFor = () => createMcpAppSandboxFrame({ proxy: proxyEndpoint, }); -const rpcNotification = (method: string, params: Record = {}) => ({ - jsonrpc: '2.0' as const, - method, - params, -}); - it('serves one immutable, different-origin shell and no MCP or session route', async () => { const host = createServer(); host.listen({ host: '127.0.0.1', port: 0 }); @@ -193,108 +186,6 @@ it('freezes a validated relay configuration instead of retaining a caller-owned expect(Object.isFrozen(frame.relay)).toBe(true); }); -it('enforces the JSON-RPC proxy lifecycle and holds host traffic until initialized', () => { - const sent: { message: unknown; targetOrigin: string }[] = []; - const forwarded: unknown[] = []; - const proxyWindow = { - postMessage(message: unknown, targetOrigin: string) { - sent.push({ message, targetOrigin }); - }, - }; - const bridge = createMcpAppSandboxBridge({ - frame: frameFor(), - onMessage: (message) => forwarded.push(message), - proxyWindow, - }); - const event = (data: unknown, source: unknown = proxyWindow, origin = 'http://127.0.0.1:43124') => ({ data, origin, source }); - - expect(bridge.provideResource({ html: '

Hello

' })).toBe(false); - expect(bridge.receive(event(rpcNotification('ui/notifications/sandbox-proxy-ready'), {}))).toBe(false); - expect(bridge.receive(event(rpcNotification('ui/notifications/sandbox-proxy-ready'), proxyWindow, 'http://127.0.0.1:43124'))).toBe(true); - expect(bridge.lifecycle).toBe('proxy-ready'); - - expect(bridge.provideResource({ html: '

Rejected

', sandbox: {} as unknown as string })).toBe(false); - expect(bridge.provideResource({ - csp: { connectDomains: ['https://api.example.test'] }, - html: '

Hello

', - permissions: { camera: {} }, - sandbox: 'allow-scripts', - })).toBe(true); - expect(bridge.lifecycle).toBe('resource-ready'); - expect(bridge.send(rpcNotification('app/too-large', { value: 'x'.repeat(1_024) }))).toBe(false); - expect(bridge.send(rpcNotification('app/ping'))).toBe(true); - expect(bridge.send(rpcNotification('app/second-ping'))).toBe(false); - expect(bridge.send(rpcNotification('ui/notifications/sandbox-invented'))).toBe(false); - expect(sent).toEqual([{ - message: rpcNotification('ui/notifications/sandbox-resource-ready', { - allow: 'camera', - contentSecurityPolicy: "default-src 'none'; base-uri 'self'; connect-src 'none'; frame-src 'none'; img-src data:; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'", - html: '

Hello

', - sandbox: 'allow-scripts', - }), - targetOrigin: 'http://127.0.0.1:43124', - }]); - - expect(bridge.receive(event(rpcNotification('ui/notifications/sandbox-resource-ready')))).toBe(false); - expect(bridge.receive(event({ id: 'init-1', jsonrpc: '2.0', method: 'ui/initialize', params: {} }))).toBe(true); - expect(bridge.lifecycle).toBe('initializing'); - expect(forwarded).toEqual([{ id: 'init-1', jsonrpc: '2.0', method: 'ui/initialize', params: {} }]); - expect(bridge.receive(event(rpcNotification('ui/notifications/initialized')))).toBe(false); - expect(bridge.send({ id: 'wrong', jsonrpc: '2.0', result: {} })).toBe(false); - expect(bridge.send({ id: 'init-1', jsonrpc: '2.0', result: { protocolVersion: '2025-06-18' } })).toBe(true); - expect(bridge.lifecycle).toBe('initialize-responded'); - expect(bridge.receive(event(rpcNotification('ui/notifications/initialized')))).toBe(true); - expect(bridge.lifecycle).toBe('initialized'); - expect(sent).toEqual([ - sent[0], - { - message: { id: 'init-1', jsonrpc: '2.0', result: { protocolVersion: '2025-06-18' } }, - targetOrigin: 'http://127.0.0.1:43124', - }, - { - message: rpcNotification('app/ping'), - targetOrigin: 'http://127.0.0.1:43124', - }, - ]); - expect(bridge.receive(event(rpcNotification('ui/notifications/sandbox-unknown')))).toBe(false); - expect(bridge.receive(event(rpcNotification('app/pong')))).toBe(true); - expect(forwarded.at(-1)).toEqual(rpcNotification('app/pong')); - - bridge.close(); - expect(bridge.lifecycle).toBe('closed'); - expect(bridge.send(rpcNotification('app/after-close'))).toBe(false); -}); - -it('provides a valid built App resource without imposing the runtime message-size limit', () => { - const sent: unknown[] = []; - const proxyWindow = { - postMessage(message: unknown) { - sent.push(message); - }, - }; - const frame = frameFor(); - const bridge = createMcpAppSandboxBridge({ - frame, - proxyWindow, - }); - const proxyReady = { - data: rpcNotification('ui/notifications/sandbox-proxy-ready'), - origin: 'http://127.0.0.1:43124', - source: proxyWindow, - }; - const html = `
${'x'.repeat(relay.maxMessageBytes)}
`; - expect(bridge.receive(proxyReady)).toBe(true); - - expect(bridge.provideResource({ html })).toBe(true); - expect(sent).toEqual([ - rpcNotification('ui/notifications/sandbox-resource-ready', { - allow: frame.allow, - contentSecurityPolicy: frame.policy.contentSecurityPolicy, - html, - }), - ]); -}); - it('uses an opaque child relay shell with real MCP Apps JSON-RPC notification methods', async () => { const host = createServer(); host.listen({ host: '127.0.0.1', port: 0 }); diff --git a/packages/rsc-runtime/src/effect/boundary.ts b/packages/rsc-runtime/src/effect/boundary.ts index ccce1ae96..d06a1083a 100644 --- a/packages/rsc-runtime/src/effect/boundary.ts +++ b/packages/rsc-runtime/src/effect/boundary.ts @@ -88,19 +88,6 @@ export const runPromise = async ( options?: RunPromiseOptions, ): Promise
=> throwExitFailure(await Effect.runPromiseExit(effect, runOptions(options))); -/** Promise edge that preserves the Effect `Exit` for callers that branch on cause. */ -export const runPromiseExit = async ( - effect: Effect.Effect, - options?: RunPromiseOptions, -): Promise> => Effect.runPromiseExit(effect, runOptions(options)); - -/** - * Sync edge for effects that cannot suspend. Do not use for I/O, streams, - * or anything that waits on a fiber. - */ -export const runSync = (effect: Effect.Effect): A => - throwExitFailure(Effect.runSyncExit(effect)); - /** * Internal long-lived Effect runtime. Its Layer owns one Scope, which stays * live across Promise API calls and is finalized exactly once by close(). diff --git a/packages/rsc-runtime/src/effect/render-stream.ts b/packages/rsc-runtime/src/effect/render-stream.ts index 2ef98e4c9..88adeaddc 100644 --- a/packages/rsc-runtime/src/effect/render-stream.ts +++ b/packages/rsc-runtime/src/effect/render-stream.ts @@ -1,10 +1,9 @@ -import { Effect, Latch, Stream } from 'effect'; +import { Effect, Latch } from 'effect'; import { createAgentRenderEventSequence, type AgentRenderEvent, type AgentRenderEventInput, - type AgentRenderLimits, } from '../agent-document.js'; import { toRuntimeError } from './boundary.js'; @@ -45,19 +44,3 @@ export const emitBoundRenderEvent = ( catch: (error) => toRuntimeError(error), try: () => sequence.emit(input), }); - -/** - * Contract bounds as a stream stage: sequence numbers, elapsed / rate / - * count / event-bytes, document snapshot bounds (depth / nodes / bytes), - * and handoff-required after complete. Uses the same stepper as - * `createAgentRenderEventSequence` so the #140 tests stay the source of - * truth. - */ -export const boundRenderEventStream = ( - limits?: Partial, -): ( - stream: Stream.Stream, -) => Stream.Stream => { - const sequence = createAgentRenderEventSequence(limits); - return (stream) => Stream.mapEffect(stream, (input) => emitBoundRenderEvent(sequence, input)); -}; diff --git a/packages/rsc-runtime/tests/agent-document.test.ts b/packages/rsc-runtime/tests/agent-document.test.ts index 5a545071b..8547d2001 100644 --- a/packages/rsc-runtime/tests/agent-document.test.ts +++ b/packages/rsc-runtime/tests/agent-document.test.ts @@ -1,4 +1,3 @@ -import { Effect, Stream } from 'effect'; import { describe, expect, it } from 'effect-rstest'; import { @@ -9,7 +8,6 @@ import { type AgentDocumentNode, type AgentRenderInvocation, } from '../src/index.js'; -import { boundRenderEventStream } from '../src/effect/render-stream.js'; const root = (): AgentDocumentNode => ({ children: [ @@ -257,29 +255,6 @@ describe('Agent render events', () => { }); }); -describe('boundRenderEventStream', () => { - it.effect('assigns sequence numbers and fails closed after complete', () => Effect.gen(function*() { - const events = yield* Stream.runCollect( - Stream.make( - { completed: 0, type: 'progress' as const }, - { completed: 1, type: 'progress' as const }, - ).pipe(boundRenderEventStream()), - ); - expect(events.map((event) => event.sequence)).toEqual([0, 1]); - - const failure = yield* Stream.runCollect( - Stream.make( - { - document: { root: root(), status: 'success' as const, version: 1 as const }, - type: 'complete' as const, - }, - { completed: 2, type: 'progress' as const }, - ).pipe(boundRenderEventStream()), - ).pipe(Effect.flip); - expect(failure).toMatchObject({ code: 'handoff-required' }); - })); -}); - describe('AgentRenderInvocation', () => { it('discriminates typed props for every invocation kind', () => { expect([ diff --git a/packages/rsc-runtime/tests/effect-boundary.test.ts b/packages/rsc-runtime/tests/effect-boundary.test.ts index efec73939..cc4e7ded0 100644 --- a/packages/rsc-runtime/tests/effect-boundary.test.ts +++ b/packages/rsc-runtime/tests/effect-boundary.test.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Stream } from 'effect'; +import { Cause, Effect, Stream } from 'effect'; import { describe, expect, it } from '@rstest/core'; import { AgentContractError } from '../src/agent-document.js'; @@ -12,8 +12,6 @@ import { isTypedRuntimeError, mapCause, runPromise, - runPromiseExit, - runSync, streamToReadableStream, toRuntimeError, } from '../src/effect/boundary.js'; @@ -22,13 +20,11 @@ import * as runtime from '../src/index.js'; describe('effect boundary', () => { it('is not part of the public runtime export', () => { expect('runPromise' in runtime).toBe(false); - expect('runSync' in runtime).toBe(false); expect('interruptWhenAborted' in runtime).toBe(false); }); it('resolves a successful effect', async () => { await expect(runPromise(Effect.succeed(41))).resolves.toBe(41); - expect(runSync(Effect.succeed('ok'))).toBe('ok'); }); it('rethrows AgentRequestError and AgentContractError from the fail channel', async () => { @@ -74,15 +70,6 @@ describe('effect boundary', () => { await expect(pending).rejects.toSatisfy(isAbortError); }); - it('preserves Exit on runPromiseExit', async () => { - const success = await runPromiseExit(Effect.succeed(7)); - expect(Exit.isSuccess(success)).toBe(true); - if (Exit.isSuccess(success)) expect(success.value).toBe(7); - - const failure = await runPromiseExit(Effect.fail('nope')); - expect(Exit.isFailure(failure)).toBe(true); - }); - it('wraps non-Error fail values', () => { expect(toRuntimeError('plain')).toEqual(new Error('plain')); expect(abortError().name).toBe('AbortError'); diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 5114c48cd..28fa8511c 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -22,7 +22,6 @@ "react-dom": "19.2.8", "react-markdown": "10.1.0", "remark-gfm": "4.0.1", - "scheduler": "0.27.0", "shiki": "4.4.3", "zod": "4.5.4" }, diff --git a/packages/workbench/src/evals/evals-page.tsx b/packages/workbench/src/evals/evals-page.tsx index ff9974a69..e219800c4 100644 --- a/packages/workbench/src/evals/evals-page.tsx +++ b/packages/workbench/src/evals/evals-page.tsx @@ -72,22 +72,6 @@ const evalClientScopeKeyFor = (client: EvalClient): number => { const errorMessage = (reason: unknown): string => messageFrom(reason, 'The eval request could not be completed.'); -const noEvalEvents: readonly EvalRunEvent[] = Object.freeze([]); - -/** A held replay may only paint for the run identity that opened it. */ -export const eventsForActiveEvalRun = ( - activeRunId: string | undefined, - sourceRunId: string | undefined, - events: readonly EvalRunEvent[], -): readonly EvalRunEvent[] => activeRunId === sourceRunId ? events : noEvalEvents; - -/** A bounded-history notice belongs to the same run identity as its events. */ -export const discardedSequenceForActiveEvalRun = ( - activeRunId: string | undefined, - sourceRunId: string | undefined, - discardedThroughSequence: number | undefined, -): number | undefined => activeRunId === sourceRunId ? discardedThroughSequence : undefined; - /** Starts one authored run over the selection the browser is allowed to make. */ export const startEvalRun = async ( client: EvalClient, diff --git a/packages/workbench/src/shell/build-status-model.ts b/packages/workbench/src/shell/build-status-model.ts index b9f919489..b80f40c37 100644 --- a/packages/workbench/src/shell/build-status-model.ts +++ b/packages/workbench/src/shell/build-status-model.ts @@ -366,7 +366,3 @@ export const problemsFor = ({ catalog, runtimeDiagnostic, status, tree }: Proble .sort((left, right) => severityRank[left.problem.severity] - severityRank[right.problem.severity] || left.index - right.index) .map(({ problem }) => problem)); }; - -/** The header badge count: error-severity problems. */ -export const problemFailureCount = (problems: readonly Problem[]): number => - problems.filter((problem) => problem.severity === 'error').length; diff --git a/packages/workbench/tests/build-status-model.test.ts b/packages/workbench/tests/build-status-model.test.ts index 1622dee8e..ee3e4fd4d 100644 --- a/packages/workbench/tests/build-status-model.test.ts +++ b/packages/workbench/tests/build-status-model.test.ts @@ -2,7 +2,7 @@ import { expect, it } from '@rstest/core'; import type { ProjectStatus } from '../../agent-bundle/src/contracts/project.ts'; import type { ApplicationTree } from '../src/application/application-tree-model.ts'; -import { buildStatusFor, problemFailureCount, problemsFor, staleCatalogMessage } from '../src/shell/build-status-model.ts'; +import { buildStatusFor, problemsFor, staleCatalogMessage } from '../src/shell/build-status-model.ts'; it('projects one real active epoch, generated targets, and a current-source summary', () => { const model = buildStatusFor({ @@ -219,7 +219,6 @@ it('lists source, build, contract-gate, catalog, host, and runtime problems, err expect(problems[5]).toMatchObject({ message: staleCatalogMessage, repairable: true }); expect(problems[6]).toMatchObject({ location: 'agent-bundle.config.ts', repairable: true }); expect(problems[6]!.node).toBeUndefined(); - expect(problemFailureCount(problems)).toBe(4); expect(Object.isFrozen(problems)).toBe(true); }); diff --git a/packages/workbench/tests/evals-page.test.ts b/packages/workbench/tests/evals-page.test.ts index 28941b9fd..16b8bb89c 100644 --- a/packages/workbench/tests/evals-page.test.ts +++ b/packages/workbench/tests/evals-page.test.ts @@ -10,13 +10,11 @@ import { ForegroundRouteClient } from '../src/mcp/mcp-route-client.ts'; import { evalRunSelectionFor, evalRunViewFor } from '../src/evals/evals-model.ts'; import { beginEvalCancellation, - discardedSequenceForActiveEvalRun, evalArtifactPresentationKey, EvalRunControls, EvalRunReport, EvalsPage, EvalsRequestLifecycle, - eventsForActiveEvalRun, observeEvalRunEvents, openEvalRun, prepareEvalArtifactDisplay, @@ -236,17 +234,6 @@ it('renders the persisted timeline, server evidence channels, host/model matrix, expect(markup).toContain('evidence.json'); }); -it('does not paint held prior-run events while a replacement run waits for replay', () => { - const priorRunEvents = deepFreeze([ - { kind: 'run.started', payload: Object.freeze({ trials: 3 }), schemaVersion: 1, sequence: 1, timestamp: '2026-08-17T00:00:00.000Z' }, - ]); - - expect(eventsForActiveEvalRun('run-b', 'run-a', priorRunEvents)).toEqual([]); - expect(eventsForActiveEvalRun('run-a', 'run-a', priorRunEvents)).toEqual(priorRunEvents); - expect(discardedSequenceForActiveEvalRun('run-b', 'run-a', 12)).toBeUndefined(); - expect(discardedSequenceForActiveEvalRun('run-a', 'run-a', 12)).toBe(12); -}); - it('labels a bounded timeline when earlier durable events are not shown', () => { const bounded = evalRunViewFor({ discardedThroughSequence: 12, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b36f3452..d3dd15af0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,6 @@ importers: agent-bundle: specifier: workspace:* version: link:packages/agent-bundle - commander: - specifier: 15.0.0 - version: 15.0.0 pkg-pr-new: specifier: 0.0.88 version: 0.0.88 @@ -304,9 +301,6 @@ importers: npm-package-arg: specifier: 14.0.0 version: 14.0.0 - open: - specifier: 11.0.2 - version: 11.0.2 ws: specifier: 8.21.3 version: 8.21.3 @@ -456,9 +450,6 @@ importers: remark-gfm: specifier: 4.0.1 version: 4.0.1(supports-color@7.2.0) - scheduler: - specifier: 0.27.0 - version: 0.27.0 shiki: specifier: 4.4.3 version: 4.4.3 diff --git a/scripts/measure-preflight-cold-start.mjs b/scripts/measure-preflight-cold-start.mjs deleted file mode 100644 index a7bf70ead..000000000 --- a/scripts/measure-preflight-cold-start.mjs +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env node - -/** - * Compares process startup with the cheap event preflight path and the full - * standalone rendered-event path. The fixture intentionally uses the public - * CLI and the composite-root hook layout emitted after #578. - */ - -import { spawn } from 'node:child_process'; -import { constants } from 'node:fs'; -import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { fileURLToPath } from 'node:url'; - -const workspaceRoot = fileURLToPath(new URL('..', import.meta.url)); -const cli = join(workspaceRoot, 'packages', 'agent-bundle', 'bin', 'agent-bundle.js'); -const defaultRuns = 7; - -const median = (values) => { - const sorted = [...values].sort((left, right) => left - right); - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 - ? (sorted[middle - 1] + sorted[middle]) / 2 - : sorted[middle]; -}; - -const roundMs = (value) => Math.round(value * 100) / 100; - -const parseRuns = () => { - let value = process.env.AGENT_BUNDLE_BENCH_RUNS; - for (let index = 2; index < process.argv.length; index += 1) { - const argument = process.argv[index]; - if (argument === '--runs') { - value = process.argv[index + 1]; - index += 1; - continue; - } - if (argument.startsWith('--runs=')) { - value = argument.slice('--runs='.length); - continue; - } - throw new Error(`Unknown argument ${JSON.stringify(argument)}. Use --runs .`); - } - if (value === undefined) return defaultRuns; - if (!/^[1-9]\d*$/u.test(value)) { - throw new Error(`Run count must be a positive integer, received ${JSON.stringify(value)}.`); - } - return Number(value); -}; - -const run = (command, args, options = {}) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env ?? process.env, - stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], - }); - let stderr = ''; - let stdout = ''; - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); - child.stdout?.on('data', (chunk) => { - stdout += chunk.toString(); - }); - child.once('error', reject); - child.once('close', (code, signal) => resolve({ code, signal, stderr, stdout })); - if (options.input !== undefined) child.stdin?.end(options.input); - }); - -const assertSuccessful = (label, result) => { - if (result.code !== 0 || result.signal !== null) { - throw new Error( - `${label} failed (exit ${String(result.code)}, signal ${String(result.signal)}):\n${result.stderr || result.stdout}`, - ); - } -}; - -const findGnuTime = async () => { - for (const candidate of ['/usr/bin/time', '/bin/time']) { - try { - await access(candidate, constants.X_OK); - } catch { - continue; - } - const version = await run(candidate, ['--version']); - if (version.code === 0 && /GNU time/iu.test(`${version.stdout}\n${version.stderr}`)) return candidate; - } - return undefined; -}; - -const measureOnce = async ({ args, command, input, label, time, timeFile }) => { - const measuredArgs = time === undefined - ? args - : [`--format=%M`, `--output=${timeFile}`, '--', command, ...args]; - const measuredCommand = time ?? command; - const started = performance.now(); - const result = await run(measuredCommand, measuredArgs, { input }); - const wallMs = roundMs(performance.now() - started); - assertSuccessful(label, result); - - let maxRssKiB = null; - if (time !== undefined) { - const rss = (await readFile(timeFile, 'utf8')).trim(); - if (!/^\d+$/u.test(rss)) { - throw new Error(`GNU time returned an invalid max RSS for ${label}: ${JSON.stringify(rss)}.`); - } - maxRssKiB = Number(rss); - } - return { maxRssKiB, result, wallMs }; -}; - -const cursorBeforeTool = (command) => JSON.stringify({ - conversation_id: 'conversation-cold-start', - cwd: '/workspace', - hook_event_name: 'preToolUse', - session_id: 'session-cold-start', - tool_input: { command }, - tool_name: 'Shell', - tool_use_id: 'tool-cold-start', -}); - -const writeFixture = async (root) => { - await mkdir(join(root, 'src', 'events', 'tool'), { recursive: true }); - await Promise.all([ - symlink(join(workspaceRoot, 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'), - writeFile(join(root, 'package.json'), JSON.stringify({ - dependencies: { - '@agent-bundle/runtime': 'workspace:*', - react: '19.2.8', - }, - name: 'preflight-cold-start-fixture', - type: 'module', - version: '0.0.0', - })), - writeFile( - join(root, 'agent-bundle.config.ts'), - [ - "import { defineConfig } from 'agent-bundle/config';", - "export default defineConfig({ plugin: { name: 'preflight-cold-start-fixture', version: '0.0.0' }, targets: ['cursor'] });", - '', - ].join('\n'), - ), - writeFile( - join(root, 'src', 'events', 'tool', 'before.preflight.ts'), - [ - 'export default ({ canonical }) => {', - ' const input = canonical.payload.toolInput?.value;', - ' const command = input !== null && typeof input === "object" && !Array.isArray(input)', - ' ? input.command', - ' : undefined;', - ' return command === "execute-rendered-route" ? "execute" : { outcome: "continue" };', - '};', - '', - ].join('\n'), - ), - writeFile( - join(root, 'src', 'events', 'tool', 'before.tsx'), - [ - "import { Agent } from '@agent-bundle/runtime';", - "import { createElement } from 'react';", - "export { default as preflight } from './before.preflight.js';", - "export const config = { providers: [], runtime: 'standalone', targets: ['cursor'] };", - 'export default async function BeforeTool() {', - " return createElement(Agent.Result, { value: { outcome: 'allow' } });", - '}', - '', - ].join('\n'), - ), - ]); -}; - -const validatePlainNode = (result) => { - if (result.stdout !== '' || result.stderr !== '') { - throw new Error(`Plain Node produced output: ${JSON.stringify({ stderr: result.stderr, stdout: result.stdout })}.`); - } -}; - -const validatePreflight = (result) => { - if (result.stdout !== '' || result.stderr !== '') { - throw new Error( - `No-op event preflight must pass through without output; received ${JSON.stringify({ stderr: result.stderr, stdout: result.stdout })}. The generated hook may still be loading and executing the rendered route.`, - ); - } -}; - -const validateRenderedRoute = (result) => { - if (result.stderr !== '') { - throw new Error(`Rendered event route wrote stderr: ${JSON.stringify(result.stderr)}.`); - } - let output; - try { - output = JSON.parse(result.stdout); - } catch { - throw new Error(`Rendered event route returned invalid JSON: ${JSON.stringify(result.stdout)}.`); - } - if ( - output === null - || typeof output !== 'object' - || Array.isArray(output) - || output.permission !== 'allow' - || Object.keys(output).length !== 1 - ) { - throw new Error(`Rendered event route returned an unexpected result: ${JSON.stringify(output)}.`); - } -}; - -const summarize = (measurements) => { - const wallMs = measurements.map((sample) => sample.wallMs); - const maxRssKiB = measurements.map((sample) => sample.maxRssKiB); - const availableRss = maxRssKiB.every((value) => value !== null) - ? maxRssKiB - : null; - return { - samples: { - maxRssKiB, - wallMs, - }, - medians: { - maxRssKiB: availableRss === null ? null : median(availableRss), - wallMs: roundMs(median(wallMs)), - }, - }; -}; - -const main = async () => { - const runs = parseRuns(); - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-preflight-cold-start-')); - const output = join(root, 'artifact'); - const time = await findGnuTime(); - try { - await writeFixture(root); - const built = await run(process.execPath, [cli, 'build', '--root', root, '--output', output], { - cwd: workspaceRoot, - }); - assertSuccessful('agent-bundle build', built); - - // #578 emits all generated surfaces at the composite root. A one-target - // event still keeps its host suffix because hook codecs are host-specific. - const outputFiles = await readdir(output, { recursive: true }); - const hookMatches = outputFiles - .filter((path) => path.endsWith('/hooks/event-route-tool-before.cursor.mjs') - || path === 'hooks/event-route-tool-before.cursor.mjs' - || path.endsWith('/hooks/event-route-tool-before.mjs') - || path === 'hooks/event-route-tool-before.mjs'); - if (hookMatches.length !== 1) { - throw new Error( - `Build emitted ${String(hookMatches.length)} cursor tool/before wrappers; expected exactly one. Hooks: ${JSON.stringify(outputFiles.filter((path) => path.includes('hooks/')))}.`, - ); - } - const hook = join(output, hookMatches[0]); - - const measurements = { - plainNode: [], - preflightContinue: [], - renderedRoute: [], - }; - for (let index = 0; index < runs; index += 1) { - const plain = await measureOnce({ - args: ['-e', ''], - command: process.execPath, - label: 'plain Node', - time, - timeFile: join(root, `time-plain-${String(index)}.txt`), - }); - validatePlainNode(plain.result); - measurements.plainNode.push(plain); - - const preflight = await measureOnce({ - args: [hook], - command: process.execPath, - input: cursorBeforeTool('no-op'), - label: 'event preflight continue', - time, - timeFile: join(root, `time-preflight-${String(index)}.txt`), - }); - validatePreflight(preflight.result); - measurements.preflightContinue.push(preflight); - - const rendered = await measureOnce({ - args: [hook], - command: process.execPath, - input: cursorBeforeTool('execute-rendered-route'), - label: 'rendered event route', - time, - timeFile: join(root, `time-rendered-${String(index)}.txt`), - }); - validateRenderedRoute(rendered.result); - measurements.renderedRoute.push(rendered); - } - - const report = { - kind: 'event-preflight-cold-start', - runs, - node: process.version, - platform: process.platform, - rss: time === undefined - ? { - available: false, - reason: 'GNU time was not found; max RSS samples and medians are null.', - source: null, - unit: 'KiB', - } - : { - available: true, - reason: null, - source: 'GNU time %M', - unit: 'KiB', - }, - benchmarks: { - plainNode: { - path: "node -e ''", - ...summarize(measurements.plainNode), - }, - preflightContinue: { - path: 'hooks/event-route-tool-before.cursor.mjs (preflight continue)', - ...summarize(measurements.preflightContinue), - }, - renderedRoute: { - path: 'hooks/event-route-tool-before.cursor.mjs (execute rendered route)', - ...summarize(measurements.renderedRoute), - }, - }, - }; - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - } finally { - await rm(root, { force: true, recursive: true }); - } -}; - -await main();