From e98a7117fd563e0b765ce664c761ecdeb8f50856 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 03:38:19 +0000 Subject: [PATCH 1/3] feat(dev): add semantic lifecycle replay Replay compiled event routes through native validation, canonical decoding, the real render dispatcher, and host projection while preserving fixture provenance and stale-manifest safety. --- .changeset/semantic-lifecycle-replay.md | 5 + packages/agent-bundle/src/adapters/claude.ts | 8 +- packages/agent-bundle/src/adapters/codex.ts | 8 +- packages/agent-bundle/src/adapters/cursor.ts | 8 +- .../src/adapters/hook-contract.ts | 134 ++++--- packages/agent-bundle/src/build/entries.ts | 5 +- .../agent-bundle/src/contracts/lifecycles.ts | 77 ++++ .../agent-bundle/src/dev/foreground-server.ts | 10 + .../src/dev/logs/dev-log-kinds.ts | 9 +- .../src/dev/logs/dev-log-service.ts | 1 + .../dev/playground/lifecycle-replay-routes.ts | 146 ++++++++ .../playground/lifecycle-replay-service.ts | 352 ++++++++++++++++++ .../agent-bundle/src/dev/workbench-server.ts | 16 + packages/agent-bundle/src/events/project.ts | 105 ++++++ .../agent-bundle/tests/event-project.test.ts | 44 +++ .../tests/lifecycle-replay-routes.test.ts | 189 ++++++++++ .../tests/lifecycle-replay-service.test.ts | 89 +++++ .../tests/route-unit/lifecycle-replay.test.ts | 124 ++++++ 18 files changed, 1269 insertions(+), 61 deletions(-) create mode 100644 .changeset/semantic-lifecycle-replay.md create mode 100644 packages/agent-bundle/src/contracts/lifecycles.ts create mode 100644 packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts create mode 100644 packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts create mode 100644 packages/agent-bundle/tests/lifecycle-replay-routes.test.ts create mode 100644 packages/agent-bundle/tests/lifecycle-replay-service.test.ts create mode 100644 packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts diff --git a/.changeset/semantic-lifecycle-replay.md b/.changeset/semantic-lifecycle-replay.md new file mode 100644 index 000000000..4becbd758 --- /dev/null +++ b/.changeset/semantic-lifecycle-replay.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add stage 3 semantic lifecycle replay: authenticated dev-server lifecycle replay routes, single-sourced native event validation, and the Workbench lifecycle page. diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 433fdc3a0..3e49ba846 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -24,6 +24,7 @@ import { } from './capability-state.ts'; import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import { + createNativeEventStarter, mergeHookDocuments, encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, @@ -125,6 +126,7 @@ const validateLsp = validator.compile(lspSchema); /** The pinned Claude hooks validator, shared with the unified bundle adapter. */ export const claudeHooksValidator = validateHooks; +const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ hostContractRevision: capabilityTable.observedCliVersion, commandRoot: '${CLAUDE_PLUGIN_ROOT}', @@ -132,9 +134,13 @@ const hookContract = Object.freeze({ encodePlaygroundOutput: (result, event, nativeEvent) => encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'claude'), eventNames: capabilityTable.hooks.events, - eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes), + eventRouteNames, manifestPath: 'hooks/hooks.json', matchers: capabilityTable.hooks.matchers, + nativeEventStarter: (event) => { + const nativeEvent = eventRouteNames[event]; + return nativeEvent === undefined ? undefined : createNativeEventStarter('claude', event, nativeEvent); + }, readNativeCommands: readStandardNativeHookCommands, wrapperPath: (hook: NormalizedPlugin['hooks'][number]) => `hooks/${hook.name}.mjs`, wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index e220605d8..94d47e094 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -24,6 +24,7 @@ import { } from './capability-state.ts'; import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { + createNativeEventStarter, mergeHookDocuments, encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, @@ -107,6 +108,7 @@ const pluginValidatorFor = (mcpRelativePath: string): ValidateFunction => { export const codexPluginDocumentValidator = (mcpRelativePath: string): TargetArtifactDocumentValidator => validateJsonSchemaDocument(pluginValidatorFor(mcpRelativePath)); const validateHooks = validator.compile(hooksSchema); +const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ hostContractRevision: capabilityTable.observedCliVersion, commandRoot: '${PLUGIN_ROOT}', @@ -114,9 +116,13 @@ const hookContract = Object.freeze({ encodePlaygroundOutput: (result, event, nativeEvent) => encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'codex'), eventNames: capabilityTable.hooks.events, - eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes), + eventRouteNames, manifestPath: 'hooks/hooks.json', matchers: capabilityTable.hooks.matchers, + nativeEventStarter: (event) => { + const nativeEvent = eventRouteNames[event]; + return nativeEvent === undefined ? undefined : createNativeEventStarter('codex', event, nativeEvent); + }, readNativeCommands: readStandardNativeHookCommands, wrapperPath: (hook: NormalizedPlugin['hooks'][number]) => `hooks/${hook.name}.mjs`, wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 5a70346f1..35ef2dcf0 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -22,6 +22,7 @@ import { } from './capability-state.ts'; import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; import { + createNativeEventStarter, cursorHookWrapperSource, encodeCursorPlaygroundInput, encodeCursorPlaygroundOutput, @@ -121,6 +122,7 @@ export const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 }) const cursorHookDocumentEntry = (input: TargetHookDocumentEntryInput): Record => ({ ...input }); const cursorHookDocumentEnvelope = (hooks: Record): Record => ({ hooks, version: 1 }); +const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); export interface CursorHookContractOptions { /** See TargetHookContract.indexedWrappers; the bundle's Cursor wrappers are document variants. */ @@ -144,10 +146,14 @@ export const createCursorHookContract = (options: CursorHookContractOptions): Ta encodePlaygroundInput: encodeCursorPlaygroundInput, encodePlaygroundOutput: (result, canonicalEvent) => encodeCursorPlaygroundOutput(result, canonicalEvent), eventNames: capabilityTable.hooks.events, - eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes), + eventRouteNames, ...(options.indexedWrappers === false ? { indexedWrappers: false as const } : {}), manifestPath: options.manifestPath, matchers: capabilityTable.hooks.matchers, + nativeEventStarter: (event) => { + const nativeEvent = eventRouteNames[event]; + return nativeEvent === undefined ? undefined : createNativeEventStarter('cursor', event, nativeEvent); + }, readNativeCommands: readCursorNativeHookCommands, wrapperPath: options.wrapperPath, wrapperSource: cursorHookWrapperSource, diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 53c80cfe9..d97b2e0b8 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -74,11 +74,90 @@ export interface TargetHookContract { readonly indexedWrappers?: false; readonly manifestPath: string; readonly matchers: Readonly>>; + /** Minimal checked-in host envelope for semantic lifecycle replay. */ + readonly nativeEventStarter?: ( + canonicalEvent: CanonicalAgentEvent, + ) => Readonly> | undefined; readonly readNativeCommands?: (document: unknown) => TargetNativeHookCommandsReadResult; readonly wrapperPath: (hook: NormalizedHook) => string; readonly wrapperSource: (entry: TargetHookWrapper) => string; } +export type NativeEventStarterTarget = 'claude' | 'codex' | 'cursor'; + +/** Creates only fields required by the shared native event envelope validator. */ +export const createNativeEventStarter = ( + target: NativeEventStarterTarget, + canonicalEvent: CanonicalAgentEvent, + nativeEvent: string, +): Readonly> => { + const base = target === 'cursor' + ? { + hook_event_name: nativeEvent, + session_id: 'lifecycle-replay', + } + : { + cwd: '/tmp/agent-bundle-lifecycle-replay', + hook_event_name: nativeEvent, + session_id: 'lifecycle-replay', + transcript_path: target === 'codex' ? null : '/tmp/agent-bundle-lifecycle-replay/transcript.jsonl', + }; + switch (canonicalEvent) { + case 'session/start': + return deepFreeze(target === 'cursor' ? base : { ...base, source: 'startup' }); + case 'tool/before': + return deepFreeze({ + ...base, + tool_input: {}, + tool_name: 'Write', + tool_use_id: 'lifecycle-replay-tool', + }); + case 'tool/after': + return deepFreeze({ + ...base, + tool_input: {}, + tool_name: 'Write', + ...(target === 'cursor' ? { tool_output: '{}' } : { tool_response: {} }), + tool_use_id: 'lifecycle-replay-tool', + }); + case 'stop': + return deepFreeze(target === 'cursor' + ? { ...base, loop_count: 0 } + : { ...base, last_assistant_message: 'Lifecycle replay stopped.', stop_hook_active: false }); + case 'agent/start': + return deepFreeze(target === 'cursor' + ? base + : { + ...base, + agent_id: 'lifecycle-replay-agent', + agent_type: 'general-purpose', + ...(target === 'codex' + ? { model: 'default', permission_mode: 'default', turn_id: 'lifecycle-replay-turn' } + : {}), + }); + case 'agent/stop': + return deepFreeze(target === 'cursor' + ? base + : { + ...base, + agent_id: 'lifecycle-replay-agent', + agent_transcript_path: null, + agent_type: 'general-purpose', + last_assistant_message: null, + ...(target === 'codex' + ? { model: 'default', permission_mode: 'default', turn_id: 'lifecycle-replay-turn' } + : {}), + stop_hook_active: false, + }); + case 'workspace/open': + return deepFreeze(base); + default: { + const exhaustive: never = canonicalEvent; + return exhaustive; + } + } +}; + const snapshotNativeHookCommands = (value: unknown): readonly TargetNativeHookCommand[] | undefined => { const candidates = dataArrayValues(value); if (candidates === undefined) return undefined; @@ -333,9 +412,9 @@ const eventRouteHookWrapperSource = ( return [ "import { dirname, resolve } from 'node:path';", `import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, + `import { ${standalone ? 'createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute, ' : ''}validateNativeEventEnvelope } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, ...(standalone ? [ - `import { createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, `import * as routeModule from ${JSON.stringify(entry.hook.source)};`, ] : []), @@ -351,58 +430,7 @@ const eventRouteHookWrapperSource = ( `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, "const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;", '', - 'const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', - 'const requireString = (input, field) => { if (typeof input[field] !== "string" || input[field].trim() === "") fail(`native ${field} must be a nonempty string`); };', - 'const validateNative = (input) => {', - ' if (!isRecord(input)) fail("stdin JSON value must be an object");', - ' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);', - ' if (target === "cursor") {', - ' if (typeof input.session_id !== "string" && typeof input.conversation_id !== "string") fail("native session_id or conversation_id must be a string");', - ' if (canonicalEvent === "tool/before" || canonicalEvent === "tool/after") {', - ' requireString(input, "tool_name");', - ' if (!isRecord(input.tool_input)) fail("native tool_input must be an object");', - ' requireString(input, "tool_use_id");', - ' if (canonicalEvent === "tool/after") requireString(input, "tool_output");', - ' }', - ' if (canonicalEvent === "stop" && typeof input.loop_count !== "number") fail("native loop_count must be a number");', - ' return input;', - ' }', - ' requireString(input, "session_id");', - ' if (target === "codex") {', - ' if (input.transcript_path !== null && typeof input.transcript_path !== "string") fail("native transcript_path must be a string or null");', - ' } else {', - ' requireString(input, "transcript_path");', - ' }', - ' requireString(input, "cwd");', - ' if (canonicalEvent === "session/start") requireString(input, "source");', - ' if (canonicalEvent === "tool/before" || canonicalEvent === "tool/after") {', - ' requireString(input, "tool_name");', - ' if (!isRecord(input.tool_input)) fail("native tool_input must be an object");', - ' requireString(input, "tool_use_id");', - ' if (canonicalEvent === "tool/after" && !isRecord(input.tool_response)) fail("native tool_response must be an object");', - ' }', - ' if (canonicalEvent === "agent/start" || canonicalEvent === "agent/stop") {', - ' requireString(input, "agent_id");', - ' requireString(input, "agent_type");', - ' if (target === "codex") {', - ' requireString(input, "turn_id");', - ' requireString(input, "model");', - ' requireString(input, "permission_mode");', - ' if (!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"].includes(input.permission_mode)) fail("native permission_mode is invalid");', - ' }', - ' if (canonicalEvent === "agent/stop") {', - ' if (typeof input.stop_hook_active !== "boolean") fail("native stop_hook_active must be a boolean");', - ' if (input.agent_transcript_path !== null && typeof input.agent_transcript_path !== "string") fail("native agent_transcript_path must be a string or null");', - ' if (input.last_assistant_message !== null && typeof input.last_assistant_message !== "string") fail("native last_assistant_message must be a string or null");', - ' }', - ' }', - ' if (canonicalEvent === "stop") {', - ' if (typeof input.stop_hook_active !== "boolean") fail("native stop_hook_active must be a boolean");', - ' requireString(input, "last_assistant_message");', - ' }', - ' return input;', - '};', ...(standalone ? [ 'const runStandalone = async (native, signal) => {', @@ -423,7 +451,7 @@ const eventRouteHookWrapperSource = ( ' }', ' let parsed;', ' try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { fail("stdin must contain exactly one JSON value"); }', - ' const native = validateNative(parsed);', + ' const native = validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target });', ' const controller = new AbortController();', ' let output;', ' if (runtimeMode === "standalone") {', diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 44ecf944c..886850345 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -497,10 +497,7 @@ export const compileHooks = async ( const compiled = planCompiledHooks(entries, options); const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined); const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc'); - const eventProjectRuntime = routeEntries.some((entry) => - entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') - ? eventRuntimeModulePath('project') - : undefined; + const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project'); const evidence = await buildWithRslib({ cwd: options.cwd, entries: compiled.map((entry, index) => ({ diff --git a/packages/agent-bundle/src/contracts/lifecycles.ts b/packages/agent-bundle/src/contracts/lifecycles.ts new file mode 100644 index 000000000..637123790 --- /dev/null +++ b/packages/agent-bundle/src/contracts/lifecycles.ts @@ -0,0 +1,77 @@ +import type { AgentDocument, AgentRenderEvent } from '@agent-bundle/runtime'; + +import type { + AgentEventCanonicalIdentity, + CanonicalAgentEvent, +} from '../routes/public.ts'; + +export interface LifecycleBinding { + readonly manifestDigest: string; + readonly routeId: string; + readonly target: string; +} + +export interface LifecycleDiagnostic { + readonly code: string; + readonly message: string; + readonly severity: 'error' | 'warning'; + readonly target?: string; +} + +export interface LifecycleReplayDiagnostic extends LifecycleDiagnostic { + readonly event: CanonicalAgentEvent; + readonly target: string; +} + +export interface LifecycleTarget { + readonly fixture?: Readonly<{ + readonly label: string; + readonly native: Readonly>; + }>; + readonly hostContractRevision: string; + readonly nativeEvent: string; + readonly target: string; +} + +export interface Lifecycle { + readonly diagnostics: readonly LifecycleDiagnostic[]; + readonly event: CanonicalAgentEvent; + readonly routeId: string; + readonly routePath: string; + readonly targets: readonly LifecycleTarget[]; +} + +export interface LifecycleListResponse { + readonly lifecycles: readonly Lifecycle[]; + readonly manifestDigest: string; +} + +export type LifecycleReplaySource = 'fixture' | 'observed'; + +export interface LifecycleReplayRequest { + readonly binding: LifecycleBinding; + readonly native: Readonly>; + readonly source: LifecycleReplaySource; +} + +export interface LifecycleReplay { + readonly binding: LifecycleBinding; + readonly canonical: AgentEventCanonicalIdentity; + readonly document?: AgentDocument; + readonly events: readonly AgentRenderEvent[]; + readonly nativeInput: Readonly>; + readonly nativeResponse?: Readonly>; + readonly projectionDiagnostic?: Readonly<{ readonly code: string; readonly message: string }>; + readonly requestContext: Readonly<{ + readonly hostContractRevision: string; + readonly invocationKind: 'event'; + readonly nativeEvent: string; + readonly routeId: string; + readonly target: string; + }>; + readonly source: LifecycleReplaySource; +} + +export interface LifecycleReplayDiagnosticResult { + readonly diagnostics: readonly LifecycleReplayDiagnostic[]; +} diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index f3f3a3083..ede6e1f6d 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -13,6 +13,7 @@ import { EvalRoutes, type EvalRouteService } from './eval/eval-routes.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; import { InspectorRoutes, type InspectorRouteService } from './inspector-routes.ts'; import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playground/hook-playground-routes.ts'; +import { LifecycleReplayRoutes, type LifecycleReplayRouteService } from './playground/lifecycle-replay-routes.ts'; import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; import { McpSessionRoutes } from './mcp-session/mcp-session-routes.ts'; import type { McpSessionService } from './mcp-session/mcp-session-service.ts'; @@ -131,6 +132,8 @@ export interface ForegroundServerOptions { readonly mcpAppPreviews?: McpAppRoutePreviewService; /** Epoch-bound hook playground service; the browser never selects a wrapper or artifact path. */ readonly hookPlayground?: HookPlaygroundRouteService; + /** Read-only semantic lifecycle replay over the latest valid prepared graph. */ + readonly lifecycleReplay?: LifecycleReplayRouteService; /** Opt-in standalone MCP Inspector child; never auto-started. */ readonly inspector?: InspectorRouteService; /** Persistent MCP sessions are supplied by the workbench service, never by browser input. */ @@ -434,6 +437,7 @@ export class ForegroundServer { readonly #hookPlaygroundRoutes: HookPlaygroundRoutes; readonly #host: string; readonly #inspectorRoutes: InspectorRoutes; + readonly #lifecycleReplayRoutes: LifecycleReplayRoutes; readonly #mcpAppPreviews: McpAppRoutePreviewService | undefined; readonly #mcpAppRoutes: McpAppRoutes; readonly #runtimeMcpRoutes: RuntimeMcpRoutes; @@ -513,6 +517,10 @@ export class ForegroundServer { authorize: (request) => this.#assertMutationSession(request), ...(options.hookPlayground === undefined ? {} : { service: options.hookPlayground }), }); + this.#lifecycleReplayRoutes = new LifecycleReplayRoutes({ + authorize: (request) => this.#assertMutationSession(request), + ...(options.lifecycleReplay === undefined ? {} : { service: options.lifecycleReplay }), + }); this.#inspectorRoutes = new InspectorRoutes({ authorize: (request) => this.#assertMutationSession(request), ...(options.inspector === undefined ? {} : { service: options.inspector }), @@ -674,6 +682,7 @@ export class ForegroundServer { this.#inspectorRoutes.close(); this.#artifactRoutes.close(); this.#routeManifestRoutes.close(); + this.#lifecycleReplayRoutes.close(); const releaseEvals = this.#evalRoutes.close(); void releaseEvals.catch(() => undefined); // Fence both public Eval authorities in this turn. Agent API handlers can @@ -756,6 +765,7 @@ export class ForegroundServer { if (await this.#runtimeMcpRoutes.handle(request, response)) return; if (await this.#runtimeRoutes.handle(request, response)) return; if (await this.#hookPlaygroundRoutes.handle(request, response)) return; + if (await this.#lifecycleReplayRoutes.handle(request, response)) return; if (await this.#playgroundRoutes.handle(request, response)) return; if (await this.#inspectorRoutes.handle(request, response)) return; if (await this.#artifactRoutes.handle(request, response)) return; diff --git a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts index 998d2ae8b..65480645e 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts @@ -24,7 +24,14 @@ export const devLogKinds = Object.freeze({ 'invalidation.diagnostic', 'runtime.event.diagnostic', 'source.changed.diagnostic', 'source.status.diagnostic', ] as const), eval: Object.freeze(['eval.run.completed', 'eval.run.failed', 'eval.run.started'] as const), - hook: Object.freeze(['hook.simulate.completed', 'hook.simulate.failed', 'hook.simulate.started'] as const), + hook: Object.freeze([ + 'hook.simulate.completed', + 'hook.simulate.failed', + 'hook.simulate.started', + 'lifecycle.replay.completed', + 'lifecycle.replay.failed', + 'lifecycle.replay.started', + ] as const), mcp: Object.freeze(['mcp.logging', 'mcp.stderr', 'mcp.operation.failed', 'mcp.operation.started', 'mcp.operation.succeeded'] as const), playground: Object.freeze(['playground.event.appended'] as const), project: Object.freeze([ diff --git a/packages/agent-bundle/src/dev/logs/dev-log-service.ts b/packages/agent-bundle/src/dev/logs/dev-log-service.ts index be147f9ce..ec6c3b91c 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-service.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-service.ts @@ -120,6 +120,7 @@ const safeContextKeys = new Set([ 'epochId', 'hookId', 'projectId', + 'routeId', 'runId', 'sessionId', 'target', diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts new file mode 100644 index 000000000..82b9bb63c --- /dev/null +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts @@ -0,0 +1,146 @@ +import { Buffer } from 'node:buffer'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { + LifecycleListResponse, + LifecycleReplay, + LifecycleReplayDiagnosticResult, + LifecycleReplayRequest, +} from '../../contracts/lifecycles.ts'; +import { isRecord } from '../../core/strict-json.ts'; +import { + diagnostic, + hasOnly, + isRequestDiagnostic, + nonemptyString, + rawPathname, + readJsonBody, + requestError, + responseDiagnostic, + responseJson as writeJsonResponse, +} from '../http.ts'; + +type Route = 'list' | 'replay'; +type JsonObject = Record; + +export const lifecycleReplayResponseLimit = 16 * 1024 * 1024; + +export interface LifecycleReplayRouteService { + list(): LifecycleListResponse | Promise; + replay( + request: LifecycleReplayRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise; +} + +export interface LifecycleReplayRoutesOptions { + readonly authorize: (request: IncomingMessage) => void; + readonly responseByteLimit?: number; + readonly service?: LifecycleReplayRouteService; +} + +const responseJson = (response: ServerResponse, body: unknown): void => + writeJsonResponse(response, body, { destroyIfEnded: true }); + +const invalidShape = (): never => { + throw requestError(diagnostic('AB8211', 'Lifecycle replay request has an invalid shape.', 400)); +}; + +const route = (requestTarget: string | undefined): Route | undefined => { + const pathname = rawPathname(requestTarget); + if (pathname !== '/api/lifecycles' && !pathname.startsWith('/api/lifecycles/')) return undefined; + if (pathname === '/api/lifecycles') return 'list'; + if (pathname === '/api/lifecycles/replays') return 'replay'; + throw requestError(diagnostic('AB8210', 'Lifecycle replay route path is not valid.', 400)); +}; + +const noQuery = (requestTarget: string | undefined): void => { + if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); +}; + +const replayRequest = (value: JsonObject): LifecycleReplayRequest => { + if (!hasOnly(value, ['binding', 'native', 'source'])) return invalidShape(); + const { binding, native, source } = value; + if ( + !isRecord(binding) + || !hasOnly(binding, ['manifestDigest', 'routeId', 'target']) + || !nonemptyString(binding.manifestDigest) + || !nonemptyString(binding.routeId) + || !nonemptyString(binding.target) + || !isRecord(native) + || (source !== 'fixture' && source !== 'observed') + ) return invalidShape(); + return Object.freeze({ + binding: Object.freeze({ + manifestDigest: binding.manifestDigest, + routeId: binding.routeId, + target: binding.target, + }), + native: Object.freeze({ ...native }), + source, + }); +}; + +const replayResponse = ( + result: LifecycleReplay | LifecycleReplayDiagnosticResult, +): Readonly<{ readonly diagnostics: LifecycleReplayDiagnosticResult['diagnostics'] }> | Readonly<{ readonly replay: LifecycleReplay }> => + 'diagnostics' in result + ? Object.freeze({ diagnostics: result.diagnostics }) + : Object.freeze({ replay: result }); + +/** Authenticated foreground boundary for read-only semantic lifecycle replay. */ +export class LifecycleReplayRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #responseByteLimit: number; + readonly #service: LifecycleReplayRouteService | undefined; + #closed = false; + + constructor(options: LifecycleReplayRoutesOptions) { + this.#authorize = options.authorize; + this.#responseByteLimit = options.responseByteLimit ?? lifecycleReplayResponseLimit; + this.#service = options.service; + } + + close(): void { + this.#closed = true; + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const parsed = route(request.url); + if (parsed === undefined) return false; + this.#authorize(request); + if (this.#closed) throw this.#unavailable(503); + const service = this.#service; + if (service === undefined) throw this.#unavailable(404); + noQuery(request.url); + try { + const method = request.method ?? 'GET'; + if (parsed === 'list') { + if (method !== 'GET') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } + responseJson(response, await service.list()); + return true; + } + if (method !== 'POST') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } + const body = await readJsonBody(request, { invalidShape }); + const result = replayResponse(await service.replay(replayRequest(body))); + if (Buffer.byteLength(JSON.stringify(result), 'utf8') > this.#responseByteLimit) { + throw requestError(diagnostic('AB8214', 'Lifecycle replay exceeds the 16 MiB response limit.', 413)); + } + responseJson(response, result); + return true; + } catch (error) { + if (isRequestDiagnostic(error)) throw error; + throw requestError(diagnostic('AB8212', 'Lifecycle replay operation could not be completed.', 502)); + } + } + + #unavailable(status: number): Error { + return requestError(diagnostic('AB8212', 'Lifecycle replay routes are not available.', status)); + } +} diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts new file mode 100644 index 000000000..e8349aa13 --- /dev/null +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -0,0 +1,352 @@ +import { createJiti } from 'jiti'; + +import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; +import type { TargetHookContract } from '../../adapters/hook-contract.ts'; +import type { + Lifecycle, + LifecycleBinding, + LifecycleDiagnostic, + LifecycleListResponse, + LifecycleReplay, + LifecycleReplayDiagnosticResult, + LifecycleReplayRequest, + LifecycleTarget, +} from '../../contracts/lifecycles.ts'; +import { deepFreeze } from '../../core/freeze.ts'; +import { isJsonRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts'; +import { + createCanonicalEventProps, + projectEventDocument, + validateNativeEventEnvelope, +} from '../../events/project.ts'; +import { + canonicalAgentEvents, + type CanonicalAgentEvent, +} from '../../routes/public.ts'; +import type { CompiledAgentRoute, CompiledRouteGraph } from '../../routes/types.ts'; +import { renderRouteEvents } from '../../test/render.ts'; +import type { AgentRouteModule } from '../../test/types.ts'; +import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; + +const concreteHosts = new Set(['claude', 'codex', 'cursor']); +const projectionDiagnosticCode = 'lifecycle.projection.unsupported'; + +export interface LifecyclePreparedProject { + readonly graph: CompiledRouteGraph; + readonly targets: readonly string[]; +} + +export interface LifecycleReplayServiceOptions { + readonly loadRouteModule?: (source: string) => Promise; + readonly logger?: DevLogSink; + readonly prepared: () => LifecyclePreparedProject; + readonly registry?: TargetRegistry; + readonly render?: typeof renderRouteEvents; +} + +export class LifecycleReplayRequestError extends Error { + readonly code: 'AB8211' | 'AB8213'; + readonly status: 400 | 409; + + constructor(code: 'AB8211' | 'AB8213', message: string, status: 400 | 409) { + super(message); + this.name = 'LifecycleReplayRequestError'; + this.code = code; + this.status = status; + } +} + +const importRouteModule = async (source: string): Promise => { + // These are optional peers. Loading them only when replay is requested keeps + // manifest-only dev servers importable, matching the route-unit renderer. + const [runtime, react] = await Promise.all([ + import('@agent-bundle/runtime'), + import('react'), + ]); + const jiti = createJiti(import.meta.url, { + interopDefault: false, + jsx: { runtime: 'automatic' }, + moduleCache: false, + nativeModules: ['typescript'], + virtualModules: { + '@agent-bundle/runtime': runtime, + react, + }, + }); + return jiti.import(source); +}; + +const expandedTargets = (targets: readonly string[]): readonly string[] => Object.freeze( + [...new Set(targets.flatMap((target) => target === 'plugin' ? ['claude', 'codex'] : [target]))] + .sort((left, right) => left.localeCompare(right)), +); + +const eventForRouteId = (routeId: string): CanonicalAgentEvent | undefined => { + if (!routeId.startsWith('event:')) return undefined; + const event = routeId.slice('event:'.length); + return canonicalAgentEvents.find((candidate) => candidate === event); +}; + +const targetDiagnostic = ( + code: string, + message: string, + target: string, + severity: 'error' | 'warning' = 'error', +): LifecycleDiagnostic => Object.freeze({ code, message, severity, target }); + +const replayDiagnostic = ( + code: string, + message: string, + target: string, + event: CanonicalAgentEvent, +): LifecycleReplayDiagnosticResult => deepFreeze({ + diagnostics: [{ + code, + event, + message, + severity: 'error', + target, + }], +}); + +const eventContract = ( + registry: TargetRegistry, + target: string, + event: CanonicalAgentEvent, +): Readonly<{ readonly contract: TargetHookContract; readonly hostContractRevision: string; readonly nativeEvent: string }> | undefined => { + if (!registry.has(target)) return undefined; + const contract = registry.hookContract(target); + const nativeEvent = contract?.eventRouteNames?.[event]; + const hostContractRevision = contract?.hostContractRevision; + if ( + contract === undefined + || typeof nativeEvent !== 'string' + || nativeEvent.trim() === '' + || typeof hostContractRevision !== 'string' + || hostContractRevision.trim() === '' + ) return undefined; + return Object.freeze({ contract, hostContractRevision, nativeEvent }); +}; + +const targetFor = ( + registry: TargetRegistry, + target: string, + event: CanonicalAgentEvent, +): LifecycleTarget | LifecycleDiagnostic => { + const mapped = eventContract(registry, target, event); + if (mapped === undefined) { + return targetDiagnostic( + 'lifecycle.target.unsupported', + `Lifecycle replay target ${JSON.stringify(target)} cannot map canonical event ${JSON.stringify(event)}.`, + target, + ); + } + const starter = mapped.contract.nativeEventStarter?.(event); + return deepFreeze({ + ...(starter === undefined + ? {} + : { fixture: { label: `${target} ${mapped.nativeEvent} starter`, native: starter } }), + hostContractRevision: mapped.hostContractRevision, + nativeEvent: mapped.nativeEvent, + target, + }); +}; + +/** Semantic replay over the latest valid prepared route graph; it never compiles or writes the project. */ +export class LifecycleReplayService { + readonly #loadRouteModule: (source: string) => Promise; + readonly #logger: DevLogSink | undefined; + readonly #prepared: () => LifecyclePreparedProject; + readonly #registry: TargetRegistry; + readonly #render: typeof renderRouteEvents; + + constructor(options: LifecycleReplayServiceOptions) { + this.#loadRouteModule = options.loadRouteModule ?? importRouteModule; + this.#logger = options.logger; + this.#prepared = options.prepared; + this.#registry = options.registry ?? createDefaultRegistry(); + this.#render = options.render ?? renderRouteEvents; + } + + list(): LifecycleListResponse { + const prepared = this.#prepared(); + const lifecycles = prepared.graph.events.map((route): Lifecycle => { + const projected = this.#targetsFor(route, prepared.targets); + return deepFreeze({ + diagnostics: projected.diagnostics, + event: route.event!, + routeId: route.id, + routePath: route.provenance.relativePath, + targets: projected.targets, + }); + }); + return deepFreeze({ lifecycles, manifestDigest: prepared.graph.digest }); + } + + async replay( + request: LifecycleReplayRequest, + options: { readonly signal?: AbortSignal } = {}, + ): Promise { + this.#log('lifecycle.replay.started', 'info', 'Lifecycle replay started.', request.binding, {}); + try { + const result = await this.#replay(request, options.signal ?? new AbortController().signal); + this.#log( + 'lifecycle.replay.completed', + 'info', + 'Lifecycle replay completed.', + request.binding, + 'diagnostics' in result ? { diagnostics: result.diagnostics } : { events: result.events.length }, + ); + return result; + } catch (error) { + this.#log('lifecycle.replay.failed', 'error', 'Lifecycle replay failed.', request.binding, { + failure: error instanceof LifecycleReplayRequestError ? error.code : 'unavailable', + }); + throw error; + } + } + + async #replay(request: LifecycleReplayRequest, signal: AbortSignal): Promise { + const prepared = this.#prepared(); + if (request.binding.manifestDigest !== prepared.graph.digest) { + throw new LifecycleReplayRequestError('AB8213', 'Lifecycle replay manifest binding is stale.', 409); + } + const route = prepared.graph.events.find((candidate) => candidate.id === request.binding.routeId); + if (route === undefined) { + const event = eventForRouteId(request.binding.routeId); + if (event === undefined) { + throw new LifecycleReplayRequestError('AB8211', 'Lifecycle replay request has an invalid route binding.', 400); + } + return replayDiagnostic( + 'lifecycle.route.unavailable', + `Lifecycle replay route ${JSON.stringify(request.binding.routeId)} is not available in the bound manifest.`, + request.binding.target, + event, + ); + } + const event = route.event!; + const listed = this.#targetsFor(route, prepared.targets); + if (!concreteHosts.has(request.binding.target)) { + return replayDiagnostic( + 'lifecycle.target.unsupported', + `Lifecycle replay target ${JSON.stringify(request.binding.target)} is not a concrete supported host.`, + request.binding.target, + event, + ); + } + const target = listed.targets.find((candidate) => candidate.target === request.binding.target); + if (target === undefined) { + return replayDiagnostic( + 'lifecycle.target.unsupported', + `Lifecycle replay target ${JSON.stringify(request.binding.target)} cannot map canonical event ${JSON.stringify(event)}.`, + request.binding.target, + event, + ); + } + let nativeInput: Readonly>; + try { + const snapshot = snapshotStrictJsonValue(request.native); + if (!isJsonRecord(snapshot)) throw new TypeError('stdin JSON value must be an object'); + nativeInput = validateNativeEventEnvelope(snapshot, { + canonicalEvent: event, + nativeEvent: target.nativeEvent, + target: target.target, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new LifecycleReplayRequestError('AB8211', message, 400); + } + const props = createCanonicalEventProps( + event, + nativeInput, + target.target, + target.nativeEvent, + target.hostContractRevision, + signal, + ); + const module = await this.#loadRouteModule(route.source); + const rendered = await this.#render(module, { + input: props, + kind: 'event-route', + routeId: route.id, + signal, + }); + let nativeResponse: Readonly> | undefined; + let projectionDiagnostic: Readonly<{ readonly code: string; readonly message: string }> | undefined; + try { + nativeResponse = projectEventDocument(rendered.document, event, target.target, target.nativeEvent); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + projectionDiagnostic = Object.freeze({ + code: projectionDiagnosticCode, + message: error.message, + }); + } + return deepFreeze({ + binding: { ...request.binding }, + canonical: props.canonical, + document: rendered.document, + events: rendered.events, + nativeInput, + ...(nativeResponse === undefined ? {} : { nativeResponse }), + ...(projectionDiagnostic === undefined ? {} : { projectionDiagnostic }), + requestContext: { + hostContractRevision: target.hostContractRevision, + invocationKind: 'event', + nativeEvent: target.nativeEvent, + routeId: route.id, + target: target.target, + }, + source: request.source, + }); + } + + #targetsFor( + route: CompiledAgentRoute, + projectTargets: readonly string[], + ): Readonly<{ readonly diagnostics: readonly LifecycleDiagnostic[]; readonly targets: readonly LifecycleTarget[] }> { + const configured = route.config['targets']; + const selected = expandedTargets( + Array.isArray(configured) + ? configured.filter((target): target is string => typeof target === 'string') + : projectTargets, + ); + const available = expandedTargets(projectTargets); + const diagnostics: LifecycleDiagnostic[] = []; + const targets: LifecycleTarget[] = []; + for (const target of available) { + if (!selected.includes(target)) { + diagnostics.push(targetDiagnostic( + 'lifecycle.target.excluded', + `Lifecycle replay route ${JSON.stringify(route.id)} excludes target ${JSON.stringify(target)}.`, + target, + 'warning', + )); + continue; + } + const projected = targetFor(this.#registry, target, route.event!); + if ('severity' in projected) diagnostics.push(projected); + else targets.push(projected); + } + return deepFreeze({ diagnostics, targets }); + } + + #log( + kind: DevLogKindFor<'hook'>, + level: 'error' | 'info', + summary: string, + binding: LifecycleBinding, + details: unknown, + ): void { + try { + this.#logger?.log({ + context: { routeId: binding.routeId, target: binding.target }, + details, + kind, + level, + producer: 'hook', + summary, + }); + } catch { /* Diagnostics cannot affect lifecycle replay. */ } + } +} diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index cfba107c8..1a1b0e603 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -13,6 +13,7 @@ import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; import { createInspectorLauncher } from './inspector-launcher.ts'; import { HookPlaygroundService } from './playground/hook-playground-service.ts'; +import { LifecycleReplayService } from './playground/lifecycle-replay-service.ts'; import { startForegroundServer, type ForegroundCoordinator, @@ -684,6 +685,20 @@ export const startDevServer = async (options: StartDevServerOptions): Promise { + const prepared = latestValidPreparedProject; + if (prepared?.model === undefined) { + throw new Error('No valid prepared project is available for lifecycle replay.'); + } + return Object.freeze({ + graph: prepared.routeGraph ?? emptyCompiledRouteGraph, + targets: Object.freeze(prepared.model.targets.map((target) => target.name)), + }); + }, + registry, + }); const skillDocuments = new SkillDocumentService({ epochStore, projectService, root }); const artifacts = new ArtifactInspectionService(epochStore, registry); const evals = new EvalService({ logger: logs, projectRoot: root, registry }); @@ -755,6 +770,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise>): Readonly> => Object.freeze(structuredClone(native)); +export interface NativeEventEnvelopeValidation { + readonly canonicalEvent: CanonicalAgentEvent; + readonly nativeEvent: string; + readonly target: string; +} + +const nativeEventError = (message: string): never => { + throw new Error(`Agent Bundle event route error: ${message}`); +}; + +const requireNativeString = (input: Readonly>, field: string): void => { + const value = input[field]; + if (typeof value !== 'string' || value.trim() === '') { + nativeEventError(`native ${field} must be a nonempty string`); + } +}; + +/** + * Validates the host envelope shared by generated event wrappers and semantic + * lifecycle replay. The process-edge stdin byte limit remains wrapper-owned. + */ +export const validateNativeEventEnvelope = ( + input: unknown, + validation: NativeEventEnvelopeValidation, +): Readonly> => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return nativeEventError('stdin JSON value must be an object'); + } + const native = input as Readonly>; + const { canonicalEvent, nativeEvent, target } = validation; + if (native.hook_event_name !== nativeEvent) { + return nativeEventError(`native hook_event_name must equal ${nativeEvent}`); + } + if (target === 'cursor') { + if (typeof native.session_id !== 'string' && typeof native.conversation_id !== 'string') { + return nativeEventError('native session_id or conversation_id must be a string'); + } + if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + if (canonicalEvent === 'tool/after') requireNativeString(native, 'tool_output'); + } + if (canonicalEvent === 'stop' && typeof native.loop_count !== 'number') { + return nativeEventError('native loop_count must be a number'); + } + return native; + } + requireNativeString(native, 'session_id'); + if (target === 'codex') { + if (native.transcript_path !== null && typeof native.transcript_path !== 'string') { + return nativeEventError('native transcript_path must be a string or null'); + } + } else { + requireNativeString(native, 'transcript_path'); + } + requireNativeString(native, 'cwd'); + if (canonicalEvent === 'session/start') requireNativeString(native, 'source'); + if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + if ( + canonicalEvent === 'tool/after' + && (typeof native.tool_response !== 'object' || native.tool_response === null || Array.isArray(native.tool_response)) + ) { + return nativeEventError('native tool_response must be an object'); + } + } + if (canonicalEvent === 'agent/start' || canonicalEvent === 'agent/stop') { + requireNativeString(native, 'agent_id'); + requireNativeString(native, 'agent_type'); + if (target === 'codex') { + requireNativeString(native, 'turn_id'); + requireNativeString(native, 'model'); + requireNativeString(native, 'permission_mode'); + if (!['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'].includes(String(native.permission_mode))) { + return nativeEventError('native permission_mode is invalid'); + } + } + if (canonicalEvent === 'agent/stop') { + if (typeof native.stop_hook_active !== 'boolean') { + return nativeEventError('native stop_hook_active must be a boolean'); + } + if (native.agent_transcript_path !== null && typeof native.agent_transcript_path !== 'string') { + return nativeEventError('native agent_transcript_path must be a string or null'); + } + if (native.last_assistant_message !== null && typeof native.last_assistant_message !== 'string') { + return nativeEventError('native last_assistant_message must be a string or null'); + } + } + } + if (canonicalEvent === 'stop') { + if (typeof native.stop_hook_active !== 'boolean') { + return nativeEventError('native stop_hook_active must be a boolean'); + } + requireNativeString(native, 'last_assistant_message'); + } + return native; +}; + const resolveServerNode = async (node: ReactNode): Promise => { if (Array.isArray(node)) return Promise.all(node.map(resolveServerNode)); if (!isValidElement>(node)) return node; diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index cc79c033c..7eab5394a 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -2,11 +2,55 @@ import { Agent } from '@agent-bundle/runtime'; import { expect, it } from '@rstest/core'; import { createElement } from 'react'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute, + validateNativeEventEnvelope, } from '../src/events/project.ts'; +import { canonicalAgentEvents } from '../src/routes/public.ts'; + +it('validates every adapter-advertised native event starter', () => { + const registry = createDefaultRegistry(); + for (const target of ['claude', 'codex', 'cursor']) { + const contract = registry.hookContract(target); + expect(contract).toBeDefined(); + for (const canonicalEvent of canonicalAgentEvents) { + const nativeEvent = contract?.eventRouteNames?.[canonicalEvent]; + if (nativeEvent === undefined) continue; + const starter = contract?.nativeEventStarter?.(canonicalEvent); + expect(starter, `${target}:${canonicalEvent}`).toBeDefined(); + expect(validateNativeEventEnvelope(starter, { canonicalEvent, nativeEvent, target })).toBe(starter); + } + } +}); + +it('validates native event envelopes with the generated wrapper error contract', () => { + const options = { + canonicalEvent: 'tool/after' as const, + nativeEvent: 'PostToolUse', + target: 'claude', + }; + const valid = { + cwd: '/tmp/lifecycle-replay', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: {}, + tool_name: 'Write', + tool_response: {}, + tool_use_id: 'tool-1', + transcript_path: '/tmp/lifecycle-replay/transcript.jsonl', + }; + + expect(validateNativeEventEnvelope(valid, options)).toBe(valid); + expect(() => validateNativeEventEnvelope({ ...valid, tool_response: 'not-an-object' }, options)) + .toThrow('Agent Bundle event route error: native tool_response must be an object'); + expect(() => validateNativeEventEnvelope({ ...valid, hook_event_name: 'BeforeToolUse' }, options)) + .toThrow('Agent Bundle event route error: native hook_event_name must equal PostToolUse'); + expect(() => validateNativeEventEnvelope([], options)) + .toThrow('Agent Bundle event route error: stdin JSON value must be an object'); +}); it('resolves nested Server Components in explicit standalone event routes', async () => { const NestedContext = async () => createElement(Agent.Context, null, 'standalone'); diff --git a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts new file mode 100644 index 000000000..924dafa35 --- /dev/null +++ b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, it } from '@rstest/core'; + +import type { + LifecycleListResponse, + LifecycleReplay, + LifecycleReplayDiagnosticResult, + LifecycleReplayRequest, +} from '../src/contracts/lifecycles.ts'; +import { + LifecycleReplayRoutes, + type LifecycleReplayRouteService, +} from '../src/dev/playground/lifecycle-replay-routes.ts'; +import { LifecycleReplayService } from '../src/dev/playground/lifecycle-replay-service.ts'; +import type { CompiledRouteGraph } from '../src/routes/types.ts'; +import { + authorize, + originHeaders, + startRoutes as startRouteServer, +} from './support/route-harness.ts'; + +const graph = Object.freeze({ + diagnostics: Object.freeze([]), + digest: 'manifest-a', + events: Object.freeze([{ + config: Object.freeze({}), + event: 'tool/after', + id: 'event:tool/after', + kind: 'event-route', + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/after.tsx' }), + source: '/project/src/events/tool/after.tsx', + }]), + providers: Object.freeze([]), + scripts: Object.freeze([]), + servers: Object.freeze([]), +} satisfies CompiledRouteGraph); + +const list: LifecycleListResponse = Object.freeze({ + lifecycles: Object.freeze([]), + manifestDigest: 'manifest-a', +}); + +class RecordingService implements LifecycleReplayRouteService { + result: LifecycleReplay | LifecycleReplayDiagnosticResult = Object.freeze({ + binding: Object.freeze({ manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }), + canonical: Object.freeze({ + event: 'tool/after', + idempotencyKey: 'key', + observedAt: '2026-09-02T00:00:00.000Z', + provenance: Object.freeze({ + host: 'claude', + hostContractRevision: '2.1.250', + nativeEvent: 'PostToolUse', + source: 'native', + }), + sequence: 1, + }), + events: Object.freeze([]), + nativeInput: Object.freeze({}), + requestContext: Object.freeze({ + hostContractRevision: '2.1.250', + invocationKind: 'event', + nativeEvent: 'PostToolUse', + routeId: 'event:tool/after', + target: 'claude', + }), + source: 'observed', + }); + + list(): LifecycleListResponse { + return list; + } + + async replay(_request: LifecycleReplayRequest): Promise { + return this.result; + } +} + +const startRoutes = async ( + service: LifecycleReplayRouteService, + responseByteLimit?: number, +) => startRouteServer(new LifecycleReplayRoutes({ + authorize, + ...(responseByteLimit === undefined ? {} : { responseByteLimit }), + service, +}), { closeMode: 'awaited' }); + +const post = (url: string, body: unknown): Promise => fetch(url, { + body: JSON.stringify(body), + headers: { ...originHeaders(), 'content-type': 'application/json' }, + method: 'POST', +}); + +it('serves the lifecycle wire contract behind the same-session guard', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + try { + const unauthorized = await fetch(`${started.url}/api/lifecycles`); + expect(unauthorized.status).toBe(403); + + const listed = await fetch(`${started.url}/api/lifecycles`, { headers: originHeaders() }); + expect(listed.status).toBe(200); + await expect(listed.json()).resolves.toEqual(list); + + const replayed = await post(`${started.url}/api/lifecycles/replays`, { + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }, + native: {}, + source: 'observed', + }); + expect(replayed.status).toBe(200); + await expect(replayed.json()).resolves.toEqual({ replay: service.result }); + } finally { + await started.close(); + } +}); + +it('preserves stale and real native-envelope diagnostics at the HTTP boundary', async () => { + const service = new LifecycleReplayService({ + prepared: () => ({ graph, targets: ['claude'] }), + render: async () => { throw new Error('render must not run'); }, + }); + const started = await startRoutes(service); + try { + const stale = await post(`${started.url}/api/lifecycles/replays`, { + binding: { manifestDigest: 'manifest-old', routeId: 'event:tool/after', target: 'claude' }, + native: {}, + source: 'observed', + }); + expect(stale.status).toBe(409); + await expect(stale.json()).resolves.toEqual({ + diagnostic: { code: 'AB8213', message: 'Lifecycle replay manifest binding is stale.' }, + }); + + const malformed = await post(`${started.url}/api/lifecycles/replays`, { + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }, + native: { + cwd: '/tmp/lifecycle-replay', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: {}, + tool_name: 'Write', + tool_response: 'invalid', + tool_use_id: 'tool-1', + transcript_path: '/tmp/lifecycle-replay/transcript.jsonl', + }, + source: 'observed', + }); + expect(malformed.status).toBe(400); + await expect(malformed.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8211', + message: 'Agent Bundle event route error: native tool_response must be an object', + }, + }); + } finally { + await started.close(); + } +}); + +it('rejects malformed bodies and aggregate replay responses over the named budget', async () => { + const service = new RecordingService(); + const started = await startRoutes(service, 256); + try { + const malformed = await post(`${started.url}/api/lifecycles/replays`, { + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }, + native: [], + source: 'capture', + }); + expect(malformed.status).toBe(400); + await expect(malformed.json()).resolves.toEqual({ + diagnostic: { code: 'AB8211', message: 'Lifecycle replay request has an invalid shape.' }, + }); + + service.result = Object.freeze({ + ...service.result, + nativeResponse: Object.freeze({ context: 'x'.repeat(512) }), + }) as LifecycleReplay; + const oversized = await post(`${started.url}/api/lifecycles/replays`, { + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }, + native: {}, + source: 'fixture', + }); + expect(oversized.status).toBe(413); + await expect(oversized.json()).resolves.toEqual({ + diagnostic: { code: 'AB8214', message: 'Lifecycle replay exceeds the 16 MiB response limit.' }, + }); + } finally { + await started.close(); + } +}); diff --git a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts new file mode 100644 index 000000000..1cc5a0012 --- /dev/null +++ b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from '@rstest/core'; + +import type { AgentRouteModule } from '../src/test/types.ts'; +import { LifecycleReplayService } from '../src/dev/playground/lifecycle-replay-service.ts'; +import type { CompiledRouteGraph } from '../src/routes/types.ts'; + +const graph = Object.freeze({ + diagnostics: Object.freeze([]), + digest: 'manifest-a', + events: Object.freeze([{ + config: Object.freeze({}), + event: 'tool/after', + id: 'event:tool/after', + kind: 'event-route', + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/after.tsx' }), + source: '/project/src/events/tool/after.tsx', + }]), + providers: Object.freeze([]), + scripts: Object.freeze([]), + servers: Object.freeze([]), +} satisfies CompiledRouteGraph); + +const service = (): LifecycleReplayService => new LifecycleReplayService({ + prepared: () => ({ + graph, + targets: ['plugin', 'cursor', 'portable'], + }), + loadRouteModule: async () => ({ default: async () => undefined }) as AgentRouteModule, + render: async () => { + throw new Error('render must not run in validation tests'); + }, +}); + +it('projects event routes across concrete hosts and diagnoses excluded targets', () => { + const listed = service().list(); + + expect(listed.manifestDigest).toBe('manifest-a'); + expect(listed.lifecycles).toHaveLength(1); + expect(listed.lifecycles[0]).toMatchObject({ + event: 'tool/after', + routeId: 'event:tool/after', + routePath: 'src/events/tool/after.tsx', + targets: [ + { nativeEvent: 'PostToolUse', target: 'claude' }, + { nativeEvent: 'PostToolUse', target: 'codex' }, + { nativeEvent: 'postToolUse', target: 'cursor' }, + ], + }); + expect(listed.lifecycles[0]?.targets.every((target) => target.target !== 'plugin')).toBe(true); + expect(listed.lifecycles[0]?.diagnostics).toContainEqual({ + code: 'lifecycle.target.unsupported', + message: 'Lifecycle replay target "portable" cannot map canonical event "tool/after".', + severity: 'error', + target: 'portable', + }); +}); + +it('fails closed before loading a route when the manifest binding is stale', async () => { + await expect(service().replay({ + binding: { manifestDigest: 'manifest-old', routeId: 'event:tool/after', target: 'claude' }, + native: {}, + source: 'observed', + })).rejects.toMatchObject({ + code: 'AB8213', + message: 'Lifecycle replay manifest binding is stale.', + status: 409, + }); +}); + +it('surfaces the real native envelope validator message as a malformed request', async () => { + await expect(service().replay({ + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/after', target: 'claude' }, + native: { + cwd: '/tmp/lifecycle-replay', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: {}, + tool_name: 'Write', + tool_response: 'invalid', + tool_use_id: 'tool-1', + transcript_path: '/tmp/lifecycle-replay/transcript.jsonl', + }, + source: 'observed', + })).rejects.toMatchObject({ + code: 'AB8211', + message: 'Agent Bundle event route error: native tool_response must be an object', + status: 400, + }); +}); diff --git a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts new file mode 100644 index 000000000..78a3418ad --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts @@ -0,0 +1,124 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import type { LifecycleReplay } from '../../src/contracts/lifecycles.ts'; +import { LifecycleReplayService } from '../../src/dev/playground/lifecycle-replay-service.ts'; +import { projectEventDocument } from '../../src/events/project.ts'; +import { compileRouteGraph } from '../../src/routes/graph.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +const createFixtureProject = async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-lifecycle-replay-')); + roots.push(root); + await symlink(join(process.cwd(), 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ name: 'lifecycle-replay-fixture', type: 'module' })), + writeProjectFile(root, 'src/events/tool/after.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + 'export default async function AfterTool({ canonical }) {', + ' return createElement(', + ' Agent.Result,', + ' null,', + " createElement(Agent.Markdown, null, `Observed ${canonical.event} from ${canonical.provenance.host}.`),", + " createElement(Agent.Context, null, 'Lifecycle replay context.'),", + ' );', + '}', + '', + ].join('\n')), + ]); + const graph = await compileRouteGraph(root, { targets: ['claude', 'codex'] } as never); + expect(graph.events.map((route) => route.id)).toEqual(['event:tool/after']); + return { graph, root }; +}; + +it('replays Claude and Codex PostToolUse through decode, route execution, render, and encode', async () => { + const { graph } = await createFixtureProject(); + const service = new LifecycleReplayService({ + prepared: () => ({ graph, targets: ['claude', 'codex'] }), + }); + const fixtures = [ + { + native: JSON.parse(await readFile( + new URL('../../../../examples/rsc-agent-runtime/tests/fixtures/events/claude-post-tool-use.json', import.meta.url), + 'utf8', + )) as Record, + source: 'fixture' as const, + target: 'claude', + }, + { + native: JSON.parse(await readFile( + new URL('../../../../examples/rsc-agent-runtime/tests/fixtures/events/codex-post-tool-use.json', import.meta.url), + 'utf8', + )) as Record, + source: 'observed' as const, + target: 'codex', + }, + ]; + + for (const fixture of fixtures) { + const result = await service.replay({ + binding: { + manifestDigest: graph.digest, + routeId: 'event:tool/after', + target: fixture.target, + }, + native: fixture.native, + source: fixture.source, + }); + expect('diagnostics' in result).toBe(false); + const replay = result as LifecycleReplay; + expect(replay.source).toBe(fixture.source); + expect(replay.binding).toEqual({ + manifestDigest: graph.digest, + routeId: 'event:tool/after', + target: fixture.target, + }); + expect(replay.canonical).toMatchObject({ + event: 'tool/after', + provenance: { + host: fixture.target, + hostContractRevision: expect.any(String), + nativeEvent: 'PostToolUse', + source: 'native', + }, + }); + expect(replay.nativeInput).toEqual(fixture.native); + expect(replay.requestContext).toMatchObject({ + invocationKind: 'event', + nativeEvent: 'PostToolUse', + routeId: 'event:tool/after', + target: fixture.target, + }); + expect(replay.events[0]?.type).toBe('shell'); + expect(replay.events.at(-1)?.type).toBe('complete'); + const firstSequence = replay.events[0]?.sequence ?? 0; + expect(replay.events.map((event) => event.sequence)).toEqual( + replay.events.map((_, index) => firstSequence + index), + ); + expect(replay.document?.status).toBe('success'); + expect(replay.nativeResponse).toEqual( + projectEventDocument(replay.document!, 'tool/after', fixture.target, 'PostToolUse'), + ); + expect(replay.nativeResponse).toEqual({ + hookSpecificOutput: { + additionalContext: 'Lifecycle replay context.', + hookEventName: 'PostToolUse', + }, + }); + } +}); From b125b558a6ac2bb1290a113e3ecff43c76bbbc21 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 03:55:16 +0000 Subject: [PATCH 2/3] feat(workbench): add semantic lifecycle replay Expose fixture and observed native replay evidence in one correlated lifecycle view with fail-closed stale-manifest repair. --- .../rstest.lifecycles.browser.config.ts | 38 ++ .../workbench/rstest.lifecycles.e2e.config.ts | 14 + .../src/lifecycles/lifecycle-client.ts | 214 +++++++++++ .../src/lifecycles/lifecycles-model.ts | 193 ++++++++++ .../src/lifecycles/lifecycles-page.css | 46 +++ .../src/lifecycles/lifecycles-page.tsx | 363 ++++++++++++++++++ packages/workbench/src/main.tsx | 26 ++ .../src/runtime/agent-document-client.ts | 4 +- .../workbench/src/workbench-capabilities.ts | 1 + packages/workbench/src/workbench-screen.tsx | 3 +- .../workbench/tests/lifecycle-client.test.ts | 270 +++++++++++++ .../workbench/tests/lifecycles-model.test.ts | 210 ++++++++++ .../tests/lifecycles-page.browser.test.tsx | 227 +++++++++++ .../workbench/tests/lifecycles-page.test.ts | 145 +++++++ .../workbench/tests/lifecycles.e2e.test.ts | 134 +++++++ .../tests/workbench-capabilities.test.ts | 3 +- .../workbench/tests/workbench-screen.test.ts | 4 +- 17 files changed, 1890 insertions(+), 5 deletions(-) create mode 100644 packages/workbench/rstest.lifecycles.browser.config.ts create mode 100644 packages/workbench/rstest.lifecycles.e2e.config.ts create mode 100644 packages/workbench/src/lifecycles/lifecycle-client.ts create mode 100644 packages/workbench/src/lifecycles/lifecycles-model.ts create mode 100644 packages/workbench/src/lifecycles/lifecycles-page.css create mode 100644 packages/workbench/src/lifecycles/lifecycles-page.tsx create mode 100644 packages/workbench/tests/lifecycle-client.test.ts create mode 100644 packages/workbench/tests/lifecycles-model.test.ts create mode 100644 packages/workbench/tests/lifecycles-page.browser.test.tsx create mode 100644 packages/workbench/tests/lifecycles-page.test.ts create mode 100644 packages/workbench/tests/lifecycles.e2e.test.ts diff --git a/packages/workbench/rstest.lifecycles.browser.config.ts b/packages/workbench/rstest.lifecycles.browser.config.ts new file mode 100644 index 000000000..a9f50b70f --- /dev/null +++ b/packages/workbench/rstest.lifecycles.browser.config.ts @@ -0,0 +1,38 @@ +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { pluginReact } from '@rsbuild/plugin-react'; +import { withRslibConfig } from '@rstest/adapter-rslib'; +import { defineConfig } from '@rstest/core'; + +const workspaceRoot = resolve(import.meta.dirname, '../..'); +const browserReactRoot = realpathSync(resolve(import.meta.dirname, 'node_modules/react')); +const browserReactDomRoot = realpathSync(resolve(import.meta.dirname, 'node_modules/react-dom')); + +export default defineConfig({ + browser: { + enabled: true, + headless: true, + provider: 'playwright', + providerOptions: { launch: { channel: 'chrome' } }, + viewport: { height: 900, width: 1440 }, + }, + extends: withRslibConfig(), + include: ['packages/workbench/tests/lifecycles-page.browser.test.tsx'], + plugins: [pluginReact()], + root: workspaceRoot, + setupFiles: ['./rstest.setup.browser.ts'], + resolve: { + alias: { + react: browserReactRoot, + 'react-dom': browserReactDomRoot, + }, + }, + tools: { + rspack: { + resolve: { + extensionAlias: { '.js': ['.js', '.ts', '.tsx'], '.jsx': ['.jsx', '.tsx'] }, + }, + }, + }, +}); diff --git a/packages/workbench/rstest.lifecycles.e2e.config.ts b/packages/workbench/rstest.lifecycles.e2e.config.ts new file mode 100644 index 000000000..e565055eb --- /dev/null +++ b/packages/workbench/rstest.lifecycles.e2e.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rstest/core'; + +import { withAgentBundleRslibConfig } from '../../rstest.rslib.ts'; + +export default defineConfig({ + extends: withAgentBundleRslibConfig(), + env: { AGENT_BUNDLE_LIFECYCLE_E2E: '1' }, + globalSetup: ['../../rstest.integration.setup.ts'], + include: ['tests/lifecycles.e2e.test.ts'], + isolate: true, + root: import.meta.dirname, + setupFiles: ['../../rstest.setup.ts'], + testTimeout: 30_000, +}); diff --git a/packages/workbench/src/lifecycles/lifecycle-client.ts b/packages/workbench/src/lifecycles/lifecycle-client.ts new file mode 100644 index 000000000..3696d7e1e --- /dev/null +++ b/packages/workbench/src/lifecycles/lifecycle-client.ts @@ -0,0 +1,214 @@ +import { z } from 'zod'; + +import type { + Lifecycle, + LifecycleBinding, + LifecycleDiagnostic, + LifecycleListResponse, + LifecycleReplay, + LifecycleReplayDiagnosticResult, + LifecycleReplayRequest, + LifecycleReplaySource, + LifecycleTarget, +} from '../../../agent-bundle/src/contracts/lifecycles.ts'; +import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; +import { + agentDocumentSchema, + agentRenderEventSchema, +} from '../runtime/agent-document-client.ts'; +import { deeplyFrozenHookValue } from '../hooks/hook-client.ts'; + +export type { + Lifecycle, + LifecycleBinding as LifecycleReplayBinding, + LifecycleDiagnostic, + LifecycleListResponse, + LifecycleReplay, + LifecycleReplayRequest, + LifecycleReplaySource, + LifecycleTarget, +}; + +export type LifecycleReplayResult = + | LifecycleReplayDiagnosticResult + | Readonly<{ readonly replay: LifecycleReplay }>; + +export interface LifecycleClientOptions { + readonly foreground: ForegroundRequestAuthority; +} + +export const LIFECYCLE_STALE_DIGEST_CODE = 'AB8213'; + +export class LifecycleClientError extends Error { + readonly code: string; + readonly status: number | undefined; + + constructor(code: string, message: string, status?: number) { + super(message); + this.name = 'LifecycleClientError'; + this.code = code; + this.status = status; + } +} + +export class LifecycleStaleDigestError extends LifecycleClientError { + constructor(code: string, message: string, status: number) { + super(code, message, status); + this.name = 'LifecycleStaleDigestError'; + } +} + +const invalidResponse = (): LifecycleClientError => + new LifecycleClientError('AB8233', 'Lifecycle replay route returned an invalid response.'); + +const textSchema = z.string(); +const canonicalEventSchema = z.enum([ + 'session/start', + 'tool/before', + 'tool/after', + 'stop', + 'agent/start', + 'agent/stop', + 'workspace/open', +]); +const jsonRecordSchema = z.record(z.string(), z.json()); +const diagnosticSchema = z.strictObject({ + code: textSchema, + message: textSchema, + severity: z.enum(['error', 'warning']), + target: textSchema.optional(), +}); +const fixtureSchema = z.strictObject({ + label: textSchema, + native: jsonRecordSchema, +}); +const targetSchema = z.strictObject({ + fixture: fixtureSchema.optional(), + hostContractRevision: textSchema, + nativeEvent: textSchema, + target: textSchema, +}); +const lifecycleSchema = z.strictObject({ + diagnostics: z.array(diagnosticSchema), + event: canonicalEventSchema, + routeId: textSchema, + routePath: textSchema, + targets: z.array(targetSchema), +}); +const listResponseSchema = z.strictObject({ + lifecycles: z.array(lifecycleSchema), + manifestDigest: textSchema, +}); +const bindingSchema = z.strictObject({ + manifestDigest: textSchema, + routeId: textSchema, + target: textSchema, +}); +const canonicalSchema = z.strictObject({ + event: canonicalEventSchema, + idempotencyKey: textSchema, + observedAt: textSchema, + provenance: z.strictObject({ + host: textSchema, + hostContractRevision: textSchema, + nativeEvent: textSchema, + source: z.literal('native'), + }), + sequence: z.number().int().nonnegative(), +}); +const requestContextSchema = z.strictObject({ + hostContractRevision: textSchema, + invocationKind: z.literal('event'), + nativeEvent: textSchema, + routeId: textSchema, + target: textSchema, +}); +const replaySchema = z.strictObject({ + binding: bindingSchema, + canonical: canonicalSchema, + document: agentDocumentSchema.optional(), + events: z.array(agentRenderEventSchema), + nativeInput: jsonRecordSchema, + nativeResponse: jsonRecordSchema.optional(), + projectionDiagnostic: z.strictObject({ code: textSchema, message: textSchema }).optional(), + requestContext: requestContextSchema, + source: z.enum(['fixture', 'observed']), +}); +const replayDiagnosticSchema = z.strictObject({ + code: textSchema, + event: canonicalEventSchema, + message: textSchema, + severity: z.literal('error'), + target: textSchema, +}); +const replayResponseSchema = z.union([ + z.strictObject({ diagnostics: z.array(replayDiagnosticSchema) }), + z.strictObject({ replay: replaySchema }), +]); +const errorResponseSchema = z.strictObject({ + diagnostic: z.strictObject({ + code: textSchema, + message: textSchema, + }), +}); + +const frozenInput = (value: unknown): unknown => { + try { + return deeplyFrozenHookValue(value); + } catch { + throw invalidResponse(); + } +}; + +const decode = (schema: z.ZodType, value: unknown): Output => { + const parsed = schema.safeParse(frozenInput(value)); + if (!parsed.success) throw invalidResponse(); + return frozenInput(parsed.data) as Output; +}; + +const failureFor = (value: unknown, status: number): LifecycleClientError => { + let parsed: z.infer | undefined; + try { + const result = errorResponseSchema.safeParse(frozenInput(value)); + if (result.success) parsed = result.data; + } catch { + // Invalid failure bodies fall through to the status-only diagnostic. + } + if (parsed === undefined) { + return new LifecycleClientError('AB8233', `Lifecycle replay request failed with HTTP ${String(status)}.`, status); + } + const { code, message } = parsed.diagnostic; + if (code === LIFECYCLE_STALE_DIGEST_CODE) { + return new LifecycleStaleDigestError(code, message, status); + } + return new LifecycleClientError(code, message, status); +}; + +/** Strict browser client for semantic lifecycle discovery and deterministic replay. */ +export class LifecycleClient { + readonly #foreground: ForegroundRequestAuthority; + + constructor(options: LifecycleClientOptions) { + this.#foreground = options.foreground; + } + + async list(signal?: AbortSignal): Promise { + return decode(listResponseSchema, await this.#json('/api/lifecycles', signal === undefined ? {} : { signal })); + } + + async replay(request: LifecycleReplayRequest, signal?: AbortSignal): Promise { + return decode(replayResponseSchema, await this.#json('/api/lifecycles/replays', { + body: JSON.stringify(request), + headers: { 'content-type': 'application/json' }, + method: 'POST', + ...(signal === undefined ? {} : { signal }), + })); + } + + async #json(path: string, init: RequestInit): Promise { + const response = await this.#foreground.protectedRequest(path, init); + const body: unknown = await response.json().catch(() => undefined); + if (!response.ok) throw failureFor(body, response.status); + return body; + } +} diff --git a/packages/workbench/src/lifecycles/lifecycles-model.ts b/packages/workbench/src/lifecycles/lifecycles-model.ts new file mode 100644 index 000000000..3caa69908 --- /dev/null +++ b/packages/workbench/src/lifecycles/lifecycles-model.ts @@ -0,0 +1,193 @@ +import { deeplyFrozenHookValue } from '../hooks/hook-client.ts'; +import type { + LifecycleDiagnostic, + LifecycleListResponse, + LifecycleReplay, + LifecycleReplayResult, + LifecycleReplaySource, +} from './lifecycle-client.ts'; + +export type LifecycleListState = 'error' | 'loading' | 'ready'; +export type LifecyclesViewState = 'diagnostics' | 'empty' | 'list-error' | 'loading' | 'ready' | 'replayed'; +export type LifecycleSourceMode = 'fixture' | 'observed'; + +export interface LifecycleDetailRow { + readonly label: string; + readonly value: string; +} + +export interface LifecycleResultDiagnostic { + readonly code: string; + readonly message: string; + readonly source: 'projection' | 'render stream'; +} + +export interface LifecycleOption { + readonly binding: Readonly<{ + readonly manifestDigest: string; + readonly routeId: string; + readonly target: string; + }>; + readonly event: string; + readonly fixture?: Readonly<{ + readonly label: string; + readonly native: Readonly>; + }>; + readonly hostContractRevision: string; + readonly key: string; + readonly label: string; + readonly nativeEvent: string; + readonly routePath: string; +} + +export interface LifecyclesViewOptions { + readonly list: LifecycleListResponse | undefined; + readonly listState: LifecycleListState; + readonly result: LifecycleReplayResult | undefined; + readonly selectedKey: string | undefined; +} + +export interface LifecyclesView { + readonly canonicalRows: readonly LifecycleDetailRow[]; + readonly listDiagnostics: readonly LifecycleDiagnostic[]; + readonly options: readonly LifecycleOption[]; + readonly replay: LifecycleReplay | undefined; + readonly replayDiagnostics: readonly LifecycleDiagnostic[]; + readonly requestRows: readonly LifecycleDetailRow[]; + readonly resultDiagnostics: readonly LifecycleResultDiagnostic[]; + readonly selected: LifecycleOption | undefined; + readonly state: LifecyclesViewState; + readonly summary: string; +} + +const noRows: readonly LifecycleDetailRow[] = Object.freeze([]); +const noDiagnostics: readonly LifecycleDiagnostic[] = Object.freeze([]); +const noResultDiagnostics: readonly LifecycleResultDiagnostic[] = Object.freeze([]); + +const row = (label: string, value: string): LifecycleDetailRow => Object.freeze({ label, value }); + +export const lifecycleOptionKeyFor = (routeId: string, target: string): string => `${target}/${routeId}`; + +export const lifecycleOptionsFor = (list: LifecycleListResponse): readonly LifecycleOption[] => Object.freeze( + list.lifecycles + .flatMap((lifecycle) => lifecycle.targets.map((target): LifecycleOption => Object.freeze({ + binding: Object.freeze({ + manifestDigest: list.manifestDigest, + routeId: lifecycle.routeId, + target: target.target, + }), + event: lifecycle.event, + ...(target.fixture === undefined ? {} : { + fixture: Object.freeze({ + label: target.fixture.label, + native: target.fixture.native, + }), + }), + hostContractRevision: target.hostContractRevision, + key: lifecycleOptionKeyFor(lifecycle.routeId, target.target), + label: `${lifecycle.event} · ${target.target}`, + nativeEvent: target.nativeEvent, + routePath: lifecycle.routePath, + }))) + .sort((left, right) => left.key.localeCompare(right.key)), +); + +export const lifecycleReplaySourceFor = ( + mode: LifecycleSourceMode, + fixtureEdited: boolean, +): LifecycleReplaySource => mode === 'fixture' && !fixtureEdited ? 'fixture' : 'observed'; + +export const canonicalRowsFor = (replay: LifecycleReplay): readonly LifecycleDetailRow[] => Object.freeze([ + row('Canonical event', replay.canonical.event), + row('Idempotency key', replay.canonical.idempotencyKey), + row('Observed at', replay.canonical.observedAt), + row('Sequence', String(replay.canonical.sequence)), + row('Host', replay.canonical.provenance.host), + row('Native event', replay.canonical.provenance.nativeEvent), + row('Host contract revision', replay.canonical.provenance.hostContractRevision), +]); + +export const requestRowsFor = (replay: LifecycleReplay): readonly LifecycleDetailRow[] => Object.freeze([ + row('Invocation kind', replay.requestContext.invocationKind), + row('Route ID', replay.requestContext.routeId), + row('Target', replay.requestContext.target), + row('Native event', replay.requestContext.nativeEvent), + row('Host contract revision', replay.requestContext.hostContractRevision), +]); + +export const resultDiagnosticsFor = (replay: LifecycleReplay): readonly LifecycleResultDiagnostic[] => Object.freeze([ + ...(replay.projectionDiagnostic === undefined ? [] : [Object.freeze({ + code: replay.projectionDiagnostic.code, + message: replay.projectionDiagnostic.message, + source: 'projection' as const, + })]), + ...replay.events.flatMap((event) => event.type === 'error' + ? [Object.freeze({ + code: event.error.code, + message: event.error.message, + source: 'render stream' as const, + })] + : []), +]); + +const summaryFor = (state: LifecyclesViewState, replay: LifecycleReplay | undefined): string => { + switch (state) { + case 'loading': + return 'Loading semantic lifecycles from the current compiled manifest.'; + case 'list-error': + return 'Semantic lifecycles could not be loaded from the current compiled manifest.'; + case 'empty': + return 'The current compiled manifest exposes no semantic event lifecycles.'; + case 'diagnostics': + return 'The lifecycle replay returned diagnostics instead of executing a route.'; + case 'replayed': + return replay === undefined + ? 'The deterministic lifecycle replay completed.' + : `Replayed ${replay.canonical.event} for ${replay.requestContext.target} from ${replay.source} input.`; + case 'ready': + return 'Choose a compiled event route and host target, then run a deterministic replay.'; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +}; + +/** Pure projection for lifecycle selection, diagnostics, and correlated replay evidence. */ +export const lifecyclesViewFor = (options: LifecyclesViewOptions): LifecyclesView => { + const detached = deeplyFrozenHookValue(options) as LifecyclesViewOptions; + const list = detached.list; + const lifecycleOptions = list === undefined ? Object.freeze([]) : lifecycleOptionsFor(list); + const selected = detached.selectedKey === undefined + ? lifecycleOptions[0] + : lifecycleOptions.find((option) => option.key === detached.selectedKey); + const result = detached.result; + const replay = result !== undefined && 'replay' in result ? result.replay : undefined; + const replayDiagnostics = result !== undefined && 'diagnostics' in result ? result.diagnostics : noDiagnostics; + const listDiagnostics = list === undefined + ? noDiagnostics + : Object.freeze(list.lifecycles.flatMap((lifecycle) => lifecycle.diagnostics)); + const state: LifecyclesViewState = detached.listState === 'loading' + ? 'loading' + : detached.listState === 'error' + ? 'list-error' + : lifecycleOptions.length === 0 + ? 'empty' + : replay !== undefined + ? 'replayed' + : replayDiagnostics.length > 0 + ? 'diagnostics' + : 'ready'; + return Object.freeze({ + canonicalRows: replay === undefined ? noRows : canonicalRowsFor(replay), + listDiagnostics, + options: lifecycleOptions, + replay, + replayDiagnostics, + requestRows: replay === undefined ? noRows : requestRowsFor(replay), + resultDiagnostics: replay === undefined ? noResultDiagnostics : resultDiagnosticsFor(replay), + selected, + state, + summary: summaryFor(state, replay), + }); +}; diff --git a/packages/workbench/src/lifecycles/lifecycles-page.css b/packages/workbench/src/lifecycles/lifecycles-page.css new file mode 100644 index 000000000..41a35a78a --- /dev/null +++ b/packages/workbench/src/lifecycles/lifecycles-page.css @@ -0,0 +1,46 @@ +.lifecycles-content { margin: 0 auto; max-width: 1220px; min-width: 0; padding: 35px 34px 64px; width: 100%; } +.lifecycles-page-heading p { color: #596372; font-size: 15px; margin: 8px 0 0; max-width: 780px; } +.lifecycles-page-heading .lifecycle-eyebrow { color: #0b5bd3; font-size: 12px; font-weight: 800; letter-spacing: .08em; margin: 0 0 7px; text-transform: uppercase; } +.lifecycle-controls { border: 1px solid #d4dce7; border-left: 3px solid #0b5bd3; display: grid; gap: 9px; justify-items: start; padding: 20px; } +.lifecycle-controls > label, .lifecycle-source legend { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; margin-top: 8px; text-transform: uppercase; } +.lifecycle-controls select { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font-size: 14px; font-weight: 600; min-height: 39px; padding: 0 34px 0 10px; width: min(100%, 720px); } +.lifecycle-selected-meta { border-bottom: 1px solid #d9dee7; border-top: 1px solid #d9dee7; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 8px 0; width: 100%; } +.lifecycle-selected-meta div { border-right: 1px solid #d9dee7; min-width: 0; padding: 10px 12px; } +.lifecycle-selected-meta div:last-child { border-right: 0; } +.lifecycle-selected-meta dt { font-size: 11px; font-weight: 750; margin-bottom: 4px; text-transform: uppercase; } +.lifecycle-selected-meta dd { font: 12px/1.45 "SFMono-Regular", Consolas, monospace; overflow-wrap: anywhere; } +.lifecycle-source { border: 0; display: flex; gap: 18px; margin: 4px 0 0; padding: 0; } +.lifecycle-source legend { margin-bottom: 8px; padding: 0; } +.lifecycle-source label { align-items: center; display: flex; font-size: 13px; gap: 6px; } +.lifecycle-source input { margin: 0; } +.lifecycle-draft-provenance { border-left: 3px solid #667386; color: #445166; font-size: 13px; margin: 5px 0; padding: 8px 11px; } +.lifecycle-draft-provenance--fixture { background: #eef7ef; border-color: #16803a; color: #205c31; } +.lifecycle-draft-provenance--observed { background: #eef5ff; border-color: #0b5bd3; color: #294969; } +.lifecycle-controls textarea { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font: 13px/1.55 "SFMono-Regular", Consolas, "Liberation Mono", monospace; min-height: 210px; padding: 11px; resize: vertical; width: min(100%, 720px); } +.lifecycle-controls textarea[aria-invalid="true"] { border-color: #c01d26; } +.lifecycle-controls > p[role="alert"], .lifecycle-selection-error { color: #b31b23; font-size: 13px; margin: 0; } +.lifecycle-controls > button, .lifecycle-stale button { background: #0b5bd3; border: 1px solid #06459e; border-radius: 4px; color: #fff; cursor: pointer; font-weight: 700; min-height: 43px; padding: 0 22px; } +.lifecycle-controls > button:hover:not(:disabled), .lifecycle-stale button:hover:not(:disabled) { background: #084eb9; } +.lifecycle-controls > button:disabled, .lifecycle-stale button:disabled { cursor: not-allowed; opacity: .55; } +.lifecycle-result { display: grid; gap: 20px; margin-top: 26px; } +.lifecycle-summary { color: #4f5866; font-size: 15px; margin: 0; } +.lifecycle-provenance { align-items: start; background: #f7f9fc; border: 1px solid #c9d4e4; border-left: 4px solid #667386; display: grid; gap: 14px; grid-template-columns: auto minmax(0, 1fr); padding: 14px; } +.lifecycle-provenance > strong { border: 1px solid currentColor; border-radius: 999px; color: #375271; font-size: 12px; padding: 5px 9px; text-transform: uppercase; } +.lifecycle-provenance--fixture { border-left-color: #16803a; } +.lifecycle-provenance--fixture > strong { color: #147b36; } +.lifecycle-provenance--observed { border-left-color: #0b5bd3; } +.lifecycle-provenance--observed > strong { color: #0759c7; } +.lifecycle-provenance h2, .lifecycle-provenance p { margin: 0; } +.lifecycle-provenance p { color: #4f5866; font-size: 13px; line-height: 1.5; margin-top: 5px; } +.lifecycle-diagnostics, .lifecycle-stale { background: #fff7f7; border-left: 3px solid #c01d26; color: #78242a; margin: 0 0 18px; padding: 11px 14px; } +.lifecycle-diagnostics h2, .lifecycle-stale h2 { color: #78242a; margin-bottom: 7px; } +.lifecycle-diagnostics p, .lifecycle-stale p { font-size: 13px; margin: 5px 0; } +.lifecycle-diagnostics span { display: block; font-size: 12px; margin-top: 4px; } +.lifecycle-stale button { margin-top: 8px; } +.lifecycle-detail { border-top: 1px solid #d9dee7; padding-top: 18px; } +.lifecycle-detail-rows { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); margin: 0; } +.lifecycle-detail-rows div { border-right: 1px solid #d9dee7; min-width: 0; padding: 11px 13px 11px 0; } +.lifecycle-detail-rows div:last-child { border-right: 0; } +.lifecycle-detail-rows dt { font-size: 11px; font-weight: 750; margin-bottom: 5px; text-transform: uppercase; } +.lifecycle-detail-rows dd { font: 12px/1.45 "SFMono-Regular", Consolas, monospace; overflow-wrap: anywhere; } +.lifecycle-json { background: #101822; border: 1px solid #25364b; color: #edf3fb; font: 13px/1.55 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin: 0; max-height: 360px; overflow: auto; padding: 18px; white-space: pre-wrap; word-break: break-word; } diff --git a/packages/workbench/src/lifecycles/lifecycles-page.tsx b/packages/workbench/src/lifecycles/lifecycles-page.tsx new file mode 100644 index 000000000..ceb82b3a7 --- /dev/null +++ b/packages/workbench/src/lifecycles/lifecycles-page.tsx @@ -0,0 +1,363 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { errorMessage as messageFrom, isAbortError } from '../client-helpers.ts'; +import { HookRequestLifecycle } from '../hooks/hooks-page.tsx'; +import { + parseRawJsonRecord, + serializeJsonRecord, + type ImmutableJsonRecord, +} from '../mcp/mcp-json-input.tsx'; +import { AgentDocumentStage } from '../runtime/agent-document-stage.tsx'; +import { + LifecycleStaleDigestError, + type LifecycleClient, + type LifecycleListResponse, + type LifecycleReplayRequest, + type LifecycleReplayResult, +} from './lifecycle-client.ts'; +import { + lifecycleReplaySourceFor, + lifecyclesViewFor, + type LifecycleDetailRow, + type LifecyclesView, + type LifecycleSourceMode, +} from './lifecycles-model.ts'; +import './lifecycles-page.css'; + +export type LifecycleClientSurface = Pick; + +export interface LifecyclesPageProps { + readonly client: LifecycleClientSurface; + /** Invalidates list and replay work when the shell observes a different compiled graph. */ + readonly manifestDigest?: string; +} + +export interface LifecycleReplayViewProps { + readonly view: LifecyclesView; +} + +type StaleReplayState = Readonly<{ + readonly code: string; + readonly message: string; + readonly repaired: boolean; +}>; + +const inputError = 'Observed native receipt must be a JSON object.'; + +const errorMessage = (reason: unknown): string => + messageFrom(reason, 'The lifecycle replay request could not be completed.'); + +export const runLifecycleReplay = ( + client: LifecycleClientSurface, + request: LifecycleReplayRequest, + signal?: AbortSignal, +): Promise => client.replay(request, signal); + +const DetailRows = ({ label, rows }: Readonly<{ + readonly label: string; + readonly rows: readonly LifecycleDetailRow[]; +}>) =>
+

{label}

+
+ {rows.map((detail) =>
{detail.label}
{detail.value}
)} +
+
; + +const JsonBlock = ({ empty, label, value }: Readonly<{ + readonly empty: string; + readonly label: string; + readonly value: Readonly> | undefined; +}>) =>
+

{label}

+ {value === undefined + ?

{empty}

+ :
{serializeJsonRecord(value as ImmutableJsonRecord)}
} +
; + +const DiagnosticList = ({ diagnostics, label }: Readonly<{ + readonly diagnostics: readonly Readonly<{ + readonly code: string; + readonly event?: string; + readonly message: string; + readonly severity?: string; + readonly source?: string; + readonly target?: string; + }>[]; + readonly label: string; +}>) => diagnostics.length === 0 ? undefined :
+

{label}

+ {diagnostics.map((diagnostic, index) =>

+ {diagnostic.code} {diagnostic.message} + + {[ + diagnostic.severity === undefined ? undefined : `Severity: ${diagnostic.severity}`, + diagnostic.event === undefined ? undefined : `Event: ${diagnostic.event}`, + diagnostic.target === undefined ? undefined : `Target: ${diagnostic.target}`, + diagnostic.source === undefined ? undefined : `Source: ${diagnostic.source}`, + ].filter((entry): entry is string => entry !== undefined).join(' · ')} + +

)} +
; + +/** Correlates native receipt provenance, canonical identity, render events, and native projection. */ +export const LifecycleReplayView = ({ view }: LifecycleReplayViewProps) => { + const replay = view.replay; + return
+

{view.summary}

+ + {replay === undefined ? undefined : <> +
+ {replay.source === 'fixture' ? 'Fixture' : 'Observed'} +
+

Deterministic replay

+

This is a deterministic replay from {replay.source === 'fixture' ? 'a checked-in adapter fixture' : 'a pasted observed native receipt'}; it is not evidence that {replay.canonical.provenance.host} dispatched this event.

+
+
+ + + + + + + } +
; +}; + +/** Discovers compiled event lifecycles and explicitly replays native host receipts through them. */ +export const LifecyclesPage = ({ client, manifestDigest }: LifecyclesPageProps) => { + const [busy, setBusy] = useState(false); + const [draft, setDraft] = useState(() => serializeJsonRecord({})); + const [error, setError] = useState(); + const [fixtureEdited, setFixtureEdited] = useState(false); + const [list, setList] = useState(); + const [listState, setListState] = useState<'error' | 'loading' | 'ready'>('loading'); + const [result, setResult] = useState(); + const [selectedKey, setSelectedKey] = useState(); + const [sourceMode, setSourceMode] = useState('fixture'); + const [stale, setStale] = useState(); + const lifecycle = useRef(new HookRequestLifecycle()).current; + const view = lifecyclesViewFor({ list, listState, result, selectedKey }); + const parsed = parseRawJsonRecord(draft); + const submittedSource = lifecycleReplaySourceFor(sourceMode, fixtureEdited); + + useEffect(() => { + lifecycle.invalidate(); + setBusy(false); + setError(undefined); + setList(undefined); + setListState('loading'); + setResult(undefined); + setSelectedKey(undefined); + setStale(undefined); + const request = lifecycle.begin('list'); + void client.list(request.signal).then( + (next) => { + if (!lifecycle.isCurrent(request)) return; + lifecycle.complete(request); + setList(next); + setListState('ready'); + }, + (reason: unknown) => { + if (!lifecycle.isCurrent(request)) return; + lifecycle.complete(request); + if (isAbortError(reason)) return; + setListState('error'); + setError(errorMessage(reason)); + }, + ); + return () => lifecycle.invalidate(); + }, [client, lifecycle, manifestDigest]); + + useEffect(() => { + const selected = view.selected; + if (selected === undefined || selectedKey !== undefined) return; + setSelectedKey(selected.key); + if (selected.fixture === undefined) { + setDraft(serializeJsonRecord({})); + setSourceMode('observed'); + } else { + setDraft(serializeJsonRecord(selected.fixture.native as ImmutableJsonRecord)); + setSourceMode('fixture'); + } + setFixtureEdited(false); + }, [selectedKey, view.selected?.key]); + + const select = (key: string): void => { + const selected = view.options.find((option) => option.key === key); + setSelectedKey(key); + setResult(undefined); + setStale(undefined); + setError(undefined); + if (selected?.fixture === undefined) { + setDraft(serializeJsonRecord({})); + setSourceMode('observed'); + } else { + setDraft(serializeJsonRecord(selected.fixture.native as ImmutableJsonRecord)); + setSourceMode('fixture'); + } + setFixtureEdited(false); + }; + + const chooseSource = (mode: LifecycleSourceMode): void => { + setSourceMode(mode); + setResult(undefined); + setStale(undefined); + setError(undefined); + if (mode === 'fixture' && view.selected?.fixture !== undefined) { + setDraft(serializeJsonRecord(view.selected.fixture.native as ImmutableJsonRecord)); + setFixtureEdited(false); + } + }; + + const run = async (): Promise => { + const selected = view.selected; + if (selected === undefined || parsed === null) return; + const request = lifecycle.begin('run'); + setBusy(true); + setError(undefined); + try { + const next = await runLifecycleReplay(client, { + binding: selected.binding, + native: parsed, + source: submittedSource, + }, request.signal); + if (!lifecycle.isCurrent(request)) return; + setResult(next); + setStale(undefined); + } catch (reason) { + if (!lifecycle.isCurrent(request) || isAbortError(reason)) return; + if (reason instanceof LifecycleStaleDigestError) { + setStale(Object.freeze({ code: reason.code, message: reason.message, repaired: false })); + } else { + setError(errorMessage(reason)); + } + } finally { + if (lifecycle.isCurrent(request)) { + setBusy(false); + lifecycle.complete(request); + } + } + }; + + const repair = (): void => { + lifecycle.invalidate(); + setBusy(false); + setError(undefined); + setListState('loading'); + setResult(undefined); + const request = lifecycle.begin('list'); + void client.list(request.signal).then( + (next) => { + if (!lifecycle.isCurrent(request)) return; + lifecycle.complete(request); + setList(next); + setListState('ready'); + setStale((current) => current === undefined ? undefined : Object.freeze({ ...current, repaired: true })); + }, + (reason: unknown) => { + if (!lifecycle.isCurrent(request)) return; + lifecycle.complete(request); + if (isAbortError(reason)) return; + setListState('error'); + setError(errorMessage(reason)); + }, + ); + }; + + return
+
+
+

Host-aware replay

+

Lifecycles

+

Decode a native host receipt, execute its compiled event route, and inspect the correlated render and projection.

+
+
+ {error === undefined ? undefined :

{error}

} + + {stale === undefined ? undefined :
+

Stale compiled manifest

+

{stale.code} {stale.message}

+ {stale.repaired + ?

The lifecycle list was refreshed. Review the preserved native input and run replay explicitly against the current manifest.

+ : } +
} + {view.state === 'loading' + ?

{view.summary}

+ : view.state === 'list-error' || view.state === 'empty' + ?

{view.summary}

+ : <> +
+ + + {view.selected === undefined ?

+ The refreshed manifest no longer exposes the previously selected lifecycle and target. Choose a current binding before replaying the preserved input. +

:
+
Route
{view.selected.routePath}
+
Native event
{view.selected.nativeEvent}
+
Host contract
{view.selected.hostContractRevision}
+
} +
+ Replay source + + +
+

+ {submittedSource === 'fixture' ? 'Fixture' : 'Observed'} + {sourceMode === 'fixture' && fixtureEdited + ? ' Edited fixture JSON is treated as observed input.' + : sourceMode === 'fixture' + ? ` ${view.selected?.fixture?.label ?? 'Checked-in adapter fixture'}` + : ' Pasted or edited native receipt.'} +

+ +