diff --git a/.changeset/exact-routes-close.md b/.changeset/exact-routes-close.md new file mode 100644 index 000000000..4687bb615 --- /dev/null +++ b/.changeset/exact-routes-close.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Select the exact Workbench production route executable from the artifact manifest and report AB8250–AB8252 for unavailable, ineligible, or failed preparation bindings (#692). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 658f0f9b5..c5290dcd3 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -49,7 +49,7 @@ even when no error diagnostic was reported. | `AB8240`–`AB8242` | Workbench unified trace routes (`/api/trace`, `/api/trace/stream`): `AB8240` invalid `after` cursor (400), `AB8241` cursor ahead of the current trace sequence (409), and `AB8242` trace routes unavailable before composition or during shutdown (404/503). | | `AB8247`–`AB8249` | Workbench hook receipt route (`POST /api/trace/receipts`, posted by a generated hook wrapper of the dev plugin): `AB8247` receipt refused — peer not loopback, `Origin` header present, missing or wrong bearer token (403), or receipts closed (409); `AB8248` malformed receipt — query string, non-object body, unknown key, out-of-range enum, or unbounded field (400, the message names the field); `AB8249` receipt over the 16 KiB limit (413). | | `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`. | +| `AB8250`–`AB8255` | Workbench production route execution: `AB8250` no manifest-selected published compiler artifact is available, `AB8251` the selected route/surface/host has no eligible executable or preparation binding in the published artifact, `AB8252` the selected compiled CLI projection or event preparation could not be imported or 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 or choose an eligible emitted host 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`. | | `AB8256` | Workbench route invocation cancellation (`POST /api/routes/invocations//cancel`): the invocation is already final (409). Reload the final invocation instead of cancelling it. | | `AB8260` | Workbench host sessions: `@lydell/node-pty` could not be resolved from the project or loaded (503). Install the PTY module in the project workspace and restart `agent-bundle dev`. | | `AB8261` | Workbench host sessions: a request body, path, query, dimension, input, or live-session delete is malformed (400/409). Send only the documented `/api/sessions` fields and forget sessions only after they exit. | diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts index ad230ccc1..54c3db497 100644 --- a/packages/agent-bundle/src/adapters/composite-layout.ts +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -62,3 +62,6 @@ export const hookWrapperPath = ( const reached = hookTargets.filter((target) => selection.has(target)); return reached.length > 1 ? `hooks/${hookName}.${host}.mjs` : `hooks/${hookName}.mjs`; }; + +/** Artifact-relative path of the standalone event-route Flight worker. */ +export const hooksFlightWorkerPath = 'hooks/hooks-flight.mjs'; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 2d575d9f9..3ddab87b2 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -3,6 +3,7 @@ import { readFile, stat } from 'node:fs/promises'; import { dirname, extname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { hooksFlightWorkerPath } from '../adapters/composite-layout.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { eventArtifactEpochToken, @@ -601,7 +602,7 @@ export const planCompiledHooks = ( ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), ...(index === workerOwner ? { - workerOutput: resolveArtifactDestination(resolve(options.outDir, 'hooks'), 'hooks-flight.mjs'), + workerOutput: resolveArtifactDestination(options.outDir, hooksFlightWorkerPath), workerSourceInputs, } : {}), @@ -639,7 +640,7 @@ export const planHooksSurface = ( ? undefined : { name: 'hooks-flight', - outputRelativePath: 'hooks/hooks-flight.mjs', + outputRelativePath: hooksFlightWorkerPath, reactServer: true as const, rscManifest: true as const, source: standaloneEventRoutes[0]!.source, @@ -731,7 +732,7 @@ export const planHooksSurface = ( ?? (() => { throw new Error(`Missing bundled deferred hook executor evidence for ${JSON.stringify(entry.name)}.`); })(), }), ...(entry.workerOutput === undefined ? {} : { - workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), + workerSourceInputs: evidenceByPath.get(hooksFlightWorkerPath) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), }), }))); }, diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index ee17c2495..c47a0e016 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -1,5 +1,3 @@ -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'; @@ -85,6 +83,7 @@ interface WorkerMessage { type ProductionRequest = RouteInvocationChildRequest & Readonly<{ readonly artifactEpoch: string; readonly artifactRoot: string; + readonly production: NonNullable; }>; const preparationFailure = (error: unknown): ProductionRouteInvocationError => @@ -110,27 +109,6 @@ const completeDocument = (value: JsonValue | undefined): AgentDocument => create 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'; @@ -146,42 +124,58 @@ const prepareInput = async ( observeTrace: EventTraceObserver, 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; + switch (request.production.kind) { + case 'direct': + return { input: request.input }; + case 'cli': { + if (request.surface.kind !== 'cli') { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `Manifest CLI binding does not match route surface ${JSON.stringify(request.surface.kind)}.`, + ); + } + const module = await importedModule>( + join(request.artifactRoot, request.production.preparation), + ); + if (!isCliInvocationModule(module)) { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + `Compiled CLI preparation ${JSON.stringify(request.production.preparation)} does not export the route invocation contract.`, + ); + } 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.`, - ); + case 'event': { + const wrapper = await importedModule( + join(request.artifactRoot, request.production.preparation), + ); + if (typeof wrapper.prepareRouteInvocation !== 'function') { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + `Compiled event preparation ${JSON.stringify(request.production.preparation)} does not export prepareRouteInvocation.`, + ); + } + const native = (request.input as { readonly native?: JsonObject }).native ?? {}; + const preflight = await wrapper.prepareRouteInvocation(native, signal, observeTrace); + return { + input: { + canonical: preflight.props.canonical, + native: preflight.native, + ...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute' + ? { preflight: preflight.gate.data } + : {}), + }, + preflight, + }; + } + default: { + const exhaustive: never = request.production; + throw new Error(`Unsupported production binding ${String(exhaustive)}.`); + } } - 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, observeTrace); - return { - input: { - canonical: preflight.props.canonical, - native: preflight.native, - ...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute' - ? { preflight: preflight.gate.data } - : {}), - }, - preflight, - }; }; const invocationFor = ( @@ -227,39 +221,6 @@ const invocationFor = ( } }; -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, @@ -453,13 +414,6 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly - error instanceof Error - && ( - error.message.includes('Generated route must default-export') - || error.message.includes('Generated rendered route must default-export') - ); - /** * Drives one compiled worker's render stream. Each event is handed to * `publishRender` as it arrives and then dropped; only the `complete` event's @@ -481,38 +435,37 @@ const renderCompiled = async ( }; }>> => { 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); - let document: AgentDocument | undefined; - try { - const reader = session.events.getReader(); - for (;;) { - const next = await reader.read(); - if (next.done) break; - if (next.value.type === 'complete') document = next.value.document; - await publishRender?.(next.value); - } - if (document === undefined) throw new Error('Compiled route render ended without a complete event.'); - return Object.freeze({ - document, - durationMs: performance.now() - startedAt, - observed: { - providers: Object.freeze([...session.observed.providers]), - timings: Object.freeze([...session.observed.timings]), - }, - }); - } catch (error) { - if (!missingRouteWorkerError(error)) throw error; - } finally { - await session.close(); + const startedAt = performance.now(); + const session = streamFromWorker( + join(request.artifactRoot, request.production.executable), + request, + invocation, + input, + signal, + env, + trace, + ); + let document: AgentDocument | undefined; + try { + const reader = session.events.getReader(); + for (;;) { + const next = await reader.read(); + if (next.done) break; + if (next.value.type === 'complete') document = next.value.document; + await publishRender?.(next.value); } + if (document === undefined) throw new Error('Compiled route render ended without a complete event.'); + return Object.freeze({ + document, + durationMs: performance.now() - startedAt, + observed: { + providers: Object.freeze([...session.observed.providers]), + timings: Object.freeze([...session.observed.timings]), + }, + }); + } 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 ( @@ -520,10 +473,14 @@ export const renderProductionRoute = async ( publishTrace?: EventTraceObserver, publishRender?: (event: AgentRenderEvent) => Promise | void, ): Promise => { - if (request.artifactEpoch === undefined || request.artifactRoot === undefined) { + if ( + request.artifactEpoch === undefined + || request.artifactRoot === undefined + || request.production === undefined + ) { throw new ProductionRouteInvocationError( ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, - 'Production route invocation requires a published artifact.', + 'Production route invocation requires a manifest-selected published artifact executable.', ); } const productionRequest = request as ProductionRequest; 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 ec2d6bbbe..c3e069889 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -7,9 +7,11 @@ import { fileURLToPath } from 'node:url'; import type { AgentDocument, AgentDocumentNode, AgentRenderEvent } from '@agent-bundle/runtime'; +import { hooksFlightWorkerPath } from '../../adapters/composite-layout.ts'; import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; import type { TargetHookContract } from '../../adapters/hook-contract.ts'; import { generatedRouteArtifactEpoch } from '../../build/entry-shell.ts'; +import type { ArtifactManifest } from '../../build/manifest.ts'; import { projectCliDocumentToMarkdown } from '../../cli-entry.ts'; import { sleep } from '../../core/async.ts'; import type { Diagnostic } from '../../core/diagnostics.ts'; @@ -28,6 +30,7 @@ import type { RequestProvenanceUnavailableReason, } from '../../contracts/request-provenance.ts'; import { createCanonicalEventProps, projectEventDocument } from '../../events/projection.ts'; +import { isRenderedCliRoute } from '../../routes/cli-commands.ts'; import { eventTraceEventKinds, type EventTraceEvent, @@ -46,6 +49,7 @@ import { applicationNodePath, applicationNodeRefForRouteId } from './application import { isProductionRouteInvocationCode, ProductionRouteInvocationError, + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, } from './route-invocation-production-error.ts'; import { emptyRetainedRenderEvents, @@ -110,10 +114,15 @@ export interface RouteInvocationFixture { */ export interface RouteInvocationPreparedProject { /** - * The published build a plain script runs from, and a target whose layout - * emits the `scripts/` directory. Absent while no build is published. + * The leased published build. `manifest` is the authoritative execution + * registry; `target` is present when a host layout emits `scripts/`. */ - readonly artifact?: Readonly<{ epochId: string; target: string }>; + readonly artifact?: Readonly<{ + readonly epochId: string; + readonly manifest: ArtifactManifest; + readonly root: string; + readonly target?: string; + }>; readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; /** Writable framework state (`devStateRoot`), shared with dev MCP sessions and never the code root. */ @@ -126,6 +135,11 @@ export interface RouteInvocationPreparedLease { readonly release: () => Promise | void; } +export type RouteInvocationProductionBinding = + | Readonly<{ readonly executable: string; readonly kind: 'direct' }> + | Readonly<{ readonly executable: string; readonly kind: 'cli'; readonly preparation: string }> + | Readonly<{ readonly executable: string; readonly kind: 'event'; readonly preparation: string }>; + export interface RouteInvocationScriptRunner { run(request: ScriptPlaygroundRunRequest): Promise; } @@ -154,6 +168,8 @@ export interface RouteInvocationChildRequest { readonly context: RequestContextProvenance; readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; + /** Exact manifest-owned executable and preparation selected by the leased parent. */ + readonly production?: RouteInvocationProductionBinding; readonly routeId: string; readonly stateRoot: string; readonly surface: RouteInvocationSurface; @@ -533,6 +549,95 @@ const plainScriptFor = (prepared: RouteInvocationPreparedProject, route: RouteMa ? prepared.manifest.scripts.find((script) => script.routeId === route.id && !script.rendered) : undefined; +const unavailableBinding = (routeId: string, detail: string): never => { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `Published artifact cannot invoke route ${JSON.stringify(routeId)}: ${detail}`, + ); +}; + +const productionBindingFor = ( + manifest: ArtifactManifest, + route: RouteManifestRoute, + surface: RouteInvocationSurface, +): RouteInvocationProductionBinding => { + if (surface.kind === 'cli') { + const command = manifest.routes.cli?.commands?.find((candidate) => + candidate.routeId === route.id && candidate.path.join(' ') === surface.command); + const bin = manifest.executables.bins.find((candidate) => + candidate.name === manifest.application.name && candidate.worker !== undefined); + if (command === undefined || (route.kind === 'cli' && !isRenderedCliRoute(route)) || bin?.worker === undefined) { + return unavailableBinding(route.id, 'the selected CLI command has no compiled executable; rebuild the project.'); + } + return Object.freeze({ executable: bin.worker, kind: 'cli', preparation: bin.path }); + } + + if (route.kind === 'event-route') { + const host = surface.kind === 'event' ? surface.host : undefined; + const execution = manifest.routes.events.find((candidate) => candidate.id === route.id)?.execution; + if (execution === undefined) { + return unavailableBinding(route.id, 'the event route has no execution record in the published artifact.'); + } + const wrappers = manifest.executables.hooks.filter((candidate) => + candidate.kind === 'event-route' && candidate.routeId === route.id); + const wrapper = host === undefined + ? undefined + : wrappers.find((candidate) => candidate.host === host); + if (host === undefined && wrappers.length === 0) { + return unavailableBinding(route.id, 'the canonical event route has no eligible emitted executable.'); + } + if (host !== undefined && wrapper === undefined) { + return unavailableBinding( + route.id, + `host ${JSON.stringify(host)} is not an eligible emitted projection, or its preparation executable is missing.`, + ); + } + const eligibleHosts = host === undefined + ? new Set(wrappers.map((candidate) => candidate.host)) + : new Set([host]); + const shared = manifest.routes.servers + .filter((server) => server.mode === 'generated') + .map((server) => manifest.executables.mcpServers.find((candidate) => + candidate.id === server.id + && candidate.kind === 'compiled' + && candidate.hosts.some((candidateHost) => eligibleHosts.has(candidateHost)) + && candidate.launch?.worker !== undefined)) + .find((candidate) => candidate !== undefined); + const standalone = manifest.files.find((file) => file.path === hooksFlightWorkerPath)?.path; + const executable = execution.runtime === 'standalone' + ? standalone + : shared?.launch?.worker ?? (execution.fallback === 'standalone' ? standalone : undefined); + if (executable === undefined) { + return unavailableBinding(route.id, 'the selected event preparation has no compiled route executable.'); + } + return wrapper === undefined + ? Object.freeze({ executable, kind: 'direct' }) + : Object.freeze({ executable, kind: 'event', preparation: wrapper.path }); + } + + if (route.kind === 'script') { + const script = manifest.executables.scripts.find((candidate) => + candidate.rendered?.routeId === route.id && candidate.worker !== undefined); + if (script?.worker === undefined) { + return unavailableBinding(route.id, 'the rendered script has no compiled executable.'); + } + return Object.freeze({ executable: script.worker, kind: 'direct' }); + } + + const server = manifest.routes.servers.find((candidate) => + candidate.routes.some((candidateRoute) => candidateRoute.id === route.id)); + const executable = server === undefined + ? undefined + : manifest.executables.mcpServers.find((candidate) => + candidate.id === server.id + && candidate.kind === 'compiled' + && candidate.launch?.worker !== undefined); + if (executable?.launch?.worker === undefined) { + return unavailableBinding(route.id, 'the owning MCP server has no compiled executable.'); + } + return Object.freeze({ executable: executable.launch.worker, kind: 'direct' }); +}; + /** * Plain scripts have no route component for the Agent renderer. Run the * emitted executable and project its output into the invocation result. @@ -546,7 +651,9 @@ const runPlainScript = async ( publishRenderEvent: (event: AgentRenderEvent) => void, ): Promise => { if (scripts === undefined) throw new Error('No script runner is available for a plain script.'); - if (prepared.artifact === undefined) throw new Error('A plain script runs from the published build; none is published.'); + if (prepared.artifact?.target === undefined) { + throw new Error('A plain script runs from the published build; no script-capable target is published.'); + } const startedAt = performance.now(); const run = await scripts.run({ epochId: prepared.artifact.epochId, @@ -1483,6 +1590,7 @@ export class RouteInvocationService { ); } const rawInput = request.input ?? fixture?.input ?? {}; + const plainScript = plainScriptFor(prepared, route); const input = route.kind === 'event-route' ? eventInput(route, rawInput, surface.kind === 'event' ? surface.host : undefined, this.#registry) : rawInput; @@ -1538,22 +1646,29 @@ export class RouteInvocationService { 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); const publishRenderEvent = (event: AgentRenderEvent): void => { this.#publishStream(streamRecord, { event, type: 'render' }); }; try { + let production: RouteInvocationProductionBinding | undefined; + if (plainScript === undefined && surface.kind !== 'unit-render' && prepared.artifact !== undefined) { + if (prepared.artifact.manifest.routes.digest !== manifest.digest) { + unavailableBinding(route.id, 'the leased artifact manifest does not match the published route manifest; rebuild the project.'); + } + production = productionBindingFor(prepared.artifact.manifest, route, surface); + } 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), + artifactRoot: prepared.artifact.root, }), context, input, manifest: prepared.manifest, + ...(production === undefined ? {} : { production }), routeId: route.id, stateRoot: prepared.stateRoot, surface, @@ -1590,6 +1705,8 @@ export class RouteInvocationService { ? 'Route invocation child stopped because the request was cancelled.' : controller.signal.aborted ? 'Route invocation child stopped because the service closed.' + : error instanceof ProductionRouteInvocationError + ? error.message : `${plainScript === undefined ? 'Route invocation child' : 'Script run'} failed: ${error instanceof Error ? error.message : String(error)}`, request: { ...request, input }, route, @@ -1667,7 +1784,9 @@ export class RouteInvocationService { throw error; } return failedInvocation({ - code: error instanceof RouteInvocationRequestError ? error.code : ROUTE_INVOCATION_CHILD_FAILURE_CODE, + code: error instanceof RouteInvocationRequestError || error instanceof ProductionRouteInvocationError + ? error.code + : ROUTE_INVOCATION_CHILD_FAILURE_CODE, completedAt, context: cancellationContext, history: streamRecord.history, diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 11c0eefaf..ee7e6c43b 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { join, resolve } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; import type { InstallHost } from '../install/install.ts'; import { HookService } from '../services/hook-service.ts'; import { AgentApi } from './agent-api.ts'; @@ -974,7 +975,6 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun .find((target) => registry.artifactLayout(target).scripts !== undefined); const epochId = artifact.activeEpoch.id; const project = Object.freeze({ - ...(scriptTarget === undefined ? {} : { artifact: { epochId, target: scriptTarget } }), manifest: testManifestFromRouteGraph({ apps: prepared.model.mcpApps, configPath: prepared.configPath, @@ -1011,8 +1011,25 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun } throw error; } + const manifestRead = await readArtifactManifest(reference.root); + if (manifestRead.status !== 'ok') { + await reference.close(); + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + `The leased artifact manifest is ${manifestRead.status}; rebuild before invoking routes.`, + 409, + ); + } return { - project, + project: Object.freeze({ + ...project, + artifact: Object.freeze({ + epochId, + manifest: manifestRead.manifest, + root: manifestRead.root, + ...(scriptTarget === undefined ? {} : { target: scriptTarget }), + }), + }), release: () => reference.close(), }; }, diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index f1bc08a5d..b9b482ccf 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -48,7 +48,7 @@ import { const renderedCliExtensions = new Set(['.jsx', '.tsx']); /** True for a rendered (`.tsx`/`.jsx`) CLI route module (#102 stage 3 surface). */ -export const isRenderedCliRoute = (route: CompiledAgentRoute): boolean => +export const isRenderedCliRoute = (route: Pick): boolean => renderedCliExtensions.has(extname(route.source).toLowerCase()); /** The path-derived command segments of one CLI route (`cli:library/audit` -> `['library', 'audit']`). */ 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 c01ee70b5..fe7ce997f 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -15,6 +15,8 @@ import type { RouteInvocation, RouteInvocationResponse } from '../src/dev/routes import type { RouteInvocationListResponse } from '../src/dev/routes/route-invocation.ts'; import type { RouteManifestResponse } from '../src/dev/routes/route-manifest.ts'; import type { TraceReplay } from '../src/dev/trace/trace-entry.ts'; +import { readArtifactManifest } from '../src/build/manifest-file.ts'; +import { serializeArtifactManifest } from '../src/build/manifest.ts'; import { confirmationRequiredMessage } from '../src/cli-entry.ts'; import { stableJson } from '../src/core/digest.ts'; import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../src/core/types.ts'; @@ -153,6 +155,20 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/cli/plain.ts': [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ok: z.boolean() }).strict();', + '', + 'export default async function Plain() {', + " writeFileSync(join(process.cwd(), '.agent-bundle', 'plain-cli-handler.marker'), 'ran');", + ' return { ok: true };', + '}', + '', + ].join('\n'), 'src/events/tool/after.preflight.ts': [ "import { appendFileSync } from 'node:fs';", "import { join } from 'node:path';", @@ -199,6 +215,18 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/events/session/end.tsx': [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "writeFileSync(join(process.cwd(), '.agent-bundle', 'session-worker.marker'), 'load\\n');", + "export const config = { runtime: 'standalone' };", + 'export default async function SessionEnd() {', + " return createElement(Agent.Result, { value: { canonical: true } });", + '}', + '', + ].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';", @@ -211,6 +239,66 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/events/tool/failure.preflight.ts': [ + "import { appendFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "export default () => {", + " appendFileSync(join(process.cwd(), '.agent-bundle', 'failure-gate.marker'), 'gate\\n');", + " throw new Error('Generated route must default-export from preflight.');", + '};', + '', + ].join('\n'), + 'src/events/tool/failure.tsx': [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "export { default as preflight } from './failure.preflight.js';", + "export const config = { runtime: 'standalone' };", + 'export default async function ToolFailure() {', + " writeFileSync(join(process.cwd(), '.agent-bundle', 'failure-handler.marker'), 'ran');", + " throw new Error('preflight failure reached handler');", + '}', + '', + ].join('\n'), + 'src/mcp/alpha/tools/fail.tsx': [ + "import { appendFileSync } from 'node:fs';", + "import { z } from 'zod';", + '', + "appendFileSync('.agent-bundle/alpha-worker.marker', 'load\\n');", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ failed: z.boolean() }).strict();', + '', + 'export default async function Fail() {', + " appendFileSync('.agent-bundle/alpha-handler.marker', 'run\\n');", + " throw new Error('Generated route must default-export an async Server Component.');", + '}', + '', + ].join('\n'), + 'src/mcp/omega/tools/pass.tsx': [ + "import { appendFileSync } from 'node:fs';", + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "appendFileSync('.agent-bundle/omega-worker.marker', 'load\\n');", + 'export const inputSchema = z.object({}).strict();', + "export const resultSchema = z.object({ selected: z.literal('omega') }).strict();", + '', + 'export default async function Pass() {', + " return createElement(Agent.Result, { value: { selected: 'omega' } });", + '}', + '', + ].join('\n'), + 'src/mcp/importer/tools/fail.tsx': [ + "import { appendFileSync } from 'node:fs';", + "import { z } from 'zod';", + '', + "appendFileSync('.agent-bundle/importer-worker.marker', 'load\\n');", + "throw new Error('Generated route must default-export from import.');", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ failed: z.boolean() }).strict();', + 'export default async function Fail() { return undefined; }', + '', + ].join('\n'), 'src/mcp/status/tools/counter.tsx': [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -307,6 +395,17 @@ it('invokes compiled tool and event routes through the foreground server', { tim "export const mapInput = (input) => ({ ...input, source: input.source ?? 'cli-projection' });", '', ].join('\n'), + 'src/mcp/status/tools/plain.cli.ts': "export const config = { command: ['plain-tool'] };\n", + 'src/mcp/status/tools/plain.ts': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({}).strict();', + "export const resultSchema = z.object({ selected: z.literal('plain-tool') }).strict();", + "export default async function PlainTool() { return createElement(Agent.Result, { value: { selected: 'plain-tool' } }); }", + '', + ].join('\n'), 'src/providers/clock.ts': [ 'export default () => ({ now: 0 });', '', @@ -525,6 +624,91 @@ it('invokes compiled tool and event routes through the foreground server', { tim const activeEpoch = server.status().artifact; if (activeEpoch.state !== 'active') throw new Error('Expected an active compiled epoch.'); const artifactRoot = join(project.root, '.agent-bundle', 'epochs', activeEpoch.activeEpoch.id); + const alphaWorkerMarker = join(project.root, '.agent-bundle', 'alpha-worker.marker'); + const alphaHandlerMarker = join(project.root, '.agent-bundle', 'alpha-handler.marker'); + const importerWorkerMarker = join(project.root, '.agent-bundle', 'importer-worker.marker'); + const omegaWorkerMarker = join(project.root, '.agent-bundle', 'omega-worker.marker'); + const sessionWorkerMarker = join(project.root, '.agent-bundle', 'session-worker.marker'); + const candidateMarkers = [ + alphaWorkerMarker, + alphaHandlerMarker, + importerWorkerMarker, + omegaWorkerMarker, + sessionWorkerMarker, + ]; + await Promise.all(candidateMarkers.map((path) => + rm(path, { force: true }))); + const exactSelectionResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:omega/pass' }), + headers, + method: 'POST', + }); + expect(exactSelectionResponse.status).toBe(200); + const exactSelection = await exactSelectionResponse.json() as RouteInvocationResponse; + expect(exactSelection.invocation).toMatchObject({ + result: { selected: 'omega' }, + status: 'succeeded', + }); + expect(existsSync(alphaWorkerMarker)).toBe(false); + expect(existsSync(importerWorkerMarker)).toBe(false); + expect(await readFile(omegaWorkerMarker, 'utf8')).toBe('load\n'); + + await Promise.all(candidateMarkers.map((path) => + rm(path, { force: true }))); + const handlerFailureResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:alpha/fail' }), + headers, + method: 'POST', + }); + expect(handlerFailureResponse.status).toBe(200); + const handlerFailure = await handlerFailureResponse.json() as RouteInvocationResponse; + expect(handlerFailure.invocation).toMatchObject({ + diagnostics: [{ code: 'AB8236' }], + status: 'failed', + }); + expect(await readFile(alphaWorkerMarker, 'utf8')).toBe('load\n'); + expect(await readFile(alphaHandlerMarker, 'utf8')).toBe('run\n'); + expect(existsSync(importerWorkerMarker)).toBe(false); + expect(existsSync(omegaWorkerMarker)).toBe(false); + + await Promise.all(candidateMarkers.map((path) => + rm(path, { force: true }))); + const importFailureResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:importer/fail' }), + headers, + method: 'POST', + }); + expect(importFailureResponse.status).toBe(200); + const importFailure = await importFailureResponse.json() as RouteInvocationResponse; + expect(importFailure.invocation).toMatchObject({ + diagnostics: [{ code: 'AB8236' }], + status: 'failed', + }); + expect(await readFile(importerWorkerMarker, 'utf8')).toBe('load\n'); + expect(existsSync(alphaWorkerMarker)).toBe(false); + expect(existsSync(omegaWorkerMarker)).toBe(false); + + await Promise.all(candidateMarkers.map((path) => rm(path, { force: true }))); + const canonicalEventResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { event: 'session/end' }, + routeId: 'event:session/end', + surface: { kind: 'event' }, + }), + headers, + method: 'POST', + }); + expect(canonicalEventResponse.status).toBe(200); + const canonicalEvent = await canonicalEventResponse.json() as RouteInvocationResponse; + expect(canonicalEvent.invocation).toMatchObject({ + result: { canonical: true }, + status: 'succeeded', + }); + expect(await readFile(sessionWorkerMarker, 'utf8')).toBe('load\n'); + expect(existsSync(alphaWorkerMarker)).toBe(false); + expect(existsSync(importerWorkerMarker)).toBe(false); + expect(existsSync(omegaWorkerMarker)).toBe(false); + const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, @@ -547,7 +731,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim stateRoot, }); const mcpName = (await readdir(join(artifactRoot, 'mcp'))) - .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); + .find((name) => name.startsWith('mcp-status-') && 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)], @@ -738,7 +922,36 @@ it('invokes compiled tool and event routes through the foreground server', { tim 'render.finish', ]); - for (const [routeId, input, expected] of [ + const failureHandlerMarker = join(project.root, '.agent-bundle', 'failure-handler.marker'); + await Promise.all([...candidateMarkers, failureHandlerMarker].map((path) => rm(path, { force: true }))); + const preflightFailureResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { + cwd: project.root, + error: 'Exit code 9', + hook_event_name: 'PostToolUseFailure', + session_id: 'session-preflight-failure', + tool_input: {}, + tool_name: 'Write', + tool_use_id: 'use-preflight-failure', + transcript_path: join(project.root, 'transcript.json'), + }, + routeId: 'event:tool/failure', + surface: { host: 'claude', kind: 'event' }, + }), + headers, + method: 'POST', + }); + expect(preflightFailureResponse.status).toBe(200); + const preflightFailure = await preflightFailureResponse.json() as RouteInvocationResponse; + expect(preflightFailure.invocation).toMatchObject({ + diagnostics: [{ code: 'AB8252' }], + status: 'failed', + }); + expect(existsSync(failureHandlerMarker)).toBe(false); + expect(candidateMarkers.every((path) => !existsSync(path))).toBe(true); + + const preflightCases = [ [ 'event:tool/before', { @@ -765,7 +978,8 @@ it('invokes compiled tool and event routes through the foreground server', { tim }, { outcome: 'continue' }, ], - ] as const) { + ] as const; + for (const [routeId, input, expected] of preflightCases) { const response = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input, routeId, surface: { host: 'claude', kind: 'event' } }), headers, @@ -803,6 +1017,98 @@ it('invokes compiled tool and event routes through the foreground server', { tim } } + const artifactManifest = await readArtifactManifest(artifactRoot); + if (artifactManifest.status !== 'ok') throw new Error('Expected a readable artifact manifest.'); + const preparation = artifactManifest.manifest.executables.hooks.find((hook) => + hook.kind === 'event-route' && hook.routeId === 'event:tool/before' && hook.host === 'claude'); + if (preparation === undefined) throw new Error('Expected the compiled Claude event preparation.'); + const preparationPath = join(artifactRoot, preparation.path); + const preparationSource = await readFile(preparationPath); + const denyHandlerMarker = join(project.root, '.agent-bundle', 'deny-handler.marker'); + await writeFile(preparationPath, 'export const unrelated = true;\n'); + try { + await Promise.all([...candidateMarkers, denyHandlerMarker].map((path) => rm(path, { force: true }))); + const response = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: preflightCases[0][1], + routeId: preflightCases[0][0], + surface: { host: 'claude', kind: 'event' }, + }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + const missingPreparation = await response.json() as RouteInvocationResponse; + expect(missingPreparation.invocation).toMatchObject({ + diagnostics: [{ code: 'AB8252' }], + status: 'failed', + }); + expect(existsSync(denyHandlerMarker)).toBe(false); + expect(candidateMarkers.every((path) => !existsSync(path))).toBe(true); + } finally { + await writeFile(preparationPath, preparationSource); + } + + const workerlessMcpServers = artifactManifest.manifest.executables.mcpServers.map((executable) => { + if (executable.id !== 'mcp:omega' || executable.launch === undefined) return executable; + return { + ...executable, + launch: { + args: executable.launch.args, + entry: executable.launch.entry, + env: executable.launch.env, + }, + }; + }); + await writeFile(artifactManifest.path, serializeArtifactManifest({ + ...artifactManifest.manifest, + executables: { + ...artifactManifest.manifest.executables, + hooks: artifactManifest.manifest.executables.hooks.filter((hook) => + hook.routeId !== 'event:tool/before' && hook.routeId !== 'event:prompt/submit'), + mcpServers: workerlessMcpServers, + }, + })); + try { + await Promise.all(candidateMarkers.map((path) => + rm(path, { force: true }))); + const unavailableExecutableResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:omega/pass' }), + headers, + method: 'POST', + }); + expect(unavailableExecutableResponse.status).toBe(200); + const unavailableExecutable = await unavailableExecutableResponse.json() as RouteInvocationResponse; + expect(unavailableExecutable.invocation).toMatchObject({ + diagnostics: [{ code: 'AB8251' }], + status: 'failed', + }); + expect(existsSync(alphaWorkerMarker)).toBe(false); + expect(existsSync(importerWorkerMarker)).toBe(false); + expect(existsSync(omegaWorkerMarker)).toBe(false); + + for (const [routeId, input] of preflightCases) { + 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).toMatchObject({ + diagnostics: [{ code: 'AB8251' }], + status: 'failed', + }); + expect(existsSync(join( + project.root, + '.agent-bundle', + routeId === 'event:tool/before' ? 'deny-handler.marker' : 'continue-handler.marker', + ))).toBe(false); + } + } finally { + await writeFile(artifactManifest.path, serializeArtifactManifest(artifactManifest.manifest)); + } + const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ routeId: 'cli:greet', surface: { args: ['Ada'], command: 'greet', kind: 'cli' } }), headers, @@ -824,6 +1130,18 @@ it('invokes compiled tool and event routes through the foreground server', { tim surface: { args: ['Ada'], command: 'greet', kind: 'cli' }, }); + const plainCliHandlerMarker = join(project.root, '.agent-bundle', 'plain-cli-handler.marker'); + const plainCliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'cli:plain', surface: { args: [], command: 'plain', kind: 'cli' } }), + headers, + method: 'POST', + }); + expect(plainCliResponse.status).toBe(200); + await expect(plainCliResponse.json()).resolves.toMatchObject({ + invocation: { diagnostics: [{ code: 'AB8251' }], status: 'failed' }, + }); + expect(existsSync(plainCliHandlerMarker)).toBe(false); + // 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` @@ -895,6 +1213,23 @@ it('invokes compiled tool and event routes through the foreground server', { tim command: 'report', kind: 'cli', }); + + const plainToolCliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + routeId: 'tool:status/plain', + surface: { args: ['--yes'], command: 'plain-tool', kind: 'cli' }, + }), + headers, + method: 'POST', + }); + expect(plainToolCliResponse.status).toBe(200); + const plainToolCli = await plainToolCliResponse.json() as RouteInvocationResponse; + expect(plainToolCli.invocation, JSON.stringify(plainToolCli.invocation.diagnostics)).toMatchObject({ + projection: { cli: { exitCode: 0 } }, + result: { selected: 'plain-tool' }, + status: 'succeeded', + }); + 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.'); @@ -1052,7 +1387,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), - { timeoutMs: 10_000 }, + { timeoutMs: 20_000 }, ); expect(failedAttempt.outcome).toBe('failed'); expect(server.status().build.state).toBe('failed'); @@ -1095,7 +1430,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), - { timeoutMs: 10_000 }, + { timeoutMs: 20_000 }, ); expect(repairedAttempt.outcome, JSON.stringify(repairedAttempt.diagnostics)).toBe('succeeded'); const repairedInvocationResponse = await fetch(`${server.url}/api/routes/invocations`, { @@ -1128,6 +1463,110 @@ it('invokes compiled tool and event routes through the foreground server', { tim } }); +it('fails closed when a valid host is ineligible for the compiled event route', { timeout: 180_000 }, async () => { + const project = await createProjectFixture({ + config: "export default { plugin: { name: 'route-invocation-host-binding', version: '1.0.0' }, targets: ['claude', 'codex'] };\n", + files: { + 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*"},"type":"module"}\n', + 'src/events/tool/before.preflight.ts': "export default () => ({ outcome: 'deny', reason: 'blocked' });\n", + 'src/events/tool/before.tsx': [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "export { default as preflight } from './before.preflight.js';", + "writeFileSync(join(process.cwd(), '.agent-bundle', 'ineligible-import.marker'), 'loaded');", + "export const config = { runtime: 'standalone', targets: ['claude'] };", + 'export default async function BeforeTool() {', + " writeFileSync(join(process.cwd(), '.agent-bundle', 'ineligible-handler.marker'), 'ran');", + " throw new Error('ineligible event handler ran');", + '}', + '', + ].join('\n'), + }, + prefix: 'agent-bundle-route-invocation-host-binding-', + }); + 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 invocation host binding'), + ]); + 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 input = { + cwd: project.root, + hook_event_name: 'PreToolUse', + permission_mode: 'default', + session_id: 'session-host-binding', + tool_input: { file_path: 'blocked.txt' }, + tool_name: 'Write', + tool_use_id: 'use-host-binding', + transcript_path: join(project.root, 'transcript.json'), + }; + const importMarker = join(project.root, '.agent-bundle', 'ineligible-import.marker'); + const handlerMarker = join(project.root, '.agent-bundle', 'ineligible-handler.marker'); + const ineligibleResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input, + routeId: 'event:tool/before', + surface: { host: 'codex', kind: 'event' }, + }), + headers, + method: 'POST', + }); + expect(ineligibleResponse.status).toBe(200); + await expect(ineligibleResponse.json()).resolves.toMatchObject({ + invocation: { + diagnostics: [{ code: 'AB8251' }], + status: 'failed', + }, + }); + expect(existsSync(importMarker)).toBe(false); + expect(existsSync(handlerMarker)).toBe(false); + + const deniedResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input, + routeId: 'event:tool/before', + surface: { host: 'claude', kind: 'event' }, + }), + headers, + method: 'POST', + }); + expect(deniedResponse.status).toBe(200); + await expect(deniedResponse.json()).resolves.toMatchObject({ + invocation: { + result: { outcome: 'deny', reason: 'blocked' }, + status: 'succeeded', + }, + }); + expect(existsSync(importMarker)).toBe(false); + expect(existsSync(handlerMarker)).toBe(false); + } finally { + await server?.close().catch(() => undefined); + await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 }); + } +}); + 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", diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 0de67355c..3d78121d4 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -188,6 +188,7 @@ it('publishes correlated invocation and kernel entries with slim details', async } as const; const trace = collectingTrace(); let currentTime = Date.parse('2026-09-05T00:00:00.000Z'); + let production: RouteInvocationChildRequest['production']; const service = new RouteInvocationService({ manifest: { manifest: () => ({ @@ -203,14 +204,32 @@ it('publishes correlated invocation and kernel entries with slim details', async now: () => new Date(currentTime += 5), prepared: async () => ({ project: { - artifact: { epochId: 'epoch-1', target: 'claude' }, + artifact: { + epochId: 'epoch-1', + manifest: { + executables: { + mcpServers: [{ + id: 'mcp:fixture', + kind: 'compiled', + launch: { worker: 'mcp/fixture-flight.mjs' }, + }], + }, + routes: { + digest: 'digest', + servers: [{ id: 'mcp:fixture', mode: 'generated', routes: [{ id: route.id }] }], + }, + } as never, + root: '/artifact', + target: 'claude', + }, manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, stateRoot: '/project/.agent-bundle/state', targets: ['claude'], }, release: () => undefined, }), - renderChild: async (_request, _signal, publishKernelEvent) => { + renderChild: async (request, _signal, publishKernelEvent) => { + production = request.production; publishKernelEvent({ at: 8, count: 1, @@ -246,6 +265,7 @@ it('publishes correlated invocation and kernel entries with slim details', async routeId: route.id, }); + expect(production).toEqual({ executable: 'mcp/fixture-flight.mjs', kind: 'direct' }); expect(result.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); expect(trace.entries).toEqual([ expect.objectContaining({ @@ -318,6 +338,7 @@ it('publishes failed event invocations with native provenance', async () => { } as const; const trace = collectingTrace(); let currentTime = Date.parse('2026-09-05T00:00:00.000Z'); + let production: RouteInvocationChildRequest['production']; const service = new RouteInvocationService({ manifest: { manifest: () => ({ @@ -333,14 +354,55 @@ it('publishes failed event invocations with native provenance', async () => { now: () => new Date(currentTime += 5), prepared: async () => ({ project: { - artifact: { epochId: 'epoch-1', target: 'claude' }, - manifest: { projectRoot: '/project' } as never, + artifact: { + epochId: 'epoch-1', + manifest: { + executables: { + hooks: [{ + host: 'claude', + kind: 'event-route', + path: 'hooks/event-route-tool-after.claude.mjs', + routeId: route.id, + }], + mcpServers: [ + { + hosts: ['codex'], + id: 'mcp:alpha', + kind: 'compiled', + launch: { worker: 'mcp/alpha-flight.mjs' }, + }, + { + hosts: ['claude'], + id: 'mcp:beta', + kind: 'compiled', + launch: { worker: 'mcp/beta-flight.mjs' }, + }, + ], + }, + files: [{ path: 'hooks/hooks-flight.mjs' }], + routes: { + digest: 'digest', + events: [{ + execution: { fallback: 'none', runtime: 'shared' }, + id: route.id, + }], + servers: [ + { id: 'mcp:alpha', mode: 'generated' }, + { id: 'mcp:beta', mode: 'generated' }, + ], + }, + } as never, + root: '/artifact', + target: 'claude', + }, + manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, stateRoot: '/project/.agent-bundle/state', targets: ['claude'], }, release: () => undefined, }), - renderChild: async () => { + renderChild: async (request) => { + production = request.production; throw new Error('render exploded'); }, trace: trace.publisher, @@ -371,6 +433,11 @@ it('publishes failed event invocations with native provenance', async () => { state: 'available', value: { conversation: 'session-1', root: 'session-1' }, }); + expect(production).toEqual({ + executable: 'mcp/beta-flight.mjs', + kind: 'event', + preparation: 'hooks/event-route-tool-after.claude.mjs', + }); expect(trace.entries).toHaveLength(2); expect(trace.entries[1]).toMatchObject({ correlation: { @@ -396,6 +463,77 @@ it('publishes failed event invocations with native provenance', async () => { }); }); +it('keeps hostless shared events on their declared standalone fallback', 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; + let production: RouteInvocationChildRequest['production']; + const service = new RouteInvocationService({ + manifest: { + manifest: () => ({ + diagnostics: [], + digest: 'digest', + events: [route], + providers: [], + scripts: [], + servers: [], + sourceRevision: 'revision', + }), + }, + prepared: async () => ({ + project: { + artifact: { + epochId: 'epoch-1', + manifest: { + executables: { + hooks: [{ + host: 'claude', + kind: 'event-route', + path: 'hooks/event-route-tool-after.claude.mjs', + routeId: route.id, + }], + mcpServers: [], + }, + files: [{ path: 'hooks/hooks-flight.mjs' }], + routes: { + digest: 'digest', + events: [{ + execution: { fallback: 'standalone', runtime: 'shared' }, + id: route.id, + }], + servers: [], + }, + } as never, + root: '/artifact', + }, + manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }, + release: () => undefined, + }), + renderChild: async (request) => { + production = request.production; + throw new Error('stop after binding selection'); + }, + }); + + await expect(service.invoke({ + input: { payload: { toolName: 'Write' } }, + routeId: route.id, + surface: { kind: 'event' }, + })).resolves.toMatchObject({ + diagnostics: [{ code: 'AB8236' }], + status: 'failed', + }); + expect(production).toEqual({ executable: 'hooks/hooks-flight.mjs', kind: 'direct' }); +}); + const echoRoute = { config: [], id: 'tool:fixture/echo', @@ -974,6 +1112,75 @@ it('rejects a canonical event surface when the compiled route has preflight', as expect(leases).toBe(0); }); +it('rejects a globally supported host absent from the route executable bindings', async () => { + const route = { + config: [], + 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' }, + source: 'src/events/tool/before.tsx', + } as const; + let childStarts = 0; + const service = new RouteInvocationService({ + manifest: { + manifest: () => ({ + diagnostics: [], + digest: 'digest', + events: [route], + providers: [], + scripts: [], + servers: [], + sourceRevision: 'revision', + }), + }, + prepared: async () => ({ + project: { + artifact: { + epochId: 'epoch-1', + manifest: { + executables: { + hooks: [{ + host: 'claude', + kind: 'event-route', + path: 'hooks/event-route-tool-before.claude.mjs', + routeId: route.id, + }], + }, + routes: { + digest: 'digest', + events: [{ + execution: { fallback: 'standalone', preflight: route.execution.preflight, runtime: 'standalone' }, + id: route.id, + }], + }, + } as never, + root: '/artifact', + }, + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude', 'codex'], + }, + release: () => undefined, + }), + renderChild: async (request) => { + childStarts += 1; + return childResult(request); + }, + }); + + await expect(service.start({ + input: {}, + routeId: route.id, + surface: { host: 'codex', kind: 'event' }, + }).result).resolves.toMatchObject({ + diagnostics: [{ code: 'AB8251' }], + status: 'failed', + }); + expect(childStarts).toBe(0); +}); + interface RouteProject { readonly root: string; readonly service: (options?: Readonly<{ timeoutMs?: number }>) => RouteInvocationService; @@ -1100,7 +1307,7 @@ const tsxSiblingProject = async (): Promise => routeProject( }, ); -it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { +it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 60_000 }, async () => { const project = await tsxSiblingProject(); try { const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report', surface: { kind: 'unit-render' } }); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 7426ddda0..141cac8bb 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -353,6 +353,18 @@ The explicit `unit-render` surface is a component-preview fallback: it loads liv route-unit harness and mounts disposable state. It is not an artifact-parity receipt. A projected CLI command keeps the canonical `tool:` route id; a mismatched command is `AB8253`, while using its duplicate-looking `cli:` id is `AB8254` with the canonical id and surface to use. + +A production surface selects its executable from the published epoch's +`agent-bundle.manifest.json` before the child runs. Rendered CLI routes, projected tool commands, +and rendered scripts use their manifest worker; MCP routes use their owning compiled server's +`launch.worker`; and event routes use the host wrapper plus the worker selected by +`routes.events[].execution`: the standalone hooks worker, or the first compiled server that +owns the shared runtime for that host. The binding fails closed: an unavailable published +artifact is `AB8250`; an ineligible host or +surface, missing executable, or missing preparation row is `AB8251`; and a preparation module +that cannot be imported or does not export its contract is `AB8252`. A preflight route submitted +without a host is refused as `AB8255`. These failures never reach the handler, and an import, +preflight, or handler failure never causes another executable to run. Production state lives at `/.agent-bundle/state`, outside the published epochs that retirement removes. It is shared with dev MCP sessions through `AGENT_BUNDLE_STATE_ROOT`, so it survives a successful republish; `unit-render` still uses a fresh temporary state root per run. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 890056495..768e5817f 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -297,6 +297,15 @@ providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲 显式的 `unit-render` 表面是组件预览后备模式:它通过 route-unit 测试工具加载实时源码并挂载一次性状态, 不能作为制品一致性的证明。投影后的 CLI 命令保留规范 `tool:` 路由 id;命令不匹配报告 `AB8253`, 使用看似重复的 `cli:` id 则报告 `AB8254`,并给出应使用的规范 id 与表面。 + +生产表面会在子进程运行前,从已发布 epoch 的 `agent-bundle.manifest.json` 选择可执行项。渲染式 CLI +路由、投影后的工具命令与渲染脚本使用各自的 manifest worker;MCP 路由使用所属已编译服务器的 +`launch.worker`;事件路由则使用宿主包装层以及 `routes.events[].execution` 选定的 worker: +standalone hooks worker,或拥有该宿主共享运行时的第一个已编译服务器。绑定失败即关闭:已发布制品 +不可用报告 `AB8250`;宿主或表面 +不适用、缺少可执行项或缺少准备行报告 `AB8251`;准备模块无法导入或未导出其契约报告 `AB8252`。未指定 +宿主提交带 preflight 的路由报告 `AB8255`。这些失败绝不会到达处理函数,导入、preflight 或处理函数 +失败也绝不会触发另一个可执行项。 生产状态位于 `/.agent-bundle/state`,在 retirement 会移除的已发布 epoch 之外。它通过 `AGENT_BUNDLE_STATE_ROOT` 与开发期 MCP 会话共享,因此能跨一次成功的重新发布保留;`unit-render` 仍为每次运行使用新的临时状态根。