From dcef3307ac2171e166fa07cc49cb25e8d1b78d62 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:24:48 +0000 Subject: [PATCH 01/33] fix(dev): stop fabricating route-invocation provider timings Zero was reported as a measurement for providers the child never observed. Record unobserved providers and only measured phases. --- .changeset/wb600-pr2a-telemetry.md | 5 + LANE-NOTES.md | 64 ++++++++++ .../dev/routes/route-invocation-service.ts | 54 +++++---- .../src/dev/routes/route-invocation.ts | 18 ++- .../tests/route-invocation-dev-server.test.ts | 4 +- .../tests/route-invocation-service.test.ts | 112 ++++++++++++++++++ .../src/application/invocation-client.ts | 2 +- .../src/application/runtime-backend.ts | 12 +- .../workbench/src/application/workspace.css | 1 + .../workbench/tests/invocation-client.test.ts | 14 +++ .../workbench/tests/route-workspace.test.ts | 21 ++++ .../docs/en/guide/development/workbench.mdx | 20 +++- .../docs/zh/guide/development/workbench.mdx | 16 ++- 13 files changed, 303 insertions(+), 40 deletions(-) create mode 100644 .changeset/wb600-pr2a-telemetry.md create mode 100644 LANE-NOTES.md diff --git a/.changeset/wb600-pr2a-telemetry.md b/.changeset/wb600-pr2a-telemetry.md new file mode 100644 index 000000000..befcbbbcb --- /dev/null +++ b/.changeset/wb600-pr2a-telemetry.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..9d186a090 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,64 @@ +# Lane A4 — P2 telemetry honesty + +## Files + +- `packages/agent-bundle/src/dev/routes/route-invocation.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` (assertion only; integration pool not run) +- `packages/workbench/src/application/invocation-client.ts` +- `packages/workbench/src/application/runtime-backend.ts` +- `packages/workbench/src/application/workspace.css` +- `packages/workbench/tests/invocation-client.test.ts` +- `packages/workbench/tests/route-workspace.test.ts` +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/zh/guide/development/workbench.mdx` +- `.changeset/wb600-pr2a-telemetry.md` + +Not edited (no decoder/view of invocation `providers`/`timings` beyond pass-through): `invocation-model.ts`, `result-tabs.tsx`. Inspector Providers/Timings already omitted absent `durationMs`; status now includes `unobserved` via the CSS class. + +## Behavior + +- Success without `child.observed`: every catalog provider is `{ id, name, status: 'unobserved' }` with no `durationMs`. Timings are only measured `render` (`child.renderDurationMs`) and `projection` (service wall time). No `handler` / `providers` / `provider:*` rows. +- Success with `child.observed`: `providers` are the observed rows exactly. Observed timings that are `handler`, `providers`, or `provider:*` are forwarded; an observed `render` is dropped so the service's `child.renderDurationMs` remains the `render` phase. +- Failure: no fabricated `failed` providers — same unobserved catalog rows. The only timing is `elapsed`: wall time from the recorded `startedAt` (before the semaphore slot) until the child/script threw. That is not render time; `render` is omitted because no document was produced. +- Workbench decoder accepts `'unobserved'` and optional `durationMs`. Providers tab shows status `unobserved` and `—` when duration is absent. Runtime-backend no longer coerces missing span durations to `0`. + +## Exported API / contract + +- `RouteInvocationProviderStatus` adds `'unobserved'`. +- `RouteInvocationProvider.durationMs` stays optional (now documented: absent = not measured). +- `RouteInvocationTiming.phase` documents `elapsed` and that zero is a measurement. +- `RouteInvocationChildResult.observed?: { providers; timings }` added (agreed A2/A4 shape). A2 may add the same field — accept the trivial conflict. +- `RouteInvocation` / `RouteInvocationProvider` are **not** exported from `src/index.ts` or another public package entry (`package.json` `exports` has no `./contracts`). `src/contracts/invocations.ts` re-exports them for the Workbench source import only. Changeset is **patch**. + +## Cross-lane requests + +- **A2** (`route-invocation-child.ts`): populate `RouteInvocationChildResult.observed` with measured provider rows and `handler`/`providers`/`provider:*` timings. When that lands, `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` currently expects the clock provider `unobserved` and timings `['render', 'projection']` — flip those assertions to the observed values. +- **A3**: none. `invoke()` ordering, `prepared`, and the constructor were left alone. + +## Open risks + +- Until A2 emits `observed`, every live Workbench run shows catalog providers as `unobserved`. That is honest, not a regression of measurement. +- `elapsed` is a new phase name on failures. The Timings tab will render it as a real bar (including `0 ms` if `now()` does not advance). +- `startedAt` for the success `render` timing is still the pre-semaphore invocation timestamp; only the duration is the child's measurement. + +## Verification + +- `pnpm build` — pass +- `npx tsc --noEmit` — pass +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass +- `pnpm lint` — pass (1389 files) +- `pnpm test:unit` — pass (275 files / 4128 tests; intended file filter ran the whole unit pool) + +Not run: `rstest.route-unit.config.ts`, `rstest.integration.config.ts` (`route-invocation-dev-server.test.ts` assertion updated but not executed), Workbench e2e. A4 gates did not require those. + +## Proposed changeset + +`.changeset/wb600-pr2a-telemetry.md` — `agent-bundle` **patch**: + +> Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) + +## Diagnostic codes + +None. A4 takes no new codes (`AB8233`–`AB8235` browser; `AB8250`–`AB8252` A2; `AB8239` A3). diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 34ef96dc5..e0d60ea26 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -114,6 +114,14 @@ export interface RouteInvocationChildResult { readonly input: JsonValue; /** Runtime-owned MCP projection, computed inside the runtime-bound child. */ readonly mcp?: JsonObject; + /** + * What the child actually measured. Absent for plain scripts and for + * failures before the child reported measurements. + */ + readonly observed?: { + readonly providers: readonly RouteInvocationProvider[]; + readonly timings: readonly RouteInvocationTiming[]; + }; readonly renderDurationMs: number; readonly result?: JsonValue; } @@ -502,20 +510,30 @@ const eventInput = ( }); }; -const providerProjection = ( - manifest: RouteManifest, - durationMs: number, - status: RouteInvocationProvider['status'], -): readonly RouteInvocationProvider[] => Object.freeze(manifest.providers.map((provider) => Object.freeze({ - durationMs, - id: provider.id, - name: provider.name, - status, -}))); - const timing = (phase: string, startedAt: Date, durationMs: number): RouteInvocationTiming => Object.freeze({ durationMs, phase, startedAt: startedAt.toISOString() }); +const isChildObservedTiming = (phase: string): boolean => + phase === 'handler' || phase === 'providers' || phase.startsWith('provider:'); + +const unobservedProviders = (manifest: RouteManifest): readonly RouteInvocationProvider[] => + Object.freeze(manifest.providers.map((provider) => Object.freeze({ + id: provider.id, + name: provider.name, + status: 'unobserved' as const, + }))); + +const invocationTimings = ( + child: RouteInvocationChildResult, + startedAt: Date, + projectionStartedAt: Date, + completedAt: Date, +): readonly RouteInvocationTiming[] => Object.freeze([ + ...(child.observed?.timings.filter((entry) => isChildObservedTiming(entry.phase)) ?? []), + timing('render', startedAt, child.renderDurationMs), + timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), +]); + const jsonObject = (value: unknown): JsonObject | undefined => { if (value === undefined) return undefined; const snapshot = snapshotStrictJsonValue(value); @@ -625,13 +643,13 @@ const failedInvocation = (input: { kind: input.route.kind as RouteInvocationKind, manifestDigest: input.manifest.digest, projection: {}, - providers: providerProjection(input.manifest, 0, 'failed'), + providers: unobservedProviders(input.manifest), routeId: input.route.id, source: input.route.source, sourceRevision: input.manifest.sourceRevision, startedAt: input.startedAt.toISOString(), status: 'failed', - timings: [timing('render', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], + timings: [timing('elapsed', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], }); }; @@ -803,20 +821,14 @@ export class RouteInvocationService { kind: route.kind as RouteInvocationKind, manifestDigest: manifest.digest, projection, - providers: providerProjection(manifest, 0, 'mounted'), + providers: child.observed?.providers ?? unobservedProviders(manifest), ...(child.result === undefined ? {} : { result: child.result }), routeId: route.id, source: route.source, sourceRevision: manifest.sourceRevision, startedAt: startedAt.toISOString(), status: 'succeeded', - timings: [ - timing('providers', startedAt, 0), - ...manifest.providers.map((provider) => timing(`provider:${provider.name}`, startedAt, 0)), - timing('handler', startedAt, 0), - timing('render', startedAt, child.renderDurationMs), - timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), - ], + timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), }); }); this.#pending.add(running); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 922e55669..9006244b5 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -48,14 +48,28 @@ export type RouteInvocationStatus = 'failed' | 'succeeded'; export interface RouteInvocationTiming { readonly durationMs: number; - /** `providers`, `handler`, `render`, `projection`, or a provider id (`provider:`). */ + /** + * A measured phase. `render` is the child's render (or plain-script run) + * duration; `projection` is host-projection time in the service; `elapsed` + * is wall time until failure when the child never produced a render + * duration. `handler`, `providers`, and `provider:` appear only when + * the child observed them. Zero is a measurement, not "unknown". + */ readonly phase: string; readonly startedAt: string; } -export type RouteInvocationProviderStatus = 'failed' | 'mounted' | 'skipped'; +/** + * Observed provider outcome. `unobserved` means the service never measured + * this provider — `durationMs` is omitted, never reported as `0`. + */ +export type RouteInvocationProviderStatus = 'failed' | 'mounted' | 'skipped' | 'unobserved'; export interface RouteInvocationProvider { + /** + * Measured mount duration in milliseconds. Absent when the phase was not + * measured (`unobserved`, or an observed row that did not record time). + */ readonly durationMs?: number; readonly id: string; readonly message?: string; diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index cba58b6aa..3a4a50f15 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -144,8 +144,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); expect(tool.invocation.providers).toEqual([ - expect.objectContaining({ name: 'clock', status: 'mounted' }), + expect.objectContaining({ name: 'clock', status: 'unobserved' }), ]); + expect(tool.invocation.providers[0]).not.toHaveProperty('durationMs'); + expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..8f1735890 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -12,6 +12,8 @@ import { RouteInvocationRequestError, invocationSummary, parseRouteInvocationRequest, + type RouteInvocationChildResult, + type RouteInvocationServiceOptions, } from '../src/dev/routes/route-invocation-service.ts'; import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; @@ -306,3 +308,113 @@ it('reaps the render child and its descendants when the service closes mid-rende await rm(project.root, { force: true, recursive: true }); } }); + +const echoRoute = { + config: [], + id: 'tool:fixture/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:fixture', + source: 'src/mcp/fixture/tools/echo.tsx', +} as const; + +const clockProvider = { + id: 'provider:clock', + name: 'clock', + source: 'src/providers/clock.ts', +} as const; + +const telemetryManifest = (): RouteManifest => ({ + diagnostics: [], + digest: 'digest', + events: [], + providers: [clockProvider], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [echoRoute] }], + sourceRevision: 'revision', +}); + +const succeededChild = (observed?: RouteInvocationChildResult['observed']): RouteInvocationChildResult => ({ + document: { + root: { children: [{ kind: 'text', text: 'ok' }], kind: 'result' }, + status: 'success', + version: 1, + }, + events: [{ + document: { + root: { children: [{ kind: 'text', text: 'ok' }], kind: 'result' }, + status: 'success', + version: 1, + }, + sequence: 1, + type: 'complete', + }], + input: {}, + mcp: { content: [] }, + ...(observed === undefined ? {} : { observed }), + renderDurationMs: 12, +}); + +const telemetryService = ( + renderChild: NonNullable, +): RouteInvocationService => new RouteInvocationService({ + manifest: { manifest: telemetryManifest }, + prepared: () => ({ + manifest: { projectRoot: '/project' } as never, + targets: ['claude'], + }), + renderChild, +}); + +it('marks catalog providers unobserved when the child reports no observations', async () => { + const result = await telemetryService(async () => succeededChild()).invoke({ + input: {}, + routeId: echoRoute.id, + }); + + expect(result.status).toBe('succeeded'); + expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); + expect(result.providers[0]).not.toHaveProperty('durationMs'); + expect(result.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); + expect(result.timings[0]).toMatchObject({ durationMs: 12, phase: 'render' }); +}); + +it('forwards observed providers and timings without fabricating the rest', async () => { + const observed = { + providers: [{ durationMs: 7, id: 'provider:clock', name: 'clock', status: 'mounted' as const }], + timings: [ + { durationMs: 3, phase: 'providers', startedAt: '2026-09-05T00:00:00.000Z' }, + { durationMs: 3, phase: 'provider:clock', startedAt: '2026-09-05T00:00:00.000Z' }, + { durationMs: 9, phase: 'handler', startedAt: '2026-09-05T00:00:00.003Z' }, + { durationMs: 99, phase: 'render', startedAt: '2026-09-05T00:00:00.012Z' }, + ], + } as const; + const result = await telemetryService(async () => succeededChild(observed)).invoke({ + input: {}, + routeId: echoRoute.id, + }); + + expect(result.providers).toEqual(observed.providers); + expect(result.timings.map((entry) => entry.phase)).toEqual([ + 'providers', + 'provider:clock', + 'handler', + 'render', + 'projection', + ]); + expect(result.timings.find((entry) => entry.phase === 'handler')).toMatchObject({ durationMs: 9 }); + expect(result.timings.find((entry) => entry.phase === 'render')).toMatchObject({ durationMs: 12 }); +}); + +it('does not fabricate failed providers when the child throws', async () => { + const result = await telemetryService(async () => { + throw new Error('provider boom'); + }).invoke({ input: {}, routeId: echoRoute.id }); + + expect(result.status).toBe('failed'); + expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); + expect(result.providers[0]).not.toHaveProperty('durationMs'); + expect(result.providers.some((provider) => provider.status === 'failed')).toBe(false); + expect(result.timings.map((entry) => entry.phase)).toEqual(['elapsed']); + expect(result.timings.some((entry) => entry.phase === 'render' || entry.phase === 'handler')).toBe(false); +}); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 5d8a2ac21..03e97f47a 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -48,7 +48,7 @@ const providerSchema = z.strictObject({ id: textSchema, message: z.string().optional(), name: textSchema, - status: z.enum(['failed', 'mounted', 'skipped']), + status: z.enum(['failed', 'mounted', 'skipped', 'unobserved']), }); const cliProjectionSchema = z.strictObject({ exitCode: z.number().int(), diff --git a/packages/workbench/src/application/runtime-backend.ts b/packages/workbench/src/application/runtime-backend.ts index 5a8b36c95..bc8e525a1 100644 --- a/packages/workbench/src/application/runtime-backend.ts +++ b/packages/workbench/src/application/runtime-backend.ts @@ -196,11 +196,13 @@ const invocationForRun = ( : Object.freeze([]); const document = documentFor(events); const timings = run.status === 'succeeded' - ? Object.freeze(run.result.trace.map((span) => Object.freeze({ - durationMs: span.durationMs ?? 0, - phase: span.phase, - startedAt: span.startedAt, - }))) + ? Object.freeze(run.result.trace.flatMap((span) => span.durationMs === undefined + ? [] + : [Object.freeze({ + durationMs: span.durationMs, + phase: span.phase, + startedAt: span.startedAt, + })])) : Object.freeze([]); const result = run.status === 'succeeded' ? run.result.agentVisible : undefined; return Object.freeze({ diff --git a/packages/workbench/src/application/workspace.css b/packages/workbench/src/application/workspace.css index 20b0bc5bd..7eb1ece50 100644 --- a/packages/workbench/src/application/workspace.css +++ b/packages/workbench/src/application/workspace.css @@ -143,6 +143,7 @@ .inspector-status--mounted { color: #14682f; } .inspector-status--failed { color: #b31b23; } .inspector-status--skipped { color: #8a5300; } +.inspector-status--unobserved { color: #596372; } .inspector-diagnostics { color: #78242a; font-size: 12px; margin: 0; padding-left: 18px; } .inspector-timings { display: grid; gap: 6px; list-style: none; margin: 0; padding: 0; } .inspector-timings li { align-items: center; display: grid; gap: 10px; grid-template-columns: 120px minmax(0, 1fr) 64px; font-size: 12px; } diff --git a/packages/workbench/tests/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts index 64e32e33f..30b63023e 100644 --- a/packages/workbench/tests/invocation-client.test.ts +++ b/packages/workbench/tests/invocation-client.test.ts @@ -114,6 +114,20 @@ it('preserves coded HTTP diagnostics', async () => { }); }); +it('decodes unobserved providers without a duration', async () => { + const unobserved = { + ...invocation, + providers: Object.freeze([{ + id: 'catalog', + name: 'Catalog', + status: 'unobserved' as const, + }]), + } satisfies RouteInvocation; + const client = new InvocationClient({ foreground: foreground(() => Response.json({ invocation: unobserved })) }); + + await expect(client.invoke({ routeId: invocation.routeId })).resolves.toEqual(unobserved); +}); + it('rejects malformed success payloads and unsafe invocation ids', async () => { const client = new InvocationClient({ foreground: foreground(() => Response.json({ invocation: { ...invocation, unexpected: true }, diff --git a/packages/workbench/tests/route-workspace.test.ts b/packages/workbench/tests/route-workspace.test.ts index 123b61361..e628c5ee9 100644 --- a/packages/workbench/tests/route-workspace.test.ts +++ b/packages/workbench/tests/route-workspace.test.ts @@ -312,6 +312,27 @@ describe('RouteInspector', () => { expect(raw).toContain('"manifestDigest": "digest-1"'); }); + it('renders unobserved providers without a fabricated 0 ms duration', () => { + const markup = renderToStaticMarkup(createElement(RouteInspector, { + backendKind: 'dev-server', + invocation: { + ...invocation, + providers: [{ id: 'provider:library', name: 'library', status: 'unobserved' }], + timings: [{ durationMs: 5, phase: 'render', startedAt: invocation.startedAt }], + }, + leaf: toolLeaf, + onTabChange: noop, + onToggle: noop, + open: true, + tab: 'providers', + })); + + expect(markup).toContain('inspector-status--unobserved'); + expect(markup).toContain('unobserved'); + expect(markup).not.toContain('0 ms'); + expect(markup).toContain('—'); + }); + it('derives one row per request-context axis', () => { expect(requestContextRows(invocation.context).map((entry) => entry.label)).toEqual([ 'Invocation kind', 'Operation ID', 'Surface', 'Host contract revision', 'Host', 'Session', 'Actor', 'Workspace', 'Lineage', diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index c67592a30..5f7a4010f 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -78,7 +78,9 @@ Results open on **Rendered**, the browser rendering of the production Agent Docu streamed progress or Suspense replacements. Secondary result tabs are **Structured result**, **Raw AgentDocument**, **MCP projection**, **CLI projection** when available, and **Trace**. The inspector opens only when requested and contains **Source**, **Schema**, **Context**, -**Providers**, **Execution timings**, **Projection**, and **Raw protocol**. +**Providers**, **Execution timings**, **Projection**, and **Raw protocol**. Providers and +timings show **unobserved** (and omit duration) when a phase was not measured; `0 ms` is a +measured zero, not a placeholder. The browser does not import or execute arbitrary route modules. The server runs the same RSC route path used by generated executables and sends its semantic render-event stream and final @@ -194,10 +196,18 @@ The route workspace uses one authenticated, origin-guarded foreground API: - `/api/project/events` publishes completed summaries as `route.invocation` events. The envelope carries canonical input, request context, providers, ordered render events, the -final Agent Document, structured result, projections, diagnostics, and execution timings when -available. A represented `Agent.Error` remains a rendered result; unknown routes or invocation -ids (`AB8231`), unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed -requests (`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. +final Agent Document, structured result, projections, diagnostics, and execution timings. +`providers` lists each catalog provider with the outcome the child measured (`mounted`, +`failed`, or `skipped`) and a `durationMs` only when that duration was measured. When the +child reports no observations — a plain script, or a failure before any provider ran — every +catalog provider is `unobserved` and `durationMs` is omitted; `0` is a measured zero, not +"unknown". `timings` lists only measured phases: `render` (the child's render or script-run +duration), `projection` (host-projection time in the service), and, when observed, `handler`, +`providers`, and `provider:`. A failed invocation records an `elapsed` timing for the +wall time until failure and does not invent `failed` provider rows. A represented +`Agent.Error` remains a rendered result; unknown routes or invocation ids (`AB8231`), +unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed requests +(`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index c1a062848..47d503268 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -66,7 +66,8 @@ schema 生成,并提供适用的夹具以及为该叶子保留的上次输入 结果首先打开 **Rendered**,即生产 Agent Document 及其流式进度或 Suspense 替换的浏览器渲染。次要结果 标签是 **Structured result**、**Raw AgentDocument**、**MCP projection**、可用时的 **CLI projection**, 以及 **Trace**。检查器仅在请求时打开,其中包含 **Source**、**Schema**、**Context**、**Providers**、 -**Execution timings**、**Projection** 与 **Raw protocol**。 +**Execution timings**、**Projection** 与 **Raw protocol**。未测到的阶段显示 **unobserved**(并省略 +时长);`0 ms` 是测得的零,不是占位。 浏览器不会导入或执行任意路由模块。服务器运行生成式可执行文件所用的同一条 RSC 路由路径,并把其语义 渲染事件流与最终的 Agent Document 发送给 Workbench。 @@ -164,10 +165,15 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 - `GET /api/routes/invocations/` 返回一次调用。 - `/api/project/events` 以 `route.invocation` 事件发布已完成的摘要。 -该信封在可用时携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、 -投影、诊断与执行计时。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 invocation id(`AB8231`)、 -不可用的 epoch(`AB8232`)、渲染超时或崩溃(`AB8236`)、格式错误的请求(`AB8237`)与未知 fixture id -(`AB8238`)会作为诊断报告。 +该信封携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、投影、 +诊断与执行计时。`providers` 列出子进程实际测到的结果(`mounted`、`failed` 或 `skipped`),仅在测到 +时长时带 `durationMs`。子进程未报告观测值时(普通脚本,或在任何 provider 运行之前失败),每个目录 +provider 为 `unobserved` 且省略 `durationMs`;`0` 是测得的零,不是「未知」。`timings` 只列出已测量 +阶段:`render`(子进程的渲染或普通脚本运行时长)、`projection`(服务端的宿主投影时间),以及观测到 +时的 `handler`、`providers` 与 `provider:`。失败的调用记录 `elapsed`(失败前的墙钟时间), +不会伪造 `failed` 的 provider 行。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 +invocation id(`AB8231`)、不可用的 epoch(`AB8232`)、渲染超时或崩溃(`AB8236`)、格式错误的请求 +(`AB8237`)与未知 fixture id(`AB8238`)会作为诊断报告。 各条触发条件与恢复指引见[诊断参考](../../reference/diagnostics.md)。 ## 以编程方式使用同一个会话 From a3cce288d280cdc6f3a8a117f391acbc181cbaab Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:28:59 +0000 Subject: [PATCH 02/33] fix(dev): pin route invocations to a leased epoch (AB8239) A queued Workbench invoke was labelled with the revision seen at enqueue while the child later imported source that may have changed. Acquire the published epoch inside the concurrency slot, release on every exit, and reject waiters whose catalog moved. --- LANE-NOTES.md | 75 ++++++++ docs/diagnostics.md | 1 + .../dev/routes/route-invocation-service.ts | 182 ++++++++++++------ .../agent-bundle/src/dev/workbench-server.ts | 30 ++- .../tests/route-invocation-service.test.ts | 129 +++++++++++-- .../docs/en/guide/development/workbench.mdx | 3 +- .../docs/zh/guide/development/workbench.mdx | 4 +- 7 files changed, 335 insertions(+), 89 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..f4a097096 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,75 @@ +# Lane A3 — P1-C epoch pinning + `stateRoot` + +## Files + +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/src/dev/workbench-server.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` +- `docs/diagnostics.md` +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/zh/guide/development/workbench.mdx` + +No new modules. `foreground-server.ts` / `route-manifest-routes.ts` / `project-service.ts` do not construct `RouteInvocationService`; the only production supplier is `workbench-server.ts`. Did not edit `route-invocation-child.ts` or A4's result assembly (`providerProjection`, `timings`, `failedInvocation`). + +## Behavior + +`invoke()` peeks the catalog only for 404 / request-shape checks. Inside the semaphore slot it: + +1. Calls the lease-aware `prepared` supplier (acquire the snapshotted published epoch). +2. Re-reads `manifest()`. +3. Rejects with `409 AB8239` when `digest` / `sourceRevision` moved while the request waited. +4. Executes against that leased prepared project. +5. Releases the lease in `finally` (success, `AB8239`, abort, timeout, close). + +The recorded `manifestDigest` / `sourceRevision` are taken from the inside-the-slot catalog, so they cannot describe a different revision than the one that ran. A queued request after a publish does not run new code under the old labels — it fails stale. + +## Leasing mechanism + +Production (`workbench-server.ts`): snapshot `latestPublishedPreparedProject` + `status().artifact.activeEpoch.id`, then `epochStore.acquireEpochReference(epochId)` (pins that compiled epoch; a concurrent publish cannot delete it). Return `{ project, release: () => reference.close() }`. `EPOCH_NOT_FOUND` maps to `AB8239`. + +Tests may still return a bare `RouteInvocationPreparedProject`; `bindPrepared` wraps it with a no-op `release`. + +Floor (also implemented, and what the queued-stale test proves): re-read after the slot is acquired and reject `AB8239` when the peeked identity moved. Used because a true "run the enqueue-time artifact" pin would require leasing *before* the wait, which the brief forbids. + +## Contract changes + +- `RouteInvocationPreparedProject.stateRoot: string` — `join(, '.agent-bundle', 'state')` via `routeInvocationStateRoot()`; matches `pluginRootFallbackExpression` cwd fallback + `resolvePluginRoot` / `PLUGIN_STATE_DIRECTORY`. Never the code root. +- `RouteInvocationChildRequest.stateRoot: string` — passed through in `invoke()`. +- `prepared` may return a project, a `{ project, release }` lease, or a `Promise` of either. +- New: `RouteInvocationPreparedLease`, `ROUTE_INVOCATION_STALE_REVISION_CODE` (`AB8239`), `ROUTE_INVOCATION_STALE_REVISION_MESSAGE`, `routeInvocationStateRoot`. + +These types are not exported from `src/index.ts`. + +## Cross-lane requests + +- **A2** (`route-invocation-child.ts`): read `request.stateRoot` as the session-state mount. The field is already on `RouteInvocationChildRequest` and filled by `invoke()`. Do not add it again. +- **A4**: leave the top of `invoke()`, the constructor/`prepared` contract, and the `finally` lease release alone. `manifestDigest` / `sourceRevision` already close over the inside-the-slot `manifest`. + +## Open risks + +- Child still Jiti-imports live source until A2 executes the leased epoch's compiled artifact. The lease keeps that epoch's directory from being deleted mid-run; A2 must actually load from it. +- Peek-then-wait `AB8239` is conservative: a queued request after a publish must be retried. Preferred pin-old-and-run was not used because the lease is acquired inside the slot. +- Sequential `prepared()` then `manifest()` inside the slot can still interleave with `onPublishedProject`. If they disagree, `AB8239` fires (manifest is compared to the enqueue peek). +- A4 merge: extra `try` / `finally` wraps the existing result assembly; those lines were not rewritten. + +## Verification + +- `pnpm build` — pass +- `npx tsc --noEmit` — pass +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass +- `pnpm lint` — pass (1389 files) +- `pnpm test:unit` — pass (4124 tests; includes `route-invocation-service.test.ts`) +- Integration: `route-invocation-dev-server.test.ts` (2) + `audiobook-curator.acceptance.e2e.test.ts` (1) — pass + +TraceDecay MCP/daemon were unavailable this session; exploration used the brief's named files. + +## Proposed changeset line + +`patch` — Reject a queued Workbench route invocation with 409 `AB8239` when the published revision moves before it runs, and pin in-flight invocations to a leased compiled epoch. (`#600`) + +Integrator owns the single PR changeset (A4). Do not add a second `.changeset` file from this lane. + +## Diagnostic codes + +- **`AB8239`** (new, 409): published `manifest.digest` / `sourceRevision` moved while the request waited for a concurrency slot, or the snapshotted epoch could not be leased (`EPOCH_NOT_FOUND`). +- `AB8231`, `AB8232`, `AB8236`–`AB8238` unchanged. `AB8233`–`AB8235` untouched. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..53d9b2635 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,7 @@ even when no error diagnostic was reported. | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | | `AB8231`–`AB8232`, `AB8236`–`AB8238` | Workbench route invocation service (`/api/routes/invocations`): `AB8231` unknown route or invocation id, `AB8232` no published build / invocation manifest unavailable, `AB8236` render child timed out or crashed, `AB8237` malformed invocation request, and `AB8238` unknown fixture id. (`AB8233`–`AB8235` were already assigned to browser-side decoders.) | +| `AB8239` | Workbench route invocation service (`/api/routes/invocations`): the published manifest digest or source revision moved while the request waited for a concurrency slot (409). Retry against the current revision so the recorded `manifestDigest`/`sourceRevision` cannot describe a different build than the one that ran. | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 34ef96dc5..93905425c 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -48,6 +48,13 @@ export const ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE = 'AB8232'; export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; +export const ROUTE_INVOCATION_STALE_REVISION_CODE = 'AB8239'; +export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = + 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; + +/** Writable state root generated entries mount for the npm-bin cwd fallback. */ +export const routeInvocationStateRoot = (projectRoot: string): string => + join(projectRoot, '.agent-bundle', 'state'); const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; @@ -77,9 +84,19 @@ export interface RouteInvocationPreparedProject { readonly artifact?: Readonly<{ epochId: string; target: string }>; readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; + /** + * Writable state directory generated entries mount for this project + * (`/state`, never the code root). + */ + readonly stateRoot: string; readonly targets: readonly RouteInvocationEventHost[]; } +export interface RouteInvocationPreparedLease { + readonly project: RouteInvocationPreparedProject; + readonly release: () => Promise | void; +} + export interface RouteInvocationScriptRunner { run(request: ScriptPlaygroundRunRequest): Promise; } @@ -89,7 +106,10 @@ export interface RouteInvocationServiceOptions { readonly historyLimit?: number; readonly manifest: RouteManifestRouteService; readonly now?: () => Date; - readonly prepared: () => RouteInvocationPreparedProject; + readonly prepared: () => + | RouteInvocationPreparedLease + | RouteInvocationPreparedProject + | Promise; readonly registry?: TargetRegistry; readonly renderChild?: ( request: RouteInvocationChildRequest, @@ -105,6 +125,7 @@ export interface RouteInvocationChildRequest { readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; readonly routeId: string; + readonly stateRoot: string; } export interface RouteInvocationChildResult { @@ -130,7 +151,8 @@ export class RouteInvocationRequestError extends Error { | typeof ROUTE_INVOCATION_MALFORMED_REQUEST_CODE | typeof ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE | typeof ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE - | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE; + | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_STALE_REVISION_CODE; readonly status: 400 | 404 | 409; constructor( @@ -153,6 +175,18 @@ const malformed = (): never => { ); }; +const isPreparedLease = ( + value: RouteInvocationPreparedLease | RouteInvocationPreparedProject, +): value is RouteInvocationPreparedLease => + isRecord(value) && typeof value.release === 'function' && isRecord(value.project); + +const bindPrepared = async ( + supplier: RouteInvocationServiceOptions['prepared'], +): Promise => { + const value = await supplier(); + return isPreparedLease(value) ? value : { project: value, release: () => undefined }; +}; + const boundedString = (value: unknown, maxLength = 4_096): value is string => typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); @@ -641,7 +675,7 @@ export class RouteInvocationService { readonly #manifest: RouteManifestRouteService; readonly #now: () => Date; readonly #pending = new Set>(); - readonly #prepared: () => RouteInvocationPreparedProject; + readonly #prepared: RouteInvocationServiceOptions['prepared']; readonly #registry: TargetRegistry; readonly #renderChild: NonNullable; readonly #scripts: RouteInvocationScriptRunner | undefined; @@ -679,11 +713,9 @@ export class RouteInvocationService { } async invoke(request: RouteInvocationRequest): Promise { - let manifest: RouteManifest; - let prepared: RouteInvocationPreparedProject; + let queued: RouteManifest; try { - manifest = this.#manifest.manifest(); - prepared = this.#prepared(); + queued = this.#manifest.manifest(); } catch (error) { if (error instanceof RouteInvocationRequestError) throw error; throw new RouteInvocationRequestError( @@ -692,7 +724,7 @@ export class RouteInvocationService { 409, ); } - const route = allManifestRoutes(manifest).find((candidate) => candidate.id === request.routeId); + const route = allManifestRoutes(queued).find((candidate) => candidate.id === request.routeId); if (route === undefined || !invocationKinds.has(route.kind as RouteInvocationKind)) { throw new RouteInvocationRequestError( ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, @@ -706,64 +738,89 @@ export class RouteInvocationService { ) { return malformed(); } - const fixtureId = request.event?.fixtureId; - const fixture = fixtureId === undefined - ? undefined - : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); - if (fixtureId !== undefined && fixture === undefined) { - throw new RouteInvocationRequestError( - ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE, - `Fixture ${JSON.stringify(fixtureId)} is not available for route ${JSON.stringify(route.id)}.`, - 400, - ); - } - const rawInput = request.input ?? fixture?.input ?? {}; - const input = route.kind === 'event-route' - ? eventInput(route, rawInput, request.event?.host, this.#registry) - : rawInput; const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); - const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); const running = this.#semaphore.run(async () => { - const controller = new AbortController(); - this.#controllers.add(controller); - if (this.#closed) { - controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); - } - const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); - let child: RouteInvocationChildResult; - const plainScript = plainScriptFor(prepared, route); + let release: RouteInvocationPreparedLease['release'] | undefined; try { - child = plainScript === undefined - ? await this.#renderChild({ - ...(request.args === undefined ? {} : { args: request.args }), + let manifest: RouteManifest; + let prepared: RouteInvocationPreparedProject; + try { + const leased = await bindPrepared(this.#prepared); + release = leased.release; + prepared = leased.project; + manifest = this.#manifest.manifest(); + } catch (error) { + if (error instanceof RouteInvocationRequestError) throw error; + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + 'No published build and route manifest are available.', + 409, + ); + } + if (manifest.digest !== queued.digest || manifest.sourceRevision !== queued.sourceRevision) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, + 409, + ); + } + const fixtureId = request.event?.fixtureId; + const fixture = fixtureId === undefined + ? undefined + : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); + if (fixtureId !== undefined && fixture === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE, + `Fixture ${JSON.stringify(fixtureId)} is not available for route ${JSON.stringify(route.id)}.`, + 400, + ); + } + const rawInput = request.input ?? fixture?.input ?? {}; + const input = route.kind === 'event-route' + ? eventInput(route, rawInput, request.event?.host, this.#registry) + : rawInput; + const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); + const controller = new AbortController(); + this.#controllers.add(controller); + if (this.#closed) { + controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); + } + const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); + let child: RouteInvocationChildResult; + const plainScript = plainScriptFor(prepared, route); + try { + child = plainScript === undefined + ? await this.#renderChild({ + ...(request.args === undefined ? {} : { args: request.args }), + context, + input, + manifest: prepared.manifest, + routeId: route.id, + stateRoot: prepared.stateRoot, + }, controller.signal) + : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); + } catch (error) { + const completedAt = this.#now(); + return failedInvocation({ + code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, + completedAt, context, - input, - manifest: prepared.manifest, - routeId: route.id, - }, controller.signal) - : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); - } catch (error) { - const completedAt = this.#now(); - return failedInvocation({ - code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, - completedAt, - context, - id, - manifest, - message: controller.signal.reason instanceof DOMException && controller.signal.reason.name === 'TimeoutError' - ? 'Route invocation child timed out.' - : controller.signal.aborted - ? 'Route invocation child stopped because the service closed.' - : `${plainScript === undefined ? 'Route invocation child' : 'Script run'} failed: ${error instanceof Error ? error.message : String(error)}`, - request: { ...request, input }, - route, - startedAt, - }); - } finally { - clearTimeout(timeout); - this.#controllers.delete(controller); - } + id, + manifest, + message: controller.signal.reason instanceof DOMException && controller.signal.reason.name === 'TimeoutError' + ? 'Route invocation child timed out.' + : controller.signal.aborted + ? 'Route invocation child stopped because the service closed.' + : `${plainScript === undefined ? 'Route invocation child' : 'Script run'} failed: ${error instanceof Error ? error.message : String(error)}`, + request: { ...request, input }, + route, + startedAt, + }); + } finally { + clearTimeout(timeout); + this.#controllers.delete(controller); + } const projectionStartedAt = this.#now(); const projection = invocationProjection( route, @@ -818,6 +875,9 @@ export class RouteInvocationService { timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), ], }); + } finally { + await release?.(); + } }); this.#pending.add(running); let invocation: RouteInvocation; diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index d872b557b..fe6074e80 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -11,7 +11,7 @@ import { runDevEpochContracts } from './dev-contract-runner.ts'; import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; -import { EpochStore } from './epoch-store.ts'; +import { EpochStore, EpochStoreError } from './epoch-store.ts'; import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; import { createInspectorLauncher } from './inspector-launcher.ts'; @@ -56,8 +56,11 @@ import { testManifestFromRouteGraph } from '../test/manifest.ts'; import type { RouteInvocationEventHost } from './routes/route-invocation.ts'; import { ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, RouteInvocationRequestError, RouteInvocationService, + routeInvocationStateRoot, } from './routes/route-invocation-service.ts'; import { routeManifestFor } from './routes/route-manifest.ts'; import type { RouteManifestRouteService } from './routes/route-manifest-routes.ts'; @@ -886,7 +889,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }; const routeInvocations = new RouteInvocationService({ manifest: routeManifest, - prepared: () => { + prepared: async () => { const prepared = latestPublishedPreparedProject; if (prepared === undefined || prepared.model === undefined) { throw new Error('No valid prepared project is available for route invocation.'); @@ -912,8 +915,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun const scriptTarget = prepared.model.targets .map((target) => target.name) .find((target) => registry.artifactLayout(target).scripts !== undefined); - return Object.freeze({ - ...(scriptTarget === undefined ? {} : { artifact: { epochId: artifact.activeEpoch.id, target: scriptTarget } }), + const epochId = artifact.activeEpoch.id; + const project = Object.freeze({ + ...(scriptTarget === undefined ? {} : { artifact: { epochId, target: scriptTarget } }), manifest: testManifestFromRouteGraph({ apps: prepared.model.mcpApps, configPath: prepared.configPath, @@ -934,8 +938,26 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), + stateRoot: routeInvocationStateRoot(prepared.root), targets, }); + let reference; + try { + reference = await epochStore.acquireEpochReference(epochId); + } catch (error) { + if (error instanceof EpochStoreError && error.code === 'EPOCH_NOT_FOUND') { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, + 409, + ); + } + throw error; + } + return { + project, + release: () => reference.close(), + }; }, registry, scripts: scriptPlayground, diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..a37128894 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -8,15 +8,20 @@ import { expect, it } from '@rstest/core'; import type { RouteInvocation } from '../src/dev/routes/route-invocation-result.ts'; import { InvocationRingBuffer, + ROUTE_INVOCATION_STALE_REVISION_CODE, RouteInvocationService, RouteInvocationRequestError, invocationSummary, parseRouteInvocationRequest, + routeInvocationStateRoot, + type RouteInvocationChildRequest, + type RouteInvocationChildResult, } from '../src/dev/routes/route-invocation-service.ts'; import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; import { isProcessGone } from './support/bin-process.ts'; +import { deferred } from './support/eventually.ts'; const invocation = (id: string, completedAt: string): RouteInvocation => ({ completedAt, @@ -110,44 +115,125 @@ it('retains a bounded newest-first invocation history', () => { expect(history.read('inv_two')?.id).toBe('inv_two'); }); +const echoRoute = { + config: [], + id: 'tool:fixture/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:fixture', + source: 'src/mcp/fixture/tools/echo.tsx', +} as const; + +const catalog = (digest: string, sourceRevision: string): RouteManifest => ({ + diagnostics: [], + digest, + events: [], + providers: [], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [echoRoute] }], + sourceRevision, +}); + +const childResult = (request: RouteInvocationChildRequest): RouteInvocationChildResult => ({ + document: { + root: { kind: 'text', text: 'ok' }, + status: 'success', + version: 1, + }, + events: [], + input: request.input, + mcp: {}, + renderDurationMs: 1, +}); + it('aborts and drains a running render when the service closes', async () => { - const route = { - config: [], - id: 'tool:fixture/echo', - kind: 'tool', - provenance: { kind: 'conventional' }, - serverId: 'mcp:fixture', - source: 'src/mcp/fixture/tools/echo.tsx', - } as const; + let releases = 0; + const started = deferred(); const service = new RouteInvocationService({ manifest: { - manifest: () => ({ - diagnostics: [], - digest: 'digest', - events: [], - providers: [], - scripts: [], - servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [route] }], - sourceRevision: 'revision', - }), + manifest: () => catalog('digest', 'revision'), }, prepared: () => ({ - manifest: { projectRoot: '/project' } as never, - targets: ['claude'], + project: { + manifest: { projectRoot: '/project' } as never, + stateRoot: routeInvocationStateRoot('/project'), + targets: ['claude'], + }, + release: () => { + releases += 1; + }, }), renderChild: (_request, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + started.resolve(); }), }); - const pending = service.invoke({ input: {}, routeId: route.id }); - await Promise.resolve(); + const pending = service.invoke({ input: {}, routeId: echoRoute.id }); + await started.promise; await service.close(); await expect(pending).resolves.toMatchObject({ diagnostics: [expect.objectContaining({ code: 'AB8236' })], status: 'failed', }); + expect(releases).toBe(1); +}); + +it('rejects a queued invocation when the published revision moves before the slot is acquired', async () => { + const hold = deferred(); + const firstStarted = deferred(); + let digest = 'digest-1'; + let sourceRevision = 'rev-1'; + const executed: RouteInvocationChildRequest[] = []; + let releases = 0; + const projectRoot = '/project'; + const service = new RouteInvocationService({ + concurrency: 1, + manifest: { + manifest: () => catalog(digest, sourceRevision), + }, + prepared: () => ({ + project: { + manifest: { projectRoot } as never, + stateRoot: routeInvocationStateRoot(projectRoot), + targets: ['claude'], + }, + release: () => { + releases += 1; + }, + }), + renderChild: async (request) => { + executed.push(request); + firstStarted.resolve(); + await hold.promise; + return childResult(request); + }, + }); + + const first = service.invoke({ input: { n: 1 }, routeId: echoRoute.id }); + await firstStarted.promise; + const second = service.invoke({ input: { n: 2 }, routeId: echoRoute.id }); + await Promise.resolve(); + digest = 'digest-2'; + sourceRevision = 'rev-2'; + hold.resolve(); + + const firstResult = await first; + expect(firstResult).toMatchObject({ + manifestDigest: 'digest-1', + sourceRevision: 'rev-1', + status: 'succeeded', + }); + expect(executed).toHaveLength(1); + expect(executed[0]?.stateRoot).toBe(routeInvocationStateRoot(projectRoot)); + expect(executed[0]?.stateRoot).not.toBe(projectRoot); + await expect(second).rejects.toMatchObject({ + code: ROUTE_INVOCATION_STALE_REVISION_CODE, + status: 409, + }); + expect(executed).toHaveLength(1); + expect(releases).toBe(2); }); interface LeakingRouteProject { @@ -217,6 +303,7 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise Date: Sat, 5 Sep 2026 17:30:45 +0000 Subject: [PATCH 03/33] fix(dev): rewrite only module specifiers in the route invocation child (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the Jiti block from route-invocation-child.ts into dev/routes/route-module-loader.ts (createRouteModuleLoader) and replace the whole-source .js→.tsx string substitution with a TypeScript AST walk over import, export … from, and literal dynamic import() specifiers, so a string such as {'./panel.js'} renders in the Workbench as the compiled program prints it. --- .../src/dev/routes/route-invocation-child.ts | 56 +-------- .../src/dev/routes/route-module-loader.ts | 115 +++++++++++++++++ .../tests/route-invocation-service.test.ts | 117 ++++++++++++++---- .../route-unit/route-module-loader.test.ts | 98 +++++++++++++++ 4 files changed, 305 insertions(+), 81 deletions(-) create mode 100644 packages/agent-bundle/src/dev/routes/route-module-loader.ts create mode 100644 packages/agent-bundle/tests/route-unit/route-module-loader.test.ts diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts index 85d4f8f9e..3a379a28c 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -1,9 +1,4 @@ -import { existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; - import * as AgentRuntime from '@agent-bundle/runtime'; -import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; -import * as React from 'react'; import type { JsonObject } from '../../core/strict-json.ts'; import { @@ -20,56 +15,9 @@ import type { RouteInvocationChildResponse, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { createRouteModuleLoader } from './route-module-loader.ts'; -/** - * Classic JSX runtime, as in the playground's lifecycle render child: the - * automatic runtime would import `react/jsx-runtime`, which jiti resolves - * without the child's `--conditions=react-server`, binding the client runtime - * to the server `react` and throwing inside React (#441). Compiled JSX calls - * `React.createElement` instead, on the route's own `react` import or on the - * global below for modules that do not import it. - */ -(globalThis as typeof globalThis & { React?: typeof React }).React = React; - -const jitiOptions: JitiOptions = { - fsCache: false, - interopDefault: false, - jsx: { runtime: 'classic' }, - moduleCache: false, - nativeModules: ['typescript'], - virtualModules: { - '@agent-bundle/runtime': AgentRuntime, - react: React, - }, -}; - -const relativeJsSpecifier = /(['"])(\.\.?\/[^'"\n]*)\.js\1/gu; - -/** - * Project code imports its TypeScript siblings by their emitted `.js` name - * (`moduleResolution: NodeNext`); the build resolves those through Rspack's - * `extensionAlias`. jiti only retries `.js` as `.ts`, so a `.js` specifier - * whose source is a `.tsx` component never resolves. Point it at the file on - * disk before the transform sees the module. - */ -const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => { - if (filename === undefined) return source; - const directory = dirname(filename); - return source.replace(relativeJsSpecifier, (match, quote: string, specifier: string) => { - const stem = resolve(directory, specifier); - if (existsSync(`${stem}.js`) || existsSync(`${stem}.ts`) || !existsSync(`${stem}.tsx`)) return match; - return `${quote}${specifier}.tsx${quote}`; - }); -}; - -const baseJiti = createJiti(import.meta.url, jitiOptions); -const jiti = createJiti(import.meta.url, { - ...jitiOptions, - transform: (options) => ({ code: baseJiti.transform({ ...options, source: rewriteTsxSpecifiers(options) }) }), -}); - -const load = (source: string): (() => Promise) => - async () => jiti.import(source); +const { load } = createRouteModuleLoader(); const installManifest = (request: RouteInvocationChildRequest): void => { const manifest = request.manifest; diff --git a/packages/agent-bundle/src/dev/routes/route-module-loader.ts b/packages/agent-bundle/src/dev/routes/route-module-loader.ts new file mode 100644 index 000000000..33e6ff6e8 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-module-loader.ts @@ -0,0 +1,115 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import * as AgentRuntime from '@agent-bundle/runtime'; +import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; +import * as React from 'react'; +import ts from 'typescript-5'; + +import { isRelativeSpecifier } from '../../routes/module-candidates.ts'; +import { parseModule } from '../../routes/module-scope.ts'; + +/** + * Evaluates one project module from live source: a route, layout, provider, + * or state module by absolute path, as the Workbench's unit-render mode and + * the route-unit harness see it. + */ +export interface RouteModuleLoader { + readonly load: (source: string) => () => Promise; +} + +/** + * Classic JSX runtime, as in the playground's lifecycle render child: the + * automatic runtime would import `react/jsx-runtime`, which jiti resolves + * without the child's `--conditions=react-server`, binding the client runtime + * to the server `react` and throwing inside React (#441). Compiled JSX calls + * `React.createElement` instead, on the route's own `react` import or on the + * global below for modules that do not import it. + */ +(globalThis as typeof globalThis & { React?: typeof React }).React = React; + +const jitiOptions: JitiOptions = { + fsCache: false, + interopDefault: false, + jsx: { runtime: 'classic' }, + moduleCache: false, + nativeModules: ['typescript'], + virtualModules: { + '@agent-bundle/runtime': AgentRuntime, + react: React, + }, +}; + +interface SpecifierLiteral { + readonly end: number; + readonly start: number; + readonly text: string; +} + +const specifierLiteral = (sourceFile: ts.SourceFile, expression: ts.Expression | undefined): SpecifierLiteral | undefined => + expression !== undefined && ts.isStringLiteralLike(expression) + ? { end: expression.end, start: expression.getStart(sourceFile), text: expression.text } + : undefined; + +/** + * The string literals that name modules — `import … from`, `export … from`, + * and a literal dynamic `import()` — in source order. A string literal + * anywhere else (JSX text, a prop, an expression) names no module and is + * never one of them. + */ +const moduleSpecifierLiterals = (sourceFile: ts.SourceFile): readonly SpecifierLiteral[] => { + const literals: SpecifierLiteral[] = []; + const visit = (node: ts.Node): void => { + let literal: SpecifierLiteral | undefined; + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + literal = specifierLiteral(sourceFile, node.moduleSpecifier); + } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + literal = specifierLiteral(sourceFile, node.arguments[0]); + } + if (literal !== undefined) literals.push(literal); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return literals; +}; + +/** + * Project code imports its TypeScript siblings by their emitted `.js` name + * (`moduleResolution: NodeNext`); the build resolves those through Rspack's + * `extensionAlias`. jiti only retries `.js` as `.ts`, so a `.js` specifier + * whose source is a `.tsx` component never resolves. Point each such module + * specifier at the file on disk before the transform sees the module. Only + * import/export specifiers change: `{'./panel.js'}` + * renders `./panel.js` here exactly as the compiled program does. + */ +const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => { + if (filename === undefined) return source; + const directory = dirname(filename); + const sourceFile = parseModule(filename, source) as ts.SourceFile; + let rewritten = source; + for (const literal of moduleSpecifierLiterals(sourceFile).toReversed()) { + if (!isRelativeSpecifier(literal.text) || !literal.text.endsWith('.js')) continue; + const stem = resolve(directory, literal.text.slice(0, -'.js'.length)); + if (existsSync(`${stem}.js`) || existsSync(`${stem}.ts`) || !existsSync(`${stem}.tsx`)) continue; + const quote = source[literal.start]!; + rewritten = `${rewritten.slice(0, literal.start)}${quote}${literal.text}x${quote}${rewritten.slice(literal.end)}`; + } + return rewritten; +}; + +/** + * Jiti over live project source with the framework's own `react` and + * `@agent-bundle/runtime` instances, no module cache, and the `.js`-to-`.tsx` + * module specifier rewrite. `load(source)` returns a lazy loader in the shape + * the harness registry's `*Loaders` maps take. + */ +export const createRouteModuleLoader = (): RouteModuleLoader => { + const baseJiti = createJiti(import.meta.url, jitiOptions); + const jiti = createJiti(import.meta.url, { + ...jitiOptions, + transform: (options) => ({ code: baseJiti.transform({ ...options, source: rewriteTsxSpecifiers(options) }) }), + }); + return Object.freeze({ + load: (source: string) => async () => jiti.import(source), + }); +}; diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..5b50d73c8 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -16,6 +16,7 @@ import { import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; +import { expectDocument } from '../src/test/matchers.ts'; import { isProcessGone } from './support/bin-process.ts'; const invocation = (id: string, completedAt: string): RouteInvocation => ({ @@ -150,37 +151,24 @@ it('aborts and drains a running render when the service closes', async () => { }); }); -interface LeakingRouteProject { - readonly pids: () => Promise | undefined>; +interface RouteProject { readonly root: string; readonly service: (options?: Readonly<{ timeoutMs?: number }>) => RouteInvocationService; } -/** A tool route that holds an interval and a forked descendant, and writes both pids. */ -const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-child-')); - const relativePath = 'src/mcp/fixture/tools/leak.tsx'; +/** One conventional tool route at `src/mcp/fixture/tools/.tsx`, with the sibling files it imports. */ +const routeProject = async ( + root: string, + name: string, + files: Readonly>, +): Promise => { + const relativePath = `src/mcp/fixture/tools/${name}.tsx`; const source = join(root, relativePath); - const pidsPath = join(root, 'pids.json'); await mkdir(dirname(source), { recursive: true }); - await writeFile(source, [ - "import { spawn } from 'node:child_process';", - "import { writeFileSync } from 'node:fs';", - "import { Agent } from '@agent-bundle/runtime';", - "import { createElement } from 'react';", - '', - 'export default async function Leak() {', - ' setInterval(() => {}, 60_000);', - " const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000)'], { stdio: 'ignore' });", - ` writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify({ child: process.pid, descendant: descendant.pid }));`, - ...(behaviour === 'hang' ? [' await new Promise(() => {});'] : []), - " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'leaked'));", - '}', - '', - ].join('\n')); + await Promise.all(Object.entries(files).map(([path, text]) => writeFile(join(root, path), text))); const compiled = { config: {}, - id: 'tool:fixture/leak', + id: `tool:fixture/${name}`, kind: 'tool', provenance: { kind: 'conventional', relativePath }, serverId: 'mcp:fixture', @@ -220,10 +208,6 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise { - if (!existsSync(pidsPath)) return undefined; - return JSON.parse(await readFile(pidsPath, 'utf8')) as Readonly<{ child: number; descendant: number }>; - }, root, service: (options = {}) => new RouteInvocationService({ manifest: { manifest: () => manifest }, @@ -233,6 +217,85 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise Promise | undefined>; +} + +/** A tool route that holds an interval and a forked descendant, and writes both pids. */ +const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-child-')); + const pidsPath = join(root, 'pids.json'); + const project = await routeProject(root, 'leak', { + 'src/mcp/fixture/tools/leak.tsx': [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + 'export default async function Leak() {', + ' setInterval(() => {}, 60_000);', + " const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000)'], { stdio: 'ignore' });", + ` writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify({ child: process.pid, descendant: descendant.pid }));`, + ...(behaviour === 'hang' ? [' await new Promise(() => {});'] : []), + " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'leaked'));", + '}', + '', + ].join('\n'), + }); + return { + ...project, + pids: async () => { + if (!existsSync(pidsPath)) return undefined; + return JSON.parse(await readFile(pidsPath, 'utf8')) as Readonly<{ child: number; descendant: number }>; + }, + }; +}; + +/** + * `report.tsx` imports `panel.tsx` by its emitted name and also renders the + * string `'./panel.js'`: the child must resolve the component and print the + * text exactly as the compiled program does (#600). + */ +const tsxSiblingProject = async (): Promise => routeProject( + await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-tsx-sibling-')), + 'report', + { + 'src/mcp/fixture/tools/panel.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + "export const Panel = () => createElement(Agent.Text, null, 'panel rendered');", + '', + ].join('\n'), + 'src/mcp/fixture/tools/report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + "import { Panel } from './panel.js';", + '', + 'export default async function Report() {', + " return createElement(Agent.Result, null, createElement(Panel), createElement(Agent.Text, null, './panel.js'));", + '}', + '', + ].join('\n'), + }, +); + +it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { + const project = await tsxSiblingProject(); + try { + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report' }); + + expect(invocation.status, JSON.stringify(invocation.diagnostics)).toBe('succeeded'); + expect(invocation.document).toBeDefined(); + expectDocument(invocation.document!) + .toContainText('panel rendered') + .toContainText('./panel.js'); + } finally { + await rm(project.root, { force: true, recursive: true }); + } +}); + /** A zombie has exited; only a process still scheduled counts as alive. */ const alive = (pid: number): boolean => { if (isProcessGone(pid)) return false; diff --git a/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts new file mode 100644 index 000000000..10671b202 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { createRouteModuleLoader } from '../../src/dev/routes/route-module-loader.ts'; +import { expectDocument } from '../../src/test/matchers.ts'; +import { renderRouteEvents } from '../../src/test/render.ts'; +import type { AgentRouteModule } from '../../src/test/types.ts'; + +/** + * Project code names its TypeScript siblings by their emitted `.js` name. The + * loader points a `.js` specifier whose source is a `.tsx` file at that file + * (jiti retries `.ts` on its own, and a real `.js` sibling is loaded as is), + * and touches nothing but module specifiers: `'./panel.js'` rendered as text + * stays `./panel.js`, as the compiled program prints it (#600). + */ +const files: Readonly> = { + 'count.ts': "export const count = 'from count.ts';\n", + 'label.tsx': "export const label = 'from label.tsx';\n", + 'lazy.tsx': "export const lazy = 'from lazy.tsx';\n", + 'panel.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + '', + 'export const Panel = () => panel rendered;', + '', + ].join('\n'), + 'plain.js': "export const plain = 'from plain.js';\n", + 'report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + '', + "import { Panel } from './panel.js';", + '', + "export { count } from './count.js';", + "export { label } from './label.js';", + "export { plain } from './plain.js';", + "export const lazy = () => import('./lazy.js');", + "export const mention = './panel.js';", + '', + 'export default async function Report() {', + ' return (', + ' ', + ' ', + " {'./panel.js'}", + ' ', + ' );', + '}', + '', + ].join('\n'), +}; + +interface ReportModule extends AgentRouteModule { + readonly count: string; + readonly label: string; + readonly lazy: () => Promise<{ readonly lazy: string }>; + readonly mention: string; + readonly plain: string; +} + +let root: string; +let report: ReportModule; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-module-loader-')); + await Promise.all(Object.entries(files).map(([name, text]) => writeFile(join(root, name), text))); + report = await createRouteModuleLoader().load(join(root, 'report.tsx'))(); +}); + +afterAll(async () => { + await rm(root, { force: true, recursive: true }); +}); + +it('resolves a `.js` import whose source is a `.tsx` component and renders the module', async () => { + const rendered = await renderRouteEvents(report, { + context: { providers: {} }, + routeId: 'tool:fixture/report', + }); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('panel rendered') + .toContainText('./panel.js'); +}); + +it('leaves a string literal outside a module specifier alone', () => { + expect(report.mention).toBe('./panel.js'); +}); + +it('follows `export … from` and dynamic `import()` specifiers to their `.tsx` source', async () => { + expect(report.label).toBe('from label.tsx'); + await expect(report.lazy()).resolves.toMatchObject({ lazy: 'from lazy.tsx' }); +}); + +it('loads a `.ts` sibling through jiti and a real `.js` sibling as is', () => { + expect(report.count).toBe('from count.ts'); + expect(report.plain).toBe('from plain.js'); +}); From 912f1b7d77da4bfe0cf97f9f121fbe580611b813 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:33:36 +0000 Subject: [PATCH 04/33] drop LANE-NOTES --- LANE-NOTES.md | 64 --------------------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 9d186a090..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,64 +0,0 @@ -# Lane A4 — P2 telemetry honesty - -## Files - -- `packages/agent-bundle/src/dev/routes/route-invocation.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` -- `packages/agent-bundle/tests/route-invocation-service.test.ts` -- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` (assertion only; integration pool not run) -- `packages/workbench/src/application/invocation-client.ts` -- `packages/workbench/src/application/runtime-backend.ts` -- `packages/workbench/src/application/workspace.css` -- `packages/workbench/tests/invocation-client.test.ts` -- `packages/workbench/tests/route-workspace.test.ts` -- `website/docs/en/guide/development/workbench.mdx` -- `website/docs/zh/guide/development/workbench.mdx` -- `.changeset/wb600-pr2a-telemetry.md` - -Not edited (no decoder/view of invocation `providers`/`timings` beyond pass-through): `invocation-model.ts`, `result-tabs.tsx`. Inspector Providers/Timings already omitted absent `durationMs`; status now includes `unobserved` via the CSS class. - -## Behavior - -- Success without `child.observed`: every catalog provider is `{ id, name, status: 'unobserved' }` with no `durationMs`. Timings are only measured `render` (`child.renderDurationMs`) and `projection` (service wall time). No `handler` / `providers` / `provider:*` rows. -- Success with `child.observed`: `providers` are the observed rows exactly. Observed timings that are `handler`, `providers`, or `provider:*` are forwarded; an observed `render` is dropped so the service's `child.renderDurationMs` remains the `render` phase. -- Failure: no fabricated `failed` providers — same unobserved catalog rows. The only timing is `elapsed`: wall time from the recorded `startedAt` (before the semaphore slot) until the child/script threw. That is not render time; `render` is omitted because no document was produced. -- Workbench decoder accepts `'unobserved'` and optional `durationMs`. Providers tab shows status `unobserved` and `—` when duration is absent. Runtime-backend no longer coerces missing span durations to `0`. - -## Exported API / contract - -- `RouteInvocationProviderStatus` adds `'unobserved'`. -- `RouteInvocationProvider.durationMs` stays optional (now documented: absent = not measured). -- `RouteInvocationTiming.phase` documents `elapsed` and that zero is a measurement. -- `RouteInvocationChildResult.observed?: { providers; timings }` added (agreed A2/A4 shape). A2 may add the same field — accept the trivial conflict. -- `RouteInvocation` / `RouteInvocationProvider` are **not** exported from `src/index.ts` or another public package entry (`package.json` `exports` has no `./contracts`). `src/contracts/invocations.ts` re-exports them for the Workbench source import only. Changeset is **patch**. - -## Cross-lane requests - -- **A2** (`route-invocation-child.ts`): populate `RouteInvocationChildResult.observed` with measured provider rows and `handler`/`providers`/`provider:*` timings. When that lands, `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` currently expects the clock provider `unobserved` and timings `['render', 'projection']` — flip those assertions to the observed values. -- **A3**: none. `invoke()` ordering, `prepared`, and the constructor were left alone. - -## Open risks - -- Until A2 emits `observed`, every live Workbench run shows catalog providers as `unobserved`. That is honest, not a regression of measurement. -- `elapsed` is a new phase name on failures. The Timings tab will render it as a real bar (including `0 ms` if `now()` does not advance). -- `startedAt` for the success `render` timing is still the pre-semaphore invocation timestamp; only the duration is the child's measurement. - -## Verification - -- `pnpm build` — pass -- `npx tsc --noEmit` — pass -- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass -- `pnpm lint` — pass (1389 files) -- `pnpm test:unit` — pass (275 files / 4128 tests; intended file filter ran the whole unit pool) - -Not run: `rstest.route-unit.config.ts`, `rstest.integration.config.ts` (`route-invocation-dev-server.test.ts` assertion updated but not executed), Workbench e2e. A4 gates did not require those. - -## Proposed changeset - -`.changeset/wb600-pr2a-telemetry.md` — `agent-bundle` **patch**: - -> Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) - -## Diagnostic codes - -None. A4 takes no new codes (`AB8233`–`AB8235` browser; `AB8250`–`AB8252` A2; `AB8239` A3). From 502e64f78ee80770eca346b8689a4ba470ff1771 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:35:37 +0000 Subject: [PATCH 05/33] fix merged telemetry/lease test fixtures --- .../tests/route-invocation-service.test.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index df4550663..da24c8058 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -395,15 +395,6 @@ it('reaps the render child and its descendants when the service closes mid-rende } }); -const echoRoute = { - config: [], - id: 'tool:fixture/echo', - kind: 'tool', - provenance: { kind: 'conventional' }, - serverId: 'mcp:fixture', - source: 'src/mcp/fixture/tools/echo.tsx', -} as const; - const clockProvider = { id: 'provider:clock', name: 'clock', @@ -447,6 +438,7 @@ const telemetryService = ( manifest: { manifest: telemetryManifest }, prepared: () => ({ manifest: { projectRoot: '/project' } as never, + stateRoot: routeInvocationStateRoot('/project'), targets: ['claude'], }), renderChild, From 5de5f1c2af416b74eb7dc304ba64f41a5286b528 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:11:21 +0000 Subject: [PATCH 06/33] feat: execute routes from compiled artifacts --- LANE-NOTES.md | 115 ++++ docs/diagnostics.md | 1 + .../src/adapters/hook-contract.ts | 32 +- .../agent-bundle/src/build/entry-shell.ts | 86 ++- packages/agent-bundle/src/cli-entry.ts | 52 +- .../src/dev/routes/route-invocation-child.ts | 11 +- .../dev/routes/route-invocation-production.ts | 548 ++++++++++++++++++ .../src/dev/routes/route-invocation-result.ts | 3 + .../dev/routes/route-invocation-service.ts | 50 +- .../src/dev/routes/route-invocation.ts | 2 + .../agent-bundle/src/dev/workbench-server.ts | 1 + packages/agent-bundle/src/test/manifest.ts | 4 + packages/agent-bundle/src/test/render.ts | 27 +- .../agent-bundle/tests/entry-shell.test.ts | 21 +- .../tests/route-invocation-dev-server.test.ts | 203 ++++++- .../tests/route-invocation-service.test.ts | 31 +- .../src/application/invocation-client.ts | 35 ++ .../workbench/tests/invocation-client.test.ts | 14 +- .../tests/support/workbench-acceptance.ts | 9 +- .../docs/en/guide/development/workbench.mdx | 11 +- .../docs/zh/guide/development/workbench.mdx | 8 +- 21 files changed, 1156 insertions(+), 108 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-production.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..ce102ff10 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,115 @@ +# Lane A2 — P1-B production execution boundary + +## Files + +- Added `packages/agent-bundle/src/dev/routes/route-invocation-production.ts`. +- Updated the route-invocation child/service/result contracts, HTTP request parser, prepared + project state root, and test manifest event identity. +- Shared generated CLI input preparation in `src/cli-entry.ts` and generated bins. +- Exposed compiled event preparation from generated hook wrappers. +- Added opt-in provider/handler/render observations to generated Flight workers. +- Updated route-invocation, generated-entry, Workbench decoder, and acceptance tests. +- Updated `docs/diagnostics.md` and the English/Chinese Workbench guides. + +## Behavior + +- An absent invocation `mode` now means `production`. The child imports the published epoch's + compiled modules and workers. `mode: "unit-render"` retains the live-source Jiti route-unit + renderer and its disposable state. +- CLI invocations import the compiled bin's `prepareRouteInvocation`, which delegates argv, + confirmation, defaults, `mapInput`, and schema validation to the same exported + `cli-entry.ts` helpers used by the generated bin. +- Event invocations import the compiled hook wrapper's `prepareRouteInvocation`. All wrappers + provide native-envelope validation and canonical props; preflight wrappers additionally run + the real gate and create the real `EventTracer`. `continue` and `deny` return before a render + worker is started. +- Production rendering dispatches to the epoch's generated Flight worker. The worker mounts the + generated request scope, selected providers, and generated runtime state. The child sets + `AGENT_BUNDLE_PLUGIN_ROOT` from `request.stateRoot`, so workspace-durable sqlite state persists + across invocations while volatile state keeps the generated in-memory behavior. +- Observation is opt-in on the worker message and records actual provider outcomes/durations, + aggregate provider duration, handler duration, and render duration. Event traces receive + provider/render phase boundaries in worker execution order. +- Full invocation responses may carry event trace events. Invocation summaries deliberately + omit them; the Workbench strict decoder accepts and validates the full trace field. + +## Generated-entry path parity + +- Hook wrapper: the child imports the generated hook wrapper's shared + `prepareRouteInvocation`, then dispatches to the generated MCP/hooks Flight worker. It skips + stdin byte framing, executor process spawning, signal forwarding, and stdout writing; native + response projection remains the shared `events/projection.ts` path in the invocation service. +- CLI bin: the child imports the generated bin's `prepareRouteInvocation` and dispatches to its + generated sibling Flight worker. It skips command-tree selection, terminal probing, output + formatting, and process exit-code handling after the selected route and argv are known. +- MCP server: the child dispatches through `createAgentRenderDispatcher` to the generated MCP + Flight worker and projects tools with `documentToCallToolResult`. It skips JSON-RPC transport, + MCP initialization, and SDK request framing; provider selection, request scope, state, route + module, layouts, and Flight rendering are the same compiled worker bytes. +- Rendered script: the child dispatches to the generated script `-flight.mjs` worker. It skips + the generated CLI process envelope and stdout formatting; route scope, state, layouts, and + rendering are the same worker bytes. +- There is no interim Jiti production path. Jiti remains only in explicit `unit-render` mode. + +## Exported API / contract changes + +- `RouteInvocationRequest.mode?: "production" | "unit-render"` is accepted on the HTTP wire. +- `RouteInvocationPreparedProject.stateRoot: string` and the child request's artifact, event + target, state root, and mode fields were added. +- `RouteInvocationChildResult.observed` carries measured providers and timings. +- `RouteInvocationChildResult.trace` and full `RouteInvocation.trace` carry event-kernel events. +- `parseGeneratedCliArgv`, `mapGeneratedCliInput`, and their supporting public types are exported + from `agent-bundle/cli-entry` for generated bins and the production boundary. +- Generated hook and CLI artifact modules export `prepareRouteInvocation`. + +## Cross-lane requests + +- A1: after merging `dev/routes/route-module-loader.ts`, rewire only the `unit-render` branch in + `route-invocation-child.ts` to that loader; keep production on + `route-invocation-production.ts`. +- A3: reconcile the temporary `RouteInvocationPreparedProject.stateRoot` field and + `join(prepared.root, ".agent-bundle", "state")` fill in + `src/dev/workbench-server.ts` with the epoch-lease implementation. Preserve the artifact root, + generated artifact epoch token, and state root passed to the child. +- A4: consume `child.observed` in the result assembly and remove fabricated zero-duration + provider/handler telemetry as planned. Preserve `trace` propagation, the + `invocationSummary()` trace omission, and the trace schema in + `packages/workbench/src/application/invocation-client.ts` while merging provider-status + decoder changes. + +## Open risks + +- Worker ownership is discovered from the published artifact's generated `*-flight.mjs` files; + `AB8251` is returned when no compiled worker owns the selected route. +- Production intentionally bypasses host transports after route selection. Transport-level MCP + handshake behavior, CLI formatting, and hook stdin/process behavior remain covered by their + existing generated-entry tests rather than being repeated by the Workbench invocation. +- The TraceDecay MCP and CLI daemon were unavailable during final review. The full diff was + manually deslop-reviewed; repository lint, type checks, tests, and dead-module checks passed. + +## Verification + +- PASS: `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint` +- PASS: focused unit pool (76 tests), including entry-shell, CLI projection, + route-invocation service, and Workbench invocation decoder coverage. +- PASS: `rstest.integration.config.ts packages/agent-bundle/tests/route-invocation-dev-server.test.ts` + (2 tests). +- PASS: generated CLI and hook integration files (52 tests). +- PASS: `rstest.integration.config.ts packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` + (1 browser acceptance test at the repository's 1440×900 viewport). +- PASS: `pnpm docs:site:build`. +- PASS: `git diff --check`. +- PASS: dead-module check; `route-invocation-production` has the production importer + `route-invocation-child.ts`. + +## Proposed changeset + +Patch `agent-bundle`: Execute Workbench route invocations through published generated artifacts +by default, including CLI projection, event preflight, persistent state, and measured runtime +telemetry. (#PR) + +## Diagnostic codes + +- `AB8250`: no published compiler artifact is available. +- `AB8251`: the selected route has no executable in the published artifact. +- `AB8252`: compiled CLI projection or event preparation failed. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..24ad72f8d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,7 @@ even when no error diagnostic was reported. | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | | `AB8231`–`AB8232`, `AB8236`–`AB8238` | Workbench route invocation service (`/api/routes/invocations`): `AB8231` unknown route or invocation id, `AB8232` no published build / invocation manifest unavailable, `AB8236` render child timed out or crashed, `AB8237` malformed invocation request, and `AB8238` unknown fixture id. (`AB8233`–`AB8235` were already assigned to browser-side decoders.) | +| `AB8250`–`AB8252` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, and `AB8252` compiled CLI projection or event preflight preparation failed. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`. | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 741f40f61..59e8380f8 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -668,7 +668,8 @@ const eventRouteHookWrapperSource = ( // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; const projectBindings = [ - ...(standalone ? ['createCanonicalEventProps', 'projectEventDocument'] : []), + 'createCanonicalEventProps', + ...(standalone ? ['projectEventDocument'] : []), 'validateNativeEventEnvelope', ]; return [ @@ -706,6 +707,11 @@ const eventRouteHookWrapperSource = ( "const endpointId = `${artifactEpoch}:${dirname(dirname(resolve(process.argv[1])))}`;", '', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', + 'export const prepareRouteInvocation = (nativeInput, signal) => {', + ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', + ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ' return Object.freeze({ gate: "execute", native, props, runtime: runtimeMode });', + '};', ...(standalone ? [ // The wrapper lives in `hooks/`, so its artifact root is the parent @@ -892,6 +898,19 @@ const eventRoutePreflightWrapperSource = ( `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, `const executor = fileURLToPath(new URL(/* webpackIgnore: true */ ${JSON.stringify(`./${executorFile}`)}, import.meta.url));`, 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', + 'export const prepareRouteInvocation = async (nativeInput, signal, observer) => {', + ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', + ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ' const trace = createEventTracer({ execution: eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent }), ...(observer === undefined ? {} : { observer }) });', + ' const gate = await executeEventPreflight(preflight, {', + ' canonical: props.canonical,', + ' host: { name: target, nativeEvent },', + ' signal,', + ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', + ' }, trace);', + ' const projected = gate === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' return Object.freeze({ gate, native, projected, props, runtime: runtimeMode, trace });', + '};', 'const runExecutor = (input, signal) => new Promise((resolve, reject) => {', ' const child = spawn(process.execPath, [executor], { signal, stdio: ["pipe", "pipe", "pipe"] });', ' const stdout = [];', @@ -919,19 +938,10 @@ const eventRoutePreflightWrapperSource = ( ' const input = Buffer.concat(chunks);', ' let parsed;', ' try { parsed = JSON.parse(input.toString("utf8")); } catch { fail("stdin must contain exactly one JSON value"); }', - ' const native = validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target });', ' const controller = new AbortController();', ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', - ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', - ' const trace = createEventTracer({ execution: eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent }) });', - ' const gate = await executeEventPreflight(preflight, {', - ' canonical: props.canonical,', - ' host: { name: target, nativeEvent },', - ' signal,', - ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', - ' }, trace);', + ' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal);', ' if (gate !== "execute") {', - ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', ' return;', ' }', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index f2d513adb..087ccfea5 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -419,7 +419,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): // module-level `process.env` read sees the composed environment. The // npm package bin runs from the operator's own shell and reads none. ...(stateFallback === 'artifact' ? [operatorEnvLayerImport] : []), - `import { CliInputError, cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + `import { mapGeneratedCliInput, parseGeneratedCliArgv, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, ...(options.web === undefined ? [] : [ @@ -456,26 +456,13 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): '', `const commands = Object.freeze(${stableJson(options.commands)});`, '', - 'const parseInput = (command, route, input) => {', - ' let mapped = { ...input };', - ' if (command.projection?.defaults !== undefined) {', - ' for (const [key, value] of Object.entries(command.projection.defaults)) {', - ' if (!Object.hasOwn(mapped, key)) mapped[key] = value;', - ' }', - ' }', - ' if (command.projection?.mapInput === true) {', - " if (typeof route.projection?.mapInput !== 'function') throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`);", - ' try {', - ' mapped = route.projection.mapInput(mapped);', - ' } catch (error) {', - ' throw new CliInputError(error instanceof Error ? error.message : String(error));', - ' }', - ' }', - ' try {', - ' return route.module.inputSchema.parse(mapped);', - ' } catch (error) {', - ' throw cliInputError(command, mapped, error);', - ' }', + 'const parseInput = (command, route, input) => mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input);', + 'export const prepareRouteInvocation = (routeId, argv) => {', + ' const command = commands.find((candidate) => candidate.routeId === routeId);', + " if (command === undefined) throw new TypeError(`Generated CLI route ${JSON.stringify(routeId)} is not available.`);", + ' const route = routes[routeId];', + " if (route === undefined) throw new TypeError(`Generated CLI route ${JSON.stringify(routeId)} has no compiled module.`);", + ' return parseInput(command, route, parseGeneratedCliArgv(command, argv).input);', '};', '', // Plain commands mount the same conventional providers as every other @@ -543,6 +530,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): '', ] : []), + 'if (import.meta.main) {', ...(options.state === undefined ? [] : ['try {']), `${options.state === undefined ? '' : ' '}await runGeneratedCliProcess({`, ' commands,', @@ -568,6 +556,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): ...(options.state === undefined ? [] : ['} finally {', ' await runtimeState.close();', '}']), + '}', '', ].join('\n'); }; @@ -695,6 +684,10 @@ export const generatedRenderedRouteWorkerSource = ( 'const render = async (message) => {', ' const route = routes[message.routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated rendered route must default-export an async function component.');", + ' const observedRoute = message.observe !== true ? route : { ...route, module: { ...route.module, default: async (props) => {', + ' const handlerStartedAt = performance.now();', + " try { return await route.module.default(props); } finally { parentPort.postMessage({ durationMs: performance.now() - handlerStartedAt, id: message.id, type: 'observed-handler' }); }", + ' } } };', ' const controller = new AbortController();', ' requests.set(message.id, controller);', ...processHitSource(' '), @@ -717,7 +710,7 @@ export const generatedRenderedRouteWorkerSource = ( ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' plugin: pluginRoot.identity,', " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", - ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation' }), + ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation', observe: 'message.observe === true' }), ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), // The executable probed its terminal once and forwards the value; a worker @@ -725,7 +718,9 @@ export const generatedRenderedRouteWorkerSource = ( " terminal: message.terminal === undefined ? unavailable('not-provided') : available(message.terminal, 'native'),", " workspace: available({ root: cwd }, 'derived'),", ' }, async () => {', - ' const flight = renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal), { signal: controller.signal });', + " if (message.observe === true) parentPort.postMessage({ id: message.id, type: 'observed-render-start' });", + ' const renderStartedAt = performance.now();', + ' const flight = renderAgentFlight(composeLayouts(observedRoute, { ...message.props, signal: controller.signal }, controller.signal), { signal: controller.signal });', ' const reader = flight.getReader();', ' while (true) {', ' const next = await reader.read();', @@ -733,6 +728,7 @@ export const generatedRenderedRouteWorkerSource = ( ' const bytes = next.value;', " parentPort.postMessage({ bytes, id: message.id, type: 'chunk' }, [bytes.buffer]);", ' }', + " if (message.observe === true) parentPort.postMessage({ durationMs: performance.now() - renderStartedAt, id: message.id, type: 'observed-render-finish' });", ' });', ...(options.state === undefined ? [] @@ -1010,24 +1006,51 @@ const providersFieldSource = ( expressions: { readonly indent: string; readonly invocation: string; + readonly observe?: string; readonly providers?: string; }, ): readonly string[] => { - const { indent, invocation, providers: providerExpression = 'providers' } = expressions; - if (providers.length === 0) return [`${indent}providers: { processLifetime: ${processLifetimeValueSource} },`]; + const { indent, invocation, observe, providers: providerExpression = 'providers' } = expressions; + if (providers.length === 0) { + if (observe === undefined) return [`${indent}providers: { processLifetime: ${processLifetimeValueSource} },`]; + return [ + `${indent}providers: async () => {`, + `${indent} const providersStartedAt = performance.now();`, + `${indent} if (${observe}) parentPort.postMessage({ id: message.id, type: 'observed-providers-start' });`, + `${indent} if (${observe}) parentPort.postMessage({ count: 0, durationMs: performance.now() - providersStartedAt, id: message.id, type: 'observed-providers-finish' });`, + `${indent} return { processLifetime: ${processLifetimeValueSource} };`, + `${indent}},`, + ]; + } return [ `${indent}providers: async (request) => {`, `${indent} const providerValues = { processLifetime: ${processLifetimeValueSource} };`, + ...(observe === undefined + ? [] + : [ + `${indent} const providersStartedAt = performance.now();`, + `${indent} if (${observe}) parentPort.postMessage({ id: message.id, type: 'observed-providers-start' });`, + ]), `${indent} for (const provider of ${providerExpression}) {`, + ...(observe === undefined ? [] : [`${indent} const providerStartedAt = performance.now();`]), `${indent} if (typeof provider.module.default !== 'function') {`, `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, `${indent} }`, `${indent} try {`, `${indent} providerValues[provider.key] = await provider.module.default({ ...request, invocation: ${invocation} });`, + ...(observe === undefined + ? [] + : [`${indent} if (${observe}) parentPort.postMessage({ durationMs: performance.now() - providerStartedAt, id: message.id, key: provider.key, source: provider.source, status: 'mounted', type: 'observed-provider' });`]), `${indent} } catch (error) {`, + ...(observe === undefined + ? [] + : [`${indent} if (${observe}) parentPort.postMessage({ durationMs: performance.now() - providerStartedAt, id: message.id, key: provider.key, message: error instanceof Error ? error.message : String(error), source: provider.source, status: 'failed', type: 'observed-provider' });`]), `${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`, `${indent} }`, `${indent} }`, + ...(observe === undefined + ? [] + : [`${indent} if (${observe}) parentPort.postMessage({ count: Object.keys(providerValues).length - 1, durationMs: performance.now() - providersStartedAt, id: message.id, type: 'observed-providers-finish' });`]), `${indent} return providerValues;`, `${indent}},`, ]; @@ -1100,6 +1123,10 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " const routeId = message.invocation.kind === 'event' ? `hook:event-route:${message.invocation.props.event.replace('/', '-')}` : message.invocation.props.operationId;", ' const route = routes[routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated route must default-export an async Server Component.');", + ' const observedRoute = message.observe !== true ? route : { ...route, module: { ...route.module, default: async (props) => {', + ' const handlerStartedAt = performance.now();', + " try { return await route.module.default(props); } finally { parentPort.postMessage({ durationMs: performance.now() - handlerStartedAt, id: message.id, type: 'observed-handler' }); }", + ' } } };', ' const controller = new AbortController();', ' requests.set(message.id, controller);', ...processHitSource(' '), @@ -1119,6 +1146,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation', + observe: 'message.observe === true', ...(hasProviderSelections ? { providers: 'route.providers ?? providers' } : {}), }), ' ...(message.session === undefined ? {} : { session: message.session }),', @@ -1132,8 +1160,12 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " const props = message.invocation.kind === 'event'", ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', ' : { input: message.invocation.props.input, signal: controller.signal };', - ' const flight = renderAgentFlight(composeLayouts(route, props, controller.signal), { signal: controller.signal });', - ' return new Uint8Array(await new Response(flight).arrayBuffer());', + " if (message.observe === true) parentPort.postMessage({ id: message.id, type: 'observed-render-start' });", + ' const renderStartedAt = performance.now();', + ' const flight = renderAgentFlight(composeLayouts(observedRoute, props, controller.signal), { signal: controller.signal });', + ' const renderedBytes = new Uint8Array(await new Response(flight).arrayBuffer());', + " if (message.observe === true) parentPort.postMessage({ durationMs: performance.now() - renderStartedAt, id: message.id, type: 'observed-render-finish' });", + ' return renderedBytes;', ' });', ' parentPort.postMessage({ bytes, id: message.id, type: \'complete\' }, [bytes.buffer]);', ...(options.state === undefined diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index 3ce16b54f..ce0c6bc05 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -514,7 +514,7 @@ const treeHelp = ( return `${lines.join('\n')}\n`; }; -interface ParsedArgv { +export interface ParsedGeneratedCliArgv { readonly input: Readonly>; readonly json: boolean; readonly ndjson: boolean; @@ -573,7 +573,7 @@ const coercePositional = (option: CompiledCliOption, value: string): unknown => }; /** Parses one resolved command's remaining argv against its compiled option surface. */ -const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): ParsedArgv => { +const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): ParsedGeneratedCliArgv => { const options = new Map(); for (const option of namedOptions(command)) { options.set(option.option, option); @@ -673,8 +673,8 @@ const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): const parseMcpCommandInput = ( command: CompiledCliCommand, - parsed: ParsedArgv, -): ParsedArgv => { + parsed: ParsedGeneratedCliArgv, +): ParsedGeneratedCliArgv => { if (command.mcp === undefined) return parsed; if (command.mcp.confirm && parsed.input['yes'] !== true) { throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); @@ -700,6 +700,46 @@ const parseMcpCommandInput = ( return { ...parsed, input: input as Readonly> }; }; +/** Parses argv and applies projected-tool confirmation exactly as the generated CLI shell does. */ +export const parseGeneratedCliArgv = ( + command: CompiledCliCommand, + argv: readonly string[], +): ParsedGeneratedCliArgv => parseMcpCommandInput(command, parseCommandArgv(command, argv)); + +export interface GeneratedCliInputSchema { + parse(input: unknown): unknown; +} + +/** Applies projection defaults, `mapInput`, and the route schema at the generated CLI boundary. */ +export const mapGeneratedCliInput = ( + command: CompiledCliCommand, + inputSchema: GeneratedCliInputSchema, + projectionModule: Readonly> | undefined, + input: Readonly>, +): unknown => { + const withDefaults: Record = { ...input }; + for (const [key, value] of Object.entries(command.projection?.defaults ?? {})) { + if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; + } + let mapped: unknown = withDefaults; + if (command.projection?.mapInput === true) { + const mapInput = projectionModule?.['mapInput']; + if (typeof mapInput !== 'function') { + throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); + } + try { + mapped = mapInput(withDefaults); + } catch (error) { + throw new CliInputError(error instanceof Error ? error.message : String(error)); + } + } + try { + return inputSchema.parse(mapped); + } catch (error) { + throw cliInputError(command, mapped, error); + } +}; + const resultExitCode = (policy: 'result' | 'zero', result: unknown): number => { if (policy === 'zero') return 0; const exitCode = typeof result === 'object' && result !== null @@ -913,7 +953,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro let node = tree; let index = 0; - let parsed: ParsedArgv | undefined; + let parsed: ParsedGeneratedCliArgv | undefined; const web = options.web !== undefined; try { if (options.argv[0] === '--version') { @@ -960,7 +1000,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro writeOut(commandHelp(options.name, command)); return 0; } - parsed = parseMcpCommandInput(command, parseCommandArgv(command, rest)); + parsed = parseGeneratedCliArgv(command, rest); signal.throwIfAborted(); // Probed once: the same value selects the output mode and reaches the // route as `request.terminal`, so the two can never disagree. diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts index 85d4f8f9e..8367fddb7 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -20,6 +20,7 @@ import type { RouteInvocationChildResponse, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { renderProductionRoute } from './route-invocation-production.ts'; /** * Classic JSX runtime, as in the playground's lifecycle render child: the @@ -105,7 +106,7 @@ const respond = (response: RouteInvocationChildResponse): Promise => new P }); }); -const render = async (request: RouteInvocationChildRequest): Promise => { +const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise => { installManifest(request); const startedAt = performance.now(); const input = request.input; @@ -138,11 +139,19 @@ const render = async (request: RouteInvocationChildRequest): Promise => + request.mode === 'unit-render' + ? renderUnitRoute(request) + : renderProductionRoute(request); + process.once('message', (request: RouteInvocationChildRequest) => { void render(request) .then((result) => respond({ result, type: 'result' })) .catch((error: unknown) => respond({ error: { + ...(typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ? { code: error.code } + : {}), message: error instanceof Error ? error.message : String(error), name: error instanceof Error ? error.name : 'Error', }, diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts new file mode 100644 index 000000000..fee2bda41 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -0,0 +1,548 @@ +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { + AGENT_DOCUMENT_VERSION, + createAgentDocument, + createAgentRenderDispatcher, + documentToCallToolResult, + type AgentDocument, + type AgentRenderEvent, + type AgentRenderInvocation, +} from '@agent-bundle/runtime'; + +import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; +import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; +import { + ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + type RouteInvocationChildRequest, + type RouteInvocationChildResult, +} from './route-invocation-service.ts'; +import type { RouteInvocationProvider, RouteInvocationTiming } from './route-invocation.ts'; + +interface CompiledCliInvocationModule { + prepareRouteInvocation(routeId: string, argv: readonly string[]): unknown; +} + +interface CompiledEventPreflight { + readonly gate: 'execute' | Readonly<{ readonly outcome: 'continue' | 'deny'; readonly reason?: string }>; + readonly native: JsonObject; + readonly projected?: JsonObject; + readonly props: Readonly<{ readonly canonical: JsonObject }>; + readonly runtime: 'shared' | 'standalone'; + readonly trace?: EventTracer; +} + +interface CompiledEventWrapperModule { + prepareRouteInvocation?( + native: JsonObject, + signal: AbortSignal, + observer: EventTraceObserver, + ): Promise; +} + +interface WorkerMessage { + readonly bytes?: Uint8Array; + readonly count?: number; + readonly durationMs?: number; + readonly id: number; + readonly key?: string; + readonly message?: string; + readonly source?: string; + readonly status?: 'failed' | 'mounted'; + readonly type: + | 'chunk' + | 'complete' + | 'end' + | 'error' + | 'observed-handler' + | 'observed-provider' + | 'observed-providers-finish' + | 'observed-providers-start' + | 'observed-render-finish' + | 'observed-render-start' + | 'progress'; + readonly update?: unknown; +} + +type ProductionRequest = RouteInvocationChildRequest & Readonly<{ + readonly artifactEpoch: string; + readonly artifactRoot: string; +}>; + +type ProductionRouteInvocationCode = + | typeof ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; + +class ProductionRouteInvocationError extends Error { + readonly code: ProductionRouteInvocationCode; + + constructor(code: ProductionRouteInvocationCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ProductionRouteInvocationError'; + this.code = code; + } +} + +const preparationFailure = (error: unknown): ProductionRouteInvocationError => + error instanceof ProductionRouteInvocationError + ? error + : new ProductionRouteInvocationError( + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + `Unable to prepare the compiled route invocation: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + +const importedModule = async (path: string): Promise => + // Artifact modules are runtime-selected compiler output; a static import cannot name the active epoch. + import(pathToFileURL(path).href) as Promise; + +const completeDocument = (value: JsonValue | undefined): AgentDocument => createAgentDocument({ + root: { + children: value === undefined ? [] : [{ kind: 'json', value }], + kind: 'result', + }, + status: 'success', + ...(value === undefined ? {} : { value }), + version: AGENT_DOCUMENT_VERSION, +}); + +const workerFiles = async (root: string): Promise => { + if (!existsSync(root)) return Object.freeze([]); + return Object.freeze((await readdir(root)) + .filter((name) => name.endsWith('-flight.mjs')) + .sort() + .map((name) => join(root, name))); +}; + +const eventWrapperPath = ( + request: ProductionRequest, +): string | undefined => { + const event = request.manifest.routes[request.routeId]?.event; + const target = request.eventTarget; + if (event === undefined || target === undefined) return undefined; + const stem = `event-route-${event.replace('/', '-')}`; + const suffixed = join(request.artifactRoot, 'hooks', `${stem}.${target}.mjs`); + if (existsSync(suffixed)) return suffixed; + const plain = join(request.artifactRoot, 'hooks', `${stem}.mjs`); + return existsSync(plain) ? plain : undefined; +}; + +const prepareInput = async ( + request: ProductionRequest, + traceEvents: EventTraceEvent[], + signal: AbortSignal, +): Promise> => { + const route = request.manifest.routes[request.routeId]; + const cliCommand = request.args === undefined + ? undefined + : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); + if (route?.kind === 'cli' || cliCommand !== undefined) { + const binRoot = join(request.artifactRoot, 'bin'); + if (!existsSync(binRoot)) { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `Compiled CLI route ${JSON.stringify(request.routeId)} has no invocation entry.`, + ); + } + const bins = (await readdir(binRoot)) + .filter((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')) + .sort(); + for (const name of bins) { + const module = await importedModule>(join(binRoot, name)); + if (typeof module.prepareRouteInvocation !== 'function') continue; + return { + input: module.prepareRouteInvocation(request.routeId, request.args ?? []) as JsonValue, + }; + } + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `Compiled CLI route ${JSON.stringify(request.routeId)} has no invocation entry.`, + ); + } + if (route?.kind !== 'event-route') return { input: request.input }; + const wrapperPath = eventWrapperPath(request); + if (wrapperPath === undefined) return { input: request.input }; + const wrapper = await importedModule(wrapperPath); + if (typeof wrapper.prepareRouteInvocation !== 'function') return { input: request.input }; + const native = (request.input as { readonly native?: JsonObject }).native ?? {}; + const preflight = await wrapper.prepareRouteInvocation(native, signal, (event) => traceEvents.push(event)); + return { + input: { canonical: preflight.props.canonical, native: preflight.native }, + preflight, + }; +}; + +const invocationFor = ( + request: ProductionRequest, + input: JsonValue, +): AgentRenderInvocation => { + const route = request.manifest.routes[request.routeId]; + if (route === undefined) throw new Error(`Route ${JSON.stringify(request.routeId)} is absent from the compiled manifest.`); + const cliCommand = request.args === undefined + ? undefined + : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); + if (cliCommand !== undefined) { + return { kind: 'cli', props: { args: request.args ?? [], command: cliCommand.path.join(' ') } }; + } + switch (route.kind) { + case 'cli': { + const command = request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); + if (command === undefined) throw new Error(`CLI route ${JSON.stringify(request.routeId)} has no compiled command.`); + return { kind: 'cli', props: { args: request.args ?? [], command: command.path.join(' ') } }; + } + case 'script': { + const script = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId); + return { kind: 'script', props: { input: request.args ?? [], name: script?.name ?? request.routeId } }; + } + case 'event-route': + return { + kind: 'event', + props: { + event: route.event!, + payload: input as never, + }, + }; + case 'prompt': + case 'resource': + case 'tool': + return { kind: 'tool', props: { input: input as never, operationId: request.routeId } }; + case 'app': + throw new Error('MCP App routes are not invocable through the route execution boundary.'); + default: { + const exhaustive: never = route.kind; + throw new Error(`Unsupported route kind ${String(exhaustive)}.`); + } + } +}; + +const candidatesFor = async (request: ProductionRequest): Promise => { + const route = request.manifest.routes[request.routeId]; + if (route === undefined) return Object.freeze([]); + if ( + request.args !== undefined + && request.manifest.cliCommands.some((candidate) => candidate.routeId === request.routeId) + ) { + return workerFiles(join(request.artifactRoot, 'bin')); + } + switch (route.kind) { + case 'cli': + return workerFiles(join(request.artifactRoot, 'bin')); + case 'script': { + const name = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId)?.name; + return name === undefined + ? Object.freeze([]) + : Object.freeze([join(request.artifactRoot, 'scripts', `${name}-flight.mjs`)]); + } + case 'event-route': + return Object.freeze([ + ...await workerFiles(join(request.artifactRoot, 'mcp')), + join(request.artifactRoot, 'hooks', 'hooks-flight.mjs'), + ].filter(existsSync)); + case 'prompt': + case 'resource': + case 'tool': + return workerFiles(join(request.artifactRoot, 'mcp')); + case 'app': + return Object.freeze([]); + default: { + const exhaustive: never = route.kind; + throw new Error(`Unsupported route kind ${String(exhaustive)}.`); + } + } +}; + +const streamFromWorker = ( + workerPath: string, + request: ProductionRequest, + invocation: AgentRenderInvocation, + input: JsonValue, + signal: AbortSignal, + trace?: EventTracer, +): Readonly<{ + readonly close: () => Promise; + readonly events: ReadableStream; + readonly observed: { + readonly providers: readonly RouteInvocationProvider[]; + readonly timings: readonly RouteInvocationTiming[]; + }; +}> => { + const worker = new Worker(pathToFileURL(workerPath), { + env: { + ...process.env, + AGENT_BUNDLE_PLUGIN_ROOT: dirname(request.stateRoot), + }, + stderr: true, + stdout: true, + }); + worker.stdout?.on('data', (chunk) => process.stderr.write(chunk)); + worker.stderr?.on('data', (chunk) => process.stderr.write(chunk)); + let sequence = 0; + const providers: RouteInvocationProvider[] = []; + const timings: RouteInvocationTiming[] = []; + const pending = new Map void; + readonly controller: ReadableStreamDefaultController; + readonly dispatchSignal: AbortSignal; + }>(); + const failAll = (error: Error): void => { + for (const [id, entry] of pending) { + pending.delete(id); + entry.dispatchSignal.removeEventListener('abort', entry.abort); + entry.controller.error(error); + } + }; + worker.on('error', failAll); + worker.on('exit', (code) => { + if (pending.size > 0) failAll(new Error(`Compiled route worker exited with code ${String(code)}.`)); + }); + worker.on('message', (message: WorkerMessage) => { + const entry = pending.get(message.id); + if (entry === undefined) return; + if (message.type === 'progress') return; + if (message.type === 'observed-providers-start') { + trace?.providersStart(); + return; + } + if (message.type === 'observed-providers-finish') { + trace?.providersFinish(message.count ?? 0); + if (message.durationMs !== undefined) { + timings.push(Object.freeze({ + durationMs: message.durationMs, + phase: 'providers', + startedAt: new Date(Date.now() - message.durationMs).toISOString(), + })); + } + return; + } + if (message.type === 'observed-render-start') { + trace?.renderStart(); + return; + } + if (message.type === 'observed-provider' && message.key !== undefined && message.status !== undefined) { + const provider = request.manifest.providers?.find((candidate) => + candidate.key === message.key || candidate.relativePath === message.source); + if (provider !== undefined) { + providers.push(Object.freeze({ + ...(message.durationMs === undefined ? {} : { durationMs: message.durationMs }), + id: provider.id, + ...(message.message === undefined ? {} : { message: message.message }), + name: provider.name, + status: message.status, + })); + if (message.durationMs !== undefined) { + timings.push(Object.freeze({ + durationMs: message.durationMs, + phase: `provider:${provider.name}`, + startedAt: new Date(Date.now() - message.durationMs).toISOString(), + })); + } + } + return; + } + if ( + (message.type === 'observed-handler' || message.type === 'observed-render-finish') + && message.durationMs !== undefined + ) { + if (message.type === 'observed-render-finish') trace?.renderFinish(); + timings.push(Object.freeze({ + durationMs: message.durationMs, + phase: message.type === 'observed-handler' ? 'handler' : 'render', + startedAt: new Date(Date.now() - message.durationMs).toISOString(), + })); + return; + } + if (message.type === 'chunk' && message.bytes !== undefined) { + entry.controller.enqueue(message.bytes); + return; + } + pending.delete(message.id); + entry.dispatchSignal.removeEventListener('abort', entry.abort); + if (message.type === 'complete' && message.bytes !== undefined) { + entry.controller.enqueue(message.bytes); + entry.controller.close(); + return; + } + if (message.type === 'end') { + entry.controller.close(); + return; + } + entry.controller.error(new Error(message.message ?? 'Compiled route worker failed.')); + }); + const host = Object.freeze({ + execute: async (dispatch: Readonly<{ + readonly invocation: AgentRenderInvocation; + readonly signal: AbortSignal; + }>): Promise> => { + const id = ++sequence; + let controller!: ReadableStreamDefaultController; + const stream = new ReadableStream({ start: (opened) => { controller = opened; } }); + const abort = (): void => { + worker.postMessage({ id, type: 'cancel' }); + controller.error(new DOMException('Agent render was aborted.', 'AbortError')); + }; + pending.set(id, { abort, controller, dispatchSignal: dispatch.signal }); + dispatch.signal.addEventListener('abort', abort, { once: true }); + worker.postMessage({ + actor: request.context.actor, + artifactEpoch: request.artifactEpoch, + host: request.context.host, + id, + invocation: dispatch.invocation, + lineage: request.context.lineage, + observe: true, + props: routeProps(request, input), + request: request.context.invocation, + requestInvocation: request.context.invocation, + routeId: request.routeId, + session: request.context.session, + terminal: { reason: 'not-provided', state: 'unavailable' }, + type: 'render', + workspace: request.context.workspace, + }); + return stream; + }, + }); + const dispatcher = createAgentRenderDispatcher(host); + return Object.freeze({ + close: async () => { await worker.terminate(); }, + events: dispatcher.stream({ artifactEpoch: request.artifactEpoch, invocation, signal }), + observed: { providers, timings }, + }); +}; + +const routeProps = (request: ProductionRequest, input: JsonValue): Readonly> => { + const kind = request.manifest.routes[request.routeId]?.kind; + if (kind === 'script') return { argv: request.args ?? [] }; + return kind === 'event-route' + ? { + canonical: (input as { readonly canonical?: unknown }).canonical, + native: (input as { readonly native?: unknown }).native, + } + : { input }; +}; + +const missingRouteWorkerError = (error: unknown): boolean => + error instanceof Error + && ( + error.message.includes('Generated route must default-export') + || error.message.includes('Generated rendered route must default-export') + ); + +const renderCompiled = async ( + request: ProductionRequest, + input: JsonValue, + signal: AbortSignal, + trace?: EventTracer, +): Promise> => { + const invocation = invocationFor(request, input); + const candidates = await candidatesFor(request); + for (const workerPath of candidates) { + const startedAt = performance.now(); + const session = streamFromWorker(workerPath, request, invocation, input, signal, trace); + const events: AgentRenderEvent[] = []; + try { + const reader = session.events.getReader(); + for (;;) { + const next = await reader.read(); + if (next.done) break; + events.push(next.value); + } + const complete = events.findLast((event) => event.type === 'complete'); + if (complete === undefined) throw new Error('Compiled route render ended without a complete event.'); + return Object.freeze({ + document: complete.document, + durationMs: performance.now() - startedAt, + events: Object.freeze(events), + observed: { + providers: Object.freeze([...session.observed.providers]), + timings: Object.freeze([...session.observed.timings]), + }, + }); + } catch (error) { + if (!missingRouteWorkerError(error)) throw error; + } finally { + await session.close(); + } + } + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `No compiled worker owns route ${JSON.stringify(request.routeId)}.`, + ); +}; + +export const renderProductionRoute = async ( + request: RouteInvocationChildRequest, +): Promise => { + if (request.artifactEpoch === undefined || request.artifactRoot === undefined) { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, + 'Production route invocation requires a published artifact.', + ); + } + const productionRequest = request as ProductionRequest; + const traceEvents: EventTraceEvent[] = []; + const controller = new AbortController(); + let prepared: Awaited>; + try { + prepared = await prepareInput(productionRequest, traceEvents, controller.signal); + } catch (error) { + throw preparationFailure(error); + } + if (prepared.preflight !== undefined && prepared.preflight.gate !== 'execute') { + const value = prepared.preflight.gate as JsonValue; + return Object.freeze({ + document: completeDocument(value), + events: Object.freeze([]), + input: prepared.input, + observed: { providers: Object.freeze([]), timings: Object.freeze([]) }, + renderDurationMs: 0, + result: value, + trace: Object.freeze(traceEvents), + }); + } + if (prepared.preflight !== undefined) { + prepared.preflight.trace?.executeStart(prepared.preflight.runtime); + } + try { + const rendered = await renderCompiled( + productionRequest, + prepared.input, + controller.signal, + prepared.preflight?.trace, + ); + const result = rendered.document.value; + return Object.freeze({ + document: rendered.document, + events: rendered.events, + input: prepared.input, + ...(request.manifest.routes[request.routeId]?.kind === 'tool' + ? { mcp: documentToCallToolResult(rendered.document, { structuredContent: result }) as JsonObject } + : {}), + observed: { + providers: rendered.observed.providers, + timings: rendered.observed.timings, + }, + renderDurationMs: rendered.durationMs, + ...(result === undefined ? {} : { result }), + trace: Object.freeze(traceEvents), + }); + } catch (error) { + prepared.preflight?.trace?.failure('render', error); + throw error; + } +}; diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-result.ts b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts index 8d1386eda..b187dc489 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-result.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts @@ -2,6 +2,7 @@ import type { AgentDocument, AgentRenderEvent } from '@agent-bundle/runtime'; import type { JsonValue } from '../../core/strict-json.ts'; import type { RequestContextProvenance } from '../../contracts/request-provenance.ts'; +import type { EventTraceEvent } from '../../events/trace.ts'; import type { RouteInvocationProjection, RouteInvocationProvider, @@ -18,6 +19,8 @@ export interface RouteInvocation extends RouteInvocationSummary { readonly providers: readonly RouteInvocationProvider[]; /** The document value parsed by the route's own `resultSchema`; absent when the module exports none or rendering failed. */ readonly result?: JsonValue; + /** Event-kernel phase events emitted by a compiled preflight execution. */ + readonly trace?: readonly EventTraceEvent[]; } /** `GET /api/routes/invocations/` and `POST /api/routes/invocations`. */ diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 34ef96dc5..9279d27a9 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -28,6 +28,7 @@ import type { } from '../../contracts/request-provenance.ts'; import { createCanonicalEventProps, projectEventDocument } from '../../events/projection.ts'; import type { CanonicalAgentEvent } from '../../routes/public.ts'; +import type { EventTraceEvent } from '../../events/trace.ts'; import { taskkill, terminateProcessTree, waitForProcessTreeExit } from '../../services/process-tree.ts'; import type { AgentBundleTestManifest, TestableScriptDescriptor } from '../../test/manifest.ts'; import type { ScriptPlaygroundResult, ScriptPlaygroundRunRequest } from '../playground/script-playground-service.ts'; @@ -48,6 +49,9 @@ export const ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE = 'AB8232'; export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; +export const ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE = 'AB8250'; +export const ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE = 'AB8251'; +export const ROUTE_INVOCATION_PREPARATION_FAILURE_CODE = 'AB8252'; const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; @@ -77,6 +81,7 @@ export interface RouteInvocationPreparedProject { readonly artifact?: Readonly<{ epochId: string; target: string }>; readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; + readonly stateRoot: string; readonly targets: readonly RouteInvocationEventHost[]; } @@ -101,10 +106,15 @@ export interface RouteInvocationServiceOptions { export interface RouteInvocationChildRequest { readonly args?: readonly string[]; + readonly artifactEpoch?: string; + readonly artifactRoot?: string; readonly context: RequestContextProvenance; + readonly eventTarget?: RouteInvocationEventHost; readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; + readonly mode?: 'production' | 'unit-render'; readonly routeId: string; + readonly stateRoot: string; } export interface RouteInvocationChildResult { @@ -114,14 +124,19 @@ export interface RouteInvocationChildResult { readonly input: JsonValue; /** Runtime-owned MCP projection, computed inside the runtime-bound child. */ readonly mcp?: JsonObject; + readonly observed?: { + readonly providers: readonly RouteInvocationProvider[]; + readonly timings: readonly RouteInvocationTiming[]; + }; readonly renderDurationMs: number; readonly result?: JsonValue; + readonly trace?: readonly EventTraceEvent[]; } export type RouteInvocationChildResponse = | Readonly<{ readonly result: RouteInvocationChildResult; readonly type: 'result' }> | Readonly<{ - readonly error: Readonly<{ readonly message: string; readonly name: string }>; + readonly error: Readonly<{ readonly code?: string; readonly message: string; readonly name: string }>; readonly type: 'error'; }>; @@ -174,12 +189,14 @@ const eventOptions = (value: unknown): RouteInvocationRequest['event'] => { export const parseRouteInvocationRequest = ( value: Readonly>, ): RouteInvocationRequest => { - if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'routeId'])) return malformed(); + if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'mode', 'routeId'])) return malformed(); const routeId = value.routeId; const correlationId = value.correlationId; const args = value.args; + const mode = value.mode; if (!boundedString(routeId)) return malformed(); if (correlationId !== undefined && !boundedString(correlationId, 256)) return malformed(); + if (mode !== undefined && mode !== 'production' && mode !== 'unit-render') return malformed(); if (args !== undefined && (!Array.isArray(args) || args.length > 1_024 || args.some((argument) => !boundedString(argument, 16_384)))) { return malformed(); } @@ -197,6 +214,7 @@ export const parseRouteInvocationRequest = ( ...(correlationId === undefined ? {} : { correlationId }), ...(event === undefined ? {} : { event }), ...(input === undefined ? {} : { input }), + ...(mode === undefined ? {} : { mode }), routeId, }); }; @@ -210,6 +228,7 @@ export const invocationSummary = (invocation: RouteInvocation): RouteInvocationS projection: _projection, providers: _providers, result: _result, + trace: _trace, ...summary } = invocation; return deepFreeze(summary); @@ -439,6 +458,7 @@ const renderInChild = async ( if (message.type === 'error') { const error = new Error(message.error.message); error.name = message.error.name; + if (message.error.code !== undefined) Object.assign(error, { code: message.error.code }); return settle(() => rejectPromise(error)); } settle(() => resolvePromise(message.result)); @@ -702,7 +722,11 @@ export class RouteInvocationService { } if ( (request.event !== undefined && route.kind !== 'event-route') - || (request.args !== undefined && route.kind !== 'cli') + || ( + request.args !== undefined + && route.kind !== 'cli' + && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) + ) ) { return malformed(); } @@ -737,16 +761,33 @@ export class RouteInvocationService { child = plainScript === undefined ? await this.#renderChild({ ...(request.args === undefined ? {} : { args: request.args }), + ...(prepared.artifact === undefined + ? {} + : { + artifactEpoch: `${prepared.manifest.plugin.name}@${prepared.manifest.plugin.version}`, + artifactRoot: join(prepared.manifest.projectRoot, '.agent-bundle', 'epochs', prepared.artifact.epochId), + }), context, + ...(request.event?.host === undefined ? {} : { eventTarget: request.event.host }), input, manifest: prepared.manifest, + ...(request.mode === undefined ? {} : { mode: request.mode }), routeId: route.id, + stateRoot: prepared.stateRoot, }, controller.signal) : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { const completedAt = this.#now(); + const childCode = typeof error === 'object' && error !== null && 'code' in error + && ( + error.code === ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + || error.code === ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + || error.code === ROUTE_INVOCATION_PREPARATION_FAILURE_CODE + ) + ? error.code + : ROUTE_INVOCATION_CHILD_FAILURE_CODE; return failedInvocation({ - code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, + code: childCode, completedAt, context, id, @@ -810,6 +851,7 @@ export class RouteInvocationService { sourceRevision: manifest.sourceRevision, startedAt: startedAt.toISOString(), status: 'succeeded', + ...(child.trace === undefined ? {} : { trace: child.trace }), timings: [ timing('providers', startedAt, 0), ...manifest.providers.map((provider) => timing(`provider:${provider.name}`, startedAt, 0)), diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 922e55669..bc2e4c232 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -40,6 +40,8 @@ export interface RouteInvocationRequest { readonly event?: RouteInvocationEventOptions; /** Tool/prompt/script input, event payload (canonical or native — see `event.host`), or resource parameters. */ readonly input?: JsonValue; + /** Generated-entry parity by default; component-only rendering is an explicit fallback. */ + readonly mode?: 'production' | 'unit-render'; /** The compiled route id, for example `tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`. */ readonly routeId: string; } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index d872b557b..e989cf932 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -934,6 +934,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), + stateRoot: join(prepared.root, '.agent-bundle', 'state'), targets, }); }, diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index 17a1f1be1..f7e6f37ce 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -7,6 +7,7 @@ import { deepFreeze } from '../core/freeze.ts'; import type { NormalizedMcpApp, NormalizedScript, NormalizedStateDefinition } from '../core/types.ts'; import { orderedProviders } from '../routes/provider-execution.ts'; import { providerKeyFromName } from '../routes/providers.ts'; +import type { CanonicalAgentEvent } from '../routes/public.ts'; import type { CompiledAgentRoute, CompiledCliCommand, @@ -127,6 +128,8 @@ export const proofLevelLabel = (level: AgentTestProofLevel): string => { export interface TestableRouteDescriptor { /** The route module's statically extracted `config` export; `{}` when absent. */ readonly config: Readonly>; + /** Canonical event identity; present only for event routes. */ + readonly event?: CanonicalAgentEvent; readonly id: string; readonly kind: CompiledRouteKind; /** Project-relative POSIX path of the route module. */ @@ -300,6 +303,7 @@ export interface CompileTestManifestOptions { const descriptorOf = (route: CompiledAgentRoute): TestableRouteDescriptor => ({ config: route.config, + ...(route.event === undefined ? {} : { event: route.event }), id: route.id, kind: route.kind, relativePath: route.provenance.relativePath, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index da60c2ed5..91b159672 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -27,8 +27,7 @@ import type { import type * as React from 'react'; import { - CliInputError, - cliInputError, + mapGeneratedCliInput, } from '../cli-entry.ts'; import type { CliRenderedEvent, @@ -1096,29 +1095,7 @@ export const parseCliCommandInput = ( inputSchema: AgentRouteSchema, projectionModule: Readonly> | undefined, input: Readonly>, -): unknown => { - const withDefaults: Record = { ...input }; - for (const [key, value] of Object.entries(command.projection?.defaults ?? {})) { - if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; - } - let mapped: unknown = withDefaults; - if (command.projection?.mapInput === true) { - const mapInput = projectionModule?.['mapInput']; - if (typeof mapInput !== 'function') { - throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); - } - try { - mapped = mapInput(withDefaults); - } catch (error) { - throw new CliInputError(error instanceof Error ? error.message : String(error)); - } - } - try { - return inputSchema.parse(mapped); - } catch (error) { - throw cliInputError(command, mapped, error); - } -}; +): unknown => mapGeneratedCliInput(command, inputSchema, projectionModule, input); /** * Accepts preloaded route modules and prepares the renderer and manifest diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 396e24448..451f9569b 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -336,7 +336,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('b177c34fc9ef98e972b5f5db1296c01219634572a455796fcae30bfaf070ba72'); + .toBe('1dfd4b9822135dd555bffe3028e6a25421430e50b23a3aac9998617176ac4f6a'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -677,7 +677,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '93cdfe64b98e0add920ed3f4daa3916620a3f750ec9dbcefc6be6419efab38e5', + '77301f3cac0f896a8be450aa986d51f9bd476455f0df9fcc7565f7eee961d3a6', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -805,19 +805,14 @@ it('imports explicit CLI projections and maps their input before canonical valid expect(source).toContain( '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); - const defaults = source.indexOf('for (const [key, value] of Object.entries(command.projection.defaults))'); - const mapping = source.indexOf('mapped = route.projection.mapInput(mapped)'); - const validation = source.indexOf('return route.module.inputSchema.parse(mapped)'); expect(source).not.toContain('command.mcp?.confirm'); expect(source).not.toContain('confirmationRequiredMessage'); expect(source).not.toContain('delete mapped.yes'); - expect(defaults).toBeGreaterThan(-1); - expect(defaults).toBeLessThan(mapping); - expect(mapping).toBeLessThan(validation); - expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); + expect(source).toContain('mapGeneratedCliInput, parseGeneratedCliArgv, runGeneratedCliProcess'); + expect(source).toContain('mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input)'); + expect(source).toContain('export const prepareRouteInvocation = (routeId, argv) => {'); + expect(source).toContain('parseGeneratedCliArgv(command, argv).input'); expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); - expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`)"); - expect(source).toContain('throw new CliInputError(error instanceof Error ? error.message : String(error));'); expect(source).toContain( "invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", ); @@ -1393,7 +1388,7 @@ it('composes the root and server layout chain around generated MCP routes and ne // throwing route still rejects the Flight root exactly as it does without a layout. expect(source).toContain('let composed = await route.module.default(props);'); expect(source).toContain('if (chain.length === 0) return createElement(route.module.default, props);'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, props, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, props, controller.signal)'); }); it('imports only the layouts some route of the worker composes through, never another server\'s layout', () => { @@ -1507,7 +1502,7 @@ it('hands rendered CLI, projected MCP, and script routes their layout chain and expect(source).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([1]) })'); expect(source).toContain('"tool:curator/inspect": Object.freeze({ id: "tool:curator/inspect", kind: "tool", name: "inspect", serverId: "mcp:curator", module: route1, layouts: Object.freeze([1,0]) })'); expect(source).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route2, layouts: Object.freeze([1]) })'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, { ...message.props, signal: controller.signal }, controller.signal)'); }); it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index cba58b6aa..84ee41c2a 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; @@ -11,6 +11,7 @@ import { startDevServer } from '../src/dev/workbench-server.ts'; import { createProjectFixture } from './helpers/project-fixture.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; import { replaceWatchedSourceAndAwaitRebuild } from './support/watched-files.ts'; +import { runNodeScript } from './support/run-node-script.ts'; const readEvent = async (response: Response, type: string): Promise> => { const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); @@ -32,14 +33,21 @@ const readEvent = async (response: Response, type: string): Promise { const project = await createProjectFixture({ config: [ + "import { join } from 'node:path';", + '', 'export default {', " plugin: { name: 'route-invocation-dev-server', version: '1.0.0' },", " targets: ['claude'],", + ' tools: {', + " rsbuild: { source: { define: { __ROUTE_INVOCATION_DEFINE__: JSON.stringify('defined') } } },", + " rspack: { resolve: { alias: { '@fixture/value': join(import.meta.dirname, 'src/aliased.ts') } } },", + ' },', '};', '', ].join('\n'), files: { 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', + 'src/aliased.ts': "export const ALIAS_VALUE = 'aliased';\n", 'src/cli/greet.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -55,9 +63,11 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/events/tool/after.preflight.ts': "export default () => 'execute';\n", 'src/events/tool/after.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", + "export { default as preflight } from './after.preflight.js';", '', "export const config = { runtime: 'standalone' };", '', @@ -66,24 +76,74 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/events/prompt/submit.preflight.ts': "export default () => ({ outcome: 'continue' });\n", + 'src/events/prompt/submit.tsx': [ + "export { default as preflight } from './submit.preflight.js';", + "export const config = { runtime: 'standalone' };", + "export default async function PromptSubmit() { throw new Error('continue preflight reached handler'); }", + '', + ].join('\n'), + 'src/events/tool/before.preflight.ts': "export default () => ({ outcome: 'deny', reason: 'blocked by preflight' });\n", + 'src/events/tool/before.tsx': [ + "export { default as preflight } from './before.preflight.js';", + "export const config = { runtime: 'standalone' };", + "export default async function BeforeTool() { throw new Error('deny preflight reached handler'); }", + '', + ].join('\n'), + 'src/mcp/status/tools/counter.tsx': [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({ key: z.string() }).strict();', + 'export const resultSchema = z.object({ count: z.number() }).strict();', + 'export default async function Counter({ input }) {', + ' const context = await agent();', + " if (context.state === undefined) throw new Error('state unavailable');", + " const committed = await context.state.dispatch('incremented', { by: 1 }, { idempotencyKey: `${input.key}:${crypto.randomUUID()}` });", + ' return createElement(Agent.Result, { value: { count: committed.state.count } });', + '}', + '', + ].join('\n'), 'src/mcp/status/tools/report.tsx': [ "import { Agent } from '@agent-bundle/runtime';", + "import { ALIAS_VALUE } from '@fixture/value';", "import { createElement } from 'react';", "import { z } from 'zod';", + 'declare const __ROUTE_INVOCATION_DEFINE__: string;', '', "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", - "export const inputSchema = z.object({ service: z.string().min(1) }).strict();", - 'export const resultSchema = z.object({ service: z.string() }).strict();', + "export const inputSchema = z.object({ service: z.string().min(1), source: z.string() }).strict();", + 'export const resultSchema = z.object({ alias: z.string(), define: z.string(), service: z.string(), source: z.string() }).strict();', '', 'export default async function Report({ input }) {', - " return createElement(Agent.Result, { value: { service: input.service } }, createElement(Agent.Text, null, `Service ${input.service}`));", + ' const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, service: input.service, source: input.source };', + " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `Service ${input.service}`));", '}', '', ].join('\n'), + 'src/mcp/status/tools/report.cli.ts': [ + "export const config = { command: ['report'], confirm: false, flags: { service: { name: 'name' }, source: { required: false } } };", + "export const mapInput = (input) => ({ ...input, source: input.source ?? 'cli-projection' });", + '', + ].join('\n'), 'src/providers/clock.ts': [ 'export default () => ({ now: 0 });', '', ].join('\n'), + 'src/state.ts': [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + 'export default defineState({', + " events: { incremented: z.object({ by: z.number() }).strict() },", + " id: 'route-invocation/counter',", + ' initial: { count: 0 },', + " lifetime: 'workspace-durable',", + ' reduce: (state, event) => ({ count: state.count + event.payload.by }),', + ' schema: z.object({ count: z.number() }).strict(),', + '});', + '', + ].join('\n'), 'src/scripts/summary.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -133,16 +193,22 @@ it('invokes compiled tool and event routes through the foreground server', { tim headers: { cookie, origin: server.url }, }); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ input: { service: 'catalog' }, routeId: 'tool:status/report' }), + body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, method: 'POST', }); expect(toolResponse.status).toBe(200); const tool = await toolResponse.json() as RouteInvocationResponse; - expect(tool.invocation.status).toBe('succeeded'); + expect(tool.invocation.status, JSON.stringify(tool.invocation.diagnostics)).toBe('succeeded'); expect(tool.invocation.events.at(-1)?.type).toBe('complete'); expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); + expect(tool.invocation.result).toEqual({ + alias: 'aliased', + define: 'defined', + service: 'catalog', + source: 'api', + }); expect(tool.invocation.providers).toEqual([ expect.objectContaining({ name: 'clock', status: 'mounted' }), ]); @@ -172,9 +238,73 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(event.invocation.events.at(-1)?.type).toBe('complete'); expect(event.invocation.document).toBeDefined(); expect(event.invocation.projection.hosts?.[0]).toMatchObject({ host: 'claude' }); + expect(event.invocation.trace?.map((trace) => trace.kind)).toEqual([ + 'preflight.start', + 'preflight.outcome', + 'execute.start', + 'providers.start', + 'providers.finish', + 'render.start', + 'render.finish', + ]); + + for (const [routeId, input, expected] of [ + [ + 'event:tool/before', + { + cwd: project.root, + hook_event_name: 'PreToolUse', + permission_mode: 'default', + session_id: 'session-preflight-deny', + tool_input: { file_path: 'blocked.txt' }, + tool_name: 'Write', + tool_use_id: 'use-deny', + transcript_path: join(project.root, 'transcript.json'), + }, + { outcome: 'deny', reason: 'blocked by preflight' }, + ], + [ + 'event:prompt/submit', + { + cwd: project.root, + hook_event_name: 'UserPromptSubmit', + permission_mode: 'default', + prompt: 'continue', + session_id: 'session-preflight-continue', + transcript_path: join(project.root, 'transcript.json'), + }, + { outcome: 'continue' }, + ], + ] as const) { + const response = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ event: { host: 'claude' }, input, routeId }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + const invoked = await response.json() as RouteInvocationResponse; + expect(invoked.invocation.status, JSON.stringify(invoked.invocation.diagnostics)).toBe('succeeded'); + expect(invoked.invocation.result).toEqual(expected); + expect(invoked.invocation.events).toEqual([]); + expect(invoked.invocation.trace?.map((trace) => trace.kind)).toEqual([ + 'preflight.start', + 'preflight.outcome', + ]); + if (routeId === 'event:tool/before') { + expect(invoked.invocation.projection.hosts?.[0]?.native).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'blocked by preflight', + }, + }); + } else { + expect(invoked.invocation.projection.hosts?.[0]?.native).toBeUndefined(); + } + } const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ input: { name: 'Ada' }, routeId: 'cli:greet' }), + body: JSON.stringify({ args: ['Ada'], routeId: 'cli:greet' }), headers, method: 'POST', }); @@ -192,6 +322,53 @@ it('invokes compiled tool and event routes through the foreground server', { tim status: 'succeeded', }); + const projectedCliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ args: ['--name', 'projection'], routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(projectedCliResponse.status).toBe(200); + const projectedCli = await projectedCliResponse.json() as RouteInvocationResponse; + expect(projectedCli.invocation.result).toMatchObject({ + alias: 'aliased', + define: 'defined', + service: 'projection', + source: 'cli-projection', + }); + const activeEpoch = server.status().artifact; + if (activeEpoch.state !== 'active') throw new Error('Expected an active compiled epoch.'); + const artifactRoot = join(project.root, '.agent-bundle', 'epochs', activeEpoch.activeEpoch.id); + const binName = (await readdir(join(artifactRoot, 'bin'))) + .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); + if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); + const generatedBin = await runNodeScript({ + args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], + cwd: project.root, + env: { AGENT_BUNDLE_PLUGIN_ROOT: join(project.root, '.agent-bundle') }, + }); + expect(generatedBin.code, generatedBin.stderr).toBe(0); + expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); + + const counter = async (mode?: 'production' | 'unit-render'): Promise => { + const response = await fetch(`${server!.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { key: mode ?? 'production' }, + ...(mode === undefined ? {} : { mode }), + routeId: 'tool:status/counter', + }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + return response.json() as Promise; + }; + const firstCounter = await counter(); + const secondCounter = await counter(); + const isolatedCounter = await counter('unit-render'); + expect(firstCounter.invocation.result).toEqual({ count: 1 }); + expect(secondCounter.invocation.result).toEqual({ count: 2 }); + expect(isolatedCounter.invocation.result).toEqual({ count: 1 }); + const scriptResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ routeId: 'script:summary' }), headers, @@ -214,9 +391,9 @@ it('invokes compiled tool and event routes through the foreground server', { tim const listed = await listedResponse.json() as RouteInvocationListResponse; expect(listed.invocations.map((invocation) => invocation.id)).toEqual([ script.invocation.id, - cli.invocation.id, - event.invocation.id, - tool.invocation.id, + isolatedCounter.invocation.id, + secondCounter.invocation.id, + firstCounter.invocation.id, ]); const read = await fetch(`${server.url}/api/routes/invocations/${tool.invocation.id}`, { headers }); await expect(read.json()).resolves.toEqual(tool); @@ -286,7 +463,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim "import { z } from 'zod';", '', "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", - "export const inputSchema = z.object({ service: z.string().min(1) }).strict();", + "export const inputSchema = z.object({ service: z.string().min(1), source: z.string().optional() }).strict();", 'export const resultSchema = z.object({ service: z.string() }).strict();', '', 'export default async function Report({ input }) {', @@ -297,7 +474,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim ].join('\n'), { timeoutMs: 10_000 }, ); - expect(repairedAttempt.outcome).toBe('succeeded'); + expect(repairedAttempt.outcome, JSON.stringify(repairedAttempt.diagnostics)).toBe('succeeded'); const repairedInvocationResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'published' }, routeId: 'tool:status/report' }), headers, @@ -420,7 +597,7 @@ it('publishes invocation routes only after a successful initial or recovered bui }); expect(publishedInvocationResponse.status).toBe(200); const publishedInvocation = await publishedInvocationResponse.json() as RouteInvocationResponse; - expect(publishedInvocation.invocation).toMatchObject({ + expect(publishedInvocation.invocation, JSON.stringify(publishedInvocation.invocation.diagnostics)).toMatchObject({ result: { version: 'published' }, status: 'succeeded', }); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..609374081 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -47,6 +47,18 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ startedAt: completedAt, status: 'succeeded', timings: [], + trace: [{ + at: 0, + execution: { + event: 'tool/after', + executionId: id, + host: 'claude', + nativeEvent: 'PostToolUse', + }, + kind: 'preflight.start', + phase: 'preflight', + sequence: 0, + }], }); it('strictly validates invocation request fields and event options', () => { @@ -61,11 +73,18 @@ it('strictly validates invocation request fields and event options', () => { }); expect(parseRouteInvocationRequest({ event: { fixtureId: 'starter', host: 'claude' }, + mode: 'unit-render', routeId: 'event:tool/after', })).toEqual({ event: { fixtureId: 'starter', host: 'claude' }, + mode: 'unit-render', routeId: 'event:tool/after', }); + expect(parseRouteInvocationRequest({ + routeId: 'tool:curator/search_audible', + })).toEqual({ + routeId: 'tool:curator/search_audible', + }); for (const value of [ {}, @@ -74,6 +93,7 @@ it('strictly validates invocation request fields and event options', () => { { args: ['ok', 1], routeId: 'cli:x' }, { event: { host: 'other' }, routeId: 'event:tool/after' }, { event: { fixtureId: '' }, routeId: 'event:tool/after' }, + { mode: 'preview', routeId: 'tool:x/y' }, ]) { expect(() => parseRouteInvocationRequest(value)).toThrow(RouteInvocationRequestError); } @@ -93,6 +113,7 @@ it('projects summaries without retaining heavy invocation payloads', () => { expect(summary).not.toHaveProperty('projection'); expect(summary).not.toHaveProperty('providers'); expect(summary).not.toHaveProperty('result'); + expect(summary).not.toHaveProperty('trace'); }); it('retains a bounded newest-first invocation history', () => { @@ -133,6 +154,7 @@ it('aborts and drains a running render when the service closes', async () => { }, prepared: () => ({ manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', targets: ['claude'], }), renderChild: (_request, signal) => new Promise((_resolve, reject) => { @@ -140,7 +162,7 @@ it('aborts and drains a running render when the service closes', async () => { }), }); - const pending = service.invoke({ input: {}, routeId: route.id }); + const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: route.id }); await Promise.resolve(); await service.close(); @@ -217,6 +239,7 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise { const project = await leakingRouteProject('reply'); try { - const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); const pids = await project.pids(); expect(invocation.status).toBe('succeeded'); @@ -270,7 +293,7 @@ it('reaps the render child and its descendants when the invocation times out', { const project = await leakingRouteProject('hang'); try { const service = project.service({ timeoutMs: 8_000 }); - const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); const pids = await recordedPids(project); expect(alive(pids.child)).toBe(true); expect(alive(pids.descendant)).toBe(true); @@ -290,7 +313,7 @@ it('reaps the render child and its descendants when the service closes mid-rende const project = await leakingRouteProject('hang'); try { const service = project.service(); - const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); const pids = await recordedPids(project); expect(alive(pids.child)).toBe(true); expect(alive(pids.descendant)).toBe(true); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 5d8a2ac21..4130cea70 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -6,6 +6,7 @@ import type { RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import type { EventTraceEvent } from '../../../agent-bundle/src/events/trace.ts'; import { agentDocumentSchema, agentRenderEventSchema, @@ -71,6 +72,39 @@ const invocationEventSchema = z.strictObject({ host: z.enum(['claude', 'codex', 'cursor']).optional(), native: jsonObjectSchema.optional(), }); +const eventTraceWireSchema = z.strictObject({ + at: z.number().finite().nonnegative(), + count: z.number().int().nonnegative().optional(), + durationMs: z.number().finite().nonnegative().optional(), + error: z.strictObject({ + code: z.string().optional(), + message: z.string(), + name: textSchema, + }).optional(), + execution: z.strictObject({ + event: textSchema, + executionId: textSchema, + host: textSchema, + nativeEvent: textSchema, + }), + kind: z.enum([ + 'preflight.start', + 'preflight.outcome', + 'execute.start', + 'providers.start', + 'providers.finish', + 'render.start', + 'render.finish', + 'failure', + ]), + outcome: z.enum(['execute', 'continue', 'deny']).optional(), + phase: z.enum(['preflight', 'execute', 'providers', 'render']), + runtime: z.enum(['shared', 'standalone']).optional(), + sequence: z.number().int().nonnegative(), +}); +const eventTraceSchema = z.custom( + (value) => eventTraceWireSchema.safeParse(value).success, +); const invocationSummaryFields = { completedAt: textSchema, correlationId: textSchema.optional(), @@ -97,6 +131,7 @@ const invocationSchema: z.ZodType = z.strictObject({ projection: projectionSchema, providers: z.array(providerSchema), result: z.json().optional(), + trace: z.array(eventTraceSchema).optional(), }); const invocationResponseSchema = z.strictObject({ invocation: invocationSchema }); const invocationListResponseSchema = z.strictObject({ diff --git a/packages/workbench/tests/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts index 64e32e33f..48529bef8 100644 --- a/packages/workbench/tests/invocation-client.test.ts +++ b/packages/workbench/tests/invocation-client.test.ts @@ -59,6 +59,18 @@ const invocation = Object.freeze({ phase: 'render', startedAt: '2026-09-05T07:00:00.000Z', }]), + trace: Object.freeze([{ + at: 1, + execution: Object.freeze({ + event: 'tool/after' as const, + executionId: 'event-execution-a', + host: 'claude', + nativeEvent: 'PostToolUse', + }), + kind: 'preflight.start' as const, + phase: 'preflight' as const, + sequence: 0, + }]), }) satisfies RouteInvocation; const foreground = (handler: (path: string, init: RequestInit) => Response | Promise): ForegroundRequestAuthority => ({ @@ -70,7 +82,7 @@ it('strictly decodes invoke, list, and read responses', async () => { const client = new InvocationClient({ foreground: foreground((path, init) => { requests.push([path, init]); return Response.json(path.includes('?limit=') - ? { invocations: [{ ...invocation, context: undefined, document: undefined, events: undefined, projection: undefined, providers: undefined, result: undefined }] } + ? { invocations: [{ ...invocation, context: undefined, document: undefined, events: undefined, projection: undefined, providers: undefined, result: undefined, trace: undefined }] } : { invocation }); }) }); diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index 1222a4bc9..acafd2f31 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -242,8 +242,15 @@ export const editWatchedSource = async ( }; export const runSelectedRoute = async (page: Page, timeout = browserTimeout): Promise => { - await workbenchTestId(page, 'routeRun').click(); const status = workbenchTestId(page, 'routeStatus'); + const invocationId = status.locator('.route-status-id'); + const previousId = await invocationId.count() === 0 ? undefined : await invocationId.textContent(); + await workbenchTestId(page, 'routeRun').click(); + await expect.poll(async () => { + const className = await status.getAttribute('class'); + const currentId = await invocationId.count() === 0 ? undefined : await invocationId.textContent(); + return className?.includes('route-status--running') === true || currentId !== previousId; + }, { timeout }).toBe(true); await expect(status).toHaveClass(/route-status--succeeded/u, { timeout }); }; diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index c67592a30..9447b5b80 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -193,11 +193,20 @@ The route workspace uses one authenticated, origin-guarded foreground API: - `GET /api/routes/invocations/` returns one invocation. - `/api/project/events` publishes completed summaries as `route.invocation` events. +Requests default to `mode: "production"`. Production mode executes the selected route from the +last published compiler epoch, so compiler aliases and defines, generated-entry request scope, +providers, persistent state, CLI `mapInput` and confirmation, event preflight, and render +semantics match the installed artifact. `mode: "unit-render"` is an explicit component-preview +fallback: it loads live source with the route-unit harness and mounts disposable state. It is not +an artifact-parity receipt. + The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings when available. A represented `Agent.Error` remains a rendered result; unknown routes or invocation ids (`AB8231`), unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed -requests (`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. +requests (`AB8237`), unknown fixture ids (`AB8238`), unavailable compiled artifacts (`AB8250`), +missing compiled route executables (`AB8251`), and compiled projection or preflight failures +(`AB8252`) are reported as diagnostics. See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index c1a062848..0adc439da 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -164,10 +164,16 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 - `GET /api/routes/invocations/` 返回一次调用。 - `/api/project/events` 以 `route.invocation` 事件发布已完成的摘要。 +请求默认使用 `mode: "production"`。生产模式从最近发布的编译器 epoch 执行所选路由,因此编译器 +alias 与 define、生成入口的请求作用域、providers、持久状态、CLI `mapInput` 与确认、事件 +preflight 以及渲染语义都与已安装制品一致。`mode: "unit-render"` 是显式的组件预览后备模式: +它通过 route-unit 测试工具加载实时源码并挂载一次性状态,不能作为制品一致性的证明。 + 该信封在可用时携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、 投影、诊断与执行计时。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 invocation id(`AB8231`)、 不可用的 epoch(`AB8232`)、渲染超时或崩溃(`AB8236`)、格式错误的请求(`AB8237`)与未知 fixture id -(`AB8238`)会作为诊断报告。 +(`AB8238`)、不可用的编译制品(`AB8250`)、缺失的已编译路由可执行项(`AB8251`),以及已编译投影或 +preflight 失败(`AB8252`)会作为诊断报告。 各条触发条件与恢复指引见[诊断参考](../../reference/diagnostics.md)。 ## 以编程方式使用同一个会话 From 61c8a3236d2d066204b0765621501d49d59aeb46 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:48:09 +0000 Subject: [PATCH 07/33] integrate A2: args check after prepared lease --- .../src/dev/routes/route-invocation-service.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index a6cbe11b8..c1d317d65 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -764,16 +764,7 @@ export class RouteInvocationService { 404, ); } - if ( - (request.event !== undefined && route.kind !== 'event-route') - || ( - request.args !== undefined - && route.kind !== 'cli' - && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) - ) - ) { - return malformed(); - } + if (request.event !== undefined && route.kind !== 'event-route') return malformed(); const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); const running = this.#semaphore.run(async () => { @@ -801,6 +792,13 @@ export class RouteInvocationService { 409, ); } + if ( + request.args !== undefined + && route.kind !== 'cli' + && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) + ) { + return malformed(); + } const fixtureId = request.event?.fixtureId; const fixture = fixtureId === undefined ? undefined From 68a938e2837ec4d75d79f58f69e4fcd00ce6c01d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:51:11 +0000 Subject: [PATCH 08/33] integrate A2: trace type through the invocations contract; unit-render fixture --- packages/agent-bundle/src/contracts/invocations.ts | 1 + packages/agent-bundle/tests/route-invocation-service.test.ts | 2 +- packages/workbench/src/application/invocation-client.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts index 2140addb3..e6756ea73 100644 --- a/packages/agent-bundle/src/contracts/invocations.ts +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -24,3 +24,4 @@ export type { RouteInvocation, RouteInvocationResponse, } from '../dev/routes/route-invocation-result.ts'; +export type { EventTraceEvent } from '../events/trace.ts'; diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index c115a6717..5843ffe4c 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -393,7 +393,7 @@ const tsxSiblingProject = async (): Promise => routeProject( it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { const project = await tsxSiblingProject(); try { - const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report' }); + const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/report' }); expect(invocation.status, JSON.stringify(invocation.diagnostics)).toBe('succeeded'); expect(invocation.document).toBeDefined(); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 219de03b6..98628f006 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; import type { + EventTraceEvent, RouteInvocation, RouteInvocationRequest, RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { EventTraceEvent } from '../../../agent-bundle/src/events/trace.ts'; import { agentDocumentSchema, agentRenderEventSchema, From 4dbe4df132d701880d8819703cfa2df228a1ac57 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:56:02 +0000 Subject: [PATCH 09/33] integration test: observed provider telemetry from the compiled worker --- .../tests/route-invocation-dev-server.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index fe2d45214..2647983d3 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -210,10 +210,16 @@ it('invokes compiled tool and event routes through the foreground server', { tim source: 'api', }); expect(tool.invocation.providers).toEqual([ - expect.objectContaining({ name: 'clock', status: 'unobserved' }), + expect.objectContaining({ durationMs: expect.any(Number), id: 'provider:clock', name: 'clock', status: 'mounted' }), ]); - expect(tool.invocation.providers[0]).not.toHaveProperty('durationMs'); - expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); + expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual([ + 'provider:clock', + 'providers', + 'handler', + 'render', + 'projection', + ]); + for (const entry of tool.invocation.timings) expect(entry.durationMs).toBeGreaterThanOrEqual(0); const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ From 83b28ed631c35b5081c300f633cc415eab47a7c9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:56:02 +0000 Subject: [PATCH 10/33] changeset: PR 2a execution parity + provenance --- .changeset/wb600-pr2a-execution-parity.md | 5 +++++ .changeset/wb600-pr2a-telemetry.md | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 .changeset/wb600-pr2a-execution-parity.md delete mode 100644 .changeset/wb600-pr2a-telemetry.md diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md new file mode 100644 index 000000000..39d168fb0 --- /dev/null +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Execute Workbench route invocations (`POST /api/routes/invocations`) through the published compiled artifact by default: the generated hook wrapper's preflight gate, the generated CLI bin's `mapInput` and confirmation, compiler aliases and defines, and persistent state on the plugin state root all behave as in the installed artifact; `mode: "unit-render"` is the explicit live-source component preview. Pin each invocation to a leased compiled epoch acquired inside the concurrency slot and answer `409 AB8239` when the published revision moved while the request waited. Rewrite only real `import`/`export … from`/`import()` specifiers when pointing a `.js` import at its `.tsx` source, so a rendered string such as `'./panel.js'` is no longer altered. Stop fabricating provider and timing rows: unmeasured providers are `unobserved` without `durationMs`, `handler`/`providers`/`provider:` timings appear only when measured, and failures record a measured `elapsed` phase. New diagnostics `AB8250`–`AB8252` for a missing published artifact, a route without a compiled executable, and a failed compiled CLI projection or event preflight preparation. (#600) diff --git a/.changeset/wb600-pr2a-telemetry.md b/.changeset/wb600-pr2a-telemetry.md deleted file mode 100644 index befcbbbcb..000000000 --- a/.changeset/wb600-pr2a-telemetry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"agent-bundle": patch ---- - -Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) From fdd89322930837640b36fe0ab1a44b667781eba1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:35:46 +0000 Subject: [PATCH 11/33] fix workbench invocation epoch state parity --- .changeset/wb600-pr2a-execution-parity.md | 2 +- LANE-NOTES.md | 68 ++++++++ packages/agent-bundle/src/dev/epoch-paths.ts | 3 + .../src/dev/mcp-session/mcp-session-launch.ts | 5 +- .../src/dev/routes/route-invocation-child.ts | 3 +- .../route-invocation-production-error.ts | 23 +++ .../dev/routes/route-invocation-production.ts | 30 ++-- .../dev/routes/route-invocation-service.ts | 146 ++++++++---------- .../src/dev/routes/route-module-loader.ts | 17 -- .../agent-bundle/src/dev/workbench-server.ts | 30 ++-- .../agent-bundle/tests/entry-shell.test.ts | 20 +-- .../tests/route-invocation-dev-server.test.ts | 25 ++- .../tests/route-invocation-service.test.ts | 31 ++-- .../route-unit/route-module-loader.test.ts | 7 - .../tests/target-hook-contract.test.ts | 21 ++- .../docs/en/guide/development/workbench.mdx | 2 + .../docs/zh/guide/development/workbench.mdx | 2 + 17 files changed, 247 insertions(+), 188 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/epoch-paths.ts create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index 39d168fb0..7b16050ea 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations (`POST /api/routes/invocations`) through the published compiled artifact by default: the generated hook wrapper's preflight gate, the generated CLI bin's `mapInput` and confirmation, compiler aliases and defines, and persistent state on the plugin state root all behave as in the installed artifact; `mode: "unit-render"` is the explicit live-source component preview. Pin each invocation to a leased compiled epoch acquired inside the concurrency slot and answer `409 AB8239` when the published revision moved while the request waited. Rewrite only real `import`/`export … from`/`import()` specifiers when pointing a `.js` import at its `.tsx` source, so a rendered string such as `'./panel.js'` is no longer altered. Stop fabricating provider and timing rows: unmeasured providers are `unobserved` without `durationMs`, `handler`/`providers`/`provider:` timings appear only when measured, and failures record a measured `elapsed` phase. New diagnostics `AB8250`–`AB8252` for a missing published artifact, a route without a compiled executable, and a failed compiled CLI projection or event preflight preparation. (#600) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#600) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..91f029865 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,68 @@ +# Lane A5 notes + +## Behavior + +- Workbench production invocation workers now receive + `AGENT_BUNDLE_PLUGIN_ROOT=/.agent-bundle/epochs/` and + `AGENT_BUNDLE_STATE_ROOT=/.agent-bundle/epochs//state`. + The state root is derived from the leased epoch root, shared with that + epoch's dev MCP sessions, and never derived from the code root. +- Generated CLI parity tests use the same code-root and state-root environment + as Workbench production invocations. +- Event wrapper source-order assertions cover validation, canonical props, + preflight, projection, the execute gate, and the Worker boundary in their + refactored functions. Generated entry hashes and structural assertions match + the current templates. +- The production invocation error type lives in a dependency-free leaf module. + This prevents ordinary packed CLI builds from eagerly loading the optional + `@agent-bundle/runtime` peer while preserving one error-class identity. +- English and Chinese Workbench documentation describe epoch-local persistent + state and `AGENT_BUNDLE_STATE_ROOT`. + +## Files + +- `.changeset/wb600-pr2a-execution-parity.md` +- `packages/agent-bundle/src/dev/epoch-paths.ts` +- `packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-production.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/src/dev/routes/route-module-loader.ts` +- `packages/agent-bundle/src/dev/workbench-server.ts` +- `packages/agent-bundle/tests/entry-shell.test.ts` +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` +- `packages/agent-bundle/tests/route-unit/route-module-loader.test.ts` +- `packages/agent-bundle/tests/target-hook-contract.test.ts` +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/zh/guide/development/workbench.mdx` + +## Deslop + +Deslop: GPT-5.6 Sol, 11 edits. + +The pass standardized the prepared-project supplier on an async lease, +replaced structural error sniffing with the single production error class, +normalized the invocation body, tightened cross-process error reconstruction, +removed five restating comments, and condensed the changeset summary. + +## Verification + +- `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint` +- `pnpm test:unit` — 4,140 passed, 6 skipped +- `npx rstest --config rstest.route-unit.config.ts` — 89 passed +- `npx rstest --config rstest.projection.config.ts` — 190 passed +- `pnpm test:integration:run` — 1,150 passed, 4 skipped +- `pnpm docs:site:build` — locale parity passed and 0 broken links across + 27,307 anchors +- `git diff --check` + +The first integration run exposed the packed CLI's eager import of the optional +runtime peer. After moving the production error type to a leaf module, the +focused packed-consumer regression and the complete integration pool passed. + +## Open risks + +None known. The build and test logs retain pre-existing Rslib top-level-await, +Node SQLite experimental, and occasional test-process listener warnings. diff --git a/packages/agent-bundle/src/dev/epoch-paths.ts b/packages/agent-bundle/src/dev/epoch-paths.ts new file mode 100644 index 000000000..dd1b203be --- /dev/null +++ b/packages/agent-bundle/src/dev/epoch-paths.ts @@ -0,0 +1,3 @@ +import { join } from 'node:path'; + +export const devEpochStateRoot = (epochRoot: string): string => join(epochRoot, 'state'); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts index a1a4a8163..2855c0555 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts @@ -1,9 +1,10 @@ -import { isAbsolute, join, resolve } from 'node:path'; +import { isAbsolute, resolve } from 'node:path'; import { assertInside } from '../../core/paths.ts'; import { pluginStateRootEnvAnchor } from '../../core/types.ts'; import { resolveMcpPathTokens } from '../../services/mcp-path-tokens.ts'; import type { ModernMcpServer, TargetMcpRuntimeContract } from '../../services/mcp-runtime.ts'; +import { devEpochStateRoot } from '../epoch-paths.ts'; import type { McpSessionInspectorConfig } from './mcp-session-protocol.ts'; export interface ResolvedMcpSessionServer { @@ -114,7 +115,7 @@ export const resolveMcpSessionLaunch = (options: ResolveMcpSessionLaunchOptions) // A dev session runs a build epoch, not an install: its framework state // lives beside that epoch and goes with it, instead of accumulating one // user-data root per rebuild. Declared env still wins, as for every key. - const stateRoot = join(options.resolved.targetRoot, 'state'); + const stateRoot = devEpochStateRoot(options.resolved.targetRoot); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts index 08a668e79..bc56e875b 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -15,6 +15,7 @@ import type { RouteInvocationChildResponse, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { ProductionRouteInvocationError } from './route-invocation-production-error.ts'; import { renderProductionRoute } from './route-invocation-production.ts'; import { createRouteModuleLoader } from './route-module-loader.ts'; @@ -97,7 +98,7 @@ process.once('message', (request: RouteInvocationChildRequest) => { .then((result) => respond({ result, type: 'result' })) .catch((error: unknown) => respond({ error: { - ...(typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ...(error instanceof ProductionRouteInvocationError ? { code: error.code } : {}), message: error instanceof Error ? error.message : String(error), diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts new file mode 100644 index 000000000..7e3eb5134 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts @@ -0,0 +1,23 @@ +export const ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE = 'AB8250'; +export const ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE = 'AB8251'; +export const ROUTE_INVOCATION_PREPARATION_FAILURE_CODE = 'AB8252'; + +type ProductionRouteInvocationCode = + | typeof ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; + +export const isProductionRouteInvocationCode = (value: unknown): value is ProductionRouteInvocationCode => + value === ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + || value === ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + || value === ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; + +export class ProductionRouteInvocationError extends Error { + readonly code: ProductionRouteInvocationCode; + + constructor(code: ProductionRouteInvocationCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ProductionRouteInvocationError'; + this.code = code; + } +} diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index fee2bda41..61f0ada71 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { Worker } from 'node:worker_threads'; @@ -16,13 +16,17 @@ import { import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; +import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../../core/types.ts'; +import type { + RouteInvocationChildRequest, + RouteInvocationChildResult, +} from './route-invocation-service.ts'; import { + ProductionRouteInvocationError, ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, - type RouteInvocationChildRequest, - type RouteInvocationChildResult, -} from './route-invocation-service.ts'; +} from './route-invocation-production-error.ts'; import type { RouteInvocationProvider, RouteInvocationTiming } from './route-invocation.ts'; interface CompiledCliInvocationModule { @@ -75,21 +79,6 @@ type ProductionRequest = RouteInvocationChildRequest & Readonly<{ readonly artifactRoot: string; }>; -type ProductionRouteInvocationCode = - | typeof ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE - | typeof ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE - | typeof ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; - -class ProductionRouteInvocationError extends Error { - readonly code: ProductionRouteInvocationCode; - - constructor(code: ProductionRouteInvocationCode, message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'ProductionRouteInvocationError'; - this.code = code; - } -} - const preparationFailure = (error: unknown): ProductionRouteInvocationError => error instanceof ProductionRouteInvocationError ? error @@ -276,7 +265,8 @@ const streamFromWorker = ( const worker = new Worker(pathToFileURL(workerPath), { env: { ...process.env, - AGENT_BUNDLE_PLUGIN_ROOT: dirname(request.stateRoot), + [pluginRootEnvAnchor]: request.artifactRoot, + [pluginStateRootEnvAnchor]: request.stateRoot, }, stderr: true, stdout: true, diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index c1d317d65..79e80f390 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -32,6 +32,10 @@ import type { EventTraceEvent } from '../../events/trace.ts'; import { taskkill, terminateProcessTree, waitForProcessTreeExit } from '../../services/process-tree.ts'; import type { AgentBundleTestManifest, TestableScriptDescriptor } from '../../test/manifest.ts'; import type { ScriptPlaygroundResult, ScriptPlaygroundRunRequest } from '../playground/script-playground-service.ts'; +import { + isProductionRouteInvocationCode, + ProductionRouteInvocationError, +} from './route-invocation-production-error.ts'; import type { RouteInvocation } from './route-invocation-result.ts'; import type { RouteInvocationEventHost, @@ -53,13 +57,6 @@ export const ROUTE_INVOCATION_STALE_REVISION_CODE = 'AB8239'; export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; -/** Writable state root generated entries mount for the npm-bin cwd fallback. */ -export const routeInvocationStateRoot = (projectRoot: string): string => - join(projectRoot, '.agent-bundle', 'state'); -export const ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE = 'AB8250'; -export const ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE = 'AB8251'; -export const ROUTE_INVOCATION_PREPARATION_FAILURE_CODE = 'AB8252'; - const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; const defaultConcurrency = 2; @@ -89,8 +86,8 @@ export interface RouteInvocationPreparedProject { readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; /** - * Writable state directory generated entries mount for this project - * (`/state`, never the code root). + * Writable framework state beside the epoch, shared with that epoch's dev + * MCP sessions, never the code root. */ readonly stateRoot: string; readonly targets: readonly RouteInvocationEventHost[]; @@ -110,10 +107,7 @@ export interface RouteInvocationServiceOptions { readonly historyLimit?: number; readonly manifest: RouteManifestRouteService; readonly now?: () => Date; - readonly prepared: () => - | RouteInvocationPreparedLease - | RouteInvocationPreparedProject - | Promise; + readonly prepared: () => Promise; readonly registry?: TargetRegistry; readonly renderChild?: ( request: RouteInvocationChildRequest, @@ -192,18 +186,6 @@ const malformed = (): never => { ); }; -const isPreparedLease = ( - value: RouteInvocationPreparedLease | RouteInvocationPreparedProject, -): value is RouteInvocationPreparedLease => - isRecord(value) && typeof value.release === 'function' && isRecord(value.project); - -const bindPrepared = async ( - supplier: RouteInvocationServiceOptions['prepared'], -): Promise => { - const value = await supplier(); - return isPreparedLease(value) ? value : { project: value, release: () => undefined }; -}; - const boundedString = (value: unknown, maxLength = 4_096): value is string => typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); @@ -492,9 +474,10 @@ const renderInChild = async ( const receive = (message: unknown): void => { if (!isChildResponse(message)) return settle(() => rejectPromise(new Error('Route invocation child returned an invalid response.'))); if (message.type === 'error') { - const error = new Error(message.error.message); - error.name = message.error.name; - if (message.error.code !== undefined) Object.assign(error, { code: message.error.code }); + const error = isProductionRouteInvocationCode(message.error.code) + ? new ProductionRouteInvocationError(message.error.code, message.error.message) + : new Error(message.error.message); + if (!(error instanceof ProductionRouteInvocationError)) error.name = message.error.name; return settle(() => rejectPromise(error)); } settle(() => resolvePromise(message.result)); @@ -773,7 +756,7 @@ export class RouteInvocationService { let manifest: RouteManifest; let prepared: RouteInvocationPreparedProject; try { - const leased = await bindPrepared(this.#prepared); + const leased = await this.#prepared(); release = leased.release; prepared = leased.project; manifest = this.#manifest.manifest(); @@ -844,12 +827,7 @@ export class RouteInvocationService { : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { const completedAt = this.#now(); - const childCode = typeof error === 'object' && error !== null && 'code' in error - && ( - error.code === ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE - || error.code === ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE - || error.code === ROUTE_INVOCATION_PREPARATION_FAILURE_CODE - ) + const childCode = error instanceof ProductionRouteInvocationError ? error.code : ROUTE_INVOCATION_CHILD_FAILURE_CODE; return failedInvocation({ @@ -871,55 +849,55 @@ export class RouteInvocationService { clearTimeout(timeout); this.#controllers.delete(controller); } - const projectionStartedAt = this.#now(); - const projection = invocationProjection( - route, - request, - rawInput, - child.result, - child.mcp, - child.document, - manifest, - prepared, - this.#registry, - ); - const completedAt = this.#now(); - const canonical = route.kind === 'event-route' - ? (child.input as JsonObject).canonical - : undefined; - return deepFreeze({ - completedAt: completedAt.toISOString(), - context, - ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), - diagnostics: [], - document: child.document, - ...(canonical !== undefined && isJsonRecord(canonical) - ? { - event: { - // Project events reject repeated object references. Keep the - // event detail detached from the identical public `input`. - canonical: jsonObject(canonical)!, - event: route.event!, - ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), - }, - } - : {}), - events: child.events, - id, - input: canonical ?? child.input, - kind: route.kind as RouteInvocationKind, - manifestDigest: manifest.digest, - projection, - providers: child.observed?.providers ?? unobservedProviders(manifest), - ...(child.result === undefined ? {} : { result: child.result }), - routeId: route.id, - source: route.source, - sourceRevision: manifest.sourceRevision, - startedAt: startedAt.toISOString(), - status: 'succeeded', - ...(child.trace === undefined ? {} : { trace: child.trace }), - timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), - }); + const projectionStartedAt = this.#now(); + const projection = invocationProjection( + route, + request, + rawInput, + child.result, + child.mcp, + child.document, + manifest, + prepared, + this.#registry, + ); + const completedAt = this.#now(); + const canonical = route.kind === 'event-route' + ? (child.input as JsonObject).canonical + : undefined; + return deepFreeze({ + completedAt: completedAt.toISOString(), + context, + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), + diagnostics: [], + document: child.document, + ...(canonical !== undefined && isJsonRecord(canonical) + ? { + event: { + // Project events reject repeated object references. Keep the + // event detail detached from the identical public `input`. + canonical: jsonObject(canonical)!, + event: route.event!, + ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), + }, + } + : {}), + events: child.events, + id, + input: canonical ?? child.input, + kind: route.kind as RouteInvocationKind, + manifestDigest: manifest.digest, + projection, + providers: child.observed?.providers ?? unobservedProviders(manifest), + ...(child.result === undefined ? {} : { result: child.result }), + routeId: route.id, + source: route.source, + sourceRevision: manifest.sourceRevision, + startedAt: startedAt.toISOString(), + status: 'succeeded', + ...(child.trace === undefined ? {} : { trace: child.trace }), + timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), + }); } finally { await release?.(); } diff --git a/packages/agent-bundle/src/dev/routes/route-module-loader.ts b/packages/agent-bundle/src/dev/routes/route-module-loader.ts index 33e6ff6e8..6deb492a1 100644 --- a/packages/agent-bundle/src/dev/routes/route-module-loader.ts +++ b/packages/agent-bundle/src/dev/routes/route-module-loader.ts @@ -9,11 +9,6 @@ import ts from 'typescript-5'; import { isRelativeSpecifier } from '../../routes/module-candidates.ts'; import { parseModule } from '../../routes/module-scope.ts'; -/** - * Evaluates one project module from live source: a route, layout, provider, - * or state module by absolute path, as the Workbench's unit-render mode and - * the route-unit harness see it. - */ export interface RouteModuleLoader { readonly load: (source: string) => () => Promise; } @@ -51,12 +46,6 @@ const specifierLiteral = (sourceFile: ts.SourceFile, expression: ts.Expression | ? { end: expression.end, start: expression.getStart(sourceFile), text: expression.text } : undefined; -/** - * The string literals that name modules — `import … from`, `export … from`, - * and a literal dynamic `import()` — in source order. A string literal - * anywhere else (JSX text, a prop, an expression) names no module and is - * never one of them. - */ const moduleSpecifierLiterals = (sourceFile: ts.SourceFile): readonly SpecifierLiteral[] => { const literals: SpecifierLiteral[] = []; const visit = (node: ts.Node): void => { @@ -97,12 +86,6 @@ const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => return rewritten; }; -/** - * Jiti over live project source with the framework's own `react` and - * `@agent-bundle/runtime` instances, no module cache, and the `.js`-to-`.tsx` - * module specifier rewrite. `load(source)` returns a lazy loader in the shape - * the harness registry's `*Loaders` maps take. - */ export const createRouteModuleLoader = (): RouteModuleLoader => { const baseJiti = createJiti(import.meta.url, jitiOptions); const jiti = createJiti(import.meta.url, { diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index fe6074e80..2ac13dc17 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -9,6 +9,7 @@ import { DevCoordinator } from './coordinator.ts'; import { DevPackageBuildService } from './package-build-service.ts'; import { runDevEpochContracts } from './dev-contract-runner.ts'; import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; +import { devEpochStateRoot } from './epoch-paths.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; import { EpochStore, EpochStoreError } from './epoch-store.ts'; @@ -60,7 +61,6 @@ import { ROUTE_INVOCATION_STALE_REVISION_MESSAGE, RouteInvocationRequestError, RouteInvocationService, - routeInvocationStateRoot, } from './routes/route-invocation-service.ts'; import { routeManifestFor } from './routes/route-manifest.ts'; import type { RouteManifestRouteService } from './routes/route-manifest-routes.ts'; @@ -916,6 +916,19 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun .map((target) => target.name) .find((target) => registry.artifactLayout(target).scripts !== undefined); const epochId = artifact.activeEpoch.id; + let reference; + try { + reference = await epochStore.acquireEpochReference(epochId); + } catch (error) { + if (error instanceof EpochStoreError && error.code === 'EPOCH_NOT_FOUND') { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, + 409, + ); + } + throw error; + } const project = Object.freeze({ ...(scriptTarget === undefined ? {} : { artifact: { epochId, target: scriptTarget } }), manifest: testManifestFromRouteGraph({ @@ -938,22 +951,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), - stateRoot: routeInvocationStateRoot(prepared.root), + stateRoot: devEpochStateRoot(reference.root), targets, }); - let reference; - try { - reference = await epochStore.acquireEpochReference(epochId); - } catch (error) { - if (error instanceof EpochStoreError && error.code === 'EPOCH_NOT_FOUND') { - throw new RouteInvocationRequestError( - ROUTE_INVOCATION_STALE_REVISION_CODE, - ROUTE_INVOCATION_STALE_REVISION_MESSAGE, - 409, - ); - } - throw error; - } return { project, release: () => reference.close(), diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index d2d65ff84..d219fc7d0 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -343,7 +343,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('ad8c21f371af0043464162750a8ed557d968f6155cdd9521ee63c0275253710a'); + .toBe('9f76d9d0664efaf028e15cfd5378e121e352aeea714f5820331dfb64845a821e'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -685,7 +685,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '4e2c248b5358b7e13650f2156cf282b03f6f7ede20e2badabafc4b33ae5b4bd5', + '2948653acc4e918b0cc24b08e4560800316a9cf06ed90194dcb01b7d3e5f6bd0', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -813,19 +813,11 @@ it('imports explicit CLI projections and maps their input before canonical valid expect(source).toContain( '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); - const defaults = source.indexOf('for (const [key, value] of Object.entries(command.projection.defaults))'); - const mapping = source.indexOf('mapped = route.projection.mapInput(mapped)'); - const validation = source.indexOf('return route.module.inputSchema.parse(mapped)'); expect(source).not.toContain('command.mcp?.confirm'); expect(source).not.toContain('confirmationRequiredMessage'); expect(source).not.toContain('delete mapped.yes'); - expect(defaults).toBeGreaterThan(-1); - expect(defaults).toBeLessThan(mapping); - expect(mapping).toBeLessThan(validation); - expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); - expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); - expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`)"); - expect(source).toContain('throw new CliInputError(error instanceof Error ? error.message : String(error));'); + expect(source).toContain('const parseInput = (command, route, input) => mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input);'); + expect(source).toContain('return parseInput(command, route, parseGeneratedCliArgv(command, argv).input);'); expect(source).toContain( "invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", ); @@ -1401,7 +1393,7 @@ it('composes the root and server layout chain around generated MCP routes and ne // throwing route still rejects the Flight root exactly as it does without a layout. expect(source).toContain('let composed = await route.module.default(props);'); expect(source).toContain('if (chain.length === 0) return createElement(route.module.default, props);'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, props, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, props, controller.signal)'); }); it('imports only the layouts some route of the worker composes through, never another server\'s layout', () => { @@ -1515,7 +1507,7 @@ it('hands rendered CLI, projected MCP, and script routes their layout chain and expect(source).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([1]) })'); expect(source).toContain('"tool:curator/inspect": Object.freeze({ id: "tool:curator/inspect", kind: "tool", name: "inspect", serverId: "mcp:curator", module: route1, layouts: Object.freeze([1,0]) })'); expect(source).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route2, layouts: Object.freeze([1]) })'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, { ...message.props, signal: controller.signal }, controller.signal)'); }); it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index 2647983d3..763cfbd81 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -6,6 +6,7 @@ import { expect, it } from '@rstest/core'; import type { RouteInvocationResponse } from '../src/dev/routes/route-invocation-result.ts'; import type { RouteInvocationListResponse } from '../src/dev/routes/route-invocation.ts'; import type { RouteManifestResponse } from '../src/dev/routes/route-manifest.ts'; +import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../src/core/types.ts'; import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; import { startDevServer } from '../src/dev/workbench-server.ts'; import { createProjectFixture } from './helpers/project-fixture.ts'; @@ -106,7 +107,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', ].join('\n'), 'src/mcp/status/tools/report.tsx': [ - "import { Agent } from '@agent-bundle/runtime';", + "import { Agent, agent } from '@agent-bundle/runtime';", "import { ALIAS_VALUE } from '@fixture/value';", "import { createElement } from 'react';", "import { z } from 'zod';", @@ -114,10 +115,12 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", "export const inputSchema = z.object({ service: z.string().min(1), source: z.string() }).strict();", - 'export const resultSchema = z.object({ alias: z.string(), define: z.string(), service: z.string(), source: z.string() }).strict();', + 'export const resultSchema = z.object({ alias: z.string(), define: z.string(), pluginRoot: z.string(), service: z.string(), source: z.string(), stateRoot: z.string() }).strict();', '', 'export default async function Report({ input }) {', - ' const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, service: input.service, source: input.source };', + ' const context = await agent();', + " if (context.plugin.state !== 'available') throw new Error('plugin unavailable');", + ' const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, pluginRoot: context.plugin.value.root, service: input.service, source: input.source, stateRoot: context.plugin.value.stateRoot };', " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `Service ${input.service}`));", '}', '', @@ -192,6 +195,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim const stream = await fetch(`${server.url}/api/project/events`, { headers: { cookie, origin: server.url }, }); + const activeEpoch = server.status().artifact; + if (activeEpoch.state !== 'active') throw new Error('Expected an active compiled epoch.'); + const artifactRoot = join(project.root, '.agent-bundle', 'epochs', activeEpoch.activeEpoch.id); + const stateRoot = join(artifactRoot, 'state'); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, @@ -206,8 +213,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.result).toEqual({ alias: 'aliased', define: 'defined', + pluginRoot: artifactRoot, service: 'catalog', source: 'api', + stateRoot, }); expect(tool.invocation.providers).toEqual([ expect.objectContaining({ durationMs: expect.any(Number), id: 'provider:clock', name: 'clock', status: 'mounted' }), @@ -340,19 +349,21 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(projectedCli.invocation.result).toMatchObject({ alias: 'aliased', define: 'defined', + pluginRoot: artifactRoot, service: 'projection', source: 'cli-projection', + stateRoot, }); - const activeEpoch = server.status().artifact; - if (activeEpoch.state !== 'active') throw new Error('Expected an active compiled epoch.'); - const artifactRoot = join(project.root, '.agent-bundle', 'epochs', activeEpoch.activeEpoch.id); const binName = (await readdir(join(artifactRoot, 'bin'))) .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); const generatedBin = await runNodeScript({ args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], cwd: project.root, - env: { AGENT_BUNDLE_PLUGIN_ROOT: join(project.root, '.agent-bundle') }, + env: { + [pluginRootEnvAnchor]: artifactRoot, + [pluginStateRootEnvAnchor]: stateRoot, + }, }); expect(generatedBin.code, generatedBin.stderr).toBe(0); expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 5843ffe4c..2453179eb 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -13,9 +13,9 @@ import { RouteInvocationRequestError, invocationSummary, parseRouteInvocationRequest, - routeInvocationStateRoot, type RouteInvocationChildRequest, type RouteInvocationChildResult, + type RouteInvocationPreparedProject, type RouteInvocationServiceOptions, } from '../src/dev/routes/route-invocation-service.ts'; import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; @@ -169,6 +169,11 @@ const childResult = (request: RouteInvocationChildRequest): RouteInvocationChild renderDurationMs: 1, }); +const preparedLease = async (project: RouteInvocationPreparedProject) => ({ + project, + release: () => undefined, +}); + it('aborts and drains a running render when the service closes', async () => { let releases = 0; const started = deferred(); @@ -176,10 +181,10 @@ it('aborts and drains a running render when the service closes', async () => { manifest: { manifest: () => catalog('digest', 'revision'), }, - prepared: () => ({ + prepared: async () => ({ project: { manifest: { projectRoot: '/project' } as never, - stateRoot: routeInvocationStateRoot('/project'), + stateRoot: '/project/.agent-bundle/epochs/epoch-1/state', targets: ['claude'], }, release: () => { @@ -211,15 +216,16 @@ it('rejects a queued invocation when the published revision moves before the slo const executed: RouteInvocationChildRequest[] = []; let releases = 0; const projectRoot = '/project'; + const epochRoot = join(projectRoot, '.agent-bundle', 'epochs', 'epoch-1'); const service = new RouteInvocationService({ concurrency: 1, manifest: { manifest: () => catalog(digest, sourceRevision), }, - prepared: () => ({ + prepared: async () => ({ project: { manifest: { projectRoot } as never, - stateRoot: routeInvocationStateRoot(projectRoot), + stateRoot: join(epochRoot, 'state'), targets: ['claude'], }, release: () => { @@ -249,7 +255,7 @@ it('rejects a queued invocation when the published revision moves before the slo status: 'succeeded', }); expect(executed).toHaveLength(1); - expect(executed[0]?.stateRoot).toBe(routeInvocationStateRoot(projectRoot)); + expect(executed[0]?.stateRoot).toBe(join(epochRoot, 'state')); expect(executed[0]?.stateRoot).not.toBe(projectRoot); await expect(second).rejects.toMatchObject({ code: ROUTE_INVOCATION_STALE_REVISION_CODE, @@ -313,14 +319,14 @@ const routeProject = async ( }; const prepared = Object.freeze({ manifest: testManifestFromRouteGraph({ graph, projectRoot: root }), - stateRoot: routeInvocationStateRoot(root), + stateRoot: join(root, 'state'), targets: ['claude' as const], }); return { root, service: (options = {}) => new RouteInvocationService({ manifest: { manifest: () => manifest }, - prepared: () => prepared, + prepared: () => preparedLease(prepared), timeoutMs: options.timeoutMs, }), }; @@ -360,11 +366,6 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => routeProject( await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-tsx-sibling-')), 'report', @@ -520,9 +521,9 @@ const telemetryService = ( renderChild: NonNullable, ): RouteInvocationService => new RouteInvocationService({ manifest: { manifest: telemetryManifest }, - prepared: () => ({ + prepared: () => preparedLease({ manifest: { projectRoot: '/project' } as never, - stateRoot: routeInvocationStateRoot('/project'), + stateRoot: '/project/state', targets: ['claude'], }), renderChild, diff --git a/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts index 10671b202..97da0a72a 100644 --- a/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts +++ b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts @@ -9,13 +9,6 @@ import { expectDocument } from '../../src/test/matchers.ts'; import { renderRouteEvents } from '../../src/test/render.ts'; import type { AgentRouteModule } from '../../src/test/types.ts'; -/** - * Project code names its TypeScript siblings by their emitted `.js` name. The - * loader points a `.js` specifier whose source is a `.tsx` file at that file - * (jiti retries `.ts` on its own, and a real `.js` sibling is loaded as is), - * and touches nothing but module specifiers: `'./panel.js'` rendered as text - * stays `./panel.js`, as the compiled program prints it (#600). - */ const files: Readonly> = { 'count.ts': "export const count = 'from count.ts';\n", 'label.tsx': "export const label = 'from label.tsx';\n", diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 0adbdbe83..4ec7da22e 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -430,12 +430,16 @@ it('runs event-route preflight in the per-host wrapper before shared IPC', () => expect(staticImportSpecifiers(source).filter((specifier) => specifier === 'react' || specifier.startsWith('react/') || specifier.endsWith('.tsx'))).toEqual([]); + const prepareBody = source.slice( + firstIndex(source, 'export const prepareRouteInvocation'), + firstIndex(source, 'const runExecutor'), + ); + expect(firstIndex(prepareBody, 'validateNativeEventEnvelope')).toBeLessThan(firstIndex(prepareBody, 'createCanonicalEventProps')); + expect(firstIndex(prepareBody, 'createCanonicalEventProps')).toBeLessThan(firstIndex(prepareBody, 'executeEventPreflight')); + expect(firstIndex(prepareBody, 'executeEventPreflight')).toBeLessThan(firstIndex(prepareBody, 'projectEventPreflightResult')); const runBody = source.slice(firstIndex(source, 'const run = async () => {')); - expect(firstIndex(runBody, 'validateNativeEventEnvelope')).toBeLessThan(firstIndex(runBody, 'createCanonicalEventProps')); - expect(firstIndex(runBody, 'createCanonicalEventProps')).toBeLessThan(firstIndex(runBody, 'executeEventPreflight')); - expect(firstIndex(runBody, 'executeEventPreflight')).toBeLessThan(runBody.search(/['"]execute['"]/u)); + expect(firstIndex(runBody, 'await prepareRouteInvocation')).toBeLessThan(runBody.search(/['"]execute['"]/u)); expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); - expect(firstIndex(runBody, 'projectEventPreflightResult')).toBeGreaterThan(firstIndex(runBody, 'executeEventPreflight')); }); it('crosses the standalone Worker boundary only after preflight returns execute', () => { @@ -463,8 +467,15 @@ it('crosses the standalone Worker boundary only after preflight returns execute' expect(entry.executeVirtualSource).toContain('new URL(/* webpackIgnore: true */ "./hooks-flight.mjs", import.meta.url)'); expect(entry.executeVirtualSource).toContain('createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal, observation)'); expect(source).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + const prepareBody = source.slice( + firstIndex(source, 'export const prepareRouteInvocation'), + firstIndex(source, 'const runExecutor'), + ); + expect(firstIndex(prepareBody, 'validateNativeEventEnvelope')).toBeLessThan(firstIndex(prepareBody, 'createCanonicalEventProps')); + expect(firstIndex(prepareBody, 'createCanonicalEventProps')).toBeLessThan(firstIndex(prepareBody, 'executeEventPreflight')); + expect(firstIndex(prepareBody, 'executeEventPreflight')).toBeLessThan(firstIndex(prepareBody, 'projectEventPreflightResult')); const runBody = source.slice(firstIndex(source, 'const run = async () => {')); - expect(firstIndex(runBody, 'executeEventPreflight')).toBeLessThan(runBody.search(/['"]execute['"]/u)); + expect(firstIndex(runBody, 'await prepareRouteInvocation')).toBeLessThan(runBody.search(/['"]execute['"]/u)); expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); }); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 696c0faaa..57f177b58 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -201,6 +201,8 @@ providers, persistent state, CLI `mapInput` and confirmation, event preflight, a semantics match the installed artifact. `mode: "unit-render"` is an explicit component-preview fallback: it loads live source with the route-unit harness and mounts disposable state. It is not an artifact-parity receipt. +Production state lives beside the published epoch at `/state`, shared with that epoch's dev +MCP sessions through `AGENT_BUNDLE_STATE_ROOT`. The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 2140311e4..e0e39ee73 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -169,6 +169,8 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 alias 与 define、生成入口的请求作用域、providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲染语义都与已安装制品一致。`mode: "unit-render"` 是显式的组件预览后备模式: 它通过 route-unit 测试工具加载实时源码并挂载一次性状态,不能作为制品一致性的证明。 +生产状态位于已发布 epoch 旁的 `/state`,并通过 `AGENT_BUNDLE_STATE_ROOT` 与该 epoch 的开发期 +MCP 会话共享。 该信封携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、投影、 诊断与执行计时。`providers` 列出子进程实际测到的结果(`mounted`、`failed` 或 `skipped`),仅在测到 From fd46e89ea8f88a29e8ae00cd2513c7b55e49f02c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:46:02 +0000 Subject: [PATCH 12/33] drop LANE-NOTES --- LANE-NOTES.md | 68 --------------------------------------------------- 1 file changed, 68 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 91f029865..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,68 +0,0 @@ -# Lane A5 notes - -## Behavior - -- Workbench production invocation workers now receive - `AGENT_BUNDLE_PLUGIN_ROOT=/.agent-bundle/epochs/` and - `AGENT_BUNDLE_STATE_ROOT=/.agent-bundle/epochs//state`. - The state root is derived from the leased epoch root, shared with that - epoch's dev MCP sessions, and never derived from the code root. -- Generated CLI parity tests use the same code-root and state-root environment - as Workbench production invocations. -- Event wrapper source-order assertions cover validation, canonical props, - preflight, projection, the execute gate, and the Worker boundary in their - refactored functions. Generated entry hashes and structural assertions match - the current templates. -- The production invocation error type lives in a dependency-free leaf module. - This prevents ordinary packed CLI builds from eagerly loading the optional - `@agent-bundle/runtime` peer while preserving one error-class identity. -- English and Chinese Workbench documentation describe epoch-local persistent - state and `AGENT_BUNDLE_STATE_ROOT`. - -## Files - -- `.changeset/wb600-pr2a-execution-parity.md` -- `packages/agent-bundle/src/dev/epoch-paths.ts` -- `packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-production.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` -- `packages/agent-bundle/src/dev/routes/route-module-loader.ts` -- `packages/agent-bundle/src/dev/workbench-server.ts` -- `packages/agent-bundle/tests/entry-shell.test.ts` -- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` -- `packages/agent-bundle/tests/route-invocation-service.test.ts` -- `packages/agent-bundle/tests/route-unit/route-module-loader.test.ts` -- `packages/agent-bundle/tests/target-hook-contract.test.ts` -- `website/docs/en/guide/development/workbench.mdx` -- `website/docs/zh/guide/development/workbench.mdx` - -## Deslop - -Deslop: GPT-5.6 Sol, 11 edits. - -The pass standardized the prepared-project supplier on an async lease, -replaced structural error sniffing with the single production error class, -normalized the invocation body, tightened cross-process error reconstruction, -removed five restating comments, and condensed the changeset summary. - -## Verification - -- `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint` -- `pnpm test:unit` — 4,140 passed, 6 skipped -- `npx rstest --config rstest.route-unit.config.ts` — 89 passed -- `npx rstest --config rstest.projection.config.ts` — 190 passed -- `pnpm test:integration:run` — 1,150 passed, 4 skipped -- `pnpm docs:site:build` — locale parity passed and 0 broken links across - 27,307 anchors -- `git diff --check` - -The first integration run exposed the packed CLI's eager import of the optional -runtime peer. After moving the production error type to a leaf module, the -focused packed-consumer regression and the complete integration pool passed. - -## Open risks - -None known. The build and test logs retain pre-existing Rslib top-level-await, -Node SQLite experimental, and occasional test-process listener warnings. From a89aa6cd627b47d77f9c244c2243fd6713c25b51 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:49:31 +0000 Subject: [PATCH 13/33] changeset: name #643 --- .changeset/wb600-pr2a-execution-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index 7b16050ea..b44f633e4 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#600) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) From 2f2ad57d7fdcb80d14687f41f4921ae66e377a08 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:17:09 +0000 Subject: [PATCH 14/33] fix(dev): retain state across epoch rebuilds --- .changeset/wb600-pr2a-execution-parity.md | 2 +- LANE-NOTES.md | 31 +++++++++++++++++++ packages/agent-bundle/src/dev/epoch-paths.ts | 3 -- .../src/dev/mcp-session/mcp-session-launch.ts | 7 ++--- packages/agent-bundle/src/dev/state-paths.ts | 3 ++ .../agent-bundle/src/dev/workbench-server.ts | 4 +-- .../tests/mcp-session-service.test.ts | 4 +-- .../tests/route-invocation-dev-server.test.ts | 9 +++++- website/docs/en/guide/authoring/mcp.mdx | 6 ++-- .../docs/en/guide/development/workbench.mdx | 5 +-- website/docs/zh/guide/authoring/mcp.mdx | 5 +-- .../docs/zh/guide/development/workbench.mdx | 5 +-- 12 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 LANE-NOTES.md delete mode 100644 packages/agent-bundle/src/dev/epoch-paths.ts create mode 100644 packages/agent-bundle/src/dev/state-paths.ts diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index b44f633e4..7e04e414d 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent project state across rebuilt epochs, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..9bed133bd --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,31 @@ +# A9 lane notes + +## Changed + +- Replaced the epoch-scoped helper with `devStateRoot(projectRoot)`. +- Workbench route invocations and dev MCP session launches now share the same + framework-owned state root. +- Extended stateful route parity across a successful republish; unit-render + remains isolated. +- Updated the MCP session environment assertion and en/zh Workbench and MCP + documentation. + +## Path contract + +- Before: `/.agent-bundle/epochs//state` +- After: `/.agent-bundle/state` + +`AGENT_BUNDLE_PLUGIN_ROOT` remains the selected epoch. Isolated unit-render +continues to create and remove its own temporary state root. + +## Tests + +- Focused integration: `route-invocation-dev-server.test.ts` and + `mcp-session-service.test.ts` +- Full gate: `pnpm build && pnpm typecheck && pnpm lint && pnpm test:unit`, + relevant integration pool, and `pnpm docs:site:build` + +## Ambiguities + +- `/tmp/wb600/notes-pr2a/A2.md` and `A5.md` were not present when this lane + began, so their requested production-path rationale could not be read. diff --git a/packages/agent-bundle/src/dev/epoch-paths.ts b/packages/agent-bundle/src/dev/epoch-paths.ts deleted file mode 100644 index dd1b203be..000000000 --- a/packages/agent-bundle/src/dev/epoch-paths.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { join } from 'node:path'; - -export const devEpochStateRoot = (epochRoot: string): string => join(epochRoot, 'state'); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts index 2855c0555..57f0b82af 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts @@ -4,7 +4,7 @@ import { assertInside } from '../../core/paths.ts'; import { pluginStateRootEnvAnchor } from '../../core/types.ts'; import { resolveMcpPathTokens } from '../../services/mcp-path-tokens.ts'; import type { ModernMcpServer, TargetMcpRuntimeContract } from '../../services/mcp-runtime.ts'; -import { devEpochStateRoot } from '../epoch-paths.ts'; +import { devStateRoot } from '../state-paths.ts'; import type { McpSessionInspectorConfig } from './mcp-session-protocol.ts'; export interface ResolvedMcpSessionServer { @@ -112,10 +112,7 @@ export const resolveMcpSessionLaunch = (options: ResolveMcpSessionLaunchOptions) const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); - // A dev session runs a build epoch, not an install: its framework state - // lives beside that epoch and goes with it, instead of accumulating one - // user-data root per rebuild. Declared env still wins, as for every key. - const stateRoot = devEpochStateRoot(options.resolved.targetRoot); + const stateRoot = devStateRoot(options.workspaceRoot); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, diff --git a/packages/agent-bundle/src/dev/state-paths.ts b/packages/agent-bundle/src/dev/state-paths.ts new file mode 100644 index 000000000..252a72b18 --- /dev/null +++ b/packages/agent-bundle/src/dev/state-paths.ts @@ -0,0 +1,3 @@ +import { join } from 'node:path'; + +export const devStateRoot = (projectRoot: string): string => join(projectRoot, '.agent-bundle', 'state'); diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 2ac13dc17..630df5af9 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -9,7 +9,7 @@ import { DevCoordinator } from './coordinator.ts'; import { DevPackageBuildService } from './package-build-service.ts'; import { runDevEpochContracts } from './dev-contract-runner.ts'; import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; -import { devEpochStateRoot } from './epoch-paths.ts'; +import { devStateRoot } from './state-paths.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; import { EpochStore, EpochStoreError } from './epoch-store.ts'; @@ -951,7 +951,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), - stateRoot: devEpochStateRoot(reference.root), + stateRoot: devStateRoot(root), targets, }); return { diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 51b860caf..c66cdf25a 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -255,9 +255,7 @@ it('keeps one generated server and plugin-data directory bound to the selected e readonly stateRoot: string; }; expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1')); - // Dev sessions pin the framework state root beside the epoch (#637), so a - // rebuild never accumulates another `~/.agent-bundle/state` directory. - expect(firstState.stateRoot).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'state')); + expect(firstState.stateRoot).toBe(join(root, '.agent-bundle', 'state')); expect(firstState.inherited).toBe('resolved-on-open'); await expect(access(firstState.data)).resolves.toBeUndefined(); expect(session.events().some((event) => event.type === 'stderr' && event.text === 'fixture stderr\n')).toBe(true); diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index 763cfbd81..cf5d34723 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -198,7 +198,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim const activeEpoch = server.status().artifact; if (activeEpoch.state !== 'active') throw new Error('Expected an active compiled epoch.'); const artifactRoot = join(project.root, '.agent-bundle', 'epochs', activeEpoch.activeEpoch.id); - const stateRoot = join(artifactRoot, 'state'); + const stateRoot = join(project.root, '.agent-bundle', 'state'); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, @@ -505,6 +505,13 @@ it('invokes compiled tool and event routes through the foreground server', { tim result: { service: 'rebuilt-published' }, status: 'succeeded', }); + const republishedEpoch = server.status().artifact; + if (republishedEpoch.state !== 'active') throw new Error('Expected an active rebuilt epoch.'); + expect(republishedEpoch.activeEpoch.id).not.toBe(activeEpoch.activeEpoch.id); + const republishedCounter = await counter(); + const republishedIsolatedCounter = await counter('unit-render'); + expect(republishedCounter.invocation.result).toEqual({ count: 3 }); + expect(republishedIsolatedCounter.invocation.result).toEqual({ count: 1 }); const missingApi = await fetch(`${server.url}/api/nope`); expect(missingApi.status).toBe(404); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 704c86133..f66283b0a 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -1141,8 +1141,10 @@ The proxy discovers the loopback server through the project's development lock a the stable Streamable HTTP endpoint at `/mcp/host/`. `--target` defaults to `portable`, and `--url` overrides discovery. Successful rebuilds keep the stdio connection open, route new calls to the active epoch, let admitted calls finish against their original epoch, and -forward MCP catalog change notifications. If the epoch or development server disappears, the -proxy fails closed with an MCP error and an `AB8024` or `AB8025` diagnostic. +forward MCP catalog change notifications. Dev MCP sessions and Workbench invocations share the +project's `/.agent-bundle/state` root, outside the retired epoch directories. If the +epoch or development server disappears, the proxy fails closed with an MCP error and an `AB8024` +or `AB8025` diagnostic. The endpoint is intentionally unauthenticated because the development server binds only to loopback and is never exposed beyond the local machine. diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 57f177b58..e7a7e988b 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -201,8 +201,9 @@ providers, persistent state, CLI `mapInput` and confirmation, event preflight, a semantics match the installed artifact. `mode: "unit-render"` is an explicit component-preview fallback: it loads live source with the route-unit harness and mounts disposable state. It is not an artifact-parity receipt. -Production state lives beside the published epoch at `/state`, shared with that epoch's dev -MCP sessions through `AGENT_BUNDLE_STATE_ROOT`. +Production state lives at `/.agent-bundle/state`, outside the published epochs that +retirement removes. It is shared with dev MCP sessions through `AGENT_BUNDLE_STATE_ROOT`, so it +survives a successful republish; `unit-render` still uses a fresh temporary state root per run. The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings. diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 6c6a4ffb1..77890aa0b 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -987,8 +987,9 @@ npx agent-bundle mcp run --artifact artifact --target claude --server curator 该代理通过项目的开发锁发现 loopback 服务器,并连接到位于 `/mcp/host/` 的稳定 Streamable HTTP 端点。`--target` 默认为 `portable`,`--url` 可覆盖发现过程。重建成功时会保持 stdio 连接不断、 -把新调用路由到当前 epoch、让已受理的调用在其原始 epoch 上完成,并转发 MCP 目录变更通知。若 epoch 或 -开发服务器消失,代理会以 MCP 错误以及 `AB8024` 或 `AB8025` 诊断失败关闭。 +把新调用路由到当前 epoch、让已受理的调用在其原始 epoch 上完成,并转发 MCP 目录变更通知。开发期 MCP +会话与 Workbench 调用共享项目的 `/.agent-bundle/state` 根目录,它不在会被退役的 epoch +目录之内。若 epoch 或开发服务器消失,代理会以 MCP 错误以及 `AB8024` 或 `AB8025` 诊断失败关闭。 该端点刻意不做认证,因为开发服务器只绑定 loopback,绝不会暴露到本机之外。 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index e0e39ee73..b1af617df 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -169,8 +169,9 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 alias 与 define、生成入口的请求作用域、providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲染语义都与已安装制品一致。`mode: "unit-render"` 是显式的组件预览后备模式: 它通过 route-unit 测试工具加载实时源码并挂载一次性状态,不能作为制品一致性的证明。 -生产状态位于已发布 epoch 旁的 `/state`,并通过 `AGENT_BUNDLE_STATE_ROOT` 与该 epoch 的开发期 -MCP 会话共享。 +生产状态位于 `/.agent-bundle/state`,在 retirement 会移除的已发布 epoch 之外。它通过 +`AGENT_BUNDLE_STATE_ROOT` 与开发期 MCP 会话共享,因此能跨一次成功的重新发布保留;`unit-render` +仍为每次运行使用新的临时状态根。 该信封携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、投影、 诊断与执行计时。`providers` 列出子进程实际测到的结果(`mounted`、`failed` 或 `skipped`),仅在测到 From 7fcc028fd996cd2134c2ce445497a340b51fda19 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:32:57 +0000 Subject: [PATCH 15/33] feat(workbench): select invocation surfaces --- .changeset/wb600-pr2a-execution-parity.md | 2 +- LANE-NOTES.md | 80 ++++++ docs/diagnostics.md | 2 +- .../agent-bundle/src/contracts/invocations.ts | 2 +- .../src/dev/routes/application-tree.ts | 5 +- .../src/dev/routes/route-invocation-child.ts | 3 +- .../dev/routes/route-invocation-production.ts | 30 +-- .../dev/routes/route-invocation-service.ts | 237 +++++++++++++----- .../src/dev/routes/route-invocation.ts | 32 +-- .../tests/application-tree.test.ts | 12 + .../tests/route-invocation-dev-server.test.ts | 57 ++++- .../tests/route-invocation-service.test.ts | 32 ++- .../src/application/event-route-workspace.tsx | 18 +- .../executable-route-workspace.tsx | 69 ++++- .../src/application/invocation-client.ts | 16 ++ .../src/application/invocation-model.ts | 1 + .../src/application/route-input-editor.tsx | 54 ++-- .../src/application/runtime-backend.ts | 17 +- .../workbench/src/application/workspace.css | 9 +- .../tests/dev-server-backend.test.ts | 2 + .../tests/event-route-workspace.test.ts | 12 +- .../workbench/tests/invocation-client.test.ts | 2 + .../workbench/tests/invocation-model.test.ts | 2 + .../tests/route-input-editor.test.ts | 20 +- .../workbench/tests/route-workspace.test.ts | 21 +- .../tests/support/workspace-fixtures.ts | 14 ++ packages/workbench/tests/trace-page.test.ts | 1 + .../docs/en/guide/development/workbench.mdx | 39 ++- .../docs/zh/guide/development/workbench.mdx | 31 ++- 29 files changed, 637 insertions(+), 185 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index b44f633e4..5cbe2f9df 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Select MCP, CLI, event, script, or explicit `unit-render` surfaces independently from the canonical operation id, record the resolved surface, and add diagnostics `AB8239`, `AB8250`–`AB8254`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..99c490a99 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,80 @@ +# Lane A6 notes + +## What changed + +- Replaced the route invocation request's top-level `mode`, `args`, and `event` fields with the + discriminated `surface` union. +- Kept `routeId` as the canonical operation id and recorded the resolved surface on every + successful or failed invocation and summary. +- Routed a tool's CLI surface through the published generated bin's existing + `prepareRouteInvocation(routeId, argv)` export after validating the selected manifest command. + Confirmation, projection defaults, `mapInput`, and canonical schema validation remain owned by + that generated entry path. +- Kept projected tools as one Application tree leaf, attached their manifest command to that leaf, + and added the MCP / projected CLI / Unit render selector, argv projection, event host/fixture + surface input, and operation-plus-surface header. +- Updated browser decoding, runtime-backend envelopes, callers, English and Chinese Workbench + docs, diagnostics, and the existing PR changeset. + +## Request and response shape + +Before: + +```ts +{ + routeId: string; + input?: JsonValue; + args?: readonly string[]; + event?: { host?: 'claude' | 'codex' | 'cursor'; fixtureId?: string }; + mode?: 'production' | 'unit-render'; +} +``` + +After: + +```ts +{ + routeId: string; + input?: JsonValue; + surface?: + | { kind: 'mcp' } + | { kind: 'cli'; command: string; args: readonly string[] } + | { kind: 'event'; host?: 'claude' | 'codex' | 'cursor'; fixtureId?: string } + | { kind: 'script' } + | { kind: 'unit-render' }; +} +``` + +Every result/summary now has required `surface: RouteInvocationSurface`, resolved from the route +kind when omitted. Defaults are MCP for tool/resource/prompt, event for event routes, script for +scripts, and the compiled command with empty argv for standalone CLI routes. Unit render is never +a default. + +## Diagnostics allocated + +- `AB8253`: selected CLI command does not project onto the canonical operation. +- `AB8254`: a projected `cli:` id was submitted instead of the canonical `tool:` id and + CLI surface. + +A7 may allocate in the same range; renumber these two during integration if needed. + +## Tests + +- Strict request-union parsing and rejection of legacy fields. +- Resolved default MCP surface and explicit unit-render surface recording. +- Generated projected-CLI parity (`mapInput` result equals generated CLI output). +- Projected CLI result projection, mismatched command `400 AB8253`, and duplicate projected + `cli:` operation `400 AB8254`. +- Application-tree command attachment without a duplicate CLI leaf. +- Workbench selector/header and event host/fixture request shape. +- Browser decoder and all typed request/result fixtures updated. + +## Decisions / ambiguity + +- `surface.command` uses the manifest command path joined with spaces (the same display and + invocation value used by the generated CLI request context). A duplicate CLI route id uses the + path joined with `/`, matching standalone `cli:` ids. +- CLI surface selection is permitted only for standalone CLI routes or explicit tool projections; + bulk MCP command generation is not treated as the tool's selectable projected CLI surface. +- Explicit unit render remains available for component routes but is rejected for scripts, which + have no isolated component render path. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 1a6430a8f..5072d14dc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -46,7 +46,7 @@ even when no error diagnostic was reported. | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | | `AB8231`–`AB8232`, `AB8236`–`AB8238` | Workbench route invocation service (`/api/routes/invocations`): `AB8231` unknown route or invocation id, `AB8232` no published build / invocation manifest unavailable, `AB8236` render child timed out or crashed, `AB8237` malformed invocation request, and `AB8238` unknown fixture id. (`AB8233`–`AB8235` were already assigned to browser-side decoders.) | | `AB8239` | Workbench route invocation service (`/api/routes/invocations`): the published manifest digest or source revision moved while the request waited for a concurrency slot (409). Retry against the current revision so the recorded `manifestDigest`/`sourceRevision` cannot describe a different build than the one that ran. | -| `AB8250`–`AB8252` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, and `AB8252` compiled CLI projection or event preflight preparation failed. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`. | +| `AB8250`–`AB8254` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, `AB8252` compiled CLI projection or event preflight preparation failed, `AB8253` a selected CLI command does not project onto the canonical operation id, and `AB8254` a projected `cli:` id was used instead of its canonical `tool:/` id plus CLI surface. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`; use the command or canonical operation named by `AB8253`/`AB8254`. | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/src/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts index e6756ea73..1942bae9b 100644 --- a/packages/agent-bundle/src/contracts/invocations.ts +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -7,7 +7,6 @@ export type { RouteInvocationCliProjection, RouteInvocationEvent, RouteInvocationEventHost, - RouteInvocationEventOptions, RouteInvocationEventPayload, RouteInvocationHostProjection, RouteInvocationKind, @@ -18,6 +17,7 @@ export type { RouteInvocationRequest, RouteInvocationStatus, RouteInvocationSummary, + RouteInvocationSurface, RouteInvocationTiming, } from '../dev/routes/route-invocation.ts'; export type { diff --git a/packages/agent-bundle/src/dev/routes/application-tree.ts b/packages/agent-bundle/src/dev/routes/application-tree.ts index afe91621c..a4a69f760 100644 --- a/packages/agent-bundle/src/dev/routes/application-tree.ts +++ b/packages/agent-bundle/src/dev/routes/application-tree.ts @@ -201,9 +201,12 @@ const mcpServers = ( inspection: ApplicationTreeManifestSources['inspection'], ): readonly ApplicationServerGroup[] => { const servers = new Map(); + const commands = new Map((manifest?.cli?.commands ?? []) + .filter((command) => command.projection !== undefined) + .map((command) => [command.routeId, command])); for (const server of manifest?.servers ?? []) { const subgroups = mcpKinds.flatMap((kind) => { - const leaves = leavesForRoutes(server.routes.filter((route) => route.kind === kind)); + const leaves = leavesForRoutes(server.routes.filter((route) => route.kind === kind), commands); return leaves.length === 0 ? [] : [Object.freeze({ diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts index bc56e875b..b89c981db 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -60,7 +60,6 @@ const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise => - request.mode === 'unit-render' + request.surface.kind === 'unit-render' ? renderUnitRoute(request) : renderProductionRoute(request); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index 61f0ada71..dcaf2b0b9 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -114,7 +114,7 @@ const eventWrapperPath = ( request: ProductionRequest, ): string | undefined => { const event = request.manifest.routes[request.routeId]?.event; - const target = request.eventTarget; + const target = request.surface.kind === 'event' ? request.surface.host : undefined; if (event === undefined || target === undefined) return undefined; const stem = `event-route-${event.replace('/', '-')}`; const suffixed = join(request.artifactRoot, 'hooks', `${stem}.${target}.mjs`); @@ -129,10 +129,7 @@ const prepareInput = async ( signal: AbortSignal, ): Promise> => { const route = request.manifest.routes[request.routeId]; - const cliCommand = request.args === undefined - ? undefined - : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); - if (route?.kind === 'cli' || cliCommand !== undefined) { + if (request.surface.kind === 'cli') { const binRoot = join(request.artifactRoot, 'bin'); if (!existsSync(binRoot)) { throw new ProductionRouteInvocationError( @@ -147,7 +144,7 @@ const prepareInput = async ( const module = await importedModule>(join(binRoot, name)); if (typeof module.prepareRouteInvocation !== 'function') continue; return { - input: module.prepareRouteInvocation(request.routeId, request.args ?? []) as JsonValue, + input: module.prepareRouteInvocation(request.routeId, request.surface.args) as JsonValue, }; } throw new ProductionRouteInvocationError( @@ -174,21 +171,21 @@ const invocationFor = ( ): AgentRenderInvocation => { const route = request.manifest.routes[request.routeId]; if (route === undefined) throw new Error(`Route ${JSON.stringify(request.routeId)} is absent from the compiled manifest.`); - const cliCommand = request.args === undefined - ? undefined - : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); - if (cliCommand !== undefined) { - return { kind: 'cli', props: { args: request.args ?? [], command: cliCommand.path.join(' ') } }; + if (request.surface.kind === 'cli') { + return { + kind: 'cli', + props: { args: request.surface.args, command: request.surface.command }, + }; } switch (route.kind) { case 'cli': { const command = request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); if (command === undefined) throw new Error(`CLI route ${JSON.stringify(request.routeId)} has no compiled command.`); - return { kind: 'cli', props: { args: request.args ?? [], command: command.path.join(' ') } }; + return { kind: 'cli', props: { args: [], command: command.path.join(' ') } }; } case 'script': { const script = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId); - return { kind: 'script', props: { input: request.args ?? [], name: script?.name ?? request.routeId } }; + return { kind: 'script', props: { input: [], name: script?.name ?? request.routeId } }; } case 'event-route': return { @@ -214,10 +211,7 @@ const invocationFor = ( const candidatesFor = async (request: ProductionRequest): Promise => { const route = request.manifest.routes[request.routeId]; if (route === undefined) return Object.freeze([]); - if ( - request.args !== undefined - && request.manifest.cliCommands.some((candidate) => candidate.routeId === request.routeId) - ) { + if (request.surface.kind === 'cli') { return workerFiles(join(request.artifactRoot, 'bin')); } switch (route.kind) { @@ -409,7 +403,7 @@ const streamFromWorker = ( const routeProps = (request: ProductionRequest, input: JsonValue): Readonly> => { const kind = request.manifest.routes[request.routeId]?.kind; - if (kind === 'script') return { argv: request.args ?? [] }; + if (kind === 'script') return { argv: [] }; return kind === 'event-route' ? { canonical: (input as { readonly canonical?: unknown }).canonical, diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 79e80f390..1da110e32 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -42,10 +42,11 @@ import type { RouteInvocationKind, RouteInvocationProvider, RouteInvocationRequest, + RouteInvocationSurface, RouteInvocationSummary, RouteInvocationTiming, } from './route-invocation.ts'; -import type { RouteManifest, RouteManifestRoute } from './route-manifest.ts'; +import type { RouteManifest, RouteManifestCliCommand, RouteManifestRoute } from './route-manifest.ts'; import type { RouteManifestRouteService } from './route-manifest-routes.ts'; export const ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE = 'AB8231'; @@ -54,6 +55,8 @@ export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; export const ROUTE_INVOCATION_STALE_REVISION_CODE = 'AB8239'; +export const ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE = 'AB8253'; +export const ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE = 'AB8254'; export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; @@ -118,16 +121,14 @@ export interface RouteInvocationServiceOptions { } export interface RouteInvocationChildRequest { - readonly args?: readonly string[]; readonly artifactEpoch?: string; readonly artifactRoot?: string; readonly context: RequestContextProvenance; - readonly eventTarget?: RouteInvocationEventHost; readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; - readonly mode?: 'production' | 'unit-render'; readonly routeId: string; readonly stateRoot: string; + readonly surface: RouteInvocationSurface; } export interface RouteInvocationChildResult { @@ -160,6 +161,8 @@ export type RouteInvocationChildResponse = export class RouteInvocationRequestError extends Error { readonly code: | typeof ROUTE_INVOCATION_MALFORMED_REQUEST_CODE + | typeof ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE + | typeof ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE | typeof ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE | typeof ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE @@ -189,35 +192,52 @@ const malformed = (): never => { const boundedString = (value: unknown, maxLength = 4_096): value is string => typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); -const eventOptions = (value: unknown): RouteInvocationRequest['event'] => { - if (!isRecord(value) || !hasOnlyOwnKeys(value, ['fixtureId', 'host'])) return malformed(); - const fixtureId = value.fixtureId; - const host = value.host; - if (fixtureId !== undefined && !boundedString(fixtureId)) return malformed(); - if (host !== undefined && (typeof host !== 'string' || !concreteHosts.has(host as RouteInvocationEventHost))) { - return malformed(); +const surfaceOptions = (value: unknown): RouteInvocationSurface => { + if (!isRecord(value) || !boundedString(value.kind, 32)) return malformed(); + switch (value.kind) { + case 'mcp': + case 'script': + case 'unit-render': + if (!hasOnlyOwnKeys(value, ['kind'])) return malformed(); + return Object.freeze({ kind: value.kind }); + case 'cli': { + if (!hasOnlyOwnKeys(value, ['args', 'command', 'kind'])) return malformed(); + if (!boundedString(value.command)) return malformed(); + if ( + !Array.isArray(value.args) + || value.args.length > 1_024 + || value.args.some((argument) => !boundedString(argument, 16_384)) + ) return malformed(); + return Object.freeze({ args: [...value.args] as readonly string[], command: value.command, kind: 'cli' }); + } + case 'event': { + if (!hasOnlyOwnKeys(value, ['fixtureId', 'host', 'kind'])) return malformed(); + const fixtureId = value.fixtureId; + const host = value.host; + if (fixtureId !== undefined && !boundedString(fixtureId)) return malformed(); + if (host !== undefined && (typeof host !== 'string' || !concreteHosts.has(host as RouteInvocationEventHost))) { + return malformed(); + } + return Object.freeze({ + ...(fixtureId === undefined ? {} : { fixtureId }), + ...(host === undefined ? {} : { host: host as RouteInvocationEventHost }), + kind: 'event', + }); + } + default: + return malformed(); } - return Object.freeze({ - ...(fixtureId === undefined ? {} : { fixtureId }), - ...(host === undefined ? {} : { host: host as RouteInvocationEventHost }), - }); }; /** Strict wire decoder used by both the HTTP boundary and unit callers. */ export const parseRouteInvocationRequest = ( value: Readonly>, ): RouteInvocationRequest => { - if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'mode', 'routeId'])) return malformed(); + if (!hasOnlyOwnKeys(value, ['correlationId', 'input', 'routeId', 'surface'])) return malformed(); const routeId = value.routeId; const correlationId = value.correlationId; - const args = value.args; - const mode = value.mode; if (!boundedString(routeId)) return malformed(); if (correlationId !== undefined && !boundedString(correlationId, 256)) return malformed(); - if (mode !== undefined && mode !== 'production' && mode !== 'unit-render') return malformed(); - if (args !== undefined && (!Array.isArray(args) || args.length > 1_024 || args.some((argument) => !boundedString(argument, 16_384)))) { - return malformed(); - } let input: JsonValue | undefined; if (Object.hasOwn(value, 'input')) { try { @@ -226,14 +246,12 @@ export const parseRouteInvocationRequest = ( return malformed(); } } - const event = value.event === undefined ? undefined : eventOptions(value.event); + const surface = value.surface === undefined ? undefined : surfaceOptions(value.surface); return deepFreeze({ - ...(args === undefined ? {} : { args: [...args] as readonly string[] }), ...(correlationId === undefined ? {} : { correlationId }), - ...(event === undefined ? {} : { event }), ...(input === undefined ? {} : { input }), - ...(mode === undefined ? {} : { mode }), routeId, + ...(surface === undefined ? {} : { surface }), }); }; @@ -307,6 +325,88 @@ const allManifestRoutes = (manifest: RouteManifest): readonly RouteManifestRoute ...manifest.scripts, ]); +const commandName = (command: RouteManifestCliCommand): string => command.path.join(' '); + +const projectedCommandForCliId = ( + manifest: RouteManifest, + routeId: string, +): RouteManifestCliCommand | undefined => { + if (!routeId.startsWith('cli:')) return undefined; + const path = routeId.slice('cli:'.length); + return manifest.cli?.commands?.find((command) => + command.projection !== undefined + && command.routeId.startsWith('tool:') + && command.path.join('/') === path); +}; + +const defaultSurface = ( + route: RouteManifestRoute, + manifest: RouteManifest, +): RouteInvocationSurface => { + switch (route.kind) { + case 'tool': + case 'resource': + case 'prompt': + return Object.freeze({ kind: 'mcp' }); + case 'event-route': + return Object.freeze({ kind: 'event' }); + case 'script': + return Object.freeze({ kind: 'script' }); + case 'cli': { + const command = manifest.cli?.commands?.find((candidate) => candidate.routeId === route.id); + if (command === undefined) return malformed(); + return Object.freeze({ args: Object.freeze([]), command: commandName(command), kind: 'cli' }); + } + case 'app': + return malformed(); + default: { + const exhaustive: never = route.kind; + return exhaustive; + } + } +}; + +const resolvedSurface = ( + route: RouteManifestRoute, + requested: RouteInvocationSurface | undefined, + manifest: RouteManifest, +): RouteInvocationSurface => { + const surface = requested ?? defaultSurface(route, manifest); + switch (surface.kind) { + case 'mcp': + if (route.kind !== 'tool' && route.kind !== 'resource' && route.kind !== 'prompt') return malformed(); + return surface; + case 'event': + if (route.kind !== 'event-route') return malformed(); + return surface; + case 'script': + if (route.kind !== 'script') return malformed(); + return surface; + case 'unit-render': + if (route.kind === 'script') return malformed(); + return surface; + case 'cli': { + if (route.kind !== 'cli' && route.kind !== 'tool') return malformed(); + const command = manifest.cli?.commands?.find((candidate) => + candidate.routeId === route.id + && commandName(candidate) === surface.command + && (route.kind === 'cli' || candidate.projection !== undefined)); + if (command === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE, + `CLI command ${JSON.stringify(surface.command)} does not project onto canonical operation ${JSON.stringify(route.id)}.`, + 400, + ); + } + return surface; + } + default: { + const exhaustive: never = surface; + return exhaustive; + } + } +}; + const diagnostic = (code: string, message: string): Diagnostic => Object.freeze({ code, message, severity: 'error' }); @@ -318,18 +418,22 @@ const unavailable = ( const contextFor = ( route: RouteManifestRoute, root: string, - host: RouteInvocationEventHost | undefined, + surface: RouteInvocationSurface, ): RequestContextProvenance => deepFreeze({ actor: unavailable('not-provided'), - host: host === undefined + host: surface.kind !== 'event' || surface.host === undefined ? unavailable('host-omitted') - : { source: 'derived', state: 'available', value: { name: host } }, + : { source: 'derived', state: 'available', value: { name: surface.host } }, invocation: { - kind: route.kind === 'event-route' + kind: surface.kind === 'event' ? 'event' - : route.kind === 'cli' ? 'cli' : route.kind === 'script' ? 'script' : 'tool', + : surface.kind === 'cli' ? 'cli' : surface.kind === 'script' ? 'script' : 'tool', operationId: route.id, - surface: route.event ?? route.id.slice(route.id.lastIndexOf('/') + 1), + surface: surface.kind === 'cli' + ? surface.command + : surface.kind === 'event' + ? route.event + : surface.kind, }, lineage: unavailable('no-shared-runtime'), session: unavailable('not-provided'), @@ -580,7 +684,7 @@ const resultExitCode = (policy: 'result' | 'zero', result: JsonValue | undefined const invocationProjection = ( route: RouteManifestRoute, - request: RouteInvocationRequest, + surface: RouteInvocationSurface, input: JsonValue, result: JsonValue | undefined, mcp: JsonObject | undefined, @@ -589,18 +693,21 @@ const invocationProjection = ( prepared: RouteInvocationPreparedProject, registry: TargetRegistry, ): RouteInvocation['projection'] => { - if (route.kind === 'tool') { - if (mcp === undefined) throw new Error('Route invocation child omitted the tool MCP projection.'); - return deepFreeze({ mcp }); - } - if (route.kind === 'resource' || route.kind === 'prompt') { + if (surface.kind === 'mcp' || (surface.kind === 'unit-render' && route.kind === 'tool')) { + if (route.kind === 'tool') { + if (mcp === undefined) throw new Error('Route invocation child omitted the tool MCP projection.'); + return deepFreeze({ mcp }); + } return deepFreeze({ ...(jsonObject(result) === undefined ? {} : { mcp: jsonObject(result) }) }); } - if (route.kind === 'cli' || route.kind === 'script') { - const command = manifest.cli?.commands?.find((candidate) => candidate.routeId === route.id); + if (surface.kind === 'cli' || surface.kind === 'script') { + const command = surface.kind === 'cli' + ? manifest.cli?.commands?.find((candidate) => + candidate.routeId === route.id && commandName(candidate) === surface.command) + : undefined; // A plain script's exit code is its process status, carried in `result`; // a rendered script exits zero like a rendered CLI command. - const policy = route.kind === 'script' + const policy = surface.kind === 'script' ? (plainScriptFor(prepared, route) === undefined ? 'zero' : 'result') : command?.exitCode ?? 'zero'; return deepFreeze({ @@ -611,8 +718,8 @@ const invocationProjection = ( }, }); } - if (route.kind === 'event-route') { - const selected = request.event?.host === undefined ? prepared.targets : [request.event.host]; + if (surface.kind === 'event') { + const selected = surface.host === undefined ? prepared.targets : [surface.host]; const hosts = selected.map((host) => { const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); if (mapped === undefined) { @@ -630,7 +737,7 @@ const invocationProjection = ( route.event as CanonicalAgentEvent, host, mapped.nativeEvent, - request.event?.host === host && isJsonRecord(input) ? input : undefined, + surface.host === host && isJsonRecord(input) ? input : undefined, ); return { diagnostics: [], host, ...(native === undefined ? {} : { native: jsonObject(native) }) }; } catch (error) { @@ -645,7 +752,9 @@ const invocationProjection = ( }); return deepFreeze({ hosts }); } - return {}; + if (surface.kind === 'unit-render') return {}; + const exhaustive: never = surface; + return exhaustive; }; const failedInvocation = (input: { @@ -658,6 +767,7 @@ const failedInvocation = (input: { readonly request: RouteInvocationRequest; readonly route: RouteManifestRoute; readonly startedAt: Date; + readonly surface: RouteInvocationSurface; }): RouteInvocation => { const renderedInput = input.request.input; const canonical = input.route.kind === 'event-route' && renderedInput !== undefined && isJsonRecord(renderedInput) @@ -680,6 +790,7 @@ const failedInvocation = (input: { sourceRevision: input.manifest.sourceRevision, startedAt: input.startedAt.toISOString(), status: 'failed', + surface: input.surface, timings: [timing('elapsed', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], }); }; @@ -741,13 +852,22 @@ export class RouteInvocationService { } const route = allManifestRoutes(queued).find((candidate) => candidate.id === request.routeId); if (route === undefined || !invocationKinds.has(route.kind as RouteInvocationKind)) { + const projected = projectedCommandForCliId(queued, request.routeId); + if (projected !== undefined) { + const command = commandName(projected); + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE, + `CLI operation ${JSON.stringify(request.routeId)} is a projection of canonical operation ${JSON.stringify(projected.routeId)}; invoke that route with surface ${JSON.stringify({ kind: 'cli', command, args: [] })}.`, + 400, + ); + } throw new RouteInvocationRequestError( ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, `Route ${JSON.stringify(request.routeId)} is not available for invocation.`, 404, ); } - if (request.event !== undefined && route.kind !== 'event-route') return malformed(); + const surface = resolvedSurface(route, request.surface, queued); const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); const running = this.#semaphore.run(async () => { @@ -775,14 +895,7 @@ export class RouteInvocationService { 409, ); } - if ( - request.args !== undefined - && route.kind !== 'cli' - && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) - ) { - return malformed(); - } - const fixtureId = request.event?.fixtureId; + const fixtureId = surface.kind === 'event' ? surface.fixtureId : undefined; const fixture = fixtureId === undefined ? undefined : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); @@ -795,9 +908,9 @@ export class RouteInvocationService { } const rawInput = request.input ?? fixture?.input ?? {}; const input = route.kind === 'event-route' - ? eventInput(route, rawInput, request.event?.host, this.#registry) + ? eventInput(route, rawInput, surface.kind === 'event' ? surface.host : undefined, this.#registry) : rawInput; - const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); + const context = contextFor(route, prepared.manifest.projectRoot, surface); const controller = new AbortController(); this.#controllers.add(controller); if (this.#closed) { @@ -809,7 +922,6 @@ export class RouteInvocationService { try { child = plainScript === undefined ? await this.#renderChild({ - ...(request.args === undefined ? {} : { args: request.args }), ...(prepared.artifact === undefined ? {} : { @@ -817,12 +929,11 @@ export class RouteInvocationService { artifactRoot: join(prepared.manifest.projectRoot, '.agent-bundle', 'epochs', prepared.artifact.epochId), }), context, - ...(request.event?.host === undefined ? {} : { eventTarget: request.event.host }), input, manifest: prepared.manifest, - ...(request.mode === undefined ? {} : { mode: request.mode }), routeId: route.id, stateRoot: prepared.stateRoot, + surface, }, controller.signal) : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { @@ -844,6 +955,7 @@ export class RouteInvocationService { request: { ...request, input }, route, startedAt, + surface, }); } finally { clearTimeout(timeout); @@ -852,7 +964,7 @@ export class RouteInvocationService { const projectionStartedAt = this.#now(); const projection = invocationProjection( route, - request, + surface, rawInput, child.result, child.mcp, @@ -878,7 +990,9 @@ export class RouteInvocationService { // event detail detached from the identical public `input`. canonical: jsonObject(canonical)!, event: route.event!, - ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), + ...(surface.kind !== 'event' || surface.host === undefined + ? {} + : { host: surface.host, native: rawInput as JsonObject }), }, } : {}), @@ -895,6 +1009,7 @@ export class RouteInvocationService { sourceRevision: manifest.sourceRevision, startedAt: startedAt.toISOString(), status: 'succeeded', + surface, ...(child.trace === undefined ? {} : { trace: child.trace }), timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), }); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 33c21c595..85d69eccd 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -21,29 +21,27 @@ export type RouteInvocationKind = 'cli' | 'event-route' | 'prompt' | 'resource' /** The hosts an event route can be invoked as; `canonical` submits the canonical payload directly. */ export type RouteInvocationEventHost = 'claude' | 'codex' | 'cursor'; -export interface RouteInvocationEventOptions { - /** - * When present, `input` is the host's native hook payload and the service - * canonicalizes it exactly as the emitted wrapper would (the lifecycle - * replay path); when absent, `input` is the canonical event payload. - */ - readonly host?: RouteInvocationEventHost; - /** A fixture id from the route's manifest fixtures; the service seeds `input` from it when `input` is absent. */ - readonly fixtureId?: string; -} +export type RouteInvocationSurface = + | Readonly<{ readonly kind: 'mcp' }> + | Readonly<{ readonly args: readonly string[]; readonly command: string; readonly kind: 'cli' }> + | Readonly<{ + readonly fixtureId?: string; + /** When present, `input` is the host's native hook payload; otherwise it is canonical. */ + readonly host?: RouteInvocationEventHost; + readonly kind: 'event'; + }> + | Readonly<{ readonly kind: 'script' }> + | Readonly<{ readonly kind: 'unit-render' }>; export interface RouteInvocationRequest { - /** CLI routes only: the argv the routed CLI would receive after the command path. */ - readonly args?: readonly string[]; /** Browser-minted correlation id, echoed on the envelope and on the `route.invocation` project event. */ readonly correlationId?: string; - readonly event?: RouteInvocationEventOptions; - /** Tool/prompt/script input, event payload (canonical or native — see `event.host`), or resource parameters. */ + /** Tool/prompt input, event payload (canonical or native), script input, or resource parameters. */ readonly input?: JsonValue; - /** Generated-entry parity by default; component-only rendering is an explicit fallback. */ - readonly mode?: 'production' | 'unit-render'; /** The compiled route id, for example `tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`. */ readonly routeId: string; + /** Selected execution surface. Omission selects the canonical default for the route kind. */ + readonly surface?: RouteInvocationSurface; } export type RouteInvocationStatus = 'failed' | 'succeeded'; @@ -130,6 +128,8 @@ export interface RouteInvocationSummary { readonly sourceRevision: string; readonly startedAt: string; readonly status: RouteInvocationStatus; + /** The resolved surface, including defaults when the request omitted it. */ + readonly surface: RouteInvocationSurface; readonly timings: readonly RouteInvocationTiming[]; } diff --git a/packages/agent-bundle/tests/application-tree.test.ts b/packages/agent-bundle/tests/application-tree.test.ts index bfa5f6101..894491292 100644 --- a/packages/agent-bundle/tests/application-tree.test.ts +++ b/packages/agent-bundle/tests/application-tree.test.ts @@ -34,6 +34,13 @@ const manifest: RouteManifest = { options: [], path: ['library', 'audit'], routeId: 'cli:library/audit', + }, { + aliases: [], + exitCode: 'zero', + options: [], + path: ['alpha'], + projection: { mapInput: true, module: 'src/mcp/alpha/tools/a-tool.cli.ts' }, + routeId: 'tool:alpha/a-tool', }], mode: 'generated', routes: [route('cli:library/audit', 'cli', 'src/cli/library/audit.ts')], @@ -120,6 +127,11 @@ describe('application tree derivation', () => { expect(mcp.servers[0]!.subgroups[0]!.leaves.map((leaf) => leaf.label)).toEqual([ 'a-tool', 'z-tool', ]); + expect(mcp.servers[0]!.subgroups[0]!.leaves[0]?.command).toMatchObject({ + path: ['alpha'], + routeId: 'tool:alpha/a-tool', + }); + expect(applicationLeaves(result).filter((leaf) => leaf.ref.kind === 'cli')).toHaveLength(1); expect(mcp.servers[0]!.subgroups.map((group) => group.leaves[0]!.execution)).toEqual([ 'invoke', 'invoke', 'invoke', 'preview', ]); diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index 763cfbd81..3f71da7b1 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -210,6 +210,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.events.at(-1)?.type).toBe('complete'); expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); + expect(tool.invocation.surface).toEqual({ kind: 'mcp' }); expect(tool.invocation.result).toEqual({ alias: 'aliased', define: 'defined', @@ -232,7 +233,6 @@ it('invokes compiled tool and event routes through the foreground server', { tim const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ - event: { host: 'claude' }, input: { cwd: project.root, hook_event_name: 'PostToolUse', @@ -244,6 +244,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim transcript_path: join(project.root, 'transcript.json'), }, routeId: 'event:tool/after', + surface: { host: 'claude', kind: 'event' }, }), headers, method: 'POST', @@ -294,7 +295,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim ], ] as const) { const response = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ event: { host: 'claude' }, input, routeId }), + body: JSON.stringify({ input, routeId, surface: { host: 'claude', kind: 'event' } }), headers, method: 'POST', }); @@ -321,7 +322,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim } const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ args: ['Ada'], routeId: 'cli:greet' }), + body: JSON.stringify({ routeId: 'cli:greet', surface: { args: ['Ada'], command: 'greet', kind: 'cli' } }), headers, method: 'POST', }); @@ -337,10 +338,14 @@ it('invokes compiled tool and event routes through the foreground server', { tim }, result: { message: 'Hello, Ada.' }, status: 'succeeded', + surface: { args: ['Ada'], command: 'greet', kind: 'cli' }, }); const projectedCliResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ args: ['--name', 'projection'], routeId: 'tool:status/report' }), + body: JSON.stringify({ + routeId: 'tool:status/report', + surface: { args: ['--name', 'projection'], command: 'report', kind: 'cli' }, + }), headers, method: 'POST', }); @@ -354,6 +359,16 @@ it('invokes compiled tool and event routes through the foreground server', { tim source: 'cli-projection', stateRoot, }); + expect(projectedCli.invocation.projection.cli).toMatchObject({ + exitCode: 0, + text: expect.stringContaining('Service projection'), + }); + expect(projectedCli.invocation.projection.mcp).toBeUndefined(); + expect(projectedCli.invocation.surface).toEqual({ + args: ['--name', 'projection'], + command: 'report', + kind: 'cli', + }); const binName = (await readdir(join(artifactRoot, 'bin'))) .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); @@ -368,12 +383,38 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(generatedBin.code, generatedBin.stderr).toBe(0); expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); - const counter = async (mode?: 'production' | 'unit-render'): Promise => { + const mismatchedCommand = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + routeId: 'tool:status/report', + surface: { args: [], command: 'greet', kind: 'cli' }, + }), + headers, + method: 'POST', + }); + expect(mismatchedCommand.status).toBe(400); + await expect(mismatchedCommand.json()).resolves.toMatchObject({ + diagnostic: { code: 'AB8253' }, + }); + + const duplicateCliOperation = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'cli:report' }), + headers, + method: 'POST', + }); + expect(duplicateCliOperation.status).toBe(400); + await expect(duplicateCliOperation.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8254', + message: 'CLI operation "cli:report" is a projection of canonical operation "tool:status/report"; invoke that route with surface {"kind":"cli","command":"report","args":[]}.', + }, + }); + + const counter = async (unitRender = false): Promise => { const response = await fetch(`${server!.url}/api/routes/invocations`, { body: JSON.stringify({ - input: { key: mode ?? 'production' }, - ...(mode === undefined ? {} : { mode }), + input: { key: unitRender ? 'unit-render' : 'production' }, routeId: 'tool:status/counter', + ...(unitRender ? { surface: { kind: 'unit-render' } } : {}), }), headers, method: 'POST', @@ -383,7 +424,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim }; const firstCounter = await counter(); const secondCounter = await counter(); - const isolatedCounter = await counter('unit-render'); + const isolatedCounter = await counter(true); expect(firstCounter.invocation.result).toEqual({ count: 1 }); expect(secondCounter.invocation.result).toEqual({ count: 2 }); expect(isolatedCounter.invocation.result).toEqual({ count: 1 }); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 2453179eb..cd39bf33c 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -53,6 +53,7 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ sourceRevision: 'revision', startedAt: completedAt, status: 'succeeded', + surface: { kind: 'mcp' }, timings: [], trace: [{ at: 0, @@ -79,14 +80,19 @@ it('strictly validates invocation request fields and event options', () => { routeId: 'tool:curator/search_audible', }); expect(parseRouteInvocationRequest({ - event: { fixtureId: 'starter', host: 'claude' }, - mode: 'unit-render', + surface: { fixtureId: 'starter', host: 'claude', kind: 'event' }, routeId: 'event:tool/after', })).toEqual({ - event: { fixtureId: 'starter', host: 'claude' }, - mode: 'unit-render', + surface: { fixtureId: 'starter', host: 'claude', kind: 'event' }, routeId: 'event:tool/after', }); + expect(parseRouteInvocationRequest({ + surface: { args: ['--name', 'Ada'], command: 'report', kind: 'cli' }, + routeId: 'tool:status/report', + })).toEqual({ + surface: { args: ['--name', 'Ada'], command: 'report', kind: 'cli' }, + routeId: 'tool:status/report', + }); expect(parseRouteInvocationRequest({ routeId: 'tool:curator/search_audible', })).toEqual({ @@ -98,9 +104,11 @@ it('strictly validates invocation request fields and event options', () => { { routeId: '' }, { routeId: 'tool:x/y', unknown: true }, { args: ['ok', 1], routeId: 'cli:x' }, - { event: { host: 'other' }, routeId: 'event:tool/after' }, - { event: { fixtureId: '' }, routeId: 'event:tool/after' }, + { event: { host: 'claude' }, routeId: 'event:tool/after' }, { mode: 'preview', routeId: 'tool:x/y' }, + { routeId: 'event:tool/after', surface: { host: 'other', kind: 'event' } }, + { routeId: 'event:tool/after', surface: { fixtureId: '', kind: 'event' } }, + { routeId: 'tool:x/y', surface: { command: 'x', kind: 'cli' } }, ]) { expect(() => parseRouteInvocationRequest(value)).toThrow(RouteInvocationRequestError); } @@ -197,7 +205,7 @@ it('aborts and drains a running render when the service closes', async () => { }), }); - const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: echoRoute.id }); + const pending = service.invoke({ input: {}, routeId: echoRoute.id, surface: { kind: 'unit-render' } }); await started.promise; await service.close(); @@ -394,9 +402,10 @@ const tsxSiblingProject = async (): Promise => routeProject( it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { const project = await tsxSiblingProject(); try { - const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/report' }); + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report', surface: { kind: 'unit-render' } }); expect(invocation.status, JSON.stringify(invocation.diagnostics)).toBe('succeeded'); + expect(invocation.surface).toEqual({ kind: 'unit-render' }); expect(invocation.document).toBeDefined(); expectDocument(invocation.document!) .toContainText('panel rendered') @@ -427,7 +436,7 @@ const recordedPids = async (project: LeakingRouteProject): Promise { const project = await leakingRouteProject('reply'); try { - const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/leak', surface: { kind: 'unit-render' } }); const pids = await project.pids(); expect(invocation.status).toBe('succeeded'); @@ -443,7 +452,7 @@ it('reaps the render child and its descendants when the invocation times out', { const project = await leakingRouteProject('hang'); try { const service = project.service({ timeoutMs: 8_000 }); - const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak', surface: { kind: 'unit-render' } }); const pids = await recordedPids(project); expect(alive(pids.child)).toBe(true); expect(alive(pids.descendant)).toBe(true); @@ -463,7 +472,7 @@ it('reaps the render child and its descendants when the service closes mid-rende const project = await leakingRouteProject('hang'); try { const service = project.service(); - const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak', surface: { kind: 'unit-render' } }); const pids = await recordedPids(project); expect(alive(pids.child)).toBe(true); expect(alive(pids.descendant)).toBe(true); @@ -536,6 +545,7 @@ it('marks catalog providers unobserved when the child reports no observations', }); expect(result.status).toBe('succeeded'); + expect(result.surface).toEqual({ kind: 'mcp' }); expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); expect(result.providers[0]).not.toHaveProperty('durationMs'); expect(result.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); diff --git a/packages/workbench/src/application/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx index b1828a761..1f32f3d86 100644 --- a/packages/workbench/src/application/event-route-workspace.tsx +++ b/packages/workbench/src/application/event-route-workspace.tsx @@ -3,7 +3,7 @@ * selector in front of it. `Canonical` submits the canonical event payload the * route's schema describes; `Claude | Codex | Cursor` submit that host's * native hook payload — seeded from the served lifecycle fixture — as - * `event: { host, fixtureId }` so the service canonicalizes it exactly as the + * `surface: { kind: 'event', host, fixtureId }` so the service canonicalizes it exactly as the * emitted wrapper would. The plugin-visible decision (the rendered document) * stays the default result; the codec panes the old Hooks page led with are * secondary tabs: canonical → host mapping, native in / out, canonical @@ -71,9 +71,16 @@ export const eventFixturesFor = (lifecycle: Lifecycle | undefined): readonly Rou export const eventRequestFor = ( host: EventHostSelection, draft: RouteInvocationDraft, + fixtureId?: string, ): RouteInvocationDraft => { - if (host === 'canonical') return draft; - return Object.freeze({ ...draft, event: Object.freeze({ host }) }); + return Object.freeze({ + ...draft, + surface: Object.freeze({ + ...(fixtureId === undefined ? {} : { fixtureId }), + ...(host === 'canonical' ? {} : { host }), + kind: 'event', + }), + }); }; const Rows = ({ rows }: { readonly rows: readonly { readonly label: string; readonly value: string }[] }): React.ReactNode =>
@@ -165,7 +172,10 @@ const ReplayTab = ({ controller, defaultHost, lifecycle }: { return; } setError(undefined); - controller.run(Object.freeze({ event: Object.freeze({ host }), input: parsed as JsonObject })); + controller.run(Object.freeze({ + input: parsed as JsonObject, + surface: Object.freeze({ host, kind: 'event' }), + })); }; return

Replay a receipt a real host produced: paste its native payload and run it through this route exactly as the emitted wrapper would.

diff --git a/packages/workbench/src/application/executable-route-workspace.tsx b/packages/workbench/src/application/executable-route-workspace.tsx index 4aa920fb2..544191cb6 100644 --- a/packages/workbench/src/application/executable-route-workspace.tsx +++ b/packages/workbench/src/application/executable-route-workspace.tsx @@ -8,7 +8,11 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { RouteInvocationRequest, RouteInvocationSummary } from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { + RouteInvocationRequest, + RouteInvocationSummary, + RouteInvocationSurface, +} from '../../../agent-bundle/src/contracts/invocations.ts'; import { errorMessage, isAbortError, isRecord } from '../client-helpers.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; @@ -202,7 +206,7 @@ export interface ExecutableRouteWorkspaceProps { readonly leaf: ApplicationLeaf; readonly onNavigate: (location: WorkbenchLocation) => void; /** Adds request options (an event host, a fixture id) to what the editor produced. */ - readonly requestFor?: (draft: RouteInvocationDraft) => RouteInvocationDraft; + readonly requestFor?: (draft: RouteInvocationDraft, fixtureId?: string) => RouteInvocationDraft; readonly tab?: string; /** Rendered between the header and the editor (the event host selector). */ readonly toolbar?: React.ReactNode; @@ -238,8 +242,8 @@ const leafKindLabel = (leaf: ApplicationLeaf): string => { }; /** Title, kind, route id, and description — the header every workspace body shares. */ -export const WorkspaceHeader = ({ leaf }: { readonly leaf: ApplicationLeaf }): React.ReactNode =>
-

{leafKindLabel(leaf)}{leaf.routeId === undefined ? '' : ` · ${leaf.routeId}`}

+export const WorkspaceHeader = ({ leaf, surface }: { readonly leaf: ApplicationLeaf; readonly surface?: string }): React.ReactNode =>
+

{leafKindLabel(leaf)}{leaf.routeId === undefined ? '' : ` · ${leaf.routeId}`}{surface === undefined ? '' : ` · ${surface}`}

{leaf.label}

{leaf.description === undefined ? undefined :

{leaf.description}

}
; @@ -258,6 +262,31 @@ export const ExecutableRouteWorkspace = ({ toolbar, }: ExecutableRouteWorkspaceProps): React.ReactNode => { const editorLeaf = inputLeaf ?? leaf; + const projectedTool = leaf.ref.kind === 'tool' && leaf.command?.projection !== undefined; + const [selectedSurface, setSelectedSurface] = useState(() => { + switch (leaf.ref.kind) { + case 'cli': + return 'cli'; + case 'event': + return 'event'; + case 'script': + return 'script'; + case 'tool': + case 'resource': + case 'prompt': + return 'mcp'; + case 'app': + case 'skill': + case 'command': + case 'rule': + return 'unit-render'; + default: { + const exhaustive: never = leaf.ref; + return exhaustive; + } + } + }); + const cliSurface = selectedSurface === 'cli'; const storageKey = inputKey ?? leaf.key; const [input, setInput] = useState(() => { const last = readLastInput(storageKey); @@ -272,6 +301,15 @@ export const ExecutableRouteWorkspace = ({ const seededFrom = useRef(undefined); useEffect(() => { setResultTab(resultTabFor(tab)); }, [tab]); + useEffect(() => { + if ( + projectedTool + && invocation !== undefined + && (invocation.surface.kind === 'mcp' || invocation.surface.kind === 'cli' || invocation.surface.kind === 'unit-render') + ) { + setSelectedSurface(invocation.surface.kind); + } + }, [invocation, projectedTool]); // A snapshot loaded by id (deep link, trace entry) replaces the editor's // input with what that invocation actually rendered, once per snapshot. @@ -287,23 +325,38 @@ export const ExecutableRouteWorkspace = ({ }; const run = (): void => { - const submission = routeInputSubmission(editorLeaf, input); + const submission = routeInputSubmission(editorLeaf, input, cliSurface); if (submission.draft === undefined) { setInput(Object.freeze({ ...input, attempted: true })); return; } - const json = routeInputJson(editorLeaf, input); + const json = routeInputJson(editorLeaf, input, cliSurface); if (json !== undefined) writeLastInput(storageKey, json); - controller.run(requestFor === undefined ? submission.draft : requestFor(submission.draft)); + const surfaced = submission.draft.surface !== undefined + ? submission.draft + : Object.freeze({ + ...submission.draft, + surface: Object.freeze({ kind: selectedSurface }) as RouteInvocationSurface, + }); + controller.run(requestFor === undefined ? surfaced : requestFor(surfaced, input.fixtureId)); }; const failed = controller.state.phase === 'failed' ? controller.state : undefined; return
- + + {projectedTool ?
+ + + +
: undefined} {toolbar} = diff --git a/packages/workbench/src/application/invocation-model.ts b/packages/workbench/src/application/invocation-model.ts index 29c33b3b6..26cc4cc16 100644 --- a/packages/workbench/src/application/invocation-model.ts +++ b/packages/workbench/src/application/invocation-model.ts @@ -128,5 +128,6 @@ export const invocationSummaryOf = ( sourceRevision: invocation.sourceRevision, startedAt: invocation.startedAt, status: invocation.status, + surface: invocation.surface, timings: invocation.timings, }); diff --git a/packages/workbench/src/application/route-input-editor.tsx b/packages/workbench/src/application/route-input-editor.tsx index 86507dae7..1009da471 100644 --- a/packages/workbench/src/application/route-input-editor.tsx +++ b/packages/workbench/src/application/route-input-editor.tsx @@ -152,35 +152,54 @@ const cliDraft = (leaf: ApplicationLeaf, argumentsValue: RouteInputArguments): R const args = cliCommandArgv(leaf.command, argumentsValue); return args === undefined ? Object.freeze({ error: 'A required CLI option is missing.' }) - : Object.freeze({ draft: Object.freeze({ args }) }); + : Object.freeze({ + draft: Object.freeze({ + surface: Object.freeze({ args, command: leaf.command.path.join(' '), kind: 'cli' }), + }), + }); }; /** The validated input the current editor value submits, or why it cannot run. */ -export const routeInputSubmission = (leaf: ApplicationLeaf, value: RouteInputValue): RouteInputSubmission => { - const isCli = leaf.ref.kind === 'cli'; +export const routeInputSubmission = ( + leaf: ApplicationLeaf, + value: RouteInputValue, + cliSurface = leaf.ref.kind === 'cli', +): RouteInputSubmission => { if (value.mode === 'raw' || leaf.inputSchema === undefined) { - if (isCli) { + if (cliSurface) { const args = parseRawArgs(value.raw); - if (args !== undefined) return Object.freeze({ draft: Object.freeze({ args }) }); + if (args !== undefined && leaf.command !== undefined) { + return Object.freeze({ + draft: Object.freeze({ + surface: Object.freeze({ args, command: leaf.command.path.join(' '), kind: 'cli' }), + }), + }); + } } const validated = validateRawRouteInput(value.raw); if (validated.error !== undefined || validated.arguments === undefined) { - return Object.freeze({ error: isCli ? 'Enter a JSON array of argv strings or a JSON object of option values.' : validated.error ?? 'Enter a valid JSON object.' }); + return Object.freeze({ error: cliSurface ? 'Enter a JSON array of argv strings or a JSON object of option values.' : validated.error ?? 'Enter a valid JSON object.' }); } - return isCli ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); + return cliSurface ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); } const validated = validateRouteInput(leaf.inputSchema, value.draft); if (validated.arguments === undefined) { return Object.freeze({ error: 'Fix the highlighted fields before running.', fieldErrors: validated.errors }); } - return isCli ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); + return cliSurface ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); }; /** The JSON the workspace persists as the leaf's last input: the argv array for CLI leaves, the input object otherwise. */ -export const routeInputJson = (leaf: ApplicationLeaf, value: RouteInputValue): JsonValue | undefined => { - const submission = routeInputSubmission(leaf, value); +export const routeInputJson = ( + leaf: ApplicationLeaf, + value: RouteInputValue, + cliSurface = leaf.ref.kind === 'cli', +): JsonValue | undefined => { + const submission = routeInputSubmission(leaf, value, cliSurface); if (submission.draft === undefined) return undefined; - return submission.draft.args === undefined ? submission.draft.input : Object.freeze([...submission.draft.args]); + return submission.draft.surface?.kind === 'cli' + ? Object.freeze([...submission.draft.surface.args]) + : submission.draft.input; }; const editorId = (leafKey: string, key: string): string => @@ -229,6 +248,7 @@ const scalarControl = ( }; export interface RouteInputEditorProps { + readonly cliSurface?: boolean; readonly disabled?: boolean; readonly fixtures?: readonly RouteInputFixture[]; readonly leaf: ApplicationLeaf; @@ -242,9 +262,9 @@ const isRunShortcut = (event: React.KeyboardEvent): boolean => event.key === 'Enter' && (event.metaKey || event.ctrlKey); /** The workspace's input panel: form or raw JSON, fixtures, argv preview, and Run. */ -export const RouteInputEditor = ({ disabled = false, fixtures = [], leaf, onChange, onRun, running, value }: RouteInputEditorProps): React.ReactNode => { +export const RouteInputEditor = ({ cliSurface, disabled = false, fixtures = [], leaf, onChange, onRun, running, value }: RouteInputEditorProps): React.ReactNode => { const schema = leaf.inputSchema; - const submission = routeInputSubmission(leaf, value); + const submission = routeInputSubmission(leaf, value, cliSurface); const fieldErrors = value.attempted && submission.fieldErrors !== undefined ? submission.fieldErrors : {}; const rawError = value.attempted && value.mode === 'raw' && submission.error !== undefined ? submission.error : undefined; const locked = disabled || running; @@ -261,7 +281,7 @@ export const RouteInputEditor = ({ disabled = false, fixtures = [], leaf, onChan if (mode === value.mode) return; if (mode === 'raw') { // Carry the form over so switching never loses an edit. - const json = routeInputJson(leaf, value); + const json = routeInputJson(leaf, value, cliSurface); onChange(Object.freeze({ ...value, mode, raw: json === undefined ? value.raw : rawJson(json) })); return; } @@ -281,9 +301,9 @@ export const RouteInputEditor = ({ disabled = false, fixtures = [], leaf, onChan } onRun(); }; - const argv = leaf.command === undefined || submission.draft?.args === undefined + const argv = submission.draft?.surface?.kind !== 'cli' ? undefined - : [...leaf.command.path, ...submission.draft.args].join(' '); + : [submission.draft.surface.command, ...submission.draft.surface.args].join(' '); return
{value.mode === 'raw' || schema === undefined ?