diff --git a/.changeset/595-event-preflight-gates.md b/.changeset/595-event-preflight-gates.md new file mode 100644 index 000000000..35cf8e776 --- /dev/null +++ b/.changeset/595-event-preflight-gates.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Allow an event route under `src/events/**` to declare a `preflight` gate — `export { default as preflight } from './.js'`, a sync or async function that receives the frozen `{ canonical, host, signal, terminal }` context and returns `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }` — which the generated hook entry runs on the canonical event before the rendered route runtime, React, or any application provider loads. `inspect`, `validate`, `build`, and `dev` report `AB4840` when `preflight` is declared inline, exported more than once, re-exported from a bare package or under a binding other than `default`, unresolvable, cyclic, or not a function, naming the route module and, once a re-export was found, its specifier. Allow an executed event route to declare the provider keys it requires (`config.providers: ['', …]`) so only that subset resolves, in the existing deterministic key/source order with `processLifetime` seeded first; a route without a declaration still resolves every conventional provider, `[]` mounts `processLifetime` alone, and `AB4841` reports a malformed declaration, a duplicate key, the reserved `processLifetime`, or a key that matches no discovered `src/providers/*` module — unknown keys list the project's provider keys. Export `EventPreflight`, `EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and `eventFamilyAllowsPreflightDeny` from `agent-bundle`, `agent-bundle/api`, and `agent-bundle/routes`. Export the payload-free `EventTraceEvent` union, `createEventTracer`, and `installEventTraceObserver` for developer tooling. (#618) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8f51159a6..efb5d5883 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -30,7 +30,7 @@ even when no error diagnostic was reported. | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | -| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), and provider conventions (see below). | +| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | @@ -1193,6 +1193,56 @@ graphs whose schemas are all inline keep their recorded digests. Workbench route detail shows a route's contract origin and the other routes sharing it. +An event route (`src/events//*`) may add a **preflight gate** (#595): +a named `preflight` export the generated hook entry runs after envelope +decoding, host validation, and canonical event construction, and before any +of the rendered route runtime — React, the RSC renderer, layouts, providers, +state, notices — is loaded. The gate is sync or async, receives a frozen +context of the `canonical` identity and payload the route would receive, the +compiled host identity and native event name, the request `signal` owned by +the hook deadline, and translated `terminal` capability metadata (never +`native`, state, notices, lineage, providers, or +the request context), and returns exactly one of `'execute'` (load the route +runtime, resolve its declared providers, render), `{ outcome: 'continue' }` +(pass through with no host decision), or `{ outcome: 'deny', reason }` (a +denial projected through the family's canonical outcome rules; +observation-only families cannot deny). `undefined`, an unknown outcome, an +extra field, or an empty reason fails closed at hook time. A gate is only +cheap when the compiler can bundle it on its own, so exactly one authoring +form is accepted: a single `export { default as preflight } from './.js'` +in the route module, whose relative target (a `.js` specifier resolves to the +`.ts`/`.tsx` source, as route imports do) is a readable module whose default +export is a function — followed, like a route's default re-export, through an +acyclic chain of relative default re-exports. The compiler records that module +on the route's own graph node (`preflight` on the compiled route, part of the +graph digest) and keeps it out of route discovery, so +`src/events/tool/before.preflight.ts` beside `before.tsx` is application code +the route names, never a second event route. A `preflight` declared inline in +the route module (`export const preflight = …`, `export function preflight`) +is rejected too: evaluating the route module evaluates its rendering and +provider imports, the very cost the gate exists to avoid. Every rejected form +is `AB4840`, once per route on the route module; the route compiles without a +gate beside the error, and because the diagnostic is an error the build fails +instead of silently taking the expensive path. + +Provider laziness is declaration-driven (#595). Preflight materializes no +application providers. An executed event route with no provider declaration +resolves every conventional provider, as before; a route that declares the +provider keys it requires — `config.providers: ['', …]`, string literals +inside the static config grammar — loads and resolves only that subset, still +once per request, sequentially in the deterministic key-then-source order +(never declaration order), fail-closed, with the framework-owned +`processLifetime` seeded first. `[]` is a valid declaration that mounts +`processLifetime` alone. Keys are the camel-cased `src/providers/.*` +stems the graph derives (`retry-policy.ts` is `retryPolicy`), the same keys +the generated `AgentBundleProviders` declares; `processLifetime` is not one of +them and must not be declared. The declaration is judged when the route graph +compiles: a declaration that is not an array of string literals, a key listed +twice, the reserved `processLifetime`, or a key naming no discovered provider +module is `AB4841`, once per route with every defect in one message; a +declaration with any defect selects nothing, so the build fails rather than +resolving a provider set the author did not write. + | Code | Severity | Trigger | | --- | --- | --- | | `AB4800` | error | An MCP server has both discovered route modules under `src/mcp//` and an existing entry claim (the conventional `src/mcp/.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.` mode. | @@ -1235,6 +1285,8 @@ sharing it. | `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | | `AB4838` | error | A CLI route's `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route inputSchema: .` — the chain is the reference path from `inputSchema`, each step ``, or ` ()` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const `, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes raise it, because only there the static contract is load-bearing: an MCP, script, or event route whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. | | `AB4839` | error | A CLI route's `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `#` is recorded and revisiting one stops the walk. The message is `CLI route inputSchema: is a reference cycle.` and prints the cycle; it is reported on the route module, with the same recovery and the same CLI-only rule as `AB4838`. | +| `AB4840` | error | An event route's `preflight` gate (#595) is not the one physically cheap form the compiler can bundle on its own. Rejected: `preflight` declared inline in the route module (`export const preflight = …`, `export function preflight`) or exported more than once; re-exported under a binding other than `default` (`export { gate as preflight } from './gate.js'`, `export { preflight } from './gate.js'`); re-exported from a non-relative specifier (a bare package such as `'@scope/gate'`); a relative target that is missing, unreadable, or part of a re-export cycle; a target default export that cannot be followed through an acyclic chain of relative default re-exports; or a target default export that is not a function the scan can see. The message names the route module and, once a re-export was found, its specifier. Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per route with the route module as `sourcePath`; the route compiles without a gate beside the error, and the build fails rather than silently taking the expensive rendered path. Write exactly `export { default as preflight } from './.js'` in the route module, and make that module default-export one sync or async function receiving `{ canonical, host, signal, terminal }` and returning `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }`. | +| `AB4841` | error | An event route's static required-provider declaration (#595) does not select a known set of conventional providers: `config.providers` is not an array of provider-key strings; a key is declared more than once; a key is the reserved `processLifetime`; or a key matches no conventional provider the route graph discovered under `src/providers/`. The message names the route and every offending key. Declare each key exactly once, spelled as the camel-cased stem of its `src/providers/.*` module, drop `processLifetime`, declare `[]` to mount `processLifetime` alone, or omit `config.providers` to preserve the all-provider compatibility default. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/package.json b/package.json index 9470f5915..a49169ea8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:watch": "rstest --config rstest.config.ts --watch", "lint": "rslint .", "bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs", + "bench:preflight-cold-start": "node scripts/measure-preflight-cold-start.mjs", "typecheck": "node scripts/check-dist-fresh.mjs && tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json && pnpm --filter @agent-bundle/docs typecheck", "check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck", "check:local-ci": "node scripts/local-ci.mjs", diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 72d842326..741f40f61 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -28,6 +28,8 @@ export interface TargetHookWrapper { } export interface TargetHookEntry extends TargetHookWrapper { + /** Heavy event executor bundled beside a cheap preflight wrapper. */ + readonly executeVirtualSource?: string; /** Timeout projected into the native host's seconds unit. */ readonly timeout?: number; readonly virtualSource: string; @@ -633,13 +635,16 @@ const standaloneEventRoute = (route: NonNullable): /** * True when a wrapper runs plugin code in its own process and therefore * imports the operator `.env` layer (#469): every handler-executing wrapper, - * and an event-route wrapper that can render standalone. A shared-runtime - * event-route wrapper forwards the event to the warm MCP process, which - * applied the layer itself when it started. The build serves the layer module - * to exactly these wrappers. + * an event-route wrapper that can render standalone, and a wrapper that + * evaluates a preflight gate in-process. A shared-runtime event-route + * wrapper without a gate forwards the event to the warm MCP process, which + * applied the layer itself when it started. The build serves the layer + * module to exactly these wrappers. */ export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean => - entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute); + entry.hook.eventRoute === undefined + || standaloneEventRoute(entry.hook.eventRoute) + || entry.hook.eventRoute.preflight !== undefined; /** * The wrapper reaches the warm MCP runtime through an endpoint identified by @@ -654,6 +659,7 @@ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, durableLineage = false, + deferredExecution = false, ): string => { const route = entry.hook.eventRoute!; const standalone = standaloneEventRoute(route); @@ -661,6 +667,10 @@ const eventRouteHookWrapperSource = ( // then) retires the durable lineage journal itself, so roots never outlive // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; + const projectBindings = [ + ...(standalone ? ['createCanonicalEventProps', 'projectEventDocument'] : []), + 'validateNativeEventEnvelope', + ]; return [ // Only a wrapper that can render in-process needs the operator `.env` // layer (#469): a shared-runtime wrapper forwards the event to the warm @@ -682,7 +692,7 @@ const eventRouteHookWrapperSource = ( ] : []), `import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, - `import { ${standalone ? 'createCanonicalEventProps, projectEventDocument, ' : ''}validateNativeEventEnvelope } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, + `import { ${projectBindings.join(', ')} } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, '', `const artifactEpoch = ${JSON.stringify(eventArtifactEpochToken)};`, ...(standalone ? [`const flightArtifactEpoch = ${JSON.stringify(eventFlightArtifactEpochToken)};`] : []), @@ -772,9 +782,9 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal) => {', - ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', - ...(retiresLineage ? [' await retireLineage(native, props.canonical.idempotencyKey, props.canonical.observedAt);'] : []), + 'const runStandalone = async (native, signal, observation) => {', + ' const resolved = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal, observation);', + ...(retiresLineage ? [' await retireLineage(native, resolved.canonical.idempotencyKey, resolved.canonical.observedAt);'] : []), ' const sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;', ' const workspaceRoot = typeof native.cwd === "string" ? native.cwd : Array.isArray(native.workspace_roots) && typeof native.workspace_roots[0] === "string" ? native.workspace_roots[0] : undefined;', // Standalone hooks hold no registry, so lineage is what the payload proves — plus, on Codex, what the @@ -790,7 +800,7 @@ const eventRouteHookWrapperSource = ( // A hook's stdout is its host envelope: no terminal, never probed (#511). ' terminal: available({ hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } }, "derived"),', ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', - ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: props.canonical, native: props.native } } }, signal));', + ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native } } }, signal));', ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', '};', ] @@ -800,24 +810,37 @@ const eventRouteHookWrapperSource = ( ' let bytes = 0;', ' for await (const chunk of process.stdin) {', ' bytes += chunk.length;', - ' if (bytes > 1024 * 1024) fail("stdin exceeds the 1 MiB native-payload limit");', + ` if (bytes > ${deferredExecution ? '8 * ' : ''}1024 * 1024) fail("stdin exceeds the ${deferredExecution ? '8 MiB deferred-payload' : '1 MiB native-payload'} limit");`, ' chunks.push(chunk);', ' }', ' let parsed;', ' try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { fail("stdin must contain exactly one JSON value"); }', - ' const native = validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target });', + ...(deferredExecution + ? [ + ' if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) fail("deferred input must be an object");', + ' const { native: nativeInput, observedAt, sequence } = parsed;', + ' if (typeof observedAt !== "string" || !Number.isInteger(sequence) || sequence < 1) fail("deferred input has an invalid canonical observation");', + ' const observation = { observedAt, sequence };', + ] + : [ + ' const nativeInput = parsed;', + ' const observation = undefined;', + ]), + ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', ' const controller = new AbortController();', ' let output;', ' if (runtimeMode === "standalone") {', - ...(standalone ? [' output = await runStandalone(native, controller.signal);'] : [' fail("standalone runtime was not compiled");']), + ...(standalone + ? [' output = await runStandalone(native, controller.signal, observation);'] + : [' fail("standalone runtime was not compiled");']), ' } else {', ' try {', - ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs });', + ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });', ' } catch (error) {', ...(standalone ? [ ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', - ' output = await runStandalone(native, controller.signal);', + ' output = await runStandalone(native, controller.signal, observation);', ] : [' throw error;']), ' }', @@ -834,6 +857,110 @@ const eventRouteHookWrapperSource = ( ].join('\n'); }; +/** + * Emits the physically cheap public entry for a gated event route. The + * rendered route kernel remains a separately bundled sibling process and is + * started only after the gate returns `execute`. + */ +const eventRoutePreflightWrapperSource = ( + entry: TargetHookWrapper, + hostContractRevision: string, +): string => { + const route = entry.hook.eventRoute!; + const preflight = route.preflight!; + const executorFile = entry.relativePath.split('/').at(-1)!.replace(/\.mjs$/u, '.execute.mjs'); + const projectBindings = [ + 'createCanonicalEventProps', + 'createEventTracer', + 'eventTraceExecution', + 'executeEventPreflight', + 'projectEventPreflightResult', + 'validateNativeEventEnvelope', + ]; + return [ + operatorEnvLayerImport, + "import { spawn } from 'node:child_process';", + "import { fileURLToPath } from 'node:url';", + `import { ${projectBindings.join(', ')} } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, + `import preflight from ${JSON.stringify(preflight.source)};`, + '', + `const canonicalEvent = ${JSON.stringify(route.event)};`, + `const capabilityRevision = ${JSON.stringify(hostContractRevision)};`, + `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, + `const target = ${JSON.stringify(entry.target)};`, + `const runtimeMode = ${JSON.stringify(route.runtime)};`, + `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}`); };', + 'const runExecutor = (input, signal) => new Promise((resolve, reject) => {', + ' const child = spawn(process.execPath, [executor], { signal, stdio: ["pipe", "pipe", "pipe"] });', + ' const stdout = [];', + ' const stderr = [];', + ' child.stdout.on("data", (chunk) => stdout.push(chunk));', + ' child.stderr.on("data", (chunk) => stderr.push(chunk));', + ' child.once("error", reject);', + ' child.stdin.once("error", reject);', + ' child.once("close", (code, childSignal) => {', + ' const errorText = Buffer.concat(stderr).toString("utf8");', + ' if (code !== 0 || childSignal !== null) { reject(new Error(errorText.trim() || `Deferred event executor failed (exit ${String(code)}, signal ${String(childSignal)}).`)); return; }', + ' if (errorText !== "") process.stderr.write(errorText);', + ' resolve(Buffer.concat(stdout));', + ' });', + ' child.stdin.end(input);', + '});', + 'const run = async () => {', + ' const chunks = [];', + ' let bytes = 0;', + ' for await (const chunk of process.stdin) {', + ' bytes += chunk.length;', + ' if (bytes > 1024 * 1024) fail("stdin exceeds the 1 MiB native-payload limit");', + ' chunks.push(chunk);', + ' }', + ' 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);', + ' if (gate !== "execute") {', + ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', + ' return;', + ' }', + ' trace.executeStart(runtimeMode);', + ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, sequence: props.canonical.sequence }));', + ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', + ' const terminate = () => controller.abort();', + ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', + ' let output;', + ' try {', + ' output = await runExecutor(executionInput, controller.signal);', + ' } catch (error) {', + ' trace.failure("execute", error);', + ' throw error;', + ' } finally {', + ' for (const terminationSignal of terminationSignals) process.off(terminationSignal, terminate);', + ' }', + ' if (output.length > 0) process.stdout.write(output);', + '};', + 'if (import.meta.main) {', + ' await run().catch((error) => {', + ' process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);', + ' process.exitCode = 1;', + ' });', + '}', + '', + ].join('\n'); +}; + /** Emits the published Cursor hook wrapper source; see encodeCursorPlaygroundInput for the envelope contract. */ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ // The installed pack's operator `.env` layer (#469): the first import, so @@ -1198,15 +1325,18 @@ export const planHooks = ( target, ...(timeout === undefined ? {} : { timeout }), }; + const preflight = hook.eventRoute?.preflight; + const hostRevision = contract.hostContractRevision ?? target; + const durableLineage = model.state?.lifetime === 'workspace-durable'; + const renderedSource = hook.eventRoute === undefined + ? contract.wrapperSource(wrapper) + : eventRouteHookWrapperSource(wrapper, hostRevision, durableLineage, preflight !== undefined); hookEntries.push({ ...wrapper, - virtualSource: hook.eventRoute === undefined - ? contract.wrapperSource(wrapper) - : eventRouteHookWrapperSource( - wrapper, - contract.hostContractRevision ?? target, - model.state?.lifetime === 'workspace-durable', - ), + ...(preflight === undefined ? {} : { executeVirtualSource: renderedSource }), + virtualSource: preflight === undefined + ? renderedSource + : eventRoutePreflightWrapperSource(wrapper, hostRevision), }); } diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index cf4234235..1732d8f68 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -49,6 +49,8 @@ export { agentEventPayloadFields, agentEventPayloadNativeKeys, canonicalAgentEvents, + eventFamilyAllowsPreflightDeny, + validateEventPreflightResult, } from './routes/public.ts'; export type { AgentEventCanonicalIdentity, @@ -71,6 +73,9 @@ export type { AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, + EventPreflight, + EventPreflightContext, + EventPreflightResult, PromptConfig, ResourceConfig, RouteSchema, @@ -78,6 +83,35 @@ export type { ToolConfig, ToolRouteProps, } from './routes/public.ts'; +export { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTraceObserver, + eventTracePhases, + installEventTraceObserver, + summarizeEventTraceError, +} from './events/trace.ts'; +export type { + CreateEventTracerOptions, + EventTraceErrorSummary, + EventTraceEvent, + EventTraceEventKind, + EventTraceExecuteStart, + EventTraceExecution, + EventTraceFailure, + EventTraceObserver, + EventTracePhase, + EventTracePreflightOutcome, + EventTracePreflightOutcomeEvent, + EventTracePreflightStart, + EventTraceProvidersFinish, + EventTraceProvidersStart, + EventTracer, + EventTraceRenderFinish, + EventTraceRenderStart, + EventTraceRuntime, +} from './events/trace.ts'; export { inspectRouteGraph } from './routes/inspect.ts'; export type { RouteGraphInspection } from './routes/inspect.ts'; export { emptyRouteConfig } from './routes/types.ts'; diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 95e8b5877..88b929a18 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -195,7 +195,11 @@ const plannedDestinations = (composite: CompositePlan, staged: StagedRoot): read ...composite.entries.map((entry) => resolveArtifactDestination(staged.root, entry.relativePath)), ...staged.compiledCliBins.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ...staged.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...staged.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledHooks.flatMap((entry) => [ + entry.output, + ...(entry.executorOutput === undefined ? [] : [entry.executorOutput]), + ...(entry.workerOutput === undefined ? [] : [entry.workerOutput]), + ]), ...staged.compiledMcpApps.map((entry) => entry.output), ...staged.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ]; @@ -250,7 +254,11 @@ const outputCandidatesFor = (options: { kind: 'bundle' as const, path: entry.output, sourceInputs: entry.sourceInputs, - }, ...(entry.workerOutput === undefined ? [] : [{ + }, ...(entry.executorOutput === undefined ? [] : [{ + kind: 'bundle' as const, + path: entry.executorOutput, + sourceInputs: entry.executorSourceInputs ?? entry.sourceInputs, + }]), ...(entry.workerOutput === undefined ? [] : [{ kind: 'bundle' as const, path: entry.workerOutput, sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, @@ -537,6 +545,7 @@ export const build = async (options: BuildOptions): Promise => { compiledHooks: Object.freeze(compiledHooks.map((entry) => Object.freeze({ ...entry, output: publishedOutput(entry), + ...(entry.executorOutput === undefined ? {} : { executorOutput: publishedOutput({ output: entry.executorOutput }) }), ...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }), }))), compiledMcpApps: Object.freeze(compiledMcpApps.map((entry) => Object.freeze({ diff --git a/packages/agent-bundle/src/build/compile-stages.ts b/packages/agent-bundle/src/build/compile-stages.ts index b37f8c196..8686f6c41 100644 --- a/packages/agent-bundle/src/build/compile-stages.ts +++ b/packages/agent-bundle/src/build/compile-stages.ts @@ -6,7 +6,7 @@ import type { CompiledMcpApp } from './mcp-apps.ts'; export interface PlannedRootOutputs { readonly compiledCliBins: readonly Pick[]; readonly compiledEntries: readonly Pick[]; - readonly compiledHooks: readonly Pick[]; + readonly compiledHooks: readonly Pick[]; readonly compiledMcpApps: readonly Pick[]; readonly compiledMcpEntries: readonly Pick[]; } @@ -31,9 +31,14 @@ export type CompileStage = | { readonly kind: 'node-surfaces'; readonly outputs: readonly string[] }; const withWorkers = ( - entries: readonly { readonly output: string; readonly workerOutput?: string }[], + entries: readonly { + readonly executorOutput?: string; + readonly output: string; + readonly workerOutput?: string; + }[], ): readonly string[] => entries.flatMap((entry) => [ entry.output, + ...(entry.executorOutput === undefined ? [] : [entry.executorOutput]), ...(entry.workerOutput === undefined ? [] : [entry.workerOutput]), ]); diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index cd7519d1f..b48f8e3ca 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -55,7 +55,7 @@ import { runtimeIgnoredRoot } from './runtime-path.ts'; * Rslib build would otherwise read the template as a directory context and * replace it with a lookup that throws at run time. */ -const eventRuntimeModulePath = (module: 'ipc' | 'project'): string => { +export const eventRuntimeModulePath = (module: 'ipc' | 'project'): string => { const here = dirname(fileURLToPath(import.meta.url)); for (const path of [ join(here, `event-${module}.js`), @@ -89,6 +89,9 @@ interface PlannedScriptEntry extends CompiledEntry { export interface CompiledHookEntry extends CompiledEntry { readonly event: TargetHookEntry['event']; + /** Heavy sibling process started only after a preflight gate returns execute. */ + readonly executorOutput?: string; + readonly executorSourceInputs?: readonly string[]; readonly id: string; /** False when this wrapper is a host-document variant excluded from the canonical hook index. */ readonly indexed?: false; @@ -552,6 +555,15 @@ export const planMcpEntriesSurface = async ( }; }; +const hookEntrySourceInputs = (entry: TargetHookEntry): readonly string[] => { + const preflight = entry.hook.eventRoute?.preflight; + return Object.freeze([ + entry.hook.provenance.sourcePath, + entry.hook.source, + ...(preflight === undefined ? [] : [preflight.source]), + ]); +}; + export const planCompiledHooks = ( entries: readonly TargetHookEntry[], options: { readonly outDir: string }, @@ -570,8 +582,17 @@ export const planCompiledHooks = ( output: resolveArtifactDestination(options.outDir, entry.relativePath), outputKind: 'bundle', source: entry.hook.source, - sourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]), + sourceInputs: hookEntrySourceInputs(entry), target: entry.target, + ...(entry.executeVirtualSource === undefined + ? {} + : { + executorOutput: resolveArtifactDestination( + options.outDir, + entry.relativePath.replace(/\.mjs$/u, '.execute.mjs'), + ), + executorSourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]), + }), ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), ...(index === workerOwner ? { @@ -637,35 +658,58 @@ export const planHooksSurface = ( }; return { entries: [ - ...compiled.map((entry, index) => ({ - // One hook can compile into several host wrappers (for example a shared - // Claude/Codex wrapper plus a Cursor-codec wrapper), so the bundler - // library id derives from the unique output path, not the hook name. - name: entries[index]!.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), - outputRelativePath: entries[index]!.relativePath, - ...(entries[index]!.hook.eventRoute?.runtime === 'standalone' - || entries[index]!.hook.eventRoute?.fallback === 'standalone' - ? { rscManifest: true as const } - : {}), - aliases: { - // Every wrapper that runs plugin code applies the operator `.env` layer (#469). - [launchEnvRuntimeSpecifier]: launchEnvRuntime, - ...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined - ? {} - : { - [eventIpcRuntimeSpecifier]: eventIpcRuntime, - ...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }), - }), - }, - source: entry.source, - sourceInputs: entry.sourceInputs, - virtualSource: entries[index]!.virtualSource - .replaceAll(eventArtifactEpochToken, options.artifactEpoch) - .replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch), - // The layer module the wrapper imports first; a shared-runtime - // event-route wrapper runs no plugin code and imports none. - ...(hookWrapperAppliesOperatorEnv(entries[index]!) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), - })), + ...compiled.flatMap((entry, index) => { + const hook = entries[index]!; + const aliases = { + [launchEnvRuntimeSpecifier]: launchEnvRuntime, + ...(hook.hook.eventRoute === undefined || eventIpcRuntime === undefined + ? {} + : { + [eventIpcRuntimeSpecifier]: eventIpcRuntime, + ...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }), + }), + }; + const wrapperEntry = { + // One hook can compile into several host wrappers (for example a shared + // Claude/Codex wrapper plus a Cursor-codec wrapper), so the bundler + // library id derives from the unique output path, not the hook name. + name: hook.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), + outputRelativePath: hook.relativePath, + ...(hook.executeVirtualSource === undefined && (hook.hook.eventRoute?.runtime === 'standalone' + || hook.hook.eventRoute?.fallback === 'standalone') + ? { rscManifest: true as const } + : {}), + aliases, + source: entry.source, + sourceInputs: entry.sourceInputs, + virtualSource: hook.virtualSource + .replaceAll(eventArtifactEpochToken, options.artifactEpoch) + .replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch), + // The layer module the wrapper imports first; a shared-runtime + // event-route wrapper runs no plugin code and imports none. + ...(hookWrapperAppliesOperatorEnv(hook) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), + }; + if (hook.executeVirtualSource === undefined) return [wrapperEntry]; + const executorRelativePath = hook.relativePath.replace(/\.mjs$/u, '.execute.mjs'); + return [ + wrapperEntry, + { + aliases, + name: executorRelativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), + outputRelativePath: executorRelativePath, + ...(hook.hook.eventRoute?.runtime === 'standalone' + || hook.hook.eventRoute?.fallback === 'standalone' + ? { rscManifest: true as const } + : {}), + source: entry.source, + sourceInputs: Object.freeze([hook.hook.provenance.sourcePath, hook.hook.source]), + virtualSource: hook.executeVirtualSource + .replaceAll(eventArtifactEpochToken, options.artifactEpoch) + .replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch), + ...(hookWrapperAppliesOperatorEnv(hook) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), + }, + ]; + }), ...(workerEntry === undefined ? [] : [workerEntry]), ], ignoredSourcePaths: [ @@ -677,6 +721,10 @@ export const planHooksSurface = ( return Object.freeze(compiled.map((entry, index) => Object.freeze({ ...entry, sourceInputs: evidenceByPath.get(entries[index]!.relativePath) ?? (() => { throw new Error(`Missing bundled hook evidence for ${JSON.stringify(entry.name)}.`); })(), + ...(entry.executorOutput === undefined ? {} : { + executorSourceInputs: evidenceByPath.get(entries[index]!.relativePath.replace(/\.mjs$/u, '.execute.mjs')) + ?? (() => { 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.'); })(), }), diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 344f7fe8b..c88c70e13 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -7,7 +7,11 @@ import { operatorEnvLayerImport, operatorEnvLayerImports, operatorEnvLayerStatem import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts'; -import { orderedProviders } from '../routes/provider-execution.ts'; +import { + orderedProviders, + requiredProviderKeyProblemMessage, + selectRequiredProviders, +} from '../routes/provider-execution.ts'; import { layoutChainFor, layoutRouteName } from '../routes/layouts.ts'; import { mcpRouteProtocolName } from '../routes/protocol-name.ts'; import { providerKeyFromName } from '../routes/providers.ts'; @@ -875,8 +879,16 @@ const eventRouteImports = ( const eventRouteRecords = ( routes: readonly NormalizedHook[], offset: number, + providers: readonly CompiledProvider[], + selections: ReadonlyMap, ): readonly string[] => routes.map((route, index) => - ` ${JSON.stringify(route.id)}: Object.freeze({ event: ${JSON.stringify(route.eventRoute!.event)}, id: ${JSON.stringify(`event:${route.eventRoute!.event}`)}, kind: 'event-route', module: route${String(offset + index)}, name: ${JSON.stringify(route.eventRoute!.event)} }),`); + ` ${JSON.stringify(route.id)}: Object.freeze({ event: ${JSON.stringify(route.eventRoute!.event)}, id: ${JSON.stringify(`event:${route.eventRoute!.event}`)}, kind: 'event-route', module: route${String(offset + index)}, name: ${JSON.stringify(route.eventRoute!.event)}${ + route.eventRoute!.providers === undefined + ? '' + : `, providers: Object.freeze([${ + (selections.get(route.id) ?? []).map((provider) => `providers[${String(providers.indexOf(provider))}]`).join(', ') + }])` + } }),`); const providerImports = (providers: readonly CompiledProvider[]): readonly string[] => providers.map((provider, index) => @@ -923,14 +935,18 @@ const processLifetimeValueSource = 'processHit'; */ const providersFieldSource = ( providers: readonly CompiledProvider[], - expressions: { readonly indent: string; readonly invocation: string }, + expressions: { + readonly indent: string; + readonly invocation: string; + readonly providers?: string; + }, ): readonly string[] => { - const { indent, invocation } = expressions; + const { indent, invocation, providers: providerExpression = 'providers' } = expressions; if (providers.length === 0) return [`${indent}providers: { processLifetime: ${processLifetimeValueSource} },`]; return [ `${indent}providers: async (request) => {`, `${indent} const providerValues = { processLifetime: ${processLifetimeValueSource} };`, - `${indent} for (const provider of providers) {`, + `${indent} for (const provider of ${providerExpression}) {`, `${indent} if (typeof provider.module.default !== 'function') {`, `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, `${indent} }`, @@ -949,9 +965,28 @@ const providersFieldSource = ( export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => { const routes = executableMcpRoutes(options.routes); const eventRoutes = options.eventRoutes ?? []; - const providers = orderedProviders(options.providers ?? []); - const layouts = workerLayouts(options.layouts ?? [], routes); const wiresInbox = wiresInboxRoute(options); + const allProviders = orderedProviders(options.providers ?? []); + const providerSelections = new Map(); + let importsAllProviders = routes.length > 0 || wiresInbox; + for (const route of eventRoutes) { + const required = route.eventRoute?.providers; + const selection = selectRequiredProviders(allProviders, required); + if (!selection.ok) { + throw new Error( + `Event route ${JSON.stringify(route.id)} has an invalid provider selection: ${ + selection.problems.map(requiredProviderKeyProblemMessage).join(' ') + }`, + ); + } + if (required === undefined) importsAllProviders = true; + else providerSelections.set(route.id, selection.providers); + } + const providers = importsAllProviders + ? allProviders + : orderedProviders([...new Set([...providerSelections.values()].flat())]); + const hasProviderSelections = providerSelections.size > 0; + const layouts = workerLayouts(options.layouts ?? [], routes); return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", @@ -981,7 +1016,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'const routes = Object.freeze({', ...routeRecords(routes, { layouts }), ...noticeInboxRecord(wiresInbox), - ...eventRouteRecords(eventRoutes, routes.length), + ...eventRouteRecords(eventRoutes, routes.length, providers, providerSelections), '});', 'const requests = new Map();', '', @@ -1009,7 +1044,11 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' plugin,', ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', - ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation' }), + ...providersFieldSource(providers, { + indent: ' ', + invocation: 'message.invocation', + ...(hasProviderSelections ? { providers: 'route.providers ?? providers' } : {}), + }), ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index f794e2182..3415f47e8 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,4 +1,8 @@ -import { hookWrapperAppliesOperatorEnv } from '../adapters/hook-contract.ts'; +import { + eventIpcRuntimeSpecifier, + eventProjectRuntimeSpecifier, + hookWrapperAppliesOperatorEnv, +} from '../adapters/hook-contract.ts'; import type { TargetHookEntry } from '../adapters/types.ts'; import { isPlainRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; @@ -21,7 +25,7 @@ import { import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import type { CompositePlan } from './compose.ts'; -import { eventRuntimeHosting, planCompiledMcpEntries, selectedServerHosts } from './entries.ts'; +import { eventRuntimeHosting, eventRuntimeModulePath, planCompiledMcpEntries, selectedServerHosts } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; import { planPackageEntries } from './package-build.ts'; @@ -321,7 +325,15 @@ const hookEntries = ( const outputRoot = artifactOutputToken; return entries.map((entry) => rslibInspectionEntry({ entry: { - aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, + aliases: { + [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), + ...(entry.hook.eventRoute === undefined + ? {} + : { + [eventIpcRuntimeSpecifier]: eventRuntimeModulePath('ipc'), + [eventProjectRuntimeSpecifier]: eventRuntimeModulePath('project'), + }), + }, name: entry.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), outputRelativePath: entry.relativePath, source: entry.hook.source, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 23f2dcf24..cba213b70 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -528,10 +528,28 @@ const normalizeHooks = ( : undefined; const fallback = route.config['fallback'] === 'standalone' ? 'standalone' as const : 'none' as const; const runtime = route.config['runtime'] === 'standalone' ? 'standalone' as const : 'shared' as const; + const configuredProviders = route.config['providers']; + const providers = Array.isArray(configuredProviders) + && configuredProviders.every((provider): provider is string => typeof provider === 'string') + ? [...configuredProviders] + : undefined; const eventName = event.replace('/', '-'); hooks.push({ event: hookEventForRoute[event], - eventRoute: Object.freeze({ event, fallback, runtime }), + eventRoute: Object.freeze({ + event, + fallback, + ...(route.preflight === undefined + ? {} + : { + preflight: { + provenance: { ...route.preflight.provenance }, + source: route.preflight.source, + }, + }), + ...(providers === undefined ? {} : { providers }), + runtime, + }), id: `hook:event-route:${eventName}`, name: `event-route-${eventName}`, provenance: { kind: 'conventional', sourcePath: route.source }, diff --git a/packages/agent-bundle/src/core/abort.ts b/packages/agent-bundle/src/core/abort.ts new file mode 100644 index 000000000..35d52ee94 --- /dev/null +++ b/packages/agent-bundle/src/core/abort.ts @@ -0,0 +1,27 @@ +/** + * Settles with an operation or rejects immediately when its owning signal + * aborts, without waiting for cooperative cancellation inside the operation. + */ +export const settleBeforeAbort = ( + operation: Promise, + signal: AbortSignal, + reason: () => unknown = () => signal.reason, +): Promise => new Promise((resolve, reject) => { + let settled = false; + const finish = (outcome: () => void): void => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + outcome(); + }; + const onAbort = (): void => finish(() => reject(reason())); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + void operation.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)), + ); +}); diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index e6588bac4..782b125c8 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -5,7 +5,13 @@ import type { AgentEventRuntimeMode, CanonicalAgentEvent, } from '../routes/public.ts'; -import type { CompiledAgentRoute, CompiledCliCommand, CompiledLayout, CompiledProvider } from '../routes/types.ts'; +import type { + CompiledAgentRoute, + CompiledCliCommand, + CompiledEventPreflight, + CompiledLayout, + CompiledProvider, +} from '../routes/types.ts'; import type { SkillHostDocument, SkillIr, SkillTreeLayoutDecision } from '../skills/ir.ts'; import type { CapabilityState } from './capabilities.ts'; @@ -524,6 +530,8 @@ export interface NormalizedHook { readonly eventRoute?: Readonly<{ readonly event: CanonicalAgentEvent; readonly fallback: AgentEventFallbackMode; + readonly preflight?: CompiledEventPreflight; + readonly providers?: readonly string[]; readonly runtime: AgentEventRuntimeMode; }>; readonly id: string; diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts index 8f8b420d8..8431c7824 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; +import { settleBeforeAbort } from '../core/abort.ts'; import { cloneMcpAppFiniteJson, type McpAppJsonValue } from './mcp-app-metadata.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from './mcp-app-profile-descriptors.ts'; import type { DevRuntimeMcpSessionView } from './runtime-provider.ts'; @@ -190,26 +191,6 @@ const canonicalOperation = (request: McpAppRuntimeOperationRequest, expectedSess const abortReason = (signal: AbortSignal): unknown => signal.reason ?? new Error('Runtime MCP App operation was cancelled.'); -const settleOnAbort = (operation: Promise, signal: AbortSignal): Promise => new Promise((resolve, reject) => { - let settled = false; - const finish = (outcome: () => void): void => { - if (settled) return; - settled = true; - signal.removeEventListener('abort', onAbort); - outcome(); - }; - const onAbort = (): void => finish(() => reject(abortReason(signal))); - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - void operation.then( - (value) => finish(() => resolve(value)), - (error: unknown) => finish(() => reject(error)), - ); -}); - export class McpAppRuntimeBindingService { readonly #entries = new Map(); readonly #pendingReleases = new Map(); @@ -296,10 +277,10 @@ export class McpAppRuntimeBindingService { const active = Object.freeze({ controller, settled }); entry.operations.add(active); try { - const operation = await settleOnAbort(Promise.resolve().then(async () => { + const operation = await settleBeforeAbort(Promise.resolve().then(async () => { if (controller.signal.aborted) throw abortReason(controller.signal); return entry.session.execute(canonical, Object.freeze({ signal: controller.signal })); - }), controller.signal); + }), controller.signal, () => abortReason(controller.signal)); this.#assertActive(entry); return this.#operationResult(entry, operation); } finally { diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index 572b81377..c0a820208 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -64,7 +64,9 @@ const eventRequestSchema = z.object({ event: z.string().min(1), hostContractRevision: z.string().min(1), native: z.record(z.string(), z.unknown()), + observedAt: z.string().min(1).optional(), protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), + sequence: z.number().int().positive().optional(), target: z.string().min(1), }).strict(); @@ -127,6 +129,8 @@ export interface EventRuntimeRequest { readonly event: string; readonly hostContractRevision: string; readonly native: Readonly>; + readonly observedAt?: string; + readonly sequence?: number; readonly target: string; } @@ -337,6 +341,8 @@ const handleConnection = Effect.fnUntraced(function*( event: parsed.data.event, hostContractRevision: parsed.data.hostContractRevision, native: parsed.data.native, + observedAt: parsed.data.observedAt, + sequence: parsed.data.sequence, target: parsed.data.target, }, signal)).pipe(Effect.exit); if (handled._tag === 'Failure') { @@ -1136,7 +1142,9 @@ const requestProgram = ( event: options.event, hostContractRevision: options.hostContractRevision, native: options.native, + observedAt: options.observedAt, protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + sequence: options.sequence, target: options.target, })}\n`); const raw = yield* readOneMessage(socket); diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts new file mode 100644 index 000000000..f067e2751 --- /dev/null +++ b/packages/agent-bundle/src/events/preflight.ts @@ -0,0 +1,157 @@ +import { settleBeforeAbort } from '../core/abort.ts'; +import { isRecord } from '../core/strict-json.ts'; +import type { CanonicalAgentEvent } from '../routes/events.ts'; +import type { AgentEventCanonicalIdentity } from '../routes/public.ts'; +import type { AgentTerminal } from '../terminal-capability.ts'; +import type { EventTracer } from './trace.ts'; + +/** + * The gate result a conventional event route's re-exported preflight may return (#595). + * `execute` is the only value that loads the rendered route; `continue` is a + * pass-through with no host decision; `deny` blocks through the existing + * canonical event outcome projection and always carries a nonempty reason. + */ +export type EventPreflightResult = + | 'execute' + | { readonly outcome: 'continue' } + | { readonly outcome: 'deny'; readonly reason: string }; + +/** + * Frozen, deliberately small context a preflight gate receives: the same + * canonical identity the rendered route would see, compiled host metadata, + * the hook-deadline signal, and translated terminal capability metadata. It does not include + * `native`, state, notices, lineage, providers, or React/RSC helpers. + */ +export interface EventPreflightContext { + readonly canonical: AgentEventCanonicalIdentity; + readonly host: Readonly<{ readonly name: string; readonly nativeEvent: string }>; + readonly signal: AbortSignal; + readonly terminal: AgentTerminal; +} + +export type EventPreflight = ( + context: EventPreflightContext, +) => EventPreflightResult | Promise; + +type PreflightObjectOutcome = 'continue' | 'deny'; + +const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome => + value === 'continue' || value === 'deny'; + +const unsupportedResult = (detail: string): never => { + throw new TypeError(`Event preflight result ${detail}`); +}; + +const unexpectedFields = (record: Readonly>, allowed: ReadonlySet): void => { + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new TypeError(`Event preflight result has unsupported field ${JSON.stringify(key)}.`); + } + } +}; + +/** + * Family-level deny admission: true when at least one supported host's + * canonical event document projection emits a blocking deny. Observation-only + * families — and families whose projection ignores deny — fail closed here + * so host-specific projection stays in `events/projection.ts`. + */ +export const eventFamilyAllowsPreflightDeny = (event: CanonicalAgentEvent): boolean => { + switch (event) { + case 'agent/idle': + case 'agent/start': + case 'agent/stop': + case 'compact/before': + case 'config/change': + case 'model-switch/before': + case 'permission/request': + case 'prompt/submit': + case 'stop': + case 'task/create': + case 'tool/before': + return true; + case 'compact/after': + case 'file/change': + case 'model-switch/after': + case 'permission/denied': + case 'session/end': + case 'session/start': + case 'stop/failure': + case 'task/complete': + case 'tool/after': + case 'tool/failure': + case 'workspace/open': + return false; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +}; + +/** + * Validates a runtime preflight return into {@link EventPreflightResult}. + * Unknown outcomes, extra fields, an empty denial reason, and deny on a + * family that cannot deny fail closed. + */ +export const validateEventPreflightResult = ( + value: unknown, + event: CanonicalAgentEvent, +): EventPreflightResult => { + if (value === 'execute') return 'execute'; + if (!isRecord(value)) { + return unsupportedResult('must be "execute" or a continue/deny object.'); + } + const outcome = value.outcome; + if (!isPreflightObjectOutcome(outcome)) { + return unsupportedResult(`outcome ${JSON.stringify(outcome)} is not supported.`); + } + switch (outcome) { + case 'continue': + unexpectedFields(value, new Set(['outcome'])); + return Object.freeze({ outcome: 'continue' }); + case 'deny': { + unexpectedFields(value, new Set(['outcome', 'reason'])); + if (!eventFamilyAllowsPreflightDeny(event)) { + throw new TypeError(`${event} cannot deny from preflight.`); + } + if (typeof value.reason !== 'string' || value.reason.trim() === '') { + throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`); + } + return Object.freeze({ outcome: 'deny', reason: value.reason }); + } + default: { + const exhaustive: never = outcome; + return exhaustive; + } + } +}; + +/** + * Runs the gate inside the common event kernel and validates its result before + * any caller projects host output or loads the rendered route runtime. + */ +export const executeEventPreflight = async ( + preflight: EventPreflight, + context: EventPreflightContext, + trace?: EventTracer, +): Promise => { + trace?.preflightStart(); + try { + context.signal.throwIfAborted(); + const frozenContext = Object.freeze({ + canonical: context.canonical, + host: Object.freeze({ ...context.host }), + signal: context.signal, + terminal: context.terminal, + }); + const value = await settleBeforeAbort(Promise.resolve().then(() => preflight(frozenContext)), context.signal); + context.signal.throwIfAborted(); + const result = validateEventPreflightResult(value, context.canonical.event); + trace?.preflightOutcome(result); + return result; + } catch (error) { + trace?.failure('preflight', error); + throw error; + } +}; diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index e07479f06..09844fe1f 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -2,6 +2,41 @@ export { projectEventPayload } from './payload.ts'; export { createCanonicalEventProps, projectEventDocument, + projectEventPreflightResult, validateNativeEventEnvelope, type NativeEventEnvelopeValidation, } from './projection.ts'; +export { + executeEventPreflight, + validateEventPreflightResult, + type EventPreflight, + type EventPreflightContext, + type EventPreflightResult, +} from './preflight.ts'; +export { + createEventTracer, + eventTraceObserver, + eventTraceEventKinds, + eventTraceExecution, + eventTracePhases, + installEventTraceObserver, + summarizeEventTraceError, + type CreateEventTracerOptions, + type EventTraceErrorSummary, + type EventTraceEvent, + type EventTraceEventKind, + type EventTraceExecuteStart, + type EventTraceExecution, + type EventTraceFailure, + type EventTraceObserver, + type EventTracePhase, + type EventTracePreflightOutcome, + type EventTracePreflightOutcomeEvent, + type EventTracePreflightStart, + type EventTraceProvidersFinish, + type EventTraceProvidersStart, + type EventTracer, + type EventTraceRenderFinish, + type EventTraceRenderStart, + type EventTraceRuntime, +} from './trace.ts'; diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 1868252ff..b72db4a5a 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -10,6 +10,7 @@ import type { CanonicalAgentEvent, } from '../routes/public.ts'; import { projectEventPayload } from './payload.ts'; +import type { EventPreflightResult } from './preflight.ts'; /** * The route result vocabulary. `continue` (or no value at all) is the @@ -434,6 +435,7 @@ export const createCanonicalEventProps = ( nativeEvent: string, hostContractRevision: string, signal: AbortSignal, + observation?: Readonly<{ readonly observedAt: string; readonly sequence: number }>, ): AgentEventRouteProps => { const native = snapshotNative(nativeInput); const canonical: AgentEventCanonicalIdentity = Object.freeze({ @@ -443,7 +445,7 @@ export const createCanonicalEventProps = ( idempotencyKey: createHash('sha256') .update(JSON.stringify({ event, native, target }), 'utf8') .digest('hex'), - observedAt: new Date().toISOString(), + observedAt: observation?.observedAt ?? new Date().toISOString(), payload: projectEventPayload(event, native, target), provenance: Object.freeze({ host: target, @@ -451,7 +453,7 @@ export const createCanonicalEventProps = ( nativeEvent, source: 'native', }), - sequence: ++eventSequence, + sequence: observation?.sequence ?? ++eventSequence, }); return Object.freeze({ canonical, native, signal }); }; @@ -912,3 +914,26 @@ export const projectEventDocument = ( } return undefined; }; + +/** + * Projects an already-validated gate outcome through the same host-owned + * decision rules as a rendered Agent.Result, without loading the renderer. + */ +export const projectEventPreflightResult = ( + result: Exclude, + event: CanonicalAgentEvent, + target: string, + nativeEvent: string, + nativeInput?: Readonly>, +): Readonly> | undefined => projectEventDocument( + { + root: { children: [], kind: 'result' }, + status: 'success', + value: result, + version: 1, + }, + event, + target, + nativeEvent, + nativeInput, +); diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts new file mode 100644 index 000000000..919e026ad --- /dev/null +++ b/packages/agent-bundle/src/events/trace.ts @@ -0,0 +1,396 @@ +import { randomUUID } from 'node:crypto'; + +import type { CanonicalAgentEvent } from '../routes/events.ts'; +import type { EventPreflightResult } from './preflight.ts'; + +/** + * The execution-kernel trace surface for conventional event routes (#600). + * + * One {@link EventTracer} lives for one hook execution — a hook wrapper + * process or one shared-runtime request — and describes what the kernel did + * with it as a sequence of frozen {@link EventTraceEvent}s: the preflight gate + * (start, outcome), the deferred route load that only an `execute` gate + * result triggers, provider materialization, the route render, and a terminal + * failure. The surface is deliberately small and observer-agnostic: the + * framework owns emission, a consumer owns the observer, and an absent + * observer costs the kernel nothing beyond a boolean check. + * + * Invariants the tests hold: + * - Every event carries the same frozen execution identity, a monotonic + * `sequence` (0, 1, 2, …) and a monotonic `at` timestamp in milliseconds. + * - Events are frozen before they reach the observer; the observer cannot + * alter them and its exceptions never reach the kernel. + * - `failure` is terminal: the tracer goes quiet afterwards. + * - Nothing here carries payloads, reasons, stacks, or the error object + * itself — only the {@link EventTraceErrorSummary} projection. + */ + +/** The kernel phases a trace can attribute time or a failure to. */ +export const eventTracePhases = Object.freeze(['preflight', 'execute', 'providers', 'render'] as const); +export type EventTracePhase = (typeof eventTracePhases)[number]; + +/** Every discriminant of {@link EventTraceEvent}, for consumers that switch or filter. */ +export const eventTraceEventKinds = Object.freeze([ + 'preflight.start', + 'preflight.outcome', + 'execute.start', + 'providers.start', + 'providers.finish', + 'render.start', + 'render.finish', + 'failure', +] as const); +export type EventTraceEventKind = (typeof eventTraceEventKinds)[number]; + +/** Which runtime the deferred execute step loads the route in. */ +export type EventTraceRuntime = 'shared' | 'standalone'; + +/** The gate decision without its reason text: `deny` is enough for a trace. */ +export type EventTracePreflightOutcome = 'execute' | 'continue' | 'deny'; + +/** + * Identity shared by every event of one execution. `executionId` is unique + * per hook execution; `host` is the compiled target name, `nativeEvent` the + * host's own event name for the canonical `event`. + */ +export interface EventTraceExecution { + readonly event: CanonicalAgentEvent; + readonly executionId: string; + readonly host: string; + readonly nativeEvent: string; +} + +/** What an observer learns about a thrown value: name, message, string code. Never the value itself. */ +export interface EventTraceErrorSummary { + readonly code?: string; + readonly message: string; + readonly name: string; +} + +interface EventTraceEventBase { + /** Monotonic milliseconds from the tracer's clock (`performance.now()` by default). */ + readonly at: number; + readonly execution: EventTraceExecution; + readonly kind: K; + readonly phase: P; + readonly sequence: number; +} + +export type EventTracePreflightStart = EventTraceEventBase<'preflight.start', 'preflight'>; +export interface EventTracePreflightOutcomeEvent extends EventTraceEventBase<'preflight.outcome', 'preflight'> { + /** Present when `preflight.start` was observed on this tracer. */ + readonly durationMs?: number; + readonly outcome: EventTracePreflightOutcome; +} +/** The gate returned `execute`: the kernel now loads the rendered route runtime. */ +export interface EventTraceExecuteStart extends EventTraceEventBase<'execute.start', 'execute'> { + readonly runtime: EventTraceRuntime; +} +export type EventTraceProvidersStart = EventTraceEventBase<'providers.start', 'providers'>; +export interface EventTraceProvidersFinish extends EventTraceEventBase<'providers.finish', 'providers'> { + readonly count: number; + /** Present when `providers.start` was observed on this tracer. */ + readonly durationMs?: number; +} +export type EventTraceRenderStart = EventTraceEventBase<'render.start', 'render'>; +export interface EventTraceRenderFinish extends EventTraceEventBase<'render.finish', 'render'> { + /** Present when `render.start` was observed on this tracer. */ + readonly durationMs?: number; +} +/** Terminal: the execution failed in `phase`. Nothing follows this event. */ +export interface EventTraceFailure extends EventTraceEventBase<'failure', EventTracePhase> { + /** Elapsed since the first event of this trace; absent when the failure is the first event. */ + readonly durationMs?: number; + readonly error: EventTraceErrorSummary; +} + +export type EventTraceEvent = + | EventTracePreflightStart + | EventTracePreflightOutcomeEvent + | EventTraceExecuteStart + | EventTraceProvidersStart + | EventTraceProvidersFinish + | EventTraceRenderStart + | EventTraceRenderFinish + | EventTraceFailure; + +/** A consumer's sink. Called synchronously with a frozen event; exceptions are swallowed. */ +export type EventTraceObserver = (event: EventTraceEvent) => void; + +const eventTraceObserverSlot = Symbol.for('agent-bundle.event-trace-observer'); +const observerRegistry = globalThis as typeof globalThis & Record; + +/** Returns the process-local observer currently installed for kernel traces. */ +export const eventTraceObserver = (): EventTraceObserver | undefined => + observerRegistry[eventTraceObserverSlot]; + +/** + * Installs the process-local observer used by framework-created tracers. + * The disposer restores the previous observer without disturbing a newer one. + */ +export const installEventTraceObserver = (observer: EventTraceObserver): (() => void) => { + const previous = observerRegistry[eventTraceObserverSlot]; + observerRegistry[eventTraceObserverSlot] = observer; + return () => { + if (observerRegistry[eventTraceObserverSlot] === observer) { + observerRegistry[eventTraceObserverSlot] = previous; + } + }; +}; + +/** + * The framework-owned emitter the kernel calls at each phase boundary. Every + * method is safe to call at any time and never throws. + */ +export interface EventTracer { + /** True once `failure` was recorded; later calls are dropped. */ + readonly closed: boolean; + /** False when the tracer was created without an observer: every method is a no-op. */ + readonly enabled: boolean; + readonly execution: EventTraceExecution; + preflightStart(): void; + preflightOutcome(result: EventPreflightResult): void; + executeStart(runtime: EventTraceRuntime): void; + providersStart(): void; + providersFinish(count: number): void; + renderStart(): void; + renderFinish(): void; + failure(phase: EventTracePhase, error: unknown): void; +} + +export interface CreateEventTracerOptions { + readonly execution: EventTraceExecution; + /** Monotonic clock in milliseconds; `performance.now` when absent. */ + readonly now?: () => number; + /** Absent means tracing is off for this execution. */ + readonly observer?: EventTraceObserver; +} + +/** Longest message an {@link EventTraceErrorSummary} carries; longer ones end in an ellipsis. */ +const MAX_ERROR_SUMMARY_MESSAGE_LENGTH = 512; +const UNPRINTABLE = '[unprintable]'; +const NON_ERROR_NAME = 'NonError'; + +const requireNonBlank = (value: string, field: string): string => { + if (typeof value !== 'string' || value.trim() === '') { + throw new TypeError(`Event trace execution ${field} must be a nonempty string.`); + } + return value; +}; + +/** Builds the frozen per-execution identity, minting a UUID `executionId` when none is given. */ +export const eventTraceExecution = ( + input: Readonly<{ + readonly event: CanonicalAgentEvent; + readonly executionId?: string; + readonly host: string; + readonly nativeEvent: string; + }>, +): EventTraceExecution => + Object.freeze({ + event: input.event, + executionId: input.executionId === undefined ? randomUUID() : requireNonBlank(input.executionId, 'executionId'), + host: requireNonBlank(input.host, 'host'), + nativeEvent: requireNonBlank(input.nativeEvent, 'nativeEvent'), + }); + +const boundedMessage = (message: string): string => + message.length > MAX_ERROR_SUMMARY_MESSAGE_LENGTH + ? `${message.slice(0, MAX_ERROR_SUMMARY_MESSAGE_LENGTH - 1)}…` + : message; + +/** Reads a property that may be a hostile getter; anything but a string yields `undefined`. */ +const stringProperty = (value: object, key: string): string | undefined => { + try { + const read: unknown = (value as Record)[key]; + return typeof read === 'string' ? read : undefined; + } catch { + return undefined; + } +}; + +const printable = (value: unknown): string => { + try { + return String(value); + } catch { + return UNPRINTABLE; + } +}; + +/** + * Projects any thrown value into a frozen, JSON-safe {@link EventTraceErrorSummary}. + * Never throws, never returns the error object, its stack, or its cause. + */ +export const summarizeEventTraceError = (error: unknown): EventTraceErrorSummary => { + if (error instanceof Error) { + const name = stringProperty(error, 'name'); + const message = stringProperty(error, 'message'); + const code = stringProperty(error, 'code'); + return Object.freeze({ + ...(code === undefined ? {} : { code }), + message: boundedMessage(message ?? UNPRINTABLE), + name: name === undefined || name === '' ? 'Error' : name, + }); + } + return Object.freeze({ message: boundedMessage(printable(error)), name: NON_ERROR_NAME }); +}; + +const preflightOutcomeOf = (result: EventPreflightResult): EventTracePreflightOutcome => { + if (result === 'execute') return 'execute'; + switch (result.outcome) { + case 'continue': + return 'continue'; + case 'deny': + return 'deny'; + default: { + const exhaustive: never = result; + return exhaustive; + } + } +}; + +const durationField = (since: number | undefined, at: number): { readonly durationMs?: number } => + since === undefined ? {} : { durationMs: at - since }; + +/** A tracer that records nothing and reads no clock; only `closed` flips on `failure`. */ +const disabledTracer = (execution: EventTraceExecution): EventTracer => { + let closed = false; + const noop = (): void => undefined; + return { + get closed() { return closed; }, + enabled: false, + execution, + executeStart: noop, + failure: () => { closed = true; }, + preflightOutcome: noop, + preflightStart: noop, + providersFinish: noop, + providersStart: noop, + renderFinish: noop, + renderStart: noop, + }; +}; + +/** + * Creates the emitter for one execution. Without `observer` every method is + * a no-op. With one, each method builds a frozen event, assigns the next + * `sequence`, stamps `at` from `now`, and hands it to the observer inside a + * try/catch: a throwing observer, a throwing clock, or re-entry from inside + * the observer never changes what the caller sees. + */ +export const createEventTracer = (options: CreateEventTracerOptions): EventTracer => { + const execution = options.execution; + const observer = options.observer ?? eventTraceObserver(); + if (observer === undefined) return disabledTracer(execution); + const now = options.now ?? (() => performance.now()); + let sequence = 0; + let closed = false; + let firstAt: number | undefined; + const startedAt: Partial> = {}; + + const readClock = (): number | undefined => { + try { + return now(); + } catch { + return undefined; + } + }; + + const deliver = (event: EventTraceEvent): void => { + try { + observer(event); + } catch { + // An observer is a consumer's concern; the kernel's behavior is not. + } + }; + + const emit = ( + build: (at: number, sequence: number, traceStartedAt: number | undefined) => EventTraceEvent, + terminal = false, + ): void => { + if (closed) return; + const at = readClock(); + if (at === undefined) return; + const traceStartedAt = firstAt; + firstAt ??= at; + const event = build(at, sequence, traceStartedAt); + sequence += 1; + if (terminal) closed = true; + deliver(Object.freeze(event)); + }; + + return { + get closed() { return closed; }, + enabled: true, + execution, + executeStart: (runtime) => { + emit((at, next) => { + startedAt.execute = at; + return { at, execution, kind: 'execute.start', phase: 'execute', runtime, sequence: next }; + }); + }, + failure: (phase, error) => { + const summary = summarizeEventTraceError(error); + emit((at, next, traceStartedAt) => ({ + at, + ...durationField(traceStartedAt, at), + error: summary, + execution, + kind: 'failure', + phase, + sequence: next, + }), true); + }, + preflightOutcome: (result) => { + const outcome = preflightOutcomeOf(result); + emit((at, next) => ({ + at, + ...durationField(startedAt.preflight, at), + execution, + kind: 'preflight.outcome', + outcome, + phase: 'preflight', + sequence: next, + })); + }, + preflightStart: () => { + emit((at, next) => { + startedAt.preflight = at; + return { at, execution, kind: 'preflight.start', phase: 'preflight', sequence: next }; + }); + }, + providersFinish: (count) => { + emit((at, next) => ({ + at, + count, + ...durationField(startedAt.providers, at), + execution, + kind: 'providers.finish', + phase: 'providers', + sequence: next, + })); + }, + providersStart: () => { + emit((at, next) => { + startedAt.providers = at; + return { at, execution, kind: 'providers.start', phase: 'providers', sequence: next }; + }); + }, + renderFinish: () => { + emit((at, next) => ({ + at, + ...durationField(startedAt.render, at), + execution, + kind: 'render.finish', + phase: 'render', + sequence: next, + })); + }, + renderStart: () => { + emit((at, next) => { + startedAt.render = at; + return { at, execution, kind: 'render.start', phase: 'render', sequence: next }; + }); + }, + }; +}; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index bd692f5f6..cde6b978a 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -25,7 +25,9 @@ export { agentEventPayloadFields, agentEventPayloadNativeKeys, canonicalAgentEvents, + eventFamilyAllowsPreflightDeny, MAX_ROUTE_RENDER_ELAPSED_MS, + validateEventPreflightResult, } from './routes/public.ts'; export type { AgentEventCanonicalIdentity, @@ -44,6 +46,9 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + EventPreflight, + EventPreflightContext, + EventPreflightResult, AgentLayoutRoute, AgentLayoutRouteKind, AgentProviderContext, @@ -91,6 +96,35 @@ export type { ToolRouteProps, ToolTaskSupport, } from './routes/public.ts'; +export { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTraceObserver, + eventTracePhases, + installEventTraceObserver, + summarizeEventTraceError, +} from './events/trace.ts'; +export type { + CreateEventTracerOptions, + EventTraceErrorSummary, + EventTraceEvent, + EventTraceEventKind, + EventTraceExecuteStart, + EventTraceExecution, + EventTraceFailure, + EventTraceObserver, + EventTracePhase, + EventTracePreflightOutcome, + EventTracePreflightOutcomeEvent, + EventTracePreflightStart, + EventTraceProvidersFinish, + EventTraceProvidersStart, + EventTracer, + EventTraceRenderFinish, + EventTraceRenderStart, + EventTraceRuntime, +} from './events/trace.ts'; export { compareEvals, runEvals, startDevServer } from './api.ts'; export { createCodexEvalHarness, diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index c698c82c7..a0f122973 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -963,6 +963,9 @@ const startEventRuntime = async ( nativeEvent, request.hostContractRevision, signal, + request.observedAt === undefined || request.sequence === undefined + ? undefined + : { observedAt: request.observedAt, sequence: request.sequence }, ); const sessionId = nativeString(request.native, 'session_id') ?? nativeString(request.native, 'conversation_id'); diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 598fa65a2..e40ed90f9 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -25,7 +25,7 @@ const unwrappedExpression = (expression: ts.Expression): ts.Expression => { }; const diagnostic = ( - code: 'AB4810' | 'AB4811' | 'AB4830' | 'AB4940', + code: 'AB4810' | 'AB4811' | 'AB4830' | 'AB4840' | 'AB4940', message: string, sourcePath: string, recovery: string, @@ -82,6 +82,11 @@ interface PendingReExport { readonly specifier: string; } +interface FollowedReExport { + readonly exports: ScannedModuleExports; + readonly source: string; +} + /** Scans one route module's top-level export surface without evaluating it. */ export const scanRouteModuleExports = ( moduleText: string, @@ -228,7 +233,7 @@ const scanModuleExports = ( // that route a single time. const targets = new Map(); const shapeOf = ({ name, specifier }: PendingReExport): BindingShape => { - if (!targets.has(specifier)) targets.set(specifier, followReExport(specifier, options, visited)); + if (!targets.has(specifier)) targets.set(specifier, followReExport(specifier, options, visited)?.exports); const exports = targets.get(specifier); return exports === undefined ? { asyncFunction: false, function: false, unresolved: true } @@ -270,7 +275,7 @@ const followReExport = ( specifier: string, options: ScanRouteModuleOptions, visited: ReadonlySet, -): ScannedModuleExports | undefined => { +): FollowedReExport | undefined => { if (options.source === undefined || !isRelativeSpecifier(specifier)) return undefined; const read = options.readModule ?? readModuleFromDisk; const seen = new Set([...visited, options.source]); @@ -280,11 +285,143 @@ const followReExport = ( if (seen.has(candidate)) return undefined; const text = read(candidate); if (text === undefined) continue; - return scanModuleExports(text, candidate, { ...options, source: candidate }, seen); + return { + exports: scanModuleExports(text, candidate, { ...options, source: candidate }, seen), + source: candidate, + }; } return undefined; }; +/** Static metadata for the independently bundleable event preflight module. */ +export interface EventRoutePreflightDiscovery { + /** + * The readable module directly named by the relative re-export. Present + * even when its default binding is rejected, so route discovery does not + * mistake a colocated support module for another event route. + */ + readonly candidateSource?: string; + readonly diagnostics: readonly Diagnostic[]; + /** Present only when the target resolves through an acyclic chain to a function default export. */ + readonly source?: string; +} + +const eventPreflightRecovery = + "Use exactly `export { default as preflight } from './name.js'`, where the relative target resolves to a sync or async default function."; + +/** + * Discovers an event route's physically separate preflight entry without + * evaluating either module. Inline bindings and non-relative or non-followable + * re-exports are rejected because they cannot form a cheap independent entry. + */ +export const discoverEventRoutePreflight = ( + moduleText: string, + relativePath: string, + sourcePath: string, + options: ScanRouteModuleOptions = {}, +): EventRoutePreflightDiscovery => { + const scanOptions = { ...options, source: options.source ?? sourcePath }; + const exports = scanRouteModuleExports(moduleText, relativePath, scanOptions); + if (!exports.named.has('preflight')) return Object.freeze({ diagnostics: Object.freeze([]) }); + + const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const declarations: PendingReExport[] = []; + let preflightExports = 0; + for (const statement of sourceFile.statements) { + if (exported(statement)) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === 'preflight') preflightExports += 1; + if (ts.isClassDeclaration(statement) && statement.name?.text === 'preflight') preflightExports += 1; + if (ts.isVariableStatement(statement)) { + preflightExports += statement.declarationList.declarations.filter( + (declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === 'preflight', + ).length; + } + } + if ( + !ts.isExportDeclaration(statement) + || statement.isTypeOnly + || statement.exportClause === undefined + || !ts.isNamedExports(statement.exportClause) + ) { + continue; + } + const specifier = statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + for (const element of statement.exportClause.elements) { + if (element.isTypeOnly || element.name.text !== 'preflight') continue; + preflightExports += 1; + if (specifier !== undefined && element.propertyName?.text === 'default') { + declarations.push({ name: 'default', specifier }); + } + } + } + + if (preflightExports !== 1 || declarations.length !== 1) { + return Object.freeze({ + diagnostics: Object.freeze([diagnostic( + 'AB4840', + `Event route module ${relativePath} exports preflight, but it is not exactly one named default re-export from a separate module.`, + sourcePath, + eventPreflightRecovery, + )]), + }); + } + + const [declaration] = declarations; + if (!isRelativeSpecifier(declaration!.specifier)) { + return Object.freeze({ + diagnostics: Object.freeze([diagnostic( + 'AB4840', + `Event route module ${relativePath} re-exports preflight from non-relative specifier ${JSON.stringify(declaration!.specifier)}.`, + sourcePath, + eventPreflightRecovery, + )]), + }); + } + + const followed = followReExport(declaration!.specifier, scanOptions, new Set()); + if (followed === undefined) { + return Object.freeze({ + diagnostics: Object.freeze([diagnostic( + 'AB4840', + `Event route module ${relativePath} re-exports preflight from ${JSON.stringify(declaration!.specifier)}, but that target is missing, unreadable, or cyclic.`, + sourcePath, + eventPreflightRecovery, + )]), + }); + } + + const shape = bindingShape(followed.exports, 'default'); + if (shape.unresolved) { + return Object.freeze({ + candidateSource: followed.source, + diagnostics: Object.freeze([diagnostic( + 'AB4840', + `Event route module ${relativePath} re-exports preflight from ${JSON.stringify(declaration!.specifier)}, but its default export cannot be followed through an acyclic relative module chain.`, + sourcePath, + eventPreflightRecovery, + )]), + }); + } + if (!shape.function) { + return Object.freeze({ + candidateSource: followed.source, + diagnostics: Object.freeze([diagnostic( + 'AB4840', + `Event route module ${relativePath} re-exports preflight from ${JSON.stringify(declaration!.specifier)}, whose default export is not a function.`, + sourcePath, + eventPreflightRecovery, + )]), + }); + } + return Object.freeze({ + candidateSource: followed.source, + diagnostics: Object.freeze([]), + source: followed.source, + }); +}; + /** * Whether the scanned default export is judged an async function component. * A default re-exported from a module the scan could not read (a bare diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 2f185c241..202edffa4 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -19,6 +19,8 @@ import { resolveRouteConfigAppReferences, } from './config-extract.ts'; import { + discoverEventRoutePreflight, + type EventRoutePreflightDiscovery, validateEventRouteModuleContract, validateLayoutModuleContract, validateProviderModuleContract, @@ -27,6 +29,10 @@ import { import { validateRouteFrameworkImports } from './framework-imports.ts'; import { extractInputSchema, type ExtractedInputSchema, type ResolvedSchemaOrigin } from './input-schema.ts'; import { isLayoutRouteKind, layoutChainFor } from './layouts.ts'; +import { + requiredProviderKeyProblemMessage, + validateRequiredProviderKeys, +} from './provider-execution.ts'; import { providerKeyFromName } from './providers.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; @@ -41,6 +47,7 @@ import { type CompiledAgentRoute, type CompiledCliMode, type CompiledCliSurface, + type CompiledEventPreflight, type CompiledLayout, type CompiledProvider, type CompiledRouteGraph, @@ -110,6 +117,34 @@ const routeError = (code: string, message: string, recovery: string, sourcePath? ...(sourcePath === undefined ? {} : { sourcePath }), }); +const eventProviderDeclarationDiagnostics = ( + route: CompiledAgentRoute, + providerKeys: Iterable, +): readonly Diagnostic[] => { + const declared = route.config['providers']; + if (declared === undefined) return []; + const recovery = + 'Declare config.providers as a distinct array of conventional provider keys, omit it to resolve every provider, or use [] to resolve none.'; + if (!Array.isArray(declared) || declared.some((key) => typeof key !== 'string')) { + return [routeError( + 'AB4841', + `Event route ${route.provenance.relativePath} config.providers must be an array of provider-key strings.`, + recovery, + route.source, + )]; + } + const problems = validateRequiredProviderKeys(declared, providerKeys); + if (problems.length === 0) return []; + return [routeError( + 'AB4841', + `Event route ${route.provenance.relativePath} has invalid config.providers: ${problems + .map(requiredProviderKeyProblemMessage) + .join(' ')}`, + recovery, + route.source, + )]; +}; + const configValue = ( config: Readonly, key: keyof AgentBundleConfig | 'routes', @@ -519,6 +554,7 @@ const compiledRoute = ( module: DiscoveredRouteModule, config: Readonly>, contract?: ContractBinding, + preflight?: CompiledEventPreflight, ): CompiledAgentRoute => ({ config, ...(contract === undefined ? {} : { contract: contract.id }), @@ -526,6 +562,7 @@ const compiledRoute = ( id: module.id, ...(contract === undefined ? {} : { inputSchema: contract.input }), kind: module.kind, + ...(preflight === undefined ? {} : { preflight }), provenance: { kind: 'conventional', relativePath: module.relativePath }, ...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }), source: module.source, @@ -553,6 +590,8 @@ const readRouteModuleText = async (source: string): Promise interface ExtractedModuleMetadata { readonly extracted: ExtractedRouteConfig; readonly inputSchema?: ExtractedInputSchema; + readonly preflight?: CompiledEventPreflight; + readonly preflightDiagnostics: readonly Diagnostic[]; } const emptyExtractedRouteConfig: ExtractedRouteConfig = deepFreeze({ @@ -565,15 +604,30 @@ const extractedModuleMetadata = ( module: DiscoveredRouteModule, moduleText: string | undefined, projectRoot: string, + preflightDiscovery?: EventRoutePreflightDiscovery, ): ExtractedModuleMetadata => { if (moduleText === undefined) { - return { extracted: emptyExtractedRouteConfig }; + return { extracted: emptyExtractedRouteConfig, preflightDiagnostics: [] }; } const extracted = extractRouteConfig(moduleText, module.relativePath, module.source, { projectRoot }); const inputSchema = extractInputSchema(moduleText, module.relativePath, { projectRoot, source: module.source }); + const discovery = module.kind === 'event-route' + ? preflightDiscovery ?? discoverEventRoutePreflight(moduleText, module.relativePath, module.source) + : undefined; + const preflight = discovery?.source === undefined + ? undefined + : { + provenance: { + kind: 'conventional' as const, + relativePath: toPosixPath(relative(projectRoot, discovery.source)), + }, + source: discovery.source, + }; return { extracted, ...(inputSchema === undefined ? {} : { inputSchema }), + ...(preflight === undefined ? {} : { preflight }), + preflightDiagnostics: discovery?.diagnostics ?? [], }; }; @@ -611,6 +665,7 @@ const routeIdentity = (route: CompiledAgentRoute): Readonly left.localeCompare(right)); const claimed = configClaimedSources(projectRoot, config); + const moduleTextBySource = new Map(); + const preflightDiscoveryBySource = new Map(); + const preflightSupportSources = new Set(); + // Resolve preflight support modules before classifying every glob match: + // a colocated `before.preflight.ts` is application code named by the + // canonical `before.ts` route, not a second event route. + for (const source of sources) { + if (claimed.artifact.has(source)) continue; + const relativePath = toPosixPath(relative(projectRoot, source)); + if (claimed.bin.has(source) && !isConventionalScriptPath(relativePath)) continue; + if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; + const module = classifyModule(source, relativePath); + if ( + module.surface !== 'route' + || module.kind !== 'event-route' + || !canonicalAgentEvents.includes(module.event!) + ) { + continue; + } + const moduleText = await readRouteModuleText(source); + if (moduleText === undefined) continue; + moduleTextBySource.set(source, moduleText); + const discovery = discoverEventRoutePreflight(moduleText, relativePath, source); + preflightDiscoveryBySource.set(source, discovery); + } + for (const [source, discovery] of preflightDiscoveryBySource) { + if (discovery.candidateSource === undefined) continue; + if (!preflightDiscoveryBySource.has(discovery.candidateSource)) { + preflightSupportSources.add(discovery.candidateSource); + continue; + } + preflightDiscoveryBySource.set(source, Object.freeze({ + candidateSource: discovery.candidateSource, + diagnostics: Object.freeze([ + ...discovery.diagnostics, + routeError( + 'AB4840', + `Event route ${toPosixPath(relative(projectRoot, source))} re-exports preflight from another conventional event route.`, + 'Move preflight to a separate support module that is not itself an event route.', + source, + ), + ]), + })); + } const modules: DiscoveredModule[] = []; const modulesById = new Map(); const providerModulesByKey = new Map(); @@ -672,6 +771,7 @@ export const compileRouteGraph = async ( const relativePath = toPosixPath(relative(projectRoot, source)); if (claimed.bin.has(source) && !isConventionalScriptPath(relativePath)) continue; if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; + if (preflightSupportSources.has(source)) continue; const module = classifyModule(source, relativePath); // The documented opt-out: a server pinned to custom, command, or remote // keeps its own entry, so its layout never enters the graph — it is not @@ -759,7 +859,6 @@ export const compileRouteGraph = async ( const cliRoutes: CompiledAgentRoute[] = []; const providers: CompiledProvider[] = []; const layouts: CompiledLayout[] = []; - const moduleTextBySource = new Map(); // Config extraction runs over the whole tree before any route compiles: // an `appResourceUri()` reference resolves against every App route the // tree declares, wherever the App module sorts relative to its referrer. @@ -802,11 +901,19 @@ export const compileRouteGraph = async ( } continue; } - const moduleText = await readRouteModuleText(module.source); + const moduleText = moduleTextBySource.get(module.source) ?? await readRouteModuleText(module.source); if (moduleText !== undefined) { moduleTextBySource.set(module.source, moduleText); } - pending.push({ metadata: extractedModuleMetadata(module, moduleText, projectRoot), module }); + pending.push({ + metadata: extractedModuleMetadata( + module, + moduleText, + projectRoot, + preflightDiscoveryBySource.get(module.source), + ), + module, + }); } const serverModes = new Map(); for (const { module } of pending) { @@ -845,10 +952,12 @@ export const compileRouteGraph = async ( ) : metadata.extracted; diagnostics.push(...resolved.diagnostics); + diagnostics.push(...metadata.preflightDiagnostics); const route = compiledRoute( module, resolved.config, metadata.inputSchema === undefined ? undefined : contractBindings.get(contractIdOf(metadata.inputSchema.origin)), + metadata.preflight, ); if (route.kind === 'event-route' && moduleText !== undefined) { diagnostics.push(...validateEventRouteModuleContract( @@ -868,6 +977,7 @@ export const compileRouteGraph = async ( break; } case 'event-route': + diagnostics.push(...eventProviderDeclarationDiagnostics(route, providerModulesByKey.keys())); events.push(route); // Every event route ships as a hook wrapper of its own, so it is // judged here; MCP and CLI routes are judged once their server or diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 1eda43e9a..38b272737 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -71,7 +71,9 @@ export { agentEventPayloadNativeKeys, appResourceUri, canonicalAgentEvents, + eventFamilyAllowsPreflightDeny, MAX_ROUTE_RENDER_ELAPSED_MS, + validateEventPreflightResult, } from './public.ts'; export type { AgentEventCanonicalIdentity, @@ -90,6 +92,9 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + EventPreflight, + EventPreflightContext, + EventPreflightResult, AgentLayoutRoute, AgentLayoutRouteKind, AgentProviderContext, diff --git a/packages/agent-bundle/src/routes/provider-execution.ts b/packages/agent-bundle/src/routes/provider-execution.ts index 9ede21379..08b9dd45a 100644 --- a/packages/agent-bundle/src/routes/provider-execution.ts +++ b/packages/agent-bundle/src/routes/provider-execution.ts @@ -11,6 +11,11 @@ import type { CompiledProvider } from './types.ts'; * self-contained; `agent-bundle/test` runs it in-process through * {@link executeProviders}. Ordering and the fail-closed messages live here so * the two cannot drift: the harness must mount exactly what the artifact does. + * + * Which providers a route resolves is decided before the loop, by + * {@link selectRequiredProviders} over the route's static declaration (#595): + * every conventional provider when it declares none, otherwise the declared + * subset in the same order. The loop itself runs whatever it is handed. */ /** Deterministic execution order: by mounted key, then by source path for a key collision. */ @@ -21,6 +26,119 @@ export const orderedProviders = , +): readonly RequiredProviderKeyProblem[] => { + const known = new Set(knownKeys); + const listed = Object.freeze([...known].sort((left, right) => left.localeCompare(right))); + const seen = new Set(); + const problems: RequiredProviderKeyProblem[] = []; + for (const key of required) { + if (seen.has(key)) { + problems.push(Object.freeze({ key, kind: 'duplicate-provider-key' })); + continue; + } + seen.add(key); + if (key === reservedProviderKey) { + problems.push(Object.freeze({ key, kind: 'reserved-provider-key' })); + } else if (!known.has(key)) { + problems.push(Object.freeze({ key, kind: 'unknown-provider-key', known: listed })); + } + } + return Object.freeze(problems); +}; + +export const requiredProviderKeyProblemMessage = (problem: RequiredProviderKeyProblem): string => { + switch (problem.kind) { + case 'duplicate-provider-key': + return `Required provider key ${JSON.stringify(problem.key)} is declared more than once.`; + case 'reserved-provider-key': + return `Required provider key ${JSON.stringify(problem.key)} is the framework-owned process identity every request mounts; do not declare it.`; + case 'unknown-provider-key': + return `Required provider key ${JSON.stringify(problem.key)} matches no conventional provider; ${ + problem.known.length === 0 ? 'the project declares none' : `known keys: ${problem.known.join(', ')}` + }.`; + default: { + const exhaustive: never = problem; + throw new TypeError(`Unhandled required provider key problem: ${JSON.stringify(exhaustive)}`); + } + } +}; + +/** + * The outcome of {@link selectRequiredProviders}: the providers one route + * resolves, already in {@link orderedProviders} order, or the declaration + * defects that stop it from resolving any. + */ +export type RequiredProviderSelection = + | { readonly ok: true; readonly providers: readonly T[] } + | { readonly ok: false; readonly problems: readonly RequiredProviderKeyProblem[] }; + +/** + * Selects the providers one route resolves from its declaration (#595): all + * of them when it declares nothing, otherwise exactly the declared keys — + * matched against the derived camel-case key a route reads, never the file + * stem — in the existing key/source order, not declaration order. A + * declaration with a duplicate, reserved, or unknown key selects nothing and + * reports every defect instead. The result is frozen; the caller's provider + * records are not touched. + */ +export const selectRequiredProviders = >( + providers: readonly T[], + required: RequiredProviderKeys, +): RequiredProviderSelection => { + const ordered = orderedProviders(providers); + if (required === undefined) return Object.freeze({ ok: true, providers: Object.freeze(ordered) }); + const keys = ordered.map((provider) => providerKeyFromName(provider.name)); + const problems = validateRequiredProviderKeys(required, keys); + if (problems.length > 0) return Object.freeze({ ok: false, problems }); + const selected = new Set(required); + return Object.freeze({ + ok: true, + providers: Object.freeze(ordered.filter((_provider, index) => selected.has(keys[index]!))), + }); +}; + export const providerFactoryMissingMessage = (key: string, source: string): string => `Context provider "${key}" (${source}) must default-export a factory.`; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index f943c5ba0..a5ebada6f 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -19,6 +19,15 @@ export type { AgentEventPayloadNativeKey, CanonicalAgentEvent, } from './events.ts'; +export { + eventFamilyAllowsPreflightDeny, + validateEventPreflightResult, +} from '../events/preflight.ts'; +export type { + EventPreflight, + EventPreflightContext, + EventPreflightResult, +} from '../events/preflight.ts'; /** The structural schema surface route props infer without coupling to one schema library. */ export interface RouteSchema { @@ -339,6 +348,11 @@ export type AgentEventFallbackMode = 'none' | 'standalone'; export interface AgentEventRouteConfig { readonly delivery?: readonly AgentEventDelivery[]; readonly fallback?: AgentEventFallbackMode; + /** + * Conventional provider keys this route resolves. Omit to preserve the + * compatibility behavior of resolving all providers; use `[]` for none. + */ + readonly providers?: readonly string[]; readonly runtime?: AgentEventRuntimeMode; readonly targets?: readonly string[]; /** Route budget within the adapter's stricter native-host deadline. */ diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 4182d8de0..a9d257d12 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -27,6 +27,13 @@ export interface RouteProvenance { readonly relativePath: string; } +/** The separately bundleable static preflight attached to one event route. */ +export interface CompiledEventPreflight { + readonly provenance: RouteProvenance; + /** Absolute preflight module path. */ + readonly source: string; +} + export type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; /** @@ -131,6 +138,8 @@ export interface CompiledAgentRoute { /** Statically projected bounded JSON Schema subset — the bound contract's `input` object; absent for missing or richer input schemas. */ readonly inputSchema?: RouteInputSchema; readonly kind: CompiledRouteKind; + /** Static cheap gate; present only on event routes that declare a valid relative default re-export. */ + readonly preflight?: CompiledEventPreflight; readonly provenance: RouteProvenance; /** The owning MCP server id (`mcp:`); MCP route kinds only. */ readonly serverId?: string; diff --git a/packages/agent-bundle/src/test/providers.ts b/packages/agent-bundle/src/test/providers.ts index 37b07c4e3..befcdc715 100644 --- a/packages/agent-bundle/src/test/providers.ts +++ b/packages/agent-bundle/src/test/providers.ts @@ -11,6 +11,8 @@ import type { import { executeProviders, providerProcessLifetimeValue, + requiredProviderKeyProblemMessage, + selectRequiredProviders, type ExecutableProvider, type ProviderProcessLifetime, type ProviderProcessLifetimeValue, @@ -64,6 +66,41 @@ export interface MountProvidersOptions { readonly provenance?: RenderedRouteProvenance; } +/** + * Chooses the manifest providers one route request may load. Event routes + * honor `config.providers`; every other surface preserves the all-provider + * compatibility contract. + */ +export const selectManifestProviderDescriptors = ( + manifest: AgentBundleTestManifest, + routeId: string | undefined, +): readonly TestableProviderDescriptor[] => { + const descriptors = manifest.providers ?? []; + const route = routeId === undefined ? undefined : manifest.routes[routeId]; + const declaration = route?.kind === 'event-route' ? route.config['providers'] : undefined; + if (declaration !== undefined && !( + Array.isArray(declaration) + && declaration.every((key): key is string => typeof key === 'string') + )) { + throw new AgentTestError( + 'contract-violation', + `Event route ${JSON.stringify(routeId)} has malformed config.providers in the compiled test manifest.`, + ); + } + const selection = selectRequiredProviders( + descriptors, + declaration, + ); + if (!selection.ok) { + throw new AgentTestError( + 'contract-violation', + `Event route ${JSON.stringify(routeId)} has invalid config.providers in the compiled test manifest.`, + { details: selection.problems.map(requiredProviderKeyProblemMessage) }, + ); + } + return selection.providers; +}; + const loadProvider = async ( manifest: AgentBundleTestManifest, descriptor: TestableProviderDescriptor, @@ -138,7 +175,7 @@ export const mountProviders = (options: MountProvidersOptions): AgentProviderVal } return async (request) => { const providers: ExecutableProvider[] = []; - for (const descriptor of manifest.providers ?? []) { + for (const descriptor of selectManifestProviderDescriptors(manifest, options.provenance?.routeId)) { providers.push(await loadProvider(manifest, descriptor, options.provenance)); } return executeProviders({ diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 4957f7100..a01f5caad 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -31,6 +31,7 @@ import type { GeneratedCliRenderContext, GeneratedCliRenderSession, } from '../cli-entry.ts'; +import { settleBeforeAbort } from '../core/abort.ts'; import { parseMcpRouteProtocolId } from '../routes/protocol-name.ts'; import { createProviderProcessLifetime, type ProviderProcessLifetime } from '../routes/provider-execution.ts'; import { routeRenderLimits, type RouteRenderBudget } from '../routes/render-budget.ts'; @@ -1229,22 +1230,6 @@ export interface PreparedScriptRenderHost { readonly terminate: (reason: Error) => void; } -/** - * Settles with `pending`, or rejects with the signal's reason as soon as it - * aborts: the pending work is abandoned, the way the generated executable - * abandons its render worker. - */ -const settledBeforeAbort = (pending: Promise, signal: AbortSignal): Promise => - new Promise((resolve, reject) => { - const onAbort = (): void => { reject(signal.reason); }; - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - pending.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort); }); - }); - /** * The render host behind the script-dispatch level for rendered scripts. It * mirrors the generated `scripts/.mjs` executable exactly: the @@ -1381,7 +1366,7 @@ export const prepareScriptRenderHost = async ( // aborts and terminates the worker, however far along the // module or state load is; the stream here fails the same way // rather than waiting for a load that may never settle. - const dispatcher = await settledBeforeAbort(pending, signal); + const dispatcher = await settleBeforeAbort(pending, signal); inner = dispatcher.stream({ invocation, signal }).getReader(); for (;;) { const next = await inner.read(); diff --git a/packages/agent-bundle/tests/claude-hook-event-name.test.ts b/packages/agent-bundle/tests/claude-hook-event-name.test.ts index 0acb229a2..31afed58d 100644 --- a/packages/agent-bundle/tests/claude-hook-event-name.test.ts +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -174,7 +174,7 @@ it('bakes the pinned Claude hook_event_name into every Claude event-route wrappe expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent); expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`); expect(wrapper!.virtualSource, entry.route).toContain('const target = "claude";'); - expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })'); + expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target })'); const native = await nativeEnvelope(entry.native); expect(native.hook_event_name, entry.route).toBe(expectedNativeEvent); diff --git a/packages/agent-bundle/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index 38bd5709d..86c7a1255 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from '@rstest/core'; -import { eventRuntimeHosting, selectedServerHosts } from '../src/build/entries.ts'; +import { planHooks } from '../src/adapters/hook-contract.ts'; +import { eventRuntimeHosting, planCompiledHooks, planHooksSurface, selectedServerHosts } from '../src/build/entries.ts'; import { runtimeIgnoredRoot } from '../src/build/runtime-path.ts'; -import type { NormalizedMcpServer } from '../src/core/types.ts'; +import type { NormalizedHook, NormalizedMcpServer, NormalizedPlugin } from '../src/core/types.ts'; +import type { CompiledEventPreflight } from '../src/routes/types.ts'; describe('runtime ignored root', () => { it('anchors a source runtime to its package when the checkout is under dist', () => { @@ -85,3 +87,88 @@ describe('event runtime hosting', () => { expect(hosting.serverIds.size).toBe(0); }); }); + +describe('event-route preflight source graph (#595)', () => { + const preflight: CompiledEventPreflight = Object.freeze({ + provenance: Object.freeze({ kind: 'conventional' as const, relativePath: 'src/events/tool/before.preflight.ts' }), + source: '/project/src/events/tool/before.preflight.ts', + }); + const hook: NormalizedHook = { + event: 'beforeTool', + eventRoute: { event: 'tool/before', fallback: 'none', preflight, runtime: 'shared' }, + id: 'hook:event-route:tool-before', + name: 'event-route-tool-before', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/before.tsx' }, + source: '/project/src/events/tool/before.tsx', + targets: ['claude'], + tools: [], + }; + const model: NormalizedPlugin = { + extensions: {}, + hooks: [hook], + mcpServers: [], + metadata: { + id: 'plugin:preflight-entries', + name: 'preflight-entries', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + version: '1.0.0', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: [{ + id: 'target:claude', + name: 'claude', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + }], + }; + const planned = planHooks(model, 'claude', { + commandRoot: '${CLAUDE_PLUGIN_ROOT}', + encodePlaygroundInput: (input) => input, + encodePlaygroundOutput: (result) => result, + eventNames: {}, + eventRouteNames: { 'tool/before': 'PreToolUse' }, + hostContractRevision: '2026-09-02', + manifestPath: 'hooks/hooks.json', + matchers: {}, + wrapperPath: (candidate) => `hooks/${candidate.name}.claude.mjs`, + wrapperSource: () => 'config-hook-only\n', + }).hookEntries; + + it('names the preflight leaf among the wrapper source inputs and keeps the rendered route as the entry source', () => { + const compiled = planCompiledHooks(planned, { outDir: '/tmp/artifact' }); + expect(compiled).toHaveLength(1); + expect(compiled[0]!.source).toBe(hook.source); + expect(compiled[0]!.sourceInputs).toEqual([ + hook.provenance.sourcePath, + hook.source, + preflight.source, + ]); + expect(compiled[0]!.target).toBe('claude'); + expect(compiled[0]!.output).toBe('/tmp/artifact/hooks/event-route-tool-before.claude.mjs'); + }); + + it('aliases the cheap event runtimes onto the per-host wrapper and applies the operator env layer', () => { + const surface = planHooksSurface(planned, { + artifactEpoch: 'preflight-entries@1.0.0', + outDir: '/tmp/artifact', + plugin: { name: 'preflight-entries', version: '1.0.0' }, + }); + expect(surface.entries).toHaveLength(2); + const entry = surface.entries[0]!; + expect(entry.outputRelativePath).toBe('hooks/event-route-tool-before.claude.mjs'); + expect(entry.aliases).toMatchObject({ + 'agent-bundle/event-ipc': expect.any(String), + 'agent-bundle/event-project': expect.any(String), + }); + expect(entry.virtualSource).toContain('executeEventPreflight'); + expect(entry.virtualSource).toContain(preflight.source); + expect(entry.virtualSource).not.toContain('__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__'); + expect(entry.virtualModules).toBeDefined(); + expect(entry.rscManifest).toBeUndefined(); + const executor = surface.entries[1]!; + expect(executor.outputRelativePath).toBe('hooks/event-route-tool-before.claude.execute.mjs'); + expect(executor.virtualSource).toContain('requestEventRuntime'); + expect(executor.virtualSource).toContain('preflight-entries@1.0.0'); + }); +}); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 8c351e2ff..26bde21fc 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -768,6 +768,167 @@ it('generates deterministic per-request provider execution in the shared Flight expect(source).toContain('return providerValues;'); }); +it('imports only the deterministic union selected by event routes and emits per-route provider lists', () => { + const providers = [ + { + id: 'provider:zeta', + name: 'zeta', + provenance: { kind: 'conventional' as const, relativePath: 'src/providers/zeta.ts' }, + source: '/project/src/providers/zeta.ts', + }, + { + id: 'provider:beta', + name: 'beta', + provenance: { kind: 'conventional' as const, relativePath: 'src/providers/beta.ts' }, + source: '/project/src/providers/beta.ts', + }, + { + id: 'provider:alpha-value', + name: 'alpha-value', + provenance: { kind: 'conventional' as const, relativePath: 'src/providers/alpha-value.ts' }, + source: '/project/src/providers/alpha-value.ts', + }, + ]; + const source = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [ + { + event: 'afterTool', + eventRoute: { + event: 'tool/after', + fallback: 'none', + providers: ['alphaValue'], + runtime: 'shared', + }, + id: 'hook:event-route:tool-after', + name: 'event-route-tool-after', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/after.tsx' }, + source: '/project/src/events/tool/after.tsx', + targets: ['claude'], + tools: [], + }, + { + event: 'sessionStart', + eventRoute: { + event: 'session/start', + fallback: 'none', + providers: [], + runtime: 'shared', + }, + id: 'hook:event-route:session-start', + name: 'event-route-session-start', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/session/start.tsx' }, + source: '/project/src/events/session/start.tsx', + targets: ['claude'], + tools: [], + }, + ], + providers, + routes: [], + serverName: 'curator', + }); + + expect(source).toContain('import * as provider0 from "/project/src/providers/alpha-value.ts"'); + expect(source).not.toContain('/project/src/providers/beta.ts'); + expect(source).not.toContain('/project/src/providers/zeta.ts'); + expect(source).toContain( + '"hook:event-route:tool-after": Object.freeze({ event: "tool/after", id: "event:tool/after", kind: \'event-route\', module: route0, name: "tool/after", providers: Object.freeze([providers[0]]) })', + ); + expect(source).toContain( + '"hook:event-route:session-start": Object.freeze({ event: "session/start", id: "event:session/start", kind: \'event-route\', module: route1, name: "session/start", providers: Object.freeze([]) })', + ); + expect(source).toContain('for (const provider of route.providers ?? providers)'); +}); + +it('keeps all-provider compatibility for MCP and undeclared event routes beside selected events', () => { + const providers = [ + { + id: 'provider:zeta', + name: 'zeta', + provenance: { kind: 'conventional' as const, relativePath: 'src/providers/zeta.ts' }, + source: '/project/src/providers/zeta.ts', + }, + { + id: 'provider:alpha-value', + name: 'alpha-value', + provenance: { kind: 'conventional' as const, relativePath: 'src/providers/alpha-value.ts' }, + source: '/project/src/providers/alpha-value.ts', + }, + ]; + const eventRoute = { + event: 'afterTool' as const, + eventRoute: { + event: 'tool/after' as const, + fallback: 'none' as const, + providers: ['zeta'], + runtime: 'shared' as const, + }, + id: 'hook:event-route:tool-after', + name: 'event-route-tool-after', + provenance: { kind: 'conventional' as const, sourcePath: '/project/src/events/tool/after.tsx' }, + source: '/project/src/events/tool/after.tsx', + targets: ['claude'], + tools: [], + }; + const source = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [ + eventRoute, + { + ...eventRoute, + event: 'sessionStart', + eventRoute: { event: 'session/start', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:session-start', + name: 'event-route-session-start', + source: '/project/src/events/session/start.tsx', + }, + ], + providers, + routes: [{ + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + source: '/project/src/mcp/curator/tools/inspect.tsx', + }], + serverName: 'curator', + }); + + expect(source).toContain('import * as provider0 from "/project/src/providers/alpha-value.ts"'); + expect(source).toContain('import * as provider1 from "/project/src/providers/zeta.ts"'); + expect(source).toContain('name: "tool/after", providers: Object.freeze([providers[1]])'); + expect(source).not.toContain('name: "session/start", providers:'); + expect(source).toContain('for (const provider of route.providers ?? providers)'); + + const inboxSource = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [eventRoute], + noticeDelivery: claudeAdapter.noticeDelivery!, + providers, + routes: [], + serverName: 'curator', + state: { + id: 'project/tasks', + lifetime: 'process', + provenance: { kind: 'conventional', sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', + }, + }); + expect(inboxSource).toContain('import * as provider0 from "/project/src/providers/alpha-value.ts"'); + expect(inboxSource).toContain('import * as provider1 from "/project/src/providers/zeta.ts"'); + + expect(() => entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [{ + ...eventRoute, + eventRoute: { ...eventRoute.eventRoute, providers: ['missing'] }, + }], + providers, + routes: [], + serverName: 'curator', + })).toThrow('invalid provider selection'); +}); + it('mounts deterministic per-request providers for plain routed CLI commands (#313)', () => { const route = { config: {}, diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts index 7134072f7..0215a4093 100644 --- a/packages/agent-bundle/tests/event-ipc.test.ts +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -178,6 +178,8 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so handle: async (request) => ({ echoed: request.native, event: request.event, + observedAt: request.observedAt, + sequence: request.sequence, }), })), (server) => Effect.promise(() => server.close()), @@ -193,6 +195,8 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so event: 'tool/after', hostContractRevision: '2.1.250', native: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + observedAt: '2026-09-05T09:00:00.000Z', + sequence: 42, signal: new AbortController().signal, target: 'claude', timeoutMs: 1_000, @@ -200,6 +204,8 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so expect(response).toEqual({ echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, event: 'tool/after', + observedAt: '2026-09-05T09:00:00.000Z', + sequence: 42, }); })); diff --git a/packages/agent-bundle/tests/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts new file mode 100644 index 000000000..aadf6def9 --- /dev/null +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -0,0 +1,185 @@ +import { expect, it } from '@rstest/core'; + +import { + executeEventPreflight, + eventFamilyAllowsPreflightDeny, + validateEventPreflightResult, + type EventPreflight, + type EventPreflightContext, +} from '../src/events/preflight.ts'; +import { projectEventPreflightResult } from '../src/events/projection.ts'; +import { + canonicalAgentEvents, + eventFamilyAllowsPreflightDeny as publicEventFamilyAllowsPreflightDeny, + validateEventPreflightResult as publicValidateEventPreflightResult, + type CanonicalAgentEvent, + type EventPreflightContext as PublicEventPreflightContext, + type EventPreflightResult as PublicEventPreflightResult, +} from '../src/routes/public.ts'; +import { + eventFamilyAllowsPreflightDeny as rootEventFamilyAllowsPreflightDeny, + validateEventPreflightResult as rootValidateEventPreflightResult, +} from '../src/index.ts'; + +/** Families whose existing projection emits a blocking deny on at least one host. */ +const familiesThatAllowDeny = [ + 'tool/before', + 'stop', + 'agent/start', + 'agent/stop', + 'prompt/submit', + 'compact/before', + 'permission/request', + 'model-switch/before', + 'config/change', + 'task/create', + 'agent/idle', +] as const satisfies readonly CanonicalAgentEvent[]; + +/** + * Families that are observation-only or ignore deny on every host — the + * portable intersection of `projectEventDocument`, not a host-specific copy. + */ +const familiesThatRejectDeny = [ + 'session/start', + 'tool/after', + 'workspace/open', + 'session/end', + 'tool/failure', + 'compact/after', + 'permission/denied', + 'stop/failure', + 'file/change', + 'task/complete', + 'model-switch/after', +] as const satisfies readonly CanonicalAgentEvent[]; + +it('classifies deny legality for every canonical event family', () => { + expect([...familiesThatAllowDeny, ...familiesThatRejectDeny].sort()).toEqual( + [...canonicalAgentEvents].sort(), + ); + for (const event of familiesThatAllowDeny) { + expect(eventFamilyAllowsPreflightDeny(event)).toBe(true); + } + for (const event of familiesThatRejectDeny) { + expect(eventFamilyAllowsPreflightDeny(event)).toBe(false); + } +}); + +it('validates execute and continue results without a host decision', () => { + expect(validateEventPreflightResult('execute', 'tool/before')).toBe('execute'); + expect(validateEventPreflightResult({ outcome: 'continue' }, 'tool/after')).toEqual({ + outcome: 'continue', + }); + expect(Object.isFrozen(validateEventPreflightResult({ outcome: 'continue' }, 'session/start'))).toBe(true); +}); + +it('validates a denying result only when the family admits deny and the reason is nonempty', () => { + expect(validateEventPreflightResult({ outcome: 'deny', reason: 'blocked command' }, 'tool/before')).toEqual({ + outcome: 'deny', + reason: 'blocked command', + }); + expect(() => validateEventPreflightResult({ outcome: 'deny' }, 'tool/before')) + .toThrow(/requires a nonempty reason when outcome is deny/u); + expect(() => validateEventPreflightResult({ outcome: 'deny', reason: '' }, 'stop')) + .toThrow(/requires a nonempty reason when outcome is deny/u); + expect(() => validateEventPreflightResult({ outcome: 'deny', reason: ' ' }, 'prompt/submit')) + .toThrow(/requires a nonempty reason when outcome is deny/u); +}); + +it('rejects deny on observation-only families instead of copying host projection', () => { + for (const event of familiesThatRejectDeny) { + expect(() => validateEventPreflightResult({ outcome: 'deny', reason: 'no' }, event)) + .toThrow(new RegExp(`${event.replace('/', '\\/')} cannot deny`, 'u')); + } +}); + +it('rejects unsupported preflight fields and results', () => { + expect(() => validateEventPreflightResult(undefined, 'tool/before')) + .toThrow(/Event preflight result/u); + expect(() => validateEventPreflightResult('continue', 'tool/before')) + .toThrow(/Event preflight result/u); + expect(() => validateEventPreflightResult({ outcome: 'allow' }, 'tool/before')) + .toThrow(/not supported/u); + expect(() => validateEventPreflightResult({ outcome: 'ask' }, 'tool/before')) + .toThrow(/not supported/u); + expect(() => validateEventPreflightResult({ outcome: 'execute' }, 'tool/before')) + .toThrow(/not supported/u); + expect(() => validateEventPreflightResult({ outcome: 'continue', reason: 'x' }, 'tool/before')) + .toThrow(/unsupported field/u); + expect(() => validateEventPreflightResult( + { outcome: 'deny', reason: 'blocked', updatedInput: {} }, + 'tool/before', + )).toThrow(/unsupported field/u); + expect(() => validateEventPreflightResult({ outcome: 'continue', extra: true }, 'tool/before')) + .toThrow(/unsupported field/u); +}); + +it('runs a gate with frozen cheap context and validates before returning', async () => { + const context = { + canonical: { event: 'tool/before' }, + host: { name: 'claude', nativeEvent: 'PreToolUse' }, + signal: new AbortController().signal, + terminal: { interactive: false }, + } as unknown as EventPreflightContext<'tool/before'>; + const result = await executeEventPreflight( + (received) => { + expect(Object.isFrozen(received)).toBe(true); + expect(Object.isFrozen(received.host)).toBe(true); + expect(received.host).toEqual({ name: 'claude', nativeEvent: 'PreToolUse' }); + return { outcome: 'deny', reason: 'blocked' }; + }, + context, + ); + expect(result).toEqual({ outcome: 'deny', reason: 'blocked' }); + expect(Object.isFrozen(result)).toBe(true); +}); + +it('honors the framework-owned abort signal before and after an asynchronous gate', async () => { + const controller = new AbortController(); + const context = { + canonical: { event: 'tool/before' }, + host: { name: 'cursor', nativeEvent: 'preToolUse' }, + signal: controller.signal, + terminal: { interactive: false }, + } as unknown as EventPreflightContext<'tool/before'>; + const execution = executeEventPreflight( + () => new Promise(() => undefined), + context, + ); + queueMicrotask(() => { controller.abort(new Error('deadline elapsed')); }); + await expect(execution).rejects.toThrow(/deadline elapsed/u); +}); + +it('projects a gate decision through the rendered event outcome rules', () => { + expect(projectEventPreflightResult( + { outcome: 'continue' }, + 'tool/before', + 'claude', + 'PreToolUse', + )).toBeUndefined(); + expect(projectEventPreflightResult( + { outcome: 'deny', reason: 'blocked' }, + 'tool/before', + 'claude', + 'PreToolUse', + )).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'blocked', + }, + }); +}); + +it('re-exports the preflight contract through the public production path', () => { + expect(publicValidateEventPreflightResult).toBe(validateEventPreflightResult); + expect(publicEventFamilyAllowsPreflightDeny).toBe(eventFamilyAllowsPreflightDeny); + expect(rootValidateEventPreflightResult).toBe(validateEventPreflightResult); + expect(rootEventFamilyAllowsPreflightDeny).toBe(eventFamilyAllowsPreflightDeny); + const result: PublicEventPreflightResult = publicValidateEventPreflightResult('execute', 'tool/before'); + const context: PublicEventPreflightContext = {} as EventPreflightContext; + const authoring: EventPreflight = () => result; + expect(result).toBe('execute'); + expect(authoring(context)).toBe('execute'); +}); diff --git a/packages/agent-bundle/tests/event-trace.test.ts b/packages/agent-bundle/tests/event-trace.test.ts new file mode 100644 index 000000000..4afde1f0d --- /dev/null +++ b/packages/agent-bundle/tests/event-trace.test.ts @@ -0,0 +1,402 @@ +import { expect, it } from '@rstest/core'; + +import { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTraceObserver, + eventTracePhases, + installEventTraceObserver, + summarizeEventTraceError, + type EventTraceErrorSummary, + type EventTraceEvent, + type EventTraceEventKind, + type EventTraceExecution, + type EventTracePhase, +} from '../src/events/trace.ts'; +import { + createEventTracer as runtimeCreateEventTracer, + eventTraceExecution as runtimeEventTraceExecution, + summarizeEventTraceError as runtimeSummarizeEventTraceError, +} from '../src/events/project.ts'; +import { + createEventTracer as apiCreateEventTracer, + eventTraceExecution as apiEventTraceExecution, + summarizeEventTraceError as apiSummarizeEventTraceError, +} from '../src/api.ts'; +import { + createEventTracer as rootCreateEventTracer, + eventTraceExecution as rootEventTraceExecution, + summarizeEventTraceError as rootSummarizeEventTraceError, + type EventTraceEvent as RootEventTraceEvent, + type EventTraceObserver as RootEventTraceObserver, +} from '../src/index.ts'; + +const execution: EventTraceExecution = eventTraceExecution({ + event: 'tool/before', + executionId: 'exec-1', + host: 'claude', + nativeEvent: 'PreToolUse', +}); + +/** A deterministic monotonic clock: every read advances by `step`. */ +const ticking = (step = 10) => { + let now = 0; + return () => { + now += step; + return now; + }; +}; + +const collect = () => { + const events: EventTraceEvent[] = []; + return { + events, + observer: (event: EventTraceEvent) => { events.push(event); }, + }; +}; + +/** Compile-time proof the union stays exhaustive: adding a kind fails here until handled. */ +const phaseOf = (event: EventTraceEvent): EventTracePhase => { + switch (event.kind) { + case 'preflight.start': + case 'preflight.outcome': + return 'preflight'; + case 'execute.start': + return 'execute'; + case 'providers.start': + case 'providers.finish': + return 'providers'; + case 'render.start': + case 'render.finish': + return 'render'; + case 'failure': + return event.phase; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +}; + +const describePhase = (phase: EventTracePhase): string => { + switch (phase) { + case 'preflight': + return 'gate'; + case 'execute': + return 'deferred route load'; + case 'providers': + return 'provider materialization'; + case 'render': + return 'route render'; + default: { + const exhaustive: never = phase; + return exhaustive; + } + } +}; + +it('freezes the execution identity and mints an id when none is given', () => { + expect(Object.isFrozen(execution)).toBe(true); + expect(execution).toEqual({ + event: 'tool/before', + executionId: 'exec-1', + host: 'claude', + nativeEvent: 'PreToolUse', + }); + const minted = eventTraceExecution({ event: 'stop', host: 'codex', nativeEvent: 'stop' }); + const again = eventTraceExecution({ event: 'stop', host: 'codex', nativeEvent: 'stop' }); + expect(minted.executionId).toMatch(/^[0-9a-f-]{36}$/u); + expect(minted.executionId).not.toBe(again.executionId); + expect(() => eventTraceExecution({ event: 'stop', executionId: '', host: 'codex', nativeEvent: 'stop' })) + .toThrow(/executionId/u); + expect(() => eventTraceExecution({ event: 'stop', host: '', nativeEvent: 'stop' })) + .toThrow(/host/u); + expect(() => eventTraceExecution({ event: 'stop', host: 'codex', nativeEvent: ' ' })) + .toThrow(/nativeEvent/u); +}); + +it('enumerates every kind and phase the union carries', () => { + const kinds: readonly EventTraceEventKind[] = eventTraceEventKinds; + expect([...kinds].sort()).toEqual([ + 'execute.start', + 'failure', + 'preflight.outcome', + 'preflight.start', + 'providers.finish', + 'providers.start', + 'render.finish', + 'render.start', + ]); + expect([...eventTracePhases]).toEqual(['preflight', 'execute', 'providers', 'render']); + for (const phase of eventTracePhases) { + expect(describePhase(phase)).toEqual(expect.any(String)); + } + expect(Object.isFrozen(eventTraceEventKinds)).toBe(true); + expect(Object.isFrozen(eventTracePhases)).toBe(true); +}); + +it('emits a complete executing trace with monotonic sequence, timestamps, and phase durations', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: ticking(), observer }); + expect(tracer.enabled).toBe(true); + expect(tracer.execution).toBe(execution); + + tracer.preflightStart(); + tracer.preflightOutcome('execute'); + tracer.executeStart('standalone'); + tracer.providersStart(); + tracer.providersFinish(2); + tracer.renderStart(); + tracer.renderFinish(); + + expect(events.map((event) => event.kind)).toEqual([ + 'preflight.start', + 'preflight.outcome', + 'execute.start', + 'providers.start', + 'providers.finish', + 'render.start', + 'render.finish', + ]); + expect(events.map((event) => event.sequence)).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(events.map((event) => event.at)).toEqual([10, 20, 30, 40, 50, 60, 70]); + expect(events.map(phaseOf)).toEqual([ + 'preflight', + 'preflight', + 'execute', + 'providers', + 'providers', + 'render', + 'render', + ]); + for (const event of events) { + expect(event.phase).toBe(phaseOf(event)); + expect(event.execution).toBe(execution); + expect(Object.isFrozen(event)).toBe(true); + } + expect(events[1]).toEqual({ + at: 20, + durationMs: 10, + execution, + kind: 'preflight.outcome', + outcome: 'execute', + phase: 'preflight', + sequence: 1, + }); + expect(events[2]).toMatchObject({ kind: 'execute.start', runtime: 'standalone' }); + expect(events[4]).toMatchObject({ count: 2, durationMs: 10, kind: 'providers.finish' }); + expect(events[6]).toMatchObject({ durationMs: 10, kind: 'render.finish' }); +}); + +it('uses the process observer for framework-created tracers and restores it safely', () => { + const { events, observer } = collect(); + const dispose = installEventTraceObserver(observer); + expect(eventTraceObserver()).toBe(observer); + const tracer = createEventTracer({ execution, now: ticking() }); + expect(tracer.enabled).toBe(true); + tracer.preflightStart(); + expect(events).toHaveLength(1); + dispose(); + expect(eventTraceObserver()).toBeUndefined(); + expect(createEventTracer({ execution }).enabled).toBe(false); +}); + +it('summarizes gate results without carrying the reason text', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: ticking(), observer }); + tracer.preflightStart(); + tracer.preflightOutcome({ outcome: 'deny', reason: 'blocked command' }); + expect(events[1]).toMatchObject({ kind: 'preflight.outcome', outcome: 'deny' }); + expect(JSON.stringify(events[1])).not.toContain('blocked command'); + + const second = collect(); + const other = createEventTracer({ execution, now: ticking(), observer: second.observer }); + other.preflightOutcome({ outcome: 'continue' }); + expect(second.events[0]).toMatchObject({ kind: 'preflight.outcome', outcome: 'continue', sequence: 0 }); + expect(second.events[0]).not.toHaveProperty('durationMs'); +}); + +it('omits a duration when the matching start was never observed', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: ticking(), observer }); + tracer.providersFinish(0); + tracer.renderFinish(); + expect(events[0]).toEqual({ + at: 10, + count: 0, + execution, + kind: 'providers.finish', + phase: 'providers', + sequence: 0, + }); + expect(events[1]).toEqual({ at: 20, execution, kind: 'render.finish', phase: 'render', sequence: 1 }); +}); + +it('records a terminal failure with an error-safe summary and then goes quiet', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: ticking(), observer }); + tracer.preflightStart(); + tracer.executeStart('shared'); + tracer.renderStart(); + const error = Object.assign(new Error('worker exited'), { code: 'E_WORKER', stack: 'secret stack' }); + tracer.failure('render', error); + expect(tracer.closed).toBe(true); + expect(events.at(-1)).toEqual({ + at: 40, + durationMs: 30, + error: { code: 'E_WORKER', message: 'worker exited', name: 'Error' }, + execution, + kind: 'failure', + phase: 'render', + sequence: 3, + }); + expect(JSON.stringify(events.at(-1))).not.toContain('secret stack'); + + const length = events.length; + tracer.renderFinish(); + tracer.failure('render', new Error('again')); + tracer.preflightStart(); + expect(events).toHaveLength(length); +}); + +it('closes before delivering a terminal failure to a reentrant observer', () => { + const events: EventTraceEvent[] = []; + let reenter = (): void => {}; + const tracer = createEventTracer({ + execution, + now: ticking(), + observer: (event) => { + events.push(event); + if (event.kind === 'failure') reenter(); + }, + }); + reenter = () => tracer.renderStart(); + tracer.failure('execute', new Error('failed')); + expect(events.map((event) => event.kind)).toEqual(['failure']); +}); + +it('measures a failure from the trace start when it has one and omits it otherwise', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: ticking(), observer }); + tracer.failure('preflight', new TypeError('gate threw')); + expect(events[0]).toEqual({ + at: 10, + error: { message: 'gate threw', name: 'TypeError' }, + execution, + kind: 'failure', + phase: 'preflight', + sequence: 0, + }); +}); + +it('summarizes arbitrary thrown values without throwing and bounds the message', () => { + const expectFrozenSummary = (summary: EventTraceErrorSummary): void => { + expect(Object.isFrozen(summary)).toBe(true); + }; + const plain = summarizeEventTraceError(new RangeError('out of range')); + expect(plain).toEqual({ message: 'out of range', name: 'RangeError' }); + expectFrozenSummary(plain); + + const coded = summarizeEventTraceError(Object.assign(new Error('coded'), { code: 'ENOENT' })); + expect(coded).toEqual({ code: 'ENOENT', message: 'coded', name: 'Error' }); + expect(summarizeEventTraceError(Object.assign(new Error('numeric'), { code: 42 }))).toEqual({ + message: 'numeric', + name: 'Error', + }); + + expect(summarizeEventTraceError('a string')).toEqual({ message: 'a string', name: 'NonError' }); + expect(summarizeEventTraceError(undefined)).toEqual({ message: 'undefined', name: 'NonError' }); + expect(summarizeEventTraceError(null)).toEqual({ message: 'null', name: 'NonError' }); + expect(summarizeEventTraceError({ toString: () => { throw new Error('nope'); } })).toEqual({ + message: '[unprintable]', + name: 'NonError', + }); + const hostile = new Error('hostile'); + Object.defineProperty(hostile, 'message', { get: () => { throw new Error('trap'); } }); + Object.defineProperty(hostile, 'name', { get: () => { throw new Error('trap'); } }); + expect(summarizeEventTraceError(hostile)).toEqual({ message: '[unprintable]', name: 'Error' }); + + const long = summarizeEventTraceError(new Error('x'.repeat(2_000))); + expect(long.message).toHaveLength(512); + expect(long.message.endsWith('…')).toBe(true); + const unnamed = new Error('anonymous'); + Object.defineProperty(unnamed, 'name', { value: '' }); + expect(summarizeEventTraceError(unnamed).name).toBe('Error'); +}); + +it('is a no-op when no observer is present', () => { + let reads = 0; + const tracer = createEventTracer({ + execution, + now: () => { reads += 1; return reads; }, + }); + expect(tracer.enabled).toBe(false); + expect(tracer.closed).toBe(false); + tracer.preflightStart(); + tracer.preflightOutcome('execute'); + tracer.executeStart('shared'); + tracer.providersStart(); + tracer.providersFinish(1); + tracer.renderStart(); + tracer.renderFinish(); + tracer.failure('render', new Error('ignored')); + expect(reads).toBe(0); + expect(tracer.closed).toBe(true); +}); + +it('never lets an observer exception, mutation, or re-entry reach the caller', () => { + const seen: EventTraceEvent[] = []; + let tracer = createEventTracer({ execution, now: ticking(), observer: () => { throw new Error('observer bug'); } }); + expect(() => { + tracer.preflightStart(); + tracer.preflightOutcome('execute'); + tracer.executeStart('shared'); + tracer.providersStart(); + tracer.providersFinish(0); + tracer.renderStart(); + tracer.renderFinish(); + tracer.failure('render', new Error('late')); + }).not.toThrow(); + + tracer = createEventTracer({ + execution, + now: ticking(), + observer: (event) => { + seen.push(event); + expect(() => { (event as { sequence: number }).sequence = 99; }).toThrow(TypeError); + expect(() => { (event.execution as { host: string }).host = 'other'; }).toThrow(TypeError); + // Re-entering the tracer from inside the observer must not corrupt ordering. + if (event.kind === 'preflight.start') tracer.renderStart(); + }, + }); + tracer.preflightStart(); + tracer.preflightOutcome({ outcome: 'continue' }); + expect(seen.map((event) => [event.kind, event.sequence])).toEqual([ + ['preflight.start', 0], + ['render.start', 1], + ['preflight.outcome', 2], + ]); +}); + +it('never lets a broken clock reach the caller', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: () => { throw new Error('clock'); }, observer }); + expect(() => { tracer.preflightStart(); }).not.toThrow(); + expect(events).toHaveLength(0); + expect(tracer.enabled).toBe(true); +}); + +it('is production-importable from the runtime, api, and root entries', () => { + expect(runtimeCreateEventTracer).toBe(createEventTracer); + expect(runtimeEventTraceExecution).toBe(eventTraceExecution); + expect(runtimeSummarizeEventTraceError).toBe(summarizeEventTraceError); + expect(apiCreateEventTracer).toBe(createEventTracer); + expect(apiEventTraceExecution).toBe(eventTraceExecution); + expect(apiSummarizeEventTraceError).toBe(summarizeEventTraceError); + expect(rootCreateEventTracer).toBe(createEventTracer); + expect(rootEventTraceExecution).toBe(eventTraceExecution); + expect(rootSummarizeEventTraceError).toBe(summarizeEventTraceError); + const observer: RootEventTraceObserver = (event: RootEventTraceEvent) => { phaseOf(event); }; + expect(rootCreateEventTracer({ execution, observer }).enabled).toBe(true); +}); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 078efa9b9..0ae875516 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -11,7 +11,9 @@ import { rspack } from '@rslib/core'; import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; -import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; +import { hookWrapperPath } from '../src/adapters/composite-layout.ts'; +import { nativeHookWrapperSource, planHooks, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; +import type { CompiledEventPreflight } from '../src/routes/types.ts'; import { build } from './support/build.ts'; import { runNodeScript } from './support/run-node-script.ts'; import { writeHookIndex } from '../src/build/emit.ts'; @@ -160,6 +162,67 @@ it('keeps the Claude and Codex native wrapper codecs byte-identical apart from i expect(claudeSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); }); +it('wires Compiled event preflight into each per-host wrapper and keeps built-in host identity baked', () => { + const selected = ['claude', 'codex', 'cursor'] as const; + const preflight: CompiledEventPreflight = Object.freeze({ + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/before.preflight.ts' }), + source: '/project/src/events/tool/before.preflight.ts', + }); + const hook: NormalizedPlugin['hooks'][number] = { + event: 'beforeTool', + eventRoute: { event: 'tool/before', fallback: 'none', preflight, runtime: 'shared' }, + id: 'hook:event-route:tool-before', + name: 'event-route-tool-before', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/before.tsx' }, + source: '/project/src/events/tool/before.tsx', + targets: [...selected], + tools: [], + }; + const model: NormalizedPlugin = { + extensions: {}, + hooks: [hook], + mcpServers: [], + metadata: { + id: 'plugin:preflight-hosts', + name: 'preflight-hosts', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + version: '1.0.0', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: selected.map((name) => ({ + id: `target:${name}`, + name, + provenance: { kind: 'config' as const, sourcePath: '/project/agent-bundle.config.ts' }, + })), + }; + const registry = createDefaultRegistry(); + + for (const host of selected) { + const contract = registry.hookContract(host); + expect(contract).toBeDefined(); + const plan = planHooks(model, host, { + ...contract!, + wrapperPath: (candidate) => hookWrapperPath(host, candidate.name, candidate.targets, selected), + }); + const entry = plan.hookEntries[0]!; + expect(plan.diagnostics).toEqual([]); + expect(entry.relativePath).toBe(`hooks/event-route-tool-before.${host}.mjs`); + expect(entry.target).toBe(host); + expect(entry.virtualSource).toContain(`const target = ${JSON.stringify(host)};`); + expect(entry.virtualSource).toContain('executeEventPreflight'); + expect(entry.virtualSource).toContain(`from ${JSON.stringify(preflight.source)}`); + expect(entry.virtualSource).toContain('projectEventPreflightResult'); + expect(entry.virtualSource).toContain(`./event-route-tool-before.${host}.execute.mjs`); + expect(entry.executeVirtualSource).toContain('requestEventRuntime'); + expect(entry.virtualSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + expect(entry.virtualSource).not.toContain("from '@agent-bundle/runtime'"); + expect(entry.virtualSource).not.toContain('/project/src/events/tool/before.tsx'); + expect(entry.virtualSource).not.toContain('import * as provider'); + } +}); + const runPublishedHook = async (wrapper: string, input: string) => runNodeScript({ args: [wrapper], input }); const runNativeHook = async (wrapper: string, input: Record) => diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 0e8bfa800..4ecc448dd 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -4,8 +4,12 @@ import { join } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; +import { planHooks } from '../src/adapters/hook-contract.ts'; import { inspect, type BundlerInspectionEntry, type ReadyInspectResult } from '../src/api.ts'; +import { composeBundlerInspection } from '../src/build/inspect-bundler.ts'; import { stableJson } from '../src/core/digest.ts'; +import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; +import type { CompiledEventPreflight } from '../src/routes/types.ts'; const roots: string[] = []; @@ -194,3 +198,75 @@ it('keeps the bundler focus out of unfocused inspections', async () => { expect(result.state).toBe('ready'); expect((result as ReadyInspectResult).selected).toBeUndefined(); }); + +it('inspects the per-host preflight wrapper under the composite identity', async () => { + const preflight: CompiledEventPreflight = Object.freeze({ + provenance: Object.freeze({ kind: 'conventional' as const, relativePath: 'src/events/tool/before.preflight.ts' }), + source: '/project/src/events/tool/before.preflight.ts', + }); + const hook: NormalizedHook = { + event: 'beforeTool', + eventRoute: { event: 'tool/before', fallback: 'none', preflight, runtime: 'shared' }, + id: 'hook:event-route:tool-before', + name: 'event-route-tool-before', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/before.tsx' }, + source: '/project/src/events/tool/before.tsx', + targets: ['claude', 'codex'], + tools: [], + }; + const model: NormalizedPlugin = { + extensions: {}, + hooks: [hook], + mcpServers: [], + metadata: { + id: 'plugin:preflight-inspect', + name: 'preflight-inspect', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + version: '1.0.0', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: ['claude', 'codex'].map((name) => ({ + id: `target:${name}`, + name, + provenance: { kind: 'config' as const, sourcePath: '/project/agent-bundle.config.ts' }, + })), + }; + const hookEntries = ['claude', 'codex'].flatMap((host) => planHooks(model, host, { + commandRoot: host === 'claude' ? '${CLAUDE_PLUGIN_ROOT}' : '${PLUGIN_ROOT}', + encodePlaygroundInput: (input) => input, + encodePlaygroundOutput: (result) => result, + eventNames: {}, + eventRouteNames: { 'tool/before': 'PreToolUse' }, + hostContractRevision: '2026-09-02', + manifestPath: 'hooks/hooks.json', + matchers: {}, + wrapperPath: () => `hooks/${hook.name}.${host}.mjs`, + wrapperSource: () => 'config-hook-only\n', + }).hookEntries); + const inspection = await composeBundlerInspection({ + composite: { + cliBin: false, + hookEntries, + identity: 'claude+codex', + noticeDelivery: undefined, + selected: ['claude', 'codex'], + }, + model, + projectRoot: '/project', + }); + const hooks = inspection.entries.filter((entry) => entry.kind === 'hook'); + expect(hooks.map((entry) => entry.outputPath).sort()).toEqual([ + 'hooks/event-route-tool-before.claude.mjs', + 'hooks/event-route-tool-before.codex.mjs', + ]); + for (const entry of hooks) { + expect(entry.target).toBe('claude+codex'); + expect(entry.generatedEntry).toContain('executeEventPreflight'); + expect(entry.generatedEntry).toContain(preflight.source); + expect(entry.generatedEntry).toContain('agent-bundle/event-project'); + expect(entry.generatedEntry).toContain('.execute.mjs'); + expect(entry.generatedEntry).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + } +}); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 87bf1eb13..addc09221 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1306,6 +1306,63 @@ const routeGraphWithGeneratedServer = (root: string): CompiledRouteGraph => { }; }; +it('carries event-route preflight provenance and provider selection into normalized hooks', async () => { + const root = '/workspace/project'; + const selected: CompiledAgentRoute = { + config: { providers: ['zeta', 'alphaValue'] }, + event: 'tool/after', + id: 'event:tool/after', + kind: 'event-route', + preflight: { + provenance: { kind: 'conventional', relativePath: 'src/events/tool/after.preflight.ts' }, + source: `${root}/src/events/tool/after.preflight.ts`, + }, + provenance: { kind: 'conventional', relativePath: 'src/events/tool/after.tsx' }, + source: `${root}/src/events/tool/after.tsx`, + }; + const inherited: CompiledAgentRoute = { + config: emptyRouteConfig, + event: 'session/start', + id: 'event:session/start', + kind: 'event-route', + provenance: { kind: 'conventional', relativePath: 'src/events/session/start.tsx' }, + source: `${root}/src/events/session/start.tsx`, + }; + const routeGraph: CompiledRouteGraph = { + diagnostics: [], + digest: 'event-route-metadata', + events: [selected, inherited], + providers: [], + scripts: [], + servers: [], + }; + + const model = await normalizeProject( + loadedProject({ + plugin: { name: 'review-tools', version: '1.0.0' }, + targets: ['claude'], + }), + { routeGraph, skills: [] }, + registry, + ); + + expect(model.hooks.find(({ id }) => id === 'hook:event-route:tool-after')?.eventRoute).toEqual({ + event: 'tool/after', + fallback: 'none', + preflight: selected.preflight, + providers: ['zeta', 'alphaValue'], + runtime: 'shared', + }); + expect(model.hooks.find(({ id }) => id === 'hook:event-route:session-start')?.eventRoute).toEqual({ + event: 'session/start', + fallback: 'none', + runtime: 'shared', + }); + expect(Object.isFrozen( + model.hooks.find(({ id }) => id === 'hook:event-route:tool-after')?.eventRoute?.providers, + )).toBe(true); +}); + it('normalizes generated MCP route servers without a handwritten server declaration', async () => { const root = '/workspace/project'; const graph = routeGraphWithGeneratedServer(root); diff --git a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts new file mode 100644 index 000000000..4e342bc6d --- /dev/null +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -0,0 +1,372 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { isBuiltin } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; + +import { build, type BuildProjectResult } from '../src/api.ts'; +import { parseArtifactManifest } from '../src/build/manifest.ts'; +import { readModuleImports } from '../src/build/module-imports.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { compileRouteGraph } from '../src/routes/graph.ts'; +import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from '../src/routes/module-candidates.ts'; +import { runNodeScript } from './support/run-node-script.ts'; + +/** + * #595's emitted-graph proof, pre-staged at the built-artifact level: the + * public hook entry a host invokes (`hooks/hooks.json` → `hooks/.mjs`) + * must be physically cheap — its static module graph carries the preflight + * leaf and none of the rendered route, layouts, application providers, React, + * or the RSC renderer — while everything execution needs lives behind a + * deferred edge (a literal `import('./x.mjs')` or a sibling + * `new URL('./x.mjs', import.meta.url)`) in modules that stay self-contained + * (the AB6005 rule: Node built-ins and in-artifact relative imports only). + * + * Runs a real Rslib build, so it belongs in `integrationTestFiles`. + */ + +const sentinels = Object.freeze({ + layout: 'sentinel:layout:root:9d0f52', + preflightLeaf: 'sentinel:preflight-leaf:7f3a9c', + provider: 'sentinel:provider:daemon-probe:c41d07', + renderedRoute: 'sentinel:rendered-route:2b8e41', +}); + +/** Byte-level evidence of React itself and of the RSC renderer/client. */ +const reactMarker = /Symbol\.for\(["']react\./u; +const rscMarkers = [/react-server-dom/u, /renderToReadableStream|renderToPipeableStream|renderAgentFlight/u, /createAgentRenderDispatcher/u]; + +const projectFiles: Readonly> = { + 'agent-bundle.config.ts': [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'Preflight graph fixture.', name: 'preflight-graph-fixture', version: '1.0.0' },", + " targets: ['claude'],", + '});', + '', + ].join('\n'), + 'package.json': JSON.stringify({ + dependencies: { '@agent-bundle/runtime': 'workspace:*', react: '19.2.8', zod: '4.4.3' }, + name: 'preflight-graph-fixture', + type: 'module', + version: '1.0.0', + }), + // The cheap leaf: one relative helper, no React, no runtime, no providers. + 'src/cheap/tokens.ts': [ + `export const PREFLIGHT_LEAF_SENTINEL = ${JSON.stringify(sentinels.preflightLeaf)};`, + 'export const mentionsCargo = (command: string): boolean => /\\b(?:cargo|hauler)\\b/u.test(command);', + '', + ].join('\n'), + 'src/events/tool/before.preflight.ts': [ + "import { PREFLIGHT_LEAF_SENTINEL, mentionsCargo } from '../../cheap/tokens.js';", + 'export default ({ canonical }: { readonly canonical: { readonly payload?: Record } }) => {', + " const tool = canonical.payload?.['toolInput'] as { readonly value?: { readonly command?: unknown } } | undefined;", + " const command = typeof tool?.value?.command === 'string' ? tool.value.command : '';", + " if (mentionsCargo(command)) return 'execute';", + " return command === 'blocked' ? { outcome: 'deny', reason: PREFLIGHT_LEAF_SENTINEL } : { outcome: 'continue' };", + '};', + '', + ].join('\n'), + // The rendered route: standalone so the whole execution path is inside the + // artifact, with a declared provider and a deliberately heavy import graph. + 'src/events/tool/before.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { RENDERED_ROUTE_SENTINEL } from '../../heavy/rendered-route.js';", + "export { default as preflight } from './before.preflight.js';", + "export const config = { providers: ['daemonProbe'], runtime: 'standalone' };", + 'export default async function ToolBefore({ canonical }) {', + " return {canonical.event};", + '}', + '', + ].join('\n'), + 'src/heavy/rendered-route.ts': `export const RENDERED_ROUTE_SENTINEL = ${JSON.stringify(sentinels.renderedRoute)};\n`, + 'src/layout.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + `const LAYOUT_SENTINEL = ${JSON.stringify(sentinels.layout)};`, + 'export default async function Layout({ children }) {', + ' return {children};', + '}', + '', + ].join('\n'), + 'src/providers/daemon-probe.ts': [ + "import { createElement } from 'react';", + `const PROVIDER_SENTINEL = ${JSON.stringify(sentinels.provider)};`, + 'export default async function daemonProbe() {', + ' return { element: typeof createElement, sentinel: PROVIDER_SENTINEL };', + '}', + '', + ].join('\n'), +}; + +const heavySources = ['src/events/tool/before.tsx', 'src/heavy/rendered-route.ts', 'src/layout.tsx', 'src/providers/daemon-probe.ts']; + +const toPosix = (path: string): string => path.replaceAll('\\', '/'); + +/** One emitted module's edges, read the way AB6005 reads them (es-module-lexer). */ +interface EmittedModule { + readonly bare: readonly string[]; + readonly bytes: string; + /** Literal dynamic imports and sibling `new URL('./x.mjs', import.meta.url)` references, artifact-relative. */ + readonly deferred: readonly string[]; + readonly nonLiteralDynamic: number; + readonly path: string; + readonly statics: readonly string[]; +} + +const siblingUrlReference = /new URL\(\s*(?:\/\*[^*]*\*\/\s*)?["'](\.\.?\/[^"']+\.mjs)["']\s*,\s*import\.meta\.url\s*\)/gu; + +const readEmittedModule = async (artifactRoot: string, path: string): Promise => { + const bytes = await readFile(join(artifactRoot, path), 'utf8'); + const imports = await readModuleImports(bytes, { check: 'lexed' }); + const statics: string[] = []; + const deferred: string[] = []; + const bare: string[] = []; + let nonLiteralDynamic = 0; + const resolveInArtifact = (specifier: string): string | undefined => { + if (!specifier.startsWith('.') && !specifier.startsWith('file:')) return undefined; + const url = new URL(specifier, pathToFileURL(join(artifactRoot, path))); + const target = toPosix(relative(artifactRoot, fileURLToPath(url))); + return target.startsWith('../') ? undefined : target; + }; + for (const imported of imports) { + if (imported.kind === 'meta') continue; + if (imported.specifier === undefined) { + nonLiteralDynamic += 1; + continue; + } + if (isBuiltin(imported.specifier)) continue; + const target = resolveInArtifact(imported.specifier); + if (target === undefined) { + bare.push(imported.specifier); + continue; + } + (imported.kind === 'static' ? statics : deferred).push(target); + } + for (const match of bytes.matchAll(siblingUrlReference)) { + const target = resolveInArtifact(match[1]!); + if (target !== undefined) deferred.push(target); + } + return Object.freeze({ bare, bytes, deferred: Object.freeze([...new Set(deferred)]), nonLiteralDynamic, path, statics: Object.freeze([...new Set(statics)]) }); +}; + +/** Transitive closure over the chosen edge kinds, in first-seen order. */ +const closure = async ( + artifactRoot: string, + roots: readonly string[], + edges: (module: EmittedModule) => readonly string[], + cache: Map, +): Promise => { + const seen = new Set(); + const ordered: EmittedModule[] = []; + const pending = [...roots]; + while (pending.length > 0) { + const path = pending.shift()!; + if (seen.has(path)) continue; + seen.add(path); + const module = cache.get(path) ?? await readEmittedModule(artifactRoot, path); + cache.set(path, module); + ordered.push(module); + pending.push(...edges(module)); + } + return Object.freeze(ordered); +}; + +const concatenated = (modules: readonly EmittedModule[]): string => modules.map((module) => module.bytes).join('\n'); + +/** The project-source closure of one module through relative imports, as the route-graph scans resolve them. */ +const sourceClosure = (entry: string): readonly string[] => { + const seen = new Set(); + const pending = [entry]; + while (pending.length > 0) { + const path = pending.shift()!; + if (seen.has(path)) continue; + const text = readModuleFromDisk(path); + if (text === undefined) continue; + seen.add(path); + for (const match of text.matchAll(/\bfrom\s+["']([^"']+)["']|\bimport\s+["']([^"']+)["']/gu)) { + const specifier = match[1] ?? match[2]!; + if (!isRelativeSpecifier(specifier)) continue; + const candidate = moduleCandidates(dirname(path), specifier).find((file) => readModuleFromDisk(file) !== undefined); + if (candidate !== undefined) pending.push(candidate); + } + } + return Object.freeze([...seen]); +}; + +const bareSourceImports = (paths: readonly string[]): readonly string[] => paths.flatMap((path) => + [...(readModuleFromDisk(path) ?? '').matchAll(/\bfrom\s+["']([^"']+)["']|\bimport\s+["']([^"']+)["']/gu)] + .map((match) => match[1] ?? match[2]!) + .filter((specifier) => !isRelativeSpecifier(specifier))); + +describe('preflight artifact graph (#595)', () => { + let root: string; + let output: string; + let result: BuildProjectResult; + /** The public entry, artifact-relative, and the root the artifact's relative imports resolve against. */ + let artifactRoot: string; + let entryPath: string; + let entryGraph: readonly EmittedModule[]; + let deferredGraph: readonly EmittedModule[]; + const cache = new Map(); + + beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-preflight-graph-'))); + // The audiobook example's installed tree supplies @agent-bundle/runtime, react, and zod. + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + for (const [path, contents] of Object.entries(projectFiles)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), contents); + } + output = join(root, 'artifact'); + result = await build({ output, root }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + + const compiled = result.build.compiledHooks.find((hook) => hook.id === 'hook:event-route:tool-before' && hook.target === 'claude'); + if (compiled === undefined) throw new Error('The tool/before event route compiled no Claude hook entry.'); + // The wrapper lives at `/hooks/.mjs` on either side of #578. + artifactRoot = dirname(dirname(compiled.output)); + entryPath = toPosix(relative(artifactRoot, compiled.output)); + + entryGraph = await closure(artifactRoot, [entryPath], (module) => module.statics, cache); + const entryPaths = new Set(entryGraph.map((module) => module.path)); + const deferredRoots = [...new Set(entryGraph.flatMap((module) => module.deferred))].filter((path) => !entryPaths.has(path)); + deferredGraph = (await closure(artifactRoot, deferredRoots, (module) => [...module.statics, ...module.deferred], cache)) + .filter((module) => !entryPaths.has(module.path)); + }, 240_000); + + afterAll(async () => { + if (root !== undefined) await rm(root, { force: true, recursive: true }); + }); + + it('attaches the preflight leaf to the event route node, and the leaf\'s source graph is cheap by construction', async () => { + const graph = await compileRouteGraph(root, { plugin: { name: 'preflight-graph-fixture', version: '1.0.0' } }); + expect(graph.diagnostics).toEqual([]); + expect(graph.events.map((route) => route.id)).toEqual(['event:tool/before']); + expect(graph.events[0]!.preflight).toEqual({ + provenance: { kind: 'conventional', relativePath: 'src/events/tool/before.preflight.ts' }, + source: join(root, 'src/events/tool/before.preflight.ts'), + }); + expect(graph.events[0]!.config).toMatchObject({ providers: ['daemonProbe'], runtime: 'standalone' }); + expect(graph.providers.map((provider) => provider.name)).toEqual(['daemon-probe']); + expect(graph.layouts?.map((layout) => layout.id)).toEqual(['layout:root']); + + // The gate's own graph: the leaf plus one helper, no bare imports at all, + // and none of the modules the rendered route reaches. + const leaf = sourceClosure(graph.events[0]!.preflight!.source).map((path) => toPosix(relative(root, path))).sort(); + expect(leaf).toEqual(['src/cheap/tokens.ts', 'src/events/tool/before.preflight.ts']); + expect(bareSourceImports(leaf.map((path) => join(root, path)))).toEqual([]); + expect(leaf.filter((path) => heavySources.includes(path))).toEqual([]); + }); + + it('emits the entry the Claude hook document invokes, indexed once, in an AB6005-clean artifact', async () => { + const compiled = result.build.compiledHooks.find((hook) => hook.id === 'hook:event-route:tool-before')!; + const index = JSON.parse(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')) as { hooks: { id: string; path: string; target: string }[] }; + const indexed = index.hooks.filter((hook) => hook.id === 'hook:event-route:tool-before'); + expect(indexed).toHaveLength(1); + expect(join(output, indexed[0]!.path)).toBe(compiled.output); + + const document = JSON.parse(await readFile(join(artifactRoot, 'hooks', 'hooks.json'), 'utf8')) as { hooks: Record }; + const commands = Object.values(document.hooks).flat().flatMap((group) => group.hooks.map((hook) => hook.command)); + expect(commands).toEqual([`node "\${CLAUDE_PLUGIN_ROOT}/${entryPath}"`]); + + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + const bundled = manifest.files.filter((file) => file.kind === 'bundle').map((file) => join(output, file.path)); + expect(bundled).toContain(compiled.output); + // Every module of both graphs is a compiler-emitted bundle the manifest lists. + for (const module of [...entryGraph, ...deferredGraph]) { + expect(bundled, `${module.path} is not a manifest bundle`).toContain(join(artifactRoot, module.path)); + } + const diagnostics = await validateArtifact({ artifactRoot: output }); + expect(diagnostics.filter((diagnostic) => diagnostic.code === 'AB6005' || diagnostic.severity === 'error')).toEqual([]); + }); + + it('runs continue, deny, and deferred execute outcomes through the published hook process', async () => { + const invoke = (command: string) => runNodeScript({ + args: [join(artifactRoot, entryPath)], + input: JSON.stringify({ + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-1', + tool_input: { command }, + tool_name: 'Bash', + tool_use_id: 'use-1', + transcript_path: join(root, 'transcript.json'), + }), + }); + + await expect(invoke('echo hello')).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); + const denied = await invoke('blocked'); + expect(denied.code).toBe(0); + expect(denied.stderr).toBe(''); + expect(JSON.parse(denied.stdout)).toMatchObject({ + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: sentinels.preflightLeaf, + }, + }); + const executed = await invoke('cargo check'); + expect(executed.code).toBe(0); + expect(executed.stderr).toBe(''); + expect(JSON.parse(executed.stdout)).toMatchObject({ + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: sentinels.renderedRoute, + }, + }); + }); + + it('carries the preflight leaf in the public entry\'s static graph and names it among the entry\'s source inputs', () => { + const bytes = concatenated(entryGraph); + expect(bytes).toContain(sentinels.preflightLeaf); + const compiled = result.build.compiledHooks.find((hook) => hook.id === 'hook:event-route:tool-before')!; + expect(compiled.sourceInputs).toEqual(expect.arrayContaining([ + join(root, 'src/events/tool/before.preflight.ts'), + join(root, 'src/cheap/tokens.ts'), + ])); + }); + + it('keeps the rendered route, the layout, and application providers out of the public entry\'s static graph', () => { + const bytes = concatenated(entryGraph); + expect(bytes).not.toContain(sentinels.renderedRoute); + expect(bytes).not.toContain(sentinels.layout); + expect(bytes).not.toContain(sentinels.provider); + // No project source of the heavy side reaches the entry by path either. + for (const source of heavySources) expect(bytes).not.toContain(join(root, source)); + }); + + it('keeps React and the RSC renderer/client out of the public entry\'s static graph', () => { + const bytes = concatenated(entryGraph); + expect(bytes).not.toMatch(reactMarker); + for (const marker of rscMarkers) expect(bytes, `entry graph matches ${String(marker)}`).not.toMatch(marker); + }); + + it('reaches execution only through a deferred edge whose artifact includes the route, providers, React, and the RSC renderer, and stays self-contained', () => { + expect(deferredGraph.length).toBeGreaterThan(0); + const entryPaths = new Set(entryGraph.map((module) => module.path)); + expect(deferredGraph.filter((module) => entryPaths.has(module.path))).toEqual([]); + // The compiler's own report of the execution side must sit behind the boundary too. + const compiled = result.build.compiledHooks.find((hook) => hook.id === 'hook:event-route:tool-before')!; + if (compiled.workerOutput !== undefined) { + const workerPath = toPosix(relative(artifactRoot, compiled.workerOutput)); + expect(entryPaths.has(workerPath)).toBe(false); + expect(deferredGraph.map((module) => module.path)).toContain(workerPath); + } + + const bytes = concatenated(deferredGraph); + expect(bytes).toContain(sentinels.renderedRoute); + expect(bytes).toContain(sentinels.provider); + expect(bytes).toMatch(reactMarker); + expect(bytes).toMatch(/renderToReadableStream|renderToPipeableStream|renderAgentFlight/u); + // Event routes compose no layout (`layoutChainFor`), so the layout is only + // ever asserted absent from the entry, never present in the worker. + + // Self-contained on both sides of the boundary: Node built-ins and + // in-artifact relative imports only, every dynamic import literal. + for (const module of [...entryGraph, ...deferredGraph]) { + expect(module.bare, `${module.path} imports bare specifiers`).toEqual([]); + expect(module.nonLiteralDynamic, `${module.path} has non-literal dynamic imports`).toBe(0); + } + }); +}); diff --git a/packages/agent-bundle/tests/provider-execution.test.ts b/packages/agent-bundle/tests/provider-execution.test.ts new file mode 100644 index 000000000..3df5fe3a3 --- /dev/null +++ b/packages/agent-bundle/tests/provider-execution.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + executeProviders, + orderedProviders, + requiredProviderKeyProblemMessage, + selectRequiredProviders, + validateRequiredProviderKeys, + type ExecutableProvider, + type RequiredProviderKeyProblem, + type RequiredProviderSelection, +} from '../src/routes/provider-execution.ts'; +import { providerKeyFromName } from '../src/routes/providers.ts'; +import type { CompiledProvider } from '../src/routes/types.ts'; +import { selectManifestProviderDescriptors } from '../src/test/providers.ts'; +import type { AgentBundleTestManifest, TestableProviderDescriptor } from '../src/test/manifest.ts'; + +/** + * Declaration-driven required-provider selection (#595): the pure step every + * consumer runs before `executeProviders` — the graph to validate a route's + * declaration, the compiler to emit a route's provider subset, the harness to + * mount the same subset. Fixtures are deliberately out of key order so the + * tests distinguish declaration order from execution order. + */ + +const compiled = (name: string): CompiledProvider => Object.freeze({ + id: `provider:${name}`, + name, + provenance: Object.freeze({ kind: 'conventional' as const, relativePath: `src/providers/${name}.ts` }), + source: `/project/src/providers/${name}.ts`, +}); + +// Keys: zeta, alphaValue, daemonProbe — execution order is alphaValue, daemonProbe, zeta. +const providers: readonly CompiledProvider[] = Object.freeze([ + compiled('zeta'), + compiled('alpha-value'), + compiled('daemon_probe'), +]); + +const names = (selection: RequiredProviderSelection): readonly string[] => { + if (!selection.ok) throw new Error(`expected an accepted selection, got ${JSON.stringify(selection.problems)}`); + return selection.providers.map((provider) => provider.name); +}; + +describe('selectRequiredProviders', () => { + it('resolves every conventional provider in the existing deterministic order when the route declares nothing', () => { + const selection = selectRequiredProviders(providers, undefined); + expect(selection).toEqual({ ok: true, providers: orderedProviders(providers) }); + expect(names(selection)).toEqual(['alpha-value', 'daemon_probe', 'zeta']); + }); + + it('selects only the declared keys, in key/source order rather than declaration order', () => { + const selection = selectRequiredProviders(providers, Object.freeze(['zeta', 'alphaValue'])); + expect(names(selection)).toEqual(['alpha-value', 'zeta']); + expect(names(selectRequiredProviders(providers, Object.freeze(['daemonProbe'])))).toEqual(['daemon_probe']); + }); + + it('selects no conventional provider for an empty declaration', () => { + expect(selectRequiredProviders(providers, Object.freeze([]))).toEqual({ ok: true, providers: [] }); + }); + + it('accepts an empty declaration and rejects every explicit key for a project without providers', () => { + expect(selectRequiredProviders([], Object.freeze([]))).toEqual({ ok: true, providers: [] }); + expect(selectRequiredProviders([], undefined)).toEqual({ ok: true, providers: [] }); + expect(selectRequiredProviders([], Object.freeze(['zeta']))).toEqual({ + ok: false, + problems: [{ key: 'zeta', kind: 'unknown-provider-key', known: [] }], + }); + }); + + it('reports duplicate, reserved and unknown keys together, once per offending occurrence, in declaration order', () => { + const selection = selectRequiredProviders( + providers, + Object.freeze(['zeta', 'nope', 'zeta', 'processLifetime', 'nope', 'processLifetime']), + ); + expect(selection).toEqual({ + ok: false, + problems: [ + { key: 'nope', kind: 'unknown-provider-key', known: ['alphaValue', 'daemonProbe', 'zeta'] }, + { key: 'zeta', kind: 'duplicate-provider-key' }, + { key: 'processLifetime', kind: 'reserved-provider-key' }, + { key: 'nope', kind: 'duplicate-provider-key' }, + { key: 'processLifetime', kind: 'duplicate-provider-key' }, + ], + }); + }); + + it('matches declared keys against the derived camel-case key, not the file stem', () => { + // `alpha-value` is the stem; `alphaValue` is the mounted key a route reads. + expect(selectRequiredProviders(providers, Object.freeze(['alpha-value']))).toEqual({ + ok: false, + problems: [{ key: 'alpha-value', kind: 'unknown-provider-key', known: ['alphaValue', 'daemonProbe', 'zeta'] }], + }); + }); + + it('returns frozen results without mutating or reordering its inputs', () => { + const input = [compiled('zeta'), compiled('alpha-value')]; + const required = ['zeta', 'alphaValue']; + const accepted = selectRequiredProviders(input, required); + const rejected = selectRequiredProviders(input, ['zeta', 'zeta']); + expect(input.map((provider) => provider.name)).toEqual(['zeta', 'alpha-value']); + expect(required).toEqual(['zeta', 'alphaValue']); + expect(Object.isFrozen(accepted)).toBe(true); + expect(accepted.ok && Object.isFrozen(accepted.providers)).toBe(true); + expect(Object.isFrozen(rejected)).toBe(true); + expect(!rejected.ok && Object.isFrozen(rejected.problems)).toBe(true); + expect(!rejected.ok && rejected.problems.every((problem) => Object.isFrozen(problem))).toBe(true); + // Selection never freezes the caller's provider records: the graph froze + // its own; a harness fixture stays the caller's to mutate. + expect(Object.isFrozen(input)).toBe(false); + }); +}); + +describe('validateRequiredProviderKeys', () => { + it('returns no problems for a distinct subset of the known keys', () => { + expect(validateRequiredProviderKeys(Object.freeze(['zeta', 'alphaValue']), ['alphaValue', 'zeta'])).toEqual([]); + expect(validateRequiredProviderKeys(Object.freeze([]), [])).toEqual([]); + }); + + it('accepts any iterable of known keys and lists them sorted and unique in an unknown-key problem', () => { + const fromSet = validateRequiredProviderKeys(['nope'], new Set(['zeta', 'alphaValue'])); + const fromArray = validateRequiredProviderKeys(['nope'], ['zeta', 'alphaValue', 'zeta']); + expect(fromSet).toEqual([{ key: 'nope', kind: 'unknown-provider-key', known: ['alphaValue', 'zeta'] }]); + expect(fromArray).toEqual(fromSet); + expect(Object.isFrozen(fromSet)).toBe(true); + expect(Object.isFrozen(fromSet[0])).toBe(true); + expect(Object.isFrozen((fromSet[0] as { known: readonly string[] }).known)).toBe(true); + }); + + it('flags the reserved processLifetime key even when a caller lists it as known', () => { + // The graph refuses a provider module deriving this key (AB4942); the + // request scope seeds it itself, so a declaration never selects it. + expect(validateRequiredProviderKeys(['processLifetime'], ['processLifetime', 'zeta'])).toEqual([ + { key: 'processLifetime', kind: 'reserved-provider-key' }, + ]); + }); +}); + +describe('requiredProviderKeyProblemMessage', () => { + it('names the key and the defect for every problem kind', () => { + const problems: readonly RequiredProviderKeyProblem[] = [ + { key: 'zeta', kind: 'duplicate-provider-key' }, + { key: 'processLifetime', kind: 'reserved-provider-key' }, + { key: 'nope', kind: 'unknown-provider-key', known: ['alphaValue', 'zeta'] }, + { key: 'nope', kind: 'unknown-provider-key', known: [] }, + ]; + expect(problems.map(requiredProviderKeyProblemMessage)).toEqual([ + 'Required provider key "zeta" is declared more than once.', + 'Required provider key "processLifetime" is the framework-owned process identity every request mounts; do not declare it.', + 'Required provider key "nope" matches no conventional provider; known keys: alphaValue, zeta.', + 'Required provider key "nope" matches no conventional provider; the project declares none.', + ]); + }); +}); + +describe('executeProviders over a selection', () => { + const lifetime = { hits: 1, instanceId: 'instance-1', pid: 42 }; + const request = { + host: { reason: 'not-provided', state: 'unavailable' }, + lineage: { reason: 'not-provided', state: 'unavailable' }, + plugin: { reason: 'not-provided', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + signal: new AbortController().signal, + workspace: { reason: 'not-provided', state: 'unavailable' }, + } as const; + + const executable = ( + selection: RequiredProviderSelection, + factories: Record unknown>, + ): readonly ExecutableProvider[] => { + if (!selection.ok) throw new Error('expected an accepted selection'); + return selection.providers.map((provider) => { + const key = providerKeyFromName(provider.name); + return { key, module: { default: factories[key] }, source: provider.provenance.relativePath }; + }); + }; + + it('always mounts processLifetime, alone when the declaration selects nothing', async () => { + const values = await executeProviders({ + invocation: { kind: 'event' }, + processLifetime: lifetime, + providers: executable(selectRequiredProviders(providers, Object.freeze([])), {}), + request, + }); + expect(values).toEqual({ processLifetime: { hits: 1, instanceId: 'instance-1', pid: 42 } }); + }); + + it('runs only the selected factories, in the existing deterministic order, after processLifetime', async () => { + const calls: string[] = []; + const factories = { + alphaValue: () => { calls.push('alphaValue'); return 'a'; }, + daemonProbe: () => { calls.push('daemonProbe'); throw new Error('must not be selected'); }, + zeta: () => { calls.push('zeta'); return 'z'; }, + }; + const values = await executeProviders({ + invocation: { kind: 'event' }, + processLifetime: lifetime, + providers: executable(selectRequiredProviders(providers, Object.freeze(['zeta', 'alphaValue'])), factories), + request, + }); + expect(Object.keys(values)).toEqual(['processLifetime', 'alphaValue', 'zeta']); + expect(values).toEqual({ alphaValue: 'a', processLifetime: { hits: 1, instanceId: 'instance-1', pid: 42 }, zeta: 'z' }); + expect(calls).toEqual(['alphaValue', 'zeta']); + }); +}); + +describe('test-manifest provider selection', () => { + const descriptors = providers.map((provider): TestableProviderDescriptor => ({ + id: provider.id, + key: providerKeyFromName(provider.name), + name: provider.name, + relativePath: provider.provenance.relativePath, + source: provider.source, + })); + const manifest = { + providers: descriptors, + routes: { + 'event:tool/before': { + config: { providers: ['alphaValue'] }, + id: 'event:tool/before', + kind: 'event-route', + relativePath: 'src/events/tool/before.ts', + source: '/project/src/events/tool/before.ts', + }, + 'tool:server/example': { + config: { providers: [] }, + id: 'tool:server/example', + kind: 'tool', + relativePath: 'src/mcp/server/tools/example.ts', + source: '/project/src/mcp/server/tools/example.ts', + }, + }, + } as unknown as AgentBundleTestManifest; + + it('selects only an event route declaration and preserves compatibility elsewhere', () => { + expect(selectManifestProviderDescriptors(manifest, 'event:tool/before').map(({ key }) => key)) + .toEqual(['alphaValue']); + expect(selectManifestProviderDescriptors(manifest, 'tool:server/example').map(({ key }) => key)) + .toEqual(['alphaValue', 'daemonProbe', 'zeta']); + expect(selectManifestProviderDescriptors(manifest, undefined).map(({ key }) => key)) + .toEqual(['alphaValue', 'daemonProbe', 'zeta']); + }); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 123f21a73..015277d80 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -2235,6 +2235,129 @@ it('discovers the canonical event families and validates their component contrac expect(graph.diagnostics[1]?.sourcePath).toBe(join(root, 'src/events/tool/before.tsx')); }); +it('attaches a statically followable event preflight re-export to the event route node', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/events/tool/before.preflight.ts': 'export default ({ canonical }) => canonical.event === "tool/before" ? "execute" : { outcome: "continue" };\n', + 'src/events/tool/before.tsx': [ + "export { default as preflight } from './before.preflight.js';", + 'export default async function BeforeTool() { return undefined; }', + '', + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics).toEqual([]); + expect(graph.events).toHaveLength(1); + expect(graph.events[0]).toMatchObject({ + id: 'event:tool/before', + preflight: { + provenance: { kind: 'conventional', relativePath: 'src/events/tool/before.preflight.ts' }, + source: join(root, 'src/events/tool/before.preflight.ts'), + }, + }); + expect(Object.isFrozen(graph.events[0]!.preflight)).toBe(true); + + const otherRoot = await createRoot(); + await writeTree(otherRoot, { + 'src/events/tool/before.preflight.ts': 'export default () => "execute";\n', + 'src/events/tool/before.tsx': [ + "export { default as preflight } from './before.preflight.js';", + 'export default async function BeforeTool() { return undefined; }', + '', + ].join('\n'), + }); + expect((await compileRouteGraph(otherRoot, fixtureConfig())).digest).toBe(graph.digest); +}); + +it('rejects a conventional event route reused as another route preflight', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/events/session/start.ts': 'export default function SessionStart() { return { outcome: "continue" }; }\n', + 'src/events/tool/before.ts': [ + "export { default as preflight } from '../session/start.js';", + 'export default async function BeforeTool() { return undefined; }', + '', + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.events.map((route) => route.id)).toEqual(['event:session/start', 'event:tool/before']); + expect(graph.events.find((route) => route.id === 'event:tool/before')?.preflight).toBeUndefined(); + expect(graph.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB4840', sourcePath: join(root, 'src/events/tool/before.ts') }), + ])); +}); + +it('rejects event preflights that are inline, non-relative, unresolvable, cyclic, or non-functions', async () => { + const root = await createRoot(); + const eventRoute = (preflight: string): string => [ + preflight, + 'export default async function EventRoute() { return undefined; }', + '', + ].join('\n'); + await writeTree(root, { + 'src/events/agent/start.ts': eventRoute('export const preflight = () => "execute";'), + 'src/events/agent/stop.ts': eventRoute("export { default as preflight } from '@fixture/preflight';"), + 'src/events/compact/after.ts': eventRoute("export { default as preflight } from './missing.js';"), + 'src/events/compact/before.preflight.ts': "export { default } from './_before.preflight-cycle.js';\n", + 'src/events/compact/_before.preflight-cycle.ts': "export { default } from './before.preflight.js';\n", + 'src/events/compact/before.ts': eventRoute("export { default as preflight } from './before.preflight.js';"), + 'src/events/session/end.preflight.ts': 'export default { outcome: "continue" };\n', + 'src/events/session/end.ts': eventRoute("export { default as preflight } from './end.preflight.js';"), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.events.every((route) => route.preflight === undefined)).toBe(true); + expect(graph.diagnostics.map(({ code, sourcePath }) => ({ + code, + source: sourcePath?.slice(root.length + 1).replaceAll('\\', '/'), + }))).toEqual([ + { code: 'AB4840', source: 'src/events/agent/start.ts' }, + { code: 'AB4840', source: 'src/events/agent/stop.ts' }, + { code: 'AB4840', source: 'src/events/compact/after.ts' }, + { code: 'AB4840', source: 'src/events/compact/before.ts' }, + { code: 'AB4840', source: 'src/events/session/end.ts' }, + ]); +}); + +it('validates event route provider declarations against conventional provider keys', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/events/tool/before.ts': [ + "export const config = { providers: ['projectAuth'] };", + 'export default async function BeforeTool() { return undefined; }', + '', + ].join('\n'), + 'src/events/tool/after.ts': [ + "export const config = { providers: ['missing', 'projectAuth', 'projectAuth', 'processLifetime'] };", + 'export default async function AfterTool() { return undefined; }', + '', + ].join('\n'), + 'src/events/session/start.ts': [ + "export const config = { providers: 'projectAuth' };", + 'export default async function SessionStart() { return undefined; }', + '', + ].join('\n'), + 'src/providers/project-auth.ts': 'export default () => ({ authenticated: true });\n', + 'src/providers/zeta.ts': 'export default () => "zeta";\n', + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.events.find((route) => route.id === 'event:tool/before')?.config).toMatchObject({ + providers: ['projectAuth'], + }); + expect(graph.diagnostics.filter(({ code }) => code === 'AB4841').map(({ sourcePath }) => + sourcePath?.slice(root.length + 1).replaceAll('\\', '/'))).toEqual([ + 'src/events/session/start.ts', + 'src/events/tool/after.ts', + ]); +}); + it('fails unavailable event routes before packaging while admitting supported targets', async () => { const eventSource = 'export default async function WorkspaceOpen() { return undefined; }\n'; const configSource = [ diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index fc964d463..7123ef01d 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -11,6 +11,29 @@ import { } from '../../src/events/project.ts'; import { renderRoute } from '../../src/test/render.ts'; +it('preserves a canonical observation across deferred projection', () => { + const native = { hook_event_name: 'PreToolUse', tool_name: 'Write' }; + const first = createCanonicalEventProps( + 'tool/before', + native, + 'claude', + 'PreToolUse', + '2.1.250', + new AbortController().signal, + ); + const deferred = createCanonicalEventProps( + 'tool/before', + native, + 'claude', + 'PreToolUse', + '2.1.250', + new AbortController().signal, + { observedAt: first.canonical.observedAt, sequence: first.canonical.sequence }, + ); + + expect(deferred.canonical).toEqual(first.canonical); +}); + it('renders standalone event projection fixtures through real Flight', async () => { const NestedContext = async () => createElement(Agent.Context, null, 'standalone'); const Route = async () => createElement( diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 73d7a3733..0adbdbe83 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -17,8 +17,14 @@ import { TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { normalizeProject, type NormalizationTargetRegistry } from '../src/config/index.ts'; import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; +import type { CompiledEventPreflight } from '../src/routes/types.ts'; import { build } from './support/build.ts'; +const eventPreflight: CompiledEventPreflight = Object.freeze({ + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/before.preflight.ts' }), + source: '/project/src/events/tool/before.preflight.ts', +}); + const metadata = Object.freeze({ adapterRevision: 'test', observedVersion: 'test', @@ -364,6 +370,102 @@ it('plans a thin epoch-bound event-route client and keeps standalone execution e expect(degradedSource).toContain('await resolveStandaloneLineage(target, native)'); expect(degradedSource).not.toContain('import * as routeModule'); expect(degradedSource).not.toContain('renderStandaloneEventRoute'); + expect(sharedSource).not.toContain('executeEventPreflight'); + expect(degradedSource).not.toContain('executeEventPreflight'); +}); + +const firstIndex = (source: string, snippet: string): number => { + const index = source.indexOf(snippet); + expect(index).toBeGreaterThanOrEqual(0); + return index; +}; + +const staticImportSpecifiers = (source: string): readonly string[] => Object.freeze([ + ...source.matchAll(/\bimport\s+["']([^"']+)["']/gu), + ...source.matchAll(/\bfrom\s+["']([^"']+)["']/gu), +].map((match) => match[1]!)); + +it('runs event-route preflight in the per-host wrapper before shared IPC', () => { + const hook: NormalizedHook = { + ...planningHook('beforeTool', []), + eventRoute: { event: 'tool/before', fallback: 'none', preflight: eventPreflight, runtime: 'shared' }, + timeoutMs: 1_250, + }; + const contract: TargetHookContract = { + hostContractRevision: 'synthetic-1', + commandRoot: '${SYNTHETIC_PLUGIN_ROOT}', + ...playgroundCodec, + eventNames: {}, + eventRouteNames: { 'tool/before': 'SyntheticPreToolUse' }, + manifestPath: 'native-events/registration.json', + matchers: {}, + wrapperPath: (candidate) => `hooks/${candidate.name}.synthetic.mjs`, + wrapperSource: () => 'config-hook-only\n', + }; + const plan = planHooks(planningModel([hook]), 'synthetic', contract); + const entry = plan.hookEntries[0]!; + const source = entry.virtualSource; + + expect(entry.relativePath).toBe('hooks/beforeTool.synthetic.mjs'); + expect(entry.target).toBe('synthetic'); + expect(source).toContain(`from ${JSON.stringify(eventPreflight.source)}`); + expect(source).toContain('validateNativeEventEnvelope'); + expect(source).toContain('createCanonicalEventProps'); + expect(source).toContain('executeEventPreflight'); + expect(source).toContain('projectEventPreflightResult'); + expect(entry.executeVirtualSource).toContain('requestEventRuntime'); + expect(source).toContain('const timeoutMs = 1250;'); + expect(source).toContain('AbortSignal.timeout(timeoutMs)'); + expect(source).toContain('process.once(terminationSignal, terminate)'); + expect(source).toContain('process.off(terminationSignal, terminate)'); + expect(source).toContain('observedAt: props.canonical.observedAt, sequence: props.canonical.sequence'); + expect(entry.executeVirtualSource).toContain('const observation = { observedAt, sequence };'); + expect(entry.executeVirtualSource).toContain('observedAt: observation?.observedAt, sequence: observation?.sequence'); + expect(source).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + expect(source).not.toContain('createAgentRenderDispatcher'); + expect(source).not.toContain('import * as routeModule'); + expect(source).not.toContain("from '@agent-bundle/runtime'"); + expect(source).not.toContain("from '@agent-bundle/runtime/flight"); + expect(source).not.toContain('import * as provider'); + expect(staticImportSpecifiers(source).filter((specifier) => + specifier === 'react' || specifier.startsWith('react/') || specifier.endsWith('.tsx'))).toEqual([]); + + 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(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', () => { + const hook: NormalizedHook = { + ...planningHook('beforeTool', []), + eventRoute: { event: 'tool/before', fallback: 'none', preflight: eventPreflight, runtime: 'standalone' }, + }; + const contract: TargetHookContract = { + hostContractRevision: 'synthetic-1', + commandRoot: '${SYNTHETIC_PLUGIN_ROOT}', + ...playgroundCodec, + eventNames: {}, + eventRouteNames: { 'tool/before': 'SyntheticPreToolUse' }, + manifestPath: 'native-events/registration.json', + matchers: {}, + wrapperPath: (candidate) => `hooks/${candidate.name}.synthetic.mjs`, + wrapperSource: () => 'config-hook-only\n', + }; + const entry = planHooks(planningModel([hook]), 'synthetic', contract).hookEntries[0]!; + const source = entry.virtualSource; + + expect(source).toContain('executeEventPreflight'); + expect(source).toContain('projectEventPreflightResult'); + expect(source).toContain('new URL(/* webpackIgnore: true */ "./beforeTool.synthetic.execute.mjs", import.meta.url)'); + 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 runBody = source.slice(firstIndex(source, 'const run = async () => {')); + expect(firstIndex(runBody, 'executeEventPreflight')).toBeLessThan(runBody.search(/['"]execute['"]/u)); + expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); }); it('continues planning valid hooks after a prior hook mapping error', () => { diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 1738f6547..fe2ebaeec 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -66,6 +66,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/mcp.test.ts', 'packages/agent-bundle/tests/package-build.test.ts', 'packages/agent-bundle/tests/path-token-resolver.test.ts', + 'packages/agent-bundle/tests/preflight-artifact-graph.test.ts', 'packages/agent-bundle/tests/prebuilt-payload.test.ts', 'packages/agent-bundle/tests/prepack.test.ts', 'packages/agent-bundle/tests/provider-typegen.test.ts', diff --git a/scripts/measure-preflight-cold-start.mjs b/scripts/measure-preflight-cold-start.mjs new file mode 100644 index 000000000..a7bf70ead --- /dev/null +++ b/scripts/measure-preflight-cold-start.mjs @@ -0,0 +1,333 @@ +#!/usr/bin/env node + +/** + * Compares process startup with the cheap event preflight path and the full + * standalone rendered-event path. The fixture intentionally uses the public + * CLI and the composite-root hook layout emitted after #578. + */ + +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; + +const workspaceRoot = fileURLToPath(new URL('..', import.meta.url)); +const cli = join(workspaceRoot, 'packages', 'agent-bundle', 'bin', 'agent-bundle.js'); +const defaultRuns = 7; + +const median = (values) => { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +}; + +const roundMs = (value) => Math.round(value * 100) / 100; + +const parseRuns = () => { + let value = process.env.AGENT_BUNDLE_BENCH_RUNS; + for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === '--runs') { + value = process.argv[index + 1]; + index += 1; + continue; + } + if (argument.startsWith('--runs=')) { + value = argument.slice('--runs='.length); + continue; + } + throw new Error(`Unknown argument ${JSON.stringify(argument)}. Use --runs .`); + } + if (value === undefined) return defaultRuns; + if (!/^[1-9]\d*$/u.test(value)) { + throw new Error(`Run count must be a positive integer, received ${JSON.stringify(value)}.`); + } + return Number(value); +}; + +const run = (command, args, options = {}) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], + }); + let stderr = ''; + let stdout = ''; + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stderr, stdout })); + if (options.input !== undefined) child.stdin?.end(options.input); + }); + +const assertSuccessful = (label, result) => { + if (result.code !== 0 || result.signal !== null) { + throw new Error( + `${label} failed (exit ${String(result.code)}, signal ${String(result.signal)}):\n${result.stderr || result.stdout}`, + ); + } +}; + +const findGnuTime = async () => { + for (const candidate of ['/usr/bin/time', '/bin/time']) { + try { + await access(candidate, constants.X_OK); + } catch { + continue; + } + const version = await run(candidate, ['--version']); + if (version.code === 0 && /GNU time/iu.test(`${version.stdout}\n${version.stderr}`)) return candidate; + } + return undefined; +}; + +const measureOnce = async ({ args, command, input, label, time, timeFile }) => { + const measuredArgs = time === undefined + ? args + : [`--format=%M`, `--output=${timeFile}`, '--', command, ...args]; + const measuredCommand = time ?? command; + const started = performance.now(); + const result = await run(measuredCommand, measuredArgs, { input }); + const wallMs = roundMs(performance.now() - started); + assertSuccessful(label, result); + + let maxRssKiB = null; + if (time !== undefined) { + const rss = (await readFile(timeFile, 'utf8')).trim(); + if (!/^\d+$/u.test(rss)) { + throw new Error(`GNU time returned an invalid max RSS for ${label}: ${JSON.stringify(rss)}.`); + } + maxRssKiB = Number(rss); + } + return { maxRssKiB, result, wallMs }; +}; + +const cursorBeforeTool = (command) => JSON.stringify({ + conversation_id: 'conversation-cold-start', + cwd: '/workspace', + hook_event_name: 'preToolUse', + session_id: 'session-cold-start', + tool_input: { command }, + tool_name: 'Shell', + tool_use_id: 'tool-cold-start', +}); + +const writeFixture = async (root) => { + await mkdir(join(root, 'src', 'events', 'tool'), { recursive: true }); + await Promise.all([ + symlink(join(workspaceRoot, 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'), + writeFile(join(root, 'package.json'), JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + react: '19.2.8', + }, + name: 'preflight-cold-start-fixture', + type: 'module', + version: '0.0.0', + })), + writeFile( + join(root, 'agent-bundle.config.ts'), + [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'preflight-cold-start-fixture', version: '0.0.0' }, targets: ['cursor'] });", + '', + ].join('\n'), + ), + writeFile( + join(root, 'src', 'events', 'tool', 'before.preflight.ts'), + [ + 'export default ({ canonical }) => {', + ' const input = canonical.payload.toolInput?.value;', + ' const command = input !== null && typeof input === "object" && !Array.isArray(input)', + ' ? input.command', + ' : undefined;', + ' return command === "execute-rendered-route" ? "execute" : { outcome: "continue" };', + '};', + '', + ].join('\n'), + ), + writeFile( + join(root, 'src', 'events', 'tool', 'before.tsx'), + [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export { default as preflight } from './before.preflight.js';", + "export const config = { providers: [], runtime: 'standalone', targets: ['cursor'] };", + 'export default async function BeforeTool() {', + " return createElement(Agent.Result, { value: { outcome: 'allow' } });", + '}', + '', + ].join('\n'), + ), + ]); +}; + +const validatePlainNode = (result) => { + if (result.stdout !== '' || result.stderr !== '') { + throw new Error(`Plain Node produced output: ${JSON.stringify({ stderr: result.stderr, stdout: result.stdout })}.`); + } +}; + +const validatePreflight = (result) => { + if (result.stdout !== '' || result.stderr !== '') { + throw new Error( + `No-op event preflight must pass through without output; received ${JSON.stringify({ stderr: result.stderr, stdout: result.stdout })}. The generated hook may still be loading and executing the rendered route.`, + ); + } +}; + +const validateRenderedRoute = (result) => { + if (result.stderr !== '') { + throw new Error(`Rendered event route wrote stderr: ${JSON.stringify(result.stderr)}.`); + } + let output; + try { + output = JSON.parse(result.stdout); + } catch { + throw new Error(`Rendered event route returned invalid JSON: ${JSON.stringify(result.stdout)}.`); + } + if ( + output === null + || typeof output !== 'object' + || Array.isArray(output) + || output.permission !== 'allow' + || Object.keys(output).length !== 1 + ) { + throw new Error(`Rendered event route returned an unexpected result: ${JSON.stringify(output)}.`); + } +}; + +const summarize = (measurements) => { + const wallMs = measurements.map((sample) => sample.wallMs); + const maxRssKiB = measurements.map((sample) => sample.maxRssKiB); + const availableRss = maxRssKiB.every((value) => value !== null) + ? maxRssKiB + : null; + return { + samples: { + maxRssKiB, + wallMs, + }, + medians: { + maxRssKiB: availableRss === null ? null : median(availableRss), + wallMs: roundMs(median(wallMs)), + }, + }; +}; + +const main = async () => { + const runs = parseRuns(); + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-preflight-cold-start-')); + const output = join(root, 'artifact'); + const time = await findGnuTime(); + try { + await writeFixture(root); + const built = await run(process.execPath, [cli, 'build', '--root', root, '--output', output], { + cwd: workspaceRoot, + }); + assertSuccessful('agent-bundle build', built); + + // #578 emits all generated surfaces at the composite root. A one-target + // event still keeps its host suffix because hook codecs are host-specific. + const outputFiles = await readdir(output, { recursive: true }); + const hookMatches = outputFiles + .filter((path) => path.endsWith('/hooks/event-route-tool-before.cursor.mjs') + || path === 'hooks/event-route-tool-before.cursor.mjs' + || path.endsWith('/hooks/event-route-tool-before.mjs') + || path === 'hooks/event-route-tool-before.mjs'); + if (hookMatches.length !== 1) { + throw new Error( + `Build emitted ${String(hookMatches.length)} cursor tool/before wrappers; expected exactly one. Hooks: ${JSON.stringify(outputFiles.filter((path) => path.includes('hooks/')))}.`, + ); + } + const hook = join(output, hookMatches[0]); + + const measurements = { + plainNode: [], + preflightContinue: [], + renderedRoute: [], + }; + for (let index = 0; index < runs; index += 1) { + const plain = await measureOnce({ + args: ['-e', ''], + command: process.execPath, + label: 'plain Node', + time, + timeFile: join(root, `time-plain-${String(index)}.txt`), + }); + validatePlainNode(plain.result); + measurements.plainNode.push(plain); + + const preflight = await measureOnce({ + args: [hook], + command: process.execPath, + input: cursorBeforeTool('no-op'), + label: 'event preflight continue', + time, + timeFile: join(root, `time-preflight-${String(index)}.txt`), + }); + validatePreflight(preflight.result); + measurements.preflightContinue.push(preflight); + + const rendered = await measureOnce({ + args: [hook], + command: process.execPath, + input: cursorBeforeTool('execute-rendered-route'), + label: 'rendered event route', + time, + timeFile: join(root, `time-rendered-${String(index)}.txt`), + }); + validateRenderedRoute(rendered.result); + measurements.renderedRoute.push(rendered); + } + + const report = { + kind: 'event-preflight-cold-start', + runs, + node: process.version, + platform: process.platform, + rss: time === undefined + ? { + available: false, + reason: 'GNU time was not found; max RSS samples and medians are null.', + source: null, + unit: 'KiB', + } + : { + available: true, + reason: null, + source: 'GNU time %M', + unit: 'KiB', + }, + benchmarks: { + plainNode: { + path: "node -e ''", + ...summarize(measurements.plainNode), + }, + preflightContinue: { + path: 'hooks/event-route-tool-before.cursor.mjs (preflight continue)', + ...summarize(measurements.preflightContinue), + }, + renderedRoute: { + path: 'hooks/event-route-tool-before.cursor.mjs (execute rendered route)', + ...summarize(measurements.renderedRoute), + }, + }, + }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +await main(); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index c5338b069..a8db18058 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -183,8 +183,12 @@ index. ## Event routes -Config-declared hooks are the compact form. The second authoring shape is an **event route**: a -file under `src/events/` whose path is the canonical event family it handles +Config-declared hooks are the compact, native escape hatch: the handler runs in-process inside +the generated wrapper, and its second argument (`HookHandlerContext`) hands it the validated host +envelope verbatim as `nativeInput`, beside the invoking `target` and `nativeEvent`. Use one when +the handler genuinely needs the raw host envelope or host-specific behavior. For ordinary semantic +events — including high-frequency events that should return before rendering — prefer an **event +route**: a file under `src/events/` whose path is the canonical event family it handles (`src/events/tool/before.tsx`, `src/events/stop.tsx`). It is one async default Server Component, like an MCP tool route, plus a statically extracted `config` export: @@ -222,6 +226,149 @@ hashed from the event, target, and native payload, `observedAt`, a `sequence`, t `payload`. `native` is a frozen snapshot of the validated host envelope. Nothing in `canonical` is fabricated: a host that does not report a field leaves it absent. +### Preflight gates + +A named `preflight` export gives an event route a cheap gate that runs on the canonical event +after envelope decoding, host validation, and canonicalization, and before any of the rendered +route runtime — React, the RSC renderer, layouts, providers, state, notices — is loaded. Exactly +one authoring form is accepted: a single relative default re-export in the route module, naming a +module whose default export is the gate function. + +```tsx +// src/events/tool/before.tsx +import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle'; + +export const config = { providers: ['projectPolicy'] } satisfies AgentEventRouteConfig; +export { default as preflight } from './before.preflight.js'; + +export default async function BeforeTool( + { canonical, native, signal }: AgentEventRouteProps<'tool/before'>, +) { + // Runs only after preflight returned 'execute'. +} +``` + +```ts +// src/events/tool/before.preflight.ts +import type { EventPreflight } from 'agent-bundle'; + +export default (({ canonical }) => + canonical.payload.toolName?.value === 'Write' + ? 'execute' + : { outcome: 'continue' }) satisfies EventPreflight<'tool/before'>; +``` + +Keep the preflight module plain Node/application code: import nothing from React, the RSC +helpers, the rendered route module, or `src/providers/` (type-only imports are erased and are +fine). The relative re-export is what makes the gate cheap: the compiler records the target module +on the route's own graph node — `preflight` on the compiled route, part of the graph digest — as +an entry it can bundle on its own, and keeps it out of route discovery, so `before.preflight.ts` +beside `before.tsx` is application code the route names, never a second event route. The `.js` +specifier resolves to the `.ts`/`.tsx` source, as route imports do. A gate declared inline in the +route module (`export const preflight = …`, `export function preflight`) is rejected: evaluating +that module evaluates its rendering and provider imports, the very cost the gate exists to avoid. + +Every rejected form is `AB4840`, reported once per route on the route module by `inspect`, +`validate`, `build`, and `dev`: `preflight` declared inline or exported more than once; +re-exported under a binding other than `default` (`export { gate as preflight } from './gate.js'`); +re-exported from a bare package specifier; a relative target that is missing, unreadable, or part +of a re-export cycle; a default export that cannot be followed through an acyclic chain of +relative default re-exports; or a default export that is not a function the static scan can see — +an object literal, a string, a class, or an identifier the module imports rather than declares +(`satisfies`, `as`, and parentheses are looked through, so the typed form above still reads as a +function). The route compiles without a gate beside the error, and because the diagnostic is an +error the build fails rather than silently taking the expensive rendered path. + +Preflight may be synchronous or asynchronous and has exactly three results +(`EventPreflightResult`): + +| Result | Meaning | +| --- | --- | +| `'execute'` | Load the rendered route entry, resolve its declared providers, and render it. | +| `{ outcome: 'continue' }` | Pass through: nothing is projected and no host decision is written, so the host's normal flow applies — on `tool/before`, its own permission prompt. | +| `{ outcome: 'deny', reason }` | Deny with a non-empty reason, projected per host by the same rules as a rendered route's `deny` (`hookSpecificOutput.permissionDecision: 'deny'` with `permissionDecisionReason` on a Claude `tool/before`, for example). | + +`execute` is the only result that loads the rendered route runtime. Validation +(`validateEventPreflightResult`, exported from `agent-bundle`) fails closed on everything else: +`undefined`, the bare string `'continue'`, an outcome other than `continue` or `deny` — a gate has +no `allow`, `ask`, or `updatedInput`; return `'execute'` and let the rendered route make those +decisions — any extra field (`reason` beside `continue`, `updatedInput` beside `deny`), an empty +or whitespace-only reason, and `deny` on a family where no host projects a blocking denial. +`eventFamilyAllowsPreflightDeny` (also exported) is that family list: a gate may deny on +`tool/before`, `stop`, `agent/start`, `agent/stop`, `agent/idle`, `prompt/submit`, +`compact/before`, `permission/request`, `model-switch/before`, `config/change`, and +`task/create`; `session/start`, `session/end`, `tool/after`, `tool/failure`, `compact/after`, +`permission/denied`, `stop/failure`, `file/change`, `task/complete`, `model-switch/after`, and +`workspace/open` cannot deny from preflight. The per-host projection rules described under +[The canonical payload](#the-canonical-payload) still apply to a gate's denial. + +The gate receives one frozen `EventPreflightContext` — `{ canonical, host, signal, terminal }` +— deliberately smaller than `AgentEventRouteProps`: + +| Field | What it is | +| --- | --- | +| `canonical` | The exact `AgentEventCanonicalIdentity` the rendered route would receive: `event`, `idempotencyKey`, `observedAt`, `sequence`, `provenance`, and the family's canonical `payload` (see [The canonical payload](#the-canonical-payload)). Type the gate to its family — `EventPreflight<'tool/before'>` — and `payload` narrows the same way it does for the route. | +| `host` | `{ name, nativeEvent }`, frozen: the target the wrapper was compiled for (`'claude'`, `'codex'`, `'cursor'`) and the native event name it validated (`'PreToolUse'`, `'preToolUse'`), both known from the compiled hook before any envelope is read. | +| `signal` | The request `AbortSignal` owned by the hook deadline. The kernel refuses to start a gate on an already-aborted signal, abandons a pending asynchronous gate the moment the signal aborts — rejecting with the signal's reason rather than waiting for the gate to notice — and checks the signal once more before validating the result, so a late answer is never projected. | +| `terminal` | The `AgentTerminal` described in [The terminal capability](./mcp.mdx#the-terminal-capability): `hostSurface`, `stdout`, `stderr`, `sharesTarget`. Information only, never a writer; the `hook` surface reports `none` on both streams. | + +It does **not** contain `native`, state, notices, lineage, rendered layouts, application +providers, React/RSC helpers, or the application request context. Envelope bounds and decoding, +native host validation, canonicalization, capability translation, deadlines and aborts, outcome +validation, and host projection remain framework-owned. A gate that must read an unmapped native +field has two options: return `'execute'` and read `native` in the rendered route, or move the +logic into a config-declared handler — the raw/native escape hatch, which receives the validated +envelope verbatim as `context.nativeInput` and runs in-process in the wrapper. `EventPreflight`, +`EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and +`eventFamilyAllowsPreflightDeny` are exported from `agent-bundle`, `agent-bundle/api`, and +`agent-bundle/routes`. + +### Execution trace events + +The exported `EventTraceEvent` discriminated union gives developer tooling one stable, +payload-free vocabulary for the event kernel: `preflight.start`, `preflight.outcome`, +`execute.start`, `providers.start` / `providers.finish`, `render.start` / `render.finish`, and +terminal `failure`. Generated preflight shells currently emit the preflight and execute +boundaries, including failures in either phase; provider and render boundaries are reserved for +the unified trace consumer to connect inside the deferred worker. +Each frozen event carries one `EventTraceExecution` identity, a monotonic timestamp and sequence, +and only phase-specific metadata; failures use a bounded, stack-free `EventTraceErrorSummary`. +`createEventTracer` is a no-op without an observer and isolates observer errors from execution. +Developer tooling installs the process-local sink with `installEventTraceObserver`; the returned +disposer restores the previous sink. The trace types and helpers are exported from +`agent-bundle` and `agent-bundle/api`. + +### Declaring route providers + +Provider laziness is declaration-driven, and only event routes declare it: +`AgentEventRouteConfig.providers` is an array of string literals inside the static config +grammar, naming the conventional providers the rendered route reads through +`(await agent()).providers.`. Preflight materializes no application providers whatever the +route declares. An executed route with a declaration loads and resolves only that subset — still +once per request, sequentially in the deterministic key-then-source order (never declaration +order), fail-closed, with the framework-owned `processLifetime` seeded first. `[]` mounts +`processLifetime` alone. A route with no declaration keeps the compatibility behavior and +resolves every conventional provider. + +Keys are the camel-cased stems of the `src/providers/.*` modules the route graph discovers +(`retry-policy.ts` is `retryPolicy`), the same keys `AgentBundleProviders` in the generated +`.agent-bundle/routes.d.ts` lists. `processLifetime` is not one of them — it is seeded whatever +the route declares — and must not be declared. The declaration is judged when the route graph +compiles, so `inspect`, `validate`, `build`, and `dev` all report `AB4841`, once per route with +every defect in one message: a `providers` that is not an array of string literals, a key listed +twice, the reserved `processLifetime`, or a key that matches no discovered provider — a +misspelling, a stem camel-cased differently, or a module discovery skips (an `_`- or `.`-prefixed +path segment, a `.d.ts` file, or a path the project's ignore rules exclude) — with the project's +provider keys listed for an unknown one. A declaration with any defect selects nothing, so the +build fails rather than resolving a provider set the author did not write. + +Declare the narrow subset for ubiquitous routes so unrelated daemon, state, or provider work +stays outside both the preflight executable and the deferred rendered route. This changes +neither provider value types nor the synchronous `(await agent()).providers.` contract. +`renderRoute` from `agent-bundle/test` honors the same declaration: a route-unit render of +`event:tool/before` mounts exactly the declared subset (every provider when the route declares +none), so a test exercises the provider set the route will see. + ### The canonical payload `canonical.payload` is the cross-host reading of the envelope for the route's family: the diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index ce2e64374..c266525db 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -167,9 +167,11 @@ export default defineConfig({ ## 事件路由 -配置声明的钩子是紧凑形式。第二种编写形态是**事件路由**:`src/events/` 下的一个文件,其路径就是它处理的 -规范事件族(`src/events/tool/before.tsx`、`src/events/stop.tsx`)。它像 MCP 工具路由一样,是一个异步 -默认导出的 Server Component,再加一个可静态提取的 `config` 导出: +配置声明的钩子是紧凑的原生逃生口。只有当处理器确实需要读取原始宿主信封或实现宿主特定行为时才使用它: +处理器的第二个参数会提供经过校验的 `nativeInput`,以及 `target` 和 `nativeEvent`。对于普通的语义事件—— +包括需要在渲染前尽早返回的高频事件——请优先使用**事件路由**:在 `src/events/` 下创建文件,并用路径表示 +它处理的规范事件族(`src/events/tool/before.tsx`、`src/events/stop.tsx`)。与 MCP 工具路由一样,它包含 +一个异步默认导出的 Server Component,以及一个可静态提取的 `config` 导出: ```tsx // src/events/tool/after.tsx @@ -203,6 +205,96 @@ export default async function AfterFileEdit( `observedAt`、一个 `sequence`、记录触发宿主与原生事件名的 `provenance`,以及该事件族的规范 `payload`。 `native` 是经过校验的宿主信封的冻结快照。`canonical` 中没有任何伪造:宿主未报告的字段保持缺失。 +### 预检(preflight) + +命名导出的 `preflight` 会在加载渲染式路由运行时之前,为同一条事件路由提供一道低开销门控。要真正避免 +提前加载路由运行时,应通过编译器可静态追踪的相对路径重新导出: + +```tsx +// src/events/tool/before.tsx +import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle'; + +export const config = { providers: ['projectPolicy'] } satisfies AgentEventRouteConfig; +export { default as preflight } from './before.preflight.js'; + +export default async function BeforeTool( + { canonical, native, signal }: AgentEventRouteProps<'tool/before'>, +) { + // 仅在 preflight 返回 'execute' 后运行。 +} +``` + +```ts +// src/events/tool/before.preflight.ts +import type { EventPreflight } from 'agent-bundle'; + +export default (({ canonical }) => + canonical.payload.toolName?.value === 'Write' + ? 'execute' + : { outcome: 'continue' }) satisfies EventPreflight<'tool/before'>; +``` + +`preflight` 模块应保持为普通的 Node/应用代码:不要导入 React、RSC 辅助函数、渲染式路由模块或应用 +provider。通过相对路径重新导出,编译器可以构建独立的 `preflight` 源码图,并把路由图推迟到门控放行后 +再加载。直接在渲染式路由模块中声明函数,只是逻辑上先执行;由于求值该模块也会求值其静态渲染代码并加载 +provider,实际开销并不会降低。通过裸包名导入、形成循环、导出非函数,或采用其他无法静态追踪的方式提供 +`preflight`,都会导致构建错误;编译器不会悄悄退回高开销的路由路径。 +这些无效形式由 `inspect`、`validate`、`build` 和 `dev` 以 `AB4840` 报告。 + +`preflight` 可以是同步或异步的,并且恰好只有三种结果: + +| 结果 | 含义 | +| --- | --- | +| `'execute'` | 加载渲染式路由入口,解析其声明的 provider,并渲染它。 | +| `{ outcome: 'continue' }` | 返回放行输出,不表达任何宿主决定。 | +| `{ outcome: 'deny', reason: string }` | 按该事件既有的规范结果规则投影一次拒绝。 | + +`execute` 是唯一会加载渲染式路由运行时的结果。返回 `undefined`、未知字段或未知结果,或给出空的拒绝 +原因,都会无法通过框架校验并终止执行。仅用于观察的事件不能拒绝。 + +预检收到的上下文是一个冻结对象,且恰好只有 `{ canonical, host, signal, terminal }` 四项,因此它刻意 +小于 `AgentEventRouteProps`。`canonical` 是渲染式路由将收到的同一份规范身份与载荷;`host` 是编译后的 +`{ name, nativeEvent }` 宿主身份;`signal` 由钩子的截止时间控制;`terminal` 是框架推导出的 +`AgentTerminal` 能力视图。由于 hook 的 stdout 是宿主信封而不是面向用户的终端,`terminal` 会表明 +`hostSurface: 'hook'`,且 stdout 和 stderr 的 `kind` 都是 `'none'`。上下文**不**包含 `native`、state、 +notices、lineage 存储、渲染式布局、应用 provider、React/RSC 辅助函数或应用请求上下文。信封大小限制与 +解码、宿主原生输入校验、规范化、能力转换、截止时间与中止、结果校验及宿主投影,仍由框架负责。如果门控 +必须检查尚未映射的原生字段,请改用配置声明的处理器,并从其第二个参数读取 `nativeInput`。 + +| 字段 | 含义 | +| --- | --- | +| `canonical` | 与渲染式路由相同的规范事件身份与载荷。 | +| `host` | 冻结的 `{ name, nativeEvent }` 编译宿主身份。 | +| `signal` | 由 hook 截止时间控制的请求 `AbortSignal`。 | +| `terminal` | 框架推导出的只读 `AgentTerminal` 能力视图。 | + +### 执行跟踪事件 + +导出的 `EventTraceEvent` 可辨识联合为开发工具提供稳定且不含载荷的事件内核词汇: +`preflight.start`、`preflight.outcome`、`execute.start`、`providers.start` / +`providers.finish`、`render.start` / `render.finish`,以及终止性的 `failure`。每个冻结事件 +都携带同一份 `EventTraceExecution` 身份、单调时间戳和序号,并且只包含该阶段的元数据。 +生成的 preflight shell 当前会发出 preflight 与 execute 边界及这两个阶段的失败;provider +和 render 边界保留给统一 trace 消费方在延迟 worker 内连接。失败使用有长度上限且不含堆栈的 +`EventTraceErrorSummary`。没有 observer 时 `createEventTracer` 是空操作;observer 抛错也不会改变执行。 +开发工具通过 `installEventTraceObserver` 安装进程级 sink;返回的 disposer 会恢复之前的 sink。 +相关类型与辅助函数从 `agent-bundle` 和 `agent-bundle/api` 导出。 + +### 声明路由的 provider + +provider 是否按需加载由声明决定。`preflight` 不会实例化任何应用 provider。事件路由执行后,运行时只会 +加载并解析它静态声明的 provider 子集。选中的 provider 在每次请求中仍只实例化一次,并按确定的键名/来源 +顺序依次执行;`processLifetime` 会先注入,任何失败都会终止该请求。键名重复或未知都会导致构建错误。 +无效的 `config.providers` 声明由 `AB4841` 报告。 + +声明方式是 `export const config = { providers: ['projectPolicy'] }`。provider 键由 `src/providers/` +下的文件名转换为 camelCase;空数组表示不选择任何应用 provider。 + +为了向后兼容,执行时未声明 `config.providers` 的路由会解析所有约定 provider。对于随处触发的路由,应 +明确声明所需的最小子集,避免无关的 daemon、state 或 provider 工作进入独立的 `preflight` 可执行文件或 +延后加载的渲染式路由。这不会改变 provider 的值类型,也不会改变同步的 +`(await agent()).providers.` 契约。 + ### 规范载荷 `canonical.payload` 是该路由事件族对信封的跨宿主读法:至少两个宿主以各自名字报告的那些字段——工具族上的