From b651d3430b28f4ab34983381006180dc7941ae64 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:40:27 +0000 Subject: [PATCH 01/16] feat(events): add preflight gate contract --- .changeset/595-event-preflight-gates.md | 5 + docs/diagnostics.md | 55 ++++- packages/agent-bundle/src/api.ts | 5 + packages/agent-bundle/src/events/preflight.ts | 151 +++++++++++++ packages/agent-bundle/src/events/project.ts | 8 + .../agent-bundle/src/events/projection.ts | 24 +++ packages/agent-bundle/src/index.ts | 5 + packages/agent-bundle/src/routes/contract.ts | 145 ++++++++++++- packages/agent-bundle/src/routes/graph.ts | 101 ++++++++- packages/agent-bundle/src/routes/index.ts | 5 + .../src/routes/provider-execution.ts | 118 ++++++++++ packages/agent-bundle/src/routes/public.ts | 14 ++ packages/agent-bundle/src/routes/types.ts | 9 + .../tests/event-preflight.test.ts | 184 ++++++++++++++++ .../tests/provider-execution.test.ts | 203 ++++++++++++++++++ .../agent-bundle/tests/route-graph.test.ts | 103 +++++++++ website/docs/en/guide/authoring/hooks.mdx | 72 ++++++- website/docs/zh/guide/authoring/hooks.mdx | 64 +++++- 18 files changed, 1255 insertions(+), 16 deletions(-) create mode 100644 .changeset/595-event-preflight-gates.md create mode 100644 packages/agent-bundle/src/events/preflight.ts create mode 100644 packages/agent-bundle/tests/event-preflight.test.ts create mode 100644 packages/agent-bundle/tests/provider-execution.test.ts diff --git a/.changeset/595-event-preflight-gates.md b/.changeset/595-event-preflight-gates.md new file mode 100644 index 000000000..785f739bd --- /dev/null +++ b/.changeset/595-event-preflight-gates.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Let an event route under `src/events/**` declare a `preflight` gate — `export { default as preflight } from './.js'`, a sync or async function that receives the frozen `{ canonical, 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. The compiler attaches the preflight module to the route's own graph node (excluded from route discovery, part of the graph digest) and reports the new `AB4838` from `inspect`, `validate`, `build`, and `dev` 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 the specifier. Let an executed event route 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 the new `AB4839` reports a malformed declaration, a duplicate key, the reserved `processLifetime`, or a key that matches no discovered `src/providers/*` module, listing the project's provider keys. `EventPreflight`, `EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and `eventFamilyAllowsPreflightDeny` are exported from `agent-bundle`, `agent-bundle/api`, and `agent-bundle/routes`. (#595) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9ba6cfeb2..d8eb5071e 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`), 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`), an event route's `preflight` gate export (`AB4838`), an event route's declared provider keys (`AB4839`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`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). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | @@ -685,7 +685,7 @@ framework-owned plugin twice by accident. | `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. | | `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4837`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4839`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -861,6 +861,55 @@ Anything outside that grammar — identifier references (including shared schema constants), unions, nested objects, transforms, coercions — raises `AB4814` naming the offending construct. +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 +request `signal` owned by the hook deadline, and the 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 `AB4838`, 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 `AB4839`, 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. | @@ -901,6 +950,8 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | | `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/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 | 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`, `export class 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 (an object literal, a string, a class, or an identifier the module imports rather than declares). 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 (the `.js` specifier resolves to the `.ts`/`.tsx` source, as route imports do), and make that module default-export one sync or async function — declared in that module, `export default ({ canonical, signal, terminal }) => …` — returning `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }`, with no import of React, RSC helpers, a provider, or the route module itself. Move the gate's logic out of the route module instead of exporting it inline. A gate that must read an unmapped `native` field belongs in a config-declared `hooks` handler, not in a preflight. | +| `AB4839` | 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 string literals; a key is declared more than once; a key is the reserved `processLifetime`, the framework-owned process identity every request mounts whatever the route declares; or a key matches no conventional provider the route graph discovered under `src/providers/` — a misspelling, a stem that camel-cases differently (`retry-policy.ts` is `retryPolicy`, never `retry-policy`), or a module discovery skips (an `_`- or `.`-prefixed path segment, a `.d.ts` file, or one the project's ignore rules exclude). The message names the route and every offending key — a repeated key from its second occurrence on, and for an unknown key the project's provider keys (or that it declares none) — so one build surfaces every defect. Judged when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per route with the route module as `sourcePath`; a declaration with any defect selects nothing, so no request resolves a provider set the author did not write. Declare each key exactly once, spelled as the camel-cased stem of its `src/providers/.*` module (the keys `AgentBundleProviders` in the generated `.agent-bundle/routes.d.ts` lists), drop `processLifetime` (it is seeded first regardless), remove keys of providers the route does not read, declare `[]` to mount `processLifetime` alone, or omit `config.providers` entirely to keep the compatibility default of resolving every conventional provider. To add a provider the route needs, create `src/providers/.ts` (`AB4940`–`AB4942` govern the module and its key). | | `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/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 1bf116788..ab1f86c06 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -47,6 +47,8 @@ export { agentEventPayloadFields, agentEventPayloadNativeKeys, canonicalAgentEvents, + eventFamilyAllowsPreflightDeny, + validateEventPreflightResult, } from './routes/public.ts'; export type { AgentEventCanonicalIdentity, @@ -69,6 +71,9 @@ export type { AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, + EventPreflight, + EventPreflightContext, + EventPreflightResult, PromptConfig, ResourceConfig, RouteSchema, diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts new file mode 100644 index 000000000..9af1f2aed --- /dev/null +++ b/packages/agent-bundle/src/events/preflight.ts @@ -0,0 +1,151 @@ +import type { CanonicalAgentEvent } from '../routes/events.ts'; +import type { AgentEventCanonicalIdentity } from '../routes/public.ts'; +import type { AgentTerminal } from '../terminal-capability.ts'; + +/** + * The gate result a conventional `export const 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, the hook-deadline signal, + * and already-translated terminal capability metadata. It does not include + * `native`, state, notices, lineage, providers, or React/RSC helpers. + */ +export interface EventPreflightContext { + readonly canonical: AgentEventCanonicalIdentity; + /** Target-specific host identity already known from the compiled hook. */ + readonly host: Readonly<{ readonly name: string; readonly nativeEvent: string }>; + readonly signal: AbortSignal; + readonly terminal: AgentTerminal; +} + +/** Sync or async gate export on an event route module. */ +export type EventPreflight = ( + context: EventPreflightContext, +) => EventPreflightResult | Promise; + +const preflightObjectOutcomes = ['continue', 'deny'] as const; +type PreflightObjectOutcome = (typeof preflightObjectOutcomes)[number]; + +const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome => + value === 'continue' || value === 'deny'; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +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 (!isPlainObject(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, +): Promise => { + context.signal.throwIfAborted(); + const frozenContext = Object.freeze({ + canonical: context.canonical, + host: Object.freeze({ ...context.host }), + signal: context.signal, + terminal: context.terminal, + }); + const value = await preflight(frozenContext); + context.signal.throwIfAborted(); + return validateEventPreflightResult(value, context.canonical.event); +}; diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index e07479f06..86a3afefa 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -2,6 +2,14 @@ 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'; diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index eb7e69042..e5e02bcee 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 @@ -915,3 +916,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/index.ts b/packages/agent-bundle/src/index.ts index bd692f5f6..23e2bd89d 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, diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 598fa65a2..99cc044b3 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' | 'AB4838' | 'AB4839' | '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( + 'AB4838', + `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( + 'AB4838', + `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( + 'AB4838', + `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( + 'AB4838', + `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( + 'AB4838', + `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 60544109e..8f7e3452b 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 } 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, @@ -109,6 +116,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( + 'AB4839', + `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( + 'AB4839', + `Event route ${route.provenance.relativePath} has invalid config.providers: ${problems + .map(requiredProviderKeyProblemMessage) + .join(' ')}`, + recovery, + route.source, + )]; +}; + const configValue = ( config: Readonly, key: keyof AgentBundleConfig | 'routes', @@ -504,12 +539,14 @@ const compiledRoute = ( module: DiscoveredRouteModule, config: Readonly>, inputSchema?: RouteInputSchema, + preflight?: CompiledEventPreflight, ): CompiledAgentRoute => ({ config, ...(module.event === undefined ? {} : { event: module.event }), id: module.id, ...(inputSchema === undefined ? {} : { inputSchema }), kind: module.kind, + ...(preflight === undefined ? {} : { preflight }), provenance: { kind: 'conventional', relativePath: module.relativePath }, ...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }), source: module.source, @@ -537,6 +574,8 @@ const readRouteModuleText = async (source: string): Promise interface ExtractedModuleMetadata { readonly extracted: ExtractedRouteConfig; readonly inputSchema?: RouteInputSchema; + readonly preflight?: CompiledEventPreflight; + readonly preflightDiagnostics: readonly Diagnostic[]; } const emptyExtractedRouteConfig: ExtractedRouteConfig = deepFreeze({ @@ -549,15 +588,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); + 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 ?? [], }; }; @@ -588,6 +642,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); + if (discovery.candidateSource !== undefined) preflightSupportSources.add(discovery.candidateSource); + } const modules: DiscoveredModule[] = []; const modulesById = new Map(); const providerModulesByKey = new Map(); @@ -649,6 +730,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 @@ -736,7 +818,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. @@ -779,11 +860,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) { @@ -811,7 +900,8 @@ export const compileRouteGraph = async ( ) : metadata.extracted; diagnostics.push(...resolved.diagnostics); - const route = compiledRoute(module, resolved.config, metadata.inputSchema); + diagnostics.push(...metadata.preflightDiagnostics); + const route = compiledRoute(module, resolved.config, metadata.inputSchema, metadata.preflight); if (route.kind === 'event-route' && moduleText !== undefined) { diagnostics.push(...validateEventRouteModuleContract( moduleText, @@ -830,6 +920,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 b4fbb7856..61e77f928 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -69,7 +69,9 @@ export { agentEventPayloadNativeKeys, appResourceUri, canonicalAgentEvents, + eventFamilyAllowsPreflightDeny, MAX_ROUTE_RENDER_ELAPSED_MS, + validateEventPreflightResult, } from './public.ts'; export type { AgentEventCanonicalIdentity, @@ -88,6 +90,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 77cc8f011..43d5c78bd 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'; /** @@ -97,6 +104,8 @@ export interface CompiledAgentRoute { /** Statically projected bounded JSON Schema subset; 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/tests/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts new file mode 100644 index 000000000..c25a25d1b --- /dev/null +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -0,0 +1,184 @@ +import { expect, it } from '@rstest/core'; + +import { + executeEventPreflight, + eventFamilyAllowsPreflightDeny, + validateEventPreflightResult, + type EventPreflight, + type EventPreflightContext, + type EventPreflightResult, +} 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'>; + await expect(executeEventPreflight(async () => { + controller.abort(new Error('deadline elapsed')); + return 'execute'; + }, context)).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/provider-execution.test.ts b/packages/agent-bundle/tests/provider-execution.test.ts new file mode 100644 index 000000000..4cbca1c85 --- /dev/null +++ b/packages/agent-bundle/tests/provider-execution.test.ts @@ -0,0 +1,203 @@ +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'; + +/** + * 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']); + }); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 6d19fcef3..6cd24fb19 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1956,6 +1956,109 @@ 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 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: 'AB4838', source: 'src/events/agent/start.ts' }, + { code: 'AB4838', source: 'src/events/agent/stop.ts' }, + { code: 'AB4838', source: 'src/events/compact/after.ts' }, + { code: 'AB4838', source: 'src/events/compact/before.ts' }, + { code: 'AB4838', 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 === 'AB4839').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/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 6132ab1b0..5d2b9946d 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -178,8 +178,10 @@ 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. 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: @@ -213,6 +215,72 @@ 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 the same event route a cheap gate before the rendered route +runtime loads. The physically cheap pattern is a statically followable relative re-export: + +```ts +// src/events/tool/before.tsx +export { default as preflight } from './before.preflight.js'; + +export default async function BeforeTool({ canonical, signal }) { + // The full rendered event route runs only after preflight returns "execute". +} +``` + +```ts +// src/events/tool/before.preflight.ts +export default ({ canonical }) => + mentionsCargo(commandFrom(canonical.payload)) + ? 'execute' + : { outcome: 'continue' }; +``` + +Keep the preflight module plain Node/application code: do not import React, RSC helpers, the +rendered route module, or application providers. A relative re-export lets the compiler build an +independent preflight source graph and defer the route graph. A locally declared function in the +rendered route module is logically early but not physically cheap, because evaluating that module +also evaluates its static rendering and provider imports. Bare-package, cyclic, non-function, and +otherwise non-followable preflight exports are build errors; the compiler does not silently use +the expensive route path. + +Preflight may be synchronous or asynchronous and has exactly three results: + +| Result | Meaning | +| --- | --- | +| `'execute'` | Load the rendered route entry, resolve its declared providers, and render it. | +| `{ outcome: 'continue' }` | Return pass-through output without expressing a host decision. | +| `{ outcome: 'deny', reason: string }` | Project a denial through the event's existing canonical outcome rules. | + +`execute` is the only result that loads the rendered route runtime. `undefined`, unknown fields +or outcomes, and an empty denial reason fail closed through framework validation. Observation-only +events cannot deny. + +The preflight context is frozen and deliberately smaller than `AgentEventRouteProps`. It contains +the exact `canonical` identity and payload the rendered route would receive, the request `signal` +owned by the hook deadline, translated host/terminal capability metadata already available +without application code, and only explicitly framework-owned cheap values. It does **not** +contain `native`, state, notices, lineage stores, 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. If a gate must inspect an unmapped native field, use +a config-declared handler instead. + +### Declaring route providers + +Provider laziness is declaration-driven. Preflight materializes no application providers. An +executed event route can statically declare the provider keys it needs; the runtime then +loads and resolves only that subset. Selected providers still materialize once per request, +sequentially in deterministic key/source order, with `processLifetime` seeded first, and failures +remain closed. Duplicate or unknown keys are build errors. + +For compatibility, an executed route with no provider declaration resolves all conventional +providers. 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 +does not change provider value types or the synchronous +`(await agent()).providers.` contract. + ### 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 d7f276dc6..5557583e0 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -163,9 +163,10 @@ export default defineConfig({ ## 事件路由 -配置声明的钩子是紧凑形式。第二种编写形态是**事件路由**:`src/events/` 下的一个文件,其路径就是它处理的 -规范事件族(`src/events/tool/before.tsx`、`src/events/stop.tsx`)。它像 MCP 工具路由一样,是一个异步 -默认导出的 Server Component,再加一个可静态提取的 `config` 导出: +配置声明的钩子是紧凑的、面向原生的逃生舱。只有当处理器确实需要原始宿主信封或宿主特定行为时才使用它。 +对普通语义事件——包括那些应当在渲染之前就返回的高频事件——请优先使用**事件路由**:`src/events/` +下的一个文件,其路径就是它处理的规范事件族(`src/events/tool/before.tsx`、`src/events/stop.tsx`)。 +它像 MCP 工具路由一样,是一个异步默认导出的 Server Component,再加一个可静态提取的 `config` 导出: ```tsx // src/events/tool/after.tsx @@ -195,6 +196,63 @@ export default async function AfterFileEdit({ canonical, native, signal }: Agent `observedAt`、一个 `sequence`、记录触发宿主与原生事件名的 `provenance`,以及该事件族的规范 `payload`。 `native` 是经过校验的宿主信封的冻结快照。`canonical` 中没有任何伪造:宿主未报告的字段保持缺失。 +### 预检(preflight) + +命名的 `preflight` 导出会在加载渲染式路由运行时之前,给同一条事件路由一道便宜的门控。物理上便宜的写法是 +可被静态跟随的相对路径重新导出: + +```ts +// src/events/tool/before.tsx +export { default as preflight } from './before.preflight.js'; + +export default async function BeforeTool({ canonical, signal }) { + // 完整渲染式事件路由只在 preflight 返回 "execute" 之后才会运行。 +} +``` + +```ts +// src/events/tool/before.preflight.ts +export default ({ canonical }) => + mentionsCargo(commandFrom(canonical.payload)) + ? 'execute' + : { outcome: 'continue' }; +``` + +把 `preflight` 模块保持为普通的 Node/应用代码:不要导入 React、RSC 辅助函数、渲染式路由模块或应用 +provider。相对路径重新导出让编译器可以构建一份独立的 `preflight` 源图,并把路由图推迟到之后。在渲染式 +路由模块里就地声明的函数在逻辑上是提前的,但在物理上并不便宜,因为求值该模块也会求值它的静态渲染与 +provider 导入。裸包、循环、非函数以及其他无法跟随的 `preflight` 导出都是构建错误;编译器不会悄悄退回 +昂贵的路由路径。 + +`preflight` 可以是同步或异步的,并且恰好只有三种结果: + +| 结果 | 含义 | +| --- | --- | +| `'execute'` | 加载渲染式路由入口,解析其声明的 provider,并渲染它。 | +| `{ outcome: 'continue' }` | 返回放行输出,不表达任何宿主决定。 | +| `{ outcome: 'deny', reason: string }` | 按该事件既有的规范结果规则投影一次拒绝。 | + +`execute` 是唯一会加载渲染式路由运行时的结果。`undefined`、未知字段或未知结果、以及空的拒绝原因, +都会通过框架校验失败即关闭。仅可观察的事件不能拒绝。 + +预检上下文是冻结的,并且刻意比 `AgentEventRouteProps` 更小。它包含渲染式路由会收到的那份精确 +`canonical` 身份与载荷、由钩子截止时间拥有的请求 `signal`、无需应用代码即可获得的已翻译宿主/终端 +能力元数据,以及仅限框架明确拥有的便宜取值。它**不**包含 `native`、state、notices、lineage 存储、 +渲染式布局、应用 provider、React/RSC 辅助函数,或应用请求上下文。信封边界与解码、原生宿主校验、 +规范化、能力翻译、截止时间与中止、结果校验,以及宿主投影,仍归框架所有。如果一道门控必须检查未映射 +的原生字段,请改用配置声明的处理器。 + +### 声明路由的 provider + +Provider 的惰性是声明驱动的。`preflight` 不会解析任何应用 provider。被执行的事件路由可以静态声明它 +需要的 provider 键;运行时随后只加载并解析那个子集。被选中的 provider 仍按确定性的键/来源顺序逐个 +挂载,每个请求一次,先挂载 `processLifetime`,并且失败保持关闭。重复或未知的键是构建错误。 + +为了兼容,没有声明任何 provider 的已执行路由会解析全部约定 provider。为随处触发的路由声明那个窄 +子集,这样无关的 daemon、state 或 provider 工作就不会进入 `preflight` 可执行文件,也不会进入被推迟的 +渲染式路由。这不会改变 provider 的值类型,也不会改变同步的 +`(await agent()).providers.` 契约。 + ### 规范载荷 `canonical.payload` 是该路由事件族对信封的跨宿主读法:至少两个宿主以各自名字报告的那些字段——工具族上的 From f9f494526223a614343f0bd14a4fe5d7b61dba52 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:44:11 +0000 Subject: [PATCH 02/16] feat(events): select declared providers lazily --- .changeset/595-event-preflight-gates.md | 2 +- docs/diagnostics.md | 5 ++- packages/agent-bundle/src/events/preflight.ts | 6 +-- packages/agent-bundle/src/test/providers.ts | 39 +++++++++++++++++- .../tests/event-preflight.test.ts | 2 +- .../tests/provider-execution.test.ts | 40 +++++++++++++++++++ website/docs/en/guide/authoring/hooks.mdx | 4 ++ website/docs/zh/guide/authoring/hooks.mdx | 4 ++ 8 files changed, 94 insertions(+), 8 deletions(-) diff --git a/.changeset/595-event-preflight-gates.md b/.changeset/595-event-preflight-gates.md index 785f739bd..d2ab1bec1 100644 --- a/.changeset/595-event-preflight-gates.md +++ b/.changeset/595-event-preflight-gates.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Let an event route under `src/events/**` declare a `preflight` gate — `export { default as preflight } from './.js'`, a sync or async function that receives the frozen `{ canonical, 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. The compiler attaches the preflight module to the route's own graph node (excluded from route discovery, part of the graph digest) and reports the new `AB4838` from `inspect`, `validate`, `build`, and `dev` 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 the specifier. Let an executed event route 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 the new `AB4839` reports a malformed declaration, a duplicate key, the reserved `processLifetime`, or a key that matches no discovered `src/providers/*` module, listing the project's provider keys. `EventPreflight`, `EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and `eventFamilyAllowsPreflightDeny` are exported from `agent-bundle`, `agent-bundle/api`, and `agent-bundle/routes`. (#595) +Let an event route under `src/events/**` 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. The compiler attaches the preflight module to the route's own graph node (excluded from route discovery, part of the graph digest) and reports the new `AB4838` from `inspect`, `validate`, `build`, and `dev` 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 the specifier. Let an executed event route 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 the new `AB4839` reports a malformed declaration, a duplicate key, the reserved `processLifetime`, or a key that matches no discovered `src/providers/*` module, listing the project's provider keys. `EventPreflight`, `EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and `eventFamilyAllowsPreflightDeny` are exported from `agent-bundle`, `agent-bundle/api`, and `agent-bundle/routes`. (#595) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9588051f1..e08d37bde 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -872,8 +872,9 @@ 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 -request `signal` owned by the hook deadline, and the translated `terminal` -capability metadata (never `native`, state, notices, lineage, providers, or +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 diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index 9af1f2aed..7881a8a46 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -3,7 +3,7 @@ import type { AgentEventCanonicalIdentity } from '../routes/public.ts'; import type { AgentTerminal } from '../terminal-capability.ts'; /** - * The gate result a conventional `export const preflight` may return (#595). + * 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. @@ -15,8 +15,8 @@ export type EventPreflightResult = /** * Frozen, deliberately small context a preflight gate receives: the same - * canonical identity the rendered route would see, the hook-deadline signal, - * and already-translated terminal capability metadata. It does not include + * 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 { diff --git a/packages/agent-bundle/src/test/providers.ts b/packages/agent-bundle/src/test/providers.ts index 37b07c4e3..f1df9f3ca 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.some((key) => 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 as readonly string[] | undefined, + ); + 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/tests/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts index c25a25d1b..4057ffa0c 100644 --- a/packages/agent-bundle/tests/event-preflight.test.ts +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -146,7 +146,7 @@ it('honors the framework-owned abort signal before and after an asynchronous gat } as unknown as EventPreflightContext<'tool/before'>; await expect(executeEventPreflight(async () => { controller.abort(new Error('deadline elapsed')); - return 'execute'; + return 'execute' as const; }, context)).rejects.toThrow(/deadline elapsed/u); }); diff --git a/packages/agent-bundle/tests/provider-execution.test.ts b/packages/agent-bundle/tests/provider-execution.test.ts index 4cbca1c85..3df5fe3a3 100644 --- a/packages/agent-bundle/tests/provider-execution.test.ts +++ b/packages/agent-bundle/tests/provider-execution.test.ts @@ -12,6 +12,8 @@ import { } 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 @@ -201,3 +203,41 @@ describe('executeProviders over a selection', () => { 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/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 5d2b9946d..c4d9cd925 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -222,6 +222,7 @@ runtime loads. The physically cheap pattern is a statically followable relative ```ts // src/events/tool/before.tsx +export const config = { providers: ['projectPolicy'] }; export { default as preflight } from './before.preflight.js'; export default async function BeforeTool({ canonical, signal }) { @@ -275,6 +276,9 @@ loads and resolves only that subset. Selected providers still materialize once p sequentially in deterministic key/source order, with `processLifetime` seeded first, and failures remain closed. Duplicate or unknown keys are build errors. +Use `export const config = { providers: ['projectPolicy'] }`; provider keys are the camel-cased +filenames under `src/providers/`. An empty array selects no application providers. + For compatibility, an executed route with no provider declaration resolves all conventional providers. 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 diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 5557583e0..658101607 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -203,6 +203,7 @@ export default async function AfterFileEdit({ canonical, native, signal }: Agent ```ts // src/events/tool/before.tsx +export const config = { providers: ['projectPolicy'] }; export { default as preflight } from './before.preflight.js'; export default async function BeforeTool({ canonical, signal }) { @@ -248,6 +249,9 @@ Provider 的惰性是声明驱动的。`preflight` 不会解析任何应用 prov 需要的 provider 键;运行时随后只加载并解析那个子集。被选中的 provider 仍按确定性的键/来源顺序逐个 挂载,每个请求一次,先挂载 `processLifetime`,并且失败保持关闭。重复或未知的键是构建错误。 +写成 `export const config = { providers: ['projectPolicy'] }`;provider 键来自 `src/providers/` +下文件名的小驼峰形式。空数组表示不选择任何应用 provider。 + 为了兼容,没有声明任何 provider 的已执行路由会解析全部约定 provider。为随处触发的路由声明那个窄 子集,这样无关的 daemon、state 或 provider 工作就不会进入 `preflight` 可执行文件,也不会进入被推迟的 渲染式路由。这不会改变 provider 的值类型,也不会改变同步的 From eda787aa031ce9e1a7422d54166e7aaae1dbd22f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:44:36 +0000 Subject: [PATCH 03/16] fix(events): satisfy preflight lint --- packages/agent-bundle/src/events/preflight.ts | 3 +-- packages/agent-bundle/tests/event-preflight.test.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index 7881a8a46..a27cb3811 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -32,8 +32,7 @@ export type EventPreflight context: EventPreflightContext, ) => EventPreflightResult | Promise; -const preflightObjectOutcomes = ['continue', 'deny'] as const; -type PreflightObjectOutcome = (typeof preflightObjectOutcomes)[number]; +type PreflightObjectOutcome = 'continue' | 'deny'; const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome => value === 'continue' || value === 'deny'; diff --git a/packages/agent-bundle/tests/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts index 4057ffa0c..416f52123 100644 --- a/packages/agent-bundle/tests/event-preflight.test.ts +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -6,7 +6,6 @@ import { validateEventPreflightResult, type EventPreflight, type EventPreflightContext, - type EventPreflightResult, } from '../src/events/preflight.ts'; import { projectEventPreflightResult } from '../src/events/projection.ts'; import { From eb2d00f4418240ee807c8de35e1860fba3eb3fb2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:47:26 +0000 Subject: [PATCH 04/16] feat(events): abort stalled preflight gates --- packages/agent-bundle/src/core/abort.ts | 27 +++++++++++++++++++ .../dev/mcp-app-runtime-binding-service.ts | 25 +++-------------- packages/agent-bundle/src/events/preflight.ts | 3 ++- packages/agent-bundle/src/test/render.ts | 19 ++----------- .../tests/event-preflight.test.ts | 10 ++++--- 5 files changed, 40 insertions(+), 44 deletions(-) create mode 100644 packages/agent-bundle/src/core/abort.ts 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/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/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index a27cb3811..d9e0a1f5b 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -1,3 +1,4 @@ +import { settleBeforeAbort } from '../core/abort.ts'; import type { CanonicalAgentEvent } from '../routes/events.ts'; import type { AgentEventCanonicalIdentity } from '../routes/public.ts'; import type { AgentTerminal } from '../terminal-capability.ts'; @@ -144,7 +145,7 @@ export const executeEventPreflight = async ( signal: context.signal, terminal: context.terminal, }); - const value = await preflight(frozenContext); + const value = await settleBeforeAbort(Promise.resolve().then(() => preflight(frozenContext)), context.signal); context.signal.throwIfAborted(); return validateEventPreflightResult(value, context.canonical.event); }; diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index fb5ed6ea9..bc355eaa3 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 { createProviderProcessLifetime, type ProviderProcessLifetime } from '../routes/provider-execution.ts'; import { routeRenderLimits, type RouteRenderBudget } from '../routes/render-budget.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; @@ -1220,22 +1221,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 @@ -1372,7 +1357,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/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts index 416f52123..aadf6def9 100644 --- a/packages/agent-bundle/tests/event-preflight.test.ts +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -143,10 +143,12 @@ it('honors the framework-owned abort signal before and after an asynchronous gat signal: controller.signal, terminal: { interactive: false }, } as unknown as EventPreflightContext<'tool/before'>; - await expect(executeEventPreflight(async () => { - controller.abort(new Error('deadline elapsed')); - return 'execute' as const; - }, context)).rejects.toThrow(/deadline elapsed/u); + 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', () => { From 9a951cb3486c1e3ec033c497f01c521ef3588ed8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:05:17 +0000 Subject: [PATCH 05/16] feat(events): emit cheap preflight hook shells --- .changeset/595-event-preflight-gates.md | 2 +- .../src/adapters/hook-contract.ts | 176 +++++++- packages/agent-bundle/src/api.ts | 27 ++ packages/agent-bundle/src/build/build.ts | 13 +- .../agent-bundle/src/build/compile-stages.ts | 9 +- packages/agent-bundle/src/build/entries.ts | 89 +++- .../agent-bundle/src/build/entry-shell.ts | 57 ++- .../agent-bundle/src/build/inspect-bundler.ts | 26 +- packages/agent-bundle/src/config/normalize.ts | 20 +- packages/agent-bundle/src/core/types.ts | 10 +- packages/agent-bundle/src/events/project.ts | 25 ++ packages/agent-bundle/src/events/trace.ts | 379 ++++++++++++++++++ packages/agent-bundle/src/index.ts | 27 ++ packages/agent-bundle/tests/entries.test.ts | 91 ++++- .../agent-bundle/tests/entry-shell.test.ts | 161 ++++++++ .../agent-bundle/tests/event-trace.test.ts | 371 +++++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 65 ++- .../tests/inspect-bundler.test.ts | 76 ++++ .../agent-bundle/tests/normalization.test.ts | 57 +++ .../tests/preflight-artifact-graph.test.ts | 339 ++++++++++++++++ .../tests/target-hook-contract.test.ts | 94 +++++ rstest.integration-tests.ts | 1 + scripts/measure-preflight-cold-start.mjs | 333 +++++++++++++++ website/docs/en/guide/authoring/hooks.mdx | 175 +++++--- website/docs/zh/guide/authoring/hooks.mdx | 67 ++-- 25 files changed, 2549 insertions(+), 141 deletions(-) create mode 100644 packages/agent-bundle/src/events/trace.ts create mode 100644 packages/agent-bundle/tests/event-trace.test.ts create mode 100644 packages/agent-bundle/tests/preflight-artifact-graph.test.ts create mode 100644 scripts/measure-preflight-cold-start.mjs diff --git a/.changeset/595-event-preflight-gates.md b/.changeset/595-event-preflight-gates.md index d2ab1bec1..5bfefb5ca 100644 --- a/.changeset/595-event-preflight-gates.md +++ b/.changeset/595-event-preflight-gates.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Let an event route under `src/events/**` 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. The compiler attaches the preflight module to the route's own graph node (excluded from route discovery, part of the graph digest) and reports the new `AB4838` from `inspect`, `validate`, `build`, and `dev` 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 the specifier. Let an executed event route 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 the new `AB4839` reports a malformed declaration, a duplicate key, the reserved `processLifetime`, or a key that matches no discovered `src/providers/*` module, listing the project's provider keys. `EventPreflight`, `EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and `eventFamilyAllowsPreflightDeny` are exported from `agent-bundle`, `agent-bundle/api`, and `agent-bundle/routes`. (#595) +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`. (#595) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 72d842326..9ec13863b 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; @@ -630,16 +632,24 @@ export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFA const standaloneEventRoute = (route: NonNullable): boolean => route.runtime === 'standalone' || route.fallback === 'standalone'; +/** The independently bundleable preflight leaf on an event route, when present. */ +export const eventRoutePreflight = ( + route: NormalizedHook['eventRoute'], +): NonNullable['preflight'] => route?.preflight; + /** * 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) + || eventRoutePreflight(entry.hook.eventRoute) !== undefined; /** * The wrapper reaches the warm MCP runtime through an endpoint identified by @@ -654,19 +664,30 @@ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, durableLineage = false, + includePreflight = true, ): string => { const route = entry.hook.eventRoute!; const standalone = standaloneEventRoute(route); + const preflight = includePreflight ? eventRoutePreflight(route) : undefined; // A standalone `session/end` (the warm runtime has usually already exited by // 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 = [ + ...new Set([ + ...(standalone || preflight !== undefined ? ['createCanonicalEventProps'] : []), + ...(preflight !== undefined ? ['executeEventPreflight', 'projectEventPreflightResult'] : []), + ...(standalone ? ['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 // MCP process, which applied the layer itself when it started. First, - // so it evaluates before every other module of the bundle. - ...(standalone ? [operatorEnvLayerImport] : []), + // so it evaluates before every other module of the bundle — including a + // preflight leaf that may read `process.env`. + ...(standalone || preflight !== undefined ? [operatorEnvLayerImport] : []), "import { dirname, resolve } from 'node:path';", ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), ...(standalone @@ -682,7 +703,8 @@ 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)};`, + ...(preflight === undefined ? [] : [`import preflight from ${JSON.stringify(preflight.source)};`]), '', `const artifactEpoch = ${JSON.stringify(eventArtifactEpochToken)};`, ...(standalone ? [`const flightArtifactEpoch = ${JSON.stringify(eventFlightArtifactEpochToken)};`] : []), @@ -772,9 +794,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, props) => {', + ' const resolved = props ?? createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ...(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 +812,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);', '};', ] @@ -807,17 +829,42 @@ const eventRouteHookWrapperSource = ( ' 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 });', ' const controller = new AbortController();', + ...(preflight === undefined + ? [] + : [ + ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', + ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ' 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" } },', + ' });', + ' if (gate !== "execute") {', + ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', + ' return;', + ' }', + ]), ' let output;', ' if (runtimeMode === "standalone") {', - ...(standalone ? [' output = await runStandalone(native, controller.signal);'] : [' fail("standalone runtime was not compiled");']), + ...(standalone + ? [preflight === undefined + ? ' output = await runStandalone(native, controller.signal);' + : ' output = await runStandalone(native, signal, props);'] + : [' fail("standalone runtime was not compiled");']), ' } else {', ' try {', - ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs });', + ...(preflight === undefined + ? [' 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, signal, target, timeoutMs });']), ' } catch (error) {', ...(standalone ? [ ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', - ' output = await runStandalone(native, controller.signal);', + preflight === undefined + ? ' output = await runStandalone(native, controller.signal);' + : ' output = await runStandalone(native, signal, props);', ] : [' throw error;']), ' }', @@ -834,6 +881,86 @@ 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 = eventRoutePreflight(route)!; + const executorFile = entry.relativePath.split('/').at(-1)!.replace(/\.mjs$/u, '.execute.mjs'); + const projectBindings = ['createCanonicalEventProps', '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 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.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 signal = AbortSignal.timeout(timeoutMs);', + ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ' 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" } },', + ' });', + ' if (gate !== "execute") {', + ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', + ' return;', + ' }', + ' const output = await runExecutor(input, signal);', + ' 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,28 @@ export const planHooks = ( target, ...(timeout === undefined ? {} : { timeout }), }; + const preflight = eventRoutePreflight(hook.eventRoute); hookEntries.push({ ...wrapper, - virtualSource: hook.eventRoute === undefined - ? contract.wrapperSource(wrapper) - : eventRouteHookWrapperSource( + ...(hook.eventRoute === undefined || preflight === undefined + ? {} + : { + executeVirtualSource: eventRouteHookWrapperSource( wrapper, contract.hostContractRevision ?? target, model.state?.lifetime === 'workspace-durable', + false, ), + }), + virtualSource: hook.eventRoute === undefined + ? contract.wrapperSource(wrapper) + : preflight === undefined + ? eventRouteHookWrapperSource( + wrapper, + contract.hostContractRevision ?? target, + model.state?.lifetime === 'workspace-durable', + ) + : eventRoutePreflightWrapperSource(wrapper, contract.hostContractRevision ?? target), }); } diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 97bacd162..bfbddafd9 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -83,6 +83,33 @@ export type { ToolConfig, ToolRouteProps, } from './routes/public.ts'; +export { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTracePhases, + 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 6cbe3277b..bddc4a637 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -9,6 +9,7 @@ import { eventFlightArtifactEpochToken, eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier, + eventRoutePreflight, hookWrapperAppliesOperatorEnv, type TargetHookEntry, } from '../adapters/hook-contract.ts'; @@ -54,7 +55,7 @@ import type { RslibEntry, RslibSurfacePlan } from './rslib.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`), @@ -109,6 +110,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; @@ -572,6 +576,15 @@ export const planMcpEntriesSurface = async ( }; }; +const hookEntrySourceInputs = (entry: TargetHookEntry): readonly string[] => { + const preflight = eventRoutePreflight(entry.hook.eventRoute); + return Object.freeze([ + entry.hook.provenance.sourcePath, + entry.hook.source, + ...(preflight === undefined ? [] : [preflight.source]), + ]); +}; + export const planCompiledHooks = ( entries: readonly TargetHookEntry[], options: { readonly outDir: string }, @@ -590,8 +603,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 ? { @@ -657,35 +679,58 @@ export const planHooksSurface = ( }; return { entries: [ - ...compiled.map((entry, index) => ({ + ...compiled.flatMap((entry, index) => { + const hook = entries[index]!; + const executorRelativePath = hook.relativePath.replace(/\.mjs$/u, '.execute.mjs'); + 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: entries[index]!.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), - outputRelativePath: entries[index]!.relativePath, - ...(entries[index]!.hook.eventRoute?.runtime === 'standalone' - || entries[index]!.hook.eventRoute?.fallback === 'standalone' + 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: { - // 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 }), - }), - }, + aliases, source: entry.source, sourceInputs: entry.sourceInputs, - virtualSource: entries[index]!.virtualSource + 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(entries[index]!) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), - })), + ...(hookWrapperAppliesOperatorEnv(hook) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), + }; + if (hook.executeVirtualSource === undefined) return [wrapperEntry]; + 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: [ @@ -697,6 +742,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 9e59e3708..ea19152cd 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 { providerKeyFromName } from '../routes/providers.ts'; import type { CompiledAgentRoute, CompiledCliCommand, CompiledLayout, CompiledProvider } from '../routes/types.ts'; @@ -877,8 +881,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) => @@ -925,14 +937,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} }`, @@ -951,9 +967,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';", @@ -983,7 +1018,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();', '', @@ -1011,7 +1046,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..5830e93a3 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'; @@ -319,9 +323,20 @@ const hookEntries = ( tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { const outputRoot = artifactOutputToken; - return entries.map((entry) => rslibInspectionEntry({ + return entries.map((entry) => { + const eventIpcRuntime = entry.hook.eventRoute === undefined ? undefined : eventRuntimeModulePath('ipc'); + const eventProjectRuntime = entry.hook.eventRoute === undefined ? undefined : eventRuntimeModulePath('project'); + return rslibInspectionEntry({ entry: { - aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, + aliases: { + [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), + ...(eventIpcRuntime === undefined + ? {} + : { + [eventIpcRuntimeSpecifier]: eventIpcRuntime, + ...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }), + }), + }, name: entry.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), outputRelativePath: entry.relativePath, source: entry.hook.source, @@ -338,7 +353,8 @@ const hookEntries = ( source: entry.hook.source, target, ...(tools === undefined ? {} : { tools }), - })); + }); + }); }; const mcpAppsEntry = ( diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 11c5e4c84..9db4643ca 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -527,10 +527,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/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/events/project.ts b/packages/agent-bundle/src/events/project.ts index 86a3afefa..f803e55be 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -13,3 +13,28 @@ export { type EventPreflightContext, type EventPreflightResult, } from './preflight.ts'; +export { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTracePhases, + 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/trace.ts b/packages/agent-bundle/src/events/trace.ts new file mode 100644 index 000000000..6a6e80e55 --- /dev/null +++ b/packages/agent-bundle/src/events/trace.ts @@ -0,0 +1,379 @@ +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; + /** Position within this execution's trace, starting at 0. */ + 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'> { + /** Providers materialized for this request. */ + 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; + +/** + * 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, observer } = options; + 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. + } + }; + + /** + * `build` receives the timestamp, the next sequence number, and the trace's + * first timestamp before this event (undefined when this is the first). + * The sequence advances only when an event is actually built, so a broken + * clock leaves no gap. + */ + const emit = (build: (at: number, sequence: number, traceStartedAt: number | undefined) => EventTraceEvent): void => { + if (closed) return; + const at = readClock(); + if (at === undefined) return; + const traceStartedAt = firstAt; + firstAt ??= at; + const event = build(at, sequence, traceStartedAt); + sequence += 1; + 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, + })); + closed = 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 23e2bd89d..c0dfc47d3 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -96,6 +96,33 @@ export type { ToolRouteProps, ToolTaskSupport, } from './routes/public.ts'; +export { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTracePhases, + 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/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index 5b751f7cb..1e249f917 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from '@rstest/core'; -import type { NormalizedMcpServer } from '../src/core/types.ts'; -import { eventRuntimeHosting, runtimeIgnoredRoot, selectedServerHosts } from '../src/build/entries.ts'; +import { planHooks } from '../src/adapters/hook-contract.ts'; +import { eventRuntimeHosting, planCompiledHooks, planHooksSurface, runtimeIgnoredRoot, selectedServerHosts } from '../src/build/entries.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', () => { @@ -84,3 +86,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-trace.test.ts b/packages/agent-bundle/tests/event-trace.test.ts new file mode 100644 index 000000000..2c8327a77 --- /dev/null +++ b/packages/agent-bundle/tests/event-trace.test.ts @@ -0,0 +1,371 @@ +import { expect, it } from '@rstest/core'; + +import { + createEventTracer, + eventTraceEventKinds, + eventTraceExecution, + eventTracePhases, + 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('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('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..93d1c4694 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-ipc'); + expect(entry.generatedEntry).toContain('agent-bundle/event-project'); + 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..8133cfd6c --- /dev/null +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -0,0 +1,339 @@ +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'; + +/** + * #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` + * (rstest.integration-tests.ts) when it lands; until then it carries its own + * timeouts. Contract seams the compiler does not fill yet on this branch: + * the standalone wrapper never imports the preflight leaf, and it inlines + * `@agent-bundle/runtime` (React, the Flight client, the render dispatcher). + */ + +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 : '';", + " return mentionsCargo(command) ? 'execute' : { outcome: 'deny', reason: PREFLIGHT_LEAF_SENTINEL };", + '};', + '', + ].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 {RENDERED_ROUTE_SENTINEL};', + '}', + '', + ].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('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/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 73d7a3733..afffab3a2 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,94 @@ 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(source).toContain('requestEventRuntime'); + expect(source).toContain('const timeoutMs = 1250;'); + expect(source).toMatch(/AbortSignal\.timeout|controller\.abort|signal\.throwIfAborted/u); + 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, 'requestEventRuntime')); + 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 source = planHooks(planningModel([hook]), 'synthetic', contract).hookEntries[0]!.virtualSource; + + expect(source).toContain('executeEventPreflight'); + expect(source).toContain('projectEventPreflightResult'); + expect(source).toContain('new URL(/* webpackIgnore: true */ "./hooks-flight.mjs", import.meta.url)'); + 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, 'runStandalone')); }); 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 64499f6a2..1f44e3207 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -183,10 +183,12 @@ index. ## Event routes -Config-declared hooks are the compact, native escape hatch. 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 +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: @@ -226,73 +228,142 @@ hashed from the event, target, and native payload, `observedAt`, a `sequence`, t ### Preflight gates -A named `preflight` export gives the same event route a cheap gate before the rendered route -runtime loads. The physically cheap pattern is a statically followable relative re-export: +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. -```ts +```tsx // src/events/tool/before.tsx -export const config = { providers: ['projectPolicy'] }; +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, signal }) { - // The full rendered event route runs only after preflight returns "execute". +export default async function BeforeTool( + { canonical, native, signal }: AgentEventRouteProps<'tool/before'>, +) { + // Runs only after preflight returned 'execute'. } ``` ```ts // src/events/tool/before.preflight.ts -export default ({ canonical }) => - mentionsCargo(commandFrom(canonical.payload)) +import type { EventPreflight } from 'agent-bundle'; + +export default (({ canonical }) => + canonical.payload.toolName?.value === 'Write' ? 'execute' - : { outcome: 'continue' }; + : { outcome: 'continue' }) satisfies EventPreflight<'tool/before'>; ``` -Keep the preflight module plain Node/application code: do not import React, RSC helpers, the -rendered route module, or application providers. A relative re-export lets the compiler build an -independent preflight source graph and defer the route graph. A locally declared function in the -rendered route module is logically early but not physically cheap, because evaluating that module -also evaluates its static rendering and provider imports. Bare-package, cyclic, non-function, and -otherwise non-followable preflight exports are build errors; the compiler does not silently use -the expensive route path. - -Preflight may be synchronous or asynchronous and has exactly three results: +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' }` | Return pass-through output without expressing a host decision. | -| `{ outcome: 'deny', reason: string }` | Project a denial through the event's existing canonical outcome rules. | - -`execute` is the only result that loads the rendered route runtime. `undefined`, unknown fields -or outcomes, and an empty denial reason fail closed through framework validation. Observation-only -events cannot deny. - -The preflight context is frozen and deliberately smaller than `AgentEventRouteProps`. It contains -the exact `canonical` identity and payload the rendered route would receive, the request `signal` -owned by the hook deadline, translated host/terminal capability metadata already available -without application code, and only explicitly framework-owned cheap values. It does **not** -contain `native`, state, notices, lineage stores, 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. If a gate must inspect an unmapped native field, use -a config-declared handler instead. +| `{ 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 view of the event kernel: `preflight.start`, `preflight.outcome`, `execute.start`, +`providers.start` / `providers.finish`, `render.start` / `render.finish`, and terminal `failure`. +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. +The trace types and helpers are exported from `agent-bundle`, `agent-bundle/api`, and +`agent-bundle/event-project`. ### Declaring route providers -Provider laziness is declaration-driven. Preflight materializes no application providers. An -executed event route can statically declare the provider keys it needs; the runtime then -loads and resolves only that subset. Selected providers still materialize once per request, -sequentially in deterministic key/source order, with `processLifetime` seeded first, and failures -remain closed. Duplicate or unknown keys are build errors. - -Use `export const config = { providers: ['projectPolicy'] }`; provider keys are the camel-cased -filenames under `src/providers/`. An empty array selects no application providers. - -For compatibility, an executed route with no provider declaration resolves all conventional -providers. 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 -does not change provider value types or the synchronous -`(await agent()).providers.` contract. +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 diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 96f87cb34..657a17cb8 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -167,10 +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 @@ -206,8 +207,8 @@ export default async function AfterFileEdit( ### 预检(preflight) -命名的 `preflight` 导出会在加载渲染式路由运行时之前,给同一条事件路由一道便宜的门控。物理上便宜的写法是 -可被静态跟随的相对路径重新导出: +命名导出的 `preflight` 会在加载渲染式路由运行时之前,为同一条事件路由提供一道低开销门控。要真正避免 +提前加载路由运行时,应通过编译器可静态追踪的相对路径重新导出: ```ts // src/events/tool/before.tsx @@ -227,11 +228,11 @@ export default ({ canonical }) => : { outcome: 'continue' }; ``` -把 `preflight` 模块保持为普通的 Node/应用代码:不要导入 React、RSC 辅助函数、渲染式路由模块或应用 -provider。相对路径重新导出让编译器可以构建一份独立的 `preflight` 源图,并把路由图推迟到之后。在渲染式 -路由模块里就地声明的函数在逻辑上是提前的,但在物理上并不便宜,因为求值该模块也会求值它的静态渲染与 -provider 导入。裸包、循环、非函数以及其他无法跟随的 `preflight` 导出都是构建错误;编译器不会悄悄退回 -昂贵的路由路径。 +`preflight` 模块应保持为普通的 Node/应用代码:不要导入 React、RSC 辅助函数、渲染式路由模块或应用 +provider。通过相对路径重新导出,编译器可以构建独立的 `preflight` 源码图,并把路由图推迟到门控放行后 +再加载。直接在渲染式路由模块中声明函数,只是逻辑上先执行;由于求值该模块也会求值其静态渲染代码并加载 +provider,实际开销并不会降低。通过裸包名导入、形成循环、导出非函数,或采用其他无法静态追踪的方式提供 +`preflight`,都会导致构建错误;编译器不会悄悄退回高开销的路由路径。 `preflight` 可以是同步或异步的,并且恰好只有三种结果: @@ -241,28 +242,40 @@ provider 导入。裸包、循环、非函数以及其他无法跟随的 `prefli | `{ outcome: 'continue' }` | 返回放行输出,不表达任何宿主决定。 | | `{ outcome: 'deny', reason: string }` | 按该事件既有的规范结果规则投影一次拒绝。 | -`execute` 是唯一会加载渲染式路由运行时的结果。`undefined`、未知字段或未知结果、以及空的拒绝原因, -都会通过框架校验失败即关闭。仅可观察的事件不能拒绝。 +`execute` 是唯一会加载渲染式路由运行时的结果。返回 `undefined`、未知字段或未知结果,或给出空的拒绝 +原因,都会无法通过框架校验并终止执行。仅用于观察的事件不能拒绝。 -预检上下文是冻结的,并且刻意比 `AgentEventRouteProps` 更小。它包含渲染式路由会收到的那份精确 -`canonical` 身份与载荷、由钩子截止时间拥有的请求 `signal`、无需应用代码即可获得的已翻译宿主/终端 -能力元数据,以及仅限框架明确拥有的便宜取值。它**不**包含 `native`、state、notices、lineage 存储、 -渲染式布局、应用 provider、React/RSC 辅助函数,或应用请求上下文。信封边界与解码、原生宿主校验、 -规范化、能力翻译、截止时间与中止、结果校验,以及宿主投影,仍归框架所有。如果一道门控必须检查未映射 -的原生字段,请改用配置声明的处理器。 +预检收到的上下文是一个冻结对象,且恰好只有 `{ 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`。 + +### 执行跟踪事件 + +导出的 `EventTraceEvent` 可辨识联合为开发工具提供稳定且不含载荷的事件内核视图: +`preflight.start`、`preflight.outcome`、`execute.start`、`providers.start` / +`providers.finish`、`render.start` / `render.finish`,以及终止性的 `failure`。每个冻结事件 +都携带同一份 `EventTraceExecution` 身份、单调时间戳和序号,并且只包含该阶段的元数据; +失败使用有长度上限且不含堆栈的 `EventTraceErrorSummary`。没有 observer 时 +`createEventTracer` 是空操作;observer 抛错也不会改变执行。相关类型与辅助函数从 +`agent-bundle`、`agent-bundle/api` 和 `agent-bundle/event-project` 导出。 ### 声明路由的 provider -Provider 的惰性是声明驱动的。`preflight` 不会解析任何应用 provider。被执行的事件路由可以静态声明它 -需要的 provider 键;运行时随后只加载并解析那个子集。被选中的 provider 仍按确定性的键/来源顺序逐个 -挂载,每个请求一次,先挂载 `processLifetime`,并且失败保持关闭。重复或未知的键是构建错误。 +provider 是否按需加载由声明决定。`preflight` 不会实例化任何应用 provider。事件路由执行后,运行时只会 +加载并解析它静态声明的 provider 子集。选中的 provider 在每次请求中仍只实例化一次,并按确定的键名/来源 +顺序依次执行;`processLifetime` 会先注入,任何失败都会终止该请求。键名重复或未知都会导致构建错误。 -写成 `export const config = { providers: ['projectPolicy'] }`;provider 键来自 `src/providers/` -下文件名的小驼峰形式。空数组表示不选择任何应用 provider。 +声明方式是 `export const config = { providers: ['projectPolicy'] }`。provider 键由 `src/providers/` +下的文件名转换为 camelCase;空数组表示不选择任何应用 provider。 -为了兼容,没有声明任何 provider 的已执行路由会解析全部约定 provider。为随处触发的路由声明那个窄 -子集,这样无关的 daemon、state 或 provider 工作就不会进入 `preflight` 可执行文件,也不会进入被推迟的 -渲染式路由。这不会改变 provider 的值类型,也不会改变同步的 +为了向后兼容,执行时未声明 `config.providers` 的路由会解析所有约定 provider。对于随处触发的路由,应 +明确声明所需的最小子集,避免无关的 daemon、state 或 provider 工作进入独立的 `preflight` 可执行文件或 +延后加载的渲染式路由。这不会改变 provider 的值类型,也不会改变同步的 `(await agent()).providers.` 契约。 ### 规范载荷 From 7aa77c7eb9e6da9e2bc8c07d5c2c856e59637305 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:07:55 +0000 Subject: [PATCH 06/16] fix(events): defer gated route executors --- .../tests/inspect-bundler.test.ts | 2 +- website/docs/zh/guide/authoring/hooks.mdx | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 93d1c4694..4ecc448dd 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -265,8 +265,8 @@ it('inspects the per-host preflight wrapper under the composite identity', async expect(entry.target).toBe('claude+codex'); expect(entry.generatedEntry).toContain('executeEventPreflight'); expect(entry.generatedEntry).toContain(preflight.source); - expect(entry.generatedEntry).toContain('agent-bundle/event-ipc'); 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/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 6799c274e..e92080ba4 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -210,22 +210,28 @@ export default async function AfterFileEdit( 命名导出的 `preflight` 会在加载渲染式路由运行时之前,为同一条事件路由提供一道低开销门控。要真正避免 提前加载路由运行时,应通过编译器可静态追踪的相对路径重新导出: -```ts +```tsx // src/events/tool/before.tsx -export const config = { providers: ['projectPolicy'] }; +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, signal }) { - // 完整渲染式事件路由只在 preflight 返回 "execute" 之后才会运行。 +export default async function BeforeTool( + { canonical, native, signal }: AgentEventRouteProps<'tool/before'>, +) { + // 仅在 preflight 返回 'execute' 后运行。 } ``` ```ts // src/events/tool/before.preflight.ts -export default ({ canonical }) => - mentionsCargo(commandFrom(canonical.payload)) +import type { EventPreflight } from 'agent-bundle'; + +export default (({ canonical }) => + canonical.payload.toolName?.value === 'Write' ? 'execute' - : { outcome: 'continue' }; + : { outcome: 'continue' }) satisfies EventPreflight<'tool/before'>; ``` `preflight` 模块应保持为普通的 Node/应用代码:不要导入 React、RSC 辅助函数、渲染式路由模块或应用 @@ -233,6 +239,7 @@ provider。通过相对路径重新导出,编译器可以构建独立的 `pref 再加载。直接在渲染式路由模块中声明函数,只是逻辑上先执行;由于求值该模块也会求值其静态渲染代码并加载 provider,实际开销并不会降低。通过裸包名导入、形成循环、导出非函数,或采用其他无法静态追踪的方式提供 `preflight`,都会导致构建错误;编译器不会悄悄退回高开销的路由路径。 +这些无效形式由 `inspect`、`validate`、`build` 和 `dev` 以 `AB4840` 报告。 `preflight` 可以是同步或异步的,并且恰好只有三种结果: @@ -254,6 +261,13 @@ notices、lineage 存储、渲染式布局、应用 provider、React/RSC 辅助 解码、宿主原生输入校验、规范化、能力转换、截止时间与中止、结果校验及宿主投影,仍由框架负责。如果门控 必须检查尚未映射的原生字段,请改用配置声明的处理器,并从其第二个参数读取 `nativeInput`。 +| 字段 | 含义 | +| --- | --- | +| `canonical` | 与渲染式路由相同的规范事件身份与载荷。 | +| `host` | 冻结的 `{ name, nativeEvent }` 编译宿主身份。 | +| `signal` | 由 hook 截止时间控制的请求 `AbortSignal`。 | +| `terminal` | 框架推导出的只读 `AgentTerminal` 能力视图。 | + ### 执行跟踪事件 导出的 `EventTraceEvent` 可辨识联合为开发工具提供稳定且不含载荷的事件内核视图: @@ -269,6 +283,7 @@ notices、lineage 存储、渲染式布局、应用 provider、React/RSC 辅助 provider 是否按需加载由声明决定。`preflight` 不会实例化任何应用 provider。事件路由执行后,运行时只会 加载并解析它静态声明的 provider 子集。选中的 provider 在每次请求中仍只实例化一次,并按确定的键名/来源 顺序依次执行;`processLifetime` 会先注入,任何失败都会终止该请求。键名重复或未知都会导致构建错误。 +无效的 `config.providers` 声明由 `AB4841` 报告。 声明方式是 `export const config = { providers: ['projectPolicy'] }`。provider 键由 `src/providers/` 下的文件名转换为 camelCase;空数组表示不选择任何应用 provider。 From 5a8fe3d50fa5f2377ac28e8f061befc2d9aa73b3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:26:49 +0000 Subject: [PATCH 07/16] fix(events): wire trace observer and execution proof --- .changeset/595-event-preflight-gates.md | 2 +- docs/diagnostics.md | 4 +- .../src/adapters/hook-contract.ts | 57 +++++++------------ packages/agent-bundle/src/api.ts | 2 + packages/agent-bundle/src/events/preflight.ts | 30 ++++++---- packages/agent-bundle/src/events/project.ts | 2 + packages/agent-bundle/src/events/trace.ts | 24 +++++++- packages/agent-bundle/src/index.ts | 2 + .../agent-bundle/tests/event-trace.test.ts | 15 +++++ .../tests/preflight-artifact-graph.test.ts | 47 ++++++++++++--- website/docs/en/guide/authoring/hooks.mdx | 5 +- website/docs/zh/guide/authoring/hooks.mdx | 3 +- 12 files changed, 132 insertions(+), 61 deletions(-) diff --git a/.changeset/595-event-preflight-gates.md b/.changeset/595-event-preflight-gates.md index 5bfefb5ca..0739b1f13 100644 --- a/.changeset/595-event-preflight-gates.md +++ b/.changeset/595-event-preflight-gates.md @@ -2,4 +2,4 @@ "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`. (#595) +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. (#595) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8b06e08bf..0bf04fa4f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1219,7 +1219,7 @@ 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 `AB4838`, once per route on the route module; the route compiles without a +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. @@ -1237,7 +1237,7 @@ 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 `AB4839`, once per route with every defect in one message; a +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. diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 9ec13863b..423772003 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -664,19 +664,16 @@ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, durableLineage = false, - includePreflight = true, ): string => { const route = entry.hook.eventRoute!; const standalone = standaloneEventRoute(route); - const preflight = includePreflight ? eventRoutePreflight(route) : undefined; // A standalone `session/end` (the warm runtime has usually already exited by // 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 = [ ...new Set([ - ...(standalone || preflight !== undefined ? ['createCanonicalEventProps'] : []), - ...(preflight !== undefined ? ['executeEventPreflight', 'projectEventPreflightResult'] : []), + ...(standalone ? ['createCanonicalEventProps'] : []), ...(standalone ? ['projectEventDocument'] : []), 'validateNativeEventEnvelope', ]), @@ -687,7 +684,7 @@ const eventRouteHookWrapperSource = ( // MCP process, which applied the layer itself when it started. First, // so it evaluates before every other module of the bundle — including a // preflight leaf that may read `process.env`. - ...(standalone || preflight !== undefined ? [operatorEnvLayerImport] : []), + ...(standalone ? [operatorEnvLayerImport] : []), "import { dirname, resolve } from 'node:path';", ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), ...(standalone @@ -704,7 +701,6 @@ const eventRouteHookWrapperSource = ( : []), `import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, `import { ${projectBindings.join(', ')} } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, - ...(preflight === undefined ? [] : [`import preflight from ${JSON.stringify(preflight.source)};`]), '', `const artifactEpoch = ${JSON.stringify(eventArtifactEpochToken)};`, ...(standalone ? [`const flightArtifactEpoch = ${JSON.stringify(eventFlightArtifactEpochToken)};`] : []), @@ -794,8 +790,8 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal, props) => {', - ' const resolved = props ?? createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + 'const runStandalone = async (native, signal) => {', + ' const resolved = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', ...(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;', @@ -829,42 +825,19 @@ const eventRouteHookWrapperSource = ( ' 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 });', ' const controller = new AbortController();', - ...(preflight === undefined - ? [] - : [ - ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', - ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', - ' 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" } },', - ' });', - ' if (gate !== "execute") {', - ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', - ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', - ' return;', - ' }', - ]), ' let output;', ' if (runtimeMode === "standalone") {', ...(standalone - ? [preflight === undefined - ? ' output = await runStandalone(native, controller.signal);' - : ' output = await runStandalone(native, signal, props);'] + ? [' output = await runStandalone(native, controller.signal);'] : [' fail("standalone runtime was not compiled");']), ' } else {', ' try {', - ...(preflight === undefined - ? [' 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, signal, target, timeoutMs });']), + ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs });', ' } catch (error) {', ...(standalone ? [ ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', - preflight === undefined - ? ' output = await runStandalone(native, controller.signal);' - : ' output = await runStandalone(native, signal, props);', + ' output = await runStandalone(native, controller.signal);', ] : [' throw error;']), ' }', @@ -893,7 +866,14 @@ const eventRoutePreflightWrapperSource = ( const route = entry.hook.eventRoute!; const preflight = eventRoutePreflight(route)!; const executorFile = entry.relativePath.split('/').at(-1)!.replace(/\.mjs$/u, '.execute.mjs'); - const projectBindings = ['createCanonicalEventProps', 'executeEventPreflight', 'projectEventPreflightResult', 'validateNativeEventEnvelope']; + const projectBindings = [ + 'createCanonicalEventProps', + 'createEventTracer', + 'eventTraceExecution', + 'executeEventPreflight', + 'projectEventPreflightResult', + 'validateNativeEventEnvelope', + ]; return [ operatorEnvLayerImport, "import { spawn } from 'node:child_process';", @@ -905,6 +885,7 @@ const eventRoutePreflightWrapperSource = ( `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}`); };', @@ -915,6 +896,7 @@ const eventRoutePreflightWrapperSource = ( ' 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; }', @@ -937,17 +919,19 @@ const eventRoutePreflightWrapperSource = ( ' const native = validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target });', ' const 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 output = await runExecutor(input, signal);', ' if (output.length > 0) process.stdout.write(output);', '};', @@ -1335,7 +1319,6 @@ export const planHooks = ( wrapper, contract.hostContractRevision ?? target, model.state?.lifetime === 'workspace-durable', - false, ), }), virtualSource: hook.eventRoute === undefined diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index bfbddafd9..1732d8f68 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -87,7 +87,9 @@ export { createEventTracer, eventTraceEventKinds, eventTraceExecution, + eventTraceObserver, eventTracePhases, + installEventTraceObserver, summarizeEventTraceError, } from './events/trace.ts'; export type { diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index d9e0a1f5b..c10b6687a 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -2,6 +2,7 @@ import { settleBeforeAbort } from '../core/abort.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). @@ -137,15 +138,24 @@ export const validateEventPreflightResult = ( export const executeEventPreflight = async ( preflight: EventPreflight, context: EventPreflightContext, + trace?: EventTracer, ): Promise => { - 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(); - return validateEventPreflightResult(value, context.canonical.event); + 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 f803e55be..09844fe1f 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -15,9 +15,11 @@ export { } from './preflight.ts'; export { createEventTracer, + eventTraceObserver, eventTraceEventKinds, eventTraceExecution, eventTracePhases, + installEventTraceObserver, summarizeEventTraceError, type CreateEventTracerOptions, type EventTraceErrorSummary, diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index 6a6e80e55..3913bdcf1 100644 --- a/packages/agent-bundle/src/events/trace.ts +++ b/packages/agent-bundle/src/events/trace.ts @@ -119,6 +119,27 @@ export type EventTraceEvent = /** 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. @@ -260,7 +281,8 @@ const disabledTracer = (execution: EventTraceExecution): EventTracer => { * the observer never changes what the caller sees. */ export const createEventTracer = (options: CreateEventTracerOptions): EventTracer => { - const { execution, observer } = options; + const execution = options.execution; + const observer = options.observer ?? eventTraceObserver(); if (observer === undefined) return disabledTracer(execution); const now = options.now ?? (() => performance.now()); let sequence = 0; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index c0dfc47d3..cde6b978a 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -100,7 +100,9 @@ export { createEventTracer, eventTraceEventKinds, eventTraceExecution, + eventTraceObserver, eventTracePhases, + installEventTraceObserver, summarizeEventTraceError, } from './events/trace.ts'; export type { diff --git a/packages/agent-bundle/tests/event-trace.test.ts b/packages/agent-bundle/tests/event-trace.test.ts index 2c8327a77..a5de15967 100644 --- a/packages/agent-bundle/tests/event-trace.test.ts +++ b/packages/agent-bundle/tests/event-trace.test.ts @@ -4,7 +4,9 @@ import { createEventTracer, eventTraceEventKinds, eventTraceExecution, + eventTraceObserver, eventTracePhases, + installEventTraceObserver, summarizeEventTraceError, type EventTraceErrorSummary, type EventTraceEvent, @@ -187,6 +189,19 @@ it('emits a complete executing trace with monotonic sequence, timestamps, and ph 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 }); diff --git a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts index 8133cfd6c..4e342bc6d 100644 --- a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -12,6 +12,7 @@ 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 @@ -23,11 +24,7 @@ import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from '../sr * `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` - * (rstest.integration-tests.ts) when it lands; until then it carries its own - * timeouts. Contract seams the compiler does not fill yet on this branch: - * the standalone wrapper never imports the preflight leaf, and it inlines - * `@agent-bundle/runtime` (React, the Flight client, the render dispatcher). + * Runs a real Rslib build, so it belongs in `integrationTestFiles`. */ const sentinels = Object.freeze({ @@ -67,7 +64,8 @@ const projectFiles: Readonly> = { '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 : '';", - " return mentionsCargo(command) ? 'execute' : { outcome: 'deny', reason: PREFLIGHT_LEAF_SENTINEL };", + " if (mentionsCargo(command)) return 'execute';", + " return command === 'blocked' ? { outcome: 'deny', reason: PREFLIGHT_LEAF_SENTINEL } : { outcome: 'continue' };", '};', '', ].join('\n'), @@ -79,7 +77,7 @@ const projectFiles: Readonly> = { "export { default as preflight } from './before.preflight.js';", "export const config = { providers: ['daemonProbe'], runtime: 'standalone' };", 'export default async function ToolBefore({ canonical }) {', - ' return {RENDERED_ROUTE_SENTINEL};', + " return {canonical.event};", '}', '', ].join('\n'), @@ -284,6 +282,41 @@ describe('preflight artifact graph (#595)', () => { 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); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 6c1090b26..4c43a2ce1 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -331,8 +331,9 @@ payload-free view of the event kernel: `preflight.start`, `preflight.outcome`, ` 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. -The trace types and helpers are exported from `agent-bundle`, `agent-bundle/api`, and -`agent-bundle/event-project`. +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 diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index e92080ba4..448e93dcc 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -276,7 +276,8 @@ notices、lineage 存储、渲染式布局、应用 provider、React/RSC 辅助 都携带同一份 `EventTraceExecution` 身份、单调时间戳和序号,并且只包含该阶段的元数据; 失败使用有长度上限且不含堆栈的 `EventTraceErrorSummary`。没有 observer 时 `createEventTracer` 是空操作;observer 抛错也不会改变执行。相关类型与辅助函数从 -`agent-bundle`、`agent-bundle/api` 和 `agent-bundle/event-project` 导出。 +开发工具通过 `installEventTraceObserver` 安装进程级 sink;返回的 disposer 会恢复之前的 sink。 +相关类型与辅助函数从 `agent-bundle` 和 `agent-bundle/api` 导出。 ### 声明路由的 provider From 749660d6f4a4fc436288773315c13d2bdc3f9a9f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:30:09 +0000 Subject: [PATCH 08/16] docs(events): clarify emitted trace phases --- packages/agent-bundle/src/adapters/hook-contract.ts | 3 ++- website/docs/en/guide/authoring/hooks.mdx | 7 +++++-- website/docs/zh/guide/authoring/hooks.mdx | 9 +++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 423772003..3372555ab 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -932,7 +932,8 @@ const eventRoutePreflightWrapperSource = ( ' return;', ' }', ' trace.executeStart(runtimeMode);', - ' const output = await runExecutor(input, signal);', + ' let output;', + ' try { output = await runExecutor(input, signal); } catch (error) { trace.failure("execute", error); throw error; }', ' if (output.length > 0) process.stdout.write(output);', '};', 'if (import.meta.main) {', diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 4c43a2ce1..a8db18058 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -326,8 +326,11 @@ envelope verbatim as `context.nativeInput` and runs in-process in the wrapper. ` ### Execution trace events The exported `EventTraceEvent` discriminated union gives developer tooling one stable, -payload-free view of the event kernel: `preflight.start`, `preflight.outcome`, `execute.start`, -`providers.start` / `providers.finish`, `render.start` / `render.finish`, and terminal `failure`. +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. diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 448e93dcc..c266525db 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -270,12 +270,13 @@ notices、lineage 存储、渲染式布局、应用 provider、React/RSC 辅助 ### 执行跟踪事件 -导出的 `EventTraceEvent` 可辨识联合为开发工具提供稳定且不含载荷的事件内核视图: +导出的 `EventTraceEvent` 可辨识联合为开发工具提供稳定且不含载荷的事件内核词汇: `preflight.start`、`preflight.outcome`、`execute.start`、`providers.start` / `providers.finish`、`render.start` / `render.finish`,以及终止性的 `failure`。每个冻结事件 -都携带同一份 `EventTraceExecution` 身份、单调时间戳和序号,并且只包含该阶段的元数据; -失败使用有长度上限且不含堆栈的 `EventTraceErrorSummary`。没有 observer 时 -`createEventTracer` 是空操作;observer 抛错也不会改变执行。相关类型与辅助函数从 +都携带同一份 `EventTraceExecution` 身份、单调时间戳和序号,并且只包含该阶段的元数据。 +生成的 preflight shell 当前会发出 preflight 与 execute 边界及这两个阶段的失败;provider +和 render 边界保留给统一 trace 消费方在延迟 worker 内连接。失败使用有长度上限且不含堆栈的 +`EventTraceErrorSummary`。没有 observer 时 `createEventTracer` 是空操作;observer 抛错也不会改变执行。 开发工具通过 `installEventTraceObserver` 安装进程级 sink;返回的 disposer 会恢复之前的 sink。 相关类型与辅助函数从 `agent-bundle` 和 `agent-bundle/api` 导出。 From 62476f20793c786c49442e06b6e9326c6ce9302d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:35:46 +0000 Subject: [PATCH 09/16] docs(changeset): reference pull request 618 --- .changeset/595-event-preflight-gates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/595-event-preflight-gates.md b/.changeset/595-event-preflight-gates.md index 0739b1f13..35cf8e776 100644 --- a/.changeset/595-event-preflight-gates.md +++ b/.changeset/595-event-preflight-gates.md @@ -2,4 +2,4 @@ "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. (#595) +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) From 56586aacd0c19a254fa19b806efc8a10bfcc7181 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:45:03 +0000 Subject: [PATCH 10/16] refactor(events): simplify preflight implementation --- .../src/adapters/hook-contract.ts | 35 ++++++------------- packages/agent-bundle/src/events/preflight.ts | 8 ++--- packages/agent-bundle/src/events/trace.ts | 8 ----- packages/agent-bundle/src/test/providers.ts | 10 +++--- 4 files changed, 18 insertions(+), 43 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 3372555ab..3b0d56469 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -632,7 +632,6 @@ export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFA const standaloneEventRoute = (route: NonNullable): boolean => route.runtime === 'standalone' || route.fallback === 'standalone'; -/** The independently bundleable preflight leaf on an event route, when present. */ export const eventRoutePreflight = ( route: NormalizedHook['eventRoute'], ): NonNullable['preflight'] => route?.preflight; @@ -672,11 +671,8 @@ const eventRouteHookWrapperSource = ( // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; const projectBindings = [ - ...new Set([ - ...(standalone ? ['createCanonicalEventProps'] : []), - ...(standalone ? ['projectEventDocument'] : []), - 'validateNativeEventEnvelope', - ]), + ...(standalone ? ['createCanonicalEventProps', 'projectEventDocument'] : []), + 'validateNativeEventEnvelope', ]; return [ // Only a wrapper that can render in-process needs the operator `.env` @@ -1311,26 +1307,17 @@ export const planHooks = ( ...(timeout === undefined ? {} : { timeout }), }; const preflight = eventRoutePreflight(hook.eventRoute); + const hostRevision = contract.hostContractRevision ?? target; + const durableLineage = model.state?.lifetime === 'workspace-durable'; + const renderedSource = hook.eventRoute === undefined + ? contract.wrapperSource(wrapper) + : eventRouteHookWrapperSource(wrapper, hostRevision, durableLineage); hookEntries.push({ ...wrapper, - ...(hook.eventRoute === undefined || preflight === undefined - ? {} - : { - executeVirtualSource: eventRouteHookWrapperSource( - wrapper, - contract.hostContractRevision ?? target, - model.state?.lifetime === 'workspace-durable', - ), - }), - virtualSource: hook.eventRoute === undefined - ? contract.wrapperSource(wrapper) - : preflight === undefined - ? eventRouteHookWrapperSource( - wrapper, - contract.hostContractRevision ?? target, - model.state?.lifetime === 'workspace-durable', - ) - : eventRoutePreflightWrapperSource(wrapper, contract.hostContractRevision ?? target), + ...(preflight === undefined ? {} : { executeVirtualSource: renderedSource }), + virtualSource: preflight === undefined + ? renderedSource + : eventRoutePreflightWrapperSource(wrapper, hostRevision), }); } diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index c10b6687a..f067e2751 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -1,4 +1,5 @@ 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'; @@ -23,13 +24,11 @@ export type EventPreflightResult = */ export interface EventPreflightContext { readonly canonical: AgentEventCanonicalIdentity; - /** Target-specific host identity already known from the compiled hook. */ readonly host: Readonly<{ readonly name: string; readonly nativeEvent: string }>; readonly signal: AbortSignal; readonly terminal: AgentTerminal; } -/** Sync or async gate export on an event route module. */ export type EventPreflight = ( context: EventPreflightContext, ) => EventPreflightResult | Promise; @@ -39,9 +38,6 @@ type PreflightObjectOutcome = 'continue' | 'deny'; const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome => value === 'continue' || value === 'deny'; -const isPlainObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const unsupportedResult = (detail: string): never => { throw new TypeError(`Event preflight result ${detail}`); }; @@ -103,7 +99,7 @@ export const validateEventPreflightResult = ( event: CanonicalAgentEvent, ): EventPreflightResult => { if (value === 'execute') return 'execute'; - if (!isPlainObject(value)) { + if (!isRecord(value)) { return unsupportedResult('must be "execute" or a continue/deny object.'); } const outcome = value.outcome; diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index 3913bdcf1..cbcfa528a 100644 --- a/packages/agent-bundle/src/events/trace.ts +++ b/packages/agent-bundle/src/events/trace.ts @@ -73,7 +73,6 @@ interface EventTraceEventBase; export interface EventTraceProvidersFinish extends EventTraceEventBase<'providers.finish', 'providers'> { - /** Providers materialized for this request. */ readonly count: number; /** Present when `providers.start` was observed on this tracer. */ readonly durationMs?: number; @@ -306,12 +304,6 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace } }; - /** - * `build` receives the timestamp, the next sequence number, and the trace's - * first timestamp before this event (undefined when this is the first). - * The sequence advances only when an event is actually built, so a broken - * clock leaves no gap. - */ const emit = (build: (at: number, sequence: number, traceStartedAt: number | undefined) => EventTraceEvent): void => { if (closed) return; const at = readClock(); diff --git a/packages/agent-bundle/src/test/providers.ts b/packages/agent-bundle/src/test/providers.ts index f1df9f3ca..283222451 100644 --- a/packages/agent-bundle/src/test/providers.ts +++ b/packages/agent-bundle/src/test/providers.ts @@ -78,10 +78,10 @@ export const selectManifestProviderDescriptors = ( 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.some((key) => typeof key !== 'string')) - ) { + 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.`, @@ -89,7 +89,7 @@ export const selectManifestProviderDescriptors = ( } const selection = selectRequiredProviders( descriptors, - declaration as readonly string[] | undefined, + declaration === undefined ? undefined : declaration, ); if (!selection.ok) { throw new AgentTestError( From 94deab9a3dd0cdfd1c45cfce67956c4279ef4046 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:51:30 +0000 Subject: [PATCH 11/16] refactor(events): remove trivial preflight accessor and redundant wrapper-plan indirection Deslop of the #595 branch, behavior unchanged: drop the one-line eventRoutePreflight helper in favor of ?.preflight at its call sites, collapse the pointless Set spread when composing project-runtime import bindings, remove a redundant eventRoute undefined check in planHooks, restore hookEntries in inspect-bundler to a single map expression without the dead nested runtime-path check, and fix inconsistent indentation left by the planHooksSurface and normalizeHooks edits. Co-authored-by: Zack Jackson --- .../src/adapters/hook-contract.ts | 20 +++------ packages/agent-bundle/src/build/entries.ts | 41 +++++++++---------- .../agent-bundle/src/build/inspect-bundler.ts | 14 +++---- packages/agent-bundle/src/config/normalize.ts | 10 ++--- 4 files changed, 36 insertions(+), 49 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 3372555ab..c2244d0d5 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -632,11 +632,6 @@ export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFA const standaloneEventRoute = (route: NonNullable): boolean => route.runtime === 'standalone' || route.fallback === 'standalone'; -/** The independently bundleable preflight leaf on an event route, when present. */ -export const eventRoutePreflight = ( - route: NormalizedHook['eventRoute'], -): NonNullable['preflight'] => route?.preflight; - /** * True when a wrapper runs plugin code in its own process and therefore * imports the operator `.env` layer (#469): every handler-executing wrapper, @@ -649,7 +644,7 @@ export const eventRoutePreflight = ( export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean => entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute) - || eventRoutePreflight(entry.hook.eventRoute) !== undefined; + || entry.hook.eventRoute.preflight !== undefined; /** * The wrapper reaches the warm MCP runtime through an endpoint identified by @@ -672,11 +667,8 @@ const eventRouteHookWrapperSource = ( // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; const projectBindings = [ - ...new Set([ - ...(standalone ? ['createCanonicalEventProps'] : []), - ...(standalone ? ['projectEventDocument'] : []), - 'validateNativeEventEnvelope', - ]), + ...(standalone ? ['createCanonicalEventProps', 'projectEventDocument'] : []), + 'validateNativeEventEnvelope', ]; return [ // Only a wrapper that can render in-process needs the operator `.env` @@ -864,7 +856,7 @@ const eventRoutePreflightWrapperSource = ( hostContractRevision: string, ): string => { const route = entry.hook.eventRoute!; - const preflight = eventRoutePreflight(route)!; + const preflight = route.preflight!; const executorFile = entry.relativePath.split('/').at(-1)!.replace(/\.mjs$/u, '.execute.mjs'); const projectBindings = [ 'createCanonicalEventProps', @@ -1310,10 +1302,10 @@ export const planHooks = ( target, ...(timeout === undefined ? {} : { timeout }), }; - const preflight = eventRoutePreflight(hook.eventRoute); + const preflight = hook.eventRoute?.preflight; hookEntries.push({ ...wrapper, - ...(hook.eventRoute === undefined || preflight === undefined + ...(preflight === undefined ? {} : { executeVirtualSource: eventRouteHookWrapperSource( diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index e2c75754c..b48f8e3ca 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -9,7 +9,6 @@ import { eventFlightArtifactEpochToken, eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier, - eventRoutePreflight, hookWrapperAppliesOperatorEnv, type TargetHookEntry, } from '../adapters/hook-contract.ts'; @@ -557,7 +556,7 @@ export const planMcpEntriesSurface = async ( }; const hookEntrySourceInputs = (entry: TargetHookEntry): readonly string[] => { - const preflight = eventRoutePreflight(entry.hook.eventRoute); + const preflight = entry.hook.eventRoute?.preflight; return Object.freeze([ entry.hook.provenance.sourcePath, entry.hook.source, @@ -661,7 +660,6 @@ export const planHooksSurface = ( entries: [ ...compiled.flatMap((entry, index) => { const hook = entries[index]!; - const executorRelativePath = hook.relativePath.replace(/\.mjs$/u, '.execute.mjs'); const aliases = { [launchEnvRuntimeSpecifier]: launchEnvRuntime, ...(hook.hook.eventRoute === undefined || eventIpcRuntime === undefined @@ -672,26 +670,27 @@ export const planHooksSurface = ( }), }; 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()] } : {}), + // 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, { diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 5830e93a3..3415f47e8 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -323,18 +323,15 @@ const hookEntries = ( tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { const outputRoot = artifactOutputToken; - return entries.map((entry) => { - const eventIpcRuntime = entry.hook.eventRoute === undefined ? undefined : eventRuntimeModulePath('ipc'); - const eventProjectRuntime = entry.hook.eventRoute === undefined ? undefined : eventRuntimeModulePath('project'); - return rslibInspectionEntry({ + return entries.map((entry) => rslibInspectionEntry({ entry: { aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), - ...(eventIpcRuntime === undefined + ...(entry.hook.eventRoute === undefined ? {} : { - [eventIpcRuntimeSpecifier]: eventIpcRuntime, - ...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }), + [eventIpcRuntimeSpecifier]: eventRuntimeModulePath('ipc'), + [eventProjectRuntimeSpecifier]: eventRuntimeModulePath('project'), }), }, name: entry.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), @@ -353,8 +350,7 @@ const hookEntries = ( source: entry.hook.source, target, ...(tools === undefined ? {} : { tools }), - }); - }); + })); }; const mcpAppsEntry = ( diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 21000460f..cba213b70 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -542,11 +542,11 @@ const normalizeHooks = ( ...(route.preflight === undefined ? {} : { - preflight: { - provenance: { ...route.preflight.provenance }, - source: route.preflight.source, - }, - }), + preflight: { + provenance: { ...route.preflight.provenance }, + source: route.preflight.source, + }, + }), ...(providers === undefined ? {} : { providers }), runtime, }), From e57a8507ab77ab589857bb939cad8fe1c051713b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:59:55 +0000 Subject: [PATCH 12/16] fix(events): scope deadlines to preflight --- package.json | 1 + packages/agent-bundle/src/adapters/hook-contract.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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 3b0d56469..7a8f3e2bf 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -913,7 +913,8 @@ const eventRoutePreflightWrapperSource = ( ' 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 signal = AbortSignal.timeout(timeoutMs);', + ' 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, {', @@ -929,7 +930,7 @@ const eventRoutePreflightWrapperSource = ( ' }', ' trace.executeStart(runtimeMode);', ' let output;', - ' try { output = await runExecutor(input, signal); } catch (error) { trace.failure("execute", error); throw error; }', + ' try { output = await runExecutor(input, controller.signal); } catch (error) { trace.failure("execute", error); throw error; }', ' if (output.length > 0) process.stdout.write(output);', '};', 'if (import.meta.main) {', From 4cd6f87e0c3bf1cc8cb2a7731357f58b73789c1c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 09:10:52 +0000 Subject: [PATCH 13/16] fix(events): forward hook termination to executor --- .../agent-bundle/src/adapters/hook-contract.ts | 15 ++++++++++++--- packages/agent-bundle/src/test/providers.ts | 2 +- .../tests/target-hook-contract.test.ts | 16 ++++++++++------ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 9e70d6cd5..b29645469 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -674,8 +674,7 @@ const eventRouteHookWrapperSource = ( // Only a wrapper that can render in-process needs the operator `.env` // layer (#469): a shared-runtime wrapper forwards the event to the warm // MCP process, which applied the layer itself when it started. First, - // so it evaluates before every other module of the bundle — including a - // preflight leaf that may read `process.env`. + // so it evaluates before every other module of the bundle. ...(standalone ? [operatorEnvLayerImport] : []), "import { dirname, resolve } from 'node:path';", ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), @@ -925,8 +924,18 @@ const eventRoutePreflightWrapperSource = ( ' return;', ' }', ' trace.executeStart(runtimeMode);', + ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', + ' const terminate = () => controller.abort();', + ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', ' let output;', - ' try { output = await runExecutor(input, controller.signal); } catch (error) { trace.failure("execute", error); throw error; }', + ' try {', + ' output = await runExecutor(input, 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) {', diff --git a/packages/agent-bundle/src/test/providers.ts b/packages/agent-bundle/src/test/providers.ts index 283222451..befcdc715 100644 --- a/packages/agent-bundle/src/test/providers.ts +++ b/packages/agent-bundle/src/test/providers.ts @@ -89,7 +89,7 @@ export const selectManifestProviderDescriptors = ( } const selection = selectRequiredProviders( descriptors, - declaration === undefined ? undefined : declaration, + declaration, ); if (!selection.ok) { throw new AgentTestError( diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index afffab3a2..d47ab7c49 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -413,9 +413,11 @@ it('runs event-route preflight in the per-host wrapper before shared IPC', () => expect(source).toContain('createCanonicalEventProps'); expect(source).toContain('executeEventPreflight'); expect(source).toContain('projectEventPreflightResult'); - expect(source).toContain('requestEventRuntime'); + expect(entry.executeVirtualSource).toContain('requestEventRuntime'); expect(source).toContain('const timeoutMs = 1250;'); - expect(source).toMatch(/AbortSignal\.timeout|controller\.abort|signal\.throwIfAborted/u); + expect(source).toContain('AbortSignal.timeout(timeoutMs)'); + expect(source).toContain('process.once(terminationSignal, terminate)'); + expect(source).toContain('process.off(terminationSignal, terminate)'); expect(source).not.toContain('AGENT_BUNDLE_HOOK_HOST'); expect(source).not.toContain('createAgentRenderDispatcher'); expect(source).not.toContain('import * as routeModule'); @@ -429,7 +431,7 @@ it('runs event-route preflight in the per-host wrapper before shared IPC', () => 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, 'requestEventRuntime')); + expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); expect(firstIndex(runBody, 'projectEventPreflightResult')).toBeGreaterThan(firstIndex(runBody, 'executeEventPreflight')); }); @@ -449,15 +451,17 @@ it('crosses the standalone Worker boundary only after preflight returns execute' wrapperPath: (candidate) => `hooks/${candidate.name}.synthetic.mjs`, wrapperSource: () => 'config-hook-only\n', }; - const source = planHooks(planningModel([hook]), 'synthetic', contract).hookEntries[0]!.virtualSource; + 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 */ "./hooks-flight.mjs", import.meta.url)'); + 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(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, 'runStandalone')); + expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); }); it('continues planning valid hooks after a prior hook mapping error', () => { From 9c089d8ae0932f3ddaee222a2d107d7fd85f3982 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 09:23:41 +0000 Subject: [PATCH 14/16] fix(events): preserve deferred canonical identity --- .../src/adapters/hook-contract.ts | 31 +++++++++++++------ packages/agent-bundle/src/events/ipc.ts | 4 +++ .../agent-bundle/src/events/projection.ts | 5 +-- packages/agent-bundle/src/events/trace.ts | 9 ++++-- .../agent-bundle/src/mcp-server-runtime.ts | 3 ++ packages/agent-bundle/src/routes/graph.ts | 20 +++++++++++- .../agent-bundle/tests/event-trace.test.ts | 16 ++++++++++ .../agent-bundle/tests/route-graph.test.ts | 20 ++++++++++++ .../tests/route-unit/event-project.test.ts | 23 ++++++++++++++ .../tests/target-hook-contract.test.ts | 4 +++ 10 files changed, 120 insertions(+), 15 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index b29645469..741f40f61 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -659,6 +659,7 @@ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, durableLineage = false, + deferredExecution = false, ): string => { const route = entry.hook.eventRoute!; const standalone = standaloneEventRoute(route); @@ -781,8 +782,8 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal) => {', - ' const resolved = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + '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;', @@ -809,26 +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);'] + ? [' 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;']), ' }', @@ -924,12 +936,13 @@ const eventRoutePreflightWrapperSource = ( ' 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(input, controller.signal);', + ' output = await runExecutor(executionInput, controller.signal);', ' } catch (error) {', ' trace.failure("execute", error);', ' throw error;', @@ -1317,7 +1330,7 @@ export const planHooks = ( const durableLineage = model.state?.lifetime === 'workspace-durable'; const renderedSource = hook.eventRoute === undefined ? contract.wrapperSource(wrapper) - : eventRouteHookWrapperSource(wrapper, hostRevision, durableLineage); + : eventRouteHookWrapperSource(wrapper, hostRevision, durableLineage, preflight !== undefined); hookEntries.push({ ...wrapper, ...(preflight === undefined ? {} : { executeVirtualSource: renderedSource }), diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index 572b81377..c539cae3b 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; } diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 32b447633..b72db4a5a 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -435,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({ @@ -444,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, @@ -452,7 +453,7 @@ export const createCanonicalEventProps = ( nativeEvent, source: 'native', }), - sequence: ++eventSequence, + sequence: observation?.sequence ?? ++eventSequence, }); return Object.freeze({ canonical, native, signal }); }; diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index cbcfa528a..919e026ad 100644 --- a/packages/agent-bundle/src/events/trace.ts +++ b/packages/agent-bundle/src/events/trace.ts @@ -304,7 +304,10 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace } }; - const emit = (build: (at: number, sequence: number, traceStartedAt: number | undefined) => EventTraceEvent): void => { + const emit = ( + build: (at: number, sequence: number, traceStartedAt: number | undefined) => EventTraceEvent, + terminal = false, + ): void => { if (closed) return; const at = readClock(); if (at === undefined) return; @@ -312,6 +315,7 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace firstAt ??= at; const event = build(at, sequence, traceStartedAt); sequence += 1; + if (terminal) closed = true; deliver(Object.freeze(event)); }; @@ -335,8 +339,7 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace kind: 'failure', phase, sequence: next, - })); - closed = true; + }), true); }, preflightOutcome: (result) => { const outcome = preflightOutcomeOf(result); 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/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 81b6cca4a..202edffa4 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -743,7 +743,25 @@ export const compileRouteGraph = async ( moduleTextBySource.set(source, moduleText); const discovery = discoverEventRoutePreflight(moduleText, relativePath, source); preflightDiscoveryBySource.set(source, discovery); - if (discovery.candidateSource !== undefined) preflightSupportSources.add(discovery.candidateSource); + } + 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(); diff --git a/packages/agent-bundle/tests/event-trace.test.ts b/packages/agent-bundle/tests/event-trace.test.ts index a5de15967..4afde1f0d 100644 --- a/packages/agent-bundle/tests/event-trace.test.ts +++ b/packages/agent-bundle/tests/event-trace.test.ts @@ -260,6 +260,22 @@ it('records a terminal failure with an error-safe summary and then goes quiet', 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 }); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index eae159da6..015277d80 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -2271,6 +2271,26 @@ it('attaches a statically followable event preflight re-export to the event rout 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 => [ 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 d47ab7c49..0adbdbe83 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -418,6 +418,9 @@ it('runs event-route preflight in the per-host wrapper before shared IPC', () => 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'); @@ -458,6 +461,7 @@ it('crosses the standalone Worker boundary only after preflight returns execute' 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)); From ce16f1f771e2ca5eea02250ee56b87f13d776023 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 09:28:55 +0000 Subject: [PATCH 15/16] fix(events): relay observations through IPC --- packages/agent-bundle/src/events/ipc.ts | 4 ++++ packages/agent-bundle/tests/event-ipc.test.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index c539cae3b..c0a820208 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -341,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') { @@ -1140,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/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, }); })); From 3311f04c719d7066c168b90a4b7f6135f5495af8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 09:37:10 +0000 Subject: [PATCH 16/16] test(events): follow canonical input naming --- packages/agent-bundle/tests/claude-hook-event-name.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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);