diff --git a/.changeset/wb600-live-trace.md b/.changeset/wb600-live-trace.md new file mode 100644 index 000000000..c08076dc6 --- /dev/null +++ b/.changeset/wb600-live-trace.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Add the Workbench Trace page and live development tracing through `/api/trace`, `/api/trace/stream`, and `/api/trace/receipts`; stream route runs with `POST /api/routes/invocations` using `stream: true` and `GET /api/routes/invocations//stream`, cancel them with `POST /api/routes/invocations//cancel`, and report cancelled runs with `status: 'cancelled'`. `route.invocation` project events now also fire when an invocation starts, with a running record (no `completedAt`/`outcome`); narrow on `status` before reading completion fields. `createEventTracer` called without `observer` now reads the process-local observer on every emission (and `enabled` reflects it live) instead of being permanently disabled, so a tracer created before `installEventTraceObserver` starts emitting once one is installed. Add diagnostics `AB8240`–`AB8243`, `AB8247`–`AB8249`, and `AB8256`. (#666) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 95bd71ac3..4b4e46a90 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -44,9 +44,13 @@ even when no error diagnostic was reported. | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | +| `AB8243` | Workbench browser-side strict decoder rejecting a `/api/trace` replay or NDJSON stream frame (unknown `source`, malformed correlation, unsafe text, or a cursor the reply does not account for). It sits between the server-side trace routes (`AB8240`–`AB8242`) and the hook receipt route (`AB8247`–`AB8249`); `AB8244`–`AB8246` are unassigned. | | `AB8231`–`AB8232`, `AB8236`–`AB8238` | Workbench route invocation service (`/api/routes/invocations`): `AB8231` unknown route or invocation id, `AB8232` no published build / invocation manifest unavailable, `AB8236` render child timed out or crashed, `AB8237` malformed invocation request, and `AB8238` unknown fixture id. (`AB8233`–`AB8235` were already assigned to browser-side decoders.) | +| `AB8240`–`AB8242` | Workbench unified trace routes (`/api/trace`, `/api/trace/stream`): `AB8240` invalid `after` cursor (400), `AB8241` cursor ahead of the current trace sequence (409), and `AB8242` trace routes unavailable before composition or during shutdown (404/503). | +| `AB8247`–`AB8249` | Workbench hook receipt route (`POST /api/trace/receipts`, posted by a generated hook wrapper of the dev plugin): `AB8247` receipt refused — peer not loopback, `Origin` header present, missing or wrong bearer token (403), or receipts closed (409); `AB8248` malformed receipt — query string, non-object body, unknown key, out-of-range enum, or unbounded field (400, the message names the field); `AB8249` receipt over the 16 KiB limit (413). | | `AB8239` | Workbench route invocation service (`/api/routes/invocations`): the published manifest digest or source revision moved while the request waited for a concurrency slot (409). Retry against the current revision so the recorded `manifestDigest`/`sourceRevision` cannot describe a different build than the one that ran. | | `AB8250`–`AB8255` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, `AB8252` compiled CLI projection or event preflight preparation failed, `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:` id was used instead of its canonical `tool:/` id plus CLI surface, and `AB8255` an event route with compiled preflight was submitted without a concrete host surface. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`; use the command or canonical operation named by `AB8253`/`AB8254`; select a generated host wrapper for `AB8255`. | +| `AB8256` | Workbench route invocation cancellation (`POST /api/routes/invocations//cancel`): the invocation is already final (409). Reload the final invocation instead of cancelling it. | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 959d5d0d5..f09158a66 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -667,8 +667,14 @@ const eventRouteHookWrapperSource = ( // then) retires the durable lineage journal itself, so roots never outlive // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; + // The deferred executor is spawned by a preflight wrapper that already + // holds this execution's tracer and receipt; it traces into a disabled + // tracer so one host invocation yields one receipt (#600). const projectBindings = [ 'createCanonicalEventProps', + 'createEventTracer', + 'eventTraceExecution', + ...(deferredExecution ? [] : ['openEventTraceReceipt']), ...(standalone ? ['projectEventDocument'] : []), 'validateNativeEventEnvelope', ]; @@ -790,7 +796,7 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal, observation, preflight) => {', + 'const runStandalone = async (native, signal, observation, preflight, trace, receipt) => {', ' const resolved = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal, observation);', ...(retiresLineage ? [' await retireLineage(native, resolved.canonical.idempotencyKey, resolved.canonical.observedAt);'] : []), ' const sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;', @@ -798,17 +804,26 @@ const eventRouteHookWrapperSource = ( // Standalone hooks hold no registry, so lineage is what the payload proves — plus, on Codex, what the // thread's own rollout named in the payload records (docs/audits/2026-09-03-host-lineage-matrix.md, #423). ' const lineage = target === "claude" || target === "codex" || target === "cursor" ? await resolveStandaloneLineage(target, native) : unavailable("no-subagent-events");', - ' const document = await runAgentRequest({', - ' host: available({ name: target }, "native"),', - ' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },', - ' lineage,', - ' plugin: pluginRoot.identity,', - ' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),', - ' signal,', + ' receipt?.lineage(lineage);', + ' trace.renderStart();', + ' let document;', + ' try {', + ' document = await runAgentRequest({', + ' host: available({ name: target }, "native"),', + ' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },', + ' lineage,', + ' plugin: pluginRoot.identity,', + ' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),', + ' signal,', // A hook's stdout is its host envelope: no terminal, never probed (#511). - ' terminal: available({ hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } }, "derived"),', - ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', - ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native, ...(preflight === undefined ? {} : { preflight }) } } }, signal));', + ' terminal: available({ hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } }, "derived"),', + ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', + ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native, ...(preflight === undefined ? {} : { preflight }) } } }, signal));', + ' } catch (error) {', + ' trace.failure("render", error);', + ' throw error;', + ' }', + ' trace.renderFinish();', ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', '};', ] @@ -836,25 +851,44 @@ const eventRouteHookWrapperSource = ( ' const preflight = undefined;', ]), ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', + ' const execution = eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent });', + deferredExecution + ? ' const receipt = undefined;' + : ' const receipt = await openEventTraceReceipt({ anchor: import.meta.url, env: process.env, execution });', + ' const trace = createEventTracer({ execution, ...(receipt === undefined ? {} : { observer: receipt.observer }) });', + ' receipt?.identity(native);', ' const controller = new AbortController();', ' let output;', - ' if (runtimeMode === "standalone") {', + ' try {', + ' if (runtimeMode === "standalone") {', ...(standalone - ? [' output = await runStandalone(native, controller.signal, observation, preflight);'] - : [' fail("standalone runtime was not compiled");']), - ' } else {', - ' try {', - ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, preflight, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });', - ' } catch (error) {', + ? [ + ' trace.executeStart("standalone");', + ' output = await runStandalone(native, controller.signal, observation, preflight, trace, receipt);', + ] + : [' fail("standalone runtime was not compiled");']), + ' } else {', + ' trace.executeStart("shared");', + ' try {', + ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, preflight, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });', + ' } catch (error) {', ...(standalone ? [ - ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', - ' output = await runStandalone(native, controller.signal, observation, preflight);', + ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', + ' trace.executeStart("standalone");', + ' output = await runStandalone(native, controller.signal, observation, preflight, trace, receipt);', ] - : [' throw error;']), + : [' throw error;']), + ' }', ' }', + ' if (output !== undefined) process.stdout.write(JSON.stringify(output));', + ' } catch (error) {', + // A no-op once a phase already attributed the failure: the tracer is terminal after `failure`. + ' trace.failure("execute", error);', + ' throw error;', + ' } finally {', + ' await receipt?.send();', ' }', - ' if (output !== undefined) process.stdout.write(JSON.stringify(output));', '};', 'if (import.meta.main) {', ' await run().catch((error) => {', @@ -883,6 +917,7 @@ const eventRoutePreflightWrapperSource = ( 'createEventTracer', 'eventTraceExecution', 'executeEventPreflight', + 'openEventTraceReceipt', 'projectEventPreflightResult', 'validateNativeEventEnvelope', ]; @@ -943,26 +978,33 @@ const eventRoutePreflightWrapperSource = ( ' try { parsed = JSON.parse(input.toString("utf8")); } catch { fail("stdin must contain exactly one JSON value"); }', ' const controller = new AbortController();', ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', - ' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal);', - ' if (gate !== "execute" && gate.outcome !== "execute") {', - ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', - ' return;', - ' }', - ' trace.executeStart(runtimeMode);', - ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, ...(gate === "execute" ? {} : { preflight: gate.data }), sequence: props.canonical.sequence }));', - ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', - ' const terminate = () => controller.abort();', - ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', - ' let output;', + ' const execution = eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent });', + ' const receipt = await openEventTraceReceipt({ anchor: import.meta.url, env: process.env, execution });', ' try {', - ' output = await runExecutor(executionInput, controller.signal);', - ' } catch (error) {', - ' trace.failure("execute", error);', - ' throw error;', + ' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal, receipt?.observer);', + ' receipt?.identity(native);', + ' if (gate !== "execute" && gate.outcome !== "execute") {', + ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', + ' return;', + ' }', + ' trace.executeStart(runtimeMode);', + ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, ...(gate === "execute" ? {} : { preflight: gate.data }), sequence: props.canonical.sequence }));', + ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', + ' const terminate = () => controller.abort();', + ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', + ' let output;', + ' try {', + ' output = await runExecutor(executionInput, controller.signal);', + ' } catch (error) {', + ' trace.failure("execute", error);', + ' throw error;', + ' } finally {', + ' for (const terminationSignal of terminationSignals) process.off(terminationSignal, terminate);', + ' }', + ' if (output.length > 0) process.stdout.write(output);', ' } finally {', - ' for (const terminationSignal of terminationSignals) process.off(terminationSignal, terminate);', + ' await receipt?.send();', ' }', - ' if (output.length > 0) process.stdout.write(output);', '};', 'if (import.meta.main) {', ' await run().catch((error) => {', diff --git a/packages/agent-bundle/src/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts index 5b5397bb2..b6be95b2c 100644 --- a/packages/agent-bundle/src/contracts/invocations.ts +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -17,6 +17,7 @@ export type { RouteInvocationProviderStatus, RouteInvocationRequest, RouteInvocationStatus, + RunningRouteInvocation, RouteInvocationSummary, RouteInvocationSurface, RouteInvocationTiming, @@ -24,5 +25,8 @@ export type { export type { RouteInvocation, RouteInvocationResponse, + RouteInvocationStart, + RouteInvocationStreamMessage, + RunningRouteInvocationResponse, } from '../dev/routes/route-invocation-result.ts'; export type { EventTraceEvent } from '../events/trace.ts'; diff --git a/packages/agent-bundle/src/contracts/mcp-session.ts b/packages/agent-bundle/src/contracts/mcp-session.ts index d5346bc0c..65cc6cf86 100644 --- a/packages/agent-bundle/src/contracts/mcp-session.ts +++ b/packages/agent-bundle/src/contracts/mcp-session.ts @@ -8,9 +8,13 @@ export type { McpSessionInspectorConfig, McpSessionOperation, McpSessionTraceEntry, + McpSessionTraceMeta, McpSessionTraceReplayGap, } from '../dev/mcp-session/mcp-session-protocol.ts'; +/** MCP `params._meta` key used to correlate a tool call with its Workbench invocation. */ +export const mcpCorrelationMetaKey = 'agent-bundle/correlationId'; + /** The host targets a Workbench MCP session may bind. */ export const MCP_SESSION_TARGETS = Object.freeze(['claude', 'codex', 'cursor', 'portable'] as const); diff --git a/packages/agent-bundle/src/contracts/trace.ts b/packages/agent-bundle/src/contracts/trace.ts new file mode 100644 index 000000000..e9276752a --- /dev/null +++ b/packages/agent-bundle/src/contracts/trace.ts @@ -0,0 +1,16 @@ +/** + * Browser-consumable contract surface for the Workbench unified trace + * (`GET /api/trace`, `GET /api/trace/stream`). The source vocabulary is + * dependency-free runtime code; the entry shapes are type-only. + */ +export { isTraceReplayGap, isTraceSource, traceSources } from '../dev/trace/trace-entry.ts'; +export type { + TraceCorrelation, + TraceEntry, + TraceEntryInput, + TraceMessage, + TraceReplay, + TraceReplayGap, + TraceSource, + TraceStatus, +} from '../dev/trace/trace-entry.ts'; diff --git a/packages/agent-bundle/src/core/loopback-origin.ts b/packages/agent-bundle/src/core/loopback-origin.ts new file mode 100644 index 000000000..acbc1c491 --- /dev/null +++ b/packages/agent-bundle/src/core/loopback-origin.ts @@ -0,0 +1,17 @@ +/** + * A serialized loopback HTTP origin — `http://127.0.0.1:` or + * `http://[::1]:` with nothing after the authority. The one shape the + * dev lock publishes, the host MCP proxy dials, and a generated hook wrapper + * may post a trace receipt to. + */ +export const isLoopbackHttpOrigin = (value: unknown): value is string => { + if (typeof value !== 'string') return false; + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' + && (parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]') + && parsed.origin === value; + } catch { + return false; + } +}; diff --git a/packages/agent-bundle/src/dev/dev-lock.ts b/packages/agent-bundle/src/dev/dev-lock.ts index a6598982f..683d44399 100644 --- a/packages/agent-bundle/src/dev/dev-lock.ts +++ b/packages/agent-bundle/src/dev/dev-lock.ts @@ -6,6 +6,7 @@ import { sleep } from '../core/async.ts'; import { stableJson } from '../core/digest.ts'; import { publishFileByLink } from '../core/durable-fs.ts'; import { CodedError, isErrno } from '../core/errors.ts'; +import { isLoopbackHttpOrigin } from '../core/loopback-origin.ts'; import { acquireOwnerLockFile, isProcessAlive, ownerLockRaceLost } from '../core/owner-lock.ts'; export interface DevLockOwner { @@ -57,25 +58,13 @@ interface RecoveryRecord { readonly owner: DevLockOwner; } -const isLoopbackServerUrl = (value: unknown): value is string => { - if (typeof value !== 'string') return false; - try { - const parsed = new URL(value); - return parsed.protocol === 'http:' && - (parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]') && - parsed.origin === value; - } catch { - return false; - } -}; - const parseOwnerValue = (value: unknown, projectRoot: string): DevLockOwner | undefined => { try { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; const parsed = value as Partial; const pid = parsed.pid; const hasUrl = Object.hasOwn(parsed, 'url'); - const url = hasUrl && isLoopbackServerUrl(parsed.url) ? parsed.url : undefined; + const url = hasUrl && isLoopbackHttpOrigin(parsed.url) ? parsed.url : undefined; if ( Object.keys(parsed).length !== (hasUrl ? 5 : 4) || !Object.hasOwn(parsed, 'createdAt') || @@ -134,7 +123,7 @@ const parseServerUrl = (contents: string, owner: DevLockOwner): string | undefin if ( Object.keys(record).length !== 2 || record.nonce !== owner.nonce || - !isLoopbackServerUrl(record.url) + !isLoopbackHttpOrigin(record.url) ) return undefined; const canonical = `${stableJson({ nonce: record.nonce, url: record.url })}\n`; return contents === canonical ? record.url : undefined; @@ -317,7 +306,7 @@ export class DevLock { } publishServerUrl(url: string): Promise { - if (!isLoopbackServerUrl(url)) return Promise.reject(new TypeError('Development server URL must be a loopback HTTP origin.')); + if (!isLoopbackHttpOrigin(url)) return Promise.reject(new TypeError('Development server URL must be a loopback HTTP origin.')); if (this.#closed || this.#closePromise !== undefined) return Promise.reject(new Error('Development lock is closing.')); if (this.#owner.url === url) return Promise.resolve(); if (this.#owner.url !== undefined) return Promise.reject(new Error('Development lock already published a different server URL.')); diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 111eece8d..6b79fe36b 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -14,6 +14,7 @@ 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 { HostDiscoveryRoutes, type HostDiscoveryRouteService } from './playground/host-discovery-routes.ts'; +import type { HookReceiptRoutes } from './hooks/hook-receipt-endpoint.ts'; import type { HostMcpRoutes } from './host-mcp-routes.ts'; import { LifecycleReplayRoutes, type LifecycleReplayRouteService } from './playground/lifecycle-replay-routes.ts'; import { McpProbeRoutes, type McpProbeRouteService } from './playground/mcp-probe-routes.ts'; @@ -27,6 +28,8 @@ import { PlaygroundRoutes, type PlaygroundRouteService } from './playground/play import { RouteInvocationRoutes, type RouteInvocationRouteService } from './routes/route-invocation-routes.ts'; import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/route-manifest-routes.ts'; import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts'; +import type { TraceHub } from './trace/trace-hub.ts'; +import { TraceRoutes } from './trace/trace-routes.ts'; import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts'; import { WebHostRoutes, type WebHostEpochSource, type WebHostLaunchOptions } from './web-host-routes.ts'; import { workbenchAssetCacheControl } from './workbench-assets.ts'; @@ -86,7 +89,7 @@ export class ForegroundServerError extends Error { export interface ForegroundServerCloseFailure { readonly error: unknown; - readonly resource: 'agent-api' | 'coordinator' | 'eval-routes' | 'eval-service' | 'hook-playground' | 'logs' | 'mcp-apps' | 'route-invocations' | 'server'; + readonly resource: 'agent-api' | 'coordinator' | 'eval-routes' | 'eval-service' | 'hook-playground' | 'logs' | 'mcp-apps' | 'route-invocations' | 'server' | 'trace'; } export interface ForegroundServerStartFailure { @@ -172,6 +175,7 @@ export interface ForegroundServerOptions { readonly mcpAppSandboxOrigin?: () => string | undefined; /** Epoch-bound hook playground service; the browser never selects a wrapper or artifact path. */ readonly hookPlayground?: HookPlaygroundRouteService; + readonly hookReceipts?: HookReceiptRoutes; /** Read-only host probes, install inventory, bundle drift, and runtime endpoint health. */ readonly hostDiscovery?: HostDiscoveryRouteService; /** Stateful MCP surface used only by stable development host proxies. */ @@ -197,6 +201,8 @@ export interface ForegroundServerOptions { readonly routeInvocations?: RouteInvocationRouteService; /** Optional runtime session; its lifecycle remains Workbench-owned. */ readonly runtime?: DevRuntimeSession; + /** Correlated application activity retained for authenticated replay and streaming. */ + readonly trace?: TraceHub; /** Read-only Skill document/resource service for the workbench. */ readonly skillDocuments?: SkillDocumentService; /** Injectable only to make integration contracts deterministic. */ @@ -408,6 +414,7 @@ export class ForegroundServer { readonly #evalRoutes: EvalRoutes; readonly #eventHub: ProjectEventHub; readonly #hookPlaygroundRoutes: HookPlaygroundRoutes; + readonly #hookReceiptRoutes: HookReceiptRoutes | undefined; readonly #hostDiscoveryRoutes: HostDiscoveryRoutes; readonly #hostMcpRoutes: HostMcpRoutes | undefined; readonly #host: string; @@ -429,6 +436,7 @@ export class ForegroundServer { readonly #sockets = new Set(); readonly #streamSubscriptions = new Set(); readonly #testing: ForegroundServerTesting | undefined; + readonly #traceRoutes: TraceRoutes; readonly #webHostEpochSubscription: ProjectEventSubscription | undefined; readonly #webHostRoutes: WebHostRoutes; readonly #workbenchDevOrigins: ReadonlySet; @@ -467,6 +475,7 @@ export class ForegroundServer { this.#eventHub = options.eventHub; this.#host = host; this.#hostMcpRoutes = options.hostMcp; + this.#hookReceiptRoutes = options.hookReceipts; this.instanceId = instanceId; this.#mcpAppPreviews = options.mcpAppPreviews; this.#now = options.now ?? (() => new Date()); @@ -570,6 +579,10 @@ export class ForegroundServer { authorize: (request) => this.#assertMutationSession(request), ...(options.logs === undefined ? {} : { service: options.logs }), }); + this.#traceRoutes = new TraceRoutes({ + authorize: (request) => this.#assertMutationSession(request), + ...(options.trace === undefined ? {} : { hub: options.trace }), + }); this.#server = createServer((request, response) => { void this.#handle(request, response).catch((error: unknown) => { responseDiagnostic( @@ -730,6 +743,8 @@ export class ForegroundServer { // aggregation below can report it with its fixed resource label. const releaseLogs = this.#devLogRoutes.close(); void releaseLogs.catch(() => undefined); + const releaseTrace = this.#traceRoutes.close(); + void releaseTrace.catch(() => undefined); // The Agent API owns admissions over every shared foreground service. It // must publish closure and drain active handlers before those services or // the epoch-owning coordinator begin their own shutdown. @@ -757,7 +772,7 @@ export class ForegroundServer { return closeServer(this.#server); })() : Promise.resolve(); - const [server, coordinator, evalRoutes, evalService, hookPlayground, logs, routeInvocations] = await Promise.allSettled([ + const [server, coordinator, evalRoutes, evalService, hookPlayground, logs, routeInvocations, trace] = await Promise.allSettled([ releaseServer, releaseCoordinator, releaseEvals, @@ -765,6 +780,7 @@ export class ForegroundServer { releaseHookPlayground, releaseLogs, releaseRouteInvocations, + releaseTrace, ]); const failures: ForegroundServerCloseFailure[] = []; if (agentApi.status === 'rejected') failures.push(Object.freeze({ error: agentApi.reason, resource: 'agent-api' })); @@ -782,6 +798,7 @@ export class ForegroundServer { if (routeInvocations.status === 'rejected') { failures.push(Object.freeze({ error: routeInvocations.reason, resource: 'route-invocations' })); } + if (trace.status === 'rejected') failures.push(Object.freeze({ error: trace.reason, resource: 'trace' })); return Object.freeze(failures); } @@ -792,6 +809,7 @@ export class ForegroundServer { const pathname = new URL(request.url ?? '/', this.url).pathname; const method = request.method ?? 'GET'; if (await this.#hostMcpRoutes?.handle(request, response)) return; + if (await this.#hookReceiptRoutes?.handle(request, response)) return; if (pathname === '/mcp') { if (this.#agentApi === undefined) return responseDiagnostic(response, diagnostic('AB8007', 'Route was not found.', 404)); this.#assertAgentApiOrigin(request); @@ -812,6 +830,7 @@ export class ForegroundServer { if (this.#routeManifestRoutes.handle(request, response)) return; if (await this.#evalRoutes.handle(request, response)) return; if (await this.#devLogRoutes.handle(request, response)) return; + if (await this.#traceRoutes.handle(request, response)) return; const route = skillRoute(request.url); if (route !== undefined) return this.#serveSkill(route, response, method); if (pathname === '/api/project/status') { diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts new file mode 100644 index 000000000..fe9498af7 --- /dev/null +++ b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts @@ -0,0 +1,170 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { dirname, resolve } from 'node:path'; + +import { isLoopbackHttpOrigin } from '../../core/loopback-origin.ts'; +import { + EVENT_TRACE_RECEIPT_MAX_BYTES, + EVENT_TRACE_RECEIPT_PATH, + EVENT_TRACE_RECEIPT_TOKEN_ENV, + EVENT_TRACE_RECEIPT_URL_ENV, + eventTraceReceiptEndpointPath, + type EventTraceReceiptEndpoint, +} from '../../events/trace-receipt.ts'; +import { diagnostic, rawPathname, readJsonBody, requestError, responseDiagnostic, singleHeader } from '../http.ts'; +import type { TraceEntryInput } from '../trace/trace-entry.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; +import { + decodeHookReceipt, + HOOK_RECEIPT_MALFORMED_CODE, + HOOK_RECEIPT_TOO_LARGE_CODE, + HOOK_RECEIPT_UNAUTHORIZED_CODE, + HookReceiptDecodeError, + lowerHookReceipt, +} from './hook-receipts.ts'; + +/** + * Host hook receipts use a per-server bearer token published in the + * owner-only endpoint record. Browser origins and non-loopback peers are + * refused independently of the Workbench session guard. + */ + +const loopbackAddresses: ReadonlySet = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); + +const unauthorized = (message: string): never => { + throw requestError(diagnostic(HOOK_RECEIPT_UNAUTHORIZED_CODE, message, 403)); +}; + +const bearerToken = (request: IncomingMessage): string | undefined => { + const header = singleHeader(request.headers.authorization); + if (header === undefined) return undefined; + const match = /^Bearer\s+(\S+)$/u.exec(header); + return match?.[1]; +}; + +const sameToken = (expected: string, actual: string): boolean => { + const left = Buffer.from(expected, 'utf8'); + const right = Buffer.from(actual, 'utf8'); + return left.length === right.length && timingSafeEqual(left, right); +}; + +export interface HookReceiptRoutesOptions { + readonly token: string; + readonly trace: TracePublisher; +} + +export class HookReceiptRoutes { + readonly #token: string; + readonly #trace: TracePublisher; + #closed = false; + + constructor(options: HookReceiptRoutesOptions) { + this.#token = options.token; + this.#trace = options.trace; + } + + close(): void { + this.#closed = true; + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + if (rawPathname(request.url) !== EVENT_TRACE_RECEIPT_PATH) return false; + this.#authorize(request); + if ((request.method ?? 'GET') !== 'POST') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } + if (this.#closed) throw requestError(diagnostic(HOOK_RECEIPT_UNAUTHORIZED_CODE, 'Hook receipts are not accepted.', 409)); + if (new URL(request.url ?? '/', 'http://localhost').searchParams.size > 0) { + throw requestError(diagnostic(HOOK_RECEIPT_MALFORMED_CODE, 'Hook receipt request has an invalid shape.', 400)); + } + const body = await readJsonBody(request, { + invalidShape: () => { + throw requestError(diagnostic(HOOK_RECEIPT_MALFORMED_CODE, 'Hook receipt request has an invalid shape.', 400)); + }, + read: { + code: HOOK_RECEIPT_TOO_LARGE_CODE, + limit: EVENT_TRACE_RECEIPT_MAX_BYTES, + message: 'Hook receipt exceeds 16 KiB.', + }, + }); + let entries: readonly TraceEntryInput[]; + try { + entries = lowerHookReceipt(decodeHookReceipt(body)); + } catch (error) { + if (error instanceof HookReceiptDecodeError) { + throw requestError(diagnostic(HOOK_RECEIPT_MALFORMED_CODE, error.message, 400)); + } + throw error; + } + for (const entry of entries) this.#trace.publish(entry); + response.writeHead(204, { 'cache-control': 'no-store' }); + response.end(); + return true; + } + + #authorize(request: IncomingMessage): void { + const peer = request.socket.remoteAddress; + if (peer === undefined || !loopbackAddresses.has(peer)) unauthorized('Hook receipts are accepted from loopback only.'); + if (singleHeader(request.headers.origin) !== undefined) unauthorized('Hook receipts are not accepted from a browser.'); + const token = bearerToken(request); + if (token === undefined || !sameToken(this.#token, token)) unauthorized('A valid hook receipt token is required.'); + } +} + +export interface AttachHookReceiptsOptions { + /** The project whose dev server this is; the endpoint record lands under its `.agent-bundle/`. */ + readonly projectRoot: string; + readonly trace: TracePublisher; +} + +export interface HookReceiptAttachment { + /** Closes the route (further posts are refused) and removes the endpoint record. */ + close(): Promise; + /** Environment a dev-server-spawned hook simulation inherits so its wrapper reports here. */ + environment(url: string): Readonly>; + /** + * Writes `/.agent-bundle/hook-receipts.json` so the wrappers of + * attached hosts find this server. Call once the foreground URL is known + * (beside `devLock.publishServerUrl`); rewrite on a new URL. + */ + publishEndpoint(url: string): Promise; + readonly routes: HookReceiptRoutes; + readonly token: string; +} + +export const attachHookReceipts = (options: AttachHookReceiptsOptions): HookReceiptAttachment => { + const token = randomBytes(32).toString('base64url'); + const routes = new HookReceiptRoutes({ token, trace: options.trace }); + const recordPath = eventTraceReceiptEndpointPath(resolve(options.projectRoot)); + const endpoint = (url: string): EventTraceReceiptEndpoint => { + if (!isLoopbackHttpOrigin(url)) { + throw new TypeError(`Hook receipt endpoint must be a loopback HTTP origin, got ${JSON.stringify(url)}.`); + } + return { token, url }; + }; + const attachment: HookReceiptAttachment = { + close: async () => { + routes.close(); + await rm(recordPath, { force: true }); + }, + environment: (url) => { + const target = endpoint(url); + return Object.freeze({ + [EVENT_TRACE_RECEIPT_TOKEN_ENV]: target.token, + [EVENT_TRACE_RECEIPT_URL_ENV]: target.url, + }); + }, + publishEndpoint: async (url) => { + const target = endpoint(url); + await mkdir(dirname(recordPath), { recursive: true }); + // `mode` applies on creation only: replace rather than overwrite a record with wider permissions. + await rm(recordPath, { force: true }); + await writeFile(recordPath, `${JSON.stringify({ pid: process.pid, token: target.token, url: target.url })}\n`, { mode: 0o600 }); + }, + routes, + token, + }; + return Object.freeze(attachment); +}; diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts new file mode 100644 index 000000000..2fa069a8e --- /dev/null +++ b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts @@ -0,0 +1,407 @@ +import type { RequestLineageProvenance, RequestProvenanceAxis } from '../../contracts/request-provenance.ts'; +import { hasOnlyOwnKeys, isRecord, type JsonObject, type JsonValue } from '../../core/strict-json.ts'; +import { + EVENT_TRACE_RECEIPT_VERSION, + type EventTraceReceipt, + type EventTraceReceiptEvent, + type EventTraceReceiptIdentity, +} from '../../events/trace-receipt.ts'; +import { + eventTraceEventKinds, + eventTracePhases, + type EventTraceErrorSummary, + type EventTracePhase, +} from '../../events/trace.ts'; +import { canonicalAgentEvents } from '../../routes/events.ts'; +import { applicationNodePath } from '../routes/application-node.ts'; +import type { TraceCorrelation, TraceEntryInput } from '../trace/trace-entry.ts'; + +export const HOOK_RECEIPT_UNAUTHORIZED_CODE = 'AB8247'; +export const HOOK_RECEIPT_MALFORMED_CODE = 'AB8248'; +export const HOOK_RECEIPT_TOO_LARGE_CODE = 'AB8249'; + +const hookReceiptMaxEvents = 32; +const MAX_ID_LENGTH = 256; +const MAX_ERROR_MESSAGE_LENGTH = 512; + +export class HookReceiptDecodeError extends TypeError { + constructor(readonly path: string) { + super(`Hook receipt field ${path} is not valid.`); + this.name = 'HookReceiptDecodeError'; + } +} + +const fail: (path: string) => never = (path) => { + throw new HookReceiptDecodeError(path); +}; + +const record = (value: unknown, path: string): Readonly> => + isRecord(value) ? value : fail(path); + +const onlyKeys = (value: Readonly>, keys: readonly string[], path: string): void => { + if (!hasOnlyOwnKeys(value, keys)) fail(path); +}; + +const boundedString = (value: unknown, path: string, maxLength = MAX_ID_LENGTH): string => + typeof value === 'string' && value.trim() !== '' && value.length <= maxLength && !value.includes('\0') + ? value + : fail(path); + +const optionalString = (value: unknown, path: string, maxLength = MAX_ID_LENGTH): string | undefined => + value === undefined ? undefined : boundedString(value, path, maxLength); + +const finiteNumber = (value: unknown, path: string): number => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fail(path); + +const optionalDuration = (value: unknown, path: string): number | undefined => + value === undefined ? undefined : finiteNumber(value, path); + +const nonNegativeInteger = (value: unknown, path: string): number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fail(path); + +const oneOf = (value: unknown, values: readonly Value[], path: string): Value => + typeof value === 'string' && (values as readonly string[]).includes(value) ? (value as Value) : fail(path); + +const isoInstant = (value: unknown, path: string): string => { + const text = boundedString(value, path, 64); + return Number.isNaN(Date.parse(text)) ? fail(path) : text; +}; + +const provenanceSources = ['native', 'receipt', 'derived'] as const; +const unavailableReasons = [ + 'not-provided', + 'unsupported-surface', + 'host-omitted', + 'unauthenticated', + 'no-subagent-events', + 'id-not-resolvable', + 'cloud-agent-no-user-hooks', + 'no-shared-runtime', +] as const; +const lineageResolutions = ['native', 'registry', 'confirmed', 'transcript', 'inferred'] as const; + +const decodeSubagent = (value: unknown, path: string): NonNullable => { + const input = record(value, path); + onlyKeys(input, ['id', 'isParallelWorker', 'toolCallId', 'type'], path); + const isParallelWorker = input.isParallelWorker === undefined || typeof input.isParallelWorker === 'boolean' + ? input.isParallelWorker + : fail(`${path}.isParallelWorker`); + const toolCallId = optionalString(input.toolCallId, `${path}.toolCallId`); + const type = optionalString(input.type, `${path}.type`); + return Object.freeze({ + id: boundedString(input.id, `${path}.id`), + ...(isParallelWorker === undefined ? {} : { isParallelWorker }), + ...(toolCallId === undefined ? {} : { toolCallId }), + ...(type === undefined ? {} : { type }), + }); +}; + +const decodeLineage = (value: unknown): RequestProvenanceAxis => { + const axis = record(value, 'lineage'); + if (axis.state === 'unavailable') { + onlyKeys(axis, ['reason', 'state'], 'lineage'); + return Object.freeze({ reason: oneOf(axis.reason, unavailableReasons, 'lineage.reason'), state: 'unavailable' }); + } + if (axis.state !== 'available') fail('lineage.state'); + onlyKeys(axis, ['source', 'state', 'value'], 'lineage'); + const input = record(axis.value, 'lineage.value'); + onlyKeys(input, ['conversation', 'depth', 'generation', 'parent', 'resolution', 'root', 'subagent'], 'lineage.value'); + const generation = optionalString(input.generation, 'lineage.value.generation'); + const parent = optionalString(input.parent, 'lineage.value.parent'); + return Object.freeze({ + source: oneOf(axis.source, provenanceSources, 'lineage.source'), + state: 'available', + value: Object.freeze({ + conversation: boundedString(input.conversation, 'lineage.value.conversation'), + depth: nonNegativeInteger(input.depth, 'lineage.value.depth'), + ...(generation === undefined ? {} : { generation }), + ...(parent === undefined ? {} : { parent }), + resolution: oneOf(input.resolution, lineageResolutions, 'lineage.value.resolution'), + root: boundedString(input.root, 'lineage.value.root'), + ...(input.subagent === undefined ? {} : { subagent: decodeSubagent(input.subagent, 'lineage.value.subagent') }), + }), + }); +}; + +const decodeIdentity = (value: unknown): EventTraceReceiptIdentity => { + const input = record(value, 'identity'); + onlyKeys(input, ['conversationId', 'requestId', 'sessionId'], 'identity'); + const conversationId = optionalString(input.conversationId, 'identity.conversationId'); + const requestId = optionalString(input.requestId, 'identity.requestId'); + const sessionId = optionalString(input.sessionId, 'identity.sessionId'); + return Object.freeze({ + ...(conversationId === undefined ? {} : { conversationId }), + ...(requestId === undefined ? {} : { requestId }), + ...(sessionId === undefined ? {} : { sessionId }), + }); +}; + +const decodeError = (value: unknown, path: string): EventTraceErrorSummary => { + const input = record(value, path); + onlyKeys(input, ['code', 'message', 'name'], path); + const code = optionalString(input.code, `${path}.code`); + return Object.freeze({ + ...(code === undefined ? {} : { code }), + message: boundedString(input.message, `${path}.message`, MAX_ERROR_MESSAGE_LENGTH), + name: boundedString(input.name, `${path}.name`, 128), + }); +}; + +const decodeEvent = (value: unknown, index: number): EventTraceReceiptEvent => { + const path = `events[${index}]`; + const input = record(value, path); + const kind = oneOf(input.kind, eventTraceEventKinds, `${path}.kind`); + const phase = oneOf(input.phase, eventTracePhases, `${path}.phase`); + const base = { + at: finiteNumber(input.at, `${path}.at`), + sequence: nonNegativeInteger(input.sequence, `${path}.sequence`), + }; + const durationMs = optionalDuration(input.durationMs, `${path}.durationMs`); + const withDuration = durationMs === undefined ? {} : { durationMs }; + const expectPhase = (expected: EventTracePhase): void => { + if (phase !== expected) fail(`${path}.phase`); + }; + switch (kind) { + case 'preflight.start': + expectPhase('preflight'); + onlyKeys(input, ['at', 'kind', 'phase', 'sequence'], path); + return Object.freeze({ ...base, kind, phase: 'preflight' }); + case 'preflight.outcome': + expectPhase('preflight'); + onlyKeys(input, ['at', 'durationMs', 'kind', 'outcome', 'phase', 'sequence'], path); + return Object.freeze({ + ...base, + ...withDuration, + kind, + outcome: oneOf(input.outcome, ['execute', 'continue', 'deny'] as const, `${path}.outcome`), + phase: 'preflight', + }); + case 'execute.start': + expectPhase('execute'); + onlyKeys(input, ['at', 'kind', 'phase', 'runtime', 'sequence'], path); + return Object.freeze({ + ...base, + kind, + phase: 'execute', + runtime: oneOf(input.runtime, ['shared', 'standalone'] as const, `${path}.runtime`), + }); + case 'providers.start': + expectPhase('providers'); + onlyKeys(input, ['at', 'kind', 'phase', 'sequence'], path); + return Object.freeze({ ...base, kind, phase: 'providers' }); + case 'providers.finish': + expectPhase('providers'); + onlyKeys(input, ['at', 'count', 'durationMs', 'kind', 'phase', 'sequence'], path); + return Object.freeze({ + ...base, + count: nonNegativeInteger(input.count, `${path}.count`), + ...withDuration, + kind, + phase: 'providers', + }); + case 'render.start': + expectPhase('render'); + onlyKeys(input, ['at', 'kind', 'phase', 'sequence'], path); + return Object.freeze({ ...base, kind, phase: 'render' }); + case 'render.finish': + expectPhase('render'); + onlyKeys(input, ['at', 'durationMs', 'kind', 'phase', 'sequence'], path); + return Object.freeze({ ...base, ...withDuration, kind, phase: 'render' }); + case 'failure': + onlyKeys(input, ['at', 'durationMs', 'error', 'kind', 'phase', 'sequence'], path); + return Object.freeze({ ...base, ...withDuration, error: decodeError(input.error, `${path}.error`), kind, phase }); + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +/** + * Strictly decodes a posted receipt. Every field is bounded, every object + * closed to unknown keys, every enum checked against the kernel's own lists; + * anything else throws {@link HookReceiptDecodeError} naming the field. + */ +export const decodeHookReceipt = (value: unknown): EventTraceReceipt => { + const input = record(value, 'receipt'); + onlyKeys(input, ['events', 'execution', 'identity', 'lineage', 'startedAt', 'version'], 'receipt'); + if (input.version !== EVENT_TRACE_RECEIPT_VERSION) fail('version'); + const execution = record(input.execution, 'execution'); + onlyKeys(execution, ['event', 'executionId', 'host', 'nativeEvent'], 'execution'); + const rawEvents: unknown = input.events; + if (!Array.isArray(rawEvents) || rawEvents.length > hookReceiptMaxEvents) fail('events'); + const events = rawEvents.map(decodeEvent); + for (let index = 1; index < events.length; index += 1) { + if (events[index]!.sequence <= events[index - 1]!.sequence) fail(`events[${index}].sequence`); + } + return Object.freeze({ + events: Object.freeze(events), + execution: Object.freeze({ + event: oneOf(execution.event, canonicalAgentEvents, 'execution.event'), + executionId: boundedString(execution.executionId, 'execution.executionId', 128), + host: boundedString(execution.host, 'execution.host', 64), + nativeEvent: boundedString(execution.nativeEvent, 'execution.nativeEvent', 128), + }), + identity: decodeIdentity(input.identity), + lineage: decodeLineage(input.lineage), + startedAt: isoInstant(input.startedAt, 'startedAt'), + version: EVENT_TRACE_RECEIPT_VERSION, + }); +}; + +type HookReceiptOutcome = + | Readonly<{ readonly kind: 'completed'; readonly gate?: 'continue' | 'deny' }> + | Readonly<{ readonly error: EventTraceErrorSummary; readonly kind: 'failed'; readonly phase: EventTracePhase }>; + +const hookReceiptOutcome = (receipt: EventTraceReceipt): HookReceiptOutcome => { + let gate: 'continue' | 'deny' | undefined; + for (const event of receipt.events) { + if (event.kind === 'failure') return Object.freeze({ error: event.error, kind: 'failed', phase: event.phase }); + if (event.kind === 'preflight.outcome' && event.outcome !== 'execute') gate = event.outcome; + } + return Object.freeze({ kind: 'completed', ...(gate === undefined ? {} : { gate }) }); +}; + +const hookRuntime = (receipt: EventTraceReceipt): 'shared' | 'standalone' | undefined => { + let runtime: 'shared' | 'standalone' | undefined; + for (const event of receipt.events) if (event.kind === 'execute.start') runtime = event.runtime; + return runtime; +}; + +const correlationOf = (receipt: EventTraceReceipt): TraceCorrelation => { + const conversationId = receipt.identity.conversationId + ?? (receipt.lineage.state === 'available' ? receipt.lineage.value.conversation : undefined); + return Object.freeze({ + ...(conversationId === undefined ? {} : { conversationId }), + executionId: receipt.execution.executionId, + host: receipt.execution.host, + ...(receipt.identity.requestId === undefined ? {} : { requestId: receipt.identity.requestId }), + routeId: `event:${receipt.execution.event}`, + ...(receipt.identity.sessionId === undefined ? {} : { sessionId: receipt.identity.sessionId }), + }); +}; + +const eventsDetail = (receipt: EventTraceReceipt): readonly JsonObject[] => { + const origin = receipt.events[0]?.at ?? 0; + return receipt.events.map((event) => ({ + atMs: Math.max(0, Math.round((event.at - origin) * 1000) / 1000), + kind: event.kind, + phase: event.phase, + ...('durationMs' in event && event.durationMs !== undefined ? { durationMs: Math.round(event.durationMs * 1000) / 1000 } : {}), + ...(event.kind === 'preflight.outcome' ? { outcome: event.outcome } : {}), + ...(event.kind === 'execute.start' ? { runtime: event.runtime } : {}), + ...(event.kind === 'providers.finish' ? { count: event.count } : {}), + })); +}; + +const lineageDetail = (lineage: EventTraceReceipt['lineage']): JsonValue => { + if (lineage.state === 'unavailable') return { reason: lineage.reason, state: 'unavailable' }; + const { subagent, ...rest } = lineage.value; + return { + source: lineage.source, + state: 'available', + value: { ...rest, ...(subagent === undefined ? {} : { subagent: { ...subagent } }) }, + }; +}; + +const instantAfter = (startedAt: string, receipt: EventTraceReceipt, at: number | undefined): string => { + const origin = receipt.events[0]?.at; + if (at === undefined || origin === undefined) return startedAt; + const started = Date.parse(startedAt); + return Number.isNaN(started) ? startedAt : new Date(started + Math.max(0, at - origin)).toISOString(); +}; + +const describe = (receipt: EventTraceReceipt): string => + `${receipt.execution.host} ${receipt.execution.nativeEvent} → ${receipt.execution.event}`; + +/** + * Lowers one decoded receipt into the entries a `TracePublisher` receives, in + * publish order. Pure: the same receipt always yields the same entries. + */ +export const lowerHookReceipt = (receipt: EventTraceReceipt): readonly TraceEntryInput[] => { + const correlation = correlationOf(receipt); + const href = applicationNodePath({ event: receipt.execution.event, kind: 'event' }); + const outcome = hookReceiptOutcome(receipt); + const runtime = hookRuntime(receipt); + const first = receipt.events[0]; + const last = receipt.events.at(-1); + const durationMs = first === undefined || last === undefined + ? undefined + : Math.max(0, Math.round((last.at - first.at) * 1000) / 1000); + const completedAt = instantAfter(receipt.startedAt, receipt, last?.at); + const label = describe(receipt); + const entries: TraceEntryInput[] = [{ + correlation, + details: { execution: { ...receipt.execution }, identity: { ...receipt.identity } }, + href, + kind: 'hook.received', + occurredAt: receipt.startedAt, + source: 'hook', + status: 'ok', + summary: `${label} received`, + }]; + if (receipt.execution.event === 'session/start') { + entries.push({ + correlation, + href, + kind: 'session.started', + occurredAt: receipt.startedAt, + source: 'hook', + status: 'ok', + summary: `${receipt.execution.host} session started${correlation.sessionId === undefined ? '' : ` (${correlation.sessionId})`}`, + }); + } + const details: JsonObject = { + events: eventsDetail(receipt), + execution: { ...receipt.execution }, + identity: { ...receipt.identity }, + lineage: lineageDetail(receipt.lineage), + ...(runtime === undefined ? {} : { runtime }), + }; + switch (outcome.kind) { + case 'completed': + entries.push({ + correlation, + details: { ...details, ...(outcome.gate === undefined ? {} : { gate: outcome.gate }) }, + ...(durationMs === undefined ? {} : { durationMs }), + href, + kind: 'hook.completed', + occurredAt: completedAt, + source: 'hook', + status: 'ok', + summary: outcome.gate === undefined + ? `${label} completed` + : `${label} ${outcome.gate === 'deny' ? 'denied' : 'continued'} by preflight`, + }); + break; + case 'failed': + entries.push({ + correlation, + details: { ...details, error: { ...outcome.error }, failedPhase: outcome.phase }, + ...(durationMs === undefined ? {} : { durationMs }), + href, + kind: 'hook.failed', + occurredAt: completedAt, + source: 'hook', + status: 'error', + summary: `${label} failed in ${outcome.phase}: ${outcome.error.name}: ${outcome.error.message}`, + }); + break; + default: { + const exhaustive: never = outcome; + return exhaustive; + } + } + if (receipt.execution.event === 'session/end') { + entries.push({ + correlation, + href, + kind: 'session.ended', + occurredAt: completedAt, + source: 'hook', + status: outcome.kind === 'failed' ? 'error' : 'ok', + summary: `${receipt.execution.host} session ended${correlation.sessionId === undefined ? '' : ` (${correlation.sessionId})`}`, + }); + } + return Object.freeze(entries); +}; 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 35db01ec4..c3ea10e87 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts @@ -50,10 +50,17 @@ export type DevLogKindFor = DevLogKindMap[TPro /** The closed set of context keys a producer may attach; everything else is dropped at the boundary. */ export const safeContextKeys: ReadonlySet = new Set([ 'buildId', + 'conversationId', + 'correlationId', 'diagnosticCode', 'epochId', + 'executionId', 'hookId', + 'invocationId', + 'mcpRequestId', + 'mcpSessionId', 'projectId', + 'requestId', 'routeId', 'runId', 'sessionId', diff --git a/packages/agent-bundle/src/dev/logs/dev-log-producers.ts b/packages/agent-bundle/src/dev/logs/dev-log-producers.ts index 3c4166d9d..1a8ad99dc 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-producers.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-producers.ts @@ -20,6 +20,17 @@ const contextFor = (event: ProjectEvent): Readonly> => { if (event.type === 'artifact.available' || event.type === 'dev.contract.status' || event.type === 'dev.host.sync' || event.type === 'runtime.event') { return event.epochId === undefined ? Object.freeze({}) : Object.freeze({ epochId: event.epochId }); } + if (event.type === 'route.invocation') { + const invocation = event.payload.invocation; + const correlationId = stringAt(invocation, 'correlationId'); + const invocationId = stringAt(invocation, 'id'); + const routeId = stringAt(invocation, 'routeId'); + return Object.freeze({ + ...(correlationId === undefined ? {} : { correlationId }), + ...(invocationId === undefined ? {} : { invocationId }), + ...(routeId === undefined ? {} : { routeId }), + }); + } return 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 f7137d6db..a966089cf 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-service.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-service.ts @@ -3,6 +3,12 @@ import { resolve } from 'node:path'; import { isCredentialKey, redactEvalCredentialText } from '../../eval/credentials.ts'; import { isJsonRecord as isRecord, snapshotStrictJsonValue, type JsonValue } from '../../core/strict-json.ts'; +import { + applicationNodePath, + applicationNodeRefForRouteId, +} from '../routes/application-node.ts'; +import type { TraceCorrelation } from '../trace/trace-entry.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; import { devLogKinds, devLogLevels, @@ -84,6 +90,7 @@ export interface DevLogServiceOptions { readonly recordLimit?: number; readonly subscriberByteLimit?: number; readonly subscriberRecordLimit?: number; + readonly trace?: TracePublisher; } export type DevLogServiceErrorCode = 'DEV_LOG_CURSOR_AHEAD' | 'DEV_LOG_CURSOR_INVALID' | 'DEV_LOG_SERVICE_CLOSED'; @@ -117,6 +124,28 @@ const unavailable = '[UNAVAILABLE]' as const; const redacted = '[REDACTED]'; const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/u; const maxSummaryLength = 2_048; +const absolutePosixPath = /(?:^|[\s"'`([=:,])\/[^\s/]+(?:\/[^\s/]+)*/u; +const homeRelativePath = /(?:^|[\s"'`([=:,])~\/[^\s/]+/u; +const traceRequestContextKeys: ReadonlySet = new Set([ + 'conversationId', + 'correlationId', + 'executionId', + 'invocationId', + 'mcpRequestId', + 'mcpSessionId', + 'requestId', + 'sessionId', +]); +const loweredProjectEventMirrors: ReadonlySet = new Set([ + 'build:build.failed', + 'diagnostic:build.failed.diagnostic', + 'diagnostic:dev.contract.status.diagnostic', + 'diagnostic:dev.host.sync.diagnostic', + 'diagnostic:route.invocation.diagnostic', + 'project:dev.contract.status', + 'project:dev.host.sync', + 'project:route.invocation', +]); // Records and gap messages are deep-frozen, so their encoded size never goes // stale; caching it spares one full JSON.stringify per retain/evict/deliver. @@ -190,7 +219,11 @@ const projectPath = (value: string, roots: readonly string[]): string => { const redactAbsolutePaths = (value: string, roots: readonly string[]): string => { const sanitized = projectPath(value, roots); const withoutProjectPaths = sanitized.replace(/(?:\/[A-Za-z0-9._@+-]+)*/gu, ''); - return hasControlOrSeparators(withoutProjectPaths) || /(?:file:|[A-Za-z]:|\\\\)/iu.test(withoutProjectPaths) + const withoutPathSeparators = withoutProjectPaths.replaceAll('/', '').replaceAll('\\', ''); + return hasControlOrSeparators(withoutPathSeparators) + || absolutePosixPath.test(withoutProjectPaths) + || homeRelativePath.test(withoutProjectPaths) + || /(?:file:|[A-Za-z]:[\\/]|\\\\)/iu.test(withoutProjectPaths) ? redacted : sanitized; }; @@ -219,6 +252,11 @@ const detailsFor = (value: unknown, roots: readonly string[]): DevLogDetails => } }; +const safeContextIdentifier = (key: string, value: string): boolean => + key === 'routeId' + ? applicationNodeRefForRouteId(value) !== undefined && !hasControlOrSeparators(value.replaceAll('/', '')) + : safeIdentifier.test(value) && !hasControlOrSeparators(value); + const contextFor = (value: unknown): Readonly> => { if (value === undefined) return Object.freeze({}); try { @@ -227,8 +265,9 @@ const contextFor = (value: unknown): Readonly> => { const context: Record = {}; for (const [key, entry] of Object.entries(snapshot)) { if ( - safeContextKeys.has(key) && typeof entry === 'string' && safeIdentifier.test(entry) - && redactEvalCredentialText(entry) === entry && !hasControlOrSeparators(entry) + safeContextKeys.has(key) && typeof entry === 'string' + && safeContextIdentifier(key, entry) + && redactEvalCredentialText(entry) === entry ) context[key] = entry; } return Object.freeze(context); @@ -237,6 +276,35 @@ const contextFor = (value: unknown): Readonly> => { } }; +const traceCorrelationFor = (context: Readonly>): TraceCorrelation => Object.freeze({ + ...(context.correlationId === undefined ? {} : { correlationId: context.correlationId }), + ...(context.conversationId === undefined ? {} : { conversationId: context.conversationId }), + ...(context.epochId === undefined ? {} : { epochId: context.epochId }), + ...(context.executionId === undefined ? {} : { executionId: context.executionId }), + ...(context.invocationId === undefined ? {} : { invocationId: context.invocationId }), + ...(context.mcpRequestId === undefined ? {} : { mcpRequestId: context.mcpRequestId }), + ...(context.mcpSessionId === undefined ? {} : { mcpSessionId: context.mcpSessionId }), + ...(context.requestId === undefined ? {} : { requestId: context.requestId }), + ...(context.routeId === undefined ? {} : { routeId: context.routeId }), + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), +}); + +const hasTraceRequestContext = (context: Readonly>): boolean => + Object.keys(context).some((key) => traceRequestContextKeys.has(key)); + +const isLoweredProjectEventMirror = (record: DevLogRecord): boolean => + loweredProjectEventMirrors.has(`${record.producer}:${record.kind}`); + +const traceHrefFor = (record: DevLogRecord): string => { + const routeId = record.context.routeId; + const node = routeId === undefined ? undefined : applicationNodeRefForRouteId(routeId); + if (node === undefined) return `/advanced/logs?sequence=${String(record.sequence)}`; + const invocationId = record.context.invocationId; + return invocationId === undefined + ? applicationNodePath(node) + : `${applicationNodePath(node)}?invocation=${encodeURIComponent(invocationId)}`; +}; + const summaryFor = (value: unknown, roots: readonly string[]): string => typeof value === 'string' && value.length > 0 ? truncate(redactAbsolutePaths(value, roots), maxSummaryLength) : unavailable; @@ -254,6 +322,7 @@ export class DevLogService { readonly #subscriberByteLimit: number; readonly #subscriberRecordLimit: number; readonly #subscriptions = new Set(); + readonly #trace: TracePublisher | undefined; readonly #undelivered: DevLogRecord[] = []; #closePromise: Promise | undefined; #closed = false; @@ -276,6 +345,7 @@ export class DevLogService { this.#roots = rootFormsFor(options.projectRoot); this.#subscriberByteLimit = positiveInteger(options.subscriberByteLimit ?? defaultSubscriberByteLimit, 'subscriberByteLimit'); this.#subscriberRecordLimit = positiveInteger(options.subscriberRecordLimit ?? defaultSubscriberRecordLimit, 'subscriberRecordLimit'); + this.#trace = options.trace; } get latestSequence(): number { @@ -310,6 +380,7 @@ export class DevLogService { } if (byteLength(record) > this.#recordByteLimit) return undefined; this.#retain(record); + this.#publishTrace(record); return record; } catch { return undefined; @@ -393,6 +464,26 @@ export class DevLogService { return this.#recordFor(Object.freeze({ ...input, summary: unavailable }) as DevLogInput, unavailable, Object.freeze({})); } + #publishTrace(record: DevLogRecord): void { + const trace = this.#trace; + if (trace === undefined || isLoweredProjectEventMirror(record)) return; + const correlation = traceCorrelationFor(record.context); + if (record.level !== 'warning' && record.level !== 'error' && !hasTraceRequestContext(record.context)) return; + try { + trace.publish({ + correlation, + href: traceHrefFor(record), + kind: `log.${record.producer}.${record.kind}`, + occurredAt: record.occurredAt, + source: 'log', + ...(record.level === 'error' ? { status: 'error' } : {}), + summary: record.summary, + }); + } catch { + // Logging must not depend on the trace observer. + } + } + #retain(record: DevLogRecord): void { this.#sequence = record.sequence; this.#history.push(record); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts index 791d57295..8e4b9c75f 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts @@ -28,11 +28,31 @@ interface McpSessionTraceEntryBase { readonly kind: McpSessionTraceKind; } +/** + * Correlation keys lifted from a request's `params._meta`, lowered to the + * vocabulary `docs/entry-conventions.md` gives each host: Claude's + * `claudecode/toolUseId` is the `requestId`; Codex's `x-codex-turn-metadata` + * names the `conversationId` (`thread_id`) and `sessionId` (`session_id`); + * `agent-bundle/correlationId` is the Workbench-minted `correlationId`. + */ +export interface McpSessionTraceMeta { + readonly correlationId?: string; + readonly conversationId?: string; + readonly requestId?: string; + readonly sessionId?: string; +} + export interface McpSessionFrameTraceEntry extends McpSessionTraceEntryBase { readonly direction: 'client' | 'server'; + /** The JSON-RPC `id` as a string; absent on notifications. */ + readonly id?: string; readonly kind: 'frame'; /** The exact object observed by the MCP transport; it is never translated. */ readonly message: unknown; + /** Lifted from a request's `params._meta`; absent when it carries no known key. */ + readonly meta?: McpSessionTraceMeta; + /** The JSON-RPC `method`; absent on responses. */ + readonly method?: string; } export interface McpSessionStderrTraceEntry extends McpSessionTraceEntryBase { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts index 173072a18..935bbe669 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts @@ -14,7 +14,7 @@ import { responseJson, type RequestDiagnostic, } from '../http.ts'; -import { McpSessionStaleEpochError } from './mcp-session-service.ts'; +import { McpSessionStaleEpochError, mcpCorrelationMetaKey } from './mcp-session-service.ts'; import type { McpSessionBinding, McpSessionConnectionState, @@ -23,6 +23,7 @@ import type { McpSessionTraceReplay, McpSessionTraceSubscription, } from './mcp-session-service.ts'; +import type { McpRequestMeta } from './mcp-session-types.ts'; import { createBackpressuredWriter, encodedNdjsonFrame, writeKeepAliveStreamHead } from '../route-streams.ts'; interface CreateRoute { @@ -38,17 +39,22 @@ type Route = CreateRoute | SessionRoute; type JsonObject = Record; +export interface McpSessionRouteToolCall { + /** Only the route stamps this: the Workbench `correlationId` under `agent-bundle/correlationId`. */ + readonly _meta?: McpRequestMeta; + readonly arguments: Readonly>; + readonly name: string; + readonly requestId?: string; +} + export interface McpSessionRouteSession { readonly binding: McpSessionBinding; readonly connection: McpSessionConnectionState; readonly id: string; readonly timeoutMs: number; - callTool(options: { readonly arguments: Readonly>; readonly name: string; readonly requestId?: string }): Promise; + callTool(options: McpSessionRouteToolCall): Promise; /** A task-augmented `tools/call` (#369): answered by a `CreateTaskResult` handle. */ - callToolTask(options: { - readonly arguments: Readonly>; - readonly name: string; - readonly requestId?: string; + callToolTask(options: McpSessionRouteToolCall & { readonly task: Readonly<{ readonly pollInterval?: number; readonly ttl?: number }>; }): Promise; cancel(requestId: string): boolean; @@ -112,6 +118,9 @@ const route = (requestTarget: string | undefined): Route | undefined => { return Object.freeze({ id, kind }); }; +/** The same bound `RouteInvocationRequest.correlationId` carries. */ +const maxCorrelationIdLength = 256; + const stringRecord = (value: unknown): value is Record => isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'); @@ -142,6 +151,8 @@ type Operation = | Readonly<{ readonly operation: 'resources/read'; readonly uri: string }> | Readonly<{ readonly arguments: Readonly>; + /** The route workspace's run id (`RouteInvocationRequest.correlationId`), stamped into `params._meta` for the trace. */ + readonly correlationId?: string; readonly name: string; readonly operation: 'tools/call'; readonly requestId?: string; @@ -191,14 +202,19 @@ const operationRequest = (value: JsonObject): Operation => { return Object.freeze({ operation, uri }); } if (operation === 'tools/call') { - if (!hasOnly(value, ['arguments', 'name', 'operation', 'requestId', 'task'])) return invalidShape(); + if (!hasOnly(value, ['arguments', 'correlationId', 'name', 'operation', 'requestId', 'task'])) return invalidShape(); const argumentsValue = value.arguments; + const correlationId = value.correlationId; const name = value.name; const requestId = value.requestId; if (!nonemptyString(name) || !isRecord(argumentsValue)) return invalidShape(); if (requestId !== undefined && !nonemptyString(requestId)) return invalidShape(); + if (correlationId !== undefined && (!nonemptyString(correlationId) || correlationId.length > maxCorrelationIdLength)) { + return invalidShape(); + } return Object.freeze({ arguments: argumentsValue, + ...(correlationId === undefined ? {} : { correlationId }), name, operation, ...(requestId === undefined ? {} : { requestId }), @@ -398,7 +414,8 @@ export class McpSessionRoutes { } if (operation.operation === 'resources/read') return session.readResource({ uri: operation.uri }); if (operation.operation === 'tools/call') { - const call = { + const call: McpSessionRouteToolCall = { + ...(operation.correlationId === undefined ? {} : { _meta: { [mcpCorrelationMetaKey]: operation.correlationId } }), arguments: operation.arguments, name: operation.name, ...(operation.requestId === undefined ? {} : { requestId: operation.requestId }), diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index dd87d70ad..e2a65532e 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -41,7 +41,9 @@ import type { McpSessionId, } from './mcp-session-protocol.ts'; import { McpSession, requestOptions } from './mcp-session.ts'; -import type { McpSessionTraceSink } from './mcp-session-trace.ts'; +import { composeMcpSessionTraceSinks, type McpSessionTraceSink } from './mcp-session-trace.ts'; +import { createMcpSessionTraceSink } from './mcp-session-trace-publisher.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; import { canonicalMcpAppJson, canonicalMcpAppResource, @@ -80,6 +82,8 @@ export type { OpenMcpSessionOptions, } from './mcp-session-types.ts'; export type { McpSessionTraceSink } from './mcp-session-trace.ts'; +export { mcpCorrelationMetaKey } from '../../contracts/mcp-session.ts'; +export { createMcpSessionTraceSink, liftMcpFrame } from './mcp-session-trace-publisher.ts'; export type { McpSessionBinding, @@ -90,6 +94,7 @@ export type { McpSessionTraceEntry, McpSessionTraceListener, McpSessionTraceMessage, + McpSessionTraceMeta, McpSessionTraceReplay, McpSessionTraceReplayGap, McpSessionTraceSubscription, @@ -259,6 +264,7 @@ export class McpSessionService { readonly #projectRoot: string; readonly #registry: TargetRegistry; readonly #run: PlatformRun; + readonly #trace: TracePublisher | undefined; readonly #traceSink: McpSessionTraceSink | undefined; readonly #openingSessions = new Set(); readonly #sessions = new Map(); @@ -278,6 +284,7 @@ export class McpSessionService { this.#projectRoot = resolve(options.projectRoot); this.#registry = options.registry ?? createDefaultRegistry(); this.#run = platformRunOf(options.platformRuntime); + this.#trace = options.trace; this.#traceSink = options.traceSink; } @@ -385,12 +392,19 @@ export class McpSessionService { () => releaseUnlessTransferred(releasePluginData), ); const sessionId = randomUUID(); + const binding: McpSessionBinding = { epochId: options.epochId, serverName: options.serverName, target }; + const traceSink = composeMcpSessionTraceSinks( + this.#traceSink, + this.#trace === undefined + ? undefined + : createMcpSessionTraceSink({ binding, projectRoot: this.#projectRoot, sessionId, trace: this.#trace }), + ); const session = yield* liftTry(() => new McpSession({ assertEpochAvailable: async () => { const probe = await this.#epochStore.acquireEpochReference(options.epochId); await probe.close(); }, - binding: { epochId: options.epochId, serverName: options.serverName, target }, + binding, createClient: this.#createClient, createStdioTransport: this.#createStdioTransport, createStreamableHttpTransport: this.#createStreamableHttpTransport, @@ -402,7 +416,7 @@ export class McpSessionService { releasePluginData, resolved: { runtime, server, target, targetRoot }, timeoutMs: options.timeoutMs, - ...(this.#traceSink === undefined ? {} : { traceSink: this.#traceSink }), + ...(traceSink === undefined ? {} : { traceSink }), workspaceRoot: resolve(options.workspaceRoot ?? this.#projectRoot), })); constructed = session; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts new file mode 100644 index 000000000..028b77af2 --- /dev/null +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts @@ -0,0 +1,359 @@ +import { isRecord, type JsonValue } from '../../core/strict-json.ts'; +import { mcpCorrelationMetaKey } from '../../contracts/mcp-session.ts'; +import { nonemptyString } from '../http.ts'; +import { hasControlOrSeparators } from '../logs/dev-log-kinds.ts'; +import { safeDevWireText } from '../logs/dev-log-service.ts'; +import { applicationNodePath, applicationNodeRefForRouteId } from '../routes/application-node.ts'; +import type { TraceCorrelation, TraceEntryInput, TraceStatus } from '../trace/trace-entry.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; +import type { + McpSessionBinding, + McpSessionFrameTraceEntry, + McpSessionId, + McpSessionNotificationTraceEntry, + McpSessionOperationTraceEntry, + McpSessionStderrTraceEntry, + McpSessionTraceEntry, + McpSessionTraceMeta, +} from './mcp-session-protocol.ts'; +import type { McpSessionTraceSink } from './mcp-session-trace.ts'; + +/** The `_meta` keys lifted onto a frame and their trace vocabulary, per `docs/entry-conventions.md`. */ +const claudeToolUseIdKey = 'claudecode/toolUseId'; +const codexTurnMetadataKey = 'x-codex-turn-metadata'; + +const maxKeyLength = 256; +const maxStderrSummaryLength = 200; +const maxPendingRequests = 1_024; + +/** Notification methods the session already records as their own trace entry; their frame is not lowered twice. */ +const dedicatedNotificationMethods: ReadonlySet = new Set(['notifications/message', 'notifications/progress']); + +export interface LiftedMcpFrame { + readonly id?: string; + readonly meta?: McpSessionTraceMeta; + readonly method?: string; +} + +export interface McpSessionTracePublisherOptions { + readonly binding: McpSessionBinding; + /** Redaction root for stderr and error text (`safeDevWireText`). */ + readonly projectRoot: string; + readonly sessionId: McpSessionId; + readonly trace: TracePublisher; +} + +interface PendingRequest { + readonly at: number; + readonly correlation: TraceCorrelation; + readonly label: string; + readonly method: string; + readonly progressToken?: string; +} + +/** A bounded, NUL-free label such as a method or tool name. */ +const wireText = (value: unknown): string | undefined => + nonemptyString(value) && value.length <= maxKeyLength ? value : undefined; + +/** A correlation key: a label that is also free of control characters and path separators. */ +const wireKey = (value: unknown): string | undefined => { + const text = wireText(value); + return text !== undefined && !hasControlOrSeparators(text) ? text : undefined; +}; + +const jsonRpcId = (value: unknown): string | undefined => { + if (typeof value === 'string') return wireKey(value); + if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value); + return undefined; +}; + +const liftMeta = (meta: unknown): McpSessionTraceMeta | undefined => { + if (!isRecord(meta)) return undefined; + const turn = meta[codexTurnMetadataKey]; + const correlationId = wireKey(meta[mcpCorrelationMetaKey]); + const conversationId = isRecord(turn) ? wireKey(turn.thread_id) : undefined; + const requestId = wireKey(meta[claudeToolUseIdKey]); + const sessionId = isRecord(turn) ? wireKey(turn.session_id) : undefined; + if (correlationId === undefined && conversationId === undefined && requestId === undefined && sessionId === undefined) { + return undefined; + } + return Object.freeze({ + ...(correlationId === undefined ? {} : { correlationId }), + ...(conversationId === undefined ? {} : { conversationId }), + ...(requestId === undefined ? {} : { requestId }), + ...(sessionId === undefined ? {} : { sessionId }), + }); +}; + +/** Lifts the JSON-RPC `id`, `method`, and the known `params._meta` keys off one frame; never fails on a foreign shape. */ +export const liftMcpFrame = (message: unknown): LiftedMcpFrame => { + if (!isRecord(message)) return Object.freeze({}); + const id = jsonRpcId(message.id); + const method = wireText(message.method); + const meta = isRecord(message.params) ? liftMeta(message.params._meta) : undefined; + return Object.freeze({ + ...(id === undefined ? {} : { id }), + ...(meta === undefined ? {} : { meta }), + ...(method === undefined ? {} : { method }), + }); +}; + +/** `tool:/` and `prompt:/` from the request; a resource read names a URI, not a route. */ +const routeIdFor = (method: string, params: unknown, serverName: string): string | undefined => { + if (!isRecord(params)) return undefined; + const name = wireKey(params.name); + if (name === undefined) return undefined; + if (method === 'tools/call') return `tool:${serverName}/${name}`; + if (method === 'prompts/get') return `prompt:${serverName}/${name}`; + return undefined; +}; + +const hrefFor = (routeId: string | undefined, sessionId: McpSessionId): string => { + const node = routeId === undefined ? undefined : applicationNodeRefForRouteId(routeId); + const path = node === undefined ? '/advanced/protocol' : applicationNodePath(node); + return `${path}?session=${encodeURIComponent(sessionId)}`; +}; + +const byteLength = (value: unknown): number => { + const encoded = JSON.stringify(value); + return encoded === undefined ? 0 : Buffer.byteLength(encoded); +}; + +const firstLine = (text: string): string => { + const line = text.trimStart().split(/\r?\n/u, 1)[0] ?? ''; + return line.length <= maxStderrSummaryLength ? line : `${line.slice(0, maxStderrSummaryLength - 1)}…`; +}; + +const isoTime = (occurredAt: number): string => new Date(occurredAt).toISOString(); + +/** A frame with a lifted `id` or `method` is a record; a foreign shape reads as empty. */ +const messageOf = (entry: McpSessionFrameTraceEntry): Readonly> => + isRecord(entry.message) ? entry.message : {}; + +/** + * Lowers one session's `McpSessionTraceEntry` stream onto the unified trace. + * Each frame becomes one `TraceEntry`; a response inherits its request's + * correlation by JSON-RPC `id` and measures `durationMs` from it. The full + * frame stays on the session's own trace behind `href`. + */ +export const createMcpSessionTraceSink = (options: McpSessionTracePublisherOptions): McpSessionTraceSink => { + const { binding, projectRoot, sessionId, trace } = options; + const base: TraceCorrelation = Object.freeze({ epochId: binding.epochId, host: binding.target, mcpSessionId: sessionId }); + const pending = new Map(); + const progressTokens = new Map(); + const protocolHref = hrefFor(undefined, sessionId); + let started = false; + let closed = false; + + const publish = (input: Omit): void => { + trace.publish({ ...input, source: 'mcp' }); + }; + + const remember = (id: string, request: PendingRequest): void => { + if (pending.size >= maxPendingRequests) { + const oldest = pending.keys().next(); + if (!oldest.done) { + const evicted = pending.get(oldest.value); + pending.delete(oldest.value); + if (evicted?.progressToken !== undefined) progressTokens.delete(evicted.progressToken); + } + } + pending.set(id, request); + if (request.progressToken !== undefined) progressTokens.set(request.progressToken, id); + }; + + const forget = (id: string): PendingRequest | undefined => { + const request = pending.get(id); + if (request === undefined) return undefined; + pending.delete(id); + if (request.progressToken !== undefined) progressTokens.delete(request.progressToken); + return request; + }; + + const request = (entry: McpSessionFrameTraceEntry, id: string, method: string): void => { + const message = messageOf(entry); + const params = message.params; + const name = isRecord(params) ? wireText(params.name) : undefined; + const routeId = routeIdFor(method, params, binding.serverName); + const meta = entry.meta; + const correlation: TraceCorrelation = Object.freeze({ + ...base, + ...(meta?.correlationId === undefined ? {} : { correlationId: meta.correlationId }), + ...(meta?.conversationId === undefined ? {} : { conversationId: meta.conversationId }), + mcpRequestId: id, + ...(meta?.requestId === undefined ? {} : { requestId: meta.requestId }), + ...(routeId === undefined ? {} : { routeId }), + ...(meta?.sessionId === undefined ? {} : { sessionId: meta.sessionId }), + }); + const label = name === undefined ? method : `${method} ${name}`; + const progressToken = isRecord(params) && isRecord(params._meta) ? jsonRpcId(params._meta.progressToken) : undefined; + remember(id, { + at: entry.occurredAt, + correlation, + label, + method, + ...(progressToken === undefined ? {} : { progressToken }), + }); + publish({ + correlation, + details: { method, ...(name === undefined ? {} : { name }), paramsBytes: byteLength(params) }, + href: hrefFor(routeId, sessionId), + kind: 'mcp.request', + occurredAt: isoTime(entry.occurredAt), + status: 'running', + summary: label, + }); + }; + + const response = (entry: McpSessionFrameTraceEntry, id: string): void => { + const message = messageOf(entry); + const matched = forget(id); + const correlation = matched?.correlation ?? Object.freeze({ ...base, mcpRequestId: id }); + const label = matched?.label ?? 'response'; + const error = isRecord(message.error) ? message.error : undefined; + const result = message.result; + const toolError = isRecord(result) && result.isError === true; + const status: TraceStatus = error !== undefined || toolError ? 'error' : 'ok'; + const details: JsonValue = error === undefined + ? { ...(toolError ? { isError: true } : {}), resultBytes: byteLength(result) } + : { + error: { + ...(typeof error.code === 'number' && Number.isFinite(error.code) ? { code: error.code } : {}), + message: typeof error.message === 'string' ? safeDevWireText(error.message, projectRoot) : '', + }, + }; + publish({ + correlation, + details, + ...(matched === undefined ? {} : { durationMs: Math.max(0, entry.occurredAt - matched.at) }), + href: hrefFor(correlation.routeId, sessionId), + kind: 'mcp.response', + occurredAt: isoTime(entry.occurredAt), + status, + summary: error === undefined + ? `${label} ${toolError ? 'tool error' : 'ok'}` + : `${label} error${typeof error.code === 'number' ? ` ${error.code}` : ''}`, + }); + }; + + const notification = (entry: McpSessionFrameTraceEntry, method: string): void => { + const message = messageOf(entry); + const params = message.params; + const cancelled = method === 'notifications/cancelled' && isRecord(params) ? jsonRpcId(params.requestId) : undefined; + const matched = cancelled === undefined ? undefined : pending.get(cancelled); + const correlation = matched?.correlation ?? Object.freeze({ ...base, ...(cancelled === undefined ? {} : { mcpRequestId: cancelled }) }); + publish({ + correlation, + details: { direction: entry.direction, method }, + href: hrefFor(correlation.routeId, sessionId), + kind: 'mcp.notification', + occurredAt: isoTime(entry.occurredAt), + summary: method, + }); + }; + + const frame = (entry: McpSessionFrameTraceEntry): void => { + const { id, method } = entry; + if (method !== undefined && dedicatedNotificationMethods.has(method)) return; + if (id !== undefined && method !== undefined) return request(entry, id, method); + if (id !== undefined) return response(entry, id); + if (method !== undefined) return notification(entry, method); + }; + + const progress = (entry: McpSessionNotificationTraceEntry): void => { + const payload = isRecord(entry.payload) ? entry.payload : undefined; + const token = payload === undefined ? undefined : jsonRpcId(payload.progressToken); + const matched = token === undefined ? undefined : pending.get(progressTokens.get(token) ?? token); + const correlation = matched?.correlation ?? base; + const current = typeof payload?.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : undefined; + const total = typeof payload?.total === 'number' && Number.isFinite(payload.total) ? payload.total : undefined; + publish({ + correlation, + details: { + ...(current === undefined ? {} : { progress: current }), + ...(token === undefined ? {} : { progressToken: token }), + ...(total === undefined ? {} : { total }), + }, + href: hrefFor(correlation.routeId, sessionId), + kind: 'mcp.progress', + occurredAt: isoTime(entry.occurredAt), + status: 'running', + summary: current === undefined ? 'progress' : `progress ${current}${total === undefined ? '' : `/${total}`}`, + }); + }; + + const logging = (entry: McpSessionNotificationTraceEntry): void => { + const payload = isRecord(entry.payload) ? entry.payload : undefined; + const level = wireText(payload?.level); + const logger = wireText(payload?.logger); + publish({ + correlation: base, + details: { ...(level === undefined ? {} : { level }), ...(logger === undefined ? {} : { logger }) }, + href: protocolHref, + kind: 'mcp.logging', + occurredAt: isoTime(entry.occurredAt), + summary: `log${level === undefined ? '' : ` ${level}`}${logger === undefined ? '' : ` ${logger}`}`, + }); + }; + + const stderr = (entry: McpSessionStderrTraceEntry): void => { + publish({ + correlation: base, + details: { bytes: Buffer.byteLength(entry.text) }, + href: protocolHref, + kind: 'mcp.stderr', + occurredAt: isoTime(entry.occurredAt), + summary: `stderr: ${safeDevWireText(firstLine(entry.text), projectRoot)}`, + }); + }; + + const operation = (entry: McpSessionOperationTraceEntry): void => { + const label = `${binding.serverName} (${binding.target})`; + if ((entry.operation === 'initialize' && !started) || entry.operation === 'restart') { + if (entry.phase !== 'succeeded') return; + const restarted = started && entry.operation === 'restart'; + started = true; + publish({ + correlation: base, + details: { operation: entry.operation }, + href: protocolHref, + kind: 'mcp.session.started', + occurredAt: isoTime(entry.occurredAt), + status: 'ok', + summary: `MCP session ${label} ${restarted ? 'restarted' : 'started'}`, + }); + return; + } + if (entry.operation === 'close' && entry.phase !== 'started' && !closed) { + closed = true; + publish({ + correlation: base, + details: { operation: entry.operation }, + href: protocolHref, + kind: 'mcp.session.closed', + occurredAt: isoTime(entry.occurredAt), + status: entry.phase === 'failed' ? 'error' : 'ok', + summary: `MCP session ${label} closed${entry.phase === 'failed' ? ' with cleanup failure' : ''}`, + }); + } + }; + + return (_binding: McpSessionBinding, entry: McpSessionTraceEntry): void => { + switch (entry.kind) { + case 'frame': + return frame(entry); + case 'progress': + return progress(entry); + case 'logging': + return logging(entry); + case 'stderr': + return stderr(entry); + case 'operation': + return operation(entry); + default: { + const exhaustive: never = entry; + return exhaustive; + } + } + }; +}; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace.ts index 14445d40e..a6af4a51b 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace.ts @@ -11,6 +11,28 @@ import type { export type McpSessionTraceSink = (binding: McpSessionBinding, entry: McpSessionTraceEntry) => void; +/** + * Fans one entry out to every sink, isolating each: a throwing or slow + * observer (a trace publisher, the dev-log sink) never starves the others + * and never reaches the session. + */ +export const composeMcpSessionTraceSinks = ( + ...sinks: readonly (McpSessionTraceSink | undefined)[] +): McpSessionTraceSink | undefined => { + const active = sinks.filter((sink): sink is McpSessionTraceSink => sink !== undefined); + if (active.length === 0) return undefined; + if (active.length === 1) return active[0]; + return (binding, entry) => { + for (const sink of active) { + try { + sink(binding, entry); + } catch { + // One observer's failure is not another's, and none is the session's. + } + } + }; +}; + interface TraceSubscription { closed: boolean; lastDeliveredSequence: number; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts index f66f5133a..578c3ac5a 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts @@ -21,6 +21,7 @@ import type { McpSessionReplayOverflow, } from './mcp-session-protocol.ts'; import type { McpSessionTraceSink } from './mcp-session-trace.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; import { CodedError } from '../../core/errors.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -175,6 +176,12 @@ export interface McpSessionServiceOptions { readonly registry?: TargetRegistry; /** The dev server's session runtime; absent, each program runs on its own `platformLayer`. */ readonly platformRuntime?: DevPlatformRuntime; + /** + * The Workbench's unified trace (#600). Every session lowers its frames, + * notifications, stderr, and lifecycle onto it through + * `createMcpSessionTraceSink`; absent, nothing is published. + */ + readonly trace?: TracePublisher; /** Optional observability sink. It receives safe trace categories, never changes session behavior. */ readonly traceSink?: McpSessionTraceSink; } diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts index 51792a376..f4c2e0a11 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts @@ -40,6 +40,7 @@ import { type ResolvedMcpSessionServer, } from './mcp-session-launch.ts'; import { McpSessionTraceLog, type McpSessionTraceSink } from './mcp-session-trace.ts'; +import { liftMcpFrame } from './mcp-session-trace-publisher.ts'; import { RecordingTransport } from './mcp-recording-transport.ts'; import { McpSessionError, @@ -682,6 +683,7 @@ export class McpSession { this.#retain(this.#frames, Object.freeze({ direction, message: snapshot, sequence }), maxRetainedFrames); this.#recordTrace(Object.freeze({ direction, + ...liftMcpFrame(snapshot), kind: 'frame', message: snapshot, occurredAt: Date.now(), diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 74a2dd058..f2dec7709 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -20,7 +20,7 @@ import type { } from '../../contracts/lifecycles.ts'; import type { RequestContextProvenance } from '../../contracts/request-provenance.ts'; import { deepFreeze } from '../../core/freeze.ts'; -import { isJsonRecord, isRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts'; +import { isJsonRecord, isRecord, snapshotStrictJsonValue, type JsonObject } from '../../core/strict-json.ts'; import { createCanonicalEventProps, projectEventDocument, @@ -34,6 +34,7 @@ import type { CompiledAgentRoute, CompiledRouteGraph } from '../../routes/types. import type { RenderRouteContext, renderRouteEvents } from '../../test/render.ts'; import type { AgentRouteModule } from '../../test/types.ts'; import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; +import { nativeEventRequestContext } from '../routes/route-invocation.ts'; import type { LifecycleRenderChildRequest, LifecycleRenderChildResponse, @@ -44,74 +45,6 @@ import { YieldableFrameworkError } from '../../effect/errors.ts'; const concreteHosts = new Set(['claude', 'codex', 'cursor']); const projectionDiagnosticCode = 'lifecycle.projection.unsupported'; -const nativeText = (native: Readonly>, key: string): string | undefined => { - const value = native[key]; - return typeof value === 'string' && value.trim() !== '' ? value : undefined; -}; - -/** - * What one replayed receipt proves about its place in the conversation tree: - * a Claude or Codex payload with no `agent_id` is the root itself; anything - * subagent-shaped (and every Cursor payload) needs the warm runtime's registry, - * which a deterministic replay does not have. - */ -const replayLineage = ( - native: Readonly>, - target: string, -): RequestContextProvenance['lineage'] => { - if (!concreteHosts.has(target)) return { reason: 'no-subagent-events', state: 'unavailable' }; - if (target === 'cursor') return { reason: 'no-shared-runtime', state: 'unavailable' }; - const root = nativeText(native, 'session_id'); - const agentId = nativeText(native, 'agent_id'); - if (root === undefined || agentId !== undefined) return { reason: 'no-shared-runtime', state: 'unavailable' }; - const generation = target === 'codex' ? nativeText(native, 'turn_id') : nativeText(native, 'prompt_id'); - return { - source: 'receipt', - state: 'available', - value: { - conversation: root, - depth: 0, - ...(generation === undefined ? {} : { generation }), - resolution: 'native', - root, - }, - }; -}; - -const replayRequestContext = ( - event: CanonicalAgentEvent, - native: Readonly>, - routeId: string, - target: string, - hostContractRevision: string, -): RequestContextProvenance => { - const sessionId = nativeText(native, 'session_id') ?? nativeText(native, 'conversation_id'); - const workspaceRoots = native['workspace_roots']; - const firstWorkspaceRoot = Array.isArray(workspaceRoots) && - typeof workspaceRoots[0] === 'string' && - workspaceRoots[0].trim() !== '' - ? workspaceRoots[0] - : undefined; - const workspaceRoot = nativeText(native, 'cwd') ?? firstWorkspaceRoot; - return deepFreeze({ - actor: { reason: 'not-provided', state: 'unavailable' }, - host: { source: 'receipt', state: 'available', value: { name: target } }, - invocation: { - hostContractRevision, - kind: 'event', - operationId: routeId, - surface: event, - }, - lineage: replayLineage(native, target), - session: sessionId === undefined - ? { reason: 'not-provided', state: 'unavailable' } - : { source: 'receipt', state: 'available', value: { sessionId } }, - workspace: workspaceRoot === undefined - ? { reason: 'not-provided', state: 'unavailable' } - : { source: 'receipt', state: 'available', value: { root: workspaceRoot } }, - }); -}; - const renderContext = (requestContext: RequestContextProvenance): RenderRouteContext => deepFreeze({ actor: requestContext.actor, host: requestContext.host, @@ -461,9 +394,11 @@ export class LifecycleReplayService { ); } let nativeInput: Readonly>; + let strictNativeInput: JsonObject; try { const snapshot = snapshotStrictJsonValue(request.native); if (!isJsonRecord(snapshot)) throw new TypeError('stdin JSON value must be an object'); + strictNativeInput = snapshot; nativeInput = validateNativeEventEnvelope(snapshot, { canonicalEvent: event, nativeEvent: target.nativeEvent, @@ -473,13 +408,13 @@ export class LifecycleReplayService { const message = error instanceof Error ? error.message : String(error); throw new LifecycleReplayRequestError('AB8211', message, 400); } - const requestContext = replayRequestContext( + const requestContext = nativeEventRequestContext({ event, - nativeInput, - route.id, - target.target, - target.hostContractRevision, - ); + hostContractRevision: target.hostContractRevision, + native: strictNativeInput, + routeId: route.id, + target: target.target, + }); let rendered: LifecycleRenderChildResult; if (this.#renderInProcess) { const props = createCanonicalEventProps( diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts index de93d020e..5eb4e25f3 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -1,7 +1,15 @@ import * as AgentRuntime from '@agent-bundle/runtime'; +import type { AgentRenderEvent } from '@agent-bundle/runtime'; import { renderedDocumentExitCode } from '../../cli-entry.ts'; -import type { JsonObject } from '../../core/strict-json.ts'; +import { isJsonRecord, type JsonObject } from '../../core/strict-json.ts'; +import { + createEventTracer, + eventTraceExecution, + installEventTraceObserver, + type EventTraceEvent, +} from '../../events/trace.ts'; +import { canonicalAgentEvents } from '../../routes/public.ts'; import { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, @@ -56,6 +64,14 @@ const respond = (response: RouteInvocationChildResponse): Promise => new P }); }); +const forwardEventTrace = (event: EventTraceEvent): void => { + process.send?.({ event, type: 'trace' } satisfies RouteInvocationChildResponse); +}; + +const forwardRenderEvent = (event: AgentRenderEvent): void => { + process.send?.({ event, type: 'render' } satisfies RouteInvocationChildResponse); +}; + /** * The exit code a generated executable would set for this unit render. There * is no compiled bin to ask in `unit-render`, so the same `cli-entry.ts` @@ -86,18 +102,38 @@ const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise event === request.routeId.slice('event:'.length)) + : undefined; + const nativeInput = isJsonRecord(input) ? input.native : undefined; + const nativeEvent = nativeInput !== undefined && isJsonRecord(nativeInput) && typeof nativeInput.hook_event_name === 'string' + ? nativeInput.hook_event_name + : eventName; + const host = request.context.host.state === 'available' ? request.context.host.value.name : 'workbench'; + const trace = eventName === undefined || nativeEvent === undefined + ? undefined + : createEventTracer({ execution: eventTraceExecution({ event: eventName, host, nativeEvent }) }); + trace?.executeStart('standalone'); + trace?.renderStart(); + let rendered: Awaited>; + try { + rendered = await renderRouteEvents(request.routeId, { + context: { + actor: request.context.actor, + host: request.context.host, + invocation: request.context.invocation, + lineage: request.context.lineage, + session: request.context.session, + workspace: request.context.workspace, + }, + input, + manifest: request.manifest, + }); + trace?.renderFinish(); + } catch (error) { + trace?.failure('render', error); + throw error; + } const exitCode = unitRenderExitCode(request, rendered.document, rendered.result ?? rendered.document.value); return Object.freeze({ document: rendered.document, @@ -116,24 +152,31 @@ const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise => - request.surface.kind === 'unit-render' - ? renderUnitRoute(request) - : renderProductionRoute(request); +const render = async (request: RouteInvocationChildRequest): Promise => { + const result = request.surface.kind === 'unit-render' + ? await renderUnitRoute(request) + : await renderProductionRoute(request, forwardEventTrace, forwardRenderEvent); + if (request.surface.kind === 'unit-render') { + for (const event of result.events) forwardRenderEvent(event); + } + return result; +}; process.once('message', (request: RouteInvocationChildRequest) => { + const disposeTraceObserver = installEventTraceObserver(forwardEventTrace); void render(request) - .then((result) => respond({ result, type: 'result' })) - .catch((error: unknown) => respond({ - error: { - ...(error instanceof ProductionRouteInvocationError - ? { code: error.code } - : {}), - message: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : 'Error', - }, - type: 'error', - })) + .then( + (result) => respond({ result, type: 'result' }), + (error: unknown) => respond({ + error: { + ...(error instanceof ProductionRouteInvocationError ? { code: error.code } : {}), + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : 'Error', + }, + type: 'error', + }), + ) + .finally(disposeTraceObserver) .then(() => process.disconnect?.()) .catch((error: unknown) => { console.error(error); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index c82a1b7d9..7263bcdc7 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -140,7 +140,7 @@ interface PreparedInput { const prepareInput = async ( request: ProductionRequest, - traceEvents: EventTraceEvent[], + observeTrace: EventTraceObserver, signal: AbortSignal, ): Promise => { const route = request.manifest.routes[request.routeId]; @@ -168,7 +168,7 @@ const prepareInput = async ( const wrapper = await importedModule(wrapperPath); if (typeof wrapper.prepareRouteInvocation !== 'function') return { input: request.input }; const native = (request.input as { readonly native?: JsonObject }).native ?? {}; - const preflight = await wrapper.prepareRouteInvocation(native, signal, (event) => traceEvents.push(event)); + const preflight = await wrapper.prepareRouteInvocation(native, signal, observeTrace); return { input: { canonical: preflight.props.canonical, @@ -442,6 +442,7 @@ const renderCompiled = async ( signal: AbortSignal, env: NodeJS.ProcessEnv, trace?: EventTracer, + publishRender?: (event: AgentRenderEvent) => void, ): Promise event.type === 'complete'); if (complete === undefined) throw new Error('Compiled route render ended without a complete event.'); @@ -489,6 +491,8 @@ const renderCompiled = async ( export const renderProductionRoute = async ( request: RouteInvocationChildRequest, + publishTrace?: EventTraceObserver, + publishRender?: (event: AgentRenderEvent) => void, ): Promise => { if (request.artifactEpoch === undefined || request.artifactRoot === undefined) { throw new ProductionRouteInvocationError( @@ -504,10 +508,14 @@ export const renderProductionRoute = async ( }; applyOperatorEnv({ env, pluginRoot: productionRequest.artifactRoot }); const traceEvents: EventTraceEvent[] = []; + const observeTrace: EventTraceObserver = (event) => { + traceEvents.push(event); + publishTrace?.(event); + }; const controller = new AbortController(); let prepared: PreparedInput; try { - prepared = await prepareInput(productionRequest, traceEvents, controller.signal); + prepared = await prepareInput(productionRequest, observeTrace, controller.signal); } catch (error) { throw preparationFailure(error); } @@ -535,6 +543,7 @@ export const renderProductionRoute = async ( controller.signal, env, prepared.preflight?.trace, + publishRender, ); const result = rendered.document.value; const kind = request.manifest.routes[request.routeId]?.kind; diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-result.ts b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts index b187dc489..cdad02cb9 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-result.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts @@ -4,6 +4,7 @@ import type { JsonValue } from '../../core/strict-json.ts'; import type { RequestContextProvenance } from '../../contracts/request-provenance.ts'; import type { EventTraceEvent } from '../../events/trace.ts'; import type { + RunningRouteInvocation, RouteInvocationProjection, RouteInvocationProvider, RouteInvocationSummary, @@ -23,6 +24,21 @@ export interface RouteInvocation extends RouteInvocationSummary { readonly trace?: readonly EventTraceEvent[]; } +export type RouteInvocationStreamMessage = + | Readonly<{ readonly event: AgentRenderEvent; readonly type: 'render' }> + | Readonly<{ readonly event: EventTraceEvent; readonly type: 'trace' }> + | Readonly<{ readonly type: 'truncated' }> + | Readonly<{ readonly invocation: RouteInvocation; readonly type: 'final' }>; + +export interface RouteInvocationStart { + readonly invocation: RunningRouteInvocation; + readonly result: Promise; +} + +export interface RunningRouteInvocationResponse { + readonly invocation: RunningRouteInvocation; +} + /** `GET /api/routes/invocations/` and `POST /api/routes/invocations`. */ export interface RouteInvocationResponse { readonly invocation: RouteInvocation; diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts index 9a1556d03..8caef040c 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts @@ -2,8 +2,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { ProjectEventHub } from '../events.ts'; import { + createBackpressuredWriter, + writeKeepAliveStreamHead, +} from '../route-streams.ts'; +import { + badRequest, decodedOpaqueSegment, diagnostic, + noQuery, rawPathname, readJsonBody, requestError, @@ -13,6 +19,9 @@ import { import type { RouteInvocation, RouteInvocationResponse, + RouteInvocationStart, + RouteInvocationStreamMessage, + RunningRouteInvocationResponse, } from './route-invocation-result.ts'; import type { RouteInvocationListResponse, @@ -26,14 +35,27 @@ import { type RouteInvocationRequestError, } from './route-invocation-service.ts'; +const streamQueueByteLimit = 256 * 1024; +const streamQueueEntryLimit = 128; +const invalidShape = badRequest( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation request has an invalid shape.', +); + export interface RouteInvocationRouteService { close?(): Promise | void; invoke( request: RouteInvocationRequest, options?: Readonly<{ readonly signal?: AbortSignal }>, ): Promise; + cancel(id: string): Promise; list(limit?: number): RouteInvocationListResponse['invocations']; read(id: string): RouteInvocation | undefined; + start( + request: RouteInvocationRequest, + options?: Readonly<{ readonly signal?: AbortSignal }>, + ): RouteInvocationStart; + subscribe(id: string, listener: (message: RouteInvocationStreamMessage) => void): () => void; } export interface RouteInvocationRoutesOptions { @@ -44,14 +66,21 @@ export interface RouteInvocationRoutesOptions { type InvocationPath = | Readonly<{ readonly kind: 'collection' }> - | Readonly<{ readonly id: string; readonly kind: 'item' }>; + | Readonly<{ readonly id: string; readonly kind: 'item' | 'stream' | 'cancel' }>; const invocationPath = (requestTarget: string | undefined): InvocationPath | undefined => { const pathname = rawPathname(requestTarget); if (pathname !== '/api/routes/invocations' && !pathname.startsWith('/api/routes/invocations/')) return undefined; if (pathname === '/api/routes/invocations') return Object.freeze({ kind: 'collection' }); const parts = pathname.split('/'); - if (parts.length !== 5 || parts[4] === undefined) { + if ((parts.length !== 5 && parts.length !== 6) || parts[4] === undefined) { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation path is not valid.', + 400, + )); + } + if (parts.length === 6 && parts[5] !== 'stream' && parts[5] !== 'cancel') { throw requestError(diagnostic( ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, 'Route invocation path is not valid.', @@ -65,7 +94,7 @@ const invocationPath = (requestTarget: string | undefined): InvocationPath | und message: 'Route invocation path is not valid.', rejectBlank: true, }), - kind: 'item', + kind: parts.length === 5 ? 'item' : parts[5] as 'cancel' | 'stream', }); }; @@ -91,20 +120,18 @@ const listLimit = (requestTarget: string | undefined): number => { return limit; }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) { - throw requestError(diagnostic( - ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, - 'Route invocation request has an invalid shape.', - 400, - )); - } -}; - const unavailable = (): never => { throw requestError(diagnostic('AB8232', 'Route invocation service is not available.', 409)); }; +const throwInvocationError = (error: unknown): never => { + const failure = error as Partial; + if (typeof failure.code === 'string' && typeof failure.message === 'string' && typeof failure.status === 'number') { + throw requestError(diagnostic(failure.code, failure.message, failure.status)); + } + throw error; +}; + export class RouteInvocationRoutes { readonly #authorize: (request: IncomingMessage) => void; readonly #eventHub: ProjectEventHub; @@ -131,21 +158,43 @@ export class RouteInvocationRoutes { if (service === undefined) return unavailable(); const method = request.method ?? 'GET'; if (path.kind === 'collection' && method === 'POST') { - noQuery(request.url); + noQuery(request.url, invalidShape); const body = await readJsonBody(request, { - invalidShape: () => { - throw requestError(diagnostic( - ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, - 'Route invocation request has an invalid shape.', - 400, - )); - }, + invalidShape, read: { code: ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, limit: 64 * 1024, message: 'Route invocation request exceeds 64 KiB.', }, }); + if (body.stream !== undefined && typeof body.stream !== 'boolean') { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation request has an invalid shape.', + 400, + )); + } + const { stream, ...requestBody } = body; + if (stream === true) { + let started: RouteInvocationStart; + try { + started = service.start(parseRouteInvocationRequest(requestBody)); + } catch (error) { + return throwInvocationError(error); + } + this.#eventHub.publish({ + payload: { invocation: started.invocation }, + type: 'route.invocation', + }); + void started.result.then((invocation) => { + this.#eventHub.publish({ + payload: { invocation: invocationSummary(invocation) }, + type: 'route.invocation', + }); + }, () => undefined); + responseJson(response, { invocation: started.invocation } satisfies RunningRouteInvocationResponse, { status: 202 }); + return true; + } let invocation: RouteInvocation; const controller = new AbortController(); const cancel = (): void => controller.abort(new DOMException('Route invocation request was cancelled.', 'AbortError')); @@ -153,13 +202,9 @@ export class RouteInvocationRoutes { response.once('close', cancel); try { if (response.destroyed) cancel(); - invocation = await service.invoke(parseRouteInvocationRequest(body), { signal: controller.signal }); + invocation = await service.invoke(parseRouteInvocationRequest(requestBody), { signal: controller.signal }); } catch (error) { - const failure = error as Partial; - if (typeof failure.code === 'string' && typeof failure.message === 'string' && typeof failure.status === 'number') { - throw requestError(diagnostic(failure.code, failure.message, failure.status)); - } - throw error; + return throwInvocationError(error); } finally { request.off('aborted', cancel); response.off('close', cancel); @@ -181,8 +226,27 @@ export class RouteInvocationRoutes { responseJson(response, { invocations: service.list(listLimit(request.url)) } satisfies RouteInvocationListResponse); return true; } + if (path.kind === 'stream' && method === 'GET') { + noQuery(request.url, invalidShape); + try { + this.#stream(service, path.id, response); + } catch (error) { + return throwInvocationError(error); + } + return true; + } + if (path.kind === 'cancel' && method === 'POST') { + noQuery(request.url, invalidShape); + try { + const invocation = await service.cancel(path.id); + responseJson(response, { invocation } satisfies RouteInvocationResponse, { status: 202 }); + } catch (error) { + return throwInvocationError(error); + } + return true; + } if (path.kind === 'item' && method === 'GET') { - noQuery(request.url); + noQuery(request.url, invalidShape); const invocation = service.read(path.id); if (invocation === undefined) { throw requestError(diagnostic('AB8231', `Route invocation ${JSON.stringify(path.id)} was not found.`, 404)); @@ -193,4 +257,39 @@ export class RouteInvocationRoutes { responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); return true; } + + #stream(service: RouteInvocationRouteService, id: string, response: ServerResponse): void { + let terminal = false; + const stream = { unsubscribe: undefined as (() => void) | undefined }; + const finish = (): void => { + if (!terminal || !writer.idle || response.writableEnded || response.destroyed) return; + stream.unsubscribe?.(); + response.end(); + }; + const writer = createBackpressuredWriter(response, { + byteLimit: streamQueueByteLimit, + onIdle: finish, + recordLimit: streamQueueEntryLimit, + }); + const deliver = (message: RouteInvocationStreamMessage): void => { + const result = writer.enqueue(`event: ${message.type}\ndata: ${JSON.stringify(message)}\n\n`); + if (result === 'overflow') response.destroy(); + if (message.type === 'final') terminal = true; + finish(); + }; + response.once('close', () => { + writer.markClosed(); + stream.unsubscribe?.(); + }); + const replay: RouteInvocationStreamMessage[] = []; + let replaying = true; + stream.unsubscribe = service.subscribe(id, (message) => replaying ? replay.push(message) : deliver(message)); + writeKeepAliveStreamHead(response, { + cacheControl: 'no-cache', + contentType: 'text/event-stream; charset=utf-8', + }); + replaying = false; + for (const message of replay) deliver(message); + finish(); + } } diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 34c0a09e6..697d394fa 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -5,7 +5,7 @@ import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { AgentDocument, AgentDocumentNode } from '@agent-bundle/runtime'; +import type { AgentDocument, AgentDocumentNode, AgentRenderEvent } from '@agent-bundle/runtime'; import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; import type { TargetHookContract } from '../../adapters/hook-contract.ts'; @@ -28,22 +28,38 @@ import type { RequestProvenanceUnavailableReason, } from '../../contracts/request-provenance.ts'; import { createCanonicalEventProps, projectEventDocument } from '../../events/projection.ts'; -import type { CanonicalAgentEvent } from '../../routes/public.ts'; -import type { EventTraceEvent } from '../../events/trace.ts'; +import { + eventTraceEventKinds, + type EventTraceEvent, + type EventTracePreflightOutcome, +} from '../../events/trace.ts'; +import { + canonicalAgentEvents, + type CanonicalAgentEvent, +} from '../../routes/public.ts'; import { taskkill, terminateProcessTree, waitForProcessTreeExit } from '../../services/process-tree.ts'; import type { AgentBundleTestManifest, TestableScriptDescriptor } from '../../test/manifest.ts'; import type { ScriptPlaygroundResult, ScriptPlaygroundRunRequest } from '../playground/script-playground-service.ts'; +import type { TraceCorrelation, TraceStatus } from '../trace/trace-entry.ts'; +import type { TracePublisher } from '../trace/trace-hub.ts'; +import { applicationNodePath, applicationNodeRefForRouteId } from './application-node.ts'; import { isProductionRouteInvocationCode, ProductionRouteInvocationError, } from './route-invocation-production-error.ts'; -import type { RouteInvocation } from './route-invocation-result.ts'; +import type { + RouteInvocation, + RouteInvocationStart, + RouteInvocationStreamMessage, +} from './route-invocation-result.ts'; +import { nativeEventRequestContext } from './route-invocation.ts'; import type { RouteInvocationEventHost, RouteInvocationKind, RouteInvocationOutcome, RouteInvocationProvider, RouteInvocationRequest, + RunningRouteInvocation, RouteInvocationSurface, RouteInvocationSummary, RouteInvocationTiming, @@ -60,6 +76,7 @@ export const ROUTE_INVOCATION_STALE_REVISION_CODE = 'AB8239'; export const ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE = 'AB8253'; export const ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE = 'AB8254'; export const ROUTE_INVOCATION_EVENT_HOST_REQUIRED_CODE = 'AB8255'; +export const ROUTE_INVOCATION_ALREADY_FINAL_CODE = 'AB8256'; export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; @@ -115,9 +132,12 @@ export interface RouteInvocationServiceOptions { readonly renderChild?: ( request: RouteInvocationChildRequest, signal: AbortSignal, + publishKernelEvent: (event: EventTraceEvent) => void, + publishRenderEvent: (event: AgentRenderEvent) => void, ) => Promise; readonly scripts?: RouteInvocationScriptRunner; readonly timeoutMs?: number; + readonly trace?: TracePublisher; } export interface RouteInvocationChildRequest { @@ -159,6 +179,8 @@ export interface RouteInvocationChildResult { export type RouteInvocationChildResponse = | Readonly<{ readonly result: RouteInvocationChildResult; readonly type: 'result' }> + | Readonly<{ readonly event: AgentRenderEvent; readonly type: 'render' }> + | Readonly<{ readonly event: EventTraceEvent; readonly type: 'trace' }> | Readonly<{ readonly error: Readonly<{ readonly code?: string; readonly message: string; readonly name: string }>; readonly type: 'error'; @@ -173,6 +195,7 @@ export class RouteInvocationRequestError extends Error { | typeof ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE | typeof ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_ALREADY_FINAL_CODE | typeof ROUTE_INVOCATION_STALE_REVISION_CODE; readonly status: 400 | 404 | 409; @@ -531,9 +554,83 @@ const runPlainScript = async ( }); }; +const eventTracePhases = new Set(['preflight', 'execute', 'providers', 'render']); +const canonicalEvents = new Set(canonicalAgentEvents); +const finiteNonnegative = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; + +const isEventTraceEvent = (value: unknown): value is EventTraceEvent => { + if (!isRecord(value) || !isRecord(value.execution)) return false; + const execution = value.execution; + if ( + typeof value.kind !== 'string' + || !(eventTraceEventKinds as readonly string[]).includes(value.kind) + || typeof value.phase !== 'string' + || !eventTracePhases.has(value.phase) + || !finiteNonnegative(value.at) + || !Number.isSafeInteger(value.sequence) + || (value.sequence as number) < 0 + || typeof execution.event !== 'string' + || !canonicalEvents.has(execution.event) + || typeof execution.executionId !== 'string' + || typeof execution.host !== 'string' + || typeof execution.nativeEvent !== 'string' + || !hasOnlyOwnKeys(execution, ['event', 'executionId', 'host', 'nativeEvent']) + ) return false; + const durationValid = value.durationMs === undefined || finiteNonnegative(value.durationMs); + switch (value.kind) { + case 'preflight.start': + return value.phase === 'preflight' + && hasOnlyOwnKeys(value, ['at', 'execution', 'kind', 'phase', 'sequence']); + case 'preflight.outcome': + return value.phase === 'preflight' + && durationValid + && (value.outcome === 'continue' || value.outcome === 'deny' || value.outcome === 'execute') + && hasOnlyOwnKeys(value, ['at', 'durationMs', 'execution', 'kind', 'outcome', 'phase', 'sequence']); + case 'execute.start': + return value.phase === 'execute' + && (value.runtime === 'shared' || value.runtime === 'standalone') + && hasOnlyOwnKeys(value, ['at', 'execution', 'kind', 'phase', 'runtime', 'sequence']); + case 'providers.start': + return value.phase === 'providers' + && hasOnlyOwnKeys(value, ['at', 'execution', 'kind', 'phase', 'sequence']); + case 'providers.finish': + return value.phase === 'providers' + && durationValid + && Number.isSafeInteger(value.count) + && (value.count as number) >= 0 + && hasOnlyOwnKeys(value, ['at', 'count', 'durationMs', 'execution', 'kind', 'phase', 'sequence']); + case 'render.start': + return value.phase === 'render' + && hasOnlyOwnKeys(value, ['at', 'execution', 'kind', 'phase', 'sequence']); + case 'render.finish': + return value.phase === 'render' + && durationValid + && hasOnlyOwnKeys(value, ['at', 'durationMs', 'execution', 'kind', 'phase', 'sequence']); + case 'failure': + return durationValid + && isRecord(value.error) + && typeof value.error.name === 'string' + && typeof value.error.message === 'string' + && (value.error.code === undefined || typeof value.error.code === 'string') + && hasOnlyOwnKeys(value.error, ['code', 'message', 'name']) + && hasOnlyOwnKeys(value, ['at', 'durationMs', 'error', 'execution', 'kind', 'phase', 'sequence']); + default: + return false; + } +}; + const isChildResponse = (value: unknown): value is RouteInvocationChildResponse => { if (!isRecord(value)) return false; if (value.type === 'result') return isRecord(value.result); + if (value.type === 'render') { + return isRecord(value.event) + && typeof value.event.type === 'string' + && ['complete', 'error', 'progress', 'replace', 'shell'].includes(value.event.type); + } + if (value.type === 'trace') { + return hasOnlyOwnKeys(value, ['event', 'type']) && isEventTraceEvent(value.event); + } return value.type === 'error' && isRecord(value.error) && typeof value.error.name === 'string' && typeof value.error.message === 'string'; }; @@ -577,6 +674,8 @@ const terminateChild = async (child: ChildProcess): Promise => { const renderInChild = async ( request: RouteInvocationChildRequest, signal: AbortSignal, + publishKernelEvent: (event: EventTraceEvent) => void, + publishRenderEvent: (event: AgentRenderEvent) => void, ): Promise => { if (signal.aborted) throw signal.reason; const executable = childPath(); @@ -611,6 +710,14 @@ const renderInChild = async ( ))); const receive = (message: unknown): void => { if (!isChildResponse(message)) return settle(() => rejectPromise(new Error('Route invocation child returned an invalid response.'))); + if (message.type === 'trace') { + publishKernelEvent(message.event); + return; + } + if (message.type === 'render') { + publishRenderEvent(message.event); + return; + } if (message.type === 'error') { const error = isProductionRouteInvocationCode(message.error.code) ? new ProductionRouteInvocationError(message.error.code, message.error.message) @@ -624,7 +731,7 @@ const renderInChild = async ( // still emits `error`, and an unobserved one would crash the dev server. child.on('error', fail); child.once('exit', exited); - child.once('message', receive); + child.on('message', receive); child.send(request, (error) => { if (error !== null) fail(error); }); @@ -648,6 +755,177 @@ const eventContract = ( return Object.freeze({ contract, hostContractRevision, nativeEvent }); }; +const contextForRequest = ( + route: RouteManifestRoute, + root: string, + surface: RouteInvocationSurface, + nativeInput: JsonValue, + registry: TargetRegistry, +): RequestContextProvenance => { + const host = surface.kind === 'event' ? surface.host : undefined; + if (route.kind !== 'event-route' || host === undefined || !isJsonRecord(nativeInput)) { + return contextFor(route, root, surface); + } + const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); + if (mapped === undefined) return contextFor(route, root, surface); + return nativeEventRequestContext({ + event: route.event!, + hostContractRevision: mapped.hostContractRevision, + native: nativeInput, + routeId: route.id, + target: host, + }); +}; + +const routeHref = (routeId: string, invocationId: string): string | undefined => { + const node = applicationNodeRefForRouteId(routeId); + return node === undefined + ? undefined + : `${applicationNodePath(node)}?invocation=${encodeURIComponent(invocationId)}`; +}; + +const routeLabel = ( + kind: RouteInvocationKind, + routeId: string, + event: string | undefined, + host: RouteInvocationEventHost | undefined, +): string => { + const identity = routeId.slice(routeId.indexOf(':') + 1); + switch (kind) { + case 'tool': + case 'resource': + case 'prompt': + return `MCP ${kind} ${identity}`; + case 'event-route': + return `event ${event ?? identity}${host === undefined ? '' : ` (${host})`}`; + case 'cli': + return `CLI ${identity}`; + case 'script': + return `script ${identity}`; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +const durationText = (durationMs: number): string => `${durationMs.toFixed(1)} ms`; + +const traceCorrelation = ( + request: RouteInvocationRequest, + context: RequestContextProvenance, + invocationId: string, + epochId: string | undefined, +): TraceCorrelation => ({ + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), + ...(context.lineage.state === 'available' ? { conversationId: context.lineage.value.conversation } : {}), + ...(epochId === undefined ? {} : { epochId }), + ...(context.host.state === 'available' ? { host: context.host.value.name } : {}), + invocationId, + routeId: request.routeId, + ...(context.session.state === 'available' ? { sessionId: context.session.value.sessionId } : {}), +}); + +const projectionKind = (projection: RouteInvocation['projection']): 'cli' | 'hosts' | 'mcp' | 'none' => { + if (projection.mcp !== undefined) return 'mcp'; + if (projection.cli !== undefined) return 'cli'; + if (projection.hosts !== undefined) return 'hosts'; + return 'none'; +}; + +const invocationTraceDetails = (invocation: RouteInvocation): JsonObject => ({ + diagnosticCodes: invocation.diagnostics.map((entry) => entry.code), + ...(invocation.projection.cli === undefined ? {} : { exitCode: invocation.projection.cli.exitCode }), + projectionKind: projectionKind(invocation.projection), + providers: invocation.providers.map((provider) => ({ + ...(provider.durationMs === undefined ? {} : { durationMs: provider.durationMs }), + name: provider.name, + })), + status: invocation.status, +}); + +const kernelStatus = (event: EventTraceEvent): TraceStatus => { + switch (event.kind) { + case 'failure': + return 'error'; + case 'preflight.outcome': + case 'providers.finish': + case 'render.finish': + return 'ok'; + case 'preflight.start': + case 'execute.start': + case 'providers.start': + case 'render.start': + return 'running'; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +}; + +const kernelSummary = (event: EventTraceEvent): string => { + const label = `event ${event.execution.event} (${event.execution.host})`; + switch (event.kind) { + case 'preflight.start': + return `${label} · preflight started`; + case 'preflight.outcome': + return `${label} · ${event.outcome}`; + case 'execute.start': + return `${label} · ${event.runtime} execution`; + case 'providers.start': + return `${label} · providers started`; + case 'providers.finish': + return `${label} · providers finished`; + case 'render.start': + return `${label} · render started`; + case 'render.finish': + return `${label} · render finished`; + case 'failure': + return `${label} · ${event.error.name}: ${event.error.message}`; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +}; + +const kernelDetails = (event: EventTraceEvent): JsonObject => { + const base = { + event: event.execution.event, + nativeEvent: event.execution.nativeEvent, + phase: event.phase, + sequence: event.sequence, + }; + switch (event.kind) { + case 'preflight.start': + case 'providers.start': + case 'render.start': + return base; + case 'preflight.outcome': + return { ...base, outcome: event.outcome }; + case 'execute.start': + return { ...base, runtime: event.runtime }; + case 'providers.finish': + return { ...base, count: event.count }; + case 'render.finish': + return base; + case 'failure': + return { + ...base, + error: { + ...(event.error.code === undefined ? {} : { code: event.error.code }), + message: event.error.message, + name: event.error.name, + }, + }; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +}; + const eventInput = ( route: RouteManifestRoute, input: JsonValue, @@ -893,9 +1171,71 @@ const failedInvocation = (input: { }); }; +interface InvocationStreamRecord { + readonly controller: AbortController; + readonly listeners: Set<(message: RouteInvocationStreamMessage) => void>; + readonly messages: RouteInvocationStreamMessage[]; + readonly running: RunningRouteInvocation; + cancelRequested: boolean; + final?: RouteInvocation; + result?: Promise; +} + +const latestDocument = ( + messages: readonly RouteInvocationStreamMessage[], +): AgentDocument | undefined => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (message.type !== 'render') continue; + if (message.event.type === 'shell' || message.event.type === 'replace' || message.event.type === 'complete') { + return message.event.document; + } + } + return undefined; +}; + +const cancelledInvocation = (input: { + readonly context: RequestContextProvenance; + readonly id: string; + readonly manifest: RouteManifest; + readonly messages: readonly RouteInvocationStreamMessage[]; + readonly request: RouteInvocationRequest; + readonly route: RouteManifestRoute; + readonly startedAt: Date; + readonly surface: RouteInvocationSurface; + readonly completedAt: Date; +}): RouteInvocation => { + const events = input.messages.flatMap((message) => message.type === 'render' ? [message.event] : []); + const document = latestDocument(input.messages); + return deepFreeze({ + completedAt: input.completedAt.toISOString(), + context: input.context, + ...(input.request.correlationId === undefined ? {} : { correlationId: input.request.correlationId }), + diagnostics: [], + ...(document === undefined ? {} : { document }), + events, + id: input.id, + input: input.request.input ?? {}, + kind: input.route.kind as RouteInvocationKind, + manifestDigest: input.manifest.digest, + projection: {}, + providers: unobservedProviders(input.manifest), + routeId: input.route.id, + source: input.route.source, + sourceRevision: input.manifest.sourceRevision, + startedAt: input.startedAt.toISOString(), + status: 'cancelled', + surface: input.surface, + timings: [timing('elapsed', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], + }); +}; + export class RouteInvocationService { + readonly #completedStreams: string[] = []; readonly #controllers = new Set(); readonly #history: InvocationRingBuffer; + readonly #historyLimit: number; + readonly #streams = new Map(); readonly #manifest: RouteManifestRouteService; readonly #now: () => Date; readonly #pending = new Set>(); @@ -905,10 +1245,12 @@ export class RouteInvocationService { readonly #scripts: RouteInvocationScriptRunner | undefined; readonly #semaphore: InvocationSemaphore; readonly #timeoutMs: number; + readonly #trace: TracePublisher | undefined; readonly #closeController = new AbortController(); constructor(options: RouteInvocationServiceOptions) { - this.#history = new InvocationRingBuffer(options.historyLimit); + this.#historyLimit = options.historyLimit ?? defaultHistoryLimit; + this.#history = new InvocationRingBuffer(this.#historyLimit); this.#manifest = options.manifest; this.#now = options.now ?? (() => new Date()); this.#prepared = options.prepared; @@ -917,6 +1259,7 @@ export class RouteInvocationService { this.#scripts = options.scripts; this.#semaphore = new InvocationSemaphore(options.concurrency ?? defaultConcurrency); this.#timeoutMs = options.timeoutMs ?? defaultTimeoutMs; + this.#trace = options.trace; if (!Number.isSafeInteger(this.#timeoutMs) || this.#timeoutMs < 1) throw new RangeError('Invocation timeout must be positive.'); } @@ -928,6 +1271,69 @@ export class RouteInvocationService { return this.#history.read(id); } + subscribe(id: string, listener: (message: RouteInvocationStreamMessage) => void): () => void { + const record = this.#streams.get(id); + if (record === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, + `Route invocation ${JSON.stringify(id)} was not found.`, + 404, + ); + } + for (const message of record.messages) listener(message); + if (record.final === undefined) record.listeners.add(listener); + return () => record.listeners.delete(listener); + } + + async cancel(id: string): Promise { + const record = this.#streams.get(id); + if (record === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, + `Route invocation ${JSON.stringify(id)} was not found.`, + 404, + ); + } + if (record.final !== undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_ALREADY_FINAL_CODE, + `Route invocation ${JSON.stringify(id)} is already final.`, + 409, + ); + } + record.cancelRequested = true; + record.controller.abort(new DOMException('Route invocation cancelled by the operator.', 'AbortError')); + const invocation = await record.result!; + if (invocation.status !== 'cancelled') { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_ALREADY_FINAL_CODE, + `Route invocation ${JSON.stringify(id)} is already final.`, + 409, + ); + } + return invocation; + } + + #publishStream(record: InvocationStreamRecord, message: RouteInvocationStreamMessage): void { + if (message.type === 'render') { + const renderCount = record.messages.reduce((count, retained) => + count + (retained.type === 'render' ? 1 : 0), 0); + if (renderCount === 256) { + const oldest = record.messages.findIndex((retained) => retained.type === 'render'); + if (oldest !== -1) record.messages.splice(oldest, 1); + const markerIndex = record.messages.findIndex((retained) => retained.type === 'truncated'); + if (markerIndex === -1) { + const marker = deepFreeze({ type: 'truncated' }); + record.messages.unshift(marker); + for (const listener of record.listeners) listener(marker); + } + } + } + const frozen = deepFreeze(message); + record.messages.push(frozen); + for (const listener of record.listeners) listener(frozen); + } + async close(): Promise { this.#closeController.abort(new DOMException('Route invocation service closed.', 'AbortError')); for (const controller of this.#controllers) { @@ -936,10 +1342,29 @@ export class RouteInvocationService { await Promise.allSettled([...this.#pending]); } - async invoke( + invoke( request: RouteInvocationRequest, options: Readonly<{ readonly signal?: AbortSignal }> = {}, ): Promise { + try { + return this.#start(request, options, false).result; + } catch (error) { + return Promise.reject(error); + } + } + + start( + request: RouteInvocationRequest, + options: Readonly<{ readonly signal?: AbortSignal }> = {}, + ): RouteInvocationStart { + return this.#start(request, options, true); + } + + #start( + request: RouteInvocationRequest, + options: Readonly<{ readonly signal?: AbortSignal }>, + terminalErrors: boolean, + ): RouteInvocationStart { let queued: RouteManifest; try { queued = this.#manifest.manifest(); @@ -971,9 +1396,38 @@ export class RouteInvocationService { const surface = resolvedSurface(route, request.surface, queued); const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); - const admissionSignal = options.signal === undefined - ? this.#closeController.signal - : AbortSignal.any([this.#closeController.signal, options.signal]); + const operationController = new AbortController(); + const runningInvocation: RunningRouteInvocation = deepFreeze({ + id, + routeId: route.id, + startedAt: startedAt.toISOString(), + status: 'running', + surface, + }); + const streamRecord: InvocationStreamRecord = { + cancelRequested: false, + controller: operationController, + listeners: new Set(), + messages: [], + running: runningInvocation, + }; + this.#streams.set(id, streamRecord); + this.#controllers.add(operationController); + let traceMeta: Readonly<{ + readonly correlation: TraceCorrelation; + readonly eventOutcome: () => EventTracePreflightOutcome | undefined; + readonly href?: string; + readonly label: string; + }> | undefined; + let cancellationContext = deepFreeze({ + ...contextFor(route, '', surface), + workspace: unavailable>('not-provided'), + }); + const admissionSignal = AbortSignal.any([ + this.#closeController.signal, + operationController.signal, + ...(options.signal === undefined ? [] : [options.signal]), + ]); const running = this.#semaphore.run(async () => { admissionSignal.throwIfAborted(); let release: RouteInvocationPreparedLease['release'] | undefined; @@ -1015,8 +1469,52 @@ export class RouteInvocationService { const input = route.kind === 'event-route' ? eventInput(route, rawInput, surface.kind === 'event' ? surface.host : undefined, this.#registry) : rawInput; - const context = contextFor(route, prepared.manifest.projectRoot, surface); + const context = contextForRequest(route, prepared.manifest.projectRoot, surface, rawInput, this.#registry); + cancellationContext = context; + const correlation = traceCorrelation(request, context, id, prepared.artifact?.epochId); + const href = routeHref(route.id, id); + const label = routeLabel( + route.kind as RouteInvocationKind, + route.id, + route.event, + surface.kind === 'event' ? surface.host : undefined, + ); + let eventOutcome: EventTracePreflightOutcome | undefined; + traceMeta = { + correlation, + eventOutcome: () => eventOutcome, + ...(href === undefined ? {} : { href }), + label, + }; admissionSignal.throwIfAborted(); + this.#trace?.publish({ + correlation, + details: { status: 'running' }, + ...(href === undefined ? {} : { href }), + kind: 'invocation.started', + occurredAt: startedAt.toISOString(), + source: 'invocation', + status: 'running', + summary: `${label} · running`, + }); + const publishKernelEvent = (event: EventTraceEvent): void => { + if (event.kind === 'preflight.outcome') eventOutcome = event.outcome; + this.#publishStream(streamRecord, { event, type: 'trace' }); + this.#trace?.publish({ + correlation: { + ...correlation, + executionId: event.execution.executionId, + host: event.execution.host, + }, + details: kernelDetails(event), + ...('durationMs' in event && event.durationMs !== undefined ? { durationMs: event.durationMs } : {}), + ...(href === undefined ? {} : { href }), + kind: `kernel.${event.kind}`, + source: 'kernel', + status: kernelStatus(event), + summary: kernelSummary(event), + }); + }; const controller = new AbortController(); const abort = (): void => controller.abort(admissionSignal.reason); this.#controllers.add(controller); @@ -1039,10 +1537,25 @@ export class RouteInvocationService { routeId: route.id, stateRoot: prepared.stateRoot, surface, - }, controller.signal) + }, controller.signal, publishKernelEvent, (event) => { + this.#publishStream(streamRecord, { event, type: 'render' }); + }) : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { const completedAt = this.#now(); + if (streamRecord.cancelRequested) { + return cancelledInvocation({ + completedAt, + context, + id, + manifest, + messages: streamRecord.messages, + request: { ...request, input }, + route, + startedAt, + surface, + }); + } const childCode = error instanceof ProductionRouteInvocationError ? error.code : ROUTE_INVOCATION_CHILD_FAILURE_CODE; @@ -1115,15 +1628,91 @@ export class RouteInvocationService { } finally { await release?.(); } - }, admissionSignal); + }, admissionSignal).catch((error: unknown) => { + const completedAt = this.#now(); + if (streamRecord.cancelRequested) { + return cancelledInvocation({ + completedAt, + context: cancellationContext, + id, + manifest: queued, + messages: streamRecord.messages, + request, + route, + startedAt, + surface, + }); + } + if (!terminalErrors) { + this.#streams.delete(id); + throw error; + } + return failedInvocation({ + code: error instanceof RouteInvocationRequestError ? error.code : ROUTE_INVOCATION_CHILD_FAILURE_CODE, + completedAt, + context: cancellationContext, + id, + manifest: queued, + message: error instanceof Error ? error.message : String(error), + request, + route, + startedAt, + surface, + }); + }); this.#pending.add(running); - let invocation: RouteInvocation; - try { - invocation = await running; - } finally { - this.#pending.delete(running); - } - this.#history.push(invocation); - return invocation; + const result = (async (): Promise => { + let invocation: RouteInvocation; + try { + invocation = await running; + } finally { + this.#pending.delete(running); + this.#controllers.delete(operationController); + } + this.#history.push(invocation); + streamRecord.final = invocation; + this.#publishStream(streamRecord, { invocation, type: 'final' }); + streamRecord.listeners.clear(); + this.#completedStreams.push(id); + while (this.#completedStreams.length > this.#historyLimit) { + const expired = this.#completedStreams.shift(); + if (expired !== undefined) this.#streams.delete(expired); + } + const completedTrace = traceMeta ?? { + correlation: traceCorrelation(request, cancellationContext, id, undefined), + eventOutcome: () => undefined, + href: routeHref(route.id, id), + label: routeLabel( + route.kind as RouteInvocationKind, + route.id, + route.event, + surface.kind === 'event' ? surface.host : undefined, + ), + }; + const durationMs = new Date(invocation.completedAt).getTime() - new Date(invocation.startedAt).getTime(); + const kind = invocation.status === 'succeeded' + ? 'invocation.completed' + : invocation.status === 'cancelled' + ? 'invocation.cancelled' + : 'invocation.failed'; + this.#trace?.publish({ + correlation: completedTrace.correlation, + details: invocationTraceDetails(invocation), + durationMs, + ...(completedTrace.href === undefined ? {} : { href: completedTrace.href }), + kind, + occurredAt: invocation.completedAt, + source: 'invocation', + status: invocation.status === 'succeeded' ? 'ok' : 'error', + summary: invocation.status === 'succeeded' + ? `${completedTrace.label} · ${route.kind === 'event-route' && completedTrace.eventOutcome() !== undefined + ? completedTrace.eventOutcome() + : durationText(durationMs)}` + : `${completedTrace.label} · ${invocation.status}`, + }); + return invocation; + })(); + streamRecord.result = result; + return Object.freeze({ invocation: runningInvocation, result }); } } diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 498700a34..2a2beb7d0 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -13,7 +13,9 @@ * invocation summaries without requiring the optional runtime peer. */ import type { Diagnostic } from '../../core/diagnostics.ts'; +import { deepFreeze } from '../../core/freeze.ts'; import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; +import type { RequestContextProvenance } from '../../contracts/request-provenance.ts'; /** The route kinds the invocation service renders; `app` routes are browser surfaces previewed through the MCP App preview instead. */ export type RouteInvocationKind = 'cli' | 'event-route' | 'prompt' | 'resource' | 'script' | 'tool'; @@ -44,13 +46,21 @@ export interface RouteInvocationRequest { readonly surface?: RouteInvocationSurface; } +export interface RunningRouteInvocation { + readonly id: string; + readonly routeId: string; + readonly startedAt: string; + readonly status: 'running'; + readonly surface: RouteInvocationSurface; +} + /** * Whether the execution boundary completed. `succeeded` means the route ran * to a final document (or a plain script exited) and the envelope carries * what it produced; what the run *meant* is `outcome`. `failed` means the * boundary never completed — child crash, timeout, abort, `AB825x`. */ -export type RouteInvocationStatus = 'failed' | 'succeeded'; +export type RouteInvocationStatus = 'cancelled' | 'failed' | 'succeeded'; /** * The application result of a completed run, judged by the surface the route @@ -158,7 +168,72 @@ export interface RouteInvocationListResponse { readonly invocations: readonly RouteInvocationSummary[]; } -/** The `route.invocation` project event payload published on `/api/project/events` when an invocation completes. */ +/** The running record and final summary published as `route.invocation` on `/api/project/events`. */ export interface RouteInvocationEventPayload { - readonly invocation: RouteInvocationSummary; + readonly invocation: RouteInvocationSummary | RunningRouteInvocation; } + +const nativeText = (native: JsonObject, key: string): string | undefined => { + const value = native[key]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +}; + +const nativeEventLineage = ( + native: JsonObject, + target: string, +): RequestContextProvenance['lineage'] => { + if (target !== 'claude' && target !== 'codex' && target !== 'cursor') { + return { reason: 'no-subagent-events', state: 'unavailable' }; + } + if (target === 'cursor') return { reason: 'no-shared-runtime', state: 'unavailable' }; + const root = nativeText(native, 'session_id'); + const agentId = nativeText(native, 'agent_id'); + if (root === undefined || agentId !== undefined) return { reason: 'no-shared-runtime', state: 'unavailable' }; + const generation = target === 'codex' ? nativeText(native, 'turn_id') : nativeText(native, 'prompt_id'); + return { + source: 'receipt', + state: 'available', + value: { + conversation: root, + depth: 0, + ...(generation === undefined ? {} : { generation }), + resolution: 'native', + root, + }, + }; +}; + +/** Lowers one native event receipt into the request provenance shared by replay and invocation surfaces. */ +export const nativeEventRequestContext = (input: Readonly<{ + readonly event: string; + readonly hostContractRevision: string; + readonly native: JsonObject; + readonly routeId: string; + readonly target: string; +}>): RequestContextProvenance => { + const sessionId = nativeText(input.native, 'session_id') ?? nativeText(input.native, 'conversation_id'); + const workspaceRoots = input.native.workspace_roots; + const firstWorkspaceRoot = Array.isArray(workspaceRoots) + && typeof workspaceRoots[0] === 'string' + && workspaceRoots[0].trim() !== '' + ? workspaceRoots[0] + : undefined; + const workspaceRoot = nativeText(input.native, 'cwd') ?? firstWorkspaceRoot; + return deepFreeze({ + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { source: 'receipt', state: 'available', value: { name: input.target } }, + invocation: { + hostContractRevision: input.hostContractRevision, + kind: 'event', + operationId: input.routeId, + surface: input.event, + }, + lineage: nativeEventLineage(input.native, input.target), + session: sessionId === undefined + ? { reason: 'not-provided', state: 'unavailable' } + : { source: 'receipt', state: 'available', value: { sessionId } }, + workspace: workspaceRoot === undefined + ? { reason: 'not-provided', state: 'unavailable' } + : { source: 'receipt', state: 'available', value: { root: workspaceRoot } }, + }); +}; diff --git a/packages/agent-bundle/src/dev/trace/trace-entry.ts b/packages/agent-bundle/src/dev/trace/trace-entry.ts new file mode 100644 index 000000000..d18f4af11 --- /dev/null +++ b/packages/agent-bundle/src/dev/trace/trace-entry.ts @@ -0,0 +1,88 @@ +import type { JsonValue } from '../../core/strict-json.ts'; + +export const traceSources = Object.freeze([ + /** `RouteInvocation` lifecycle from `/api/routes/invocations`. */ + 'invocation', + /** `EventTraceEvent` from the execution kernel (`events/trace.ts`) observed inside a render. */ + 'kernel', + /** JSON-RPC frames, progress, and logging on a Workbench-owned MCP session. */ + 'mcp', + /** A host-invoked hook or event route observed against the dev plugin. */ + 'hook', + /** A dev log record that carries a correlation key. */ + 'log', + /** Build, contract-gate, and host-attach diagnostics. */ + 'diagnostic', +] as const); + +export type TraceSource = (typeof traceSources)[number]; + +export type TraceStatus = 'ok' | 'error' | 'running'; + +/** + * Every key a publisher can know. Entries join on any shared key; the + * Workbench groups by `conversationId` → `sessionId` → `invocationId` / + * `executionId` and falls back to `correlationId`. + */ +export interface TraceCorrelation { + /** Browser-minted id the route workspace attaches to a run (`RouteInvocationRequest.correlationId`). */ + readonly correlationId?: string; + readonly conversationId?: string; + readonly epochId?: string; + /** Kernel execution id (`EventTraceExecution.executionId`). */ + readonly executionId?: string; + /** Compiled target name (`claude`, `codex`, `cursor`, `portable`). */ + readonly host?: string; + /** `RouteInvocation.id` (`inv_…`). */ + readonly invocationId?: string; + /** JSON-RPC `id` of the MCP request this entry belongs to. */ + readonly mcpRequestId?: string; + readonly mcpSessionId?: string; + readonly requestId?: string; + /** Compiled route id (`tool:/`, `event:tool/before`, …). */ + readonly routeId?: string; + readonly sessionId?: string; +} + +export interface TraceEntryInput { + readonly correlation: TraceCorrelation; + /** Slim, already-safe details (no absolute paths, no credentials); bounded by the hub. */ + readonly details?: JsonValue; + readonly durationMs?: number; + /** Workbench path that opens the full record, e.g. `/routes/mcp/curator/tool/search?invocation=inv_1`. */ + readonly href?: string; + /** Dotted, publisher-owned kind: `invocation.completed`, `kernel.render.finish`, `mcp.request`, … */ + readonly kind: string; + readonly occurredAt?: string; + readonly source: TraceSource; + readonly status?: TraceStatus; + /** One line, ≤ 240 characters. */ + readonly summary: string; +} + +export interface TraceEntry extends TraceEntryInput { + readonly id: string; + readonly occurredAt: string; + readonly sequence: number; +} + +export interface TraceReplayGap { + readonly droppedCount: number; + readonly firstAvailableSequence: number; + readonly requestedAfterSequence: number; + readonly type: 'trace.gap'; +} + +export type TraceMessage = TraceEntry | TraceReplayGap; + +export interface TraceReplay { + readonly entries: readonly TraceEntry[]; + readonly gap?: TraceReplayGap; + readonly latestSequence: number; +} + +export const isTraceSource = (value: unknown): value is TraceSource => + typeof value === 'string' && (traceSources as readonly string[]).includes(value); + +export const isTraceReplayGap = (message: TraceMessage): message is TraceReplayGap => + 'type' in message && message.type === 'trace.gap'; diff --git a/packages/agent-bundle/src/dev/trace/trace-hub.ts b/packages/agent-bundle/src/dev/trace/trace-hub.ts new file mode 100644 index 000000000..8e1976d9e --- /dev/null +++ b/packages/agent-bundle/src/dev/trace/trace-hub.ts @@ -0,0 +1,390 @@ +import { Buffer } from 'node:buffer'; + +import { deepFreeze } from '../../core/freeze.ts'; +import { snapshotStrictJsonValue, type JsonValue } from '../../core/strict-json.ts'; +import { safeDevWireText } from '../logs/dev-log-service.ts'; +import { + isTraceSource, + type TraceEntry, + type TraceEntryInput, + type TraceMessage, + type TraceReplay, +} from './trace-entry.ts'; + +/** + * The publish-only face every producer receives (`trace?: TracePublisher` in + * its options). Producers never see retention, subscribers, or transport. + */ +export interface TracePublisher { + publish(input: TraceEntryInput): TraceEntry; +} + +export interface TraceSubscribeOptions { + readonly afterSequence?: number; +} + +/** Returning false releases a slow subscriber without holding up the others. */ +export type TraceListener = (message: TraceMessage) => boolean | void; + +export interface TraceSubscription { + close(): void; + readonly closed: boolean; +} + +export interface TraceHubOptions { + readonly encodedHistoryByteLimit?: number; + readonly entryByteLimit?: number; + readonly entryLimit?: number; + readonly now?: () => Date; + readonly projectRoot: string; + readonly subscriberByteLimit?: number; + readonly subscriberEntryLimit?: number; +} + +export type TraceHubErrorCode = 'TRACE_CURSOR_AHEAD' | 'TRACE_CURSOR_INVALID' | 'TRACE_HUB_CLOSED'; + +export class TraceHubError extends Error { + readonly code: TraceHubErrorCode; + + constructor(code: TraceHubErrorCode, message: string) { + super(message); + this.name = 'TraceHubError'; + this.code = code; + } +} + +interface Subscription { + closed: boolean; + lastDeliveredSequence: number; + listener: TraceListener; + pending: TraceMessage[]; + pendingBytes: number; + replaying: boolean; +} + +const defaultEncodedHistoryByteLimit = 2 * 1024 * 1024; +const defaultEntryByteLimit = 16 * 1024; +const defaultEntryLimit = 4_096; +const defaultSubscriberByteLimit = 256 * 1024; +const defaultSubscriberEntryLimit = 128; +const minimumEntryByteLimit = 256; +const maxSummaryLength = 240; +const unavailable = '[UNAVAILABLE]'; +const encodedSizes = new WeakMap(); + +const byteLength = (value: object): number => { + const cached = encodedSizes.get(value); + if (cached !== undefined) return cached; + const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8'); + encodedSizes.set(value, bytes); + return bytes; +}; + +const positiveInteger = (value: number, label: string): number => { + if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${label} must be a positive safe integer.`); + return value; +}; + +const dropControlCharacters = (value: string): string => { + let sanitized = ''; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code > 0x1f && (code < 0x7f || code > 0x9f)) sanitized += value[index]; + } + return sanitized; +}; + +const sanitizeText = (value: string, projectRoot: string): string => + safeDevWireText(dropControlCharacters(value), projectRoot); + +const sanitizeDetails = (value: JsonValue, projectRoot: string): JsonValue => { + if (typeof value === 'string') return sanitizeText(value, projectRoot); + if (value === null || typeof value === 'boolean' || typeof value === 'number') return value; + if (Array.isArray(value)) return Object.freeze(value.map((entry) => sanitizeDetails(entry, projectRoot))); + const entries: Array = []; + const keys = new Set(); + for (const [key, entry] of Object.entries(value)) { + const sanitizedKey = sanitizeText(key, projectRoot); + if (keys.has(sanitizedKey)) throw new TypeError('Trace detail keys must remain unique after sanitization.'); + keys.add(sanitizedKey); + entries.push([sanitizedKey, sanitizeDetails(entry, projectRoot)]); + } + return Object.freeze(Object.fromEntries(entries)); +}; + +/** + * Bounded in-memory trace with cursor replay, shared by every publisher of a + * dev server and read by `GET /api/trace` and `GET /api/trace/stream`. + * Entries are deep-frozen on publish and evicted oldest-first past + * `entryLimit`; a replay that starts before the retained window reports a gap. + */ +export class TraceHub implements TracePublisher { + readonly #encodedHistoryByteLimit: number; + readonly #entries: TraceEntry[] = []; + readonly #entryByteLimit: number; + readonly #entryLimit: number; + readonly #now: () => Date; + readonly #projectRoot: string; + readonly #subscriberByteLimit: number; + readonly #subscriberEntryLimit: number; + readonly #subscriptions = new Set(); + readonly #undelivered: TraceEntry[] = []; + #closed = false; + #delivering: Subscription | undefined; + #dispatching = false; + #droppedThroughSequence = 0; + #historyBytes = 0; + #sequence = 0; + #undeliveredBytes = 0; + #undeliveredOverflowed = false; + + constructor(options: TraceHubOptions) { + this.#encodedHistoryByteLimit = positiveInteger( + options.encodedHistoryByteLimit ?? defaultEncodedHistoryByteLimit, + 'encodedHistoryByteLimit', + ); + this.#entryByteLimit = positiveInteger(options.entryByteLimit ?? defaultEntryByteLimit, 'entryByteLimit'); + if (this.#entryByteLimit < minimumEntryByteLimit) { + throw new RangeError(`entryByteLimit must be at least ${minimumEntryByteLimit} bytes.`); + } + this.#entryLimit = positiveInteger(options.entryLimit ?? defaultEntryLimit, 'entryLimit'); + this.#now = options.now ?? (() => new Date()); + this.#projectRoot = options.projectRoot; + this.#subscriberByteLimit = positiveInteger( + options.subscriberByteLimit ?? defaultSubscriberByteLimit, + 'subscriberByteLimit', + ); + this.#subscriberEntryLimit = positiveInteger( + options.subscriberEntryLimit ?? defaultSubscriberEntryLimit, + 'subscriberEntryLimit', + ); + } + + get closed(): boolean { + return this.#closed; + } + + get latestSequence(): number { + return this.#sequence; + } + + get subscriptionCount(): number { + return this.#subscriptions.size; + } + + publish(input: TraceEntryInput): TraceEntry { + this.#assertOpen(); + if (!isTraceSource(input.source)) throw new TypeError('Trace source is not recognized.'); + const details = this.#detailsFor(input.details); + let entry = deepFreeze({ + ...input, + ...(details === undefined ? {} : { details }), + id: `trc_${this.#sequence + 1}`, + occurredAt: input.occurredAt ?? this.#now().toISOString(), + sequence: this.#sequence + 1, + summary: this.#summaryFor(input.summary), + }); + if (byteLength(entry) > this.#entryByteLimit && entry.details !== undefined) { + entry = deepFreeze({ ...entry, details: unavailable }); + } + if (byteLength(entry) > this.#entryByteLimit) { + throw new RangeError(`Trace entry exceeds ${this.#entryByteLimit} encoded bytes.`); + } + this.#retain(entry); + return entry; + } + + replay(options: TraceSubscribeOptions = {}): TraceReplay { + this.#assertOpen(); + const after = this.#afterSequence(options.afterSequence ?? 0); + const gap = this.#gapFor(after); + return deepFreeze({ + entries: this.#entries.filter((entry) => entry.sequence > after), + ...(gap === undefined ? {} : { gap }), + latestSequence: this.#sequence, + }); + } + + /** Replays the retained window after `afterSequence`, then delivers live entries in order. */ + subscribe(listener: TraceListener, options: TraceSubscribeOptions = {}): TraceSubscription { + this.#assertOpen(); + if (typeof listener !== 'function') throw new TypeError('A trace listener is required.'); + const afterSequence = this.#afterSequence(options.afterSequence ?? 0); + const boundary = this.#sequence; + const gap = this.#gapFor(afterSequence); + const replay = this.#entries.filter((entry) => entry.sequence > afterSequence && entry.sequence <= boundary); + const initial = Object.freeze([...(gap === undefined ? [] : [gap]), ...replay]); + const subscription: Subscription = { + closed: false, + lastDeliveredSequence: afterSequence, + listener, + pending: [], + pendingBytes: 0, + replaying: true, + }; + this.#subscriptions.add(subscription); + for (const message of initial) this.#enqueueReplay(subscription, message); + while (!subscription.closed && subscription.pending.length > 0) { + const message = subscription.pending.shift(); + if (message !== undefined) { + subscription.pendingBytes -= byteLength(message); + this.#deliver(subscription, message); + } + } + subscription.replaying = false; + return { + close: () => this.#removeSubscription(subscription), + get closed() { + return subscription.closed; + }, + }; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + for (const subscription of this.#subscriptions) this.#removeSubscription(subscription); + this.#undelivered.length = 0; + this.#undeliveredBytes = 0; + } + + #deliver(subscription: Subscription, message: TraceMessage): void { + if (subscription.closed) return; + if ('sequence' in message) { + if (message.sequence <= subscription.lastDeliveredSequence) return; + subscription.lastDeliveredSequence = message.sequence; + } + const previous = this.#delivering; + this.#delivering = subscription; + try { + if (subscription.listener(message) === false) this.#removeSubscription(subscription); + } catch { + this.#removeSubscription(subscription); + } finally { + this.#delivering = previous; + } + } + + #afterSequence(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TraceHubError('TRACE_CURSOR_INVALID', 'Trace cursor must be a non-negative safe integer.'); + } + if (value > this.#sequence) { + throw new TraceHubError('TRACE_CURSOR_AHEAD', `Trace cursor ${value} is ahead of the latest sequence ${this.#sequence}.`); + } + return value; + } + + #assertOpen(): void { + if (this.#closed) throw new TraceHubError('TRACE_HUB_CLOSED', 'The trace hub is closed.'); + } + + #detailsFor(value: JsonValue | undefined): JsonValue | undefined { + if (value === undefined) return undefined; + try { + return sanitizeDetails(snapshotStrictJsonValue(value), this.#projectRoot); + } catch { + return unavailable; + } + } + + #drainLive(): void { + if (this.#dispatching) return; + this.#dispatching = true; + try { + while (this.#undelivered.length > 0 || this.#undeliveredOverflowed) { + while (this.#undelivered.length > 0) { + const entry = this.#undelivered.shift(); + if (entry === undefined) continue; + this.#undeliveredBytes -= byteLength(entry); + for (const subscription of this.#subscriptions) { + if (!subscription.replaying) this.#deliver(subscription, entry); + } + } + if (this.#undeliveredOverflowed) { + this.#undeliveredOverflowed = false; + const recovery = Object.freeze([...this.#entries]); + const subscriptions = Object.freeze([...this.#subscriptions]); + const gaps = new Map(subscriptions.map((subscription) => [ + subscription, + subscription.replaying ? undefined : this.#gapFor(subscription.lastDeliveredSequence), + ])); + for (const subscription of subscriptions) { + if (subscription.replaying || subscription.closed) continue; + const gap = gaps.get(subscription); + if (gap !== undefined) this.#deliver(subscription, gap); + for (const entry of recovery) this.#deliver(subscription, entry); + } + } + } + } finally { + this.#dispatching = false; + } + } + + #enqueueReplay(subscription: Subscription, message: TraceMessage): void { + const bytes = byteLength(message); + if ( + subscription.pending.length >= this.#subscriberEntryLimit + || subscription.pendingBytes + bytes > this.#subscriberByteLimit + ) { + this.#removeSubscription(subscription); + return; + } + subscription.pending.push(message); + subscription.pendingBytes += bytes; + } + + #gapFor(afterSequence: number) { + const firstAvailableSequence = this.#entries[0]?.sequence ?? this.#sequence + 1; + const latestDroppedSequence = Math.max(this.#droppedThroughSequence, firstAvailableSequence - 1); + if (afterSequence >= latestDroppedSequence) return undefined; + return deepFreeze({ + droppedCount: latestDroppedSequence - afterSequence, + firstAvailableSequence, + requestedAfterSequence: afterSequence, + type: 'trace.gap' as const, + }); + } + + #removeSubscription(subscription: Subscription): void { + if (subscription.closed) return; + subscription.closed = true; + subscription.pending.length = 0; + subscription.pendingBytes = 0; + this.#subscriptions.delete(subscription); + } + + #retain(entry: TraceEntry): void { + this.#sequence = entry.sequence; + this.#entries.push(entry); + this.#historyBytes += byteLength(entry); + while (this.#entries.length > this.#entryLimit || this.#historyBytes > this.#encodedHistoryByteLimit) { + const dropped = this.#entries.shift(); + if (dropped === undefined) break; + this.#historyBytes -= byteLength(dropped); + this.#droppedThroughSequence = Math.max(this.#droppedThroughSequence, dropped.sequence); + } + for (const subscription of this.#subscriptions) { + if (subscription.replaying) this.#enqueueReplay(subscription, entry); + } + const bytes = byteLength(entry); + if ( + this.#undelivered.length >= this.#subscriberEntryLimit + || this.#undeliveredBytes + bytes > this.#subscriberByteLimit + ) { + this.#undeliveredOverflowed = true; + if (this.#delivering !== undefined) this.#removeSubscription(this.#delivering); + } else { + this.#undelivered.push(entry); + this.#undeliveredBytes += bytes; + } + this.#drainLive(); + } + + #summaryFor(value: string): string { + if (typeof value !== 'string') throw new TypeError('Trace summary must be a string.'); + const sanitized = sanitizeText(value, this.#projectRoot); + return sanitized.length <= maxSummaryLength ? sanitized : `${sanitized.slice(0, maxSummaryLength - 1)}…`; + } +} diff --git a/packages/agent-bundle/src/dev/trace/trace-project-events.ts b/packages/agent-bundle/src/dev/trace/trace-project-events.ts new file mode 100644 index 000000000..47c56ddf4 --- /dev/null +++ b/packages/agent-bundle/src/dev/trace/trace-project-events.ts @@ -0,0 +1,115 @@ +import type { Diagnostic } from '../../core/diagnostics.ts'; +import type { ProjectEventHub } from '../events.ts'; +import type { ProjectEventMessage } from '../types.ts'; +import type { TracePublisher } from './trace-hub.ts'; + +const diagnosticDetails = (diagnostics: readonly Diagnostic[]) => + Object.freeze(diagnostics.map((diagnostic) => Object.freeze({ + code: diagnostic.code, + message: diagnostic.message, + severity: diagnostic.severity, + }))); + +const publishBuildFailure = ( + trace: TracePublisher, + event: Extract, +): void => { + trace.publish({ + correlation: { + ...(event.epochId === undefined ? {} : { epochId: event.epochId }), + }, + details: { + buildId: event.payload.id, + diagnostics: diagnosticDetails(event.payload.diagnostics), + sourceRevision: event.payload.sourceRevision, + }, + href: '/problems', + kind: 'diagnostic.build.failed', + occurredAt: event.occurredAt, + source: 'diagnostic', + status: 'error', + summary: 'Build failed.', + }); +}; + +const publishContractFailure = ( + trace: TracePublisher, + event: Extract, +): void => { + if (event.payload.state !== 'failed') return; + const failures = event.payload.failures.length === 0 ? [undefined] : event.payload.failures; + for (const failure of failures) { + trace.publish({ + correlation: { + epochId: event.epochId, + ...(failure === undefined ? {} : { routeId: failure.routeId }), + }, + details: { + ...(failure === undefined ? {} : { checks: failure.checks }), + diagnostics: diagnosticDetails(event.payload.diagnostics), + }, + href: '/problems', + kind: 'diagnostic.contract.failed', + occurredAt: event.occurredAt, + source: 'diagnostic', + status: 'error', + summary: event.payload.summary, + }); + } +}; + +const publishHostSyncFailure = ( + trace: TracePublisher, + event: Extract, +): void => { + if (event.payload.state !== 'failed') return; + trace.publish({ + correlation: { + epochId: event.epochId, + host: event.payload.host, + }, + details: { diagnostics: diagnosticDetails(event.payload.diagnostics) }, + href: '/problems', + kind: 'diagnostic.host.sync', + occurredAt: event.occurredAt, + source: 'diagnostic', + status: 'error', + summary: `${event.payload.host} host sync failed.`, + }); +}; + +const receive = (trace: TracePublisher, event: ProjectEventMessage): void => { + switch (event.type) { + case 'build.failed': + publishBuildFailure(trace, event); + break; + case 'dev.contract.status': + publishContractFailure(trace, event); + break; + case 'dev.host.sync': + publishHostSyncFailure(trace, event); + break; + case 'route.invocation': + case 'artifact.available': + case 'artifact.status': + case 'build.started': + case 'invalidation': + case 'replay.gap': + case 'runtime.event': + case 'source.changed': + case 'source.status': + break; + default: { + const exhausted: never = event; + throw new Error(`Unhandled project event: ${String(exhausted)}`); + } + } +}; + +export const attachProjectEventTrace = ( + trace: TracePublisher, + projectEvents: ProjectEventHub, +): (() => void) => { + const subscription = projectEvents.subscribe((event) => receive(trace, event)); + return () => subscription.unsubscribe(); +}; diff --git a/packages/agent-bundle/src/dev/trace/trace-routes.ts b/packages/agent-bundle/src/dev/trace/trace-routes.ts new file mode 100644 index 000000000..b3507a8bb --- /dev/null +++ b/packages/agent-bundle/src/dev/trace/trace-routes.ts @@ -0,0 +1,186 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { + diagnostic, + rawPathname, + requestError, + responseDiagnostic, + responseJson, + type RequestDiagnostic, +} from '../http.ts'; +import { createBackpressuredWriter, encodedNdjsonFrame, writeKeepAliveStreamHead } from '../route-streams.ts'; +import { + TraceHubError, + type TraceHub, + type TraceSubscription, +} from './trace-hub.ts'; +import type { TraceMessage } from './trace-entry.ts'; + +const streamQueueByteLimit = 256 * 1024; +const streamQueueEntryLimit = 128; + +type Route = 'replay' | 'stream'; + +export interface TraceRoutesOptions { + readonly authorize: (request: IncomingMessage) => void; + readonly hub?: TraceHub; +} + +const route = (requestTarget: string | undefined): Route | undefined => { + const pathname = rawPathname(requestTarget); + if (pathname === '/api/trace') return 'replay'; + if (pathname === '/api/trace/stream') return 'stream'; + return undefined; +}; + +const cursor = (requestTarget: string | undefined): number => { + const query = new URL(requestTarget ?? '/', 'http://localhost').searchParams; + if ([...query.keys()].some((key) => key !== 'after') || query.getAll('after').length > 1) { + throw requestError(diagnostic('AB8240', 'Trace cursor is not valid.', 400)); + } + const value = query.get('after'); + if (value === null) return 0; + if (!/^(0|[1-9]\d*)$/u.test(value)) { + throw requestError(diagnostic('AB8240', 'Trace cursor is not valid.', 400)); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw requestError(diagnostic('AB8240', 'Trace cursor is not valid.', 400)); + } + return parsed; +}; + +const mappedHubError = (error: unknown): RequestDiagnostic | undefined => { + if (!(error instanceof TraceHubError)) return undefined; + switch (error.code) { + case 'TRACE_CURSOR_INVALID': + return diagnostic('AB8240', 'Trace cursor is not valid.', 400); + case 'TRACE_CURSOR_AHEAD': + return diagnostic('AB8241', 'Trace cursor is ahead of retained history.', 409); + case 'TRACE_HUB_CLOSED': + return diagnostic('AB8242', 'Trace routes are not available.', 503); + default: { + const exhausted: never = error.code; + throw new Error(`Unhandled TraceHub error code: ${String(exhausted)}`); + } + } +}; + +/** Authenticated replay and backpressured NDJSON transport for the unified trace. */ +export class TraceRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #closeStreams = new Set<() => Promise>(); + readonly #hub: TraceHub | undefined; + #closePromise: Promise | undefined; + + constructor(options: TraceRoutesOptions) { + this.#authorize = options.authorize; + this.#hub = options.hub; + } + + close(): Promise { + if (this.#closePromise !== undefined) return this.#closePromise; + this.#closePromise = Promise.resolve().then(async () => { + const results = await Promise.allSettled([...this.#closeStreams].map(async (close) => close())); + this.#closeStreams.clear(); + const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failures.length > 0) { + throw new AggregateError(failures.map((failure) => failure.reason), 'Trace streams could not close.'); + } + }); + return this.#closePromise; + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const parsed = route(request.url); + if (parsed === undefined) return false; + this.#authorize(request); + if (this.#closePromise !== undefined) { + throw requestError(diagnostic('AB8242', 'Trace routes are not available.', 503)); + } + const hub = this.#hub; + if (hub === undefined) throw requestError(diagnostic('AB8242', 'Trace routes are not available.', 404)); + if ((request.method ?? 'GET') !== 'GET') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } + try { + const afterSequence = cursor(request.url); + if (parsed === 'replay') { + responseJson(response, hub.replay({ afterSequence }), { destroyIfEnded: true }); + } else { + this.#stream(hub, afterSequence, response); + } + } catch (error) { + const mapped = mappedHubError(error); + if (mapped !== undefined) throw requestError(mapped); + if (error instanceof Error && 'status' in error) throw error; + throw requestError(diagnostic('AB8242', 'Trace routes are not available.', 503)); + } + return true; + } + + #stream(hub: TraceHub, afterSequence: number, response: ServerResponse): void { + hub.replay({ afterSequence }); + const writer = createBackpressuredWriter(response, { + byteLimit: streamQueueByteLimit, + recordLimit: streamQueueEntryLimit, + }); + const stream = { subscription: undefined as TraceSubscription | undefined }; + let closePromise: Promise | undefined; + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + writer.markClosed(); + stream.subscription?.close(); + this.#closeStreams.delete(close); + response.off('close', closeFromPeer); + closePromise = new Promise((resolvePromise, rejectPromise) => { + if (response.destroyed || response.writableEnded) { + resolvePromise(); + return; + } + let settled = false; + const settle = (error?: Error): void => { + if (settled) return; + settled = true; + response.off('finish', onFinish); + response.off('close', onClose); + response.off('error', onError); + if (error === undefined) resolvePromise(); + else rejectPromise(error); + }; + const onFinish = (): void => settle(); + const onClose = (): void => settle(); + const onError = (error: Error): void => settle(error); + response.once('finish', onFinish); + response.once('close', onClose); + response.once('error', onError); + try { + response.end(); + } catch (error) { + settle(error instanceof Error ? error : new Error('Trace stream could not close.')); + } + }); + return closePromise; + }; + const closeFromPeer = (): void => { void close(); }; + const closeSlow = (): boolean => { + void close(); + response.destroy(); + return false; + }; + const deliver = (message: TraceMessage): boolean => { + const result = writer.enqueue(encodedNdjsonFrame(message)); + if (result === 'overflow') return closeSlow(); + return result !== 'closed'; + }; + this.#closeStreams.add(close); + response.once('close', closeFromPeer); + writeKeepAliveStreamHead(response, { + cacheControl: 'no-cache', + contentType: 'application/x-ndjson; charset=utf-8', + }); + stream.subscription = hub.subscribe(deliver, { afterSequence }); + if (writer.closed || stream.subscription.closed) void close(); + } +} diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 47ec8e761..2029210e0 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import type { InstallHost } from '../install/install.ts'; +import { HookService } from '../services/hook-service.ts'; import { AgentApi } from './agent-api.ts'; import { ArtifactInspectionService } from './artifacts/artifact-inspection-service.ts'; import { DevCoordinator } from './coordinator.ts'; @@ -15,6 +16,7 @@ import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogge import { EpochStore, EpochStoreError } from './epoch-store.ts'; import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; +import { attachHookReceipts } from './hooks/hook-receipt-endpoint.ts'; import { createInspectorLauncher } from './inspector-launcher.ts'; import { HookPlaygroundService } from './playground/hook-playground-service.ts'; import { DevHostInstallManager } from './host-install-manager.ts'; @@ -79,6 +81,8 @@ import { } from './runtime-provider.ts'; import { ScriptPlaygroundService } from './playground/script-playground-service.ts'; import { SkillDocumentService } from './skill-document-service.ts'; +import { TraceHub } from './trace/trace-hub.ts'; +import { attachProjectEventTrace } from './trace/trace-project-events.ts'; import { createWorkbenchAssetSource } from './workbench-assets.ts'; import type { Invalidation, ProjectStatus } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -444,6 +448,7 @@ export interface DevServerRuntimeLifecycleResources { export interface DevServerLifecycleOptions { readonly coordinator: Closeable; readonly detachProjectLogs?: () => void; + readonly detachProjectTrace?: () => void; readonly epochAdoption?: Closeable; readonly hostInstalls?: Closeable; readonly logs?: DevLogService; @@ -452,12 +457,14 @@ export interface DevServerLifecycleOptions { readonly mcpSessions: Closeable; readonly playground?: Closeable; readonly runtimeResources?: DevServerRuntimeLifecycleResources; + readonly trace?: TraceHub; } /** Closes persistent MCP state alongside the coordinator, preserving all cleanup failures. */ export const closeDevServerLifecycle = async ({ coordinator, detachProjectLogs, + detachProjectTrace, epochAdoption, hostInstalls, inspector, @@ -466,6 +473,7 @@ export const closeDevServerLifecycle = async ({ mcpSessions, playground, runtimeResources, + trace, }: DevServerLifecycleOptions): Promise => { // ForegroundServer owns the Agent API admission gate. This lifecycle owns // only the shared services that are released after foreground routing ends. @@ -498,6 +506,8 @@ export const closeDevServerLifecycle = async ({ } try { detachProjectLogs?.(); } catch { /* The subscription is observability-only and cannot hold shutdown. */ } + try { detachProjectTrace?.(); } + catch { /* The subscription is observability-only and cannot hold shutdown. */ } logs?.log({ details: { failures: failures.length }, kind: 'dev.shutdown.completed', @@ -506,6 +516,7 @@ export const closeDevServerLifecycle = async ({ summary: failures.length === 0 ? 'Development workbench shutdown completed.' : 'Development workbench shutdown completed with failures.', }); if (logs !== undefined) await closeResource('logs', logs); + trace?.close(); if (failures.length > 0) throw new DevServerLifecycleCloseError(failures); }; @@ -519,8 +530,12 @@ const withMcpSessionLifecycle = ( playground: Closeable, logs: DevLogService, detachProjectLogs: () => void, + trace: TraceHub, + detachProjectTrace: () => void, inspector: Closeable, epochAdoption: EpochAdoptionPolicy, + hookReceipts: ReturnType, + publishHookReceiptUrl: (url: string) => void, hostInstalls?: DevHostInstallManager, ): ForegroundCoordinator => Object.freeze({ close: () => { @@ -528,6 +543,7 @@ const withMcpSessionLifecycle = ( return closeDevServerLifecycle({ coordinator, detachProjectLogs, + detachProjectTrace, epochAdoption, hostInstalls, inspector, @@ -536,9 +552,14 @@ const withMcpSessionLifecycle = ( mcpSessions, playground, runtimeResources: { clientSurfaces, runtime }, + trace, }); }, - publishServerUrl: (url: string) => coordinator.publishServerUrl(url), + publishServerUrl: async (url: string) => { + await coordinator.publishServerUrl(url); + publishHookReceiptUrl(url); + await hookReceipts.publishEndpoint(url); + }, rebuild: (invalidation: Invalidation) => coordinator.rebuild(invalidation), start: async () => { hostInstalls?.start(); @@ -599,8 +620,12 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun const openBrowser = options.openBrowser ?? openInBrowser; const eventHub = new ProjectEventHub(); const epochStore = new EpochStore({ projectRoot: root }); - const logs = new DevLogService({ projectRoot: root }); + const traceHub = new TraceHub({ projectRoot: root }); + const hookReceipts = attachHookReceipts({ projectRoot: root, trace: traceHub }); + let hookReceiptUrl: string | undefined; + const logs = new DevLogService({ projectRoot: root, trace: traceHub }); const detachProjectLogs = attachProjectEventLogs(logs, eventHub); + const detachProjectTrace = attachProjectEventTrace(traceHub, eventHub); const projectService = new ProjectService({ includeDevRuntime: true, logger: createProjectDevLogger(logs), @@ -776,6 +801,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun projectRoot: root, registry, platformRuntime, + trace: traceHub, traceSink: createMcpDevLogTraceSink(logs), }); const epochAdoption = new EpochAdoptionPolicy({ @@ -806,7 +832,16 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun platformRuntime, }); const hostMcp = new HostMcpRoutes({ adoption: epochAdoption, epochStore, eventHub, mcpSessions }); - const hookPlayground = new HookPlaygroundService({ epochStore, logger: logs, registry, platformRuntime }); + const hookPlayground = new HookPlaygroundService({ + epochStore, + hookService: new HookService({ + environment: () => hookReceiptUrl === undefined ? {} : hookReceipts.environment(hookReceiptUrl), + registry, + }), + logger: logs, + registry, + platformRuntime, + }); const preparedBundle = () => { const prepared = latestValidPreparedProject; if (prepared?.model === undefined) return undefined; @@ -849,7 +884,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun const artifacts = new ArtifactInspectionService(epochStore, registry); const evals = new EvalService({ logger: logs, projectRoot: root, registry, platformRuntime }); // The resolved root is the project's stable identity: a store copied elsewhere must not reopen. - const trace = new PlaygroundService({ + const playgroundTrace = new PlaygroundService({ logger: logs, projectId: root, projectRoot: root, @@ -864,7 +899,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun native: new NativePlaygroundService({ projectRoot: root, platformRuntime }), scripts: scriptPlayground, skillDocuments, - trace, + trace: playgroundTrace, }); const inspector = createInspectorLauncher({ projectRoot: root }); // The manifest is a projection of the prepared project's own compiler pass; @@ -961,6 +996,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }, registry, scripts: scriptPlayground, + trace: traceHub, }); const agentApi = agentApiEnabled ? new AgentApi({ @@ -994,8 +1030,12 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun playground, logs, detachProjectLogs, + traceHub, + detachProjectTrace, inspector, epochAdoption, + hookReceipts, + (url) => { hookReceiptUrl = url; }, hostInstalls, ), evals, @@ -1003,6 +1043,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun epochs: epochStore, eventHub, hookPlayground, + hookReceipts: hookReceipts.routes, hostDiscovery, hostMcp, inspector, @@ -1018,6 +1059,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun routeInvocations, ...(runtime === undefined ? {} : { runtime }), skillDocuments, + trace: traceHub, webHostLaunch: { projectRoot: root, registry }, ...(options.workbenchDevOrigins === undefined || options.workbenchDevOrigins.length === 0 ? {} @@ -1045,7 +1087,11 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun void mcpApps?.prepareClose().catch(() => undefined); clientSurfaces.beginClose(); try { - await foreground.close(); + try { + await hookReceipts.close(); + } finally { + await foreground.close(); + } } finally { // Probe transports whose teardown outlived their response boundary own // their plugin-data removal; joining them here (bounded by the probe's diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index 09844fe1f..f7a47f86b 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -40,3 +40,6 @@ export { type EventTraceRenderStart, type EventTraceRuntime, } from './trace.ts'; +export { + openEventTraceReceipt, +} from './trace-receipt.ts'; diff --git a/packages/agent-bundle/src/events/trace-receipt.ts b/packages/agent-bundle/src/events/trace-receipt.ts new file mode 100644 index 000000000..ee08e4551 --- /dev/null +++ b/packages/agent-bundle/src/events/trace-receipt.ts @@ -0,0 +1,248 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { AgentLineage, Observed } from '@agent-bundle/runtime'; + +import type { RequestLineageProvenance, RequestProvenanceAxis } from '../contracts/request-provenance.ts'; +import { isLoopbackHttpOrigin } from '../core/loopback-origin.ts'; +import type { EventTraceEvent, EventTraceExecution, EventTraceObserver } from './trace.ts'; + +/** + * Carries a host hook's kernel events to an attached development server. + * Receipts contain execution and correlation metadata, never payloads, + * environment values, or filesystem paths. + */ + +export const EVENT_TRACE_RECEIPT_VERSION = 1 as const; +export const EVENT_TRACE_RECEIPT_PATH = '/api/trace/receipts'; +export const EVENT_TRACE_RECEIPT_MAX_BYTES = 16 * 1024; +export const EVENT_TRACE_RECEIPT_URL_ENV = 'AGENT_BUNDLE_DEV_TRACE_URL'; +export const EVENT_TRACE_RECEIPT_TOKEN_ENV = 'AGENT_BUNDLE_DEV_TRACE_TOKEN'; +export const EVENT_TRACE_RECEIPT_ENDPOINT_FILE = 'hook-receipts.json'; +/** + * The dev host installer's marker at the installed bundle root + * (`DEV_INSTALL_MARKER` in `dev/host-install-manager.ts`; spelled here so the + * wrapper bundle does not pull the installer in — `hook-receipts.test.ts` + * pins the two equal). + */ +export const DEV_INSTALL_MARKER_FILE = '.agent-bundle-dev.json'; +export const EVENT_TRACE_RECEIPT_TIMEOUT_MS = 750; + +export interface EventTraceReceiptEndpoint { + readonly token: string; + readonly url: string; +} + +export interface EventTraceReceiptIdentity { + readonly conversationId?: string; + readonly requestId?: string; + readonly sessionId?: string; +} + +type DistributiveOmit = Value extends unknown ? Omit : never; + +export type EventTraceReceiptEvent = DistributiveOmit; + +export interface EventTraceReceipt { + readonly events: readonly EventTraceReceiptEvent[]; + readonly execution: EventTraceExecution; + readonly identity: EventTraceReceiptIdentity; + readonly lineage: RequestProvenanceAxis; + /** Wall-clock instant of `events[0]`; each event's `at` is the tracer's monotonic clock, so `at - events[0].at` offsets from here. */ + readonly startedAt: string; + readonly version: typeof EVENT_TRACE_RECEIPT_VERSION; +} + +export interface OpenEventTraceReceiptOptions { + /** `import.meta.url` of the wrapper; the dev install marker is looked up beside its directory. */ + readonly anchor: string; + readonly env: Readonly; + readonly execution: EventTraceExecution; + readonly fetch?: typeof fetch; +} + +/** + * One execution's receipt in the making. `observer` is handed to the tracer; + * `identity` and `lineage` project what the wrapper learned; `send` posts + * once and never throws — a hook's exit code belongs to the route, not to + * the Workbench. + */ +export interface EventTraceReceiptRecorder { + readonly endpoint: EventTraceReceiptEndpoint; + readonly observer: EventTraceObserver; + identity(native: Readonly>): void; + lineage(observed: Observed): void; + send(): Promise; +} + +const nativeString = (native: Readonly>, key: string): string | undefined => { + const value = native[key]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +}; + +/** The host/session/request ids a native payload names, by the host vocabulary in `docs/entry-conventions.md`. */ +export const eventTraceReceiptIdentity = ( + host: string, + native: Readonly>, +): EventTraceReceiptIdentity => { + const sessionId = nativeString(native, 'session_id') ?? nativeString(native, 'conversation_id'); + const conversationId = host === 'cursor' + ? nativeString(native, 'conversation_id') + : nativeString(native, 'agent_id') ?? nativeString(native, 'session_id'); + const requestId = nativeString(native, 'tool_use_id') ?? nativeString(native, 'tool_call_id'); + return Object.freeze({ + ...(conversationId === undefined ? {} : { conversationId }), + ...(requestId === undefined ? {} : { requestId }), + ...(sessionId === undefined ? {} : { sessionId }), + }); +}; + +/** The lineage axis on the wire: the runtime's `Observed` without its live `tree`. */ +export const eventTraceReceiptLineage = ( + observed: Observed, +): RequestProvenanceAxis => { + if (observed.state === 'unavailable') return Object.freeze({ reason: observed.reason, state: 'unavailable' }); + const { conversation, depth, generation, parent, resolution, root, subagent } = observed.value; + return Object.freeze({ + source: observed.source, + state: 'available', + value: Object.freeze({ + conversation, + depth, + ...(generation === undefined ? {} : { generation }), + ...(parent === undefined ? {} : { parent }), + resolution, + root, + ...(subagent === undefined + ? {} + : { + subagent: Object.freeze({ + id: subagent.id, + ...(subagent.isParallelWorker === undefined ? {} : { isParallelWorker: subagent.isParallelWorker }), + ...(subagent.toolCallId === undefined ? {} : { toolCallId: subagent.toolCallId }), + ...(subagent.type === undefined ? {} : { type: subagent.type }), + }), + }), + }), + }); +}; + +const receiptEndpoint = (url: unknown, token: unknown): EventTraceReceiptEndpoint | undefined => + isLoopbackHttpOrigin(url) && typeof token === 'string' && token.trim() !== '' + ? Object.freeze({ token, url }) + : undefined; + +const readJsonRecord = async (path: string): Promise> | undefined> => { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, 'utf8')); + } catch { + return undefined; + } + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Readonly>) + : undefined; +}; + +/** The endpoint record a dev server publishes for its attached hosts. */ +export const eventTraceReceiptEndpointPath = (projectRoot: string): string => + join(projectRoot, '.agent-bundle', EVENT_TRACE_RECEIPT_ENDPOINT_FILE); + +/** + * Finds the dev server a hook execution should report to, or `undefined` in + * production. Environment first (a dev-server-spawned simulation), then the + * dev install marker beside the wrapper's directory, whose `projectRoot` + * names the project whose running dev server published its endpoint record. + */ +export const resolveEventTraceReceiptEndpoint = async ( + options: Pick, +): Promise => { + const fromEnv = receiptEndpoint(options.env[EVENT_TRACE_RECEIPT_URL_ENV], options.env[EVENT_TRACE_RECEIPT_TOKEN_ENV]); + if (fromEnv !== undefined) return fromEnv; + let markerPath: string; + try { + markerPath = fileURLToPath(new URL(`../${DEV_INSTALL_MARKER_FILE}`, options.anchor)); + } catch { + return undefined; + } + const marker = await readJsonRecord(markerPath); + if (marker === undefined || typeof marker.projectRoot !== 'string' || marker.projectRoot === '') return undefined; + const record = await readJsonRecord(eventTraceReceiptEndpointPath(marker.projectRoot)); + if (record === undefined || typeof record.pid !== 'number' || !Number.isSafeInteger(record.pid)) return undefined; + if (record.pid !== process.pid) { + try { + process.kill(record.pid, 0); + } catch { + return undefined; + } + } + return receiptEndpoint(record.url, record.token); +}; + +const withoutExecution = (event: EventTraceEvent): EventTraceReceiptEvent => { + const { execution: _execution, ...rest } = event; + return rest; +}; + +/** + * Opens the receipt for one execution: resolves the endpoint and, when there + * is one, returns the recorder whose `observer` the tracer feeds. `undefined` + * means no dev server is listening and the wrapper traces nothing. + */ +export const openEventTraceReceipt = async ( + options: OpenEventTraceReceiptOptions, +): Promise => { + const endpoint = await resolveEventTraceReceiptEndpoint(options); + if (endpoint === undefined) return undefined; + const post = options.fetch ?? fetch; + const events: EventTraceReceiptEvent[] = []; + let startedAt: string | undefined; + let identity: EventTraceReceiptIdentity = Object.freeze({}); + let lineage: RequestProvenanceAxis = Object.freeze({ + reason: 'not-provided', + state: 'unavailable', + }); + let sent = false; + const recorder: EventTraceReceiptRecorder = { + endpoint, + identity: (native) => { + identity = eventTraceReceiptIdentity(options.execution.host, native); + }, + lineage: (observed) => { + lineage = eventTraceReceiptLineage(observed); + }, + observer: (event) => { + startedAt ??= new Date().toISOString(); + events.push(withoutExecution(event)); + }, + send: async () => { + if (sent || startedAt === undefined) return; + sent = true; + const receipt: EventTraceReceipt = { + events, + execution: options.execution, + identity, + lineage, + startedAt, + version: EVENT_TRACE_RECEIPT_VERSION, + }; + const body = JSON.stringify(receipt); + if (Buffer.byteLength(body, 'utf8') > EVENT_TRACE_RECEIPT_MAX_BYTES) return; + try { + await post(new URL(EVENT_TRACE_RECEIPT_PATH, endpoint.url), { + body, + headers: { + authorization: `Bearer ${endpoint.token}`, + 'content-type': 'application/json', + }, + method: 'POST', + signal: AbortSignal.timeout(EVENT_TRACE_RECEIPT_TIMEOUT_MS), + }); + } catch { + // The Workbench is an observer of the hook, never a participant in its outcome. + } + }, + }; + return Object.freeze(recorder); +}; diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index f3429b250..9bbc45ebc 100644 --- a/packages/agent-bundle/src/events/trace.ts +++ b/packages/agent-bundle/src/events/trace.ts @@ -145,7 +145,7 @@ export const installEventTraceObserver = (observer: EventTraceObserver): (() => export interface EventTracer { /** True once `failure` was recorded; later calls are dropped. */ readonly closed: boolean; - /** False when the tracer was created without an observer: every method is a no-op. */ + /** Whether an explicit or process-local observer is currently available. */ readonly enabled: boolean; readonly execution: EventTraceExecution; preflightStart(): void; @@ -162,7 +162,7 @@ export interface CreateEventTracerOptions { readonly execution: EventTraceExecution; /** Monotonic clock in milliseconds; `performance.now` when absent. */ readonly now?: () => number; - /** Absent means tracing is off for this execution. */ + /** When absent, each emission reads the process-local observer. */ readonly observer?: EventTraceObserver; } @@ -252,36 +252,17 @@ const preflightOutcomeOf = (result: EventPreflightResult): EventTracePreflightOu const durationField = (since: number | undefined, at: number): { readonly durationMs?: number } => since === undefined ? {} : { durationMs: at - since }; -/** A tracer that records nothing and reads no clock; only `closed` flips on `failure`. */ -const disabledTracer = (execution: EventTraceExecution): EventTracer => { - let closed = false; - const noop = (): void => undefined; - return { - get closed() { return closed; }, - enabled: false, - execution, - executeStart: noop, - failure: () => { closed = true; }, - preflightOutcome: noop, - preflightStart: noop, - providersFinish: noop, - providersStart: noop, - renderFinish: noop, - renderStart: noop, - }; -}; - /** - * Creates the emitter for one execution. Without `observer` every method is - * a no-op. With one, each method builds a frozen event, assigns the next - * `sequence`, stamps `at` from `now`, and hands it to the observer inside a - * try/catch: a throwing observer, a throwing clock, or re-entry from inside - * the observer never changes what the caller sees. + * Creates the emitter for one execution. An explicit observer is fixed for + * the tracer's lifetime; otherwise every emission reads the process slot so + * a framework-created tracer can outlive observer installation. With an + * observer, each method builds a frozen event, assigns the next `sequence`, + * stamps `at` from `now`, and hands it to the observer inside a try/catch: a + * throwing observer, clock, or re-entry never changes what the caller sees. */ export const createEventTracer = (options: CreateEventTracerOptions): EventTracer => { const execution = options.execution; - const observer = options.observer ?? eventTraceObserver(); - if (observer === undefined) return disabledTracer(execution); + const explicitObserver = options.observer; const now = options.now ?? (() => performance.now()); let sequence = 0; let closed = false; @@ -296,7 +277,7 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace } }; - const deliver = (event: EventTraceEvent): void => { + const deliver = (observer: EventTraceObserver, event: EventTraceEvent): void => { try { observer(event); } catch { @@ -309,6 +290,11 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace terminal = false, ): void => { if (closed) return; + const observer = explicitObserver ?? eventTraceObserver(); + if (observer === undefined) { + if (terminal) closed = true; + return; + } const at = readClock(); if (at === undefined) return; const traceStartedAt = firstAt; @@ -316,12 +302,12 @@ export const createEventTracer = (options: CreateEventTracerOptions): EventTrace const event = build(at, sequence, traceStartedAt); sequence += 1; if (terminal) closed = true; - deliver(Object.freeze(event)); + deliver(observer, Object.freeze(event)); }; return { get closed() { return closed; }, - enabled: true, + get enabled() { return (explicitObserver ?? eventTraceObserver()) !== undefined; }, execution, executeStart: (runtime) => { emit((at, next) => { diff --git a/packages/agent-bundle/src/services/hook-service.ts b/packages/agent-bundle/src/services/hook-service.ts index 0c20821aa..fbed769f5 100644 --- a/packages/agent-bundle/src/services/hook-service.ts +++ b/packages/agent-bundle/src/services/hook-service.ts @@ -35,6 +35,12 @@ export interface HookSimulationOptions { } export interface HookServiceOptions { + /** + * Extra environment for the wrapper child, read at each simulation: the dev + * server passes its hook receipt endpoint (`HookReceiptAttachment.environment`) + * so a simulated hook lands on the trace like a host-invoked one (#600). + */ + readonly environment?: () => Readonly>; /** Internal test seam; production uses the current host platform. */ readonly platform?: NodeJS.Platform; /** Target contracts that own and validate the artifact. */ @@ -82,6 +88,7 @@ class HookSimulationTerminationError extends YieldableFrameworkError { const runWrapper = async (options: { readonly cwd: string; + readonly environment: Readonly>; readonly input: Record; readonly platform: NodeJS.Platform; readonly signal?: AbortSignal; @@ -108,7 +115,7 @@ const runWrapper = async (options: { const child = spawn(process.execPath, [options.wrapper], { cwd: options.cwd, detached: options.platform !== 'win32', - env: { ...process.env, AGENT_BUNDLE_HOOK_SIMULATION: '1' }, + env: { ...process.env, ...options.environment, AGENT_BUNDLE_HOOK_SIMULATION: '1' }, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; @@ -235,11 +242,13 @@ const runWrapper = async (options: { }); export class HookService { + readonly #environment: () => Readonly>; readonly #platform: NodeJS.Platform; readonly #registry: TargetRegistry; readonly #taskkill: ProcessTreeTaskkill; constructor(options: HookServiceOptions = {}) { + this.#environment = options.environment ?? (() => ({})); this.#platform = options.platform ?? process.platform; this.#registry = options.registry ?? createDefaultRegistry(); this.#taskkill = options.taskkill ?? taskkill; @@ -282,6 +291,7 @@ export class HookService { const wrapper = joinArtifact(artifact, hook.path); return runWrapper({ cwd: artifact, + environment: this.#environment(), input: options.input, platform: this.#platform, ...(options.signal === undefined ? {} : { signal: options.signal }), diff --git a/packages/agent-bundle/tests/dev-log-producers.test.ts b/packages/agent-bundle/tests/dev-log-producers.test.ts index df7da1891..8b35a6288 100644 --- a/packages/agent-bundle/tests/dev-log-producers.test.ts +++ b/packages/agent-bundle/tests/dev-log-producers.test.ts @@ -53,3 +53,42 @@ it('records project service events and derives build, artifact, and diagnostic r expect(records[2]?.context).toEqual({ buildId: 'build-1', diagnosticCode: 'BUILD_FAILED' }); expect(records[3]?.context).toEqual({ epochId: 'epoch-1' }); }); + +it('stamps route invocation records with their trace join keys', () => { + const logs = new DevLogService({ projectRoot: '/work/project' }); + const events = new ProjectEventHub(); + const detach = attachProjectEventLogs(logs, events); + + events.publish({ + payload: { + invocation: { + completedAt: '2026-08-18T12:01:00.000Z', + correlationId: 'correlation-1', + diagnostics: [], + id: 'invocation-1', + input: {}, + kind: 'tool', + manifestDigest: 'manifest-1', + outcome: { kind: 'success' }, + routeId: 'tool:curator/search', + source: 'src/tools/search.tsx', + sourceRevision: 'source-1', + startedAt: '2026-08-18T12:00:00.000Z', + status: 'succeeded', + surface: { kind: 'mcp' }, + timings: [], + }, + }, + type: 'route.invocation', + }); + detach(); + + expect(logs.replay().records).toMatchObject([{ + context: { + correlationId: 'correlation-1', + invocationId: 'invocation-1', + routeId: 'tool:curator/search', + }, + kind: 'route.invocation', + }]); +}); diff --git a/packages/agent-bundle/tests/dev-log-service.test.ts b/packages/agent-bundle/tests/dev-log-service.test.ts index 981ac0633..09ef2e823 100644 --- a/packages/agent-bundle/tests/dev-log-service.test.ts +++ b/packages/agent-bundle/tests/dev-log-service.test.ts @@ -2,7 +2,26 @@ import { Buffer } from 'node:buffer'; import { expect, it } from '@rstest/core'; -import { DevLogService, type DevLogInput } from '../src/dev/logs/dev-log-service.ts'; +import { + DevLogService, + safeDevWireText, + type DevLogInput, + type DevLogServiceOptions, +} from '../src/dev/logs/dev-log-service.ts'; +import { TraceHub } from '../src/dev/trace/trace-hub.ts'; + +it('preserves relative route identities while redacting filesystem paths', () => { + const projectRoot = '/Users/x/project'; + expect(safeDevWireText('MCP tool curator/search_audible · 2.9 s', projectRoot)) + .toBe('MCP tool curator/search_audible · 2.9 s'); + expect(safeDevWireText('event tool/before (claude)', projectRoot)) + .toBe('event tool/before (claude)'); + expect(safeDevWireText('/Users/x/project/src/a.ts', projectRoot)).toBe('/src/a.ts'); + expect(safeDevWireText('/src/a.ts', projectRoot)).toBe('/src/a.ts'); + for (const unsafe of ['file:///x', 'C:\\x', '\\\\server\\share', '~/.ssh/id_rsa', '/etc/passwd']) { + expect(safeDevWireText(unsafe, projectRoot)).toBe('[REDACTED]'); + } +}); it('records detached redacted details and replaces its own project root', () => { const service = new DevLogService({ @@ -38,6 +57,120 @@ it('records detached redacted details and replaces its own project root', () => expect(Object.isFrozen(record.details)).toBe(true); }); +it('publishes warnings, errors, and correlated records to trace without plain info chatter', () => { + const trace = new TraceHub({ projectRoot: '/work/project' }); + const service = new DevLogService({ + projectRoot: '/work/project', + trace, + } as DevLogServiceOptions); + + service.log({ + context: { target: 'codex' }, + kind: 'project.load', + level: 'info', + producer: 'project', + summary: 'Plain project chatter.', + }); + service.log({ + context: { buildId: 'build-1', epochId: 'epoch-1', routeId: 'tool:curator/search', target: 'codex' }, + kind: 'project.prepared', + level: 'info', + producer: 'project', + summary: 'Build and route facets are not request correlation.', + }); + service.log({ + context: { + conversationId: 'conversation-1', + correlationId: 'correlation-1', + executionId: 'execution-1', + invocationId: 'invocation-1', + mcpRequestId: 'mcp-request-1', + mcpSessionId: 'mcp-session-1', + requestId: 'request-1', + sessionId: 'session-1', + }, + kind: 'project.prepared', + level: 'info', + producer: 'project', + summary: 'Correlated project event.', + }); + service.log({ + kind: 'mcp.stderr', + level: 'warning', + producer: 'mcp', + summary: 'Uncorrelated warning.', + }); + service.log({ + context: { + invocationId: 'invocation-1', + mcpRequestId: 'request-1', + routeId: 'tool:curator/search', + }, + kind: 'route.invocation', + level: 'error', + producer: 'project', + summary: 'Route failed.', + }); + service.log({ + kind: 'project.invalid-source', + level: 'error', + producer: 'project', + summary: 'Uncorrelated error.', + }); + + expect(trace.replay().entries).toMatchObject([ + { + correlation: { + conversationId: 'conversation-1', + correlationId: 'correlation-1', + executionId: 'execution-1', + invocationId: 'invocation-1', + mcpRequestId: 'mcp-request-1', + mcpSessionId: 'mcp-session-1', + requestId: 'request-1', + sessionId: 'session-1', + }, + href: '/advanced/logs?sequence=3', + kind: 'log.project.project.prepared', + source: 'log', + summary: 'Correlated project event.', + }, + { + correlation: {}, + href: '/advanced/logs?sequence=4', + kind: 'log.mcp.mcp.stderr', + source: 'log', + summary: 'Uncorrelated warning.', + }, + { + correlation: {}, + href: '/advanced/logs?sequence=6', + kind: 'log.project.project.invalid-source', + source: 'log', + status: 'error', + summary: 'Uncorrelated error.', + }, + ]); +}); + +it('does not republish project event mirrors already lowered by dedicated trace producers', () => { + const trace = new TraceHub({ projectRoot: '/work/project' }); + const service = new DevLogService({ projectRoot: '/work/project', trace }); + const mirrors: readonly DevLogInput[] = [ + { context: { buildId: 'build-1' }, kind: 'build.failed', level: 'error', producer: 'build', summary: 'Build failed.' }, + { context: { epochId: 'epoch-1' }, kind: 'dev.contract.status', level: 'error', producer: 'project', summary: 'Contract failed.' }, + { context: { epochId: 'epoch-1' }, kind: 'dev.host.sync', level: 'error', producer: 'project', summary: 'Host sync failed.' }, + { context: { invocationId: 'inv-1' }, kind: 'route.invocation', level: 'error', producer: 'project', summary: 'Invocation failed.' }, + { context: { diagnosticCode: 'BUILD_FAILED' }, kind: 'build.failed.diagnostic', level: 'error', producer: 'diagnostic', summary: 'Build diagnostic.' }, + { context: { diagnosticCode: 'CONTRACT_FAILED' }, kind: 'dev.contract.status.diagnostic', level: 'error', producer: 'diagnostic', summary: 'Contract diagnostic.' }, + { context: { diagnosticCode: 'HOST_SYNC_FAILED' }, kind: 'dev.host.sync.diagnostic', level: 'error', producer: 'diagnostic', summary: 'Host diagnostic.' }, + ]; + for (const mirror of mirrors) service.log(mirror); + + expect(trace.replay().entries).toEqual([]); + expect(service.replay().records).toHaveLength(mirrors.length); +}); + it('rejects hostile envelopes without breaking the producer', () => { const service = new DevLogService({ projectRoot: '/work/project' }); const hostile = Object.create(null) as { readonly payload?: unknown }; diff --git a/packages/agent-bundle/tests/event-trace.test.ts b/packages/agent-bundle/tests/event-trace.test.ts index 4afde1f0d..5014ffb5d 100644 --- a/packages/agent-bundle/tests/event-trace.test.ts +++ b/packages/agent-bundle/tests/event-trace.test.ts @@ -202,6 +202,21 @@ it('uses the process observer for framework-created tracers and restores it safe expect(createEventTracer({ execution }).enabled).toBe(false); }); +it('observes framework-created tracers when the process observer is installed after creation', () => { + const { events, observer } = collect(); + const tracer = createEventTracer({ execution, now: ticking() }); + expect(tracer.enabled).toBe(false); + + const dispose = installEventTraceObserver(observer); + expect(tracer.enabled).toBe(true); + tracer.preflightStart(); + dispose(); + expect(tracer.enabled).toBe(false); + tracer.preflightOutcome('execute'); + + expect(events.map((event) => event.kind)).toEqual(['preflight.start']); +}); + it('summarizes gate results without carrying the reason text', () => { const { events, observer } = collect(); const tracer = createEventTracer({ execution, now: ticking(), observer }); diff --git a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts new file mode 100644 index 000000000..46d04f9cb --- /dev/null +++ b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts @@ -0,0 +1,220 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; +import { attachHookReceipts } from '../src/dev/hooks/hook-receipt-endpoint.ts'; +import { diagnostic, isRequestDiagnostic, responseDiagnostic } from '../src/dev/http.ts'; +import type { TraceEntry } from '../src/dev/trace/trace-entry.ts'; +import { TraceHub } from '../src/dev/trace/trace-hub.ts'; +import { DEV_INSTALL_MARKER_FILE } from '../src/events/trace-receipt.ts'; + +/** + * #600 PR 2, lane T7: a host-invoked hook against the dev plugin reports a + * receipt to the dev server. The generated Claude hook wrapper is spawned the + * way Claude spawns it — `node hooks/.mjs` with the native payload on + * stdin — and the receipt lands on a `TraceHub` behind the same route class + * the foreground server mounts. + */ + +const cleanups: (() => Promise | void)[] = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +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); +}; + +interface HookRun { + readonly code: number | null; + readonly stderr: string; + readonly stdout: string; +} + +const runHook = async ( + entry: string, + input: Readonly>, + env: Readonly>, +): Promise => new Promise((resolve, reject) => { + const childEnv: NodeJS.ProcessEnv = { ...process.env, ...env, PLUGIN_ROOT: undefined }; + for (const [key, value] of Object.entries(childEnv)) if (value === undefined) delete childEnv[key]; + const child = spawn(process.execPath, [entry], { env: childEnv, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr.on('data', (chunk) => { stderr += String(chunk); }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stderr, stdout })); + child.stdin.end(JSON.stringify(input)); +}); + +const listen = async (hub: TraceHub, projectRoot: string) => { + const attachment = attachHookReceipts({ projectRoot, trace: hub }); + const server: Server = createServer((request, response) => { + void attachment.routes.handle(request, response).then((handled) => { + if (!handled) responseDiagnostic(response, diagnostic('AB8005', 'Not found.', 404)); + }).catch((error: unknown) => { + responseDiagnostic(response, isRequestDiagnostic(error) ? error : diagnostic('AB8007', 'Request could not be completed.', 500)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + cleanups.push(async () => { + await attachment.close(); + await new Promise((resolve) => server.close(() => resolve())); + }); + return { attachment, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +}; + +const nativePreToolUse = (root: string, toolUseId: string): Readonly> => ({ + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-receipt', + tool_input: { command: 'echo never-on-the-trace' }, + tool_name: 'Bash', + tool_use_id: toolUseId, + transcript_path: join(root, 'transcript.jsonl'), +}); + +it('posts a host-invoked hook execution to the dev server as hook.received / hook.completed', { timeout: 90_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hook-receipt-pipe-')); + cleanups.push(() => rm(root, { force: true, recursive: true })); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { '@agent-bundle/runtime': 'workspace:*', react: '19.2.8' }, + name: 'hook-receipt-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'hook-receipt-fixture', version: '1.0.0' }, targets: ['claude'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/events/tool/before.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone', targets: ['claude'] };", + 'export default async function BeforeTool({ native }) {', + " return createElement(Agent.Result, null, createElement(Agent.Context, null, `receipt:${native.tool_name}`));", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/tool/after.tsx', [ + "export const config = { runtime: 'standalone', targets: ['claude'] };", + 'export default async function AfterTool() {', + " throw new Error('after-tool exploded');", + '}', + '', + ].join('\n')), + ]); + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['claude'] }); + const before = compiled.build.compiledHooks.find((hook) => hook.event === 'beforeTool'); + const after = compiled.build.compiledHooks.find((hook) => hook.event === 'afterTool'); + expect(before).toBeDefined(); + expect(after).toBeDefined(); + + const projectRoot = join(root, 'dev-project'); + const hub = new TraceHub({ projectRoot }); + const { attachment, url } = await listen(hub, projectRoot); + + // (1) A dev-server-spawned simulation: the endpoint travels in the environment. + const simulated = await runHook(before!.output, nativePreToolUse(root, 'toolu_env'), attachment.environment(url)); + expect(simulated.code, simulated.stderr).toBe(0); + expect(JSON.parse(simulated.stdout)).toMatchObject({ + hookSpecificOutput: { additionalContext: 'receipt:Bash', hookEventName: 'PreToolUse' }, + }); + const afterEnv = hub.replay().entries; + expect(afterEnv.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.completed']); + const [received, completed] = afterEnv as [TraceEntry, TraceEntry]; + expect(received).toMatchObject({ + correlation: { + conversationId: 'session-receipt', + host: 'claude', + requestId: 'toolu_env', + routeId: 'event:tool/before', + sessionId: 'session-receipt', + }, + href: '/routes/events/tool/before', + source: 'hook', + status: 'ok', + summary: 'claude PreToolUse → tool/before received', + }); + expect(received.correlation.executionId).toMatch(/^[0-9a-f-]{36}$/u); + expect(completed.correlation).toEqual(received.correlation); + expect(completed).toMatchObject({ + details: { + events: [ + { kind: 'execute.start', phase: 'execute', runtime: 'standalone' }, + { kind: 'render.start', phase: 'render' }, + { kind: 'render.finish', phase: 'render' }, + ], + lineage: { source: 'native', state: 'available', value: { conversation: 'session-receipt', depth: 0, root: 'session-receipt' } }, + runtime: 'standalone', + }, + href: '/routes/events/tool/before', + status: 'ok', + summary: 'claude PreToolUse → tool/before completed', + }); + expect(typeof completed.durationMs).toBe('number'); + const serialized = JSON.stringify(afterEnv); + expect(serialized).not.toContain('never-on-the-trace'); + expect(serialized).not.toContain('tool_input'); + expect(serialized).not.toContain(root); + expect(serialized).not.toContain(attachment.token); + + // (2) A host's own invocation: no environment, the dev install marker beside + // the wrapper names the project whose dev server published its endpoint. + await attachment.publishEndpoint(url); + await writeFile( + join(dirname(before!.output), '..', DEV_INSTALL_MARKER_FILE), + `${JSON.stringify({ epochId: 'epoch-1', host: 'claude', projectRoot, schemaVersion: 1 })}\n`, + ); + const hosted = await runHook(before!.output, nativePreToolUse(root, 'toolu_marker'), {}); + expect(hosted.code, hosted.stderr).toBe(0); + const afterMarker = hub.replay().entries.slice(afterEnv.length); + expect(afterMarker.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.completed']); + expect(afterMarker[0]!.correlation).toMatchObject({ requestId: 'toolu_marker' }); + expect(afterMarker[0]!.correlation.executionId).not.toBe(received.correlation.executionId); + + // (3) A thrown route still reports: hook.failed with the kernel error summary, + // and the host still sees exit 1 with the message on stderr. + const thrown = await runHook(after!.output, { + cwd: root, + hook_event_name: 'PostToolUse', + session_id: 'session-receipt', + tool_input: { command: 'echo' }, + tool_name: 'Bash', + tool_response: { ok: true }, + tool_use_id: 'toolu_thrown', + transcript_path: join(root, 'transcript.jsonl'), + }, {}); + expect(thrown.code).toBe(1); + expect(thrown.stdout).toBe(''); + expect(thrown.stderr).toContain('after-tool exploded'); + const afterThrown = hub.replay().entries.slice(afterEnv.length + afterMarker.length); + expect(afterThrown.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.failed']); + expect(afterThrown[1]).toMatchObject({ + correlation: { requestId: 'toolu_thrown', routeId: 'event:tool/after' }, + details: { error: { message: 'after-tool exploded', name: 'Error' }, failedPhase: 'render' }, + href: '/routes/events/tool/after', + status: 'error', + }); + + // (4) Production silence: the dev server is gone, the wrapper answers the host and reports nothing. + await attachment.close(); + const alone = await runHook(before!.output, nativePreToolUse(root, 'toolu_alone'), {}); + expect(alone.code, alone.stderr).toBe(0); + expect(JSON.parse(alone.stdout)).toMatchObject({ hookSpecificOutput: { additionalContext: 'receipt:Bash' } }); + expect(hub.latestSequence).toBe(afterEnv.length + afterMarker.length + afterThrown.length); +}); diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts new file mode 100644 index 000000000..e2a6ff911 --- /dev/null +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -0,0 +1,475 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { DEV_INSTALL_MARKER } from '../src/dev/host-install-manager.ts'; +import { attachHookReceipts, HookReceiptRoutes } from '../src/dev/hooks/hook-receipt-endpoint.ts'; +import { + decodeHookReceipt, + HOOK_RECEIPT_MALFORMED_CODE, + HOOK_RECEIPT_TOO_LARGE_CODE, + HOOK_RECEIPT_UNAUTHORIZED_CODE, + HookReceiptDecodeError, + lowerHookReceipt, +} from '../src/dev/hooks/hook-receipts.ts'; +import { diagnostic, isRequestDiagnostic, responseDiagnostic } from '../src/dev/http.ts'; +import type { TraceEntry, TraceEntryInput } from '../src/dev/trace/trace-entry.ts'; +import { TraceHub } from '../src/dev/trace/trace-hub.ts'; +import { + DEV_INSTALL_MARKER_FILE, + EVENT_TRACE_RECEIPT_PATH, + EVENT_TRACE_RECEIPT_TOKEN_ENV, + EVENT_TRACE_RECEIPT_URL_ENV, + eventTraceReceiptEndpointPath, + eventTraceReceiptIdentity, + eventTraceReceiptLineage, + openEventTraceReceipt, + resolveEventTraceReceiptEndpoint, + type EventTraceReceipt, +} from '../src/events/trace-receipt.ts'; +import { createEventTracer, eventTraceExecution } from '../src/events/trace.ts'; +import { isLoopbackHttpOrigin } from '../src/core/loopback-origin.ts'; + +const cleanups: (() => Promise | void)[] = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +const execution = Object.freeze({ + event: 'tool/before', + executionId: 'exec-1', + host: 'claude', + nativeEvent: 'PreToolUse', +} as const); + +const receipt = (overrides: Partial = {}): EventTraceReceipt => ({ + events: [ + { at: 100, kind: 'execute.start', phase: 'execute', runtime: 'standalone', sequence: 0 }, + { at: 101, kind: 'render.start', phase: 'render', sequence: 1 }, + { at: 106.5, durationMs: 5.5, kind: 'render.finish', phase: 'render', sequence: 2 }, + ], + execution, + identity: { conversationId: 'agent-7', requestId: 'toolu_1', sessionId: 'session-1' }, + lineage: { + source: 'native', + state: 'available', + value: { conversation: 'session-1', depth: 1, parent: 'session-1', resolution: 'native', root: 'session-1', subagent: { id: 'agent-7', type: 'Explore' } }, + }, + startedAt: '2026-09-05T15:00:00.000Z', + version: 1, + ...overrides, +}); + +class FakePublisher { + readonly entries: TraceEntryInput[] = []; + + publish(input: TraceEntryInput): TraceEntry { + this.entries.push(input); + return { ...input, id: `trc_${this.entries.length}`, occurredAt: input.occurredAt ?? 'now', sequence: this.entries.length }; + } +} + +it('pins the wrapper-side marker name to the dev host installer', () => { + expect(DEV_INSTALL_MARKER_FILE).toBe(DEV_INSTALL_MARKER); +}); + +it('accepts only serialized loopback HTTP origins', () => { + expect(isLoopbackHttpOrigin('http://127.0.0.1:4321')).toBe(true); + expect(isLoopbackHttpOrigin('http://[::1]:4321')).toBe(true); + for (const rejected of [ + 'http://127.0.0.1:4321/', + 'http://localhost:4321', + 'https://127.0.0.1:4321', + 'http://10.0.0.1:4321', + 'http://127.0.0.1:4321?x=1', + 'http://user@127.0.0.1:4321', + 'not a url', + 4321, + undefined, + ]) { + expect(isLoopbackHttpOrigin(rejected)).toBe(false); + } +}); + +it('projects host ids from the native payload without carrying the payload', () => { + expect(eventTraceReceiptIdentity('claude', { + agent_id: 'agent-7', + session_id: 'session-1', + tool_input: { command: 'rm -rf /' }, + tool_use_id: 'toolu_1', + })).toEqual({ conversationId: 'agent-7', requestId: 'toolu_1', sessionId: 'session-1' }); + expect(eventTraceReceiptIdentity('claude', { session_id: 'session-1' })) + .toEqual({ conversationId: 'session-1', sessionId: 'session-1' }); + expect(eventTraceReceiptIdentity('codex', { session_id: 'thread-1', tool_call_id: 'call-1', turn_id: 'turn-1' })) + .toEqual({ conversationId: 'thread-1', requestId: 'call-1', sessionId: 'thread-1' }); + expect(eventTraceReceiptIdentity('cursor', { conversation_id: 'conv-1', generation_id: 'gen-1' })) + .toEqual({ conversationId: 'conv-1', sessionId: 'conv-1' }); + expect(eventTraceReceiptIdentity('cursor', { session_id: ' ' })).toEqual({}); +}); + +it('projects the lineage axis without the live tree', () => { + expect(eventTraceReceiptLineage({ reason: 'no-subagent-events', state: 'unavailable' })) + .toEqual({ reason: 'no-subagent-events', state: 'unavailable' }); + const projected = eventTraceReceiptLineage({ + source: 'native', + state: 'available', + value: { + conversation: 'c', + depth: 2, + parent: 'p', + resolution: 'registry', + root: 'r', + subagent: { id: 's', isParallelWorker: true, toolCallId: 't' }, + tree: { children: [], id: 'r', parents: [] }, + } as never, + }); + expect(projected).toEqual({ + source: 'native', + state: 'available', + value: { conversation: 'c', depth: 2, parent: 'p', resolution: 'registry', root: 'r', subagent: { id: 's', isParallelWorker: true, toolCallId: 't' } }, + }); + expect(JSON.stringify(projected)).not.toContain('tree'); +}); + +it('decodes a well-formed receipt and rejects unknown keys, bad enums, and unbounded fields', () => { + const wire = JSON.parse(JSON.stringify(receipt())) as unknown; + expect(decodeHookReceipt(wire)).toEqual(receipt()); + const rejects = (mutate: (value: Record) => void, path: string): void => { + const value = JSON.parse(JSON.stringify(receipt())) as Record; + mutate(value); + let caught: unknown; + try { + decodeHookReceipt(value); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(HookReceiptDecodeError); + expect((caught as HookReceiptDecodeError).path).toBe(path); + }; + rejects((value) => { value.version = 2; }, 'version'); + rejects((value) => { value.native = { tool_input: {} }; }, 'receipt'); + rejects((value) => { (value.execution as Record).event = 'tool/whatever'; }, 'execution.event'); + rejects((value) => { (value.execution as Record).executionId = 'x'.repeat(129); }, 'execution.executionId'); + rejects((value) => { (value.identity as Record).cwd = '/home/me'; }, 'identity'); + rejects((value) => { value.lineage = { state: 'available', value: {} }; }, 'lineage.source'); + rejects((value) => { value.lineage = { reason: 'because', state: 'unavailable' }; }, 'lineage.reason'); + rejects((value) => { ((value.lineage as Record).value as Record).depth = -1; }, 'lineage.value.depth'); + rejects((value) => { value.startedAt = 'yesterday'; }, 'startedAt'); + rejects((value) => { value.events = new Array(33).fill({ at: 0, kind: 'render.start', phase: 'render', sequence: 0 }); }, 'events'); + rejects((value) => { (value.events as unknown[])[0] = { at: 0, kind: 'execute.start', phase: 'execute', runtime: 'cloud', sequence: 0 }; }, 'events[0].runtime'); + rejects((value) => { (value.events as unknown[])[1] = { at: 0, kind: 'render.start', phase: 'execute', sequence: 1 }; }, 'events[1].phase'); + rejects((value) => { (value.events as unknown[])[1] = { at: 0, kind: 'render.start', payload: {}, phase: 'render', sequence: 1 }; }, 'events[1]'); + rejects((value) => { (value.events as unknown[])[2] = { at: 0, kind: 'render.finish', phase: 'render', sequence: 1 }; }, 'events[2].sequence'); + rejects((value) => { + (value.events as unknown[])[2] = { at: 1, error: { message: 'boom', name: 'Error', stack: 'at …' }, kind: 'failure', phase: 'render', sequence: 2 }; + }, 'events[2].error'); +}); + +it('lowers a completed receipt to hook.received and hook.completed with the event route href', () => { + const publisher = new FakePublisher(); + for (const entry of lowerHookReceipt(receipt())) publisher.publish(entry); + const entries = publisher.entries; + expect(entries.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.completed']); + const correlation = { + conversationId: 'agent-7', + executionId: 'exec-1', + host: 'claude', + requestId: 'toolu_1', + routeId: 'event:tool/before', + sessionId: 'session-1', + }; + expect(entries[0]).toMatchObject({ + correlation, + href: '/routes/events/tool/before', + occurredAt: '2026-09-05T15:00:00.000Z', + source: 'hook', + status: 'ok', + summary: 'claude PreToolUse → tool/before received', + }); + expect(entries[1]).toMatchObject({ + correlation, + details: { + events: [ + { atMs: 0, kind: 'execute.start', phase: 'execute', runtime: 'standalone' }, + { atMs: 1, kind: 'render.start', phase: 'render' }, + { atMs: 6.5, durationMs: 5.5, kind: 'render.finish', phase: 'render' }, + ], + lineage: { source: 'native', state: 'available', value: { conversation: 'session-1', depth: 1, root: 'session-1' } }, + runtime: 'standalone', + }, + durationMs: 6.5, + href: '/routes/events/tool/before', + occurredAt: '2026-09-05T15:00:00.006Z', + status: 'ok', + summary: 'claude PreToolUse → tool/before completed', + }); + expect(entries.every((entry) => !entry.href?.includes('invocation='))).toBe(true); + expect(JSON.stringify(entries)).not.toContain('tool_input'); +}); + +it('lowers a failure to hook.failed with the kernel error summary, and a gate outcome to a completed entry', () => { + const failed = lowerHookReceipt(receipt({ + events: [ + { at: 10, kind: 'execute.start', phase: 'execute', runtime: 'shared', sequence: 0 }, + { at: 12, durationMs: 2, error: { code: 'runtime-failed', message: 'render exploded', name: 'EventRuntimeTransportError' }, kind: 'failure', phase: 'execute', sequence: 1 }, + ], + })); + expect(failed.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.failed']); + expect(failed[1]).toMatchObject({ + details: { error: { code: 'runtime-failed', message: 'render exploded', name: 'EventRuntimeTransportError' }, failedPhase: 'execute', runtime: 'shared' }, + durationMs: 2, + status: 'error', + summary: 'claude PreToolUse → tool/before failed in execute: EventRuntimeTransportError: render exploded', + }); + const denied = receipt({ + events: [ + { at: 0, kind: 'preflight.start', phase: 'preflight', sequence: 0 }, + { at: 3, durationMs: 3, kind: 'preflight.outcome', outcome: 'deny', phase: 'preflight', sequence: 1 }, + ], + }); + const gated = lowerHookReceipt(denied); + expect(gated[1]).toMatchObject({ + details: { gate: 'deny' }, + kind: 'hook.completed', + status: 'ok', + summary: 'claude PreToolUse → tool/before denied by preflight', + }); + expect(gated[1]!.details).not.toHaveProperty('runtime'); +}); + +it('adds session.started and session.ended around session lifecycle receipts', () => { + const started = lowerHookReceipt(receipt({ + execution: { ...execution, event: 'session/start', nativeEvent: 'SessionStart' }, + identity: { conversationId: 'session-1', sessionId: 'session-1' }, + })); + expect(started.map((entry) => entry.kind)).toEqual(['hook.received', 'session.started', 'hook.completed']); + expect(started[1]).toMatchObject({ + correlation: { sessionId: 'session-1' }, + href: '/routes/events/session/start', + summary: 'claude session started (session-1)', + }); + const ended = lowerHookReceipt(receipt({ + events: [ + { at: 0, kind: 'execute.start', phase: 'execute', runtime: 'standalone', sequence: 0 }, + { at: 4, durationMs: 4, error: { message: 'no', name: 'Error' }, kind: 'failure', phase: 'render', sequence: 1 }, + ], + execution: { ...execution, event: 'session/end', nativeEvent: 'SessionEnd' }, + })); + expect(ended.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.failed', 'session.ended']); + expect(ended[2]).toMatchObject({ status: 'error', summary: 'claude session ended (session-1)' }); +}); + +it('falls back to the runtime lineage conversation when the payload named none', () => { + const [received] = lowerHookReceipt(receipt({ identity: { sessionId: 'session-1' } })); + expect(received!.correlation).toEqual({ + conversationId: 'session-1', + executionId: 'exec-1', + host: 'claude', + routeId: 'event:tool/before', + sessionId: 'session-1', + }); + const [bare] = lowerHookReceipt(receipt({ identity: {}, lineage: { reason: 'not-provided', state: 'unavailable' } })); + expect(bare!.correlation).toEqual({ executionId: 'exec-1', host: 'claude', routeId: 'event:tool/before' }); +}); + +const listen = async ( + handle: (request: IncomingMessage, response: ServerResponse) => Promise, +): Promise<{ readonly server: Server; readonly url: string }> => { + const server = createServer((request, response) => { + void handle(request, response).then((handled) => { + if (!handled) responseDiagnostic(response, diagnostic('AB8005', 'Not found.', 404)); + }).catch((error: unknown) => { + responseDiagnostic(response, isRequestDiagnostic(error) ? error : diagnostic('AB8007', 'Request could not be completed.', 500)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + return { server, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +}; + +const post = async (url: string, body: string, headers: Record): Promise => + fetch(new URL(EVENT_TRACE_RECEIPT_PATH, url), { body, headers, method: 'POST' }); + +const jsonHeaders = (token: string): Record => ({ + authorization: `Bearer ${token}`, + 'content-type': 'application/json', +}); + +it('accepts a bearer-authenticated loopback receipt and publishes its lowering to the trace hub', async () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + const routes = new HookReceiptRoutes({ token: 'secret-token', trace: hub }); + const { url } = await listen((request, response) => routes.handle(request, response)); + const accepted = await post(url, JSON.stringify(receipt()), jsonHeaders('secret-token')); + expect(accepted.status).toBe(204); + expect(hub.replay().entries.map((entry) => entry.kind)).toEqual(['hook.received', 'hook.completed']); + expect(hub.replay().entries[1]).toMatchObject({ correlation: { executionId: 'exec-1' }, id: 'trc_2', source: 'hook' }); + + const other = await fetch(`${url}/api/trace`, { method: 'GET' }); + expect(other.status).toBe(404); +}); + +it('refuses receipts without the token, with an Origin header, over the size cap, or malformed', async () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + const routes = new HookReceiptRoutes({ token: 'secret-token', trace: hub }); + const { url } = await listen((request, response) => routes.handle(request, response)); + const body = JSON.stringify(receipt()); + const code = async (response: Response): Promise<{ status: number; code: string }> => ({ + code: ((await response.json()) as { diagnostic: { code: string } }).diagnostic.code, + status: response.status, + }); + + await expect(code(await post(url, body, { 'content-type': 'application/json' }))) + .resolves.toEqual({ code: HOOK_RECEIPT_UNAUTHORIZED_CODE, status: 403 }); + await expect(code(await post(url, body, jsonHeaders('wrong-token')))) + .resolves.toEqual({ code: HOOK_RECEIPT_UNAUTHORIZED_CODE, status: 403 }); + await expect(code(await post(url, body, { ...jsonHeaders('secret-token'), origin: url }))) + .resolves.toEqual({ code: HOOK_RECEIPT_UNAUTHORIZED_CODE, status: 403 }); + await expect(code(await post(url, body, { 'content-type': 'application/json', cookie: 'agent-bundle-foreground-session-x=secret-token', 'x-agent-bundle-session': 'secret-token' }))) + .resolves.toEqual({ code: HOOK_RECEIPT_UNAUTHORIZED_CODE, status: 403 }); + await expect(code(await fetch(new URL(EVENT_TRACE_RECEIPT_PATH, url), { headers: jsonHeaders('secret-token'), method: 'GET' }))) + .resolves.toEqual({ code: 'AB8007', status: 405 }); + await expect(code(await post(url, body, { authorization: 'Bearer secret-token', 'content-type': 'text/plain' }))) + .resolves.toEqual({ code: 'AB8009', status: 415 }); + await expect(code(await post(url, '{"version":1,', jsonHeaders('secret-token')))) + .resolves.toEqual({ code: 'AB8001', status: 400 }); + await expect(code(await post(url, JSON.stringify({ ...receipt(), native: { tool_input: {} } }), jsonHeaders('secret-token')))) + .resolves.toEqual({ code: HOOK_RECEIPT_MALFORMED_CODE, status: 400 }); + await expect(code(await post(url, JSON.stringify(receipt({ identity: { sessionId: 'x'.repeat(17_000) } })), jsonHeaders('secret-token')))) + .resolves.toEqual({ code: HOOK_RECEIPT_TOO_LARGE_CODE, status: 413 }); + await expect(code(await fetch(`${new URL(EVENT_TRACE_RECEIPT_PATH, url).href}?replay=1`, { body, headers: jsonHeaders('secret-token'), method: 'POST' }))) + .resolves.toEqual({ code: HOOK_RECEIPT_MALFORMED_CODE, status: 400 }); + expect(hub.latestSequence).toBe(0); + + routes.close(); + await expect(code(await post(url, body, jsonHeaders('secret-token')))) + .resolves.toEqual({ code: HOOK_RECEIPT_UNAUTHORIZED_CODE, status: 409 }); +}); + +it('publishes an owner-only endpoint record under the project and removes it on close', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-hook-receipts-')); + cleanups.push(() => rm(projectRoot, { force: true, recursive: true })); + const hub = new TraceHub({ projectRoot: '/work/project' }); + const attachment = attachHookReceipts({ projectRoot, trace: hub }); + expect(attachment.token).toMatch(/^[A-Za-z0-9_-]{43}$/u); + expect(attachment.routes).toBeInstanceOf(HookReceiptRoutes); + expect(() => attachment.environment('http://localhost:4321')).toThrow(/loopback/u); + expect(attachment.environment('http://127.0.0.1:4321')).toEqual({ + [EVENT_TRACE_RECEIPT_TOKEN_ENV]: attachment.token, + [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://127.0.0.1:4321', + }); + + const recordPath = eventTraceReceiptEndpointPath(projectRoot); + await attachment.publishEndpoint('http://127.0.0.1:4321'); + expect(JSON.parse(await readFile(recordPath, 'utf8'))).toEqual({ + pid: process.pid, + token: attachment.token, + url: 'http://127.0.0.1:4321', + }); + if (process.platform !== 'win32') expect((await stat(recordPath)).mode & 0o777).toBe(0o600); + + await attachment.publishEndpoint('http://127.0.0.1:4322'); + expect(JSON.parse(await readFile(recordPath, 'utf8'))).toMatchObject({ url: 'http://127.0.0.1:4322' }); + + await attachment.close(); + await expect(stat(recordPath)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('resolves the wrapper endpoint from the environment, else the dev install marker beside the wrapper', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-receipt-resolve-')); + cleanups.push(() => rm(root, { force: true, recursive: true })); + const anchor = pathToFileURL(join(root, 'bundle', 'hooks', 'before-tool.claude.mjs')).href; + const fromEnv = await resolveEventTraceReceiptEndpoint({ + anchor, + env: { [EVENT_TRACE_RECEIPT_TOKEN_ENV]: 'env-token', [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://127.0.0.1:5000' }, + }); + expect(fromEnv).toEqual({ token: 'env-token', url: 'http://127.0.0.1:5000' }); + await expect(resolveEventTraceReceiptEndpoint({ + anchor, + env: { [EVENT_TRACE_RECEIPT_TOKEN_ENV]: 'env-token', [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://evil.example:5000' }, + })).resolves.toBeUndefined(); + await expect(resolveEventTraceReceiptEndpoint({ anchor, env: {} })).resolves.toBeUndefined(); + + const projectRoot = join(root, 'project'); + const hub = new TraceHub({ projectRoot: '/work/project' }); + const attachment = attachHookReceipts({ projectRoot, trace: hub }); + await attachment.publishEndpoint('http://127.0.0.1:5001'); + await mkdir(join(root, 'bundle', 'hooks'), { recursive: true }); + await writeFile(join(root, 'bundle', DEV_INSTALL_MARKER_FILE), JSON.stringify({ epochId: 'e1', host: 'claude', projectRoot, schemaVersion: 1 })); + await expect(resolveEventTraceReceiptEndpoint({ anchor, env: {} })) + .resolves.toEqual({ token: attachment.token, url: 'http://127.0.0.1:5001' }); + await writeFile(eventTraceReceiptEndpointPath(projectRoot), JSON.stringify({ + pid: 2_147_483_647, + token: attachment.token, + url: 'http://127.0.0.1:5001', + })); + await expect(resolveEventTraceReceiptEndpoint({ anchor, env: {} })).resolves.toBeUndefined(); + await attachment.close(); + await expect(resolveEventTraceReceiptEndpoint({ anchor, env: {} })).resolves.toBeUndefined(); +}); + +it('records kernel events through the tracer and posts one bounded receipt that never throws', async () => { + const posted: { url: string; init: RequestInit }[] = []; + const fetchStub: typeof fetch = async (input, init) => { + posted.push({ init: init!, url: String(input) }); + throw new TypeError('connection refused'); + }; + const traced = eventTraceExecution({ event: 'tool/before', host: 'claude', nativeEvent: 'PreToolUse' }); + const recorder = await openEventTraceReceipt({ + anchor: 'file:///nowhere/hooks/x.mjs', + env: { [EVENT_TRACE_RECEIPT_TOKEN_ENV]: 't', [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://127.0.0.1:6000' }, + execution: traced, + fetch: fetchStub, + }); + expect(recorder).toBeDefined(); + let clock = 50; + const tracer = createEventTracer({ execution: traced, now: () => clock, observer: recorder!.observer }); + recorder!.identity({ session_id: 's', tool_input: { secret: true }, tool_use_id: 'u' }); + recorder!.lineage({ reason: 'no-subagent-events', state: 'unavailable' }); + tracer.executeStart('standalone'); + clock = 52; + tracer.renderStart(); + clock = 60; + tracer.renderFinish(); + await recorder!.send(); + await recorder!.send(); + expect(posted).toHaveLength(1); + expect(posted[0]!.url).toBe('http://127.0.0.1:6000/api/trace/receipts'); + expect(posted[0]!.init.headers).toEqual({ authorization: 'Bearer t', 'content-type': 'application/json' }); + const body = JSON.parse(posted[0]!.init.body as string) as EventTraceReceipt; + expect(body.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/u); + expect(body).toEqual({ + events: [ + { at: 50, kind: 'execute.start', phase: 'execute', runtime: 'standalone', sequence: 0 }, + { at: 52, kind: 'render.start', phase: 'render', sequence: 1 }, + { at: 60, durationMs: 8, kind: 'render.finish', phase: 'render', sequence: 2 }, + ], + execution: traced, + identity: { conversationId: 's', requestId: 'u', sessionId: 's' }, + lineage: { reason: 'no-subagent-events', state: 'unavailable' }, + startedAt: body.startedAt, + version: 1, + }); + expect(posted[0]!.init.body).not.toContain('secret'); + expect(decodeHookReceipt(body)).toEqual(body); + + const silent = await openEventTraceReceipt({ anchor: 'file:///nowhere/hooks/x.mjs', env: {}, execution: traced, fetch: fetchStub }); + expect(silent).toBeUndefined(); +}); + +it('does not post a receipt when nothing was traced', async () => { + let calls = 0; + const recorder = await openEventTraceReceipt({ + anchor: 'file:///nowhere/hooks/x.mjs', + env: { [EVENT_TRACE_RECEIPT_TOKEN_ENV]: 't', [EVENT_TRACE_RECEIPT_URL_ENV]: 'http://127.0.0.1:6000' }, + execution, + fetch: async () => { calls += 1; return new Response(null, { status: 204 }); }, + }); + await recorder!.send(); + expect(calls).toBe(0); +}); diff --git a/packages/agent-bundle/tests/mcp-session-routes.test.ts b/packages/agent-bundle/tests/mcp-session-routes.test.ts index ed6851698..6af5aa28d 100644 --- a/packages/agent-bundle/tests/mcp-session-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-session-routes.test.ts @@ -396,6 +396,31 @@ it('exposes the frozen operation and catalog surface without a generic launch or options: { arguments: { city: 'Paris' }, name: 'forecast', requestId: 'request-a' }, }); + // The Workbench's run id rides `params._meta` so the frame joins the route + // workspace's invocation on the unified trace; the browser never writes `_meta` itself. + const correlated = await fetch(`${started.url}/api/mcp/sessions/session-a/operations`, { + body: JSON.stringify({ arguments: {}, correlationId: 'corr-1', name: 'forecast', operation: 'tools/call' }), + headers: { ...headers(), 'content-type': 'application/json' }, + method: 'POST', + }); + expect(correlated.status).toBe(200); + expect(service.session.calls).toContainEqual({ + kind: 'callTool', + options: { _meta: { 'agent-bundle/correlationId': 'corr-1' }, arguments: {}, name: 'forecast' }, + }); + for (const malformed of [ + { arguments: {}, correlationId: '', name: 'forecast', operation: 'tools/call' }, + { arguments: {}, correlationId: 'c'.repeat(257), name: 'forecast', operation: 'tools/call' }, + { _meta: { 'agent-bundle/correlationId': 'corr-1' }, arguments: {}, name: 'forecast', operation: 'tools/call' }, + ]) { + const invalid = await fetch(`${started.url}/api/mcp/sessions/session-a/operations`, { + body: JSON.stringify(malformed), + headers: { ...headers(), 'content-type': 'application/json' }, + method: 'POST', + }); + expect(invalid.status).toBe(400); + } + const rejected = await fetch(`${started.url}/api/mcp/sessions/session-a/operations`, { body: JSON.stringify({ command: '/tmp/untrusted', operation: 'initialize' }), headers: { ...headers(), 'content-type': 'application/json' }, diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 62bcda43e..5a3c9856a 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -26,9 +26,12 @@ import { McpSessionService, type McpSessionTraceSubscription, } from '../src/dev/mcp-session/mcp-session-service.ts'; +import type { TraceEntry, TraceEntryInput } from '../src/dev/trace/trace-entry.ts'; +import type { TracePublisher } from '../src/dev/trace/trace-hub.ts'; import type { ArtifactEpoch } from '../src/dev/types.ts'; import { pathTokens, type NormalizationTargetRegistry } from '../src/core/types.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; +import { eventually } from './support/eventually.ts'; import { mcpCatalogStub, stdioTransportStub } from './support/mcp-client-stub.ts'; import { loadedProject } from './support/loaded-project.ts'; @@ -311,6 +314,118 @@ it('keeps one generated server and plugin-data directory bound to the selected e } }, 30_000); +it('lowers every session trace entry onto the unified trace with request/response pairing and host correlation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-trace-')); + const published: TraceEntryInput[] = []; + const trace: TracePublisher = { + publish(input) { + published.push(input); + return { ...input, id: `trc_${published.length}`, occurredAt: input.occurredAt ?? '', sequence: published.length } as TraceEntry; + }, + }; + const ofKind = (kind: string) => published.filter((entry) => entry.kind === kind); + try { + const epochStore = await publishFixtureEpoch(root, 'epoch-1'); + const service = new McpSessionService({ epochStore, projectRoot: root, trace }); + const session = await service.open({ epochId: 'epoch-1', serverName: 'fixture', target: 'portable' }); + const href = (path: string) => `${path}?session=${encodeURIComponent(session.id)}`; + + expect(ofKind('mcp.session.started')).toMatchObject([{ + correlation: { epochId: 'epoch-1', host: 'portable', mcpSessionId: session.id }, + href: href('/advanced/protocol'), + source: 'mcp', + status: 'ok', + summary: 'MCP session fixture (portable) started', + }]); + expect(ofKind('mcp.request').map((entry) => entry.summary)).toEqual(['initialize']); + expect(ofKind('mcp.notification').map((entry) => entry.summary)).toEqual(['notifications/initialized']); + + await session.callTool({ + _meta: { 'agent-bundle/correlationId': 'corr-1', 'claudecode/toolUseId': 'toolu_01' }, + arguments: {}, + name: 'inspect', + }); + await session.getPrompt({ name: 'fixture' }); + await session.readResource({ uri: 'ui://fixture/resource.txt' }); + await eventually(() => ofKind('mcp.stderr').length > 0, 2_000); + + const requests = ofKind('mcp.request'); + const responses = ofKind('mcp.response'); + expect(requests.map((entry) => entry.summary)).toEqual([ + 'initialize', + 'tools/call inspect', + 'prompts/get fixture', + 'resources/read', + ]); + expect(responses.map((entry) => entry.summary)).toEqual([ + 'initialize ok', + 'tools/call inspect ok', + 'prompts/get fixture ok', + 'resources/read ok', + ]); + for (const [index, request] of requests.entries()) { + const response = responses[index]; + expect(request.correlation.mcpRequestId).toBeDefined(); + expect(response?.correlation).toEqual(request.correlation); + expect(response?.durationMs).toBeGreaterThanOrEqual(0); + expect(request.status).toBe('running'); + expect(response?.status).toBe('ok'); + expect(request.href).toBe(response?.href); + } + expect(requests[1]).toMatchObject({ + correlation: { + correlationId: 'corr-1', + epochId: 'epoch-1', + host: 'portable', + mcpSessionId: session.id, + requestId: 'toolu_01', + routeId: 'tool:fixture/inspect', + }, + details: { method: 'tools/call', name: 'inspect' }, + href: href('/routes/mcp/fixture/tool/inspect'), + }); + expect(requests[2]).toMatchObject({ + correlation: { routeId: 'prompt:fixture/fixture' }, + href: href('/routes/mcp/fixture/prompt/fixture'), + }); + expect(requests[3]?.correlation).not.toHaveProperty('routeId'); + expect(requests[3]?.href).toBe(href('/advanced/protocol')); + expect(session.trace().entries.find((entry) => entry.kind === 'frame' && entry.method === 'tools/call')).toMatchObject({ + id: requests[1]?.correlation.mcpRequestId, + meta: { correlationId: 'corr-1', requestId: 'toolu_01' }, + method: 'tools/call', + }); + + expect(ofKind('mcp.stderr')).toMatchObject([{ + correlation: { epochId: 'epoch-1', host: 'portable', mcpSessionId: session.id }, + details: { bytes: Buffer.byteLength('fixture stderr\n') }, + href: href('/advanced/protocol'), + summary: 'stderr: fixture stderr', + }]); + expect(JSON.stringify(published)).not.toContain(root); + + await session.close(); + expect(ofKind('mcp.session.closed')).toMatchObject([{ status: 'ok', summary: 'MCP session fixture (portable) closed' }]); + expect(published.at(-1)?.kind).toBe('mcp.session.closed'); + expect(published.every((entry) => entry.source === 'mcp' && entry.correlation.mcpSessionId === session.id)).toBe(true); + await service.close(); + + // A failing publisher is the trace's problem, never the session's. + const throwing = new McpSessionService({ + epochStore, + projectRoot: root, + trace: { publish: () => { throw new Error('trace hub is closed'); } }, + }); + const isolated = await throwing.open({ epochId: 'epoch-1', serverName: 'fixture', target: 'portable' }); + await expect(isolated.callTool({ arguments: {}, name: 'inspect' })).resolves.toMatchObject({ structuredContent: { answer: 42 } }); + expect(isolated.trace().entries.some((entry) => entry.kind === 'frame' && entry.method === 'tools/call')).toBe(true); + await isolated.close(); + await throwing.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('rejects an MCP server not declared for the selected projection', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-hosts-')); try { diff --git a/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts new file mode 100644 index 000000000..0b16358f3 --- /dev/null +++ b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts @@ -0,0 +1,222 @@ +import { expect, it } from '@rstest/core'; + +import { mcpCorrelationMetaKey } from '../src/contracts/mcp-session.ts'; +import type { McpSessionBinding, McpSessionTraceEntry } from '../src/dev/mcp-session/mcp-session-protocol.ts'; +import { composeMcpSessionTraceSinks, McpSessionTraceLog } from '../src/dev/mcp-session/mcp-session-trace.ts'; +import { + createMcpSessionTraceSink, + liftMcpFrame, +} from '../src/dev/mcp-session/mcp-session-trace-publisher.ts'; +import type { TraceEntry, TraceEntryInput } from '../src/dev/trace/trace-entry.ts'; +import type { TracePublisher } from '../src/dev/trace/trace-hub.ts'; + +const binding: McpSessionBinding = Object.freeze({ epochId: 'epoch-7', serverName: 'curator', target: 'claude' }); +const projectRoot = '/home/dev/projects/curator'; +const sessionId = 'sess-1'; + +const fakePublisher = (): TracePublisher & { readonly published: TraceEntryInput[] } => { + const published: TraceEntryInput[] = []; + return { + published, + publish(input) { + published.push(input); + return { ...input, id: `trc_${published.length}`, occurredAt: input.occurredAt ?? 'now', sequence: published.length } as TraceEntry; + }, + }; +}; + +let sequence = 0; + +const frame = (direction: 'client' | 'server', message: unknown, occurredAt: number): McpSessionTraceEntry => Object.freeze({ + direction, + ...liftMcpFrame(message), + kind: 'frame', + message, + occurredAt, + sequence: ++sequence, +}); + +const operation = ( + operation: 'close' | 'initialize' | 'listTools' | 'restart', + phase: 'failed' | 'started' | 'succeeded', + occurredAt = 1_000, +): McpSessionTraceEntry => Object.freeze({ kind: 'operation', occurredAt, operation, phase, sequence: ++sequence }); + +it('lifts the JSON-RPC id, method, and host correlation keys off a frame without translating it', () => { + expect(liftMcpFrame('not an object')).toEqual({}); + expect(liftMcpFrame({ id: 4, jsonrpc: '2.0', result: {} })).toEqual({ id: '4' }); + expect(liftMcpFrame({ jsonrpc: '2.0', method: 'notifications/initialized' })).toEqual({ method: 'notifications/initialized' }); + expect(liftMcpFrame({ + id: 'req-a', + jsonrpc: '2.0', + method: 'tools/call', + params: { + _meta: { + [mcpCorrelationMetaKey]: 'corr-1', + 'claudecode/toolUseId': 'toolu_01', + progressToken: 9, + 'x-codex-turn-metadata': { session_id: 'codex-session', thread_id: 'thread-a', turn_id: 'turn-3' }, + }, + name: 'search', + }, + })).toEqual({ + id: 'req-a', + meta: { correlationId: 'corr-1', conversationId: 'thread-a', requestId: 'toolu_01', sessionId: 'codex-session' }, + method: 'tools/call', + }); + expect(liftMcpFrame({ id: 1.5, method: 'x'.repeat(300), params: { _meta: { 'claudecode/toolUseId': 'bad\u0000id' } } })).toEqual({}); +}); + +it('lowers a tools/call request and its response onto the trace, paired by id with the route and duration', () => { + const trace = fakePublisher(); + const sink = createMcpSessionTraceSink({ binding, projectRoot, sessionId, trace }); + sink(binding, frame('client', { + id: 7, + jsonrpc: '2.0', + method: 'tools/call', + params: { _meta: { 'claudecode/toolUseId': 'toolu_01', [mcpCorrelationMetaKey]: 'corr-9' }, arguments: { query: 'jazz' }, name: 'search' }, + }, 10_000)); + sink(binding, frame('server', { id: 7, jsonrpc: '2.0', result: { content: [], structuredContent: { hits: 3 } } }, 10_250)); + + expect(trace.published).toHaveLength(2); + const [request, response] = trace.published; + expect(request).toMatchObject({ + correlation: { + correlationId: 'corr-9', + epochId: 'epoch-7', + host: 'claude', + mcpRequestId: '7', + mcpSessionId: sessionId, + requestId: 'toolu_01', + routeId: 'tool:curator/search', + }, + details: { method: 'tools/call', name: 'search' }, + href: '/routes/mcp/curator/tool/search?session=sess-1', + kind: 'mcp.request', + occurredAt: new Date(10_000).toISOString(), + source: 'mcp', + status: 'running', + summary: 'tools/call search', + }); + expect((request?.details as { readonly paramsBytes: number }).paramsBytes).toBeGreaterThan(0); + expect(response).toMatchObject({ + correlation: request?.correlation, + durationMs: 250, + href: '/routes/mcp/curator/tool/search?session=sess-1', + kind: 'mcp.response', + status: 'ok', + summary: 'tools/call search ok', + }); + expect(response?.details).not.toHaveProperty('structuredContent'); +}); + +it('marks JSON-RPC errors and tool errors, redacts error text, and links unrouted frames to the protocol page', () => { + const trace = fakePublisher(); + const sink = createMcpSessionTraceSink({ binding, projectRoot, sessionId, trace }); + sink(binding, frame('client', { id: 'a', jsonrpc: '2.0', method: 'resources/read', params: { uri: 'ui://x/y' } }, 1)); + sink(binding, frame('server', { error: { code: -32602, message: `missing ${projectRoot}/src/secret.ts` }, id: 'a', jsonrpc: '2.0' }, 5)); + sink(binding, frame('client', { id: 'b', jsonrpc: '2.0', method: 'tools/call', params: { arguments: {}, name: 'inspect' } }, 6)); + sink(binding, frame('server', { id: 'b', jsonrpc: '2.0', result: { content: [], isError: true } }, 9)); + sink(binding, frame('server', { id: 'orphan', jsonrpc: '2.0', result: {} }, 10)); + + expect(trace.published.map((entry) => [entry.kind, entry.status, entry.summary, entry.href])).toEqual([ + ['mcp.request', 'running', 'resources/read', '/advanced/protocol?session=sess-1'], + ['mcp.response', 'error', 'resources/read error -32602', '/advanced/protocol?session=sess-1'], + ['mcp.request', 'running', 'tools/call inspect', '/routes/mcp/curator/tool/inspect?session=sess-1'], + ['mcp.response', 'error', 'tools/call inspect tool error', '/routes/mcp/curator/tool/inspect?session=sess-1'], + ['mcp.response', 'ok', 'response ok', '/advanced/protocol?session=sess-1'], + ]); + const failed = trace.published[1]?.details as { readonly error: { readonly code: number; readonly message: string } }; + expect(failed.error.code).toBe(-32602); + expect(failed.error.message).not.toContain(projectRoot); + expect(trace.published[1]?.durationMs).toBe(4); + expect(trace.published[4]).not.toHaveProperty('durationMs'); + expect(trace.published[4]?.correlation).toEqual({ epochId: 'epoch-7', host: 'claude', mcpRequestId: 'orphan', mcpSessionId: sessionId }); +}); + +it('lowers notifications, progress, logging, and stderr once each without raw payloads', () => { + const trace = fakePublisher(); + const sink = createMcpSessionTraceSink({ binding, projectRoot, sessionId, trace }); + sink(binding, frame('client', { jsonrpc: '2.0', method: 'notifications/initialized' }, 1)); + sink(binding, frame('client', { + id: 3, + jsonrpc: '2.0', + method: 'prompts/get', + params: { _meta: { progressToken: 'tok-3' }, name: 'brief' }, + }, 2)); + const progressFrame = { jsonrpc: '2.0', method: 'notifications/progress', params: { progress: 2, progressToken: 'tok-3', total: 5 } }; + sink(binding, frame('server', progressFrame, 3)); + sink(binding, Object.freeze({ kind: 'progress', occurredAt: 3, payload: progressFrame.params, sequence: ++sequence })); + const loggingFrame = { jsonrpc: '2.0', method: 'notifications/message', params: { data: { secret: 'value' }, level: 'warning', logger: 'fixture' } }; + sink(binding, frame('server', loggingFrame, 4)); + sink(binding, Object.freeze({ kind: 'logging', occurredAt: 4, payload: loggingFrame.params, sequence: ++sequence })); + sink(binding, Object.freeze({ kind: 'stderr', occurredAt: 5, sequence: ++sequence, text: `failed to load ${projectRoot}/dist/server.js line 12\nsecond line\n` })); + sink(binding, frame('client', { jsonrpc: '2.0', method: 'notifications/cancelled', params: { reason: 'user', requestId: 3 } }, 6)); + + expect(trace.published.map((entry) => [entry.kind, entry.summary])).toEqual([ + ['mcp.notification', 'notifications/initialized'], + ['mcp.request', 'prompts/get brief'], + ['mcp.progress', 'progress 2/5'], + ['mcp.logging', 'log warning fixture'], + ['mcp.stderr', 'stderr: failed to load /dist/server.js line 12'], + ['mcp.notification', 'notifications/cancelled'], + ]); + expect(trace.published[2]?.correlation).toMatchObject({ mcpRequestId: '3', routeId: 'prompt:curator/brief' }); + expect(trace.published[2]?.href).toBe('/routes/mcp/curator/prompt/brief?session=sess-1'); + expect(trace.published[3]?.details).toEqual({ level: 'warning', logger: 'fixture' }); + expect(trace.published[4]?.details).toEqual({ bytes: Buffer.byteLength(`failed to load ${projectRoot}/dist/server.js line 12\nsecond line\n`) }); + expect(trace.published[5]?.correlation).toMatchObject({ mcpRequestId: '3', routeId: 'prompt:curator/brief' }); +}); + +it('publishes session started and closed once from the lifecycle operations and nothing for catalog operations', () => { + const trace = fakePublisher(); + const sink = createMcpSessionTraceSink({ binding, projectRoot, sessionId, trace }); + sink(binding, operation('initialize', 'started')); + sink(binding, operation('initialize', 'succeeded', 2_000)); + sink(binding, operation('initialize', 'succeeded', 2_500)); + sink(binding, operation('listTools', 'started')); + sink(binding, operation('listTools', 'succeeded')); + sink(binding, operation('restart', 'succeeded', 3_000)); + sink(binding, operation('close', 'started')); + sink(binding, operation('close', 'failed', 4_000)); + sink(binding, operation('close', 'succeeded', 4_100)); + + expect(trace.published.map((entry) => [entry.kind, entry.status, entry.summary, entry.occurredAt])).toEqual([ + ['mcp.session.started', 'ok', 'MCP session curator (claude) started', new Date(2_000).toISOString()], + ['mcp.session.started', 'ok', 'MCP session curator (claude) restarted', new Date(3_000).toISOString()], + ['mcp.session.closed', 'error', 'MCP session curator (claude) closed with cleanup failure', new Date(4_000).toISOString()], + ]); + expect(trace.published.every((entry) => entry.href === '/advanced/protocol?session=sess-1')).toBe(true); + expect(trace.published.every((entry) => entry.correlation.mcpSessionId === sessionId && entry.correlation.host === 'claude')).toBe(true); +}); + +it('isolates a throwing trace publisher from the session trace log and its sibling sinks', () => { + const seen: McpSessionTraceEntry[] = []; + const throwing: TracePublisher = { + publish() { + throw new Error('trace hub is closed'); + }, + }; + const composed = composeMcpSessionTraceSinks( + undefined, + (_binding, entry) => { + seen.push(entry); + }, + createMcpSessionTraceSink({ binding, projectRoot, sessionId, trace: throwing }), + ); + expect(composed).toBeDefined(); + const log = new McpSessionTraceLog(binding, composed); + const delivered: McpSessionTraceEntry[] = []; + log.subscribe({}, (message) => { + if ('kind' in message) delivered.push(message); + }); + const entry = frame('client', { id: 1, jsonrpc: '2.0', method: 'tools/list' }, 1); + expect(() => log.record(entry)).not.toThrow(); + expect(seen).toEqual([entry]); + expect(delivered).toEqual([entry]); + expect(log.replay().entries).toEqual([entry]); + + const only = (_binding: McpSessionBinding, _entry: McpSessionTraceEntry): void => undefined; + expect(composeMcpSessionTraceSinks(undefined, undefined)).toBeUndefined(); + expect(composeMcpSessionTraceSinks(only)).toBe(only); +}); diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index 9aeceba2a..92f6601e9 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -9,6 +9,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import type { RouteInvocationResponse } from '../src/dev/routes/route-invocation-result.ts'; import type { RouteInvocationListResponse } from '../src/dev/routes/route-invocation.ts'; import type { RouteManifestResponse } from '../src/dev/routes/route-manifest.ts'; +import type { TraceReplay } from '../src/dev/trace/trace-entry.ts'; import { confirmationRequiredMessage } from '../src/cli-entry.ts'; import { stableJson } from '../src/core/digest.ts'; import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../src/core/types.ts'; @@ -19,7 +20,11 @@ import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; import { replaceWatchedSourceAndAwaitRebuild } from './support/watched-files.ts'; import { runNodeScript } from './support/run-node-script.ts'; -const readEvent = async (response: Response, type: string): Promise> => { +const readEvent = async ( + response: Response, + type: string, + matches: (event: Record) => boolean = () => true, +): Promise> => { const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); let buffered = ''; for (;;) { @@ -31,7 +36,30 @@ const readEvent = async (response: Response, type: string): Promise line.startsWith('data: ')); - if (data !== undefined) return JSON.parse(data.slice('data: '.length)) as Record; + if (data !== undefined) { + const event = JSON.parse(data.slice('data: '.length)) as Record; + if (matches(event)) return event; + } + } + } +}; + +const readInvocationStream = async (response: Response): Promise[]> => { + const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); + const messages: Record[] = []; + let buffered = ''; + for (;;) { + const next = await reader.read(); + if (next.done) throw new Error('Invocation stream ended before final.'); + buffered += next.value; + const frames = buffered.split('\n\n'); + buffered = frames.pop() ?? ''; + for (const frame of frames) { + const data = frame.split('\n').find((line) => line.startsWith('data: ')); + if (data === undefined) continue; + const message = JSON.parse(data.slice('data: '.length)) as Record; + messages.push(message); + if (message.type === 'final') return messages; } } }; @@ -194,6 +222,23 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/mcp/status/tools/live.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement, Suspense } from 'react';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ done: z.boolean() }).strict();', + 'const Slow = async () => {', + ' await new Promise((resolve) => setTimeout(resolve, 1_000));', + " return createElement(Agent.Text, null, 'stream complete');", + '};', + '', + 'export default async function Live() {', + " return createElement(Agent.Result, { value: { done: true } }, createElement(Suspense, { fallback: createElement(Agent.Progress, { completed: 0, message: 'streaming', total: 1 }) }, createElement(Slow)));", + '}', + '', + ].join('\n'), 'src/mcp/status/tools/report.cli.ts': [ "export const config = { command: ['report'], confirm: true, flags: { service: { name: 'name' }, source: { required: false } } };", "export const mapInput = (input) => ({ ...input, source: input.source ?? 'cli-projection' });", @@ -264,10 +309,54 @@ it('invokes compiled tool and event routes through the foreground server', { tim const stream = await fetch(`${server.url}/api/project/events`, { headers: { cookie, origin: server.url }, }); + const stateRoot = join(project.root, '.agent-bundle', 'state'); + const startLive = async () => { + const response = await fetch(`${server!.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:status/live', stream: true }), + headers, + method: 'POST', + }); + expect(response.status).toBe(202); + return response.json() as Promise<{ readonly invocation: { readonly id: string; readonly status: string } }>; + }; + const live = await startLive(); + expect(live.invocation.status).toBe('running'); + const liveStreamResponse = await fetch(`${server.url}/api/routes/invocations/${live.invocation.id}/stream`, { headers }); + expect(liveStreamResponse.status).toBe(200); + const liveMessages = await readInvocationStream(liveStreamResponse); + expect(liveMessages.findIndex((message) => message.type === 'render')).toBeGreaterThanOrEqual(0); + expect(liveMessages.at(-1)).toMatchObject({ + invocation: { status: 'succeeded' }, + type: 'final', + }); + + const cancelling = await startLive(); + const cancellingStream = await fetch(`${server.url}/api/routes/invocations/${cancelling.invocation.id}/stream`, { headers }); + const cancellingMessages = readInvocationStream(cancellingStream); + const cancelResponse = await fetch(`${server.url}/api/routes/invocations/${cancelling.invocation.id}/cancel`, { + headers, + method: 'POST', + }); + expect(cancelResponse.status).toBe(202); + const cancelled = await cancelResponse.json() as RouteInvocationResponse; + expect(cancelled.invocation).toMatchObject({ status: 'cancelled' }); + expect(cancelled.invocation).not.toHaveProperty('outcome'); + expect((await cancellingMessages).at(-1)).toMatchObject({ + invocation: { status: 'cancelled' }, + type: 'final', + }); + const finalCancelResponse = await fetch(`${server.url}/api/routes/invocations/${cancelling.invocation.id}/cancel`, { + headers, + method: 'POST', + }); + expect(finalCancelResponse.status).toBe(409); + await expect(finalCancelResponse.json()).resolves.toMatchObject({ diagnostic: { code: 'AB8256' } }); + const unknownStream = await fetch(`${server.url}/api/routes/invocations/inv_missing/stream`, { headers }); + expect(unknownStream.status).toBe(404); + const activeEpoch = server.status().artifact; if (activeEpoch.state !== 'active') throw new Error('Expected an active compiled epoch.'); const artifactRoot = join(project.root, '.agent-bundle', 'epochs', activeEpoch.activeEpoch.id); - const stateRoot = join(project.root, '.agent-bundle', 'state'); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, @@ -316,6 +405,31 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.providers).toEqual([ expect.objectContaining({ durationMs: expect.any(Number), id: 'provider:clock', name: 'clock', status: 'mounted' }), ]); + const toolTraceResponse = await fetch(`${server.url}/api/trace?after=0`, { headers }); + expect(toolTraceResponse.status).toBe(200); + const toolTrace = await toolTraceResponse.json() as TraceReplay; + const toolEntries = toolTrace.entries.filter((entry) => + entry.correlation.invocationId === tool.invocation.id && entry.source === 'invocation'); + expect(toolEntries.map((entry) => entry.kind)).toEqual([ + 'invocation.started', + 'invocation.completed', + ]); + expect(toolEntries).toEqual([ + expect.objectContaining({ + correlation: expect.objectContaining({ + invocationId: tool.invocation.id, + routeId: 'tool:status/report', + }), + href: expect.stringMatching(new RegExp(`\\?invocation=${tool.invocation.id}$`, 'u')), + }), + expect.objectContaining({ + correlation: expect.objectContaining({ + invocationId: tool.invocation.id, + routeId: 'tool:status/report', + }), + href: expect.stringMatching(new RegExp(`\\?invocation=${tool.invocation.id}$`, 'u')), + }), + ]); expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual([ 'provider:clock', 'providers', @@ -376,6 +490,46 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(await readFile(join(project.root, '.agent-bundle', 'defer-gate.marker'), 'utf8')).toBe('gate\n'); expect(await readFile(join(project.root, '.agent-bundle', 'defer-handler.marker'), 'utf8')).toBe('run\n'); expect(event.invocation.projection.hosts?.[0]).toMatchObject({ host: 'claude' }); + expect(event.invocation.context.session).toEqual({ + source: 'receipt', + state: 'available', + value: { sessionId: 'session-1' }, + }); + expect(event.invocation.context.lineage).toMatchObject({ + source: 'receipt', + state: 'available', + value: { conversation: 'session-1', root: 'session-1' }, + }); + const eventTraceResponse = await fetch(`${server.url}/api/trace?after=0`, { headers }); + expect(eventTraceResponse.status).toBe(200); + const eventTrace = await eventTraceResponse.json() as TraceReplay; + const eventEntries = eventTrace.entries.filter((entry) => + entry.correlation.invocationId === event.invocation.id); + expect(eventEntries.filter((entry) => entry.source === 'invocation').map((entry) => entry.kind)).toEqual([ + 'invocation.started', + 'invocation.completed', + ]); + expect(eventEntries.filter((entry) => entry.source === 'invocation')).toEqual([ + expect.objectContaining({ + correlation: expect.objectContaining({ + invocationId: event.invocation.id, + routeId: 'event:tool/after', + }), + href: expect.stringMatching(new RegExp(`\\?invocation=${event.invocation.id}$`, 'u')), + }), + expect.objectContaining({ + correlation: expect.objectContaining({ + invocationId: event.invocation.id, + routeId: 'event:tool/after', + }), + href: expect.stringMatching(new RegExp(`\\?invocation=${event.invocation.id}$`, 'u')), + }), + ]); + const kernelEntries = eventEntries.filter((entry) => entry.source === 'kernel'); + expect(kernelEntries.length).toBeGreaterThan(0); + expect(kernelEntries.every((entry) => entry.kind.startsWith('kernel.'))).toBe(true); + expect(new Set(kernelEntries.map((entry) => entry.correlation.executionId)).size).toBe(1); + expect(kernelEntries[0]?.correlation.executionId).toBeDefined(); expect(event.invocation.trace?.map((trace) => trace.kind)).toEqual([ 'preflight.start', 'preflight.outcome', @@ -622,6 +776,21 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(secondCounter.invocation.result).toEqual({ count: 2 }); expect(isolatedCounter.invocation.result).toEqual({ count: 1 }); + const nonStreamingCounter = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { key: 'stream-false' }, + routeId: 'tool:status/counter', + stream: false, + }), + headers, + method: 'POST', + }); + expect(nonStreamingCounter.status).toBe(200); + const nonStreaming = await nonStreamingCounter.json() as RouteInvocationResponse; + expect(nonStreaming).toMatchObject({ + invocation: { status: 'succeeded' }, + }); + const scriptResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ routeId: 'script:summary' }), headers, @@ -644,14 +813,17 @@ it('invokes compiled tool and event routes through the foreground server', { tim const listed = await listedResponse.json() as RouteInvocationListResponse; expect(listed.invocations.map((invocation) => invocation.id)).toEqual([ script.invocation.id, + nonStreaming.invocation.id, isolatedCounter.invocation.id, secondCounter.invocation.id, - firstCounter.invocation.id, ]); const read = await fetch(`${server.url}/api/routes/invocations/${tool.invocation.id}`, { headers }); await expect(read.json()).resolves.toEqual(tool); - const published = await readEvent(stream, 'route.invocation'); + const published = await readEvent(stream, 'route.invocation', (event) => { + const invocation = (event.payload as { readonly invocation?: { readonly routeId?: string; readonly status?: string } } | undefined)?.invocation; + return invocation?.routeId === 'tool:status/report' && invocation.status === 'succeeded'; + }); expect(published).toMatchObject({ payload: { invocation: { outcome: { kind: 'success' }, routeId: 'tool:status/report', status: 'succeeded' } }, type: 'route.invocation', @@ -744,7 +916,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(republishedEpoch.activeEpoch.id).not.toBe(activeEpoch.activeEpoch.id); const republishedCounter = await counter(); const republishedIsolatedCounter = await counter(true); - expect(republishedCounter.invocation.result).toEqual({ count: 3 }); + expect(republishedCounter.invocation.result).toEqual({ count: 4 }); expect(republishedIsolatedCounter.invocation.result).toEqual({ count: 1 }); const missingApi = await fetch(`${server.url}/api/nope`); @@ -819,9 +991,6 @@ it('enforces compiled preflight, MCP schemas, and operator env across production async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), { timeout: 10_000 }, ).toBe(200); - const artifact = server.status().artifact; - if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); - await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); const invalidResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 1 }, routeId: 'tool:status/report' }), @@ -839,6 +1008,9 @@ it('enforces compiled preflight, MCP schemas, and operator env across production }); expect(await readdir(join(project.root, '.agent-bundle'))).not.toContain('handler-ran'); + const artifact = server.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected an active compiled epoch.'); + await writeFile(join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, '.env'), 'OPERATOR_VALUE=layered\n'); const invoke = async (surface: { readonly args: readonly string[]; readonly command: string; readonly kind: 'cli' } | { readonly kind: 'mcp' }) => { const response = await fetch(`${server!.url}/api/routes/invocations`, { body: JSON.stringify({ diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index b24fe0c93..e5c5c9e89 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -2,12 +2,16 @@ import { existsSync, readFileSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { expect, it } from '@rstest/core'; +import { expect, it, rs } from '@rstest/core'; +import type { TraceEntry, TraceEntryInput } from '../src/dev/trace/trace-entry.ts'; +import type { TracePublisher } from '../src/dev/trace/trace-hub.ts'; import type { RouteInvocation } from '../src/dev/routes/route-invocation-result.ts'; import { InvocationRingBuffer, + ROUTE_INVOCATION_ALREADY_FINAL_CODE, ROUTE_INVOCATION_STALE_REVISION_CODE, RouteInvocationService, RouteInvocationRequestError, @@ -46,6 +50,7 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ input: {}, kind: 'tool', manifestDigest: 'digest', + outcome: { kind: 'success' }, projection: {}, providers: [], routeId: 'tool:fixture/echo', @@ -69,6 +74,27 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ }], }); +const collectingTrace = (): Readonly<{ + readonly entries: TraceEntryInput[]; + readonly publisher: TracePublisher; +}> => { + const entries: TraceEntryInput[] = []; + return { + entries, + publisher: { + publish: (input): TraceEntry => { + entries.push(input); + return { + ...input, + id: `trace-${String(entries.length)}`, + occurredAt: input.occurredAt ?? '2026-09-05T00:00:00.000Z', + sequence: entries.length, + }; + }, + }, + }; +}; + it('strictly validates invocation request fields and event options', () => { expect(parseRouteInvocationRequest({ correlationId: 'browser-1', @@ -104,6 +130,7 @@ it('strictly validates invocation request fields and event options', () => { { routeId: '' }, { routeId: 'tool:x/y', unknown: true }, { args: ['ok', 1], routeId: 'cli:x' }, + { requestId: '', routeId: 'tool:x/y' }, { event: { host: 'claude' }, routeId: 'event:tool/after' }, { mode: 'preview', routeId: 'tool:x/y' }, { routeId: 'event:tool/after', surface: { host: 'other', kind: 'event' } }, @@ -146,6 +173,226 @@ it('retains a bounded newest-first invocation history', () => { expect(history.read('inv_two')?.id).toBe('inv_two'); }); +it('publishes correlated invocation and kernel entries with slim details', async () => { + const route = { + config: [], + id: 'tool:fixture/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:fixture', + source: 'src/mcp/fixture/tools/echo.tsx', + } as const; + const trace = collectingTrace(); + let currentTime = Date.parse('2026-09-05T00:00:00.000Z'); + const service = new RouteInvocationService({ + manifest: { + manifest: () => ({ + diagnostics: [], + digest: 'digest', + events: [], + providers: [{ id: 'provider:clock', name: 'clock', source: 'src/providers/clock.ts' }], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [route] }], + sourceRevision: 'revision', + }), + }, + now: () => new Date(currentTime += 5), + prepared: async () => ({ + project: { + artifact: { epochId: 'epoch-1', target: 'claude' }, + manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }, + release: () => undefined, + }), + renderChild: async (_request, _signal, publishKernelEvent) => { + publishKernelEvent({ + at: 8, + count: 1, + durationMs: 3, + execution: { + event: 'tool/before', + executionId: 'execution-1', + host: 'claude', + nativeEvent: 'PreToolUse', + }, + kind: 'providers.finish', + phase: 'providers', + sequence: 0, + }); + const document = { + root: { kind: 'text' as const, text: 'Echo' }, + status: 'success' as const, + version: 1 as const, + }; + return { + document, + events: [{ document, sequence: 1, type: 'complete' }], + input: { value: 'echo' }, + mcp: { content: [] }, + renderDurationMs: 4, + }; + }, + trace: trace.publisher, + }); + + const result = await service.invoke({ + correlationId: 'correlation-1', + input: { value: 'echo' }, + routeId: route.id, + }); + + expect(result.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); + expect(trace.entries).toEqual([ + expect.objectContaining({ + correlation: { + correlationId: 'correlation-1', + epochId: 'epoch-1', + invocationId: result.id, + routeId: route.id, + }, + details: { status: 'running' }, + href: `/routes/mcp/fixture/tool/echo?invocation=${result.id}`, + kind: 'invocation.started', + source: 'invocation', + status: 'running', + summary: 'MCP tool fixture/echo · running', + }), + expect.objectContaining({ + correlation: { + correlationId: 'correlation-1', + epochId: 'epoch-1', + executionId: 'execution-1', + host: 'claude', + invocationId: result.id, + routeId: route.id, + }, + details: { + count: 1, + event: 'tool/before', + nativeEvent: 'PreToolUse', + phase: 'providers', + sequence: 0, + }, + durationMs: 3, + kind: 'kernel.providers.finish', + source: 'kernel', + status: 'ok', + summary: 'event tool/before (claude) · providers finished', + }), + expect.objectContaining({ + correlation: { + correlationId: 'correlation-1', + epochId: 'epoch-1', + invocationId: result.id, + routeId: route.id, + }, + details: { + diagnosticCodes: [], + projectionKind: 'mcp', + providers: [{ name: 'clock' }], + status: 'succeeded', + }, + durationMs: 10, + href: `/routes/mcp/fixture/tool/echo?invocation=${result.id}`, + kind: 'invocation.completed', + source: 'invocation', + status: 'ok', + summary: 'MCP tool fixture/echo · 10.0 ms', + }), + ]); +}); + +it('publishes failed event invocations with native provenance', async () => { + const route = { + config: [], + event: 'tool/after', + id: 'event:tool/after', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/events/tool/after.tsx', + } as const; + const trace = collectingTrace(); + let currentTime = Date.parse('2026-09-05T00:00:00.000Z'); + const service = new RouteInvocationService({ + manifest: { + manifest: () => ({ + diagnostics: [], + digest: 'digest', + events: [route], + providers: [], + scripts: [], + servers: [], + sourceRevision: 'revision', + }), + }, + now: () => new Date(currentTime += 5), + prepared: async () => ({ + project: { + artifact: { epochId: 'epoch-1', target: 'claude' }, + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }, + release: () => undefined, + }), + renderChild: async () => { + throw new Error('render exploded'); + }, + trace: trace.publisher, + }); + + const result = await service.invoke({ + input: { + cwd: '/workspace', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: {}, + tool_name: 'Write', + tool_response: { ok: true }, + tool_use_id: 'use-1', + transcript_path: '/workspace/transcript.json', + }, + routeId: route.id, + surface: { host: 'claude', kind: 'event' }, + }); + + expect(result.context.session).toEqual({ + source: 'receipt', + state: 'available', + value: { sessionId: 'session-1' }, + }); + expect(result.context.lineage).toMatchObject({ + source: 'receipt', + state: 'available', + value: { conversation: 'session-1', root: 'session-1' }, + }); + expect(trace.entries).toHaveLength(2); + expect(trace.entries[1]).toMatchObject({ + correlation: { + conversationId: 'session-1', + epochId: 'epoch-1', + host: 'claude', + invocationId: result.id, + routeId: route.id, + sessionId: 'session-1', + }, + details: { + diagnosticCodes: ['AB8236'], + projectionKind: 'none', + providers: [], + status: 'failed', + }, + durationMs: 5, + href: `/routes/events/tool/after?invocation=${result.id}`, + kind: 'invocation.failed', + source: 'invocation', + status: 'error', + summary: 'event tool/after (claude) · failed', + }); +}); + const echoRoute = { config: [], id: 'tool:fixture/echo', @@ -182,6 +429,158 @@ const preparedLease = async (project: RouteInvocationPreparedProject) => ({ release: () => undefined, }); +const streamingService = ( + renderChild: NonNullable, + trace?: TracePublisher, +): RouteInvocationService => new RouteInvocationService({ + manifest: { manifest: () => catalog('digest', 'revision') }, + prepared: () => preparedLease({ + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }), + renderChild, + ...(trace === undefined ? {} : { trace }), +}); + +it('publishes render events before the final invocation', async () => { + const release = deferred(); + const rendered = deferred(); + const document = childResult({ + context: {} as never, + input: {}, + manifest: {} as never, + routeId: echoRoute.id, + stateRoot: '/project/state', + surface: { kind: 'unit-render' }, + }).document; + const service = streamingService(async (request, _signal, _trace, publishRender) => { + publishRender({ document, sequence: 0, type: 'shell' }); + rendered.resolve(); + await release.promise; + publishRender({ document, sequence: 1, type: 'complete' }); + return { ...childResult(request), events: [ + { document, sequence: 0, type: 'shell' }, + { document, sequence: 1, type: 'complete' }, + ] }; + }); + + const started = service.start({ input: {}, routeId: echoRoute.id }); + await rendered.promise; + const messages: Parameters[1]>[0][] = []; + service.subscribe(started.invocation.id, (message) => messages.push(message)); + expect(messages.map((message) => message.type)).toEqual(['render']); + release.resolve(); + await started.result; + expect(messages.map((message) => message.type)).toEqual(['render', 'render', 'final']); +}); + +it('retains only the newest 256 render events and one truncation marker', async () => { + const release = deferred(); + const rendered = deferred(); + const document = childResult({ + context: {} as never, + input: {}, + manifest: {} as never, + routeId: echoRoute.id, + stateRoot: '/project/state', + surface: { kind: 'unit-render' }, + }).document; + const service = streamingService(async (request, _signal, _trace, publishRender) => { + for (let sequence = 0; sequence < 300; sequence += 1) { + publishRender({ document, sequence, type: 'shell' }); + } + rendered.resolve(); + await release.promise; + return childResult(request); + }); + + const started = service.start({ input: {}, routeId: echoRoute.id }); + await rendered.promise; + const messages: Parameters[1]>[0][] = []; + service.subscribe(started.invocation.id, (message) => messages.push(message)); + expect(messages.filter((message) => message.type === 'render')).toHaveLength(256); + expect(messages.filter((message) => message.type === 'truncated')).toEqual([ + { type: 'truncated' }, + ]); + expect(messages.find((message) => message.type === 'render')).toMatchObject({ + event: { sequence: 44 }, + }); + release.resolve(); + await started.result; + expect(messages.findLast((message) => message.type === 'final')).toMatchObject({ + invocation: { document }, + }); +}); + +it('cancels a running invocation without an outcome and publishes cancellation', async () => { + const startedChild = deferred(); + const trace = collectingTrace(); + let childAborted = false; + const service = streamingService((_request, signal) => new Promise((_resolve, reject) => { + startedChild.resolve(); + signal.addEventListener('abort', () => { + childAborted = true; + reject(signal.reason); + }, { once: true }); + }), trace.publisher); + + const started = service.start({ input: {}, routeId: echoRoute.id }); + await startedChild.promise; + const cancelled = await service.cancel(started.invocation.id); + + expect(childAborted).toBe(true); + expect(cancelled).toMatchObject({ id: started.invocation.id, status: 'cancelled' }); + expect(cancelled).not.toHaveProperty('outcome'); + expect(await started.result).toBe(cancelled); + expect(trace.entries.at(-1)).toMatchObject({ + kind: 'invocation.cancelled', + status: 'error', + }); +}); + +it('rejects cancellation after an invocation is final', async () => { + const service = streamingService(async (request) => childResult(request)); + const started = service.start({ input: {}, routeId: echoRoute.id }); + await started.result; + + await expect(service.cancel(started.invocation.id)).rejects.toMatchObject({ + code: ROUTE_INVOCATION_ALREADY_FINAL_CODE, + status: 409, + }); +}); + +it('rejects cancellation when completion wins the race', async () => { + const childCompleted = deferred(); + const release = deferred(); + const service = new RouteInvocationService({ + manifest: { manifest: () => catalog('digest', 'revision') }, + prepared: async () => ({ + project: { + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }, + release: () => release.promise, + }), + renderChild: async (request) => { + childCompleted.resolve(); + return childResult(request); + }, + }); + + const started = service.start({ input: {}, routeId: echoRoute.id }); + await childCompleted.promise; + const cancelled = service.cancel(started.invocation.id); + release.resolve(); + + await expect(cancelled).rejects.toMatchObject({ + code: ROUTE_INVOCATION_ALREADY_FINAL_CODE, + status: 409, + }); + await expect(started.result).resolves.toMatchObject({ status: 'succeeded' }); +}); + it('aborts and drains a running render when the service closes', async () => { let releases = 0; const started = deferred(); @@ -274,6 +673,45 @@ it('rejects a queued invocation when the published revision moves before the slo expect(releases).toBe(2); }); +it('removes a rejecting invocation from the stream registry', async () => { + const encoded = rs.spyOn(Buffer.prototype, 'toString') + .mockReturnValueOnce('0101010101010101') + .mockReturnValueOnce('0202020202020202'); + const hold = deferred(); + const firstStarted = deferred(); + let digest = 'digest-1'; + const service = new RouteInvocationService({ + concurrency: 1, + manifest: { manifest: () => catalog(digest, 'revision') }, + now: () => new Date(0), + prepared: async () => ({ + project: { + manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', + targets: ['claude'], + }, + release: () => undefined, + }), + renderChild: async (request) => { + firstStarted.resolve(); + await hold.promise; + return childResult(request); + }, + }); + + const first = service.invoke({ input: { n: 1 }, routeId: echoRoute.id }); + await firstStarted.promise; + const second = service.invoke({ input: { n: 2 }, routeId: echoRoute.id }); + digest = 'digest-2'; + hold.resolve(); + await first; + await expect(second).rejects.toMatchObject({ code: ROUTE_INVOCATION_STALE_REVISION_CODE }); + encoded.mockRestore(); + + const id = 'inv_00202020202020202'; + expect(() => service.subscribe(id, () => undefined)).toThrow(RouteInvocationRequestError); +}); + it('does not spawn a child for an invocation aborted while queued', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-queued-abort-')); const marker = join(root, 'queued-child-started'); @@ -602,6 +1040,25 @@ it('reaps the render child and its descendants when the invocation times out', { } }); +it('reaps the render child and its descendants when the invocation is cancelled', { timeout: 30_000 }, async () => { + const project = await leakingRouteProject('hang'); + try { + const service = project.service(); + const started = service.start({ input: {}, routeId: 'tool:fixture/leak', surface: { kind: 'unit-render' } }); + const pids = await recordedPids(project); + expect(alive(pids.child)).toBe(true); + expect(alive(pids.descendant)).toBe(true); + + const cancelled = await service.cancel(started.invocation.id); + expect(cancelled.status).toBe('cancelled'); + expect(alive(pids.child)).toBe(false); + expect(alive(pids.descendant)).toBe(false); + expect(await started.result).toBe(cancelled); + } finally { + await rm(project.root, { force: true, recursive: true }); + } +}); + it('reaps the render child and its descendants when the service closes mid-render', { timeout: 30_000 }, async () => { const project = await leakingRouteProject('hang'); try { @@ -623,6 +1080,136 @@ it('reaps the render child and its descendants when the service closes mid-rende } }); +it('forwards kernel events from tool and event routes rendered in the real child', { timeout: 30_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-trace-')); + const toolSource = join(root, 'src/mcp/fixture/tools/traced.tsx'); + const eventSource = join(root, 'src/events/tool/before.tsx'); + const traceModule = fileURLToPath(new URL('../src/events/trace.ts', import.meta.url)); + await Promise.all([ + mkdir(dirname(toolSource), { recursive: true }), + mkdir(dirname(eventSource), { recursive: true }), + ]); + const routeSource = (executionId: string, event: string, nativeEvent: string): string => [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + `import { createEventTracer, eventTraceExecution } from ${JSON.stringify(traceModule)};`, + '', + 'export default async function Traced() {', + ` const trace = createEventTracer({ execution: eventTraceExecution({ event: ${JSON.stringify(event)}, executionId: ${JSON.stringify(executionId)}, host: 'claude', nativeEvent: ${JSON.stringify(nativeEvent)} }) });`, + ' trace.renderStart();', + ' trace.renderFinish();', + " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'traced'));", + '}', + '', + ].join('\n'); + await Promise.all([ + writeFile(toolSource, routeSource('execution-tool', 'tool/before', 'PreToolUse')), + writeFile(eventSource, routeSource('execution-event', 'tool/before', 'PreToolUse')), + ]); + const toolRoute = { + config: {}, + id: 'tool:fixture/traced', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/fixture/tools/traced.tsx' }, + serverId: 'mcp:fixture', + source: toolSource, + } as const; + const eventRoute = { + config: { runtime: 'standalone' }, + event: 'tool/before', + id: 'event:tool/before', + kind: 'event-route', + provenance: { kind: 'conventional', relativePath: 'src/events/tool/before.tsx' }, + source: eventSource, + } as const; + const graph = { + diagnostics: [], + digest: 'digest', + events: [eventRoute], + providers: [], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [toolRoute] }], + } satisfies CompiledRouteGraph; + const manifest: RouteManifest = { + diagnostics: [], + digest: 'digest', + events: [{ + config: [], + event: eventRoute.event, + id: eventRoute.id, + kind: eventRoute.kind, + provenance: { kind: 'conventional' }, + source: eventRoute.provenance.relativePath, + }], + providers: [], + scripts: [], + servers: [{ + id: 'mcp:fixture', + mode: 'generated', + name: 'fixture', + routes: [{ + config: [], + id: toolRoute.id, + kind: toolRoute.kind, + provenance: { kind: 'conventional' }, + serverId: toolRoute.serverId, + source: toolRoute.provenance.relativePath, + }], + }], + sourceRevision: 'revision', + }; + const trace = collectingTrace(); + const service = new RouteInvocationService({ + manifest: { manifest: () => manifest }, + prepared: () => preparedLease({ + manifest: testManifestFromRouteGraph({ graph, projectRoot: root }), + stateRoot: join(root, '.agent-bundle', 'state'), + targets: ['claude'], + }), + trace: trace.publisher, + }); + try { + const tool = await service.invoke({ routeId: toolRoute.id, surface: { kind: 'unit-render' } }); + const event = await service.invoke({ input: {}, routeId: eventRoute.id, surface: { kind: 'unit-render' } }); + const kernel = trace.entries.filter((entry) => entry.source === 'kernel'); + const manualKernel = kernel.filter((entry) => + entry.correlation.executionId === 'execution-tool' + || entry.correlation.executionId === 'execution-event'); + + expect(manualKernel.map((entry) => entry.correlation)).toEqual([ + expect.objectContaining({ + executionId: 'execution-tool', + invocationId: tool.id, + routeId: toolRoute.id, + }), + expect.objectContaining({ + executionId: 'execution-tool', + invocationId: tool.id, + routeId: toolRoute.id, + }), + expect.objectContaining({ + executionId: 'execution-event', + invocationId: event.id, + routeId: eventRoute.id, + }), + expect.objectContaining({ + executionId: 'execution-event', + invocationId: event.id, + routeId: eventRoute.id, + }), + ]); + expect(manualKernel.map((entry) => entry.kind)).toEqual([ + 'kernel.render.start', + 'kernel.render.finish', + 'kernel.render.start', + 'kernel.render.finish', + ]); + } finally { + await service.close(); + await rm(root, { force: true, recursive: true }); + } +}); + const clockProvider = { id: 'provider:clock', name: 'clock', diff --git a/packages/agent-bundle/tests/trace-dev-server.test.ts b/packages/agent-bundle/tests/trace-dev-server.test.ts new file mode 100644 index 000000000..cd3aaf35f --- /dev/null +++ b/packages/agent-bundle/tests/trace-dev-server.test.ts @@ -0,0 +1,262 @@ +import { spawn } from 'node:child_process'; +import { cp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { startForegroundServer } from '../src/dev/foreground-server.ts'; +import type { TraceHub } from '../src/dev/trace/trace-hub.ts'; +import type { TraceReplay } from '../src/dev/trace/trace-entry.ts'; +import type { EventTraceReceipt } from '../src/events/trace-receipt.ts'; +import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; +import { startDevServer } from '../src/dev/workbench-server.ts'; +import { createProjectFixture } from './helpers/project-fixture.ts'; +import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; +import { replaceWatchedSourceAndAwaitRebuild } from './support/watched-files.ts'; + +const runHook = ( + entry: string, + input: Readonly>, +): Promise> => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr.on('data', (chunk) => { stderr += String(chunk); }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stderr, stdout })); + child.stdin.end(JSON.stringify(input)); +}); + +it('serves replay and live trace entries and lowers build failures', { timeout: 60_000 }, async () => { + const project = await createProjectFixture({ + config: [ + 'export default {', + " plugin: { name: 'trace-dev-server', version: '1.0.0' },", + " targets: ['claude'],", + '};', + '', + ].join('\n'), + files: { + 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', + 'src/events/tool/before.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + "export const config = { runtime: 'standalone', targets: ['claude'] };", + '', + 'export default async function BeforeTool({ native }) {', + " return createElement(Agent.Result, null, createElement(Agent.Context, null, `Observed ${native.tool_name}.`));", + '}', + '', + ].join('\n'), + 'src/mcp/status/tools/report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ready: z.boolean() }).strict();', + 'export default async function Report() {', + " return createElement(Agent.Text, null, 'Ready.');", + '}', + '', + ].join('\n'), + }, + prefix: 'agent-bundle-trace-dev-server-', + }); + const assetsRoot = join(project.root, 'workbench'); + const reportPath = join(project.root, 'src/mcp/status/tools/report.tsx'); + let server: Awaited> | undefined; + let trace: TraceHub | undefined; + await mkdir(assetsRoot, { recursive: true }); + await Promise.all([ + symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'), + writeFile(join(assetsRoot, 'index.html'), 'Trace'), + ]); + try { + server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + port: 0, + root: project.root, + testing: { + startForegroundServer: async (options) => { + trace = options.trace; + return startForegroundServer(options); + }, + }, + }); + if (trace === undefined) throw new Error('Expected the dev server to compose a TraceHub.'); + const bootstrap = await fetch(`${server.url}/api/project/session`, { + headers: { 'sec-fetch-site': 'same-origin' }, + }); + const session = await bootstrap.json() as { readonly token: string }; + const cookie = bootstrap.headers.get('set-cookie')!.split(';', 1)[0]!; + const headers = { + origin: server.url, + 'x-agent-bundle-session': session.token, + }; + try { + await expect.poll( + async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), + { timeout: 10_000 }, + ).toBe(200); + } catch (error) { + throw new Error(`Route manifest did not become ready: ${JSON.stringify(server.status())}`, { cause: error }); + } + + trace.publish({ + correlation: { invocationId: 'inv_replay', routeId: 'tool:status/report' }, + href: '/routes/mcp/status/tool/report?invocation=inv_replay', + kind: 'invocation.completed', + source: 'invocation', + status: 'ok', + summary: 'Replay entry.', + }); + const replayResponse = await fetch(`${server.url}/api/trace?after=0`, { headers }); + expect(replayResponse.status).toBe(200); + const replay = await replayResponse.json() as TraceReplay; + expect(replay.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'invocation.completed', summary: 'Replay entry.' }), + ])); + + const receiptRecordPath = join(project.root, '.agent-bundle', 'hook-receipts.json'); + const receiptEndpoint = JSON.parse(await readFile(receiptRecordPath, 'utf8')) as { + readonly token: string; + readonly url: string; + }; + expect(receiptEndpoint.url).toBe(server.url); + const receipt: EventTraceReceipt = { + events: [ + { at: 100, kind: 'execute.start', phase: 'execute', runtime: 'standalone', sequence: 0 }, + { at: 102, kind: 'render.start', phase: 'render', sequence: 1 }, + { at: 105, durationMs: 3, kind: 'render.finish', phase: 'render', sequence: 2 }, + ], + execution: { + event: 'tool/before', + executionId: 'trace-dev-server-receipt', + host: 'claude', + nativeEvent: 'PreToolUse', + }, + identity: { + conversationId: 'conversation-receipt', + requestId: 'request-receipt', + sessionId: 'session-receipt', + }, + lineage: { reason: 'not-provided', state: 'unavailable' }, + startedAt: '2026-09-05T15:00:00.000Z', + version: 1, + }; + const browserReceipt = await fetch(`${server.url}/api/trace/receipts`, { + body: JSON.stringify(receipt), + headers: { + 'content-type': 'application/json', + cookie, + origin: server.url, + 'x-agent-bundle-session': session.token, + }, + method: 'POST', + }); + expect(browserReceipt.status).toBe(403); + await expect(browserReceipt.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8247', + message: 'Hook receipts are not accepted from a browser.', + }, + }); + const postedReceipt = await fetch(`${server.url}/api/trace/receipts`, { + body: JSON.stringify(receipt), + headers: { + authorization: `Bearer ${receiptEndpoint.token}`, + 'content-type': 'application/json', + }, + method: 'POST', + }); + expect(postedReceipt.status).toBe(204); + const receiptReplayResponse = await fetch(`${server.url}/api/trace?after=0`, { headers }); + const receiptReplay = await receiptReplayResponse.json() as TraceReplay; + expect(receiptReplay.entries.filter((entry) => entry.correlation.executionId === 'trace-dev-server-receipt')) + .toEqual([ + expect.objectContaining({ kind: 'hook.received', source: 'hook' }), + expect.objectContaining({ kind: 'hook.completed', source: 'hook' }), + ]); + + const artifact = server.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected an active artifact for the hook wrapper.'); + const artifactRoot = join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id); + const installedRoot = join(project.root, 'installed-claude'); + await cp(artifactRoot, installedRoot, { recursive: true }); + await writeFile(join(installedRoot, '.agent-bundle-dev.json'), `${JSON.stringify({ + epochId: artifact.activeEpoch.id, + host: 'claude', + projectRoot: project.root, + schemaVersion: 1, + })}\n`); + const hookEntryName = (await readdir(join(installedRoot, 'hooks'))) + .find((name) => name.endsWith('.mjs')); + if (hookEntryName === undefined) throw new Error('Expected a generated hook wrapper.'); + const hostedHook = await runHook(join(installedRoot, 'hooks', hookEntryName), { + cwd: project.root, + hook_event_name: 'PreToolUse', + session_id: 'session-marker', + tool_input: { command: 'echo marker-discovery' }, + tool_name: 'Bash', + tool_use_id: 'request-marker', + transcript_path: join(project.root, 'transcript.jsonl'), + }); + expect(hostedHook.code, hostedHook.stderr).toBe(0); + const markerTraceResponse = await fetch(`${server.url}/api/trace?after=0`, { headers }); + const markerTrace = await markerTraceResponse.json() as TraceReplay; + expect(markerTrace.entries.filter((entry) => entry.correlation.requestId === 'request-marker')) + .toEqual([ + expect.objectContaining({ kind: 'hook.received', source: 'hook' }), + expect.objectContaining({ kind: 'hook.completed', source: 'hook' }), + ]); + + const stream = await fetch(`${server.url}/api/trace/stream?after=${trace.latestSequence}`, { headers }); + expect(stream.status).toBe(200); + trace.publish({ + correlation: { mcpSessionId: 'mcp_1' }, + kind: 'mcp.request', + source: 'mcp', + status: 'running', + summary: 'Live entry.', + }); + const reader = stream.body?.getReader(); + if (reader === undefined) throw new Error('Expected a trace stream body.'); + const frame = await reader.read(); + expect(new TextDecoder().decode(frame.value)).toContain('"kind":"mcp.request"'); + await reader.cancel(); + + const failed = await replaceWatchedSourceAndAwaitRebuild( + server, + project.root, + reportPath, + [ + "import './missing.js';", + "export default function Report() { return 'broken'; }", + '', + ].join('\n'), + { timeoutMs: 10_000 }, + ); + expect(failed.outcome).toBe('failed'); + await expect.poll(async () => { + const response = await fetch(`${server!.url}/api/trace?after=0`, { headers }); + const current = await response.json() as TraceReplay; + return current.entries.find((entry) => entry.kind === 'diagnostic.build.failed'); + }, { timeout: 10_000 }).toMatchObject({ + href: '/problems', + source: 'diagnostic', + status: 'error', + }); + + await server.close(); + server = undefined; + expect(trace.closed).toBe(true); + await expect(readFile(receiptRecordPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await server?.close().catch(() => undefined); + await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 }); + } +}); diff --git a/packages/agent-bundle/tests/trace-hub.test.ts b/packages/agent-bundle/tests/trace-hub.test.ts new file mode 100644 index 000000000..8e31e2962 --- /dev/null +++ b/packages/agent-bundle/tests/trace-hub.test.ts @@ -0,0 +1,218 @@ +import { Buffer } from 'node:buffer'; + +import { expect, it } from '@rstest/core'; + +import { ProjectEventHub } from '../src/dev/events.ts'; +import { TraceHub, TraceHubError } from '../src/dev/trace/trace-hub.ts'; +import { attachProjectEventTrace } from '../src/dev/trace/trace-project-events.ts'; + +const input = (summary: string) => ({ + correlation: {}, + kind: 'diagnostic.build.failed', + source: 'diagnostic' as const, + summary, +}); + +it('sanitizes every wire string and bounds oversized details', () => { + const hub = new TraceHub({ + entryByteLimit: 16 * 1024, + now: () => new Date('2026-09-05T12:00:00.000Z'), + projectRoot: '/work/project', + }); + + const entry = hub.publish({ + ...input('Failed\n/work/project/src/index.ts'), + details: { + nested: ['See\t/work/project/src/index.ts', 'x'.repeat(20 * 1024)], + }, + }); + + expect(entry.summary).toBe('Failed/src/index.ts'); + expect(entry.details).toBe('[UNAVAILABLE]'); + expect(Buffer.byteLength(JSON.stringify(entry), 'utf8')).toBeLessThanOrEqual(16 * 1024); + expect(JSON.stringify(entry)).not.toContain('/work/project'); +}); + +it('rejects an unknown source at the runtime boundary', () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + + expect(() => hub.publish({ ...input('ignored'), source: 'unknown' as never })).toThrow(TypeError); + expect(hub.latestSequence).toBe(0); +}); + +it('evicts by total encoded bytes and reports the resulting replay gap', () => { + const hub = new TraceHub({ + encodedHistoryByteLimit: 520, + entryLimit: 10, + projectRoot: '/work/project', + }); + hub.publish({ ...input('one'), details: { value: 'x'.repeat(120) } }); + hub.publish({ ...input('two'), details: { value: 'x'.repeat(120) } }); + hub.publish({ ...input('three'), details: { value: 'x'.repeat(120) } }); + + const replay = hub.replay({ afterSequence: 0 }); + + expect(replay.gap).toMatchObject({ + requestedAfterSequence: 0, + type: 'trace.gap', + }); + expect(replay.entries.at(-1)?.summary).toBe('three'); + expect(Buffer.byteLength(JSON.stringify(replay.entries), 'utf8')).toBeLessThanOrEqual(520); +}); + +it('keeps replay and reentrant live delivery ordered without duplicates', () => { + const hub = new TraceHub({ entryLimit: 2, projectRoot: '/work/project' }); + hub.publish(input('one')); + hub.publish(input('two')); + hub.publish(input('three')); + const received: string[] = []; + + hub.subscribe((message) => { + received.push('type' in message ? message.type : `${message.sequence}:${message.summary}`); + if ('type' in message) hub.publish(input('four')); + }, { afterSequence: 0 }); + + expect(received).toEqual(['trace.gap', '2:two', '3:three', '4:four']); +}); + +it('closes only the slow subscriber when reentrant publication exceeds its pending cap', () => { + const hub = new TraceHub({ + projectRoot: '/work/project', + subscriberByteLimit: 4_096, + subscriberEntryLimit: 2, + }); + hub.publish(input('one')); + const slow = hub.subscribe((message) => { + if (!('type' in message) && message.sequence === 2) { + for (let index = 0; index < 8; index += 1) hub.publish(input(`flood-${index}`)); + } + }, { afterSequence: 1 }); + const healthy: number[] = []; + hub.subscribe((message) => { + if (!('type' in message)) healthy.push(message.sequence); + }, { afterSequence: 1 }); + + hub.publish(input('two')); + + expect(slow.closed).toBe(true); + expect(healthy).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 10]); +}); + +it('rejects invalid and ahead cursors and closes subscriptions with the hub', () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + hub.publish(input('one')); + expect(() => hub.replay({ afterSequence: -1 })).toThrow(TraceHubError); + expect(() => hub.replay({ afterSequence: 2 })).toThrow(TraceHubError); + const subscription = hub.subscribe(() => undefined, { afterSequence: 1 }); + + hub.close(); + + expect(subscription.closed).toBe(true); + expect(() => hub.replay()).toThrow(TraceHubError); +}); + +it('lowers failed project diagnostics with their available correlation', () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + const events = new ProjectEventHub(); + const detach = attachProjectEventTrace(hub, events); + + events.publish({ + payload: { + completedAt: '2026-09-05T12:00:01.000Z', + diagnostics: [{ code: 'BUILD', message: 'Broken /work/project/src/index.ts', severity: 'error' }], + id: 'build-1', + outcome: 'failed', + sourceRevision: 'source-1', + startedAt: '2026-09-05T12:00:00.000Z', + }, + type: 'build.failed', + }); + events.publish({ + epochId: 'epoch-1', + payload: { + diagnostics: [{ code: 'CONTRACT', message: 'Route failed.', severity: 'error' }], + epochId: 'epoch-1', + failures: [{ checks: ['schema'], routeId: 'tool:status/report' }], + state: 'failed', + summary: 'Contract gate failed.', + }, + type: 'dev.contract.status', + }); + events.publish({ + epochId: 'epoch-1', + payload: { + diagnostics: [{ code: 'HOST', message: 'Host sync failed.', severity: 'error' }], + epochId: 'epoch-1', + host: 'claude', + state: 'failed', + }, + type: 'dev.host.sync', + }); + detach(); + + expect(hub.replay().entries).toMatchObject([ + { + details: { + buildId: 'build-1', + diagnostics: [{ message: 'Broken /src/index.ts' }], + }, + href: '/problems', + kind: 'diagnostic.build.failed', + source: 'diagnostic', + status: 'error', + }, + { + correlation: { epochId: 'epoch-1', routeId: 'tool:status/report' }, + href: '/problems', + kind: 'diagnostic.contract.failed', + status: 'error', + }, + { + correlation: { epochId: 'epoch-1', host: 'claude' }, + href: '/problems', + kind: 'diagnostic.host.sync', + status: 'error', + }, + ]); +}); + +it('ignores successful diagnostics and route invocations owned by their publishing services', () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + const events = new ProjectEventHub(); + attachProjectEventTrace(hub, events); + + events.publish({ + epochId: 'epoch-1', + payload: { + diagnostics: [], + epochId: 'epoch-1', + failures: [], + state: 'passed', + summary: 'Contract gate passed.', + }, + type: 'dev.contract.status', + }); + events.publish({ + payload: { + invocation: { + completedAt: '2026-09-05T12:00:01.000Z', + diagnostics: [], + id: 'inv_1', + input: {}, + kind: 'tool', + manifestDigest: 'manifest-1', + outcome: { kind: 'success' }, + routeId: 'tool:status/report', + source: 'src/mcp/status/tools/report.tsx', + sourceRevision: 'source-1', + startedAt: '2026-09-05T12:00:00.000Z', + status: 'succeeded', + surface: { kind: 'mcp' }, + timings: [], + }, + }, + type: 'route.invocation', + }); + + expect(hub.replay().entries).toEqual([]); +}); diff --git a/packages/agent-bundle/tests/trace-routes.test.ts b/packages/agent-bundle/tests/trace-routes.test.ts new file mode 100644 index 000000000..d76d1571d --- /dev/null +++ b/packages/agent-bundle/tests/trace-routes.test.ts @@ -0,0 +1,105 @@ +import { expect, it } from '@rstest/core'; + +import { TraceHub } from '../src/dev/trace/trace-hub.ts'; +import { TraceRoutes } from '../src/dev/trace/trace-routes.ts'; +import { + authorizeSession as authorize, + sessionHeaders as headers, + startRoutes as startRouteServer, +} from './support/route-harness.ts'; + +const startRoutes = async (hub: TraceHub) => + startRouteServer(new TraceRoutes({ authorize, hub }), { closeMode: 'awaited' }); + +const publish = (hub: TraceHub, summary: string) => hub.publish({ + correlation: {}, + kind: 'diagnostic.build.failed', + source: 'diagnostic', + summary, +}); + +it('requires the foreground session guard', async () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + publish(hub, 'Build failed.'); + const started = await startRoutes(hub); + try { + const response = await fetch(`${started.url}/api/trace`); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + diagnostic: { code: 'AB8004', message: 'A valid same-session token is required.' }, + }); + } finally { + await started.close(); + } +}); + +it('maps invalid, ahead, and closed cursors to trace diagnostics', async () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + publish(hub, 'Build failed.'); + const started = await startRoutes(hub); + try { + const invalid = await fetch(`${started.url}/api/trace?after=-1`, { headers: headers() }); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toEqual({ + diagnostic: { code: 'AB8240', message: 'Trace cursor is not valid.' }, + }); + + const ahead = await fetch(`${started.url}/api/trace/stream?after=2`, { headers: headers() }); + expect(ahead.status).toBe(409); + await expect(ahead.json()).resolves.toEqual({ + diagnostic: { code: 'AB8241', message: 'Trace cursor is ahead of retained history.' }, + }); + + hub.close(); + const closed = await fetch(`${started.url}/api/trace`, { headers: headers() }); + expect(closed.status).toBe(503); + await expect(closed.json()).resolves.toEqual({ + diagnostic: { code: 'AB8242', message: 'Trace routes are not available.' }, + }); + } finally { + await started.close(); + } +}); + +it('replays the TraceReplay contract and streams ordered NDJSON messages', async () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + publish(hub, 'one'); + const started = await startRoutes(hub); + try { + const replay = await fetch(`${started.url}/api/trace?after=0`, { headers: headers() }); + expect(replay.status).toBe(200); + await expect(replay.json()).resolves.toMatchObject({ + entries: [{ sequence: 1, summary: 'one' }], + latestSequence: 1, + }); + + const stream = await fetch(`${started.url}/api/trace/stream?after=1`, { headers: headers() }); + expect(stream.status).toBe(200); + publish(hub, 'two'); + const reader = stream.body?.getReader(); + if (reader === undefined) throw new Error('Expected an NDJSON stream body.'); + const frame = await reader.read(); + expect(new TextDecoder().decode(frame.value)).toContain('"sequence":2'); + await reader.cancel(); + } finally { + await started.close(); + } +}); + +it('owns stream shutdown and releases the hub subscription', async () => { + const hub = new TraceHub({ projectRoot: '/work/project' }); + const started = await startRoutes(hub); + try { + const stream = await fetch(`${started.url}/api/trace/stream?after=0`, { headers: headers() }); + const reader = stream.body?.getReader(); + if (reader === undefined) throw new Error('Expected an NDJSON stream body.'); + const pending = reader.read(); + + await started.routes.close(); + + await expect(pending).resolves.toMatchObject({ done: true }); + expect(hub.subscriptionCount).toBe(0); + } finally { + await started.close(); + } +}); diff --git a/packages/workbench/src/advanced/advanced-page.tsx b/packages/workbench/src/advanced/advanced-page.tsx index 5df32facc..7d343bc4a 100644 --- a/packages/workbench/src/advanced/advanced-page.tsx +++ b/packages/workbench/src/advanced/advanced-page.tsx @@ -74,9 +74,10 @@ const downloadMcpFile = ({ blob, filename }: McpDownload): void => downloadBlob( * published epoch's servers as advisory defaults. Unmounting closes any App * preview the page opened; the session controller itself outlives the section. */ -const ProtocolSection = ({ appClient, artifactClient, protocol, status }: { +const ProtocolSection = ({ appClient, artifactClient, onNavigate, protocol, status }: { readonly appClient: McpAppClient; readonly artifactClient: Pick; + readonly onNavigate: (location: WorkbenchLocation) => void; readonly protocol: AdvancedProtocolSession; readonly status: ProjectStatus; }) => { @@ -110,6 +111,7 @@ const ProtocolSection = ({ appClient, artifactClient, protocol, status }: { inspectorLaunch={protocol.inspectorLaunch} onDownloadConfig={downloadMcpFile} onDownloadTrace={downloadMcpFile} + onNavigate={onNavigate} onResetSession={protocol.onResetSession} presentationActive={true} serverCatalogState={serverCatalogState} @@ -119,14 +121,14 @@ const ProtocolSection = ({ appClient, artifactClient, protocol, status }: { ; }; -const AdvancedSectionContent = ({ clients, manifestSourceRevision, protocol, section, status }: Omit) => { +const AdvancedSectionContent = ({ clients, manifestSourceRevision, onNavigate, protocol, section, status }: AdvancedPageProps) => { switch (section) { case 'evals': return ; case 'artifact': return ; case 'protocol': - return ; + return ; case 'hosts': return ; case 'logs': diff --git a/packages/workbench/src/application/app-route-workspace.tsx b/packages/workbench/src/application/app-route-workspace.tsx index a24dc57a9..4bf829dba 100644 --- a/packages/workbench/src/application/app-route-workspace.tsx +++ b/packages/workbench/src/application/app-route-workspace.tsx @@ -17,11 +17,11 @@ import { workbenchMcpAppHostContext, type McpAppJsonValue, type McpAppPreviewPro import { McpAppPreview } from '../mcp/mcp-app-preview.tsx'; import { McpJsonInput } from '../mcp/mcp-json-input.tsx'; import { supportedMcpAppPreviewProfiles } from '../mcp/mcp-page.tsx'; -import { createMcpSessionController, type McpSessionController } from '../mcp/mcp-session-controller.ts'; +import { createMcpSessionController, type McpSessionController, type McpSessionControllerRequest } from '../mcp/mcp-session-controller.ts'; import type { McpBrowserSessionModel } from '../mcp/mcp-session-model.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; -import { WorkspaceHeader } from './executable-route-workspace.tsx'; +import { newCorrelationId, WorkspaceHeader } from './executable-route-workspace.tsx'; import { displayAgentDocumentValue } from './rendered-document.tsx'; import { publishedEpochFor, type WorkspaceClients } from './workspace-contracts.ts'; import './workspace.css'; @@ -68,6 +68,20 @@ export const orderedToolsForApp = (tools: readonly McpCatalogTool[], resourceUri ...tools.filter((tool) => resourceUri === undefined || tool.resourceUri !== resourceUri), ]); +/** + * The tool call the App workspace hands the session controller: plain MCP params + * plus the Workbench correlation, which the route stamps into `_meta` itself + * (a browser-sent `_meta` is refused with `AB8016`). + */ +export const appToolCallRequest = ( + name: string, + input: JsonObject, + correlationId: string, +): Pick => Object.freeze({ + correlationId, + request: Object.freeze({ arguments: input, name }), +}); + interface ToolCall { readonly input: JsonObject; readonly result: McpAppJsonValue; @@ -138,10 +152,11 @@ export const AppRouteWorkspace = ({ clients, leaf, onNavigate, status }: AppRout setCalling(true); setCallError(undefined); const sessionId = model.sessionId; + const correlationId = newCorrelationId(); void controller.invoke({ id: `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, operation: 'callTool', - request: { arguments: input, name: tool.name }, + ...appToolCallRequest(tool.name, input, correlationId), }).then( (result) => { setCall(Object.freeze({ input, result: result as McpAppJsonValue, sessionId, toolName: tool.name })); }, (reason: unknown) => { setCallError(errorMessage(reason, 'The tool call failed.')); }, diff --git a/packages/workbench/src/application/dev-server-backend.ts b/packages/workbench/src/application/dev-server-backend.ts index 6e6a2fb70..1d850e659 100644 --- a/packages/workbench/src/application/dev-server-backend.ts +++ b/packages/workbench/src/application/dev-server-backend.ts @@ -3,7 +3,7 @@ import type { } from '../../../agent-bundle/src/contracts/invocations.ts'; import type { ProjectEventMessage } from '../../../agent-bundle/src/contracts/project.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; -import type { InvocationBackend } from './invocation-backend.ts'; +import type { InvocationBackend, InvocationBackendUpdate } from './invocation-backend.ts'; import type { InvocationClient } from './invocation-client.ts'; export interface DevServerBackendOptions { @@ -19,6 +19,8 @@ export const createDevServerBackend = ({ }: DevServerBackendOptions): InvocationBackend => Object.freeze({ accepts: (leaf: ApplicationLeaf): boolean => leaf.execution === 'invoke' && leaf.routeId !== undefined, + cancel: (invocationId: string, signal?: AbortSignal) => + client.cancel(invocationId, signal), history: async (leaf: ApplicationLeaf, signal?: AbortSignal) => { if (leaf.routeId === undefined) return Object.freeze([]); const invocations = await client.list(50, signal); @@ -28,11 +30,19 @@ export const createDevServerBackend = ({ _leaf: ApplicationLeaf, request: RouteInvocationRequest, signal?: AbortSignal, - ) => client.invoke(request, signal), + listener?: (update: InvocationBackendUpdate) => void, + ) => listener === undefined + ? client.invoke(request, signal) + : client.start(request, signal).then((started) => { + listener(started); + return client.stream(started.id, listener, signal); + }), kind: 'dev-server', read: (invocationId: string, signal?: AbortSignal) => client.read(invocationId, signal), subscribe: (listener: Parameters[0]) => events.subscribe((event) => { - if (event.type === 'route.invocation') listener(event.payload.invocation); + if (event.type === 'route.invocation' && event.payload.invocation.status !== 'running') { + listener(event.payload.invocation); + } }), }); diff --git a/packages/workbench/src/application/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx index b206d3a2b..006f97cbe 100644 --- a/packages/workbench/src/application/event-route-workspace.tsx +++ b/packages/workbench/src/application/event-route-workspace.tsx @@ -16,6 +16,7 @@ import type { JsonObject } from '../../../agent-bundle/src/contracts/strict-json import { errorMessage } from '../client-helpers.ts'; import type { Lifecycle, LifecycleClient, LifecycleTarget } from '../lifecycles/lifecycle-client.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { TraceClient } from '../trace/trace-client.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; import { ExecutableRouteWorkspace } from './executable-route-workspace.tsx'; import { outcomeLabel, statusLabel } from './invocation-model.ts'; @@ -213,9 +214,11 @@ const ReplayTab = ({ controller, defaultHost, lifecycle }: { export interface EventRouteWorkspaceProps { readonly clients: Pick; readonly controller: RouteInvocationController; + readonly invocationId?: string; readonly leaf: ApplicationLeaf; readonly onNavigate: (location: WorkbenchLocation) => void; readonly tab?: string; + readonly trace?: TraceClient; } const useLifecycle = (client: LifecycleClient, leaf: ApplicationLeaf): LifecycleState => { @@ -233,7 +236,7 @@ const useLifecycle = (client: LifecycleClient, leaf: ApplicationLeaf): Lifecycle }; /** Host selector → executable body with the event codec tabs. */ -export const EventRouteWorkspace = ({ clients, controller, leaf, onNavigate, tab }: EventRouteWorkspaceProps): React.ReactNode => { +export const EventRouteWorkspace = ({ clients, controller, invocationId, leaf, onNavigate, tab, trace }: EventRouteWorkspaceProps): React.ReactNode => { const lifecycleState = useLifecycle(clients.lifecycleClient, leaf); const lifecycle = lifecycleState.state === 'ready' ? lifecycleState.lifecycle : undefined; const fixtures = useMemo(() => eventFixturesFor(lifecycle), [lifecycle]); @@ -301,11 +304,13 @@ export const EventRouteWorkspace = ({ clients, controller, leaf, onNavigate, tab fixtures={hostFixtures} inputKey={host === 'canonical' ? leaf.key : `${leaf.key}#${host}`} inputLeaf={host === 'canonical' ? leaf : nativeLeaf} + invocationId={invocationId} key={host} leaf={leaf} onNavigate={onNavigate} requestFor={(draft) => eventRequestFor(host, draft)} tab={tab} + trace={trace} toolbar={toolbar} />; }; diff --git a/packages/workbench/src/application/executable-route-workspace.tsx b/packages/workbench/src/application/executable-route-workspace.tsx index 1fa7fa9ee..cf0db6e9f 100644 --- a/packages/workbench/src/application/executable-route-workspace.tsx +++ b/packages/workbench/src/application/executable-route-workspace.tsx @@ -15,6 +15,7 @@ import type { } from '../../../agent-bundle/src/contracts/invocations.ts'; import { errorMessage, isAbortError, isRecord } from '../client-helpers.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { TraceClient } from '../trace/trace-client.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; import type { InvocationBackend } from './invocation-backend.ts'; import { @@ -57,7 +58,7 @@ const failureOf = (reason: unknown): { readonly code: string; readonly message: return Object.freeze({ code, message: errorMessage(reason, 'The invocation request failed.') }); }; -const newCorrelationId = (): string => { +export const newCorrelationId = (): string => { const random = globalThis.crypto; return random !== undefined && typeof random.randomUUID === 'function' ? random.randomUUID() @@ -133,7 +134,25 @@ export const useRouteInvocation = ({ backends, invocationId, leaf }: UseRouteInv const next: RouteInvocationRequest = Object.freeze({ ...draft, correlationId, routeId }); setRequest(next); dispatch({ correlationId, startedAt: Date.now(), type: 'start' }); - void backend.invoke(leaf, next, controller.signal).then( + void backend.invoke(leaf, next, controller.signal, (update) => { + if ('status' in update) { + dispatch({ invocationId: update.id, type: 'stream.start' }); + return; + } + switch (update.type) { + case 'render': + dispatch({ event: update.event, type: 'render' }); + break; + case 'final': + case 'trace': + case 'truncated': + break; + default: { + const exhaustive: never = update; + return exhaustive; + } + } + }).then( (invocation) => { if (!controller.signal.aborted) dispatch({ completedAt: Date.now(), invocation, type: 'settle' }); }, (reason: unknown) => { if (controller.signal.aborted || isAbortError(reason)) return; @@ -142,14 +161,22 @@ export const useRouteInvocation = ({ backends, invocationId, leaf }: UseRouteInv ); }, [backend, leaf, routeId]); + const cancel = useCallback((): void => { + if (backend?.cancel === undefined || state.phase !== 'running' || state.invocationId === undefined) return; + void backend.cancel(state.invocationId).catch((reason: unknown) => { + if (!isAbortError(reason)) dispatch({ completedAt: Date.now(), failure: failureOf(reason), type: 'fail' }); + }); + }, [backend, state]); + return useMemo(() => Object.freeze({ ...(backend === undefined ? {} : { backendKind: backend.kind }), + cancel, history, load, ...(request === undefined ? {} : { request }), run, state, - }), [backend, history, load, request, run, state]); + }), [backend, cancel, history, load, request, run, state]); }; const stateSummary = (state: InvocationState): string => { @@ -160,8 +187,10 @@ const stateSummary = (state: InvocationState): string => { return 'Running…'; case 'succeeded': return `${statusLabel('succeeded')}${state.durationMs === undefined ? '' : ` in ${String(state.durationMs)} ms`}`; - case 'failed': - return `${statusLabel('failed')}${state.durationMs === undefined ? '' : ` after ${String(state.durationMs)} ms`}`; + case 'failed': { + const status = state.invocation?.status ?? 'failed'; + return `${statusLabel(status)}${state.durationMs === undefined ? '' : ` after ${String(state.durationMs)} ms`}`; + } default: { const exhaustive: never = state; return exhaustive; @@ -172,7 +201,7 @@ const stateSummary = (state: InvocationState): string => { const InvocationStatusLine = ({ backendKind, state }: { readonly backendKind?: string; readonly state: InvocationState }): React.ReactNode => { const invocation = invocationOf(state); return

- {stateSummary(state)} + {stateSummary(state)} {invocation?.outcome === undefined ? undefined : } {backendKind === undefined ? undefined : via {backendKind}} {invocation === undefined ? undefined : {invocation.id}} @@ -205,11 +234,14 @@ export interface ExecutableRouteWorkspaceProps { readonly inputLeaf?: ApplicationLeaf; /** Where the last input persists; defaults to the leaf key. */ readonly inputKey?: string; + /** The snapshot requested by the current deep link. */ + readonly invocationId?: string; readonly leaf: ApplicationLeaf; readonly onNavigate: (location: WorkbenchLocation) => void; /** Adds request options (an event host, a fixture id) to what the editor produced. */ readonly requestFor?: (draft: RouteInvocationDraft, fixtureId?: string) => RouteInvocationDraft; readonly tab?: string; + readonly trace?: TraceClient; /** Rendered between the header and the editor (the event host selector). */ readonly toolbar?: React.ReactNode; } @@ -257,11 +289,13 @@ export const ExecutableRouteWorkspace = ({ fixtures, inputKey, inputLeaf, + invocationId, leaf, onNavigate, requestFor, tab, toolbar, + trace, }: ExecutableRouteWorkspaceProps): React.ReactNode => { const editorLeaf = inputLeaf ?? leaf; const projectedTool = leaf.ref.kind === 'tool' && leaf.command?.projection !== undefined; @@ -344,6 +378,7 @@ export const ExecutableRouteWorkspace = ({ }; const failed = controller.state.phase === 'failed' ? controller.state : undefined; + const missingDeepLink = invocationId !== undefined && failed?.failure?.code === 'AB8231'; return

@@ -368,10 +403,24 @@ export const ExecutableRouteWorkspace = ({ value={input} /> + {controller.state.phase === 'running' + ? + : undefined} {failed === undefined || (failed.diagnostics.length === 0 && failed.failure === undefined) ? undefined : } - + {missingDeepLink + ?
+

Invocation not in this session

+

Invocation {invocationId} is not in this session.

+
+ : }
; + invoke( + leaf: ApplicationLeaf, + request: RouteInvocationRequest, + signal?: AbortSignal, + listener?: (update: InvocationBackendUpdate) => void, + ): Promise; + cancel?(invocationId: string, signal?: AbortSignal): Promise; /** Recent invocations of the leaf this backend knows about, newest first. */ history(leaf: ApplicationLeaf, signal?: AbortSignal): Promise; /** Loads one invocation snapshot by id (deep links, trace entries). */ diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 9f984f6e6..2f6dee33f 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -2,11 +2,14 @@ import { z } from 'zod'; import type { EventTraceEvent, + RunningRouteInvocation, RouteInvocation, RouteInvocationRequest, + RouteInvocationStreamMessage, RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import { parseJsonWithoutDuplicateKeys } from '../../../agent-bundle/src/contracts/strict-json.ts'; import { agentDocumentSchema, agentRenderEventSchema, @@ -139,7 +142,7 @@ const invocationSummaryFields = { source: z.string(), sourceRevision: textSchema, startedAt: textSchema, - status: z.enum(['failed', 'succeeded']), + status: z.enum(['cancelled', 'failed', 'succeeded']), surface: invocationSurfaceSchema, timings: z.array(timingSchema), } as const; @@ -160,6 +163,20 @@ const invocationSchema: z.ZodType = z.strictObject({ trace: z.array(eventTraceSchema).optional(), }).refine(outcomeMatchesStatus); const invocationResponseSchema = z.strictObject({ invocation: invocationSchema }); +const runningInvocationSchema: z.ZodType = z.strictObject({ + id: textSchema, + routeId: textSchema, + startedAt: textSchema, + status: z.literal('running'), + surface: invocationSurfaceSchema, +}); +const runningInvocationResponseSchema = z.strictObject({ invocation: runningInvocationSchema }); +const streamMessageSchema: z.ZodType = z.discriminatedUnion('type', [ + z.strictObject({ event: agentRenderEventSchema, type: z.literal('render') }), + z.strictObject({ event: eventTraceSchema, type: z.literal('trace') }), + z.strictObject({ type: z.literal('truncated') }), + z.strictObject({ invocation: invocationSchema, type: z.literal('final') }), +]); const invocationListResponseSchema = z.strictObject({ invocations: z.array(invocationSummarySchema), }); @@ -233,6 +250,74 @@ export class InvocationClient { return invocationBody(body); } + async start(request: RouteInvocationRequest, signal?: AbortSignal): Promise { + const response = await this.#foreground.protectedRequest('/api/routes/invocations', { + body: JSON.stringify({ ...request, stream: true }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + ...(signal === undefined ? {} : { signal }), + }); + const body = await bodyFor(response); + if (!response.ok) throw responseError(body, response.status); + const decoded = runningInvocationResponseSchema.safeParse(body); + if (!decoded.success) throw invalid('Route invocation start returned an invalid response.'); + return Object.freeze(decoded.data.invocation); + } + + async stream( + id: string, + listener: (message: RouteInvocationStreamMessage) => void, + signal?: AbortSignal, + ): Promise { + const response = await this.#foreground.protectedRequest( + `/api/routes/invocations/${opaqueInvocationId(id)}/stream`, + signal === undefined ? {} : { signal }, + ); + if (!response.ok) throw responseError(await bodyFor(response), response.status); + if (response.body === null) throw invalid('Route invocation stream returned no body.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let buffered = ''; + let final: RouteInvocation | undefined; + for (;;) { + const next = await reader.read(); + buffered += decoder.decode(next.value, { stream: !next.done }); + let boundary = buffered.indexOf('\n\n'); + while (boundary !== -1) { + const frame = buffered.slice(0, boundary); + buffered = buffered.slice(boundary + 2); + const lines = frame.split('\n'); + const event = lines.find((line) => line.startsWith('event: '))?.slice(7); + const data = lines.find((line) => line.startsWith('data: '))?.slice(6); + if (event === undefined || data === undefined) throw invalid('Route invocation stream returned an invalid frame.'); + let parsed: unknown; + try { + parsed = parseJsonWithoutDuplicateKeys(data); + } catch { + throw invalid('Route invocation stream returned an invalid frame.'); + } + const decoded = streamMessageSchema.safeParse(parsed); + if (!decoded.success || decoded.data.type !== event) throw invalid('Route invocation stream returned an invalid frame.'); + listener(decoded.data); + if (decoded.data.type === 'final') final = decoded.data.invocation; + boundary = buffered.indexOf('\n\n'); + } + if (next.done) break; + } + if (final === undefined) throw invalid('Route invocation stream ended without a final invocation.'); + return final; + } + + async cancel(id: string, signal?: AbortSignal): Promise { + const response = await this.#foreground.protectedRequest( + `/api/routes/invocations/${opaqueInvocationId(id)}/cancel`, + { method: 'POST', ...(signal === undefined ? {} : { signal }) }, + ); + const body = await bodyFor(response); + if (!response.ok) throw responseError(body, response.status); + return invocationBody(body); + } + async list(limit = 50, signal?: AbortSignal): Promise { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) { throw invalid('Route invocation list limit must be an integer from 1 through 50.'); diff --git a/packages/workbench/src/application/invocation-model.ts b/packages/workbench/src/application/invocation-model.ts index 5fd8d7fed..4ff670c08 100644 --- a/packages/workbench/src/application/invocation-model.ts +++ b/packages/workbench/src/application/invocation-model.ts @@ -14,6 +14,7 @@ import { parseJsonWithoutDuplicateKeys, type JsonValue, } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import type { AgentRenderEvent } from '../runtime/agent-document-client.ts'; import { snapshotStrictJsonValue } from '../strict-json.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; import type { InvocationBackend } from './invocation-backend.ts'; @@ -26,7 +27,13 @@ export interface InvocationFailure { /** The workspace's view of one run: idle, running, or settled with the envelope (or the transport failure). */ export type InvocationState = | Readonly<{ readonly phase: 'idle' }> - | Readonly<{ readonly correlationId: string; readonly phase: 'running'; readonly startedAt: number }> + | Readonly<{ + readonly correlationId: string; + readonly events?: readonly AgentRenderEvent[]; + readonly invocationId?: string; + readonly phase: 'running'; + readonly startedAt: number; + }> | Readonly<{ readonly durationMs?: number; readonly invocation: RouteInvocation; readonly phase: 'succeeded' }> | Readonly<{ readonly diagnostics: readonly Diagnostic[]; @@ -42,6 +49,8 @@ export type InvocationAction = | Readonly<{ readonly completedAt: number; readonly invocation: RouteInvocation; readonly type: 'settle' }> /** The backend rejected (transport, malformed request, unknown route). */ | Readonly<{ readonly completedAt: number; readonly failure: InvocationFailure; readonly type: 'fail' }> + | Readonly<{ readonly event: AgentRenderEvent; readonly type: 'render' }> + | Readonly<{ readonly invocationId: string; readonly type: 'stream.start' }> /** A snapshot loaded by id (deep link, trace entry) — no timing of our own. */ | Readonly<{ readonly invocation: RouteInvocation; readonly type: 'load' }> | Readonly<{ readonly type: 'reset' }>; @@ -52,6 +61,9 @@ const settled = (invocation: RouteInvocation, durationMs?: number): InvocationSt export const idleInvocationState: InvocationState = Object.freeze({ phase: 'idle' }); +/** Matches RouteInvocationService's retained render-event bound. */ +const maximumLiveRenderEvents = 256; + export const reduceInvocationState = (state: InvocationState, action: InvocationAction): InvocationState => { switch (action.type) { case 'start': @@ -65,6 +77,14 @@ export const reduceInvocationState = (state: InvocationState, action: Invocation failure: action.failure, phase: 'failed', }); + case 'render': + return state.phase === 'running' + ? Object.freeze({ ...state, events: Object.freeze([...(state.events ?? []), action.event].slice(-maximumLiveRenderEvents)) }) + : state; + case 'stream.start': + return state.phase === 'running' + ? Object.freeze({ ...state, invocationId: action.invocationId }) + : state; case 'load': return settled(action.invocation); case 'reset': @@ -112,6 +132,8 @@ export const writeLastInput = (leafKey: string, input: JsonValue): void => { /** The execution status as the UI words it: the boundary completed or did not; never "succeeded", which the outcome decides. */ export const statusLabel = (status: RouteInvocationStatus): string => { switch (status) { + case 'cancelled': + return 'Cancelled'; case 'succeeded': return 'Completed'; case 'failed': diff --git a/packages/workbench/src/application/rendered-document.tsx b/packages/workbench/src/application/rendered-document.tsx index ba41d360c..54756da98 100644 --- a/packages/workbench/src/application/rendered-document.tsx +++ b/packages/workbench/src/application/rendered-document.tsx @@ -223,7 +223,7 @@ export const RenderedAgentDocument = ({ emptyLabel, events, streaming = false }: {fold.document === undefined ? undefined : Version {String(fold.document.version)}} {fold.progress === undefined || fold.complete ? undefined - : + : {agentDocumentProgressLabel(fold.progress)} } diff --git a/packages/workbench/src/application/result-tabs.tsx b/packages/workbench/src/application/result-tabs.tsx index ee144a6be..531bb724f 100644 --- a/packages/workbench/src/application/result-tabs.tsx +++ b/packages/workbench/src/application/result-tabs.tsx @@ -5,21 +5,28 @@ * produced them), and this leaf's trace as secondary tabs. Event workspaces * append their codec panes through `extraTabs`. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import type { RouteInvocation, RouteInvocationOutcome, RouteInvocationStatus, - RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; +import { + isTraceReplayGap, + type TraceEntry, +} from '../../../agent-bundle/src/contracts/trace.ts'; +import { ShellLink } from '../shell/shell-link.tsx'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { TraceClient } from '../trace/trace-client.ts'; import type { ApplicationLeaf } from './application-tree-model.ts'; import { outcomeLabel, statusLabel } from './invocation-model.ts'; import { agentRenderEventLabel, displayAgentDocumentValue, RenderedAgentDocument } from './rendered-document.tsx'; import { invocationOf, type RouteInvocationController, type WorkspaceResultTab } from './workspace-contracts.ts'; import './workspace.css'; +type Navigate = (location: WorkbenchLocation) => void; + export interface ResultTabDefinition { readonly id: WorkspaceResultTab; readonly label: string; @@ -31,9 +38,10 @@ export interface ResultTabsProps { /** Codec panes appended after the core tabs (event workspaces). */ readonly extraTabs?: readonly ResultTabDefinition[]; readonly leaf: ApplicationLeaf; - readonly onNavigate: (location: WorkbenchLocation) => void; + readonly onNavigate?: Navigate; readonly onTabChange: (tab: WorkspaceResultTab) => void; readonly tab: WorkspaceResultTab; + readonly trace?: TraceClient; } const coreTabLabels: Readonly> = Object.freeze({ @@ -54,11 +62,6 @@ const formatTime = (iso: string): string => { return Number.isNaN(date.getTime()) ? iso : date.toLocaleTimeString(); }; -const durationOf = (summary: Pick): string => { - const ms = new Date(summary.completedAt).getTime() - new Date(summary.startedAt).getTime(); - return Number.isFinite(ms) && ms >= 0 ? `${String(ms)} ms` : '—'; -}; - /** Whether the execution boundary completed — never the run's verdict, which {@link OutcomeBadge} carries. */ export const StatusBadge = ({ status }: { readonly status: RouteInvocationStatus }): React.ReactNode => {statusLabel(status)}; @@ -121,42 +124,95 @@ const CliProjection = ({ invocation }: { readonly invocation?: RouteInvocation }
; }; -const TraceList = ({ current, history, leaf, onNavigate, onSelect }: { - readonly current?: string; - readonly history: readonly RouteInvocationSummary[]; - readonly leaf: ApplicationLeaf; - readonly onNavigate: (location: WorkbenchLocation) => void; - readonly onSelect: (invocationId: string) => void; -}): React.ReactNode => history.length === 0 - ?

No invocations of this route have been recorded in this dev session.

- :
    - {history.map((summary) =>
  1. - -
  2. )} +const traceMatches = ( + entry: TraceEntry, + invocationId: string, + correlationId: string | undefined, +): boolean => entry.correlation.invocationId === invocationId || + (correlationId !== undefined && entry.correlation.correlationId === correlationId); + +const orderedTraceEntries = ( + entries: readonly TraceEntry[], + invocationId: string, + correlationId: string | undefined, +): readonly TraceEntry[] => entries + .filter((entry) => traceMatches(entry, invocationId, correlationId)) + .sort((left, right) => left.sequence - right.sequence); + +const TraceRow = ({ entry, onNavigate }: { readonly entry: TraceEntry; readonly onNavigate?: Navigate }): React.ReactNode =>
  3. + + + {entry.kind.replaceAll('.', ' · ')} + {entry.summary} + {entry.durationMs === undefined ? '—' : `${String(entry.durationMs)} ms`} + +
  4. ; + +export const TraceTimeline = ({ correlationId, entries, invocationId, onNavigate }: { + readonly correlationId?: string; + readonly entries: readonly TraceEntry[]; + readonly invocationId: string; + readonly onNavigate?: Navigate; +}): React.ReactNode => { + const matching = orderedTraceEntries(entries, invocationId, correlationId); + if (matching.length === 0) { + return

    No correlated trace entries have arrived for this invocation.

    ; + } + const kernel = matching.filter((entry) => entry.source === 'kernel'); + const outer = matching.filter((entry) => entry.source !== 'kernel'); + return
      + {outer.map((entry, index) => + + {index === 0 && kernel.length > 0 + ?
    1. +
        {kernel.map((phase) => )}
      +
    2. + : undefined} +
      )} + {outer.length === 0 ? kernel.map((entry) => ) : undefined}
    ; +}; + +type TraceLoadState = + | Readonly<{ readonly state: 'loading' }> + | Readonly<{ readonly entries: readonly TraceEntry[]; readonly state: 'ready' }>; + +const useTraceEntries = (trace: TraceClient | undefined): TraceLoadState => { + const [state, setState] = useState({ state: 'loading' }); + useEffect(() => { + if (trace === undefined) return; + const controller = new AbortController(); + void trace.replay().then((replay) => { + if (controller.signal.aborted) return; + setState({ entries: replay.entries, state: 'ready' }); + return trace.stream(replay.latestSequence, (message) => { + if (isTraceReplayGap(message)) return; + setState((current) => { + const entries = current.state === 'ready' ? current.entries : []; + return { + entries: Object.freeze([...entries.filter((entry) => entry.id !== message.id), message]), + state: 'ready', + }; + }); + }, controller.signal); + }).catch(() => { + if (!controller.signal.aborted) setState({ entries: Object.freeze([]), state: 'ready' }); + }); + return () => controller.abort(); + }, [trace]); + return state; +}; /** The tabbed result pane; `rendered` is the default and always present. */ -export const ResultTabs = ({ controller, extraTabs = [], leaf, onNavigate, onTabChange, tab }: ResultTabsProps): React.ReactNode => { +export const ResultTabs = ({ controller, extraTabs = [], leaf, onNavigate, onTabChange, tab, trace }: ResultTabsProps): React.ReactNode => { const invocation = invocationOf(controller.state); const running = controller.state.phase === 'running'; + const events = running ? controller.state.events ?? [] : invocation?.events ?? []; + const traceState = useTraceEntries(trace); const definitions: readonly ResultTabDefinition[] = [ { id: 'rendered', label: coreTabLabels.rendered, render: () => }, { id: 'structured', label: coreTabLabels.structured, render: () => }, @@ -164,18 +220,25 @@ export const ResultTabs = ({ controller, extraTabs = [], leaf, onNavigate, onTab ...(invocation?.projection.mcp === undefined ? [] : [{ id: 'mcp' as const, label: coreTabLabels.mcp, render: () => }]), ...(invocation?.projection.cli === undefined ? [] : [{ id: 'cli' as const, label: coreTabLabels.cli, render: () => }]), ...extraTabs, - { id: 'trace', label: coreTabLabels.trace, render: () => }, + { id: 'trace', label: coreTabLabels.trace, render: () => invocation === undefined + ?

    Run the route to see its correlated trace.

    + : <> +
    + + {invocation.outcome === undefined ? undefined : } +
    + {traceState.state === 'loading' + ?

    Loading correlated trace…

    + : } + }, ]; const active = definitions.find((definition) => definition.id === tab) ?? definitions[0]!; const panel = panelId(leaf.key); return
    + {invocation?.correlationId === undefined ? undefined :
    + Open in Trace +
    }
    {definitions.map((definition) =>
    ; -const InvokeWorkspace = ({ backends, clients, invocationId, leaf, onNavigate, tab }: RouteWorkspaceProps): React.ReactNode => { +const InvokeWorkspace = ({ backends, clients, invocationId, leaf, onNavigate, tab, trace }: RouteWorkspaceProps): React.ReactNode => { const controller = useRouteInvocation({ backends, ...(invocationId === undefined ? {} : { invocationId }), leaf }); return leaf.ref.kind === 'event' - ? - : ; + ? + : ; }; /** Mounts the workspace body the selected leaf's execution kind calls for. */ diff --git a/packages/workbench/src/application/workspace-contracts.ts b/packages/workbench/src/application/workspace-contracts.ts index 63346605b..a3294204f 100644 --- a/packages/workbench/src/application/workspace-contracts.ts +++ b/packages/workbench/src/application/workspace-contracts.ts @@ -19,6 +19,7 @@ import type { McpAppClient } from '../mcp/mcp-app-client.ts'; import type { ForegroundRouteClient, McpRouteClient } from '../mcp/mcp-route-client.ts'; import type { SkillClient } from '../skill-client.ts'; import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { TraceClient } from '../trace/trace-client.ts'; import type { ApplicationLeaf, ApplicationTree } from './application-tree-model.ts'; import type { InvocationBackend, InvocationBackendKind } from './invocation-backend.ts'; import type { InvocationState } from './invocation-model.ts'; @@ -63,6 +64,8 @@ export interface RouteWorkspaceProps { readonly status: ProjectStatus; /** Deep-linked result tab (`?tab=`); the workspace falls back to `rendered`. */ readonly tab?: string; + /** Unified trace transport; supplied by the Workbench root once connected. */ + readonly trace?: TraceClient; readonly tree: ApplicationTree; } @@ -93,6 +96,8 @@ export type RouteInvocationDraft = Omit void; /** Recent invocations of this leaf, newest first (the Trace tab). */ readonly history: readonly RouteInvocationSummary[]; /** Loads one snapshot by id into `state` (trace entries, deep links). */ diff --git a/packages/workbench/src/application/workspace.css b/packages/workbench/src/application/workspace.css index e79555797..4130e73a8 100644 --- a/packages/workbench/src/application/workspace.css +++ b/packages/workbench/src/application/workspace.css @@ -40,6 +40,8 @@ .route-run, .route-input-editor .route-run { background: #0b5bd3; border: 1px solid #0b5bd3; border-radius: 4px; color: #fff; cursor: pointer; font-size: 13px; font-weight: 700; min-width: 96px; padding: 8px 18px; } .route-run:hover:not(:disabled) { background: #0a4fb8; } .route-run:disabled { cursor: not-allowed; opacity: .55; } +.route-cancel { background: #fff; border: 1px solid #b31b23; border-radius: 4px; color: #b31b23; cursor: pointer; justify-self: start; padding: 6px 14px; } +.route-cancel:disabled { cursor: not-allowed; opacity: .55; } .route-input-shortcut { color: #7a8492; font-size: 11px; } .route-input-error { color: #aa1f2a; display: block; font-size: 11px; margin-top: 4px; } @@ -64,6 +66,8 @@ /* Result tabs */ .result-tabs { display: grid; gap: 0; min-width: 0; } +.result-actions { display: flex; justify-content: flex-end; margin-bottom: 4px; } +.result-actions a { color: #0b5bd3; font-size: 12px; font-weight: 700; } .result-tablist { border-bottom: 1px solid #d9dee7; display: flex; flex-wrap: wrap; gap: 2px; } .result-tab { background: transparent; border: 0; border-bottom: 2px solid transparent; color: #596372; cursor: pointer; font-size: 13px; font-weight: 650; margin-bottom: -1px; padding: 9px 12px; } .result-tab:hover { color: #1e2938; } @@ -79,7 +83,15 @@ .result-cli { display: grid; gap: 8px; } .result-cli h3 { color: #35445a; font-size: 12px; margin: 6px 0 0; text-transform: uppercase; } .result-cli-exit { font-size: 13px; margin: 0; } +.result-trace-verdict { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } .result-trace { display: grid; gap: 2px; list-style: none; margin: 0; padding: 0; } +.result-trace-row a { align-items: baseline; border-left: 3px solid transparent; color: #1e2938; display: grid; gap: 12px; grid-template-columns: 90px 180px minmax(0, 1fr) 70px; padding: 7px 10px; text-decoration: none; } +.result-trace-row a:hover { background: #e8effb; } +.result-trace-row--error a { background: #fff7f7; border-left-color: #c01d26; color: #78242a; } +.result-trace-kind { font: 11px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; } +.result-trace-summary { overflow-wrap: anywhere; } +.result-trace-kernel { border-left: 1px solid #c9d4e4; margin: 0 0 4px 26px; padding-left: 10px; } +.result-trace-kernel ol { display: grid; gap: 2px; list-style: none; margin: 0; padding: 0; } .result-trace-entry button { align-items: center; background: transparent; border: 0; border-left: 3px solid transparent; color: #1e2938; cursor: pointer; display: flex; flex-wrap: wrap; font-size: 12px; gap: 12px; padding: 8px 10px; text-align: left; width: 100%; } .result-trace-entry button:hover { background: #e8effb; } .result-trace-entry--current button { background: #e6effd; border-left-color: #0b5bd3; } @@ -89,6 +101,9 @@ .result-trace-time, .result-trace-duration, .result-trace-id { color: #596372; font: 11px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .result-trace-id { flex: 1 1 120px; min-width: 0; } .result-trace-host { border: 1px solid #c9d4e4; border-radius: 999px; color: #375271; font-size: 11px; font-weight: 750; padding: 1px 7px; text-transform: capitalize; } +.result-missing-invocation { background: #f7f9fc; border: 1px solid #d9dee7; border-radius: 6px; padding: 18px; } +.result-missing-invocation h2 { font-size: 16px; margin: 0 0 6px; } +.result-missing-invocation p { color: #596372; margin: 0; } /* Rendered Agent Document */ .rendered-document { display: grid; gap: 12px; min-width: 0; } diff --git a/packages/workbench/src/logs/log-client.ts b/packages/workbench/src/logs/log-client.ts index 65f75546f..6e3d40f78 100644 --- a/packages/workbench/src/logs/log-client.ts +++ b/packages/workbench/src/logs/log-client.ts @@ -9,18 +9,19 @@ import { type DevLogReplay, type DevLogReplayGap, } from '../../../agent-bundle/src/contracts/dev-logs.ts'; +import { isCredentialKey, redactEvalCredentialText } from '../../../agent-bundle/src/contracts/credentials.ts'; import { parseJsonWithoutDuplicateKeys, type JsonValue, } from '../../../agent-bundle/src/contracts/strict-json.ts'; import { exactKeys, isRecord, parseStrictResponseJson, strictJsonSnapshot } from '../client-helpers.ts'; -import { isCredentialKey, redactEvalCredentialText } from '../../../agent-bundle/src/contracts/credentials.ts'; import { awaitWithAbort, ForegroundRouteClientError, type ForegroundRequestAuthority, } from '../mcp/mcp-route-client.ts'; import { deepFreeze } from '../freeze.ts'; +import { hasControlCharacters, pathLikeText } from '../shell/wire-text.ts'; export interface LogClientOptions { @@ -56,12 +57,9 @@ const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/u; const safeInteger = (value: unknown, minimum = 0): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum; const isDate = (value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; -const safeProjectRelativePath = /(?:\/[A-Za-z0-9._@+-]+)*/gu; const isSafeWireText = (value: unknown, maximum = maximumLogFrameBytes): value is string => { if (typeof value !== 'string' || value.length === 0 || value.length > maximum || redactEvalCredentialText(value) !== value) return false; - const withoutProjectPaths = value.replace(safeProjectRelativePath, ''); - return !hasControlOrSeparators(withoutProjectPaths) && - !/(?:^|[^A-Za-z0-9])(?:file:|[A-Za-z]:|\\\\)/iu.test(withoutProjectPaths); + return !hasControlCharacters(value) && !pathLikeText.test(value); }; const isSafeDetailKey = (value: string): boolean => !isCredentialKey(value) && !hasControlOrSeparators(value); diff --git a/packages/workbench/src/logs/logs-page.tsx b/packages/workbench/src/logs/logs-page.tsx index c5e5a4dae..79a62df59 100644 --- a/packages/workbench/src/logs/logs-page.tsx +++ b/packages/workbench/src/logs/logs-page.tsx @@ -20,22 +20,29 @@ const isCursorAhead = (reason: unknown): boolean => { catch { return false; } }; +const traceCorrelationFor = (record: DevLogRecord): string | undefined => + record.context.correlationId ?? record.context.invocationId ?? record.context.mcpSessionId; + export const LogsView = ({ view }: { readonly view: LogsViewModel }) =>
    {view.gap === undefined ? undefined :

    Earlier records are no longer retained.

    }

    {view.summary}

    {view.records.length === 0 ?

    No production log record matches this filter.

    :
      - {view.records.map((record) =>
    1. -
      - #{record.sequence} - - {record.producer} - {record.level} - {record.kind} -
      -

      {record.summary}

      -

      {Object.entries(record.context).map(([key, value]) => {key} {value})}

      -
      Details
      {JSON.stringify({ context: record.context, details: record.details }, null, 2)}
      -
    2. )} + {view.records.map((record) => { + const traceCorrelation = traceCorrelationFor(record); + return
    3. +
      + #{record.sequence} + + {record.producer} + {record.level} + {record.kind} +
      +

      {record.summary}

      +

      {Object.entries(record.context).map(([key, value]) => {key} {value})}

      + {traceCorrelation === undefined ? undefined : Open in Trace} +
      Details
      {JSON.stringify({ context: record.context, details: record.details }, null, 2)}
      +
    4. ; + })}
    }
    ; diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index ba2924d4c..be7fc1859 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -50,6 +50,7 @@ import { applicationNodePath, type WorkbenchLocation } from './shell/workbench-l import { createWorkbenchRouter, type WorkbenchRouter } from './shell/workbench-router.ts'; import { ApplicationArea, SelectRouteState, UnknownRouteState, WorkbenchShell } from './shell/workbench-shell.tsx'; import { SkillClient } from './skill-client.ts'; +import { ForegroundTraceClient, type TraceClient } from './trace/trace-client.ts'; import { TracePage } from './trace/trace-page.tsx'; import { applicationTreeSourcesFor, @@ -119,17 +120,19 @@ const createClients = () => { routeManifestClient: new RouteManifestClient({ foreground }), runtimeClient: new RuntimeClient(foreground), skillClient: new SkillClient(), + traceClient: new ForegroundTraceClient({ foreground }), }); }; type WorkbenchClients = ReturnType; -const ApplicationExplorer = ({ backends, clients, location, onNavigate, status, tree }: { +const ApplicationExplorer = ({ backends, clients, location, onNavigate, status, trace, tree }: { readonly backends: readonly InvocationBackend[]; readonly clients: WorkspaceClients; readonly location: Extract; readonly onNavigate: (location: WorkbenchLocation) => void; readonly status: ProjectStatus; + readonly trace: TraceClient; readonly tree: ApplicationTree | undefined; }) => { const [query, setQuery] = useState(''); @@ -146,6 +149,7 @@ const ApplicationExplorer = ({ backends, clients, location, onNavigate, status, onNavigate={onNavigate} status={status} tab={location.tab} + trace={trace} tree={tree} />; return { const area = ((): ReactNode => { switch (location.area) { case 'application': - return ; + return ; case 'trace': - return tree === undefined - ?

    No build has published yet; invocations appear once a route can run.

    - : ; + return ; case 'problems': return ; case 'sessions': diff --git a/packages/workbench/src/mcp/agent-bundle-remote-transport.ts b/packages/workbench/src/mcp/agent-bundle-remote-transport.ts index a146aa7d6..122e5b965 100644 --- a/packages/workbench/src/mcp/agent-bundle-remote-transport.ts +++ b/packages/workbench/src/mcp/agent-bundle-remote-transport.ts @@ -1,5 +1,6 @@ import type { JSONRPCMessage, Transport, TransportSendOptions } from '@modelcontextprotocol/client'; +import { mcpCorrelationMetaKey } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { RuntimeVector } from '../../../agent-bundle/src/contracts/runtime.ts'; import { isRecord, parseStrictResponseJson } from '../client-helpers.ts'; import { readNdjsonResponseFrames } from '../ndjson.ts'; @@ -97,8 +98,12 @@ const operationFor = (message: JsonRpcRequest): OperationResolution => { // MCP 2025-11-25 Tasks (#369): `params.task` asks the server to answer with a task handle. const task = params.task === undefined ? undefined : taskCreation(params.task); if (task === 'invalid') return { kind: 'invalid' }; + // The route refuses a browser `_meta` (AB8016) and stamps the correlation itself + // from the top-level field; the rest of `_meta` (the SDK's progress token) stays behind. + const correlationId = asRecord(params._meta)?.[mcpCorrelationMetaKey]; return { kind: 'operation', operation: { arguments: params.arguments ?? {}, + ...(typeof correlationId === 'string' && correlationId.length > 0 ? { correlationId } : {}), name: params.name, operation: 'tools/call', requestId: requestKey(message.id), diff --git a/packages/workbench/src/mcp/mcp-page.css b/packages/workbench/src/mcp/mcp-page.css index 74ca969f3..f00cced10 100644 --- a/packages/workbench/src/mcp/mcp-page.css +++ b/packages/workbench/src/mcp/mcp-page.css @@ -105,6 +105,21 @@ gap: 0.45rem; } +.mcp-page-frame-facts { + color: #a8b8ca; + display: flex; + flex-wrap: wrap; + font-size: 0.8rem; + gap: 0.35rem 0.9rem; + margin: 0 0 0.35rem; +} + +.mcp-page-frame-direction { font-weight: 700; } +.mcp-page-frame-direction--client { color: #8ab4f8; } +.mcp-page-frame-direction--server { color: #81c995; } +.mcp-page-frame-link { color: #8ab4f8; font-weight: 700; text-decoration: none; } +.mcp-page-frame-link:hover { text-decoration: underline; } + .mcp-page-binding, .mcp-page-catalog-grid { align-items: start; diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index dae6ed929..f461e34eb 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'; -import type { McpSessionBinding, McpSessionInspectorConfig, McpSessionOperation } from '../../../agent-bundle/src/contracts/mcp-session.ts'; +import type { McpSessionBinding, McpSessionInspectorConfig, McpSessionOperation, McpSessionTraceMeta } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { DevRuntimeMcpAppRunBinding } from '../../../agent-bundle/src/contracts/runtime.ts'; import { isRecord } from '../client-helpers.ts'; import type { @@ -27,13 +27,18 @@ import { } from './mcp-app-client.ts'; import { createMcpAppFrameRelay } from '../../../agent-bundle/src/web-host/browser/frame-relay.ts'; import { mcpInspectorDeepLink, type McpInspectorLaunchModel } from './mcp-inspector-launch-model.ts'; -import type { - McpBrowserSessionInvocation, - McpBrowserSessionModel, - McpBrowserSessionTimelineEntry, +import { + isMcpFrameEntry, + mcpFrameMetaKeys, + type McpBrowserSessionFrameEntry, + type McpBrowserSessionInvocation, + type McpBrowserSessionModel, + type McpBrowserSessionTimelineEntry, } from './mcp-session-model.ts'; import type { McpSessionControllerBinding, McpSessionControllerReplay, McpSessionControllerRequest } from './mcp-session-controller.ts'; import type { McpToolPrefill } from '../routes/routes-model.ts'; +import { ShellLink } from '../shell/shell-link.tsx'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; import { mcpProtocolTraceDownload, type McpDownload, @@ -72,6 +77,8 @@ interface McpPageCommonProps { readonly inspectorLaunch?: McpPageInspectorLaunch; readonly onDownloadConfig?: (download: McpConfigDownload) => void; readonly onDownloadTrace?: (download: McpDownload) => void; + /** The shell router; a frame's lifted `correlationId` links to the unified Trace through it. */ + readonly onNavigate?: (location: WorkbenchLocation) => void; /** Replaces the terminal controller with a fresh idle controller in the parent. */ readonly onResetSession?: () => void; /** Lets the host serialize a Runtime departure through this Page's existing preview lifecycle. */ @@ -158,6 +165,7 @@ export type McpConfigDownload = McpDownload; export interface McpProtocolEvidenceProps { readonly ariaLabel: string; + readonly onNavigate?: (location: WorkbenchLocation) => void; readonly protocol?: unknown; readonly trace: readonly unknown[]; } @@ -933,11 +941,41 @@ export const mcpAppConsentDetailsSummary = (request: unknown): string => { return boundedConsentSummary(`${summary}${fingerprint === undefined ? '' : `; action reference: ${fingerprint}`}`); }; +const frameMetaLabels: Readonly> = Object.freeze({ + conversationId: 'conversation', + correlationId: 'correlation', + requestId: 'request', + sessionId: 'session', +}); + +/** The keys the server lifted beside a raw frame; the correlation opens the unified Trace filtered to it. */ +const McpFrameFacts = ({ frame, onNavigate }: { + readonly frame: McpBrowserSessionFrameEntry; + readonly onNavigate?: (location: WorkbenchLocation) => void; +}): React.ReactNode => { + const meta = frame.meta ?? {}; + return

    + {frame.direction === 'client' ? 'client → server' : 'server → client'} + {frame.method === undefined ? undefined : method {frame.method}} + {frame.id === undefined ? undefined : id {frame.id}} + {mcpFrameMetaKeys.map((key) => { + const value = meta[key]; + if (value === undefined) return undefined; + return {frameMetaLabels[key]} {key === 'correlationId' + ? {value} + : {value}}; + })} +

    ; +}; + /** Read-only provider evidence shared by the live MCP page and Runtime Inspector. */ -export const McpProtocolEvidence = ({ ariaLabel, protocol, trace }: McpProtocolEvidenceProps): React.ReactNode =>
    +export const McpProtocolEvidence = ({ ariaLabel, onNavigate, protocol, trace }: McpProtocolEvidenceProps): React.ReactNode =>

    {ariaLabel}

    {protocol === undefined ? undefined :
    Protocol
    {display(protocol)}
    } - {trace.length === 0 ?

    No protocol evidence yet.

    :
      {trace.map((entry, index) =>
    1. {display(entry)}
    2. )}
    } + {trace.length === 0 ?

    No protocol evidence yet.

    :
      {trace.map((entry, index) =>
    1. + {isMcpFrameEntry(entry) ? : undefined} +
      {display(entry)}
      +
    2. )}
    }
    ; const errorMessage = (reason: unknown): string => reason instanceof Error ? reason.message : 'The MCP session action failed.'; @@ -1250,7 +1288,7 @@ const mcpPageInspectorStatusLine = ( }; export const McpPage = (props: McpPageProps) => { - const { controller, initialBinding, initialPreview, initialToolPrefill, inspectorLaunch, onDownloadConfig, onDownloadTrace, onResetSession, registerPreviewClose } = props; + const { controller, initialBinding, initialPreview, initialToolPrefill, inspectorLaunch, onDownloadConfig, onDownloadTrace, onNavigate, onResetSession, registerPreviewClose } = props; const runtimeProps: McpPageRuntimeProps | undefined = 'runtimePreviewDependencies' in props ? props : undefined; const artifactProps: McpPageArtifactProps | undefined = 'runtimePreviewDependencies' in props ? undefined : props; const [runtimeAdmission] = useState(() => runtimeProps === undefined @@ -1851,7 +1889,7 @@ export const McpPage = (props: McpPageProps) => {
    {traceTab === 'raw' - ? + ? : <>

    {traceLabel}

    {traceEntries.length === 0 ?

    No {traceLabel.toLowerCase()} entries yet.

    :
      {traceEntries.map((entry, index) =>
    1. {display(traceValue(entry))}
    2. )}
    }} diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index 737b6626c..dc6c23218 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -90,6 +90,8 @@ export type McpRouteOperation = | Readonly<{ readonly operation: 'resources/read'; readonly uri: string }> | Readonly<{ readonly arguments: Readonly>; + /** The Workbench correlation id; the route stamps it into `params._meta` itself and refuses a browser-sent `_meta` (`AB8016`). */ + readonly correlationId?: string; readonly name: string; readonly operation: 'tools/call'; readonly requestId?: string; diff --git a/packages/workbench/src/mcp/mcp-session-controller.ts b/packages/workbench/src/mcp/mcp-session-controller.ts index f0c316f3b..e41752310 100644 --- a/packages/workbench/src/mcp/mcp-session-controller.ts +++ b/packages/workbench/src/mcp/mcp-session-controller.ts @@ -7,12 +7,13 @@ import { type TransportSendOptions, } from '@modelcontextprotocol/client'; -import { isMcpSessionTarget } from '../../../agent-bundle/src/contracts/mcp-session.ts'; +import { isMcpSessionTarget, mcpCorrelationMetaKey } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { McpSessionBinding, McpSessionInspectorConfig, McpSessionOperation, McpSessionTraceEntry, + McpSessionTraceMeta, McpSessionTraceReplayGap, } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { @@ -32,6 +33,7 @@ import { AgentBundleRemoteTransport, dispatchAgentBundleMcpRequest, type AgentBu import { invocationHistoryFor, createMcpBrowserSessionModel, + mcpFrameMetaKeys, reduceMcpBrowserSession, type McpBrowserSessionConnection, type McpBrowserSessionDiagnostic, @@ -65,6 +67,8 @@ export type McpSessionControllerBinding = export type McpSessionControllerOperation = Exclude; export interface McpSessionControllerRequest { + /** The Workbench correlation id for a tool call; reaches the route as the top-level `correlationId`. */ + readonly correlationId?: string; readonly id: string; readonly operation: McpSessionControllerOperation; readonly request: Readonly>; @@ -386,6 +390,27 @@ const validSequence = (value: unknown): value is number => const validCursor = (value: unknown): value is number => validSequence(value) && value > 0; +/** The server bounds every lifted frame key at 256 characters; anything else on the wire is a corrupt frame. */ +const maxFrameKeyLength = 256; + +const frameKey = (value: unknown): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0 || value.length > maxFrameKeyLength) throw invalidTrace(); + return value; +}; + +const knownFrameMetaKeys: ReadonlySet = new Set(mcpFrameMetaKeys); + +const frameMeta = (value: unknown): McpSessionTraceMeta | undefined => { + if (value === undefined) return undefined; + if (!isRecord(value) || Object.keys(value).some((key) => !knownFrameMetaKeys.has(key))) throw invalidTrace(); + const meta: McpSessionTraceMeta = Object.fromEntries(mcpFrameMetaKeys.flatMap((key) => { + const text = frameKey(value[key]); + return text === undefined ? [] : [[key, text]]; + })); + return Object.keys(meta).length === 0 ? undefined : meta; +}; + const traceEntry = (value: unknown): McpSessionTraceEntry | McpSessionTraceReplayGap => { if (!isRecord(value)) throw invalidTrace(); if (value.type === 'replay.gap') { @@ -403,7 +428,19 @@ const traceEntry = (value: unknown): McpSessionTraceEntry | McpSessionTraceRepla } if (!validCursor(value.sequence) || typeof value.occurredAt !== 'number' || !Number.isFinite(value.occurredAt)) throw invalidTrace(); if (value.kind === 'frame' && (value.direction === 'client' || value.direction === 'server')) { - return { direction: value.direction, kind: 'frame', message: value.message, occurredAt: value.occurredAt, sequence: value.sequence }; + const id = frameKey(value.id); + const meta = frameMeta(value.meta); + const method = frameKey(value.method); + return { + direction: value.direction, + ...(id === undefined ? {} : { id }), + kind: 'frame', + message: value.message, + ...(meta === undefined ? {} : { meta }), + ...(method === undefined ? {} : { method }), + occurredAt: value.occurredAt, + sequence: value.sequence, + }; } if (value.kind === 'stderr' && typeof value.text === 'string') { return { kind: 'stderr', occurredAt: value.occurredAt, sequence: value.sequence, text: value.text }; @@ -459,9 +496,18 @@ interface ControllerWireRequest { readonly resultSchema?: StandardSchemaV1; } +/** The SDK sees the correlation as MCP `_meta`; the remote transport lowers it to the route's top-level field. */ +const correlatedParams = ( + params: Readonly>, + correlationId: string | undefined, +): Readonly> => correlationId === undefined + ? params + : { ...params, _meta: { ...(isRecord(params._meta) ? params._meta : {}), [mcpCorrelationMetaKey]: correlationId } }; + const requestFor = ( operation: McpSessionControllerOperation, params: Readonly>, + correlationId: string | undefined, ): ControllerWireRequest => { if (operation === 'initialize') return { method: 'initialize' }; if (operation === 'listTools') return { method: 'tools/list' }; @@ -470,12 +516,16 @@ const requestFor = ( if (operation === 'listPrompts') return { method: 'prompts/list' }; if (operation === 'getPrompt') return { method: 'prompts/get', params }; if (operation === 'readResource') return { method: 'resources/read', params }; - if (operation === 'callTool') return { method: 'tools/call', params }; + if (operation === 'callTool') return { method: 'tools/call', params: correlatedParams(params, correlationId) }; // The 2025-11-25 Tasks utility (#369): a task-augmented call carries // `params.task`; the task operations are outside the SDK's typed method // surface, so each names the SDK schema its result is validated against. if (operation === 'callToolTask') { - return { method: 'tools/call', params: { ...params, task: isRecord(params.task) ? params.task : {} }, resultSchema: specTypeSchemas.CreateTaskResult }; + return { + method: 'tools/call', + params: correlatedParams({ ...params, task: isRecord(params.task) ? params.task : {} }, correlationId), + resultSchema: specTypeSchemas.CreateTaskResult, + }; } if (operation === 'getTask') return { method: 'tasks/get', params, resultSchema: specTypeSchemas.GetTaskResult }; if (operation === 'getTaskResult') return { method: 'tasks/result', params, resultSchema: specTypeSchemas.CallToolResult }; @@ -488,12 +538,19 @@ const runtimeRouteOperationFor = ( operation: McpSessionControllerOperation, request: Readonly>, requestId: string, + correlationId: string | undefined, ): McpRouteOperation => { if (operation === 'listTools') return { operation: 'tools/list' }; if (operation === 'listResources') return { operation: 'resources/list' }; if (operation === 'readResource' && typeof request.uri === 'string') return { operation: 'resources/read', uri: request.uri }; if (operation === 'callTool' && typeof request.name === 'string' && (request.arguments === undefined || isRecord(request.arguments))) { - return { arguments: request.arguments ?? {}, name: request.name, operation: 'tools/call', requestId }; + return { + arguments: request.arguments ?? {}, + ...(correlationId === undefined ? {} : { correlationId }), + name: request.name, + operation: 'tools/call', + requestId, + }; } throw new McpSessionControllerError(`MCP operation ${JSON.stringify(operation)} is not routed for runtime App access.`); }; @@ -1444,7 +1501,7 @@ export class McpSessionController { if (this.#requests.has(input.id)) throw new McpSessionControllerError(`MCP invocation ${JSON.stringify(input.id)} is already active.`); let operation: ControllerWireRequest; try { - operation = requestFor(input.operation, input.request); + operation = requestFor(input.operation, input.request, input.correlationId); } catch (reason) { this.#publish({ diagnostic: diagnosticFor('mcp.operation.unsupported', reason), type: 'failed' }); throw reason; @@ -1485,7 +1542,7 @@ export class McpSessionController { } let operation: McpRouteOperation; try { - operation = runtimeRouteOperationFor(input.operation, input.request, input.id); + operation = runtimeRouteOperationFor(input.operation, input.request, input.id, input.correlationId); } catch (reason) { this.#publish({ diagnostic: diagnosticFor('mcp.operation.unsupported', reason), type: 'failed' }); throw reason; diff --git a/packages/workbench/src/mcp/mcp-session-model.ts b/packages/workbench/src/mcp/mcp-session-model.ts index dd7c97d46..20d97bdb6 100644 --- a/packages/workbench/src/mcp/mcp-session-model.ts +++ b/packages/workbench/src/mcp/mcp-session-model.ts @@ -3,9 +3,11 @@ import type { McpSessionInspectorConfig, McpSessionOperation, McpSessionTraceEntry, + McpSessionTraceMeta, McpSessionTraceReplayGap, } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { DevRuntimeMcpAppRunBinding, RuntimeVector } from '../../../agent-bundle/src/contracts/runtime.ts'; +import { isRecord } from '../client-helpers.ts'; import { deepFreeze } from '../freeze.ts'; @@ -73,6 +75,17 @@ export interface McpBrowserSessionInvocationTimelineEntry { readonly type: 'invocation'; } +/** A raw JSON-RPC frame with the keys the server lifts beside it: `id`, `method`, and the known `_meta` correlation keys. */ +export type McpBrowserSessionFrameEntry = Extract; + +/** The `_meta` keys the server lifts onto a frame, in display order. */ +export const mcpFrameMetaKeys: readonly (keyof McpSessionTraceMeta)[] = Object.freeze(['correlationId', 'conversationId', 'requestId', 'sessionId']); + +/** Narrows a timeline value the Protocol page renders; the controller's strict decoder is the only producer of frames. */ +export const isMcpFrameEntry = (entry: unknown): entry is McpBrowserSessionFrameEntry => + isRecord(entry) && entry.kind === 'frame' && (entry.direction === 'client' || entry.direction === 'server') && + typeof entry.sequence === 'number'; + export type McpBrowserSessionTimelineEntry = | McpSessionTraceEntry | McpSessionTraceReplayGap diff --git a/packages/workbench/src/project-client.ts b/packages/workbench/src/project-client.ts index d5eb054b5..9ccf1268f 100644 --- a/packages/workbench/src/project-client.ts +++ b/packages/workbench/src/project-client.ts @@ -106,11 +106,15 @@ const projectEventTypes = [ 'dev.host.sync', 'invalidation', 'replay.gap', + 'route.invocation', 'runtime.event', 'source.changed', 'source.status', ] as const; +/** Activity events: they never change project status, so they do not trigger a status refresh. */ +const activityEventTypes: ReadonlySet = new Set(['route.invocation', 'runtime.event']); + const browserEvents: EventSourceFactory = (url) => new EventSource(url); const retryDelay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); const retryDelayMilliseconds = 250; @@ -692,7 +696,7 @@ export class ProjectClient { this.#publishEvent(queued.event); if (this.#closed) return; if (queued.sequence !== undefined) this.#lastEventId = queued.sequence; - if (queued.event.type !== 'runtime.event' && !synthesizedGap) this.#queueEventRefresh(); + if (!activityEventTypes.has(queued.event.type) && !synthesizedGap) this.#queueEventRefresh(); } }).finally(() => { this.#eventDrainPromise = undefined; diff --git a/packages/workbench/src/shell/shell-link.tsx b/packages/workbench/src/shell/shell-link.tsx new file mode 100644 index 000000000..17b22cfa3 --- /dev/null +++ b/packages/workbench/src/shell/shell-link.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { formatWorkbenchLocation, type WorkbenchLocation } from './workbench-location.ts'; + +export type ShellLinkProps = Readonly<{ + readonly location: WorkbenchLocation; + /** Absent when the host has no router (a static render): the anchor is then a plain `href`. */ + readonly onNavigate?: (location: WorkbenchLocation) => void; +}> & Omit, 'href' | 'onClick'>; + +/** A shell link: a real `href` for middle-click and copy, the router for a plain click. */ +export const ShellLink = ({ location, onNavigate, ...anchor }: ShellLinkProps): React.ReactNode => + { event.preventDefault(); onNavigate(location); }} + />; diff --git a/packages/workbench/src/shell/shell.css b/packages/workbench/src/shell/shell.css index 4ec0eabcb..327ce5222 100644 --- a/packages/workbench/src/shell/shell.css +++ b/packages/workbench/src/shell/shell.css @@ -164,18 +164,11 @@ .shell-actions button:disabled, .shell-primary-button:disabled { cursor: wait; opacity: .7; } .problems-banner { background: #fff8e8; border-left: 3px solid #b06c00; color: #704600; font-size: 14px; line-height: 1.45; margin: 0 0 20px; padding: 12px 14px; } -.problem-list, .trace-table { min-width: 0; } +.problem-list { min-width: 0; } .problem-source { border: 1px solid #c9d4e4; border-radius: 4px; color: #375271; font-size: 11px; font-weight: 800; letter-spacing: .04em; padding: 2px 6px; text-transform: uppercase; white-space: nowrap; } .problem-recovery { color: #596372; display: block; font-size: 13px; margin-top: 5px; } -.problem-link, .trace-link { color: #0759c7; font-weight: 700; text-decoration: none; } -.problem-link:hover, .trace-link:hover { text-decoration: underline; } -.trace-status { font-size: 12px; font-weight: 750; text-transform: capitalize; } -.trace-status--succeeded { color: #147b36; } -.trace-status--failed { color: #b31b23; } -.trace-entry { border: 1px solid #d9dee7; border-radius: 10px; display: grid; gap: 14px; padding: 22px; } -.trace-entry dl { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin: 0; } -.trace-entry dt { color: #596372; font-size: 12px; font-weight: 750; margin-bottom: 5px; } -.trace-entry dd { margin: 0; overflow-wrap: anywhere; } +.problem-link { color: #0759c7; font-weight: 700; text-decoration: none; } +.problem-link:hover { text-decoration: underline; } @keyframes shell-pulse { 0%, 100% { opacity: 1; } diff --git a/packages/workbench/src/shell/wire-text.ts b/packages/workbench/src/shell/wire-text.ts new file mode 100644 index 000000000..57a12934d --- /dev/null +++ b/packages/workbench/src/shell/wire-text.ts @@ -0,0 +1,10 @@ +export const hasControlCharacters = (value: string): boolean => { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +}; + +/** A token that is an absolute, home-relative, drive-letter, or UNC path, or a `file:` URL. */ +export const pathLikeText = /(?:^|[\s"'`([=:,])(?:\/[^\s/]+){2,}|~[\\/]|file:|(?:^|[^A-Za-z0-9])[A-Za-z]:|\\\\/u; diff --git a/packages/workbench/src/shell/workbench-location.ts b/packages/workbench/src/shell/workbench-location.ts index fbc69769a..f5d1f8084 100644 --- a/packages/workbench/src/shell/workbench-location.ts +++ b/packages/workbench/src/shell/workbench-location.ts @@ -6,13 +6,15 @@ * * / Application (no selection) * /routes/… One application leaf (see application-node.ts) - * /trace · /trace/ Live trace, one entry + * /trace · /trace/ Live trace, one selected entry * /problems Diagnostics * /sessions · /sessions/ Embedded host sessions (PR 3) * /advanced/
    evals | artifact | protocol | hosts | logs * * `?invocation=` on a route path opens that route with the named * invocation snapshot loaded; `?tab=` selects a workspace tab. + * `?correlation=` on `/trace` selects the correlated group holding any + * entry that carries that id. */ import { type ApplicationNodeRef, @@ -43,7 +45,8 @@ export type WorkbenchArea = 'advanced' | 'application' | 'problems' | 'sessions' export type WorkbenchLocation = | Readonly<{ readonly area: 'application'; readonly invocationId?: string; readonly node?: ApplicationNodeRef; readonly tab?: string }> - | Readonly<{ readonly area: 'trace'; readonly invocationId?: string }> + /** `invocationId` is the selected trace entry id (`/trace/`); the name predates the unified trace and still accepts an `inv_…` id. */ + | Readonly<{ readonly area: 'trace'; readonly correlation?: string; readonly invocationId?: string }> | Readonly<{ readonly area: 'problems' }> | Readonly<{ readonly area: 'sessions'; readonly host?: string }> | Readonly<{ readonly area: 'advanced'; readonly section: AdvancedSection }>; @@ -59,6 +62,9 @@ const decode = (value: string): string | undefined => { } }; +const nonempty = (value: string | null): string | undefined => + value === null || value.length === 0 || value.includes('\0') ? undefined : value; + const isAdvancedSection = (value: string): value is AdvancedSection => (advancedSections as readonly string[]).includes(value); const applicationRoot: WorkbenchLocation = Object.freeze({ area: 'application' }); @@ -73,6 +79,7 @@ export const parseWorkbenchLocation = (pathname: string, search = ''): Workbench const query = new URLSearchParams(search); const invocationId = query.get('invocation') ?? undefined; const tab = query.get('tab') ?? undefined; + const correlation = nonempty(query.get('correlation')); const [area, ...rest] = segments; switch (area) { case undefined: @@ -89,7 +96,11 @@ export const parseWorkbenchLocation = (pathname: string, search = ''): Workbench } case 'trace': { const id = rest.length === 1 ? decode(rest[0]!) : undefined; - return Object.freeze({ area: 'trace', ...(id === undefined ? {} : { invocationId: id }) }); + return Object.freeze({ + area: 'trace', + ...(correlation === undefined ? {} : { correlation }), + ...(id === undefined ? {} : { invocationId: id }), + }); } case 'problems': return Object.freeze({ area: 'problems' }); @@ -117,8 +128,10 @@ export const formatWorkbenchLocation = (location: WorkbenchLocation): string => const search = query.toString(); return `${applicationNodePath(location.node)}${search.length === 0 ? '' : `?${search}`}`; } - case 'trace': - return location.invocationId === undefined ? '/trace' : `/trace/${segment(location.invocationId)}`; + case 'trace': { + const path = location.invocationId === undefined ? '/trace' : `/trace/${segment(location.invocationId)}`; + return location.correlation === undefined ? path : `${path}?correlation=${segment(location.correlation)}`; + } case 'problems': return '/problems'; case 'sessions': diff --git a/packages/workbench/src/trace/trace-client.ts b/packages/workbench/src/trace/trace-client.ts new file mode 100644 index 000000000..9bee61143 --- /dev/null +++ b/packages/workbench/src/trace/trace-client.ts @@ -0,0 +1,328 @@ +import { isCredentialKey, redactEvalCredentialText } from '../../../agent-bundle/src/contracts/credentials.ts'; +import { parseJsonWithoutDuplicateKeys, type JsonValue } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { + isTraceSource, + type TraceCorrelation, + type TraceEntry, + type TraceMessage, + type TraceReplay, + type TraceReplayGap, + type TraceStatus, +} from '../../../agent-bundle/src/contracts/trace.ts'; +import { isWorkbenchShellPath } from '../../../agent-bundle/src/contracts/workbench-shell.ts'; +import { errorMessage, exactKeys, hasAllowedKeys, isAbortError, isRecord, parseStrictResponseJson, strictJsonSnapshot } from '../client-helpers.ts'; +import { deepFreeze } from '../freeze.ts'; +import { awaitWithAbort, ForegroundRouteClientError, type ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; +import { readNdjsonResponseFrames } from '../ndjson.ts'; +import { hasControlCharacters, pathLikeText } from '../shell/wire-text.ts'; +import { mergeTraceEntries } from './trace-model.ts'; + +export interface TraceClient { + replay(after?: number): Promise; + /** Resolves when the stream ends or `signal` aborts; rejects on a malformed frame or a refused request. */ + stream(after: number | undefined, onMessage: (message: TraceMessage) => void, signal: AbortSignal): Promise; +} + +export interface TraceClientOptions { + /** Reuses Workbench's single foreground session and invalidation authority. */ + readonly foreground: ForegroundRequestAuthority; +} + +/** `AB8243`: the route answered with bytes this client refuses to interpret. Other codes are the server's own refusals. */ +export class TraceClientError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'TraceClientError'; + this.code = code; + } +} + +export const TRACE_INVALID_RESPONSE_CODE = 'AB8243'; + +const maximumFrameBytes = 64 * 1024; +const maximumSummaryLength = 240; +const maximumKindLength = 128; +const maximumIdentifierLength = 256; +const maximumHrefLength = 2_048; +const maximumDurationMs = 1_000 * 60 * 60 * 24 * 365; +const traceStatuses: readonly TraceStatus[] = Object.freeze(['ok', 'error', 'running']); +const correlationKeys: readonly (keyof TraceCorrelation)[] = Object.freeze([ + 'correlationId', 'conversationId', 'epochId', 'executionId', 'host', 'invocationId', + 'mcpRequestId', 'mcpSessionId', 'requestId', 'routeId', 'sessionId', +]); +const entryKeys: readonly string[] = Object.freeze(['correlation', 'id', 'kind', 'occurredAt', 'sequence', 'source', 'summary']); +const optionalEntryKeys: readonly string[] = Object.freeze(['details', 'durationMs', 'href', 'status']); +const gapKeys: readonly string[] = Object.freeze(['droppedCount', 'firstAvailableSequence', 'requestedAfterSequence', 'type']); + +const invalid = (): TraceClientError => new TraceClientError(TRACE_INVALID_RESPONSE_CODE, 'Trace route returned an invalid response.'); + +const safeInteger = (value: unknown, minimum = 0): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum; +const isDate = (value: unknown): value is string => + typeof value === 'string' && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; +const identifier = /^[A-Za-z0-9_][A-Za-z0-9._:@+/-]*$/u; + +/** + * Free text the server promised was already safe (`safeDevWireText`): no + * control characters, no credential-shaped tokens, and no absolute path. Route + * ids (`tool:curator/search`), MCP methods (`tools/call`), and event names + * (`tool/before`) keep their single slash. + */ +const isSafeText = (value: unknown, maximum: number): value is string => + typeof value === 'string' && value.length > 0 && value.length <= maximum && !hasControlCharacters(value) && + redactEvalCredentialText(value) === value && !pathLikeText.test(value); +const isIdentifier = (value: unknown): value is string => + isSafeText(value, maximumIdentifierLength) && identifier.test(value); +const isSafeDetail = (value: JsonValue): boolean => { + if (value === null || typeof value === 'boolean' || typeof value === 'number') return true; + if (typeof value === 'string') return value.length === 0 || isSafeText(value, maximumFrameBytes); + if (Array.isArray(value)) return value.every(isSafeDetail); + return Object.entries(value).every(([key, entry]) => !isCredentialKey(key) && !hasControlCharacters(key) && isSafeDetail(entry)); +}; +const isCorrelation = (value: unknown): value is TraceCorrelation => + isRecord(value) && Object.entries(value).every(([key, entry]) => + (correlationKeys as readonly string[]).includes(key) && isIdentifier(entry)); + +/** A Workbench path (`/routes/…?invocation=…`, `/advanced/protocol?session=…`): same origin, a shell area, no fragment. */ +const isWorkbenchHref = (value: unknown): value is string => { + if (typeof value !== 'string' || value.length === 0 || value.length > maximumHrefLength || !value.startsWith('/') || value.startsWith('//') || hasControlCharacters(value)) return false; + let url: URL; + try { url = new URL(value, 'http://workbench.invalid'); } + catch { return false; } + return url.origin === 'http://workbench.invalid' && url.hash === '' && `${url.pathname}${url.search}` === value && isWorkbenchShellPath(url.pathname); +}; + +const isEntry = (value: unknown): value is TraceEntry => { + if (!hasAllowedKeys(value, entryKeys, optionalEntryKeys)) return false; + if (!isTraceSource(value.source) || !isCorrelation(value.correlation)) return false; + if (!safeInteger(value.sequence, 1) || !isIdentifier(value.id) || !isDate(value.occurredAt)) return false; + if (!isSafeText(value.kind, maximumKindLength) || !identifier.test(value.kind) || !value.kind.includes('.')) return false; + if (!isSafeText(value.summary, maximumSummaryLength)) return false; + if (Object.hasOwn(value, 'status') && !(traceStatuses as readonly unknown[]).includes(value.status)) return false; + if (Object.hasOwn(value, 'durationMs') && !(typeof value.durationMs === 'number' && Number.isFinite(value.durationMs) && value.durationMs >= 0 && value.durationMs <= maximumDurationMs)) return false; + if (Object.hasOwn(value, 'href') && !isWorkbenchHref(value.href)) return false; + return !Object.hasOwn(value, 'details') || isSafeDetail(value.details as JsonValue); +}; + +const isGap = (value: unknown): value is TraceReplayGap => + exactKeys(value, gapKeys) && value.type === 'trace.gap' && safeInteger(value.requestedAfterSequence) && + safeInteger(value.droppedCount, 1) && safeInteger(value.firstAvailableSequence, 1) && + value.firstAvailableSequence === value.requestedAfterSequence + value.droppedCount + 1; + +const contiguous = (entries: readonly TraceEntry[], afterSequence: number): boolean => + entries.every((entry, index) => entry.sequence === afterSequence + index + 1); + +export const decodeTraceEntry = (value: unknown): TraceEntry => { + const detached = strictJsonSnapshot(value, invalid); + if (!isEntry(detached)) throw invalid(); + return deepFreeze(detached); +}; + +export const decodeTraceMessage = (value: unknown): TraceMessage => { + const detached = strictJsonSnapshot(value, invalid); + if (isEntry(detached) || isGap(detached)) return deepFreeze(detached); + throw invalid(); +}; + +/** The body of `GET /api/trace?after=`: `TraceReplay` as `TraceHub.replay` returns it. */ +export const decodeTraceReplay = (value: unknown, after: number): TraceReplay => { + const detached = strictJsonSnapshot(value, invalid); + if (!hasAllowedKeys(detached, ['entries', 'latestSequence'], ['gap']) || !Array.isArray(detached.entries) || !safeInteger(detached.latestSequence)) throw invalid(); + if (!detached.entries.every(isEntry)) throw invalid(); + const entries: readonly TraceEntry[] = detached.entries; + const gap = Object.hasOwn(detached, 'gap') ? detached.gap : undefined; + if (gap !== undefined && (!isGap(gap) || gap.requestedAfterSequence !== after)) throw invalid(); + const start = gap === undefined ? after : gap.firstAvailableSequence - 1; + if (!contiguous(entries, start) || detached.latestSequence < after) throw invalid(); + const last = entries.at(-1); + const expectedLatest = last?.sequence ?? (gap === undefined ? after : gap.firstAvailableSequence - 1); + if (detached.latestSequence !== expectedLatest) throw invalid(); + return deepFreeze({ entries, ...(gap === undefined ? {} : { gap }), latestSequence: detached.latestSequence }); +}; + +const refusal = (value: unknown, status: number): TraceClientError => { + if (!exactKeys(value, ['diagnostic']) || !exactKeys(value.diagnostic, ['code', 'message']) || + typeof value.diagnostic.code !== 'string' || !/^AB\d{4}$/u.test(value.diagnostic.code)) return invalid(); + return new TraceClientError(value.diagnostic.code, `Trace route refused the request (${value.diagnostic.code}, HTTP ${String(status)}).`); +}; + +/** Production `TraceClient` over the foreground session authority. */ +export class ForegroundTraceClient implements TraceClient { + readonly #foreground: ForegroundRequestAuthority; + + constructor(options: TraceClientOptions) { + this.#foreground = options.foreground; + } + + async replay(after = 0, signal?: AbortSignal): Promise { + if (!safeInteger(after)) throw invalid(); + const response = await this.#response(`/api/trace?after=${String(after)}`, signal); + let body: JsonValue; + try { + body = parseStrictResponseJson(new Uint8Array(await awaitWithAbort(signal, () => response.arrayBuffer())), invalid); + } catch (error) { + if (error instanceof TraceClientError || isAbortError(error) || signal?.aborted === true) throw error; + throw invalid(); + } + return decodeTraceReplay(body, after); + } + + async stream(after: number | undefined, onMessage: (message: TraceMessage) => void, signal: AbortSignal): Promise { + const start = after ?? 0; + if (!safeInteger(start)) throw invalid(); + let response: Response; + try { + response = await this.#response(`/api/trace/stream?after=${String(start)}`, signal); + } catch (error) { + if (isAbortError(error) || signal.aborted) return; + throw error; + } + let expected = start + 1; + const decoder = new TextDecoder('utf-8', { fatal: true }); + try { + await readNdjsonResponseFrames(response, (bytes) => { + if (signal.aborted) return; + const line = decoder.decode(bytes).trim(); + if (line.length === 0) return; + let parsed: unknown; + try { parsed = parseJsonWithoutDuplicateKeys(line); } + catch { throw invalid(); } + const message = decodeTraceMessage(parsed); + if ('sequence' in message) { + if (message.sequence !== expected) throw invalid(); + expected += 1; + } else { + if (message.requestedAfterSequence !== expected - 1) throw invalid(); + expected = message.firstAvailableSequence; + } + onMessage(message); + }, { invalidFrameError: invalid, maxFrameBytes: maximumFrameBytes, signal }); + } catch (error) { + if (isAbortError(error) || signal.aborted) return; + if (error instanceof TraceClientError) throw error; + throw invalid(); + } + } + + async #response(path: string, signal: AbortSignal | undefined): Promise { + try { + const response = await this.#foreground.protectedRequest(path, { signal }); + if (response.ok) return response; + const bytes = await awaitWithAbort(signal, () => response.arrayBuffer()); + throw refusal(parseStrictResponseJson(new Uint8Array(bytes), invalid), response.status); + } catch (error) { + if (error instanceof TraceClientError || isAbortError(error) || signal?.aborted === true) throw error; + if (error instanceof ForegroundRouteClientError) throw new TraceClientError(error.code, error.message); + throw invalid(); + } + } +} + +export interface TraceFeedState { + /** True between a successful replay and the end of its stream. */ + readonly connected: boolean; + readonly entries: readonly TraceEntry[]; + readonly error?: string; + /** The oldest retained boundary the server reported; earlier entries are gone. */ + readonly gap?: TraceReplayGap; + /** False until the first replay settles, so the page can tell "empty" from "loading". */ + readonly loaded: boolean; +} + +export interface TraceFeedOptions { + readonly client: TraceClient; + readonly onState: (state: TraceFeedState) => void; + /** Injected so tests do not wait out the real back-off. */ + readonly retryDelay?: (milliseconds: number) => Promise; +} + +export interface TraceFeed { + close(): void; +} + +const initialRetryMs = 250; +const maximumRetryMs = 5_000; +const wait = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * Replay once, then follow the stream; when the stream ends or fails, back off + * (250 ms doubling to 5 s) and replay again from the last delivered sequence. + * A refused replay from a non-zero cursor means the dev server restarted with + * a fresh hub, so the feed starts over from zero rather than looping. Every + * state change goes through `onState` with the full merged list. + */ +export const openTraceFeed = (options: TraceFeedOptions): TraceFeed => { + const retryDelay = options.retryDelay ?? wait; + let open = true; + let entries: readonly TraceEntry[] = Object.freeze([]); + let gap: TraceReplayGap | undefined; + let error: string | undefined; + let loaded = false; + let connected = false; + let latest = 0; + let retryMs = initialRetryMs; + let controller: AbortController | undefined; + const publish = (): void => { + if (!open) return; + options.onState(Object.freeze({ connected, entries, ...(error === undefined ? {} : { error }), ...(gap === undefined ? {} : { gap }), loaded })); + }; + const receive = (message: TraceMessage): void => { + if ('sequence' in message) { + latest = Math.max(latest, message.sequence); + entries = mergeTraceEntries(entries, [message]); + } else { + gap = message; + } + publish(); + }; + const failed = (reason: unknown): void => { + connected = false; + error = errorMessage(reason, 'The trace could not be read.'); + publish(); + }; + const run = async (): Promise => { + while (open) { + const attempt = new AbortController(); + controller = attempt; + let resetCursor = false; + try { + const replay = await options.client.replay(latest); + if (!open || attempt.signal.aborted) return; + latest = Math.max(latest, replay.latestSequence); + entries = mergeTraceEntries(entries, replay.entries); + if (replay.gap !== undefined) gap = replay.gap; + error = undefined; + loaded = true; + connected = true; + retryMs = initialRetryMs; + publish(); + await options.client.stream(latest, (message) => { if (open && !attempt.signal.aborted) receive(message); }, attempt.signal); + if (!open || attempt.signal.aborted) return; + connected = false; + publish(); + } catch (reason) { + if (!open || attempt.signal.aborted) return; + resetCursor = latest > 0 && reason instanceof TraceClientError && reason.code !== TRACE_INVALID_RESPONSE_CODE; + if (resetCursor) { + latest = 0; + entries = Object.freeze([]); + gap = undefined; + } + failed(reason); + } + if (!resetCursor) { + await retryDelay(retryMs); + retryMs = Math.min(retryMs * 2, maximumRetryMs); + } + } + }; + void run(); + return Object.freeze({ + close: () => { + open = false; + controller?.abort(); + }, + }); +}; diff --git a/packages/workbench/src/trace/trace-model.ts b/packages/workbench/src/trace/trace-model.ts new file mode 100644 index 000000000..679fb2e45 --- /dev/null +++ b/packages/workbench/src/trace/trace-model.ts @@ -0,0 +1,313 @@ +import type { + TraceCorrelation, + TraceEntry, + TraceSource, + TraceStatus, +} from '../../../agent-bundle/src/contracts/trace.ts'; + +/** Matches `TraceHub`'s default retention so the page never holds more than the server does. */ +export const maximumTraceEntries = 4_096; + +/** + * The correlation keys entries join on, in the priority order that names a + * group. `epochId`, `host`, and `routeId` are facets, not joins: every entry + * of an epoch would otherwise become one group. + */ +const traceJoinKeys = Object.freeze([ + 'conversationId', + 'sessionId', + 'mcpSessionId', + 'invocationId', + 'executionId', + 'mcpRequestId', + 'correlationId', +] as const); + +type TraceJoinKey = (typeof traceJoinKeys)[number]; + +export type TraceGroupKeyKind = TraceJoinKey | 'entry'; + +export interface TraceRow { + readonly depth: 0 | 1; + readonly entry: TraceEntry; +} + +export interface TraceGroup { + readonly endedAt: string; + readonly firstSequence: number; + readonly headline: TraceEntry; + readonly key: string; + readonly keyKind: TraceGroupKeyKind; + readonly lastSequence: number; + readonly rows: readonly TraceRow[]; + readonly spanMs: number; + readonly startedAt: string; + readonly status: TraceStatus; +} + +const millis = (value: string): number => { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +/** + * Replay and live entries share one server sequence: the result is ordered by + * `sequence`, a sequence seen twice keeps the first copy, and the oldest + * entries beyond {@link maximumTraceEntries} fall off the front. + */ +export const mergeTraceEntries = ( + existing: readonly TraceEntry[], + incoming: readonly TraceEntry[], +): readonly TraceEntry[] => { + const merged: TraceEntry[] = []; + let existingIndex = 0; + let incomingIndex = 0; + while (existingIndex < existing.length || incomingIndex < incoming.length) { + const previous = existing[existingIndex]; + const next = incoming[incomingIndex]; + if (next === undefined || (previous !== undefined && previous.sequence < next.sequence)) { + merged.push(previous!); + existingIndex += 1; + } else if (previous === undefined || next.sequence < previous.sequence) { + merged.push(next); + incomingIndex += 1; + } else { + merged.push(previous); + existingIndex += 1; + incomingIndex += 1; + } + } + return Object.freeze(merged.slice(-maximumTraceEntries)); +}; + +/** `mcpRequestId` is only meaningful within its session; session ids join across publishers. */ +const joinValue = (correlation: TraceCorrelation, key: TraceJoinKey): string | undefined => { + const value = correlation[key]; + if (value === undefined) return undefined; + if (key !== 'mcpRequestId') return value; + return correlation.mcpSessionId === undefined ? undefined : `${correlation.mcpSessionId}/${value}`; +}; + +const joinToken = (correlation: TraceCorrelation, key: TraceJoinKey): string | undefined => { + const value = joinValue(correlation, key); + return value === undefined ? undefined : `correlation:${value}`; +}; + +const headlinePriority: Readonly> = Object.freeze({ + hook: 0, + invocation: 1, + mcp: 2, + kernel: 3, + diagnostic: 4, + log: 5, +}); + +const isInvocationLevel = (entry: TraceEntry): boolean => { + switch (entry.source) { + case 'hook': + case 'invocation': + return true; + case 'kernel': + case 'mcp': + case 'log': + case 'diagnostic': + return false; + default: { + const exhaustive: never = entry.source; + return exhaustive; + } + } +}; + +const groupStatus = (entries: readonly TraceEntry[]): TraceStatus => { + if (entries.some((entry) => entry.status === 'error')) return 'error'; + return entries.at(-1)?.status === 'running' ? 'running' : 'ok'; +}; + +const groupFor = (entries: readonly TraceEntry[]): TraceGroup => { + const first = entries[0]!; + const last = entries.at(-1)!; + let key = `entry:${first.id}`; + let keyKind: TraceGroupKeyKind = 'entry'; + search: for (const joinKey of traceJoinKeys) { + for (const entry of entries) { + const value = joinValue(entry.correlation, joinKey); + if (value === undefined) continue; + key = `${joinKey}:${value}`; + keyKind = joinKey; + break search; + } + } + const headline = entries.reduce((best, entry) => + headlinePriority[entry.source] < headlinePriority[best.source] ? entry : best, first); + const rows = entries.map((entry): TraceRow => Object.freeze({ + depth: isInvocationLevel(entry) || entries.length === 1 ? 0 : 1, + entry, + })); + return Object.freeze({ + endedAt: last.occurredAt, + firstSequence: first.sequence, + headline, + key, + keyKind, + lastSequence: last.sequence, + rows: Object.freeze(rows), + spanMs: entries.length === 1 ? first.durationMs ?? 0 : Math.max(0, millis(last.occurredAt) - millis(first.occurredAt)), + startedAt: first.occurredAt, + status: groupStatus(entries), + }); +}; + +/** + * Folds entries into correlated groups: two entries share a group when they + * share any join key, transitively, so a kernel event that knows only its + * `executionId` still lands beside the invocation that also carries the + * `conversationId`. Groups are ordered by their first entry; rows within a + * group by sequence. Entries with no join key are groups of one. + */ +export const groupTraceEntries = (entries: readonly TraceEntry[]): readonly TraceGroup[] => { + const parent = new Map(); + const find = (node: string): string => { + let root = node; + while (parent.get(root) !== root) root = parent.get(root)!; + let cursor = node; + while (parent.get(cursor) !== root) { + const next = parent.get(cursor)!; + parent.set(cursor, root); + cursor = next; + } + return root; + }; + const union = (left: string, right: string): void => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) parent.set(rightRoot, leftRoot); + }; + const entryNode = (entry: TraceEntry): string => `entry:${entry.id}`; + for (const entry of entries) { + const node = entryNode(entry); + parent.set(node, node); + for (const joinKey of traceJoinKeys) { + const token = joinToken(entry.correlation, joinKey); + if (token === undefined) continue; + if (!parent.has(token)) parent.set(token, token); + union(node, token); + } + } + const members = new Map(); + for (const entry of entries) { + const root = find(entryNode(entry)); + const list = members.get(root); + if (list === undefined) members.set(root, [entry]); + else list.push(entry); + } + return Object.freeze([...members.values()].map((list) => groupFor(Object.freeze(list)))); +}; + +/** Every correlation value on an entry, plus its own id: what `?correlation=` may name. */ +const traceEntryCorrelationValues = (entry: TraceEntry): readonly string[] => Object.freeze([ + entry.id, + ...Object.values(entry.correlation).filter((value): value is string => typeof value === 'string'), +]); + +/** The group holding any entry that carries `id` as one of its correlation values (or as its own id). */ +export const selectTraceGroup = (groups: readonly TraceGroup[], id: string): TraceGroup | undefined => + groups.find((group) => group.rows.some((row) => traceEntryCorrelationValues(row.entry).includes(id))); + +/** Accepts both trace-entry ids and invocation ids used by deep links. */ +export const selectTraceEntry = (entries: readonly TraceEntry[], id: string): TraceEntry | undefined => + entries.find((entry) => entry.id === id) ?? + entries.findLast((entry) => entry.correlation.invocationId === id); + +const timeFormats = new Map(); + +/** `HH:MM:SS.mmm`; local time unless a zone is given (tests pass `UTC`). */ +export const formatTraceTime = (value: string, timeZone?: string): string => { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + let format = timeFormats.get(timeZone); + if (format === undefined) { + format = new Intl.DateTimeFormat('en-GB', { + fractionalSecondDigits: 3, + hour: '2-digit', + hourCycle: 'h23', + minute: '2-digit', + second: '2-digit', + ...(timeZone === undefined ? {} : { timeZone }), + }); + timeFormats.set(timeZone, format); + } + return format.format(new Date(parsed)); +}; + +export const formatTraceDuration = (value: number): string => { + if (!Number.isFinite(value) || value < 0) return ''; + if (value < 1) return '<1 ms'; + if (value < 1000) return `${value < 10 ? value.toFixed(1) : String(Math.round(value))} ms`; + return `${(value / 1000).toFixed(2)} s`; +}; + +/** The glyph the timeline puts in front of a row, one per source. */ +export const traceSourceGlyph = (source: TraceSource): string => { + switch (source) { + case 'invocation': + return '▶'; + case 'kernel': + return '⚙'; + case 'mcp': + return '⇄'; + case 'hook': + return '⚑'; + case 'log': + return '≡'; + case 'diagnostic': + return '⚠'; + default: { + const exhaustive: never = source; + return exhaustive; + } + } +}; + +const kindLabels: ReadonlyMap = new Map([ + ['invocation.started', 'invocation started'], + ['invocation.completed', 'invocation completed'], + ['invocation.failed', 'invocation failed'], + ['kernel.preflight.start', 'preflight'], + ['kernel.preflight.outcome', 'preflight outcome'], + ['kernel.execute.start', 'execute'], + ['kernel.providers.start', 'providers'], + ['kernel.providers.finish', 'providers finished'], + ['kernel.render.start', 'render'], + ['kernel.render.finish', 'render finished'], + ['kernel.failure', 'kernel failure'], + ['mcp.request', 'MCP request'], + ['mcp.response', 'MCP response'], + ['mcp.notification', 'MCP notification'], + ['mcp.progress', 'MCP progress'], + ['mcp.logging', 'MCP log'], + ['mcp.session.started', 'MCP session started'], + ['mcp.session.closed', 'MCP session closed'], + ['mcp.stderr', 'MCP stderr'], + ['hook.received', 'hook received'], + ['hook.completed', 'hook completed'], + ['hook.failed', 'hook failed'], + ['session.started', 'session started'], + ['session.ended', 'session ended'], + ['diagnostic.build.failed', 'build failed'], + ['diagnostic.contract.failed', 'contract failed'], + ['diagnostic.host.sync', 'host sync'], +]); + +/** + * The short label for a row's `kind`: the vocabulary in the PR 2 brief maps to + * a phrase; `log..` shows ` `; anything else + * shows its dotted tail with the source prefix removed. + */ +export const traceKindLabel = (entry: TraceEntry): string => { + const known = kindLabels.get(entry.kind); + if (known !== undefined) return known; + const prefix = `${entry.source}.`; + const tail = entry.kind.startsWith(prefix) ? entry.kind.slice(prefix.length) : entry.kind; + return tail.split('.').join(' '); +}; diff --git a/packages/workbench/src/trace/trace-page.css b/packages/workbench/src/trace/trace-page.css new file mode 100644 index 000000000..0cd629192 --- /dev/null +++ b/packages/workbench/src/trace/trace-page.css @@ -0,0 +1,65 @@ +/* Trace: a full-height timeline column with an optional detail drawer beside it. Desktop only (≥ 1024 px). */ +.trace-page { display: grid; grid-template-columns: minmax(0, 1fr); height: calc(100vh - var(--header-height)); max-width: none; padding: 0; } +.trace-page--detail { grid-template-columns: minmax(0, 1fr) 420px; } +.trace-main { display: flex; flex-direction: column; min-height: 0; min-width: 0; padding: 28px 34px 0; } +.trace-heading { align-items: flex-end; margin-bottom: 18px; } +.trace-scope { color: #375271; font-size: 14px; margin: 0; } +.trace-gap { color: #7a5200; font-size: 13px; font-weight: 600; margin: 0 0 12px; } + +.trace-timeline-wrap { flex: 1; min-height: 0; position: relative; } +.trace-timeline { height: 100%; overflow-y: auto; padding: 8px 0 40px; scrollbar-gutter: stable; } +.trace-empty { color: #4f5866; font-size: 15px; line-height: 1.55; margin: 48px auto; max-width: 560px; text-align: center; } + +/* Groups: header line, then rows indented under it. */ +.trace-group { border-bottom: 1px solid #e6eaf0; padding: 6px 0 10px; } +.trace-group[data-selected="true"] { background: #f6f9ff; box-shadow: inset 3px 0 0 #0b5bd3; } +.trace-group-head { align-items: baseline; display: grid; gap: 12px; grid-template-columns: 104px 18px minmax(0, 1fr) auto 84px; padding: 6px 12px; } +.trace-group-title { font-size: 14px; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trace-group-meta { align-items: baseline; color: #596372; display: inline-flex; font-size: 12px; gap: 14px; white-space: nowrap; } +.trace-group-key { font-weight: 700; } +.trace-group-key .identifier { color: #375271; font-size: 12px !important; } +.trace-rows { list-style: none; margin: 0; padding: 0; } +.trace-row { margin: 0; } +.trace-line { align-items: baseline; border-radius: 4px; color: #1e2938; display: grid; gap: 12px; grid-template-columns: 104px 190px minmax(0, 1fr) auto 84px; padding: 4px 12px 4px 30px; text-decoration: none; } +.trace-row--depth-1 .trace-line { padding-left: 54px; } +.trace-line:hover { background: #f1f5fb; } +.trace-line[aria-current="true"] { background: #e6effd; box-shadow: inset 0 0 0 1px #0b5bd3; } +.trace-row--error .trace-line { background: #fff7f7; } +.trace-row--error .trace-line:hover { background: #fdeeee; } +.trace-time { color: #596372; font: 12px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", monospace; white-space: nowrap; } +.trace-kind { align-items: baseline; color: #375271; display: inline-flex; font-size: 12px; font-weight: 700; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trace-summary { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trace-row-flag { background: #b31b23; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 800; line-height: 16px; min-width: 16px; text-align: center; } +.trace-duration { color: #4f5866; font: 12px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", monospace; text-align: right; white-space: nowrap; } +.trace-glyph { display: inline-block; font-size: 12px; min-width: 14px; text-align: center; } +.trace-glyph--invocation { color: #0b5bd3; } +.trace-glyph--kernel { color: #6b4fbb; } +.trace-glyph--mcp { color: #0a7f8c; } +.trace-glyph--runtime { color: #b3661b; } +.trace-glyph--hook { color: #147b36; } +.trace-glyph--log { color: #596372; } +.trace-glyph--diagnostic { color: #b31b23; } +.trace-link { color: #0759c7; font-weight: 700; text-decoration: none; } +.trace-link:hover { text-decoration: underline; } +.trace-status { font-size: 12px; font-weight: 750; text-transform: capitalize; } +.trace-status--ok { color: #147b36; } +.trace-status--error { color: #b31b23; } +.trace-status--running { color: #8a5700; } + +/* Detail drawer. */ +.trace-detail { background: #f7f9fc; border-left: 1px solid #d9dee7; min-width: 0; overflow-y: auto; padding: 24px 24px 48px; } +.trace-detail-head { align-items: flex-start; display: flex; gap: 16px; justify-content: space-between; } +.trace-detail-head h2 { color: #141821; font-size: 17px; font-weight: 700; letter-spacing: 0; line-height: 1.35; margin: 6px 0 0; overflow-wrap: anywhere; text-transform: none; } +.trace-detail-eyebrow { color: #596372; font-size: 12px; font-weight: 750; margin: 0; text-transform: none; } +.trace-detail-close { border-radius: 4px; color: #596372; font-size: 22px; line-height: 1; padding: 2px 8px; text-decoration: none; } +.trace-detail-close:hover { background: #e6effd; color: #0b3f8f; } +.trace-detail-actions { margin: 18px 0 22px; } +.trace-primary-action { background: #0b5bd3; border-radius: 6px; color: #fff; display: inline-block; font-size: 14px; font-weight: 750; padding: 9px 16px; text-decoration: none; } +.trace-primary-action:hover { background: #0949a8; } +.trace-detail-no-route { color: #596372; font-size: 13px; } +.trace-detail h3 { color: #4f5866; font-size: 12px; font-weight: 750; letter-spacing: .03em; margin: 22px 0 10px; text-transform: uppercase; } +.trace-detail-facts { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.trace-detail-facts dt, .trace-detail-keys dt { color: #596372; font-size: 12px; font-weight: 750; margin-bottom: 3px; } +.trace-detail-facts dd, .trace-detail-keys dd { font-size: 13px; margin: 0; overflow-wrap: anywhere; } +.trace-detail-keys { display: grid; gap: 10px; margin: 0; } +.trace-detail-json { background: #fff; border: 1px solid #e1e6ee; border-radius: 4px; color: #344054; font-size: 12px; margin: 0; max-width: 100%; overflow-wrap: anywhere; padding: 10px; white-space: pre-wrap; word-break: break-word; } diff --git a/packages/workbench/src/trace/trace-page.tsx b/packages/workbench/src/trace/trace-page.tsx index 130fcb5c2..cac96470f 100644 --- a/packages/workbench/src/trace/trace-page.tsx +++ b/packages/workbench/src/trace/trace-page.tsx @@ -1,227 +1,243 @@ -/** Route invocations from the current dev session, newest first. */ -import React, { useEffect, useState } from 'react'; - -import type { RouteInvocation, RouteInvocationSummary } from '../../../agent-bundle/src/contracts/invocations.ts'; -import { applicationLeaves, type ApplicationTree } from '../application/application-tree-model.ts'; -import type { InvocationBackend } from '../application/invocation-backend.ts'; -import { errorMessage, isAbortError } from '../client-helpers.ts'; -import { applicationNodeRefForRouteId, formatWorkbenchLocation, type WorkbenchLocation } from '../shell/workbench-location.ts'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { type TraceEntry, type TraceStatus } from '../../../agent-bundle/src/contracts/trace.ts'; +import { ShellLink } from '../shell/shell-link.tsx'; +import { parseWorkbenchLocation, type WorkbenchLocation } from '../shell/workbench-location.ts'; +import { openTraceFeed, type TraceClient, type TraceFeedState } from './trace-client.ts'; +import { + formatTraceDuration, + formatTraceTime, + groupTraceEntries, + selectTraceEntry, + selectTraceGroup, + traceKindLabel, + traceSourceGlyph, + type TraceGroup, + type TraceGroupKeyKind, +} from './trace-model.ts'; +import './trace-page.css'; export interface TracePageProps { - readonly backends: readonly InvocationBackend[]; - /** `/trace/`: show this one entry instead of the table. */ - readonly invocationId?: string; + readonly client: TraceClient; + readonly correlation?: string; + readonly entries?: readonly TraceEntry[]; + readonly entryId?: string; readonly onNavigate: (location: WorkbenchLocation) => void; - readonly tree: ApplicationTree; + /** Row timestamps' zone; the browser's when absent. Tests pass `UTC`. */ + readonly timeZone?: string; } -const completedAtMillis = (summary: RouteInvocationSummary): number => { - const completed = Date.parse(summary.completedAt); - return Number.isNaN(completed) ? Date.parse(summary.startedAt) : completed; -}; - -/** Newest first; ties keep the id order stable. */ -export const sortTraceEntries = (entries: readonly RouteInvocationSummary[]): readonly RouteInvocationSummary[] => - Object.freeze([...entries].sort((left, right) => completedAtMillis(right) - completedAtMillis(left) || left.id.localeCompare(right.id))); - -/** Merges by id (a later summary for the same id wins) and re-sorts. */ -export const mergeTraceEntries = ( - existing: readonly RouteInvocationSummary[], - incoming: readonly RouteInvocationSummary[], -): readonly RouteInvocationSummary[] => { - const byId = new Map(existing.map((entry) => [entry.id, entry])); - for (const entry of incoming) byId.set(entry.id, entry); - return sortTraceEntries([...byId.values()]); +const groupKeyLabel = (kind: TraceGroupKeyKind): string => { + switch (kind) { + case 'conversationId': + return 'conversation'; + case 'sessionId': + case 'mcpSessionId': + return 'session'; + case 'invocationId': + return 'invocation'; + case 'executionId': + return 'execution'; + case 'mcpRequestId': + return 'MCP request'; + case 'correlationId': + return 'correlation'; + case 'entry': + return 'entry'; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } }; -/** Wall-clock duration of an invocation, falling back to its recorded phase timings. */ -export const traceDurationMs = (summary: RouteInvocationSummary): number => { - const started = Date.parse(summary.startedAt); - const completed = Date.parse(summary.completedAt); - if (!Number.isNaN(started) && !Number.isNaN(completed) && completed >= started) return completed - started; - return summary.timings.reduce((total, timing) => total + timing.durationMs, 0); +const groupKeyValue = (group: TraceGroup): string => { + const separator = group.key.indexOf(':'); + return separator === -1 ? group.key : group.key.slice(separator + 1); }; -/** The workspace deep link for an entry, or undefined when its route id is not an application node. */ -export const traceEntryLocation = (summary: RouteInvocationSummary): WorkbenchLocation | undefined => { - const node = applicationNodeRefForRouteId(summary.routeId); - return node === undefined ? undefined : Object.freeze({ area: 'application', invocationId: summary.id, node }); +const splitHref = (href: string): readonly [string, string] => { + const index = href.indexOf('?'); + return index === -1 ? [href, ''] : [href.slice(0, index), href.slice(index)]; }; -const timeFormat = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +const initialFeedState = (entries: readonly TraceEntry[] | undefined): TraceFeedState => + Object.freeze({ connected: false, entries: entries ?? [], loaded: entries !== undefined }); -const formatTime = (value: string): string => { - const millis = Date.parse(value); - return Number.isNaN(millis) ? value : timeFormat.format(new Date(millis)); +const useTraceFeed = (client: TraceClient, supplied: readonly TraceEntry[] | undefined): TraceFeedState => { + const [state, setState] = useState(() => initialFeedState(supplied)); + useEffect(() => { + if (supplied !== undefined) return undefined; + setState(initialFeedState(undefined)); + const feed = openTraceFeed({ client, onState: setState }); + return () => feed.close(); + }, [client, supplied]); + return supplied === undefined ? state : initialFeedState(supplied); }; -const formatDuration = (millis: number): string => millis < 1000 ? `${String(Math.round(millis))} ms` : `${(millis / 1000).toFixed(2)} s`; - -interface TraceState { - readonly entries: readonly RouteInvocationSummary[]; - readonly error?: string; - readonly loading: boolean; -} +const StatusPill = ({ status }: { readonly status: TraceStatus }) => + {status}; -export interface TraceHistory { - readonly entries: readonly RouteInvocationSummary[]; - /** The first non-abort failure among the per-leaf history reads, when any. */ - readonly error?: string; -} - -/** - * Every invocable leaf's history from the backends that accept it, merged by - * id so a leaf with history on both backends lists each invocation once. One - * failed read degrades to a message rather than hiding the rest. - */ -export const loadTraceHistory = async ( - backends: readonly InvocationBackend[], - tree: ApplicationTree, - signal?: AbortSignal, -): Promise => { - const leaves = applicationLeaves(tree).filter((leaf) => leaf.execution === 'invoke'); - const loads = leaves.flatMap((leaf) => backends.filter((backend) => backend.accepts(leaf)).map((backend) => backend.history(leaf, signal))); - const results = await Promise.allSettled(loads); - const entries = mergeTraceEntries([], results.flatMap((result) => result.status === 'fulfilled' ? [...result.value] : [])); - const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected' && !isAbortError(result.reason)); - return Object.freeze({ - entries, - ...(failure === undefined ? {} : { error: errorMessage(failure.reason, 'Some invocation history could not be read.') }), - }); -}; - -/** Loads history once per backend set and tree, then folds live completions in. */ -const useTraceEntries = (backends: readonly InvocationBackend[], tree: ApplicationTree): TraceState => { - const [state, setState] = useState({ entries: [], loading: true }); - useEffect(() => { - const request = new AbortController(); - setState({ entries: [], loading: true }); - const unsubscribes = backends.map((backend) => backend.subscribe((summary) => { - if (request.signal.aborted) return; - setState((current) => ({ ...current, entries: mergeTraceEntries(current.entries, [summary]) })); - })); - void loadTraceHistory(backends, tree, request.signal).then((history) => { - if (request.signal.aborted) return; - setState((current) => ({ - entries: mergeTraceEntries(current.entries, history.entries), - ...(history.error === undefined ? {} : { error: history.error }), - loading: false, - })); - }); - return () => { - request.abort(); - for (const unsubscribe of unsubscribes) unsubscribe(); - }; - }, [backends, tree]); - return state; -}; +const GroupView = ({ correlation, group, onNavigate, selected, selectedEntryId, timeZone }: { + readonly correlation: string | undefined; + readonly group: TraceGroup; + readonly onNavigate: (location: WorkbenchLocation) => void; + readonly selected: boolean; + readonly selectedEntryId: string | undefined; + readonly timeZone: string | undefined; +}) => +
    +
    + {formatTraceTime(group.startedAt, timeZone)} + + {group.headline.summary} + + {groupKeyLabel(group.keyKind)} {groupKeyValue(group)} + {String(group.rows.length)} {group.rows.length === 1 ? 'entry' : 'entries'} + + + {formatTraceDuration(group.spanMs)} +
    +
      + {group.rows.map(({ depth, entry }) => { + const status = entry.status ?? 'ok'; + return
    1. + + {formatTraceTime(entry.occurredAt, timeZone)} + + + {traceKindLabel(entry)} + + {entry.summary} + {status === 'error' ? ! : undefined} + {entry.durationMs === undefined ? '' : formatTraceDuration(entry.durationMs)} + +
    2. ; + })} +
    +
    ; -const EntryLink = ({ children, onNavigate, summary }: { - readonly children: string; +const DetailDrawer = ({ correlation, entry, onNavigate, timeZone }: { + readonly correlation: string | undefined; + readonly entry: TraceEntry; readonly onNavigate: (location: WorkbenchLocation) => void; - readonly summary: RouteInvocationSummary; + readonly timeZone: string | undefined; }) => { - const location = traceEntryLocation(summary); - return location === undefined - ? {children} - :
    { event.preventDefault(); onNavigate(location); }}>{children}; + const status = entry.status ?? 'ok'; + const keys = Object.entries(entry.correlation).filter((pair): pair is [string, string] => typeof pair[1] === 'string'); + const [pathname, search] = entry.href === undefined ? ['', ''] : splitHref(entry.href); + return ; }; -const TraceTable = ({ entries, onNavigate }: { readonly entries: readonly RouteInvocationSummary[]; readonly onNavigate: (location: WorkbenchLocation) => void }) => -
    - - {entries.map((entry) => { - const traceLocation: WorkbenchLocation = Object.freeze({ area: 'trace', invocationId: entry.id }); - return - - - - - - - ; - })} -
    TimeKindRouteStatusDurationCorrelation
    { event.preventDefault(); onNavigate(traceLocation); }}>{formatTime(entry.completedAt)}{entry.kind}{entry.routeId}{entry.status}{formatDuration(traceDurationMs(entry))}{entry.correlationId ?? '—'}
    ; - -const useTraceEntry = ( - backends: readonly InvocationBackend[], - entries: readonly RouteInvocationSummary[], - invocationId: string | undefined, -): Readonly<{ entry?: RouteInvocationSummary; error?: string; loading: boolean }> => { - const known = entries.find((entry) => entry.id === invocationId); - const [loaded, setLoaded] = useState>(); - useEffect(() => { - if (invocationId === undefined || known !== undefined) return undefined; - const request = new AbortController(); - void (async () => { - let lastError: unknown = new Error('No backend knows this invocation.'); - for (const backend of backends) { - try { - const entry = await backend.read(invocationId, request.signal); - if (!request.signal.aborted) setLoaded({ entry, id: invocationId }); - return; - } catch (reason) { - if (isAbortError(reason)) return; - lastError = reason; - } - } - if (!request.signal.aborted) setLoaded({ error: errorMessage(lastError, 'The invocation could not be read.'), id: invocationId }); - })(); - return () => request.abort(); - }, [backends, invocationId, known]); - if (invocationId === undefined) return { loading: false }; - if (known !== undefined) return { entry: known, loading: false }; - if (loaded?.id !== invocationId) return { loading: true }; - return { ...(loaded.entry === undefined ? {} : { entry: loaded.entry }), ...(loaded.error === undefined ? {} : { error: loaded.error }), loading: false }; +const emptyMessage = (feed: TraceFeedState): string => { + if (!feed.loaded) return 'Connecting to the trace…'; + return 'Nothing has been traced in this dev session yet. Run a route, call a tool in Advanced → Protocol, or invoke the plugin from a host, and it appears here.'; }; -const TraceEntry = ({ entry, onNavigate }: { readonly entry: RouteInvocationSummary; readonly onNavigate: (location: WorkbenchLocation) => void }) => -
    -
    -
    Route
    {entry.routeId}
    -
    Kind
    {entry.kind}
    -
    Status
    {entry.status}
    -
    Started
    {formatTime(entry.startedAt)}
    -
    Duration
    {formatDuration(traceDurationMs(entry))}
    -
    Correlation id
    {entry.correlationId ?? '—'}
    -
    Invocation id
    {entry.id}
    -
    Source
    {entry.source}
    -
    Manifest
    {entry.manifestDigest.slice(0, 12)}
    -
    - {entry.diagnostics.length === 0 ? undefined :
      - {entry.diagnostics.map((diagnostic, index) =>
    • - {diagnostic.severity} {diagnostic.code} {diagnostic.message} -
    • )} -
    } - {entry.timings.length === 0 ? undefined :
    - - {entry.timings.map((timing) => )} -
    PhaseDuration
    {timing.phase}{formatDuration(timing.durationMs)}
    } -
    ; - -export const TracePage = ({ backends, invocationId, onNavigate, tree }: TracePageProps) => { - const trace = useTraceEntries(backends, tree); - const selected = useTraceEntry(backends, trace.entries, invocationId); - const traceRoot: WorkbenchLocation = Object.freeze({ area: 'trace' }); - return
    -
    -
    -

    Trace

    -

    {invocationId === undefined - ? `Route invocations from this dev session, newest first${trace.loading ? ' — loading history…' : ` (${String(trace.entries.length)})`}.` - : <>One invocation. { event.preventDefault(); onNavigate(traceRoot); }}>All invocations} -

    +export const TracePage = ({ client, correlation, entries: suppliedEntries, entryId, onNavigate, timeZone }: TracePageProps) => { + const feed = useTraceFeed(client, suppliedEntries); + const groups = useMemo(() => groupTraceEntries(feed.entries), [feed.entries]); + const selectedEntry = entryId === undefined ? undefined : selectTraceEntry(feed.entries, entryId); + const correlatedGroup = correlation === undefined ? undefined : selectTraceGroup(groups, correlation); + const selectedGroup = correlatedGroup ?? (selectedEntry === undefined ? undefined : selectTraceGroup(groups, selectedEntry.id)); + const scope = correlation === undefined ? groups : correlatedGroup === undefined ? [] : [correlatedGroup]; + + const heading = !feed.loaded + ? 'Connecting…' + : `${String(feed.entries.length)} ${feed.entries.length === 1 ? 'entry' : 'entries'} in ${String(groups.length)} ${groups.length === 1 ? 'group' : 'groups'}${feed.connected ? ' · live' : feed.error === undefined ? '' : ' · reconnecting'}`; + + return
    +
    +
    +
    +

    Trace

    +

    {heading}

    +
    + {correlation === undefined ? undefined :

    + Correlated by {correlation} · Show all +

    } +
    + {feed.error === undefined ? undefined :

    {feed.error}

    } + {feed.gap === undefined ? undefined :

    {String(feed.gap.droppedCount)} earlier {feed.gap.droppedCount === 1 ? 'entry is' : 'entries are'} no longer retained.

    } +
    +
    + {feed.entries.length === 0 + ?

    {emptyMessage(feed)}

    + : scope.length === 0 + ?

    {`No entry carries ${correlation}.`}

    + : scope.map((group) => )} +
    - {trace.error === undefined ? undefined :

    {trace.error}

    } - {invocationId === undefined - ? trace.entries.length === 0 - ?

    {trace.loading ? 'Loading invocation history…' : 'No route has been invoked in this dev session yet. Run one from the application tree and it appears here.'}

    - : - : selected.entry !== undefined - ? - : selected.loading - ?

    Loading invocation {invocationId}…

    - :

    {selected.error ?? `Invocation ${invocationId} is not known to this dev session.`}

    } + {selectedEntry !== undefined + ? + : entryId === undefined + ? undefined + : }
    ; }; diff --git a/packages/workbench/tests/agent-bundle-remote-transport.test.ts b/packages/workbench/tests/agent-bundle-remote-transport.test.ts index c3740c182..9db1f1bbc 100644 --- a/packages/workbench/tests/agent-bundle-remote-transport.test.ts +++ b/packages/workbench/tests/agent-bundle-remote-transport.test.ts @@ -1,5 +1,6 @@ import { expect, it } from '@rstest/core'; import type { JSONRPCMessage } from '@modelcontextprotocol/client'; +import { mcpCorrelationMetaKey } from '../../agent-bundle/src/contracts/mcp-session.ts'; import { AgentBundleRemoteTransport, dispatchAgentBundleMcpRequest } from '../src/mcp/agent-bundle-remote-transport.ts'; import { McpRouteClient } from '../src/mcp/mcp-route-client.ts'; @@ -542,6 +543,34 @@ it('defaults omitted modern tool arguments and gives known invalid parameters a await transport.close(); }); +it('lowers the SDK-side _meta correlation to the route body\'s top-level correlationId and sends no _meta', async () => { + const stream = heldStream(); + const fixture = routeFetch({ + operation: () => ({ content: [] }), + streams: [stream.response], + }); + const transport = new AgentBundleRemoteTransport({ binding, routes: new McpRouteClient({ fetch: fixture.fetch }) }); + const messages: JSONRPCMessage[] = []; + transport.onmessage = (message) => messages.push(message); + + await transport.start(); + await transport.send({ id: 20, jsonrpc: '2.0', method: 'tools/call', params: { + _meta: { [mcpCorrelationMetaKey]: 'corr-app', progressToken: 'p1' }, + arguments: { city: 'London' }, + name: 'forecast', + } }); + await transport.send({ id: 21, jsonrpc: '2.0', method: 'tools/call', params: { _meta: { progressToken: 'p2' }, arguments: {}, name: 'forecast' } }); + await eventually(() => messages.length === 2); + + const bodies = fixture.requests.filter((request) => request.url.endsWith('/operations')).map((request) => request.body); + expect(bodies).toEqual([ + '{"arguments":{"city":"London"},"correlationId":"corr-app","name":"forecast","operation":"tools/call","requestId":"number:20"}', + '{"arguments":{},"name":"forecast","operation":"tools/call","requestId":"number:21"}', + ]); + expect(bodies.some((body) => body?.includes('_meta'))).toBe(false); + await transport.close(); +}); + it('aborts and waits for a bypassed cancellation before releasing its session', async () => { const stream = cancellableStream(); const errors: string[] = []; diff --git a/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts b/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts index 2420212da..73d1504a4 100644 --- a/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts +++ b/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts @@ -13,7 +13,27 @@ import { expectHealthyExamplePage, writeExampleReport, } from './support/example-acceptance.ts'; -import { editWatchedSource, expectApplicationTree, expectHeading, expectPrimaryNav, expectRenderedDocument, expectUnknownRouteMessage, fillRouteInput, openWorkbench, readBuildEpoch, readInvocationId, rebuildTimeout, runSelectedRoute, selectApplicationLeaf, waitForBuildEpochAdvance, workbenchTestId } from './support/workbench-acceptance.ts'; +import { + editWatchedSource, + expectApplicationTree, + expectHeading, + expectPrimaryNav, + expectRenderedDocument, + expectToolInvocationTraceGroup, + expectUnknownRouteMessage, + fillRouteInput, + invokeRouteFromWorkbench, + openWorkbench, + readBuildEpoch, + readCorrelationId, + readInvocationId, + rebuildTimeout, + runSelectedRoute, + selectApplicationLeaf, + traceEntryRow, + waitForBuildEpochAdvance, + workbenchTestId, +} from './support/workbench-acceptance.ts'; import { buildWorkbench, e2e, waitForWorkbenchIdle, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; import { inspectWorkbenchSurface, workbenchLeafPath } from '../../agent-bundle/src/test/index.ts'; import { applicationLeafForRouteId, applicationLeaves } from '../src/application/application-tree-model.ts'; @@ -30,9 +50,19 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou await mkdir(acceptanceLibrary); await writeFile(join(acceptanceLibrary, 'invalid.mp3'), 'not an audio stream'); const conversionSource = join(project.root, 'src', 'conversion.ts'); + const analysisSource = join(project.root, 'src', 'components', 'library-analysis.tsx'); const searchSource = join(project.root, 'src', 'mcp', 'curator', 'tools', 'search_audible.tsx'); const healthyConversion = await readFile(conversionSource, 'utf8'); + const healthyAnalysis = await readFile(analysisSource, 'utf8'); const healthySearch = await readFile(searchSource, 'utf8'); + const delayedAnalysis = healthyAnalysis.replace( + ' const measuredGroups = await Promise.all(', + ' await new Promise((resolve) => setTimeout(resolve, 1_500));\n const measuredGroups = await Promise.all(', + ); + if (delayedAnalysis === healthyAnalysis) { + throw new Error('library-analysis.tsx no longer contains the measured-groups anchor for the acceptance delay.'); + } + await writeFile(analysisSource, delayedAnalysis); const server = await startDevServer({ assets: createWorkbenchAssetSource({ root: workbenchAssets }), open: false, @@ -51,10 +81,11 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou throw new Error(`search_audible leaf was ${searchLeaf.ref.kind}, expected tool.`); } const inventoryLeaf = applicationLeafForRouteId(surface.application, 'tool:curator/inventory_sources'); + const auditLeaf = applicationLeafForRouteId(surface.application, 'tool:curator/audit_library'); const inventoryCliLeaf = applicationLeaves(surface.application).find((leaf) => leaf.routeId === 'cli:inventory' && leaf.ref.kind === 'cli'); - if (inventoryLeaf?.ref.kind !== 'tool' || inventoryCliLeaf?.ref.kind !== 'cli') { - throw new Error('inspectWorkbenchSurface did not project the inventory tool and CLI routes.'); + if (auditLeaf?.ref.kind !== 'tool' || inventoryLeaf?.ref.kind !== 'tool' || inventoryCliLeaf?.ref.kind !== 'cli') { + throw new Error('inspectWorkbenchSurface did not project the audit, inventory, and CLI routes.'); } expect(searchLeaf.ref.server).toBe('curator'); const searchPath = workbenchLeafPath(searchLeaf); @@ -66,6 +97,51 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou await expectApplicationTree(page, surface.application); await captureExampleState(page, 'audiobook-curator', 'application-populated'); + await selectApplicationLeaf(page, server.url, auditLeaf); + await workbenchTestId(page, 'routeInputEditor').getByRole('button', { name: 'Raw JSON' }).click(); + await workbenchTestId(page, 'routeInputEditor').locator('textarea').fill(JSON.stringify({ + sources: [acceptanceLibrary], + })); + await workbenchTestId(page, 'routeRun').click(); + await expect(workbenchTestId(page, 'routeRunningStatus')).toBeVisible({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'routeCancel')).toBeVisible({ timeout: browserTimeout }); + const liveDocument = workbenchTestId(page, 'renderedDocument'); + await expect(liveDocument).toHaveAttribute('aria-busy', 'true'); + await expect(liveDocument.locator('.rendered-document-body')).toBeVisible({ timeout: browserTimeout }); + await expect(liveDocument.locator('.rendered-document-body')).not.toBeEmpty(); + const liveInvocationId = await readInvocationId(page); + const liveCorrelationId = await readCorrelationId(page); + const tracePage = await page.context().newPage(); + await openWorkbench(tracePage, server.url, `/trace?correlation=${encodeURIComponent(liveCorrelationId)}`); + await expectHeading(tracePage, 'Trace'); + const runningGroup = workbenchTestId(tracePage, 'traceGroup').filter({ hasText: liveInvocationId }); + await expect(runningGroup).toBeVisible({ timeout: browserTimeout }); + await expect(runningGroup.locator('[data-kind="invocation.started"][data-status="running"]')).toBeVisible(); + await expect(runningGroup.locator('[data-testid="trace-entry"]')).not.toHaveCount(0); + await expect(workbenchTestId(page, 'routeStatus')).toHaveClass(/route-status--succeeded/u, { timeout: runTimeout }); + + await workbenchTestId(page, 'routeRun').click(); + await expect(workbenchTestId(page, 'routeRunningStatus')).toBeVisible({ timeout: browserTimeout }); + await workbenchTestId(page, 'routeCancel').click(); + await expect(workbenchTestId(page, 'routeStatus')).toContainText('Cancelled', { timeout: runTimeout }); + const cancelledInvocationId = await readInvocationId(page); + const cancelledCorrelationId = await readCorrelationId(page); + await expect(workbenchTestId(page, 'routeOutcome')).toHaveCount(0); + await openWorkbench(tracePage, server.url, `/trace?correlation=${encodeURIComponent(cancelledCorrelationId)}`); + const cancelledGroup = workbenchTestId(tracePage, 'traceGroup').filter({ hasText: cancelledInvocationId }); + await expect(cancelledGroup.locator('[data-kind="invocation.cancelled"]')).toBeVisible({ timeout: browserTimeout }); + const cancelledEnvelope = await page.evaluate(async (invocationId) => { + const session = await fetch('/api/project/session').then(async (response) => response.json()) as { token: string }; + const response = await fetch(`/api/routes/invocations/${encodeURIComponent(invocationId)}`, { + headers: { 'x-agent-bundle-session': session.token }, + }); + return response.json() as Promise<{ invocation: { outcome?: unknown; status: string } }>; + }, cancelledInvocationId); + expect(cancelledEnvelope.invocation.status).toBe('cancelled'); + expect(cancelledEnvelope.invocation).not.toHaveProperty('outcome'); + await tracePage.close(); + + await selectApplicationLeaf(page, server.url, inventoryLeaf); await fillRouteInput(page, { source: acceptanceLibrary }); await workbenchTestId(page, 'routeInputEditor').getByLabel('Strict').selectOption('true'); await runSelectedRoute(page, runTimeout); @@ -111,6 +187,33 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou await expect(workbenchTestId(page, 'resultTabTrace')).toBeVisible(); await captureExampleState(page, 'audiobook-curator', 'tool-rendered'); const invocationId = await readInvocationId(page); + const correlationId = await readCorrelationId(page); + const finalEnvelope = await page.evaluate(async (id) => { + const session = await fetch('/api/project/session').then(async (response) => response.json()) as { token: string }; + const response = await fetch(`/api/routes/invocations/${encodeURIComponent(id)}`, { + headers: { 'x-agent-bundle-session': session.token }, + }); + return response.json() as Promise<{ invocation: { + outcome?: { kind: string }; + providers: readonly { name: string; status: string }[]; + status: string; + timings: readonly { durationMs: number; phase: string }[]; + } }>; + }, invocationId); + expect(finalEnvelope.invocation.providers.length).toBeGreaterThan(0); + expect(finalEnvelope.invocation.timings.length).toBeGreaterThan(0); + expect(finalEnvelope.invocation.outcome).toBeDefined(); + const finalOutcome = finalEnvelope.invocation.outcome!; + await expect(workbenchTestId(page, 'routeStatus')).toHaveClass(new RegExp(`route-status--${finalEnvelope.invocation.status}`, 'u')); + await expect(workbenchTestId(page, 'routeOutcome')).toContainText(new RegExp(finalOutcome.kind, 'iu')); + const finalProvider = finalEnvelope.invocation.providers[0]!; + await workbenchTestId(page, 'inspectorToggle').click(); + await page.getByRole('tab', { name: 'Providers' }).click(); + await expect(page.getByRole('row').filter({ hasText: finalProvider.name })).toContainText(finalProvider.status); + const finalTiming = finalEnvelope.invocation.timings[0]!; + await page.getByRole('tab', { name: 'Timings' }).click(); + const timingRow = page.locator('.inspector-timings li').filter({ hasText: finalTiming.phase }); + await expect(timingRow).toContainText(`${String(finalTiming.durationMs)} ms`); const epochBeforeEdit = await readBuildEpoch(page); const markedSearch = healthySearch.replace( @@ -133,17 +236,123 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou await expect(workbenchTestId(page, 'routeWorkspace')).toBeVisible({ timeout: browserTimeout }); await expectRenderedDocument(page, runTimeout); expect(await readInvocationId(page)).toBe(invocationId); + await page.reload(); + await waitForWorkbenchIdle(page); + await expect(workbenchTestId(page, 'routeStatus')).toHaveClass(new RegExp(`route-status--${finalEnvelope.invocation.status}`, 'u')); + await expect(workbenchTestId(page, 'routeOutcome')).toContainText(new RegExp(finalOutcome.kind, 'iu')); + await expect(workbenchTestId(page, 'routeStatus')).toContainText(correlationId); + const routeId = searchLeaf.routeId ?? 'tool:curator/search_audible'; await openWorkbench(page, server.url, '/trace'); await expect(page.getByRole('heading', { name: 'Trace', exact: true })).toBeVisible({ timeout: browserTimeout }); - const traceRow = page.locator(`.trace-table tr[data-invocation-id=${JSON.stringify(invocationId)}]`); - await expect(traceRow).toBeVisible({ timeout: browserTimeout }); - await expect(traceRow).toContainText(searchLeaf.routeId ?? 'tool:curator/search_audible'); + await expectToolInvocationTraceGroup(page, { invocationId, routeId }); await captureExampleState(page, 'audiobook-curator', 'trace-populated'); - await traceRow.getByRole('link').first().click(); + + await openWorkbench(page, server.url, `/trace?correlation=${encodeURIComponent(correlationId)}`); + await expect(page.getByRole('heading', { name: 'Trace', exact: true })).toBeVisible({ timeout: browserTimeout }); + await expect(page.locator('.trace-scope')).toContainText(correlationId, { timeout: browserTimeout }); + const scopedGroup = await expectToolInvocationTraceGroup(page, { invocationId, routeId }); + await expect(workbenchTestId(page, 'traceGroup')).toHaveCount(1); + const scopedCompleted = scopedGroup.locator(`[data-testid="trace-entry"][data-kind="invocation.completed"]`); + await expect(scopedCompleted).toHaveCount(1); + + await scopedCompleted.click(); + await waitForWorkbenchIdle(page); + const detailPath = new URL(page.url()).pathname; + expect(detailPath).toMatch(/^\/trace\/trc_\d+$/u); + await expect(workbenchTestId(page, 'traceDetail')).toBeVisible({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'traceDetail')).toContainText(routeId, { timeout: browserTimeout }); + const entryId = detailPath.slice('/trace/'.length); + + await page.goto(workbenchUrl(server.url, `/trace/${encodeURIComponent(entryId)}`)); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe(`/trace/${entryId}`); + await expect(workbenchTestId(page, 'traceDetail')).toBeVisible({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'traceDetail')).toHaveAttribute('data-entry-id', entryId); + await expect(workbenchTestId(page, 'traceDetail')).toContainText(correlationId); + await page.reload(); + await waitForWorkbenchIdle(page); + await expect(workbenchTestId(page, 'traceDetail')).toHaveAttribute('data-entry-id', entryId); + await expect(workbenchTestId(page, 'traceDetail')).toContainText(correlationId); + + await workbenchTestId(page, 'traceDetail').getByRole('link', { name: 'Open route', exact: true }).click(); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe(searchPath); + expect(new URL(page.url()).searchParams.get('invocation')).toBe(invocationId); + await expect(workbenchTestId(page, 'routeWorkspace')).toBeVisible({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'routeStatus')).toHaveClass(/route-status--succeeded/u, { timeout: browserTimeout }); + expect(await readInvocationId(page)).toBe(invocationId); + await expectRenderedDocument(page, runTimeout); + await page.reload(); await waitForWorkbenchIdle(page); - expect(new URL(page.url()).pathname).toBe(`/trace/${encodeURIComponent(invocationId)}`); - await expect(page.getByTestId('trace-entry')).toBeVisible({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'routeStatus')).toHaveClass(new RegExp(`route-status--${finalEnvelope.invocation.status}`, 'u')); + await expect(workbenchTestId(page, 'routeOutcome')).toContainText(new RegExp(finalOutcome.kind, 'iu')); + await expect(workbenchTestId(page, 'routeStatus')).toContainText(correlationId); + + await openWorkbench(page, server.url, '/trace'); + const completedBeforeLive = await traceEntryRow(page, 'invocation.completed').count(); + const liveUrl = page.url(); + const traceLiveInvocationId = await invokeRouteFromWorkbench(page, { input: { title: searchTitle }, routeId }); + expect(traceLiveInvocationId).not.toBe(invocationId); + await expect.poll( + async () => traceEntryRow(page, 'invocation.completed').count(), + { timeout: browserTimeout }, + ).toBeGreaterThan(completedBeforeLive); + await expectToolInvocationTraceGroup(page, { invocationId: traceLiveInvocationId, routeId }); + expect(page.url()).toBe(liveUrl); + + await openWorkbench(page, server.url, '/advanced/protocol'); + await expectHeading(page, 'MCP playground'); + await page.locator('#mcp-target').selectOption('claude'); + await page.locator('#mcp-server-name').fill('curator'); + const openedMcp = page.waitForResponse((response) => + response.url() === `${server.url}/api/mcp/sessions` && response.request().method() === 'POST'); + await page.getByRole('button', { name: 'Open MCP session' }).click(); + const mcpSession = await (await openedMcp).json() as { session: { id: string } }; + await expect(page.locator('.mcp-page-phase')).toContainText('Session ready', { timeout: browserTimeout }); + await page.getByRole('button', { name: 'search_audible', exact: true }).click(); + const mcpArguments = page.locator('#mcp-tool-arguments-raw'); + if (await mcpArguments.count() === 0) await page.getByLabel('Raw JSON').check(); + await mcpArguments.fill(JSON.stringify({ title: searchTitle })); + await page.getByRole('button', { name: 'Call search_audible' }).click(); + await expect(page.getByRole('region', { name: 'Invocation history' })).toContainText(searchTitle, { timeout: runTimeout }); + const receiptEndpoint = JSON.parse( + await readFile(join(project.root, '.agent-bundle', 'hook-receipts.json'), 'utf8'), + ) as { token: string; url: string }; + const receiptResponse = await fetch(`${receiptEndpoint.url}/api/trace/receipts`, { + body: JSON.stringify({ + events: [ + { at: 0, kind: 'execute.start', phase: 'execute', runtime: 'standalone', sequence: 0 }, + { at: 1, durationMs: 1, kind: 'render.finish', phase: 'render', sequence: 1 }, + ], + execution: { + event: 'tool/before', + executionId: `execution-${mcpSession.session.id}`, + host: 'claude', + nativeEvent: 'PreToolUse', + }, + identity: { + conversationId: mcpSession.session.id, + requestId: 'request-browser-correlation', + sessionId: mcpSession.session.id, + }, + lineage: { reason: 'not-provided', state: 'unavailable' }, + startedAt: new Date().toISOString(), + version: 1, + }), + headers: { + authorization: `Bearer ${receiptEndpoint.token}`, + 'content-type': 'application/json', + }, + method: 'POST', + }); + expect(receiptResponse.status).toBe(204); + await openWorkbench(page, server.url, `/trace?correlation=${encodeURIComponent(mcpSession.session.id)}`); + await expectHeading(page, 'Trace'); + const sessionGroup = workbenchTestId(page, 'traceGroup'); + await expect(sessionGroup).toHaveCount(1, { timeout: browserTimeout }); + await expect(sessionGroup.locator('[data-kind="mcp.request"]').filter({ hasText: 'tools/call search_audible' })).toBeVisible({ timeout: browserTimeout }); + await expect(sessionGroup.locator('[data-kind="hook.received"]').first()).toBeVisible({ timeout: browserTimeout }); await editWatchedSource(server, project.root, conversionSource, `${healthyConversion}\nconst = ;\n`, 'failed'); await page.reload(); diff --git a/packages/workbench/tests/event-route-workspace.test.ts b/packages/workbench/tests/event-route-workspace.test.ts index fb085f48f..046e8c73e 100644 --- a/packages/workbench/tests/event-route-workspace.test.ts +++ b/packages/workbench/tests/event-route-workspace.test.ts @@ -48,6 +48,7 @@ const hostInvocation: RouteInvocation = { const controller = (state: RouteInvocationController['state']): RouteInvocationController => ({ backendKind: 'dev-server', + cancel: noop, history: [summaryOf(hostInvocation), summaryOf(invocation)], load: noop, run: noop, diff --git a/packages/workbench/tests/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts index 64d2ec3e4..a17a0cdbe 100644 --- a/packages/workbench/tests/invocation-client.test.ts +++ b/packages/workbench/tests/invocation-client.test.ts @@ -2,6 +2,7 @@ import { expect, it } from '@rstest/core'; import type { RouteInvocation } from '../../agent-bundle/src/contracts/invocations.ts'; import { InvocationClient, InvocationClientError } from '../src/application/invocation-client.ts'; +import { invocationSummaryOf } from '../src/application/invocation-model.ts'; import type { ForegroundRequestAuthority } from '../src/mcp/mcp-route-client.ts'; const unavailable = () => Object.freeze({ @@ -118,6 +119,24 @@ it('strictly decodes invoke, list, and read responses', async () => { }); }); +it('sends and decodes the optional correlationId on invocations and summaries', async () => { + const requests: Array = []; + const correlated = { ...invocation, correlationId: 'corr-1' } satisfies RouteInvocation; + const client = new InvocationClient({ foreground: foreground((path, init) => { + requests.push([path, init]); + return Response.json(path.includes('?limit=') + ? { invocations: [{ ...invocationSummaryOf(correlated) }] } + : { invocation: correlated }); + }) }); + + await expect(client.invoke({ correlationId: 'corr-1', input: { title: 'Dune' }, routeId: invocation.routeId })).resolves.toEqual(correlated); + await expect(client.list(1)).resolves.toEqual([expect.objectContaining({ correlationId: 'corr-1', id: invocation.id })]); + expect(JSON.parse(String(requests[0]?.[1].body))).toEqual({ correlationId: 'corr-1', input: { title: 'Dune' }, routeId: invocation.routeId }); + + const rejecting = new InvocationClient({ foreground: foreground(() => Response.json({ invocation: { ...invocation, requestId: 7 } })) }); + await expect(rejecting.invoke({ routeId: invocation.routeId })).rejects.toMatchObject({ code: 'AB8230' }); +}); + it('preserves coded HTTP diagnostics', async () => { const client = new InvocationClient({ foreground: foreground(() => Response.json({ diagnostic: { code: 'AB8232', message: 'No published build.' }, diff --git a/packages/workbench/tests/invocation-model.test.ts b/packages/workbench/tests/invocation-model.test.ts index 1933a2ba3..e87fad40c 100644 --- a/packages/workbench/tests/invocation-model.test.ts +++ b/packages/workbench/tests/invocation-model.test.ts @@ -96,6 +96,26 @@ it('reduces invocation lifecycle states without retaining stale failures', () => expect(reduceInvocationState(running, { type: 'reset' })).toBe(idleInvocationState); }); +it('retains only the newest 256 live render events', () => { + let state = reduceInvocationState(idleInvocationState, { correlationId: 'c1', startedAt: 1_000, type: 'start' }); + for (let sequence = 0; sequence < 300; sequence += 1) { + state = reduceInvocationState(state, { + event: { + document: { root: { kind: 'text', text: 'rendering' }, status: 'success', version: 1 }, + sequence, + type: 'shell', + }, + type: 'render', + }); + } + + expect(state).toMatchObject({ phase: 'running' }); + if (state.phase !== 'running') throw new Error('Expected a running invocation.'); + expect(state.events).toHaveLength(256); + expect(state.events?.[0]?.sequence).toBe(44); + expect(state.events?.at(-1)?.sequence).toBe(299); +}); + it('stores strict JSON last-input snapshots by leaf key and tolerates unavailable storage', () => { writeLastInput(leaf.key, { regions: ['us'], title: 'Dune' }); expect(readLastInput(leaf.key)).toEqual({ regions: ['us'], title: 'Dune' }); diff --git a/packages/workbench/tests/lifecycles.e2e.test.ts b/packages/workbench/tests/lifecycles.e2e.test.ts index fc07fabb5..8afce65c7 100644 --- a/packages/workbench/tests/lifecycles.e2e.test.ts +++ b/packages/workbench/tests/lifecycles.e2e.test.ts @@ -84,11 +84,11 @@ e2e( await expect(stage).toContainText('Recorded observed-lifecycle.txt from claude', { timeout: browserTimeout }); await page.getByRole('tab', { name: 'Canonical → host mapping' }).click(); const requestContext = page.getByRole('tabpanel'); - await expect(requestContext).toContainText('claude · derived'); + await expect(requestContext).toContainText('claude · receipt'); await expect(requestContext).toContainText('lifecycle-observed'); await expect(requestContext).toContainText('/tmp'); await expect(requestContext).toContainText('Unavailable · not-provided'); - await expect(requestContext).toContainText('Unavailable · no-shared-runtime'); + await expect(requestContext).toContainText('lifecycle-observed · depth 0 · native · receipt'); const sessionToken = await page.evaluate(async () => { const response = await fetch('/api/project/session', { credentials: 'same-origin' }); diff --git a/packages/workbench/tests/log-client.test.ts b/packages/workbench/tests/log-client.test.ts index 7f49f7e8b..ab7ab68f4 100644 --- a/packages/workbench/tests/log-client.test.ts +++ b/packages/workbench/tests/log-client.test.ts @@ -169,6 +169,8 @@ it('rejects duplicate replay keys, extra record fields, and unsafe wire text bef await expect(unsafeText.replay()).rejects.toMatchObject({ code: 'AB8093', message: 'Dev Log route returned an invalid response.' }); for (const summary of [ + '/home/zack/private/fixture', + '~/private/fixture', 'C:\\private\\fixture', 'C:/private/fixture', 'C:private', @@ -180,6 +182,16 @@ it('rejects duplicate replay keys, extra record fields, and unsafe wire text bef } }); +it('accepts slash-bearing relative identities in free log text', async () => { + const records = [ + { ...record, summary: 'MCP tool curator/search_audible · 2.9 s' }, + { ...record, details: { event: 'event tool/before (claude)' }, sequence: 2 }, + ]; + await expect(clientFor(json({ + replay: { cursor: { afterSequence: 2 }, records }, + })).replay()).resolves.toMatchObject({ records }); +}); + it('rejects malformed UTF-8 and a frame larger than 64 KiB before decoding NDJSON records', async () => { const encoder = new TextEncoder(); const malformedPrefix = encoder.encode('{"context":{},"details":{},"kind":"project.load","level":"info","occurredAt":"2026-08-18T12:00:00.000Z","producer":"project","sequence":1,"summary":"'); diff --git a/packages/workbench/tests/logs-page.test.ts b/packages/workbench/tests/logs-page.test.ts index f5cbd9b36..1f1cf0d96 100644 --- a/packages/workbench/tests/logs-page.test.ts +++ b/packages/workbench/tests/logs-page.test.ts @@ -9,7 +9,7 @@ import { logsViewFor, maximumLogViewRecords, mergeDevLogRecords } from '../src/l const records: readonly DevLogRecord[] = Object.freeze([ Object.freeze({ - context: Object.freeze({ epochId: 'epoch-1', target: 'codex' }), + context: Object.freeze({ correlationId: 'correlation-1', epochId: 'epoch-1', target: 'codex' }), details: Object.freeze({ changed: ['src/index.ts'] }), kind: 'build.started', level: 'info', @@ -85,3 +85,20 @@ it('renders independent production log filters without a playground session', () expect(markup).toContain('id="logs-context"'); expect(markup).toContain('Project diagnostic was recorded.'); }); + +it('links correlated raw log rows into the trace without linking uncorrelated rows', () => { + const markup = renderToStaticMarkup(createElement(LogsView, { + view: logsViewFor({ + context: undefined, + gap: undefined, + kind: undefined, + level: undefined, + producer: undefined, + records, + }), + })); + + expect(markup).toContain('href="/trace?correlation=correlation-1"'); + expect(markup).toContain('Open in Trace'); + expect(markup.match(/Open in Trace/gu)).toHaveLength(1); +}); diff --git a/packages/workbench/tests/mcp-page.test.ts b/packages/workbench/tests/mcp-page.test.ts index c37e0e885..21fc550e6 100644 --- a/packages/workbench/tests/mcp-page.test.ts +++ b/packages/workbench/tests/mcp-page.test.ts @@ -564,6 +564,42 @@ describe('MCP page', () => { expect(markup).toContain('Trace delivery is delayed.'); }); + it('shows a raw frame\'s lifted id, method, and _meta keys and links its correlation to the unified Trace', () => { + const frame = { + direction: 'client', + id: 'number:7', + kind: 'frame', + message: { id: 7, jsonrpc: '2.0', method: 'tools/call', params: { arguments: {}, name: 'weather' } }, + meta: { correlationId: 'corr-1', requestId: 'req/1', sessionId: 'sess-1' }, + method: 'tools/call', + occurredAt: 1_700_000_000_001, + sequence: 1, + }; + const response = { direction: 'server', kind: 'frame', message: { id: 7, jsonrpc: '2.0', result: {} }, occurredAt: 1_700_000_000_002, sequence: 2 }; + const withFrames = { + ...model, + conciseTrace: [frame, response], + timeline: { droppedThroughSequence: 0, entries: [frame, response], lastSequence: 2 }, + } as unknown as McpBrowserSessionModel; + const markup = renderToStaticMarkup(createElement(McpPage, { + controller: { ...controller(), model: withFrames }, + epochOptions: ['epoch-1'], + onNavigate: () => undefined, + targetOptions: ['codex'], + })); + + expect(markup).toContain('data-testid="mcp-frame-facts"'); + expect(markup).toContain('client → server'); + expect(markup).toContain('server → client'); + expect(markup).toContain('method tools/call'); + expect(markup).toContain('id number:7'); + expect(markup).toContain('href="/trace?correlation=corr-1"'); + expect(markup).toContain('request req/1'); + expect(markup).toContain('session sess-1'); + expect(markup).not.toContain('conversation '); + expect(markup.match(/data-testid="mcp-frame-facts"/gu)).toHaveLength(2); + }); + it('builds a detached export of the complete current protocol trace without launch credentials', async () => { const mutableHistory = [{ binding: { epochId: 'epoch-1', serverName: 'weather', target: 'codex' }, diff --git a/packages/workbench/tests/mcp-session-controller.test.ts b/packages/workbench/tests/mcp-session-controller.test.ts index 9c4e95a42..642f5dfbe 100644 --- a/packages/workbench/tests/mcp-session-controller.test.ts +++ b/packages/workbench/tests/mcp-session-controller.test.ts @@ -1,5 +1,6 @@ import { expect, it } from '@rstest/core'; import { specTypeSchemas, type Client, type Transport } from '@modelcontextprotocol/client'; +import { mcpCorrelationMetaKey } from '../../agent-bundle/src/contracts/mcp-session.ts'; import type { McpAppBoundOperationResult } from '../../agent-bundle/src/dev/mcp-app-runtime-binding-service.ts'; import type { McpAppBindingOperation } from '../../agent-bundle/src/dev/mcp-app-runtime-preview-service.ts'; @@ -304,7 +305,12 @@ it('attaches one non-owning runtime App client through one exact App authority a await attachedTransport.send({ id: 1, jsonrpc: '2.0', method: 'tools/list' }); await attachedTransport.send({ id: 2, jsonrpc: '2.0', method: 'resources/list' }); await attachedTransport.send({ id: 3, jsonrpc: '2.0', method: 'resources/read', params: { uri: 'weather://today' } }); - await attachedTransport.send({ id: 4, jsonrpc: '2.0', method: 'tools/call', params: { arguments: { city: 'Paris' }, name: 'forecast' } }); + await attachedTransport.send({ + id: 4, + jsonrpc: '2.0', + method: 'tools/call', + params: { _meta: { [mcpCorrelationMetaKey]: 'corr-runtime-app' }, arguments: { city: 'Paris' }, name: 'forecast' }, + }); await attachedTransport.send({ id: 5, jsonrpc: '2.0', method: 'prompts/list' }); await expect(attachedTransport.send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progress: 1 } })).rejects.toThrow( 'MCP remote transport received an invalid notification.', @@ -2159,6 +2165,71 @@ it('keeps a built MCP App resource frame in the live trace', async () => { await controller.close(); }); +it('carries the lifted frame id, method, and _meta correlation keys onto the browser frame', async () => { + const stream = traceStream(); + const routes: McpSessionControllerRoutes = { ...emptyRoutes, stream: async () => stream.response }; + const controller = createMcpSessionController({ clientFactory: fakeClient, routes, transportFactory: fakeTransport }); + await controller.open(binding); + + const message = { id: 7, jsonrpc: '2.0', method: 'tools/call', params: { arguments: {}, name: 'forecast' } }; + stream.send({ direction: 'client', id: '7', kind: 'frame', message, meta: { correlationId: 'corr-1', requestId: 'req-1' }, method: 'tools/call', occurredAt: 1, sequence: 1 }); + stream.send({ direction: 'server', kind: 'frame', message: { jsonrpc: '2.0', method: 'notifications/progress' }, method: 'notifications/progress', occurredAt: 2, sequence: 2 }); + await eventually(() => controller.model.timeline.entries.length === 2); + + expect(controller.model.timeline.entries).toEqual([ + { direction: 'client', id: '7', kind: 'frame', message, meta: { correlationId: 'corr-1', requestId: 'req-1' }, method: 'tools/call', occurredAt: 1, sequence: 1 }, + { direction: 'server', kind: 'frame', message: { jsonrpc: '2.0', method: 'notifications/progress' }, method: 'notifications/progress', occurredAt: 2, sequence: 2 }, + ]); + stream.close(); + await controller.close(); +}); + +it('fails the trace stream on a frame whose lifted keys are unbounded or carry an unknown meta key', async () => { + const frame = { direction: 'client', kind: 'frame', message: {}, occurredAt: 1, sequence: 1 }; + for (const corrupt of [ + { ...frame, id: 'x'.repeat(257) }, + { ...frame, method: '' }, + { ...frame, meta: { correlationId: 7 } }, + { ...frame, meta: { toolUseId: 'toolu_01' } }, + { ...frame, meta: 'corr-1' }, + ]) { + const stream = traceStream(); + const controller = createMcpSessionController({ + clientFactory: fakeClient, + routes: { ...emptyRoutes, stream: async () => stream.response }, + transportFactory: fakeTransport, + }); + await controller.open(binding); + stream.send(corrupt); + await eventually(() => controller.model.phase === 'error'); + expect(controller.model.diagnostics).toContainEqual(expect.objectContaining({ code: 'mcp.trace.stream.error' })); + expect(controller.model.timeline.entries).toEqual([]); + stream.close(); + await controller.close(); + } +}); + +it('stamps an invoke correlationId into the SDK request _meta under the route\'s key', async () => { + const sent: unknown[] = []; + const client: McpSessionControllerClient = { + ...fakeClient(), + request: async (request) => { sent.push(request); return { content: [] }; }, + }; + const controller = createMcpSessionController({ clientFactory: () => client, routes: emptyRoutes, transportFactory: fakeTransport }); + await controller.open(binding); + + await controller.invoke({ correlationId: 'corr-app', id: 'call-1', operation: 'callTool', request: { arguments: { city: 'London' }, name: 'forecast' } }); + await controller.invoke({ id: 'call-2', operation: 'callTool', request: { arguments: {}, name: 'forecast' } }); + await controller.invoke({ correlationId: 'corr-task', id: 'call-3', operation: 'callToolTask', request: { arguments: {}, name: 'forecast', task: { ttl: 1_000 } } }); + + expect(sent).toEqual([ + { method: 'tools/call', params: { _meta: { [mcpCorrelationMetaKey]: 'corr-app' }, arguments: { city: 'London' }, name: 'forecast' } }, + { method: 'tools/call', params: { arguments: {}, name: 'forecast' } }, + { method: 'tools/call', params: { _meta: { [mcpCorrelationMetaKey]: 'corr-task' }, arguments: {}, name: 'forecast', task: { ttl: 1_000 } } }, + ]); + await controller.close(); +}); + const invalidTraceBodies = (): readonly (readonly [string, BodyInit])[] => { const entry = { direction: 'server', kind: 'logging', occurredAt: 1, payload: { message: 'partial' }, sequence: 1 }; const serialized = JSON.stringify(entry); diff --git a/packages/workbench/tests/mcp-session-model.test.ts b/packages/workbench/tests/mcp-session-model.test.ts index 873ca16db..e1eae6697 100644 --- a/packages/workbench/tests/mcp-session-model.test.ts +++ b/packages/workbench/tests/mcp-session-model.test.ts @@ -3,9 +3,31 @@ import { expect, it } from '@rstest/core'; import { createMcpBrowserSessionModel, invocationHistoryFor, + isMcpFrameEntry, reduceMcpBrowserSession, } from '../src/mcp/mcp-session-model.ts'; +it('carries the lifted id, method, and _meta keys on a frame and narrows only frames', () => { + let model = createMcpBrowserSessionModel('session-weather'); + model = reduceMcpBrowserSession(model, { binding: { epochId: 'epoch-a', serverName: 'weather', target: 'claude' }, type: 'open' }); + const meta = { correlationId: 'corr-1', requestId: 'req-1', sessionId: 'sess-1' }; + model = reduceMcpBrowserSession(model, { + entry: { direction: 'client', id: 'number:7', kind: 'frame', message: { id: 7, method: 'tools/call' }, meta, method: 'tools/call', occurredAt: 100, sequence: 1 }, + type: 'trace', + }); + model = reduceMcpBrowserSession(model, { + entry: { kind: 'logging', occurredAt: 101, payload: { message: 'hi' }, sequence: 2 }, + type: 'trace', + }); + + const [frame, logging] = model.timeline.entries; + expect(frame).toEqual({ direction: 'client', id: 'number:7', kind: 'frame', message: { id: 7, method: 'tools/call' }, meta, method: 'tools/call', occurredAt: 100, sequence: 1 }); + expect(Object.isFrozen(frame) && isMcpFrameEntry(frame) && Object.isFrozen(frame.meta)).toBe(true); + expect(isMcpFrameEntry(logging)).toBe(false); + expect(isMcpFrameEntry({ direction: 'client', kind: 'frame' })).toBe(false); + expect(model.conciseTrace).toBe(model.timeline.entries); +}); + it('snapshots and freezes the selected session binding, connection, catalogs, and config', () => { const binding = { epochId: 'epoch-a', serverName: 'weather', target: 'claude' as const }; const connection = { diff --git a/packages/workbench/tests/packed-outage-ledger.test.ts b/packages/workbench/tests/packed-outage-ledger.test.ts index eee242364..2f6cfab43 100644 --- a/packages/workbench/tests/packed-outage-ledger.test.ts +++ b/packages/workbench/tests/packed-outage-ledger.test.ts @@ -423,7 +423,18 @@ test('outage ledger rejects the legacy duplicate, cross-origin, and missing-clea expect(() => validateOutageLedger(preCloseFreshStreamCancellation)).toThrow(/fresh B MCP stream cancellation is not action-induced/u); expect(() => validateOutageLedger(postCloseFreshStreamCancellation)).toThrow(/fresh B MCP stream cancellation is not action-induced/u); expect(() => validateOutageLedger(validPostRecovery)).not.toThrow(); + const postRecoveryTraceStreamCancellation = Object.freeze({ + ...validPostRecovery, + requests: Object.freeze([ + ...validPostRecovery.requests, + ledgerRequest({ + at: 1_345, completedAt: 1_351, error: 'net::ERR_ABORTED', method: 'GET', path: '/api/trace/stream', + respondedAt: 1_346, status: 200, url: `${valid.origin}/api/trace/stream?after=29`, + }), + ]), + }); expect(() => validateOutageLedger(navigationLiveStreamCancellation)).not.toThrow(); + expect(() => validateOutageLedger(postRecoveryTraceStreamCancellation)).not.toThrow(); expect(() => validateOutageLedger(navigationRespondedCatalogCancellation)).not.toThrow(); expect(() => validateOutageLedger(sameMillisecondDepartedRequest)).not.toThrow(); expect(() => validateOutageLedger(sameMillisecondNextPageRequest)).toThrow(/unknown post-recovery failure/u); diff --git a/packages/workbench/tests/packed-release.e2e.test.ts b/packages/workbench/tests/packed-release.e2e.test.ts index b624e2294..e8676fb2c 100644 --- a/packages/workbench/tests/packed-release.e2e.test.ts +++ b/packages/workbench/tests/packed-release.e2e.test.ts @@ -438,7 +438,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * const epochBMarker = 'Epoch B changed the packed review guidance.'; await replaceSourceAndAwaitWatcherRebuild('epoch B', skillSource, `${originalSkill}\n\n${epochBMarker}\n`); await openPrimaryArea('problems'); - await expect(page.getByRole('heading', { name: /^Problems/u })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: /^Problems(?: \([0-9]+\))?$/u })).toBeVisible({ timeout: browserTimeout }); await rebuildFromProblems('epoch B'); const epochBStatus = activeEpochFrom(await call('project_status'), 'epoch B'); expect(epochBStatus.artifactStatus.state).toBe('active'); @@ -472,7 +472,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * if (invalidConfig === originalConfig) throw new Error('The packed fixture did not contain the resource URI used for the invalid rebuild.'); await replaceSourceAndAwaitWatcherRebuild('invalid epoch B', configSource, invalidConfig); await openPrimaryArea('problems'); - await expect(page.getByRole('heading', { name: /^Problems/u })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: /^Problems(?: \([0-9]+\))?$/u })).toBeVisible({ timeout: browserTimeout }); await rebuildFromProblems('invalid epoch B'); const staleStatus = activeEpochFrom(await call('project_status'), 'stale epoch B'); expect(staleStatus.artifactStatus.state).toBe('stale'); @@ -855,7 +855,9 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * const openedIndex = browserRequests.length; await page.getByTestId('workbench-nav').locator(`[data-area="${route.label.toLowerCase()}"]`).click(); if (route.heading !== undefined) { - await expect(page.getByRole('heading', { name: new RegExp(`^${route.heading}`, 'u') })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { + name: route.heading === 'Problems' ? /^Problems(?: \([0-9]+\))?$/u : new RegExp(`^${route.heading}$`, 'u'), + })).toBeVisible({ timeout: browserTimeout }); } if (route.testId !== undefined) { await expect(page.getByTestId(route.testId)).toBeVisible({ timeout: browserTimeout }); diff --git a/packages/workbench/tests/project-client.test.ts b/packages/workbench/tests/project-client.test.ts index 86ef7f446..a59a86083 100644 --- a/packages/workbench/tests/project-client.test.ts +++ b/packages/workbench/tests/project-client.test.ts @@ -518,6 +518,62 @@ it('delivers synchronous runtime events once in FIFO order and refreshes after a expect(requests).toEqual(['/api/project/status', '/api/project/status', '/api/project/status']); }); +it('delivers route.invocation events to subscribers without refreshing project status', async () => { + const stream = new RecordingEventSource(); + const requests: string[] = []; + const received: string[] = []; + const client = new ProjectClient({ + events: () => stream, + fetch: withForegroundSession(async (input) => { + requests.push(String(input)); + return Response.json({ status: status() }); + }), + }); + await client.connect(() => undefined, undefined, (event) => received.push(`legacy:${event.type}`)); + client.subscribeEvents((event) => { + if (event.type !== 'route.invocation') return; + received.push(`${event.type}:${String(event.sequence)}:${event.payload.invocation.id}:${String(Object.isFrozen(event.payload.invocation))}`); + }); + expect(stream.listeners.some((listener) => listener.type === 'route.invocation')).toBe(true); + + const invocation = (sequence: number): { readonly data: string; readonly lastEventId: string } => ({ + data: JSON.stringify({ + occurredAt: '2026-09-05T07:00:01.000Z', + payload: { + invocation: { + completedAt: '2026-09-05T07:00:01.000Z', + correlationId: 'corr-1', + diagnostics: [], + id: `inv_${String(sequence)}`, + input: {}, + kind: 'tool', + manifestDigest: 'a'.repeat(64), + routeId: 'tool:curator/search', + source: 'src/mcp/curator/tools/search.tsx', + sourceRevision: 'r', + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded', + timings: [], + }, + }, + sequence, + type: 'route.invocation', + }), + lastEventId: String(sequence), + }); + stream.emit('route.invocation', invocation(1)); + stream.emit('route.invocation', invocation(2)); + await flushEvents(); + + expect(received).toEqual([ + 'legacy:route.invocation', 'route.invocation:1:inv_1:true', + 'legacy:route.invocation', 'route.invocation:2:inv_2:true', + ]); + expect(client.lastEventId).toBe(2); + expect(requests).toEqual(['/api/project/status']); + client.close(); +}); + it('preserves a synchronous runtime event after replay gap delivery', async () => { const stream = new RecordingEventSource(); const requests: string[] = []; diff --git a/packages/workbench/tests/route-workspace.test.ts b/packages/workbench/tests/route-workspace.test.ts index 0c34b853d..7adcd50da 100644 --- a/packages/workbench/tests/route-workspace.test.ts +++ b/packages/workbench/tests/route-workspace.test.ts @@ -3,11 +3,12 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from '@rstest/core'; -import { appResourceUriFor, catalogToolsFor, orderedToolsForApp } from '../src/application/app-route-workspace.tsx'; +import type { TraceEntry } from '../../agent-bundle/src/contracts/trace.ts'; +import { appResourceUriFor, appToolCallRequest, catalogToolsFor, orderedToolsForApp } from '../src/application/app-route-workspace.tsx'; import { defaultEventHostSelection } from '../src/application/event-route-workspace.tsx'; import { ExecutableRouteWorkspace, resultTabFor } from '../src/application/executable-route-workspace.tsx'; import { idleInvocationState, reduceInvocationState, selectBackend } from '../src/application/invocation-model.ts'; -import { ResultTabs } from '../src/application/result-tabs.tsx'; +import { ResultTabs, TraceTimeline } from '../src/application/result-tabs.tsx'; import { requestContextRows, RouteInspector } from '../src/application/route-inspector.tsx'; import { RouteWorkspace } from '../src/application/route-workspace.tsx'; import type { RouteInvocationController } from '../src/application/workspace-contracts.ts'; @@ -30,6 +31,7 @@ const noop = (): void => undefined; const controllerWith = (overrides: Partial = {}): RouteInvocationController => ({ backendKind: 'dev-server', + cancel: noop, history: [summaryOf(invocation)], load: noop, run: noop, @@ -62,6 +64,45 @@ describe('invocation state contract', () => { expect(reduceInvocationState(loaded, { type: 'reset' })).toBe(idleInvocationState); }); + it('streams render progress, exposes cancellation, and settles cancelled without an outcome', () => { + const running = reduceInvocationState(idleInvocationState, { correlationId: 'c', startedAt: 1_000, type: 'start' }); + const identified = reduceInvocationState(running, { invocationId: 'inv-live', type: 'stream.start' }); + const rendered = reduceInvocationState(identified, { + event: invocation.events[0]!, + type: 'render', + }); + const progressed = reduceInvocationState(rendered, { + event: invocation.events[1]!, + type: 'render', + }); + expect(progressed).toMatchObject({ + events: [expect.objectContaining({ type: 'shell' }), expect.objectContaining({ type: 'progress' })], + invocationId: 'inv-live', + phase: 'running', + }); + + const { outcome: _outcome, ...withoutOutcome } = invocation; + const cancelled = { ...withoutOutcome, status: 'cancelled' as const }; + expect(reduceInvocationState(progressed, { + completedAt: 1_200, + invocation: cancelled, + type: 'settle', + })).toMatchObject({ + invocation: { status: 'cancelled' }, + phase: 'failed', + }); + + const markup = renderToStaticMarkup(createElement(ExecutableRouteWorkspace, { + controller: controllerWith({ state: progressed }), + leaf: toolLeaf, + onNavigate: noop, + })); + expect(markup).toContain('data-testid="route-running-status"'); + expect(markup).toContain('data-testid="route-cancel"'); + expect(markup).toContain('data-testid="rendered-document-progress"'); + expect(markup).toContain('Searching us'); + }); + it('selects the first backend that accepts the leaf', () => { const runtime = fakeBackend(invocation, 'runtime'); const devServer = fakeBackend(); @@ -322,10 +363,66 @@ describe('ResultTabs', () => { expect(raw).toContain('Progress · #1 · Searching us · 1 / 2'); expect(raw).toContain('Complete · #2 · success'); expect(render('mcp')).toContain('structuredContent'); - const trace = render('trace'); - expect(trace).toContain('aria-label="Invocations of this route"'); - expect(trace).toContain('inv-1'); - expect(trace).toContain('result-trace-entry--current'); + expect(render('trace')).toContain('Loading correlated trace…'); + }); + + it('filters unified trace entries by invocation correlation and nests kernel phases', () => { + const entries: readonly TraceEntry[] = [ + { + correlation: { correlationId: 'corr-1', invocationId: 'inv-1' }, + id: 'trace-invocation', + kind: 'invocation.completed', + occurredAt: '2026-09-05T08:00:00.432Z', + sequence: 4, + source: 'invocation', + status: 'ok', + summary: 'search_audible completed', + }, + { + correlation: { correlationId: 'corr-1', executionId: 'exec-1', invocationId: 'inv-1' }, + durationMs: 5, + id: 'trace-render', + kind: 'kernel.render.finish', + occurredAt: '2026-09-05T08:00:00.407Z', + sequence: 3, + source: 'kernel', + status: 'ok', + summary: 'Rendered AgentDocument', + }, + { + correlation: { correlationId: 'other' }, + id: 'trace-other', + kind: 'mcp.request', + occurredAt: '2026-09-05T08:00:00.100Z', + sequence: 2, + source: 'mcp', + summary: 'Unrelated request', + }, + ]; + const markup = renderToStaticMarkup(createElement(TraceTimeline, { + correlationId: invocation.correlationId, + entries, + invocationId: invocation.id, + })); + + expect(markup).toContain('search_audible completed'); + expect(markup).toContain('Rendered AgentDocument'); + expect(markup).toContain('result-trace-kernel'); + expect(markup).toContain('href="/trace/trace-render"'); + expect(markup).not.toContain('Unrelated request'); + }); + + it('offers Open in Trace for a settled correlated invocation', () => { + const markup = renderToStaticMarkup(createElement(ResultTabs, { + controller: succeeded, + leaf: toolLeaf, + onNavigate: noop, + onTabChange: noop, + tab: 'rendered', + })); + + expect(markup).toContain('href="/trace?correlation=corr-1"'); + expect(markup).toContain('Open in Trace'); }); it('marks the rendered pane pending while the backend is running', () => { @@ -342,6 +439,23 @@ describe('ResultTabs', () => { }); }); +it('shows an explicit state when a deep-linked invocation is not in this session', () => { + const markup = renderToStaticMarkup(createElement(ExecutableRouteWorkspace, { + controller: controllerWith({ + state: { + diagnostics: [], + failure: { code: 'AB8231', message: 'Invocation was not found.' }, + phase: 'failed', + }, + }), + invocationId: 'inv-missing', + leaf: toolLeaf, + onNavigate: noop, + })); + + expect(markup).toContain('Invocation inv-missing is not in this session.'); +}); + describe('RouteInspector', () => { it('stays closed by default and opens to the evidence tabs', () => { const closed = renderToStaticMarkup(createElement(RouteInspector, { @@ -432,4 +546,11 @@ describe('App leaf tool binding', () => { expect(orderedToolsForApp(tools, 'ui://curator/library.html').map((tool) => tool.name)).toEqual(['browse_library', 'inventory_sources']); expect(orderedToolsForApp(tools, undefined).map((tool) => tool.name)).toEqual(['inventory_sources', 'browse_library']); }); + + it('carries the browser correlation id beside plain MCP params, never as a browser-sent _meta', () => { + expect(appToolCallRequest('browse_library', { query: 'Dune' }, 'corr-app')).toEqual({ + correlationId: 'corr-app', + request: { arguments: { query: 'Dune' }, name: 'browse_library' }, + }); + }); }); diff --git a/packages/workbench/tests/shell-link.test.ts b/packages/workbench/tests/shell-link.test.ts new file mode 100644 index 000000000..ec6379dfa --- /dev/null +++ b/packages/workbench/tests/shell-link.test.ts @@ -0,0 +1,36 @@ +import { createElement, isValidElement, type MouseEvent } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it } from '@rstest/core'; + +import { ShellLink, type ShellLinkProps } from '../src/shell/shell-link.tsx'; +import type { WorkbenchLocation } from '../src/shell/workbench-location.ts'; + +interface AnchorProps { + readonly href: string; + readonly onClick?: (event: Pick, 'preventDefault'>) => void; +} + +/** The anchor element the component returns, before React renders it. */ +const anchorOf = (props: ShellLinkProps): AnchorProps => { + const rendered = ShellLink(props); + if (!isValidElement(rendered) || rendered.type !== 'a') throw new Error('ShellLink must render an anchor.'); + return rendered.props; +}; + +it('renders the formatted href and routes a plain click through the shell instead of reloading', () => { + const navigated: WorkbenchLocation[] = []; + const location: WorkbenchLocation = { area: 'trace', correlation: 'corr 1' }; + const props: ShellLinkProps = { children: 'Open in Trace', className: 'x', location, onNavigate: (next) => navigated.push(next) }; + + expect(renderToStaticMarkup(createElement(ShellLink, props))).toBe('Open in Trace'); + let prevented = 0; + anchorOf(props).onClick?.({ preventDefault: () => { prevented += 1; } }); + expect(prevented).toBe(1); + expect(navigated).toEqual([location]); +}); + +it('stays a plain anchor when the host has no router', () => { + const anchor = anchorOf({ children: 'x', location: { area: 'trace', invocationId: 'inv_1' } }); + expect(anchor.href).toBe('/trace/inv_1'); + expect(anchor.onClick).toBeUndefined(); +}); diff --git a/packages/workbench/tests/support/example-acceptance.ts b/packages/workbench/tests/support/example-acceptance.ts index b0db29099..b0f55728d 100644 --- a/packages/workbench/tests/support/example-acceptance.ts +++ b/packages/workbench/tests/support/example-acceptance.ts @@ -108,6 +108,8 @@ const allowedUnmountCancellation = ({ error, request }: FailedRequest, origin: s } return url.pathname === '/api/logs/stream' || url.pathname === '/api/logs/replay' + || url.pathname === '/api/trace' + || url.pathname === '/api/trace/stream' // The MCP page's server-catalog effect (main.tsx) inspects the active // epoch under an AbortController it aborts on unmount, so leaving the page // while that GET is in flight is a designed cancellation. Whether the diff --git a/packages/workbench/tests/support/packed-outage-ledger.ts b/packages/workbench/tests/support/packed-outage-ledger.ts index 601707c8f..9dcc22662 100644 --- a/packages/workbench/tests/support/packed-outage-ledger.ts +++ b/packages/workbench/tests/support/packed-outage-ledger.ts @@ -137,11 +137,12 @@ const netCode = (text: string): string | undefined => /\b(net::ERR_[A-Z_]+)\b/u. const ledgerFailureAt = (request: NetworkLedgerEntry): number => request.completedAt ?? request.at; -type KnownStreamClass = 'evals' | 'logs' | 'playground'; +type KnownStreamClass = 'evals' | 'logs' | 'playground' | 'trace'; const knownStreamClass = (path: string): KnownStreamClass | undefined => { const segments = path.split('/').filter((segment) => segment.length > 0); if (path === '/api/logs/stream') return 'logs'; + if (path === '/api/trace/stream') return 'trace'; if (segments.length !== 5 || segments[0] !== 'api' || segments[3]!.length === 0 || segments[4] !== 'stream') return undefined; if (segments[1] === 'playground' && segments[2] === 'sessions') return 'playground'; return segments[1] === 'evals' && segments[2] === 'runs' ? 'evals' : undefined; @@ -178,6 +179,10 @@ const isLogsReplayCancellation = (request: NetworkLedgerEntry): boolean => request.path === '/api/logs/replay' && request.completedAt !== undefined && request.at <= request.completedAt && (responseIsAbsent(request) || isSuccessStatus(request.status)); +const isTraceReplayCancellation = (request: NetworkLedgerEntry): boolean => + request.path === '/api/trace' && request.completedAt !== undefined && request.at <= request.completedAt && + (responseIsAbsent(request) || isSuccessStatus(request.status)); + /** * The playground screen retires a superseded in-flight catalog request when * its effect re-runs (one AbortController per effect), and route changes abort @@ -189,7 +194,8 @@ const isKnownPreOutageClientCancellation = (request: NetworkLedgerEntry): boolea request.path === '/api/playground/catalog' || isPlaygroundSessionReadCancellation(request) || isPlaygroundSessionReplayPath(request.path) || - isLogsReplayCancellation(request) + isLogsReplayCancellation(request) || + isTraceReplayCancellation(request) ); export const hasCanonicalAfterCursor = (url: URL): boolean => { @@ -319,7 +325,8 @@ export const validateOutageLedger = (ledger: OutageLedger): void => { // contract. Report only the unclaimed ones — a dump of every failure reads // as if the recognized ones were at fault. const unrecognizedPostRecoveryFailures = postRecoveryFailures.filter((request) => - !freshMcpStreamFailures.includes(request) && !navigationFailures.has(request), + !freshMcpStreamFailures.includes(request) && !navigationFailures.has(request) && + !isKnownPreOutageClientCancellation(request), ); assertOutageLedger(unrecognizedPostRecoveryFailures.length === 0, `unknown post-recovery failure: ${JSON.stringify(unrecognizedPostRecoveryFailures)}`); diff --git a/packages/workbench/tests/support/trace-fixtures.ts b/packages/workbench/tests/support/trace-fixtures.ts new file mode 100644 index 000000000..2311c7670 --- /dev/null +++ b/packages/workbench/tests/support/trace-fixtures.ts @@ -0,0 +1,86 @@ +import type { TraceEntry, TraceEntryInput } from '../../../agent-bundle/src/contracts/trace.ts'; + +/** Builds an entry the way `TraceHub.publish` would, from a publisher's input plus its sequence. */ +export const traceEntry = (sequence: number, input: TraceEntryInput & { readonly occurredAt: string }): TraceEntry => Object.freeze({ + ...input, + id: `trc_${String(sequence)}`, + sequence, +}); + +const at = (millis: number): string => new Date(Date.UTC(2026, 8, 5, 22, 41, 4, 101) + millis).toISOString(); + +/** + * The owner's sample timeline from the PR 2 brief: one Claude session whose + * hook, kernel, and MCP entries share a conversation, a Workbench-invoked tool + * on its own, and a log line with no correlation. + */ +export const sampleTraceEntries: readonly TraceEntry[] = Object.freeze([ + traceEntry(1, { + correlation: { conversationId: 'conv-1', host: 'claude', sessionId: 'sess-1' }, + kind: 'session.started', + occurredAt: at(0), + source: 'hook', + summary: 'Claude session started', + }), + traceEntry(2, { + correlation: { conversationId: 'conv-1', executionId: 'exec-1', host: 'claude', invocationId: 'inv_1', routeId: 'event:session/start', sessionId: 'sess-1' }, + details: { result: 'continue' }, + href: '/routes/events/session/start?invocation=inv_1', + kind: 'hook.completed', + occurredAt: at(17), + source: 'hook', + status: 'ok', + summary: 'event session/start · result = continue + context', + }), + traceEntry(3, { + correlation: { executionId: 'exec-1' }, + durationMs: 8.1, + kind: 'kernel.render.finish', + occurredAt: at(25), + source: 'kernel', + summary: 'render complete', + }), + traceEntry(4, { + correlation: { conversationId: 'conv-1', host: 'claude', invocationId: 'inv_2', routeId: 'event:tool/before', sessionId: 'sess-1' }, + href: '/routes/events/tool/before?invocation=inv_2', + kind: 'hook.completed', + occurredAt: at(5_431), + source: 'hook', + summary: 'tool/before · tool = Bash', + }), + traceEntry(5, { + correlation: { conversationId: 'conv-1', mcpRequestId: '7', mcpSessionId: 'mcp-1', routeId: 'tool:hauler/hauler_status' }, + details: { input: { lane: 'all' } }, + href: '/advanced/protocol?session=mcp-1', + kind: 'mcp.request', + occurredAt: at(5_440), + source: 'mcp', + summary: 'MCP tools/call hauler_status', + }), + traceEntry(6, { + correlation: { mcpRequestId: '7', mcpSessionId: 'mcp-1' }, + durationMs: 14.7, + href: '/advanced/protocol?session=mcp-1', + kind: 'mcp.response', + occurredAt: at(5_455), + source: 'mcp', + summary: 'MCP tools/call hauler_status · complete', + }), + traceEntry(7, { + correlation: { correlationId: 'corr-1', invocationId: 'inv_3', routeId: 'tool:curator/search' }, + durationMs: 120, + href: '/routes/mcp/curator/tool/search?invocation=inv_3', + kind: 'invocation.completed', + occurredAt: at(9_000), + source: 'invocation', + status: 'ok', + summary: 'tool:curator/search succeeded', + }), + traceEntry(8, { + correlation: {}, + kind: 'log.build.started', + occurredAt: at(15_000), + source: 'log', + summary: 'Project build started.', + }), +]); diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index c16c93b82..c200ac5e2 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -31,12 +31,18 @@ export const workbenchTestIds = Object.freeze({ resultTabRendered: 'result-tab-rendered', resultTabStructured: 'result-tab-structured', resultTabTrace: 'result-tab-trace', + routeCancel: 'route-cancel', routeInputEditor: 'route-input-editor', + routeOutcome: 'route-outcome', routeRun: 'route-run', + routeRunningStatus: 'route-running-status', routeStatus: 'route-status', routeWorkspace: 'route-workspace', shellBuildStatus: 'shell-build-status', staticAuthoredDocument: 'static-authored-document', + traceDetail: 'trace-detail', + traceEntry: 'trace-entry', + traceGroup: 'trace-group', unknownRoute: 'unknown-route', workbenchLoading: 'workbench-loading', workbenchNav: 'workbench-nav', @@ -287,6 +293,92 @@ export const readInvocationId = async (page: Page, timeout = browserTimeout): Pr return text; }; +export const readCorrelationId = async (page: Page, timeout = browserTimeout): Promise => { + const id = workbenchTestId(page, 'routeStatus').locator('.route-status-correlation'); + await expect(id).toBeVisible({ timeout }); + const text = (await id.innerText()).trim(); + const match = /^correlation (.+)$/u.exec(text); + if (match?.[1] === undefined || match[1].length === 0) { + throw new Error('route-status rendered an invocation without a correlation id.'); + } + return match[1]; +}; + +export const traceEntryRow = (page: Page, kind?: string): Locator => + kind === undefined + ? workbenchTestId(page, 'traceEntry') + : page.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind=${JSON.stringify(kind)}]`); + +/** Group for one tool invocation, with only its server-published start and completion rows. */ +export const expectToolInvocationTraceGroup = async ( + page: Page, + options: Readonly<{ readonly invocationId: string; readonly routeId: string }>, + timeout = browserTimeout, +): Promise => { + const group = workbenchTestId(page, 'traceGroup').filter({ hasText: options.invocationId }).first(); + await expect(group).toBeVisible({ timeout }); + await group.scrollIntoViewIfNeeded(); + const rows = group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}]`); + await expect(rows).toHaveCount(2, { timeout }); + await expect(group).not.toContainText('[REDACTED]', { timeout }); + const completed = group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind="invocation.completed"]`); + await expect(completed).toBeVisible({ timeout }); + await expect(completed).toContainText(options.routeId.slice(options.routeId.indexOf(':') + 1), { timeout }); + await expect(group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind="invocation.started"]`)) + .toBeVisible({ timeout }); + await expect(group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind^="log.project."]`)) + .toHaveCount(0); + await expect(completed.locator('.trace-duration')).toHaveText(/\d|>; + readonly routeId: string; + }>, +): Promise => + page.evaluate(async (body) => { + const sessionResponse = await fetch('/api/project/session', { credentials: 'same-origin' }); + const sessionBody: unknown = await sessionResponse.json(); + if ( + !sessionResponse.ok + || typeof sessionBody !== 'object' + || sessionBody === null + || typeof (sessionBody as { readonly token?: unknown }).token !== 'string' + ) { + throw new Error(`Workbench session bootstrap failed with ${String(sessionResponse.status)}.`); + } + const response = await fetch('/api/routes/invocations', { + body: JSON.stringify({ + correlationId: globalThis.crypto.randomUUID(), + input: body.input, + routeId: body.routeId, + }), + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'x-agent-bundle-session': (sessionBody as { readonly token: string }).token, + }, + method: 'POST', + }); + const payload: unknown = await response.json(); + if (!response.ok) { + throw new Error(`POST /api/routes/invocations failed with ${String(response.status)}: ${JSON.stringify(payload)}`); + } + const invocation = (payload as { readonly invocation?: { readonly id?: unknown } }).invocation; + if (typeof invocation?.id !== 'string' || invocation.id.length === 0) { + throw new Error('POST /api/routes/invocations omitted invocation.id.'); + } + return invocation.id; + }, request); + /** * Selects the Rendered tab and waits for a complete, error-free Agent Document. * A pending stream (`aria-busy`) and the empty placeholder are not accepted. diff --git a/packages/workbench/tests/trace-client.test.ts b/packages/workbench/tests/trace-client.test.ts new file mode 100644 index 000000000..696d40c6f --- /dev/null +++ b/packages/workbench/tests/trace-client.test.ts @@ -0,0 +1,274 @@ +import { expect, it } from '@rstest/core'; + +import type { TraceMessage, TraceReplay } from '../../agent-bundle/src/contracts/trace.ts'; +import { ForegroundRouteClient } from '../src/mcp/mcp-route-client.ts'; +import { + decodeTraceEntry, + decodeTraceMessage, + decodeTraceReplay, + ForegroundTraceClient, + openTraceFeed, + TRACE_INVALID_RESPONSE_CODE, + TraceClientError, + type TraceClient, + type TraceFeedState, +} from '../src/trace/trace-client.ts'; +import { sampleTraceEntries } from './support/trace-fixtures.ts'; + +const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + status, +}); +const session = (): Response => json({ + cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', + instanceId: 'foreground-instance-a', + origin: 'http://foreground.test', + token: 'test-session-token', +}); +const ndjson = (chunks: readonly Uint8Array[]): Response => new Response(new ReadableStream({ + start: (controller) => { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, +}), { headers: { 'content-type': 'application/x-ndjson' } }); +const clientFor = (respond: (url: string) => Response | Promise): ForegroundTraceClient => new ForegroundTraceClient({ + foreground: new ForegroundRouteClient({ fetch: async (input) => String(input).includes('/api/project/session') ? session() : respond(String(input)) }), +}); +const encode = (messages: readonly unknown[]): Uint8Array => new TextEncoder().encode(`${messages.map((message) => JSON.stringify(message)).join('\n')}\n`); +const replayOf = (entries: readonly unknown[], extra: Record = {}): unknown => ({ + entries, + latestSequence: (entries.at(-1) as { readonly sequence: number } | undefined)?.sequence ?? 0, + ...extra, +}); +const [first, second] = sampleTraceEntries; +const invalid = { code: TRACE_INVALID_RESPONSE_CODE, name: 'TraceClientError' }; + +it('decodes the replay the hub produces and freezes it', async () => { + const requested: string[] = []; + const client = clientFor((url) => { requested.push(url); return json(replayOf(sampleTraceEntries)); }); + const replay = await client.replay(); + expect(requested).toEqual(['/api/trace?after=0']); + expect(replay.entries).toEqual(sampleTraceEntries); + expect(replay.latestSequence).toBe(8); + expect(Object.isFrozen(replay) && Object.isFrozen(replay.entries[1]) && Object.isFrozen(replay.entries[1]?.correlation) && Object.isFrozen(replay.entries[1]?.details)).toBe(true); + + const gap = { droppedCount: 2, firstAvailableSequence: 3, requestedAfterSequence: 0, type: 'trace.gap' }; + const gapped = decodeTraceReplay(replayOf(sampleTraceEntries.slice(2), { gap }), 0); + expect(gapped.gap).toEqual(gap); + expect(decodeTraceReplay({ entries: [], latestSequence: 4 }, 4)).toEqual({ entries: [], latestSequence: 4 }); +}); + +it('rejects replay envelopes that are malformed, non-contiguous, or inconsistent with their cursor', () => { + const reject = (value: unknown, after = 0): void => { expect(() => decodeTraceReplay(value, after)).toThrow(TraceClientError); }; + reject({ entries: [first] }); + reject({ entries: [first], latestSequence: 1, extra: true }); + reject({ entries: [second], latestSequence: 2 }); + reject({ entries: [first, second], latestSequence: 3 }); + reject({ entries: [], latestSequence: 3 }, 0); + reject({ entries: [], latestSequence: 2 }, 4); + reject({ entries: [first], latestSequence: 1 }, 1); + reject({ entries: [first], latestSequence: 1, gap: { droppedCount: 0, firstAvailableSequence: 1, requestedAfterSequence: 0, type: 'trace.gap' } }); + reject({ entries: [second], latestSequence: 2, gap: { droppedCount: 1, firstAvailableSequence: 2, requestedAfterSequence: 1, type: 'trace.gap' } }, 0); + reject('[]'); +}); + +it('rejects an entry with an unknown source, a stray key, or unsafe text instead of crashing', () => { + const accept = (value: unknown): void => { expect(decodeTraceEntry(value)).toEqual(value); }; + const reject = (value: unknown): void => { expect(() => decodeTraceEntry(value)).toThrow(expect.objectContaining(invalid)); }; + // The browser decoder's own code, between the trace routes (AB8240–AB8242) and the hook receipt route (AB8247–AB8249). + expect(TRACE_INVALID_RESPONSE_CODE).toBe('AB8243'); + accept(second); + accept({ ...first, status: 'running', durationMs: 0, details: null }); + accept({ ...first, correlation: { mcpRequestId: 'req/1:2', routeId: 'tool:curator/search_audible', host: 'codex' } }); + accept({ ...first, summary: 'tool:curator/search · /src/x.tsx · tools/call' }); + reject({ ...first, source: 'notice' }); + reject({ ...first, source: undefined }); + reject({ ...first, extra: 1 }); + reject({ ...first, id: 'trc 1' }); + reject({ ...first, id: '' }); + reject({ ...first, sequence: 0 }); + reject({ ...first, sequence: 1.5 }); + reject({ ...first, occurredAt: '2026-09-05 22:41:04' }); + reject({ ...first, kind: 'started' }); + reject({ ...first, kind: 'hook started' }); + reject({ ...first, status: 'succeeded' }); + reject({ ...first, durationMs: -1 }); + reject({ ...first, durationMs: Number.NaN }); + reject({ ...first, summary: '' }); + reject({ ...first, summary: 'x'.repeat(241) }); + reject({ ...first, summary: 'line\nbreak' }); + reject({ ...first, summary: 'wrote /home/zack/project/out.json' }); + reject({ ...first, summary: 'C:\\Users\\zack\\out.json' }); + reject({ ...first, summary: 'file:///tmp/x' }); + reject({ ...first, summary: 'token sk-proj-abcdefghijklmnopqrst' }); + reject({ ...first, correlation: { sessionId: 'a b' } }); + reject({ ...first, correlation: { unknownKey: 'x' } }); + reject({ ...first, correlation: { host: 1 } }); + reject({ ...first, correlation: [] }); + reject({ ...first, details: { apiKey: 'x' } }); + reject({ ...first, details: { path: '/home/zack/secret' } }); + reject({ ...first, details: { nested: ['ok', 'ghp_abcdefghijklmnopqrst'] } }); + reject({ ...first, href: 'https://example.com/routes/x' }); + reject({ ...first, href: '//evil/routes/x' }); + reject({ ...first, href: '/api/routes/invocations/inv_1' }); + reject({ ...first, href: '/routes/x#hash' }); + reject({ ...first, href: 'routes/x' }); + accept({ ...first, href: '/trace/trc_9?correlation=exec-1' }); + accept({ ...first, href: '/routes/mcp/curator/tool/search_audible?invocation=inv_1&tab=raw' }); +}); + +it('decodes gaps and rejects a gap whose arithmetic does not add up', () => { + const gap = { droppedCount: 4, firstAvailableSequence: 7, requestedAfterSequence: 2, type: 'trace.gap' }; + expect(decodeTraceMessage(gap)).toEqual(gap); + expect(() => decodeTraceMessage({ ...gap, firstAvailableSequence: 8 })).toThrow(TraceClientError); + expect(() => decodeTraceMessage({ ...gap, droppedCount: 0, firstAvailableSequence: 3 })).toThrow(TraceClientError); + expect(() => decodeTraceMessage({ ...gap, type: 'replay.gap' })).toThrow(TraceClientError); + expect(() => decodeTraceMessage({ ...gap, extra: 1 })).toThrow(TraceClientError); +}); + +it('streams contiguous NDJSON messages, accepts a live gap, and rejects a sequence skip', async () => { + const gap = { droppedCount: 1, firstAvailableSequence: 6, requestedAfterSequence: 4, type: 'trace.gap' }; + const received: TraceMessage[] = []; + const client = clientFor(() => ndjson([encode([sampleTraceEntries[2], sampleTraceEntries[3], gap, sampleTraceEntries[5]])])); + await client.stream(2, (message) => received.push(message), new AbortController().signal); + expect(received.map((message) => 'sequence' in message ? message.sequence : 'gap')).toEqual([3, 4, 'gap', 6]); + expect(received.every((message) => Object.isFrozen(message))).toBe(true); + + const skipped = clientFor(() => ndjson([encode([sampleTraceEntries[2], sampleTraceEntries[4]])])); + await expect(skipped.stream(2, () => undefined, new AbortController().signal)).rejects.toMatchObject(invalid); + + const wrongGap = clientFor(() => ndjson([encode([{ droppedCount: 1, firstAvailableSequence: 4, requestedAfterSequence: 2, type: 'trace.gap' }])])); + await expect(wrongGap.stream(3, () => undefined, new AbortController().signal)).rejects.toMatchObject(invalid); +}); + +it('rejects a trailing unterminated frame, an oversized frame, malformed UTF-8, and duplicate keys', async () => { + const encoder = new TextEncoder(); + await expect(clientFor(() => ndjson([encoder.encode(JSON.stringify(first))])).stream(0, () => undefined, new AbortController().signal)).rejects.toMatchObject(invalid); + await expect(clientFor(() => ndjson([encode([{ ...first, summary: 'x'.repeat(65 * 1024) }])])).stream(0, () => undefined, new AbortController().signal)).rejects.toMatchObject(invalid); + const malformed = new Uint8Array([...encoder.encode('{"a":"'), 0xff, ...encoder.encode('"}\n')]); + await expect(clientFor(() => ndjson([malformed])).stream(0, () => undefined, new AbortController().signal)).rejects.toMatchObject(invalid); + const duplicate = `${JSON.stringify(first).replace('"kind":"session.started"', '"kind":"session.started","kind":"session.ended"')}\n`; + await expect(clientFor(() => ndjson([encoder.encode(duplicate)])).stream(0, () => undefined, new AbortController().signal)).rejects.toMatchObject(invalid); + await expect(clientFor(() => new Response('{')).replay()).rejects.toMatchObject(invalid); + await expect(clientFor(() => json(replayOf([{ ...first, source: 'notice' }]))).replay()).rejects.toMatchObject(invalid); +}); + +it('splits frames across chunks and stops delivering once the signal aborts', async () => { + const bytes = encode([first, second]); + const received: number[] = []; + const split = clientFor(() => ndjson([bytes.subarray(0, 40), bytes.subarray(40)])); + await split.stream(0, (message) => { if ('sequence' in message) received.push(message.sequence); }, new AbortController().signal); + expect(received).toEqual([1, 2]); + + const controller = new AbortController(); + const aborted: number[] = []; + await clientFor(() => ndjson([bytes])).stream(0, (message) => { + if ('sequence' in message) aborted.push(message.sequence); + controller.abort(); + }, controller.signal); + expect(aborted).toEqual([1]); +}); + +it('surfaces a coded server refusal, maps hostile refusals to the local error, and returns quietly from an aborted stream request', async () => { + await expect(clientFor(() => json({ diagnostic: { code: 'AB8242', message: 'Trace cursor is ahead.' } }, 409)).replay(5)) + .rejects.toMatchObject({ code: 'AB8242', message: 'Trace route refused the request (AB8242, HTTP 409).', name: 'TraceClientError' }); + await expect(clientFor(() => json({ diagnostic: { code: 'nope', message: '/etc/passwd' } }, 500)).replay()).rejects.toMatchObject(invalid); + await expect(clientFor(() => json({ diagnostic: { code: 'AB8242', message: 'x' } }, 409)).stream(5, () => undefined, new AbortController().signal)) + .rejects.toMatchObject({ code: 'AB8242' }); + await expect(clientFor(() => json({ entries: [], latestSequence: 0 })).replay(-1)).rejects.toMatchObject(invalid); + const controller = new AbortController(); + controller.abort(); + await expect(clientFor(() => json(replayOf([]))).stream(0, () => undefined, controller.signal)).resolves.toBeUndefined(); +}); + +interface FakeStream { + readonly after: number | undefined; + readonly deliver: (message: TraceMessage) => void; + readonly end: (reason?: unknown) => void; +} + +/** A scripted `TraceClient`: each `replay` answer is consumed in order; every stream stays open until the test ends it. */ +const fakeClient = (replays: readonly (TraceReplay | Error)[]): TraceClient & { readonly replayCursors: number[]; readonly streams: FakeStream[] } => { + const replayCursors: number[] = []; + const streams: FakeStream[] = []; + let index = 0; + return { + replay: async (after = 0) => { + replayCursors.push(after); + const answer = replays[Math.min(index, replays.length - 1)]; + index += 1; + if (answer === undefined || answer instanceof Error) throw answer ?? new Error('no replay scripted'); + return answer; + }, + replayCursors, + stream: (after, onMessage, signal) => new Promise((resolve, reject) => { + streams.push({ + after, + deliver: (message) => { if (!signal.aborted) onMessage(message); }, + end: (reason) => { if (reason === undefined) resolve(); else reject(reason); }, + }); + signal.addEventListener('abort', () => resolve(), { once: true }); + }), + streams, + }; +}; + +const settle = async (): Promise => { + for (let index = 0; index < 4; index += 1) await new Promise((resolve) => setImmediate(resolve)); +}; + +it('replays, follows the stream, merges live entries, and reconnects from the last sequence with back-off when the stream ends', async () => { + const client = fakeClient([ + { entries: sampleTraceEntries.slice(0, 2), latestSequence: 2 }, + { entries: sampleTraceEntries.slice(3, 4), latestSequence: 4 }, + ]); + const states: TraceFeedState[] = []; + const delays: number[] = []; + const feed = openTraceFeed({ client, onState: (state) => states.push(state), retryDelay: async (ms) => { delays.push(ms); } }); + await settle(); + expect(states.at(-1)).toMatchObject({ connected: true, loaded: true, entries: sampleTraceEntries.slice(0, 2) }); + expect(client.streams[0]?.after).toBe(2); + + client.streams[0]!.deliver(sampleTraceEntries[2]!); + client.streams[0]!.deliver({ droppedCount: 1, firstAvailableSequence: 2, requestedAfterSequence: 0, type: 'trace.gap' }); + expect(states.at(-1)).toMatchObject({ connected: true, entries: sampleTraceEntries.slice(0, 3), gap: { droppedCount: 1 } }); + + client.streams[0]!.end(); + await settle(); + expect(delays).toEqual([250]); + expect(client.replayCursors).toEqual([0, 3]); + expect(client.streams[1]?.after).toBe(4); + expect(states.at(-1)).toMatchObject({ connected: true, entries: sampleTraceEntries.slice(0, 4) }); + expect(states.some((state) => !state.connected && state.loaded && state.error === undefined)).toBe(true); + + feed.close(); + const count = states.length; + client.streams[1]!.deliver(sampleTraceEntries[4]!); + await settle(); + expect(states).toHaveLength(count); +}); + +it('reports a failed replay, doubles the back-off, and starts over from zero when a non-zero cursor is refused', async () => { + const refused = new TraceClientError('AB8242', 'Trace route refused the request (AB8242, HTTP 409).'); + const client = fakeClient([ + new Error('offline'), + { entries: sampleTraceEntries.slice(0, 1), latestSequence: 1 }, + refused, + { entries: sampleTraceEntries.slice(6, 7), latestSequence: 7 }, + ]); + const states: TraceFeedState[] = []; + const delays: number[] = []; + const feed = openTraceFeed({ client, onState: (state) => states.push(state), retryDelay: async (ms) => { delays.push(ms); } }); + await settle(); + expect(states[0]).toMatchObject({ connected: false, error: 'offline', loaded: false, entries: [] }); + expect(states.at(-1)).toMatchObject({ connected: true, loaded: true, entries: sampleTraceEntries.slice(0, 1) }); + expect(delays).toEqual([250]); + + client.streams[0]!.end(new TraceClientError(TRACE_INVALID_RESPONSE_CODE, 'Trace route returned an invalid response.')); + await settle(); + expect(delays).toEqual([250, 250]); + expect(client.replayCursors).toEqual([0, 0, 1, 0]); + expect(states.at(-1)).toMatchObject({ connected: true, entries: sampleTraceEntries.slice(6, 7) }); + expect(states.some((state) => state.error === refused.message && state.entries.length === 0)).toBe(true); + feed.close(); +}); diff --git a/packages/workbench/tests/trace-model.test.ts b/packages/workbench/tests/trace-model.test.ts new file mode 100644 index 000000000..35f528737 --- /dev/null +++ b/packages/workbench/tests/trace-model.test.ts @@ -0,0 +1,123 @@ +import { expect, it } from '@rstest/core'; + +import { + formatTraceDuration, + formatTraceTime, + groupTraceEntries, + maximumTraceEntries, + mergeTraceEntries, + selectTraceEntry, + selectTraceGroup, + traceKindLabel, + traceSourceGlyph, +} from '../src/trace/trace-model.ts'; +import { sampleTraceEntries, traceEntry } from './support/trace-fixtures.ts'; + +const sequences = (entries: readonly { readonly sequence: number }[]): readonly number[] => entries.map((entry) => entry.sequence); + +it('merges replay and live entries by sequence, keeps the first copy of a duplicate, and bounds the list', () => { + const [first, second, third, fourth] = sampleTraceEntries; + const merged = mergeTraceEntries([first!, third!], [second!, { ...third!, summary: 'a later duplicate' }, fourth!]); + expect(sequences(merged)).toEqual([1, 2, 3, 4]); + expect(merged[2]?.summary).toBe('render complete'); + expect(Object.isFrozen(merged)).toBe(true); + + const many = Array.from({ length: maximumTraceEntries + 2 }, (_value, index) => ({ ...first!, id: `trc_${String(index + 1)}`, sequence: index + 1 })); + const bounded = mergeTraceEntries([], many); + expect(bounded).toHaveLength(maximumTraceEntries); + expect(bounded[0]?.sequence).toBe(3); +}); + +it('groups entries that share any join key transitively and names the group by its strongest key', () => { + const groups = groupTraceEntries(sampleTraceEntries); + expect(groups.map((group) => [group.key, group.keyKind, sequences(group.rows.map((row) => row.entry))])).toEqual([ + ['conversationId:conv-1', 'conversationId', [1, 2, 3, 4, 5, 6]], + ['invocationId:inv_3', 'invocationId', [7]], + ['entry:trc_8', 'entry', [8]], + ]); + const session = groups[0]!; + expect(session.headline.kind).toBe('session.started'); + expect(session.status).toBe('ok'); + expect(session.spanMs).toBe(5_455); + expect(session.startedAt).toBe(sampleTraceEntries[0]!.occurredAt); + expect(session.endedAt).toBe(sampleTraceEntries[5]!.occurredAt); + expect(session.rows.map((row) => [row.entry.source, row.depth])).toEqual([ + ['hook', 0], ['hook', 0], ['kernel', 1], ['hook', 0], ['mcp', 1], ['mcp', 1], + ]); + expect(groups[1]?.spanMs).toBe(sampleTraceEntries[6]!.durationMs); + expect(groups[2]?.status).toBe('ok'); + expect(groups[2]?.rows[0]?.depth).toBe(0); + expect(groups[2]?.spanMs).toBe(0); + expect(Object.isFrozen(groups) && groups.every((group) => Object.isFrozen(group) && Object.isFrozen(group.rows))).toBe(true); +}); + +it('does not join on facets, treats an MCP request id as session-scoped, and reports a trailing running entry', () => { + const entries = [ + traceEntry(1, { correlation: { host: 'claude', routeId: 'tool:a/b', epochId: 'e1' }, kind: 'invocation.started', occurredAt: '2026-09-05T07:00:00.000Z', source: 'invocation', status: 'running', summary: 'a' }), + traceEntry(2, { correlation: { host: 'claude', routeId: 'tool:a/b', epochId: 'e1' }, kind: 'invocation.started', occurredAt: '2026-09-05T07:00:01.000Z', source: 'invocation', status: 'running', summary: 'b' }), + traceEntry(3, { correlation: { mcpRequestId: '1', mcpSessionId: 's1' }, kind: 'mcp.request', occurredAt: '2026-09-05T07:00:02.000Z', source: 'mcp', summary: 'c' }), + traceEntry(4, { correlation: { mcpRequestId: '1', mcpSessionId: 's2' }, kind: 'mcp.request', occurredAt: '2026-09-05T07:00:03.000Z', source: 'mcp', summary: 'd' }), + traceEntry(5, { correlation: { mcpRequestId: '1' }, kind: 'mcp.request', occurredAt: '2026-09-05T07:00:04.000Z', source: 'mcp', summary: 'e' }), + ]; + const groups = groupTraceEntries(entries); + expect(groups.map((group) => [group.key, sequences(group.rows.map((row) => row.entry))])).toEqual([ + ['entry:trc_1', [1]], + ['entry:trc_2', [2]], + ['mcpSessionId:s1', [3]], + ['mcpSessionId:s2', [4]], + ['entry:trc_5', [5]], + ]); + expect(groups[0]?.status).toBe('running'); + expect(groupTraceEntries([])).toEqual([]); +}); + +it('joins the same correlation value across publisher-specific keys', () => { + const groups = groupTraceEntries([ + traceEntry(1, { + correlation: { mcpSessionId: 'session-1' }, + kind: 'mcp.request', + occurredAt: '2026-09-05T12:00:00.000Z', + source: 'mcp', + summary: 'tools/call search_audible', + }), + traceEntry(2, { + correlation: { sessionId: 'session-1' }, + kind: 'hook.received', + occurredAt: '2026-09-05T12:00:00.001Z', + source: 'hook', + summary: 'hook receipt', + }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.key).toBe('sessionId:session-1'); + expect(sequences(groups[0]!.rows.map((row) => row.entry))).toEqual([1, 2]); +}); + +it('selects a group by any correlation value and an entry by its id or a PR 1 invocation id', () => { + const groups = groupTraceEntries(sampleTraceEntries); + expect(selectTraceGroup(groups, 'exec-1')?.key).toBe('conversationId:conv-1'); + expect(selectTraceGroup(groups, 'mcp-1')?.key).toBe('conversationId:conv-1'); + expect(selectTraceGroup(groups, 'corr-1')?.key).toBe('invocationId:inv_3'); + expect(selectTraceGroup(groups, 'nope')).toBeUndefined(); + + expect(selectTraceEntry(sampleTraceEntries, 'trc_3')?.sequence).toBe(3); + expect(selectTraceEntry(sampleTraceEntries, 'inv_3')?.sequence).toBe(7); + expect(selectTraceEntry(sampleTraceEntries, 'exec-1')).toBeUndefined(); +}); + +it('formats times to the millisecond, durations by magnitude, and kinds to short labels', () => { + expect(formatTraceTime('2026-09-05T22:41:04.101Z', 'UTC')).toBe('22:41:04.101'); + expect(formatTraceTime('2026-09-05T00:00:00.000Z', 'UTC')).toBe('00:00:00.000'); + expect(formatTraceTime('not a date', 'UTC')).toBe('not a date'); + expect(formatTraceDuration(0.4)).toBe('<1 ms'); + expect(formatTraceDuration(3.21)).toBe('3.2 ms'); + expect(formatTraceDuration(14.7)).toBe('15 ms'); + expect(formatTraceDuration(1_250)).toBe('1.25 s'); + expect(formatTraceDuration(-1)).toBe(''); + expect(traceKindLabel(sampleTraceEntries[2]!)).toBe('render finished'); + expect(traceKindLabel(sampleTraceEntries[7]!)).toBe('build started'); + expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'mcp.tasks.polled', occurredAt: '2026-09-05T07:00:00.000Z', source: 'mcp', summary: 'x' }))).toBe('tasks polled'); + expect(traceKindLabel(traceEntry(1, { correlation: {}, kind: 'session.started', occurredAt: '2026-09-05T07:00:00.000Z', source: 'hook', summary: 'x' }))).toBe('session started'); + expect(new Set(['invocation', 'kernel', 'mcp', 'hook', 'log', 'diagnostic'].map((source) => traceSourceGlyph(source as 'mcp'))).size).toBe(6); +}); diff --git a/packages/workbench/tests/trace-page.test.ts b/packages/workbench/tests/trace-page.test.ts index 4ecf0328f..120314cbb 100644 --- a/packages/workbench/tests/trace-page.test.ts +++ b/packages/workbench/tests/trace-page.test.ts @@ -3,115 +3,109 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { expect, it } from '@rstest/core'; -import type { RouteInvocationSummary } from '../../agent-bundle/src/contracts/invocations.ts'; -import type { ApplicationLeaf, ApplicationTree } from '../src/application/application-tree-model.ts'; -import type { InvocationBackend } from '../src/application/invocation-backend.ts'; -import { - loadTraceHistory, - mergeTraceEntries, - sortTraceEntries, - traceDurationMs, - traceEntryLocation, - TracePage, -} from '../src/trace/trace-page.tsx'; - -const summary = (id: string, completedAt: string, overrides: Partial = {}): RouteInvocationSummary => ({ - completedAt, - diagnostics: [], - id, - input: {}, - kind: 'tool', - manifestDigest: 'a'.repeat(64), - outcome: { kind: 'success' }, - routeId: 'tool:curator/search_audible', - source: 'src/mcp/curator/tools/search_audible.tsx', - sourceRevision: 'r', - startedAt: '2026-09-05T07:00:00.000Z', - status: 'succeeded', - surface: { kind: 'mcp' }, - timings: [{ durationMs: 12, phase: 'handler', startedAt: '2026-09-05T07:00:00.000Z' }], - ...overrides, -}); - -const leaf = (routeId: string, execution: ApplicationLeaf['execution'] = 'invoke'): ApplicationLeaf => ({ - config: [], - execution, - key: routeId, - label: routeId, - ref: { kind: 'script', name: routeId }, - routeId, -}); +import type { TraceClient } from '../src/trace/trace-client.ts'; +import { TracePage, type TracePageProps } from '../src/trace/trace-page.tsx'; +import { sampleTraceEntries } from './support/trace-fixtures.ts'; -const tree: ApplicationTree = { - diagnostics: [], - groups: [ - { key: 'scripts', kind: 'scripts', label: 'Scripts', leaves: [leaf('script:sync'), leaf('script:report')] }, - { key: 'skills', kind: 'skills', label: 'Skills', leaves: [leaf('skill:review', 'document')] }, - ], - leafCount: 3, - state: 'fresh', +/** The page never opens the feed when a snapshot is supplied; this client fails loudly if it does. */ +const untouched: TraceClient = { + replay: () => Promise.reject(new Error('replay is not under test')), + stream: () => Promise.reject(new Error('stream is not under test')), }; -const backend = (kind: InvocationBackend['kind'], history: (leaf: ApplicationLeaf) => Promise, accepts: (leaf: ApplicationLeaf) => boolean = () => true): InvocationBackend & { readonly asked: string[] } => { - const asked: string[] = []; - return { - accepts, - asked, - history: (target) => { asked.push(target.key); return history(target); }, - invoke: () => Promise.reject(new Error('not under test')), - kind, - read: () => Promise.reject(new Error('not under test')), - subscribe: () => () => undefined, - }; -}; +const render = (props: Partial = {}): string => renderToStaticMarkup(createElement(TracePage, { + client: untouched, + entries: sampleTraceEntries, + onNavigate: () => undefined, + timeZone: 'UTC', + ...props, +})); -it('sorts newest first and merges by id with the later summary winning', () => { - const older = summary('a', '2026-09-05T07:00:01.000Z'); - const newer = summary('b', '2026-09-05T07:00:05.000Z'); - const updated = summary('a', '2026-09-05T07:00:09.000Z', { status: 'failed' }); - expect(sortTraceEntries([older, newer]).map((entry) => entry.id)).toEqual(['b', 'a']); - const merged = mergeTraceEntries([older, newer], [updated]); - expect(merged.map((entry) => [entry.id, entry.status])).toEqual([['a', 'failed'], ['b', 'succeeded']]); - expect(Object.isFrozen(merged)).toBe(true); -}); +const count = (markup: string, needle: string): number => markup.split(needle).length - 1; + +it('renders the correlated timeline oldest first with one line per entry, nested under its group headline', () => { + const markup = render(); + expect(markup).toContain('

    Trace

    '); + expect(markup).toContain('8 entries in 3 groups'); + expect(markup).toContain('data-testid="trace-timeline"'); + expect(count(markup, 'data-testid="trace-group"')).toBe(3); + expect(count(markup, 'data-testid="trace-entry"')).toBe(8); + expect(markup).not.toContain('data-testid="trace-empty"'); + expect(markup).not.toContain('data-testid="trace-detail"'); -it('measures duration from the envelope clock and falls back to phase timings', () => { - expect(traceDurationMs(summary('a', '2026-09-05T07:00:00.250Z'))).toBe(250); - expect(traceDurationMs(summary('a', 'not-a-date'))).toBe(12); + expect(markup.indexOf('data-group-key="conversationId:conv-1"')).toBeLessThan(markup.indexOf('data-group-key="invocationId:inv_3"')); + expect(markup).toContain('22:41:04.101'); + expect(markup).toContain('22:41:09.541'); + expect(markup).toContain('Claude session started'); + expect(markup).toContain('conversation conv-1'); + expect(markup).toContain('6 entries'); + expect(markup).toContain('trace-row trace-row--depth-1 trace-row--ok'); + expect(markup).toContain('render finished'); + expect(markup).toContain('8.1 ms'); + expect(markup).toContain('15 ms'); + expect(markup).toContain('href="/trace/trc_3"'); + expect(markup).toContain('data-group-key="entry:trc_8"'); }); -it('deep-links an entry to its route workspace with the invocation loaded', () => { - expect(traceEntryLocation(summary('inv-1', '2026-09-05T07:00:01.000Z'))).toEqual({ - area: 'application', - invocationId: 'inv-1', - node: { kind: 'tool', name: 'search_audible', server: 'curator' }, - }); - expect(traceEntryLocation(summary('inv-1', '2026-09-05T07:00:01.000Z', { routeId: 'nonsense' }))).toBeUndefined(); +it('shows the empty state that explains what produces entries, and a connecting state before the first replay', () => { + const empty = render({ entries: [] }); + expect(empty).toContain('data-testid="trace-empty"'); + expect(empty).toContain('Run a route, call a tool in Advanced → Protocol, or invoke the plugin from a host'); + expect(count(empty, 'data-testid="trace-group"')).toBe(0); + + const connecting = renderToStaticMarkup(createElement(TracePage, { client: untouched, onNavigate: () => undefined })); + expect(connecting).toContain('Connecting…'); + expect(connecting).toContain('data-testid="trace-empty"'); + expect(connecting).toContain('Connecting to the trace…'); }); -it('loads history only for invocable leaves the backend accepts, dedupes across backends, and reports one failure', async () => { - const shared = summary('shared', '2026-09-05T07:00:01.000Z', { routeId: 'script:sync' }); - const devServer = backend('dev-server', async (target) => target.routeId === 'script:sync' ? [shared, summary('dev-only', '2026-09-05T07:00:02.000Z')] : []); - const runtime = backend('runtime', async (target) => { - if (target.routeId === 'script:report') throw new Error('runtime history offline'); - return [shared]; - }, (target) => target.routeId !== 'skill:review'); - const history = await loadTraceHistory([runtime, devServer], tree); - expect(devServer.asked).toEqual(['script:sync', 'script:report']); - expect(runtime.asked).toEqual(['script:sync', 'script:report']); - expect(history.entries.map((entry) => entry.id)).toEqual(['dev-only', 'shared']); - expect(history.error).toBe('runtime history offline'); +it('opens the detail drawer for /trace/ with correlation links and the primary Open route action', () => { + const markup = render({ entryId: 'trc_5' }); + expect(markup).toContain('data-testid="trace-detail"'); + expect(markup).toContain('data-entry-id="trc_5"'); + expect(markup).toContain('trace-page trace-page--detail'); + expect(markup).toContain('

    MCP tools/call hauler_status

    '); + expect(markup).toContain('mcp · mcp.request'); + expect(markup).toContain('href="/advanced/protocol?session=mcp-1"'); + expect(markup).toContain('>Open route'); + expect(markup).toContain('href="/trace/trc_5?correlation=conv-1"'); + expect(markup).toContain('href="/trace/trc_5?correlation=mcp-1"'); + expect(markup).toContain('href="/trace/trc_5?correlation=7"'); + expect(markup).toContain('"lane": "all"'); + expect(markup).toContain('aria-current="true"'); + expect(markup).toContain('data-selected="true"'); + expect(markup).toContain('aria-label="Close entry"'); + expect(markup).toContain('href="/trace"'); + + const invocation = render({ entryId: 'inv_3' }); + expect(invocation).toContain('data-entry-id="trc_7"'); + expect(invocation).toContain('href="/routes/mcp/curator/tool/search?invocation=inv_3"'); + + const routeless = render({ entryId: 'trc_8' }); + expect(routeless).toContain('No route record behind this entry.'); + expect(routeless).toContain('This entry carries no correlation key.'); + expect(routeless).toContain('No details were published with this entry.'); + + const unknown = render({ entryId: 'trc_404' }); + expect(unknown).toContain('data-testid="trace-detail"'); + expect(unknown).toContain('Not in this trace'); + expect(unknown).toContain('No retained entry is trc_404.'); }); -it('renders the table shell in its loading state and the single-entry heading', () => { - const idle = backend('dev-server', async () => []); - const list = renderToStaticMarkup(createElement(TracePage, { backends: [idle], onNavigate: () => undefined, tree })); - expect(list).toContain('

    Trace

    '); - expect(list).toContain('loading history…'); - expect(list).toContain('data-testid="trace-empty"'); - - const one = renderToStaticMarkup(createElement(TracePage, { backends: [idle], invocationId: 'inv-9', onNavigate: () => undefined, tree })); - expect(one).toContain('One invocation.'); - expect(one).toContain('href="/trace"'); - expect(one).toContain('Loading invocation inv-9…'); +it('scopes the timeline to the group ?correlation= names and offers the way back', () => { + const markup = render({ correlation: 'exec-1' }); + expect(count(markup, 'data-testid="trace-group"')).toBe(1); + expect(count(markup, 'data-testid="trace-entry"')).toBe(6); + expect(markup).toContain('Correlated by exec-1'); + expect(markup).toContain('>Show all'); + expect(markup).toContain('href="/trace/trc_1?correlation=exec-1"'); + + const withEntry = render({ correlation: 'exec-1', entryId: 'trc_3' }); + expect(withEntry).toContain('href="/trace?correlation=exec-1"'); + expect(withEntry).toContain('href="/trace/trc_3"'); + + const missing = render({ correlation: 'nobody' }); + expect(count(missing, 'data-testid="trace-group"')).toBe(0); + expect(missing).toContain('No entry carries nobody.'); + expect(missing).not.toContain('data-testid="trace-empty"'); }); diff --git a/packages/workbench/tests/workbench-location.test.ts b/packages/workbench/tests/workbench-location.test.ts index 3c13ba2ed..dfb3098ca 100644 --- a/packages/workbench/tests/workbench-location.test.ts +++ b/packages/workbench/tests/workbench-location.test.ts @@ -27,6 +27,9 @@ const roundTrips: readonly Readonly<{ readonly location: WorkbenchLocation; read }, { location: { area: 'trace' }, url: '/trace' }, { location: { area: 'trace', invocationId: 'inv 1/a' }, url: '/trace/inv%201%2Fa' }, + { location: { area: 'trace', invocationId: 'trc_12' }, url: '/trace/trc_12' }, + { location: { area: 'trace', correlation: 'conv-1' }, url: '/trace?correlation=conv-1' }, + { location: { area: 'trace', correlation: 'tool:a/b c', invocationId: 'trc_12' }, url: '/trace/trc_12?correlation=tool%3Aa%2Fb%20c' }, { location: { area: 'problems' }, url: '/problems' }, { location: { area: 'sessions' }, url: '/sessions' }, { location: { area: 'sessions', host: 'claude' }, url: '/sessions/claude' }, @@ -75,6 +78,18 @@ it('drops query parameters that do not belong to the area', () => { expect(parseWorkbenchLocation('/', '?invocation=inv-1&tab=raw')).toEqual({ area: 'application' }); expect(parseWorkbenchLocation('/problems', '?tab=raw')).toEqual({ area: 'problems' }); expect(parseWorkbenchLocation('/trace', '?invocation=inv-1')).toEqual({ area: 'trace' }); + expect(parseWorkbenchLocation('/problems', '?correlation=conv-1')).toEqual({ area: 'problems' }); + expect(parseWorkbenchLocation('/routes/scripts/sync', '?correlation=conv-1')).toEqual({ area: 'application', node: { kind: 'script', name: 'sync' } }); +}); + +it('reads ?correlation= on the trace area and ignores an empty or malformed value', () => { + expect(parseWorkbenchLocation('/trace', '?correlation=exec-1&invocation=inv-1&tab=raw')).toEqual({ area: 'trace', correlation: 'exec-1' }); + expect(parseWorkbenchLocation('/trace/trc_3', '?correlation=conv-1')).toEqual({ area: 'trace', correlation: 'conv-1', invocationId: 'trc_3' }); + expect(parseWorkbenchLocation('/trace/a/b', '?correlation=conv-1')).toEqual({ area: 'trace', correlation: 'conv-1' }); + expect(parseWorkbenchLocation('/trace', '?correlation=')).toEqual({ area: 'trace' }); + expect(parseWorkbenchLocation('/trace', '?correlation=a%00b')).toEqual({ area: 'trace' }); + expect(formatWorkbenchLocation({ area: 'trace', correlation: 'a&b=c' })).toBe('/trace?correlation=a%26b%3Dc'); + expect(parseWorkbenchLocation('/trace', '?correlation=a%26b%3Dc')).toEqual({ area: 'trace', correlation: 'a&b=c' }); }); it('normalizes trace, sessions, and advanced tails', () => { diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index eb78663e4..53dc20a30 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -53,6 +53,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/examples-contract.test.ts', 'packages/agent-bundle/tests/generated-route-server.test.ts', 'packages/agent-bundle/tests/hook-playground-service.test.ts', + 'packages/agent-bundle/tests/hook-receipt-pipe.test.ts', 'packages/agent-bundle/tests/hooks.test.ts', 'packages/agent-bundle/tests/host-adapters.test.ts', 'packages/agent-bundle/tests/host-discovery-dev-server.test.ts', @@ -87,6 +88,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', 'packages/agent-bundle/tests/test-browser-rstest.test.ts', + 'packages/agent-bundle/tests/trace-dev-server.test.ts', 'packages/agent-bundle/tests/workbench-surface-dev-server.test.ts', 'packages/agent-bundle/tests/worktree-proximity-journeys.test.ts', 'packages/rsc-markdown-stream/tests/react-server.test.ts', diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index a5921ca7d..d660a2ec3 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -98,7 +98,9 @@ Start `pnpm example:audiobook` from the repository root for the visual developme 3. Inspect **Rendered** first: it shows the actual Agent Document from the route's production RSC execution. Structured data, raw document, MCP/CLI projections, and Trace remain available as secondary tabs. -4. Edit `src/mcp/curator/tools/search_audible.tsx` or a component it renders. After the rebuild +4. Open **Trace** to see the invocation start and completion correlated under the run, then use + **Open route** to return to this recorded result. +5. Edit `src/mcp/curator/tools/search_audible.tsx` or a component it renders. After the rebuild reaches **Idle**, rerun the saved input and inspect the updated rendered result. The route is directly addressable at diff --git a/website/docs/en/examples/hooks-and-scripts.mdx b/website/docs/en/examples/hooks-and-scripts.mdx index 900e1ea09..dfa54e313 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -58,8 +58,10 @@ an authored script. packaging. 3. Switch the target to portable and select `detect-risk`. It reads `release/risk-register.json`, reports high-severity `REL-204`, exits with code 2, and finalizes a durable blocking trace. -4. Follow the runs in **Trace**. Use **Advanced → Raw logs** for uncorrelated producer details, - **Advanced → Artifact** for emitted files and provenance, and +4. Follow the runs in **Trace** to see each invocation and kernel phase; an attached host's real + hook delivery adds its correlated receipt there too. Use **Open route** to restore a recorded + snapshot. Use **Advanced → Raw logs** for + uncorrelated producer details, **Advanced → Artifact** for emitted files and provenance, and **Advanced → Evals → Compare** after two eval runs exist. ## The reversible diagnostic walkthrough diff --git a/website/docs/en/examples/mcp-app.mdx b/website/docs/en/examples/mcp-app.mdx index e330e3c0c..e3d8db4b8 100644 --- a/website/docs/en/examples/mcp-app.mdx +++ b/website/docs/en/examples/mcp-app.mdx @@ -95,6 +95,8 @@ to see how the surfaces fit together instead of studying one of them alone. the App preview: the rendered panel shows the same record through the MCP Apps bridge, with a text-labelled amber `degraded` indicator. Inspect the protocol trace, use **Restart MCP session**, then close, reset, and reopen the session to exercise the lifecycle. + Then open **Trace** to see the MCP request, response, notifications, and session activity joined + by their session and JSON-RPC request ids. 7. In **Advanced → Evals → Runs**, select `mcp-app-status`, run `status-is-healthy`, and inspect the completed passing trial attributed to `service-readiness`. diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 245580d55..0d6baf2c9 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -336,7 +336,9 @@ boundaries, including failures in either phase; provider and render boundaries a the unified trace consumer to connect inside the deferred worker. Each frozen event carries one `EventTraceExecution` identity, a monotonic timestamp and sequence, and only phase-specific metadata; failures use a bounded, stack-free `EventTraceErrorSummary`. -`createEventTracer` is a no-op without an observer and isolates observer errors from execution. +`createEventTracer` emits to its explicit observer or, without one, to the process-local observer +current at each emission (a no-op while none is installed), and isolates observer errors from +execution. Developer tooling installs the process-local sink with `installEventTraceObserver`; the returned disposer restores the previous sink. The trace types and helpers are exported from `agent-bundle` and `agent-bundle/api`. diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 6e06c3a66..9e9deb332 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -222,6 +222,13 @@ and prints it in every failure, because a pass at one level is never a receipt f Workbench renders, so tests assert leaves and paths instead of a fixed list of pages. `WorkbenchPageName` and `workbenchPageLabel` are no longer exported. +For browser acceptance of the live Workbench, run a real route and assert the populated trace +rather than only its empty state. `data-testid="trace-timeline"` identifies the timeline, +`trace-entry` identifies each selectable row, `trace-group` identifies correlated groups, and +`trace-detail` identifies the selected entry's detail view. Assert the row's source, kind, and +correlation evidence, then use **Open route** and verify that the route workspace loaded the +recorded `?invocation=` snapshot. + Two further levels sit alongside these nine, for eleven in all. `agent-bundle/test/browser` supplies `mountBrowserApp` for the browser-safe `browser-app` level — production-compiled MCP App HTML mounted over the product bridge in a real browser page — and `simulated` reuses the installed-host helper diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 2b878db4c..e6fe361c4 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -31,7 +31,8 @@ These are contracts, not defaults: The primary navigation has four destinations in this release: - **Application** — the compiled application tree and the workspace for its selected leaf. -- **Trace** — invocations observed in this foreground development session. +- **Trace** — the correlated timeline of application activity observed in this foreground + development session. - **Problems** — current compiler, runtime, and contract diagnostics. - **Advanced** — Evals, Artifact, Protocol, Host diagnostics, and Raw logs. @@ -75,8 +76,9 @@ executable route, the input editor is generated from its schema and offers appli and the last input kept for that leaf. **Run** executes the production route path on the foreground server. -Results open on **Rendered**, the browser rendering of the production Agent Document and its -streamed progress or Suspense replacements. Secondary result tabs are **Structured result**, +Results open on **Rendered**, the browser rendering of the production Agent Document. While a +route runs, streamed progress and Suspense replacements update this view in place and **Cancel** +stops the child; cancelled invocations have no `outcome`. Secondary result tabs are **Structured result**, **Raw AgentDocument**, **MCP projection**, **CLI projection** when available, and **Trace**. The inspector opens only when requested and contains **Source**, **Schema**, **Context**, **Providers**, **Execution timings**, **Projection**, and **Raw protocol**. A provider the run @@ -127,10 +129,82 @@ inside Skill Markdown remain inert. ## Trace -Trace lists this foreground development session's route invocations and updates when a -`route.invocation` project event arrives. Select an entry to inspect it, or follow its route link -to load that invocation snapshot in the route workspace. This release does not claim a durable -cross-session trace or embedded host session. +Trace is the live, ordered timeline of what the foreground server observes the application doing. +For example, running the `sessionStart` event route in +[Hooks and Scripts](../../examples/hooks-and-scripts.mdx) can produce a sequence like: + +```text +22:41:04.101 invocation.started event:session/start +22:41:04.118 kernel.preflight.start session/start · claude +22:41:04.121 kernel.execute.start event:session/start +22:41:04.126 kernel.providers.start +22:41:04.129 kernel.providers.finish +22:41:04.132 kernel.render.start +22:41:04.146 kernel.render.finish +22:41:04.149 invocation.completed sessionStart succeeded +``` + +An invocation through the Protocol inspector adds `mcp.request`, progress or logging +notifications, and `mcp.response` to the same timeline. A hook delivered by an attached host adds +`hook.received` and `hook.completed` or `hook.failed`; its payload-free kernel phases are retained +in the terminal row's details. + +### Entries, correlation, and grouping + +The timeline is a lowering of records that already exist. It does not make a second copy of an +invocation, protocol frame, hook receipt, log record, or diagnostic. Each row carries +its occurrence time, `source`, publisher-owned dotted `kind`, one-line summary, known correlation +keys, optional status and duration, and, when a full record is available, an `href` to it: + +| Source | Kinds | Correlation keys | Destination | +| --- | --- | --- | --- | +| `invocation` | `invocation.started`, `invocation.completed`, `invocation.failed`, `invocation.cancelled` | `correlationId`, `invocationId`, `routeId`, `epochId`, plus session/conversation when available | The Application route with `?invocation=` | +| `kernel` | `kernel.preflight.start`, `kernel.preflight.outcome`, `kernel.execute.start`, provider and render start/finish, `kernel.failure` | `executionId`, route, host, session and conversation identity when available | The corresponding route invocation | +| `mcp` | request, response, notification, progress, logging, session, and stderr kinds | `mcpSessionId`, JSON-RPC `mcpRequestId`, `requestId`, route and host metadata when known | The route workspace with `?session=`, or the bound Protocol session | +| `hook` | receipt/completion/failure and host session start/end | `requestId`, `executionId`, `sessionId`, `conversationId`, `routeId`, `host` | The event route and captured receipt | +| `log` | `log..` for records with a shared key | Any correlation key retained by the safe log projection | Advanced → Raw logs | +| `diagnostic` | build, contract, and host-sync failures | Build, epoch, route, and shared request identity when known | Problems or the affected route | + +Trace groups entries transitively on shared identity. The group label uses the strongest available +key in this order: conversation, session, invocation, kernel execution, MCP request, +then the browser-minted correlation id. An MCP request id joins only within its MCP session. The +grouping is evidence-based: unrelated activity is not joined merely because it happened nearby. +`/trace?correlation=` selects the group containing an entry with that exact correlation +value. + +### Inspecting and deep-linking + +Select a row to open its detail at `/trace/`. **Open route** follows the row's `href` and +loads the immutable invocation snapshot in the Application workspace rather than rerunning the +route. The route workspace's **Open in Trace** action returns to the matching correlated group. +Advanced → Raw logs offers the same action when a record carries `correlationId`, `invocationId`, +or `mcpSessionId`. + +Trace replay loads from `GET /api/trace?after=` and the live view continues from +`GET /api/trace/stream?after=`. A dropped replay window is represented explicitly as a +gap; the browser does not silently imply that the remaining rows are complete. Trace belongs to +the current foreground development session and is not durable across server restarts. + +### Host hook receipts + +Attached generated hook wrappers can post a receipt of at most 16 KiB to +`POST /api/trace/receipts`. The wrapper discovers the active loopback endpoint and random +per-dev-server bearer token from the development install marker and the project's owner-only +receipt file; simulations receive the pair directly from the foreground server, and shutdown +removes the file. The write-only route rejects a non-loopback peer and any request with an +`Origin` header; neither the Workbench cookie nor its session header authorizes it. A receipt +contains execution identity, payload-free kernel events, host/session/request ids, and resolved +lineage — never the native hook payload. Posting has a 750 ms budget and failure is ignored, so +Workbench observation cannot change the hook result. Because the wrapper awaits that post after +writing stdout, the same 750 ms timeout bounds added hook exit latency. This is narrow +authenticated telemetry, not a remote-control or general-purpose ingestion endpoint. + +### Deliberately absent data + +Trace never includes request or response payload bodies, rendered Agent Documents, native event +envelopes, environment variables, credentials, absolute paths, or error stacks. Open the linked +invocation, route, Protocol session, Raw log record, or Problem when its bounded full record is +available and you need more detail. ## Problems and stale-catalog repair @@ -158,8 +232,9 @@ Repair a stale catalog in this order: [MCP Inspector](https://github.com/modelcontextprotocol/inspector) launcher. - **Host diagnostics** is limited to installed state, version, path, whether the current plugin is attached, actionable errors, and one MCP handshake indicator. -- **Raw logs** contains producer streams for framework-level diagnosis. Trace is the normal route - execution view. +- **Raw logs** remains the framework-level producer stream for details that do not belong on the + typed timeline. Trace is the normal observability view; a log record carrying + `correlationId`, `invocationId`, or `mcpSessionId` offers **Open in Trace**. An MCP protocol session remains pinned to `{ epochId, target, serverName }`. Restarting it respawns that generated server on the selected epoch; open a new session to use a newly published epoch. @@ -186,7 +261,8 @@ Workbench uses paths and browser history, not `#page` hashes: /routes/skills/ /routes/commands/ /routes/rules/ -/trace/ +/trace/ +/trace?correlation= /problems /advanced/
    ``` @@ -201,10 +277,19 @@ survive refresh. The route workspace uses one authenticated, origin-guarded foreground API: - `POST /api/routes/invocations` accepts a route invocation request and returns its completed - invocation envelope. + invocation envelope. With `stream: true`, it returns `202` immediately with the running id. +- `GET /api/routes/invocations//stream` streams retained and live `render`, `trace`, + `truncated`, and terminal `final` messages as server-sent events. It retains the newest 256 + render events and signals truncation once. +- `POST /api/routes/invocations//cancel` stops a running or queued invocation and returns its + cancelled final envelope; cancelling an already-final invocation reports `AB8256` (409). - `GET /api/routes/invocations?limit=50` returns newest-first summaries for Trace. - `GET /api/routes/invocations/` returns one invocation. -- `/api/project/events` publishes completed summaries as `route.invocation` events. +- `/api/project/events` publishes the running record and final summary as `route.invocation` events. +- `GET /api/trace?after=` returns a correlated trace replay. +- `GET /api/trace/stream?after=` streams trace entries and replay gaps as NDJSON. +- `POST /api/trace/receipts` accepts one bounded, bearer-authenticated hook receipt from a + development wrapper; it is not a browser route. Requests identify one canonical operation with `routeId` and may select one shaped `surface`: `{ kind: "mcp" }`, `{ kind: "cli", command, args }`, `{ kind: "event", host?, fixtureId? }`, @@ -239,8 +324,9 @@ unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed (`AB8237`), unknown fixture ids (`AB8238`), a queued invocation whose published revision moved before it acquired a slot (`AB8239`), unavailable compiled artifacts (`AB8250`), missing compiled route executables (`AB8251`), compiled projection or preflight failures (`AB8252`), -CLI command/operation mismatches (`AB8253`), projected duplicate CLI ids (`AB8254`), and -preflight routes submitted without a concrete host (`AB8255`) are reported as diagnostics. +CLI command/operation mismatches (`AB8253`), projected duplicate CLI ids (`AB8254`), preflight +routes submitted without a concrete host (`AB8255`), and cancellation of an already-final +invocation (`AB8256`) are reported as diagnostics. See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. @@ -249,18 +335,25 @@ facts. `status` says whether the execution boundary completed; `outcome` says wh run meant, judged by the surface the route runs through. A failed boundary carries no `outcome`, and a completed run always carries one, so a represented error or a non-zero exit is never reported as plain success. +`route.invocation` also fires when a run starts with a `status: "running"` record that has no +`completedAt` or `outcome`; narrow on `status` before reading completion fields. | Field | Value | Meaning | | --- | --- | --- | | `status` | `succeeded` | The child completed a final document (or a plain script exited). | -| `status` | `failed` | The boundary never completed: crash, timeout, abort, or an `AB825x` diagnostic. | +| `status` | `failed` | The boundary never completed: crash, timeout, or an `AB825x` diagnostic. | +| `status` | `cancelled` | The operator cancelled a running or queued invocation; no `outcome` is present. | | `outcome` | `{ kind: "success" }` | The projected result is a success by the route's surface. | | `outcome` | `{ kind: "represented-error", summary }` | The MCP projection carries `isError: true` (a non-`success` Agent Document, such as `Agent.Error`), or an event route's decision is `deny`. `summary` lists the `Agent.Error` codes and messages, or the deny reason. | | `outcome` | `{ kind: "process-exit", exitCode }` | A CLI or script route completed and its generated executable set a non-zero exit code — the bin's own `exitCode` policy applied to the validated result, or `1` for a non-`success` document. A zero exit reports `success`. | -The Workbench status line shows the execution status (`Completed` / `Failed`) beside an outcome +The Workbench status line shows the execution status (`Running` / `Completed` / `Failed` / +`Cancelled`) beside an outcome badge, and Trace lists both for every entry. +The complete browser-facing HTTP shapes, including the trace entry and replay contracts, are in +the [development-server HTTP reference](../../reference/dev-server-http.mdx). + ## The same session programmatically The public `startDevServer` export accepts the options the CLI flags map to (`root`, `port`, diff --git a/website/docs/en/reference/_meta.json b/website/docs/en/reference/_meta.json index 85b4476a3..fbea230e6 100644 --- a/website/docs/en/reference/_meta.json +++ b/website/docs/en/reference/_meta.json @@ -8,6 +8,7 @@ "events", "notices", "diagnostics", + "dev-server-http", "runtime-environment", "security", "limitations", diff --git a/website/docs/en/reference/dev-server-http.mdx b/website/docs/en/reference/dev-server-http.mdx new file mode 100644 index 000000000..4db8da2da --- /dev/null +++ b/website/docs/en/reference/dev-server-http.mdx @@ -0,0 +1,146 @@ +--- +description: 'Browser-facing HTTP routes for Workbench invocations, the unified trace, raw logs, and host hook receipts.' +--- + +# Development-server HTTP + +`agent-bundle dev` mounts these routes on its loopback foreground server for the Workbench. They +are development protocols, not public deployment endpoints. Browser routes require the +foreground session guard and enforce the Workbench origin policy described in +[Security](./security.mdx). Unless noted otherwise, cursors are non-negative safe integers and +responses are JSON. + +## Route invocations + +| Method | Path | Response | +| --- | --- | --- | +| `POST` | `/api/routes/invocations` | `{ invocation: RouteInvocation }`; with `stream: true`, `202` and `{ invocation: RunningRouteInvocation }` | +| `GET` | `/api/routes/invocations?limit=<1..200>` | `{ invocations: RouteInvocationSummary[] }`, newest first | +| `GET` | `/api/routes/invocations/` | `{ invocation: RouteInvocation }` | +| `GET` | `/api/routes/invocations//stream` | Server-sent `render`, `trace`, `truncated`, and terminal `final` events | +| `POST` | `/api/routes/invocations//cancel` | `202` and the final `{ invocation: RouteInvocation }` | + +The POST body is a `RouteInvocationRequest`: `routeId` plus optional `input`, `args`, +`correlationId`, caller `requestId`, and event fixture options. The foreground echoes the two +identifiers on the invocation so the route workspace and trace can join the run. The completed +envelope holds the input, provenance context, providers, render events, Agent Document, result, +projections, diagnostics, and timings. Trace entries link to that full snapshot instead of +duplicating it. A cancelled invocation has `status: "cancelled"` and no `outcome`; cancelling an +already-final invocation returns `AB8256` (409). + +## Development runtime runs + +`POST /api/runtime/runs` accepts a `DevRuntimeInvocationRequest` with `surfaceId`, `target`, +`input`, and optional `fixtureId` and `expectedGenerationId`. `GET /api/runtime/runs` lists recent +runs and `GET /api/runtime/runs/` reads one run. + +## MCP operations and correlation + +`POST /api/mcp/sessions//operations` keeps the existing operation union. A +`tools/call` operation additionally accepts an optional top-level `correlationId` of at most 256 +characters. The browser cannot supply `_meta` directly; the foreground copies this value to +`params._meta["agent-bundle/correlationId"]` before sending the request. + +The MCP trace lifts the JSON-RPC id and method plus bounded host metadata. Request/response pairs +share `mcpRequestId` and duration; tool calls and prompt reads link to their Application route +with `?session=`. An operation that cannot resolve a route links to +`/advanced/protocol?session=`. + +## Unified trace + +| Method | Path | Transport | +| --- | --- | --- | +| `GET` | `/api/trace?after=` | `TraceReplay` JSON | +| `GET` | `/api/trace/stream?after=` | `application/x-ndjson` frames of `TraceMessage` | + +Omitting `after` is equivalent to `after=0`. Replay has this shape: + +```ts +interface TraceReplay { + readonly entries: readonly TraceEntry[]; + readonly gap?: TraceReplayGap; + readonly latestSequence: number; +} +``` + +Each `TraceEntry` contains: + +```ts +interface TraceEntry { + readonly id: string; + readonly sequence: number; + readonly occurredAt: string; + readonly source: + | 'invocation' + | 'kernel' + | 'mcp' + | 'hook' + | 'log' + | 'diagnostic'; + readonly kind: string; + readonly summary: string; + readonly correlation: TraceCorrelation; + readonly status?: 'ok' | 'error' | 'running'; + readonly durationMs?: number; + readonly details?: JsonValue; + readonly href?: string; +} +``` + +`TraceCorrelation` can carry `correlationId`, `conversationId`, `epochId`, `executionId`, `host`, +`invocationId`, `mcpRequestId`, `mcpSessionId`, `requestId`, `routeId`, and `sessionId`. +Publishers fill only keys they know. `details` is a bounded, already-safe JSON projection; it is +not a payload body. + +When the requested cursor predates retained history, replay returns a gap and the stream emits the +same `TraceReplayGap` as one NDJSON frame: + +```ts +interface TraceReplayGap { + readonly droppedCount: number; + readonly firstAvailableSequence: number; + readonly requestedAfterSequence: number; + readonly type: 'trace.gap'; +} +``` + +A malformed cursor returns `400`, a cursor ahead of current history returns `409`, and a closed +or unavailable trace hub returns `503`. These responses use the trace diagnostics registered in +the generated [diagnostics reference](./diagnostics.md). + +## Raw logs + +| Method | Path | Transport | +| --- | --- | --- | +| `GET` | `/api/logs/replay?after=` | `{ replay: DevLogReplay }` JSON | +| `GET` | `/api/logs/stream?after=` | `application/x-ndjson` frames of `DevLogMessage` | + +Raw logs remain a framework-diagnostics stream. Records expose only allowlisted context values +and browser-safe text. A record carrying `correlationId`, `invocationId`, or `mcpSessionId` can +link into `/trace?correlation=`; uncorrelated records stay in Advanced → Raw logs. + +## Host hook receipts + +| Method | Path | Transport | +| --- | --- | --- | +| `POST` | `/api/trace/receipts` | One `EventTraceReceipt` JSON body, at most 16 KiB; success is `204` | + +Generated hook processes post one bounded receipt to this foreground-only route. This is not a +browser API: it rejects an `Origin` header and non-loopback peer, requires the receipt endpoint's +random per-dev-server bearer token, and exposes no read or command operation. The Workbench cookie +and session header do not authorize it. A receipt contains version `1`, one +`EventTraceExecution`, payload-free kernel events, their wall-clock start, +host/session/request identity, and the resolved lineage axis. Native event bodies, tool input or +output, rendered documents, environment values, credentials, filesystem paths, and error stacks +are absent. + +For a host invocation, the wrapper finds `.agent-bundle-dev.json` beside its installed bundle, +then reads the active endpoint from the named project's +`.agent-bundle/hook-receipts.json`. A dev-server-spawned simulation receives the same loopback URL +and token through the two internal receipt environment variables. The endpoint file is replaced +with owner-only mode and removed when the server closes. Only an exact +`http://127.0.0.1:` or `http://[::1]:` origin is accepted. The wrapper gives the post +750 ms and ignores transport failure, so Workbench observation can never change the hook result. + +See [Trace](../guide/development/workbench.mdx#trace) for how receipt and kernel entries appear in +the timeline. diff --git a/website/docs/en/reference/index.mdx b/website/docs/en/reference/index.mdx index 9e82f9de9..1219a8ec3 100644 --- a/website/docs/en/reference/index.mdx +++ b/website/docs/en/reference/index.mdx @@ -20,6 +20,7 @@ only the contract. | [Event and hook matrix](./events.md) | Canonical events to native events per host, tool selectors to native matchers, deferred native events. Generated at build time. | | [Notice delivery matrix](./notices.md) | Which notice channels each host supports and why the rest are unavailable. Generated at build time. | | [Diagnostics reference](./diagnostics.md) | Every `AB` code family, trigger, severity, and recovery hint. Generated at build time from the repository contract. | +| [Development-server HTTP](./dev-server-http.mdx) | Browser-facing invocation, trace, raw-log, and host hook-receipt routes and wire shapes. | | [Runtime environment](./runtime-environment.mdx) | Node floors, path tokens, environment variables, `.env` layering, and durable state locations. | | [Security](./security.mdx) | The credential, network, and trust boundaries. | | [Limitations](./limitations.mdx) | What the framework does not currently do or prove. | diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 6b8f5506e..3acdd1569 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -45,6 +45,8 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench event route workspace sets it. | +| `AGENT_BUNDLE_DEV_TRACE_URL` | Generated hook wrappers in development | Internal loopback origin for posting a payload-free hook trace receipt. The foreground server sets it for simulations; host-invoked development wrappers normally discover the same endpoint from the development install marker. | +| `AGENT_BUNDLE_DEV_TRACE_TOKEN` | Generated hook wrappers in development | Internal bearer token paired with `AGENT_BUNDLE_DEV_TRACE_URL`. It authenticates `POST /api/trace/receipts` and must not be logged or persisted outside the private development endpoint record. | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | Contributor test suites | `1` compares the installed host CLI contract. | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | Contributor test suites | `1` runs the signed-in Claude native smoke. | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | Contributor test suites | `1` runs the signed-in Codex native smoke. | diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index 9b8aafa14..e97369785 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -81,7 +81,8 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck 2. 在生成的输入编辑器中输入诸如 `Dune` 这样的标题,然后选择 **Run**。 3. 首先检视 **Rendered**:它展示该路由生产 RSC 执行得到的真实 Agent Document。结构化数据、原始文档、 MCP/CLI 投影与 Trace 仍作为次要标签可用。 -4. 编辑 `src/mcp/curator/tools/search_audible.tsx` 或它所渲染的某个组件。重建到达 **Idle** 之后, +4. 打开 **Trace**,查看归入此次运行的调用开始与完成条目,然后使用 **Open route** 返回这份已记录结果。 +5. 编辑 `src/mcp/curator/tools/search_audible.tsx` 或它所渲染的某个组件。重建到达 **Idle** 之后, 重新运行已保存的输入,并检视更新后的渲染结果。 该路由可直接寻址 diff --git a/website/docs/zh/examples/hooks-and-scripts.mdx b/website/docs/zh/examples/hooks-and-scripts.mdx index 676738d9f..655179816 100644 --- a/website/docs/zh/examples/hooks-and-scripts.mdx +++ b/website/docs/zh/examples/hooks-and-scripts.mdx @@ -50,8 +50,9 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个脚本 读取自己模块旁边打包好的 `release/release-manifest.json`,并报告 2.4.0 版本已可打包。 3. 把 target 换成 portable 并选择 `detect-risk`。它读取 `release/risk-register.json`,报告高严重级别的 `REL-204`,以退出码 2 结束,并定稿一条持久的阻断性轨迹。 -4. 在 **Trace** 中跟随这些运行。用 **Advanced → Raw logs** 查看未关联的生产者细节,用 - **Advanced → Artifact** 查看输出文件与 provenance,并在已有两次 eval 运行之后使用 +4. 在 **Trace** 中跟随这些运行,查看每次调用及其内核阶段;已附加宿主实际投递钩子时,其关联收据也会 + 出现在这里。使用 **Open route** 恢复已记录的快照。用 **Advanced → Raw logs** 查看未关联的 + 生产者细节,用 **Advanced → Artifact** 查看输出文件与 provenance,并在已有两次 eval 运行之后使用 **Advanced → Evals → Compare**。 ## 可逆的诊断演练 diff --git a/website/docs/zh/examples/mcp-app.mdx b/website/docs/zh/examples/mcp-app.mdx index 91755aad9..995c9ea1e 100644 --- a/website/docs/zh/examples/mcp-app.mdx +++ b/website/docs/zh/examples/mcp-app.mdx @@ -81,6 +81,7 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 P95 latency 的检查,其中后者失败。打开 App 预览:渲染出的面板通过 MCP Apps 桥接展示同一条记录, 并带一个以文字标注的琥珀色 `degraded` 指示。检视协议轨迹、使用 **Restart MCP session**,然后关闭、 重置并重新打开会话,以演练整个生命周期。 + 随后打开 **Trace**,查看通过会话 id 与 JSON-RPC 请求 id 关联起来的 MCP 请求、响应、通知与会话活动。 7. 在 **Advanced → Evals → Runs** 中选中 `mcp-app-status`,运行 `status-is-healthy`,查看归属于 `service-readiness` 的那次已完成且通过的试次。 diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 1a326ab2b..1340901d9 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -279,7 +279,7 @@ notices、lineage 存储、渲染式布局、应用 provider、React/RSC 辅助 都携带同一份 `EventTraceExecution` 身份、单调时间戳和序号,并且只包含该阶段的元数据。 生成的 preflight shell 当前会发出 preflight 与 execute 边界及这两个阶段的失败;provider 和 render 边界保留给统一 trace 消费方在延迟 worker 内连接。失败使用有长度上限且不含堆栈的 -`EventTraceErrorSummary`。没有 observer 时 `createEventTracer` 是空操作;observer 抛错也不会改变执行。 +`EventTraceErrorSummary`。`createEventTracer` 会发送到显式 observer,没有时则发送到每次发出时当前的进程级 observer(尚未安装时为空操作);observer 抛错也不会改变执行。 开发工具通过 `installEventTraceObserver` 安装进程级 sink;返回的 disposer 会恢复之前的 sink。 相关类型与辅助函数从 `agent-bundle` 和 `agent-bundle/api` 导出。 diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index f03ba6892..0ba085eaf 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -195,6 +195,11 @@ try { 形状,因此测试断言的是叶子与路径,而不是一份固定的页面列表。 `WorkbenchPageName` 与 `workbenchPageLabel` 不再导出。 +对实时 Workbench 做浏览器验收时,应运行一条真实路由并断言已填充的 Trace,而不只是检查空状态。 +`data-testid="trace-timeline"` 标识时间线,`trace-entry` 标识每个可选择行,`trace-group` 标识关联组, +`trace-detail` 标识所选条目的详情视图。断言行的来源、kind 与关联证据,然后使用 **Open route**, +并验证路由工作区已加载记录下来的 `?invocation=` 快照。 + 在这九个级别之外还有两个并列级别,共十一个。`agent-bundle/test/browser` 为浏览器安全的 `browser-app` 级别 提供 `mountBrowserApp`,用于在真实浏览器页面中把生产编译的 MCP App HTML 挂载到产品桥接层之上; 而 `simulated` 复用不带 `sessionEvidence` 的已安装宿主辅助函数 `openInstalledHostMcpServer` diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 4dff8c47d..a0f12436a 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -27,7 +27,7 @@ npx agent-bundle dev --root . --port 3100 --no-open 本版本的主导航有四个目的地: - **Application** — 已编译的应用树,以及所选叶子的工作区。 -- **Trace** — 本次前台开发会话中观察到的调用。 +- **Trace** — 本次前台开发会话中观察到的应用活动关联时间线。 - **Problems** — 当前的编译器、运行时与契约诊断。 - **Advanced** — Evals、Artifact、Protocol、Host diagnostics 与 Raw logs。 @@ -64,7 +64,8 @@ Rules / Commands 选中一个叶子会打开同一个工作区,而不是把你送到某个表面专用的页面。对于可执行路由,输入编辑器由其 schema 生成,并提供适用的夹具以及为该叶子保留的上次输入。**Run** 在前台服务器上执行生产路由路径。 -结果首先打开 **Rendered**,即生产 Agent Document 及其流式进度或 Suspense 替换的浏览器渲染。次要结果 +结果首先打开 **Rendered**,即生产 Agent Document 的浏览器渲染。路由运行期间,流式进度与 Suspense +替换会就地更新该视图;**Cancel** 会停止子进程,被取消的调用不带 `outcome`。次要结果 标签是 **Structured result**、**Raw AgentDocument**、**MCP projection**、可用时的 **CLI projection**, 以及 **Trace**。检查器仅在请求时打开,其中包含 **Source**、**Schema**、**Context**、**Providers**、 **Execution timings**、**Projection** 与 **Raw protocol**。本次运行未测量的 provider 显示 @@ -106,9 +107,70 @@ Markdown 中的原始 HTML、JSX/MDX 与 Mermaid 保持惰性。 ## Trace -Trace 列出本次前台开发会话的路由调用,并在收到 `route.invocation` 项目事件时更新。选中一条记录即可 -检视它,或跟随其路由链接,在路由工作区中加载该调用快照。本版本不声称提供跨会话的持久轨迹,也不嵌入 -宿主会话。 +Trace 是前台服务器所观察到的应用活动的实时有序时间线。例如,运行 +[钩子与脚本](../../examples/hooks-and-scripts.mdx)中的 `sessionStart` 事件路由可能产生如下序列: + +```text +22:41:04.101 invocation.started event:session/start +22:41:04.118 kernel.preflight.start session/start · claude +22:41:04.121 kernel.execute.start event:session/start +22:41:04.126 kernel.providers.start +22:41:04.129 kernel.providers.finish +22:41:04.132 kernel.render.start +22:41:04.146 kernel.render.finish +22:41:04.149 invocation.completed sessionStart succeeded +``` + +通过 Protocol 检查器发起的调用还会向同一时间线加入 `mcp.request`、进度或日志通知,以及 +`mcp.response`。由已附加宿主投递的钩子会加入 `hook.received` 以及 `hook.completed` 或 +`hook.failed`;其不含载荷的内核阶段保留在终止行的详情中。 + +### 条目、关联与分组 + +时间线是对已有记录的降级表示,不会再次复制调用、协议帧、钩子收据、日志记录或诊断。 +每一行都包含发生时间、`source`、由生产者所有的点分 `kind`、单行摘要、已知关联键、可选状态与耗时; +当完整记录可用时,还包含指向它的 `href`: + +| 来源 | Kind | 关联键 | 目的地 | +| --- | --- | --- | --- | +| `invocation` | `invocation.started`、`invocation.completed`、`invocation.failed`、`invocation.cancelled` | `correlationId`、`invocationId`、`routeId`、`epochId`,以及可用时的会话/对话标识 | 带 `?invocation=` 的 Application 路由 | +| `kernel` | `kernel.preflight.start`、`kernel.preflight.outcome`、`kernel.execute.start`、provider 与 render 的开始/结束,以及 `kernel.failure` | `executionId`、路由、宿主,以及可用时的会话与对话标识 | 对应的路由调用 | +| `mcp` | request、response、notification、progress、logging、session 与 stderr kind | `mcpSessionId`、JSON-RPC `mcpRequestId`、`requestId`,以及已知的路由与宿主元数据 | 带 `?session=` 的路由工作区,或绑定的 Protocol 会话 | +| `hook` | 收据/完成/失败与宿主会话开始/结束 | `requestId`、`executionId`、`sessionId`、`conversationId`、`routeId`、`host` | 事件路由与捕获的收据 | +| `log` | 具有共享键的记录对应的 `log..` | 安全日志投影保留的任意关联键 | Advanced → Raw logs | +| `diagnostic` | 构建、契约与宿主同步失败 | 已知的构建、epoch、路由与共享请求标识 | Problems 或受影响的路由 | + +Trace 会根据共享标识传递式地分组条目。组标签按以下顺序采用最强的可用键:对话、会话、调用、内核执行、 +MCP 请求,最后是浏览器生成的关联 id。MCP 请求 id 只在其 MCP 会话内参与关联。分组以证据 +为依据:不会仅因活动发生时间相近就把无关活动合并。`/trace?correlation=` 会选中包含具有该精确 +关联值条目的组。 + +### 检视与深度链接 + +选择一行会在 `/trace/` 打开其详情。**Open route** 会沿该行的 `href` 前往 Application +工作区并加载不可变的调用快照,而不是重新运行路由。路由工作区的 **Open in Trace** 操作会返回匹配的 +关联组。Advanced → Raw logs 中的记录若带有 `correlationId`、`invocationId` 或 `mcpSessionId`, +也会提供同一操作。 + +Trace 重放从 `GET /api/trace?after=` 加载,实时视图再从 +`GET /api/trace/stream?after=` 继续。重放窗口丢失会被明确表示为 gap;浏览器不会暗示剩余 +行是完整记录。Trace 属于当前前台开发会话,服务器重启后不会持久保留。 + +### 宿主钩子收据 + +已附加的生成式钩子包装器可以向 `POST /api/trace/receipts` 提交一份最多 16 KiB 的收据。包装器通过 +开发安装标记与项目中仅所有者可读的收据文件发现当前 loopback 端点及每次开发服务器随机生成的 bearer +token;模拟调用直接从前台服务器取得这对值,服务器关闭时会移除该文件。这个只写路由拒绝非 loopback +对端以及任何带 `Origin` 标头的请求;Workbench cookie 与会话标头均不能授权它。收据包含执行标识、 +不含载荷的内核事件、宿主/会话/请求 id 与解析后的 lineage,绝不包含原生钩子载荷。提交预算为 750 ms, +失败会被忽略,因此 Workbench 观察不会改变钩子结果。包装器会在写入 stdout 后等待该提交,因此同一个 +750 ms 超时也限定了新增的钩子退出延迟。这是范围严格且经过认证的遥测,而不是远程控制或通用摄取端点。 + +### 刻意排除的数据 + +Trace 绝不包含请求或响应载荷正文、渲染后的 Agent Document、原生事件信封、环境变量、凭据、绝对路径或 +错误堆栈。需要更多详情时,请打开已链接的调用、路由、Protocol 会话、Raw log 记录或 Problem 中可用的 +有界完整记录。 ## Problems 与过期目录修复 @@ -131,7 +193,9 @@ Problems 收集当前诊断。失败的构建不会发布新的 epoch,因此 [MCP Inspector](https://github.com/modelcontextprotocol/inspector) 启动器。 - **Host diagnostics** 仅限于已安装状态、版本、路径、当前插件是否已附加、可操作的错误,以及一个 MCP 握手指示。 -- **Raw logs** 包含用于框架级诊断的生产者流。Trace 才是常规的路由执行视图。 +- **Raw logs** 保留为完整且经过脱敏的生产者流,用于不属于类型化时间线的框架级细节。Trace 是常规的 + 可观测性视图;带有 `correlationId`、`invocationId` 或 `mcpSessionId` 的日志记录会提供 + **Open in Trace**。 MCP 协议会话仍然固定到 `{ epochId, target, serverName }`。重启它会在所选 epoch 上重新拉起该生成式 服务器;要使用新发布的 epoch,请打开一个新会话。兼容的 App 通过同一个已绑定会话预览。见 @@ -156,7 +220,8 @@ Workbench 使用路径与浏览器历史,而不是 `#page` 哈希: /routes/skills/ /routes/commands/ /routes/rules/ -/trace/ +/trace/ +/trace?correlation= /problems /advanced/
    ``` @@ -169,10 +234,18 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 路由工作区使用一套经过认证、受 origin 守卫的前台 API: -- `POST /api/routes/invocations` 接受一条路由调用请求,并返回其已完成的调用信封。 +- `POST /api/routes/invocations` 接受一条路由调用请求,并返回其已完成的调用信封。传入 + `stream: true` 时会立即以 `202` 返回运行中的 id。 +- `GET /api/routes/invocations//stream` 通过服务器发送事件重放并实时发送 `render`、`trace`、 + `truncated` 与终止 `final` 消息。它保留最新 256 条 render 事件,并用一条标记表示发生了截断。 +- `POST /api/routes/invocations//cancel` 停止运行中或排队中的调用并返回取消后的最终信封;取消已经 + 终止的调用会报告 `AB8256`(409)。 - `GET /api/routes/invocations?limit=50` 为 Trace 返回按最新优先的摘要。 - `GET /api/routes/invocations/` 返回一次调用。 -- `/api/project/events` 以 `route.invocation` 事件发布已完成的摘要。 +- `/api/project/events` 以 `route.invocation` 事件发布运行中记录与最终摘要。 +- `GET /api/trace?after=` 返回一份关联的 Trace 重放。 +- `GET /api/trace/stream?after=` 以 NDJSON 流式传输 Trace 条目与重放 gap。 +- `POST /api/trace/receipts` 接受来自开发包装器的一份有界且经过 bearer 认证的钩子收据;它不是浏览器路由。 请求以 `routeId` 标识一个规范操作,并可选择一种有固定形状的 `surface`: `{ kind: "mcp" }`、`{ kind: "cli", command, args }`、 @@ -203,22 +276,29 @@ invocation id(`AB8231`)、不可用的 epoch(`AB8232`)、渲染超时或 (`AB8237`)、未知 fixture id(`AB8238`)、在获得并发槽位前发布修订已变更的排队调用(`AB8239`)、不可用的编译制品 (`AB8250`)、缺失的已编译路由可执行项(`AB8251`)、已编译投影或 preflight 失败(`AB8252`)、 CLI 命令/操作不匹配(`AB8253`)、投影形成的重复 CLI id(`AB8254`),以及未指定具体宿主的 preflight -路由(`AB8255`)会作为诊断报告。 +路由(`AB8255`),以及取消已终止调用(`AB8256`)会作为诊断报告。 各条触发条件与恢复指引见[诊断参考](../../reference/diagnostics.md)。 信封、`route.invocation` 事件载荷与路由工作区报告两个彼此独立的事实。`status` 表示执行边界是否完成; `outcome` 表示一次已完成的运行意味着什么,由该路由所经过的表面来判定。失败的边界不带 `outcome`, 已完成的运行总是带有一个,因此被表示的错误或非零退出码永远不会被报告为普通成功。 +`route.invocation` 也会在调用开始时触发,此时记录的 `status` 为 `"running"`,且没有 +`completedAt` 或 `outcome`;读取完成字段前请先按 `status` 缩小类型。 | 字段 | 值 | 含义 | | --- | --- | --- | | `status` | `succeeded` | 子进程完成了最终文档(或普通脚本已退出)。 | -| `status` | `failed` | 边界从未完成:崩溃、超时、中止,或 `AB825x` 诊断。 | +| `status` | `failed` | 边界从未完成:崩溃、超时,或 `AB825x` 诊断。 | +| `status` | `cancelled` | 操作者取消了运行中或排队中的调用;不带 `outcome`。 | | `outcome` | `{ kind: "success" }` | 按该路由的表面判定,投影结果为成功。 | | `outcome` | `{ kind: "represented-error", summary }` | MCP 投影带有 `isError: true`(非 `success` 的 Agent Document,例如 `Agent.Error`),或事件路由的决策为 `deny`。`summary` 列出 `Agent.Error` 的代码与消息,或 deny 的原因。 | | `outcome` | `{ kind: "process-exit", exitCode }` | CLI 或脚本路由已完成,且其生成的可执行文件设置了非零退出码——即该 bin 自己的 `exitCode` 策略作用于已校验结果,或非 `success` 文档对应的 `1`。零退出码报告为 `success`。 | -Workbench 状态行在执行状态(`Completed` / `Failed`)旁展示 outcome 徽章,Trace 为每个条目同时列出两者。 +Workbench 状态行在执行状态(`Running` / `Completed` / `Failed` / `Cancelled`)旁展示 outcome +徽章,Trace 为每个条目同时列出两者。 + +完整的浏览器侧 HTTP 形状(包括 Trace 条目与重放契约)见 +[开发服务器 HTTP 参考](../../reference/dev-server-http.mdx)。 ## 以编程方式使用同一个会话 diff --git a/website/docs/zh/reference/_meta.json b/website/docs/zh/reference/_meta.json index 85b4476a3..fbea230e6 100644 --- a/website/docs/zh/reference/_meta.json +++ b/website/docs/zh/reference/_meta.json @@ -8,6 +8,7 @@ "events", "notices", "diagnostics", + "dev-server-http", "runtime-environment", "security", "limitations", diff --git a/website/docs/zh/reference/dev-server-http.mdx b/website/docs/zh/reference/dev-server-http.mdx new file mode 100644 index 000000000..77656dde7 --- /dev/null +++ b/website/docs/zh/reference/dev-server-http.mdx @@ -0,0 +1,132 @@ +--- +description: 'Workbench 调用、统一 Trace、Raw logs 与宿主钩子收据所使用的浏览器侧 HTTP 路由。' +--- + +# 开发服务器 HTTP + +`agent-bundle dev` 在其 loopback 前台服务器上为 Workbench 挂载这些路由。它们是开发协议,不是公开部署 +端点。浏览器路由要求前台会话守卫,并强制执行[安全](./security.mdx)中描述的 Workbench origin 策略。 +除非另有说明,游标都是非负安全整数,响应均为 JSON。 + +## 路由调用 + +| 方法 | 路径 | 响应 | +| --- | --- | --- | +| `POST` | `/api/routes/invocations` | `{ invocation: RouteInvocation }`;传入 `stream: true` 时返回 `202` 与 `{ invocation: RunningRouteInvocation }` | +| `GET` | `/api/routes/invocations?limit=<1..200>` | `{ invocations: RouteInvocationSummary[] }`,最新优先 | +| `GET` | `/api/routes/invocations/` | `{ invocation: RouteInvocation }` | +| `GET` | `/api/routes/invocations//stream` | 服务器发送的 `render`、`trace`、`truncated` 与终止 `final` 事件 | +| `POST` | `/api/routes/invocations//cancel` | `202` 与最终 `{ invocation: RouteInvocation }` | + +POST 正文是一份 `RouteInvocationRequest`:包含 `routeId`,以及可选的 `input`、`args`、 +`correlationId`、调用方 `requestId` 与事件夹具选项。前台会把这两个标识回显到调用上,使路由工作区与 +Trace 可以关联此次运行。完成后的信封包含输入、provenance 上下文、providers、渲染事件、Agent +Document、结果、投影、诊断与计时。Trace 条目链接到这份完整快照,而不会复制它。取消后的调用带有 +`status: "cancelled"` 且不含 `outcome`;取消已终止的调用会返回 `AB8256`(409)。 + +## 开发运行时运行 + +`POST /api/runtime/runs` 接受一份 `DevRuntimeInvocationRequest`,其中包含 `surfaceId`、`target`、 +`input`,以及可选的 `fixtureId` 与 `expectedGenerationId`。`GET /api/runtime/runs` 列出最近的运行, +`GET /api/runtime/runs/` 读取一次运行。 + +## MCP 操作与关联 + +`POST /api/mcp/sessions//operations` 保留现有操作联合类型。`tools/call` 操作额外接受一个 +最多 256 个字符的可选顶层 `correlationId`。浏览器不能直接提供 `_meta`;前台在发送请求前会把该值 +复制到 `params._meta["agent-bundle/correlationId"]`。 + +MCP Trace 会提取 JSON-RPC id 与方法,以及有界的宿主元数据。请求/响应对共享 `mcpRequestId` 与耗时; +工具调用和 prompt 读取会通过 `?session=` 链接到其 Application 路由。无法解析到路由的 +操作会链接到 `/advanced/protocol?session=`。 + +## 统一 Trace + +| 方法 | 路径 | 传输 | +| --- | --- | --- | +| `GET` | `/api/trace?after=` | `TraceReplay` JSON | +| `GET` | `/api/trace/stream?after=` | `TraceMessage` 的 `application/x-ndjson` 帧 | + +省略 `after` 等同于 `after=0`。重放形状如下: + +```ts +interface TraceReplay { + readonly entries: readonly TraceEntry[]; + readonly gap?: TraceReplayGap; + readonly latestSequence: number; +} +``` + +每个 `TraceEntry` 包含: + +```ts +interface TraceEntry { + readonly id: string; + readonly sequence: number; + readonly occurredAt: string; + readonly source: + | 'invocation' + | 'kernel' + | 'mcp' + | 'hook' + | 'log' + | 'diagnostic'; + readonly kind: string; + readonly summary: string; + readonly correlation: TraceCorrelation; + readonly status?: 'ok' | 'error' | 'running'; + readonly durationMs?: number; + readonly details?: JsonValue; + readonly href?: string; +} +``` + +`TraceCorrelation` 可以携带 `correlationId`、`conversationId`、`epochId`、`executionId`、`host`、 +`invocationId`、`mcpRequestId`、`mcpSessionId`、`requestId`、`routeId` 与 `sessionId`。 +生产者只填充自己已知的键。`details` 是有界且已确保安全的 JSON 投影,不是载荷正文。 + +当请求的游标早于保留的历史时,重放会返回一个 gap,流也会把同一份 `TraceReplayGap` 作为一个 NDJSON +帧发出: + +```ts +interface TraceReplayGap { + readonly droppedCount: number; + readonly firstAvailableSequence: number; + readonly requestedAfterSequence: number; + readonly type: 'trace.gap'; +} +``` + +格式错误的游标返回 `400`,超前于当前历史的游标返回 `409`,已关闭或不可用的 Trace hub 返回 `503`。 +这些响应使用生成式[诊断参考](./diagnostics.md)中登记的 Trace 诊断。 + +## Raw logs + +| 方法 | 路径 | 传输 | +| --- | --- | --- | +| `GET` | `/api/logs/replay?after=` | `{ replay: DevLogReplay }` JSON | +| `GET` | `/api/logs/stream?after=` | `DevLogMessage` 的 `application/x-ndjson` 帧 | + +Raw logs 仍是框架诊断流。记录只暴露白名单中的上下文值与浏览器安全文本。带有 `correlationId`、 +`invocationId` 或 `mcpSessionId` 的记录可以链接到 `/trace?correlation=`;未关联的记录仍留在 +Advanced → Raw logs。 + +## 宿主钩子收据 + +| 方法 | 路径 | 传输 | +| --- | --- | --- | +| `POST` | `/api/trace/receipts` | 一份最多 16 KiB 的 `EventTraceReceipt` JSON 正文;成功返回 `204` | + +生成式钩子进程会向这个仅限前台的路由提交一份有界收据。它不是浏览器 API:会拒绝 `Origin` 标头与非 +loopback 对端,要求收据端点在每次开发服务器运行时随机生成的 bearer token,并且不暴露读取或命令操作。 +Workbench cookie 与会话标头不能授权它。收据包含版本 `1`、一份 `EventTraceExecution`、不含载荷的 +内核事件及其挂钟开始时间、宿主/会话/请求标识和解析后的 lineage 轴。原生事件正文、工具输入或输出、 +渲染文档、环境值、凭据、文件系统路径与错误堆栈均不存在。 + +对于宿主调用,包装器会在已安装捆绑包旁查找 `.agent-bundle-dev.json`,再从其中所指项目的 +`.agent-bundle/hook-receipts.json` 读取当前端点。由开发服务器生成的模拟调用通过两个内部收据环境变量 +取得相同的 loopback URL 与 token。端点文件会以仅所有者可读模式替换,并在服务器关闭时移除。只接受 +精确的 `http://127.0.0.1:` 或 `http://[::1]:` origin。包装器为提交保留 750 ms,并忽略 +传输失败,因此 Workbench 观察绝不会改变钩子结果。 + +收据与内核条目在时间线中的呈现方式见 [Trace](../guide/development/workbench.mdx#trace)。 diff --git a/website/docs/zh/reference/index.mdx b/website/docs/zh/reference/index.mdx index 484a679a7..c470afb57 100644 --- a/website/docs/zh/reference/index.mdx +++ b/website/docs/zh/reference/index.mdx @@ -19,6 +19,7 @@ description: 'agent-bundle 参考资料:命令行表面、配置字段、targe | [事件与钩子矩阵](./events.md) | 各宿主的规范事件到原生事件、工具选择器到原生匹配器、被推迟的原生事件。构建时生成。 | | [通知投递矩阵](./notices.md) | 每个宿主支持哪些通知通道,其余通道为何不可用。构建时生成。 | | [诊断参考](./diagnostics.md) | 每个 `AB` 代码族、触发条件、严重级别与恢复提示。构建时从仓库契约生成。 | +| [开发服务器 HTTP](./dev-server-http.mdx) | 浏览器侧调用、Trace、Raw-log 与宿主钩子收据路由及其 wire 形状。 | | [运行时环境](./runtime-environment.mdx) | Node 版本下限、路径 token、环境变量、`.env` 分层与持久状态位置。 | | [安全](./security.mdx) | 凭据、网络与信任边界。 | | [已知限制](./limitations.mdx) | 框架目前不做什么、不能证明什么。 | diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index d36f7875f..ab417ed3b 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -41,6 +41,8 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的事件路由工作区会设置它。 | +| `AGENT_BUNDLE_DEV_TRACE_URL` | 开发环境中的生成式钩子 wrapper | 提交不含载荷的钩子 Trace 收据所使用的内部 loopback origin。前台服务器为模拟调用设置它;由宿主调用的开发包装器通常从开发安装标记发现同一端点。 | +| `AGENT_BUNDLE_DEV_TRACE_TOKEN` | 开发环境中的生成式钩子 wrapper | 与 `AGENT_BUNDLE_DEV_TRACE_URL` 配对的内部 bearer token。它认证 `POST /api/trace/receipts`,不得记录日志,也不得持久化到私有开发端点记录之外。 | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | 贡献者测试套件 | `1` 用于比对已安装宿主 CLI 的契约。 | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Claude 原生冒烟测试。 | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Codex 原生冒烟测试。 |