From f2b3ce826733ffebb21a8eedc6745219707957a7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 03:12:12 +0000 Subject: [PATCH 1/4] Revert "feat(events): pass preflight data to routes (#664)" This reverts commit d94223a663c5be3fda85f8809285512b5676e634. --- .changeset/661-preflight-data.md | 5 --- .../src/adapters/hook-contract.ts | 19 +++++----- .../agent-bundle/src/build/entry-shell.ts | 2 +- .../dev/routes/route-invocation-production.ts | 20 ++-------- packages/agent-bundle/src/events/ipc.ts | 5 --- packages/agent-bundle/src/events/preflight.ts | 38 +++++++------------ .../agent-bundle/src/events/projection.ts | 2 +- packages/agent-bundle/src/events/trace.ts | 2 +- .../agent-bundle/src/mcp-server-runtime.ts | 9 +---- packages/agent-bundle/src/routes/public.ts | 9 +---- packages/agent-bundle/src/test/event-input.ts | 4 +- packages/agent-bundle/src/test/render.ts | 9 +---- .../agent-bundle/tests/entry-shell.test.ts | 3 +- packages/agent-bundle/tests/event-ipc.test.ts | 5 --- .../tests/event-preflight.test.ts | 17 ++------- .../tests/preflight-artifact-graph.test.ts | 38 +++++++++---------- .../tests/route-invocation-dev-server.test.ts | 12 ++---- .../tests/target-hook-contract.test.ts | 4 +- website/docs/en/guide/authoring/hooks.mdx | 7 +--- website/docs/zh/guide/authoring/hooks.mdx | 5 +-- 20 files changed, 66 insertions(+), 149 deletions(-) delete mode 100644 .changeset/661-preflight-data.md diff --git a/.changeset/661-preflight-data.md b/.changeset/661-preflight-data.md deleted file mode 100644 index 1c359185c..000000000 --- a/.changeset/661-preflight-data.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'agent-bundle': patch ---- - -Pass typed JSON data from an event preflight gate to its rendered route through `AgentEventRouteProps.preflight` (#664) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 959d5d0d5..ef73058aa 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -790,7 +790,7 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal, observation, preflight) => {', + '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;', @@ -808,7 +808,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: resolved.canonical, native: resolved.native, ...(preflight === undefined ? {} : { preflight }) } } }, signal));', + ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native } } }, signal));', ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', '};', ] @@ -826,30 +826,29 @@ const eventRouteHookWrapperSource = ( ...(deferredExecution ? [ ' if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) fail("deferred input must be an object");', - ' const { native: nativeInput, observedAt, preflight, sequence } = parsed;', + ' 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 preflight = undefined;', ]), ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', ' const controller = new AbortController();', ' let output;', ' if (runtimeMode === "standalone") {', ...(standalone - ? [' output = await runStandalone(native, controller.signal, observation, preflight);'] + ? [' 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, observedAt: observation?.observedAt, preflight, sequence: observation?.sequence, 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, observation, preflight);', + ' output = await runStandalone(native, controller.signal, observation);', ] : [' throw error;']), ' }', @@ -911,7 +910,7 @@ const eventRoutePreflightWrapperSource = ( ' signal,', ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', ' }, trace);', - ' const projected = gate === "execute" || gate.outcome === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' const projected = gate === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', ' return Object.freeze({ gate, native, projected, props, runtime: runtimeMode, trace });', '};', 'const runExecutor = (input, signal) => new Promise((resolve, reject) => {', @@ -944,12 +943,12 @@ const eventRoutePreflightWrapperSource = ( ' const controller = new AbortController();', ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', ' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal);', - ' if (gate !== "execute" && gate.outcome !== "execute") {', + ' if (gate !== "execute") {', ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', ' return;', ' }', ' trace.executeStart(runtimeMode);', - ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, ...(gate === "execute" ? {} : { preflight: gate.data }), sequence: props.canonical.sequence }));', + ' 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);', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index b0ae6a780..1f2c599d5 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -1167,7 +1167,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ' }, async () => {', ' let validationError;', " const props = message.invocation.kind === 'event'", - ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), ...(message.invocation.props.payload.preflight === undefined ? {} : { preflight: Object.freeze(message.invocation.props.payload.preflight) }), signal: controller.signal })', + ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', // The MCP server hands the worker input the SDK already validated; only // the Workbench, which bypasses the SDK, asks the worker to validate. ' : message.validateInput !== true ? { input: message.invocation.props.input, signal: controller.signal } : (() => {', diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index c82a1b7d9..cce484cb0 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -15,7 +15,6 @@ import { } from '@agent-bundle/runtime'; import { renderedDocumentExitCode } from '../../cli-entry.ts'; -import type { EventPreflightResult } from '../../events/preflight.ts'; import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../../core/types.ts'; @@ -39,7 +38,7 @@ interface CompiledCliInvocationModule { } interface CompiledEventPreflight { - readonly gate: EventPreflightResult; + readonly gate: 'execute' | Readonly<{ readonly outcome: 'continue' | 'deny'; readonly reason?: string }>; readonly native: JsonObject; readonly projected?: JsonObject; readonly props: Readonly<{ readonly canonical: JsonObject }>; @@ -170,13 +169,7 @@ const prepareInput = async ( const native = (request.input as { readonly native?: JsonObject }).native ?? {}; const preflight = await wrapper.prepareRouteInvocation(native, signal, (event) => traceEvents.push(event)); return { - input: { - canonical: preflight.props.canonical, - native: preflight.native, - ...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute' - ? { preflight: preflight.gate.data } - : {}), - }, + input: { canonical: preflight.props.canonical, native: preflight.native }, preflight, }; }; @@ -422,9 +415,6 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly>; readonly observedAt?: string; - readonly preflight?: JsonValue; readonly sequence?: number; readonly target: string; } @@ -345,7 +342,6 @@ const handleConnection = Effect.fnUntraced(function*( hostContractRevision: parsed.data.hostContractRevision, native: parsed.data.native, observedAt: parsed.data.observedAt, - ...(parsed.data.preflight === undefined ? {} : { preflight: snapshotStrictJsonValue(parsed.data.preflight) }), sequence: parsed.data.sequence, target: parsed.data.target, }, signal)).pipe(Effect.exit); @@ -1147,7 +1143,6 @@ const requestProgram = ( hostContractRevision: options.hostContractRevision, native: options.native, observedAt: options.observedAt, - preflight: options.preflight, protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, sequence: options.sequence, target: options.target, diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index e238214e7..f067e2751 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -1,5 +1,5 @@ import { settleBeforeAbort } from '../core/abort.ts'; -import { isRecord, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.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'; @@ -7,13 +7,12 @@ import type { EventTracer } from './trace.ts'; /** * The gate result a conventional event route's re-exported preflight may return (#595). - * The bare `execute` value or an `execute` object carrying JSON data loads the - * rendered route; `continue` passes through with no host decision; `deny` - * blocks through the existing canonical event outcome projection. + * `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 = +export type EventPreflightResult = | 'execute' - | { readonly outcome: 'execute'; readonly data: Data } | { readonly outcome: 'continue' } | { readonly outcome: 'deny'; readonly reason: string }; @@ -30,17 +29,14 @@ export interface EventPreflightContext = ( +export type EventPreflight = ( context: EventPreflightContext, -) => EventPreflightResult | Promise>; +) => EventPreflightResult | Promise; -type PreflightObjectOutcome = 'continue' | 'deny' | 'execute'; +type PreflightObjectOutcome = 'continue' | 'deny'; const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome => - value === 'continue' || value === 'deny' || value === 'execute'; + value === 'continue' || value === 'deny'; const unsupportedResult = (detail: string): never => { throw new TypeError(`Event preflight result ${detail}`); @@ -104,16 +100,13 @@ export const validateEventPreflightResult = ( ): EventPreflightResult => { if (value === 'execute') return 'execute'; if (!isRecord(value)) { - return unsupportedResult('must be "execute" or an execute/continue/deny object.'); + 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 'execute': - unexpectedFields(value, new Set(['outcome', 'data'])); - return Object.freeze({ data: snapshotStrictJsonValue(value.data), outcome: 'execute' }); case 'continue': unexpectedFields(value, new Set(['outcome'])); return Object.freeze({ outcome: 'continue' }); @@ -138,14 +131,11 @@ export const validateEventPreflightResult = ( * 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 < - E extends CanonicalAgentEvent, - Data extends JsonValue = JsonValue, ->( - preflight: EventPreflight, +export const executeEventPreflight = async ( + preflight: EventPreflight, context: EventPreflightContext, trace?: EventTracer, -): Promise> => { +): Promise => { trace?.preflightStart(); try { context.signal.throwIfAborted(); @@ -157,7 +147,7 @@ export const executeEventPreflight = async < }); const value = await settleBeforeAbort(Promise.resolve().then(() => preflight(frozenContext)), context.signal); context.signal.throwIfAborted(); - const result = validateEventPreflightResult(value, context.canonical.event) as EventPreflightResult; + const result = validateEventPreflightResult(value, context.canonical.event); trace?.preflightOutcome(result); return result; } catch (error) { diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 4efb258f1..b72db4a5a 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -920,7 +920,7 @@ export const projectEventDocument = ( * decision rules as a rendered Agent.Result, without loading the renderer. */ export const projectEventPreflightResult = ( - result: Extract, + result: Exclude, event: CanonicalAgentEvent, target: string, nativeEvent: string, diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index f3429b250..919e026ad 100644 --- a/packages/agent-bundle/src/events/trace.ts +++ b/packages/agent-bundle/src/events/trace.ts @@ -236,7 +236,7 @@ export const summarizeEventTraceError = (error: unknown): EventTraceErrorSummary }; const preflightOutcomeOf = (result: EventPreflightResult): EventTracePreflightOutcome => { - if (result === 'execute' || result.outcome === 'execute') return 'execute'; + if (result === 'execute') return 'execute'; switch (result.outcome) { case 'continue': return 'continue'; diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index e5943e508..478724f16 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -1007,14 +1007,7 @@ const startEventRuntime = async ( kind: 'event', // The event payload crosses the render boundary as data; the route // props type is what gives it shape on the other side. - props: { - event, - payload: { - canonical: props.canonical, - native: props.native, - ...(request.preflight === undefined ? {} : { preflight: request.preflight }), - } as never, - }, + props: { event, payload: { canonical: props.canonical, native: props.native } as never }, }, signal, }), diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 2b0edc756..dbaccc388 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -67,8 +67,7 @@ export type AgentEventNativePayload = Readonly>; * Props received by an event route's async default Server Component. * `canonical.payload` is the cross-host reading of the envelope for the * route's family; `native` is the frozen host envelope itself, for the - * host-specific fields the payload does not model; `preflight` is strict JSON - * returned by the route's gate with an execute outcome. + * host-specific fields the payload does not model. * * Read transport-owned request context with `await agent()` from * `@agent-bundle/runtime`. The invocation, host, session, actor, workspace, @@ -79,13 +78,9 @@ export type AgentEventNativePayload = Readonly>; * is unavailable on hook-driven event scopes. The framework never derives or * surfaces the operator's identity from a host payload. */ -export interface AgentEventRouteProps< - E extends CanonicalAgentEvent = CanonicalAgentEvent, - Preflight extends JsonValue = JsonValue, -> { +export interface AgentEventRouteProps { readonly canonical: AgentEventCanonicalIdentity; readonly native: AgentEventNativePayload; - readonly preflight?: Preflight; readonly signal: AbortSignal; } diff --git a/packages/agent-bundle/src/test/event-input.ts b/packages/agent-bundle/src/test/event-input.ts index 22b8bdc0c..021153b44 100644 --- a/packages/agent-bundle/src/test/event-input.ts +++ b/packages/agent-bundle/src/test/event-input.ts @@ -1,5 +1,4 @@ import { createCanonicalEventProps, validateNativeEventEnvelope } from '../events/project.ts'; -import type { JsonValue } from '../core/strict-json.ts'; import type { AgentEventCanonicalIdentity, AgentEventNativePayload, CanonicalAgentEvent } from '../routes/public.ts'; import { AgentTestError, captured } from './errors.ts'; @@ -21,11 +20,10 @@ export interface CreateEventRouteInputOptions { readonly validate?: boolean; } -/** The `{ canonical, native, preflight? }` half of `AgentEventRouteProps`; the harness supplies `signal`. */ +/** The `{ canonical, native }` half of `AgentEventRouteProps`; the harness supplies `signal`. */ export interface AgentEventRouteInput { readonly canonical: AgentEventCanonicalIdentity; readonly native: AgentEventNativePayload; - readonly preflight?: JsonValue; } /** diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 487e3938a..c53569de1 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -396,14 +396,9 @@ const componentProps = ( // The public event-route contract is `{ canonical, native, signal }`, // and the generated Flight worker unwraps the payload into exactly that. const payload = (invocation.props as { - readonly payload?: { readonly canonical?: unknown; readonly native?: unknown; readonly preflight?: unknown }; + readonly payload?: { readonly canonical?: unknown; readonly native?: unknown }; }).payload ?? {}; - return { - canonical: payload.canonical, - native: payload.native, - ...(payload.preflight === undefined ? {} : { preflight: payload.preflight }), - signal, - }; + return { canonical: payload.canonical, native: payload.native, signal }; } case 'cli': return { input: options.input ?? {}, signal }; diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 8b420c907..1b662827e 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -684,12 +684,11 @@ it('generates the warm react-server Flight worker separately from the MCP dispat ); expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); - expect(source).toContain('preflight: Object.freeze(message.invocation.props.payload.preflight)'); expect(source).toContain('route.module.inputSchema.parse(message.invocation.props.input)'); expect(source).toContain('message.validateInput !== true ? { input: message.invocation.props.input'); expect(source).toContain("createElement(Agent.Error, { code: 'invalid-input' }"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '68b593d21fdf4aaa5c51d99cffb1a106773e50ef1837072bfb0774699719f98c', + '9780b027d8d5fef12aa0843ba9eb5ab6bd0336ef137ec1bfa552a2ff19daa217', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts index 5f10e57aa..0215a4093 100644 --- a/packages/agent-bundle/tests/event-ipc.test.ts +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -179,8 +179,6 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so echoed: request.native, event: request.event, observedAt: request.observedAt, - preflight: request.preflight, - preflightFrozen: Object.isFrozen(request.preflight), sequence: request.sequence, }), })), @@ -198,7 +196,6 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so hostContractRevision: '2.1.250', native: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, observedAt: '2026-09-05T09:00:00.000Z', - preflight: { tickets: ['cc-7'] }, sequence: 42, signal: new AbortController().signal, target: 'claude', @@ -208,8 +205,6 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, event: 'tool/after', observedAt: '2026-09-05T09:00:00.000Z', - preflight: { tickets: ['cc-7'] }, - preflightFrozen: true, sequence: 42, }); })); diff --git a/packages/agent-bundle/tests/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts index df77b76ce..aadf6def9 100644 --- a/packages/agent-bundle/tests/event-preflight.test.ts +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -68,10 +68,6 @@ it('classifies deny legality for every canonical event family', () => { it('validates execute and continue results without a host decision', () => { expect(validateEventPreflightResult('execute', 'tool/before')).toBe('execute'); - expect(validateEventPreflightResult( - { data: { tickets: ['cc-7'] }, outcome: 'execute' }, - 'tool/after', - )).toEqual({ data: { tickets: ['cc-7'] }, outcome: 'execute' }); expect(validateEventPreflightResult({ outcome: 'continue' }, 'tool/after')).toEqual({ outcome: 'continue', }); @@ -108,9 +104,7 @@ it('rejects unsupported preflight fields and results', () => { expect(() => validateEventPreflightResult({ outcome: 'ask' }, 'tool/before')) .toThrow(/not supported/u); expect(() => validateEventPreflightResult({ outcome: 'execute' }, 'tool/before')) - .toThrow(/JSON values/u); - expect(() => validateEventPreflightResult({ data: new Date(), outcome: 'execute' }, 'tool/before')) - .toThrow(/JSON objects must be plain objects/u); + .toThrow(/not supported/u); expect(() => validateEventPreflightResult({ outcome: 'continue', reason: 'x' }, 'tool/before')) .toThrow(/unsupported field/u); expect(() => validateEventPreflightResult( @@ -184,11 +178,8 @@ it('re-exports the preflight contract through the public production path', () => expect(rootValidateEventPreflightResult).toBe(validateEventPreflightResult); expect(rootEventFamilyAllowsPreflightDeny).toBe(eventFamilyAllowsPreflightDeny); const result: PublicEventPreflightResult = publicValidateEventPreflightResult('execute', 'tool/before'); - const context: PublicEventPreflightContext<'tool/before'> = {} as EventPreflightContext<'tool/before'>; - const authoring: EventPreflight<'tool/before', { readonly ticket: string }> = () => ({ - data: { ticket: 'cc-7' }, - outcome: 'execute', - }); + const context: PublicEventPreflightContext = {} as EventPreflightContext; + const authoring: EventPreflight = () => result; expect(result).toBe('execute'); - expect(authoring(context)).toEqual({ data: { ticket: 'cc-7' }, outcome: 'execute' }); + expect(authoring(context)).toBe('execute'); }); diff --git a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts index d039cb4b5..c41934c3e 100644 --- a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -64,7 +64,7 @@ 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 : '';", - " if (mentionsCargo(command)) return { outcome: 'execute', data: { ticket: 'cc-7' } };", + " if (mentionsCargo(command)) return 'execute';", " return command === 'blocked' ? { outcome: 'deny', reason: PREFLIGHT_LEAF_SENTINEL } : { outcome: 'continue' };", '};', '', @@ -73,12 +73,11 @@ const projectFiles: Readonly> = { // artifact, with a declared provider and a deliberately heavy import graph. 'src/events/tool/before.tsx': [ "import { Agent } from '@agent-bundle/runtime';", - "import type { AgentEventRouteProps } from 'agent-bundle';", "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, preflight }: AgentEventRouteProps<'tool/before', { readonly ticket: string }>) {", - " return {canonical.event};", + 'export default async function ToolBefore({ canonical }) {', + " return {canonical.event};", '}', '', ].join('\n'), @@ -304,20 +303,20 @@ describe('preflight artifact graph (#595)', () => { })); }); - 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'), - }), - }); + 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'), + }), + }); - it('short-circuits continue and deny outcomes before deferred execution', async () => { await expect(invoke('echo hello')).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); const denied = await invoke('blocked'); expect(denied.code).toBe(0); @@ -328,16 +327,13 @@ describe('preflight artifact graph (#595)', () => { permissionDecisionReason: sentinels.preflightLeaf, }, }); - }); - - it('passes computed preflight data to the deferred route', async () => { 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}:cc-7`, + permissionDecisionReason: sentinels.renderedRoute, }, }); }); diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index 9aeceba2a..a9e46ff9e 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -89,7 +89,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', 'export default () => {', " appendFileSync(join(process.cwd(), '.agent-bundle', 'defer-gate.marker'), 'gate\\n');", - " return { outcome: 'execute', data: { ticket: 'cc-7' } };", + " return 'execute';", '};', '', ].join('\n'), @@ -102,10 +102,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', "export const config = { providers: ['clock'], runtime: 'standalone' };", '', - 'export default async function AfterTool({ canonical, preflight }) {', + 'export default async function AfterTool({ canonical }) {', ' const context = await agent();', " appendFileSync(join(process.cwd(), '.agent-bundle', 'defer-handler.marker'), 'run\\n');", - " const value = { outcome: 'defer', providers: Object.keys(context.providers).sort(), ticket: preflight.ticket };", + " const value = { outcome: 'defer', providers: Object.keys(context.providers).sort() };", " return createElement(Agent.Result, { value }, createElement(Agent.Context, null, `Observed ${canonical.payload.toolName}.`));", '}', '', @@ -368,11 +368,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(event.invocation.outcome).toEqual({ kind: 'success' }); expect(event.invocation.events.at(-1)?.type).toBe('complete'); expect(event.invocation.document).toBeDefined(); - expect(event.invocation.result).toEqual({ - outcome: 'defer', - providers: ['clock', 'processLifetime'], - ticket: 'cc-7', - }); + expect(event.invocation.result).toEqual({ outcome: 'defer', providers: ['clock', 'processLifetime'] }); expect(await readFile(join(project.root, '.agent-bundle', 'defer-gate.marker'), 'utf8')).toBe('gate\n'); expect(await readFile(join(project.root, '.agent-bundle', 'defer-handler.marker'), 'utf8')).toBe('run\n'); expect(event.invocation.projection.hosts?.[0]).toMatchObject({ host: 'claude' }); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index b902419dc..8671bccb7 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -420,9 +420,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('...(gate === "execute" ? {} : { preflight: gate.data })'); + 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, preflight, sequence: observation?.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'); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 245580d55..379e629ad 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -279,21 +279,18 @@ an object literal, a string, a class, or an identifier the module imports rather 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 four results +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: 'execute', data }` | Load and render the route with the strict-JSON `data` available as its `preflight` prop. | | `{ 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). | -When the rendered route needs a value the gate already computed, type both sides with the same JSON shape — `EventPreflight<'tool/after', CompletedTickets>` and `AgentEventRouteProps<'tool/after', CompletedTickets>` — then return `{ outcome: 'execute', data }`; the route receives that value as the optional `preflight` prop (it is absent after bare `'execute'` or when no gate exists). The kernel snapshots `data` as strict JSON before it crosses the existing deferred executor and shared-runtime boundaries, so functions, class instances, cycles, accessors, and non-finite numbers fail closed instead of reaching the route. - `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 `execute`, `continue`, or `deny` — a gate has +`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. diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 1a326ab2b..470e84c09 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -241,17 +241,14 @@ provider,实际开销并不会降低。通过裸包名导入、形成循环、 `preflight`,都会导致构建错误;编译器不会悄悄退回高开销的路由路径。 这些无效形式由 `inspect`、`validate`、`build` 和 `dev` 以 `AB4840` 报告。 -`preflight` 可以是同步或异步的,并且恰好只有四种结果: +`preflight` 可以是同步或异步的,并且恰好只有三种结果: | 结果 | 含义 | | --- | --- | | `'execute'` | 加载渲染式路由入口,解析其声明的 provider,并渲染它。 | -| `{ outcome: 'execute', data }` | 加载并渲染路由,同时把严格 JSON 的 `data` 作为其 `preflight` prop。 | | `{ outcome: 'continue' }` | 返回放行输出,不表达任何宿主决定。 | | `{ outcome: 'deny', reason: string }` | 按该事件既有的规范结果规则投影一次拒绝。 | -当渲染式路由需要门控已经算出的值时,请用同一种 JSON 形状标注两端——`EventPreflight<'tool/after', CompletedTickets>` 与 `AgentEventRouteProps<'tool/after', CompletedTickets>`——再返回 `{ outcome: 'execute', data }`;路由会从可选的 `preflight` prop 收到该值(返回裸 `'execute'` 或没有门控时此字段缺失)。内核会先把 `data` 快照为严格 JSON,再让它跨过既有的延迟执行器与共享运行时边界,因此函数、类实例、循环引用、访问器与非有限数字都会失败即关闭,而不会到达路由。 - `execute` 是唯一会加载渲染式路由运行时的结果。返回 `undefined`、未知字段或未知结果,或给出空的拒绝 原因,都会无法通过框架校验并终止执行。仅用于观察的事件不能拒绝。 From 471aca34f54c328830693d57c67940f41875c7e0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 03:22:48 +0000 Subject: [PATCH 2/4] test: bind route proofs to invoked epoch --- .../tests/route-invocation-dev-server.test.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index a9e46ff9e..f682103a2 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -259,6 +259,12 @@ it('invokes compiled tool and event routes through the foreground server', { tim } catch (error) { throw new Error(`Route manifest did not become ready: ${JSON.stringify(server.status())}`, { cause: error }); } + await expect.poll(() => { + const status = server!.status(); + return status.build.state === 'idle' && status.artifact.state === 'active' + ? status.artifact.activeEpoch.id + : undefined; + }).toEqual(expect.any(String)); const cookie = bootstrap.headers.get('set-cookie')!.split(';', 1)[0]!; const stream = await fetch(`${server.url}/api/project/events`, { @@ -524,7 +530,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(projectedCli.invocation.result).toMatchObject({ alias: 'aliased', define: 'defined', - pluginRoot: artifactRoot, + pluginRoot: expect.stringContaining(`${join(project.root, '.agent-bundle', 'epochs')}/`), service: 'projection', source: 'cli-projection', stateRoot, @@ -539,34 +545,37 @@ it('invokes compiled tool and event routes through the foreground server', { tim command: 'report', kind: 'cli', }); - const binName = (await readdir(join(artifactRoot, 'bin'))) + const projectedArtifactRoot = (projectedCli.invocation.result as { readonly pluginRoot?: unknown } | undefined) + ?.pluginRoot; + if (typeof projectedArtifactRoot !== 'string') throw new Error('Projected CLI returned no plugin root.'); + const binName = (await readdir(join(projectedArtifactRoot, 'bin'))) .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); const generatedUnconfirmed = await runNodeScript({ - args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], + args: [join(projectedArtifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], cwd: project.root, env: { - [pluginRootEnvAnchor]: artifactRoot, + [pluginRootEnvAnchor]: projectedArtifactRoot, [pluginStateRootEnvAnchor]: stateRoot, }, }); expect(generatedUnconfirmed.code).toBe(2); expect(generatedUnconfirmed.stderr).toContain(confirmationMessage); const generatedBin = await runNodeScript({ - args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--yes', '--json'], + args: [join(projectedArtifactRoot, 'bin', binName), 'report', '--name', 'projection', '--yes', '--json'], cwd: project.root, env: { - [pluginRootEnvAnchor]: artifactRoot, + [pluginRootEnvAnchor]: projectedArtifactRoot, [pluginStateRootEnvAnchor]: stateRoot, }, }); expect(generatedBin.code, generatedBin.stderr).toBe(0); expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); const generatedExit = await runNodeScript({ - args: [join(artifactRoot, 'bin', binName), 'exit', '3'], + args: [join(projectedArtifactRoot, 'bin', binName), 'exit', '3'], cwd: project.root, env: { - [pluginRootEnvAnchor]: artifactRoot, + [pluginRootEnvAnchor]: projectedArtifactRoot, [pluginStateRootEnvAnchor]: stateRoot, }, }); @@ -815,10 +824,12 @@ it('enforces compiled preflight, MCP schemas, and operator env across production async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), { timeout: 10_000 }, ).toBe(200); - const artifact = server.status().artifact; - if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); - await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); - + await expect.poll(() => { + const status = server!.status(); + return status.build.state === 'idle' && status.artifact.state === 'active' + ? status.artifact.activeEpoch.id + : undefined; + }).toEqual(expect.any(String)); const invalidResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 1 }, routeId: 'tool:status/report' }), headers, @@ -836,6 +847,9 @@ it('enforces compiled preflight, MCP schemas, and operator env across production expect(await readdir(join(project.root, '.agent-bundle'))).not.toContain('handler-ran'); const invoke = async (surface: { readonly args: readonly string[]; readonly command: string; readonly kind: 'cli' } | { readonly kind: 'mcp' }) => { + const artifact = server!.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); + await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); const response = await fetch(`${server!.url}/api/routes/invocations`, { body: JSON.stringify({ ...(surface.kind === 'mcp' ? { input: { service: 'mcp' } } : {}), From b3e0043bf640b3cf6b6e166b22e26d49990cdeef Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 03:24:40 +0000 Subject: [PATCH 3/4] docs: record route event revert --- .changeset/restore-route-events.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/restore-route-events.md diff --git a/.changeset/restore-route-events.md b/.changeset/restore-route-events.md new file mode 100644 index 000000000..8e98bde2b --- /dev/null +++ b/.changeset/restore-route-events.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Restore the prior route event preflight contract after reverting the broken event-data change (#668). From 6d9542a5e878aeb852dfe43e8ca424db02085e05 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 03:36:31 +0000 Subject: [PATCH 4/4] Revert "test: bind route proofs to invoked epoch" This reverts commit 471aca34f54c328830693d57c67940f41875c7e0. --- .../tests/route-invocation-dev-server.test.ts | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index f682103a2..a9e46ff9e 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -259,12 +259,6 @@ it('invokes compiled tool and event routes through the foreground server', { tim } catch (error) { throw new Error(`Route manifest did not become ready: ${JSON.stringify(server.status())}`, { cause: error }); } - await expect.poll(() => { - const status = server!.status(); - return status.build.state === 'idle' && status.artifact.state === 'active' - ? status.artifact.activeEpoch.id - : undefined; - }).toEqual(expect.any(String)); const cookie = bootstrap.headers.get('set-cookie')!.split(';', 1)[0]!; const stream = await fetch(`${server.url}/api/project/events`, { @@ -530,7 +524,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(projectedCli.invocation.result).toMatchObject({ alias: 'aliased', define: 'defined', - pluginRoot: expect.stringContaining(`${join(project.root, '.agent-bundle', 'epochs')}/`), + pluginRoot: artifactRoot, service: 'projection', source: 'cli-projection', stateRoot, @@ -545,37 +539,34 @@ it('invokes compiled tool and event routes through the foreground server', { tim command: 'report', kind: 'cli', }); - const projectedArtifactRoot = (projectedCli.invocation.result as { readonly pluginRoot?: unknown } | undefined) - ?.pluginRoot; - if (typeof projectedArtifactRoot !== 'string') throw new Error('Projected CLI returned no plugin root.'); - const binName = (await readdir(join(projectedArtifactRoot, 'bin'))) + const binName = (await readdir(join(artifactRoot, 'bin'))) .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); const generatedUnconfirmed = await runNodeScript({ - args: [join(projectedArtifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], + args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], cwd: project.root, env: { - [pluginRootEnvAnchor]: projectedArtifactRoot, + [pluginRootEnvAnchor]: artifactRoot, [pluginStateRootEnvAnchor]: stateRoot, }, }); expect(generatedUnconfirmed.code).toBe(2); expect(generatedUnconfirmed.stderr).toContain(confirmationMessage); const generatedBin = await runNodeScript({ - args: [join(projectedArtifactRoot, 'bin', binName), 'report', '--name', 'projection', '--yes', '--json'], + args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--yes', '--json'], cwd: project.root, env: { - [pluginRootEnvAnchor]: projectedArtifactRoot, + [pluginRootEnvAnchor]: artifactRoot, [pluginStateRootEnvAnchor]: stateRoot, }, }); expect(generatedBin.code, generatedBin.stderr).toBe(0); expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); const generatedExit = await runNodeScript({ - args: [join(projectedArtifactRoot, 'bin', binName), 'exit', '3'], + args: [join(artifactRoot, 'bin', binName), 'exit', '3'], cwd: project.root, env: { - [pluginRootEnvAnchor]: projectedArtifactRoot, + [pluginRootEnvAnchor]: artifactRoot, [pluginStateRootEnvAnchor]: stateRoot, }, }); @@ -824,12 +815,10 @@ it('enforces compiled preflight, MCP schemas, and operator env across production async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), { timeout: 10_000 }, ).toBe(200); - await expect.poll(() => { - const status = server!.status(); - return status.build.state === 'idle' && status.artifact.state === 'active' - ? status.artifact.activeEpoch.id - : undefined; - }).toEqual(expect.any(String)); + const artifact = server.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); + await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); + const invalidResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 1 }, routeId: 'tool:status/report' }), headers, @@ -847,9 +836,6 @@ it('enforces compiled preflight, MCP schemas, and operator env across production expect(await readdir(join(project.root, '.agent-bundle'))).not.toContain('handler-ran'); const invoke = async (surface: { readonly args: readonly string[]; readonly command: string; readonly kind: 'cli' } | { readonly kind: 'mcp' }) => { - const artifact = server!.status().artifact; - if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); - await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); const response = await fetch(`${server!.url}/api/routes/invocations`, { body: JSON.stringify({ ...(surface.kind === 'mcp' ? { input: { service: 'mcp' } } : {}),