Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exact-routes-close.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<command>` id was used instead of its canonical `tool:<server>/<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:<command>` id was used instead of its canonical `tool:<server>/<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/<id>/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. |
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/composite-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
7 changes: 4 additions & 3 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
: {}),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.'); })(),
}),
})));
},
Expand Down
207 changes: 82 additions & 125 deletions packages/agent-bundle/src/dev/routes/route-invocation-production.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -85,6 +83,7 @@ interface WorkerMessage {
type ProductionRequest = RouteInvocationChildRequest & Readonly<{
readonly artifactEpoch: string;
readonly artifactRoot: string;
readonly production: NonNullable<RouteInvocationChildRequest['production']>;
}>;

const preparationFailure = (error: unknown): ProductionRouteInvocationError =>
Expand All @@ -110,27 +109,6 @@ const completeDocument = (value: JsonValue | undefined): AgentDocument => create
version: AGENT_DOCUMENT_VERSION,
});

const workerFiles = async (root: string): Promise<readonly string[]> => {
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<CompiledCliInvocationModule>): module is CompiledCliInvocationModule =>
typeof module.prepareRouteInvocation === 'function' && typeof module.routeInvocationExitCode === 'function';

Expand All @@ -146,42 +124,58 @@ const prepareInput = async (
observeTrace: EventTraceObserver,
signal: AbortSignal,
): Promise<PreparedInput> => {
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<Partial<CompiledCliInvocationModule>>(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<Partial<CompiledCliInvocationModule>>(
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<CompiledEventWrapperModule>(
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<CompiledEventWrapperModule>(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 = (
Expand Down Expand Up @@ -227,39 +221,6 @@ const invocationFor = (
}
};

const candidatesFor = async (request: ProductionRequest): Promise<readonly string[]> => {
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,
Expand Down Expand Up @@ -453,13 +414,6 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly<Reco
: { 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')
);

/**
* Drives one compiled worker's render stream. Each event is handed to
* `publishRender` as it arrives and then dropped; only the `complete` event's
Expand All @@ -481,49 +435,52 @@ 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 (
request: RouteInvocationChildRequest,
publishTrace?: EventTraceObserver,
publishRender?: (event: AgentRenderEvent) => Promise<void> | void,
): Promise<RouteInvocationChildResult> => {
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;
Expand Down
Loading
Loading