diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md new file mode 100644 index 000000000..c4f0681ee --- /dev/null +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Run Workbench route invocations (`POST /api/routes/invocations`) through the published compiler epoch by default, so compiler aliases and defines, generated CLI `mapInput` and confirmation, event preflight, operator `.env` layering, and measured provider and render telemetry match the installed artifact and MCP input is validated against the compiled `inputSchema` before the handler runs; `agent-bundle dev` pins `AGENT_BUNDLE_STATE_ROOT` for invocations and dev MCP sessions to `/.agent-bundle/state` so state survives a republish. Requests replace `args`/`event` with a `surface` union (`mcp`, `cli`, `event`, `script`, `unit-render`) resolved from the route kind when omitted and recorded on every envelope, and a completed run reports an `outcome` (`success`, `represented-error`, or `process-exit` with the generated bin's exit code) separately from its execution `status` in the envelope, the `route.invocation` project event, and the Workbench. New diagnostics: `AB8239` (published revision moved while queued), `AB8250`–`AB8252` (compiled artifact, route executable, or preparation unavailable), `AB8253`/`AB8254` (CLI command or projected `cli:` id instead of the canonical `tool:` operation), and `AB8255` (preflight event route submitted without a concrete host). The unit-render loader rewrites only module specifiers from `.js` to `.tsx`, leaving string literals intact. (#643) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d09d91dae..b9cc2c9d6 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,8 @@ 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. | +| `AB8250`–`AB8255` | 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, `AB8254` a projected `cli:` id was used instead of its canonical `tool:/` id plus CLI surface, and `AB8255` an event route with compiled preflight was submitted without a concrete host 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`; select a generated host wrapper for `AB8255`. | | `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 41a59b98b..ef73058aa 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 code root is the parent @@ -894,6 +900,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 = [];', @@ -921,19 +940,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 500c5875a..9021ba25b 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -428,7 +428,10 @@ 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)};`, + ...(stateFallback === 'artifact' || options.web?.pluginRootRelativeUrl !== undefined + ? [] + : ["import { fileURLToPath } from 'node:url';"]), + `import { mapGeneratedCliInput, parseGeneratedCliArgv, renderedDocumentExitCode, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, ...(options.web === undefined ? [] : [ @@ -465,25 +468,27 @@ 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));', - ' }', - ' }', + 'const parseInput = (command, route, input) => mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input);', + 'const invocationRoute = (routeId) => {', + ' 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 { command, route };', + '};', + 'export const prepareRouteInvocation = (routeId, argv) => {', + ' const { command, route } = invocationRoute(routeId);', + ' return parseInput(command, route, parseGeneratedCliArgv(command, argv).input);', + '};', + // The exit code this bin sets for a completed rendered document: the + // same validate → status → policy decision `runRenderedInvocation` makes, + // with the failures that shell reports and exits 1 on folded to 1. + 'export const routeInvocationExitCode = (routeId, document) => {', + ' const { command, route } = invocationRoute(routeId);', ' try {', - ' return route.module.inputSchema.parse(mapped);', - ' } catch (error) {', - ' throw cliInputError(command, mapped, error);', + ' return renderedDocumentExitCode(command.exitCode, document, route.module.resultSchema.parse(document.value));', + ' } catch {', + ' return 1;', ' }', '};', '', @@ -552,6 +557,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): '', ] : []), + 'if (import.meta.main) {', ...(options.state === undefined ? [] : ['try {']), `${options.state === undefined ? '' : ' '}await runGeneratedCliProcess({`, ' commands,', @@ -577,6 +583,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): ...(options.state === undefined ? [] : ['} finally {', ' await runtimeState.close();', '}']), + '}', '', ].join('\n'); }; @@ -704,6 +711,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(' '), @@ -726,7 +737,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 @@ -734,7 +745,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();', @@ -742,6 +755,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 ? [] @@ -1019,24 +1033,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}},`, ]; @@ -1072,7 +1113,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", - "import { resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + "import { Agent, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", ...pluginRootImports('artifact'), ...generatedStateImports(options.state), ...noticeInboxImport(wiresInbox), @@ -1109,6 +1150,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(' '), @@ -1128,6 +1173,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 }),', @@ -1138,11 +1184,22 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " terminal: message.terminal ?? unavailable('not-provided'),", ' ...(message.workspace === undefined ? {} : { workspace: message.workspace }),', ' }, async () => {', + ' let validationError;', " 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());', + // The MCP server hands the worker input the SDK already validated; only + // the Workbench, which bypasses the SDK, asks the worker to validate. + ' : message.validateInput !== true ? { input: message.invocation.props.input, signal: controller.signal } : (() => {', + ' try { return { input: route.module.inputSchema.parse(message.invocation.props.input), signal: controller.signal }; }', + " catch (error) { validationError = error; return { input: message.invocation.props.input, signal: controller.signal }; }", + ' })();', + " if (message.observe === true) parentPort.postMessage({ id: message.id, type: 'observed-render-start' });", + ' const renderStartedAt = performance.now();', + " const element = validationError === undefined ? composeLayouts(observedRoute, props, controller.signal) : createElement(Agent.Result, null, createElement(Agent.Error, { code: 'invalid-input' }, `Input validation error: ${validationError instanceof Error ? validationError.message : String(validationError)}`));", + ' const flight = renderAgentFlight(element, { 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..ada73a06b 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 @@ -711,6 +751,19 @@ const resultExitCode = (policy: 'result' | 'zero', result: unknown): number => { return exitCode; }; +/** + * The exit code a completed rendered run sets once its value has passed the + * route's `resultSchema`: 1 for a non-`success` document, else the policy's + * code. Throws when the `result` policy finds no valid `exitCode` (the shell + * reports the message and exits 1). Generated bins export this decision so + * the Workbench production path records the bin's own verdict. + */ +export const renderedDocumentExitCode = ( + policy: 'result' | 'zero', + document: Pick, + value: unknown, +): number => document.status === 'success' ? resultExitCode(policy, value) : 1; + const markdownBlocks = (node: CliRenderedDocumentNode): readonly string[] => { switch (node.kind) { case 'result': @@ -896,8 +949,7 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise 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 = join(options.resolved.targetRoot, 'state'); + const stateRoot = devStateRoot(options.workspaceRoot); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, diff --git a/packages/agent-bundle/src/dev/routes/application-tree.ts b/packages/agent-bundle/src/dev/routes/application-tree.ts index afe91621c..140ffcbf5 100644 --- a/packages/agent-bundle/src/dev/routes/application-tree.ts +++ b/packages/agent-bundle/src/dev/routes/application-tree.ts @@ -27,6 +27,7 @@ export interface ApplicationLeaf { readonly inputSchema?: RouteInputSchema; readonly key: string; readonly label: string; + readonly preflight?: string; readonly ref: ApplicationNodeRef; readonly routeId?: string; readonly source?: string; @@ -171,6 +172,7 @@ const leafForRoute = ( ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), key: applicationNodeKey(ref), label: routeLabel(ref), + ...(route.execution?.preflight === undefined ? {} : { preflight: route.execution.preflight }), ref, routeId: route.id, source: route.source, @@ -201,9 +203,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 85d4f8f9e..de93d020e 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -1,10 +1,6 @@ -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 { renderedDocumentExitCode } from '../../cli-entry.ts'; import type { JsonObject } from '../../core/strict-json.ts'; import { AGENT_TEST_REGISTRY_VERSION, @@ -20,56 +16,11 @@ 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'; -/** - * 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; @@ -105,12 +56,37 @@ const respond = (response: RouteInvocationChildResponse): Promise => new P }); }); -const render = async (request: RouteInvocationChildRequest): Promise => { +/** + * The exit code a generated executable would set for this unit render. There + * is no compiled bin to ask in `unit-render`, so the same `cli-entry.ts` + * decision the bin runs is applied to the route's policy: a routed command's + * `exitCode` policy, `zero` for rendered scripts; a tool rendered in isolation + * has no process surface and reports its document outcome instead. + */ +const unitRenderExitCode = ( + request: RouteInvocationChildRequest, + document: RouteInvocationChildResult['document'], + result: unknown, +): number | undefined => { + const kind = request.manifest.routes[request.routeId]?.kind; + const command = kind === 'cli' + ? request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId) + : undefined; + const policy = command?.exitCode ?? (kind === 'script' ? 'zero' : undefined); + if (policy === undefined) return undefined; + try { + return renderedDocumentExitCode(policy, document, result); + } catch { + // A `result` policy without a valid `exitCode` is a contract failure the bin exits 1 on. + return 1; + } +}; + +const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise => { installManifest(request); const startedAt = performance.now(); const input = request.input; const rendered = await renderRouteEvents(request.routeId, { - ...(request.args === undefined ? {} : { args: request.args }), context: { actor: request.context.actor, host: request.context.host, @@ -122,9 +98,11 @@ const render = async (request: RouteInvocationChildRequest): Promise => + request.surface.kind === '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: { + ...(error instanceof ProductionRouteInvocationError + ? { 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-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 new file mode 100644 index 000000000..cce484cb0 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -0,0 +1,555 @@ +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { 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 { renderedDocumentExitCode } from '../../cli-entry.ts'; +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 { applyOperatorEnv } from '../../launch-env.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, +} from './route-invocation-production-error.ts'; +import type { RouteInvocationProvider, RouteInvocationTiming } from './route-invocation.ts'; + +interface CompiledCliInvocationModule { + prepareRouteInvocation(routeId: string, argv: readonly string[]): unknown; + /** The exit code the bin sets for this completed document (`cli-entry.ts` rules, decided by the bin). */ + routeInvocationExitCode(routeId: string, document: AgentDocument): number; +} + +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; +}>; + +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.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`); + if (existsSync(suffixed)) return suffixed; + const plain = join(request.artifactRoot, 'hooks', `${stem}.mjs`); + return existsSync(plain) ? plain : undefined; +}; + +const isCliInvocationModule = (module: Partial): module is CompiledCliInvocationModule => + typeof module.prepareRouteInvocation === 'function' && typeof module.routeInvocationExitCode === 'function'; + +interface PreparedInput { + /** The generated bin that prepared a CLI-surface input; it also decides the run's exit code. */ + readonly cli?: CompiledCliInvocationModule; + readonly input: JsonValue; + readonly preflight?: CompiledEventPreflight; +} + +const prepareInput = async ( + request: ProductionRequest, + traceEvents: EventTraceEvent[], + signal: AbortSignal, +): Promise => { + const route = request.manifest.routes[request.routeId]; + if (request.surface.kind === 'cli') { + const binRoot = join(request.artifactRoot, 'bin'); + const bins = existsSync(binRoot) + ? (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 (!isCliInvocationModule(module)) continue; + return { + cli: module, + input: module.prepareRouteInvocation(request.routeId, request.surface.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.`); + 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: [], command: command.path.join(' ') } }; + } + case 'script': { + const script = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId); + return { kind: 'script', props: { input: [], 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.surface.kind === 'cli') { + 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, + env: NodeJS.ProcessEnv, + 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, + 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', + validateInput: true, + 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: [] }; + 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, + env: NodeJS.ProcessEnv, + 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, env, 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 env: NodeJS.ProcessEnv = { + ...process.env, + [pluginRootEnvAnchor]: productionRequest.artifactRoot, + [pluginStateRootEnvAnchor]: productionRequest.stateRoot, + }; + applyOperatorEnv({ env, pluginRoot: productionRequest.artifactRoot }); + const traceEvents: EventTraceEvent[] = []; + const controller = new AbortController(); + let prepared: PreparedInput; + 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, + 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, + env, + prepared.preflight?.trace, + ); + const result = rendered.document.value; + const kind = request.manifest.routes[request.routeId]?.kind; + // A process surface records the exit code its generated executable sets: + // the bin's own decision for CLI surfaces; the rendered-script envelope's + // fixed `zero` policy (`runGeneratedRenderedScript`) for rendered scripts. + const exitCode = prepared.cli !== undefined + ? prepared.cli.routeInvocationExitCode(request.routeId, rendered.document) + : kind === 'script' + ? renderedDocumentExitCode('zero', rendered.document, result) + : undefined; + return Object.freeze({ + document: rendered.document, + events: rendered.events, + ...(exitCode === undefined ? {} : { exitCode }), + input: prepared.input, + ...(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-routes.ts b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts index 52fa516f1..9a1556d03 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts @@ -28,7 +28,10 @@ import { export interface RouteInvocationRouteService { close?(): Promise | void; - invoke(request: RouteInvocationRequest): Promise; + invoke( + request: RouteInvocationRequest, + options?: Readonly<{ readonly signal?: AbortSignal }>, + ): Promise; list(limit?: number): RouteInvocationListResponse['invocations']; read(id: string): RouteInvocation | undefined; } @@ -144,14 +147,22 @@ export class RouteInvocationRoutes { }, }); let invocation: RouteInvocation; + const controller = new AbortController(); + const cancel = (): void => controller.abort(new DOMException('Route invocation request was cancelled.', 'AbortError')); + request.once('aborted', cancel); + response.once('close', cancel); try { - invocation = await service.invoke(parseRouteInvocationRequest(body)); + if (response.destroyed) cancel(); + invocation = await service.invoke(parseRouteInvocationRequest(body), { signal: controller.signal }); } catch (error) { const failure = error as Partial; if (typeof failure.code === 'string' && typeof failure.message === 'string' && typeof failure.status === 'number') { throw requestError(diagnostic(failure.code, failure.message, failure.status)); } throw error; + } finally { + request.off('aborted', cancel); + response.off('close', cancel); } this.#eventHub.publish({ payload: { invocation: invocationSummary(invocation) }, 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..34c0a09e6 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -5,10 +5,11 @@ import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { AgentDocument } from '@agent-bundle/runtime'; +import type { AgentDocument, AgentDocumentNode } from '@agent-bundle/runtime'; import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; import type { TargetHookContract } from '../../adapters/hook-contract.ts'; +import { generatedRouteArtifactEpoch } from '../../build/entry-shell.ts'; import { projectCliDocumentToMarkdown } from '../../cli-entry.ts'; import { sleep } from '../../core/async.ts'; import type { Diagnostic } from '../../core/diagnostics.ts'; @@ -28,19 +29,26 @@ 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'; +import { + isProductionRouteInvocationCode, + ProductionRouteInvocationError, +} from './route-invocation-production-error.ts'; import type { RouteInvocation } from './route-invocation-result.ts'; import type { RouteInvocationEventHost, RouteInvocationKind, + RouteInvocationOutcome, 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'; @@ -48,6 +56,12 @@ 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_CLI_COMMAND_MISMATCH_CODE = 'AB8253'; +export const ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE = 'AB8254'; +export const ROUTE_INVOCATION_EVENT_HOST_REQUIRED_CODE = 'AB8255'; +export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = + 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; @@ -77,9 +91,16 @@ export interface RouteInvocationPreparedProject { readonly artifact?: Readonly<{ epochId: string; target: string }>; readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; + /** Writable framework state (`devStateRoot`), shared with dev MCP sessions and 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 +110,7 @@ export interface RouteInvocationServiceOptions { readonly historyLimit?: number; readonly manifest: RouteManifestRouteService; readonly now?: () => Date; - readonly prepared: () => RouteInvocationPreparedProject; + readonly prepared: () => Promise; readonly registry?: TargetRegistry; readonly renderChild?: ( request: RouteInvocationChildRequest, @@ -100,37 +121,59 @@ export interface RouteInvocationServiceOptions { } export interface RouteInvocationChildRequest { - readonly args?: readonly string[]; + readonly artifactEpoch?: string; + readonly artifactRoot?: string; readonly context: RequestContextProvenance; readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; readonly routeId: string; + readonly stateRoot: string; + readonly surface: RouteInvocationSurface; } export interface RouteInvocationChildResult { readonly document: NonNullable; readonly events: RouteInvocation['events']; + /** + * Process surfaces only: the exit code the generated executable sets for + * this completed run — a plain script's real exit status, the generated + * bin's own decision for CLI surfaces, the rendered-script rule otherwise. + */ + readonly exitCode?: number; /** The input handed to the route after hosted-event canonicalization. */ readonly input: JsonValue; /** Runtime-owned MCP projection, computed inside the runtime-bound child. */ readonly mcp?: JsonObject; - readonly renderDurationMs: number; + /** + * 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; + 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'; }>; export class RouteInvocationRequestError extends Error { readonly code: | typeof ROUTE_INVOCATION_MALFORMED_REQUEST_CODE + | typeof ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE + | typeof ROUTE_INVOCATION_EVENT_HOST_REQUIRED_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; + | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_STALE_REVISION_CODE; readonly status: 400 | 404 | 409; constructor( @@ -156,33 +199,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', 'routeId'])) return malformed(); + if (!hasOnlyOwnKeys(value, ['correlationId', 'input', 'routeId', 'surface'])) return malformed(); const routeId = value.routeId; const correlationId = value.correlationId; - const args = value.args; if (!boundedString(routeId)) return malformed(); if (correlationId !== undefined && !boundedString(correlationId, 256)) 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 { @@ -191,13 +253,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 }), routeId, + ...(surface === undefined ? {} : { surface }), }); }; @@ -210,6 +271,7 @@ export const invocationSummary = (invocation: RouteInvocation): RouteInvocationS projection: _projection, providers: _providers, result: _result, + trace: _trace, ...summary } = invocation; return deepFreeze(summary); @@ -249,10 +311,7 @@ class InvocationSemaphore { this.#limit = limit; } - async run(operation: () => Promise): Promise { - if (this.#active >= this.#limit) { - await new Promise((resolvePromise) => this.#waiting.push(resolvePromise)); - } + async #execute(operation: () => Promise): Promise { this.#active += 1; try { return await operation(); @@ -261,6 +320,24 @@ class InvocationSemaphore { this.#waiting.shift()?.(); } } + + async run(operation: () => Promise, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + if (this.#active < this.#limit) return this.#execute(operation); + return new Promise((resolvePromise, rejectPromise) => { + const start = (): void => { + signal?.removeEventListener('abort', abort); + void this.#execute(operation).then(resolvePromise, rejectPromise); + }; + const abort = (): void => { + const index = this.#waiting.indexOf(start); + if (index !== -1) this.#waiting.splice(index, 1); + rejectPromise(signal?.reason); + }; + this.#waiting.push(start); + signal?.addEventListener('abort', abort, { once: true }); + }); + } } const allManifestRoutes = (manifest: RouteManifest): readonly RouteManifestRoute[] => Object.freeze([ @@ -270,6 +347,95 @@ 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(); + if (route.execution?.preflight !== undefined && surface.host === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_EVENT_HOST_REQUIRED_CODE, + `Event route ${JSON.stringify(route.id)} has compiled preflight; select an event host surface with a concrete host.`, + 400, + ); + } + 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' }); @@ -281,18 +447,26 @@ 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' ? 'event' - : route.kind === 'cli' ? 'cli' : route.kind === 'script' ? 'script' : 'tool', + : route.kind === 'cli' + ? 'cli' + : route.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'), @@ -350,6 +524,7 @@ const runPlainScript = async ( return deepFreeze({ document, events: [{ document, sequence: 1, type: 'complete' }], + exitCode: run.exitCode, input, renderDurationMs: performance.now() - startedAt, result: { exitCode: run.exitCode, stderr: run.stderr, stdout: run.stdout }, @@ -437,8 +612,9 @@ 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; + const error = isProductionRouteInvocationCode(message.error.code) + ? new ProductionRouteInvocationError(message.error.code, message.error.message) + : Object.assign(new Error(message.error.message), { name: message.error.name }); return settle(() => rejectPromise(error)); } settle(() => resolvePromise(message.result)); @@ -502,100 +678,180 @@ 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)) ?? []), + ...(child.renderDurationMs === undefined ? [] : [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); return isJsonRecord(snapshot) ? snapshot : undefined; }; -const resultExitCode = (policy: 'result' | 'zero', result: JsonValue | undefined): number => { - if (policy === 'zero') return 0; - if (result === undefined || !isJsonRecord(result)) return 1; - const value = result.exitCode; - return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 ? value : 1; +const appendErrorSummaries = (node: AgentDocumentNode, summaries: string[]): void => { + switch (node.kind) { + case 'result': + for (const child of node.children) appendErrorSummaries(child, summaries); + break; + case 'error': + summaries.push(`[${node.code}] ${node.message}`); + break; + case 'audio': + case 'context': + case 'image': + case 'json': + case 'markdown': + case 'progress': + case 'resource': + case 'text': + break; + default: { + const exhaustive: never = node; + throw new Error(`Unsupported Agent Document node ${String((exhaustive as { kind?: unknown }).kind)}.`); + } + } +}; + +/** The `Agent.Error` nodes of a represented-error document, as the MCP projection prints them. */ +const documentErrorSummary = (document: AgentDocument): string => { + const summaries: string[] = []; + appendErrorSummaries(document.root, summaries); + return summaries.length === 0 ? `The document reports status ${document.status}.` : summaries.join('; '); +}; + +/** + * What the completed run meant, judged by the surface it ran through. A + * process surface reports the exit code its executable decided (`exitCode` is + * only ever set by one); an MCP surface reports a projected `isError`; an + * event surface reports an error document or a `deny` decision. + */ +const invocationOutcome = ( + route: RouteManifestRoute, + child: RouteInvocationChildResult, +): RouteInvocationOutcome => { + if (child.exitCode !== undefined) { + return child.exitCode === 0 ? { kind: 'success' } : { exitCode: child.exitCode, kind: 'process-exit' }; + } + if (child.mcp?.isError === true || child.document.status !== 'success') { + return { kind: 'represented-error', summary: documentErrorSummary(child.document) }; + } + const decision: unknown = child.result ?? child.document.value; + if (route.kind === 'event-route' && isRecord(decision) && decision.outcome === 'deny') { + return { + kind: 'represented-error', + summary: typeof decision.reason === 'string' ? `deny: ${decision.reason}` : 'deny', + }; + } + return { kind: 'success' }; +}; + +const unitRenderProjectionKind = (route: RouteManifestRoute): RouteInvocationSurface['kind'] => { + switch (route.kind) { + case 'tool': + case 'resource': + case 'prompt': + return 'mcp'; + case 'event-route': + return 'event'; + case 'cli': + return 'cli'; + case 'script': + return 'script'; + case 'app': + return 'unit-render'; + default: { + const exhaustive: never = route.kind; + return exhaustive; + } + } }; const invocationProjection = ( route: RouteManifestRoute, - request: RouteInvocationRequest, + requested: RouteInvocationSurface, input: JsonValue, - result: JsonValue | undefined, - mcp: JsonObject | undefined, - document: NonNullable, - manifest: RouteManifest, + child: RouteInvocationChildResult, 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') { + const { document, mcp, result } = child; + // An isolated render is projected the way the route's default surface would be. + const kind = requested.kind === 'unit-render' ? unitRenderProjectionKind(route) : requested.kind; + const host = requested.kind === 'event' ? requested.host : undefined; + if (kind === 'mcp') { + 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); - // 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' - ? (plainScriptFor(prepared, route) === undefined ? 'zero' : 'result') - : command?.exitCode ?? 'zero'; + if (kind === 'cli' || kind === 'script') { + // The exit code is the executable's own: a plain script's process status, + // the generated bin's decision, or the rendered-script rule the child applied. + if (child.exitCode === undefined) throw new Error('Route invocation child omitted the process exit code.'); return deepFreeze({ cli: { - exitCode: resultExitCode(policy, result), + exitCode: child.exitCode, ...(result === undefined ? {} : { json: result }), text: projectCliDocumentToMarkdown(document), }, }); } - if (route.kind === 'event-route') { - const selected = request.event?.host === undefined ? prepared.targets : [request.event.host]; - const hosts = selected.map((host) => { - const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); + if (kind === 'event') { + const selected = host === undefined ? prepared.targets : [host]; + const hosts = selected.map((target) => { + const mapped = eventContract(registry, target, route.event as CanonicalAgentEvent); if (mapped === undefined) { return { diagnostics: [diagnostic( 'route.invocation.projection.unsupported', - `Event ${JSON.stringify(route.event)} cannot be projected to ${host}.`, + `Event ${JSON.stringify(route.event)} cannot be projected to ${target}.`, )], - host, + host: target, }; } try { const native = projectEventDocument( document, route.event as CanonicalAgentEvent, - host, + target, mapped.nativeEvent, - request.event?.host === host && isJsonRecord(input) ? input : undefined, + host === target && isJsonRecord(input) ? input : undefined, ); - return { diagnostics: [], host, ...(native === undefined ? {} : { native: jsonObject(native) }) }; + return { diagnostics: [], host: target, ...(native === undefined ? {} : { native: jsonObject(native) }) }; } catch (error) { return { diagnostics: [diagnostic( 'route.invocation.projection.failed', error instanceof Error ? error.message : String(error), )], - host, + host: target, }; } }); return deepFreeze({ hosts }); } + // An `app` route rendered in isolation has no host projection. return {}; }; @@ -609,6 +865,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) @@ -625,13 +882,14 @@ 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())], + surface: input.surface, + timings: [timing('elapsed', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], }); }; @@ -641,13 +899,13 @@ 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; readonly #semaphore: InvocationSemaphore; readonly #timeoutMs: number; - #closed = false; + readonly #closeController = new AbortController(); constructor(options: RouteInvocationServiceOptions) { this.#history = new InvocationRingBuffer(options.historyLimit); @@ -671,19 +929,20 @@ export class RouteInvocationService { } async close(): Promise { - this.#closed = true; + this.#closeController.abort(new DOMException('Route invocation service closed.', 'AbortError')); for (const controller of this.#controllers) { controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); } await Promise.allSettled([...this.#pending]); } - async invoke(request: RouteInvocationRequest): Promise { - let manifest: RouteManifest; - let prepared: RouteInvocationPreparedProject; + async invoke( + request: RouteInvocationRequest, + options: Readonly<{ readonly signal?: AbortSignal }> = {}, + ): Promise { + 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,133 +951,171 @@ 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)) { + 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') - || (request.args !== undefined && route.kind !== 'cli') - ) { - 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 surface = resolvedSurface(route, request.surface, queued); 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 admissionSignal = options.signal === undefined + ? this.#closeController.signal + : AbortSignal.any([this.#closeController.signal, options.signal]); 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); + admissionSignal.throwIfAborted(); + 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 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 = surface.kind === 'event' ? surface.fixtureId : undefined; + 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, surface.kind === 'event' ? surface.host : undefined, this.#registry) + : rawInput; + const context = contextFor(route, prepared.manifest.projectRoot, surface); + admissionSignal.throwIfAborted(); + const controller = new AbortController(); + const abort = (): void => controller.abort(admissionSignal.reason); + this.#controllers.add(controller); + admissionSignal.addEventListener('abort', abort, { once: true }); + 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({ + ...(prepared.artifact === undefined + ? {} + : { + artifactEpoch: generatedRouteArtifactEpoch(prepared.manifest.plugin), + artifactRoot: join(prepared.manifest.projectRoot, '.agent-bundle', 'epochs', prepared.artifact.epochId), + }), + context, + input, + manifest: prepared.manifest, + routeId: route.id, + stateRoot: prepared.stateRoot, + surface, + }, controller.signal) + : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); + } catch (error) { + const completedAt = this.#now(); + const childCode = error instanceof ProductionRouteInvocationError + ? error.code + : ROUTE_INVOCATION_CHILD_FAILURE_CODE; + return failedInvocation({ + code: childCode, + completedAt, context, - input, - manifest: prepared.manifest, - routeId: route.id, - }, controller.signal) - : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); - } catch (error) { + id, + manifest, + message: controller.signal.reason instanceof DOMException && controller.signal.reason.name === 'TimeoutError' + ? 'Route invocation child timed out.' + : options.signal?.aborted === true + ? 'Route invocation child stopped because the request was cancelled.' + : 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, + surface, + }); + } finally { + clearTimeout(timeout); + admissionSignal.removeEventListener('abort', abort); + this.#controllers.delete(controller); + } + const projectionStartedAt = this.#now(); + const projection = invocationProjection(route, surface, rawInput, child, prepared, this.#registry); const completedAt = this.#now(); - return failedInvocation({ - code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, - completedAt, + 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!, + ...(surface.kind !== 'event' || surface.host === undefined + ? {} + : { host: surface.host, native: rawInput as JsonObject }), + }, + } + : {}), + events: child.events, 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, + input: canonical ?? child.input, + kind: route.kind as RouteInvocationKind, + manifestDigest: manifest.digest, + outcome: invocationOutcome(route, child), + 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', + surface, + ...(child.trace === undefined ? {} : { trace: child.trace }), + timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), }); } finally { - clearTimeout(timeout); - this.#controllers.delete(controller); + await release?.(); } - 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: providerProjection(manifest, 0, 'mounted'), - ...(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()), - ], - }); - }); + }, admissionSignal); this.#pending.add(running); let invocation: RouteInvocation; try { diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 922e55669..498700a34 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -21,41 +21,74 @@ 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; /** 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; } +/** + * Whether the execution boundary completed. `succeeded` means the route ran + * to a final document (or a plain script exited) and the envelope carries + * what it produced; what the run *meant* is `outcome`. `failed` means the + * boundary never completed — child crash, timeout, abort, `AB825x`. + */ export type RouteInvocationStatus = 'failed' | 'succeeded'; +/** + * The application result of a completed run, judged by the surface the route + * was invoked through. `represented-error`: the MCP projection carries + * `isError: true` (a non-`success` Agent Document), or an event route's + * decision is `deny`. `process-exit`: the generated CLI bin (or script + * executable) sets a non-zero exit code for this run — the bin's own rule, + * captured on the production path, never re-derived here. + */ +export type RouteInvocationOutcome = + | Readonly<{ readonly kind: 'success' }> + | Readonly<{ readonly kind: 'represented-error'; readonly summary: string }> + | Readonly<{ readonly exitCode: number; readonly kind: 'process-exit' }>; + 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; @@ -100,7 +133,7 @@ export interface RouteInvocationEvent { export interface RouteInvocationSummary { readonly completedAt: string; readonly correlationId?: string; - /** Failure diagnostics; empty when the route rendered. A `represented-error` document is a success with an error node, not a failure. */ + /** Failure diagnostics; empty when the route rendered. A `represented-error` document completes the boundary and is reported through `outcome`, not here. */ readonly diagnostics: readonly Diagnostic[]; readonly event?: RouteInvocationEvent; readonly id: string; @@ -109,11 +142,15 @@ export interface RouteInvocationSummary { readonly kind: RouteInvocationKind; /** The route manifest digest the invocation resolved the route through. */ readonly manifestDigest: string; + /** Present on every `succeeded` invocation; absent when the boundary did not complete. */ + readonly outcome?: RouteInvocationOutcome; readonly routeId: string; readonly source: string; 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/src/dev/routes/route-module-loader.ts b/packages/agent-bundle/src/dev/routes/route-module-loader.ts new file mode 100644 index 000000000..421adac8c --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-module-loader.ts @@ -0,0 +1,99 @@ +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'; + +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; + +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 specifier = literal.text.slice(0, -'.js'.length); + const stem = resolve(directory, specifier); + if (existsSync(`${stem}.js`) || existsSync(`${stem}.ts`) || !existsSync(`${stem}.tsx`)) continue; + const quote = source[literal.start]!; + rewritten = `${rewritten.slice(0, literal.start)}${quote}${specifier}.tsx${quote}${rewritten.slice(literal.end)}`; + } + return rewritten; +}; + +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/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 d872b557b..47ec8e761 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -9,9 +9,10 @@ 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 { 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 } 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,6 +57,8 @@ 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, } from './routes/route-invocation-service.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: devStateRoot(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/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index 0ef961a07..c4c3b24e1 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -20,7 +20,7 @@ import type * as AgentRuntime from '@agent-bundle/runtime'; import type { RegisteredRouteId } from '@agent-bundle/runtime'; -import { runGeneratedCliEntry } from '../cli-entry.ts'; +import { mapGeneratedCliInput, runGeneratedCliEntry } from '../cli-entry.ts'; import type { CliRenderedEvent } from '../cli-entry.ts'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; @@ -31,7 +31,6 @@ import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers. import { registeredRouteLoader, testManifest } from './registry.ts'; import { loadCliProjectionModule, - parseCliCommandInput, prepareCliRenderHost, type HarnessOptionsArguments, type RenderRouteContextInit, @@ -250,12 +249,7 @@ export const invokeCli = async ( recovery: 'Export both zod schemas from the command module; the routed CLI validates argv through them.', }); } - const parsed = parseCliCommandInput( - command, - module.inputSchema, - await loadCliProjectionModule(manifest, command), - input, - ); + const parsed = mapGeneratedCliInput(command, module.inputSchema, await loadCliProjectionModule(manifest, command), input); const root = process.cwd(); const plugin = harnessPluginRoot({ context, manifest, resolvePluginRoot: runtime.resolvePluginRoot }); // Same provider invocation the generated plain-command path builds (#366). 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..c53569de1 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -26,10 +26,7 @@ import type { } from '@agent-bundle/runtime'; import type * as React from 'react'; -import { - CliInputError, - cliInputError, -} from '../cli-entry.ts'; +import { mapGeneratedCliInput } from '../cli-entry.ts'; import type { CliRenderedEvent, GeneratedCliRenderContext, @@ -1087,39 +1084,6 @@ export const loadCliProjectionModule = async ( } }; -/** - * Mirrors the generated bin's explicit defaults, mapping, and canonical - * validation boundary; confirmation is the shell's (`parseMcpCommandInput`). - */ -export const parseCliCommandInput = ( - command: CompiledCliCommand, - 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); - } -}; - /** * Accepts preloaded route modules and prepares the renderer and manifest * state before the synchronous generated-shell render factory is installed. @@ -1174,12 +1138,7 @@ export const prepareCliRenderHost = async ( }, ); } - const parsed = parseCliCommandInput( - command, - module.inputSchema, - projectionModule, - input, - ); + const parsed = mapGeneratedCliInput(command, module.inputSchema, projectionModule, input); const commandName = command.path.join(' '); const invocation: AgentRenderInvocation = { kind: 'cli', diff --git a/packages/agent-bundle/tests/application-tree.test.ts b/packages/agent-bundle/tests/application-tree.test.ts index bfa5f6101..00330975e 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')], @@ -41,7 +48,10 @@ const manifest: RouteManifest = { diagnostics: [{ code: 'AB4801', message: 'Fixture diagnostic.', severity: 'warning' }], digest: 'd'.repeat(64), events: [ - route('event:tool/before', 'event-route', 'src/events/tool/before.ts', { event: 'tool/before' }), + route('event:tool/before', 'event-route', 'src/events/tool/before.ts', { + event: 'tool/before', + execution: { fallback: 'standalone', preflight: 'src/events/tool/before.preflight.ts', runtime: 'standalone' }, + }), ], providers: [], scripts: [ @@ -104,6 +114,12 @@ const tree = () => applicationTreeForManifest({ }); describe('application tree derivation', () => { + it('carries compiled event preflight metadata to the workspace leaf', () => { + expect(applicationLeafForRouteId(tree(), 'event:tool/before')).toMatchObject({ + preflight: 'src/events/tool/before.preflight.ts', + }); + }); + it('covers every route kind in fixed group and subgroup order', () => { const result = tree(); @@ -120,6 +136,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/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 538d7c893..5854a8a9a 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -346,7 +346,7 @@ describe('generated entry templates', () => { // the generator without #564 (hash of the same input on this commit's // `entry-shell.ts`; #596's projection steps and `kind: 'cli'` request // moved the pin from the pre-#564 value, #637's `stateAnchor` moved it - // again). + // again, #643's `routeInvocationExitCode` export moved it once more). const withoutWeb = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, @@ -354,7 +354,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('ad8c21f371af0043464162750a8ed557d968f6155cdd9521ee63c0275253710a'); + .toBe('fad5a6fe047fe71e2e061a5f7d1880d39502afaa5e725538e7de364499334580'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -695,8 +695,11 @@ 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(source).toContain('route.module.inputSchema.parse(message.invocation.props.input)'); + expect(source).toContain('message.validateInput !== true ? { input: message.invocation.props.input'); + expect(source).toContain("createElement(Agent.Error, { code: 'invalid-input' }"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '4e2c248b5358b7e13650f2156cf282b03f6f7ede20e2badabafc4b33ae5b4bd5', + '9780b027d8d5fef12aa0843ba9eb5ab6bd0336ef137ec1bfa552a2ff19daa217', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -824,19 +827,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(' ') } }", ); @@ -1412,7 +1407,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('validationError === undefined ? composeLayouts(observedRoute, props, controller.signal)'); }); it('imports only the layouts some route of the worker composes through, never another server\'s layout', () => { @@ -1526,7 +1521,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/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 3d09f6b96..62bcda43e 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -263,9 +263,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 cba58b6aa..a9e46ff9e 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -1,16 +1,23 @@ -import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; 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 { confirmationRequiredMessage } from '../src/cli-entry.ts'; +import { stableJson } from '../src/core/digest.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'; 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(); @@ -29,17 +36,24 @@ const readEvent = async (response: Response, type: string): Promise { +it('invokes compiled tool and event routes through the foreground server', { timeout: 180_000 }, async () => { 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,35 +69,153 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), - 'src/events/tool/after.tsx': [ + 'src/cli/exit.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", + "import { z } from 'zod';", '', - "export const config = { runtime: 'standalone' };", + "export const config = { description: 'Exits with the requested code.', exitCode: 'result', positionals: ['code'] };", + 'export const inputSchema = z.object({ code: z.number().int().min(0).max(255) }).strict();', + 'export const resultSchema = z.object({ exitCode: z.number() }).strict();', + '', + 'export default async function Exit({ input }) {', + ' return createElement(Agent.Result, { value: { exitCode: input.code } }, createElement(Agent.Text, null, `Exiting ${input.code}.`));', + '}', + '', + ].join('\n'), + 'src/events/tool/after.preflight.ts': [ + "import { appendFileSync } from 'node:fs';", + "import { join } from 'node:path';", + '', + 'export default () => {', + " appendFileSync(join(process.cwd(), '.agent-bundle', 'defer-gate.marker'), 'gate\\n');", + " return 'execute';", + '};', + '', + ].join('\n'), + 'src/events/tool/after.tsx': [ + "import { appendFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export { default as preflight } from './after.preflight.js';", + '', + "export const config = { providers: ['clock'], runtime: 'standalone' };", '', 'export default async function AfterTool({ canonical }) {', - " return createElement(Agent.Result, null, createElement(Agent.Context, null, `Observed ${canonical.payload.toolName}.`));", + ' const context = await agent();', + " appendFileSync(join(process.cwd(), '.agent-bundle', 'defer-handler.marker'), 'run\\n');", + " const value = { outcome: 'defer', providers: Object.keys(context.providers).sort() };", + " return createElement(Agent.Result, { value }, createElement(Agent.Context, null, `Observed ${canonical.payload.toolName}.`));", '}', '', ].join('\n'), - 'src/mcp/status/tools/report.tsx': [ + 'src/events/prompt/submit.preflight.ts': "export default () => ({ outcome: 'continue' });\n", + 'src/events/prompt/submit.tsx': [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "export { default as preflight } from './submit.preflight.js';", + "export const config = { runtime: 'standalone' };", + "export default async function PromptSubmit() {", + " writeFileSync(join(process.cwd(), '.agent-bundle', 'continue-handler.marker'), 'ran');", + " 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': [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "export { default as preflight } from './before.preflight.js';", + "export const config = { runtime: 'standalone' };", + "export default async function BeforeTool() {", + " writeFileSync(join(process.cwd(), '.agent-bundle', 'deny-handler.marker'), 'ran');", + " 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/refuse.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", "import { z } from 'zod';", '', + 'export const inputSchema = z.object({ reason: z.string() }).strict();', + 'export const resultSchema = z.object({ refused: z.boolean() }).strict();', + 'export default async function Refuse({ input }) {', + " return createElement(Agent.Result, { value: { refused: true } }, createElement(Agent.Error, { code: 'refused' }, `Refused: ${input.reason}`));", + '}', + '', + ].join('\n'), + 'src/mcp/status/tools/report.tsx': [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { ALIAS_VALUE } from '@fixture/value';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "import { Panel } from './panel.js';", + '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(), pluginRoot: z.string(), service: z.string(), source: z.string(), stateRoot: 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 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(Panel), createElement(Agent.Text, null, './panel.js'), createElement(Agent.Text, null, `Service ${input.service}`));", + '}', + '', + ].join('\n'), + 'src/mcp/status/tools/panel.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "export const Panel = () => createElement(Agent.Text, null, 'panel rendered');", + "export const inputSchema = z.object({}).strict();", + "export const resultSchema = z.object({ panel: z.literal(true) }).strict();", + 'export default async function PanelRoute() {', + " return createElement(Agent.Result, { value: { panel: true } }, createElement(Panel));", '}', '', ].join('\n'), + 'src/mcp/status/tools/report.cli.ts': [ + "export const config = { command: ['report'], confirm: true, 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';", @@ -132,24 +264,87 @@ 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(project.root, '.agent-bundle', 'state'); 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.outcome).toEqual({ kind: 'success' }); 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', + pluginRoot: artifactRoot, + service: 'catalog', + source: 'api', + stateRoot, + }); + const mcpName = (await readdir(join(artifactRoot, 'mcp'))) + .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); + if (mcpName === undefined) throw new Error('Expected a generated MCP server.'); + const mcpTransport = new StdioClientTransport({ + args: [join(artifactRoot, 'mcp', mcpName)], + command: process.execPath, + cwd: project.root, + env: { + [pluginRootEnvAnchor]: artifactRoot, + [pluginStateRootEnvAnchor]: stateRoot, + }, + stderr: 'pipe', + }); + const mcpClient = new Client({ name: 'route-invocation-document-parity', version: '1.0.0' }); + await mcpClient.connect(mcpTransport); + try { + const generatedMcp = await mcpClient.callTool({ + arguments: { service: 'catalog', source: 'api' }, + name: 'report', + }); + expect(stableJson(tool.invocation.projection.mcp)).toBe(stableJson(generatedMcp)); + } finally { + await mcpClient.close(); + } expect(tool.invocation.providers).toEqual([ - expect.objectContaining({ name: 'clock', status: 'mounted' }), + expect.objectContaining({ durationMs: expect.any(Number), id: 'provider:clock', name: 'clock', status: 'mounted' }), + ]); + 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); + + // A completed run whose document represents an error: the boundary + // succeeded, the MCP projection says `isError`, and the outcome says so too. + const refuseResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: { reason: 'policy' }, routeId: 'tool:status/refuse' }), + headers, + method: 'POST', + }); + expect(refuseResponse.status).toBe(200); + const refuse = await refuseResponse.json() as RouteInvocationResponse; + expect(refuse.invocation.status, JSON.stringify(refuse.invocation.diagnostics)).toBe('succeeded'); + expect(refuse.invocation.document?.status).toBe('represented-error'); + expect(refuse.invocation.projection.mcp).toMatchObject({ isError: true }); + expect(refuse.invocation.outcome).toEqual({ + kind: 'represented-error', + summary: '[refused] Refused: policy', + }); + expect(refuse.invocation.result).toEqual({ refused: true }); const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ - event: { host: 'claude' }, input: { cwd: project.root, hook_event_name: 'PostToolUse', @@ -161,6 +356,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', @@ -168,13 +364,91 @@ it('invokes compiled tool and event routes through the foreground server', { tim const eventFailure = eventResponse.status === 200 ? undefined : await eventResponse.clone().text(); expect(eventResponse.status, eventFailure).toBe(200); const event = await eventResponse.json() as RouteInvocationResponse; - expect(event.invocation.status).toBe('succeeded'); + expect(event.invocation.status, JSON.stringify(event.invocation.diagnostics)).toBe('succeeded'); + expect(event.invocation.outcome).toEqual({ kind: 'success' }); expect(event.invocation.events.at(-1)?.type).toBe('complete'); expect(event.invocation.document).toBeDefined(); + expect(event.invocation.result).toEqual({ outcome: 'defer', providers: ['clock', 'processLifetime'] }); + expect(await readFile(join(project.root, '.agent-bundle', 'defer-gate.marker'), 'utf8')).toBe('gate\n'); + expect(await readFile(join(project.root, '.agent-bundle', 'defer-handler.marker'), 'utf8')).toBe('run\n'); 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({ input, routeId, surface: { host: 'claude', kind: 'event' } }), + 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.outcome).toEqual( + expected.outcome === 'deny' + ? { kind: 'represented-error', summary: 'deny: blocked by preflight' } + : { kind: 'success' }, + ); + expect(invoked.invocation.events).toEqual([]); + expect(invoked.invocation.trace?.map((trace) => trace.kind)).toEqual([ + 'preflight.start', + 'preflight.outcome', + ]); + expect(existsSync(join( + project.root, + '.agent-bundle', + routeId === 'event:tool/before' ? 'deny-handler.marker' : 'continue-handler.marker', + ))).toBe(false); + 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({ routeId: 'cli:greet', surface: { args: ['Ada'], command: 'greet', kind: 'cli' } }), headers, method: 'POST', }); @@ -188,10 +462,162 @@ it('invokes compiled tool and event routes through the foreground server', { tim text: expect.stringContaining('Hello, Ada.'), }, }, + outcome: { kind: 'success' }, result: { message: 'Hello, Ada.' }, status: 'succeeded', + surface: { args: ['Ada'], command: 'greet', kind: 'cli' }, + }); + + // A completed run whose bin exits non-zero: `status` stays `succeeded` + // (the boundary completed), the outcome carries the bin's own exit code, + // and the generated bin agrees when run as a real process. `unit-render` + // has no bin and no argv parser, so it takes the parsed input and applies + // the same `cli-entry.ts` exit-code rule to the route's policy. + const exitInvocation = async (unitRender = false): Promise => { + const response = await fetch(`${server!.url}/api/routes/invocations`, { + body: JSON.stringify({ + ...(unitRender + ? { input: { code: 3 }, surface: { kind: 'unit-render' } } + : { surface: { args: ['3'], command: 'exit', kind: 'cli' } }), + routeId: 'cli:exit', + }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + return response.json() as Promise; + }; + for (const exit of [await exitInvocation(), await exitInvocation(true)]) { + expect(exit.invocation, JSON.stringify(exit.invocation.diagnostics)).toMatchObject({ + kind: 'cli', + outcome: { exitCode: 3, kind: 'process-exit' }, + projection: { cli: { exitCode: 3, text: expect.stringContaining('Exiting 3.') } }, + result: { exitCode: 3 }, + status: 'succeeded', + }); + } + + const confirmationMessage = confirmationRequiredMessage('status', 'report'); + const unconfirmedProjectedCliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + routeId: 'tool:status/report', + surface: { args: ['--name', 'projection'], command: 'report', kind: 'cli' }, + }), + headers, + method: 'POST', + }); + expect(unconfirmedProjectedCliResponse.status).toBe(200); + const unconfirmedProjectedCli = await unconfirmedProjectedCliResponse.json() as RouteInvocationResponse; + expect(unconfirmedProjectedCli.invocation.status).toBe('failed'); + expect(unconfirmedProjectedCli.invocation.diagnostics[0]?.message).toContain(confirmationMessage); + + const projectedCliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + routeId: 'tool:status/report', + surface: { args: ['--name', 'projection', '--yes'], command: 'report', kind: 'cli' }, + }), + headers, + method: 'POST', + }); + expect(projectedCliResponse.status).toBe(200); + const projectedCli = await projectedCliResponse.json() as RouteInvocationResponse; + expect(projectedCli.invocation.result).toMatchObject({ + alias: 'aliased', + define: 'defined', + pluginRoot: artifactRoot, + service: 'projection', + 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', '--yes'], + 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.'); + const generatedUnconfirmed = await runNodeScript({ + args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], + cwd: project.root, + env: { + [pluginRootEnvAnchor]: artifactRoot, + [pluginStateRootEnvAnchor]: stateRoot, + }, + }); + expect(generatedUnconfirmed.code).toBe(2); + expect(generatedUnconfirmed.stderr).toContain(confirmationMessage); + const generatedBin = await runNodeScript({ + args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--yes', '--json'], + cwd: project.root, + env: { + [pluginRootEnvAnchor]: artifactRoot, + [pluginStateRootEnvAnchor]: stateRoot, + }, + }); + expect(generatedBin.code, generatedBin.stderr).toBe(0); + expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); + const generatedExit = await runNodeScript({ + args: [join(artifactRoot, 'bin', binName), 'exit', '3'], + cwd: project.root, + env: { + [pluginRootEnvAnchor]: artifactRoot, + [pluginStateRootEnvAnchor]: stateRoot, + }, + }); + expect(generatedExit.code, generatedExit.stderr).toBe(3); + + 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: unitRender ? 'unit-render' : 'production' }, + routeId: 'tool:status/counter', + ...(unitRender ? { surface: { kind: 'unit-render' } } : {}), + }), + 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(true); + 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,16 +640,16 @@ 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); const published = await readEvent(stream, 'route.invocation'); expect(published).toMatchObject({ - payload: { invocation: { routeId: 'tool:status/report', status: 'succeeded' } }, + payload: { invocation: { outcome: { kind: 'success' }, routeId: 'tool:status/report', status: 'succeeded' } }, type: 'route.invocation', }); @@ -286,7 +712,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 +723,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, @@ -309,6 +735,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(true); + 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); @@ -321,6 +754,141 @@ it('invokes compiled tool and event routes through the foreground server', { tim } }); +it('enforces compiled preflight, MCP schemas, and operator env across production surfaces', { timeout: 180_000 }, async () => { + const project = await createProjectFixture({ + config: "export default { plugin: { name: 'route-parity', version: '1.0.0' }, targets: ['claude'] };\n", + files: { + 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', + 'src/events/tool/before.preflight.ts': "export default () => ({ outcome: 'deny', reason: 'blocked' });\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('preflight handler ran'); }", + '', + ].join('\n'), + 'src/mcp/status/tools/report.cli.ts': [ + "export const config = { command: ['report'], confirm: false, flags: { service: { name: 'service' } } };", + '', + ].join('\n'), + 'src/mcp/status/tools/report.tsx': [ + "import { writeFileSync } from 'node:fs';", + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "export const inputSchema = z.object({ service: z.string().min(1) }).strict();", + "export const resultSchema = z.object({ operator: z.string(), service: z.string() }).strict();", + '', + 'export default async function Report({ input }) {', + " writeFileSync('.agent-bundle/handler-ran', 'yes');", + " return createElement(Agent.Result, { value: { operator: process.env.OPERATOR_VALUE ?? 'missing', service: input.service } });", + '}', + '', + ].join('\n'), + }, + prefix: 'agent-bundle-route-parity-', + }); + const assetsRoot = join(project.root, 'workbench'); + let server: Awaited> | undefined; + await mkdir(assetsRoot, { recursive: true }); + await Promise.all([ + symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'), + writeFile(join(assetsRoot, 'index.html'), 'Route parity'), + ]); + try { + server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + port: 0, + root: project.root, + }); + const bootstrap = await fetch(`${server.url}/api/project/session`, { + headers: { 'sec-fetch-site': 'same-origin' }, + }); + const session = await bootstrap.json() as { readonly token: string }; + const headers = { + 'content-type': 'application/json', + origin: server.url, + 'x-agent-bundle-session': session.token, + }; + await expect.poll( + async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), + { timeout: 10_000 }, + ).toBe(200); + const artifact = server.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); + await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); + + const invalidResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: { service: 1 }, routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(invalidResponse.status).toBe(200); + const invalid = await invalidResponse.json() as RouteInvocationResponse; + expect(invalid.invocation.status, JSON.stringify(invalid.invocation.diagnostics)).toBe('succeeded'); + expect(invalid.invocation).toMatchObject({ + document: { status: 'represented-error' }, + outcome: { kind: 'represented-error', summary: expect.stringContaining('Input validation error') }, + projection: { mcp: { isError: true } }, + status: 'succeeded', + }); + expect(await readdir(join(project.root, '.agent-bundle'))).not.toContain('handler-ran'); + + const invoke = async (surface: { readonly args: readonly string[]; readonly command: string; readonly kind: 'cli' } | { readonly kind: 'mcp' }) => { + const response = await fetch(`${server!.url}/api/routes/invocations`, { + body: JSON.stringify({ + ...(surface.kind === 'mcp' ? { input: { service: 'mcp' } } : {}), + routeId: 'tool:status/report', + surface, + }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + return (await response.json() as RouteInvocationResponse).invocation; + }; + const mcp = await invoke({ kind: 'mcp' }); + const cli = await invoke({ args: ['--service', 'cli'], command: 'report', kind: 'cli' }); + expect(mcp.result).toEqual({ operator: 'layered', service: 'mcp' }); + expect(cli.result).toEqual({ operator: 'layered', service: 'cli' }); + + const canonical = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: {}, routeId: 'event:tool/before', surface: { kind: 'event' } }), + headers, + method: 'POST', + }); + expect(canonical.status).toBe(400); + await expect(canonical.json()).resolves.toMatchObject({ diagnostic: { code: 'AB8255' } }); + + const deniedResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { + cwd: project.root, + hook_event_name: 'PreToolUse', + permission_mode: 'default', + session_id: 'session-deny', + tool_input: {}, + tool_name: 'Write', + tool_use_id: 'use-deny', + transcript_path: join(project.root, 'transcript.json'), + }, + routeId: 'event:tool/before', + surface: { host: 'claude', kind: 'event' }, + }), + headers, + method: 'POST', + }); + const denied = await deniedResponse.json() as RouteInvocationResponse; + expect(deniedResponse.status, JSON.stringify(denied)).toBe(200); + expect(denied.invocation.timings.map((entry) => entry.phase)).toEqual(['projection']); + expect(denied.invocation.providers).toEqual([]); + } finally { + await server?.close().catch(() => undefined); + await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 }); + } +}); + it('publishes invocation routes only after a successful initial or recovered build', { timeout: 90_000 }, async () => { const project = await createProjectFixture({ config: [ @@ -420,7 +988,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..b24fe0c93 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -8,15 +8,22 @@ 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, + 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'; 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'; +import { deferred } from './support/eventually.ts'; const invocation = (id: string, completedAt: string): RouteInvocation => ({ completedAt, @@ -46,7 +53,20 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ sourceRevision: 'revision', startedAt: completedAt, status: 'succeeded', + surface: { kind: 'mcp' }, 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', () => { @@ -60,20 +80,35 @@ it('strictly validates invocation request fields and event options', () => { routeId: 'tool:curator/search_audible', }); expect(parseRouteInvocationRequest({ - event: { fixtureId: 'starter', host: 'claude' }, + surface: { fixtureId: 'starter', host: 'claude', kind: 'event' }, routeId: 'event:tool/after', })).toEqual({ - event: { fixtureId: 'starter', host: 'claude' }, + 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({ + routeId: 'tool:curator/search_audible', + }); for (const value of [ {}, { 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); } @@ -93,6 +128,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', () => { @@ -110,77 +146,285 @@ 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, text = 'ok'): RouteInvocationChildResult => ({ + document: { + root: { kind: 'text', text }, + status: 'success', + version: 1, + }, + events: [], + input: request.input, + mcp: {}, + 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(); + const service = new RouteInvocationService({ + manifest: { + manifest: () => catalog('digest', 'revision'), + }, + prepared: async () => ({ + project: { + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + 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: echoRoute.id, surface: { kind: 'unit-render' } }); + 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 stateRoot = join(projectRoot, '.agent-bundle', 'state'); + const service = new RouteInvocationService({ + concurrency: 1, + manifest: { + manifest: () => catalog(digest, sourceRevision), + }, + prepared: async () => ({ + project: { + manifest: { projectRoot } as never, + stateRoot, + targets: ['claude'], + }, + release: () => { + releases += 1; + }, + }), + renderChild: async (request) => { + executed.push(request); + firstStarted.resolve(); + await hold.promise; + return childResult(request, 'old output'); + }, + }); + + 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({ + document: { root: { kind: 'text', text: 'old output' } }, + manifestDigest: 'digest-1', + sourceRevision: 'rev-1', + status: 'succeeded', + }); + expect(executed).toHaveLength(1); + expect(executed[0]?.stateRoot).toBe(stateRoot); + 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); +}); + +it('does not spawn a child for an invocation aborted while queued', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-queued-abort-')); + const marker = join(root, 'queued-child-started'); + const hold = deferred(); + const firstStarted = deferred(); + let childStarts = 0; + const service = new RouteInvocationService({ + concurrency: 1, + manifest: { + manifest: () => catalog('digest', 'revision'), + }, + prepared: async () => ({ + project: { + manifest: { projectRoot: root } as never, + stateRoot: join(root, '.agent-bundle', 'state'), + targets: ['claude'], + }, + release: () => undefined, + }), + renderChild: async (request) => { + childStarts += 1; + if ((request.input as { readonly n?: number }).n === 2) await writeFile(marker, 'spawned'); + firstStarted.resolve(); + await hold.promise; + return childResult(request); + }, + }); + + try { + const first = service.invoke({ input: { n: 1 }, routeId: echoRoute.id }); + await firstStarted.promise; + const controller = new AbortController(); + const second = service.invoke( + { input: { n: 2 }, routeId: echoRoute.id }, + { signal: controller.signal }, + ); + const cancelled = expect(second).rejects.toMatchObject({ name: 'AbortError' }); + controller.abort(new DOMException('Queued invocation cancelled.', 'AbortError')); + await cancelled; + + expect(childStarts).toBe(1); + expect(existsSync(marker)).toBe(false); + hold.resolve(); + await first; + expect(childStarts).toBe(1); + expect(existsSync(marker)).toBe(false); + } finally { + hold.resolve(); + await service.close(); + await rm(root, { force: true, recursive: true }); + } +}); + +it('does not lease or execute an invocation aborted while queued', async () => { + const hold = deferred(); + const firstStarted = deferred(); + const executed: RouteInvocationChildRequest[] = []; + let leases = 0; + const service = new RouteInvocationService({ + concurrency: 1, + manifest: { manifest: () => catalog('digest', 'revision') }, + prepared: async () => { + leases += 1; + return { + project: { + manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }, + release: () => undefined, + }; + }, + 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 controller = new AbortController(); + const queued = service.invoke({ input: { n: 2 }, routeId: echoRoute.id }, { signal: controller.signal }); + controller.abort(new DOMException('Request closed.', 'AbortError')); + hold.resolve(); + + await expect(first).resolves.toMatchObject({ status: 'succeeded' }); + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }); + expect(executed).toHaveLength(1); + expect(leases).toBe(1); +}); + +it('rejects a canonical event surface when the compiled route has preflight', async () => { const route = { config: [], - id: 'tool:fixture/echo', - kind: 'tool', + event: 'tool/before', + id: 'event:tool/before', + execution: { fallback: 'standalone', preflight: 'src/events/tool/before.preflight.ts', runtime: 'standalone' }, + kind: 'event-route', provenance: { kind: 'conventional' }, - serverId: 'mcp:fixture', - source: 'src/mcp/fixture/tools/echo.tsx', + source: 'src/events/tool/before.tsx', } as const; + let leases = 0; const service = new RouteInvocationService({ manifest: { manifest: () => ({ diagnostics: [], digest: 'digest', - events: [], + events: [route], providers: [], scripts: [], - servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [route] }], + servers: [], sourceRevision: 'revision', }), }, - prepared: () => ({ - manifest: { projectRoot: '/project' } as never, - targets: ['claude'], - }), - renderChild: (_request, signal) => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }), + prepared: async () => { + leases += 1; + throw new Error('canonical preflight submission must fail before leasing'); + }, }); - const pending = service.invoke({ input: {}, routeId: route.id }); - await Promise.resolve(); - await service.close(); - - await expect(pending).resolves.toMatchObject({ - diagnostics: [expect.objectContaining({ code: 'AB8236' })], - status: 'failed', + await expect(service.invoke({ + input: {}, + routeId: route.id, + surface: { kind: 'event' }, + })).rejects.toMatchObject({ + code: 'AB8255', + status: 400, }); + expect(leases).toBe(0); }); -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', @@ -217,22 +461,94 @@ 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 }, - prepared: () => prepared, + prepared: () => preparedLease(prepared), timeoutMs: options.timeoutMs, }), }; }; +interface LeakingRouteProject extends RouteProject { + readonly pids: () => 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 }>; + }, + }; +}; + +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', 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') + .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; @@ -254,7 +570,7 @@ const recordedPids = async (project: LeakingRouteProject): 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: {}, routeId: 'tool:fixture/leak', surface: { kind: 'unit-render' } }); const pids = await project.pids(); expect(invocation.status).toBe('succeeded'); @@ -270,7 +586,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: {}, 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); @@ -290,7 +606,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: {}, 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); @@ -306,3 +622,161 @@ it('reaps the render child and its descendants when the service closes mid-rende await rm(project.root, { force: true, recursive: true }); } }); + +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: () => preparedLease({ + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/state', + 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.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']); + expect(result.timings[0]).toMatchObject({ durationMs: 12, phase: 'render' }); +}); + +it('omits render timing when the child did not render', async () => { + const result = await telemetryService(async () => { + const { renderDurationMs: _renderDurationMs, ...withoutRender } = succeededChild(); + return withoutRender; + }).invoke({ + input: {}, + routeId: echoRoute.id, + }); + + expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); + expect(result.timings.map((entry) => entry.phase)).toEqual(['projection']); +}); + +it('reports an event route kind for unit-render provenance', async () => { + const route = { + config: [], + event: 'tool/after', + id: 'event:tool/after', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/events/tool/after.tsx', + } as const; + const service = new RouteInvocationService({ + manifest: { + manifest: () => ({ + diagnostics: [], + digest: 'digest', + events: [route], + providers: [], + scripts: [], + servers: [], + sourceRevision: 'revision', + }), + }, + prepared: () => preparedLease({ + manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }), + renderChild: async (request) => childResult(request), + }); + + const result = await service.invoke({ + input: {}, + routeId: route.id, + surface: { kind: 'unit-render' }, + }); + + expect(result.context.invocation).toMatchObject({ + kind: 'event', + operationId: route.id, + surface: 'unit-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/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..97da0a72a --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts @@ -0,0 +1,91 @@ +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'; + +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'); +}); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index fcd329895..8671bccb7 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -432,12 +432,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', () => { @@ -465,8 +469,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/packages/workbench/src/application/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx index b1828a761..b206d3a2b 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 @@ -18,8 +18,9 @@ import type { Lifecycle, LifecycleClient, LifecycleTarget } from '../lifecycles/ import type { WorkbenchLocation } from '../shell/workbench-location.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; import { ExecutableRouteWorkspace } from './executable-route-workspace.tsx'; +import { outcomeLabel, statusLabel } from './invocation-model.ts'; import { displayAgentDocumentValue } from './rendered-document.tsx'; -import type { ResultTabDefinition } from './result-tabs.tsx'; +import { OutcomeBadge, StatusBadge, type ResultTabDefinition } from './result-tabs.tsx'; import { requestContextRows } from './route-inspector.tsx'; import { invocationOf, @@ -56,6 +57,15 @@ export const lifecycleForLeaf = (lifecycles: readonly Lifecycle[], leaf: Applica export const eventHostTarget = (lifecycle: Lifecycle | undefined, host: RouteInvocationEventHost): LifecycleTarget | undefined => lifecycle?.targets.find((target) => target.target === host); +export const defaultEventHostSelection = ( + leaf: ApplicationLeaf, + lifecycle: Lifecycle | undefined, +): EventHostSelection => { + if (leaf.preflight === undefined) return 'canonical'; + const first = lifecycle?.targets.find((target) => isEventHost(target.target))?.target; + return first !== undefined && isEventHost(first) ? first : eventHosts[0]!; +}; + /** One native payload fixture per host the lifecycle catalog serves for this route. */ export const eventFixturesFor = (lifecycle: Lifecycle | undefined): readonly RouteInputFixture[] => Object.freeze( (lifecycle?.targets ?? []) @@ -68,13 +78,13 @@ export const eventFixturesFor = (lifecycle: Lifecycle | undefined): readonly Rou })), ); -export const eventRequestFor = ( - host: EventHostSelection, - draft: RouteInvocationDraft, -): RouteInvocationDraft => { - if (host === 'canonical') return draft; - return Object.freeze({ ...draft, event: Object.freeze({ host }) }); -}; +export const eventRequestFor = (host: EventHostSelection, draft: RouteInvocationDraft): RouteInvocationDraft => Object.freeze({ + ...draft, + surface: Object.freeze({ + ...(host === 'canonical' ? {} : { host }), + kind: 'event', + }), +}); const Rows = ({ rows }: { readonly rows: readonly { readonly label: string; readonly value: string }[] }): React.ReactNode =>
{rows.map((entry) =>
{entry.label}
{entry.value}
)} @@ -135,7 +145,8 @@ const CanonicalResultTab = ({ invocation }: { readonly invocation?: RouteInvocat return
{value === undefined ? The document carries no value; the decision is expressed by its nodes (see Rendered). @@ -165,7 +176,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.

@@ -186,7 +200,8 @@ const ReplayTab = ({ controller, defaultHost, lifecycle }: { ? No host-submitted invocations of this route have been recorded in this dev session. :
    {observed.map((summary) =>
  1. + + +
: undefined} {toolbar} ( + (value) => eventTraceWireSchema.safeParse(value).success, +); +const outcomeSchema = z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('success') }), + z.strictObject({ kind: z.literal('represented-error'), summary: z.string() }), + z.strictObject({ exitCode: z.number().int(), kind: z.literal('process-exit') }), +]); const invocationSummaryFields = { completedAt: textSchema, correlationId: textSchema.optional(), @@ -80,15 +134,21 @@ const invocationSummaryFields = { input: z.json(), kind: z.enum(['cli', 'event-route', 'prompt', 'resource', 'script', 'tool']), manifestDigest: textSchema, + outcome: outcomeSchema.optional(), routeId: textSchema, source: z.string(), sourceRevision: textSchema, startedAt: textSchema, status: z.enum(['failed', 'succeeded']), + surface: invocationSurfaceSchema, timings: z.array(timingSchema), } as const; +// A completed boundary always says what the run meant; a boundary that never +// completed has nothing to judge. The wire never gets to imply success by omission. +const outcomeMatchesStatus = (value: Pick): boolean => + (value.status === 'succeeded') === (value.outcome !== undefined); const invocationSummarySchema: z.ZodType = - z.strictObject(invocationSummaryFields); + z.strictObject(invocationSummaryFields).refine(outcomeMatchesStatus); const invocationSchema: z.ZodType = z.strictObject({ ...invocationSummaryFields, context: requestContextProvenanceSchema, @@ -97,7 +157,8 @@ const invocationSchema: z.ZodType = z.strictObject({ projection: projectionSchema, providers: z.array(providerSchema), result: z.json().optional(), -}); + trace: z.array(eventTraceSchema).optional(), +}).refine(outcomeMatchesStatus); const invocationResponseSchema = z.strictObject({ invocation: invocationSchema }); const invocationListResponseSchema = z.strictObject({ invocations: z.array(invocationSummarySchema), diff --git a/packages/workbench/src/application/invocation-model.ts b/packages/workbench/src/application/invocation-model.ts index 29c33b3b6..5fd8d7fed 100644 --- a/packages/workbench/src/application/invocation-model.ts +++ b/packages/workbench/src/application/invocation-model.ts @@ -6,6 +6,8 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; import type { RouteInvocation, + RouteInvocationOutcome, + RouteInvocationStatus, RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; import { @@ -107,6 +109,36 @@ export const writeLastInput = (leafKey: string, input: JsonValue): void => { } }; +/** The execution status as the UI words it: the boundary completed or did not; never "succeeded", which the outcome decides. */ +export const statusLabel = (status: RouteInvocationStatus): string => { + switch (status) { + case 'succeeded': + return 'Completed'; + case 'failed': + return 'Failed'; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +}; + +/** What a completed run meant, in the words every outcome badge shows. */ +export const outcomeLabel = (outcome: RouteInvocationOutcome): string => { + switch (outcome.kind) { + case 'success': + return 'Success'; + case 'represented-error': + return `Represented error · ${outcome.summary}`; + case 'process-exit': + return `Exit code ${String(outcome.exitCode)}`; + default: { + const exhaustive: never = outcome; + return exhaustive; + } + } +}; + export const selectBackend = ( backends: readonly InvocationBackend[], leaf: ApplicationLeaf, @@ -123,10 +155,12 @@ export const invocationSummaryOf = ( input: invocation.input, kind: invocation.kind, manifestDigest: invocation.manifestDigest, + ...(invocation.outcome === undefined ? {} : { outcome: invocation.outcome }), routeId: invocation.routeId, source: invocation.source, sourceRevision: invocation.sourceRevision, startedAt: invocation.startedAt, status: invocation.status, + surface: invocation.surface, timings: invocation.timings, }); diff --git a/packages/workbench/src/application/result-tabs.tsx b/packages/workbench/src/application/result-tabs.tsx index cbd57b8bb..ee144a6be 100644 --- a/packages/workbench/src/application/result-tabs.tsx +++ b/packages/workbench/src/application/result-tabs.tsx @@ -7,9 +7,15 @@ */ import React from 'react'; -import type { RouteInvocation, RouteInvocationSummary } from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { + RouteInvocation, + RouteInvocationOutcome, + RouteInvocationStatus, + RouteInvocationSummary, +} from '../../../agent-bundle/src/contracts/invocations.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; +import { outcomeLabel, statusLabel } from './invocation-model.ts'; import { agentRenderEventLabel, displayAgentDocumentValue, RenderedAgentDocument } from './rendered-document.tsx'; import { invocationOf, type RouteInvocationController, type WorkspaceResultTab } from './workspace-contracts.ts'; import './workspace.css'; @@ -53,6 +59,14 @@ const durationOf = (summary: Pick= 0 ? `${String(ms)} ms` : '—'; }; +/** Whether the execution boundary completed — never the run's verdict, which {@link OutcomeBadge} carries. */ +export const StatusBadge = ({ status }: { readonly status: RouteInvocationStatus }): React.ReactNode => + {statusLabel(status)}; + +/** The application outcome of a completed run, shown beside — never merged into — its status. */ +export const OutcomeBadge = ({ outcome }: { readonly outcome: RouteInvocationOutcome }): React.ReactNode => + {outcomeLabel(outcome)}; + const StructuredResult = ({ invocation }: { readonly invocation?: RouteInvocation }): React.ReactNode => { if (invocation === undefined) return

Run the route to see its structured result.

; if (invocation.result !== undefined) return
{displayAgentDocumentValue(invocation.result)}
; @@ -125,7 +139,8 @@ const TraceList = ({ current, history, leaf, onNavigate, onSelect }: { }} type="button" > - {summary.status} + + {summary.outcome === undefined ? undefined : } {formatTime(summary.startedAt)} {durationOf(summary)} {summary.event?.host === undefined ? undefined : {summary.event.host}} diff --git a/packages/workbench/src/application/route-input-editor.tsx b/packages/workbench/src/application/route-input-editor.tsx index 86507dae7..b3733c4d1 100644 --- a/packages/workbench/src/application/route-input-editor.tsx +++ b/packages/workbench/src/application/route-input-editor.tsx @@ -147,40 +147,56 @@ const parseRawArgs = (raw: string): readonly string[] | undefined => { } }; +const cliSurfaceDraft = (command: NonNullable, args: readonly string[]): RouteInputSubmission => + Object.freeze({ + draft: Object.freeze({ + surface: Object.freeze({ args, command: command.path.join(' '), kind: 'cli' }), + }), + }); + const cliDraft = (leaf: ApplicationLeaf, argumentsValue: RouteInputArguments): RouteInputSubmission => { if (leaf.command === undefined) return Object.freeze({ error: 'This CLI route has no compiled command grammar to build argv from.' }); const args = cliCommandArgv(leaf.command, argumentsValue); return args === undefined ? Object.freeze({ error: 'A required CLI option is missing.' }) - : Object.freeze({ draft: Object.freeze({ args }) }); + : cliSurfaceDraft(leaf.command, args); }; /** 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 cliSurfaceDraft(leaf.command, args); } 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 +245,7 @@ const scalarControl = ( }; export interface RouteInputEditorProps { + readonly cliSurface?: boolean; readonly disabled?: boolean; readonly fixtures?: readonly RouteInputFixture[]; readonly leaf: ApplicationLeaf; @@ -242,9 +259,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 +278,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 +298,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 ?