From 6d155f3709e8f824c436e112cbd854ab0cb51719 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:08:06 +0000 Subject: [PATCH 01/70] feat(dev): trace entry contract and bounded TraceHub for the unified Workbench trace (#600 PR 2 foundation) --- packages/agent-bundle/src/contracts/trace.ts | 16 ++ .../agent-bundle/src/dev/trace/trace-entry.ts | 103 ++++++++++++ .../agent-bundle/src/dev/trace/trace-hub.ts | 154 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 packages/agent-bundle/src/contracts/trace.ts create mode 100644 packages/agent-bundle/src/dev/trace/trace-entry.ts create mode 100644 packages/agent-bundle/src/dev/trace/trace-hub.ts 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/dev/trace/trace-entry.ts b/packages/agent-bundle/src/dev/trace/trace-entry.ts new file mode 100644 index 000000000..5cf742d16 --- /dev/null +++ b/packages/agent-bundle/src/dev/trace/trace-entry.ts @@ -0,0 +1,103 @@ +import type { JsonValue } from '../../core/strict-json.ts'; + +/** + * One entry on the Workbench's unified live trace (#600 PR 2): the correlated + * timeline of everything the dev server observes the application doing. + * Browser-safe and runtime-free; the Workbench and every server-side publisher + * share it through `contracts/trace.ts`. + * + * Each publisher lowers its own record into this shape and keeps its full + * record behind `href` (the Workbench path that opens it). The trace never + * becomes a second copy of an invocation, an MCP frame, or a log line. + */ + +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', + /** `devRuntime` provider runs and generations. */ + 'runtime', + /** 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; + /** `devRuntime` run id. */ + readonly runId?: 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..344131abc --- /dev/null +++ b/packages/agent-bundle/src/dev/trace/trace-hub.ts @@ -0,0 +1,154 @@ +import { deepFreeze } from '../../core/freeze.ts'; +import type { TraceEntry, TraceEntryInput, TraceMessage, 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 entryLimit?: number; + readonly now?: () => Date; +} + +export type TraceHubErrorCode = 'TRACE_CURSOR_AHEAD' | '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; + listener: TraceListener; +} + +const defaultEntryLimit = 4_096; +const maxSummaryLength = 240; + +/** + * 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 #entries: TraceEntry[] = []; + readonly #entryLimit: number; + readonly #now: () => Date; + readonly #subscriptions = new Set(); + #closed = false; + #sequence = 0; + + constructor(options: TraceHubOptions = {}) { + this.#entryLimit = options.entryLimit ?? defaultEntryLimit; + this.#now = options.now ?? (() => new Date()); + } + + get closed(): boolean { + return this.#closed; + } + + get latestSequence(): number { + return this.#sequence; + } + + publish(input: TraceEntryInput): TraceEntry { + if (this.#closed) throw new TraceHubError('TRACE_HUB_CLOSED', 'The trace hub is closed.'); + this.#sequence += 1; + const entry = deepFreeze({ + ...input, + id: `trc_${this.#sequence}`, + occurredAt: input.occurredAt ?? this.#now().toISOString(), + sequence: this.#sequence, + summary: input.summary.length <= maxSummaryLength ? input.summary : `${input.summary.slice(0, maxSummaryLength - 1)}…`, + }); + this.#entries.push(entry); + if (this.#entries.length > this.#entryLimit) this.#entries.splice(0, this.#entries.length - this.#entryLimit); + for (const subscription of this.#subscriptions) this.#deliver(subscription, entry); + return entry; + } + + replay(options: TraceSubscribeOptions = {}): TraceReplay { + const after = options.afterSequence ?? 0; + if (after > this.#sequence) { + throw new TraceHubError('TRACE_CURSOR_AHEAD', `Trace cursor ${after} is ahead of the latest sequence ${this.#sequence}.`); + } + const first = this.#entries[0]; + const gap = first !== undefined && after + 1 < first.sequence + ? deepFreeze({ + droppedCount: first.sequence - after - 1, + firstAvailableSequence: first.sequence, + requestedAfterSequence: after, + type: 'trace.gap' as const, + }) + : undefined; + 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 { + if (this.#closed) throw new TraceHubError('TRACE_HUB_CLOSED', 'The trace hub is closed.'); + const subscription: Subscription = { closed: false, listener }; + const replay = this.replay(options); + if (replay.gap !== undefined) this.#deliver(subscription, replay.gap); + for (const entry of replay.entries) { + if (subscription.closed) break; + this.#deliver(subscription, entry); + } + if (!subscription.closed) this.#subscriptions.add(subscription); + return { + close: () => { + subscription.closed = true; + this.#subscriptions.delete(subscription); + }, + get closed() { + return subscription.closed; + }, + }; + } + + close(): void { + this.#closed = true; + for (const subscription of this.#subscriptions) subscription.closed = true; + this.#subscriptions.clear(); + } + + #deliver(subscription: Subscription, message: TraceMessage): void { + if (subscription.closed) return; + let keep: boolean | void; + try { + keep = subscription.listener(message); + } catch { + keep = false; + } + if (keep === false) { + subscription.closed = true; + this.#subscriptions.delete(subscription); + } + } +} From f9b2c40b626a18ed16dd9bdea637c0f78e7a7aad Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:16:51 +0000 Subject: [PATCH 02/70] feat(workbench): correlate route workspace traces --- LANE-NOTES.md | 38 +++++ .../src/application/app-route-workspace.tsx | 16 ++- .../src/application/event-route-workspace.tsx | 7 +- .../executable-route-workspace.tsx | 16 ++- .../workbench/src/application/result-tabs.tsx | 136 ++++++++++++------ .../src/application/route-workspace.tsx | 6 +- .../src/application/runtime-backend.ts | 8 +- .../src/application/workspace-contracts.ts | 3 + .../workbench/src/application/workspace.css | 12 ++ .../workbench/tests/route-workspace.test.ts | 94 +++++++++++- .../workbench/tests/runtime-backend.test.ts | 1 + 11 files changed, 279 insertions(+), 58 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..d6e613f77 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,38 @@ +# T6 lane notes + +## Files changed + +- `packages/workbench/src/application/app-route-workspace.tsx` +- `packages/workbench/src/application/event-route-workspace.tsx` +- `packages/workbench/src/application/executable-route-workspace.tsx` +- `packages/workbench/src/application/result-tabs.tsx` +- `packages/workbench/src/application/route-workspace.tsx` +- `packages/workbench/src/application/runtime-backend.ts` +- `packages/workbench/src/application/workspace-contracts.ts` +- `packages/workbench/src/application/workspace.css` +- `packages/workbench/tests/route-workspace.test.ts` +- `packages/workbench/tests/runtime-backend.test.ts` + +## Exported API + +- `TraceTimeline` +- `appToolCallRequest` +- `newCorrelationId` +- `RouteWorkspaceProps.trace?: TraceClient` + +## Cross-lane requests + +- T5: replace the `packages/workbench/src/trace/trace-client.ts` stub with the real client, construct it in `main.tsx`, and pass the exact prop `trace: TraceClient` through `ApplicationExplorer` to `RouteWorkspace`. +- T3: preserve `_meta['agent-bundle/correlationId']` from App workspace `callTool` requests through the MCP session client and session service. +- T2: if `RouteInvocationRequest.requestId` lands, no application controller change is needed because the draft spread preserves it; ensure invocation decoding and persistence echo it. + +## Open risks + +- The lane keeps `RouteWorkspaceProps.trace` optional so it compiles before T5's `main.tsx` wiring lands. Without that integration prop, the Trace result tab remains in its loading state. +- The App correlation token is stamped on the MCP request but is not currently displayed in the App workspace. + +## Changeset and diagnostics + +- No changeset: `packages/workbench` is private. +- No new diagnostics. +- Proposed changeset line: none. diff --git a/packages/workbench/src/application/app-route-workspace.tsx b/packages/workbench/src/application/app-route-workspace.tsx index a24dc57a9..a86171c35 100644 --- a/packages/workbench/src/application/app-route-workspace.tsx +++ b/packages/workbench/src/application/app-route-workspace.tsx @@ -21,7 +21,7 @@ import { createMcpSessionController, type McpSessionController } from '../mcp/mc 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,17 @@ export const orderedToolsForApp = (tools: readonly McpCatalogTool[], resourceUri ...tools.filter((tool) => resourceUri === undefined || tool.resourceUri !== resourceUri), ]); +/** MCP tool params carrying the Workbench correlation key understood by the session service. */ +export const appToolCallRequest = ( + name: string, + input: JsonObject, + correlationId: string, +): Readonly> => Object.freeze({ + _meta: Object.freeze({ 'agent-bundle/correlationId': correlationId }), + arguments: input, + name, +}); + interface ToolCall { readonly input: JsonObject; readonly result: McpAppJsonValue; @@ -138,10 +149,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 }, + request: 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/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx index b1828a761..0fa673228 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 { displayAgentDocumentValue } from './rendered-document.tsx'; @@ -198,9 +199,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 => { @@ -218,7 +221,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]); @@ -275,11 +278,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 4aa920fb2..5d9a2b792 100644 --- a/packages/workbench/src/application/executable-route-workspace.tsx +++ b/packages/workbench/src/application/executable-route-workspace.tsx @@ -11,6 +11,7 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics import type { RouteInvocationRequest, RouteInvocationSummary } 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 { @@ -52,7 +53,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() @@ -199,11 +200,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) => RouteInvocationDraft; readonly tab?: string; + readonly trace?: TraceClient; /** Rendered between the header and the editor (the event host selector). */ readonly toolbar?: React.ReactNode; } @@ -251,11 +255,13 @@ export const ExecutableRouteWorkspace = ({ fixtures, inputKey, inputLeaf, + invocationId, leaf, onNavigate, requestFor, tab, toolbar, + trace, }: ExecutableRouteWorkspaceProps): React.ReactNode => { const editorLeaf = inputLeaf ?? leaf; const storageKey = inputKey ?? leaf.key; @@ -298,6 +304,7 @@ export const ExecutableRouteWorkspace = ({ }; const failed = controller.state.phase === 'failed' ? controller.state : undefined; + const missingDeepLink = invocationId !== undefined && failed?.failure?.code === 'AB8231'; return
@@ -316,7 +323,12 @@ export const ExecutableRouteWorkspace = ({ {failed === undefined || (failed.diagnostics.length === 0 && failed.failure === undefined) ? undefined : } - + {missingDeepLink + ?
+

Invocation not in this session

+

Invocation {invocationId} is not in this session.

+
+ : }
void; + readonly onNavigate?: (location: WorkbenchLocation) => void; readonly onTabChange: (tab: WorkspaceResultTab) => void; readonly tab: WorkspaceResultTab; + readonly trace?: TraceClient; } const coreTabLabels: Readonly> = Object.freeze({ @@ -48,11 +54,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` : '—'; -}; - const StructuredResult = ({ invocation }: { readonly invocation?: RouteInvocation }): React.ReactNode => { if (invocation === undefined) return

Run the route to see its structured result.

; if (invocation.result !== undefined) return
{displayAgentDocumentValue(invocation.result)}
; @@ -107,37 +108,89 @@ 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 }: { readonly entry: TraceEntry }): React.ReactNode =>
  3. + + + {entry.kind.replaceAll('.', ' · ')} + {entry.summary} + {entry.durationMs === undefined ? '—' : `${String(entry.durationMs)} ms`} + +
  4. ; + +export const TraceTimeline = ({ correlationId, entries, invocationId }: { + readonly correlationId?: string; + readonly entries: readonly TraceEntry[]; + readonly invocationId: string; +}): 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, onTabChange, tab, trace }: ResultTabsProps): React.ReactNode => { const invocation = invocationOf(controller.state); const running = controller.state.phase === 'running'; + const traceState = useTraceEntries(trace); const definitions: readonly ResultTabDefinition[] = [ { id: 'rendered', label: coreTabLabels.rendered, 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.

    + : 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 : }
    {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/runtime-backend.ts b/packages/workbench/src/application/runtime-backend.ts index 5a8b36c95..4a1fa8cd5 100644 --- a/packages/workbench/src/application/runtime-backend.ts +++ b/packages/workbench/src/application/runtime-backend.ts @@ -21,7 +21,7 @@ import { InvocationClientError } from './invocation-client.ts'; import { invocationSummaryOf } from './invocation-model.ts'; export interface RuntimeInvocationClient { - createRun(request: DevRuntimeInvocationRequest): Promise; + createRun(request: DevRuntimeInvocationRequest & Readonly<{ readonly correlationId?: string }>): Promise; readRun(runId: string): Promise; readRunDocument( runId: string, @@ -305,7 +305,8 @@ export const createRuntimeBackend = ({ ); } leafBySurfaceId.set(surface.id, leaf); - const run = await runtimeClient.createRun(Object.freeze({ + const runtimeRequest = Object.freeze({ + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), ...(controller.model.status?.activeVector === undefined ? {} : { @@ -315,7 +316,8 @@ export const createRuntimeBackend = ({ input: request.input ?? Object.freeze({}), surfaceId: surface.id, target, - })); + }) satisfies DevRuntimeInvocationRequest & Readonly<{ readonly correlationId?: string }>; + const run = await runtimeClient.createRun(runtimeRequest); abortIfRequested(signal); if (request.correlationId !== undefined) { correlationByRunId.set(run.id, request.correlationId); diff --git a/packages/workbench/src/application/workspace-contracts.ts b/packages/workbench/src/application/workspace-contracts.ts index 63346605b..4eb031b42 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; } diff --git a/packages/workbench/src/application/workspace.css b/packages/workbench/src/application/workspace.css index 20b0bc5bd..644a1ff58 100644 --- a/packages/workbench/src/application/workspace.css +++ b/packages/workbench/src/application/workspace.css @@ -59,6 +59,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; } @@ -75,6 +77,13 @@ .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 { 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: grid; font-size: 12px; gap: 12px; grid-template-columns: 88px 90px 70px auto minmax(0, 1fr); 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; } @@ -83,6 +92,9 @@ .result-trace-status--failed { color: #b31b23; } .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-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/tests/route-workspace.test.ts b/packages/workbench/tests/route-workspace.test.ts index 123b61361..d55cc9600 100644 --- a/packages/workbench/tests/route-workspace.test.ts +++ b/packages/workbench/tests/route-workspace.test.ts @@ -3,10 +3,11 @@ 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 { 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'; @@ -243,10 +244,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', () => { @@ -263,6 +320,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, { @@ -332,4 +406,12 @@ 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('stamps App tool calls with the browser correlation id', () => { + expect(appToolCallRequest('browse_library', { query: 'Dune' }, 'corr-app')).toEqual({ + _meta: { 'agent-bundle/correlationId': 'corr-app' }, + arguments: { query: 'Dune' }, + name: 'browse_library', + }); + }); }); diff --git a/packages/workbench/tests/runtime-backend.test.ts b/packages/workbench/tests/runtime-backend.test.ts index 0e6091e59..2eab082e9 100644 --- a/packages/workbench/tests/runtime-backend.test.ts +++ b/packages/workbench/tests/runtime-backend.test.ts @@ -120,6 +120,7 @@ it('matches runtime surfaces and maps a completed run into the shared invocation }); expect(setup.requests).toEqual([{ + correlationId: 'correlation-a', expectedGenerationId: 'generation-a', input: { title: 'Dune' }, surfaceId: surface.id, From 8d07e96522726a90372a9c58cf538503fd984982 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:21:57 +0000 Subject: [PATCH 03/70] feat(dev): fan runtime and logs into trace --- LANE-NOTES.md | 86 +++++++++++ .../src/dev/logs/dev-log-kinds.ts | 7 + .../src/dev/logs/dev-log-producers.ts | 11 ++ .../src/dev/logs/dev-log-service.ts | 64 +++++++- .../src/dev/runtime-controller.ts | 137 ++++++++++++++++-- .../agent-bundle/src/dev/runtime-protocol.ts | 3 + .../agent-bundle/src/dev/runtime-routes.ts | 6 +- .../tests/dev-log-producers.test.ts | 37 +++++ .../tests/dev-log-service.test.ts | 88 ++++++++++- .../tests/runtime-provider.test.ts | 102 +++++++++++++ .../agent-bundle/tests/runtime-routes.test.ts | 2 + packages/workbench/src/logs/logs-page.tsx | 31 ++-- packages/workbench/tests/logs-page.test.ts | 19 ++- 13 files changed, 564 insertions(+), 29 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..15d5730c8 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,86 @@ +# T4 — devRuntime and Dev Log trace fan-in + +## Files changed + +- `packages/agent-bundle/src/dev/runtime-controller.ts` +- `packages/agent-bundle/src/dev/runtime-protocol.ts` +- `packages/agent-bundle/src/dev/runtime-routes.ts` +- `packages/agent-bundle/src/dev/logs/dev-log-kinds.ts` +- `packages/agent-bundle/src/dev/logs/dev-log-producers.ts` +- `packages/agent-bundle/src/dev/logs/dev-log-service.ts` +- `packages/agent-bundle/tests/runtime-provider.test.ts` +- `packages/agent-bundle/tests/runtime-routes.test.ts` +- `packages/agent-bundle/tests/dev-log-producers.test.ts` +- `packages/agent-bundle/tests/dev-log-service.test.ts` +- `packages/workbench/src/logs/logs-page.tsx` +- `packages/workbench/tests/logs-page.test.ts` + +## Exported API and behavior + +- `DevRuntimeControllerOptions` and `DevLogServiceOptions` accept + `readonly trace?: TracePublisher`. +- `DevRuntimeInvocationRequest` accepts the optional browser-minted field + `correlationId`. +- `DevRuntimeSurface` accepts optional `routeId`, the compiled route represented + by that provider surface. Runtime trace links require this value. +- Runtime run, generation, and App update events lower into the shared trace. + Run events carry all available run/correlation/session/epoch fields and link + to the route invocation. Generation compiling/activated/failed events use + `runtime.generation.published` with running/ok/error status. +- Dev Logs retain the raw, redacted stream. Warning/error records and records + carrying trace correlation publish `log..` entries. Ordinary + uncorrelated info records do not. +- `route.invocation` project logs now carry `invocationId`, `correlationId`, + and `routeId`. + +## Raw logs decision + +Keep **Advanced → Raw logs** as the complete redacted producer firehose and +retain its generic producer/level/kind/context filters. It remains useful for +uncorrelated framework chatter and record details that the intentionally slim +trace does not copy. Do not add a competing per-invocation timeline to this +page; Trace owns that workflow. + +A row carrying one of the browser join keys links to Trace as: + +`/trace?correlation=` + +The precedence is `correlationId`, then `invocationId`, then `mcpSessionId`. +Rows without one of those keys remain raw-log-only. + +## Cross-lane requests + +- **T1 / integrator:** pass the same `TracePublisher` as `trace` to both + `new DevRuntimeController(...)` and `new DevLogService(...)` in + `workbench-server.ts`. +- **T6:** in `packages/workbench/src/application/runtime-backend.ts`, pass the + browser-minted id on `DevRuntimeInvocationRequest.correlationId` when calling + `runtimeClient.createRun`. +- **T5:** parse the Raw logs link using exactly the `correlation` query key: + `/trace?correlation=`. +- **Integrator:** provider surfaces must populate `DevRuntimeSurface.routeId` + for runtime entries to receive `routeId` and `href`. The example + `examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts` currently + emits only provider-local ids (`hook.` / `mcp.`); wire its known + application route ids when integrating this lane. +- **Docs lane:** state the Raw logs decision above: Trace owns correlated + per-invocation timelines; Raw logs remains the complete redacted firehose and + links correlated rows into Trace. + +## Open risks + +- Existing third-party runtime providers that omit the new optional + `DevRuntimeSurface.routeId` still publish runtime trace entries, but those + entries cannot carry a route deep link. +- `runtime.app.updated` currently has an MCP session identity but no run id in + the existing producer event. It therefore cannot link to a run unless that + producer later supplies `runId`. + +## Changeset and diagnostics + +Proposed patch changeset: + +> Publish correlated devRuntime and Dev Log activity to the Workbench trace +> (#PR) + +No diagnostic codes added. 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..440e27a56 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'; @@ -219,6 +226,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 +239,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 +250,30 @@ 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.runId === undefined ? {} : { runId: context.runId }), + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), +}); + +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 ?? record.context.runId; + 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 +291,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 +314,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 +349,7 @@ export class DevLogService { } if (byteLength(record) > this.#recordByteLimit) return undefined; this.#retain(record); + this.#publishTrace(record); return record; } catch { return undefined; @@ -393,6 +433,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) return; + const correlation = traceCorrelationFor(record.context); + if (record.level !== 'warning' && record.level !== 'error' && Object.keys(correlation).length === 0) 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/runtime-controller.ts b/packages/agent-bundle/src/dev/runtime-controller.ts index 25f0a9728..0a16393ce 100644 --- a/packages/agent-bundle/src/dev/runtime-controller.ts +++ b/packages/agent-bundle/src/dev/runtime-controller.ts @@ -1,8 +1,15 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; import { isRecord } from '../core/strict-json.ts'; +import { + applicationNodePath, + applicationNodeRefForRouteId, +} from './routes/application-node.ts'; +import type { TraceStatus } from './trace/trace-entry.ts'; +import type { TracePublisher } from './trace/trace-hub.ts'; import type { ArtifactStatus, JsonObject, JsonValue, RuntimeEvent } from './types.ts'; import { DevRuntimeUnavailableError, @@ -264,11 +271,11 @@ const snapshotFixture = (value: unknown): DevRuntimeSurface['fixtures'][number] }; const snapshotSurface = (value: unknown): DevRuntimeSurface => { - const surface = exactRecord( - value, - ['fixtures', 'id', 'kind', 'label', 'readOnly', 'targets'], - ['defaultTarget', 'inputSchema'], - ); + const surface = exactRecord(value, ['fixtures', 'id', 'kind', 'label', 'readOnly', 'targets'], [ + 'defaultTarget', + 'inputSchema', + 'routeId', + ]); const kind = ownDataValue(surface, 'kind'); const readOnly = ownDataValue(surface, 'readOnly'); if (typeof kind !== 'string' || !surfaceKinds.has(kind as DevRuntimeSurface['kind']) || typeof readOnly !== 'boolean') return snapshotInvalid(); @@ -280,6 +287,9 @@ const snapshotSurface = (value: unknown): DevRuntimeSurface => { : undefined; if (inputSchemaValue !== undefined && !isRecord(inputSchemaValue)) return snapshotInvalid(); const inputSchema = inputSchemaValue as JsonObject | undefined; + const routeId = Object.hasOwn(surface, 'routeId') + ? snapshotString(ownDataValue(surface, 'routeId')) + : undefined; return Object.freeze({ ...(defaultTarget === undefined ? {} : { defaultTarget }), fixtures: Object.freeze(snapshotArray(ownDataValue(surface, 'fixtures')).map(snapshotFixture)), @@ -288,6 +298,7 @@ const snapshotSurface = (value: unknown): DevRuntimeSurface => { kind: kind as DevRuntimeSurface['kind'], label: snapshotString(ownDataValue(surface, 'label')), readOnly, + ...(routeId === undefined ? {} : { routeId }), targets: snapshotStrings(ownDataValue(surface, 'targets')), }); }; @@ -312,8 +323,55 @@ export interface DevRuntimeControllerOptions { readonly providerSessionId?: string; readonly startupTimeoutMs?: number; readonly storageRoot: string; + readonly trace?: TracePublisher; +} + +interface RuntimeInvocationContext { + readonly correlationId?: string; + readonly surfaceId: string; } +const runtimeTraceKind = ( + type: DevRuntimeEventInput['type'], +): Readonly<{ readonly kind: string; readonly status: TraceStatus }> | undefined => { + switch (type) { + case 'runtime.run.started': + return Object.freeze({ kind: type, status: 'running' }); + case 'runtime.run.completed': + return Object.freeze({ kind: type, status: 'ok' }); + case 'runtime.run.failed': + return Object.freeze({ kind: type, status: 'error' }); + case 'runtime.generation.compiling': + return Object.freeze({ kind: 'runtime.generation.published', status: 'running' }); + case 'runtime.generation.activated': + return Object.freeze({ kind: 'runtime.generation.published', status: 'ok' }); + case 'runtime.generation.failed': + return Object.freeze({ kind: 'runtime.generation.published', status: 'error' }); + case 'runtime.app.updated': + return Object.freeze({ kind: type, status: 'ok' }); + case 'runtime.status': + case 'runtime.mcp.restarting': + case 'runtime.mcp.ready': + case 'runtime.mcp.failed': + case 'runtime.hmr.client-connected': + case 'runtime.hmr.client-disconnected': + return undefined; + default: { + const exhaustive: never = type; + return exhaustive; + } + } +}; + +const runtimeDurationMs = (run: DevRuntimeRun | undefined): number | undefined => { + if (run?.status !== 'succeeded') return undefined; + const durations = run.result.trace.flatMap((span) => + span.durationMs === undefined ? [] : [span.durationMs]); + return durations.length === 0 + ? undefined + : durations.reduce((total, duration) => total + duration, 0); +}; + /** * Workbench-owned adapter around one optional trusted runtime provider. It owns * the stable controller identity and keeps provider start/reconcile failures @@ -324,6 +382,7 @@ export class DevRuntimeController implements DevRuntimeSession { readonly #emit: (event: RuntimeEvent) => void; readonly #environment: Readonly>; readonly #initialProviderPath: string; + readonly #invocationContext = new AsyncLocalStorage(); readonly #mcpRegistry: DevRuntimeMcpRegistry; readonly #projectRoot: string; readonly #provider: DevRuntimeProvider | undefined; @@ -331,6 +390,7 @@ export class DevRuntimeController implements DevRuntimeSession { readonly #sessionClosures = new WeakMap>(); readonly #startupTimeoutMs: number; readonly #storageRoot: string; + readonly #trace: TracePublisher | undefined; #bufferedPrepared: DevRuntimePreparedProject; #bufferedStartupEvents: readonly DevRuntimeEventInput[] = Object.freeze([]); #closePromise: Promise | undefined; @@ -361,6 +421,7 @@ export class DevRuntimeController implements DevRuntimeSession { this.#providerSessionId = options.providerSessionId ?? randomUUID(); this.#startupTimeoutMs = options.startupTimeoutMs ?? defaultStartupTimeoutMs; this.#storageRoot = resolve(options.storageRoot, this.#providerSessionId); + this.#trace = options.trace; this.#status = options.provider === undefined ? statusFor(unavailableDescriptor, 'failed', [lifecycleDiagnostic()]) : statusFor(options.provider.descriptor, 'starting'); @@ -390,7 +451,13 @@ export class DevRuntimeController implements DevRuntimeSession { } invoke(request: DevRuntimeInvocationRequest): Promise { - return this.#activeSession().invoke(request); + return this.#invocationContext.run( + Object.freeze({ + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), + surfaceId: request.surfaceId, + }), + () => this.#activeSession().invoke(request), + ); } readAsset(request: DevRuntimeAssetRequest): Promise { @@ -679,11 +746,63 @@ export class DevRuntimeController implements DevRuntimeSession { } if (!this.#refreshingSnapshot) this.#refreshSnapshot(); } + this.#notify(event); + } + + #notify(event: DevRuntimeEventInput): void { try { this.#emit(runtimeEvent(this.#providerSessionId, event)); } catch { // Provider health must not depend on a failed observer. } + this.#publishTrace(event); + } + + #publishTrace(event: DevRuntimeEventInput): void { + const trace = this.#trace; + const lowered = runtimeTraceKind(event.type); + if (trace === undefined || lowered === undefined) return; + const invocation = this.#invocationContext.getStore(); + let run: DevRuntimeRun | undefined; + if (event.runId !== undefined) { + try { run = this.#session?.run(event.runId); } + catch { run = undefined; } + } + const surfaceId = run?.surfaceId ?? invocation?.surfaceId; + const routeId = surfaceId === undefined + ? undefined + : this.#surfaces.find((surface) => surface.id === surfaceId)?.routeId; + const node = routeId === undefined ? undefined : applicationNodeRefForRouteId(routeId); + const durationMs = runtimeDurationMs(run); + const epochId = run?.vector.artifactEpochId ?? this.#status.activeVector?.artifactEpochId; + const correlationId = event.correlationId ?? invocation?.correlationId; + const occurredAt = event.type === 'runtime.run.started' + ? run?.startedAt + : event.type === 'runtime.run.completed' || event.type === 'runtime.run.failed' + ? run?.completedAt + : undefined; + try { + trace.publish({ + correlation: Object.freeze({ + ...(correlationId === undefined ? {} : { correlationId }), + ...(epochId === undefined ? {} : { epochId }), + ...(event.mcpSessionId === undefined ? {} : { mcpSessionId: event.mcpSessionId }), + ...(routeId === undefined ? {} : { routeId }), + ...(event.runId === undefined ? {} : { runId: event.runId }), + }), + ...(durationMs === undefined ? {} : { durationMs }), + ...(node === undefined || event.runId === undefined + ? {} + : { href: `${applicationNodePath(node)}?invocation=${encodeURIComponent(event.runId)}` }), + kind: lowered.kind, + ...(occurredAt === undefined ? {} : { occurredAt }), + source: 'runtime', + status: lowered.status, + summary: event.type.replaceAll('.', ' '), + }); + } catch { + // Provider health must not depend on a failed trace observer. + } } /** Coalesces startup lifecycle events until browser-visible snapshots are installed. */ @@ -699,11 +818,7 @@ export class DevRuntimeController implements DevRuntimeSession { this.#bufferedStartupEvents = Object.freeze([]); for (const event of events) { if (this.#closed || this.#topologyFailed || this.#session === undefined || !this.#refreshSnapshot()) return; - try { - this.#emit(runtimeEvent(this.#providerSessionId, event)); - } catch { - // Provider health must not depend on a failed observer. - } + this.#notify(event); } } diff --git a/packages/agent-bundle/src/dev/runtime-protocol.ts b/packages/agent-bundle/src/dev/runtime-protocol.ts index 43f9188f6..929764f8b 100644 --- a/packages/agent-bundle/src/dev/runtime-protocol.ts +++ b/packages/agent-bundle/src/dev/runtime-protocol.ts @@ -55,6 +55,8 @@ export interface DevRuntimeSurface { readonly kind: 'hook' | 'mcp-tool' | 'mcp-resource' | 'mcp-app'; readonly label: string; readonly readOnly: boolean; + /** Compiled application route represented by this provider surface. */ + readonly routeId?: string; readonly targets: readonly string[]; } @@ -142,6 +144,7 @@ export type DevRuntimeStatus = Readonly<{ }>; export interface DevRuntimeInvocationRequest { + readonly correlationId?: string; readonly expectedGenerationId?: string; readonly fixtureId?: string; readonly input: JsonValue; diff --git a/packages/agent-bundle/src/dev/runtime-routes.ts b/packages/agent-bundle/src/dev/runtime-routes.ts index 90b1f3f82..2b8d62188 100644 --- a/packages/agent-bundle/src/dev/runtime-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-routes.ts @@ -168,10 +168,11 @@ const jsonValue = (value: unknown): JsonValue => { }; const invocation = (value: Record, surfaces: readonly DevRuntimeSurface[]): DevRuntimeInvocationRequest => { - if (!hasOnly(value, ['expectedGenerationId', 'fixtureId', 'input', 'surfaceId', 'target'])) return invalidShape(); - const { expectedGenerationId, fixtureId, input, surfaceId, target } = value; + if (!hasOnly(value, ['correlationId', 'expectedGenerationId', 'fixtureId', 'input', 'surfaceId', 'target'])) return invalidShape(); + const { correlationId, expectedGenerationId, fixtureId, input, surfaceId, target } = value; if ( !nonemptyString(surfaceId) || !nonemptyString(target) || input === undefined || + (correlationId !== undefined && !nonemptyString(correlationId)) || (expectedGenerationId !== undefined && !nonemptyString(expectedGenerationId)) || (fixtureId !== undefined && !nonemptyString(fixtureId)) ) return invalidShape(); @@ -179,6 +180,7 @@ const invocation = (value: Record, surfaces: readonly DevRuntim if (surface === undefined || !surface.targets.includes(target)) return invalidShape(); if (fixtureId !== undefined && !surface.fixtures.some((fixture) => fixture.id === fixtureId)) return invalidShape(); return Object.freeze({ + ...(correlationId === undefined ? {} : { correlationId: correlationId as string }), ...(expectedGenerationId === undefined ? {} : { expectedGenerationId: expectedGenerationId as string }), ...(fixtureId === undefined ? {} : { fixtureId: fixtureId as string }), input: jsonValue(input), diff --git a/packages/agent-bundle/tests/dev-log-producers.test.ts b/packages/agent-bundle/tests/dev-log-producers.test.ts index df7da1891..4aedf2843 100644 --- a/packages/agent-bundle/tests/dev-log-producers.test.ts +++ b/packages/agent-bundle/tests/dev-log-producers.test.ts @@ -53,3 +53,40 @@ 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', + routeId: 'tool:curator/search', + source: 'src/tools/search.tsx', + sourceRevision: 'source-1', + startedAt: '2026-08-18T12:00:00.000Z', + status: 'succeeded', + 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..d55fcf573 100644 --- a/packages/agent-bundle/tests/dev-log-service.test.ts +++ b/packages/agent-bundle/tests/dev-log-service.test.ts @@ -2,7 +2,12 @@ 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, + type DevLogInput, + type DevLogServiceOptions, +} from '../src/dev/logs/dev-log-service.ts'; +import { TraceHub } from '../src/dev/trace/trace-hub.ts'; it('records detached redacted details and replaces its own project root', () => { const service = new DevLogService({ @@ -38,6 +43,87 @@ 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(); + 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: { + conversationId: 'conversation-1', + correlationId: 'correlation-1', + executionId: 'execution-1', + mcpSessionId: 'mcp-session-1', + requestId: 'request-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.', + }); + + expect(trace.replay().entries).toMatchObject([ + { + correlation: { + conversationId: 'conversation-1', + correlationId: 'correlation-1', + executionId: 'execution-1', + mcpSessionId: 'mcp-session-1', + requestId: 'request-1', + }, + href: '/advanced/logs?sequence=2', + kind: 'log.project.project.prepared', + source: 'log', + summary: 'Correlated project event.', + }, + { + correlation: {}, + href: '/advanced/logs?sequence=3', + kind: 'log.mcp.mcp.stderr', + source: 'log', + summary: 'Uncorrelated warning.', + }, + { + correlation: { + invocationId: 'invocation-1', + mcpRequestId: 'request-1', + routeId: 'tool:curator/search', + }, + href: '/routes/mcp/curator/tool/search?invocation=invocation-1', + kind: 'log.project.route.invocation', + source: 'log', + status: 'error', + summary: 'Route failed.', + }, + ]); +}); + 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/runtime-provider.test.ts b/packages/agent-bundle/tests/runtime-provider.test.ts index f02986043..f7d7157f6 100644 --- a/packages/agent-bundle/tests/runtime-provider.test.ts +++ b/packages/agent-bundle/tests/runtime-provider.test.ts @@ -22,6 +22,7 @@ import { DevRuntimeProviderLoadError, resolveDevRuntimeProvider, } from '../src/dev/runtime-provider-loader.ts'; +import { TraceHub } from '../src/dev/trace/trace-hub.ts'; const createProviderFixture = async (): Promise<{ readonly provider: string; @@ -426,6 +427,101 @@ it('refreshes terminal run snapshots before completed or failed events without r await controller.close(); }); +it('publishes correlated runtime lifecycle entries from the run surface and inspection envelope', async () => { + const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; + const trace = new TraceHub({ now: () => new Date('2026-08-15T00:00:02.000Z') }); + let emit: Parameters[0]['emit'] | undefined; + const tracedSurface = { ...surface, routeId: 'event:tool/after' }; + const tracedRun = { + ...run, + result: { + ...run.result, + trace: [ + { durationMs: 2, id: 'render', phase: 'render', startedAt: run.startedAt, status: 'succeeded' as const }, + { durationMs: 3, id: 'lower', phase: 'lower', startedAt: run.startedAt, status: 'succeeded' as const }, + ], + }, + vector: { ...run.vector, artifactEpochId: 'epoch-a' }, + } satisfies DevRuntimeRun; + const session = { + close: async () => undefined, + invoke: async () => { + emit?.({ + mcpSessionId: 'mcp-a', + runId: tracedRun.id, + type: 'runtime.run.completed', + }); + return tracedRun; + }, + mcpRegistry: {}, + reconcilePreparedRuntime: async () => undefined, + run: (runId: string) => runId === tracedRun.id ? tracedRun : undefined, + status: () => ({ activeVector: tracedRun.vector, descriptor, diagnostics: [], hmrReady: true, state: 'active' as const }), + surfaces: () => [tracedSurface], + } as unknown as DevRuntimeSession; + const controller = new DevRuntimeController({ + artifactStatus: () => ({ state: 'missing' }), + emit: () => undefined, + environment: {}, + preparedRuntime: { apps: [], provider: './src/dev/provider.ts', servers: [], sourceRevision: 'source-1' }, + projectRoot: '/workspace/project', + provider: { + descriptor, + start: async (context) => { + emit = context.emit; + return session; + }, + }, + storageRoot: '/workspace/project/.agent-bundle/runtime', + trace, + }); + + await controller.start(); + await controller.invoke({ + correlationId: 'correlation-a', + input: {}, + surfaceId: tracedSurface.id, + target: 'claude', + }); + emit?.({ runtimeGenerationId: 'generation-a', type: 'runtime.generation.activated' }); + emit?.({ runId: tracedRun.id, type: 'runtime.app.updated' }); + + expect(trace.replay().entries).toMatchObject([ + { + correlation: { + correlationId: 'correlation-a', + epochId: 'epoch-a', + mcpSessionId: 'mcp-a', + routeId: 'event:tool/after', + runId: 'run-a', + }, + durationMs: 5, + href: '/routes/events/tool/after?invocation=run-a', + kind: 'runtime.run.completed', + source: 'runtime', + status: 'ok', + }, + { + correlation: { epochId: 'epoch-a' }, + kind: 'runtime.generation.published', + source: 'runtime', + status: 'ok', + }, + { + correlation: { + epochId: 'epoch-a', + routeId: 'event:tool/after', + runId: 'run-a', + }, + href: '/routes/events/tool/after?invocation=run-a', + kind: 'runtime.app.updated', + source: 'runtime', + status: 'ok', + }, + ]); + await controller.close(); +}); + it('does not overwrite a controller-owned lifecycle failure while publishing its status event', async () => { const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; const controller = new DevRuntimeController({ @@ -728,6 +824,7 @@ it('buffers synchronous startup failure and status until controller snapshots in it('buffers synchronous startup activation until controller snapshots install', async () => { const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; const seen: Array> = []; + const trace = new TraceHub(); const controller = new DevRuntimeController({ artifactStatus: () => ({ state: 'missing' }), emit: (event) => { @@ -757,6 +854,7 @@ it('buffers synchronous startup activation until controller snapshots install', }, }, storageRoot: '/workspace/project/.agent-bundle/runtime', + trace, }); await controller.start(); @@ -765,6 +863,10 @@ it('buffers synchronous startup activation until controller snapshots install', { state: 'starting', surfaceCount: 0, type: 'runtime.generation.compiling' }, { generation: vector.runtimeGenerationId, state: 'active', surfaceCount: 1, type: 'runtime.generation.activated' }, ]); + expect(trace.replay().entries).toMatchObject([ + { kind: 'runtime.generation.published', status: 'running' }, + { kind: 'runtime.generation.published', status: 'ok' }, + ]); await controller.close(); }); diff --git a/packages/agent-bundle/tests/runtime-routes.test.ts b/packages/agent-bundle/tests/runtime-routes.test.ts index 3f135e17e..65c28ab95 100644 --- a/packages/agent-bundle/tests/runtime-routes.test.ts +++ b/packages/agent-bundle/tests/runtime-routes.test.ts @@ -242,6 +242,7 @@ it('requires the foreground session capability for every runtime input, trace, a const invoked = await fetch(`${server.url}/api/runtime/runs`, { body: JSON.stringify({ + correlationId: 'browser-run-1', expectedGenerationId: 'g1', fixtureId: 'after-edit', input: { path: 'src/a.ts' }, @@ -254,6 +255,7 @@ it('requires the foreground session capability for every runtime input, trace, a expect(invoked.status).toBe(200); await expect(invoked.json()).resolves.toEqual({ run: expect.objectContaining({ surfaceId: 'hook.after-edit', target: 'claude' }) }); expect(runtime.invocations).toEqual([{ + correlationId: 'browser-run-1', expectedGenerationId: 'g1', fixtureId: 'after-edit', input: { path: 'src/a.ts' }, 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/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); +}); From ee6b59288a5a83a35050a40e6541fde8bb1bbcde Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:22:20 +0000 Subject: [PATCH 04/70] feat: publish route invocation kernel traces --- LANE-NOTES.md | 75 ++++ .../src/dev/routes/route-invocation-child.ts | 28 +- .../dev/routes/route-invocation-service.ts | 352 ++++++++++++++++- .../src/dev/routes/route-invocation.ts | 70 ++++ packages/agent-bundle/src/events/trace.ts | 48 +-- .../agent-bundle/tests/event-trace.test.ts | 15 + .../tests/route-invocation-dev-server.test.ts | 19 +- .../tests/route-invocation-service.test.ts | 372 ++++++++++++++++++ 8 files changed, 926 insertions(+), 53 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..8fbef39ab --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,75 @@ +# T2 — route invocation + execution-kernel publishing + +## Files changed + +- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation.ts` +- `packages/agent-bundle/src/events/trace.ts` +- `packages/agent-bundle/tests/event-trace.test.ts` +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` + +`route-invocation-routes.ts` remains unchanged, preserving the existing +`route.invocation` project event exactly as shipped by PR 1. + +## Exported API + +- `RouteInvocationServiceOptions.trace?: TracePublisher` +- `RouteInvocationRequest.requestId?: string` +- `RouteInvocationSummary.requestId?: string` +- `nativeEventRequestContext(...)` from `dev/routes/route-invocation.ts` + (shared server-side lowering seam; not re-exported from `contracts/invocations.ts`) + +The render-child IPC now has a `trace` message carrying one `EventTraceEvent`. + +## Cross-lane integration requests + +1. T1: pass the dev server's `TraceHub` as `trace` when constructing + `RouteInvocationService` in `workbench-server.ts`. +2. Integrator/lifecycle owner: in + `dev/playground/lifecycle-replay-service.ts`, import + `nativeEventRequestContext` from `../routes/route-invocation.ts`; replace + the `replayRequestContext(event, native, routeId, target, + hostContractRevision)` call with + `nativeEventRequestContext({ event, native, routeId, target, + hostContractRevision })`; then delete the local `nativeText`, + `replayLineage`, and `replayRequestContext` helpers. This completes the + extract-and-rewire and removes the temporary duplicate lowering. +3. Workbench invocation-client owner: allow optional `requestId` in the strict + request/summary decoders and thread it from browser invocation requests. +4. After T1's `/api/trace` route lands, extend + `route-invocation-dev-server.test.ts` to assert the HTTP replay. This lane + tests publishing through a fake `TracePublisher`; the route and a + `startDevServer` trace injection option are absent on this branch. + +## Open risks + +- `EventTraceProvidersFinish` exposes only aggregate provider duration and + count, not per-provider durations. The invocation's aggregate `providers` + timing uses the real duration, while named `providers[]` and + `provider:` timings remain `0` until the kernel exposes named + durations. +- The child observer covers Workbench route invocations. Standalone installed + host-wrapper processes still need their own runtime-to-foreground transport. + +## Verification + +- `pnpm build` +- `npx tsc --noEmit` +- `pnpm lint` +- Unit: 25 passed +- Integration: 2 passed +- Deslop: GPT-5.6 Sol, 2 edits (strict child trace decoding; removed one + unused import). + +## Changeset + +Proposed patch summary: + +> Publish correlated route invocation and execution-kernel entries to the +> Workbench trace, including native event provenance and request IDs. (#PR) + +## Diagnostics + +No new diagnostic codes. 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 85d4f8f9e..959ba42b4 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -6,6 +6,10 @@ import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; import * as React from 'react'; import type { JsonObject } from '../../core/strict-json.ts'; +import { + installEventTraceObserver, + type EventTraceEvent, +} from '../../events/trace.ts'; import { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, @@ -105,6 +109,10 @@ const respond = (response: RouteInvocationChildResponse): Promise => new P }); }); +const forwardEventTrace = (event: EventTraceEvent): void => { + process.send?.({ event, type: 'trace' } satisfies RouteInvocationChildResponse); +}; + const render = async (request: RouteInvocationChildRequest): Promise => { installManifest(request); const startedAt = performance.now(); @@ -139,15 +147,19 @@ const render = async (request: RouteInvocationChildRequest): Promise { + const disposeTraceObserver = installEventTraceObserver(forwardEventTrace); void render(request) - .then((result) => respond({ result, type: 'result' })) - .catch((error: unknown) => respond({ - error: { - 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: { + 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-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 34ef96dc5..661cd4b70 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -27,18 +27,31 @@ import type { RequestProvenanceUnavailableReason, } from '../../contracts/request-provenance.ts'; import { createCanonicalEventProps, projectEventDocument } from '../../events/projection.ts'; -import type { CanonicalAgentEvent } from '../../routes/public.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 type { RouteInvocation } from './route-invocation-result.ts'; -import type { - RouteInvocationEventHost, - RouteInvocationKind, - RouteInvocationProvider, - RouteInvocationRequest, - RouteInvocationSummary, - RouteInvocationTiming, +import { + nativeEventRequestContext, + type RouteInvocationProjection, + type RouteInvocationEventHost, + type RouteInvocationKind, + type RouteInvocationProvider, + type RouteInvocationRequest, + type RouteInvocationSummary, + type RouteInvocationTiming, } from './route-invocation.ts'; import type { RouteManifest, RouteManifestRoute } from './route-manifest.ts'; import type { RouteManifestRouteService } from './route-manifest-routes.ts'; @@ -94,9 +107,11 @@ export interface RouteInvocationServiceOptions { readonly renderChild?: ( request: RouteInvocationChildRequest, signal: AbortSignal, + publishKernelEvent: (event: EventTraceEvent) => void, ) => Promise; readonly scripts?: RouteInvocationScriptRunner; readonly timeoutMs?: number; + readonly trace?: TracePublisher; } export interface RouteInvocationChildRequest { @@ -120,6 +135,7 @@ export interface RouteInvocationChildResult { export type RouteInvocationChildResponse = | Readonly<{ readonly result: RouteInvocationChildResult; readonly type: 'result' }> + | Readonly<{ readonly event: EventTraceEvent; readonly type: 'trace' }> | Readonly<{ readonly error: Readonly<{ readonly message: string; readonly name: string }>; readonly type: 'error'; @@ -174,12 +190,14 @@ const eventOptions = (value: unknown): RouteInvocationRequest['event'] => { export const parseRouteInvocationRequest = ( value: Readonly>, ): RouteInvocationRequest => { - if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'routeId'])) return malformed(); + if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'requestId', 'routeId'])) return malformed(); const routeId = value.routeId; const correlationId = value.correlationId; + const requestId = value.requestId; const args = value.args; if (!boundedString(routeId)) return malformed(); if (correlationId !== undefined && !boundedString(correlationId, 256)) return malformed(); + if (requestId !== undefined && !boundedString(requestId, 256)) return malformed(); if (args !== undefined && (!Array.isArray(args) || args.length > 1_024 || args.some((argument) => !boundedString(argument, 16_384)))) { return malformed(); } @@ -197,6 +215,7 @@ export const parseRouteInvocationRequest = ( ...(correlationId === undefined ? {} : { correlationId }), ...(event === undefined ? {} : { event }), ...(input === undefined ? {} : { input }), + ...(requestId === undefined ? {} : { requestId }), routeId, }); }; @@ -356,9 +375,80 @@ 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 === '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'; }; @@ -402,6 +492,7 @@ const terminateChild = async (child: ChildProcess): Promise => { const renderInChild = async ( request: RouteInvocationChildRequest, signal: AbortSignal, + publishKernelEvent: (event: EventTraceEvent) => void, ): Promise => { if (signal.aborted) throw signal.reason; const executable = childPath(); @@ -436,6 +527,10 @@ 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 === 'error') { const error = new Error(message.error.message); error.name = message.error.name; @@ -448,7 +543,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); }); @@ -472,6 +567,182 @@ const eventContract = ( return Object.freeze({ contract, hostContractRevision, nativeEvent }); }; +const contextForRequest = ( + route: RouteManifestRoute, + root: string, + request: RouteInvocationRequest, + nativeInput: JsonValue, + registry: TargetRegistry, +): RequestContextProvenance => { + const host = request.event?.host; + if (route.kind !== 'event-route' || host === undefined || !isJsonRecord(nativeInput)) { + return contextFor(route, root, host); + } + const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); + if (mapped === undefined) return contextFor(route, root, host); + 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, + ...(request.requestId === undefined ? {} : { requestId: request.requestId }), + routeId: request.routeId, + ...(context.session.state === 'available' + ? { sessionId: context.session.value.sessionId } + : {}), +}); + +const projectionKind = (projection: RouteInvocationProjection): '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, @@ -626,6 +897,7 @@ const failedInvocation = (input: { manifestDigest: input.manifest.digest, projection: {}, providers: providerProjection(input.manifest, 0, 'failed'), + ...(input.request.requestId === undefined ? {} : { requestId: input.request.requestId }), routeId: input.route.id, source: input.route.source, sourceRevision: input.manifest.sourceRevision, @@ -647,6 +919,7 @@ export class RouteInvocationService { readonly #scripts: RouteInvocationScriptRunner | undefined; readonly #semaphore: InvocationSemaphore; readonly #timeoutMs: number; + readonly #trace: TracePublisher | undefined; #closed = false; constructor(options: RouteInvocationServiceOptions) { @@ -659,6 +932,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.'); } @@ -723,7 +997,22 @@ export class RouteInvocationService { : rawInput; const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); - const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); + const context = contextForRequest(route, prepared.manifest.projectRoot, request, rawInput, this.#registry); + 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, request.event?.host); + this.#trace?.publish({ + correlation, + details: { status: 'running' }, + ...(href === undefined ? {} : { href }), + kind: 'invocation.started', + occurredAt: startedAt.toISOString(), + source: 'invocation', + status: 'running', + summary: `${label} · running`, + }); + let eventOutcome: EventTracePreflightOutcome | undefined; + let providersDurationMs = 0; const running = this.#semaphore.run(async () => { const controller = new AbortController(); this.#controllers.add(controller); @@ -733,6 +1022,28 @@ export class RouteInvocationService { const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); let child: RouteInvocationChildResult; const plainScript = plainScriptFor(prepared, route); + const publishKernelEvent = (event: EventTraceEvent): void => { + if (event.kind === 'preflight.outcome') eventOutcome = event.outcome; + if (event.kind === 'providers.finish' && event.durationMs !== undefined) { + providersDurationMs = event.durationMs; + } + 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), + }); + }; try { child = plainScript === undefined ? await this.#renderChild({ @@ -741,7 +1052,7 @@ export class RouteInvocationService { input, manifest: prepared.manifest, routeId: route.id, - }, controller.signal) + }, controller.signal, publishKernelEvent) : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { const completedAt = this.#now(); @@ -804,6 +1115,7 @@ export class RouteInvocationService { manifestDigest: manifest.digest, projection, providers: providerProjection(manifest, 0, 'mounted'), + ...(request.requestId === undefined ? {} : { requestId: request.requestId }), ...(child.result === undefined ? {} : { result: child.result }), routeId: route.id, source: route.source, @@ -811,7 +1123,7 @@ export class RouteInvocationService { startedAt: startedAt.toISOString(), status: 'succeeded', timings: [ - timing('providers', startedAt, 0), + timing('providers', startedAt, providersDurationMs), ...manifest.providers.map((provider) => timing(`provider:${provider.name}`, startedAt, 0)), timing('handler', startedAt, 0), timing('render', startedAt, child.renderDurationMs), @@ -827,6 +1139,20 @@ export class RouteInvocationService { this.#pending.delete(running); } this.#history.push(invocation); + const durationMs = new Date(invocation.completedAt).getTime() - new Date(invocation.startedAt).getTime(); + this.#trace?.publish({ + correlation, + details: invocationTraceDetails(invocation), + durationMs, + ...(href === undefined ? {} : { href }), + kind: invocation.status === 'succeeded' ? 'invocation.completed' : 'invocation.failed', + occurredAt: invocation.completedAt, + source: 'invocation', + status: invocation.status === 'succeeded' ? 'ok' : 'error', + summary: invocation.status === 'succeeded' + ? `${label} · ${route.kind === 'event-route' && eventOutcome !== undefined ? eventOutcome : durationText(durationMs)}` + : `${label} · failed`, + }); return invocation; } } diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 922e55669..63c26ce3a 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'; @@ -40,6 +42,8 @@ export interface RouteInvocationRequest { readonly event?: RouteInvocationEventOptions; /** Tool/prompt/script input, event payload (canonical or native — see `event.host`), or resource parameters. */ readonly input?: JsonValue; + /** Optional caller request id, echoed on the envelope and trace correlation. */ + readonly requestId?: string; /** The compiled route id, for example `tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`. */ readonly routeId: string; } @@ -109,6 +113,7 @@ export interface RouteInvocationSummary { readonly kind: RouteInvocationKind; /** The route manifest digest the invocation resolved the route through. */ readonly manifestDigest: string; + readonly requestId?: string; readonly routeId: string; readonly source: string; readonly sourceRevision: string; @@ -125,3 +130,68 @@ export interface RouteInvocationListResponse { export interface RouteInvocationEventPayload { readonly invocation: RouteInvocationSummary; } + +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/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index 919e026ad..3f8a30a7c 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/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/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index cba58b6aa..8a8513176 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -133,7 +133,11 @@ it('invokes compiled tool and event routes through the foreground server', { tim headers: { cookie, origin: server.url }, }); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ input: { service: 'catalog' }, routeId: 'tool:status/report' }), + body: JSON.stringify({ + input: { service: 'catalog' }, + requestId: 'request-tool-1', + routeId: 'tool:status/report', + }), headers, method: 'POST', }); @@ -143,6 +147,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.events.at(-1)?.type).toBe('complete'); expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); + expect(tool.invocation.requestId).toBe('request-tool-1'); expect(tool.invocation.providers).toEqual([ expect.objectContaining({ name: 'clock', status: 'mounted' }), ]); @@ -160,6 +165,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim tool_use_id: 'use-1', transcript_path: join(project.root, 'transcript.json'), }, + requestId: 'request-event-1', routeId: 'event:tool/after', }), headers, @@ -172,6 +178,17 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(event.invocation.events.at(-1)?.type).toBe('complete'); expect(event.invocation.document).toBeDefined(); 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' }, + }); + expect(event.invocation.requestId).toBe('request-event-1'); const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { name: 'Ada' }, routeId: 'cli:greet' }), diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..42507129a 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -2,9 +2,12 @@ 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 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, @@ -49,14 +52,37 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ timings: [], }); +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', input: { query: 'Dune' }, + requestId: 'request-1', routeId: 'tool:curator/search_audible', })).toEqual({ correlationId: 'browser-1', input: { query: 'Dune' }, + requestId: 'request-1', routeId: 'tool:curator/search_audible', }); expect(parseRouteInvocationRequest({ @@ -72,6 +98,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: 'other' }, routeId: 'event:tool/after' }, { event: { fixtureId: '' }, routeId: 'event:tool/after' }, ]) { @@ -110,6 +137,225 @@ 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: () => ({ + artifact: { epochId: 'epoch-1', target: 'claude' }, + manifest: { projectRoot: '/project' } as never, + targets: ['claude'], + }), + 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' }, + requestId: 'request-1', + routeId: route.id, + }); + + expect(result.requestId).toBe('request-1'); + expect(result.timings.find((entry) => entry.phase === 'providers')?.durationMs).toBe(3); + expect(trace.entries).toEqual([ + expect.objectContaining({ + correlation: { + correlationId: 'correlation-1', + epochId: 'epoch-1', + invocationId: result.id, + requestId: 'request-1', + 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, + requestId: 'request-1', + 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, + requestId: 'request-1', + routeId: route.id, + }, + details: { + diagnosticCodes: [], + projectionKind: 'mcp', + providers: [{ durationMs: 0, 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: () => ({ + artifact: { epochId: 'epoch-1', target: 'claude' }, + manifest: { projectRoot: '/project' } as never, + targets: ['claude'], + }), + renderChild: async () => { + throw new Error('render exploded'); + }, + trace: trace.publisher, + }); + + const result = await service.invoke({ + event: { host: 'claude' }, + 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', + }, + requestId: 'request-2', + routeId: route.id, + }); + + 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, + requestId: 'request-2', + 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', + }); +}); + it('aborts and drains a running render when the service closes', async () => { const route = { config: [], @@ -306,3 +552,129 @@ it('reaps the render child and its descendants when the service closes mid-rende await rm(project.root, { force: true, recursive: true }); } }); + +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: () => ({ + manifest: testManifestFromRouteGraph({ graph, projectRoot: root }), + targets: ['claude'], + }), + trace: trace.publisher, + }); + try { + const tool = await service.invoke({ routeId: toolRoute.id }); + const event = await service.invoke({ input: {}, routeId: eventRoute.id }); + const kernel = trace.entries.filter((entry) => entry.source === 'kernel'); + + expect(kernel.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(kernel.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 }); + } +}); From b2194819d8c353f556d374570ac6175cb07a1bff Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:23:22 +0000 Subject: [PATCH 05/70] feat(dev): add unified trace hub routes --- LANE-NOTES.md | 37 ++ docs/diagnostics.md | 1 + .../agent-bundle/src/dev/foreground-server.ts | 18 +- .../agent-bundle/src/dev/trace/trace-hub.ts | 328 +++++++++++++++--- .../src/dev/trace/trace-project-events.ts | 115 ++++++ .../src/dev/trace/trace-routes.ts | 186 ++++++++++ .../agent-bundle/src/dev/workbench-server.ts | 27 +- .../tests/trace-dev-server.test.ts | 140 ++++++++ packages/agent-bundle/tests/trace-hub.test.ts | 216 ++++++++++++ .../agent-bundle/tests/trace-routes.test.ts | 105 ++++++ rstest.integration-tests.ts | 1 + 11 files changed, 1123 insertions(+), 51 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/trace/trace-project-events.ts create mode 100644 packages/agent-bundle/src/dev/trace/trace-routes.ts create mode 100644 packages/agent-bundle/tests/trace-dev-server.test.ts create mode 100644 packages/agent-bundle/tests/trace-hub.test.ts create mode 100644 packages/agent-bundle/tests/trace-routes.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..a1b1571ce --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,37 @@ +# T1 lane notes + +## Files + +- Hardened `packages/agent-bundle/src/dev/trace/trace-hub.ts`. +- Added `packages/agent-bundle/src/dev/trace/trace-routes.ts`. +- Added `packages/agent-bundle/src/dev/trace/trace-project-events.ts`. +- Wired the trace through `packages/agent-bundle/src/dev/foreground-server.ts` and `packages/agent-bundle/src/dev/workbench-server.ts`. +- Registered diagnostics in `docs/diagnostics.md`. +- Added `trace-hub.test.ts`, `trace-routes.test.ts`, and `trace-dev-server.test.ts`; registered the integration test in `rstest.integration-tests.ts`. + +## Exported API + +- `TraceHub` retains the existing `publish`, `replay`, `subscribe`, `close`, and `latestSequence` API. Construction now requires `projectRoot`; options also expose encoded history, entry, and subscriber byte/count bounds. +- `TraceRoutes` mounts authenticated `GET /api/trace` replay and `GET /api/trace/stream` NDJSON streaming. +- `attachProjectEventTrace(trace, projectEvents)` returns a detach callback and lowers failed build, contract, and host-sync events. It intentionally ignores `route.invocation`, because T2 publishes invocation trace entries directly. + +## Cross-lane requests + +- Drop the final `STUBS (drop on integration)` commit once T2/T3/T4 provide `readonly trace?: TracePublisher` on `RouteInvocationServiceOptions`, `McpSessionServiceOptions`, `DevRuntimeControllerOptions`, and `DevLogServiceOptions`. +- Preserve the `trace` option name on all four services; `workbench-server.ts` already supplies it. +- No edits are requested for frozen `dev/trace/trace-entry.ts` or `contracts/trace.ts`. + +## Open risks + +- The four stub option fields accept the hub but do not publish. T2/T3/T4 own those producer implementations. +- Project-event lowering emits one contract diagnostic entry per failed route so each entry carries `routeId`; a failed contract event without route failures still emits one epoch-correlated entry. + +## Changeset + +Proposed patch summary: Expose authenticated live trace replay and streaming with AB8240–AB8242 diagnostics (#PR). + +## Diagnostics + +- `AB8240`: invalid trace cursor (400). +- `AB8241`: trace cursor ahead of the current sequence (409). +- `AB8242`: trace routes unavailable (404/503). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 56eadb58f..bd76b9217 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,7 @@ even when no error diagnostic was reported. | `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. | | `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). | | `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/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 22121291a..58a14897e 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -27,6 +27,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 } from './web-host-routes.ts'; import { isWorkbenchShellPath } from './workbench-shell-paths.ts'; @@ -85,7 +87,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 { @@ -196,6 +198,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. */ @@ -426,6 +430,7 @@ export class ForegroundServer { readonly #sockets = new Set(); readonly #streamSubscriptions = new Set(); readonly #testing: ForegroundServerTesting | undefined; + readonly #traceRoutes: TraceRoutes; readonly #webHostRoutes: WebHostRoutes; readonly #workbenchDevOrigins: ReadonlySet; #closePromise: Promise | undefined; @@ -552,6 +557,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( @@ -711,6 +720,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. @@ -738,7 +749,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, @@ -746,6 +757,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' })); @@ -763,6 +775,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); } @@ -793,6 +806,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/trace/trace-hub.ts b/packages/agent-bundle/src/dev/trace/trace-hub.ts index 344131abc..8e1976d9e 100644 --- a/packages/agent-bundle/src/dev/trace/trace-hub.ts +++ b/packages/agent-bundle/src/dev/trace/trace-hub.ts @@ -1,5 +1,15 @@ +import { Buffer } from 'node:buffer'; + import { deepFreeze } from '../../core/freeze.ts'; -import type { TraceEntry, TraceEntryInput, TraceMessage, TraceReplay } from './trace-entry.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 @@ -22,11 +32,16 @@ export interface TraceSubscription { } 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_HUB_CLOSED'; +export type TraceHubErrorCode = 'TRACE_CURSOR_AHEAD' | 'TRACE_CURSOR_INVALID' | 'TRACE_HUB_CLOSED'; export class TraceHubError extends Error { readonly code: TraceHubErrorCode; @@ -40,11 +55,62 @@ export class TraceHubError extends Error { 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 @@ -53,16 +119,45 @@ const maxSummaryLength = 240; * `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.#entryLimit = options.entryLimit ?? defaultEntryLimit; + 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 { @@ -73,36 +168,36 @@ export class TraceHub implements TracePublisher { return this.#sequence; } + get subscriptionCount(): number { + return this.#subscriptions.size; + } + publish(input: TraceEntryInput): TraceEntry { - if (this.#closed) throw new TraceHubError('TRACE_HUB_CLOSED', 'The trace hub is closed.'); - this.#sequence += 1; - const entry = deepFreeze({ + this.#assertOpen(); + if (!isTraceSource(input.source)) throw new TypeError('Trace source is not recognized.'); + const details = this.#detailsFor(input.details); + let entry = deepFreeze({ ...input, - id: `trc_${this.#sequence}`, + ...(details === undefined ? {} : { details }), + id: `trc_${this.#sequence + 1}`, occurredAt: input.occurredAt ?? this.#now().toISOString(), - sequence: this.#sequence, - summary: input.summary.length <= maxSummaryLength ? input.summary : `${input.summary.slice(0, maxSummaryLength - 1)}…`, + sequence: this.#sequence + 1, + summary: this.#summaryFor(input.summary), }); - this.#entries.push(entry); - if (this.#entries.length > this.#entryLimit) this.#entries.splice(0, this.#entries.length - this.#entryLimit); - for (const subscription of this.#subscriptions) this.#deliver(subscription, entry); + 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 { - const after = options.afterSequence ?? 0; - if (after > this.#sequence) { - throw new TraceHubError('TRACE_CURSOR_AHEAD', `Trace cursor ${after} is ahead of the latest sequence ${this.#sequence}.`); - } - const first = this.#entries[0]; - const gap = first !== undefined && after + 1 < first.sequence - ? deepFreeze({ - droppedCount: first.sequence - after - 1, - firstAvailableSequence: first.sequence, - requestedAfterSequence: after, - type: 'trace.gap' as const, - }) - : undefined; + 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 }), @@ -112,20 +207,33 @@ export class TraceHub implements TracePublisher { /** Replays the retained window after `afterSequence`, then delivers live entries in order. */ subscribe(listener: TraceListener, options: TraceSubscribeOptions = {}): TraceSubscription { - if (this.#closed) throw new TraceHubError('TRACE_HUB_CLOSED', 'The trace hub is closed.'); - const subscription: Subscription = { closed: false, listener }; - const replay = this.replay(options); - if (replay.gap !== undefined) this.#deliver(subscription, replay.gap); - for (const entry of replay.entries) { - if (subscription.closed) break; - this.#deliver(subscription, entry); + 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); + } } - if (!subscription.closed) this.#subscriptions.add(subscription); + subscription.replaying = false; return { - close: () => { - subscription.closed = true; - this.#subscriptions.delete(subscription); - }, + close: () => this.#removeSubscription(subscription), get closed() { return subscription.closed; }, @@ -133,22 +241,150 @@ export class TraceHub implements TracePublisher { } close(): void { + if (this.#closed) return; this.#closed = true; - for (const subscription of this.#subscriptions) subscription.closed = true; - this.#subscriptions.clear(); + 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; - let keep: boolean | void; + if ('sequence' in message) { + if (message.sequence <= subscription.lastDeliveredSequence) return; + subscription.lastDeliveredSequence = message.sequence; + } + const previous = this.#delivering; + this.#delivering = subscription; try { - keep = subscription.listener(message); + if (subscription.listener(message) === false) this.#removeSubscription(subscription); } catch { - keep = false; + this.#removeSubscription(subscription); + } finally { + this.#delivering = previous; } - if (keep === false) { - subscription.closed = true; - this.#subscriptions.delete(subscription); + } + + #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 3170df9e3..3b6e69a4e 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -76,6 +76,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'; @@ -441,6 +443,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; @@ -449,12 +452,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, @@ -463,6 +468,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. @@ -495,6 +501,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', @@ -503,6 +511,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); }; @@ -516,6 +525,8 @@ const withMcpSessionLifecycle = ( playground: Closeable, logs: DevLogService, detachProjectLogs: () => void, + trace: TraceHub, + detachProjectTrace: () => void, inspector: Closeable, epochAdoption: EpochAdoptionPolicy, hostInstalls?: DevHostInstallManager, @@ -525,6 +536,7 @@ const withMcpSessionLifecycle = ( return closeDevServerLifecycle({ coordinator, detachProjectLogs, + detachProjectTrace, epochAdoption, hostInstalls, inspector, @@ -533,6 +545,7 @@ const withMcpSessionLifecycle = ( mcpSessions, playground, runtimeResources: { clientSurfaces, runtime }, + trace, }); }, publishServerUrl: (url: string) => coordinator.publishServerUrl(url), @@ -596,8 +609,10 @@ 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 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), @@ -661,6 +676,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun provider, providerLoadError, storageRoot: join(root, '.agent-bundle', 'runtime'), + trace: traceHub, }); } const appPreviews = new DeferredMcpAppPreviewService(); @@ -773,6 +789,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun projectRoot: root, registry, platformRuntime, + trace: traceHub, traceSink: createMcpDevLogTraceSink(logs), }); const epochAdoption = new EpochAdoptionPolicy({ @@ -846,7 +863,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, @@ -861,7 +878,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; @@ -939,6 +956,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }, registry, scripts: scriptPlayground, + trace: traceHub, }); const agentApi = agentApiEnabled ? new AgentApi({ @@ -972,6 +990,8 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun playground, logs, detachProjectLogs, + traceHub, + detachProjectTrace, inspector, epochAdoption, hostInstalls, @@ -996,6 +1016,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun routeInvocations, ...(runtime === undefined ? {} : { runtime }), skillDocuments, + trace: traceHub, ...(options.workbenchDevOrigins === undefined || options.workbenchDevOrigins.length === 0 ? {} : { workbenchDevOrigins: options.workbenchDevOrigins }), 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..21c1e4430 --- /dev/null +++ b/packages/agent-bundle/tests/trace-dev-server.test.ts @@ -0,0 +1,140 @@ +import { mkdir, 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 { 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'; + +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/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 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 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); + } 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..50b9da407 --- /dev/null +++ b/packages/agent-bundle/tests/trace-hub.test.ts @@ -0,0 +1,216 @@ +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', + routeId: 'tool:status/report', + source: 'src/mcp/status/tools/report.tsx', + sourceRevision: 'source-1', + startedAt: '2026-09-05T12:00:00.000Z', + status: 'succeeded', + 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/rstest.integration-tests.ts b/rstest.integration-tests.ts index a6d495479..6218259d8 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -86,6 +86,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', From c5fb87ff5e18cda2fa59eb2f3b3667e449568e59 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:28:52 +0000 Subject: [PATCH 06/70] feat(dev): lower Workbench MCP session traces onto the unified trace (#600 PR 2, lane T3) - McpSessionServiceOptions.trace?: TracePublisher; one createMcpSessionTraceSink per session, composed with the dev-log sink (composeMcpSessionTraceSinks, per-sink isolation) - mcp.request / mcp.response paired by JSON-RPC id with durationMs, mcp.notification, mcp.progress, mcp.logging, mcp.stderr (summary through safeDevWireText), mcp.session.started / mcp.session.closed; correlation mcpSessionId, mcpRequestId, routeId (tool:/prompt:), epochId, host, and _meta-lifted requestId / conversationId / sessionId / correlationId; href via applicationNodePath + ?session= - McpSessionFrameTraceEntry lifts id, method, meta (McpSessionTraceMeta) as optional fields - tools/call session route accepts correlationId and stamps _meta['agent-bundle/correlationId'] - tests: mcp-session-trace-publisher (unit), mcp-session-routes, mcp-session-service (fixture) --- LANE-NOTES.md | 132 +++++++ .../agent-bundle/src/contracts/mcp-session.ts | 1 + .../dev/mcp-session/mcp-session-protocol.ts | 20 + .../src/dev/mcp-session/mcp-session-routes.ts | 33 +- .../dev/mcp-session/mcp-session-service.ts | 19 +- .../mcp-session-trace-publisher.ts | 365 ++++++++++++++++++ .../src/dev/mcp-session/mcp-session-trace.ts | 22 ++ .../src/dev/mcp-session/mcp-session-types.ts | 7 + .../src/dev/mcp-session/mcp-session.ts | 2 + .../tests/mcp-session-routes.test.ts | 25 ++ .../tests/mcp-session-service.test.ts | 115 ++++++ .../tests/mcp-session-trace-publisher.test.ts | 222 +++++++++++ 12 files changed, 952 insertions(+), 11 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts create mode 100644 packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..dc0571463 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,132 @@ +# Lane T3 — MCP protocol fan-in (#600 PR 2) + +Branch `lane/wb600-pr2-t3`, based on `wb600-pr2-trace`. + +## Files + +Added + +- `packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts` — `createMcpSessionTraceSink`, + `liftMcpFrame`, `mcpCorrelationMetaKey`. Imported by `mcp-session.ts` (frame lifting) and + `mcp-session-service.ts` (per-session sink); re-exported from `mcp-session-service.ts`. +- `packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts` — unit pool. + +Changed + +- `mcp-session-protocol.ts` — `McpSessionFrameTraceEntry` gains optional `id` (JSON-RPC id as string), `method`, + `meta: McpSessionTraceMeta` (`correlationId` / `conversationId` / `requestId` / `sessionId`, lifted from + `params._meta`). New exported `McpSessionTraceMeta`; also re-exported from `contracts/mcp-session.ts`. Additive. +- `mcp-session-trace.ts` — `composeMcpSessionTraceSinks(...sinks)`: per-sink try/catch fan-out. +- `mcp-session-types.ts` — `McpSessionServiceOptions.trace?: TracePublisher`. +- `mcp-session-service.ts` — builds one `createMcpSessionTraceSink({ binding, projectRoot, sessionId, trace })` per + session and composes it with the existing `traceSink` (dev logs). `createMcpDevLogTraceSink` is untouched. +- `mcp-session.ts` — `#recordFrame` spreads `liftMcpFrame(snapshot)` onto the frame trace entry. +- `mcp-session-routes.ts` — `tools/call` accepts optional `correlationId` (nonempty, ≤ 256 chars, same bound as + `RouteInvocationRequest.correlationId`); the route stamps it into `params._meta['agent-bundle/correlationId']`. + `McpSessionRouteSession.callTool` / `callToolTask` option type is now the exported `McpSessionRouteToolCall` + (adds `_meta?: McpRequestMeta`). A browser-supplied `_meta` is still rejected (AB8016). +- `tests/mcp-session-routes.test.ts`, `tests/mcp-session-service.test.ts` — new cases (see Tests). + +## Lowering (kind → TraceEntry) + +All entries: `source: 'mcp'`, correlation `{ epochId, host: binding.target, mcpSessionId }`, `occurredAt` from the +session entry's unix-ms clock. + +| Session entry | Trace kind | Correlation / details | +|---|---|---| +| client frame with `id` + `method` | `mcp.request` (`status: running`) | `mcpRequestId`, `routeId` (`tool:/` for `tools/call`, `prompt:/` for `prompts/get`), `_meta` keys → `requestId` (`claudecode/toolUseId`), `conversationId` / `sessionId` (`x-codex-turn-metadata.thread_id` / `.session_id`), `correlationId` (`agent-bundle/correlationId`). `details: { method, name?, paramsBytes }` — params size only. | +| frame with `id`, no `method` | `mcp.response` (`ok` / `error`) | inherits the request's correlation by JSON-RPC id; `durationMs` = response − request `occurredAt`; `details: { resultBytes, isError? }` or `{ error: { code, message } }` with the message through `safeDevWireText`. A JSON-RPC error or `result.isError === true` is `error`. | +| frame with `method`, no `id` (except progress/message) | `mcp.notification` | `notifications/cancelled` joins the pending request by `params.requestId`. `details: { direction, method }`. | +| `progress` entry | `mcp.progress` (`running`) | joins the request whose id or `_meta.progressToken` equals `progressToken`; `details: { progress?, total?, progressToken? }`. | +| `logging` entry | `mcp.logging` | `details: { level?, logger? }` — never `data`. | +| `stderr` entry | `mcp.stderr` | summary = first line, ≤ 200 chars, through `safeDevWireText`; `details: { bytes }`. | +| `operation initialize succeeded` (first) / `restart succeeded` | `mcp.session.started` | once per connect; restart summary says "restarted". | +| `operation close succeeded|failed` | `mcp.session.closed` (`ok` / `error`) | once. | +| other `operation` entries | — | not lowered: the request/response frames already carry the protocol operation, and the `kind` vocabulary has no `mcp.operation`. The dev-log sink still records them. | + +Frames for `notifications/progress` and `notifications/message` are not lowered as frames; the session's dedicated +`progress` / `logging` entries carry them, so each event is one `TraceEntry`. + +`href`: `applicationNodePath(node) + '?session='` when the route id maps to a node, else +`/advanced/protocol?session=`. Responses, progress, and cancellations use the href of the request they +join. + +Every `_meta` key is bounded (≤ 256 chars, no control characters or path separators) before it reaches the wire. +Pending requests are capped at 1 024 per session (oldest evicted) so an unanswered cancel cannot grow the map. + +## Cross-lane requests + +- **T1 (`workbench-server.ts`)** — pass the hub as `trace` on the existing constructor: + + ```ts + const mcpSessions = new McpSessionService({ + epochStore, projectRoot: root, registry, platformRuntime, + trace: traceHub, + traceSink: createMcpDevLogTraceSink(logs), + }); + ``` + + The option name is `trace`, as the brief specifies. No other server change. + +- **T6 (route workspace / MCP controller)** — the browser cannot put `_meta` on a session `tools/call` today (the route + rejects it), so it could not pass a correlation id. The route now accepts a top-level `correlationId` on the + `tools/call` operation body and stamps it into `_meta` itself. To join an MCP-backed run to the route workspace's + `correlationId`, add `correlationId?: string` to the `tools/call` member of `McpRouteOperation` + (`packages/workbench/src/mcp/mcp-route-client.ts` ~line 91) and thread the workspace's minted id through + `McpSessionController.invoke` → `AgentBundleRemoteTransport` → the operation body. Nothing else on the server needs to + change; the `mcp.request` / `mcp.response` entries then carry `correlation.correlationId`. + +- **T5 (Protocol page, `packages/workbench/src/mcp/mcp-session-controller.ts` `traceEntry`)** — the decoder does not + reject the new fields, so it was left alone; it does *drop* them when re-picking known keys (lines ~405–406). To show + and link `id` / `method` / `meta` on Advanced → Protocol, copy them through when present (all optional strings, `meta` + an object of optional strings — `McpSessionTraceMeta` is exported from `contracts/mcp-session.ts`). + +- **Trace entry contract (frozen)** — no additions needed. One observation: `TraceCorrelation.requestId` is used here + for the host tool-use id per the brief; the MCP session's own cancel-handle `requestId` + (`McpSessionToolCallOptions.requestId`) is intentionally *not* lowered to avoid the collision. + +## Open risks / notes + +- `resources/read` has no `routeId`: the request names a URI, the route id needs the resource's name, and the session + only holds `{ epochId, serverName, target }`. Those entries link to `/advanced/protocol?session=…`. Mapping URI → + `resource:/` needs the route manifest (`RouteManifestRoute.config`) — a follow-up if wanted. +- Session `serverName` is used as the route id's `` segment; that matches how the App workspace opens sessions + (`leaf.ref.server`) and how `protocol-name.ts` derives the wire name (final id segment, no override). +- `notifications/cancelled` leaves the pending request in place (the server may still answer); the map is bounded. +- `safeDevWireText` redacts a whole line that contains `:` (drive-letter guard), so a stderr line like + `warn: …` becomes `stderr: [REDACTED]`. That is the shared helper's existing behavior, not new here. +- Host-originated MCP (Claude/Codex talking to the generated server directly) is still invisible — only Workbench-owned + sessions publish, as scoped. + +## Proposed changeset line (patch) + +``` +Publish every Workbench MCP session frame, notification, stderr line, and lifecycle step onto the unified trace +(`mcp.request` / `mcp.response` paired by JSON-RPC id with `durationMs`, `mcp.notification`, `mcp.progress`, +`mcp.logging`, `mcp.stderr`, `mcp.session.started` / `mcp.session.closed`) through the new +`McpSessionServiceOptions.trace`; lift `id`, `method`, and `_meta` correlation keys onto `McpSessionTraceEntry`; accept +`correlationId` on the session `tools/call` operation and stamp it into `_meta['agent-bundle/correlationId']` (#PR) +``` + +## Diagnostic codes + +None added. `AB8016` already covers the malformed `correlationId` shape. + +## Tests + +- `packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts` (unit): frame lifting; request/response pairing, + duration, tool `routeId`, `_meta` → correlation, `href`; JSON-RPC error / tool error status and redaction; orphan + responses; notification / progress / logging / stderr lowering (one entry each, no raw payloads, stderr redaction); + session started/closed once; throwing publisher isolated from the `McpSessionTraceLog` and sibling sinks. +- `packages/agent-bundle/tests/mcp-session-service.test.ts` (integration, existing file): real fixture server — + `mcp.session.started` → `initialize` request/response → `notifications/initialized` → `tools/call inspect` + (with `claudecode/toolUseId` + `agent-bundle/correlationId` in `_meta`) → `prompts/get` → `resources/read` → stderr + → `mcp.session.closed`; every response equals its request's correlation with `durationMs ≥ 0`; no project root on the + wire; the lifted `id` / `method` / `meta` are on the session's own trace; a throwing publisher does not break + `open` / `callTool` / `close`. +- `packages/agent-bundle/tests/mcp-session-routes.test.ts` (unit): `correlationId` stamps `_meta`; empty, over-long, + and browser-supplied `_meta` are `AB8016`. + +Gate run: `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint`, +unit: `mcp-session-trace-publisher`, `mcp-session-routes`, `mcp-tasks`, `dev-log-producers`, +`workbench/mcp-session-controller`, `workbench/mcp-session-model`; integration: `mcp-session-service.test.ts` (27/27). diff --git a/packages/agent-bundle/src/contracts/mcp-session.ts b/packages/agent-bundle/src/contracts/mcp-session.ts index d5346bc0c..9640c3ce2 100644 --- a/packages/agent-bundle/src/contracts/mcp-session.ts +++ b/packages/agent-bundle/src/contracts/mcp-session.ts @@ -8,6 +8,7 @@ export type { McpSessionInspectorConfig, McpSessionOperation, McpSessionTraceEntry, + McpSessionTraceMeta, McpSessionTraceReplayGap, } from '../dev/mcp-session/mcp-session-protocol.ts'; 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 afdd4df14..4d315c94a 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 @@ -36,7 +36,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, @@ -75,6 +77,7 @@ export type { OpenMcpSessionOptions, } from './mcp-session-types.ts'; export type { McpSessionTraceSink } from './mcp-session-trace.ts'; +export { createMcpSessionTraceSink, liftMcpFrame, mcpCorrelationMetaKey } from './mcp-session-trace-publisher.ts'; export type { McpSessionBinding, @@ -85,6 +88,7 @@ export type { McpSessionTraceEntry, McpSessionTraceListener, McpSessionTraceMessage, + McpSessionTraceMeta, McpSessionTraceReplay, McpSessionTraceReplayGap, McpSessionTraceSubscription, @@ -247,6 +251,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(); @@ -266,6 +271,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; } @@ -364,12 +370,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, @@ -381,7 +394,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..5b35511c7 --- /dev/null +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-trace-publisher.ts @@ -0,0 +1,365 @@ +import { isRecord, type JsonValue } from '../../core/strict-json.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 `params._meta` key the Workbench stamps on a `tools/call` it makes + * through a session route so the frame joins the route workspace's run + * (`RouteInvocationRequest.correlationId`) on the unified trace. + */ +export const mcpCorrelationMetaKey = 'agent-bundle/correlationId'; + +/** 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/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 5c734d55d..bd2d9d0fb 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -20,9 +20,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'; @@ -301,6 +304,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('uses the admitted session timeout for initialization, catalog, operations, and restart', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-timeout-')); const observed: Array = []; 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..15222fa50 --- /dev/null +++ b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts @@ -0,0 +1,222 @@ +import { expect, it } from '@rstest/core'; + +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, + mcpCorrelationMetaKey, +} 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); +}); From 52e2026063ad7da3ef059f219c5b4fb51aa31507 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:36:03 +0000 Subject: [PATCH 07/70] docs: explain unified Workbench trace --- LANE-NOTES.md | 98 ++++++++++++ .../docs/en/examples/audiobook-curator.mdx | 4 +- .../docs/en/examples/hooks-and-scripts.mdx | 6 +- website/docs/en/examples/mcp-app.mdx | 2 + website/docs/en/guide/development/testing.mdx | 7 + .../docs/en/guide/development/workbench.mdx | 100 +++++++++++- website/docs/en/reference/_meta.json | 1 + website/docs/en/reference/dev-server-http.mdx | 146 ++++++++++++++++++ website/docs/en/reference/index.mdx | 1 + .../docs/en/reference/runtime-environment.mdx | 2 + 10 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 website/docs/en/reference/dev-server-http.mdx diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..bc954a91a --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,98 @@ +# T8 — English docs for the unified trace + +## Files changed + +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/en/guide/development/testing.mdx` +- `website/docs/en/examples/audiobook-curator.mdx` +- `website/docs/en/examples/hooks-and-scripts.mdx` +- `website/docs/en/examples/mcp-app.mdx` +- `website/docs/en/reference/dev-server-http.mdx` (new) +- `website/docs/en/reference/index.mdx` +- `website/docs/en/reference/runtime-environment.mdx` +- `website/docs/en/reference/_meta.json` + +No current hand-written architecture page under `docs/*.md` describes the unified Workbench +trace. The matches there are historical plans/specifications or unrelated Effect/TraceDecay +references, so none was changed. `docs/diagnostics.md` remains owned by T1/T7. + +## Page and section ledger for the zh lane + +Mirror these changes 1:1: + +1. **Developer Workbench** + - Navigation: Trace is the correlated application-activity timeline. + - Replace **Trace** with the real event-route transcript, producer/kind/correlation/href table, + transitive grouping order, source/host/route/status/text filters, `?correlation=`, + `/trace/`, Open route, route-workspace and Raw-log Open in Trace, replay/NDJSON + routes, host receipt security, and excluded payload/document/environment data. + - **Advanced → Raw logs**: retained as the complete redacted producer firehose; correlated + rows link into Trace. + - **URL model**: add `?correlation=`, generalize `/trace/`. + - **Route invocation API**: add trace replay, stream, receipt routes and link the HTTP + reference. +2. **Testing** + - After `inspectWorkbenchSurface`, add browser acceptance guidance for `trace-timeline`, + `trace-entry`, `trace-group`, and `trace-detail`, including Open route snapshot verification. +3. **Audiobook Curator** + - Workbench step 4: open Trace for the invocation start/completion and return with Open route. +4. **Hooks and Scripts** + - Workbench step 4: follow invocation/kernel activity and attached-host receipts in Trace, + restore snapshots with Open route, and reserve Raw logs for uncorrelated details. +5. **MCP App** + - Workbench step 6: after the Protocol invocation, open Trace for MCP + request/response/notification/session activity. +6. **Development-server HTTP** (new reference page) + - Route invocation, runtime run, MCP correlation, trace replay/stream, Raw logs, and + `POST /api/trace/receipts` contracts. +7. **Reference index and navigation** + - Add Development-server HTTP after Diagnostics. +8. **Runtime environment** + - Add `AGENT_BUNDLE_DEV_TRACE_URL` and `AGENT_BUNDLE_DEV_TRACE_TOKEN`. + +## Source reconciliation + +- T1: `GET /api/trace?after=`, + `GET /api/trace/stream?after=`, JSON replay plus NDJSON stream, and diagnostics + `AB8240`–`AB8242`. +- T2: invocation/kernel kinds; optional `requestId` on `RouteInvocationRequest` and summary; + event-route kernel entries carry `executionId`. +- T3: MCP kinds and `_meta` correlation; `tools/call` accepts top-level `correlationId`; route + hrefs use `?session=`, with Protocol fallback. +- T4: `DevRuntimeInvocationRequest.correlationId`; retained Raw logs; Open in Trace precedence + `correlationId`, then `invocationId`, then `mcpSessionId`. +- T5: transitive join keys and priority; source/host/route/status/text filters; + `?correlation=` group selection; `/trace/` detail and `Open route`; required test ids. +- T6: route workspace Trace tab and `Open in Trace`; runtime requests carry `correlationId`. +- T7: `POST /api/trace/receipts`; 16 KiB strict receipt; random per-dev-server bearer token; + owner-only endpoint record removed at close; no-Origin plus loopback-peer guard; 750 ms + best-effort send; payload-free receipt and `hook.*`/`session.*` lowering. T7 owns + `AB8247`–`AB8249`. + +## Exported API + +None. Documentation only. + +## Cross-lane requests + +- Zh lane: mirror the page/section ledger above, including the new page and `_meta.json` entry. +- Integrator: after T1/T7 are merged, confirm generated Diagnostics includes + `AB8240`–`AB8242` and `AB8247`–`AB8249`; do not hand-edit generated website diagnostics. + +## Verification + +- Initial `pnpm install --frozen-lockfile --prefer-offline && pnpm build`: passed. +- `pnpm docs:site:build`: typecheck passed, then stopped only at the expected locale drift: + - `en/guide/development/workbench.mdx`: fenced blocks, heading count, table rows + - `en/reference/_meta.json`: entry count + - `en/reference/dev-server-http.mdx`: missing zh twin + - `en/reference/index.mdx`: table rows + - `en/reference/runtime-environment.mdx`: table rows +- A parity-skipping diagnostics/build attempt passed diagnostics coverage and rendered every + page, then Rspress stopped only because `zh/reference/dev-server-http.mdx` is absent. +- The built-link scan checked 27,116 anchors and reported only that expected missing zh page. +- IDE diagnostics: none. + +## Changeset and diagnostics + +No changeset: documentation only. No diagnostic code is introduced by this lane. diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 694d30a22..0f0163d85 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 f3a5eeca2..66b1c8590 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -57,8 +57,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/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index 850f16a9b..886c2553c 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 63837ba8e..252984716 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -30,7 +30,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. @@ -110,10 +111,84 @@ 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, runtime run, 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` | `correlationId`, `requestId`, `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 | +| `runtime` | run start/completion/failure, generation publication, and App updates | `runId`, `correlationId`, `routeId`, `epochId`, `mcpSessionId` | The route with `?invocation=` | +| `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, runtime run, 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. +Filter by source, host, route, or status; the text filter matches summaries and kinds. +`/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: it is not durable across server restarts and is not +the embedded host-session UI planned for a later release. + +### 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. 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 @@ -141,8 +216,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. @@ -169,7 +245,8 @@ Workbench uses paths and browser history, not `#page` hashes: /routes/skills/ /routes/commands/ /routes/rules/ -/trace/ +/trace/ +/trace?correlation= /problems /advanced/
    ``` @@ -188,6 +265,10 @@ The route workspace uses one authenticated, origin-guarded foreground API: - `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. +- `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. The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings when @@ -197,6 +278,9 @@ requests (`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnost See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. +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 714e233ef..3de610163 100644 --- a/website/docs/en/reference/_meta.json +++ b/website/docs/en/reference/_meta.json @@ -7,6 +7,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..bfbee5b35 --- /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 }` | +| `GET` | `/api/routes/invocations?limit=<1..200>` | `{ invocations: RouteInvocationSummary[] }`, newest first | +| `GET` | `/api/routes/invocations/` | `{ 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. + +## Development runtime runs + +`POST /api/runtime/runs` accepts a `DevRuntimeInvocationRequest` with `surfaceId`, `target`, +`input`, and optional `fixtureId`, `expectedGenerationId`, and `correlationId`. The foreground +passes `correlationId` to the runtime provider and copies it onto the run's trace entries; runtime +events can also carry it directly. `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' + | 'runtime' + | '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`, `runId`, 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 06a0f3c5f..d7c59f40a 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -43,6 +43,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. | From 6fc96f46a1bb53e059a6d2dfe7dba373f1f2a6fe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:40:41 +0000 Subject: [PATCH 08/70] feat(workbench): Trace page as a correlated live timeline over /api/trace (#600 PR 2, lane T5) - trace-client.ts: TraceClient (replay/stream), ForegroundTraceClient, strict decoders for TraceEntry / TraceReplayGap / replay body (AB8249), and openTraceFeed with replay -> NDJSON stream -> back-off reconnect and a restart-from-zero path for a refused non-zero cursor. - trace-model.ts: merge by sequence (bounded 4096), union-find grouping over conversationId > sessionId > invocationId | executionId | runId | mcpSessionId:mcpRequestId > correlationId, nesting, span/status/headline, filters, facets, ?correlation= and /trace/ selection, formatting. - trace-page.tsx + trace-page.css: timeline with grouped rows, sticky filter bar, "N new" pill with scroll anchoring, empty state, detail drawer with correlation links and Open route. PR 1 stopgap over /api/routes/invocations removed. - workbench-location.ts: /trace/ and ?correlation=. - project-client.ts: 'route.invocation' joins projectEventTypes; activity events no longer trigger a status refetch. - main.tsx: construct ForegroundTraceClient and pass it to TracePage. - docs/diagnostics.md: register AB8249. - Tests: trace-model, trace-client (decoders, stream, feed reconnect), trace-page (static render), workbench-location, project-client. --- LANE-NOTES.md | 200 +++++++ docs/diagnostics.md | 1 + packages/workbench/src/main.tsx | 6 +- packages/workbench/src/project-client.ts | 6 +- .../workbench/src/shell/workbench-location.ts | 23 +- packages/workbench/src/trace/trace-client.ts | 345 ++++++++++++ packages/workbench/src/trace/trace-model.ts | 385 +++++++++++++ packages/workbench/src/trace/trace-page.css | 77 +++ packages/workbench/src/trace/trace-page.tsx | 525 +++++++++++------- .../workbench/tests/project-client.test.ts | 56 ++ .../workbench/tests/support/trace-fixtures.ts | 95 ++++ packages/workbench/tests/trace-client.test.ts | 272 +++++++++ packages/workbench/tests/trace-model.test.ts | 138 +++++ packages/workbench/tests/trace-page.test.ts | 209 +++---- .../tests/workbench-location.test.ts | 15 + 15 files changed, 2052 insertions(+), 301 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/workbench/src/trace/trace-client.ts create mode 100644 packages/workbench/src/trace/trace-model.ts create mode 100644 packages/workbench/src/trace/trace-page.css create mode 100644 packages/workbench/tests/support/trace-fixtures.ts create mode 100644 packages/workbench/tests/trace-client.test.ts create mode 100644 packages/workbench/tests/trace-model.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..d27a864e8 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,200 @@ +# Lane T5 — Trace page: correlated live timeline + +Branch `lane/wb600-pr2-t5` on `wb600-pr2-trace`. Gate green: `pnpm build && npx tsc --project +packages/workbench/tsconfig.json --noEmit && pnpm lint && npx rstest --config rstest.unit.config.ts +packages/workbench/tests/trace-*.test.ts packages/workbench/tests/workbench-location.test.ts +packages/workbench/tests/project-client*.test.ts` (65 tests). Also green alongside: +`dev-server-backend`, `workbench-shell`, `workbench-router` unit tests. + +## Files + +Added + +- `packages/workbench/src/trace/trace-client.ts` — `TraceClient`, `ForegroundTraceClient`, strict decoders, + `openTraceFeed` (replay → stream → back-off reconnect). Imported by `trace-page.tsx` and `main.tsx`. +- `packages/workbench/src/trace/trace-model.ts` — pure merge / group / filter / select / format helpers. + Imported by `trace-client.ts` (merge) and `trace-page.tsx`. +- `packages/workbench/src/trace/trace-page.css` — page layout; imported by `trace-page.tsx`. +- `packages/workbench/tests/support/trace-fixtures.ts` — `traceEntry(sequence, overrides)` and + `sampleTraceEntries` (the brief's example timeline: one Claude session with hook/kernel/mcp rows, a lone + invocation, a failed runtime run, a lone log line). Shared by the three trace tests. +- `packages/workbench/tests/trace-client.test.ts`, `packages/workbench/tests/trace-model.test.ts`. + +Changed + +- `packages/workbench/src/trace/trace-page.tsx` — rewritten. The PR 1 stopgap (`loadTraceHistory`, + `mergeTraceEntries` over `/api/routes/invocations`, `sortTraceEntries`, `traceDurationMs`, + `traceEntryLocation`, the `.trace-table` markup) is gone; the page reads `/api/trace` only. +- `packages/workbench/src/main.tsx` — constructs `new ForegroundTraceClient({ foreground })` in + `createClients` and renders ``. The Trace case no + longer waits for the application tree (it does not need it). +- `packages/workbench/src/shell/workbench-location.ts` — `/trace/` and `/trace?correlation=` + parse and format; `WorkbenchLocation['trace']` gains `correlation?: string`. `invocationId` stays as the + field name for the selected entry (the shell and PR 1 tests read it); the doc comment says so. +- `packages/workbench/src/project-client.ts` — `'route.invocation'` added to `projectEventTypes` (the + inventory §2 latent bug: the browser subscribed to the event but the allowlist dropped it). Activity + events (`route.invocation`, `runtime.event`) no longer trigger a `/api/status` refetch — they never change + project status, and a hot invocation loop would otherwise hammer the status route. +- `packages/workbench/tests/trace-page.test.ts`, `workbench-location.test.ts`, `project-client.test.ts` — + new cases; every pre-existing case still passes. +- `docs/diagnostics.md` — `AB8249` registered (see below). + +## Exported API (for T6 and the integrator) + +`packages/workbench/src/trace/trace-client.ts` + +```ts +export interface TraceClient { + replay(after?: number): Promise; + stream(after: number | undefined, onMessage: (message: TraceMessage) => void, signal: AbortSignal): Promise; +} +export class ForegroundTraceClient implements TraceClient { constructor(options: { foreground: ForegroundRequestAuthority }) } +export class TraceClientError extends Error { readonly code: string } +export const TRACE_INVALID_RESPONSE_CODE = 'AB8249'; +export const decodeTraceEntry: (value: unknown) => TraceEntry; // throws TraceClientError(AB8249) +export const decodeTraceMessage: (value: unknown) => TraceMessage; // entry | gap frame +export const decodeTraceReplay: (value: unknown, after: number) => TraceReplay; +export interface TraceFeedState { connected; entries; error?; gap?; loaded } +export const openTraceFeed: (options: { client; onState; retryDelay? }) => { close(): void }; +``` + +`TraceReplay` is `{ entries, latestSequence, gap? }` — the shape of `GET /api/trace?after=` per the brief. +Decoder bounds: 64 KiB per NDJSON frame; `summary` ≤ 240 chars; `kind` ≤ 128, identifier grammar, must contain +a `.`; `details` is any JSON whose strings pass the safe-text rule (no control characters, no credential-shaped +tokens via `redactEvalCredentialText`, no absolute/Windows/UNC path or `file:` URL) and whose keys are not +credential keys; `href` must be a shell path (`/routes/…`, `/trace…`, `/problems`, `/advanced/…`, same origin, +no hash); `source` must be one of the seven `TraceSource` values (unknown → `AB8249`, not a crash); `status` ∈ +`ok|error|running`; `id` and every correlation value are identifiers `^[A-Za-z0-9_][A-Za-z0-9._:@+/-]*$` ≤ 256 +chars (route ids like `tool:curator/search` pass); `occurredAt` must round-trip through `toISOString()`; +`sequence ≥ 1`. Replay entries must be contiguous from `after` (or from `gap.firstAvailableSequence - 1`), +`gap.requestedAfterSequence` must equal `after`, and `latestSequence` must equal the last entry's sequence +(or `after` when empty, or `gap.firstAvailableSequence - 1` for an empty gap) — anything else is `AB8249`. + +Reconnect: replay once, then stream from the last delivered sequence; on stream end or failure back off +250 ms doubling to 5 s and replay again from the cursor. A refused replay from a non-zero cursor +(`TRACE_CURSOR_AHEAD` after a dev-server restart) restarts from `after = 0`. Retention in the browser is +`maximumTraceEntries = 4096` (matches the hub's default cap); a server `gap` frame is surfaced in the page. + +`packages/workbench/src/trace/trace-model.ts` + +```ts +export const mergeTraceEntries: (existing, incoming) => readonly TraceEntry[]; // by sequence, bounded +export const groupTraceEntries: (entries) => readonly TraceGroup[]; +export const filterTraceGroups: (groups, filter: TraceFilter) => readonly TraceGroup[]; +export const matchesTraceFilter, isEmptyTraceFilter, traceFacetsFor; +export const selectTraceGroup: (groups, id) => TraceGroup | undefined; // ?correlation= +export const selectTraceEntry: (entries, id) => TraceEntry | undefined; // /trace/; also a PR 1 inv_… id +export const formatTraceTime (HH:MM:SS.mmm), formatTraceDuration, traceSourceGlyph, traceKindLabel; +export interface TraceGroup { key; keyKind; headline; rows: TraceRow[]; startedAt; endedAt; spanMs; status; firstSequence; lastSequence } +``` + +Grouping is a union-find over the join keys `conversationId → sessionId → invocationId | executionId | runId | +mcpSessionId:mcpRequestId → correlationId`; a group's `key`/`keyKind` is the strongest key it shares, else +`entry:` for a singleton. `mcpRequestId` alone never joins (request ids repeat across sessions). Facets +(`host`, `routeId`, `epochId`) never join. Within a group, `invocation`/`hook`/`diagnostic` rows sit at depth 0 +and `kernel`/`mcp`/`log`/`runtime` rows at depth 1. Headline priority: invocation > hook > runtime > mcp > +kernel > diagnostic > log, earliest wins ties. Group status: `error` if any row errors, `running` if the +last row is running, else `ok`. Filters are applied to rows *after* grouping, so a filter never re-keys a group. + +`packages/workbench/src/trace/trace-page.tsx` + +```ts +export interface TracePageProps { client: TraceClient; correlation?: string; entries?: readonly TraceEntry[]; entryId?: string; onNavigate; timeZone?: string } +export const TracePage: (props: TracePageProps) => JSX.Element; +``` + +`entries` is a supplied snapshot for static/server rendering and tests (the live feed is not opened). +Row markup: `[data-testid="trace-entry"][data-entry-id][data-kind][data-source][data-status]` inside +`[data-testid="trace-group"][data-group-key]`; also `trace-timeline`, `trace-filter-bar`, `trace-detail`, +`trace-new-pill`, `trace-empty`. Clicking a row pushes `/trace/` (keeps `?correlation=`). + +`packages/workbench/src/shell/workbench-location.ts` + +```ts +| Readonly<{ readonly area: 'trace'; readonly correlation?: string; readonly invocationId?: string }> +``` + +`/trace/trc_5?correlation=conv-1` ⇄ `{ area: 'trace', correlation: 'conv-1', invocationId: 'trc_5' }`. + +## Cross-lane requests (exact edits for the integrator) + +1. **`packages/workbench/src/shell/shell.css` lines 167–178** (PR 1 trace rules, now dead): + + ```css + .problem-list, .trace-table { min-width: 0; } + ... + .problem-link, .trace-link { color: #0759c7; ... } + .problem-link:hover, .trace-link:hover { text-decoration: underline; } + .trace-status { ... } .trace-status--succeeded { ... } .trace-status--failed { ... } + .trace-entry { ... } .trace-entry dl { ... } .trace-entry dt { ... } .trace-entry dd { ... } + ``` + + Drop `.trace-table` and `.trace-link` from the shared selectors (keep `.problem-list`, `.problem-link`) + and delete the `.trace-status*` and `.trace-entry*` rules. My rows use `.trace-line`, so nothing collides + today; this is delete-on-sight. + +2. **`packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` lines 110–119** — the PR 1 trace + step reads `.trace-table tr[data-invocation-id]`. Once T1 (`/api/trace`) and T2 (invocation → trace + entries) land, replace with: + + ```ts + await openWorkbench(page, server.url, `/trace?correlation=${encodeURIComponent(invocationId)}`); + await expect(page.getByRole('heading', { name: 'Trace', exact: true })).toBeVisible({ timeout: browserTimeout }); + const traceRow = page.locator('[data-testid="trace-entry"][data-kind="invocation.completed"]').first(); + await expect(traceRow).toBeVisible({ timeout: browserTimeout }); + await expect(traceRow).toContainText(searchLeaf.routeId ?? 'tool:curator/search_audible'); + await captureExampleState(page, 'audiobook-curator', 'trace-populated'); + await traceRow.click(); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toMatch(/^\/trace\/trc_\d+$/u); + await expect(page.getByTestId('trace-detail')).toBeVisible({ timeout: browserTimeout }); + ``` + + `/trace/` still resolves (`selectTraceEntry` falls back to the latest entry carrying that + `invocationId`), so the old deep link keeps working; only the clicked-row URL changed to the entry id. + +3. **`website/docs/en/guide/development/workbench.mdx` line 172 and the `zh` twin** — `/trace/` + → `/trace/` plus a line `/trace?correlation=` ("show one correlated group"). Whoever owns the + docs page for PR 2 should also describe the Trace page: sources, correlated groups, filter bar, detail + drawer, "Open route". + +4. **T1 (`/api/trace` route)** — the client sends `GET /api/trace?after=` and `GET /api/trace/stream?after=` + through `ForegroundRequestAuthority.protectedRequest` (session header + origin guard, no custom `Accept`), + and expects the replay body `{ entries, latestSequence, gap? }` and the stream as one `TraceMessage` per + line — a bare `TraceEntry` object or a bare `TraceReplayGap` (`type: 'trace.gap'`), exactly the contract + union, no envelope. Stream entries must be contiguous from `after + 1`; a skip is `AB8249` unless a gap + frame precedes it (`requestedAfterSequence` = last delivered, then entries resume at + `firstAvailableSequence`). No heartbeat frame is expected; blank lines are ignored, so an empty line is a + safe keep-alive. If T1 emits a typed heartbeat, tell me the shape and I add it to `decodeTraceMessage`. A + refusal must be the standard `{ diagnostic: { code: 'ABnnnn', message } }` body; any refusal of a replay from a + non-zero cursor (the hub's `TRACE_CURSOR_AHEAD` after a dev-server restart) triggers restart-from-zero (the + retained list is dropped, since the hub that numbered it is gone); a refusal at `after = 0`, or an `AB8249` + decode failure, is shown in the page (`· reconnecting`) and retried with the back-off. + +5. **Server-side `source` set** — the decoder accepts exactly the seven `TraceSource` values in + `contracts/trace.ts`. A new source needs a decoder + glyph + kind label in this lane's files, not a + silent pass-through. + +## Open risks + +- No jsdom in the unit pool, so `trace-page.test.ts` covers rendering via `renderToStaticMarkup` with supplied + snapshots (empty state, groups/rows/depth, selected-entry drawer with correlation links and "Open route", + filter bar, `?correlation=` scoping, error flags). Scroll-anchoring, the "N new" pill, and the live feed + are exercised only through `openTraceFeed`'s fake-client tests and a manual 1440×900 render check; the + browser acceptance of the live behaviour belongs to the integration e2e once T1/T2 land. +- `entryId` still travels as `WorkbenchLocation.invocationId` — renaming it touches the shell and PR 1 tests + outside this lane. Cheap follow-up if the integrator wants it. +- `ProjectClient` now forwards `route.invocation` to subscribers; nothing in the Workbench consumes it yet + (the Trace page reads `/api/trace`, not project events). It exists so T6/T7 can react without another + allowlist bug, and the test pins that it does not trigger a status refetch. + +## Proposed changeset line (agent-bundle only; Workbench is private) + +None from this lane — every change is under `packages/workbench` plus `docs/diagnostics.md`. If the +integrator wants the diagnostics registry mentioned, append to the PR's single changeset: +"… the Workbench Trace page's browser decoder rejects a malformed `/api/trace` reply with `AB8249`." + +## Proposed diagnostic codes + +- `AB8249` — Workbench browser-side strict decoder rejecting a `/api/trace` replay or stream frame. Registered + in `docs/diagnostics.md` (Workbench family table). `AB8240`–`AB8248` left for the server-side trace route (T1). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 56eadb58f..d8cd7cad9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -44,6 +44,7 @@ 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. | +| `AB8249` | 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). `AB8240`–`AB8248` are reserved for the server-side trace route. | | `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.) | | `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). | diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index ba2924d4c..7c1b89324 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 } from './trace/trace-client.ts'; import { TracePage } from './trace/trace-page.tsx'; import { applicationTreeSourcesFor, @@ -119,6 +120,7 @@ const createClients = () => { routeManifestClient: new RouteManifestClient({ foreground }), runtimeClient: new RuntimeClient(foreground), skillClient: new SkillClient(), + traceClient: new ForegroundTraceClient({ foreground }), }); }; @@ -444,9 +446,7 @@ const Workbench = () => { case 'application': 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/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/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..6beaf22de --- /dev/null +++ b/packages/workbench/src/trace/trace-client.ts @@ -0,0 +1,345 @@ +/** + * Same-origin client for the unified trace (#600 PR 2): `GET /api/trace?after=` + * replays the retained window, `GET /api/trace/stream?after=` follows it as + * NDJSON. Every wire shape is decoded strictly before the page sees it — an + * unknown `source`, a stray key, a non-contiguous sequence, or path-like text + * is a `TraceClientError`, never a crash and never a rendered row. + */ +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 { mergeTraceEntries } from './trace-model.ts'; + +/** What the Trace page and the route workspace (T6) code against; `ForegroundTraceClient` is the production implementation. */ +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; +} + +/** `AB8249`: 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 = 'AB8249'; + +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', 'runId', '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 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; +}; +const identifier = /^[A-Za-z0-9_][A-Za-z0-9._:@+/-]*$/u; +/** A token that is an absolute POSIX path (two or more segments), a Windows path, a UNC path, or a `file:` URL. */ +const pathLikeText = /(?:^|[\s"'`([=:,])(?:\/[^\s/]+){2,}|file:|(?:^|[^A-Za-z0-9])[A-Za-z]:[\\/]|\\\\/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); + +/** Decodes one already-snapshotted JSON value as a `TraceEntry`; exported so fixtures and T6 can share the rule. */ +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..82ef80224 --- /dev/null +++ b/packages/workbench/src/trace/trace-model.ts @@ -0,0 +1,385 @@ +/** + * Pure model behind the Trace page (#600 PR 2): the ordered entry list, the + * correlated groups it folds into, the filters, and the selection rules the + * URL drives. No transport, no React; the page calls these with whatever the + * feed has delivered so far. + */ +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. + */ +export const traceJoinKeys = Object.freeze([ + 'conversationId', + 'sessionId', + 'invocationId', + 'executionId', + 'runId', + 'mcpRequestId', + 'correlationId', +] as const); + +export type TraceJoinKey = (typeof traceJoinKeys)[number]; + +export type TraceGroupKeyKind = TraceJoinKey | 'entry'; + +export interface TraceRow { + /** 0 for an invocation-level row, 1 for a row nested under its invocation. */ + readonly depth: 0 | 1; + readonly entry: TraceEntry; +} + +export interface TraceGroup { + readonly endedAt: string; + readonly firstSequence: number; + /** The entry that names the group in the timeline header. */ + readonly headline: TraceEntry; + /** Stable identity: the highest-priority join key the group shares, else the lone entry id. */ + readonly key: string; + readonly keyKind: TraceGroupKeyKind; + readonly lastSequence: number; + readonly rows: readonly TraceRow[]; + /** First to last `occurredAt` in milliseconds; a lone entry reports its own `durationMs`. */ + readonly spanMs: number; + readonly startedAt: string; + readonly status: TraceStatus; +} + +export interface TraceFilter { + readonly host?: string; + readonly routeId?: string; + readonly sources?: ReadonlySet; + readonly status?: TraceStatus; + /** Case-insensitive substring over `summary` and `kind`. */ + readonly text?: string; +} + +export interface TraceFacets { + readonly hosts: readonly string[]; + readonly routeIds: readonly string[]; + readonly sources: readonly TraceSource[]; +} + +const millis = (value: string): number => { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const sortedUnique = (values: readonly string[]): readonly string[] => + Object.freeze([...new Set(values)].sort((left, right) => left.localeCompare(right))); + +/** + * 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; the other keys stand alone. */ +const joinToken = (correlation: TraceCorrelation, key: TraceJoinKey): string | undefined => { + const value = correlation[key]; + if (value === undefined) return undefined; + if (key !== 'mcpRequestId') return `${key}:${value}`; + return correlation.mcpSessionId === undefined ? undefined : `${key}:${correlation.mcpSessionId}/${value}`; +}; + +const headlinePriority: Readonly> = Object.freeze({ + hook: 0, + invocation: 1, + runtime: 2, + mcp: 3, + kernel: 4, + diagnostic: 5, + log: 6, +}); + +const isInvocationLevel = (entry: TraceEntry): boolean => { + switch (entry.source) { + case 'hook': + case 'invocation': + case 'runtime': + 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 token = joinToken(entry.correlation, joinKey); + if (token === undefined) continue; + key = token; + 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)))); +}; + +const matchesText = (entry: TraceEntry, needle: string): boolean => + entry.summary.toLowerCase().includes(needle) || entry.kind.toLowerCase().includes(needle); + +export const matchesTraceFilter = (entry: TraceEntry, filter: TraceFilter): boolean => { + if (filter.sources !== undefined && filter.sources.size > 0 && !filter.sources.has(entry.source)) return false; + if (filter.host !== undefined && entry.correlation.host !== filter.host) return false; + if (filter.routeId !== undefined && entry.correlation.routeId !== filter.routeId) return false; + if (filter.status !== undefined && (entry.status ?? 'ok') !== filter.status) return false; + const needle = filter.text?.trim().toLowerCase(); + return needle === undefined || needle.length === 0 || matchesText(entry, needle); +}; + +export const isEmptyTraceFilter = (filter: TraceFilter): boolean => + (filter.sources === undefined || filter.sources.size === 0) && filter.host === undefined && + filter.routeId === undefined && filter.status === undefined && (filter.text?.trim() ?? '') === ''; + +/** + * Keeps each group's identity and headline while hiding the rows the filter + * excludes; a group with no visible row disappears. Grouping before filtering + * means a source filter on `mcp` still shows the frames under the session that + * produced them rather than re-grouping them on their own. + */ +export const filterTraceGroups = (groups: readonly TraceGroup[], filter: TraceFilter): readonly TraceGroup[] => { + if (isEmptyTraceFilter(filter)) return groups; + return Object.freeze(groups.flatMap((group) => { + const rows = group.rows.filter((row) => matchesTraceFilter(row.entry, filter)); + return rows.length === 0 ? [] : [Object.freeze({ ...group, rows: Object.freeze(rows) })]; + })); +}; + +export const traceFacetsFor = (entries: readonly TraceEntry[]): TraceFacets => Object.freeze({ + hosts: sortedUnique(entries.flatMap((entry) => entry.correlation.host === undefined ? [] : [entry.correlation.host])), + routeIds: sortedUnique(entries.flatMap((entry) => entry.correlation.routeId === undefined ? [] : [entry.correlation.routeId])), + sources: Object.freeze([...new Set(entries.map((entry) => entry.source))].sort((left, right) => + headlinePriority[left] - headlinePriority[right])), +}); + +/** Every correlation value on an entry, plus its own id: what `?correlation=` may name. */ +export 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))); + +/** + * `/trace/`: the entry with that id. A PR 1 deep link named an invocation + * id, so an `inv_…` id still resolves — to the latest entry of that invocation. + */ +export const selectTraceEntry = (entries: readonly TraceEntry[], id: string): TraceEntry | undefined => + entries.find((entry) => entry.id === id) ?? + entries.findLast((entry) => entry.correlation.invocationId === id || entry.correlation.runId === 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 'runtime': + 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'], + ['runtime.run.started', 'run started'], + ['runtime.run.completed', 'run completed'], + ['runtime.run.failed', 'run failed'], + ['runtime.generation.published', 'generation published'], + ['runtime.app.updated', 'app updated'], + ['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..295130375 --- /dev/null +++ b/packages/workbench/src/trace/trace-page.css @@ -0,0 +1,77 @@ +/* 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-filter-bar { align-items: center; background: #fff; border-bottom: 1px solid #d9dee7; border-top: 1px solid #d9dee7; display: flex; flex-wrap: wrap; gap: 10px 14px; padding: 12px 0; position: sticky; top: 0; z-index: 2; } +.trace-filter-sources { display: flex; flex-wrap: wrap; gap: 6px; } +.trace-chip { align-items: center; background: #fff; border: 1px solid #bfc8d5; border-radius: 999px; color: #2b3646; cursor: pointer; display: inline-flex; font-size: 12px; font-weight: 700; gap: 6px; padding: 4px 10px; } +.trace-chip:hover:not(:disabled) { border-color: #0b5bd3; } +.trace-chip:disabled { color: #9aa4b2; cursor: default; } +.trace-chip[aria-pressed="true"] { background: #e6effd; border-color: #0b5bd3; color: #0b3f8f; } +.trace-filter-field { align-items: center; color: #596372; display: inline-flex; font-size: 12px; font-weight: 750; gap: 8px; letter-spacing: .03em; text-transform: uppercase; } +.trace-filter-field select, .trace-filter-field input { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font-size: 13px; font-weight: 600; letter-spacing: 0; max-width: 260px; min-height: 32px; padding: 0 8px; text-transform: none; } +.trace-filter-text input { width: 220px; } +.trace-clear { background: transparent; border: 1px solid transparent; border-radius: 4px; color: #0759c7; cursor: pointer; font-size: 13px; font-weight: 700; margin-left: auto; padding: 5px 10px; } +.trace-clear:hover:not(:disabled) { background: #e6effd; } +.trace-clear:disabled { color: #9aa4b2; cursor: default; } + +.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; } +.trace-new-pill { background: #0b5bd3; border: 0; border-radius: 999px; bottom: 18px; box-shadow: 0 6px 18px rgba(11, 91, 211, .35); color: #fff; cursor: pointer; font-size: 13px; font-weight: 750; left: 50%; padding: 8px 16px; position: absolute; transform: translateX(-50%); } +.trace-new-pill:hover { background: #0949a8; } + +/* 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-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..d13b774b0 100644 --- a/packages/workbench/src/trace/trace-page.tsx +++ b/packages/workbench/src/trace/trace-page.tsx @@ -1,227 +1,362 @@ -/** Route invocations from the current dev session, newest first. */ -import React, { useEffect, useState } from 'react'; +/** + * The Trace page (#600 PR 2): one correlated, live timeline of everything the + * dev server observed the application doing. Entries arrive from `/api/trace` + * already lowered; this page groups them, filters them, and opens the full + * record behind each entry's `href`. + */ +import React, { useEffect, useMemo, useRef, 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 { type TraceEntry, type TraceSource, type TraceStatus, traceSources } from '../../../agent-bundle/src/contracts/trace.ts'; +import { formatWorkbenchLocation, parseWorkbenchLocation, type WorkbenchLocation } from '../shell/workbench-location.ts'; +import { openTraceFeed, type TraceClient, type TraceFeedState } from './trace-client.ts'; +import { + filterTraceGroups, + formatTraceDuration, + formatTraceTime, + groupTraceEntries, + isEmptyTraceFilter, + selectTraceEntry, + selectTraceGroup, + traceFacetsFor, + traceKindLabel, + traceSourceGlyph, + type TraceFacets, + type TraceFilter, + 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; + /** `?correlation=`: show only the group holding an entry that carries this id. */ + readonly correlation?: string; + /** A supplied snapshot keeps server/static rendering deterministic; the live feed is not opened. */ + readonly entries?: readonly TraceEntry[]; + /** `/trace/`: the selected entry (a trace entry id, or a PR 1 invocation id). */ + 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; -}; +const all = ''; +const bottomThresholdPx = 8; + +interface FilterState { + readonly host: string; + readonly routeId: string; + readonly sources: ReadonlySet; + readonly status: string; + readonly text: string; +} -/** 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 emptyFilter: FilterState = Object.freeze({ host: all, routeId: all, sources: new Set(), status: all, text: all }); + +const isStatus = (value: string): value is TraceStatus => value === 'ok' || value === 'error' || value === 'running'; + +const traceFilterFor = (state: FilterState): TraceFilter => Object.freeze({ + ...(state.host === all ? {} : { host: state.host }), + ...(state.routeId === all ? {} : { routeId: state.routeId }), + ...(state.sources.size === 0 ? {} : { sources: state.sources }), + ...(isStatus(state.status) ? { status: state.status } : {}), + ...(state.text === all ? {} : { text: state.text }), +}); + +const groupKeyLabel = (kind: TraceGroupKeyKind): string => { + switch (kind) { + case 'conversationId': + return 'conversation'; + case 'sessionId': + return 'session'; + case 'invocationId': + return 'invocation'; + case 'executionId': + return 'execution'; + case 'runId': + return 'run'; + 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)); +/** Opens the live feed once per client; a supplied snapshot short-circuits it. */ +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; -} +/** A shell link: a real `href` for middle-click and copy, the router for a plain click. */ +const Link = ({ location, onNavigate, ...anchor }: { + readonly location: WorkbenchLocation; + readonly onNavigate: (location: WorkbenchLocation) => void; +} & Omit, 'href' | 'onClick'>) => + { event.preventDefault(); onNavigate(location); }} />; -export interface TraceHistory { - readonly entries: readonly RouteInvocationSummary[]; - /** The first non-abort failure among the per-leaf history reads, when any. */ - readonly error?: string; -} +const StatusPill = ({ status }: { readonly status: TraceStatus }) => + {status}; -/** - * 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.') }), - }); +const FilterBar = ({ facets, filter, onChange }: { + readonly facets: TraceFacets; + readonly filter: FilterState; + readonly onChange: (next: FilterState) => void; +}) => { + const toggleSource = (source: TraceSource): void => { + const sources = new Set(filter.sources); + if (!sources.delete(source)) sources.add(source); + onChange({ ...filter, sources }); + }; + const active = !isEmptyTraceFilter(traceFilterFor(filter)); + return
    +
    + {traceSources.map((source) => )} +
    + + + + + +
    ; }; -/** 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 = ({ client, correlation, entries: suppliedEntries, entryId, onNavigate, timeZone }: TracePageProps) => { + const feed = useTraceFeed(client, suppliedEntries); + const [filter, setFilter] = useState(emptyFilter); + const groups = useMemo(() => groupTraceEntries(feed.entries), [feed.entries]); + const facets = useMemo(() => traceFacetsFor(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 visible = filterTraceGroups(scope, traceFilterFor(filter)); + const lastSequence = feed.entries.at(-1)?.sequence ?? 0; -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} -

    + // New groups land at the bottom. While the user is reading further up the + // timeline the scroll position stays put and the pill counts what arrived; + // at the bottom the timeline follows the feed. + const timelineRef = useRef(null); + const [atBottom, setAtBottom] = useState(true); + const [seenSequence, setSeenSequence] = useState(0); + useEffect(() => { + if (!atBottom) return; + const timeline = timelineRef.current; + if (timeline !== null) timeline.scrollTop = timeline.scrollHeight; + setSeenSequence(lastSequence); + }, [atBottom, lastSequence]); + const pending = atBottom ? 0 : visible.filter((group) => group.firstSequence > seenSequence).length; + const onScroll = (): void => { + const timeline = timelineRef.current; + if (timeline !== null) setAtBottom(timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight <= bottomThresholdPx); + }; + + 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)}

    + : visible.length === 0 + ?

    {correlation !== undefined && correlatedGroup === undefined ? `No entry carries ${correlation}.` : 'No entry matches this filter.'}

    + : visible.map((group) => )} +
    + {pending === 0 ? undefined : }
    - {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/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/support/trace-fixtures.ts b/packages/workbench/tests/support/trace-fixtures.ts new file mode 100644 index 000000000..40b2dce03 --- /dev/null +++ b/packages/workbench/tests/support/trace-fixtures.ts @@ -0,0 +1,95 @@ +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, a failed runtime run, 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: { host: 'portable', routeId: 'tool:curator/search', runId: 'run_9' }, + href: '/routes/mcp/curator/tool/search?invocation=run_9', + kind: 'runtime.run.failed', + occurredAt: at(12_000), + source: 'runtime', + status: 'error', + summary: 'devRuntime run failed: fixture invalid', + }), + traceEntry(9, { + correlation: {}, + kind: 'log.build.started', + occurredAt: at(15_000), + source: 'log', + summary: 'Project build started.', + }), +]); diff --git a/packages/workbench/tests/trace-client.test.ts b/packages/workbench/tests/trace-client.test.ts new file mode 100644 index 000000000..0a754c98f --- /dev/null +++ b/packages/workbench/tests/trace-client.test.ts @@ -0,0 +1,272 @@ +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(9); + 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)); }; + 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..d000a44a1 --- /dev/null +++ b/packages/workbench/tests/trace-model.test.ts @@ -0,0 +1,138 @@ +import { expect, it } from '@rstest/core'; + +import { + filterTraceGroups, + formatTraceDuration, + formatTraceTime, + groupTraceEntries, + isEmptyTraceFilter, + matchesTraceFilter, + maximumTraceEntries, + mergeTraceEntries, + selectTraceEntry, + selectTraceGroup, + traceEntryCorrelationValues, + traceFacetsFor, + 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]], + ['runId:run_9', 'runId', [8]], + ['entry:trc_9', 'entry', [9]], + ]); + 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('error'); + expect(groups[3]?.rows[0]?.depth).toBe(0); + expect(groups[3]?.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]], + ['mcpRequestId:s1/1', [3]], + ['mcpRequestId:s2/1', [4]], + ['entry:trc_5', [5]], + ]); + expect(groups[0]?.status).toBe('running'); + expect(groupTraceEntries([])).toEqual([]); +}); + +it('filters rows by source, host, route, status, and text while keeping group identity, and drops empty groups', () => { + const groups = groupTraceEntries(sampleTraceEntries); + const mcpOnly = filterTraceGroups(groups, { sources: new Set(['mcp']) }); + expect(mcpOnly.map((group) => [group.key, sequences(group.rows.map((row) => row.entry))])).toEqual([['conversationId:conv-1', [5, 6]]]); + expect(mcpOnly[0]?.headline.kind).toBe('session.started'); + + expect(filterTraceGroups(groups, { host: 'portable' }).map((group) => group.key)).toEqual(['runId:run_9']); + expect(filterTraceGroups(groups, { routeId: 'tool:curator/search' }).map((group) => group.key)).toEqual(['invocationId:inv_3', 'runId:run_9']); + expect(filterTraceGroups(groups, { status: 'error' }).map((group) => group.key)).toEqual(['runId:run_9']); + expect(filterTraceGroups(groups, { status: 'ok' }).flatMap((group) => sequences(group.rows.map((row) => row.entry)))).toEqual([1, 2, 3, 4, 5, 6, 7, 9]); + expect(filterTraceGroups(groups, { text: ' HAULER_status ' }).flatMap((group) => sequences(group.rows.map((row) => row.entry)))).toEqual([5, 6]); + expect(filterTraceGroups(groups, { text: 'render.finish' }).flatMap((group) => sequences(group.rows.map((row) => row.entry)))).toEqual([3]); + expect(filterTraceGroups(groups, { sources: new Set(['diagnostic']) })).toEqual([]); + + expect(filterTraceGroups(groups, {})).toBe(groups); + expect(isEmptyTraceFilter({ sources: new Set(), text: ' ' })).toBe(true); + expect(isEmptyTraceFilter({ host: 'claude' })).toBe(false); + expect(matchesTraceFilter(sampleTraceEntries[7]!, { status: 'error', host: 'portable' })).toBe(true); + expect(matchesTraceFilter(sampleTraceEntries[7]!, { status: 'error', host: 'claude' })).toBe(false); +}); + +it('exposes facets in a stable order', () => { + expect(traceFacetsFor(sampleTraceEntries)).toEqual({ + hosts: ['claude', 'portable'], + routeIds: ['event:session/start', 'event:tool/before', 'tool:curator/search', 'tool:hauler/hauler_status'], + sources: ['hook', 'invocation', 'runtime', 'mcp', 'kernel', 'log'], + }); +}); + +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, 'trc_8')?.key).toBe('runId:run_9'); + 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, 'run_9')?.sequence).toBe(8); + expect(selectTraceEntry(sampleTraceEntries, 'exec-1')).toBeUndefined(); + expect(traceEntryCorrelationValues(sampleTraceEntries[5]!)).toEqual(['trc_6', '7', 'mcp-1']); +}); + +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[8]!)).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', 'runtime', 'hook', 'log', 'diagnostic'].map((source) => traceSourceGlyph(source as 'mcp'))).size).toBe(7); +}); diff --git a/packages/workbench/tests/trace-page.test.ts b/packages/workbench/tests/trace-page.test.ts index 0e1a89bfc..b454bfc07 100644 --- a/packages/workbench/tests/trace-page.test.ts +++ b/packages/workbench/tests/trace-page.test.ts @@ -3,113 +3,128 @@ 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), - routeId: 'tool:curator/search_audible', - source: 'src/mcp/curator/tools/search_audible.tsx', - sourceRevision: 'r', - startedAt: '2026-09-05T07:00:00.000Z', - status: 'succeeded', - 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, +})); + +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('9 entries in 4 groups'); + expect(markup).toContain('data-testid="trace-timeline"'); + expect(markup).toContain('data-testid="trace-filter-bar"'); + expect(count(markup, 'data-testid="trace-group"')).toBe(4); + expect(count(markup, 'data-testid="trace-entry"')).toBe(9); + expect(markup).not.toContain('data-testid="trace-empty"'); + expect(markup).not.toContain('data-testid="trace-detail"'); + expect(markup).not.toContain('data-testid="trace-new-pill"'); -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); + expect(markup.indexOf('data-group-key="conversationId:conv-1"')).toBeLessThan(markup.indexOf('data-group-key="runId:run_9"')); + 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('trace-row--error'); + expect(markup).toContain('aria-label="error"'); + expect(markup).toContain('data-group-key="entry:trc_9"'); }); -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); +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); + expect(count(empty, 'trace-chip')).toBe(7); + expect(count(empty, 'disabled=""')).toBe(8); + + 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('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('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_9' }); + 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('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('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"'); }); -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('renders the filter bar with facets from the entries and every source chip', () => { + const markup = render(); + expect(markup).toContain(''); + expect(markup).toContain(''); + expect(markup).toContain(''); + expect(markup).toContain(''); + expect(markup).toContain('placeholder="Filter summaries…"'); + expect(markup).toContain('>Clear'); + for (const source of ['invocation', 'kernel', 'mcp', 'runtime', 'hook', 'log', 'diagnostic']) expect(markup).toContain(`data-source="${source}"`); + expect(markup).toContain('data-source="diagnostic" disabled=""'); + expect(markup).not.toContain('data-source="mcp" disabled=""'); }); 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', () => { From dcd4222b2cdbc4fa26742a06efbeadc173ba5cb1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 15:48:25 +0000 Subject: [PATCH 09/70] feat(dev): host-invoked hook receipts on the trace (#600 PR 2, lane T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated hook wrappers of the dev plugin post a slim receipt — the kernel's EventTraceEvents, EventTraceExecution identity, resolved lineage keys, and the host/session/request ids the native payload names, never the payload — to the authenticated dev server's POST /api/trace/receipts, discovered through the dev install marker (.agent-bundle-dev.json -> /.agent-bundle/hook-receipts.json) or AGENT_BUNDLE_DEV_TRACE_URL/AGENT_BUNDLE_DEV_TRACE_TOKEN for dev-server-spawned simulations. HookReceiptRoutes lowers each receipt to hook.received / hook.completed / hook.failed (+ session.started / session.ended) on the TraceHub. Diagnostics AB8247-AB8249. Wiring for T1 in LANE-NOTES.md. --- LANE-NOTES.md | 289 +++++++++++ docs/diagnostics.md | 1 + .../src/adapters/hook-contract.ts | 137 +++-- .../agent-bundle/src/core/loopback-origin.ts | 17 + .../src/dev/hooks/hook-receipt-endpoint.ts | 190 +++++++ .../src/dev/hooks/hook-receipts.ts | 435 ++++++++++++++++ packages/agent-bundle/src/events/project.ts | 21 + .../agent-bundle/src/events/trace-receipt.ts | 268 ++++++++++ .../agent-bundle/src/services/hook-service.ts | 12 +- .../tests/hook-receipt-pipe.test.ts | 220 +++++++++ .../agent-bundle/tests/hook-receipts.test.ts | 467 ++++++++++++++++++ rstest.integration-tests.ts | 1 + 12 files changed, 2010 insertions(+), 48 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/core/loopback-origin.ts create mode 100644 packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts create mode 100644 packages/agent-bundle/src/dev/hooks/hook-receipts.ts create mode 100644 packages/agent-bundle/src/events/trace-receipt.ts create mode 100644 packages/agent-bundle/tests/hook-receipt-pipe.test.ts create mode 100644 packages/agent-bundle/tests/hook-receipts.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..523d17188 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,289 @@ +# Lane T7 — host-invoked hook receipts on the trace (#600 PR 2) + +Branch `lane/wb600-pr2-t7`, worktree `/fast/projects/agent-bundle-wt/wb600-pr2-t7`. + +## The seam, and why (deliverable 1) + +**Chosen: (b), a slim `POST /api/trace/receipts` on the authenticated foreground +server, with two discovery paths.** (a) was checked and does not exist: the dev +artifact's event executables talk only to their *own* MCP process's event +runtime (`events/ipc.ts`, `agent-bundle/event-ipc`, `endpointId = +:`), never to anything the dev server owns. The dev +server's only channels to an attached host are the MCP proxy (`host-mcp-proxy.ts` +→ `host-mcp-routes.ts`) and the hook wrapper's own stdin/stdout, and a hook +wrapper does not go through the proxy: Claude/Codex/Cursor exec +`node /hooks/.mjs` directly. The shared runtime +(`mcp-server-runtime.ts` → `createEventRuntimeServer`) runs inside the dev +server *only* for `dev.host.sync` hosts whose MCP server is the one the proxy +spawned, and even then the kernel tracer for a shared-runtime request lives in +that MCP child, not in the foreground process. So the least new machinery is +one wrapper-side carrier and one route. + +Discovery, in order, by the generated wrapper (`events/trace-receipt.ts`, +`resolveEventTraceReceiptEndpoint`): + +1. `AGENT_BUNDLE_DEV_TRACE_URL` + `AGENT_BUNDLE_DEV_TRACE_TOKEN` — set by the dev + server when *it* spawns a simulation (`HookService` gained an + `environment` option; `HookReceiptAttachment.environment(url)` produces the + pair). A real host never has these. +2. The dev install marker the dev host installer already writes at the + installed bundle root (`.agent-bundle-dev.json`, `DEV_INSTALL_MARKER` in + `dev/host-install-manager.ts`; the wrapper spells it as + `DEV_INSTALL_MARKER_FILE` so the bundle does not pull the installer in — + `hook-receipts.test.ts` pins the two equal). Its `projectRoot` names the + project whose dev server published + `/.agent-bundle/hook-receipts.json` = `{ url, token }` (mode + 0600, replaced not overwritten, removed on close). This reuses the existing + attach mechanism unchanged: no installer edit, no host config edit, no new + env for real hosts. +3. Neither → `undefined` → the tracer is created without an observer + (disabled) and the wrapper behaves exactly as before. A production install + pays one failed `readFile` of a sibling path. + +The wrapper never blocks the host on the Workbench: `send()` runs in a +`finally`, is bounded by `AbortSignal.timeout(750 ms)`, swallows every error, +and skips a body over 16 KiB. Exit code and stdout are unchanged in every path +(integration test asserts both for success and for a thrown route). + +## The receipt (deliverable 2) + +`EventTraceReceipt` (`events/trace-receipt.ts`), version 1: + +| field | content | +| --- | --- | +| `execution` | `EventTraceExecution` — `event`, `executionId` (UUID minted per wrapper process), `host`, `nativeEvent` | +| `events` | the kernel's `EventTraceEvent`s for this execution minus their repeated `execution` (`execute.start`, `providers.*`, `render.*`, `preflight.*`, `failure` with the kernel's `EventTraceErrorSummary`) | +| `identity` | `sessionId` (`session_id`, else Cursor `conversation_id`), `conversationId` (Claude/Codex `agent_id` else `session_id`; Cursor `conversation_id`), `requestId` (`tool_use_id` / `tool_call_id`) — per `docs/entry-conventions.md` | +| `lineage` | `RequestProvenanceAxis` from the wrapper's existing `resolveStandaloneLineage` (`conversation`, `root`, `parent`, `depth`, `generation`, `resolution`, `subagent{id,type,toolCallId,isParallelWorker}`) — the runtime's `Observed` without its live `tree` | +| `startedAt` | ISO instant of `events[0]`; each event's `at` is the tracer's monotonic clock | + +Never present: the native payload, `tool_input` / `tool_response`, `cwd`, +`transcript_path`, the environment, any filesystem path, the token. + +Lowering (`dev/hooks/hook-receipts.ts`, `lowerHookReceipt`, pure): + +- `hook.received` (status `ok`, `occurredAt = startedAt`) → then + `hook.completed` (status `ok`, `durationMs`, `details.gate` = `deny` / + `continue` when preflight short-circuited) or `hook.failed` (status `error`, + `details.error` = kernel summary, `details.failedPhase`). +- `session/start` adds `session.started` after `hook.received`; `session/end` + adds `session.ended` after the terminal entry. +- Kernel events ride as `details.events[] = { kind, phase, atMs, durationMs?, + outcome? | runtime? | count? }` on the terminal entry — not as entries of + their own, so a host turn reads as N hooks, not 6N rows. +- `correlation`: `executionId`, `host`, `routeId = event:`, + `sessionId`, `requestId`, `conversationId` (identity first, else the + runtime's `lineage.value.conversation`). +- `href = applicationNodePath({ kind: 'event', event })`, no `?invocation=`. + T5/T6: a `source: 'hook'` entry has no `RouteInvocation`; "Replay in + workspace" should build the event fixture from `details.execution.nativeEvent` + and the route page, not from an invocation id. + +Wrapper changes (`adapters/hook-contract.ts`): both templates now build +`execution` once, `await openEventTraceReceipt(...)`, create the tracer with the +receipt's observer, call `receipt.identity(native)`, and `send()` in `finally`. +The plain wrapper traces `execute.start()` (again on the +shared→standalone fallback), `render.start/finish`, and `failure('execute' | +'render', …)`; `runStandalone` also hands the resolved lineage to the receipt. +The preflight wrapper keeps its existing tracer calls and gains the receipt. The +deferred executor spawned by a preflight wrapper does **not** open a receipt +(`receipt = undefined`, disabled tracer): the preflight wrapper already owns +that execution's receipt, so one host invocation yields one receipt — but that +receipt carries `lineage: { state: 'unavailable', reason: 'not-provided' }` +because lineage is resolved in the executor. Open risk below. + +## `/api/lineage` (deliverable 3) — skipped, by the stated rule + +`AgentLineageRegistry.snapshot()` reads the plugin's `state/` SQLite, which only +the generated MCP process opens (`@agent-bundle/runtime/state/sqlite` in the +built entry); no dev-server service opens that store today, and the wrapper's +standalone path holds no registry at all (`resolveStandaloneLineage` is +payload-derived). Opening it from the foreground would be exactly the new +cross-process store access the task said to skip. Instead every receipt carries +the resolved lineage keys, so Trace can label a group by +`correlation.conversationId` / `details.lineage.value.root` today without a +snapshot route. No change under `packages/rsc-runtime/src/agent-lineage/**`. + +## Files + +Added +- `packages/agent-bundle/src/core/loopback-origin.ts` — `isLoopbackHttpOrigin` + (shared by wrapper and server; the one shape the endpoint may be). +- `packages/agent-bundle/src/events/trace-receipt.ts` — wire shape, discovery, + `openEventTraceReceipt` recorder. Re-exported from `events/project.ts`, so it + ships in `dist/event-project.js` = the module every wrapper imports. +- `packages/agent-bundle/src/dev/hooks/hook-receipts.ts` — `decodeHookReceipt` + (strict, closed objects, bounded strings, kernel enums), `lowerHookReceipt`, + `hookReceiptOutcome`, codes. +- `packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts` — + `HookReceiptRoutes` (`handle(request, response): Promise`), + `attachHookReceipts(options): HookReceiptAttachment`. +- `packages/agent-bundle/tests/hook-receipts.test.ts` (unit, 15 tests: decoder + rejections, lowering with a fake publisher, route auth matrix over a real + loopback listener, endpoint file, discovery, recorder). +- `packages/agent-bundle/tests/hook-receipt-pipe.test.ts` (integration: builds a + Claude `tool/before` + throwing `tool/after` fixture, spawns the emitted + `hooks/*.mjs` with a native `PreToolUse` payload four ways — env, marker, + thrown, dev server gone — and asserts the entries on a `TraceHub`). + +Changed +- `packages/agent-bundle/src/adapters/hook-contract.ts` — both wrapper templates + (above). `trace.ts` untouched; no `trace.ts` need surfaced. +- `packages/agent-bundle/src/events/project.ts` — re-exports. +- `packages/agent-bundle/src/services/hook-service.ts` — `HookServiceOptions.environment?: () => Record`, + merged into the wrapper child env. +- `rstest.integration-tests.ts` — registers the integration test. +- `docs/diagnostics.md` — `AB8247`–`AB8249`. + +Production reachability: `trace-receipt.ts` ← `events/project.ts` (every +wrapper); `loopback-origin.ts` ← `trace-receipt.ts`; `hook-receipts.ts` ← +`hook-receipt-endpoint.ts`; **`hook-receipt-endpoint.ts` has no production +importer until T1 applies the wiring below** — that is the cross-lane seam the +task defined. + +## Exported API + +```ts +// packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts +export const attachHookReceipts: (options: { + readonly projectRoot: string; + readonly token?: string; // test seam + readonly trace: TracePublisher; +}) => HookReceiptAttachment; + +export interface HookReceiptAttachment { + readonly routes: HookReceiptRoutes; // handle(req, res): Promise + readonly token: string; + publishEndpoint(url: string): Promise; // writes /.agent-bundle/hook-receipts.json + environment(url: string): Readonly>; // AGENT_BUNDLE_DEV_TRACE_{URL,TOKEN} + close(): Promise; // refuses further posts, removes the record +} +``` + +Option name is `trace` as the brief specifies. + +## Cross-lane requests (T1 / integrator) — exact edits + +`packages/agent-bundle/src/dev/workbench-server.ts` + +```ts +import { attachHookReceipts } from './hooks/hook-receipt-endpoint.ts'; +// where the TraceHub is constructed: +const hookReceipts = attachHookReceipts({ projectRoot: root, trace: traceHub }); +let hookReceiptUrl: string | undefined; +// HookPlaygroundService construction (line ~806) — so Workbench simulations land on the trace too: +const hookPlayground = new HookPlaygroundService({ + epochStore, logger: logs, registry, platformRuntime, + hookService: new HookService({ + environment: () => (hookReceiptUrl === undefined ? {} : hookReceipts.environment(hookReceiptUrl)), + registry, + }), +}); +// the ForegroundCoordinator surface (line ~538): +publishServerUrl: async (url: string) => { + await coordinator.publishServerUrl(url); + hookReceiptUrl = url; + await hookReceipts.publishEndpoint(url); +}, +// pass to the server: +new ForegroundServer({ …, hookReceipts: hookReceipts.routes, … }) +// in the close sequence, before the foreground closes: +await hookReceipts.close(); +``` + +`packages/agent-bundle/src/dev/foreground-server.ts` + +```ts +import type { HookReceiptRoutes } from './hooks/hook-receipt-endpoint.ts'; +// ForegroundServerOptions: +readonly hookReceipts?: HookReceiptRoutes; +// field + constructor: +readonly #hookReceiptRoutes: HookReceiptRoutes | undefined; +this.#hookReceiptRoutes = options.hookReceipts; +// #handle, right after the host MCP routes and before every session-authorized route: +if (await this.#hookReceiptRoutes?.handle(request, response)) return; +``` + +The route authorizes itself (bearer token, loopback, no `Origin`); do **not** +wrap it in `#assertMutationSession` — the caller is a wrapper process with no +cookie or session header. `requestHostMatches` at the top of `#handle` still +applies and passes: the wrapper posts to the exact origin the record names. + +T5 (`packages/workbench/src/trace/**`): entries arrive with `source: 'hook'`, +kinds `hook.received | hook.completed | hook.failed | session.started | +session.ended`, `details` as described above. T2: no overlap — `kernel.*` +entries from an in-process render and `hook.*` from a host share only +`executionId` semantics, never an id (different processes). + +## Security posture + +**Exposed:** one route, `POST /api/trace/receipts`, on the foreground dev +server, which already binds loopback only; the route re-checks +`socket.remoteAddress ∈ {127.0.0.1, ::1, ::ffff:127.0.0.1}` (403 `AB8247` +otherwise). **To whom:** processes on the developer's machine that hold the +per-dev-server receipt token — the generated hook wrappers of the *dev* install +(they read it from `/.agent-bundle/hook-receipts.json`, written +mode 0600 by the dev server and removed on close) and hook simulations the dev +server itself spawns (token in the child's environment). **Authenticated by:** +`Authorization: Bearer `, 32 random bytes base64url minted per +`attachHookReceipts` (per dev server run), compared in constant time; the +browser session cookie and `x-agent-bundle-session` header are not accepted, +and any request carrying an `Origin` header is refused, so a page in the +Workbench origin cannot post a receipt even with the token; `Content-Type` must +be JSON, the body is capped at 16 KiB before parse (413 `AB8249`), parsed +without duplicate keys, and decoded by a closed-shape validator that rejects +unknown keys, unlisted enums, and over-long strings (400 `AB8248` naming the +field). Wrapper side, the endpoint is honored only if its `url` is a serialized +loopback HTTP origin (`http://127.0.0.1:` / `http://[::1]:`, nothing +after the authority), so a tampered record or env cannot make a wrapper post +anywhere else. **Never sent:** the native payload, tool input/output, `cwd`, +`transcript_path`, `workspace_roots`, the environment, absolute paths, the +token itself (the receipt is what `openEventTraceReceipt` builds from ids and +kernel events only; `hook-receipt-pipe.test.ts` asserts the trace contains +neither the tool command, `tool_input`, the fixture root, nor the token). The +receipt never alters the host's permission behavior: it is posted after the +wrapper has already produced its stdout and cannot change exit code or output +(`finally`, 750 ms cap, all errors swallowed, sends at most once). + +## Open risks + +- A preflight-gated route's receipt has no lineage (resolved in the deferred + executor, which deliberately opens no receipt). Fix if wanted: pass + `executionId` to the executor on stdin and let the executor post a second, + lineage-only receipt, or have the executor print lineage on a side channel — + both are additions inside `hook-contract.ts`, none needed for PR 2. +- Shared-runtime executions (the MCP child's `createEventRuntimeServer`) are + reported by the wrapper that requested them (`execute.start('shared')`, then + the outcome), not from inside the child, so `providers.*` / `render.*` are + absent for that path; the trace still gets received/completed/failed. +- The endpoint record lives at `/.agent-bundle/hook-receipts.json`; + `.agent-bundle` is already gitignored and in the config ignore list, and + `dev-lock.ts` uses the same directory. +- `attachHookReceipts` has no production importer until T1 wires it + (intentional; brief). + +## Proposed changeset line (patch) + +> Report host-invoked hook and event-route executions of the dev plugin to the +> Workbench trace: generated hook wrappers post a slim receipt (kernel +> `EventTraceEvent`s, execution identity, lineage keys, host/session/request +> ids — never the payload) to the authenticated dev server's +> `POST /api/trace/receipts`, discovered through the dev install marker or +> `AGENT_BUNDLE_DEV_TRACE_URL`/`AGENT_BUNDLE_DEV_TRACE_TOKEN`; lowered to +> `hook.received` / `hook.completed` / `hook.failed` (+ `session.started` / +> `session.ended`). New diagnostics `AB8247`–`AB8249`. (#PR) + +## Diagnostic codes + +`AB8247` refused (403 / 409 closed), `AB8248` malformed (400), `AB8249` over +16 KiB (413) — registered in `docs/diagnostics.md`. Took the top of the +`AB8240`–`AB8249` trace range to leave `AB8240`–`AB8246` for T1/T5. + +## Gate + +`pnpm build` ✓ · `npx tsc --noEmit` ✓ · `pnpm lint` ✓ · +`npx rstest --config rstest.unit.config.ts tests/hook-receipts.test.ts tests/event-trace.test.ts tests/target-hook-contract.test.ts tests/hook-handler-contract.test.ts` ✓ (33) · +`npx rstest --config rstest.integration.config.ts tests/hook-receipt-pipe.test.ts tests/generated-route-server.test.ts` ✓ (see below) · +also `tests/hook-playground-service.test.ts tests/hooks.test.ts` ✓ (57) and the +bundling unit files (`entry-shell`, `entries`, `adapter-contract`, +`inspect-bundler`, `artifact-validator`) ✓ (58). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 56eadb58f..3212348b0 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,7 @@ even when no error diagnostic was reported. | `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. | | `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.) | +| `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). | | `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 741f40f61..b9e5764d6 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -667,8 +667,15 @@ 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 = [ - ...(standalone ? ['createCanonicalEventProps', 'projectEventDocument'] : []), + ...(standalone ? ['createCanonicalEventProps'] : []), + 'createEventTracer', + 'eventTraceExecution', + ...(deferredExecution ? [] : ['openEventTraceReceipt']), + ...(standalone ? ['projectEventDocument'] : []), 'validateNativeEventEnvelope', ]; return [ @@ -782,7 +789,7 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal, observation) => {', + 'const runStandalone = async (native, signal, observation, 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;', @@ -790,17 +797,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 } } }, 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 } } }, signal));', + ' } catch (error) {', + ' trace.failure("render", error);', + ' throw error;', + ' }', + ' trace.renderFinish();', ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', '};', ] @@ -827,25 +843,44 @@ const eventRouteHookWrapperSource = ( ' const observation = 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);'] - : [' fail("standalone runtime was not compiled");']), - ' } else {', - ' try {', - ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });', - ' } catch (error) {', + ? [ + ' trace.executeStart("standalone");', + ' output = await runStandalone(native, controller.signal, observation, 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, 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);', + ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', + ' trace.executeStart("standalone");', + ' output = await runStandalone(native, controller.signal, observation, 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) => {', @@ -874,6 +909,7 @@ const eventRoutePreflightWrapperSource = ( 'createEventTracer', 'eventTraceExecution', 'executeEventPreflight', + 'openEventTraceReceipt', 'projectEventPreflightResult', 'validateNativeEventEnvelope', ]; @@ -923,33 +959,40 @@ const eventRoutePreflightWrapperSource = ( ' const controller = new AbortController();', ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', - ' const trace = createEventTracer({ execution: eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent }) });', - ' const gate = await executeEventPreflight(preflight, {', - ' canonical: props.canonical,', - ' host: { name: target, nativeEvent },', - ' signal,', - ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', - ' }, trace);', - ' if (gate !== "execute") {', - ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', - ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', - ' return;', - ' }', - ' trace.executeStart(runtimeMode);', - ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, sequence: props.canonical.sequence }));', - ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', - ' const terminate = () => controller.abort();', - ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', - ' let output;', + ' const execution = eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent });', + ' const receipt = await openEventTraceReceipt({ anchor: import.meta.url, env: process.env, execution });', + ' const trace = createEventTracer({ execution, ...(receipt === undefined ? {} : { observer: receipt.observer }) });', + ' receipt?.identity(native);', ' try {', - ' output = await runExecutor(executionInput, controller.signal);', - ' } catch (error) {', - ' trace.failure("execute", error);', - ' throw error;', + ' const gate = await executeEventPreflight(preflight, {', + ' canonical: props.canonical,', + ' host: { name: target, nativeEvent },', + ' signal,', + ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', + ' }, trace);', + ' if (gate !== "execute") {', + ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', + ' return;', + ' }', + ' trace.executeStart(runtimeMode);', + ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, sequence: props.canonical.sequence }));', + ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', + ' const terminate = () => controller.abort();', + ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', + ' let output;', + ' try {', + ' output = await runExecutor(executionInput, controller.signal);', + ' } catch (error) {', + ' trace.failure("execute", error);', + ' throw error;', + ' } finally {', + ' for (const terminationSignal of terminationSignals) process.off(terminationSignal, terminate);', + ' }', + ' if (output.length > 0) process.stdout.write(output);', ' } 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/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/hooks/hook-receipt-endpoint.ts b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts new file mode 100644 index 000000000..fe5ce2e1a --- /dev/null +++ b/packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts @@ -0,0 +1,190 @@ +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'; + +/** + * `POST /api/trace/receipts` on the authenticated foreground dev server + * (#600 PR 2, lane T7), and the endpoint record that tells the dev plugin's + * hook wrappers where it is. + * + * The caller is a generated hook wrapper running inside a host's process + * tree, not the Workbench browser, so the route has its own credential: a + * per-dev-server bearer token minted here, published only to + * `/.agent-bundle/hook-receipts.json` (owner-only mode) and to + * the environment of a dev-server-spawned hook simulation. The browser + * session cookie and same-session header never authorize this route, a + * request carrying an `Origin` header is refused outright, and the peer must + * be loopback — the foreground server only listens there, and this route + * checks again. + */ + +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; + } + + /** True when the request was this route's; the foreground server tries the next handler otherwise. */ + 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; + /** Test seam; production mints 32 random bytes. */ + readonly token?: 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; +} + +/** + * Builds the receipt route and the endpoint publication for one dev server. + * Wiring (T1, `workbench-server.ts` → `ForegroundServer` dispatch): construct + * with the server's `TraceHub`, dispatch `routes.handle` before the + * session-authorized API routes, publish the endpoint after the server URL, + * close with the server. + */ +export const attachHookReceipts = (options: AttachHookReceiptsOptions): HookReceiptAttachment => { + const token = options.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({ 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..8f546ec9f --- /dev/null +++ b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts @@ -0,0 +1,435 @@ +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'; + +/** + * Server side of the hook receipt (#600 PR 2, lane T7): the strict decoder for + * what a generated hook wrapper posts to `POST /api/trace/receipts`, and the + * lowering of one receipt onto the unified trace. + * + * One receipt becomes `hook.received` and then `hook.completed` or + * `hook.failed`; a `session/start` receipt adds `session.started`, a + * `session/end` receipt `session.ended`. The kernel's phase events ride along + * as slim `details` on the terminal entry rather than as entries of their own, + * so a host turn that fires five hooks reads as five things on the trace, not + * thirty. `href` opens the event route page; there is no `RouteInvocation` for + * a host-invoked hook, so there is no `?invocation=` — the workspace can offer + * "Replay in workspace" through the event fixture instead. + */ + +/** Diagnostic codes this lane owns in the Workbench trace range (`AB8240`–`AB8249`). */ +export const HOOK_RECEIPT_UNAUTHORIZED_CODE = 'AB8247'; +export const HOOK_RECEIPT_MALFORMED_CODE = 'AB8248'; +export const HOOK_RECEIPT_TOO_LARGE_CODE = 'AB8249'; + +/** Most kernel events a receipt may carry: one execution emits at most nine. */ +export const HOOK_RECEIPT_MAX_EVENTS = 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 > HOOK_RECEIPT_MAX_EVENTS) 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, + }); +}; + +/** The trace kinds this lowering publishes, for consumers that filter. */ +export const hookTraceKinds = Object.freeze([ + 'hook.received', + 'hook.completed', + 'hook.failed', + 'session.started', + 'session.ended', +] as const); +export type HookTraceKind = (typeof hookTraceKinds)[number]; + +export type HookReceiptOutcome = + | Readonly<{ readonly kind: 'completed'; readonly gate?: 'continue' | 'deny' }> + | Readonly<{ readonly error: EventTraceErrorSummary; readonly kind: 'failed'; readonly phase: EventTracePhase }>; + +/** What the kernel events say happened: a terminal `failure`, a gate that short-circuited, or a completed run. */ +export 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/events/project.ts b/packages/agent-bundle/src/events/project.ts index 09844fe1f..0421da57c 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -40,3 +40,24 @@ export { type EventTraceRenderStart, type EventTraceRuntime, } from './trace.ts'; +export { + DEV_INSTALL_MARKER_FILE, + EVENT_TRACE_RECEIPT_ENDPOINT_FILE, + EVENT_TRACE_RECEIPT_MAX_BYTES, + EVENT_TRACE_RECEIPT_PATH, + EVENT_TRACE_RECEIPT_TIMEOUT_MS, + EVENT_TRACE_RECEIPT_TOKEN_ENV, + EVENT_TRACE_RECEIPT_URL_ENV, + EVENT_TRACE_RECEIPT_VERSION, + eventTraceReceiptEndpointPath, + eventTraceReceiptIdentity, + eventTraceReceiptLineage, + openEventTraceReceipt, + resolveEventTraceReceiptEndpoint, + type EventTraceReceipt, + type EventTraceReceiptEndpoint, + type EventTraceReceiptEvent, + type EventTraceReceiptIdentity, + type EventTraceReceiptRecorder, + type OpenEventTraceReceiptOptions, +} 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..f92679eb5 --- /dev/null +++ b/packages/agent-bundle/src/events/trace-receipt.ts @@ -0,0 +1,268 @@ +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'; + +/** + * The receipt a host-invoked hook execution posts to the developer's dev + * server (#600 PR 2, lane T7). A hook wrapper runs in the host's own process + * tree, so the kernel {@link EventTraceEvent}s it emits are invisible to the + * Workbench unless the wrapper carries them out: this module is that carrier. + * + * The wire shape is deliberately slim — the execution identity, the kernel's + * events without their repeated `execution`, the host/session/request ids the + * native payload names, and the lineage axis the runtime resolved. Never the + * payload body, tool input or output, the environment, or a filesystem path. + * + * Resolution is developer-local by construction: the wrapper reports only + * when it can find a receipt endpoint — the `AGENT_BUNDLE_DEV_TRACE_URL` / + * `AGENT_BUNDLE_DEV_TRACE_TOKEN` pair a dev-server-spawned simulation sets, + * or, for a host's own invocation, the dev install marker beside the wrapper + * (`.agent-bundle-dev.json`, written by the dev host installer) pointing at + * the project whose dev server published `/.agent-bundle/hook-receipts.json`. + * A production install has neither and pays one failed `stat`. + */ + +export const EVENT_TRACE_RECEIPT_VERSION = 1 as const; +/** The authenticated foreground route the receipt is posted to. */ +export const EVENT_TRACE_RECEIPT_PATH = '/api/trace/receipts'; +/** Largest receipt body the dev server accepts. */ +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'; +/** `/.agent-bundle/`: the endpoint record the dev server publishes for its attached hosts. */ +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'; +/** How long a wrapper waits on the receipt post before letting the host go. */ +export const EVENT_TRACE_RECEIPT_TIMEOUT_MS = 750; + +export interface EventTraceReceiptEndpoint { + readonly token: string; + /** A loopback HTTP origin (`http://127.0.0.1:`). */ + readonly url: string; +} + +/** The ids the native payload names, per `docs/entry-conventions.md`; never the payload itself. */ +export interface EventTraceReceiptIdentity { + /** Claude/Codex `agent_id` else `session_id`; Cursor `conversation_id`. */ + readonly conversationId?: string; + /** The host's tool-call id (`tool_use_id` / `tool_call_id`) when the event carries one. */ + readonly requestId?: string; + /** `session_id`, else Cursor's `conversation_id`. */ + readonly sessionId?: string; +} + +type DistributiveOmit = Value extends unknown ? Omit : never; + +/** A kernel event on the wire: the execution identity travels once, on the receipt. */ +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; + readonly now?: () => Date; + readonly timeoutMs?: number; +} + +/** + * 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)); + return record === undefined ? undefined : 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 now = options.now ?? (() => new Date()); + const post = options.fetch ?? fetch; + const timeoutMs = options.timeoutMs ?? EVENT_TRACE_RECEIPT_TIMEOUT_MS; + 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 ??= now().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(timeoutMs), + }); + } 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/services/hook-service.ts b/packages/agent-bundle/src/services/hook-service.ts index fe0ccbee7..368a48df6 100644 --- a/packages/agent-bundle/src/services/hook-service.ts +++ b/packages/agent-bundle/src/services/hook-service.ts @@ -39,6 +39,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. */ @@ -86,6 +92,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; @@ -112,7 +119,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 = ''; @@ -239,11 +246,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; @@ -284,6 +293,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/hook-receipt-pipe.test.ts b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts new file mode 100644 index 000000000..2b9b38079 --- /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(); + 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..9c63b9a00 --- /dev/null +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -0,0 +1,467 @@ +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, + hookReceiptOutcome, + 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 }, + ], + }); + expect(hookReceiptOutcome(denied)).toEqual({ gate: 'deny', kind: 'completed' }); + 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(); + 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(); + 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(); + 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({ 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(); + 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 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, + now: () => new Date('2026-09-05T15:00:00.000Z'), + }); + 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).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: '2026-09-05T15:00:00.000Z', + 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/rstest.integration-tests.ts b/rstest.integration-tests.ts index a6d495479..45c4045dc 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', From d1d34305b2438882c9214e20ed81beb3e41a6495 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:18:49 +0000 Subject: [PATCH 10/70] drop LANE-NOTES --- LANE-NOTES.md | 200 -------------------------------------------------- 1 file changed, 200 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index d27a864e8..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,200 +0,0 @@ -# Lane T5 — Trace page: correlated live timeline - -Branch `lane/wb600-pr2-t5` on `wb600-pr2-trace`. Gate green: `pnpm build && npx tsc --project -packages/workbench/tsconfig.json --noEmit && pnpm lint && npx rstest --config rstest.unit.config.ts -packages/workbench/tests/trace-*.test.ts packages/workbench/tests/workbench-location.test.ts -packages/workbench/tests/project-client*.test.ts` (65 tests). Also green alongside: -`dev-server-backend`, `workbench-shell`, `workbench-router` unit tests. - -## Files - -Added - -- `packages/workbench/src/trace/trace-client.ts` — `TraceClient`, `ForegroundTraceClient`, strict decoders, - `openTraceFeed` (replay → stream → back-off reconnect). Imported by `trace-page.tsx` and `main.tsx`. -- `packages/workbench/src/trace/trace-model.ts` — pure merge / group / filter / select / format helpers. - Imported by `trace-client.ts` (merge) and `trace-page.tsx`. -- `packages/workbench/src/trace/trace-page.css` — page layout; imported by `trace-page.tsx`. -- `packages/workbench/tests/support/trace-fixtures.ts` — `traceEntry(sequence, overrides)` and - `sampleTraceEntries` (the brief's example timeline: one Claude session with hook/kernel/mcp rows, a lone - invocation, a failed runtime run, a lone log line). Shared by the three trace tests. -- `packages/workbench/tests/trace-client.test.ts`, `packages/workbench/tests/trace-model.test.ts`. - -Changed - -- `packages/workbench/src/trace/trace-page.tsx` — rewritten. The PR 1 stopgap (`loadTraceHistory`, - `mergeTraceEntries` over `/api/routes/invocations`, `sortTraceEntries`, `traceDurationMs`, - `traceEntryLocation`, the `.trace-table` markup) is gone; the page reads `/api/trace` only. -- `packages/workbench/src/main.tsx` — constructs `new ForegroundTraceClient({ foreground })` in - `createClients` and renders ``. The Trace case no - longer waits for the application tree (it does not need it). -- `packages/workbench/src/shell/workbench-location.ts` — `/trace/` and `/trace?correlation=` - parse and format; `WorkbenchLocation['trace']` gains `correlation?: string`. `invocationId` stays as the - field name for the selected entry (the shell and PR 1 tests read it); the doc comment says so. -- `packages/workbench/src/project-client.ts` — `'route.invocation'` added to `projectEventTypes` (the - inventory §2 latent bug: the browser subscribed to the event but the allowlist dropped it). Activity - events (`route.invocation`, `runtime.event`) no longer trigger a `/api/status` refetch — they never change - project status, and a hot invocation loop would otherwise hammer the status route. -- `packages/workbench/tests/trace-page.test.ts`, `workbench-location.test.ts`, `project-client.test.ts` — - new cases; every pre-existing case still passes. -- `docs/diagnostics.md` — `AB8249` registered (see below). - -## Exported API (for T6 and the integrator) - -`packages/workbench/src/trace/trace-client.ts` - -```ts -export interface TraceClient { - replay(after?: number): Promise; - stream(after: number | undefined, onMessage: (message: TraceMessage) => void, signal: AbortSignal): Promise; -} -export class ForegroundTraceClient implements TraceClient { constructor(options: { foreground: ForegroundRequestAuthority }) } -export class TraceClientError extends Error { readonly code: string } -export const TRACE_INVALID_RESPONSE_CODE = 'AB8249'; -export const decodeTraceEntry: (value: unknown) => TraceEntry; // throws TraceClientError(AB8249) -export const decodeTraceMessage: (value: unknown) => TraceMessage; // entry | gap frame -export const decodeTraceReplay: (value: unknown, after: number) => TraceReplay; -export interface TraceFeedState { connected; entries; error?; gap?; loaded } -export const openTraceFeed: (options: { client; onState; retryDelay? }) => { close(): void }; -``` - -`TraceReplay` is `{ entries, latestSequence, gap? }` — the shape of `GET /api/trace?after=` per the brief. -Decoder bounds: 64 KiB per NDJSON frame; `summary` ≤ 240 chars; `kind` ≤ 128, identifier grammar, must contain -a `.`; `details` is any JSON whose strings pass the safe-text rule (no control characters, no credential-shaped -tokens via `redactEvalCredentialText`, no absolute/Windows/UNC path or `file:` URL) and whose keys are not -credential keys; `href` must be a shell path (`/routes/…`, `/trace…`, `/problems`, `/advanced/…`, same origin, -no hash); `source` must be one of the seven `TraceSource` values (unknown → `AB8249`, not a crash); `status` ∈ -`ok|error|running`; `id` and every correlation value are identifiers `^[A-Za-z0-9_][A-Za-z0-9._:@+/-]*$` ≤ 256 -chars (route ids like `tool:curator/search` pass); `occurredAt` must round-trip through `toISOString()`; -`sequence ≥ 1`. Replay entries must be contiguous from `after` (or from `gap.firstAvailableSequence - 1`), -`gap.requestedAfterSequence` must equal `after`, and `latestSequence` must equal the last entry's sequence -(or `after` when empty, or `gap.firstAvailableSequence - 1` for an empty gap) — anything else is `AB8249`. - -Reconnect: replay once, then stream from the last delivered sequence; on stream end or failure back off -250 ms doubling to 5 s and replay again from the cursor. A refused replay from a non-zero cursor -(`TRACE_CURSOR_AHEAD` after a dev-server restart) restarts from `after = 0`. Retention in the browser is -`maximumTraceEntries = 4096` (matches the hub's default cap); a server `gap` frame is surfaced in the page. - -`packages/workbench/src/trace/trace-model.ts` - -```ts -export const mergeTraceEntries: (existing, incoming) => readonly TraceEntry[]; // by sequence, bounded -export const groupTraceEntries: (entries) => readonly TraceGroup[]; -export const filterTraceGroups: (groups, filter: TraceFilter) => readonly TraceGroup[]; -export const matchesTraceFilter, isEmptyTraceFilter, traceFacetsFor; -export const selectTraceGroup: (groups, id) => TraceGroup | undefined; // ?correlation= -export const selectTraceEntry: (entries, id) => TraceEntry | undefined; // /trace/; also a PR 1 inv_… id -export const formatTraceTime (HH:MM:SS.mmm), formatTraceDuration, traceSourceGlyph, traceKindLabel; -export interface TraceGroup { key; keyKind; headline; rows: TraceRow[]; startedAt; endedAt; spanMs; status; firstSequence; lastSequence } -``` - -Grouping is a union-find over the join keys `conversationId → sessionId → invocationId | executionId | runId | -mcpSessionId:mcpRequestId → correlationId`; a group's `key`/`keyKind` is the strongest key it shares, else -`entry:` for a singleton. `mcpRequestId` alone never joins (request ids repeat across sessions). Facets -(`host`, `routeId`, `epochId`) never join. Within a group, `invocation`/`hook`/`diagnostic` rows sit at depth 0 -and `kernel`/`mcp`/`log`/`runtime` rows at depth 1. Headline priority: invocation > hook > runtime > mcp > -kernel > diagnostic > log, earliest wins ties. Group status: `error` if any row errors, `running` if the -last row is running, else `ok`. Filters are applied to rows *after* grouping, so a filter never re-keys a group. - -`packages/workbench/src/trace/trace-page.tsx` - -```ts -export interface TracePageProps { client: TraceClient; correlation?: string; entries?: readonly TraceEntry[]; entryId?: string; onNavigate; timeZone?: string } -export const TracePage: (props: TracePageProps) => JSX.Element; -``` - -`entries` is a supplied snapshot for static/server rendering and tests (the live feed is not opened). -Row markup: `[data-testid="trace-entry"][data-entry-id][data-kind][data-source][data-status]` inside -`[data-testid="trace-group"][data-group-key]`; also `trace-timeline`, `trace-filter-bar`, `trace-detail`, -`trace-new-pill`, `trace-empty`. Clicking a row pushes `/trace/` (keeps `?correlation=`). - -`packages/workbench/src/shell/workbench-location.ts` - -```ts -| Readonly<{ readonly area: 'trace'; readonly correlation?: string; readonly invocationId?: string }> -``` - -`/trace/trc_5?correlation=conv-1` ⇄ `{ area: 'trace', correlation: 'conv-1', invocationId: 'trc_5' }`. - -## Cross-lane requests (exact edits for the integrator) - -1. **`packages/workbench/src/shell/shell.css` lines 167–178** (PR 1 trace rules, now dead): - - ```css - .problem-list, .trace-table { min-width: 0; } - ... - .problem-link, .trace-link { color: #0759c7; ... } - .problem-link:hover, .trace-link:hover { text-decoration: underline; } - .trace-status { ... } .trace-status--succeeded { ... } .trace-status--failed { ... } - .trace-entry { ... } .trace-entry dl { ... } .trace-entry dt { ... } .trace-entry dd { ... } - ``` - - Drop `.trace-table` and `.trace-link` from the shared selectors (keep `.problem-list`, `.problem-link`) - and delete the `.trace-status*` and `.trace-entry*` rules. My rows use `.trace-line`, so nothing collides - today; this is delete-on-sight. - -2. **`packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` lines 110–119** — the PR 1 trace - step reads `.trace-table tr[data-invocation-id]`. Once T1 (`/api/trace`) and T2 (invocation → trace - entries) land, replace with: - - ```ts - await openWorkbench(page, server.url, `/trace?correlation=${encodeURIComponent(invocationId)}`); - await expect(page.getByRole('heading', { name: 'Trace', exact: true })).toBeVisible({ timeout: browserTimeout }); - const traceRow = page.locator('[data-testid="trace-entry"][data-kind="invocation.completed"]').first(); - await expect(traceRow).toBeVisible({ timeout: browserTimeout }); - await expect(traceRow).toContainText(searchLeaf.routeId ?? 'tool:curator/search_audible'); - await captureExampleState(page, 'audiobook-curator', 'trace-populated'); - await traceRow.click(); - await waitForWorkbenchIdle(page); - expect(new URL(page.url()).pathname).toMatch(/^\/trace\/trc_\d+$/u); - await expect(page.getByTestId('trace-detail')).toBeVisible({ timeout: browserTimeout }); - ``` - - `/trace/` still resolves (`selectTraceEntry` falls back to the latest entry carrying that - `invocationId`), so the old deep link keeps working; only the clicked-row URL changed to the entry id. - -3. **`website/docs/en/guide/development/workbench.mdx` line 172 and the `zh` twin** — `/trace/` - → `/trace/` plus a line `/trace?correlation=` ("show one correlated group"). Whoever owns the - docs page for PR 2 should also describe the Trace page: sources, correlated groups, filter bar, detail - drawer, "Open route". - -4. **T1 (`/api/trace` route)** — the client sends `GET /api/trace?after=` and `GET /api/trace/stream?after=` - through `ForegroundRequestAuthority.protectedRequest` (session header + origin guard, no custom `Accept`), - and expects the replay body `{ entries, latestSequence, gap? }` and the stream as one `TraceMessage` per - line — a bare `TraceEntry` object or a bare `TraceReplayGap` (`type: 'trace.gap'`), exactly the contract - union, no envelope. Stream entries must be contiguous from `after + 1`; a skip is `AB8249` unless a gap - frame precedes it (`requestedAfterSequence` = last delivered, then entries resume at - `firstAvailableSequence`). No heartbeat frame is expected; blank lines are ignored, so an empty line is a - safe keep-alive. If T1 emits a typed heartbeat, tell me the shape and I add it to `decodeTraceMessage`. A - refusal must be the standard `{ diagnostic: { code: 'ABnnnn', message } }` body; any refusal of a replay from a - non-zero cursor (the hub's `TRACE_CURSOR_AHEAD` after a dev-server restart) triggers restart-from-zero (the - retained list is dropped, since the hub that numbered it is gone); a refusal at `after = 0`, or an `AB8249` - decode failure, is shown in the page (`· reconnecting`) and retried with the back-off. - -5. **Server-side `source` set** — the decoder accepts exactly the seven `TraceSource` values in - `contracts/trace.ts`. A new source needs a decoder + glyph + kind label in this lane's files, not a - silent pass-through. - -## Open risks - -- No jsdom in the unit pool, so `trace-page.test.ts` covers rendering via `renderToStaticMarkup` with supplied - snapshots (empty state, groups/rows/depth, selected-entry drawer with correlation links and "Open route", - filter bar, `?correlation=` scoping, error flags). Scroll-anchoring, the "N new" pill, and the live feed - are exercised only through `openTraceFeed`'s fake-client tests and a manual 1440×900 render check; the - browser acceptance of the live behaviour belongs to the integration e2e once T1/T2 land. -- `entryId` still travels as `WorkbenchLocation.invocationId` — renaming it touches the shell and PR 1 tests - outside this lane. Cheap follow-up if the integrator wants it. -- `ProjectClient` now forwards `route.invocation` to subscribers; nothing in the Workbench consumes it yet - (the Trace page reads `/api/trace`, not project events). It exists so T6/T7 can react without another - allowlist bug, and the test pins that it does not trigger a status refetch. - -## Proposed changeset line (agent-bundle only; Workbench is private) - -None from this lane — every change is under `packages/workbench` plus `docs/diagnostics.md`. If the -integrator wants the diagnostics registry mentioned, append to the PR's single changeset: -"… the Workbench Trace page's browser decoder rejects a malformed `/api/trace` reply with `AB8249`." - -## Proposed diagnostic codes - -- `AB8249` — Workbench browser-side strict decoder rejecting a `/api/trace` replay or stream frame. Registered - in `docs/diagnostics.md` (Workbench family table). `AB8240`–`AB8248` left for the server-side trace route (T1). From 9617bc0585c13493d7e93d430b753a2a4484fddc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:19:02 +0000 Subject: [PATCH 11/70] drop LANE-NOTES --- LANE-NOTES.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index d6e613f77..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,38 +0,0 @@ -# T6 lane notes - -## Files changed - -- `packages/workbench/src/application/app-route-workspace.tsx` -- `packages/workbench/src/application/event-route-workspace.tsx` -- `packages/workbench/src/application/executable-route-workspace.tsx` -- `packages/workbench/src/application/result-tabs.tsx` -- `packages/workbench/src/application/route-workspace.tsx` -- `packages/workbench/src/application/runtime-backend.ts` -- `packages/workbench/src/application/workspace-contracts.ts` -- `packages/workbench/src/application/workspace.css` -- `packages/workbench/tests/route-workspace.test.ts` -- `packages/workbench/tests/runtime-backend.test.ts` - -## Exported API - -- `TraceTimeline` -- `appToolCallRequest` -- `newCorrelationId` -- `RouteWorkspaceProps.trace?: TraceClient` - -## Cross-lane requests - -- T5: replace the `packages/workbench/src/trace/trace-client.ts` stub with the real client, construct it in `main.tsx`, and pass the exact prop `trace: TraceClient` through `ApplicationExplorer` to `RouteWorkspace`. -- T3: preserve `_meta['agent-bundle/correlationId']` from App workspace `callTool` requests through the MCP session client and session service. -- T2: if `RouteInvocationRequest.requestId` lands, no application controller change is needed because the draft spread preserves it; ensure invocation decoding and persistence echo it. - -## Open risks - -- The lane keeps `RouteWorkspaceProps.trace` optional so it compiles before T5's `main.tsx` wiring lands. Without that integration prop, the Trace result tab remains in its loading state. -- The App correlation token is stamped on the MCP request but is not currently displayed in the App workspace. - -## Changeset and diagnostics - -- No changeset: `packages/workbench` is private. -- No new diagnostics. -- Proposed changeset line: none. From 91897afecf364c03935df478cab966cb9551999c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:19:02 +0000 Subject: [PATCH 12/70] drop LANE-NOTES --- LANE-NOTES.md | 98 --------------------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index bc954a91a..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,98 +0,0 @@ -# T8 — English docs for the unified trace - -## Files changed - -- `website/docs/en/guide/development/workbench.mdx` -- `website/docs/en/guide/development/testing.mdx` -- `website/docs/en/examples/audiobook-curator.mdx` -- `website/docs/en/examples/hooks-and-scripts.mdx` -- `website/docs/en/examples/mcp-app.mdx` -- `website/docs/en/reference/dev-server-http.mdx` (new) -- `website/docs/en/reference/index.mdx` -- `website/docs/en/reference/runtime-environment.mdx` -- `website/docs/en/reference/_meta.json` - -No current hand-written architecture page under `docs/*.md` describes the unified Workbench -trace. The matches there are historical plans/specifications or unrelated Effect/TraceDecay -references, so none was changed. `docs/diagnostics.md` remains owned by T1/T7. - -## Page and section ledger for the zh lane - -Mirror these changes 1:1: - -1. **Developer Workbench** - - Navigation: Trace is the correlated application-activity timeline. - - Replace **Trace** with the real event-route transcript, producer/kind/correlation/href table, - transitive grouping order, source/host/route/status/text filters, `?correlation=`, - `/trace/`, Open route, route-workspace and Raw-log Open in Trace, replay/NDJSON - routes, host receipt security, and excluded payload/document/environment data. - - **Advanced → Raw logs**: retained as the complete redacted producer firehose; correlated - rows link into Trace. - - **URL model**: add `?correlation=`, generalize `/trace/`. - - **Route invocation API**: add trace replay, stream, receipt routes and link the HTTP - reference. -2. **Testing** - - After `inspectWorkbenchSurface`, add browser acceptance guidance for `trace-timeline`, - `trace-entry`, `trace-group`, and `trace-detail`, including Open route snapshot verification. -3. **Audiobook Curator** - - Workbench step 4: open Trace for the invocation start/completion and return with Open route. -4. **Hooks and Scripts** - - Workbench step 4: follow invocation/kernel activity and attached-host receipts in Trace, - restore snapshots with Open route, and reserve Raw logs for uncorrelated details. -5. **MCP App** - - Workbench step 6: after the Protocol invocation, open Trace for MCP - request/response/notification/session activity. -6. **Development-server HTTP** (new reference page) - - Route invocation, runtime run, MCP correlation, trace replay/stream, Raw logs, and - `POST /api/trace/receipts` contracts. -7. **Reference index and navigation** - - Add Development-server HTTP after Diagnostics. -8. **Runtime environment** - - Add `AGENT_BUNDLE_DEV_TRACE_URL` and `AGENT_BUNDLE_DEV_TRACE_TOKEN`. - -## Source reconciliation - -- T1: `GET /api/trace?after=`, - `GET /api/trace/stream?after=`, JSON replay plus NDJSON stream, and diagnostics - `AB8240`–`AB8242`. -- T2: invocation/kernel kinds; optional `requestId` on `RouteInvocationRequest` and summary; - event-route kernel entries carry `executionId`. -- T3: MCP kinds and `_meta` correlation; `tools/call` accepts top-level `correlationId`; route - hrefs use `?session=`, with Protocol fallback. -- T4: `DevRuntimeInvocationRequest.correlationId`; retained Raw logs; Open in Trace precedence - `correlationId`, then `invocationId`, then `mcpSessionId`. -- T5: transitive join keys and priority; source/host/route/status/text filters; - `?correlation=` group selection; `/trace/` detail and `Open route`; required test ids. -- T6: route workspace Trace tab and `Open in Trace`; runtime requests carry `correlationId`. -- T7: `POST /api/trace/receipts`; 16 KiB strict receipt; random per-dev-server bearer token; - owner-only endpoint record removed at close; no-Origin plus loopback-peer guard; 750 ms - best-effort send; payload-free receipt and `hook.*`/`session.*` lowering. T7 owns - `AB8247`–`AB8249`. - -## Exported API - -None. Documentation only. - -## Cross-lane requests - -- Zh lane: mirror the page/section ledger above, including the new page and `_meta.json` entry. -- Integrator: after T1/T7 are merged, confirm generated Diagnostics includes - `AB8240`–`AB8242` and `AB8247`–`AB8249`; do not hand-edit generated website diagnostics. - -## Verification - -- Initial `pnpm install --frozen-lockfile --prefer-offline && pnpm build`: passed. -- `pnpm docs:site:build`: typecheck passed, then stopped only at the expected locale drift: - - `en/guide/development/workbench.mdx`: fenced blocks, heading count, table rows - - `en/reference/_meta.json`: entry count - - `en/reference/dev-server-http.mdx`: missing zh twin - - `en/reference/index.mdx`: table rows - - `en/reference/runtime-environment.mdx`: table rows -- A parity-skipping diagnostics/build attempt passed diagnostics coverage and rendered every - page, then Rspress stopped only because `zh/reference/dev-server-http.mdx` is absent. -- The built-link scan checked 27,116 anchors and reported only that expected missing zh page. -- IDE diagnostics: none. - -## Changeset and diagnostics - -No changeset: documentation only. No diagnostic code is introduced by this lane. From 2540fd60e8adb5a07370a24f2aa6c084101ac784 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:20:32 +0000 Subject: [PATCH 13/70] integrate: TraceHub requires projectRoot in lane tests --- packages/agent-bundle/tests/dev-log-service.test.ts | 2 +- packages/agent-bundle/tests/hook-receipt-pipe.test.ts | 2 +- packages/agent-bundle/tests/hook-receipts.test.ts | 8 ++++---- packages/agent-bundle/tests/runtime-provider.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/tests/dev-log-service.test.ts b/packages/agent-bundle/tests/dev-log-service.test.ts index d55fcf573..69e38d892 100644 --- a/packages/agent-bundle/tests/dev-log-service.test.ts +++ b/packages/agent-bundle/tests/dev-log-service.test.ts @@ -44,7 +44,7 @@ it('records detached redacted details and replaces its own project root', () => }); it('publishes warnings, errors, and correlated records to trace without plain info chatter', () => { - const trace = new TraceHub(); + const trace = new TraceHub({ projectRoot: '/work/project' }); const service = new DevLogService({ projectRoot: '/work/project', trace, diff --git a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts index 2b9b38079..46d04f9cb 100644 --- a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts +++ b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts @@ -125,7 +125,7 @@ it('posts a host-invoked hook execution to the dev server as hook.received / hoo expect(after).toBeDefined(); const projectRoot = join(root, 'dev-project'); - const hub = new TraceHub(); + const hub = new TraceHub({ projectRoot }); const { attachment, url } = await listen(hub, projectRoot); // (1) A dev-server-spawned simulation: the endpoint travels in the environment. diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts index 9c63b9a00..dba5f6b83 100644 --- a/packages/agent-bundle/tests/hook-receipts.test.ts +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -304,7 +304,7 @@ const jsonHeaders = (token: string): Record => ({ }); it('accepts a bearer-authenticated loopback receipt and publishes its lowering to the trace hub', async () => { - const hub = new TraceHub(); + 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')); @@ -317,7 +317,7 @@ it('accepts a bearer-authenticated loopback receipt and publishes its lowering t }); it('refuses receipts without the token, with an Origin header, over the size cap, or malformed', async () => { - const hub = new TraceHub(); + 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()); @@ -356,7 +356,7 @@ it('refuses receipts without the token, with an Origin header, over the size cap 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(); + 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); @@ -394,7 +394,7 @@ it('resolves the wrapper endpoint from the environment, else the dev install mar await expect(resolveEventTraceReceiptEndpoint({ anchor, env: {} })).resolves.toBeUndefined(); const projectRoot = join(root, 'project'); - const hub = new TraceHub(); + 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 }); diff --git a/packages/agent-bundle/tests/runtime-provider.test.ts b/packages/agent-bundle/tests/runtime-provider.test.ts index f7d7157f6..6cf340434 100644 --- a/packages/agent-bundle/tests/runtime-provider.test.ts +++ b/packages/agent-bundle/tests/runtime-provider.test.ts @@ -429,7 +429,7 @@ it('refreshes terminal run snapshots before completed or failed events without r it('publishes correlated runtime lifecycle entries from the run surface and inspection envelope', async () => { const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; - const trace = new TraceHub({ now: () => new Date('2026-08-15T00:00:02.000Z') }); + const trace = new TraceHub({ now: () => new Date('2026-08-15T00:00:02.000Z'), projectRoot: '/work/project' }); let emit: Parameters[0]['emit'] | undefined; const tracedSurface = { ...surface, routeId: 'event:tool/after' }; const tracedRun = { @@ -824,7 +824,7 @@ it('buffers synchronous startup failure and status until controller snapshots in it('buffers synchronous startup activation until controller snapshots install', async () => { const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; const seen: Array> = []; - const trace = new TraceHub(); + const trace = new TraceHub({ projectRoot: '/work/project' }); const controller = new DevRuntimeController({ artifactStatus: () => ({ state: 'missing' }), emit: (event) => { From 7cb34c64fe77b44198423bbf9bbba8a53f45abde Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:29:42 +0000 Subject: [PATCH 14/70] docs(zh): document unified Workbench trace --- LANE-NOTES.md | 41 ++++++ .../docs/zh/examples/audiobook-curator.mdx | 3 +- .../docs/zh/examples/hooks-and-scripts.mdx | 5 +- website/docs/zh/examples/mcp-app.mdx | 1 + website/docs/zh/guide/development/testing.mdx | 5 + .../docs/zh/guide/development/workbench.mdx | 84 ++++++++++- website/docs/zh/reference/_meta.json | 1 + website/docs/zh/reference/dev-server-http.mdx | 131 ++++++++++++++++++ website/docs/zh/reference/index.mdx | 1 + .../docs/zh/reference/runtime-environment.mdx | 2 + 10 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 website/docs/zh/reference/dev-server-http.mdx diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..583208995 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,41 @@ +# W4 — Simplified Chinese documentation + +## Files + +- Updated `website/docs/zh/guide/development/workbench.mdx`. +- Updated `website/docs/zh/guide/development/testing.mdx`. +- Updated `website/docs/zh/examples/audiobook-curator.mdx`. +- Updated `website/docs/zh/examples/hooks-and-scripts.mdx`. +- Updated `website/docs/zh/examples/mcp-app.mdx`. +- Added `website/docs/zh/reference/dev-server-http.mdx`. +- Updated `website/docs/zh/reference/index.mdx`. +- Updated `website/docs/zh/reference/runtime-environment.mdx`. +- Updated `website/docs/zh/reference/_meta.json`. + +## Behavior documented + +- Mirrored T8's unified Trace timeline, grouping, filters, deep links, route-opening flow, Raw logs decision, HTTP contracts, and host hook receipt security in Simplified Chinese. +- Added browser acceptance guidance and updated the three example walkthroughs. +- Added the development-server HTTP reference and navigation entry. +- Documented `AGENT_BUNDLE_DEV_TRACE_URL` and `AGENT_BUNDLE_DEV_TRACE_TOKEN`. + +## English factual fixes + +- None. The English pages do not name the browser decoder diagnostic, so no `AB8249` → `AB8243` correction was needed. + +## Source verification + +- `packages/agent-bundle/src/dev/trace/trace-routes.ts` proves the replay and NDJSON routes and `AB8240`–`AB8242`. +- `packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts` and `packages/agent-bundle/src/events/trace-receipt.ts` prove the receipt route, `AB8247`–`AB8249`, environment variables, endpoint record, size bound, and security behavior. +- `packages/workbench/src/logs/logs-page.tsx` proves `/trace?correlation=` and correlation precedence. + +## Verification + +- `pnpm install --frozen-lockfile --prefer-offline && pnpm build` passed. +- `pnpm build && pnpm docs:site:build` passed. +- Locale drift: 0 failures across 35 page pairs and 10 meta files. +- Built links: 0 broken links across 27,147 anchors. + +## Open risks + +- None. diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index a40d23435..1f2c69982 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 be3808de2..5f48315f4 100644 --- a/website/docs/zh/examples/hooks-and-scripts.mdx +++ b/website/docs/zh/examples/hooks-and-scripts.mdx @@ -49,8 +49,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/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index b98fd5fea..a38ca85b0 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 bbfabd194..9ae507141 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -26,7 +26,7 @@ npx agent-bundle dev --root . --port 3100 --no-open 本版本的主导航有四个目的地: - **Application** — 已编译的应用树,以及所选叶子的工作区。 -- **Trace** — 本次前台开发会话中观察到的调用。 +- **Trace** — 本次前台开发会话中观察到的应用活动关联时间线。 - **Problems** — 当前的编译器、运行时与契约诊断。 - **Advanced** — Evals、Artifact、Protocol、Host diagnostics 与 Raw logs。 @@ -93,9 +93,72 @@ 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` | `correlationId`、`requestId`、`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 会话 | +| `runtime` | 运行开始/完成/失败、世代发布与 App 更新 | `runId`、`correlationId`、`routeId`、`epochId`、`mcpSessionId` | 带 `?invocation=` 的路由 | +| `hook` | 收据/完成/失败与宿主会话开始/结束 | `requestId`、`executionId`、`sessionId`、`conversationId`、`routeId`、`host` | 事件路由与捕获的收据 | +| `log` | 具有共享键的记录对应的 `log..` | 安全日志投影保留的任意关联键 | Advanced → Raw logs | +| `diagnostic` | 构建、契约与宿主同步失败 | 已知的构建、epoch、路由与共享请求标识 | Problems 或受影响的路由 | + +Trace 会根据共享标识传递式地分组条目。组标签按以下顺序采用最强的可用键:对话、会话、调用、内核执行、 +运行时运行、MCP 请求,最后是浏览器生成的关联 id。MCP 请求 id 只在其 MCP 会话内参与关联。分组以证据 +为依据:不会仅因活动发生时间相近就把无关活动合并。可按来源、宿主、路由或状态筛选;文本筛选会匹配 +摘要与 kind。`/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 观察不会改变钩子结果。这是范围严格且经过认证的遥测,而不是远程控制或 +通用摄取端点。 + +### 刻意排除的数据 + +Trace 绝不包含请求或响应载荷正文、渲染后的 Agent Document、原生事件信封、环境变量、凭据、绝对路径或 +错误堆栈。需要更多详情时,请打开已链接的调用、路由、Protocol 会话、Raw log 记录或 Problem 中可用的 +有界完整记录。 ## Problems 与过期目录修复 @@ -118,7 +181,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 通过同一个已绑定会话预览。见 @@ -143,7 +208,8 @@ Workbench 使用路径与浏览器历史,而不是 `#page` 哈希: /routes/skills/ /routes/commands/ /routes/rules/ -/trace/ +/trace/ +/trace?correlation= /problems /advanced/
    ``` @@ -160,6 +226,9 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 - `GET /api/routes/invocations?limit=50` 为 Trace 返回按最新优先的摘要。 - `GET /api/routes/invocations/` 返回一次调用。 - `/api/project/events` 以 `route.invocation` 事件发布已完成的摘要。 +- `GET /api/trace?after=` 返回一份关联的 Trace 重放。 +- `GET /api/trace/stream?after=` 以 NDJSON 流式传输 Trace 条目与重放 gap。 +- `POST /api/trace/receipts` 接受来自开发包装器的一份有界且经过 bearer 认证的钩子收据;它不是浏览器路由。 该信封在可用时携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、 投影、诊断与执行计时。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 invocation id(`AB8231`)、 @@ -167,6 +236,9 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 (`AB8238`)会作为诊断报告。 各条触发条件与恢复指引见[诊断参考](../../reference/diagnostics.md)。 +完整的浏览器侧 HTTP 形状(包括 Trace 条目与重放契约)见 +[开发服务器 HTTP 参考](../../reference/dev-server-http.mdx)。 + ## 以编程方式使用同一个会话 公开的 `startDevServer` 导出接受 CLI 标志所映射的那些选项(`root`、`port`、`open`、`agentApi`、 diff --git a/website/docs/zh/reference/_meta.json b/website/docs/zh/reference/_meta.json index 714e233ef..3de610163 100644 --- a/website/docs/zh/reference/_meta.json +++ b/website/docs/zh/reference/_meta.json @@ -7,6 +7,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..a692c8c18 --- /dev/null +++ b/website/docs/zh/reference/dev-server-http.mdx @@ -0,0 +1,131 @@ +--- +description: 'Workbench 调用、统一 Trace、Raw logs 与宿主钩子收据所使用的浏览器侧 HTTP 路由。' +--- + +# 开发服务器 HTTP + +`agent-bundle dev` 在其 loopback 前台服务器上为 Workbench 挂载这些路由。它们是开发协议,不是公开部署 +端点。浏览器路由要求前台会话守卫,并强制执行[安全](./security.mdx)中描述的 Workbench origin 策略。 +除非另有说明,游标都是非负安全整数,响应均为 JSON。 + +## 路由调用 + +| 方法 | 路径 | 响应 | +| --- | --- | --- | +| `POST` | `/api/routes/invocations` | `{ invocation: RouteInvocation }` | +| `GET` | `/api/routes/invocations?limit=<1..200>` | `{ invocations: RouteInvocationSummary[] }`,最新优先 | +| `GET` | `/api/routes/invocations/` | `{ invocation: RouteInvocation }` | + +POST 正文是一份 `RouteInvocationRequest`:包含 `routeId`,以及可选的 `input`、`args`、 +`correlationId`、调用方 `requestId` 与事件夹具选项。前台会把这两个标识回显到调用上,使路由工作区与 +Trace 可以关联此次运行。完成后的信封包含输入、provenance 上下文、providers、渲染事件、Agent +Document、结果、投影、诊断与计时。Trace 条目链接到这份完整快照,而不会复制它。 + +## 开发运行时运行 + +`POST /api/runtime/runs` 接受一份 `DevRuntimeInvocationRequest`,其中包含 `surfaceId`、`target`、 +`input`,以及可选的 `fixtureId`、`expectedGenerationId` 与 `correlationId`。前台会把 +`correlationId` 传给运行时 provider,并复制到此次运行的 Trace 条目;运行时事件也可以直接携带它。 +`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' + | 'runtime' + | '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`、`runId` 与 `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 d6feb4bcb..98bfb49b3 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -39,6 +39,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 原生冒烟测试。 | From 4fcdff2f222b1264f77589f2d20616d3918599be Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:44:31 +0000 Subject: [PATCH 15/70] feat(dev): wire unified server trace --- LANE-NOTES.md | 49 +++++++ .../src/dev/rsbuild-runtime-session.ts | 18 ++- .../tests/dev-provider.integration.test.ts | 8 +- .../agent-bundle/src/dev/foreground-server.ts | 5 + .../src/dev/hooks/hook-receipts.ts | 2 +- .../playground/lifecycle-replay-service.ts | 85 ++---------- .../src/dev/routes/route-invocation-child.ts | 53 ++++++-- .../agent-bundle/src/dev/workbench-server.ts | 32 ++++- .../tests/hook-receipt-pipe.test.ts | 4 +- .../agent-bundle/tests/hook-receipts.test.ts | 8 +- .../tests/route-invocation-dev-server.test.ts | 56 ++++++++ .../tests/trace-dev-server.test.ts | 124 +++++++++++++++++- 12 files changed, 336 insertions(+), 108 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..36a5f74e1 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,49 @@ +# W1 — server wiring + +## Files + +- `packages/agent-bundle/src/dev/workbench-server.ts` +- `packages/agent-bundle/src/dev/foreground-server.ts` +- `packages/agent-bundle/src/dev/hooks/hook-receipts.ts` +- `packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` +- `packages/agent-bundle/tests/hook-receipt-pipe.test.ts` +- `packages/agent-bundle/tests/hook-receipts.test.ts` +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` +- `packages/agent-bundle/tests/trace-dev-server.test.ts` +- `examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts` +- `examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts` + +## Behavior + +- The foreground dev server now mounts the self-authorizing hook receipt route before browser-session-authorized routes. +- Dev startup publishes `.agent-bundle/hook-receipts.json`, passes its environment to Workbench hook simulations, and removes the record before closing the foreground server. +- Hook receipts replay as unified `hook.received` and terminal hook entries. Their display labels use `tool · before` rather than the canonical `tool/before`, because browser-wire path sanitization intentionally redacts slash-bearing prose; exact canonical identity remains in correlation and `href`. +- Lifecycle replay now uses the shared `nativeEventRequestContext` implementation. +- Event route invocations emit kernel phase trace entries from the invocation child, correlated with the invocation trace. +- Runtime-provider hook, tool, resource, and App surfaces now carry their application route IDs. +- The trace dev-server integration test verifies bearer authorization, browser refusal (`AB8247`), trace replay, endpoint-record cleanup, and a generated hook wrapper discovering the endpoint through an installed `.agent-bundle-dev.json` marker. + +## Cross-lane requests + +- T7 request fulfilled: hook receipt attachment, endpoint publication, simulation environment, foreground dispatch, close ordering, and integration coverage are wired. +- T2 requests fulfilled: lifecycle request-context extraction is rewired and route invocation trace replay covers invocation and kernel entries. +- T4 request fulfilled: runtime provider surfaces carry application route IDs. +- No outgoing cross-lane requests. + +## Open risks + +- None known. The RSC demo models one logical MCP server across its target descriptors; tool and resource route IDs use that server name, while App route IDs use each App descriptor's `serverName`. + +## Verification + +- `pnpm build && npx tsc --noEmit && pnpm lint` +- Required agent-bundle unit tests: 109 passed. +- Required agent-bundle integration tests: 36 passed. +- Lifecycle route-unit tests: 5 passed. +- RSC runtime example tests: 172 passed, 6 skipped; route-unit tests: 3 passed. +- Focused RSC provider integration rerun after deslop: 41 passed; example typecheck passed. + +## Proposed changeset line + +Expose host hook receipts and correlated route execution in the unified development trace. (#600) diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index 06d67ea3d..3890d15d8 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -1350,23 +1350,26 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256) throw new Error('Historical runtime definition changed.'); const definition = JSON.parse(bytes.toString('utf8')) as Partial; const targets = Object.freeze([...new Set(generation.manifest.metadata.servers.map((server) => server.target))]); + const serverName = generation.manifest.metadata.servers[0]?.name; if (surfaceId.startsWith('hook.')) { const host = surfaceId.slice('hook.'.length); if ((host !== 'claude' && host !== 'codex') || !definition.nativeHooks?.some((hook) => hook.host === host)) { throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); } - return Object.freeze({ fixtures: fixturesForHook(host), id: surfaceId, kind: 'hook', label: `After tool hook (${host})`, readOnly: false, targets: Object.freeze([host]) }); + return Object.freeze({ fixtures: fixturesForHook(host), id: surfaceId, kind: 'hook', label: `After tool hook (${host})`, readOnly: false, routeId: 'event:tool/after', targets: Object.freeze([host]) }); } const name = surfaceId.startsWith('mcp.') ? surfaceId.slice('mcp.'.length) : ''; if (definition.tools?.some((tool) => tool.name === name)) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-tool', label: name, readOnly: true, targets }); + if (serverName === undefined) throw new Error('Historical runtime generation has no MCP server descriptor.'); + return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-tool', label: name, readOnly: true, routeId: `tool:${serverName}/${name}`, targets }); } if (definition.resources?.some((resource) => resource.name === name)) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-resource', label: name, readOnly: true, targets }); + if (serverName === undefined) throw new Error('Historical runtime generation has no MCP server descriptor.'); + return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-resource', label: name, readOnly: true, routeId: `resource:${serverName}/${name}`, targets }); } const app = generation.manifest.metadata.appDefinitions.find((candidate) => candidate.name === name); if (app !== undefined) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-app', label: name, readOnly: true, targets: app.targets }); + return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-app', label: name, readOnly: true, routeId: `app:${app.serverName}/${name}`, targets: app.targets }); } throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); } @@ -2876,33 +2879,39 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { prepared: Pick, ): void { this.#surfaces.clear(); + const serverName = prepared.servers[0]?.name; for (const hook of snapshot.definition.nativeHooks) { this.#surfaces.set(`hook.${hook.host}`, Object.freeze({ id: `hook.${hook.host}`, kind: 'hook', label: `After tool hook (${hook.host})`, readOnly: false, + routeId: 'event:tool/after', targets: Object.freeze([hook.host]), fixtures: fixturesForHook(hook.host), })); } for (const tool of snapshot.definition.tools) { + if (serverName === undefined) throw new Error('RSC runtime has no MCP server descriptor.'); this.#surfaces.set(`mcp.${tool.name}`, Object.freeze({ inputSchema: cloneJsonObject(tool.inputSchema), id: `mcp.${tool.name}`, kind: 'mcp-tool', label: tool.description, readOnly: tool.annotations.readOnlyHint, + routeId: `tool:${serverName}/${tool.name}`, targets: Object.freeze([...prepared.servers.flatMap((server) => server.targets)]), fixtures: Object.freeze([]), })); } for (const resource of snapshot.definition.resources) { + if (serverName === undefined) throw new Error('RSC runtime has no MCP server descriptor.'); this.#surfaces.set(`mcp.${resource.name}`, Object.freeze({ id: `mcp.${resource.name}`, kind: 'mcp-resource', label: resource.name, readOnly: true, + routeId: `resource:${serverName}/${resource.name}`, targets: Object.freeze([...prepared.servers.flatMap((server) => server.targets)]), fixtures: Object.freeze([]), })); @@ -2913,6 +2922,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { kind: 'mcp-app', label: app.name, readOnly: true, + routeId: `app:${app.serverName}/${app.name}`, targets: Object.freeze([...app.targets]), fixtures: Object.freeze([]), })); diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index 30d94c2cf..bbf644a55 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -453,10 +453,10 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil }); expect(session.status()).not.toHaveProperty('clientSurface'); expect(session.surfaces()).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: 'hook' }), - expect.objectContaining({ id: 'mcp.render_edit_timeline', kind: 'mcp-tool' }), - expect.objectContaining({ id: 'mcp.edit-timeline', kind: 'mcp-resource' }), - expect.objectContaining({ id: 'mcp.timeline', kind: 'mcp-app' }), + expect.objectContaining({ kind: 'hook', routeId: 'event:tool/after' }), + expect.objectContaining({ id: 'mcp.render_edit_timeline', kind: 'mcp-tool', routeId: 'tool:timeline/render_edit_timeline' }), + expect.objectContaining({ id: 'mcp.edit-timeline', kind: 'mcp-resource', routeId: 'resource:timeline/edit-timeline' }), + expect.objectContaining({ id: 'mcp.timeline', kind: 'mcp-app', routeId: 'app:timeline/timeline' }), ])); const registry = session.mcpRegistry.snapshot(); expect(registry).toMatchObject({ runtimeGenerationId: expect.any(String) }); diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 58a14897e..be67497f9 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'; @@ -173,6 +174,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. */ @@ -409,6 +411,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; @@ -468,6 +471,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()); @@ -786,6 +790,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); diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts index 8f546ec9f..c265955f4 100644 --- a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts +++ b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts @@ -340,7 +340,7 @@ const instantAfter = (startedAt: string, receipt: EventTraceReceipt, at: number }; const describe = (receipt: EventTraceReceipt): string => - `${receipt.execution.host} ${receipt.execution.nativeEvent} → ${receipt.execution.event}`; + `${receipt.execution.host} ${receipt.execution.nativeEvent} → ${receipt.execution.event.replaceAll('/', ' · ')}`; /** * Lowers one decoded receipt into the entries a `TracePublisher` receives, in 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 959ba42b4..2a263d321 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -5,11 +5,14 @@ import * as AgentRuntime from '@agent-bundle/runtime'; import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; import * as React from 'react'; -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, @@ -117,19 +120,41 @@ const render = 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, { + ...(request.args === undefined ? {} : { args: request.args }), + 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; + } return Object.freeze({ document: rendered.document, events: rendered.events, diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 3b6e69a4e..8f8509329 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'; @@ -14,6 +15,7 @@ import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogge import { EpochStore } 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'; @@ -529,6 +531,8 @@ const withMcpSessionLifecycle = ( detachProjectTrace: () => void, inspector: Closeable, epochAdoption: EpochAdoptionPolicy, + hookReceipts: ReturnType, + publishHookReceiptUrl: (url: string) => void, hostInstalls?: DevHostInstallManager, ): ForegroundCoordinator => Object.freeze({ close: () => { @@ -548,7 +552,11 @@ const withMcpSessionLifecycle = ( 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(); @@ -610,6 +618,8 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun const eventHub = new ProjectEventHub(); const epochStore = new EpochStore({ 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); @@ -820,7 +830,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; @@ -994,6 +1013,8 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun detachProjectTrace, inspector, epochAdoption, + hookReceipts, + (url) => { hookReceiptUrl = url; }, hostInstalls, ), evals, @@ -1001,6 +1022,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun epochs: epochStore, eventHub, hookPlayground, + hookReceipts: hookReceipts.routes, hostDiscovery, hostMcp, inspector, @@ -1043,7 +1065,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/tests/hook-receipt-pipe.test.ts b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts index 46d04f9cb..4c06e4696 100644 --- a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts +++ b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts @@ -148,7 +148,7 @@ it('posts a host-invoked hook execution to the dev server as hook.received / hoo href: '/routes/events/tool/before', source: 'hook', status: 'ok', - summary: 'claude PreToolUse → tool/before received', + summary: 'claude PreToolUse → tool · before received', }); expect(received.correlation.executionId).toMatch(/^[0-9a-f-]{36}$/u); expect(completed.correlation).toEqual(received.correlation); @@ -164,7 +164,7 @@ it('posts a host-invoked hook execution to the dev server as hook.received / hoo }, href: '/routes/events/tool/before', status: 'ok', - summary: 'claude PreToolUse → tool/before completed', + summary: 'claude PreToolUse → tool · before completed', }); expect(typeof completed.durationMs).toBe('number'); const serialized = JSON.stringify(afterEnv); diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts index dba5f6b83..d1ed9af2a 100644 --- a/packages/agent-bundle/tests/hook-receipts.test.ts +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -191,7 +191,7 @@ it('lowers a completed receipt to hook.received and hook.completed with the even occurredAt: '2026-09-05T15:00:00.000Z', source: 'hook', status: 'ok', - summary: 'claude PreToolUse → tool/before received', + summary: 'claude PreToolUse → tool · before received', }); expect(entries[1]).toMatchObject({ correlation, @@ -208,7 +208,7 @@ it('lowers a completed receipt to hook.received and hook.completed with the even href: '/routes/events/tool/before', occurredAt: '2026-09-05T15:00:00.006Z', status: 'ok', - summary: 'claude PreToolUse → tool/before completed', + summary: 'claude PreToolUse → tool · before completed', }); expect(entries.every((entry) => !entry.href?.includes('invocation='))).toBe(true); expect(JSON.stringify(entries)).not.toContain('tool_input'); @@ -226,7 +226,7 @@ it('lowers a failure to hook.failed with the kernel error summary, and a gate ou 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', + summary: 'claude PreToolUse → tool · before failed in execute: EventRuntimeTransportError: render exploded', }); const denied = receipt({ events: [ @@ -240,7 +240,7 @@ it('lowers a failure to hook.failed with the kernel error summary, and a gate ou details: { gate: 'deny' }, kind: 'hook.completed', status: 'ok', - summary: 'claude PreToolUse → tool/before denied by preflight', + summary: 'claude PreToolUse → tool · before denied by preflight', }); expect(gated[1]!.details).not.toHaveProperty('runtime'); }); 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 8a8513176..87c64bb86 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -6,6 +6,7 @@ import { expect, it } from '@rstest/core'; 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 { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; import { startDevServer } from '../src/dev/workbench-server.ts'; import { createProjectFixture } from './helpers/project-fixture.ts'; @@ -151,6 +152,31 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.providers).toEqual([ expect.objectContaining({ 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')), + }), + ]); const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ @@ -189,6 +215,36 @@ it('invokes compiled tool and event routes through the foreground server', { tim value: { conversation: 'session-1', root: 'session-1' }, }); expect(event.invocation.requestId).toBe('request-event-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(); const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { name: 'Ada' }, routeId: 'cli:greet' }), diff --git a/packages/agent-bundle/tests/trace-dev-server.test.ts b/packages/agent-bundle/tests/trace-dev-server.test.ts index 21c1e4430..cd3aaf35f 100644 --- a/packages/agent-bundle/tests/trace-dev-server.test.ts +++ b/packages/agent-bundle/tests/trace-dev-server.test.ts @@ -1,4 +1,5 @@ -import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +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'; @@ -6,12 +7,27 @@ 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: [ @@ -23,6 +39,17 @@ it('serves replay and live trace entries and lowers build failures', { timeout: ].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';", @@ -65,6 +92,7 @@ it('serves replay and live trace entries and lowers build failures', { timeout: 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, @@ -93,6 +121,99 @@ it('serves replay and live trace entries and lowers build failures', { timeout: 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({ @@ -133,6 +254,7 @@ it('serves replay and live trace entries and lowers build failures', { timeout: 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 }); From 5c508052521ae865a885673482318fc18ad5c7a9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:46:47 +0000 Subject: [PATCH 16/70] Rewrite Trace acceptance for the live /trace page and pin heading matches. The PR 1 .trace-table step is gone; the audiobook-curator e2e now covers populated groups, ?correlation= scoping, /trace/trc_n deep links, Open route, and a live POST without reload. Leaving Trace is a designed stream abort. --- LANE-NOTES.md | 193 ++++++++++++++++++ .../audiobook-curator.acceptance.e2e.test.ts | 55 ++++- .../workbench/tests/lifecycles.e2e.test.ts | 4 +- .../tests/packed-outage-ledger.test.ts | 11 + .../tests/packed-release.e2e.test.ts | 34 +-- .../tests/support/example-acceptance.ts | 2 + .../tests/support/packed-outage-ledger.ts | 13 +- .../tests/support/workbench-acceptance.ts | 87 ++++++++ 8 files changed, 372 insertions(+), 27 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..c1fa78b1a --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,193 @@ +# Lane W3 — Workbench browser acceptance (PR 2 round 2) + +Branch `lane/wb600-pr2-w3` on `wb600-pr2-trace`. No product files under +`packages/workbench/src/trace/**` needed a fix. Workbench is private: no +changeset. + +## Files + +- `packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` — replaced + the PR 1 `.trace-table` step with the T5 URL/markup model. +- `packages/workbench/tests/support/workbench-acceptance.ts` — `traceEntry`, + `traceGroup`, `traceDetail` test ids; `readCorrelationId`; + `expectToolInvocationTraceGroup`; `invokeRouteFromWorkbench`. +- `packages/workbench/tests/support/example-acceptance.ts` — leaving Trace + aborts `GET /api/trace` and `/api/trace/stream` the same way Logs aborts + its replay/stream; those `net::ERR_ABORTED` rows are allowed. +- `packages/workbench/tests/support/packed-outage-ledger.ts` + + `packed-outage-ledger.test.ts` — `/api/trace/stream` is a known live stream; + `/api/trace` replay aborts match the Logs replay contract; post-recovery + unknown-failure filter claims those client cancellations. +- `packages/workbench/tests/packed-release.e2e.test.ts` — every remaining + `getByRole('heading', { name: '…' })` is `exact: true` or a `^…$` regex. + Confirmed: no `Skills` heading assertion remains (PR 1 cut that page). +- `packages/workbench/tests/lifecycles.e2e.test.ts` — Canonical → host mapping + now prints `claude · receipt` and + `lifecycle-observed · depth 0 · native · receipt` (not `derived` / + `no-shared-runtime`). +- `packages/workbench/tests/examples-real.e2e.test.ts` — no old `.trace-table` + markup; unchanged. + +## What each e2e now proves + +`audiobook-curator.acceptance.e2e.test.ts` at 1440×900, never while +`workbench-loading` is visible: + +1. A Run of `tool:curator/search_audible` populates `/trace`: a + `[data-testid="trace-group"]` keyed by that invocation holds + `invocation.started` + `invocation.completed`. Tool routes do **not** + publish `kernel.*` (`EventTraceEvent` is event-route only); the completed + row carries `durationMs` (shown as e.g. `2.80 s`). Route identity is on + the Route facet (`tool:curator/search_audible`) and in the detail drawer — + summaries arrive as `[REDACTED]` (see W1 request below). Capture + `trace-populated`. +2. `/trace?correlation=` scopes to that one + group (`Correlated by …`, one `trace-group`). +3. Clicking the completed row pushes `/trace/trc_` and opens + `trace-detail` with the route id. +4. Cold `page.goto` of that same `/trace/` restores the detail + (`data-entry-id`) after replay. +5. Detail **Open route** goes to + `/routes/mcp/curator/tool/search_audible?invocation=` and the workspace + shows that invocation (`route-status--succeeded`, same id, rendered + document). +6. With `/trace` open, `POST /api/routes/invocations` from the page (session + bootstrap + `x-agent-bundle-session`) adds a new completed row without + reload. +7. Existing stale-diagnostic + repair (`problemsBanner`, `problems-stale`, + `problems-repaired`) still passes. + +`packed-release.e2e.test.ts`: heading matches cannot collide with a prefix +or suffix label. Leaving Trace during the desktop navigation floor no longer +fails the outage ledger. + +`lifecycles.e2e.test.ts`: request-context copy matches the current workspace +provenance labels. + +`logs-real`, `mcp-tasks`, `mcp-session-timeout`, `examples-real`: green +without markup changes. `logs-real` still accepts "Open in Trace" links +(body must not contain the project root or `fixture-secret`). + +## Cross-lane requests (exact edits) + +### W1 — TraceHub summaries of tool/event identities are `[REDACTED]` + +**Failing product behavior (asserted around):** after a successful +`tool:curator/search_audible` run, `/trace` shows + +```text +▶ invocation started [REDACTED] +▶ invocation completed [REDACTED] 2.80 s +``` + +The Route facet still lists `tool:curator/search_audible`. The published +summary is `MCP tool curator/search_audible · ` (see +`route-invocation-service.ts` `routeLabel` + `durationText`). + +**Cause:** `TraceHub.#summaryFor` runs every summary through `sanitizeText` → +`safeDevWireText` → `redactAbsolutePaths` +(`packages/agent-bundle/src/dev/logs/dev-log-service.ts` ~197–203). After +stripping `/…` prefixes, `hasControlOrSeparators` is true for any +remaining `/` (`0x2f`) and the **entire** string becomes `[REDACTED]`. +`curator/search_audible` and `tool/before` are route/event identities, not +absolute paths. + +**Requested edit** in `redactAbsolutePaths` (or a TraceHub-only sanitizer): + +Do not treat a leftover relative `word/word` as a path leak. Keep the +existing credential + project-root + `file:` / drive-letter / UNC redaction. +A summary that contains only a single slash between identifier segments must +be published verbatim. + +Suggested replacement for the leftover-slash branch (~197–203): drop the +`hasControlOrSeparators(withoutProjectPaths)` disjunct, or restrict it to +strings that still match an absolute POSIX path (`/(?:\/[^\s/]+){2,}/`), +`file:`, a drive letter, or UNC — the same grammar `trace-client.ts` +`pathLikeText` already uses. Do **not** change `hasControlOrSeparators` for +log *context keys* (those must stay slash-free). + +Kernel summaries (`event tool/before (claude) · …`) will stay `[REDACTED]` +for the same reason until this lands; tool routes did not emit `kernel.*` +rows in this acceptance run. + +### W2 — no edit required from this lane + +Provenance label change (`derived` → `receipt`) is already on the branch; +this lane updated `lifecycles.e2e.test.ts` to match. + +## Browser pool (one file at a time) + +All of these were `pnpm build` (or `AGENT_BUNDLE_WORKBENCH_PREBUILT=1` after +a fresh build) + `npx rstest --config rstest.integration.config.ts ` +except packed-release (`pnpm test:packed `). + +| File | Result | +|---|---| +| `audiobook-curator.acceptance.e2e.test.ts` | pass (after stream-abort allowlist) | +| `logs-real.e2e.test.ts` | pass first try | +| `mcp-session-timeout.e2e.test.ts` | pass first try | +| `mcp-tasks.e2e.test.ts` | pass first try | +| `lifecycles.e2e.test.ts` | fail then pass (label update) | +| `examples-real.e2e.test.ts` | pass first try | +| `contributor-hmr.e2e.test.ts` | pass | +| `discovery.e2e.test.ts` | pass | +| `evals-real.e2e.test.ts` | pass | +| `host-adoption.e2e.test.ts` | pass | +| `overview.e2e.test.ts` | pass | +| `web-command.e2e.test.ts` | pass | +| `mcp-app-real.e2e.test.ts` | pass | +| `mcp-app-preview-browser.test.ts` | pass | +| `mcp-page-app-browser.test.ts` | pass | +| `evals-compare-client-scope-browser.test.ts` | pass | +| `workbench-surface-dev-server.test.ts` | pass | +| `packed-release.e2e.test.ts` | **fail then pass** (see below) | + +`packed-release` first run (`pnpm test:packed -- ` accidentally ran the +whole packed include, 12 files): failed at phase `desktop navigation floor` +with + +```text +unknown post-recovery failure: GET /api/trace/stream?after=29 net::ERR_ABORTED (HTTP 200) +``` + +Cause: leaving `/trace` aborts the NDJSON feed; the ledger did not classify +`/api/trace/stream` as a known stream. After the ledger + heading `exact` +fixes, `pnpm test:packed packages/workbench/tests/packed-release.e2e.test.ts` +passed (1 file, 27 s tests). + +No flake on the acceptance file after the stream allowlist; no second run +needed there. + +## Acceptance captures + +Saved under `/tmp/wb600/acceptance-pr2/` (`AGENT_BUNDLE_EXAMPLE_SCREENSHOT_DIR`). +Acceptance-owned (this lane's required states): + +- `/tmp/wb600/acceptance-pr2/audiobook-curator-application-populated.png` +- `/tmp/wb600/acceptance-pr2/audiobook-curator-tool-rendered.png` +- `/tmp/wb600/acceptance-pr2/audiobook-curator-trace-populated.png` +- `/tmp/wb600/acceptance-pr2/audiobook-curator-problems-stale.png` +- `/tmp/wb600/acceptance-pr2/audiobook-curator-problems-repaired.png` +- `/tmp/wb600/acceptance-pr2/audiobook-curator-advanced-evals.png` + +`examples-real.e2e.test.ts` wrote additional example captures into the same +directory (and overwrote `report.json` with its own list). + +## Open risks + +- Until W1 stops redacting `word/word` summaries, screenshot review of + `trace-populated` shows `[REDACTED]` instead of the route identity. The + test still pins the Route facet, group membership, kinds, duration, deep + link, Open route, and live update. +- Tool routes did not emit `kernel.*` in this run. If W1 later forwards + render-child kernel events for tools, the helper already accepts them. +- `pnpm test:packed -- ` passes a literal `--` through to rstest and + runs the whole packed include; omit `--`. + +## Proposed changeset line + +None — Workbench-only. + +## Proposed diagnostic codes + +None. diff --git a/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts b/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts index 9f0f586a6..d344630eb 100644 --- a/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts +++ b/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts @@ -18,14 +18,18 @@ import { expectApplicationTree, expectPrimaryNav, expectRenderedDocument, + expectToolInvocationTraceGroup, expectUnknownRouteMessage, fillRouteInput, + invokeRouteFromWorkbench, openWorkbench, readBuildEpoch, + readCorrelationId, readInvocationId, rebuildTimeout, runSelectedRoute, selectApplicationLeaf, + traceEntryRow, waitForBuildEpochAdvance, workbenchTestId, } from './support/workbench-acceptance.ts'; @@ -84,6 +88,7 @@ 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 epochBeforeEdit = await readBuildEpoch(page); const markedSearch = healthySearch.replace( @@ -107,16 +112,54 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou await expectRenderedDocument(page, runTimeout); expect(await readInvocationId(page)).toBe(invocationId); + 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 workbenchTestId(page, 'traceDetail').getByRole('link', { name: 'Open route', exact: true }).click(); await waitForWorkbenchIdle(page); - expect(new URL(page.url()).pathname).toBe(`/trace/${encodeURIComponent(invocationId)}`); - await expect(page.getByTestId('trace-entry')).toBeVisible({ timeout: browserTimeout }); + 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 openWorkbench(page, server.url, '/trace'); + const completedBeforeLive = await traceEntryRow(page, 'invocation.completed').count(); + const liveUrl = page.url(); + const liveInvocationId = await invokeRouteFromWorkbench(page, { input: { title: searchTitle }, routeId }); + expect(liveInvocationId).not.toBe(invocationId); + await expect.poll( + async () => traceEntryRow(page, 'invocation.completed').count(), + { timeout: browserTimeout }, + ).toBeGreaterThan(completedBeforeLive); + await expectToolInvocationTraceGroup(page, { invocationId: liveInvocationId, routeId }); + expect(page.url()).toBe(liveUrl); await editWatchedSource(server, project.root, conversionSource, `${healthyConversion}\nconst = ;\n`, 'failed'); await page.reload(); diff --git a/packages/workbench/tests/lifecycles.e2e.test.ts b/packages/workbench/tests/lifecycles.e2e.test.ts index 338dff907..70261c637 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/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 37e8177c8..87449d56d 100644 --- a/packages/workbench/tests/packed-release.e2e.test.ts +++ b/packages/workbench/tests/packed-release.e2e.test.ts @@ -333,12 +333,12 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * await waitForWorkbenchIdle(page, browserTimeout); await expect(page.getByTestId('application-tree')).toContainText('review', { timeout: browserTimeout }); await page.goto(workbenchUrl(origin, '/advanced/artifact')); - await expect(page.getByRole('heading', { name: 'Artifact' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('heading', { name: 'Emitted files' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Artifact', exact: true })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Emitted files', exact: true })).toBeVisible({ timeout: browserTimeout }); phase = 'MCP and App page'; await page.goto(workbenchUrl(origin, '/advanced/protocol')); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'MCP playground', exact: true })).toBeVisible({ timeout: browserTimeout }); await page.locator('#mcp-target').selectOption('portable'); await page.locator('#mcp-server-name').fill('fixture'); const openedOldBrowserMcpSession = page.waitForResponse((response) => @@ -371,13 +371,13 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * const logsReplayListing = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/logs/replay'); await openAdvancedSection('Raw logs'); phase = 'Logs page heading'; - await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Logs', exact: true })).toBeVisible({ timeout: browserTimeout }); phase = 'Logs page replay'; const logsReplayListingResponse = await logsReplayListing; if (!logsReplayListingResponse.ok()) throw new Error(`The Logs page replay route failed with ${logsReplayListingResponse.status()}: ${await logsReplayListingResponse.text()}`); await openAdvancedSection('Evals'); phase = 'Evals page heading'; - await expect(page.getByRole('heading', { name: 'Evals' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Evals', exact: true })).toBeVisible({ timeout: browserTimeout }); phase = 'Evals suite catalog'; await expect(page.getByLabel('Suite')).toContainText('packed-deterministic', { timeout: browserTimeout }); const compareTab = page.getByRole('tab', { name: 'Compare' }); @@ -437,7 +437,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'); @@ -449,10 +449,10 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * phase = 'artifact epoch diff'; await openAdvancedSection('Artifact'); - await expect(page.getByRole('heading', { name: 'Artifact' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Artifact', exact: true })).toBeVisible({ timeout: browserTimeout }); await page.locator('#artifact-diff-base').fill(epochId); await page.getByRole('button', { name: 'Compare builds' }).click(); - await expect(page.getByRole('heading', { name: 'Build comparison' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Build comparison', exact: true })).toBeVisible({ timeout: browserTimeout }); const changedRows = page.locator('.artifact-diff-group').filter({ has: page.getByRole('heading', { name: /^Changed \([1-9][0-9]*\)$/u }), }).locator('tbody tr'); @@ -471,7 +471,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'); @@ -520,7 +520,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * }; page.on('response', collectLogsReplay); await openAdvancedSection('Raw logs'); - await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Logs', exact: true })).toBeVisible({ timeout: browserTimeout }); let logsReplayDocument: unknown; while (logsReplayDocument === undefined) { const candidate = await nextLogsReplay(); @@ -663,7 +663,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * phase = 'Evals live evidence and comparisons'; await openAdvancedSection('Evals'); - await expect(page.getByRole('heading', { name: 'Evals' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Evals', exact: true })).toBeVisible({ timeout: browserTimeout }); await page.getByLabel('Suite').selectOption('packed-deterministic'); await page.getByLabel('Harness').selectOption('deterministic'); const uiEvalAdmitted = page.waitForResponse((response) => @@ -683,8 +683,8 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * })}`, { cause: error }); } phase = 'Evals durable evidence'; - await expect(page.getByRole('heading', { name: 'Durable event timeline' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('heading', { name: 'Host / model matrix' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Durable event timeline', exact: true })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Host / model matrix', exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(page.locator('.eval-counts')).toHaveText('1 passed · 0 failed · 0 inconclusive', { timeout: browserTimeout }); await expect(page.locator('.eval-timeline .eval-event-sequence')).not.toHaveCount(0, { timeout: browserTimeout }); await expect(page.locator('.eval-timeline')).toContainText('run.completed'); @@ -724,7 +724,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * ); await closeChild(stoppedChild); phase = 'foreground restart/reconnect disconnected state'; - await expect(page.getByRole('heading', { name: 'Foreground connection unavailable' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Foreground connection unavailable', exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(page.getByText('Waiting for the foreground server to recover.')).toBeVisible({ timeout: browserTimeout }); phase = 'foreground restart/reconnect browser recovery'; child = startInstalledServer(port); @@ -766,7 +766,7 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * phase = 'foreground restart/reconnect fresh B browser MCP session'; await page.goto(workbenchUrl(origin, '/advanced/protocol')); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'MCP playground', exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(page.getByRole('button', { name: 'Open MCP session' })).toBeEnabled({ timeout: browserTimeout }); await expect(page.locator('#mcp-epoch')).toHaveValue(recoveredEpochId, { timeout: browserTimeout }); await expect(page.locator('#mcp-target')).toHaveValue('portable'); @@ -854,7 +854,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/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/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index 1222a4bc9..0b2ecde65 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -32,6 +32,9 @@ export const workbenchTestIds = Object.freeze({ routeStatus: 'route-status', routeWorkspace: 'route-workspace', shellBuildStatus: 'shell-build-status', + traceDetail: 'trace-detail', + traceEntry: 'trace-entry', + traceGroup: 'trace-group', unknownRoute: 'unknown-route', workbenchLoading: 'workbench-loading', workbenchNav: 'workbench-nav', @@ -255,6 +258,90 @@ 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. Summaries may be `[REDACTED]`; `routeId` is asserted via the Route facet. */ +export const expectToolInvocationTraceGroup = async ( + page: Page, + options: Readonly<{ readonly invocationId: string; readonly routeId: string }>, + timeout = browserTimeout, +): Promise => { + const routeSelect = page.locator('.trace-filter-field').filter({ hasText: 'Route' }).locator('select'); + await expect(routeSelect.locator('option').filter({ hasText: options.routeId })).toHaveCount(1, { timeout }); + const group = workbenchTestId(page, 'traceGroup').filter({ hasText: options.invocationId }).first(); + await expect(group).toBeVisible({ timeout }); + await group.scrollIntoViewIfNeeded(); + const completed = group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind="invocation.completed"]`); + await expect(completed).toBeVisible({ timeout }); + await expect(group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind="invocation.started"]`)) + .toBeVisible({ timeout }); + const kernel = group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind^="kernel."]`); + if (await kernel.count() > 0) await expect(kernel.first()).toBeVisible({ timeout }); + else 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. From 696a73b72df1c4c2c327c1d4fd930a3ea407d583 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:47:46 +0000 Subject: [PATCH 17/70] drop LANE-NOTES --- LANE-NOTES.md | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 36a5f74e1..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,49 +0,0 @@ -# W1 — server wiring - -## Files - -- `packages/agent-bundle/src/dev/workbench-server.ts` -- `packages/agent-bundle/src/dev/foreground-server.ts` -- `packages/agent-bundle/src/dev/hooks/hook-receipts.ts` -- `packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` -- `packages/agent-bundle/tests/hook-receipt-pipe.test.ts` -- `packages/agent-bundle/tests/hook-receipts.test.ts` -- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` -- `packages/agent-bundle/tests/trace-dev-server.test.ts` -- `examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts` -- `examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts` - -## Behavior - -- The foreground dev server now mounts the self-authorizing hook receipt route before browser-session-authorized routes. -- Dev startup publishes `.agent-bundle/hook-receipts.json`, passes its environment to Workbench hook simulations, and removes the record before closing the foreground server. -- Hook receipts replay as unified `hook.received` and terminal hook entries. Their display labels use `tool · before` rather than the canonical `tool/before`, because browser-wire path sanitization intentionally redacts slash-bearing prose; exact canonical identity remains in correlation and `href`. -- Lifecycle replay now uses the shared `nativeEventRequestContext` implementation. -- Event route invocations emit kernel phase trace entries from the invocation child, correlated with the invocation trace. -- Runtime-provider hook, tool, resource, and App surfaces now carry their application route IDs. -- The trace dev-server integration test verifies bearer authorization, browser refusal (`AB8247`), trace replay, endpoint-record cleanup, and a generated hook wrapper discovering the endpoint through an installed `.agent-bundle-dev.json` marker. - -## Cross-lane requests - -- T7 request fulfilled: hook receipt attachment, endpoint publication, simulation environment, foreground dispatch, close ordering, and integration coverage are wired. -- T2 requests fulfilled: lifecycle request-context extraction is rewired and route invocation trace replay covers invocation and kernel entries. -- T4 request fulfilled: runtime provider surfaces carry application route IDs. -- No outgoing cross-lane requests. - -## Open risks - -- None known. The RSC demo models one logical MCP server across its target descriptors; tool and resource route IDs use that server name, while App route IDs use each App descriptor's `serverName`. - -## Verification - -- `pnpm build && npx tsc --noEmit && pnpm lint` -- Required agent-bundle unit tests: 109 passed. -- Required agent-bundle integration tests: 36 passed. -- Lifecycle route-unit tests: 5 passed. -- RSC runtime example tests: 172 passed, 6 skipped; route-unit tests: 3 passed. -- Focused RSC provider integration rerun after deslop: 41 passed; example typecheck passed. - -## Proposed changeset line - -Expose host hook receipts and correlated route execution in the unified development trace. (#600) From 55a4dc30ec0601226daff4f49640ab0cd52483b7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:47:46 +0000 Subject: [PATCH 18/70] drop LANE-NOTES --- LANE-NOTES.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 583208995..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,41 +0,0 @@ -# W4 — Simplified Chinese documentation - -## Files - -- Updated `website/docs/zh/guide/development/workbench.mdx`. -- Updated `website/docs/zh/guide/development/testing.mdx`. -- Updated `website/docs/zh/examples/audiobook-curator.mdx`. -- Updated `website/docs/zh/examples/hooks-and-scripts.mdx`. -- Updated `website/docs/zh/examples/mcp-app.mdx`. -- Added `website/docs/zh/reference/dev-server-http.mdx`. -- Updated `website/docs/zh/reference/index.mdx`. -- Updated `website/docs/zh/reference/runtime-environment.mdx`. -- Updated `website/docs/zh/reference/_meta.json`. - -## Behavior documented - -- Mirrored T8's unified Trace timeline, grouping, filters, deep links, route-opening flow, Raw logs decision, HTTP contracts, and host hook receipt security in Simplified Chinese. -- Added browser acceptance guidance and updated the three example walkthroughs. -- Added the development-server HTTP reference and navigation entry. -- Documented `AGENT_BUNDLE_DEV_TRACE_URL` and `AGENT_BUNDLE_DEV_TRACE_TOKEN`. - -## English factual fixes - -- None. The English pages do not name the browser decoder diagnostic, so no `AB8249` → `AB8243` correction was needed. - -## Source verification - -- `packages/agent-bundle/src/dev/trace/trace-routes.ts` proves the replay and NDJSON routes and `AB8240`–`AB8242`. -- `packages/agent-bundle/src/dev/hooks/hook-receipt-endpoint.ts` and `packages/agent-bundle/src/events/trace-receipt.ts` prove the receipt route, `AB8247`–`AB8249`, environment variables, endpoint record, size bound, and security behavior. -- `packages/workbench/src/logs/logs-page.tsx` proves `/trace?correlation=` and correlation precedence. - -## Verification - -- `pnpm install --frozen-lockfile --prefer-offline && pnpm build` passed. -- `pnpm build && pnpm docs:site:build` passed. -- Locale drift: 0 failures across 35 page pairs and 10 meta files. -- Built links: 0 broken links across 27,147 anchors. - -## Open risks - -- None. From a93bee39b8ad228b4d6ada44ef6c458c78ce8f18 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:49:15 +0000 Subject: [PATCH 19/70] drop LANE-NOTES --- LANE-NOTES.md | 193 -------------------------------------------------- 1 file changed, 193 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index c1fa78b1a..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,193 +0,0 @@ -# Lane W3 — Workbench browser acceptance (PR 2 round 2) - -Branch `lane/wb600-pr2-w3` on `wb600-pr2-trace`. No product files under -`packages/workbench/src/trace/**` needed a fix. Workbench is private: no -changeset. - -## Files - -- `packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` — replaced - the PR 1 `.trace-table` step with the T5 URL/markup model. -- `packages/workbench/tests/support/workbench-acceptance.ts` — `traceEntry`, - `traceGroup`, `traceDetail` test ids; `readCorrelationId`; - `expectToolInvocationTraceGroup`; `invokeRouteFromWorkbench`. -- `packages/workbench/tests/support/example-acceptance.ts` — leaving Trace - aborts `GET /api/trace` and `/api/trace/stream` the same way Logs aborts - its replay/stream; those `net::ERR_ABORTED` rows are allowed. -- `packages/workbench/tests/support/packed-outage-ledger.ts` + - `packed-outage-ledger.test.ts` — `/api/trace/stream` is a known live stream; - `/api/trace` replay aborts match the Logs replay contract; post-recovery - unknown-failure filter claims those client cancellations. -- `packages/workbench/tests/packed-release.e2e.test.ts` — every remaining - `getByRole('heading', { name: '…' })` is `exact: true` or a `^…$` regex. - Confirmed: no `Skills` heading assertion remains (PR 1 cut that page). -- `packages/workbench/tests/lifecycles.e2e.test.ts` — Canonical → host mapping - now prints `claude · receipt` and - `lifecycle-observed · depth 0 · native · receipt` (not `derived` / - `no-shared-runtime`). -- `packages/workbench/tests/examples-real.e2e.test.ts` — no old `.trace-table` - markup; unchanged. - -## What each e2e now proves - -`audiobook-curator.acceptance.e2e.test.ts` at 1440×900, never while -`workbench-loading` is visible: - -1. A Run of `tool:curator/search_audible` populates `/trace`: a - `[data-testid="trace-group"]` keyed by that invocation holds - `invocation.started` + `invocation.completed`. Tool routes do **not** - publish `kernel.*` (`EventTraceEvent` is event-route only); the completed - row carries `durationMs` (shown as e.g. `2.80 s`). Route identity is on - the Route facet (`tool:curator/search_audible`) and in the detail drawer — - summaries arrive as `[REDACTED]` (see W1 request below). Capture - `trace-populated`. -2. `/trace?correlation=` scopes to that one - group (`Correlated by …`, one `trace-group`). -3. Clicking the completed row pushes `/trace/trc_` and opens - `trace-detail` with the route id. -4. Cold `page.goto` of that same `/trace/` restores the detail - (`data-entry-id`) after replay. -5. Detail **Open route** goes to - `/routes/mcp/curator/tool/search_audible?invocation=` and the workspace - shows that invocation (`route-status--succeeded`, same id, rendered - document). -6. With `/trace` open, `POST /api/routes/invocations` from the page (session - bootstrap + `x-agent-bundle-session`) adds a new completed row without - reload. -7. Existing stale-diagnostic + repair (`problemsBanner`, `problems-stale`, - `problems-repaired`) still passes. - -`packed-release.e2e.test.ts`: heading matches cannot collide with a prefix -or suffix label. Leaving Trace during the desktop navigation floor no longer -fails the outage ledger. - -`lifecycles.e2e.test.ts`: request-context copy matches the current workspace -provenance labels. - -`logs-real`, `mcp-tasks`, `mcp-session-timeout`, `examples-real`: green -without markup changes. `logs-real` still accepts "Open in Trace" links -(body must not contain the project root or `fixture-secret`). - -## Cross-lane requests (exact edits) - -### W1 — TraceHub summaries of tool/event identities are `[REDACTED]` - -**Failing product behavior (asserted around):** after a successful -`tool:curator/search_audible` run, `/trace` shows - -```text -▶ invocation started [REDACTED] -▶ invocation completed [REDACTED] 2.80 s -``` - -The Route facet still lists `tool:curator/search_audible`. The published -summary is `MCP tool curator/search_audible · ` (see -`route-invocation-service.ts` `routeLabel` + `durationText`). - -**Cause:** `TraceHub.#summaryFor` runs every summary through `sanitizeText` → -`safeDevWireText` → `redactAbsolutePaths` -(`packages/agent-bundle/src/dev/logs/dev-log-service.ts` ~197–203). After -stripping `/…` prefixes, `hasControlOrSeparators` is true for any -remaining `/` (`0x2f`) and the **entire** string becomes `[REDACTED]`. -`curator/search_audible` and `tool/before` are route/event identities, not -absolute paths. - -**Requested edit** in `redactAbsolutePaths` (or a TraceHub-only sanitizer): - -Do not treat a leftover relative `word/word` as a path leak. Keep the -existing credential + project-root + `file:` / drive-letter / UNC redaction. -A summary that contains only a single slash between identifier segments must -be published verbatim. - -Suggested replacement for the leftover-slash branch (~197–203): drop the -`hasControlOrSeparators(withoutProjectPaths)` disjunct, or restrict it to -strings that still match an absolute POSIX path (`/(?:\/[^\s/]+){2,}/`), -`file:`, a drive letter, or UNC — the same grammar `trace-client.ts` -`pathLikeText` already uses. Do **not** change `hasControlOrSeparators` for -log *context keys* (those must stay slash-free). - -Kernel summaries (`event tool/before (claude) · …`) will stay `[REDACTED]` -for the same reason until this lands; tool routes did not emit `kernel.*` -rows in this acceptance run. - -### W2 — no edit required from this lane - -Provenance label change (`derived` → `receipt`) is already on the branch; -this lane updated `lifecycles.e2e.test.ts` to match. - -## Browser pool (one file at a time) - -All of these were `pnpm build` (or `AGENT_BUNDLE_WORKBENCH_PREBUILT=1` after -a fresh build) + `npx rstest --config rstest.integration.config.ts ` -except packed-release (`pnpm test:packed `). - -| File | Result | -|---|---| -| `audiobook-curator.acceptance.e2e.test.ts` | pass (after stream-abort allowlist) | -| `logs-real.e2e.test.ts` | pass first try | -| `mcp-session-timeout.e2e.test.ts` | pass first try | -| `mcp-tasks.e2e.test.ts` | pass first try | -| `lifecycles.e2e.test.ts` | fail then pass (label update) | -| `examples-real.e2e.test.ts` | pass first try | -| `contributor-hmr.e2e.test.ts` | pass | -| `discovery.e2e.test.ts` | pass | -| `evals-real.e2e.test.ts` | pass | -| `host-adoption.e2e.test.ts` | pass | -| `overview.e2e.test.ts` | pass | -| `web-command.e2e.test.ts` | pass | -| `mcp-app-real.e2e.test.ts` | pass | -| `mcp-app-preview-browser.test.ts` | pass | -| `mcp-page-app-browser.test.ts` | pass | -| `evals-compare-client-scope-browser.test.ts` | pass | -| `workbench-surface-dev-server.test.ts` | pass | -| `packed-release.e2e.test.ts` | **fail then pass** (see below) | - -`packed-release` first run (`pnpm test:packed -- ` accidentally ran the -whole packed include, 12 files): failed at phase `desktop navigation floor` -with - -```text -unknown post-recovery failure: GET /api/trace/stream?after=29 net::ERR_ABORTED (HTTP 200) -``` - -Cause: leaving `/trace` aborts the NDJSON feed; the ledger did not classify -`/api/trace/stream` as a known stream. After the ledger + heading `exact` -fixes, `pnpm test:packed packages/workbench/tests/packed-release.e2e.test.ts` -passed (1 file, 27 s tests). - -No flake on the acceptance file after the stream allowlist; no second run -needed there. - -## Acceptance captures - -Saved under `/tmp/wb600/acceptance-pr2/` (`AGENT_BUNDLE_EXAMPLE_SCREENSHOT_DIR`). -Acceptance-owned (this lane's required states): - -- `/tmp/wb600/acceptance-pr2/audiobook-curator-application-populated.png` -- `/tmp/wb600/acceptance-pr2/audiobook-curator-tool-rendered.png` -- `/tmp/wb600/acceptance-pr2/audiobook-curator-trace-populated.png` -- `/tmp/wb600/acceptance-pr2/audiobook-curator-problems-stale.png` -- `/tmp/wb600/acceptance-pr2/audiobook-curator-problems-repaired.png` -- `/tmp/wb600/acceptance-pr2/audiobook-curator-advanced-evals.png` - -`examples-real.e2e.test.ts` wrote additional example captures into the same -directory (and overwrote `report.json` with its own list). - -## Open risks - -- Until W1 stops redacting `word/word` summaries, screenshot review of - `trace-populated` shows `[REDACTED]` instead of the route identity. The - test still pins the Route facet, group membership, kinds, duration, deep - link, Open route, and live update. -- Tool routes did not emit `kernel.*` in this run. If W1 later forwards - render-child kernel events for tools, the helper already accepts them. -- `pnpm test:packed -- ` passes a literal `--` through to rstest and - runs the whole packed include; omit `--`. - -## Proposed changeset line - -None — Workbench-only. - -## Proposed diagnostic codes - -None. From 4263f0438eed3d75bc2138e4fda354bd6e098978 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:53:07 +0000 Subject: [PATCH 20/70] W2: browser wiring for unified trace (#600 PR 2) - pass ForegroundTraceClient as trace: TraceClient through ApplicationExplorer to RouteWorkspace - tools/call correlationId: controller stamps params._meta, remote transport lowers it to the top-level route field; appToolCallRequest no longer sends _meta - decode and render lifted frame id/method/meta on the Protocol page; correlationId links to /trace?correlation= - strict requestId on RouteInvocationRequest/Summary; shown on the status line - renumber browser trace decoder code AB8249 -> AB8243 (docs/diagnostics.md row) - drop dead PR 1 trace CSS from shell.css; .trace-link/.trace-status live in trace-page.css - extract ShellLink and route Open in Trace / trace rows through the shell router --- LANE-NOTES.md | 65 +++++++++++++++++ docs/diagnostics.md | 2 +- .../workbench/src/advanced/advanced-page.tsx | 8 ++- .../src/application/app-route-workspace.tsx | 17 +++-- .../executable-route-workspace.tsx | 3 +- .../src/application/invocation-client.ts | 1 + .../src/application/invocation-model.ts | 1 + .../workbench/src/application/result-tabs.tsx | 26 ++++--- .../src/application/runtime-backend.ts | 4 +- .../workbench/src/application/workspace.css | 2 +- packages/workbench/src/main.tsx | 8 ++- .../src/mcp/agent-bundle-remote-transport.ts | 5 ++ packages/workbench/src/mcp/mcp-page.css | 15 ++++ packages/workbench/src/mcp/mcp-page.tsx | 56 ++++++++++++--- .../workbench/src/mcp/mcp-route-client.ts | 10 +++ .../src/mcp/mcp-session-controller.ts | 70 +++++++++++++++++-- .../workbench/src/mcp/mcp-session-model.ts | 13 ++++ packages/workbench/src/shell/shell-link.tsx | 17 +++++ packages/workbench/src/shell/shell.css | 13 +--- packages/workbench/src/trace/trace-client.ts | 4 +- packages/workbench/src/trace/trace-page.css | 3 + packages/workbench/src/trace/trace-page.tsx | 22 +++--- .../agent-bundle-remote-transport.test.ts | 30 +++++++- .../workbench/tests/invocation-client.test.ts | 19 +++++ packages/workbench/tests/mcp-page.test.ts | 36 ++++++++++ .../tests/mcp-session-controller.test.ts | 67 +++++++++++++++++- .../workbench/tests/mcp-session-model.test.ts | 22 ++++++ .../workbench/tests/route-workspace.test.ts | 7 +- packages/workbench/tests/shell-link.test.ts | 36 ++++++++++ packages/workbench/tests/trace-client.test.ts | 2 + 30 files changed, 508 insertions(+), 76 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/workbench/src/shell/shell-link.tsx create mode 100644 packages/workbench/tests/shell-link.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..ece1fb8ec --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,65 @@ +# W2 — browser wiring + +## Files + +- `packages/workbench/src/main.tsx` +- `packages/workbench/src/shell/shell-link.tsx` (new; imported by `trace-page.tsx`, `result-tabs.tsx`, `mcp-page.tsx`) +- `packages/workbench/src/shell/shell.css` +- `packages/workbench/src/advanced/advanced-page.tsx` +- `packages/workbench/src/application/app-route-workspace.tsx` +- `packages/workbench/src/application/executable-route-workspace.tsx` +- `packages/workbench/src/application/invocation-client.ts` +- `packages/workbench/src/application/invocation-model.ts` +- `packages/workbench/src/application/result-tabs.tsx` +- `packages/workbench/src/application/runtime-backend.ts` +- `packages/workbench/src/application/workspace.css` +- `packages/workbench/src/mcp/agent-bundle-remote-transport.ts` +- `packages/workbench/src/mcp/mcp-page.tsx`, `mcp-page.css` +- `packages/workbench/src/mcp/mcp-route-client.ts` +- `packages/workbench/src/mcp/mcp-session-controller.ts` +- `packages/workbench/src/mcp/mcp-session-model.ts` +- `packages/workbench/src/trace/trace-client.ts` +- `packages/workbench/src/trace/trace-page.tsx`, `trace-page.css` +- `docs/diagnostics.md` (the `AB8243` row only) +- Tests: `agent-bundle-remote-transport`, `invocation-client`, `mcp-page`, `mcp-session-controller`, `mcp-session-model`, `route-workspace`, `trace-client`, `shell-link` (new). + +## Behavior + +1. **Trace client reaches the route workspace.** `main.tsx` passes `clients.traceClient` as `trace: TraceClient` (required) into `ApplicationExplorer`, which forwards it to `RouteWorkspace`; the Trace result tab now leaves "Loading correlated trace…" once the replay lands. `RouteWorkspaceProps.trace` stays optional so `route-workspace.test.ts` renders without a client. + +2. **App-workspace correlation seam.** The browser never sends `_meta` to the session route any more: + - `McpRouteOperation` `tools/call` gains `correlationId?: string`; `mcp-route-client.ts` exports `mcpCorrelationMetaKey = 'agent-bundle/correlationId'` (the same literal the server's `mcp-session-trace-publisher.ts` exports; the browser cannot import from `dev/**`). + - `McpSessionControllerRequest.correlationId?` — `invoke` stamps it into the SDK request's `params._meta[mcpCorrelationMetaKey]` (for `callTool` and `callToolTask`), which is the only channel from the SDK `Client` to the transport. + - `AgentBundleRemoteTransport.operationFor` lifts that key out of `params._meta` into the top-level `correlationId` of the operation body and forwards nothing else from `_meta` (the SDK's `progressToken` was already dropped before this change). Wire body is unit-tested: `{"arguments":…,"correlationId":"corr-app","name":…,"operation":"tools/call","requestId":"number:20"}` with no `_meta`. + - `appToolCallRequest(name, input, correlationId)` now returns `{ correlationId, request: { arguments, name } }` and the App workspace spreads it into `controller.invoke`. + - Runtime-bound sessions: `runtimeRouteOperationFor` puts `correlationId` on the `McpRouteOperation`, but the runtime bridge (`appBindingOperationFor` → `McpAppBindingOperation`, `runtimeOperationRequest` → `DevRuntimeMcpOperationRequest`) has no slot for it — see cross-lane request below. + - Dev-server runs: `RouteInvocationRequest.correlationId` was already threaded by the route controller (`newCorrelationId()` → `client.invoke(request)`; `invocation-client.ts` serializes the whole request). Runtime runs: `runtime-backend.ts` already forwarded `correlationId`; `DevRuntimeInvocationRequest` already declares it, so the redundant `& Readonly<{ correlationId?: string }>` intersections were removed. + +3. **Protocol page shows the lifted MCP correlation.** `mcp-session-controller.ts` `traceEntry` decodes optional `id`, `method` (non-empty strings ≤ 256 chars) and `meta` (a record with only `correlationId`/`conversationId`/`requestId`/`sessionId`, each a bounded string); anything else fails the stream with the existing "invalid entry" error (`mcp.trace.stream.error`). `mcp-session-model.ts` exports `McpBrowserSessionFrameEntry`, `isMcpFrameEntry`, and `mcpFrameMetaKeys`; the reducer's `snapshot` already retained the fields. The Raw protocol tab (`McpProtocolEvidence`, also used by the runtime-contract compile test) renders a facts line per frame — direction, `method`, `id`, and each lifted meta key — with `correlationId` as a `ShellLink` to `/trace?correlation=`. `McpPage` and `McpProtocolEvidence` accept `onNavigate?`; `AdvancedPage` passes its router into `ProtocolSection`. + +4. **`requestId` on invocations.** `invocation-client.ts` summary/invocation decoders accept `requestId: textSchema.optional()` (a non-string is `AB8230`); `invocationSummaryOf` echoes it; the status line shows `request ` beside `correlation ` (`.route-status-request`). The invoke body carries whatever `correlationId`/`requestId` the request has (tested). + +5. **`AB8249` → `AB8243`.** `TRACE_INVALID_RESPONSE_CODE = 'AB8243'` (trace-client.ts + doc comment), pinned in `trace-client.test.ts`. `docs/diagnostics.md` row now reads: browser decoder `AB8243`, sitting between the trace routes (`AB8240`–`AB8242`) and the hook receipt route (`AB8247`–`AB8249`); `AB8244`–`AB8246` unassigned. The `AB8247`–`AB8249` server row is untouched. + +6. **Dead PR 1 trace CSS.** `shell.css` keeps only `.problem-list`, `.problem-link`, `.problem-link:hover`; `.trace-table`, `.trace-status--succeeded/--failed`, and all `.trace-entry*` rules are gone. `.trace-link` and the `.trace-status` base rule are *not* dead — `trace-page.tsx` uses both (`StatusPill`, detail-drawer links) — so they moved into `trace-page.css` next to the `--ok/--error/--running` modifiers. `git grep` confirms no markup uses the deleted classes (only `data-testid="trace-entry"` remains, which is an attribute, not the class). + +7. **Route workspace ↔ Trace round trip.** The private `Link` in `trace-page.tsx` became the shared `ShellLink` (`shell/shell-link.tsx`: real `href`, `preventDefault` + `onNavigate` on click, plain anchor when no router). `result-tabs.tsx` uses it for "Open in Trace" (`{ area: 'trace', correlation }` → `/trace?correlation=`, verified in `route-workspace.test.ts`) and for each `TraceRow` (`{ area: 'trace', invocationId: entry.id }` → `/trace/`), and `ExecutableRouteWorkspace` now passes `onNavigate` into `ResultTabs`, so the round trip no longer reloads the app. `/trace/` still resolves through `selectTraceEntry`'s invocation-id fallback (`trace-model.test.ts` covers `inv_3`). + +## Cross-lane requests + +- **Server (T3/T4 owner, `packages/agent-bundle/src/dev/runtime-protocol.ts` + `contracts/mcp-apps.ts`):** the runtime App path cannot carry the Workbench correlation. Exact edit: add `readonly correlationId?: string;` to the `call-tool` member of `DevRuntimeMcpOperationRequest` (runtime-protocol.ts ~line 240) and to the `tools/call` member of `McpAppBindingOperation`, then stamp it into `params._meta[mcpCorrelationMetaKey]` where the runtime MCP session service builds the `tools/call` request. Browser side is ready: `runtimeRouteOperationFor` already sets `correlationId` on the `McpRouteOperation`; once the contracts gain the field, `appBindingOperationFor` (mcp-session-controller.ts) and `runtimeOperationRequest` (mcp-route-client.ts) need one line each to forward it. +- **Server (`contracts/mcp-session.ts`):** consider re-exporting `mcpCorrelationMetaKey` from the contract so the browser copy in `mcp-route-client.ts` can import it instead of restating the literal. Not blocking. +- **W3 (e2e):** `tests/audiobook-curator.acceptance.e2e.test.ts:112` locates `.trace-table tr[data-invocation-id=…]`, a PR 1 selector that no longer exists in the Trace page markup (rows are `.trace-row`/`.trace-line` and carry no `data-invocation-id`). Use `getByTestId('trace-entry')` or `.trace-line[href="/trace/"]`. +- **W3 (e2e):** new hooks for acceptance: `data-testid="mcp-frame-facts"` on Protocol-page frames, `.route-status-request` on the invocation status line, `.mcp-page-frame-link` for the correlation link. + +## Open risks + +- `isMcpFrameEntry` narrows an `unknown` timeline value by `kind`/`direction`/`sequence` only; it relies on the controller's strict decoder being the sole producer of frames (it is — the model reducer never fabricates frames). +- `McpProtocolEvidence` still takes `readonly unknown[]` for the runtime-contract test; the frame facts render only for values that pass `isMcpFrameEntry`, so provider-evidence callers are unaffected. +- The redundant `& Readonly<{ correlationId?: string }>` removal in `runtime-backend.ts` is type-only; `DevRuntimeInvocationRequest.correlationId` already exists in `runtime-protocol.ts`. + +## Verification + +- `pnpm build && npx tsc --project packages/workbench/tsconfig.json --noEmit && npx tsc --noEmit && pnpm lint` — green. +- `npx rstest --config rstest.unit.config.ts` over `advanced-page`, `agent-bundle-remote-transport`, `dev-server-backend`, `invocation-client`, `invocation-model`, `logs-page`, `mcp-page`, `mcp-session-controller`, `mcp-session-model`, `route-workspace`, `runtime-backend`, `runtime-contract-compile`, `shell-link`, `trace-client`, `trace-model`, `trace-page`, `workbench-router`, `workbench-shell` — 18 files, 215 tests, 0 failures. +- Deslop pass over the diff: shared `mcpFrameMetaKeys` instead of two key lists and two `as` casts; `ShellLink` extracted and rewired in the same change (no private `Link` left behind); `runtime-backend.ts` intersection types dropped. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index db9329d60..1654de8fc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -44,7 +44,7 @@ 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. | -| `AB8249` | 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). `AB8240`–`AB8248` are reserved for the server-side trace route. | +| `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). | 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 a86171c35..4bf829dba 100644 --- a/packages/workbench/src/application/app-route-workspace.tsx +++ b/packages/workbench/src/application/app-route-workspace.tsx @@ -17,7 +17,7 @@ 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'; @@ -68,15 +68,18 @@ export const orderedToolsForApp = (tools: readonly McpCatalogTool[], resourceUri ...tools.filter((tool) => resourceUri === undefined || tool.resourceUri !== resourceUri), ]); -/** MCP tool params carrying the Workbench correlation key understood by the session service. */ +/** + * 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, -): Readonly> => Object.freeze({ - _meta: Object.freeze({ 'agent-bundle/correlationId': correlationId }), - arguments: input, - name, +): Pick => Object.freeze({ + correlationId, + request: Object.freeze({ arguments: input, name }), }); interface ToolCall { @@ -153,7 +156,7 @@ export const AppRouteWorkspace = ({ clients, leaf, onNavigate, status }: AppRout void controller.invoke({ id: `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, operation: 'callTool', - request: appToolCallRequest(tool.name, input, correlationId), + ...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/executable-route-workspace.tsx b/packages/workbench/src/application/executable-route-workspace.tsx index 5d9a2b792..cb92dfb63 100644 --- a/packages/workbench/src/application/executable-route-workspace.tsx +++ b/packages/workbench/src/application/executable-route-workspace.tsx @@ -172,6 +172,7 @@ const InvocationStatusLine = ({ backendKind, state }: { readonly backendKind?: s {backendKind === undefined ? undefined : via {backendKind}} {invocation === undefined ? undefined : {invocation.id}} {invocation?.correlationId === undefined ? undefined : correlation {invocation.correlationId}} + {invocation?.requestId === undefined ? undefined : request {invocation.requestId}}

    ; }; @@ -328,7 +329,7 @@ export const ExecutableRouteWorkspace = ({

    Invocation not in this session

    Invocation {invocationId} is not in this session.

    - : } + : } void; + export interface ResultTabDefinition { readonly id: WorkspaceResultTab; readonly label: string; @@ -30,7 +33,7 @@ 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; @@ -123,19 +126,20 @@ const orderedTraceEntries = ( .filter((entry) => traceMatches(entry, invocationId, correlationId)) .sort((left, right) => left.sequence - right.sequence); -const TraceRow = ({ entry }: { readonly entry: TraceEntry }): React.ReactNode =>
  5. - +const TraceRow = ({ entry, onNavigate }: { readonly entry: TraceEntry; readonly onNavigate?: Navigate }): React.ReactNode =>
  6. + {entry.kind.replaceAll('.', ' · ')} {entry.summary} {entry.durationMs === undefined ? '—' : `${String(entry.durationMs)} ms`} - +
  7. ; -export const TraceTimeline = ({ correlationId, entries, invocationId }: { +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) { @@ -145,14 +149,14 @@ export const TraceTimeline = ({ correlationId, entries, invocationId }: { const outer = matching.filter((entry) => entry.source !== 'kernel'); return
      {outer.map((entry, index) => - + {index === 0 && kernel.length > 0 ?
    1. -
        {kernel.map((phase) => )}
      +
        {kernel.map((phase) => )}
    2. : undefined}
      )} - {outer.length === 0 ? kernel.map((entry) => ) : undefined} + {outer.length === 0 ? kernel.map((entry) => ) : undefined}
    ; }; @@ -187,7 +191,7 @@ const useTraceEntries = (trace: TraceClient | undefined): TraceLoadState => { }; /** The tabbed result pane; `rendered` is the default and always present. */ -export const ResultTabs = ({ controller, extraTabs = [], leaf, onTabChange, tab, trace }: 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 traceState = useTraceEntries(trace); @@ -206,14 +210,14 @@ export const ResultTabs = ({ controller, extraTabs = [], leaf, onTabChange, tab, ?

    Run the route to see its correlated trace.

    : 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 + Open in Trace
    }
    {definitions.map((definition) =>
    {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..577345c18 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -84,12 +84,22 @@ export interface McpRouteTrace { readonly overflow?: unknown; } +/** + * The `params._meta` key the dev server's MCP session route stamps a `tools/call` + * `correlationId` under, and the key its trace publisher lifts back into a frame's + * `meta.correlationId`. In the browser it only travels between the session + * controller and the remote transport, which lowers it to the top-level field. + */ +export const mcpCorrelationMetaKey = 'agent-bundle/correlationId'; + export type McpRouteOperation = | Readonly<{ readonly operation: 'initialize' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'tools/list' }> | Readonly<{ readonly arguments?: Readonly>; readonly name: string; readonly operation: 'prompts/get' }> | 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..1e55b100d 100644 --- a/packages/workbench/src/mcp/mcp-session-controller.ts +++ b/packages/workbench/src/mcp/mcp-session-controller.ts @@ -13,6 +13,7 @@ import type { 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, @@ -41,6 +43,7 @@ import { type McpBrowserSessionModel, } from './mcp-session-model.ts'; import { + mcpCorrelationMetaKey, McpRouteClientError, sameRuntimeBinding, type McpRouteCatalog, @@ -65,6 +68,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 +391,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 +429,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 +497,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 +517,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 +539,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 +1502,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 +1543,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/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/trace/trace-client.ts b/packages/workbench/src/trace/trace-client.ts index 6beaf22de..5e8dcbcec 100644 --- a/packages/workbench/src/trace/trace-client.ts +++ b/packages/workbench/src/trace/trace-client.ts @@ -35,7 +35,7 @@ export interface TraceClientOptions { readonly foreground: ForegroundRequestAuthority; } -/** `AB8249`: the route answered with bytes this client refuses to interpret. Other codes are the server's own refusals. */ +/** `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; @@ -46,7 +46,7 @@ export class TraceClientError extends Error { } } -export const TRACE_INVALID_RESPONSE_CODE = 'AB8249'; +export const TRACE_INVALID_RESPONSE_CODE = 'AB8243'; const maximumFrameBytes = 64 * 1024; const maximumSummaryLength = 240; diff --git a/packages/workbench/src/trace/trace-page.css b/packages/workbench/src/trace/trace-page.css index 295130375..9670e26a1 100644 --- a/packages/workbench/src/trace/trace-page.css +++ b/packages/workbench/src/trace/trace-page.css @@ -54,6 +54,9 @@ .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; } diff --git a/packages/workbench/src/trace/trace-page.tsx b/packages/workbench/src/trace/trace-page.tsx index d13b774b0..d73f64d4d 100644 --- a/packages/workbench/src/trace/trace-page.tsx +++ b/packages/workbench/src/trace/trace-page.tsx @@ -7,7 +7,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { type TraceEntry, type TraceSource, type TraceStatus, traceSources } from '../../../agent-bundle/src/contracts/trace.ts'; -import { formatWorkbenchLocation, parseWorkbenchLocation, type WorkbenchLocation } from '../shell/workbench-location.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 { filterTraceGroups, @@ -113,13 +114,6 @@ const useTraceFeed = (client: TraceClient, supplied: readonly TraceEntry[] | und return supplied === undefined ? state : initialFeedState(supplied); }; -/** A shell link: a real `href` for middle-click and copy, the router for a plain click. */ -const Link = ({ location, onNavigate, ...anchor }: { - readonly location: WorkbenchLocation; - readonly onNavigate: (location: WorkbenchLocation) => void; -} & Omit, 'href' | 'onClick'>) => - { event.preventDefault(); onNavigate(location); }} />; - const StatusPill = ({ status }: { readonly status: TraceStatus }) => {status}; @@ -203,7 +197,7 @@ const GroupView = ({ correlation, group, onNavigate, selected, selectedEntryId, {group.rows.map(({ depth, entry }) => { const status = entry.status ?? 'ok'; return
  8. - {entry.summary} {status === 'error' ? ! : undefined} {entry.durationMs === undefined ? '' : formatTraceDuration(entry.durationMs)} - +
  9. ; })}
@@ -243,7 +237,7 @@ const DetailDrawer = ({ correlation, entry, onNavigate, timeZone }: {

{entry.source} · {entry.kind}

{entry.summary}

- × + ×
{entry.href === undefined @@ -265,7 +259,7 @@ const DetailDrawer = ({ correlation, entry, onNavigate, timeZone }: { {keys.length === 0 ?

This entry carries no correlation key.

:
{keys.map(([key, value]) =>
{key}
-
{value}
+
{value}
)}
}

Details

@@ -322,7 +316,7 @@ export const TracePage = ({ client, correlation, entries: suppliedEntries, entry

{heading}

{correlation === undefined ? undefined :

- Correlated by {correlation} · Show all + Correlated by {correlation} · Show all

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

{feed.error}

} @@ -354,7 +348,7 @@ export const TracePage = ({ client, correlation, entries: suppliedEntries, entry : } diff --git a/packages/workbench/tests/agent-bundle-remote-transport.test.ts b/packages/workbench/tests/agent-bundle-remote-transport.test.ts index c3740c182..d66e36c21 100644 --- a/packages/workbench/tests/agent-bundle-remote-transport.test.ts +++ b/packages/workbench/tests/agent-bundle-remote-transport.test.ts @@ -2,7 +2,7 @@ import { expect, it } from '@rstest/core'; import type { JSONRPCMessage } from '@modelcontextprotocol/client'; import { AgentBundleRemoteTransport, dispatchAgentBundleMcpRequest } from '../src/mcp/agent-bundle-remote-transport.ts'; -import { McpRouteClient } from '../src/mcp/mcp-route-client.ts'; +import { mcpCorrelationMetaKey, McpRouteClient } from '../src/mcp/mcp-route-client.ts'; import { deferred, eventually } from './support/async.ts'; interface RecordedRequest { @@ -542,6 +542,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/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts index 64e32e33f..2572f2bf7 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({ @@ -102,6 +103,24 @@ it('strictly decodes invoke, list, and read responses', async () => { }); }); +it('sends and decodes the optional correlationId and requestId on invocations and summaries', async () => { + const requests: Array = []; + const correlated = { ...invocation, correlationId: 'corr-1', requestId: 'req-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' }, requestId: 'req-1', routeId: invocation.routeId })).resolves.toEqual(correlated); + await expect(client.list(1)).resolves.toEqual([expect.objectContaining({ correlationId: 'corr-1', id: invocation.id, requestId: 'req-1' })]); + expect(JSON.parse(String(requests[0]?.[1].body))).toEqual({ correlationId: 'corr-1', input: { title: 'Dune' }, requestId: 'req-1', 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/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..07474dfee 100644 --- a/packages/workbench/tests/mcp-session-controller.test.ts +++ b/packages/workbench/tests/mcp-session-controller.test.ts @@ -9,7 +9,7 @@ import { type McpSessionControllerRoutes, type McpSessionControllerTransport, } from '../src/mcp/mcp-session-controller.ts'; -import { McpRouteClientError, type McpRouteCatalog } from '../src/mcp/mcp-route-client.ts'; +import { mcpCorrelationMetaKey, McpRouteClientError, type McpRouteCatalog } from '../src/mcp/mcp-route-client.ts'; const binding = Object.freeze({ epochId: 'epoch-a', serverName: 'weather', target: 'portable' as const }); const connection = Object.freeze({ @@ -2159,6 +2159,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/route-workspace.test.ts b/packages/workbench/tests/route-workspace.test.ts index d55cc9600..13a057aef 100644 --- a/packages/workbench/tests/route-workspace.test.ts +++ b/packages/workbench/tests/route-workspace.test.ts @@ -407,11 +407,10 @@ describe('App leaf tool binding', () => { expect(orderedToolsForApp(tools, undefined).map((tool) => tool.name)).toEqual(['inventory_sources', 'browse_library']); }); - it('stamps App tool calls with the browser correlation id', () => { + 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({ - _meta: { 'agent-bundle/correlationId': 'corr-app' }, - arguments: { query: 'Dune' }, - name: 'browse_library', + 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/trace-client.test.ts b/packages/workbench/tests/trace-client.test.ts index 0a754c98f..c4e06c8cd 100644 --- a/packages/workbench/tests/trace-client.test.ts +++ b/packages/workbench/tests/trace-client.test.ts @@ -75,6 +75,8 @@ it('rejects replay envelopes that are malformed, non-contiguous, or inconsistent 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' } }); From b338d9aec91eee70b1e13e1a3a5013355df0e495 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 16:59:32 +0000 Subject: [PATCH 21/70] fix trace summary signal quality --- LANE-NOTES.md | 94 +++++++++++++++++++ .../src/dev/hooks/hook-receipts.ts | 2 +- .../src/dev/logs/dev-log-service.ts | 41 +++++++- .../tests/dev-log-service.test.ts | 70 ++++++++++++-- .../tests/hook-receipt-pipe.test.ts | 4 +- .../agent-bundle/tests/hook-receipts.test.ts | 8 +- .../tests/support/workbench-acceptance.ts | 12 ++- 7 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..82a92369e --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,94 @@ +# W5 — trace signal quality + +## Files changed + +- `packages/agent-bundle/src/dev/logs/dev-log-service.ts` +- `packages/agent-bundle/src/dev/hooks/hook-receipts.ts` +- `packages/agent-bundle/tests/dev-log-service.test.ts` +- `packages/agent-bundle/tests/hook-receipts.test.ts` +- `packages/agent-bundle/tests/hook-receipt-pipe.test.ts` +- `packages/workbench/tests/support/workbench-acceptance.ts` + +## Behavior + +- `safeDevWireText` preserves slash-bearing relative route and event identities such as + `curator/search_audible`, `tool/before`, `tool:curator/search_audible`, and + `event:session/start`. +- Control characters, credentials, project roots, `file:` URLs, drive-letter paths, + UNC paths, home-relative paths, and absolute POSIX paths remain redacted. A project + path still becomes `/…`. +- Dev Log context identifiers keep their existing slash-free policy except for the + existing validated `routeId` rule. +- Dev Logs reach Trace only at warning/error level or with a request-scoped join key. + Epoch, build, route, and target facets alone no longer publish. +- Project-event mirrors already lowered by route invocation, runtime, or project + diagnostic producers do not publish a duplicate log entry. +- Hook receipt summaries use the canonical `tool/before` event identity. +- Audiobook acceptance requires exactly `invocation.started` and + `invocation.completed`, a readable `curator/search_audible` completion summary, + and no `log.project.*` row. + +`packages/workbench/src/trace/trace-client.ts` already uses a `pathLikeText` rule that +permits `curator/search_audible`, `tool/before`, and route identifiers while rejecting +absolute paths. No W2 edit is needed there. + +## Cross-lane request → W2 + +`packages/workbench/src/logs/log-client.ts` still defines `isSafeWireText` with +`hasControlOrSeparators(withoutProjectPaths)`, so the Raw Logs client rejects the +relative slash-bearing strings the corrected server sanitizer now emits. Make its +free-text rule mirror `trace/trace-client.ts` exactly: + +1. Add the local `hasControlCharacters` loop and `pathLikeText` expression from + `trace-client.ts`. +2. In `isSafeWireText`, retain the length and credential checks, then use + `!hasControlCharacters(value) && !pathLikeText.test(value)` instead of + `!hasControlOrSeparators(withoutProjectPaths)` and the old file/drive/UNC test. +3. Keep `hasControlOrSeparators` for detail keys and context identifiers; do not + loosen those generic identifier boundaries. Preserve the existing validated + handling required for `routeId`. +4. Add LogClient cases proving `MCP tool curator/search_audible · 2.9 s` and + `event tool/before (claude)` decode while absolute/file/drive/UNC/home paths do + not. + +Until that W2-owned mirror lands, `packages/workbench/tests/logs-real.e2e.test.ts` +reliably times out after navigating away and replaying the now-correct slash-bearing +records (two isolated runs). No W2-owned source file was changed in this lane. + +## Verification + +- Build, root TypeScript, Workbench TypeScript, and lint: passed. +- Six affected unit files: 52 passed. +- `trace-dev-server`, `route-invocation-dev-server`, `hook-receipt-pipe`, and + `dev-log-foreground`: 4 passed. +- Audiobook curator acceptance: passed. +- `logs-real.e2e.test.ts`: blocked by the W2 decoder mismatch above; failed twice at + the second replay-row readiness wait. +- IDE diagnostics and `git diff --check`: clean. +- TraceDecay MCP discovery and CLI fallback were attempted; both were unavailable + because the installed daemon socket was down, so review used focused native reads. + +## Acceptance captures + +- `/tmp/wb600/acceptance-pr2b/audiobook-curator-trace-populated.png` +- Other audiobook acceptance captures and report are under + `/tmp/wb600/acceptance-pr2b/`. + +The populated trace shows four entries in two invocation groups. Each group contains +only start/completion rows, both summaries name `curator/search_audible`, and no +artifact or project-log mirror group is present. + +## Open risks + +- Raw Logs remains unable to decode the widened safe free-text grammar until W2 + applies the exact mirror change above. +- No other open risk found in the six-file lane diff. + +## Proposed changeset line + +Preserve route and event identities in development trace summaries while removing +duplicate project-event log rows from the correlated timeline. (#600) + +## Diagnostic codes + +None. diff --git a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts index c265955f4..8f546ec9f 100644 --- a/packages/agent-bundle/src/dev/hooks/hook-receipts.ts +++ b/packages/agent-bundle/src/dev/hooks/hook-receipts.ts @@ -340,7 +340,7 @@ const instantAfter = (startedAt: string, receipt: EventTraceReceipt, at: number }; const describe = (receipt: EventTraceReceipt): string => - `${receipt.execution.host} ${receipt.execution.nativeEvent} → ${receipt.execution.event.replaceAll('/', ' · ')}`; + `${receipt.execution.host} ${receipt.execution.nativeEvent} → ${receipt.execution.event}`; /** * Lowers one decoded receipt into the entries a `TracePublisher` receives, in 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 440e27a56..a2827ccf2 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-service.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-service.ts @@ -124,6 +124,31 @@ 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', + 'runId', + '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', + 'diagnostic:runtime.event.diagnostic', + 'project:dev.contract.status', + 'project:dev.host.sync', + 'project:route.invocation', + 'project:runtime.event', +]); // 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. @@ -197,7 +222,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; }; @@ -264,6 +293,12 @@ const traceCorrelationFor = (context: Readonly>): TraceCo ...(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); @@ -435,9 +470,9 @@ export class DevLogService { #publishTrace(record: DevLogRecord): void { const trace = this.#trace; - if (trace === undefined) return; + if (trace === undefined || isLoweredProjectEventMirror(record)) return; const correlation = traceCorrelationFor(record.context); - if (record.level !== 'warning' && record.level !== 'error' && Object.keys(correlation).length === 0) return; + if (record.level !== 'warning' && record.level !== 'error' && !hasTraceRequestContext(record.context)) return; try { trace.publish({ correlation, diff --git a/packages/agent-bundle/tests/dev-log-service.test.ts b/packages/agent-bundle/tests/dev-log-service.test.ts index 69e38d892..26dfe86de 100644 --- a/packages/agent-bundle/tests/dev-log-service.test.ts +++ b/packages/agent-bundle/tests/dev-log-service.test.ts @@ -4,11 +4,25 @@ import { expect, it } from '@rstest/core'; 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({ now: () => new Date('2026-08-18T12:00:00.000Z'), @@ -57,13 +71,24 @@ it('publishes warnings, errors, and correlated records to trace without plain in 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', + runId: 'run-1', + sessionId: 'session-1', }, kind: 'project.prepared', level: 'info', @@ -87,6 +112,12 @@ it('publishes warnings, errors, and correlated records to trace without plain in producer: 'project', summary: 'Route failed.', }); + service.log({ + kind: 'project.invalid-source', + level: 'error', + producer: 'project', + summary: 'Uncorrelated error.', + }); expect(trace.replay().entries).toMatchObject([ { @@ -94,36 +125,55 @@ it('publishes warnings, errors, and correlated records to trace without plain in conversationId: 'conversation-1', correlationId: 'correlation-1', executionId: 'execution-1', + invocationId: 'invocation-1', + mcpRequestId: 'mcp-request-1', mcpSessionId: 'mcp-session-1', requestId: 'request-1', + runId: 'run-1', + sessionId: 'session-1', }, - href: '/advanced/logs?sequence=2', + href: '/advanced/logs?sequence=3', kind: 'log.project.project.prepared', source: 'log', summary: 'Correlated project event.', }, { correlation: {}, - href: '/advanced/logs?sequence=3', + href: '/advanced/logs?sequence=4', kind: 'log.mcp.mcp.stderr', source: 'log', summary: 'Uncorrelated warning.', }, { - correlation: { - invocationId: 'invocation-1', - mcpRequestId: 'request-1', - routeId: 'tool:curator/search', - }, - href: '/routes/mcp/curator/tool/search?invocation=invocation-1', - kind: 'log.project.route.invocation', + correlation: {}, + href: '/advanced/logs?sequence=6', + kind: 'log.project.project.invalid-source', source: 'log', status: 'error', - summary: 'Route failed.', + 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: { runId: 'run-1' }, kind: 'runtime.event', level: 'warning', producer: 'project', summary: 'Runtime 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/hook-receipt-pipe.test.ts b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts index 4c06e4696..46d04f9cb 100644 --- a/packages/agent-bundle/tests/hook-receipt-pipe.test.ts +++ b/packages/agent-bundle/tests/hook-receipt-pipe.test.ts @@ -148,7 +148,7 @@ it('posts a host-invoked hook execution to the dev server as hook.received / hoo href: '/routes/events/tool/before', source: 'hook', status: 'ok', - summary: 'claude PreToolUse → tool · before received', + summary: 'claude PreToolUse → tool/before received', }); expect(received.correlation.executionId).toMatch(/^[0-9a-f-]{36}$/u); expect(completed.correlation).toEqual(received.correlation); @@ -164,7 +164,7 @@ it('posts a host-invoked hook execution to the dev server as hook.received / hoo }, href: '/routes/events/tool/before', status: 'ok', - summary: 'claude PreToolUse → tool · before completed', + summary: 'claude PreToolUse → tool/before completed', }); expect(typeof completed.durationMs).toBe('number'); const serialized = JSON.stringify(afterEnv); diff --git a/packages/agent-bundle/tests/hook-receipts.test.ts b/packages/agent-bundle/tests/hook-receipts.test.ts index d1ed9af2a..dba5f6b83 100644 --- a/packages/agent-bundle/tests/hook-receipts.test.ts +++ b/packages/agent-bundle/tests/hook-receipts.test.ts @@ -191,7 +191,7 @@ it('lowers a completed receipt to hook.received and hook.completed with the even occurredAt: '2026-09-05T15:00:00.000Z', source: 'hook', status: 'ok', - summary: 'claude PreToolUse → tool · before received', + summary: 'claude PreToolUse → tool/before received', }); expect(entries[1]).toMatchObject({ correlation, @@ -208,7 +208,7 @@ it('lowers a completed receipt to hook.received and hook.completed with the even href: '/routes/events/tool/before', occurredAt: '2026-09-05T15:00:00.006Z', status: 'ok', - summary: 'claude PreToolUse → tool · before completed', + summary: 'claude PreToolUse → tool/before completed', }); expect(entries.every((entry) => !entry.href?.includes('invocation='))).toBe(true); expect(JSON.stringify(entries)).not.toContain('tool_input'); @@ -226,7 +226,7 @@ it('lowers a failure to hook.failed with the kernel error summary, and a gate ou 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', + summary: 'claude PreToolUse → tool/before failed in execute: EventRuntimeTransportError: render exploded', }); const denied = receipt({ events: [ @@ -240,7 +240,7 @@ it('lowers a failure to hook.failed with the kernel error summary, and a gate ou details: { gate: 'deny' }, kind: 'hook.completed', status: 'ok', - summary: 'claude PreToolUse → tool · before denied by preflight', + summary: 'claude PreToolUse → tool/before denied by preflight', }); expect(gated[1]!.details).not.toHaveProperty('runtime'); }); diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index 0b2ecde65..d72f3eda2 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -274,7 +274,7 @@ export const traceEntryRow = (page: Page, kind?: string): Locator => ? workbenchTestId(page, 'traceEntry') : page.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind=${JSON.stringify(kind)}]`); -/** Group for one tool invocation. Summaries may be `[REDACTED]`; `routeId` is asserted via the Route facet. */ +/** 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 }>, @@ -285,13 +285,17 @@ export const expectToolInvocationTraceGroup = async ( 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 }); - const kernel = group.locator(`[data-testid=${JSON.stringify(workbenchTestIds.traceEntry)}][data-kind^="kernel."]`); - if (await kernel.count() > 0) await expect(kernel.first()).toBeVisible({ timeout }); - else await expect(completed.locator('.trace-duration')).toHaveText(/\d| Date: Sat, 5 Sep 2026 17:11:49 +0000 Subject: [PATCH 22/70] drop LANE-NOTES --- LANE-NOTES.md | 65 --------------------------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index ece1fb8ec..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,65 +0,0 @@ -# W2 — browser wiring - -## Files - -- `packages/workbench/src/main.tsx` -- `packages/workbench/src/shell/shell-link.tsx` (new; imported by `trace-page.tsx`, `result-tabs.tsx`, `mcp-page.tsx`) -- `packages/workbench/src/shell/shell.css` -- `packages/workbench/src/advanced/advanced-page.tsx` -- `packages/workbench/src/application/app-route-workspace.tsx` -- `packages/workbench/src/application/executable-route-workspace.tsx` -- `packages/workbench/src/application/invocation-client.ts` -- `packages/workbench/src/application/invocation-model.ts` -- `packages/workbench/src/application/result-tabs.tsx` -- `packages/workbench/src/application/runtime-backend.ts` -- `packages/workbench/src/application/workspace.css` -- `packages/workbench/src/mcp/agent-bundle-remote-transport.ts` -- `packages/workbench/src/mcp/mcp-page.tsx`, `mcp-page.css` -- `packages/workbench/src/mcp/mcp-route-client.ts` -- `packages/workbench/src/mcp/mcp-session-controller.ts` -- `packages/workbench/src/mcp/mcp-session-model.ts` -- `packages/workbench/src/trace/trace-client.ts` -- `packages/workbench/src/trace/trace-page.tsx`, `trace-page.css` -- `docs/diagnostics.md` (the `AB8243` row only) -- Tests: `agent-bundle-remote-transport`, `invocation-client`, `mcp-page`, `mcp-session-controller`, `mcp-session-model`, `route-workspace`, `trace-client`, `shell-link` (new). - -## Behavior - -1. **Trace client reaches the route workspace.** `main.tsx` passes `clients.traceClient` as `trace: TraceClient` (required) into `ApplicationExplorer`, which forwards it to `RouteWorkspace`; the Trace result tab now leaves "Loading correlated trace…" once the replay lands. `RouteWorkspaceProps.trace` stays optional so `route-workspace.test.ts` renders without a client. - -2. **App-workspace correlation seam.** The browser never sends `_meta` to the session route any more: - - `McpRouteOperation` `tools/call` gains `correlationId?: string`; `mcp-route-client.ts` exports `mcpCorrelationMetaKey = 'agent-bundle/correlationId'` (the same literal the server's `mcp-session-trace-publisher.ts` exports; the browser cannot import from `dev/**`). - - `McpSessionControllerRequest.correlationId?` — `invoke` stamps it into the SDK request's `params._meta[mcpCorrelationMetaKey]` (for `callTool` and `callToolTask`), which is the only channel from the SDK `Client` to the transport. - - `AgentBundleRemoteTransport.operationFor` lifts that key out of `params._meta` into the top-level `correlationId` of the operation body and forwards nothing else from `_meta` (the SDK's `progressToken` was already dropped before this change). Wire body is unit-tested: `{"arguments":…,"correlationId":"corr-app","name":…,"operation":"tools/call","requestId":"number:20"}` with no `_meta`. - - `appToolCallRequest(name, input, correlationId)` now returns `{ correlationId, request: { arguments, name } }` and the App workspace spreads it into `controller.invoke`. - - Runtime-bound sessions: `runtimeRouteOperationFor` puts `correlationId` on the `McpRouteOperation`, but the runtime bridge (`appBindingOperationFor` → `McpAppBindingOperation`, `runtimeOperationRequest` → `DevRuntimeMcpOperationRequest`) has no slot for it — see cross-lane request below. - - Dev-server runs: `RouteInvocationRequest.correlationId` was already threaded by the route controller (`newCorrelationId()` → `client.invoke(request)`; `invocation-client.ts` serializes the whole request). Runtime runs: `runtime-backend.ts` already forwarded `correlationId`; `DevRuntimeInvocationRequest` already declares it, so the redundant `& Readonly<{ correlationId?: string }>` intersections were removed. - -3. **Protocol page shows the lifted MCP correlation.** `mcp-session-controller.ts` `traceEntry` decodes optional `id`, `method` (non-empty strings ≤ 256 chars) and `meta` (a record with only `correlationId`/`conversationId`/`requestId`/`sessionId`, each a bounded string); anything else fails the stream with the existing "invalid entry" error (`mcp.trace.stream.error`). `mcp-session-model.ts` exports `McpBrowserSessionFrameEntry`, `isMcpFrameEntry`, and `mcpFrameMetaKeys`; the reducer's `snapshot` already retained the fields. The Raw protocol tab (`McpProtocolEvidence`, also used by the runtime-contract compile test) renders a facts line per frame — direction, `method`, `id`, and each lifted meta key — with `correlationId` as a `ShellLink` to `/trace?correlation=`. `McpPage` and `McpProtocolEvidence` accept `onNavigate?`; `AdvancedPage` passes its router into `ProtocolSection`. - -4. **`requestId` on invocations.** `invocation-client.ts` summary/invocation decoders accept `requestId: textSchema.optional()` (a non-string is `AB8230`); `invocationSummaryOf` echoes it; the status line shows `request ` beside `correlation ` (`.route-status-request`). The invoke body carries whatever `correlationId`/`requestId` the request has (tested). - -5. **`AB8249` → `AB8243`.** `TRACE_INVALID_RESPONSE_CODE = 'AB8243'` (trace-client.ts + doc comment), pinned in `trace-client.test.ts`. `docs/diagnostics.md` row now reads: browser decoder `AB8243`, sitting between the trace routes (`AB8240`–`AB8242`) and the hook receipt route (`AB8247`–`AB8249`); `AB8244`–`AB8246` unassigned. The `AB8247`–`AB8249` server row is untouched. - -6. **Dead PR 1 trace CSS.** `shell.css` keeps only `.problem-list`, `.problem-link`, `.problem-link:hover`; `.trace-table`, `.trace-status--succeeded/--failed`, and all `.trace-entry*` rules are gone. `.trace-link` and the `.trace-status` base rule are *not* dead — `trace-page.tsx` uses both (`StatusPill`, detail-drawer links) — so they moved into `trace-page.css` next to the `--ok/--error/--running` modifiers. `git grep` confirms no markup uses the deleted classes (only `data-testid="trace-entry"` remains, which is an attribute, not the class). - -7. **Route workspace ↔ Trace round trip.** The private `Link` in `trace-page.tsx` became the shared `ShellLink` (`shell/shell-link.tsx`: real `href`, `preventDefault` + `onNavigate` on click, plain anchor when no router). `result-tabs.tsx` uses it for "Open in Trace" (`{ area: 'trace', correlation }` → `/trace?correlation=`, verified in `route-workspace.test.ts`) and for each `TraceRow` (`{ area: 'trace', invocationId: entry.id }` → `/trace/`), and `ExecutableRouteWorkspace` now passes `onNavigate` into `ResultTabs`, so the round trip no longer reloads the app. `/trace/` still resolves through `selectTraceEntry`'s invocation-id fallback (`trace-model.test.ts` covers `inv_3`). - -## Cross-lane requests - -- **Server (T3/T4 owner, `packages/agent-bundle/src/dev/runtime-protocol.ts` + `contracts/mcp-apps.ts`):** the runtime App path cannot carry the Workbench correlation. Exact edit: add `readonly correlationId?: string;` to the `call-tool` member of `DevRuntimeMcpOperationRequest` (runtime-protocol.ts ~line 240) and to the `tools/call` member of `McpAppBindingOperation`, then stamp it into `params._meta[mcpCorrelationMetaKey]` where the runtime MCP session service builds the `tools/call` request. Browser side is ready: `runtimeRouteOperationFor` already sets `correlationId` on the `McpRouteOperation`; once the contracts gain the field, `appBindingOperationFor` (mcp-session-controller.ts) and `runtimeOperationRequest` (mcp-route-client.ts) need one line each to forward it. -- **Server (`contracts/mcp-session.ts`):** consider re-exporting `mcpCorrelationMetaKey` from the contract so the browser copy in `mcp-route-client.ts` can import it instead of restating the literal. Not blocking. -- **W3 (e2e):** `tests/audiobook-curator.acceptance.e2e.test.ts:112` locates `.trace-table tr[data-invocation-id=…]`, a PR 1 selector that no longer exists in the Trace page markup (rows are `.trace-row`/`.trace-line` and carry no `data-invocation-id`). Use `getByTestId('trace-entry')` or `.trace-line[href="/trace/"]`. -- **W3 (e2e):** new hooks for acceptance: `data-testid="mcp-frame-facts"` on Protocol-page frames, `.route-status-request` on the invocation status line, `.mcp-page-frame-link` for the correlation link. - -## Open risks - -- `isMcpFrameEntry` narrows an `unknown` timeline value by `kind`/`direction`/`sequence` only; it relies on the controller's strict decoder being the sole producer of frames (it is — the model reducer never fabricates frames). -- `McpProtocolEvidence` still takes `readonly unknown[]` for the runtime-contract test; the frame facts render only for values that pass `isMcpFrameEntry`, so provider-evidence callers are unaffected. -- The redundant `& Readonly<{ correlationId?: string }>` removal in `runtime-backend.ts` is type-only; `DevRuntimeInvocationRequest.correlationId` already exists in `runtime-protocol.ts`. - -## Verification - -- `pnpm build && npx tsc --project packages/workbench/tsconfig.json --noEmit && npx tsc --noEmit && pnpm lint` — green. -- `npx rstest --config rstest.unit.config.ts` over `advanced-page`, `agent-bundle-remote-transport`, `dev-server-backend`, `invocation-client`, `invocation-model`, `logs-page`, `mcp-page`, `mcp-session-controller`, `mcp-session-model`, `route-workspace`, `runtime-backend`, `runtime-contract-compile`, `shell-link`, `trace-client`, `trace-model`, `trace-page`, `workbench-router`, `workbench-shell` — 18 files, 215 tests, 0 failures. -- Deslop pass over the diff: shared `mcpFrameMetaKeys` instead of two key lists and two `as` casts; `ShellLink` extracted and rewired in the same change (no private `Link` left behind); `runtime-backend.ts` intersection types dropped. From e2b8e5b7ae0826e7cf96e909e70b1e7dc0ea3227 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:11:49 +0000 Subject: [PATCH 23/70] drop LANE-NOTES --- LANE-NOTES.md | 94 --------------------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 82a92369e..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,94 +0,0 @@ -# W5 — trace signal quality - -## Files changed - -- `packages/agent-bundle/src/dev/logs/dev-log-service.ts` -- `packages/agent-bundle/src/dev/hooks/hook-receipts.ts` -- `packages/agent-bundle/tests/dev-log-service.test.ts` -- `packages/agent-bundle/tests/hook-receipts.test.ts` -- `packages/agent-bundle/tests/hook-receipt-pipe.test.ts` -- `packages/workbench/tests/support/workbench-acceptance.ts` - -## Behavior - -- `safeDevWireText` preserves slash-bearing relative route and event identities such as - `curator/search_audible`, `tool/before`, `tool:curator/search_audible`, and - `event:session/start`. -- Control characters, credentials, project roots, `file:` URLs, drive-letter paths, - UNC paths, home-relative paths, and absolute POSIX paths remain redacted. A project - path still becomes `/…`. -- Dev Log context identifiers keep their existing slash-free policy except for the - existing validated `routeId` rule. -- Dev Logs reach Trace only at warning/error level or with a request-scoped join key. - Epoch, build, route, and target facets alone no longer publish. -- Project-event mirrors already lowered by route invocation, runtime, or project - diagnostic producers do not publish a duplicate log entry. -- Hook receipt summaries use the canonical `tool/before` event identity. -- Audiobook acceptance requires exactly `invocation.started` and - `invocation.completed`, a readable `curator/search_audible` completion summary, - and no `log.project.*` row. - -`packages/workbench/src/trace/trace-client.ts` already uses a `pathLikeText` rule that -permits `curator/search_audible`, `tool/before`, and route identifiers while rejecting -absolute paths. No W2 edit is needed there. - -## Cross-lane request → W2 - -`packages/workbench/src/logs/log-client.ts` still defines `isSafeWireText` with -`hasControlOrSeparators(withoutProjectPaths)`, so the Raw Logs client rejects the -relative slash-bearing strings the corrected server sanitizer now emits. Make its -free-text rule mirror `trace/trace-client.ts` exactly: - -1. Add the local `hasControlCharacters` loop and `pathLikeText` expression from - `trace-client.ts`. -2. In `isSafeWireText`, retain the length and credential checks, then use - `!hasControlCharacters(value) && !pathLikeText.test(value)` instead of - `!hasControlOrSeparators(withoutProjectPaths)` and the old file/drive/UNC test. -3. Keep `hasControlOrSeparators` for detail keys and context identifiers; do not - loosen those generic identifier boundaries. Preserve the existing validated - handling required for `routeId`. -4. Add LogClient cases proving `MCP tool curator/search_audible · 2.9 s` and - `event tool/before (claude)` decode while absolute/file/drive/UNC/home paths do - not. - -Until that W2-owned mirror lands, `packages/workbench/tests/logs-real.e2e.test.ts` -reliably times out after navigating away and replaying the now-correct slash-bearing -records (two isolated runs). No W2-owned source file was changed in this lane. - -## Verification - -- Build, root TypeScript, Workbench TypeScript, and lint: passed. -- Six affected unit files: 52 passed. -- `trace-dev-server`, `route-invocation-dev-server`, `hook-receipt-pipe`, and - `dev-log-foreground`: 4 passed. -- Audiobook curator acceptance: passed. -- `logs-real.e2e.test.ts`: blocked by the W2 decoder mismatch above; failed twice at - the second replay-row readiness wait. -- IDE diagnostics and `git diff --check`: clean. -- TraceDecay MCP discovery and CLI fallback were attempted; both were unavailable - because the installed daemon socket was down, so review used focused native reads. - -## Acceptance captures - -- `/tmp/wb600/acceptance-pr2b/audiobook-curator-trace-populated.png` -- Other audiobook acceptance captures and report are under - `/tmp/wb600/acceptance-pr2b/`. - -The populated trace shows four entries in two invocation groups. Each group contains -only start/completion rows, both summaries name `curator/search_audible`, and no -artifact or project-log mirror group is present. - -## Open risks - -- Raw Logs remains unable to decode the widened safe free-text grammar until W2 - applies the exact mirror change above. -- No other open risk found in the six-file lane diff. - -## Proposed changeset line - -Preserve route and event identities in development trace summaries while removing -duplicate project-event log rows from the correlated timeline. (#600) - -## Diagnostic codes - -None. From dcef3307ac2171e166fa07cc49cb25e8d1b78d62 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:24:48 +0000 Subject: [PATCH 24/70] fix(dev): stop fabricating route-invocation provider timings Zero was reported as a measurement for providers the child never observed. Record unobserved providers and only measured phases. --- .changeset/wb600-pr2a-telemetry.md | 5 + LANE-NOTES.md | 64 ++++++++++ .../dev/routes/route-invocation-service.ts | 54 +++++---- .../src/dev/routes/route-invocation.ts | 18 ++- .../tests/route-invocation-dev-server.test.ts | 4 +- .../tests/route-invocation-service.test.ts | 112 ++++++++++++++++++ .../src/application/invocation-client.ts | 2 +- .../src/application/runtime-backend.ts | 12 +- .../workbench/src/application/workspace.css | 1 + .../workbench/tests/invocation-client.test.ts | 14 +++ .../workbench/tests/route-workspace.test.ts | 21 ++++ .../docs/en/guide/development/workbench.mdx | 20 +++- .../docs/zh/guide/development/workbench.mdx | 16 ++- 13 files changed, 303 insertions(+), 40 deletions(-) create mode 100644 .changeset/wb600-pr2a-telemetry.md create mode 100644 LANE-NOTES.md diff --git a/.changeset/wb600-pr2a-telemetry.md b/.changeset/wb600-pr2a-telemetry.md new file mode 100644 index 000000000..befcbbbcb --- /dev/null +++ b/.changeset/wb600-pr2a-telemetry.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..9d186a090 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,64 @@ +# Lane A4 — P2 telemetry honesty + +## Files + +- `packages/agent-bundle/src/dev/routes/route-invocation.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` (assertion only; integration pool not run) +- `packages/workbench/src/application/invocation-client.ts` +- `packages/workbench/src/application/runtime-backend.ts` +- `packages/workbench/src/application/workspace.css` +- `packages/workbench/tests/invocation-client.test.ts` +- `packages/workbench/tests/route-workspace.test.ts` +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/zh/guide/development/workbench.mdx` +- `.changeset/wb600-pr2a-telemetry.md` + +Not edited (no decoder/view of invocation `providers`/`timings` beyond pass-through): `invocation-model.ts`, `result-tabs.tsx`. Inspector Providers/Timings already omitted absent `durationMs`; status now includes `unobserved` via the CSS class. + +## Behavior + +- Success without `child.observed`: every catalog provider is `{ id, name, status: 'unobserved' }` with no `durationMs`. Timings are only measured `render` (`child.renderDurationMs`) and `projection` (service wall time). No `handler` / `providers` / `provider:*` rows. +- Success with `child.observed`: `providers` are the observed rows exactly. Observed timings that are `handler`, `providers`, or `provider:*` are forwarded; an observed `render` is dropped so the service's `child.renderDurationMs` remains the `render` phase. +- Failure: no fabricated `failed` providers — same unobserved catalog rows. The only timing is `elapsed`: wall time from the recorded `startedAt` (before the semaphore slot) until the child/script threw. That is not render time; `render` is omitted because no document was produced. +- Workbench decoder accepts `'unobserved'` and optional `durationMs`. Providers tab shows status `unobserved` and `—` when duration is absent. Runtime-backend no longer coerces missing span durations to `0`. + +## Exported API / contract + +- `RouteInvocationProviderStatus` adds `'unobserved'`. +- `RouteInvocationProvider.durationMs` stays optional (now documented: absent = not measured). +- `RouteInvocationTiming.phase` documents `elapsed` and that zero is a measurement. +- `RouteInvocationChildResult.observed?: { providers; timings }` added (agreed A2/A4 shape). A2 may add the same field — accept the trivial conflict. +- `RouteInvocation` / `RouteInvocationProvider` are **not** exported from `src/index.ts` or another public package entry (`package.json` `exports` has no `./contracts`). `src/contracts/invocations.ts` re-exports them for the Workbench source import only. Changeset is **patch**. + +## Cross-lane requests + +- **A2** (`route-invocation-child.ts`): populate `RouteInvocationChildResult.observed` with measured provider rows and `handler`/`providers`/`provider:*` timings. When that lands, `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` currently expects the clock provider `unobserved` and timings `['render', 'projection']` — flip those assertions to the observed values. +- **A3**: none. `invoke()` ordering, `prepared`, and the constructor were left alone. + +## Open risks + +- Until A2 emits `observed`, every live Workbench run shows catalog providers as `unobserved`. That is honest, not a regression of measurement. +- `elapsed` is a new phase name on failures. The Timings tab will render it as a real bar (including `0 ms` if `now()` does not advance). +- `startedAt` for the success `render` timing is still the pre-semaphore invocation timestamp; only the duration is the child's measurement. + +## Verification + +- `pnpm build` — pass +- `npx tsc --noEmit` — pass +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass +- `pnpm lint` — pass (1389 files) +- `pnpm test:unit` — pass (275 files / 4128 tests; intended file filter ran the whole unit pool) + +Not run: `rstest.route-unit.config.ts`, `rstest.integration.config.ts` (`route-invocation-dev-server.test.ts` assertion updated but not executed), Workbench e2e. A4 gates did not require those. + +## Proposed changeset + +`.changeset/wb600-pr2a-telemetry.md` — `agent-bundle` **patch**: + +> Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) + +## Diagnostic codes + +None. A4 takes no new codes (`AB8233`–`AB8235` browser; `AB8250`–`AB8252` A2; `AB8239` A3). 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 34ef96dc5..e0d60ea26 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -114,6 +114,14 @@ export interface RouteInvocationChildResult { readonly input: JsonValue; /** Runtime-owned MCP projection, computed inside the runtime-bound child. */ readonly mcp?: JsonObject; + /** + * What the child actually measured. Absent for plain scripts and for + * failures before the child reported measurements. + */ + readonly observed?: { + readonly providers: readonly RouteInvocationProvider[]; + readonly timings: readonly RouteInvocationTiming[]; + }; readonly renderDurationMs: number; readonly result?: JsonValue; } @@ -502,20 +510,30 @@ const eventInput = ( }); }; -const providerProjection = ( - manifest: RouteManifest, - durationMs: number, - status: RouteInvocationProvider['status'], -): readonly RouteInvocationProvider[] => Object.freeze(manifest.providers.map((provider) => Object.freeze({ - durationMs, - id: provider.id, - name: provider.name, - status, -}))); - const timing = (phase: string, startedAt: Date, durationMs: number): RouteInvocationTiming => Object.freeze({ durationMs, phase, startedAt: startedAt.toISOString() }); +const isChildObservedTiming = (phase: string): boolean => + phase === 'handler' || phase === 'providers' || phase.startsWith('provider:'); + +const unobservedProviders = (manifest: RouteManifest): readonly RouteInvocationProvider[] => + Object.freeze(manifest.providers.map((provider) => Object.freeze({ + id: provider.id, + name: provider.name, + status: 'unobserved' as const, + }))); + +const invocationTimings = ( + child: RouteInvocationChildResult, + startedAt: Date, + projectionStartedAt: Date, + completedAt: Date, +): readonly RouteInvocationTiming[] => Object.freeze([ + ...(child.observed?.timings.filter((entry) => isChildObservedTiming(entry.phase)) ?? []), + timing('render', startedAt, child.renderDurationMs), + timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), +]); + const jsonObject = (value: unknown): JsonObject | undefined => { if (value === undefined) return undefined; const snapshot = snapshotStrictJsonValue(value); @@ -625,13 +643,13 @@ const failedInvocation = (input: { kind: input.route.kind as RouteInvocationKind, manifestDigest: input.manifest.digest, projection: {}, - providers: providerProjection(input.manifest, 0, 'failed'), + providers: unobservedProviders(input.manifest), routeId: input.route.id, source: input.route.source, sourceRevision: input.manifest.sourceRevision, startedAt: input.startedAt.toISOString(), status: 'failed', - timings: [timing('render', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], + timings: [timing('elapsed', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], }); }; @@ -803,20 +821,14 @@ export class RouteInvocationService { kind: route.kind as RouteInvocationKind, manifestDigest: manifest.digest, projection, - providers: providerProjection(manifest, 0, 'mounted'), + providers: child.observed?.providers ?? unobservedProviders(manifest), ...(child.result === undefined ? {} : { result: child.result }), routeId: route.id, source: route.source, sourceRevision: manifest.sourceRevision, startedAt: startedAt.toISOString(), status: 'succeeded', - timings: [ - timing('providers', startedAt, 0), - ...manifest.providers.map((provider) => timing(`provider:${provider.name}`, startedAt, 0)), - timing('handler', startedAt, 0), - timing('render', startedAt, child.renderDurationMs), - timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), - ], + timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), }); }); this.#pending.add(running); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 922e55669..9006244b5 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -48,14 +48,28 @@ export type RouteInvocationStatus = 'failed' | 'succeeded'; export interface RouteInvocationTiming { readonly durationMs: number; - /** `providers`, `handler`, `render`, `projection`, or a provider id (`provider:`). */ + /** + * A measured phase. `render` is the child's render (or plain-script run) + * duration; `projection` is host-projection time in the service; `elapsed` + * is wall time until failure when the child never produced a render + * duration. `handler`, `providers`, and `provider:` appear only when + * the child observed them. Zero is a measurement, not "unknown". + */ readonly phase: string; readonly startedAt: string; } -export type RouteInvocationProviderStatus = 'failed' | 'mounted' | 'skipped'; +/** + * Observed provider outcome. `unobserved` means the service never measured + * this provider — `durationMs` is omitted, never reported as `0`. + */ +export type RouteInvocationProviderStatus = 'failed' | 'mounted' | 'skipped' | 'unobserved'; export interface RouteInvocationProvider { + /** + * Measured mount duration in milliseconds. Absent when the phase was not + * measured (`unobserved`, or an observed row that did not record time). + */ readonly durationMs?: number; readonly id: string; readonly message?: string; 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 cba58b6aa..3a4a50f15 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -144,8 +144,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); expect(tool.invocation.providers).toEqual([ - expect.objectContaining({ name: 'clock', status: 'mounted' }), + expect.objectContaining({ name: 'clock', status: 'unobserved' }), ]); + expect(tool.invocation.providers[0]).not.toHaveProperty('durationMs'); + expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); const eventResponse = 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 3885d269e..8f1735890 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -12,6 +12,8 @@ import { RouteInvocationRequestError, invocationSummary, parseRouteInvocationRequest, + type RouteInvocationChildResult, + type RouteInvocationServiceOptions, } from '../src/dev/routes/route-invocation-service.ts'; import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; @@ -306,3 +308,113 @@ it('reaps the render child and its descendants when the service closes mid-rende await rm(project.root, { force: true, recursive: true }); } }); + +const echoRoute = { + config: [], + id: 'tool:fixture/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:fixture', + source: 'src/mcp/fixture/tools/echo.tsx', +} as const; + +const clockProvider = { + id: 'provider:clock', + name: 'clock', + source: 'src/providers/clock.ts', +} as const; + +const telemetryManifest = (): RouteManifest => ({ + diagnostics: [], + digest: 'digest', + events: [], + providers: [clockProvider], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [echoRoute] }], + sourceRevision: 'revision', +}); + +const succeededChild = (observed?: RouteInvocationChildResult['observed']): RouteInvocationChildResult => ({ + document: { + root: { children: [{ kind: 'text', text: 'ok' }], kind: 'result' }, + status: 'success', + version: 1, + }, + events: [{ + document: { + root: { children: [{ kind: 'text', text: 'ok' }], kind: 'result' }, + status: 'success', + version: 1, + }, + sequence: 1, + type: 'complete', + }], + input: {}, + mcp: { content: [] }, + ...(observed === undefined ? {} : { observed }), + renderDurationMs: 12, +}); + +const telemetryService = ( + renderChild: NonNullable, +): RouteInvocationService => new RouteInvocationService({ + manifest: { manifest: telemetryManifest }, + prepared: () => ({ + manifest: { projectRoot: '/project' } as never, + targets: ['claude'], + }), + renderChild, +}); + +it('marks catalog providers unobserved when the child reports no observations', async () => { + const result = await telemetryService(async () => succeededChild()).invoke({ + input: {}, + routeId: echoRoute.id, + }); + + expect(result.status).toBe('succeeded'); + expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); + expect(result.providers[0]).not.toHaveProperty('durationMs'); + expect(result.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); + expect(result.timings[0]).toMatchObject({ durationMs: 12, phase: 'render' }); +}); + +it('forwards observed providers and timings without fabricating the rest', async () => { + const observed = { + providers: [{ durationMs: 7, id: 'provider:clock', name: 'clock', status: 'mounted' as const }], + timings: [ + { durationMs: 3, phase: 'providers', startedAt: '2026-09-05T00:00:00.000Z' }, + { durationMs: 3, phase: 'provider:clock', startedAt: '2026-09-05T00:00:00.000Z' }, + { durationMs: 9, phase: 'handler', startedAt: '2026-09-05T00:00:00.003Z' }, + { durationMs: 99, phase: 'render', startedAt: '2026-09-05T00:00:00.012Z' }, + ], + } as const; + const result = await telemetryService(async () => succeededChild(observed)).invoke({ + input: {}, + routeId: echoRoute.id, + }); + + expect(result.providers).toEqual(observed.providers); + expect(result.timings.map((entry) => entry.phase)).toEqual([ + 'providers', + 'provider:clock', + 'handler', + 'render', + 'projection', + ]); + expect(result.timings.find((entry) => entry.phase === 'handler')).toMatchObject({ durationMs: 9 }); + expect(result.timings.find((entry) => entry.phase === 'render')).toMatchObject({ durationMs: 12 }); +}); + +it('does not fabricate failed providers when the child throws', async () => { + const result = await telemetryService(async () => { + throw new Error('provider boom'); + }).invoke({ input: {}, routeId: echoRoute.id }); + + expect(result.status).toBe('failed'); + expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); + expect(result.providers[0]).not.toHaveProperty('durationMs'); + expect(result.providers.some((provider) => provider.status === 'failed')).toBe(false); + expect(result.timings.map((entry) => entry.phase)).toEqual(['elapsed']); + expect(result.timings.some((entry) => entry.phase === 'render' || entry.phase === 'handler')).toBe(false); +}); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 5d8a2ac21..03e97f47a 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -48,7 +48,7 @@ const providerSchema = z.strictObject({ id: textSchema, message: z.string().optional(), name: textSchema, - status: z.enum(['failed', 'mounted', 'skipped']), + status: z.enum(['failed', 'mounted', 'skipped', 'unobserved']), }); const cliProjectionSchema = z.strictObject({ exitCode: z.number().int(), diff --git a/packages/workbench/src/application/runtime-backend.ts b/packages/workbench/src/application/runtime-backend.ts index 5a8b36c95..bc8e525a1 100644 --- a/packages/workbench/src/application/runtime-backend.ts +++ b/packages/workbench/src/application/runtime-backend.ts @@ -196,11 +196,13 @@ const invocationForRun = ( : Object.freeze([]); const document = documentFor(events); const timings = run.status === 'succeeded' - ? Object.freeze(run.result.trace.map((span) => Object.freeze({ - durationMs: span.durationMs ?? 0, - phase: span.phase, - startedAt: span.startedAt, - }))) + ? Object.freeze(run.result.trace.flatMap((span) => span.durationMs === undefined + ? [] + : [Object.freeze({ + durationMs: span.durationMs, + phase: span.phase, + startedAt: span.startedAt, + })])) : Object.freeze([]); const result = run.status === 'succeeded' ? run.result.agentVisible : undefined; return Object.freeze({ diff --git a/packages/workbench/src/application/workspace.css b/packages/workbench/src/application/workspace.css index 20b0bc5bd..7eb1ece50 100644 --- a/packages/workbench/src/application/workspace.css +++ b/packages/workbench/src/application/workspace.css @@ -143,6 +143,7 @@ .inspector-status--mounted { color: #14682f; } .inspector-status--failed { color: #b31b23; } .inspector-status--skipped { color: #8a5300; } +.inspector-status--unobserved { color: #596372; } .inspector-diagnostics { color: #78242a; font-size: 12px; margin: 0; padding-left: 18px; } .inspector-timings { display: grid; gap: 6px; list-style: none; margin: 0; padding: 0; } .inspector-timings li { align-items: center; display: grid; gap: 10px; grid-template-columns: 120px minmax(0, 1fr) 64px; font-size: 12px; } diff --git a/packages/workbench/tests/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts index 64e32e33f..30b63023e 100644 --- a/packages/workbench/tests/invocation-client.test.ts +++ b/packages/workbench/tests/invocation-client.test.ts @@ -114,6 +114,20 @@ it('preserves coded HTTP diagnostics', async () => { }); }); +it('decodes unobserved providers without a duration', async () => { + const unobserved = { + ...invocation, + providers: Object.freeze([{ + id: 'catalog', + name: 'Catalog', + status: 'unobserved' as const, + }]), + } satisfies RouteInvocation; + const client = new InvocationClient({ foreground: foreground(() => Response.json({ invocation: unobserved })) }); + + await expect(client.invoke({ routeId: invocation.routeId })).resolves.toEqual(unobserved); +}); + it('rejects malformed success payloads and unsafe invocation ids', async () => { const client = new InvocationClient({ foreground: foreground(() => Response.json({ invocation: { ...invocation, unexpected: true }, diff --git a/packages/workbench/tests/route-workspace.test.ts b/packages/workbench/tests/route-workspace.test.ts index 123b61361..e628c5ee9 100644 --- a/packages/workbench/tests/route-workspace.test.ts +++ b/packages/workbench/tests/route-workspace.test.ts @@ -312,6 +312,27 @@ describe('RouteInspector', () => { expect(raw).toContain('"manifestDigest": "digest-1"'); }); + it('renders unobserved providers without a fabricated 0 ms duration', () => { + const markup = renderToStaticMarkup(createElement(RouteInspector, { + backendKind: 'dev-server', + invocation: { + ...invocation, + providers: [{ id: 'provider:library', name: 'library', status: 'unobserved' }], + timings: [{ durationMs: 5, phase: 'render', startedAt: invocation.startedAt }], + }, + leaf: toolLeaf, + onTabChange: noop, + onToggle: noop, + open: true, + tab: 'providers', + })); + + expect(markup).toContain('inspector-status--unobserved'); + expect(markup).toContain('unobserved'); + expect(markup).not.toContain('0 ms'); + expect(markup).toContain('—'); + }); + it('derives one row per request-context axis', () => { expect(requestContextRows(invocation.context).map((entry) => entry.label)).toEqual([ 'Invocation kind', 'Operation ID', 'Surface', 'Host contract revision', 'Host', 'Session', 'Actor', 'Workspace', 'Lineage', diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index c67592a30..5f7a4010f 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -78,7 +78,9 @@ Results open on **Rendered**, the browser rendering of the production Agent Docu streamed progress or Suspense replacements. 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**. +**Providers**, **Execution timings**, **Projection**, and **Raw protocol**. Providers and +timings show **unobserved** (and omit duration) when a phase was not measured; `0 ms` is a +measured zero, not a placeholder. The browser does not import or execute arbitrary route modules. The server runs the same RSC route path used by generated executables and sends its semantic render-event stream and final @@ -194,10 +196,18 @@ The route workspace uses one authenticated, origin-guarded foreground API: - `/api/project/events` publishes completed summaries as `route.invocation` events. The envelope carries canonical input, request context, providers, ordered render events, the -final Agent Document, structured result, projections, diagnostics, and execution timings when -available. A represented `Agent.Error` remains a rendered result; unknown routes or invocation -ids (`AB8231`), unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed -requests (`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. +final Agent Document, structured result, projections, diagnostics, and execution timings. +`providers` lists each catalog provider with the outcome the child measured (`mounted`, +`failed`, or `skipped`) and a `durationMs` only when that duration was measured. When the +child reports no observations — a plain script, or a failure before any provider ran — every +catalog provider is `unobserved` and `durationMs` is omitted; `0` is a measured zero, not +"unknown". `timings` lists only measured phases: `render` (the child's render or script-run +duration), `projection` (host-projection time in the service), and, when observed, `handler`, +`providers`, and `provider:`. A failed invocation records an `elapsed` timing for the +wall time until failure and does not invent `failed` provider rows. A represented +`Agent.Error` remains a rendered result; unknown routes or invocation ids (`AB8231`), +unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed requests +(`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index c1a062848..47d503268 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -66,7 +66,8 @@ schema 生成,并提供适用的夹具以及为该叶子保留的上次输入 结果首先打开 **Rendered**,即生产 Agent Document 及其流式进度或 Suspense 替换的浏览器渲染。次要结果 标签是 **Structured result**、**Raw AgentDocument**、**MCP projection**、可用时的 **CLI projection**, 以及 **Trace**。检查器仅在请求时打开,其中包含 **Source**、**Schema**、**Context**、**Providers**、 -**Execution timings**、**Projection** 与 **Raw protocol**。 +**Execution timings**、**Projection** 与 **Raw protocol**。未测到的阶段显示 **unobserved**(并省略 +时长);`0 ms` 是测得的零,不是占位。 浏览器不会导入或执行任意路由模块。服务器运行生成式可执行文件所用的同一条 RSC 路由路径,并把其语义 渲染事件流与最终的 Agent Document 发送给 Workbench。 @@ -164,10 +165,15 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 - `GET /api/routes/invocations/` 返回一次调用。 - `/api/project/events` 以 `route.invocation` 事件发布已完成的摘要。 -该信封在可用时携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、 -投影、诊断与执行计时。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 invocation id(`AB8231`)、 -不可用的 epoch(`AB8232`)、渲染超时或崩溃(`AB8236`)、格式错误的请求(`AB8237`)与未知 fixture id -(`AB8238`)会作为诊断报告。 +该信封携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、投影、 +诊断与执行计时。`providers` 列出子进程实际测到的结果(`mounted`、`failed` 或 `skipped`),仅在测到 +时长时带 `durationMs`。子进程未报告观测值时(普通脚本,或在任何 provider 运行之前失败),每个目录 +provider 为 `unobserved` 且省略 `durationMs`;`0` 是测得的零,不是「未知」。`timings` 只列出已测量 +阶段:`render`(子进程的渲染或普通脚本运行时长)、`projection`(服务端的宿主投影时间),以及观测到 +时的 `handler`、`providers` 与 `provider:`。失败的调用记录 `elapsed`(失败前的墙钟时间), +不会伪造 `failed` 的 provider 行。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 +invocation id(`AB8231`)、不可用的 epoch(`AB8232`)、渲染超时或崩溃(`AB8236`)、格式错误的请求 +(`AB8237`)与未知 fixture id(`AB8238`)会作为诊断报告。 各条触发条件与恢复指引见[诊断参考](../../reference/diagnostics.md)。 ## 以编程方式使用同一个会话 From a3cce288d280cdc6f3a8a117f391acbc181cbaab Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:28:59 +0000 Subject: [PATCH 25/70] fix(dev): pin route invocations to a leased epoch (AB8239) A queued Workbench invoke was labelled with the revision seen at enqueue while the child later imported source that may have changed. Acquire the published epoch inside the concurrency slot, release on every exit, and reject waiters whose catalog moved. --- LANE-NOTES.md | 75 ++++++++ docs/diagnostics.md | 1 + .../dev/routes/route-invocation-service.ts | 182 ++++++++++++------ .../agent-bundle/src/dev/workbench-server.ts | 30 ++- .../tests/route-invocation-service.test.ts | 129 +++++++++++-- .../docs/en/guide/development/workbench.mdx | 3 +- .../docs/zh/guide/development/workbench.mdx | 4 +- 7 files changed, 335 insertions(+), 89 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..f4a097096 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,75 @@ +# Lane A3 — P1-C epoch pinning + `stateRoot` + +## Files + +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/src/dev/workbench-server.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` +- `docs/diagnostics.md` +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/zh/guide/development/workbench.mdx` + +No new modules. `foreground-server.ts` / `route-manifest-routes.ts` / `project-service.ts` do not construct `RouteInvocationService`; the only production supplier is `workbench-server.ts`. Did not edit `route-invocation-child.ts` or A4's result assembly (`providerProjection`, `timings`, `failedInvocation`). + +## Behavior + +`invoke()` peeks the catalog only for 404 / request-shape checks. Inside the semaphore slot it: + +1. Calls the lease-aware `prepared` supplier (acquire the snapshotted published epoch). +2. Re-reads `manifest()`. +3. Rejects with `409 AB8239` when `digest` / `sourceRevision` moved while the request waited. +4. Executes against that leased prepared project. +5. Releases the lease in `finally` (success, `AB8239`, abort, timeout, close). + +The recorded `manifestDigest` / `sourceRevision` are taken from the inside-the-slot catalog, so they cannot describe a different revision than the one that ran. A queued request after a publish does not run new code under the old labels — it fails stale. + +## Leasing mechanism + +Production (`workbench-server.ts`): snapshot `latestPublishedPreparedProject` + `status().artifact.activeEpoch.id`, then `epochStore.acquireEpochReference(epochId)` (pins that compiled epoch; a concurrent publish cannot delete it). Return `{ project, release: () => reference.close() }`. `EPOCH_NOT_FOUND` maps to `AB8239`. + +Tests may still return a bare `RouteInvocationPreparedProject`; `bindPrepared` wraps it with a no-op `release`. + +Floor (also implemented, and what the queued-stale test proves): re-read after the slot is acquired and reject `AB8239` when the peeked identity moved. Used because a true "run the enqueue-time artifact" pin would require leasing *before* the wait, which the brief forbids. + +## Contract changes + +- `RouteInvocationPreparedProject.stateRoot: string` — `join(, '.agent-bundle', 'state')` via `routeInvocationStateRoot()`; matches `pluginRootFallbackExpression` cwd fallback + `resolvePluginRoot` / `PLUGIN_STATE_DIRECTORY`. Never the code root. +- `RouteInvocationChildRequest.stateRoot: string` — passed through in `invoke()`. +- `prepared` may return a project, a `{ project, release }` lease, or a `Promise` of either. +- New: `RouteInvocationPreparedLease`, `ROUTE_INVOCATION_STALE_REVISION_CODE` (`AB8239`), `ROUTE_INVOCATION_STALE_REVISION_MESSAGE`, `routeInvocationStateRoot`. + +These types are not exported from `src/index.ts`. + +## Cross-lane requests + +- **A2** (`route-invocation-child.ts`): read `request.stateRoot` as the session-state mount. The field is already on `RouteInvocationChildRequest` and filled by `invoke()`. Do not add it again. +- **A4**: leave the top of `invoke()`, the constructor/`prepared` contract, and the `finally` lease release alone. `manifestDigest` / `sourceRevision` already close over the inside-the-slot `manifest`. + +## Open risks + +- Child still Jiti-imports live source until A2 executes the leased epoch's compiled artifact. The lease keeps that epoch's directory from being deleted mid-run; A2 must actually load from it. +- Peek-then-wait `AB8239` is conservative: a queued request after a publish must be retried. Preferred pin-old-and-run was not used because the lease is acquired inside the slot. +- Sequential `prepared()` then `manifest()` inside the slot can still interleave with `onPublishedProject`. If they disagree, `AB8239` fires (manifest is compared to the enqueue peek). +- A4 merge: extra `try` / `finally` wraps the existing result assembly; those lines were not rewritten. + +## Verification + +- `pnpm build` — pass +- `npx tsc --noEmit` — pass +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass +- `pnpm lint` — pass (1389 files) +- `pnpm test:unit` — pass (4124 tests; includes `route-invocation-service.test.ts`) +- Integration: `route-invocation-dev-server.test.ts` (2) + `audiobook-curator.acceptance.e2e.test.ts` (1) — pass + +TraceDecay MCP/daemon were unavailable this session; exploration used the brief's named files. + +## Proposed changeset line + +`patch` — Reject a queued Workbench route invocation with 409 `AB8239` when the published revision moves before it runs, and pin in-flight invocations to a leased compiled epoch. (`#600`) + +Integrator owns the single PR changeset (A4). Do not add a second `.changeset` file from this lane. + +## Diagnostic codes + +- **`AB8239`** (new, 409): published `manifest.digest` / `sourceRevision` moved while the request waited for a concurrency slot, or the snapshotted epoch could not be leased (`EPOCH_NOT_FOUND`). +- `AB8231`, `AB8232`, `AB8236`–`AB8238` unchanged. `AB8233`–`AB8235` untouched. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..53d9b2635 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,7 @@ even when no error diagnostic was reported. | `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. | | `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.) | +| `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. | | `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/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index 34ef96dc5..93905425c 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -48,6 +48,13 @@ export const ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE = 'AB8232'; export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; +export const ROUTE_INVOCATION_STALE_REVISION_CODE = 'AB8239'; +export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = + 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; + +/** Writable state root generated entries mount for the npm-bin cwd fallback. */ +export const routeInvocationStateRoot = (projectRoot: string): string => + join(projectRoot, '.agent-bundle', 'state'); const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; @@ -77,9 +84,19 @@ export interface RouteInvocationPreparedProject { readonly artifact?: Readonly<{ epochId: string; target: string }>; readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; + /** + * Writable state directory generated entries mount for this project + * (`/state`, never the code root). + */ + readonly stateRoot: string; readonly targets: readonly RouteInvocationEventHost[]; } +export interface RouteInvocationPreparedLease { + readonly project: RouteInvocationPreparedProject; + readonly release: () => Promise | void; +} + export interface RouteInvocationScriptRunner { run(request: ScriptPlaygroundRunRequest): Promise; } @@ -89,7 +106,10 @@ export interface RouteInvocationServiceOptions { readonly historyLimit?: number; readonly manifest: RouteManifestRouteService; readonly now?: () => Date; - readonly prepared: () => RouteInvocationPreparedProject; + readonly prepared: () => + | RouteInvocationPreparedLease + | RouteInvocationPreparedProject + | Promise; readonly registry?: TargetRegistry; readonly renderChild?: ( request: RouteInvocationChildRequest, @@ -105,6 +125,7 @@ export interface RouteInvocationChildRequest { readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; readonly routeId: string; + readonly stateRoot: string; } export interface RouteInvocationChildResult { @@ -130,7 +151,8 @@ export class RouteInvocationRequestError extends Error { | typeof ROUTE_INVOCATION_MALFORMED_REQUEST_CODE | typeof ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE | typeof ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE - | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE; + | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_STALE_REVISION_CODE; readonly status: 400 | 404 | 409; constructor( @@ -153,6 +175,18 @@ const malformed = (): never => { ); }; +const isPreparedLease = ( + value: RouteInvocationPreparedLease | RouteInvocationPreparedProject, +): value is RouteInvocationPreparedLease => + isRecord(value) && typeof value.release === 'function' && isRecord(value.project); + +const bindPrepared = async ( + supplier: RouteInvocationServiceOptions['prepared'], +): Promise => { + const value = await supplier(); + return isPreparedLease(value) ? value : { project: value, release: () => undefined }; +}; + const boundedString = (value: unknown, maxLength = 4_096): value is string => typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); @@ -641,7 +675,7 @@ export class RouteInvocationService { readonly #manifest: RouteManifestRouteService; readonly #now: () => Date; readonly #pending = new Set>(); - readonly #prepared: () => RouteInvocationPreparedProject; + readonly #prepared: RouteInvocationServiceOptions['prepared']; readonly #registry: TargetRegistry; readonly #renderChild: NonNullable; readonly #scripts: RouteInvocationScriptRunner | undefined; @@ -679,11 +713,9 @@ export class RouteInvocationService { } async invoke(request: RouteInvocationRequest): Promise { - let manifest: RouteManifest; - let prepared: RouteInvocationPreparedProject; + let queued: RouteManifest; try { - manifest = this.#manifest.manifest(); - prepared = this.#prepared(); + queued = this.#manifest.manifest(); } catch (error) { if (error instanceof RouteInvocationRequestError) throw error; throw new RouteInvocationRequestError( @@ -692,7 +724,7 @@ export class RouteInvocationService { 409, ); } - const route = allManifestRoutes(manifest).find((candidate) => candidate.id === request.routeId); + const route = allManifestRoutes(queued).find((candidate) => candidate.id === request.routeId); if (route === undefined || !invocationKinds.has(route.kind as RouteInvocationKind)) { throw new RouteInvocationRequestError( ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, @@ -706,64 +738,89 @@ export class RouteInvocationService { ) { return malformed(); } - const fixtureId = request.event?.fixtureId; - const fixture = fixtureId === undefined - ? undefined - : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); - if (fixtureId !== undefined && fixture === undefined) { - throw new RouteInvocationRequestError( - ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE, - `Fixture ${JSON.stringify(fixtureId)} is not available for route ${JSON.stringify(route.id)}.`, - 400, - ); - } - const rawInput = request.input ?? fixture?.input ?? {}; - const input = route.kind === 'event-route' - ? eventInput(route, rawInput, request.event?.host, this.#registry) - : rawInput; const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); - const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); const running = this.#semaphore.run(async () => { - const controller = new AbortController(); - this.#controllers.add(controller); - if (this.#closed) { - controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); - } - const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); - let child: RouteInvocationChildResult; - const plainScript = plainScriptFor(prepared, route); + let release: RouteInvocationPreparedLease['release'] | undefined; try { - child = plainScript === undefined - ? await this.#renderChild({ - ...(request.args === undefined ? {} : { args: request.args }), + let manifest: RouteManifest; + let prepared: RouteInvocationPreparedProject; + try { + const leased = await bindPrepared(this.#prepared); + release = leased.release; + prepared = leased.project; + manifest = this.#manifest.manifest(); + } catch (error) { + if (error instanceof RouteInvocationRequestError) throw error; + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + 'No published build and route manifest are available.', + 409, + ); + } + if (manifest.digest !== queued.digest || manifest.sourceRevision !== queued.sourceRevision) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, + 409, + ); + } + const fixtureId = request.event?.fixtureId; + const fixture = fixtureId === undefined + ? undefined + : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); + if (fixtureId !== undefined && fixture === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE, + `Fixture ${JSON.stringify(fixtureId)} is not available for route ${JSON.stringify(route.id)}.`, + 400, + ); + } + const rawInput = request.input ?? fixture?.input ?? {}; + const input = route.kind === 'event-route' + ? eventInput(route, rawInput, request.event?.host, this.#registry) + : rawInput; + const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); + const controller = new AbortController(); + this.#controllers.add(controller); + if (this.#closed) { + controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); + } + const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); + let child: RouteInvocationChildResult; + const plainScript = plainScriptFor(prepared, route); + try { + child = plainScript === undefined + ? await this.#renderChild({ + ...(request.args === undefined ? {} : { args: request.args }), + context, + input, + manifest: prepared.manifest, + routeId: route.id, + stateRoot: prepared.stateRoot, + }, controller.signal) + : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); + } catch (error) { + const completedAt = this.#now(); + return failedInvocation({ + code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, + completedAt, context, - input, - manifest: prepared.manifest, - routeId: route.id, - }, controller.signal) - : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); - } catch (error) { - const completedAt = this.#now(); - return failedInvocation({ - code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, - completedAt, - context, - id, - manifest, - message: controller.signal.reason instanceof DOMException && controller.signal.reason.name === 'TimeoutError' - ? 'Route invocation child timed out.' - : controller.signal.aborted - ? 'Route invocation child stopped because the service closed.' - : `${plainScript === undefined ? 'Route invocation child' : 'Script run'} failed: ${error instanceof Error ? error.message : String(error)}`, - request: { ...request, input }, - route, - startedAt, - }); - } finally { - clearTimeout(timeout); - this.#controllers.delete(controller); - } + id, + manifest, + message: controller.signal.reason instanceof DOMException && controller.signal.reason.name === 'TimeoutError' + ? 'Route invocation child timed out.' + : controller.signal.aborted + ? 'Route invocation child stopped because the service closed.' + : `${plainScript === undefined ? 'Route invocation child' : 'Script run'} failed: ${error instanceof Error ? error.message : String(error)}`, + request: { ...request, input }, + route, + startedAt, + }); + } finally { + clearTimeout(timeout); + this.#controllers.delete(controller); + } const projectionStartedAt = this.#now(); const projection = invocationProjection( route, @@ -818,6 +875,9 @@ export class RouteInvocationService { timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), ], }); + } finally { + await release?.(); + } }); this.#pending.add(running); let invocation: RouteInvocation; diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index d872b557b..fe6074e80 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -11,7 +11,7 @@ import { runDevEpochContracts } from './dev-contract-runner.ts'; import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; -import { EpochStore } from './epoch-store.ts'; +import { EpochStore, EpochStoreError } from './epoch-store.ts'; import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; import { createInspectorLauncher } from './inspector-launcher.ts'; @@ -56,8 +56,11 @@ import { testManifestFromRouteGraph } from '../test/manifest.ts'; import type { RouteInvocationEventHost } from './routes/route-invocation.ts'; import { ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, RouteInvocationRequestError, RouteInvocationService, + routeInvocationStateRoot, } from './routes/route-invocation-service.ts'; import { routeManifestFor } from './routes/route-manifest.ts'; import type { RouteManifestRouteService } from './routes/route-manifest-routes.ts'; @@ -886,7 +889,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }; const routeInvocations = new RouteInvocationService({ manifest: routeManifest, - prepared: () => { + prepared: async () => { const prepared = latestPublishedPreparedProject; if (prepared === undefined || prepared.model === undefined) { throw new Error('No valid prepared project is available for route invocation.'); @@ -912,8 +915,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun const scriptTarget = prepared.model.targets .map((target) => target.name) .find((target) => registry.artifactLayout(target).scripts !== undefined); - return Object.freeze({ - ...(scriptTarget === undefined ? {} : { artifact: { epochId: artifact.activeEpoch.id, target: scriptTarget } }), + const epochId = artifact.activeEpoch.id; + const project = Object.freeze({ + ...(scriptTarget === undefined ? {} : { artifact: { epochId, target: scriptTarget } }), manifest: testManifestFromRouteGraph({ apps: prepared.model.mcpApps, configPath: prepared.configPath, @@ -934,8 +938,26 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), + stateRoot: routeInvocationStateRoot(prepared.root), targets, }); + let reference; + try { + reference = await epochStore.acquireEpochReference(epochId); + } catch (error) { + if (error instanceof EpochStoreError && error.code === 'EPOCH_NOT_FOUND') { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, + 409, + ); + } + throw error; + } + return { + project, + release: () => reference.close(), + }; }, registry, scripts: scriptPlayground, diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..a37128894 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -8,15 +8,20 @@ import { expect, it } from '@rstest/core'; import type { RouteInvocation } from '../src/dev/routes/route-invocation-result.ts'; import { InvocationRingBuffer, + ROUTE_INVOCATION_STALE_REVISION_CODE, RouteInvocationService, RouteInvocationRequestError, invocationSummary, parseRouteInvocationRequest, + routeInvocationStateRoot, + type RouteInvocationChildRequest, + type RouteInvocationChildResult, } from '../src/dev/routes/route-invocation-service.ts'; import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; import { isProcessGone } from './support/bin-process.ts'; +import { deferred } from './support/eventually.ts'; const invocation = (id: string, completedAt: string): RouteInvocation => ({ completedAt, @@ -110,44 +115,125 @@ it('retains a bounded newest-first invocation history', () => { expect(history.read('inv_two')?.id).toBe('inv_two'); }); +const echoRoute = { + config: [], + id: 'tool:fixture/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:fixture', + source: 'src/mcp/fixture/tools/echo.tsx', +} as const; + +const catalog = (digest: string, sourceRevision: string): RouteManifest => ({ + diagnostics: [], + digest, + events: [], + providers: [], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [echoRoute] }], + sourceRevision, +}); + +const childResult = (request: RouteInvocationChildRequest): RouteInvocationChildResult => ({ + document: { + root: { kind: 'text', text: 'ok' }, + status: 'success', + version: 1, + }, + events: [], + input: request.input, + mcp: {}, + renderDurationMs: 1, +}); + it('aborts and drains a running render when the service closes', 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; + let releases = 0; + const started = deferred(); const service = new RouteInvocationService({ manifest: { - manifest: () => ({ - diagnostics: [], - digest: 'digest', - events: [], - providers: [], - scripts: [], - servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [route] }], - sourceRevision: 'revision', - }), + manifest: () => catalog('digest', 'revision'), }, prepared: () => ({ - manifest: { projectRoot: '/project' } as never, - targets: ['claude'], + project: { + manifest: { projectRoot: '/project' } as never, + stateRoot: routeInvocationStateRoot('/project'), + targets: ['claude'], + }, + release: () => { + releases += 1; + }, }), renderChild: (_request, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + started.resolve(); }), }); - const pending = service.invoke({ input: {}, routeId: route.id }); - await Promise.resolve(); + const pending = service.invoke({ input: {}, routeId: echoRoute.id }); + await started.promise; await service.close(); await expect(pending).resolves.toMatchObject({ diagnostics: [expect.objectContaining({ code: 'AB8236' })], status: 'failed', }); + expect(releases).toBe(1); +}); + +it('rejects a queued invocation when the published revision moves before the slot is acquired', async () => { + const hold = deferred(); + const firstStarted = deferred(); + let digest = 'digest-1'; + let sourceRevision = 'rev-1'; + const executed: RouteInvocationChildRequest[] = []; + let releases = 0; + const projectRoot = '/project'; + const service = new RouteInvocationService({ + concurrency: 1, + manifest: { + manifest: () => catalog(digest, sourceRevision), + }, + prepared: () => ({ + project: { + manifest: { projectRoot } as never, + stateRoot: routeInvocationStateRoot(projectRoot), + targets: ['claude'], + }, + release: () => { + releases += 1; + }, + }), + renderChild: async (request) => { + executed.push(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 }); + await Promise.resolve(); + digest = 'digest-2'; + sourceRevision = 'rev-2'; + hold.resolve(); + + const firstResult = await first; + expect(firstResult).toMatchObject({ + manifestDigest: 'digest-1', + sourceRevision: 'rev-1', + status: 'succeeded', + }); + expect(executed).toHaveLength(1); + expect(executed[0]?.stateRoot).toBe(routeInvocationStateRoot(projectRoot)); + expect(executed[0]?.stateRoot).not.toBe(projectRoot); + await expect(second).rejects.toMatchObject({ + code: ROUTE_INVOCATION_STALE_REVISION_CODE, + status: 409, + }); + expect(executed).toHaveLength(1); + expect(releases).toBe(2); }); interface LeakingRouteProject { @@ -217,6 +303,7 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise Date: Sat, 5 Sep 2026 17:30:45 +0000 Subject: [PATCH 26/70] fix(dev): rewrite only module specifiers in the route invocation child (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the Jiti block from route-invocation-child.ts into dev/routes/route-module-loader.ts (createRouteModuleLoader) and replace the whole-source .js→.tsx string substitution with a TypeScript AST walk over import, export … from, and literal dynamic import() specifiers, so a string such as {'./panel.js'} renders in the Workbench as the compiled program prints it. --- .../src/dev/routes/route-invocation-child.ts | 56 +-------- .../src/dev/routes/route-module-loader.ts | 115 +++++++++++++++++ .../tests/route-invocation-service.test.ts | 117 ++++++++++++++---- .../route-unit/route-module-loader.test.ts | 98 +++++++++++++++ 4 files changed, 305 insertions(+), 81 deletions(-) create mode 100644 packages/agent-bundle/src/dev/routes/route-module-loader.ts create mode 100644 packages/agent-bundle/tests/route-unit/route-module-loader.test.ts 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 85d4f8f9e..3a379a28c 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -1,9 +1,4 @@ -import { existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; - import * as AgentRuntime from '@agent-bundle/runtime'; -import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; -import * as React from 'react'; import type { JsonObject } from '../../core/strict-json.ts'; import { @@ -20,56 +15,9 @@ import type { RouteInvocationChildResponse, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { createRouteModuleLoader } from './route-module-loader.ts'; -/** - * Classic JSX runtime, as in the playground's lifecycle render child: the - * automatic runtime would import `react/jsx-runtime`, which jiti resolves - * without the child's `--conditions=react-server`, binding the client runtime - * to the server `react` and throwing inside React (#441). Compiled JSX calls - * `React.createElement` instead, on the route's own `react` import or on the - * global below for modules that do not import it. - */ -(globalThis as typeof globalThis & { React?: typeof React }).React = React; - -const jitiOptions: JitiOptions = { - fsCache: false, - interopDefault: false, - jsx: { runtime: 'classic' }, - moduleCache: false, - nativeModules: ['typescript'], - virtualModules: { - '@agent-bundle/runtime': AgentRuntime, - react: React, - }, -}; - -const relativeJsSpecifier = /(['"])(\.\.?\/[^'"\n]*)\.js\1/gu; - -/** - * Project code imports its TypeScript siblings by their emitted `.js` name - * (`moduleResolution: NodeNext`); the build resolves those through Rspack's - * `extensionAlias`. jiti only retries `.js` as `.ts`, so a `.js` specifier - * whose source is a `.tsx` component never resolves. Point it at the file on - * disk before the transform sees the module. - */ -const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => { - if (filename === undefined) return source; - const directory = dirname(filename); - return source.replace(relativeJsSpecifier, (match, quote: string, specifier: string) => { - const stem = resolve(directory, specifier); - if (existsSync(`${stem}.js`) || existsSync(`${stem}.ts`) || !existsSync(`${stem}.tsx`)) return match; - return `${quote}${specifier}.tsx${quote}`; - }); -}; - -const baseJiti = createJiti(import.meta.url, jitiOptions); -const jiti = createJiti(import.meta.url, { - ...jitiOptions, - transform: (options) => ({ code: baseJiti.transform({ ...options, source: rewriteTsxSpecifiers(options) }) }), -}); - -const load = (source: string): (() => Promise) => - async () => jiti.import(source); +const { load } = createRouteModuleLoader(); const installManifest = (request: RouteInvocationChildRequest): void => { const manifest = request.manifest; diff --git a/packages/agent-bundle/src/dev/routes/route-module-loader.ts b/packages/agent-bundle/src/dev/routes/route-module-loader.ts new file mode 100644 index 000000000..33e6ff6e8 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-module-loader.ts @@ -0,0 +1,115 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import * as AgentRuntime from '@agent-bundle/runtime'; +import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; +import * as React from 'react'; +import ts from 'typescript-5'; + +import { isRelativeSpecifier } from '../../routes/module-candidates.ts'; +import { parseModule } from '../../routes/module-scope.ts'; + +/** + * Evaluates one project module from live source: a route, layout, provider, + * or state module by absolute path, as the Workbench's unit-render mode and + * the route-unit harness see it. + */ +export interface RouteModuleLoader { + readonly load: (source: string) => () => Promise; +} + +/** + * Classic JSX runtime, as in the playground's lifecycle render child: the + * automatic runtime would import `react/jsx-runtime`, which jiti resolves + * without the child's `--conditions=react-server`, binding the client runtime + * to the server `react` and throwing inside React (#441). Compiled JSX calls + * `React.createElement` instead, on the route's own `react` import or on the + * global below for modules that do not import it. + */ +(globalThis as typeof globalThis & { React?: typeof React }).React = React; + +const jitiOptions: JitiOptions = { + fsCache: false, + interopDefault: false, + jsx: { runtime: 'classic' }, + moduleCache: false, + nativeModules: ['typescript'], + virtualModules: { + '@agent-bundle/runtime': AgentRuntime, + react: React, + }, +}; + +interface SpecifierLiteral { + readonly end: number; + readonly start: number; + readonly text: string; +} + +const specifierLiteral = (sourceFile: ts.SourceFile, expression: ts.Expression | undefined): SpecifierLiteral | undefined => + expression !== undefined && ts.isStringLiteralLike(expression) + ? { end: expression.end, start: expression.getStart(sourceFile), text: expression.text } + : undefined; + +/** + * The string literals that name modules — `import … from`, `export … from`, + * and a literal dynamic `import()` — in source order. A string literal + * anywhere else (JSX text, a prop, an expression) names no module and is + * never one of them. + */ +const moduleSpecifierLiterals = (sourceFile: ts.SourceFile): readonly SpecifierLiteral[] => { + const literals: SpecifierLiteral[] = []; + const visit = (node: ts.Node): void => { + let literal: SpecifierLiteral | undefined; + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + literal = specifierLiteral(sourceFile, node.moduleSpecifier); + } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + literal = specifierLiteral(sourceFile, node.arguments[0]); + } + if (literal !== undefined) literals.push(literal); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return literals; +}; + +/** + * Project code imports its TypeScript siblings by their emitted `.js` name + * (`moduleResolution: NodeNext`); the build resolves those through Rspack's + * `extensionAlias`. jiti only retries `.js` as `.ts`, so a `.js` specifier + * whose source is a `.tsx` component never resolves. Point each such module + * specifier at the file on disk before the transform sees the module. Only + * import/export specifiers change: `{'./panel.js'}` + * renders `./panel.js` here exactly as the compiled program does. + */ +const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => { + if (filename === undefined) return source; + const directory = dirname(filename); + const sourceFile = parseModule(filename, source) as ts.SourceFile; + let rewritten = source; + for (const literal of moduleSpecifierLiterals(sourceFile).toReversed()) { + if (!isRelativeSpecifier(literal.text) || !literal.text.endsWith('.js')) continue; + const stem = resolve(directory, literal.text.slice(0, -'.js'.length)); + if (existsSync(`${stem}.js`) || existsSync(`${stem}.ts`) || !existsSync(`${stem}.tsx`)) continue; + const quote = source[literal.start]!; + rewritten = `${rewritten.slice(0, literal.start)}${quote}${literal.text}x${quote}${rewritten.slice(literal.end)}`; + } + return rewritten; +}; + +/** + * Jiti over live project source with the framework's own `react` and + * `@agent-bundle/runtime` instances, no module cache, and the `.js`-to-`.tsx` + * module specifier rewrite. `load(source)` returns a lazy loader in the shape + * the harness registry's `*Loaders` maps take. + */ +export const createRouteModuleLoader = (): RouteModuleLoader => { + const baseJiti = createJiti(import.meta.url, jitiOptions); + const jiti = createJiti(import.meta.url, { + ...jitiOptions, + transform: (options) => ({ code: baseJiti.transform({ ...options, source: rewriteTsxSpecifiers(options) }) }), + }); + return Object.freeze({ + load: (source: string) => async () => jiti.import(source), + }); +}; diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..5b50d73c8 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -16,6 +16,7 @@ import { import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; +import { expectDocument } from '../src/test/matchers.ts'; import { isProcessGone } from './support/bin-process.ts'; const invocation = (id: string, completedAt: string): RouteInvocation => ({ @@ -150,37 +151,24 @@ it('aborts and drains a running render when the service closes', async () => { }); }); -interface LeakingRouteProject { - readonly pids: () => Promise | undefined>; +interface RouteProject { readonly root: string; readonly service: (options?: Readonly<{ timeoutMs?: number }>) => RouteInvocationService; } -/** A tool route that holds an interval and a forked descendant, and writes both pids. */ -const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-child-')); - const relativePath = 'src/mcp/fixture/tools/leak.tsx'; +/** One conventional tool route at `src/mcp/fixture/tools/.tsx`, with the sibling files it imports. */ +const routeProject = async ( + root: string, + name: string, + files: Readonly>, +): Promise => { + const relativePath = `src/mcp/fixture/tools/${name}.tsx`; const source = join(root, relativePath); - const pidsPath = join(root, 'pids.json'); await mkdir(dirname(source), { recursive: true }); - await writeFile(source, [ - "import { spawn } from 'node:child_process';", - "import { writeFileSync } from 'node:fs';", - "import { Agent } from '@agent-bundle/runtime';", - "import { createElement } from 'react';", - '', - 'export default async function Leak() {', - ' setInterval(() => {}, 60_000);', - " const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000)'], { stdio: 'ignore' });", - ` writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify({ child: process.pid, descendant: descendant.pid }));`, - ...(behaviour === 'hang' ? [' await new Promise(() => {});'] : []), - " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'leaked'));", - '}', - '', - ].join('\n')); + await Promise.all(Object.entries(files).map(([path, text]) => writeFile(join(root, path), text))); const compiled = { config: {}, - id: 'tool:fixture/leak', + id: `tool:fixture/${name}`, kind: 'tool', provenance: { kind: 'conventional', relativePath }, serverId: 'mcp:fixture', @@ -220,10 +208,6 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise { - if (!existsSync(pidsPath)) return undefined; - return JSON.parse(await readFile(pidsPath, 'utf8')) as Readonly<{ child: number; descendant: number }>; - }, root, service: (options = {}) => new RouteInvocationService({ manifest: { manifest: () => manifest }, @@ -233,6 +217,85 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise Promise | undefined>; +} + +/** A tool route that holds an interval and a forked descendant, and writes both pids. */ +const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-child-')); + const pidsPath = join(root, 'pids.json'); + const project = await routeProject(root, 'leak', { + 'src/mcp/fixture/tools/leak.tsx': [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + 'export default async function Leak() {', + ' setInterval(() => {}, 60_000);', + " const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000)'], { stdio: 'ignore' });", + ` writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify({ child: process.pid, descendant: descendant.pid }));`, + ...(behaviour === 'hang' ? [' await new Promise(() => {});'] : []), + " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'leaked'));", + '}', + '', + ].join('\n'), + }); + return { + ...project, + pids: async () => { + if (!existsSync(pidsPath)) return undefined; + return JSON.parse(await readFile(pidsPath, 'utf8')) as Readonly<{ child: number; descendant: number }>; + }, + }; +}; + +/** + * `report.tsx` imports `panel.tsx` by its emitted name and also renders the + * string `'./panel.js'`: the child must resolve the component and print the + * text exactly as the compiled program does (#600). + */ +const tsxSiblingProject = async (): Promise => routeProject( + await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-tsx-sibling-')), + 'report', + { + 'src/mcp/fixture/tools/panel.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + "export const Panel = () => createElement(Agent.Text, null, 'panel rendered');", + '', + ].join('\n'), + 'src/mcp/fixture/tools/report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + "import { Panel } from './panel.js';", + '', + 'export default async function Report() {', + " return createElement(Agent.Result, null, createElement(Panel), createElement(Agent.Text, null, './panel.js'));", + '}', + '', + ].join('\n'), + }, +); + +it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { + const project = await tsxSiblingProject(); + try { + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report' }); + + expect(invocation.status, JSON.stringify(invocation.diagnostics)).toBe('succeeded'); + expect(invocation.document).toBeDefined(); + expectDocument(invocation.document!) + .toContainText('panel rendered') + .toContainText('./panel.js'); + } finally { + await rm(project.root, { force: true, recursive: true }); + } +}); + /** A zombie has exited; only a process still scheduled counts as alive. */ const alive = (pid: number): boolean => { if (isProcessGone(pid)) return false; diff --git a/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts new file mode 100644 index 000000000..10671b202 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { createRouteModuleLoader } from '../../src/dev/routes/route-module-loader.ts'; +import { expectDocument } from '../../src/test/matchers.ts'; +import { renderRouteEvents } from '../../src/test/render.ts'; +import type { AgentRouteModule } from '../../src/test/types.ts'; + +/** + * Project code names its TypeScript siblings by their emitted `.js` name. The + * loader points a `.js` specifier whose source is a `.tsx` file at that file + * (jiti retries `.ts` on its own, and a real `.js` sibling is loaded as is), + * and touches nothing but module specifiers: `'./panel.js'` rendered as text + * stays `./panel.js`, as the compiled program prints it (#600). + */ +const files: Readonly> = { + 'count.ts': "export const count = 'from count.ts';\n", + 'label.tsx': "export const label = 'from label.tsx';\n", + 'lazy.tsx': "export const lazy = 'from lazy.tsx';\n", + 'panel.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + '', + 'export const Panel = () => panel rendered;', + '', + ].join('\n'), + 'plain.js': "export const plain = 'from plain.js';\n", + 'report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + '', + "import { Panel } from './panel.js';", + '', + "export { count } from './count.js';", + "export { label } from './label.js';", + "export { plain } from './plain.js';", + "export const lazy = () => import('./lazy.js');", + "export const mention = './panel.js';", + '', + 'export default async function Report() {', + ' return (', + ' ', + ' ', + " {'./panel.js'}", + ' ', + ' );', + '}', + '', + ].join('\n'), +}; + +interface ReportModule extends AgentRouteModule { + readonly count: string; + readonly label: string; + readonly lazy: () => Promise<{ readonly lazy: string }>; + readonly mention: string; + readonly plain: string; +} + +let root: string; +let report: ReportModule; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-module-loader-')); + await Promise.all(Object.entries(files).map(([name, text]) => writeFile(join(root, name), text))); + report = await createRouteModuleLoader().load(join(root, 'report.tsx'))(); +}); + +afterAll(async () => { + await rm(root, { force: true, recursive: true }); +}); + +it('resolves a `.js` import whose source is a `.tsx` component and renders the module', async () => { + const rendered = await renderRouteEvents(report, { + context: { providers: {} }, + routeId: 'tool:fixture/report', + }); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('panel rendered') + .toContainText('./panel.js'); +}); + +it('leaves a string literal outside a module specifier alone', () => { + expect(report.mention).toBe('./panel.js'); +}); + +it('follows `export … from` and dynamic `import()` specifiers to their `.tsx` source', async () => { + expect(report.label).toBe('from label.tsx'); + await expect(report.lazy()).resolves.toMatchObject({ lazy: 'from lazy.tsx' }); +}); + +it('loads a `.ts` sibling through jiti and a real `.js` sibling as is', () => { + expect(report.count).toBe('from count.ts'); + expect(report.plain).toBe('from plain.js'); +}); From 912f1b7d77da4bfe0cf97f9f121fbe580611b813 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:33:36 +0000 Subject: [PATCH 27/70] drop LANE-NOTES --- LANE-NOTES.md | 64 --------------------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 9d186a090..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,64 +0,0 @@ -# Lane A4 — P2 telemetry honesty - -## Files - -- `packages/agent-bundle/src/dev/routes/route-invocation.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` -- `packages/agent-bundle/tests/route-invocation-service.test.ts` -- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` (assertion only; integration pool not run) -- `packages/workbench/src/application/invocation-client.ts` -- `packages/workbench/src/application/runtime-backend.ts` -- `packages/workbench/src/application/workspace.css` -- `packages/workbench/tests/invocation-client.test.ts` -- `packages/workbench/tests/route-workspace.test.ts` -- `website/docs/en/guide/development/workbench.mdx` -- `website/docs/zh/guide/development/workbench.mdx` -- `.changeset/wb600-pr2a-telemetry.md` - -Not edited (no decoder/view of invocation `providers`/`timings` beyond pass-through): `invocation-model.ts`, `result-tabs.tsx`. Inspector Providers/Timings already omitted absent `durationMs`; status now includes `unobserved` via the CSS class. - -## Behavior - -- Success without `child.observed`: every catalog provider is `{ id, name, status: 'unobserved' }` with no `durationMs`. Timings are only measured `render` (`child.renderDurationMs`) and `projection` (service wall time). No `handler` / `providers` / `provider:*` rows. -- Success with `child.observed`: `providers` are the observed rows exactly. Observed timings that are `handler`, `providers`, or `provider:*` are forwarded; an observed `render` is dropped so the service's `child.renderDurationMs` remains the `render` phase. -- Failure: no fabricated `failed` providers — same unobserved catalog rows. The only timing is `elapsed`: wall time from the recorded `startedAt` (before the semaphore slot) until the child/script threw. That is not render time; `render` is omitted because no document was produced. -- Workbench decoder accepts `'unobserved'` and optional `durationMs`. Providers tab shows status `unobserved` and `—` when duration is absent. Runtime-backend no longer coerces missing span durations to `0`. - -## Exported API / contract - -- `RouteInvocationProviderStatus` adds `'unobserved'`. -- `RouteInvocationProvider.durationMs` stays optional (now documented: absent = not measured). -- `RouteInvocationTiming.phase` documents `elapsed` and that zero is a measurement. -- `RouteInvocationChildResult.observed?: { providers; timings }` added (agreed A2/A4 shape). A2 may add the same field — accept the trivial conflict. -- `RouteInvocation` / `RouteInvocationProvider` are **not** exported from `src/index.ts` or another public package entry (`package.json` `exports` has no `./contracts`). `src/contracts/invocations.ts` re-exports them for the Workbench source import only. Changeset is **patch**. - -## Cross-lane requests - -- **A2** (`route-invocation-child.ts`): populate `RouteInvocationChildResult.observed` with measured provider rows and `handler`/`providers`/`provider:*` timings. When that lands, `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` currently expects the clock provider `unobserved` and timings `['render', 'projection']` — flip those assertions to the observed values. -- **A3**: none. `invoke()` ordering, `prepared`, and the constructor were left alone. - -## Open risks - -- Until A2 emits `observed`, every live Workbench run shows catalog providers as `unobserved`. That is honest, not a regression of measurement. -- `elapsed` is a new phase name on failures. The Timings tab will render it as a real bar (including `0 ms` if `now()` does not advance). -- `startedAt` for the success `render` timing is still the pre-semaphore invocation timestamp; only the duration is the child's measurement. - -## Verification - -- `pnpm build` — pass -- `npx tsc --noEmit` — pass -- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass -- `pnpm lint` — pass (1389 files) -- `pnpm test:unit` — pass (275 files / 4128 tests; intended file filter ran the whole unit pool) - -Not run: `rstest.route-unit.config.ts`, `rstest.integration.config.ts` (`route-invocation-dev-server.test.ts` assertion updated but not executed), Workbench e2e. A4 gates did not require those. - -## Proposed changeset - -`.changeset/wb600-pr2a-telemetry.md` — `agent-bundle` **patch**: - -> Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) - -## Diagnostic codes - -None. A4 takes no new codes (`AB8233`–`AB8235` browser; `AB8250`–`AB8252` A2; `AB8239` A3). From 502e64f78ee80770eca346b8689a4ba470ff1771 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:35:37 +0000 Subject: [PATCH 28/70] fix merged telemetry/lease test fixtures --- .../tests/route-invocation-service.test.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index df4550663..da24c8058 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -395,15 +395,6 @@ it('reaps the render child and its descendants when the service closes mid-rende } }); -const echoRoute = { - config: [], - id: 'tool:fixture/echo', - kind: 'tool', - provenance: { kind: 'conventional' }, - serverId: 'mcp:fixture', - source: 'src/mcp/fixture/tools/echo.tsx', -} as const; - const clockProvider = { id: 'provider:clock', name: 'clock', @@ -447,6 +438,7 @@ const telemetryService = ( manifest: { manifest: telemetryManifest }, prepared: () => ({ manifest: { projectRoot: '/project' } as never, + stateRoot: routeInvocationStateRoot('/project'), targets: ['claude'], }), renderChild, From 5102943255196239b629eacd93c1df3ad4369270 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:36:24 +0000 Subject: [PATCH 29/70] fix: carry runtime MCP trace correlation --- LANE-NOTES.md | 37 +++++++++++++++++ .../agent-bundle/src/contracts/mcp-session.ts | 3 ++ .../dev/mcp-app-runtime-binding-service.ts | 14 ++++++- .../dev/mcp-app-runtime-preview-service.ts | 15 ++++++- .../src/dev/mcp-apps/mcp-app-routes.ts | 6 ++- .../dev/mcp-session/mcp-session-service.ts | 3 +- .../mcp-session-trace-publisher.ts | 8 +--- .../src/dev/runtime-mcp-routes.ts | 17 ++++++-- .../agent-bundle/src/dev/runtime-protocol.ts | 1 + .../agent-bundle/tests/mcp-app-routes.test.ts | 32 +++++++++++++++ .../mcp-app-runtime-binding-service.test.ts | 15 ++++++- .../mcp-app-runtime-preview-service.test.ts | 9 ++++- .../tests/mcp-session-trace-publisher.test.ts | 2 +- .../tests/runtime-mcp-routes.test.ts | 40 ++++++++++++++++++- packages/workbench/src/logs/log-client.ts | 8 ++-- .../src/mcp/agent-bundle-remote-transport.ts | 2 +- .../workbench/src/mcp/mcp-route-client.ts | 14 +++---- .../src/mcp/mcp-session-controller.ts | 5 ++- packages/workbench/src/shell/wire-text.ts | 10 +++++ packages/workbench/src/trace/trace-client.ts | 10 +---- .../agent-bundle-remote-transport.test.ts | 3 +- packages/workbench/tests/log-client.test.ts | 12 ++++++ .../tests/mcp-session-controller.test.ts | 12 ++++-- .../workbench/tests/runtime-client.test.ts | 22 +++++++++- 24 files changed, 248 insertions(+), 52 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/workbench/src/shell/wire-text.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..6e7ee2374 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,37 @@ +# Lane W6 notes + +## Files and behavior + +- Added `packages/workbench/src/shell/wire-text.ts` and rewired both Raw Logs and Trace clients to share control-character and path-like-text validation. +- Raw Logs now accepts slash-bearing relative identities in summaries/details while continuing to reject absolute POSIX, home-relative, drive-letter, UNC, and `file:` paths. +- Moved `mcpCorrelationMetaKey` to `packages/agent-bundle/src/contracts/mcp-session.ts`; the dev trace publisher, Workbench controller, and remote transport now import that source. +- Added optional bounded `correlationId` fields to runtime MCP and MCP App tool-call contracts, then preserved the field through Workbench request canonicalization, runtime/App route decoders, preview execution, and runtime binding execution. +- Added focused coverage in LogClient, Workbench MCP controller/client/transport, runtime MCP routes, MCP App routes, preview service, binding service, and trace publisher tests. + +## Cross-lane requests + +- The integration lane should add the PR's single package changeset; proposed line: `Propagate Workbench correlation IDs through runtime MCP App tool calls and accept safe slash-bearing Raw Logs text. (#600)` +- `DevRuntimeMcpOperationRequest.correlationId` now reaches the provider-owned `RuntimeMcpExecutionContext.request`. This checkout has no package-owned adapter that turns that runtime operation into MCP `tools/call` params; a provider that emits an MCP frame must stamp it as `params._meta[mcpCorrelationMetaKey]`. + +## Open risks + +- `contracts/mcp-session.ts` is a browser-safe internal contract consumed by Workbench through the repository's existing source import pattern; `package.json` does not expose a separate `./mcp-session` npm subpath. +- No hand-written English or Chinese documentation page lists this internal contract constant, so no website files were changed. + +## Verification + +- `pnpm build` +- `npx tsc --noEmit` +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` +- `pnpm lint` +- Affected unit files under `rstest.unit.config.ts`: 183 tests passed. +- Full `rstest.route-unit.config.ts` pool: 85 tests passed. +- `packages/workbench/tests/logs-real.e2e.test.ts`: passed. +- `packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts`: passed. +- Dead-module check: `wire-text` has two production importers (`log-client.ts`, `trace-client.ts`). +- Deslop: GPT-5.6 Sol, 0 follow-up edits. + +## Diagnostic codes + +- Existing decoder and runtime validation codes remain unchanged: `AB8093`, `AB8015`, and `AB8203`. +- No diagnostic codes were added or removed. diff --git a/packages/agent-bundle/src/contracts/mcp-session.ts b/packages/agent-bundle/src/contracts/mcp-session.ts index 9640c3ce2..65cc6cf86 100644 --- a/packages/agent-bundle/src/contracts/mcp-session.ts +++ b/packages/agent-bundle/src/contracts/mcp-session.ts @@ -12,6 +12,9 @@ export type { 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/dev/mcp-app-runtime-binding-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts index 8431c7824..939deb631 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-binding-service.ts @@ -73,7 +73,12 @@ export interface CreateMcpAppRuntimeBindingOptions { export type McpAppRuntimeOperationRequest = | Readonly<{ readonly kind: 'list-tools' }> | Readonly<{ readonly kind: 'list-resources' }> - | Readonly<{ readonly arguments?: McpAppJsonValue; readonly kind: 'call-tool'; readonly name: string }> + | Readonly<{ + readonly arguments?: McpAppJsonValue; + readonly correlationId?: string; + readonly kind: 'call-tool'; + readonly name: string; + }> | Readonly<{ readonly kind: 'read-resource'; readonly uri: string }>; export interface McpAppRuntimeBindingInvalidation { @@ -179,8 +184,15 @@ const canonicalOperation = (request: McpAppRuntimeOperationRequest, expectedSess if (request.kind === 'call-tool') { const argumentsValue = request.arguments === undefined ? Object.freeze({}) : cloneMcpAppFiniteJson(request.arguments, 'Runtime MCP App tool arguments'); if (!isRecord(argumentsValue)) throw new TypeError('Runtime MCP App tool arguments must be a finite JSON object.'); + const correlationId = request.correlationId === undefined + ? undefined + : nonempty(request.correlationId, 'Runtime MCP App correlation id'); + if (correlationId !== undefined && correlationId.length > 256) { + throw new TypeError('Runtime MCP App correlation id must be at most 256 characters.'); + } return Object.freeze({ arguments: argumentsValue as Readonly>, + ...(correlationId === undefined ? {} : { correlationId }), expectedSessionRevision, kind: 'call-tool', name: nonempty(request.name, 'Runtime MCP App tool name'), diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts index 3b382841c..b4335e4be 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts @@ -43,7 +43,13 @@ import type { DevRuntimeMcpAppRunBinding, DevRuntimeMcpConnectionState, RuntimeV export type McpAppBindingOperation = | Readonly<{ readonly kind: 'tools/list' }> | Readonly<{ readonly kind: 'resources/list' }> - | Readonly<{ readonly arguments?: McpAppJsonValue; readonly consentId?: string; readonly kind: 'tools/call'; readonly name: string }> + | Readonly<{ + readonly arguments?: McpAppJsonValue; + readonly consentId?: string; + readonly correlationId?: string; + readonly kind: 'tools/call'; + readonly name: string; + }> | Readonly<{ readonly kind: 'resources/read'; readonly uri: string }>; export interface CreateMcpAppPreviewRequest { @@ -542,7 +548,12 @@ export class McpAppRuntimePreviewService implements McpAppRuntimeRoutePreviewSer if (request.consentId === undefined || !entry.consent.consume({ actionDigest: createMcpAppConsentActionDigest('call-tool', Object.freeze({ arguments: request.arguments ?? {}, name })), authorizationId: request.consentId, bindingId, capability: 'call-tool', profile: entry.binding.profileId })) { throw new Error('Runtime MCP App tool call requires an approved consent grant.'); } - result = await this.#bindingAuthority.execute(bindingId, { arguments: request.arguments, kind: 'call-tool', name }, Object.freeze({ signal: operation.controller.signal })); + result = await this.#bindingAuthority.execute(bindingId, { + arguments: request.arguments, + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), + kind: 'call-tool', + name, + }, Object.freeze({ signal: operation.controller.signal })); } return Object.freeze({ result }); } catch (error) { diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index 85d545b6e..d3668032f 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -370,11 +370,13 @@ const runtimeOperation = (value: JsonObject): McpAppBindingOperation => { if (value.kind === 'resources/read' && hasOnly(value, ['kind', 'uri']) && nonemptyString(value.uri)) { return Object.freeze({ kind: 'resources/read', uri: value.uri }); } - if (value.kind === 'tools/call' && hasOnly(value, ['arguments', 'consentId', 'kind', 'name']) && nonemptyString(value.name) - && (value.arguments === undefined || isJsonValue(value.arguments)) && (value.consentId === undefined || nonemptyString(value.consentId))) { + if (value.kind === 'tools/call' && hasOnly(value, ['arguments', 'consentId', 'correlationId', 'kind', 'name']) && nonemptyString(value.name) + && (value.arguments === undefined || isJsonValue(value.arguments)) && (value.consentId === undefined || nonemptyString(value.consentId)) + && (value.correlationId === undefined || (nonemptyString(value.correlationId) && value.correlationId.length <= 256))) { return Object.freeze({ ...(value.arguments === undefined ? {} : { arguments: cloneJson(value.arguments) }), ...(value.consentId === undefined ? {} : { consentId: value.consentId }), + ...(value.correlationId === undefined ? {} : { correlationId: value.correlationId }), kind: 'tools/call', name: value.name, }); } 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 08466b239..1250dba9c 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 @@ -77,7 +77,8 @@ export type { OpenMcpSessionOptions, } from './mcp-session-types.ts'; export type { McpSessionTraceSink } from './mcp-session-trace.ts'; -export { createMcpSessionTraceSink, liftMcpFrame, mcpCorrelationMetaKey } from './mcp-session-trace-publisher.ts'; +export { mcpCorrelationMetaKey } from '../../contracts/mcp-session.ts'; +export { createMcpSessionTraceSink, liftMcpFrame } from './mcp-session-trace-publisher.ts'; export type { McpSessionBinding, 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 index 5b35511c7..028b77af2 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -17,13 +18,6 @@ import type { } from './mcp-session-protocol.ts'; import type { McpSessionTraceSink } from './mcp-session-trace.ts'; -/** - * The `params._meta` key the Workbench stamps on a `tools/call` it makes - * through a session route so the frame joins the route workspace's run - * (`RouteInvocationRequest.correlationId`) on the unified trace. - */ -export const mcpCorrelationMetaKey = 'agent-bundle/correlationId'; - /** 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'; diff --git a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts index dff8203dd..e737c900c 100644 --- a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts @@ -155,7 +155,7 @@ const restartSnapshot = ( }; const rpcRequest = (body: Record, sessionId: string): DevRuntimeMcpOperationRequest => { - if (!hasOnly(body, ['arguments', 'expectedSessionRevision', 'kind', 'name', 'uri']) || !positive(body.expectedSessionRevision)) { + if (!hasOnly(body, ['arguments', 'correlationId', 'expectedSessionRevision', 'kind', 'name', 'uri']) || !positive(body.expectedSessionRevision)) { throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); } if (body.kind === 'list-tools' || body.kind === 'list-resources') { @@ -165,8 +165,19 @@ const rpcRequest = (body: Record, sessionId: string): DevRuntim if (body.kind === 'read-resource' && hasOnly(body, ['expectedSessionRevision', 'kind', 'uri']) && nonempty(body.uri)) { return Object.freeze({ expectedSessionRevision: body.expectedSessionRevision, kind: 'read-resource', uri: body.uri }); } - if (body.kind === 'call-tool' && hasOnly(body, ['arguments', 'expectedSessionRevision', 'kind', 'name']) && nonempty(body.name) && isRecord(body.arguments) && json(body.arguments)) { - return Object.freeze({ arguments: body.arguments as Readonly>, expectedSessionRevision: body.expectedSessionRevision, kind: 'call-tool', name: body.name }); + if ( + body.kind === 'call-tool' && + hasOnly(body, ['arguments', 'correlationId', 'expectedSessionRevision', 'kind', 'name']) && + nonempty(body.name) && isRecord(body.arguments) && json(body.arguments) && + (body.correlationId === undefined || (nonempty(body.correlationId) && body.correlationId.length <= 256)) + ) { + return Object.freeze({ + arguments: body.arguments as Readonly>, + ...(body.correlationId === undefined ? {} : { correlationId: body.correlationId }), + expectedSessionRevision: body.expectedSessionRevision, + kind: 'call-tool', + name: body.name, + }); } void sessionId; throw requestError(diagnostic('AB8203', 'Runtime request has an invalid shape.', 400)); diff --git a/packages/agent-bundle/src/dev/runtime-protocol.ts b/packages/agent-bundle/src/dev/runtime-protocol.ts index 929764f8b..53d008d8c 100644 --- a/packages/agent-bundle/src/dev/runtime-protocol.ts +++ b/packages/agent-bundle/src/dev/runtime-protocol.ts @@ -239,6 +239,7 @@ export type DevRuntimeMcpOperationRequest = DevRuntimeMcpOperationBase & ( | Readonly<{ readonly kind: 'list-tools' }> | Readonly<{ readonly arguments: JsonObject; + readonly correlationId?: string; readonly kind: 'call-tool'; readonly name: string; readonly requestId?: string; diff --git a/packages/agent-bundle/tests/mcp-app-routes.test.ts b/packages/agent-bundle/tests/mcp-app-routes.test.ts index ba8f2daf6..f84967002 100644 --- a/packages/agent-bundle/tests/mcp-app-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-app-routes.test.ts @@ -397,6 +397,38 @@ it('forwards one request-owned abort signal to each admitted runtime App operati } }); +it('decodes a bounded runtime App tool correlation id', async () => { + const operations: unknown[] = []; + const runtime: McpAppRuntimeRoutePreviewService = { + close: async () => undefined, + create: async () => { throw new Error('unused'); }, + createConsent: async () => { throw new Error('unused'); }, + decideConsent: async () => { throw new Error('unused'); }, + get: (bindingId) => bindingId === 'runtime-binding' + ? Object.freeze({ binding: Object.freeze({ id: bindingId }), kind: 'fallback' }) as never + : undefined, + operate: async (_bindingId, operation) => { + operations.push(operation); + return deepFreeze({ result: { content: Object.freeze([]) } }) as never; + }, + }; + const started = await startRoutes(Object.assign(new RecordingPreviewService(), { runtime })); + const call = (correlationId: string): Promise => fetch(`${started.url}/api/runtime/apps/runtime-binding/operations`, { + body: JSON.stringify({ arguments: {}, correlationId, kind: 'tools/call', name: 'forecast' }), + headers: { ...headers(), 'content-type': 'application/json' }, + method: 'POST', + }); + try { + expect((await call('corr-runtime-app')).status).toBe(200); + expect((await call('x'.repeat(257))).status).toBe(400); + expect(operations).toEqual([ + { arguments: {}, correlationId: 'corr-runtime-app', kind: 'tools/call', name: 'forecast' }, + ]); + } finally { + await started.close(); + } +}); + it('aborts an admitted runtime App operation when its HTTP client disconnects', async () => { let operationSignal: AbortSignal | undefined; const runtime: McpAppRuntimeRoutePreviewService = { diff --git a/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts b/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts index 22fd46bc5..5453a819a 100644 --- a/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts +++ b/packages/agent-bundle/tests/mcp-app-runtime-binding-service.test.ts @@ -150,11 +150,22 @@ it('executes against the stable session revision while retaining the originating const binding = await service.createBinding(optionsFor(fixture)); const read = await service.execute(binding.id, { kind: 'read-resource', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }); - const call = await service.execute(binding.id, { arguments: {}, kind: 'call-tool', name: 'render_edit_timeline' }); + const call = await service.execute(binding.id, { + arguments: {}, + correlationId: 'corr-runtime-app', + kind: 'call-tool', + name: 'render_edit_timeline', + }); expect(fixture.executeRequests).toEqual([ { expectedSessionRevision: 3, kind: 'read-resource', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, - { arguments: {}, expectedSessionRevision: 3, kind: 'call-tool', name: 'render_edit_timeline' }, + { + arguments: {}, + correlationId: 'corr-runtime-app', + expectedSessionRevision: 3, + kind: 'call-tool', + name: 'render_edit_timeline', + }, ]); expect(read.vector.runtimeGenerationId).toBe('g7'); expect(call.vector.runtimeGenerationId).toBe('g8'); diff --git a/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts b/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts index deabb2450..8419e1820 100644 --- a/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts +++ b/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts @@ -312,6 +312,7 @@ it('binds a call-tool consent grant to one exact operation and rejects a browser expect(decision.grant).toMatchObject({ bindingId: preview.binding.id, capability: 'call-tool', scope: 'action' }); await expect(service.operate(preview.binding.id, { consentId: decision.grant?.authorizationId, + correlationId: 'corr-runtime-app', kind: 'tools/call', name: 'show-weather', })).resolves.toMatchObject({ result: { operationId: 'op-4' } }); @@ -320,7 +321,13 @@ it('binds a call-tool consent grant to one exact operation and rejects a browser kind: 'tools/call', name: 'show-weather', })).rejects.toThrow('requires an approved consent'); - expect(requests.at(-1)).toEqual({ arguments: {}, expectedSessionRevision: 2, kind: 'call-tool', name: 'show-weather' }); + expect(requests.at(-1)).toEqual({ + arguments: {}, + correlationId: 'corr-runtime-app', + expectedSessionRevision: 2, + kind: 'call-tool', + name: 'show-weather', + }); }); it('times out and releases hung Runtime App operations without waiting for late provider settlement', async () => { diff --git a/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts index 15222fa50..0b16358f3 100644 --- a/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts +++ b/packages/agent-bundle/tests/mcp-session-trace-publisher.test.ts @@ -1,11 +1,11 @@ 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, - mcpCorrelationMetaKey, } 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'; diff --git a/packages/agent-bundle/tests/runtime-mcp-routes.test.ts b/packages/agent-bundle/tests/runtime-mcp-routes.test.ts index 2dd18d028..7459d4d20 100644 --- a/packages/agent-bundle/tests/runtime-mcp-routes.test.ts +++ b/packages/agent-bundle/tests/runtime-mcp-routes.test.ts @@ -194,6 +194,7 @@ it('rejects query-bearing manual runtime MCP routes before authorizing a control it('maps manual registry conflicts to 409 and waits for restart invalidation before returning a phase-safe failure', async () => { let invalidationsDrained = false; + const operations: unknown[] = []; const runtime = { mcpRegistry: { restart: async () => Object.freeze({ @@ -205,7 +206,10 @@ it('maps manual registry conflicts to 409 and waits for restart invalidation bef sequence: 4, }), session: (sessionId: string) => sessionId === 'session-a' ? Object.freeze({ - execute: async () => { throw Object.assign(new Error('stale'), { code: 'RUNTIME_MCP_REGISTRY_CONFLICT' }); }, + execute: async (operation: unknown) => { + operations.push(operation); + throw Object.assign(new Error('stale'), { code: 'RUNTIME_MCP_REGISTRY_CONFLICT' }); + }, snapshot: () => { throw new Error('unused'); }, watchClosed: () => Object.freeze({ closed: false, unsubscribe: () => undefined }), }) : undefined, @@ -233,6 +237,30 @@ it('maps manual registry conflicts to 409 and waits for restart invalidation bef body: JSON.stringify({ expectedSessionRevision: 2, kind: 'list-tools' }), headers, method: 'POST', }); expect(stale.status).toBe(409); + const correlated = await fetch(`http://127.0.0.1:${address.port}/api/runtime/mcp/sessions/session-a/rpc`, { + body: JSON.stringify({ + arguments: {}, + correlationId: 'corr-runtime-app', + expectedSessionRevision: 2, + kind: 'call-tool', + name: 'forecast', + }), + headers, + method: 'POST', + }); + expect(correlated.status).toBe(409); + const oversized = await fetch(`http://127.0.0.1:${address.port}/api/runtime/mcp/sessions/session-a/rpc`, { + body: JSON.stringify({ + arguments: {}, + correlationId: 'x'.repeat(257), + expectedSessionRevision: 2, + kind: 'call-tool', + name: 'forecast', + }), + headers, + method: 'POST', + }); + expect(oversized.status).toBe(400); const unknown = await fetch(`http://127.0.0.1:${address.port}/api/runtime/mcp/sessions/unknown-a/rpc`, { body: JSON.stringify({ expectedSessionRevision: 2, kind: 'list-tools' }), headers, method: 'POST', }); @@ -241,6 +269,16 @@ it('maps manual registry conflicts to 409 and waits for restart invalidation bef body: JSON.stringify({ expectedSessionRevision: 2, sessionId: 'session-a' }), headers, method: 'POST', }); expect(restart.status).toBe(409); + expect(operations).toEqual([ + { expectedSessionRevision: 2, kind: 'list-tools' }, + { + arguments: {}, + correlationId: 'corr-runtime-app', + expectedSessionRevision: 2, + kind: 'call-tool', + name: 'forecast', + }, + ]); expect(invalidationsDrained).toBe(true); } finally { await new Promise((resolvePromise, rejectPromise) => server.close((error) => error === undefined ? resolvePromise() : rejectPromise(error))); 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/mcp/agent-bundle-remote-transport.ts b/packages/workbench/src/mcp/agent-bundle-remote-transport.ts index f1ca1793a..122e5b965 100644 --- a/packages/workbench/src/mcp/agent-bundle-remote-transport.ts +++ b/packages/workbench/src/mcp/agent-bundle-remote-transport.ts @@ -1,10 +1,10 @@ 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'; import { - mcpCorrelationMetaKey, McpRouteClient, type McpRouteConnection, type McpRouteOperation, diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index 577345c18..d357ed368 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -84,14 +84,6 @@ export interface McpRouteTrace { readonly overflow?: unknown; } -/** - * The `params._meta` key the dev server's MCP session route stamps a `tools/call` - * `correlationId` under, and the key its trace publisher lifts back into a frame's - * `meta.correlationId`. In the browser it only travels between the session - * controller and the remote transport, which lowers it to the top-level field. - */ -export const mcpCorrelationMetaKey = 'agent-bundle/correlationId'; - export type McpRouteOperation = | Readonly<{ readonly operation: 'initialize' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'tools/list' }> | Readonly<{ readonly arguments?: Readonly>; readonly name: string; readonly operation: 'prompts/get' }> @@ -413,9 +405,13 @@ const runtimeOperationRequest = (request: DevRuntimeMcpOperationRequest): DevRun } if (request.kind === 'call-tool' && nonempty(request.name)) { const argumentsSnapshot = detachedJson(request.arguments); - if (!isRecord(argumentsSnapshot)) throw new McpRouteClientError('AB8015', 'Runtime MCP operation request is not valid.'); + if ( + !isRecord(argumentsSnapshot) || + (request.correlationId !== undefined && (!nonempty(request.correlationId) || request.correlationId.length > 256)) + ) throw new McpRouteClientError('AB8015', 'Runtime MCP operation request is not valid.'); return Object.freeze({ arguments: argumentsSnapshot as JsonObject, + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), expectedSessionRevision: request.expectedSessionRevision, kind: 'call-tool', name: request.name, diff --git a/packages/workbench/src/mcp/mcp-session-controller.ts b/packages/workbench/src/mcp/mcp-session-controller.ts index 1e55b100d..f418bfa69 100644 --- a/packages/workbench/src/mcp/mcp-session-controller.ts +++ b/packages/workbench/src/mcp/mcp-session-controller.ts @@ -7,7 +7,7 @@ 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, @@ -43,7 +43,6 @@ import { type McpBrowserSessionModel, } from './mcp-session-model.ts'; import { - mcpCorrelationMetaKey, McpRouteClientError, sameRuntimeBinding, type McpRouteCatalog, @@ -562,6 +561,7 @@ const appBindingOperationFor = (operation: McpRouteOperation): McpAppBindingOper if (operation.operation === 'resources/read') return Object.freeze({ kind: 'resources/read', uri: operation.uri }); if (operation.operation === 'tools/call') return Object.freeze({ arguments: operation.arguments as McpAppJsonValue, + ...(operation.correlationId === undefined ? {} : { correlationId: operation.correlationId }), kind: 'tools/call', name: operation.name, }); @@ -612,6 +612,7 @@ const runtimeRequestForRoute = ( if (operation.operation === 'resources/read') return Object.freeze({ expectedSessionRevision: revision, kind: 'read-resource', uri: operation.uri }); if (operation.operation === 'tools/call') return Object.freeze({ arguments: operation.arguments as JsonObject, + ...(operation.correlationId === undefined ? {} : { correlationId: operation.correlationId }), expectedSessionRevision: revision, kind: 'call-tool', name: operation.name, 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/trace/trace-client.ts b/packages/workbench/src/trace/trace-client.ts index 5e8dcbcec..3741bedcd 100644 --- a/packages/workbench/src/trace/trace-client.ts +++ b/packages/workbench/src/trace/trace-client.ts @@ -21,6 +21,7 @@ import { errorMessage, exactKeys, hasAllowedKeys, isAbortError, isRecord, parseS 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'; /** What the Trace page and the route workspace (T6) code against; `ForegroundTraceClient` is the production implementation. */ @@ -69,16 +70,7 @@ 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 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; -}; const identifier = /^[A-Za-z0-9_][A-Za-z0-9._:@+/-]*$/u; -/** A token that is an absolute POSIX path (two or more segments), a Windows path, a UNC path, or a `file:` URL. */ -const pathLikeText = /(?:^|[\s"'`([=:,])(?:\/[^\s/]+){2,}|file:|(?:^|[^A-Za-z0-9])[A-Za-z]:[\\/]|\\\\/u; /** * Free text the server promised was already safe (`safeDevWireText`): no diff --git a/packages/workbench/tests/agent-bundle-remote-transport.test.ts b/packages/workbench/tests/agent-bundle-remote-transport.test.ts index d66e36c21..9db1f1bbc 100644 --- a/packages/workbench/tests/agent-bundle-remote-transport.test.ts +++ b/packages/workbench/tests/agent-bundle-remote-transport.test.ts @@ -1,8 +1,9 @@ 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 { mcpCorrelationMetaKey, McpRouteClient } from '../src/mcp/mcp-route-client.ts'; +import { McpRouteClient } from '../src/mcp/mcp-route-client.ts'; import { deferred, eventually } from './support/async.ts'; interface RecordedRequest { 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/mcp-session-controller.test.ts b/packages/workbench/tests/mcp-session-controller.test.ts index 07474dfee..9e90f61f8 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'; @@ -9,7 +10,7 @@ import { type McpSessionControllerRoutes, type McpSessionControllerTransport, } from '../src/mcp/mcp-session-controller.ts'; -import { mcpCorrelationMetaKey, McpRouteClientError, type McpRouteCatalog } from '../src/mcp/mcp-route-client.ts'; +import { McpRouteClientError, type McpRouteCatalog } from '../src/mcp/mcp-route-client.ts'; const binding = Object.freeze({ epochId: 'epoch-a', serverName: 'weather', target: 'portable' as const }); const connection = Object.freeze({ @@ -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.', @@ -323,7 +329,7 @@ it('attaches one non-owning runtime App client through one exact App authority a { kind: 'tools/list' }, { kind: 'resources/list' }, { kind: 'resources/read', uri: 'weather://today' }, - { arguments: { city: 'Paris' }, kind: 'tools/call', name: 'forecast' }, + { arguments: { city: 'Paris' }, correlationId: 'corr-runtime-app', kind: 'tools/call', name: 'forecast' }, ]); expect(calls).toEqual([]); expect(controller.history).toEqual([ diff --git a/packages/workbench/tests/runtime-client.test.ts b/packages/workbench/tests/runtime-client.test.ts index 503f3302f..439585a5d 100644 --- a/packages/workbench/tests/runtime-client.test.ts +++ b/packages/workbench/tests/runtime-client.test.ts @@ -196,12 +196,14 @@ it('rejects absolute, protocol-relative, credentialed, and fragmented protected it('passes the exact runtime MCP operation cancellation signal to the authenticated route fetch', async () => { const abort = new AbortController(); + let body: unknown; let observed: AbortSignal | undefined; const client = new McpRouteClient({ fetch: async (input, init) => { const path = String(input); if (path === '/api/project/session') return json(foregroundSession); if (path === '/api/runtime/mcp/sessions/runtime-session-a/rpc') { + body = JSON.parse(String(init?.body)); observed = init?.signal as AbortSignal | undefined; return json({ result: { operationId: 'runtime-operation-a', sessionId: 'runtime-session-a', sessionRevision: 3, value: [], vector, @@ -212,10 +214,28 @@ it('passes the exact runtime MCP operation cancellation signal to the authentica }); await expect(client.executeRuntime('runtime-session-a', { + arguments: {}, + correlationId: 'corr-runtime-app', expectedSessionRevision: 3, - kind: 'list-tools', + kind: 'call-tool', + name: 'forecast', }, abort.signal)).resolves.toMatchObject({ operationId: 'runtime-operation-a' }); + expect(body).toEqual({ + arguments: {}, + correlationId: 'corr-runtime-app', + expectedSessionRevision: 3, + kind: 'call-tool', + name: 'forecast', + }); expect(observed).toBe(abort.signal); + + await expect(client.executeRuntime('runtime-session-a', { + arguments: {}, + correlationId: 'x'.repeat(257), + expectedSessionRevision: 3, + kind: 'call-tool', + name: 'forecast', + })).rejects.toMatchObject({ code: 'AB8015' }); }); it('bootstraps available runtime history through one shared foreground authentication session', async () => { From 2f564834cfef8d98d40cf35261101896e7b13f0c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:52:39 +0000 Subject: [PATCH 30/70] drop LANE-NOTES --- LANE-NOTES.md | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 6e7ee2374..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,37 +0,0 @@ -# Lane W6 notes - -## Files and behavior - -- Added `packages/workbench/src/shell/wire-text.ts` and rewired both Raw Logs and Trace clients to share control-character and path-like-text validation. -- Raw Logs now accepts slash-bearing relative identities in summaries/details while continuing to reject absolute POSIX, home-relative, drive-letter, UNC, and `file:` paths. -- Moved `mcpCorrelationMetaKey` to `packages/agent-bundle/src/contracts/mcp-session.ts`; the dev trace publisher, Workbench controller, and remote transport now import that source. -- Added optional bounded `correlationId` fields to runtime MCP and MCP App tool-call contracts, then preserved the field through Workbench request canonicalization, runtime/App route decoders, preview execution, and runtime binding execution. -- Added focused coverage in LogClient, Workbench MCP controller/client/transport, runtime MCP routes, MCP App routes, preview service, binding service, and trace publisher tests. - -## Cross-lane requests - -- The integration lane should add the PR's single package changeset; proposed line: `Propagate Workbench correlation IDs through runtime MCP App tool calls and accept safe slash-bearing Raw Logs text. (#600)` -- `DevRuntimeMcpOperationRequest.correlationId` now reaches the provider-owned `RuntimeMcpExecutionContext.request`. This checkout has no package-owned adapter that turns that runtime operation into MCP `tools/call` params; a provider that emits an MCP frame must stamp it as `params._meta[mcpCorrelationMetaKey]`. - -## Open risks - -- `contracts/mcp-session.ts` is a browser-safe internal contract consumed by Workbench through the repository's existing source import pattern; `package.json` does not expose a separate `./mcp-session` npm subpath. -- No hand-written English or Chinese documentation page lists this internal contract constant, so no website files were changed. - -## Verification - -- `pnpm build` -- `npx tsc --noEmit` -- `npx tsc --project packages/workbench/tsconfig.json --noEmit` -- `pnpm lint` -- Affected unit files under `rstest.unit.config.ts`: 183 tests passed. -- Full `rstest.route-unit.config.ts` pool: 85 tests passed. -- `packages/workbench/tests/logs-real.e2e.test.ts`: passed. -- `packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts`: passed. -- Dead-module check: `wire-text` has two production importers (`log-client.ts`, `trace-client.ts`). -- Deslop: GPT-5.6 Sol, 0 follow-up edits. - -## Diagnostic codes - -- Existing decoder and runtime validation codes remain unchanged: `AB8093`, `AB8015`, and `AB8203`. -- No diagnostic codes were added or removed. From 5de5f1c2af416b74eb7dc304ba64f41a5286b528 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:11:21 +0000 Subject: [PATCH 31/70] feat: execute routes from compiled artifacts --- LANE-NOTES.md | 115 ++++ docs/diagnostics.md | 1 + .../src/adapters/hook-contract.ts | 32 +- .../agent-bundle/src/build/entry-shell.ts | 86 ++- packages/agent-bundle/src/cli-entry.ts | 52 +- .../src/dev/routes/route-invocation-child.ts | 11 +- .../dev/routes/route-invocation-production.ts | 548 ++++++++++++++++++ .../src/dev/routes/route-invocation-result.ts | 3 + .../dev/routes/route-invocation-service.ts | 50 +- .../src/dev/routes/route-invocation.ts | 2 + .../agent-bundle/src/dev/workbench-server.ts | 1 + packages/agent-bundle/src/test/manifest.ts | 4 + packages/agent-bundle/src/test/render.ts | 27 +- .../agent-bundle/tests/entry-shell.test.ts | 21 +- .../tests/route-invocation-dev-server.test.ts | 203 ++++++- .../tests/route-invocation-service.test.ts | 31 +- .../src/application/invocation-client.ts | 35 ++ .../workbench/tests/invocation-client.test.ts | 14 +- .../tests/support/workbench-acceptance.ts | 9 +- .../docs/en/guide/development/workbench.mdx | 11 +- .../docs/zh/guide/development/workbench.mdx | 8 +- 21 files changed, 1156 insertions(+), 108 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-production.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..ce102ff10 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,115 @@ +# Lane A2 — P1-B production execution boundary + +## Files + +- Added `packages/agent-bundle/src/dev/routes/route-invocation-production.ts`. +- Updated the route-invocation child/service/result contracts, HTTP request parser, prepared + project state root, and test manifest event identity. +- Shared generated CLI input preparation in `src/cli-entry.ts` and generated bins. +- Exposed compiled event preparation from generated hook wrappers. +- Added opt-in provider/handler/render observations to generated Flight workers. +- Updated route-invocation, generated-entry, Workbench decoder, and acceptance tests. +- Updated `docs/diagnostics.md` and the English/Chinese Workbench guides. + +## Behavior + +- An absent invocation `mode` now means `production`. The child imports the published epoch's + compiled modules and workers. `mode: "unit-render"` retains the live-source Jiti route-unit + renderer and its disposable state. +- CLI invocations import the compiled bin's `prepareRouteInvocation`, which delegates argv, + confirmation, defaults, `mapInput`, and schema validation to the same exported + `cli-entry.ts` helpers used by the generated bin. +- Event invocations import the compiled hook wrapper's `prepareRouteInvocation`. All wrappers + provide native-envelope validation and canonical props; preflight wrappers additionally run + the real gate and create the real `EventTracer`. `continue` and `deny` return before a render + worker is started. +- Production rendering dispatches to the epoch's generated Flight worker. The worker mounts the + generated request scope, selected providers, and generated runtime state. The child sets + `AGENT_BUNDLE_PLUGIN_ROOT` from `request.stateRoot`, so workspace-durable sqlite state persists + across invocations while volatile state keeps the generated in-memory behavior. +- Observation is opt-in on the worker message and records actual provider outcomes/durations, + aggregate provider duration, handler duration, and render duration. Event traces receive + provider/render phase boundaries in worker execution order. +- Full invocation responses may carry event trace events. Invocation summaries deliberately + omit them; the Workbench strict decoder accepts and validates the full trace field. + +## Generated-entry path parity + +- Hook wrapper: the child imports the generated hook wrapper's shared + `prepareRouteInvocation`, then dispatches to the generated MCP/hooks Flight worker. It skips + stdin byte framing, executor process spawning, signal forwarding, and stdout writing; native + response projection remains the shared `events/projection.ts` path in the invocation service. +- CLI bin: the child imports the generated bin's `prepareRouteInvocation` and dispatches to its + generated sibling Flight worker. It skips command-tree selection, terminal probing, output + formatting, and process exit-code handling after the selected route and argv are known. +- MCP server: the child dispatches through `createAgentRenderDispatcher` to the generated MCP + Flight worker and projects tools with `documentToCallToolResult`. It skips JSON-RPC transport, + MCP initialization, and SDK request framing; provider selection, request scope, state, route + module, layouts, and Flight rendering are the same compiled worker bytes. +- Rendered script: the child dispatches to the generated script `-flight.mjs` worker. It skips + the generated CLI process envelope and stdout formatting; route scope, state, layouts, and + rendering are the same worker bytes. +- There is no interim Jiti production path. Jiti remains only in explicit `unit-render` mode. + +## Exported API / contract changes + +- `RouteInvocationRequest.mode?: "production" | "unit-render"` is accepted on the HTTP wire. +- `RouteInvocationPreparedProject.stateRoot: string` and the child request's artifact, event + target, state root, and mode fields were added. +- `RouteInvocationChildResult.observed` carries measured providers and timings. +- `RouteInvocationChildResult.trace` and full `RouteInvocation.trace` carry event-kernel events. +- `parseGeneratedCliArgv`, `mapGeneratedCliInput`, and their supporting public types are exported + from `agent-bundle/cli-entry` for generated bins and the production boundary. +- Generated hook and CLI artifact modules export `prepareRouteInvocation`. + +## Cross-lane requests + +- A1: after merging `dev/routes/route-module-loader.ts`, rewire only the `unit-render` branch in + `route-invocation-child.ts` to that loader; keep production on + `route-invocation-production.ts`. +- A3: reconcile the temporary `RouteInvocationPreparedProject.stateRoot` field and + `join(prepared.root, ".agent-bundle", "state")` fill in + `src/dev/workbench-server.ts` with the epoch-lease implementation. Preserve the artifact root, + generated artifact epoch token, and state root passed to the child. +- A4: consume `child.observed` in the result assembly and remove fabricated zero-duration + provider/handler telemetry as planned. Preserve `trace` propagation, the + `invocationSummary()` trace omission, and the trace schema in + `packages/workbench/src/application/invocation-client.ts` while merging provider-status + decoder changes. + +## Open risks + +- Worker ownership is discovered from the published artifact's generated `*-flight.mjs` files; + `AB8251` is returned when no compiled worker owns the selected route. +- Production intentionally bypasses host transports after route selection. Transport-level MCP + handshake behavior, CLI formatting, and hook stdin/process behavior remain covered by their + existing generated-entry tests rather than being repeated by the Workbench invocation. +- The TraceDecay MCP and CLI daemon were unavailable during final review. The full diff was + manually deslop-reviewed; repository lint, type checks, tests, and dead-module checks passed. + +## Verification + +- PASS: `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint` +- PASS: focused unit pool (76 tests), including entry-shell, CLI projection, + route-invocation service, and Workbench invocation decoder coverage. +- PASS: `rstest.integration.config.ts packages/agent-bundle/tests/route-invocation-dev-server.test.ts` + (2 tests). +- PASS: generated CLI and hook integration files (52 tests). +- PASS: `rstest.integration.config.ts packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` + (1 browser acceptance test at the repository's 1440×900 viewport). +- PASS: `pnpm docs:site:build`. +- PASS: `git diff --check`. +- PASS: dead-module check; `route-invocation-production` has the production importer + `route-invocation-child.ts`. + +## Proposed changeset + +Patch `agent-bundle`: Execute Workbench route invocations through published generated artifacts +by default, including CLI projection, event preflight, persistent state, and measured runtime +telemetry. (#PR) + +## Diagnostic codes + +- `AB8250`: no published compiler artifact is available. +- `AB8251`: the selected route has no executable in the published artifact. +- `AB8252`: compiled CLI projection or event preparation failed. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..24ad72f8d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -45,6 +45,7 @@ even when no error diagnostic was reported. | `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. | | `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.) | +| `AB8250`–`AB8252` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, and `AB8252` compiled CLI projection or event preflight preparation failed. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`. | | `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 741f40f61..59e8380f8 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -668,7 +668,8 @@ const eventRouteHookWrapperSource = ( // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; const projectBindings = [ - ...(standalone ? ['createCanonicalEventProps', 'projectEventDocument'] : []), + 'createCanonicalEventProps', + ...(standalone ? ['projectEventDocument'] : []), 'validateNativeEventEnvelope', ]; return [ @@ -706,6 +707,11 @@ const eventRouteHookWrapperSource = ( "const endpointId = `${artifactEpoch}:${dirname(dirname(resolve(process.argv[1])))}`;", '', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', + 'export const prepareRouteInvocation = (nativeInput, signal) => {', + ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', + ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ' return Object.freeze({ gate: "execute", native, props, runtime: runtimeMode });', + '};', ...(standalone ? [ // The wrapper lives in `hooks/`, so its artifact root is the parent @@ -892,6 +898,19 @@ const eventRoutePreflightWrapperSource = ( `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, `const executor = fileURLToPath(new URL(/* webpackIgnore: true */ ${JSON.stringify(`./${executorFile}`)}, import.meta.url));`, 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', + 'export const prepareRouteInvocation = async (nativeInput, signal, observer) => {', + ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', + ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', + ' const trace = createEventTracer({ execution: eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent }), ...(observer === undefined ? {} : { observer }) });', + ' const gate = await executeEventPreflight(preflight, {', + ' canonical: props.canonical,', + ' host: { name: target, nativeEvent },', + ' signal,', + ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', + ' }, trace);', + ' const projected = gate === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' return Object.freeze({ gate, native, projected, props, runtime: runtimeMode, trace });', + '};', 'const runExecutor = (input, signal) => new Promise((resolve, reject) => {', ' const child = spawn(process.execPath, [executor], { signal, stdio: ["pipe", "pipe", "pipe"] });', ' const stdout = [];', @@ -919,19 +938,10 @@ const eventRoutePreflightWrapperSource = ( ' const input = Buffer.concat(chunks);', ' let parsed;', ' try { parsed = JSON.parse(input.toString("utf8")); } catch { fail("stdin must contain exactly one JSON value"); }', - ' const native = validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target });', ' const controller = new AbortController();', ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', - ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', - ' const trace = createEventTracer({ execution: eventTraceExecution({ event: canonicalEvent, host: target, nativeEvent }) });', - ' const gate = await executeEventPreflight(preflight, {', - ' canonical: props.canonical,', - ' host: { name: target, nativeEvent },', - ' signal,', - ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', - ' }, trace);', + ' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal);', ' if (gate !== "execute") {', - ' const projected = projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', ' return;', ' }', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index f2d513adb..087ccfea5 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -419,7 +419,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): // module-level `process.env` read sees the composed environment. The // npm package bin runs from the operator's own shell and reads none. ...(stateFallback === 'artifact' ? [operatorEnvLayerImport] : []), - `import { CliInputError, cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + `import { mapGeneratedCliInput, parseGeneratedCliArgv, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, ...(options.web === undefined ? [] : [ @@ -456,26 +456,13 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): '', `const commands = Object.freeze(${stableJson(options.commands)});`, '', - 'const parseInput = (command, route, input) => {', - ' let mapped = { ...input };', - ' if (command.projection?.defaults !== undefined) {', - ' for (const [key, value] of Object.entries(command.projection.defaults)) {', - ' if (!Object.hasOwn(mapped, key)) mapped[key] = value;', - ' }', - ' }', - ' if (command.projection?.mapInput === true) {', - " if (typeof route.projection?.mapInput !== 'function') throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`);", - ' try {', - ' mapped = route.projection.mapInput(mapped);', - ' } catch (error) {', - ' throw new CliInputError(error instanceof Error ? error.message : String(error));', - ' }', - ' }', - ' try {', - ' return route.module.inputSchema.parse(mapped);', - ' } catch (error) {', - ' throw cliInputError(command, mapped, error);', - ' }', + 'const parseInput = (command, route, input) => mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input);', + 'export const prepareRouteInvocation = (routeId, argv) => {', + ' const command = commands.find((candidate) => candidate.routeId === routeId);', + " if (command === undefined) throw new TypeError(`Generated CLI route ${JSON.stringify(routeId)} is not available.`);", + ' const route = routes[routeId];', + " if (route === undefined) throw new TypeError(`Generated CLI route ${JSON.stringify(routeId)} has no compiled module.`);", + ' return parseInput(command, route, parseGeneratedCliArgv(command, argv).input);', '};', '', // Plain commands mount the same conventional providers as every other @@ -543,6 +530,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): '', ] : []), + 'if (import.meta.main) {', ...(options.state === undefined ? [] : ['try {']), `${options.state === undefined ? '' : ' '}await runGeneratedCliProcess({`, ' commands,', @@ -568,6 +556,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): ...(options.state === undefined ? [] : ['} finally {', ' await runtimeState.close();', '}']), + '}', '', ].join('\n'); }; @@ -695,6 +684,10 @@ export const generatedRenderedRouteWorkerSource = ( 'const render = async (message) => {', ' const route = routes[message.routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated rendered route must default-export an async function component.');", + ' const observedRoute = message.observe !== true ? route : { ...route, module: { ...route.module, default: async (props) => {', + ' const handlerStartedAt = performance.now();', + " try { return await route.module.default(props); } finally { parentPort.postMessage({ durationMs: performance.now() - handlerStartedAt, id: message.id, type: 'observed-handler' }); }", + ' } } };', ' const controller = new AbortController();', ' requests.set(message.id, controller);', ...processHitSource(' '), @@ -717,7 +710,7 @@ export const generatedRenderedRouteWorkerSource = ( ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' plugin: pluginRoot.identity,', " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", - ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation' }), + ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation', observe: 'message.observe === true' }), ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), // The executable probed its terminal once and forwards the value; a worker @@ -725,7 +718,9 @@ export const generatedRenderedRouteWorkerSource = ( " terminal: message.terminal === undefined ? unavailable('not-provided') : available(message.terminal, 'native'),", " workspace: available({ root: cwd }, 'derived'),", ' }, async () => {', - ' const flight = renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal), { signal: controller.signal });', + " if (message.observe === true) parentPort.postMessage({ id: message.id, type: 'observed-render-start' });", + ' const renderStartedAt = performance.now();', + ' const flight = renderAgentFlight(composeLayouts(observedRoute, { ...message.props, signal: controller.signal }, controller.signal), { signal: controller.signal });', ' const reader = flight.getReader();', ' while (true) {', ' const next = await reader.read();', @@ -733,6 +728,7 @@ export const generatedRenderedRouteWorkerSource = ( ' const bytes = next.value;', " parentPort.postMessage({ bytes, id: message.id, type: 'chunk' }, [bytes.buffer]);", ' }', + " if (message.observe === true) parentPort.postMessage({ durationMs: performance.now() - renderStartedAt, id: message.id, type: 'observed-render-finish' });", ' });', ...(options.state === undefined ? [] @@ -1010,24 +1006,51 @@ const providersFieldSource = ( expressions: { readonly indent: string; readonly invocation: string; + readonly observe?: string; readonly providers?: string; }, ): readonly string[] => { - const { indent, invocation, providers: providerExpression = 'providers' } = expressions; - if (providers.length === 0) return [`${indent}providers: { processLifetime: ${processLifetimeValueSource} },`]; + const { indent, invocation, observe, providers: providerExpression = 'providers' } = expressions; + if (providers.length === 0) { + if (observe === undefined) return [`${indent}providers: { processLifetime: ${processLifetimeValueSource} },`]; + return [ + `${indent}providers: async () => {`, + `${indent} const providersStartedAt = performance.now();`, + `${indent} if (${observe}) parentPort.postMessage({ id: message.id, type: 'observed-providers-start' });`, + `${indent} if (${observe}) parentPort.postMessage({ count: 0, durationMs: performance.now() - providersStartedAt, id: message.id, type: 'observed-providers-finish' });`, + `${indent} return { processLifetime: ${processLifetimeValueSource} };`, + `${indent}},`, + ]; + } return [ `${indent}providers: async (request) => {`, `${indent} const providerValues = { processLifetime: ${processLifetimeValueSource} };`, + ...(observe === undefined + ? [] + : [ + `${indent} const providersStartedAt = performance.now();`, + `${indent} if (${observe}) parentPort.postMessage({ id: message.id, type: 'observed-providers-start' });`, + ]), `${indent} for (const provider of ${providerExpression}) {`, + ...(observe === undefined ? [] : [`${indent} const providerStartedAt = performance.now();`]), `${indent} if (typeof provider.module.default !== 'function') {`, `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, `${indent} }`, `${indent} try {`, `${indent} providerValues[provider.key] = await provider.module.default({ ...request, invocation: ${invocation} });`, + ...(observe === undefined + ? [] + : [`${indent} if (${observe}) parentPort.postMessage({ durationMs: performance.now() - providerStartedAt, id: message.id, key: provider.key, source: provider.source, status: 'mounted', type: 'observed-provider' });`]), `${indent} } catch (error) {`, + ...(observe === undefined + ? [] + : [`${indent} if (${observe}) parentPort.postMessage({ durationMs: performance.now() - providerStartedAt, id: message.id, key: provider.key, message: error instanceof Error ? error.message : String(error), source: provider.source, status: 'failed', type: 'observed-provider' });`]), `${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`, `${indent} }`, `${indent} }`, + ...(observe === undefined + ? [] + : [`${indent} if (${observe}) parentPort.postMessage({ count: Object.keys(providerValues).length - 1, durationMs: performance.now() - providersStartedAt, id: message.id, type: 'observed-providers-finish' });`]), `${indent} return providerValues;`, `${indent}},`, ]; @@ -1100,6 +1123,10 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " const routeId = message.invocation.kind === 'event' ? `hook:event-route:${message.invocation.props.event.replace('/', '-')}` : message.invocation.props.operationId;", ' const route = routes[routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated route must default-export an async Server Component.');", + ' const observedRoute = message.observe !== true ? route : { ...route, module: { ...route.module, default: async (props) => {', + ' const handlerStartedAt = performance.now();', + " try { return await route.module.default(props); } finally { parentPort.postMessage({ durationMs: performance.now() - handlerStartedAt, id: message.id, type: 'observed-handler' }); }", + ' } } };', ' const controller = new AbortController();', ' requests.set(message.id, controller);', ...processHitSource(' '), @@ -1119,6 +1146,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation', + observe: 'message.observe === true', ...(hasProviderSelections ? { providers: 'route.providers ?? providers' } : {}), }), ' ...(message.session === undefined ? {} : { session: message.session }),', @@ -1132,8 +1160,12 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " const props = message.invocation.kind === 'event'", ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', ' : { input: message.invocation.props.input, signal: controller.signal };', - ' const flight = renderAgentFlight(composeLayouts(route, props, controller.signal), { signal: controller.signal });', - ' return new Uint8Array(await new Response(flight).arrayBuffer());', + " if (message.observe === true) parentPort.postMessage({ id: message.id, type: 'observed-render-start' });", + ' const renderStartedAt = performance.now();', + ' const flight = renderAgentFlight(composeLayouts(observedRoute, props, controller.signal), { signal: controller.signal });', + ' const renderedBytes = new Uint8Array(await new Response(flight).arrayBuffer());', + " if (message.observe === true) parentPort.postMessage({ durationMs: performance.now() - renderStartedAt, id: message.id, type: 'observed-render-finish' });", + ' return renderedBytes;', ' });', ' parentPort.postMessage({ bytes, id: message.id, type: \'complete\' }, [bytes.buffer]);', ...(options.state === undefined diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index 3ce16b54f..ce0c6bc05 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -514,7 +514,7 @@ const treeHelp = ( return `${lines.join('\n')}\n`; }; -interface ParsedArgv { +export interface ParsedGeneratedCliArgv { readonly input: Readonly>; readonly json: boolean; readonly ndjson: boolean; @@ -573,7 +573,7 @@ const coercePositional = (option: CompiledCliOption, value: string): unknown => }; /** Parses one resolved command's remaining argv against its compiled option surface. */ -const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): ParsedArgv => { +const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): ParsedGeneratedCliArgv => { const options = new Map(); for (const option of namedOptions(command)) { options.set(option.option, option); @@ -673,8 +673,8 @@ const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): const parseMcpCommandInput = ( command: CompiledCliCommand, - parsed: ParsedArgv, -): ParsedArgv => { + parsed: ParsedGeneratedCliArgv, +): ParsedGeneratedCliArgv => { if (command.mcp === undefined) return parsed; if (command.mcp.confirm && parsed.input['yes'] !== true) { throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); @@ -700,6 +700,46 @@ const parseMcpCommandInput = ( return { ...parsed, input: input as Readonly> }; }; +/** Parses argv and applies projected-tool confirmation exactly as the generated CLI shell does. */ +export const parseGeneratedCliArgv = ( + command: CompiledCliCommand, + argv: readonly string[], +): ParsedGeneratedCliArgv => parseMcpCommandInput(command, parseCommandArgv(command, argv)); + +export interface GeneratedCliInputSchema { + parse(input: unknown): unknown; +} + +/** Applies projection defaults, `mapInput`, and the route schema at the generated CLI boundary. */ +export const mapGeneratedCliInput = ( + command: CompiledCliCommand, + inputSchema: GeneratedCliInputSchema, + projectionModule: Readonly> | undefined, + input: Readonly>, +): unknown => { + const withDefaults: Record = { ...input }; + for (const [key, value] of Object.entries(command.projection?.defaults ?? {})) { + if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; + } + let mapped: unknown = withDefaults; + if (command.projection?.mapInput === true) { + const mapInput = projectionModule?.['mapInput']; + if (typeof mapInput !== 'function') { + throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); + } + try { + mapped = mapInput(withDefaults); + } catch (error) { + throw new CliInputError(error instanceof Error ? error.message : String(error)); + } + } + try { + return inputSchema.parse(mapped); + } catch (error) { + throw cliInputError(command, mapped, error); + } +}; + const resultExitCode = (policy: 'result' | 'zero', result: unknown): number => { if (policy === 'zero') return 0; const exitCode = typeof result === 'object' && result !== null @@ -913,7 +953,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro let node = tree; let index = 0; - let parsed: ParsedArgv | undefined; + let parsed: ParsedGeneratedCliArgv | undefined; const web = options.web !== undefined; try { if (options.argv[0] === '--version') { @@ -960,7 +1000,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro writeOut(commandHelp(options.name, command)); return 0; } - parsed = parseMcpCommandInput(command, parseCommandArgv(command, rest)); + parsed = parseGeneratedCliArgv(command, rest); signal.throwIfAborted(); // Probed once: the same value selects the output mode and reaches the // route as `request.terminal`, so the two can never disagree. 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 85d4f8f9e..8367fddb7 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -20,6 +20,7 @@ import type { RouteInvocationChildResponse, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { renderProductionRoute } from './route-invocation-production.ts'; /** * Classic JSX runtime, as in the playground's lifecycle render child: the @@ -105,7 +106,7 @@ const respond = (response: RouteInvocationChildResponse): Promise => new P }); }); -const render = async (request: RouteInvocationChildRequest): Promise => { +const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise => { installManifest(request); const startedAt = performance.now(); const input = request.input; @@ -138,11 +139,19 @@ const render = async (request: RouteInvocationChildRequest): Promise => + request.mode === 'unit-render' + ? renderUnitRoute(request) + : renderProductionRoute(request); + process.once('message', (request: RouteInvocationChildRequest) => { void render(request) .then((result) => respond({ result, type: 'result' })) .catch((error: unknown) => respond({ error: { + ...(typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ? { code: error.code } + : {}), message: error instanceof Error ? error.message : String(error), name: error instanceof Error ? error.name : '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 new file mode 100644 index 000000000..fee2bda41 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -0,0 +1,548 @@ +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { + AGENT_DOCUMENT_VERSION, + createAgentDocument, + createAgentRenderDispatcher, + documentToCallToolResult, + type AgentDocument, + type AgentRenderEvent, + type AgentRenderInvocation, +} from '@agent-bundle/runtime'; + +import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; +import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; +import { + ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + type RouteInvocationChildRequest, + type RouteInvocationChildResult, +} from './route-invocation-service.ts'; +import type { RouteInvocationProvider, RouteInvocationTiming } from './route-invocation.ts'; + +interface CompiledCliInvocationModule { + prepareRouteInvocation(routeId: string, argv: readonly string[]): unknown; +} + +interface CompiledEventPreflight { + readonly gate: 'execute' | Readonly<{ readonly outcome: 'continue' | 'deny'; readonly reason?: string }>; + readonly native: JsonObject; + readonly projected?: JsonObject; + readonly props: Readonly<{ readonly canonical: JsonObject }>; + readonly runtime: 'shared' | 'standalone'; + readonly trace?: EventTracer; +} + +interface CompiledEventWrapperModule { + prepareRouteInvocation?( + native: JsonObject, + signal: AbortSignal, + observer: EventTraceObserver, + ): Promise; +} + +interface WorkerMessage { + readonly bytes?: Uint8Array; + readonly count?: number; + readonly durationMs?: number; + readonly id: number; + readonly key?: string; + readonly message?: string; + readonly source?: string; + readonly status?: 'failed' | 'mounted'; + readonly type: + | 'chunk' + | 'complete' + | 'end' + | 'error' + | 'observed-handler' + | 'observed-provider' + | 'observed-providers-finish' + | 'observed-providers-start' + | 'observed-render-finish' + | 'observed-render-start' + | 'progress'; + readonly update?: unknown; +} + +type ProductionRequest = RouteInvocationChildRequest & Readonly<{ + readonly artifactEpoch: string; + readonly artifactRoot: string; +}>; + +type ProductionRouteInvocationCode = + | typeof ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; + +class ProductionRouteInvocationError extends Error { + readonly code: ProductionRouteInvocationCode; + + constructor(code: ProductionRouteInvocationCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ProductionRouteInvocationError'; + this.code = code; + } +} + +const preparationFailure = (error: unknown): ProductionRouteInvocationError => + error instanceof ProductionRouteInvocationError + ? error + : new ProductionRouteInvocationError( + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + `Unable to prepare the compiled route invocation: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + +const importedModule = async (path: string): Promise => + // Artifact modules are runtime-selected compiler output; a static import cannot name the active epoch. + import(pathToFileURL(path).href) as Promise; + +const completeDocument = (value: JsonValue | undefined): AgentDocument => createAgentDocument({ + root: { + children: value === undefined ? [] : [{ kind: 'json', value }], + kind: 'result', + }, + status: 'success', + ...(value === undefined ? {} : { value }), + version: AGENT_DOCUMENT_VERSION, +}); + +const workerFiles = async (root: string): Promise => { + if (!existsSync(root)) return Object.freeze([]); + return Object.freeze((await readdir(root)) + .filter((name) => name.endsWith('-flight.mjs')) + .sort() + .map((name) => join(root, name))); +}; + +const eventWrapperPath = ( + request: ProductionRequest, +): string | undefined => { + const event = request.manifest.routes[request.routeId]?.event; + const target = request.eventTarget; + if (event === undefined || target === undefined) return undefined; + const stem = `event-route-${event.replace('/', '-')}`; + const suffixed = join(request.artifactRoot, 'hooks', `${stem}.${target}.mjs`); + if (existsSync(suffixed)) return suffixed; + const plain = join(request.artifactRoot, 'hooks', `${stem}.mjs`); + return existsSync(plain) ? plain : undefined; +}; + +const prepareInput = async ( + request: ProductionRequest, + traceEvents: EventTraceEvent[], + signal: AbortSignal, +): Promise> => { + const route = request.manifest.routes[request.routeId]; + const cliCommand = request.args === undefined + ? undefined + : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); + if (route?.kind === 'cli' || cliCommand !== undefined) { + const binRoot = join(request.artifactRoot, 'bin'); + if (!existsSync(binRoot)) { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `Compiled CLI route ${JSON.stringify(request.routeId)} has no invocation entry.`, + ); + } + const bins = (await readdir(binRoot)) + .filter((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')) + .sort(); + for (const name of bins) { + const module = await importedModule>(join(binRoot, name)); + if (typeof module.prepareRouteInvocation !== 'function') continue; + return { + input: module.prepareRouteInvocation(request.routeId, request.args ?? []) as JsonValue, + }; + } + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `Compiled CLI route ${JSON.stringify(request.routeId)} has no invocation entry.`, + ); + } + if (route?.kind !== 'event-route') return { input: request.input }; + const wrapperPath = eventWrapperPath(request); + if (wrapperPath === undefined) return { input: request.input }; + 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)); + return { + input: { canonical: preflight.props.canonical, native: preflight.native }, + preflight, + }; +}; + +const invocationFor = ( + request: ProductionRequest, + input: JsonValue, +): AgentRenderInvocation => { + const route = request.manifest.routes[request.routeId]; + if (route === undefined) throw new Error(`Route ${JSON.stringify(request.routeId)} is absent from the compiled manifest.`); + const cliCommand = request.args === undefined + ? undefined + : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); + if (cliCommand !== undefined) { + return { kind: 'cli', props: { args: request.args ?? [], command: cliCommand.path.join(' ') } }; + } + switch (route.kind) { + case 'cli': { + const command = request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); + if (command === undefined) throw new Error(`CLI route ${JSON.stringify(request.routeId)} has no compiled command.`); + return { kind: 'cli', props: { args: request.args ?? [], command: command.path.join(' ') } }; + } + case 'script': { + const script = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId); + return { kind: 'script', props: { input: request.args ?? [], name: script?.name ?? request.routeId } }; + } + case 'event-route': + return { + kind: 'event', + props: { + event: route.event!, + payload: input as never, + }, + }; + case 'prompt': + case 'resource': + case 'tool': + return { kind: 'tool', props: { input: input as never, operationId: request.routeId } }; + case 'app': + throw new Error('MCP App routes are not invocable through the route execution boundary.'); + default: { + const exhaustive: never = route.kind; + throw new Error(`Unsupported route kind ${String(exhaustive)}.`); + } + } +}; + +const candidatesFor = async (request: ProductionRequest): Promise => { + const route = request.manifest.routes[request.routeId]; + if (route === undefined) return Object.freeze([]); + if ( + request.args !== undefined + && request.manifest.cliCommands.some((candidate) => candidate.routeId === request.routeId) + ) { + return workerFiles(join(request.artifactRoot, 'bin')); + } + switch (route.kind) { + case 'cli': + return workerFiles(join(request.artifactRoot, 'bin')); + case 'script': { + const name = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId)?.name; + return name === undefined + ? Object.freeze([]) + : Object.freeze([join(request.artifactRoot, 'scripts', `${name}-flight.mjs`)]); + } + case 'event-route': + return Object.freeze([ + ...await workerFiles(join(request.artifactRoot, 'mcp')), + join(request.artifactRoot, 'hooks', 'hooks-flight.mjs'), + ].filter(existsSync)); + case 'prompt': + case 'resource': + case 'tool': + return workerFiles(join(request.artifactRoot, 'mcp')); + case 'app': + return Object.freeze([]); + default: { + const exhaustive: never = route.kind; + throw new Error(`Unsupported route kind ${String(exhaustive)}.`); + } + } +}; + +const streamFromWorker = ( + workerPath: string, + request: ProductionRequest, + invocation: AgentRenderInvocation, + input: JsonValue, + signal: AbortSignal, + trace?: EventTracer, +): Readonly<{ + readonly close: () => Promise; + readonly events: ReadableStream; + readonly observed: { + readonly providers: readonly RouteInvocationProvider[]; + readonly timings: readonly RouteInvocationTiming[]; + }; +}> => { + const worker = new Worker(pathToFileURL(workerPath), { + env: { + ...process.env, + AGENT_BUNDLE_PLUGIN_ROOT: dirname(request.stateRoot), + }, + stderr: true, + stdout: true, + }); + worker.stdout?.on('data', (chunk) => process.stderr.write(chunk)); + worker.stderr?.on('data', (chunk) => process.stderr.write(chunk)); + let sequence = 0; + const providers: RouteInvocationProvider[] = []; + const timings: RouteInvocationTiming[] = []; + const pending = new Map void; + readonly controller: ReadableStreamDefaultController; + readonly dispatchSignal: AbortSignal; + }>(); + const failAll = (error: Error): void => { + for (const [id, entry] of pending) { + pending.delete(id); + entry.dispatchSignal.removeEventListener('abort', entry.abort); + entry.controller.error(error); + } + }; + worker.on('error', failAll); + worker.on('exit', (code) => { + if (pending.size > 0) failAll(new Error(`Compiled route worker exited with code ${String(code)}.`)); + }); + worker.on('message', (message: WorkerMessage) => { + const entry = pending.get(message.id); + if (entry === undefined) return; + if (message.type === 'progress') return; + if (message.type === 'observed-providers-start') { + trace?.providersStart(); + return; + } + if (message.type === 'observed-providers-finish') { + trace?.providersFinish(message.count ?? 0); + if (message.durationMs !== undefined) { + timings.push(Object.freeze({ + durationMs: message.durationMs, + phase: 'providers', + startedAt: new Date(Date.now() - message.durationMs).toISOString(), + })); + } + return; + } + if (message.type === 'observed-render-start') { + trace?.renderStart(); + return; + } + if (message.type === 'observed-provider' && message.key !== undefined && message.status !== undefined) { + const provider = request.manifest.providers?.find((candidate) => + candidate.key === message.key || candidate.relativePath === message.source); + if (provider !== undefined) { + providers.push(Object.freeze({ + ...(message.durationMs === undefined ? {} : { durationMs: message.durationMs }), + id: provider.id, + ...(message.message === undefined ? {} : { message: message.message }), + name: provider.name, + status: message.status, + })); + if (message.durationMs !== undefined) { + timings.push(Object.freeze({ + durationMs: message.durationMs, + phase: `provider:${provider.name}`, + startedAt: new Date(Date.now() - message.durationMs).toISOString(), + })); + } + } + return; + } + if ( + (message.type === 'observed-handler' || message.type === 'observed-render-finish') + && message.durationMs !== undefined + ) { + if (message.type === 'observed-render-finish') trace?.renderFinish(); + timings.push(Object.freeze({ + durationMs: message.durationMs, + phase: message.type === 'observed-handler' ? 'handler' : 'render', + startedAt: new Date(Date.now() - message.durationMs).toISOString(), + })); + return; + } + if (message.type === 'chunk' && message.bytes !== undefined) { + entry.controller.enqueue(message.bytes); + return; + } + pending.delete(message.id); + entry.dispatchSignal.removeEventListener('abort', entry.abort); + if (message.type === 'complete' && message.bytes !== undefined) { + entry.controller.enqueue(message.bytes); + entry.controller.close(); + return; + } + if (message.type === 'end') { + entry.controller.close(); + return; + } + entry.controller.error(new Error(message.message ?? 'Compiled route worker failed.')); + }); + const host = Object.freeze({ + execute: async (dispatch: Readonly<{ + readonly invocation: AgentRenderInvocation; + readonly signal: AbortSignal; + }>): Promise> => { + const id = ++sequence; + let controller!: ReadableStreamDefaultController; + const stream = new ReadableStream({ start: (opened) => { controller = opened; } }); + const abort = (): void => { + worker.postMessage({ id, type: 'cancel' }); + controller.error(new DOMException('Agent render was aborted.', 'AbortError')); + }; + pending.set(id, { abort, controller, dispatchSignal: dispatch.signal }); + dispatch.signal.addEventListener('abort', abort, { once: true }); + worker.postMessage({ + actor: request.context.actor, + artifactEpoch: request.artifactEpoch, + host: request.context.host, + id, + invocation: dispatch.invocation, + lineage: request.context.lineage, + observe: true, + props: routeProps(request, input), + request: request.context.invocation, + requestInvocation: request.context.invocation, + routeId: request.routeId, + session: request.context.session, + terminal: { reason: 'not-provided', state: 'unavailable' }, + type: 'render', + workspace: request.context.workspace, + }); + return stream; + }, + }); + const dispatcher = createAgentRenderDispatcher(host); + return Object.freeze({ + close: async () => { await worker.terminate(); }, + events: dispatcher.stream({ artifactEpoch: request.artifactEpoch, invocation, signal }), + observed: { providers, timings }, + }); +}; + +const routeProps = (request: ProductionRequest, input: JsonValue): Readonly> => { + const kind = request.manifest.routes[request.routeId]?.kind; + if (kind === 'script') return { argv: request.args ?? [] }; + return kind === 'event-route' + ? { + canonical: (input as { readonly canonical?: unknown }).canonical, + native: (input as { readonly native?: unknown }).native, + } + : { input }; +}; + +const missingRouteWorkerError = (error: unknown): boolean => + error instanceof Error + && ( + error.message.includes('Generated route must default-export') + || error.message.includes('Generated rendered route must default-export') + ); + +const renderCompiled = async ( + request: ProductionRequest, + input: JsonValue, + signal: AbortSignal, + trace?: EventTracer, +): Promise> => { + const invocation = invocationFor(request, input); + const candidates = await candidatesFor(request); + for (const workerPath of candidates) { + const startedAt = performance.now(); + const session = streamFromWorker(workerPath, request, invocation, input, signal, trace); + const events: AgentRenderEvent[] = []; + try { + const reader = session.events.getReader(); + for (;;) { + const next = await reader.read(); + if (next.done) break; + events.push(next.value); + } + const complete = events.findLast((event) => event.type === 'complete'); + if (complete === undefined) throw new Error('Compiled route render ended without a complete event.'); + return Object.freeze({ + document: complete.document, + durationMs: performance.now() - startedAt, + events: Object.freeze(events), + observed: { + providers: Object.freeze([...session.observed.providers]), + timings: Object.freeze([...session.observed.timings]), + }, + }); + } catch (error) { + if (!missingRouteWorkerError(error)) throw error; + } finally { + await session.close(); + } + } + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, + `No compiled worker owns route ${JSON.stringify(request.routeId)}.`, + ); +}; + +export const renderProductionRoute = async ( + request: RouteInvocationChildRequest, +): Promise => { + if (request.artifactEpoch === undefined || request.artifactRoot === undefined) { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, + 'Production route invocation requires a published artifact.', + ); + } + const productionRequest = request as ProductionRequest; + const traceEvents: EventTraceEvent[] = []; + const controller = new AbortController(); + let prepared: Awaited>; + try { + prepared = await prepareInput(productionRequest, traceEvents, controller.signal); + } catch (error) { + throw preparationFailure(error); + } + if (prepared.preflight !== undefined && prepared.preflight.gate !== 'execute') { + const value = prepared.preflight.gate as JsonValue; + return Object.freeze({ + document: completeDocument(value), + events: Object.freeze([]), + input: prepared.input, + observed: { providers: Object.freeze([]), timings: Object.freeze([]) }, + renderDurationMs: 0, + result: value, + trace: Object.freeze(traceEvents), + }); + } + if (prepared.preflight !== undefined) { + prepared.preflight.trace?.executeStart(prepared.preflight.runtime); + } + try { + const rendered = await renderCompiled( + productionRequest, + prepared.input, + controller.signal, + prepared.preflight?.trace, + ); + const result = rendered.document.value; + return Object.freeze({ + document: rendered.document, + events: rendered.events, + input: prepared.input, + ...(request.manifest.routes[request.routeId]?.kind === 'tool' + ? { mcp: documentToCallToolResult(rendered.document, { structuredContent: result }) as JsonObject } + : {}), + observed: { + providers: rendered.observed.providers, + timings: rendered.observed.timings, + }, + renderDurationMs: rendered.durationMs, + ...(result === undefined ? {} : { result }), + trace: Object.freeze(traceEvents), + }); + } catch (error) { + prepared.preflight?.trace?.failure('render', error); + throw error; + } +}; 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 8d1386eda..b187dc489 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-result.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts @@ -2,6 +2,7 @@ import type { AgentDocument, AgentRenderEvent } from '@agent-bundle/runtime'; 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 { RouteInvocationProjection, RouteInvocationProvider, @@ -18,6 +19,8 @@ export interface RouteInvocation extends RouteInvocationSummary { readonly providers: readonly RouteInvocationProvider[]; /** The document value parsed by the route's own `resultSchema`; absent when the module exports none or rendering failed. */ readonly result?: JsonValue; + /** Event-kernel phase events emitted by a compiled preflight execution. */ + readonly trace?: readonly EventTraceEvent[]; } /** `GET /api/routes/invocations/` and `POST /api/routes/invocations`. */ 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 34ef96dc5..9279d27a9 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -28,6 +28,7 @@ import type { } 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 { 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'; @@ -48,6 +49,9 @@ export const ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE = 'AB8232'; export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; +export const ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE = 'AB8250'; +export const ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE = 'AB8251'; +export const ROUTE_INVOCATION_PREPARATION_FAILURE_CODE = 'AB8252'; const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; @@ -77,6 +81,7 @@ export interface RouteInvocationPreparedProject { readonly artifact?: Readonly<{ epochId: string; target: string }>; readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; + readonly stateRoot: string; readonly targets: readonly RouteInvocationEventHost[]; } @@ -101,10 +106,15 @@ export interface RouteInvocationServiceOptions { export interface RouteInvocationChildRequest { readonly args?: readonly string[]; + readonly artifactEpoch?: string; + readonly artifactRoot?: string; readonly context: RequestContextProvenance; + readonly eventTarget?: RouteInvocationEventHost; readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; + readonly mode?: 'production' | 'unit-render'; readonly routeId: string; + readonly stateRoot: string; } export interface RouteInvocationChildResult { @@ -114,14 +124,19 @@ export interface RouteInvocationChildResult { readonly input: JsonValue; /** Runtime-owned MCP projection, computed inside the runtime-bound child. */ readonly mcp?: JsonObject; + readonly observed?: { + readonly providers: readonly RouteInvocationProvider[]; + readonly timings: readonly RouteInvocationTiming[]; + }; readonly renderDurationMs: number; readonly result?: JsonValue; + readonly trace?: readonly EventTraceEvent[]; } export type RouteInvocationChildResponse = | Readonly<{ readonly result: RouteInvocationChildResult; readonly type: 'result' }> | Readonly<{ - readonly error: Readonly<{ readonly message: string; readonly name: string }>; + readonly error: Readonly<{ readonly code?: string; readonly message: string; readonly name: string }>; readonly type: 'error'; }>; @@ -174,12 +189,14 @@ const eventOptions = (value: unknown): RouteInvocationRequest['event'] => { export const parseRouteInvocationRequest = ( value: Readonly>, ): RouteInvocationRequest => { - if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'routeId'])) return malformed(); + if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'mode', 'routeId'])) return malformed(); const routeId = value.routeId; const correlationId = value.correlationId; const args = value.args; + const mode = value.mode; if (!boundedString(routeId)) return malformed(); if (correlationId !== undefined && !boundedString(correlationId, 256)) return malformed(); + if (mode !== undefined && mode !== 'production' && mode !== 'unit-render') return malformed(); if (args !== undefined && (!Array.isArray(args) || args.length > 1_024 || args.some((argument) => !boundedString(argument, 16_384)))) { return malformed(); } @@ -197,6 +214,7 @@ export const parseRouteInvocationRequest = ( ...(correlationId === undefined ? {} : { correlationId }), ...(event === undefined ? {} : { event }), ...(input === undefined ? {} : { input }), + ...(mode === undefined ? {} : { mode }), routeId, }); }; @@ -210,6 +228,7 @@ export const invocationSummary = (invocation: RouteInvocation): RouteInvocationS projection: _projection, providers: _providers, result: _result, + trace: _trace, ...summary } = invocation; return deepFreeze(summary); @@ -439,6 +458,7 @@ const renderInChild = async ( if (message.type === 'error') { const error = new Error(message.error.message); error.name = message.error.name; + if (message.error.code !== undefined) Object.assign(error, { code: message.error.code }); return settle(() => rejectPromise(error)); } settle(() => resolvePromise(message.result)); @@ -702,7 +722,11 @@ export class RouteInvocationService { } if ( (request.event !== undefined && route.kind !== 'event-route') - || (request.args !== undefined && route.kind !== 'cli') + || ( + request.args !== undefined + && route.kind !== 'cli' + && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) + ) ) { return malformed(); } @@ -737,16 +761,33 @@ export class RouteInvocationService { child = plainScript === undefined ? await this.#renderChild({ ...(request.args === undefined ? {} : { args: request.args }), + ...(prepared.artifact === undefined + ? {} + : { + artifactEpoch: `${prepared.manifest.plugin.name}@${prepared.manifest.plugin.version}`, + artifactRoot: join(prepared.manifest.projectRoot, '.agent-bundle', 'epochs', prepared.artifact.epochId), + }), context, + ...(request.event?.host === undefined ? {} : { eventTarget: request.event.host }), input, manifest: prepared.manifest, + ...(request.mode === undefined ? {} : { mode: request.mode }), routeId: route.id, + stateRoot: prepared.stateRoot, }, controller.signal) : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { const completedAt = this.#now(); + const childCode = typeof error === 'object' && error !== null && 'code' in error + && ( + error.code === ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + || error.code === ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + || error.code === ROUTE_INVOCATION_PREPARATION_FAILURE_CODE + ) + ? error.code + : ROUTE_INVOCATION_CHILD_FAILURE_CODE; return failedInvocation({ - code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, + code: childCode, completedAt, context, id, @@ -810,6 +851,7 @@ export class RouteInvocationService { sourceRevision: manifest.sourceRevision, startedAt: startedAt.toISOString(), status: 'succeeded', + ...(child.trace === undefined ? {} : { trace: child.trace }), timings: [ timing('providers', startedAt, 0), ...manifest.providers.map((provider) => timing(`provider:${provider.name}`, startedAt, 0)), diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 922e55669..bc2e4c232 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -40,6 +40,8 @@ export interface RouteInvocationRequest { readonly event?: RouteInvocationEventOptions; /** Tool/prompt/script input, event payload (canonical or native — see `event.host`), or resource parameters. */ readonly input?: JsonValue; + /** Generated-entry parity by default; component-only rendering is an explicit fallback. */ + readonly mode?: 'production' | 'unit-render'; /** The compiled route id, for example `tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`. */ readonly routeId: string; } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index d872b557b..e989cf932 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -934,6 +934,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), + stateRoot: join(prepared.root, '.agent-bundle', 'state'), targets, }); }, diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index 17a1f1be1..f7e6f37ce 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -7,6 +7,7 @@ import { deepFreeze } from '../core/freeze.ts'; import type { NormalizedMcpApp, NormalizedScript, NormalizedStateDefinition } from '../core/types.ts'; import { orderedProviders } from '../routes/provider-execution.ts'; import { providerKeyFromName } from '../routes/providers.ts'; +import type { CanonicalAgentEvent } from '../routes/public.ts'; import type { CompiledAgentRoute, CompiledCliCommand, @@ -127,6 +128,8 @@ export const proofLevelLabel = (level: AgentTestProofLevel): string => { export interface TestableRouteDescriptor { /** The route module's statically extracted `config` export; `{}` when absent. */ readonly config: Readonly>; + /** Canonical event identity; present only for event routes. */ + readonly event?: CanonicalAgentEvent; readonly id: string; readonly kind: CompiledRouteKind; /** Project-relative POSIX path of the route module. */ @@ -300,6 +303,7 @@ export interface CompileTestManifestOptions { const descriptorOf = (route: CompiledAgentRoute): TestableRouteDescriptor => ({ config: route.config, + ...(route.event === undefined ? {} : { event: route.event }), id: route.id, kind: route.kind, relativePath: route.provenance.relativePath, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index da60c2ed5..91b159672 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -27,8 +27,7 @@ import type { import type * as React from 'react'; import { - CliInputError, - cliInputError, + mapGeneratedCliInput, } from '../cli-entry.ts'; import type { CliRenderedEvent, @@ -1096,29 +1095,7 @@ export const parseCliCommandInput = ( inputSchema: AgentRouteSchema, projectionModule: Readonly> | undefined, input: Readonly>, -): unknown => { - const withDefaults: Record = { ...input }; - for (const [key, value] of Object.entries(command.projection?.defaults ?? {})) { - if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; - } - let mapped: unknown = withDefaults; - if (command.projection?.mapInput === true) { - const mapInput = projectionModule?.['mapInput']; - if (typeof mapInput !== 'function') { - throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); - } - try { - mapped = mapInput(withDefaults); - } catch (error) { - throw new CliInputError(error instanceof Error ? error.message : String(error)); - } - } - try { - return inputSchema.parse(mapped); - } catch (error) { - throw cliInputError(command, mapped, error); - } -}; +): unknown => mapGeneratedCliInput(command, inputSchema, projectionModule, input); /** * Accepts preloaded route modules and prepares the renderer and manifest diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 396e24448..451f9569b 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -336,7 +336,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('b177c34fc9ef98e972b5f5db1296c01219634572a455796fcae30bfaf070ba72'); + .toBe('1dfd4b9822135dd555bffe3028e6a25421430e50b23a3aac9998617176ac4f6a'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -677,7 +677,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '93cdfe64b98e0add920ed3f4daa3916620a3f750ec9dbcefc6be6419efab38e5', + '77301f3cac0f896a8be450aa986d51f9bd476455f0df9fcc7565f7eee961d3a6', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -805,19 +805,14 @@ it('imports explicit CLI projections and maps their input before canonical valid expect(source).toContain( '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); - const defaults = source.indexOf('for (const [key, value] of Object.entries(command.projection.defaults))'); - const mapping = source.indexOf('mapped = route.projection.mapInput(mapped)'); - const validation = source.indexOf('return route.module.inputSchema.parse(mapped)'); expect(source).not.toContain('command.mcp?.confirm'); expect(source).not.toContain('confirmationRequiredMessage'); expect(source).not.toContain('delete mapped.yes'); - expect(defaults).toBeGreaterThan(-1); - expect(defaults).toBeLessThan(mapping); - expect(mapping).toBeLessThan(validation); - expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); + expect(source).toContain('mapGeneratedCliInput, parseGeneratedCliArgv, runGeneratedCliProcess'); + expect(source).toContain('mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input)'); + expect(source).toContain('export const prepareRouteInvocation = (routeId, argv) => {'); + expect(source).toContain('parseGeneratedCliArgv(command, argv).input'); expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); - expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`)"); - expect(source).toContain('throw new CliInputError(error instanceof Error ? error.message : String(error));'); expect(source).toContain( "invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", ); @@ -1393,7 +1388,7 @@ it('composes the root and server layout chain around generated MCP routes and ne // throwing route still rejects the Flight root exactly as it does without a layout. expect(source).toContain('let composed = await route.module.default(props);'); expect(source).toContain('if (chain.length === 0) return createElement(route.module.default, props);'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, props, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, props, controller.signal)'); }); it('imports only the layouts some route of the worker composes through, never another server\'s layout', () => { @@ -1507,7 +1502,7 @@ it('hands rendered CLI, projected MCP, and script routes their layout chain and expect(source).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([1]) })'); expect(source).toContain('"tool:curator/inspect": Object.freeze({ id: "tool:curator/inspect", kind: "tool", name: "inspect", serverId: "mcp:curator", module: route1, layouts: Object.freeze([1,0]) })'); expect(source).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route2, layouts: Object.freeze([1]) })'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, { ...message.props, signal: controller.signal }, controller.signal)'); }); it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { 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 cba58b6aa..84ee41c2a 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; @@ -11,6 +11,7 @@ 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'; +import { runNodeScript } from './support/run-node-script.ts'; const readEvent = async (response: Response, type: string): Promise> => { const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); @@ -32,14 +33,21 @@ const readEvent = async (response: Response, type: string): Promise { const project = await createProjectFixture({ config: [ + "import { join } from 'node:path';", + '', 'export default {', " plugin: { name: 'route-invocation-dev-server', version: '1.0.0' },", " targets: ['claude'],", + ' tools: {', + " rsbuild: { source: { define: { __ROUTE_INVOCATION_DEFINE__: JSON.stringify('defined') } } },", + " rspack: { resolve: { alias: { '@fixture/value': join(import.meta.dirname, 'src/aliased.ts') } } },", + ' },', '};', '', ].join('\n'), files: { 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', + 'src/aliased.ts': "export const ALIAS_VALUE = 'aliased';\n", 'src/cli/greet.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -55,9 +63,11 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/events/tool/after.preflight.ts': "export default () => 'execute';\n", 'src/events/tool/after.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", + "export { default as preflight } from './after.preflight.js';", '', "export const config = { runtime: 'standalone' };", '', @@ -66,24 +76,74 @@ it('invokes compiled tool and event routes through the foreground server', { tim '}', '', ].join('\n'), + 'src/events/prompt/submit.preflight.ts': "export default () => ({ outcome: 'continue' });\n", + 'src/events/prompt/submit.tsx': [ + "export { default as preflight } from './submit.preflight.js';", + "export const config = { runtime: 'standalone' };", + "export default async function PromptSubmit() { throw new Error('continue preflight reached handler'); }", + '', + ].join('\n'), + 'src/events/tool/before.preflight.ts': "export default () => ({ outcome: 'deny', reason: 'blocked by preflight' });\n", + 'src/events/tool/before.tsx': [ + "export { default as preflight } from './before.preflight.js';", + "export const config = { runtime: 'standalone' };", + "export default async function BeforeTool() { throw new Error('deny preflight reached handler'); }", + '', + ].join('\n'), + 'src/mcp/status/tools/counter.tsx': [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({ key: z.string() }).strict();', + 'export const resultSchema = z.object({ count: z.number() }).strict();', + 'export default async function Counter({ input }) {', + ' const context = await agent();', + " if (context.state === undefined) throw new Error('state unavailable');", + " const committed = await context.state.dispatch('incremented', { by: 1 }, { idempotencyKey: `${input.key}:${crypto.randomUUID()}` });", + ' return createElement(Agent.Result, { value: { count: committed.state.count } });', + '}', + '', + ].join('\n'), 'src/mcp/status/tools/report.tsx': [ "import { Agent } from '@agent-bundle/runtime';", + "import { ALIAS_VALUE } from '@fixture/value';", "import { createElement } from 'react';", "import { z } from 'zod';", + 'declare const __ROUTE_INVOCATION_DEFINE__: string;', '', "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", - "export const inputSchema = z.object({ service: z.string().min(1) }).strict();", - 'export const resultSchema = z.object({ service: z.string() }).strict();', + "export const inputSchema = z.object({ service: z.string().min(1), source: z.string() }).strict();", + 'export const resultSchema = z.object({ alias: z.string(), define: z.string(), service: z.string(), source: z.string() }).strict();', '', 'export default async function Report({ input }) {', - " return createElement(Agent.Result, { value: { service: input.service } }, createElement(Agent.Text, null, `Service ${input.service}`));", + ' const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, service: input.service, source: input.source };', + " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `Service ${input.service}`));", '}', '', ].join('\n'), + 'src/mcp/status/tools/report.cli.ts': [ + "export const config = { command: ['report'], confirm: false, flags: { service: { name: 'name' }, source: { required: false } } };", + "export const mapInput = (input) => ({ ...input, source: input.source ?? 'cli-projection' });", + '', + ].join('\n'), 'src/providers/clock.ts': [ 'export default () => ({ now: 0 });', '', ].join('\n'), + 'src/state.ts': [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + 'export default defineState({', + " events: { incremented: z.object({ by: z.number() }).strict() },", + " id: 'route-invocation/counter',", + ' initial: { count: 0 },', + " lifetime: 'workspace-durable',", + ' reduce: (state, event) => ({ count: state.count + event.payload.by }),', + ' schema: z.object({ count: z.number() }).strict(),', + '});', + '', + ].join('\n'), 'src/scripts/summary.tsx': [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -133,16 +193,22 @@ it('invokes compiled tool and event routes through the foreground server', { tim headers: { cookie, origin: server.url }, }); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ input: { service: 'catalog' }, routeId: 'tool:status/report' }), + body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, method: 'POST', }); expect(toolResponse.status).toBe(200); const tool = await toolResponse.json() as RouteInvocationResponse; - expect(tool.invocation.status).toBe('succeeded'); + expect(tool.invocation.status, JSON.stringify(tool.invocation.diagnostics)).toBe('succeeded'); expect(tool.invocation.events.at(-1)?.type).toBe('complete'); expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); + expect(tool.invocation.result).toEqual({ + alias: 'aliased', + define: 'defined', + service: 'catalog', + source: 'api', + }); expect(tool.invocation.providers).toEqual([ expect.objectContaining({ name: 'clock', status: 'mounted' }), ]); @@ -172,9 +238,73 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(event.invocation.events.at(-1)?.type).toBe('complete'); expect(event.invocation.document).toBeDefined(); expect(event.invocation.projection.hosts?.[0]).toMatchObject({ host: 'claude' }); + expect(event.invocation.trace?.map((trace) => trace.kind)).toEqual([ + 'preflight.start', + 'preflight.outcome', + 'execute.start', + 'providers.start', + 'providers.finish', + 'render.start', + 'render.finish', + ]); + + for (const [routeId, input, expected] of [ + [ + 'event:tool/before', + { + cwd: project.root, + hook_event_name: 'PreToolUse', + permission_mode: 'default', + session_id: 'session-preflight-deny', + tool_input: { file_path: 'blocked.txt' }, + tool_name: 'Write', + tool_use_id: 'use-deny', + transcript_path: join(project.root, 'transcript.json'), + }, + { outcome: 'deny', reason: 'blocked by preflight' }, + ], + [ + 'event:prompt/submit', + { + cwd: project.root, + hook_event_name: 'UserPromptSubmit', + permission_mode: 'default', + prompt: 'continue', + session_id: 'session-preflight-continue', + transcript_path: join(project.root, 'transcript.json'), + }, + { outcome: 'continue' }, + ], + ] as const) { + const response = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ event: { host: 'claude' }, input, routeId }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + const invoked = await response.json() as RouteInvocationResponse; + expect(invoked.invocation.status, JSON.stringify(invoked.invocation.diagnostics)).toBe('succeeded'); + expect(invoked.invocation.result).toEqual(expected); + expect(invoked.invocation.events).toEqual([]); + expect(invoked.invocation.trace?.map((trace) => trace.kind)).toEqual([ + 'preflight.start', + 'preflight.outcome', + ]); + if (routeId === 'event:tool/before') { + expect(invoked.invocation.projection.hosts?.[0]?.native).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'blocked by preflight', + }, + }); + } else { + expect(invoked.invocation.projection.hosts?.[0]?.native).toBeUndefined(); + } + } const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ input: { name: 'Ada' }, routeId: 'cli:greet' }), + body: JSON.stringify({ args: ['Ada'], routeId: 'cli:greet' }), headers, method: 'POST', }); @@ -192,6 +322,53 @@ it('invokes compiled tool and event routes through the foreground server', { tim status: 'succeeded', }); + const projectedCliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ args: ['--name', 'projection'], routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(projectedCliResponse.status).toBe(200); + const projectedCli = await projectedCliResponse.json() as RouteInvocationResponse; + expect(projectedCli.invocation.result).toMatchObject({ + alias: 'aliased', + define: 'defined', + service: 'projection', + source: 'cli-projection', + }); + 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 binName = (await readdir(join(artifactRoot, 'bin'))) + .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); + if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); + const generatedBin = await runNodeScript({ + args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], + cwd: project.root, + env: { AGENT_BUNDLE_PLUGIN_ROOT: join(project.root, '.agent-bundle') }, + }); + expect(generatedBin.code, generatedBin.stderr).toBe(0); + expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); + + const counter = async (mode?: 'production' | 'unit-render'): Promise => { + const response = await fetch(`${server!.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { key: mode ?? 'production' }, + ...(mode === undefined ? {} : { mode }), + routeId: 'tool:status/counter', + }), + headers, + method: 'POST', + }); + expect(response.status).toBe(200); + return response.json() as Promise; + }; + const firstCounter = await counter(); + const secondCounter = await counter(); + const isolatedCounter = await counter('unit-render'); + expect(firstCounter.invocation.result).toEqual({ count: 1 }); + expect(secondCounter.invocation.result).toEqual({ count: 2 }); + expect(isolatedCounter.invocation.result).toEqual({ count: 1 }); + const scriptResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ routeId: 'script:summary' }), headers, @@ -214,9 +391,9 @@ 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, - cli.invocation.id, - event.invocation.id, - tool.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); @@ -286,7 +463,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim "import { z } from 'zod';", '', "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", - "export const inputSchema = z.object({ service: z.string().min(1) }).strict();", + "export const inputSchema = z.object({ service: z.string().min(1), source: z.string().optional() }).strict();", 'export const resultSchema = z.object({ service: z.string() }).strict();', '', 'export default async function Report({ input }) {', @@ -297,7 +474,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim ].join('\n'), { timeoutMs: 10_000 }, ); - expect(repairedAttempt.outcome).toBe('succeeded'); + expect(repairedAttempt.outcome, JSON.stringify(repairedAttempt.diagnostics)).toBe('succeeded'); const repairedInvocationResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'published' }, routeId: 'tool:status/report' }), headers, @@ -420,7 +597,7 @@ it('publishes invocation routes only after a successful initial or recovered bui }); expect(publishedInvocationResponse.status).toBe(200); const publishedInvocation = await publishedInvocationResponse.json() as RouteInvocationResponse; - expect(publishedInvocation.invocation).toMatchObject({ + expect(publishedInvocation.invocation, JSON.stringify(publishedInvocation.invocation.diagnostics)).toMatchObject({ result: { version: 'published' }, status: 'succeeded', }); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3885d269e..609374081 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -47,6 +47,18 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ startedAt: completedAt, status: 'succeeded', timings: [], + trace: [{ + at: 0, + execution: { + event: 'tool/after', + executionId: id, + host: 'claude', + nativeEvent: 'PostToolUse', + }, + kind: 'preflight.start', + phase: 'preflight', + sequence: 0, + }], }); it('strictly validates invocation request fields and event options', () => { @@ -61,11 +73,18 @@ it('strictly validates invocation request fields and event options', () => { }); expect(parseRouteInvocationRequest({ event: { fixtureId: 'starter', host: 'claude' }, + mode: 'unit-render', routeId: 'event:tool/after', })).toEqual({ event: { fixtureId: 'starter', host: 'claude' }, + mode: 'unit-render', routeId: 'event:tool/after', }); + expect(parseRouteInvocationRequest({ + routeId: 'tool:curator/search_audible', + })).toEqual({ + routeId: 'tool:curator/search_audible', + }); for (const value of [ {}, @@ -74,6 +93,7 @@ it('strictly validates invocation request fields and event options', () => { { args: ['ok', 1], routeId: 'cli:x' }, { event: { host: 'other' }, routeId: 'event:tool/after' }, { event: { fixtureId: '' }, routeId: 'event:tool/after' }, + { mode: 'preview', routeId: 'tool:x/y' }, ]) { expect(() => parseRouteInvocationRequest(value)).toThrow(RouteInvocationRequestError); } @@ -93,6 +113,7 @@ it('projects summaries without retaining heavy invocation payloads', () => { expect(summary).not.toHaveProperty('projection'); expect(summary).not.toHaveProperty('providers'); expect(summary).not.toHaveProperty('result'); + expect(summary).not.toHaveProperty('trace'); }); it('retains a bounded newest-first invocation history', () => { @@ -133,6 +154,7 @@ it('aborts and drains a running render when the service closes', async () => { }, prepared: () => ({ manifest: { projectRoot: '/project' } as never, + stateRoot: '/project/.agent-bundle/state', targets: ['claude'], }), renderChild: (_request, signal) => new Promise((_resolve, reject) => { @@ -140,7 +162,7 @@ it('aborts and drains a running render when the service closes', async () => { }), }); - const pending = service.invoke({ input: {}, routeId: route.id }); + const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: route.id }); await Promise.resolve(); await service.close(); @@ -217,6 +239,7 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise { const project = await leakingRouteProject('reply'); try { - const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); const pids = await project.pids(); expect(invocation.status).toBe('succeeded'); @@ -270,7 +293,7 @@ it('reaps the render child and its descendants when the invocation times out', { const project = await leakingRouteProject('hang'); try { const service = project.service({ timeoutMs: 8_000 }); - const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); const pids = await recordedPids(project); expect(alive(pids.child)).toBe(true); expect(alive(pids.descendant)).toBe(true); @@ -290,7 +313,7 @@ it('reaps the render child and its descendants when the service closes mid-rende const project = await leakingRouteProject('hang'); try { const service = project.service(); - const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); const pids = await recordedPids(project); expect(alive(pids.child)).toBe(true); expect(alive(pids.descendant)).toBe(true); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 5d8a2ac21..4130cea70 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -6,6 +6,7 @@ import type { RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import type { EventTraceEvent } from '../../../agent-bundle/src/events/trace.ts'; import { agentDocumentSchema, agentRenderEventSchema, @@ -71,6 +72,39 @@ const invocationEventSchema = z.strictObject({ host: z.enum(['claude', 'codex', 'cursor']).optional(), native: jsonObjectSchema.optional(), }); +const eventTraceWireSchema = z.strictObject({ + at: z.number().finite().nonnegative(), + count: z.number().int().nonnegative().optional(), + durationMs: z.number().finite().nonnegative().optional(), + error: z.strictObject({ + code: z.string().optional(), + message: z.string(), + name: textSchema, + }).optional(), + execution: z.strictObject({ + event: textSchema, + executionId: textSchema, + host: textSchema, + nativeEvent: textSchema, + }), + kind: z.enum([ + 'preflight.start', + 'preflight.outcome', + 'execute.start', + 'providers.start', + 'providers.finish', + 'render.start', + 'render.finish', + 'failure', + ]), + outcome: z.enum(['execute', 'continue', 'deny']).optional(), + phase: z.enum(['preflight', 'execute', 'providers', 'render']), + runtime: z.enum(['shared', 'standalone']).optional(), + sequence: z.number().int().nonnegative(), +}); +const eventTraceSchema = z.custom( + (value) => eventTraceWireSchema.safeParse(value).success, +); const invocationSummaryFields = { completedAt: textSchema, correlationId: textSchema.optional(), @@ -97,6 +131,7 @@ const invocationSchema: z.ZodType = z.strictObject({ projection: projectionSchema, providers: z.array(providerSchema), result: z.json().optional(), + trace: z.array(eventTraceSchema).optional(), }); const invocationResponseSchema = z.strictObject({ invocation: invocationSchema }); const invocationListResponseSchema = z.strictObject({ diff --git a/packages/workbench/tests/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts index 64e32e33f..48529bef8 100644 --- a/packages/workbench/tests/invocation-client.test.ts +++ b/packages/workbench/tests/invocation-client.test.ts @@ -59,6 +59,18 @@ const invocation = Object.freeze({ phase: 'render', startedAt: '2026-09-05T07:00:00.000Z', }]), + trace: Object.freeze([{ + at: 1, + execution: Object.freeze({ + event: 'tool/after' as const, + executionId: 'event-execution-a', + host: 'claude', + nativeEvent: 'PostToolUse', + }), + kind: 'preflight.start' as const, + phase: 'preflight' as const, + sequence: 0, + }]), }) satisfies RouteInvocation; const foreground = (handler: (path: string, init: RequestInit) => Response | Promise): ForegroundRequestAuthority => ({ @@ -70,7 +82,7 @@ it('strictly decodes invoke, list, and read responses', async () => { const client = new InvocationClient({ foreground: foreground((path, init) => { requests.push([path, init]); return Response.json(path.includes('?limit=') - ? { invocations: [{ ...invocation, context: undefined, document: undefined, events: undefined, projection: undefined, providers: undefined, result: undefined }] } + ? { invocations: [{ ...invocation, context: undefined, document: undefined, events: undefined, projection: undefined, providers: undefined, result: undefined, trace: undefined }] } : { invocation }); }) }); diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index 1222a4bc9..acafd2f31 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -242,8 +242,15 @@ export const editWatchedSource = async ( }; export const runSelectedRoute = async (page: Page, timeout = browserTimeout): Promise => { - await workbenchTestId(page, 'routeRun').click(); const status = workbenchTestId(page, 'routeStatus'); + const invocationId = status.locator('.route-status-id'); + const previousId = await invocationId.count() === 0 ? undefined : await invocationId.textContent(); + await workbenchTestId(page, 'routeRun').click(); + await expect.poll(async () => { + const className = await status.getAttribute('class'); + const currentId = await invocationId.count() === 0 ? undefined : await invocationId.textContent(); + return className?.includes('route-status--running') === true || currentId !== previousId; + }, { timeout }).toBe(true); await expect(status).toHaveClass(/route-status--succeeded/u, { timeout }); }; diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index c67592a30..9447b5b80 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -193,11 +193,20 @@ The route workspace uses one authenticated, origin-guarded foreground API: - `GET /api/routes/invocations/` returns one invocation. - `/api/project/events` publishes completed summaries as `route.invocation` events. +Requests default to `mode: "production"`. Production mode executes the selected route from the +last published compiler epoch, so compiler aliases and defines, generated-entry request scope, +providers, persistent state, CLI `mapInput` and confirmation, event preflight, and render +semantics match the installed artifact. `mode: "unit-render"` is an explicit component-preview +fallback: it loads live source with the route-unit harness and mounts disposable state. It is not +an artifact-parity receipt. + The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings when available. A represented `Agent.Error` remains a rendered result; unknown routes or invocation ids (`AB8231`), unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed -requests (`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. +requests (`AB8237`), unknown fixture ids (`AB8238`), unavailable compiled artifacts (`AB8250`), +missing compiled route executables (`AB8251`), and compiled projection or preflight failures +(`AB8252`) are reported as diagnostics. See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index c1a062848..0adc439da 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -164,10 +164,16 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 - `GET /api/routes/invocations/` 返回一次调用。 - `/api/project/events` 以 `route.invocation` 事件发布已完成的摘要。 +请求默认使用 `mode: "production"`。生产模式从最近发布的编译器 epoch 执行所选路由,因此编译器 +alias 与 define、生成入口的请求作用域、providers、持久状态、CLI `mapInput` 与确认、事件 +preflight 以及渲染语义都与已安装制品一致。`mode: "unit-render"` 是显式的组件预览后备模式: +它通过 route-unit 测试工具加载实时源码并挂载一次性状态,不能作为制品一致性的证明。 + 该信封在可用时携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、 投影、诊断与执行计时。被表示的 `Agent.Error` 仍是一份已渲染结果;未知路由或 invocation id(`AB8231`)、 不可用的 epoch(`AB8232`)、渲染超时或崩溃(`AB8236`)、格式错误的请求(`AB8237`)与未知 fixture id -(`AB8238`)会作为诊断报告。 +(`AB8238`)、不可用的编译制品(`AB8250`)、缺失的已编译路由可执行项(`AB8251`),以及已编译投影或 +preflight 失败(`AB8252`)会作为诊断报告。 各条触发条件与恢复指引见[诊断参考](../../reference/diagnostics.md)。 ## 以编程方式使用同一个会话 From 61c8a3236d2d066204b0765621501d49d59aeb46 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:48:09 +0000 Subject: [PATCH 32/70] integrate A2: args check after prepared lease --- .../src/dev/routes/route-invocation-service.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 a6cbe11b8..c1d317d65 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -764,16 +764,7 @@ export class RouteInvocationService { 404, ); } - if ( - (request.event !== undefined && route.kind !== 'event-route') - || ( - request.args !== undefined - && route.kind !== 'cli' - && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) - ) - ) { - return malformed(); - } + if (request.event !== undefined && route.kind !== 'event-route') return malformed(); const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); const running = this.#semaphore.run(async () => { @@ -801,6 +792,13 @@ export class RouteInvocationService { 409, ); } + if ( + request.args !== undefined + && route.kind !== 'cli' + && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) + ) { + return malformed(); + } const fixtureId = request.event?.fixtureId; const fixture = fixtureId === undefined ? undefined From 68a938e2837ec4d75d79f58f69e4fcd00ce6c01d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:51:11 +0000 Subject: [PATCH 33/70] integrate A2: trace type through the invocations contract; unit-render fixture --- packages/agent-bundle/src/contracts/invocations.ts | 1 + packages/agent-bundle/tests/route-invocation-service.test.ts | 2 +- packages/workbench/src/application/invocation-client.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts index 2140addb3..e6756ea73 100644 --- a/packages/agent-bundle/src/contracts/invocations.ts +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -24,3 +24,4 @@ export type { RouteInvocation, RouteInvocationResponse, } from '../dev/routes/route-invocation-result.ts'; +export type { EventTraceEvent } from '../events/trace.ts'; diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index c115a6717..5843ffe4c 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -393,7 +393,7 @@ const tsxSiblingProject = async (): Promise => routeProject( it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { const project = await tsxSiblingProject(); try { - const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report' }); + const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/report' }); expect(invocation.status, JSON.stringify(invocation.diagnostics)).toBe('succeeded'); expect(invocation.document).toBeDefined(); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts index 219de03b6..98628f006 100644 --- a/packages/workbench/src/application/invocation-client.ts +++ b/packages/workbench/src/application/invocation-client.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; import type { + EventTraceEvent, RouteInvocation, RouteInvocationRequest, RouteInvocationSummary, } from '../../../agent-bundle/src/contracts/invocations.ts'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { EventTraceEvent } from '../../../agent-bundle/src/events/trace.ts'; import { agentDocumentSchema, agentRenderEventSchema, From 4dbe4df132d701880d8819703cfa2df228a1ac57 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:56:02 +0000 Subject: [PATCH 34/70] integration test: observed provider telemetry from the compiled worker --- .../tests/route-invocation-dev-server.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index fe2d45214..2647983d3 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -210,10 +210,16 @@ it('invokes compiled tool and event routes through the foreground server', { tim source: 'api', }); expect(tool.invocation.providers).toEqual([ - expect.objectContaining({ name: 'clock', status: 'unobserved' }), + expect.objectContaining({ durationMs: expect.any(Number), id: 'provider:clock', name: 'clock', status: 'mounted' }), ]); - expect(tool.invocation.providers[0]).not.toHaveProperty('durationMs'); - expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); + expect(tool.invocation.timings.map((entry) => entry.phase)).toEqual([ + 'provider:clock', + 'providers', + 'handler', + 'render', + 'projection', + ]); + for (const entry of tool.invocation.timings) expect(entry.durationMs).toBeGreaterThanOrEqual(0); const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ From 83b28ed631c35b5081c300f633cc415eab47a7c9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:56:02 +0000 Subject: [PATCH 35/70] changeset: PR 2a execution parity + provenance --- .changeset/wb600-pr2a-execution-parity.md | 5 +++++ .changeset/wb600-pr2a-telemetry.md | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 .changeset/wb600-pr2a-execution-parity.md delete mode 100644 .changeset/wb600-pr2a-telemetry.md diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md new file mode 100644 index 000000000..39d168fb0 --- /dev/null +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Execute Workbench route invocations (`POST /api/routes/invocations`) through the published compiled artifact by default: the generated hook wrapper's preflight gate, the generated CLI bin's `mapInput` and confirmation, compiler aliases and defines, and persistent state on the plugin state root all behave as in the installed artifact; `mode: "unit-render"` is the explicit live-source component preview. Pin each invocation to a leased compiled epoch acquired inside the concurrency slot and answer `409 AB8239` when the published revision moved while the request waited. Rewrite only real `import`/`export … from`/`import()` specifiers when pointing a `.js` import at its `.tsx` source, so a rendered string such as `'./panel.js'` is no longer altered. Stop fabricating provider and timing rows: unmeasured providers are `unobserved` without `durationMs`, `handler`/`providers`/`provider:` timings appear only when measured, and failures record a measured `elapsed` phase. New diagnostics `AB8250`–`AB8252` for a missing published artifact, a route without a compiled executable, and a failed compiled CLI projection or event preflight preparation. (#600) diff --git a/.changeset/wb600-pr2a-telemetry.md b/.changeset/wb600-pr2a-telemetry.md deleted file mode 100644 index befcbbbcb..000000000 --- a/.changeset/wb600-pr2a-telemetry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"agent-bundle": patch ---- - -Stop fabricating route-invocation provider and timing rows. Unmeasured providers now use status `unobserved` with no `durationMs`; `handler`, `providers`, and `provider:` timings appear only when the child observed them. Failures record a measured `elapsed` phase instead of invented `failed` providers or a fake `render`. (#600) From fdd89322930837640b36fe0ab1a44b667781eba1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:35:46 +0000 Subject: [PATCH 36/70] fix workbench invocation epoch state parity --- .changeset/wb600-pr2a-execution-parity.md | 2 +- LANE-NOTES.md | 68 ++++++++ packages/agent-bundle/src/dev/epoch-paths.ts | 3 + .../src/dev/mcp-session/mcp-session-launch.ts | 5 +- .../src/dev/routes/route-invocation-child.ts | 3 +- .../route-invocation-production-error.ts | 23 +++ .../dev/routes/route-invocation-production.ts | 30 ++-- .../dev/routes/route-invocation-service.ts | 146 ++++++++---------- .../src/dev/routes/route-module-loader.ts | 17 -- .../agent-bundle/src/dev/workbench-server.ts | 30 ++-- .../agent-bundle/tests/entry-shell.test.ts | 20 +-- .../tests/route-invocation-dev-server.test.ts | 25 ++- .../tests/route-invocation-service.test.ts | 31 ++-- .../route-unit/route-module-loader.test.ts | 7 - .../tests/target-hook-contract.test.ts | 21 ++- .../docs/en/guide/development/workbench.mdx | 2 + .../docs/zh/guide/development/workbench.mdx | 2 + 17 files changed, 247 insertions(+), 188 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/epoch-paths.ts create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index 39d168fb0..7b16050ea 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations (`POST /api/routes/invocations`) through the published compiled artifact by default: the generated hook wrapper's preflight gate, the generated CLI bin's `mapInput` and confirmation, compiler aliases and defines, and persistent state on the plugin state root all behave as in the installed artifact; `mode: "unit-render"` is the explicit live-source component preview. Pin each invocation to a leased compiled epoch acquired inside the concurrency slot and answer `409 AB8239` when the published revision moved while the request waited. Rewrite only real `import`/`export … from`/`import()` specifiers when pointing a `.js` import at its `.tsx` source, so a rendered string such as `'./panel.js'` is no longer altered. Stop fabricating provider and timing rows: unmeasured providers are `unobserved` without `durationMs`, `handler`/`providers`/`provider:` timings appear only when measured, and failures record a measured `elapsed` phase. New diagnostics `AB8250`–`AB8252` for a missing published artifact, a route without a compiled executable, and a failed compiled CLI projection or event preflight preparation. (#600) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#600) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..91f029865 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,68 @@ +# Lane A5 notes + +## Behavior + +- Workbench production invocation workers now receive + `AGENT_BUNDLE_PLUGIN_ROOT=/.agent-bundle/epochs/` and + `AGENT_BUNDLE_STATE_ROOT=/.agent-bundle/epochs//state`. + The state root is derived from the leased epoch root, shared with that + epoch's dev MCP sessions, and never derived from the code root. +- Generated CLI parity tests use the same code-root and state-root environment + as Workbench production invocations. +- Event wrapper source-order assertions cover validation, canonical props, + preflight, projection, the execute gate, and the Worker boundary in their + refactored functions. Generated entry hashes and structural assertions match + the current templates. +- The production invocation error type lives in a dependency-free leaf module. + This prevents ordinary packed CLI builds from eagerly loading the optional + `@agent-bundle/runtime` peer while preserving one error-class identity. +- English and Chinese Workbench documentation describe epoch-local persistent + state and `AGENT_BUNDLE_STATE_ROOT`. + +## Files + +- `.changeset/wb600-pr2a-execution-parity.md` +- `packages/agent-bundle/src/dev/epoch-paths.ts` +- `packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-production.ts` +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` +- `packages/agent-bundle/src/dev/routes/route-module-loader.ts` +- `packages/agent-bundle/src/dev/workbench-server.ts` +- `packages/agent-bundle/tests/entry-shell.test.ts` +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` +- `packages/agent-bundle/tests/route-invocation-service.test.ts` +- `packages/agent-bundle/tests/route-unit/route-module-loader.test.ts` +- `packages/agent-bundle/tests/target-hook-contract.test.ts` +- `website/docs/en/guide/development/workbench.mdx` +- `website/docs/zh/guide/development/workbench.mdx` + +## Deslop + +Deslop: GPT-5.6 Sol, 11 edits. + +The pass standardized the prepared-project supplier on an async lease, +replaced structural error sniffing with the single production error class, +normalized the invocation body, tightened cross-process error reconstruction, +removed five restating comments, and condensed the changeset summary. + +## Verification + +- `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint` +- `pnpm test:unit` — 4,140 passed, 6 skipped +- `npx rstest --config rstest.route-unit.config.ts` — 89 passed +- `npx rstest --config rstest.projection.config.ts` — 190 passed +- `pnpm test:integration:run` — 1,150 passed, 4 skipped +- `pnpm docs:site:build` — locale parity passed and 0 broken links across + 27,307 anchors +- `git diff --check` + +The first integration run exposed the packed CLI's eager import of the optional +runtime peer. After moving the production error type to a leaf module, the +focused packed-consumer regression and the complete integration pool passed. + +## Open risks + +None known. The build and test logs retain pre-existing Rslib top-level-await, +Node SQLite experimental, and occasional test-process listener warnings. diff --git a/packages/agent-bundle/src/dev/epoch-paths.ts b/packages/agent-bundle/src/dev/epoch-paths.ts new file mode 100644 index 000000000..dd1b203be --- /dev/null +++ b/packages/agent-bundle/src/dev/epoch-paths.ts @@ -0,0 +1,3 @@ +import { join } from 'node:path'; + +export const devEpochStateRoot = (epochRoot: string): string => join(epochRoot, 'state'); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts index a1a4a8163..2855c0555 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts @@ -1,9 +1,10 @@ -import { isAbsolute, join, resolve } from 'node:path'; +import { isAbsolute, resolve } from 'node:path'; import { assertInside } from '../../core/paths.ts'; import { pluginStateRootEnvAnchor } from '../../core/types.ts'; import { resolveMcpPathTokens } from '../../services/mcp-path-tokens.ts'; import type { ModernMcpServer, TargetMcpRuntimeContract } from '../../services/mcp-runtime.ts'; +import { devEpochStateRoot } from '../epoch-paths.ts'; import type { McpSessionInspectorConfig } from './mcp-session-protocol.ts'; export interface ResolvedMcpSessionServer { @@ -114,7 +115,7 @@ export const resolveMcpSessionLaunch = (options: ResolveMcpSessionLaunchOptions) // A dev session runs a build epoch, not an install: its framework state // lives beside that epoch and goes with it, instead of accumulating one // user-data root per rebuild. Declared env still wins, as for every key. - const stateRoot = join(options.resolved.targetRoot, 'state'); + const stateRoot = devEpochStateRoot(options.resolved.targetRoot); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, 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 08a668e79..bc56e875b 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -15,6 +15,7 @@ import type { RouteInvocationChildResponse, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { ProductionRouteInvocationError } from './route-invocation-production-error.ts'; import { renderProductionRoute } from './route-invocation-production.ts'; import { createRouteModuleLoader } from './route-module-loader.ts'; @@ -97,7 +98,7 @@ process.once('message', (request: RouteInvocationChildRequest) => { .then((result) => respond({ result, type: 'result' })) .catch((error: unknown) => respond({ error: { - ...(typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ...(error instanceof ProductionRouteInvocationError ? { code: error.code } : {}), message: error instanceof Error ? error.message : String(error), diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts new file mode 100644 index 000000000..7e3eb5134 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts @@ -0,0 +1,23 @@ +export const ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE = 'AB8250'; +export const ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE = 'AB8251'; +export const ROUTE_INVOCATION_PREPARATION_FAILURE_CODE = 'AB8252'; + +type ProductionRouteInvocationCode = + | typeof ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + | typeof ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; + +export const isProductionRouteInvocationCode = (value: unknown): value is ProductionRouteInvocationCode => + value === ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE + || value === ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE + || value === ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; + +export class ProductionRouteInvocationError extends Error { + readonly code: ProductionRouteInvocationCode; + + constructor(code: ProductionRouteInvocationCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ProductionRouteInvocationError'; + this.code = code; + } +} 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 fee2bda41..61f0ada71 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { Worker } from 'node:worker_threads'; @@ -16,13 +16,17 @@ import { import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; +import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../../core/types.ts'; +import type { + RouteInvocationChildRequest, + RouteInvocationChildResult, +} from './route-invocation-service.ts'; import { + ProductionRouteInvocationError, ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, - type RouteInvocationChildRequest, - type RouteInvocationChildResult, -} from './route-invocation-service.ts'; +} from './route-invocation-production-error.ts'; import type { RouteInvocationProvider, RouteInvocationTiming } from './route-invocation.ts'; interface CompiledCliInvocationModule { @@ -75,21 +79,6 @@ type ProductionRequest = RouteInvocationChildRequest & Readonly<{ readonly artifactRoot: string; }>; -type ProductionRouteInvocationCode = - | typeof ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE - | typeof ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE - | typeof ROUTE_INVOCATION_PREPARATION_FAILURE_CODE; - -class ProductionRouteInvocationError extends Error { - readonly code: ProductionRouteInvocationCode; - - constructor(code: ProductionRouteInvocationCode, message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'ProductionRouteInvocationError'; - this.code = code; - } -} - const preparationFailure = (error: unknown): ProductionRouteInvocationError => error instanceof ProductionRouteInvocationError ? error @@ -276,7 +265,8 @@ const streamFromWorker = ( const worker = new Worker(pathToFileURL(workerPath), { env: { ...process.env, - AGENT_BUNDLE_PLUGIN_ROOT: dirname(request.stateRoot), + [pluginRootEnvAnchor]: request.artifactRoot, + [pluginStateRootEnvAnchor]: request.stateRoot, }, stderr: true, stdout: true, 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 c1d317d65..79e80f390 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -32,6 +32,10 @@ import type { EventTraceEvent } from '../../events/trace.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 { + isProductionRouteInvocationCode, + ProductionRouteInvocationError, +} from './route-invocation-production-error.ts'; import type { RouteInvocation } from './route-invocation-result.ts'; import type { RouteInvocationEventHost, @@ -53,13 +57,6 @@ export const ROUTE_INVOCATION_STALE_REVISION_CODE = 'AB8239'; export const ROUTE_INVOCATION_STALE_REVISION_MESSAGE = 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; -/** Writable state root generated entries mount for the npm-bin cwd fallback. */ -export const routeInvocationStateRoot = (projectRoot: string): string => - join(projectRoot, '.agent-bundle', 'state'); -export const ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE = 'AB8250'; -export const ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE = 'AB8251'; -export const ROUTE_INVOCATION_PREPARATION_FAILURE_CODE = 'AB8252'; - const defaultHistoryLimit = 200; const defaultTimeoutMs = 60_000; const defaultConcurrency = 2; @@ -89,8 +86,8 @@ export interface RouteInvocationPreparedProject { readonly fixtures?: Readonly>; readonly manifest: AgentBundleTestManifest; /** - * Writable state directory generated entries mount for this project - * (`/state`, never the code root). + * Writable framework state beside the epoch, shared with that epoch's dev + * MCP sessions, never the code root. */ readonly stateRoot: string; readonly targets: readonly RouteInvocationEventHost[]; @@ -110,10 +107,7 @@ export interface RouteInvocationServiceOptions { readonly historyLimit?: number; readonly manifest: RouteManifestRouteService; readonly now?: () => Date; - readonly prepared: () => - | RouteInvocationPreparedLease - | RouteInvocationPreparedProject - | Promise; + readonly prepared: () => Promise; readonly registry?: TargetRegistry; readonly renderChild?: ( request: RouteInvocationChildRequest, @@ -192,18 +186,6 @@ const malformed = (): never => { ); }; -const isPreparedLease = ( - value: RouteInvocationPreparedLease | RouteInvocationPreparedProject, -): value is RouteInvocationPreparedLease => - isRecord(value) && typeof value.release === 'function' && isRecord(value.project); - -const bindPrepared = async ( - supplier: RouteInvocationServiceOptions['prepared'], -): Promise => { - const value = await supplier(); - return isPreparedLease(value) ? value : { project: value, release: () => undefined }; -}; - const boundedString = (value: unknown, maxLength = 4_096): value is string => typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); @@ -492,9 +474,10 @@ 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 === 'error') { - const error = new Error(message.error.message); - error.name = message.error.name; - if (message.error.code !== undefined) Object.assign(error, { code: message.error.code }); + const error = isProductionRouteInvocationCode(message.error.code) + ? new ProductionRouteInvocationError(message.error.code, message.error.message) + : new Error(message.error.message); + if (!(error instanceof ProductionRouteInvocationError)) error.name = message.error.name; return settle(() => rejectPromise(error)); } settle(() => resolvePromise(message.result)); @@ -773,7 +756,7 @@ export class RouteInvocationService { let manifest: RouteManifest; let prepared: RouteInvocationPreparedProject; try { - const leased = await bindPrepared(this.#prepared); + const leased = await this.#prepared(); release = leased.release; prepared = leased.project; manifest = this.#manifest.manifest(); @@ -844,12 +827,7 @@ export class RouteInvocationService { : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { const completedAt = this.#now(); - const childCode = typeof error === 'object' && error !== null && 'code' in error - && ( - error.code === ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE - || error.code === ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE - || error.code === ROUTE_INVOCATION_PREPARATION_FAILURE_CODE - ) + const childCode = error instanceof ProductionRouteInvocationError ? error.code : ROUTE_INVOCATION_CHILD_FAILURE_CODE; return failedInvocation({ @@ -871,55 +849,55 @@ export class RouteInvocationService { clearTimeout(timeout); this.#controllers.delete(controller); } - const projectionStartedAt = this.#now(); - const projection = invocationProjection( - route, - request, - rawInput, - child.result, - child.mcp, - child.document, - manifest, - prepared, - this.#registry, - ); - const completedAt = this.#now(); - const canonical = route.kind === 'event-route' - ? (child.input as JsonObject).canonical - : undefined; - return deepFreeze({ - completedAt: completedAt.toISOString(), - context, - ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), - diagnostics: [], - document: child.document, - ...(canonical !== undefined && isJsonRecord(canonical) - ? { - event: { - // Project events reject repeated object references. Keep the - // event detail detached from the identical public `input`. - canonical: jsonObject(canonical)!, - event: route.event!, - ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), - }, - } - : {}), - events: child.events, - id, - input: canonical ?? child.input, - kind: route.kind as RouteInvocationKind, - manifestDigest: manifest.digest, - projection, - providers: child.observed?.providers ?? unobservedProviders(manifest), - ...(child.result === undefined ? {} : { result: child.result }), - routeId: route.id, - source: route.source, - sourceRevision: manifest.sourceRevision, - startedAt: startedAt.toISOString(), - status: 'succeeded', - ...(child.trace === undefined ? {} : { trace: child.trace }), - timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), - }); + const projectionStartedAt = this.#now(); + const projection = invocationProjection( + route, + request, + rawInput, + child.result, + child.mcp, + child.document, + manifest, + prepared, + this.#registry, + ); + const completedAt = this.#now(); + const canonical = route.kind === 'event-route' + ? (child.input as JsonObject).canonical + : undefined; + return deepFreeze({ + completedAt: completedAt.toISOString(), + context, + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), + diagnostics: [], + document: child.document, + ...(canonical !== undefined && isJsonRecord(canonical) + ? { + event: { + // Project events reject repeated object references. Keep the + // event detail detached from the identical public `input`. + canonical: jsonObject(canonical)!, + event: route.event!, + ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), + }, + } + : {}), + events: child.events, + id, + input: canonical ?? child.input, + kind: route.kind as RouteInvocationKind, + manifestDigest: manifest.digest, + projection, + providers: child.observed?.providers ?? unobservedProviders(manifest), + ...(child.result === undefined ? {} : { result: child.result }), + routeId: route.id, + source: route.source, + sourceRevision: manifest.sourceRevision, + startedAt: startedAt.toISOString(), + status: 'succeeded', + ...(child.trace === undefined ? {} : { trace: child.trace }), + timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), + }); } finally { await release?.(); } diff --git a/packages/agent-bundle/src/dev/routes/route-module-loader.ts b/packages/agent-bundle/src/dev/routes/route-module-loader.ts index 33e6ff6e8..6deb492a1 100644 --- a/packages/agent-bundle/src/dev/routes/route-module-loader.ts +++ b/packages/agent-bundle/src/dev/routes/route-module-loader.ts @@ -9,11 +9,6 @@ import ts from 'typescript-5'; import { isRelativeSpecifier } from '../../routes/module-candidates.ts'; import { parseModule } from '../../routes/module-scope.ts'; -/** - * Evaluates one project module from live source: a route, layout, provider, - * or state module by absolute path, as the Workbench's unit-render mode and - * the route-unit harness see it. - */ export interface RouteModuleLoader { readonly load: (source: string) => () => Promise; } @@ -51,12 +46,6 @@ const specifierLiteral = (sourceFile: ts.SourceFile, expression: ts.Expression | ? { end: expression.end, start: expression.getStart(sourceFile), text: expression.text } : undefined; -/** - * The string literals that name modules — `import … from`, `export … from`, - * and a literal dynamic `import()` — in source order. A string literal - * anywhere else (JSX text, a prop, an expression) names no module and is - * never one of them. - */ const moduleSpecifierLiterals = (sourceFile: ts.SourceFile): readonly SpecifierLiteral[] => { const literals: SpecifierLiteral[] = []; const visit = (node: ts.Node): void => { @@ -97,12 +86,6 @@ const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => return rewritten; }; -/** - * Jiti over live project source with the framework's own `react` and - * `@agent-bundle/runtime` instances, no module cache, and the `.js`-to-`.tsx` - * module specifier rewrite. `load(source)` returns a lazy loader in the shape - * the harness registry's `*Loaders` maps take. - */ export const createRouteModuleLoader = (): RouteModuleLoader => { const baseJiti = createJiti(import.meta.url, jitiOptions); const jiti = createJiti(import.meta.url, { diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index fe6074e80..2ac13dc17 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -9,6 +9,7 @@ import { DevCoordinator } from './coordinator.ts'; import { DevPackageBuildService } from './package-build-service.ts'; import { runDevEpochContracts } from './dev-contract-runner.ts'; import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; +import { devEpochStateRoot } from './epoch-paths.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; import { EpochStore, EpochStoreError } from './epoch-store.ts'; @@ -60,7 +61,6 @@ import { ROUTE_INVOCATION_STALE_REVISION_MESSAGE, RouteInvocationRequestError, RouteInvocationService, - routeInvocationStateRoot, } from './routes/route-invocation-service.ts'; import { routeManifestFor } from './routes/route-manifest.ts'; import type { RouteManifestRouteService } from './routes/route-manifest-routes.ts'; @@ -916,6 +916,19 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun .map((target) => target.name) .find((target) => registry.artifactLayout(target).scripts !== undefined); const epochId = artifact.activeEpoch.id; + let reference; + try { + reference = await epochStore.acquireEpochReference(epochId); + } catch (error) { + if (error instanceof EpochStoreError && error.code === 'EPOCH_NOT_FOUND') { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_STALE_REVISION_CODE, + ROUTE_INVOCATION_STALE_REVISION_MESSAGE, + 409, + ); + } + throw error; + } const project = Object.freeze({ ...(scriptTarget === undefined ? {} : { artifact: { epochId, target: scriptTarget } }), manifest: testManifestFromRouteGraph({ @@ -938,22 +951,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), - stateRoot: routeInvocationStateRoot(prepared.root), + stateRoot: devEpochStateRoot(reference.root), targets, }); - let reference; - try { - reference = await epochStore.acquireEpochReference(epochId); - } catch (error) { - if (error instanceof EpochStoreError && error.code === 'EPOCH_NOT_FOUND') { - throw new RouteInvocationRequestError( - ROUTE_INVOCATION_STALE_REVISION_CODE, - ROUTE_INVOCATION_STALE_REVISION_MESSAGE, - 409, - ); - } - throw error; - } return { project, release: () => reference.close(), diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index d2d65ff84..d219fc7d0 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -343,7 +343,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('ad8c21f371af0043464162750a8ed557d968f6155cdd9521ee63c0275253710a'); + .toBe('9f76d9d0664efaf028e15cfd5378e121e352aeea714f5820331dfb64845a821e'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -685,7 +685,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '4e2c248b5358b7e13650f2156cf282b03f6f7ede20e2badabafc4b33ae5b4bd5', + '2948653acc4e918b0cc24b08e4560800316a9cf06ed90194dcb01b7d3e5f6bd0', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -813,19 +813,11 @@ it('imports explicit CLI projections and maps their input before canonical valid expect(source).toContain( '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); - const defaults = source.indexOf('for (const [key, value] of Object.entries(command.projection.defaults))'); - const mapping = source.indexOf('mapped = route.projection.mapInput(mapped)'); - const validation = source.indexOf('return route.module.inputSchema.parse(mapped)'); expect(source).not.toContain('command.mcp?.confirm'); expect(source).not.toContain('confirmationRequiredMessage'); expect(source).not.toContain('delete mapped.yes'); - expect(defaults).toBeGreaterThan(-1); - expect(defaults).toBeLessThan(mapping); - expect(mapping).toBeLessThan(validation); - expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); - expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); - expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`)"); - expect(source).toContain('throw new CliInputError(error instanceof Error ? error.message : String(error));'); + expect(source).toContain('const parseInput = (command, route, input) => mapGeneratedCliInput(command, route.module.inputSchema, route.projection, input);'); + expect(source).toContain('return parseInput(command, route, parseGeneratedCliArgv(command, argv).input);'); expect(source).toContain( "invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", ); @@ -1401,7 +1393,7 @@ it('composes the root and server layout chain around generated MCP routes and ne // throwing route still rejects the Flight root exactly as it does without a layout. expect(source).toContain('let composed = await route.module.default(props);'); expect(source).toContain('if (chain.length === 0) return createElement(route.module.default, props);'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, props, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, props, controller.signal)'); }); it('imports only the layouts some route of the worker composes through, never another server\'s layout', () => { @@ -1515,7 +1507,7 @@ it('hands rendered CLI, projected MCP, and script routes their layout chain and expect(source).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([1]) })'); expect(source).toContain('"tool:curator/inspect": Object.freeze({ id: "tool:curator/inspect", kind: "tool", name: "inspect", serverId: "mcp:curator", module: route1, layouts: Object.freeze([1,0]) })'); expect(source).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route2, layouts: Object.freeze([1]) })'); - expect(source).toContain('renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal)'); + expect(source).toContain('renderAgentFlight(composeLayouts(observedRoute, { ...message.props, signal: controller.signal }, controller.signal)'); }); it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { 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 2647983d3..763cfbd81 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -6,6 +6,7 @@ import { expect, it } from '@rstest/core'; 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 { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../src/core/types.ts'; import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; import { startDevServer } from '../src/dev/workbench-server.ts'; import { createProjectFixture } from './helpers/project-fixture.ts'; @@ -106,7 +107,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', ].join('\n'), 'src/mcp/status/tools/report.tsx': [ - "import { Agent } from '@agent-bundle/runtime';", + "import { Agent, agent } from '@agent-bundle/runtime';", "import { ALIAS_VALUE } from '@fixture/value';", "import { createElement } from 'react';", "import { z } from 'zod';", @@ -114,10 +115,12 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", "export const inputSchema = z.object({ service: z.string().min(1), source: z.string() }).strict();", - 'export const resultSchema = z.object({ alias: z.string(), define: z.string(), service: z.string(), source: z.string() }).strict();', + 'export const resultSchema = z.object({ alias: z.string(), define: z.string(), pluginRoot: z.string(), service: z.string(), source: z.string(), stateRoot: z.string() }).strict();', '', 'export default async function Report({ input }) {', - ' const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, service: input.service, source: input.source };', + ' const context = await agent();', + " if (context.plugin.state !== 'available') throw new Error('plugin unavailable');", + ' const value = { alias: ALIAS_VALUE, define: __ROUTE_INVOCATION_DEFINE__, pluginRoot: context.plugin.value.root, service: input.service, source: input.source, stateRoot: context.plugin.value.stateRoot };', " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `Service ${input.service}`));", '}', '', @@ -192,6 +195,10 @@ 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 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(artifactRoot, 'state'); const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ input: { service: 'catalog', source: 'api' }, routeId: 'tool:status/report' }), headers, @@ -206,8 +213,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.result).toEqual({ alias: 'aliased', define: 'defined', + pluginRoot: artifactRoot, service: 'catalog', source: 'api', + stateRoot, }); expect(tool.invocation.providers).toEqual([ expect.objectContaining({ durationMs: expect.any(Number), id: 'provider:clock', name: 'clock', status: 'mounted' }), @@ -340,19 +349,21 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(projectedCli.invocation.result).toMatchObject({ alias: 'aliased', define: 'defined', + pluginRoot: artifactRoot, service: 'projection', source: 'cli-projection', + stateRoot, }); - 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 binName = (await readdir(join(artifactRoot, 'bin'))) .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); const generatedBin = await runNodeScript({ args: [join(artifactRoot, 'bin', binName), 'report', '--name', 'projection', '--json'], cwd: project.root, - env: { AGENT_BUNDLE_PLUGIN_ROOT: join(project.root, '.agent-bundle') }, + env: { + [pluginRootEnvAnchor]: artifactRoot, + [pluginStateRootEnvAnchor]: stateRoot, + }, }); expect(generatedBin.code, generatedBin.stderr).toBe(0); expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 5843ffe4c..2453179eb 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -13,9 +13,9 @@ import { RouteInvocationRequestError, invocationSummary, parseRouteInvocationRequest, - routeInvocationStateRoot, type RouteInvocationChildRequest, type RouteInvocationChildResult, + type RouteInvocationPreparedProject, type RouteInvocationServiceOptions, } from '../src/dev/routes/route-invocation-service.ts'; import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; @@ -169,6 +169,11 @@ const childResult = (request: RouteInvocationChildRequest): RouteInvocationChild renderDurationMs: 1, }); +const preparedLease = async (project: RouteInvocationPreparedProject) => ({ + project, + release: () => undefined, +}); + it('aborts and drains a running render when the service closes', async () => { let releases = 0; const started = deferred(); @@ -176,10 +181,10 @@ it('aborts and drains a running render when the service closes', async () => { manifest: { manifest: () => catalog('digest', 'revision'), }, - prepared: () => ({ + prepared: async () => ({ project: { manifest: { projectRoot: '/project' } as never, - stateRoot: routeInvocationStateRoot('/project'), + stateRoot: '/project/.agent-bundle/epochs/epoch-1/state', targets: ['claude'], }, release: () => { @@ -211,15 +216,16 @@ it('rejects a queued invocation when the published revision moves before the slo const executed: RouteInvocationChildRequest[] = []; let releases = 0; const projectRoot = '/project'; + const epochRoot = join(projectRoot, '.agent-bundle', 'epochs', 'epoch-1'); const service = new RouteInvocationService({ concurrency: 1, manifest: { manifest: () => catalog(digest, sourceRevision), }, - prepared: () => ({ + prepared: async () => ({ project: { manifest: { projectRoot } as never, - stateRoot: routeInvocationStateRoot(projectRoot), + stateRoot: join(epochRoot, 'state'), targets: ['claude'], }, release: () => { @@ -249,7 +255,7 @@ it('rejects a queued invocation when the published revision moves before the slo status: 'succeeded', }); expect(executed).toHaveLength(1); - expect(executed[0]?.stateRoot).toBe(routeInvocationStateRoot(projectRoot)); + expect(executed[0]?.stateRoot).toBe(join(epochRoot, 'state')); expect(executed[0]?.stateRoot).not.toBe(projectRoot); await expect(second).rejects.toMatchObject({ code: ROUTE_INVOCATION_STALE_REVISION_CODE, @@ -313,14 +319,14 @@ const routeProject = async ( }; const prepared = Object.freeze({ manifest: testManifestFromRouteGraph({ graph, projectRoot: root }), - stateRoot: routeInvocationStateRoot(root), + stateRoot: join(root, 'state'), targets: ['claude' as const], }); return { root, service: (options = {}) => new RouteInvocationService({ manifest: { manifest: () => manifest }, - prepared: () => prepared, + prepared: () => preparedLease(prepared), timeoutMs: options.timeoutMs, }), }; @@ -360,11 +366,6 @@ const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => routeProject( await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-tsx-sibling-')), 'report', @@ -520,9 +521,9 @@ const telemetryService = ( renderChild: NonNullable, ): RouteInvocationService => new RouteInvocationService({ manifest: { manifest: telemetryManifest }, - prepared: () => ({ + prepared: () => preparedLease({ manifest: { projectRoot: '/project' } as never, - stateRoot: routeInvocationStateRoot('/project'), + stateRoot: '/project/state', targets: ['claude'], }), renderChild, diff --git a/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts index 10671b202..97da0a72a 100644 --- a/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts +++ b/packages/agent-bundle/tests/route-unit/route-module-loader.test.ts @@ -9,13 +9,6 @@ import { expectDocument } from '../../src/test/matchers.ts'; import { renderRouteEvents } from '../../src/test/render.ts'; import type { AgentRouteModule } from '../../src/test/types.ts'; -/** - * Project code names its TypeScript siblings by their emitted `.js` name. The - * loader points a `.js` specifier whose source is a `.tsx` file at that file - * (jiti retries `.ts` on its own, and a real `.js` sibling is loaded as is), - * and touches nothing but module specifiers: `'./panel.js'` rendered as text - * stays `./panel.js`, as the compiled program prints it (#600). - */ const files: Readonly> = { 'count.ts': "export const count = 'from count.ts';\n", 'label.tsx': "export const label = 'from label.tsx';\n", diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 0adbdbe83..4ec7da22e 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -430,12 +430,16 @@ it('runs event-route preflight in the per-host wrapper before shared IPC', () => expect(staticImportSpecifiers(source).filter((specifier) => specifier === 'react' || specifier.startsWith('react/') || specifier.endsWith('.tsx'))).toEqual([]); + const prepareBody = source.slice( + firstIndex(source, 'export const prepareRouteInvocation'), + firstIndex(source, 'const runExecutor'), + ); + expect(firstIndex(prepareBody, 'validateNativeEventEnvelope')).toBeLessThan(firstIndex(prepareBody, 'createCanonicalEventProps')); + expect(firstIndex(prepareBody, 'createCanonicalEventProps')).toBeLessThan(firstIndex(prepareBody, 'executeEventPreflight')); + expect(firstIndex(prepareBody, 'executeEventPreflight')).toBeLessThan(firstIndex(prepareBody, 'projectEventPreflightResult')); const runBody = source.slice(firstIndex(source, 'const run = async () => {')); - expect(firstIndex(runBody, 'validateNativeEventEnvelope')).toBeLessThan(firstIndex(runBody, 'createCanonicalEventProps')); - expect(firstIndex(runBody, 'createCanonicalEventProps')).toBeLessThan(firstIndex(runBody, 'executeEventPreflight')); - expect(firstIndex(runBody, 'executeEventPreflight')).toBeLessThan(runBody.search(/['"]execute['"]/u)); + expect(firstIndex(runBody, 'await prepareRouteInvocation')).toBeLessThan(runBody.search(/['"]execute['"]/u)); expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); - expect(firstIndex(runBody, 'projectEventPreflightResult')).toBeGreaterThan(firstIndex(runBody, 'executeEventPreflight')); }); it('crosses the standalone Worker boundary only after preflight returns execute', () => { @@ -463,8 +467,15 @@ it('crosses the standalone Worker boundary only after preflight returns execute' expect(entry.executeVirtualSource).toContain('new URL(/* webpackIgnore: true */ "./hooks-flight.mjs", import.meta.url)'); expect(entry.executeVirtualSource).toContain('createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal, observation)'); expect(source).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + const prepareBody = source.slice( + firstIndex(source, 'export const prepareRouteInvocation'), + firstIndex(source, 'const runExecutor'), + ); + expect(firstIndex(prepareBody, 'validateNativeEventEnvelope')).toBeLessThan(firstIndex(prepareBody, 'createCanonicalEventProps')); + expect(firstIndex(prepareBody, 'createCanonicalEventProps')).toBeLessThan(firstIndex(prepareBody, 'executeEventPreflight')); + expect(firstIndex(prepareBody, 'executeEventPreflight')).toBeLessThan(firstIndex(prepareBody, 'projectEventPreflightResult')); const runBody = source.slice(firstIndex(source, 'const run = async () => {')); - expect(firstIndex(runBody, 'executeEventPreflight')).toBeLessThan(runBody.search(/['"]execute['"]/u)); + expect(firstIndex(runBody, 'await prepareRouteInvocation')).toBeLessThan(runBody.search(/['"]execute['"]/u)); expect(runBody.search(/['"]execute['"]/u)).toBeLessThan(firstIndex(runBody, 'runExecutor')); }); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 696c0faaa..57f177b58 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -201,6 +201,8 @@ providers, persistent state, CLI `mapInput` and confirmation, event preflight, a semantics match the installed artifact. `mode: "unit-render"` is an explicit component-preview fallback: it loads live source with the route-unit harness and mounts disposable state. It is not an artifact-parity receipt. +Production state lives beside the published epoch at `/state`, shared with that epoch's dev +MCP sessions through `AGENT_BUNDLE_STATE_ROOT`. The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 2140311e4..e0e39ee73 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -169,6 +169,8 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 alias 与 define、生成入口的请求作用域、providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲染语义都与已安装制品一致。`mode: "unit-render"` 是显式的组件预览后备模式: 它通过 route-unit 测试工具加载实时源码并挂载一次性状态,不能作为制品一致性的证明。 +生产状态位于已发布 epoch 旁的 `/state`,并通过 `AGENT_BUNDLE_STATE_ROOT` 与该 epoch 的开发期 +MCP 会话共享。 该信封携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、投影、 诊断与执行计时。`providers` 列出子进程实际测到的结果(`mounted`、`failed` 或 `skipped`),仅在测到 From fd46e89ea8f88a29e8ae00cd2513c7b55e49f02c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:46:02 +0000 Subject: [PATCH 37/70] drop LANE-NOTES --- LANE-NOTES.md | 68 --------------------------------------------------- 1 file changed, 68 deletions(-) delete mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md deleted file mode 100644 index 91f029865..000000000 --- a/LANE-NOTES.md +++ /dev/null @@ -1,68 +0,0 @@ -# Lane A5 notes - -## Behavior - -- Workbench production invocation workers now receive - `AGENT_BUNDLE_PLUGIN_ROOT=/.agent-bundle/epochs/` and - `AGENT_BUNDLE_STATE_ROOT=/.agent-bundle/epochs//state`. - The state root is derived from the leased epoch root, shared with that - epoch's dev MCP sessions, and never derived from the code root. -- Generated CLI parity tests use the same code-root and state-root environment - as Workbench production invocations. -- Event wrapper source-order assertions cover validation, canonical props, - preflight, projection, the execute gate, and the Worker boundary in their - refactored functions. Generated entry hashes and structural assertions match - the current templates. -- The production invocation error type lives in a dependency-free leaf module. - This prevents ordinary packed CLI builds from eagerly loading the optional - `@agent-bundle/runtime` peer while preserving one error-class identity. -- English and Chinese Workbench documentation describe epoch-local persistent - state and `AGENT_BUNDLE_STATE_ROOT`. - -## Files - -- `.changeset/wb600-pr2a-execution-parity.md` -- `packages/agent-bundle/src/dev/epoch-paths.ts` -- `packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-production-error.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-production.ts` -- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` -- `packages/agent-bundle/src/dev/routes/route-module-loader.ts` -- `packages/agent-bundle/src/dev/workbench-server.ts` -- `packages/agent-bundle/tests/entry-shell.test.ts` -- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` -- `packages/agent-bundle/tests/route-invocation-service.test.ts` -- `packages/agent-bundle/tests/route-unit/route-module-loader.test.ts` -- `packages/agent-bundle/tests/target-hook-contract.test.ts` -- `website/docs/en/guide/development/workbench.mdx` -- `website/docs/zh/guide/development/workbench.mdx` - -## Deslop - -Deslop: GPT-5.6 Sol, 11 edits. - -The pass standardized the prepared-project supplier on an async lease, -replaced structural error sniffing with the single production error class, -normalized the invocation body, tightened cross-process error reconstruction, -removed five restating comments, and condensed the changeset summary. - -## Verification - -- `pnpm build && npx tsc --noEmit && npx tsc --project packages/workbench/tsconfig.json --noEmit && pnpm lint` -- `pnpm test:unit` — 4,140 passed, 6 skipped -- `npx rstest --config rstest.route-unit.config.ts` — 89 passed -- `npx rstest --config rstest.projection.config.ts` — 190 passed -- `pnpm test:integration:run` — 1,150 passed, 4 skipped -- `pnpm docs:site:build` — locale parity passed and 0 broken links across - 27,307 anchors -- `git diff --check` - -The first integration run exposed the packed CLI's eager import of the optional -runtime peer. After moving the production error type to a leaf module, the -focused packed-consumer regression and the complete integration pool passed. - -## Open risks - -None known. The build and test logs retain pre-existing Rslib top-level-await, -Node SQLite experimental, and occasional test-process listener warnings. From a89aa6cd627b47d77f9c244c2243fd6713c25b51 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:49:31 +0000 Subject: [PATCH 38/70] changeset: name #643 --- .changeset/wb600-pr2a-execution-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index 7b16050ea..b44f633e4 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#600) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) From 2f2ad57d7fdcb80d14687f41f4921ae66e377a08 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:17:09 +0000 Subject: [PATCH 39/70] fix(dev): retain state across epoch rebuilds --- .changeset/wb600-pr2a-execution-parity.md | 2 +- LANE-NOTES.md | 31 +++++++++++++++++++ packages/agent-bundle/src/dev/epoch-paths.ts | 3 -- .../src/dev/mcp-session/mcp-session-launch.ts | 7 ++--- packages/agent-bundle/src/dev/state-paths.ts | 3 ++ .../agent-bundle/src/dev/workbench-server.ts | 4 +-- .../tests/mcp-session-service.test.ts | 4 +-- .../tests/route-invocation-dev-server.test.ts | 9 +++++- website/docs/en/guide/authoring/mcp.mdx | 6 ++-- .../docs/en/guide/development/workbench.mdx | 5 +-- website/docs/zh/guide/authoring/mcp.mdx | 5 +-- .../docs/zh/guide/development/workbench.mdx | 5 +-- 12 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 LANE-NOTES.md delete mode 100644 packages/agent-bundle/src/dev/epoch-paths.ts create mode 100644 packages/agent-bundle/src/dev/state-paths.ts diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index b44f633e4..7e04e414d 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent project state across rebuilt epochs, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..9bed133bd --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,31 @@ +# A9 lane notes + +## Changed + +- Replaced the epoch-scoped helper with `devStateRoot(projectRoot)`. +- Workbench route invocations and dev MCP session launches now share the same + framework-owned state root. +- Extended stateful route parity across a successful republish; unit-render + remains isolated. +- Updated the MCP session environment assertion and en/zh Workbench and MCP + documentation. + +## Path contract + +- Before: `/.agent-bundle/epochs//state` +- After: `/.agent-bundle/state` + +`AGENT_BUNDLE_PLUGIN_ROOT` remains the selected epoch. Isolated unit-render +continues to create and remove its own temporary state root. + +## Tests + +- Focused integration: `route-invocation-dev-server.test.ts` and + `mcp-session-service.test.ts` +- Full gate: `pnpm build && pnpm typecheck && pnpm lint && pnpm test:unit`, + relevant integration pool, and `pnpm docs:site:build` + +## Ambiguities + +- `/tmp/wb600/notes-pr2a/A2.md` and `A5.md` were not present when this lane + began, so their requested production-path rationale could not be read. diff --git a/packages/agent-bundle/src/dev/epoch-paths.ts b/packages/agent-bundle/src/dev/epoch-paths.ts deleted file mode 100644 index dd1b203be..000000000 --- a/packages/agent-bundle/src/dev/epoch-paths.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { join } from 'node:path'; - -export const devEpochStateRoot = (epochRoot: string): string => join(epochRoot, 'state'); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts index 2855c0555..57f0b82af 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts @@ -4,7 +4,7 @@ import { assertInside } from '../../core/paths.ts'; import { pluginStateRootEnvAnchor } from '../../core/types.ts'; import { resolveMcpPathTokens } from '../../services/mcp-path-tokens.ts'; import type { ModernMcpServer, TargetMcpRuntimeContract } from '../../services/mcp-runtime.ts'; -import { devEpochStateRoot } from '../epoch-paths.ts'; +import { devStateRoot } from '../state-paths.ts'; import type { McpSessionInspectorConfig } from './mcp-session-protocol.ts'; export interface ResolvedMcpSessionServer { @@ -112,10 +112,7 @@ export const resolveMcpSessionLaunch = (options: ResolveMcpSessionLaunchOptions) const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); - // A dev session runs a build epoch, not an install: its framework state - // lives beside that epoch and goes with it, instead of accumulating one - // user-data root per rebuild. Declared env still wins, as for every key. - const stateRoot = devEpochStateRoot(options.resolved.targetRoot); + const stateRoot = devStateRoot(options.workspaceRoot); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, diff --git a/packages/agent-bundle/src/dev/state-paths.ts b/packages/agent-bundle/src/dev/state-paths.ts new file mode 100644 index 000000000..252a72b18 --- /dev/null +++ b/packages/agent-bundle/src/dev/state-paths.ts @@ -0,0 +1,3 @@ +import { join } from 'node:path'; + +export const devStateRoot = (projectRoot: string): string => join(projectRoot, '.agent-bundle', 'state'); diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 2ac13dc17..630df5af9 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -9,7 +9,7 @@ import { DevCoordinator } from './coordinator.ts'; import { DevPackageBuildService } from './package-build-service.ts'; import { runDevEpochContracts } from './dev-contract-runner.ts'; import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; -import { devEpochStateRoot } from './epoch-paths.ts'; +import { devStateRoot } from './state-paths.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; import { EpochStore, EpochStoreError } from './epoch-store.ts'; @@ -951,7 +951,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), targets, }), - stateRoot: devEpochStateRoot(reference.root), + stateRoot: devStateRoot(root), targets, }); return { diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 51b860caf..c66cdf25a 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -255,9 +255,7 @@ it('keeps one generated server and plugin-data directory bound to the selected e readonly stateRoot: string; }; expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1')); - // Dev sessions pin the framework state root beside the epoch (#637), so a - // rebuild never accumulates another `~/.agent-bundle/state` directory. - expect(firstState.stateRoot).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'state')); + expect(firstState.stateRoot).toBe(join(root, '.agent-bundle', 'state')); expect(firstState.inherited).toBe('resolved-on-open'); await expect(access(firstState.data)).resolves.toBeUndefined(); expect(session.events().some((event) => event.type === 'stderr' && event.text === 'fixture stderr\n')).toBe(true); 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 763cfbd81..cf5d34723 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -198,7 +198,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim 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(artifactRoot, 'state'); + 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, @@ -505,6 +505,13 @@ it('invokes compiled tool and event routes through the foreground server', { tim result: { service: 'rebuilt-published' }, status: 'succeeded', }); + const republishedEpoch = server.status().artifact; + if (republishedEpoch.state !== 'active') throw new Error('Expected an active rebuilt epoch.'); + expect(republishedEpoch.activeEpoch.id).not.toBe(activeEpoch.activeEpoch.id); + const republishedCounter = await counter(); + const republishedIsolatedCounter = await counter('unit-render'); + expect(republishedCounter.invocation.result).toEqual({ count: 3 }); + expect(republishedIsolatedCounter.invocation.result).toEqual({ count: 1 }); const missingApi = await fetch(`${server.url}/api/nope`); expect(missingApi.status).toBe(404); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 704c86133..f66283b0a 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -1141,8 +1141,10 @@ The proxy discovers the loopback server through the project's development lock a the stable Streamable HTTP endpoint at `/mcp/host/`. `--target` defaults to `portable`, and `--url` overrides discovery. Successful rebuilds keep the stdio connection open, route new calls to the active epoch, let admitted calls finish against their original epoch, and -forward MCP catalog change notifications. If the epoch or development server disappears, the -proxy fails closed with an MCP error and an `AB8024` or `AB8025` diagnostic. +forward MCP catalog change notifications. Dev MCP sessions and Workbench invocations share the +project's `/.agent-bundle/state` root, outside the retired epoch directories. If the +epoch or development server disappears, the proxy fails closed with an MCP error and an `AB8024` +or `AB8025` diagnostic. The endpoint is intentionally unauthenticated because the development server binds only to loopback and is never exposed beyond the local machine. diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 57f177b58..e7a7e988b 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -201,8 +201,9 @@ providers, persistent state, CLI `mapInput` and confirmation, event preflight, a semantics match the installed artifact. `mode: "unit-render"` is an explicit component-preview fallback: it loads live source with the route-unit harness and mounts disposable state. It is not an artifact-parity receipt. -Production state lives beside the published epoch at `/state`, shared with that epoch's dev -MCP sessions through `AGENT_BUNDLE_STATE_ROOT`. +Production state lives at `/.agent-bundle/state`, outside the published epochs that +retirement removes. It is shared with dev MCP sessions through `AGENT_BUNDLE_STATE_ROOT`, so it +survives a successful republish; `unit-render` still uses a fresh temporary state root per run. The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings. diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 6c6a4ffb1..77890aa0b 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -987,8 +987,9 @@ npx agent-bundle mcp run --artifact artifact --target claude --server curator 该代理通过项目的开发锁发现 loopback 服务器,并连接到位于 `/mcp/host/` 的稳定 Streamable HTTP 端点。`--target` 默认为 `portable`,`--url` 可覆盖发现过程。重建成功时会保持 stdio 连接不断、 -把新调用路由到当前 epoch、让已受理的调用在其原始 epoch 上完成,并转发 MCP 目录变更通知。若 epoch 或 -开发服务器消失,代理会以 MCP 错误以及 `AB8024` 或 `AB8025` 诊断失败关闭。 +把新调用路由到当前 epoch、让已受理的调用在其原始 epoch 上完成,并转发 MCP 目录变更通知。开发期 MCP +会话与 Workbench 调用共享项目的 `/.agent-bundle/state` 根目录,它不在会被退役的 epoch +目录之内。若 epoch 或开发服务器消失,代理会以 MCP 错误以及 `AB8024` 或 `AB8025` 诊断失败关闭。 该端点刻意不做认证,因为开发服务器只绑定 loopback,绝不会暴露到本机之外。 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index e0e39ee73..b1af617df 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -169,8 +169,9 @@ Workbench 外壳,因此路由、trace、Problems 与 Advanced 的深度链接 alias 与 define、生成入口的请求作用域、providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲染语义都与已安装制品一致。`mode: "unit-render"` 是显式的组件预览后备模式: 它通过 route-unit 测试工具加载实时源码并挂载一次性状态,不能作为制品一致性的证明。 -生产状态位于已发布 epoch 旁的 `/state`,并通过 `AGENT_BUNDLE_STATE_ROOT` 与该 epoch 的开发期 -MCP 会话共享。 +生产状态位于 `/.agent-bundle/state`,在 retirement 会移除的已发布 epoch 之外。它通过 +`AGENT_BUNDLE_STATE_ROOT` 与开发期 MCP 会话共享,因此能跨一次成功的重新发布保留;`unit-render` +仍为每次运行使用新的临时状态根。 该信封携带规范输入、请求上下文、providers、有序渲染事件、最终的 Agent Document、结构化结果、投影、 诊断与执行计时。`providers` 列出子进程实际测到的结果(`mounted`、`failed` 或 `skipped`),仅在测到 From 7fcc028fd996cd2134c2ce445497a340b51fda19 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:32:57 +0000 Subject: [PATCH 40/70] feat(workbench): select invocation surfaces --- .changeset/wb600-pr2a-execution-parity.md | 2 +- LANE-NOTES.md | 80 ++++++ docs/diagnostics.md | 2 +- .../agent-bundle/src/contracts/invocations.ts | 2 +- .../src/dev/routes/application-tree.ts | 5 +- .../src/dev/routes/route-invocation-child.ts | 3 +- .../dev/routes/route-invocation-production.ts | 30 +-- .../dev/routes/route-invocation-service.ts | 237 +++++++++++++----- .../src/dev/routes/route-invocation.ts | 32 +-- .../tests/application-tree.test.ts | 12 + .../tests/route-invocation-dev-server.test.ts | 57 ++++- .../tests/route-invocation-service.test.ts | 32 ++- .../src/application/event-route-workspace.tsx | 18 +- .../executable-route-workspace.tsx | 69 ++++- .../src/application/invocation-client.ts | 16 ++ .../src/application/invocation-model.ts | 1 + .../src/application/route-input-editor.tsx | 54 ++-- .../src/application/runtime-backend.ts | 17 +- .../workbench/src/application/workspace.css | 9 +- .../tests/dev-server-backend.test.ts | 2 + .../tests/event-route-workspace.test.ts | 12 +- .../workbench/tests/invocation-client.test.ts | 2 + .../workbench/tests/invocation-model.test.ts | 2 + .../tests/route-input-editor.test.ts | 20 +- .../workbench/tests/route-workspace.test.ts | 21 +- .../tests/support/workspace-fixtures.ts | 14 ++ packages/workbench/tests/trace-page.test.ts | 1 + .../docs/en/guide/development/workbench.mdx | 39 ++- .../docs/zh/guide/development/workbench.mdx | 31 ++- 29 files changed, 637 insertions(+), 185 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/.changeset/wb600-pr2a-execution-parity.md b/.changeset/wb600-pr2a-execution-parity.md index b44f633e4..5cbe2f9df 100644 --- a/.changeset/wb600-pr2a-execution-parity.md +++ b/.changeset/wb600-pr2a-execution-parity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Add explicit `unit-render` preview mode and diagnostics `AB8239`, `AB8250`–`AB8252`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) +Execute Workbench route invocations through leased published artifacts by default, including generated CLI projection, event preflight, compiler aliases, persistent epoch state, and measured runtime telemetry. Select MCP, CLI, event, script, or explicit `unit-render` surfaces independently from the canonical operation id, record the resolved surface, and add diagnostics `AB8239`, `AB8250`–`AB8254`; preserve literal text while rewriting `.js` module specifiers to `.tsx` sources. (#643) diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..99c490a99 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,80 @@ +# Lane A6 notes + +## What changed + +- Replaced the route invocation request's top-level `mode`, `args`, and `event` fields with the + discriminated `surface` union. +- Kept `routeId` as the canonical operation id and recorded the resolved surface on every + successful or failed invocation and summary. +- Routed a tool's CLI surface through the published generated bin's existing + `prepareRouteInvocation(routeId, argv)` export after validating the selected manifest command. + Confirmation, projection defaults, `mapInput`, and canonical schema validation remain owned by + that generated entry path. +- Kept projected tools as one Application tree leaf, attached their manifest command to that leaf, + and added the MCP / projected CLI / Unit render selector, argv projection, event host/fixture + surface input, and operation-plus-surface header. +- Updated browser decoding, runtime-backend envelopes, callers, English and Chinese Workbench + docs, diagnostics, and the existing PR changeset. + +## Request and response shape + +Before: + +```ts +{ + routeId: string; + input?: JsonValue; + args?: readonly string[]; + event?: { host?: 'claude' | 'codex' | 'cursor'; fixtureId?: string }; + mode?: 'production' | 'unit-render'; +} +``` + +After: + +```ts +{ + routeId: string; + input?: JsonValue; + surface?: + | { kind: 'mcp' } + | { kind: 'cli'; command: string; args: readonly string[] } + | { kind: 'event'; host?: 'claude' | 'codex' | 'cursor'; fixtureId?: string } + | { kind: 'script' } + | { kind: 'unit-render' }; +} +``` + +Every result/summary now has required `surface: RouteInvocationSurface`, resolved from the route +kind when omitted. Defaults are MCP for tool/resource/prompt, event for event routes, script for +scripts, and the compiled command with empty argv for standalone CLI routes. Unit render is never +a default. + +## Diagnostics allocated + +- `AB8253`: selected CLI command does not project onto the canonical operation. +- `AB8254`: a projected `cli:` id was submitted instead of the canonical `tool:` id and + CLI surface. + +A7 may allocate in the same range; renumber these two during integration if needed. + +## Tests + +- Strict request-union parsing and rejection of legacy fields. +- Resolved default MCP surface and explicit unit-render surface recording. +- Generated projected-CLI parity (`mapInput` result equals generated CLI output). +- Projected CLI result projection, mismatched command `400 AB8253`, and duplicate projected + `cli:` operation `400 AB8254`. +- Application-tree command attachment without a duplicate CLI leaf. +- Workbench selector/header and event host/fixture request shape. +- Browser decoder and all typed request/result fixtures updated. + +## Decisions / ambiguity + +- `surface.command` uses the manifest command path joined with spaces (the same display and + invocation value used by the generated CLI request context). A duplicate CLI route id uses the + path joined with `/`, matching standalone `cli:` ids. +- CLI surface selection is permitted only for standalone CLI routes or explicit tool projections; + bulk MCP command generation is not treated as the tool's selectable projected CLI surface. +- Explicit unit render remains available for component routes but is rejected for scripts, which + have no isolated component render path. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 1a6430a8f..5072d14dc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -46,7 +46,7 @@ even when no error diagnostic was reported. | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | | `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.) | | `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`–`AB8252` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, and `AB8252` compiled CLI projection or event preflight preparation failed. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`. | +| `AB8250`–`AB8254` | 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, and `AB8254` a projected `cli:` id was used instead of its canonical `tool:/` id plus CLI 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`. | | `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/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts index e6756ea73..1942bae9b 100644 --- a/packages/agent-bundle/src/contracts/invocations.ts +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -7,7 +7,6 @@ export type { RouteInvocationCliProjection, RouteInvocationEvent, RouteInvocationEventHost, - RouteInvocationEventOptions, RouteInvocationEventPayload, RouteInvocationHostProjection, RouteInvocationKind, @@ -18,6 +17,7 @@ export type { RouteInvocationRequest, RouteInvocationStatus, RouteInvocationSummary, + RouteInvocationSurface, RouteInvocationTiming, } from '../dev/routes/route-invocation.ts'; export type { diff --git a/packages/agent-bundle/src/dev/routes/application-tree.ts b/packages/agent-bundle/src/dev/routes/application-tree.ts index afe91621c..a4a69f760 100644 --- a/packages/agent-bundle/src/dev/routes/application-tree.ts +++ b/packages/agent-bundle/src/dev/routes/application-tree.ts @@ -201,9 +201,12 @@ const mcpServers = ( inspection: ApplicationTreeManifestSources['inspection'], ): readonly ApplicationServerGroup[] => { const servers = new Map(); + const commands = new Map((manifest?.cli?.commands ?? []) + .filter((command) => command.projection !== undefined) + .map((command) => [command.routeId, command])); for (const server of manifest?.servers ?? []) { const subgroups = mcpKinds.flatMap((kind) => { - const leaves = leavesForRoutes(server.routes.filter((route) => route.kind === kind)); + const leaves = leavesForRoutes(server.routes.filter((route) => route.kind === kind), commands); return leaves.length === 0 ? [] : [Object.freeze({ 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 bc56e875b..b89c981db 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -60,7 +60,6 @@ const renderUnitRoute = async (request: RouteInvocationChildRequest): Promise => - request.mode === 'unit-render' + request.surface.kind === 'unit-render' ? renderUnitRoute(request) : renderProductionRoute(request); 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 61f0ada71..dcaf2b0b9 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -114,7 +114,7 @@ const eventWrapperPath = ( request: ProductionRequest, ): string | undefined => { const event = request.manifest.routes[request.routeId]?.event; - const target = request.eventTarget; + const target = request.surface.kind === 'event' ? request.surface.host : undefined; if (event === undefined || target === undefined) return undefined; const stem = `event-route-${event.replace('/', '-')}`; const suffixed = join(request.artifactRoot, 'hooks', `${stem}.${target}.mjs`); @@ -129,10 +129,7 @@ const prepareInput = async ( signal: AbortSignal, ): Promise> => { const route = request.manifest.routes[request.routeId]; - const cliCommand = request.args === undefined - ? undefined - : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); - if (route?.kind === 'cli' || cliCommand !== undefined) { + if (request.surface.kind === 'cli') { const binRoot = join(request.artifactRoot, 'bin'); if (!existsSync(binRoot)) { throw new ProductionRouteInvocationError( @@ -147,7 +144,7 @@ const prepareInput = async ( const module = await importedModule>(join(binRoot, name)); if (typeof module.prepareRouteInvocation !== 'function') continue; return { - input: module.prepareRouteInvocation(request.routeId, request.args ?? []) as JsonValue, + input: module.prepareRouteInvocation(request.routeId, request.surface.args) as JsonValue, }; } throw new ProductionRouteInvocationError( @@ -174,21 +171,21 @@ const invocationFor = ( ): AgentRenderInvocation => { const route = request.manifest.routes[request.routeId]; if (route === undefined) throw new Error(`Route ${JSON.stringify(request.routeId)} is absent from the compiled manifest.`); - const cliCommand = request.args === undefined - ? undefined - : request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); - if (cliCommand !== undefined) { - return { kind: 'cli', props: { args: request.args ?? [], command: cliCommand.path.join(' ') } }; + if (request.surface.kind === 'cli') { + return { + kind: 'cli', + props: { args: request.surface.args, command: request.surface.command }, + }; } switch (route.kind) { case 'cli': { const command = request.manifest.cliCommands.find((candidate) => candidate.routeId === request.routeId); if (command === undefined) throw new Error(`CLI route ${JSON.stringify(request.routeId)} has no compiled command.`); - return { kind: 'cli', props: { args: request.args ?? [], command: command.path.join(' ') } }; + return { kind: 'cli', props: { args: [], command: command.path.join(' ') } }; } case 'script': { const script = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId); - return { kind: 'script', props: { input: request.args ?? [], name: script?.name ?? request.routeId } }; + return { kind: 'script', props: { input: [], name: script?.name ?? request.routeId } }; } case 'event-route': return { @@ -214,10 +211,7 @@ const invocationFor = ( const candidatesFor = async (request: ProductionRequest): Promise => { const route = request.manifest.routes[request.routeId]; if (route === undefined) return Object.freeze([]); - if ( - request.args !== undefined - && request.manifest.cliCommands.some((candidate) => candidate.routeId === request.routeId) - ) { + if (request.surface.kind === 'cli') { return workerFiles(join(request.artifactRoot, 'bin')); } switch (route.kind) { @@ -409,7 +403,7 @@ const streamFromWorker = ( const routeProps = (request: ProductionRequest, input: JsonValue): Readonly> => { const kind = request.manifest.routes[request.routeId]?.kind; - if (kind === 'script') return { argv: request.args ?? [] }; + if (kind === 'script') return { argv: [] }; return kind === 'event-route' ? { canonical: (input as { readonly canonical?: unknown }).canonical, 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 79e80f390..1da110e32 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -42,10 +42,11 @@ import type { RouteInvocationKind, RouteInvocationProvider, RouteInvocationRequest, + RouteInvocationSurface, RouteInvocationSummary, RouteInvocationTiming, } from './route-invocation.ts'; -import type { RouteManifest, RouteManifestRoute } from './route-manifest.ts'; +import type { RouteManifest, RouteManifestCliCommand, RouteManifestRoute } from './route-manifest.ts'; import type { RouteManifestRouteService } from './route-manifest-routes.ts'; export const ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE = 'AB8231'; @@ -54,6 +55,8 @@ export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; 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_STALE_REVISION_MESSAGE = 'The published route manifest changed while this invocation waited to run. Retry against the current revision.'; @@ -118,16 +121,14 @@ export interface RouteInvocationServiceOptions { } export interface RouteInvocationChildRequest { - readonly args?: readonly string[]; readonly artifactEpoch?: string; readonly artifactRoot?: string; readonly context: RequestContextProvenance; - readonly eventTarget?: RouteInvocationEventHost; readonly input: JsonValue; readonly manifest: AgentBundleTestManifest; - readonly mode?: 'production' | 'unit-render'; readonly routeId: string; readonly stateRoot: string; + readonly surface: RouteInvocationSurface; } export interface RouteInvocationChildResult { @@ -160,6 +161,8 @@ export type RouteInvocationChildResponse = export class RouteInvocationRequestError extends Error { readonly code: | typeof ROUTE_INVOCATION_MALFORMED_REQUEST_CODE + | typeof ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE + | typeof ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE | typeof ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE | typeof ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE @@ -189,35 +192,52 @@ const malformed = (): never => { const boundedString = (value: unknown, maxLength = 4_096): value is string => typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); -const eventOptions = (value: unknown): RouteInvocationRequest['event'] => { - if (!isRecord(value) || !hasOnlyOwnKeys(value, ['fixtureId', 'host'])) return malformed(); - const fixtureId = value.fixtureId; - const host = value.host; - if (fixtureId !== undefined && !boundedString(fixtureId)) return malformed(); - if (host !== undefined && (typeof host !== 'string' || !concreteHosts.has(host as RouteInvocationEventHost))) { - return malformed(); +const surfaceOptions = (value: unknown): RouteInvocationSurface => { + if (!isRecord(value) || !boundedString(value.kind, 32)) return malformed(); + switch (value.kind) { + case 'mcp': + case 'script': + case 'unit-render': + if (!hasOnlyOwnKeys(value, ['kind'])) return malformed(); + return Object.freeze({ kind: value.kind }); + case 'cli': { + if (!hasOnlyOwnKeys(value, ['args', 'command', 'kind'])) return malformed(); + if (!boundedString(value.command)) return malformed(); + if ( + !Array.isArray(value.args) + || value.args.length > 1_024 + || value.args.some((argument) => !boundedString(argument, 16_384)) + ) return malformed(); + return Object.freeze({ args: [...value.args] as readonly string[], command: value.command, kind: 'cli' }); + } + case 'event': { + if (!hasOnlyOwnKeys(value, ['fixtureId', 'host', 'kind'])) return malformed(); + const fixtureId = value.fixtureId; + const host = value.host; + if (fixtureId !== undefined && !boundedString(fixtureId)) return malformed(); + if (host !== undefined && (typeof host !== 'string' || !concreteHosts.has(host as RouteInvocationEventHost))) { + return malformed(); + } + return Object.freeze({ + ...(fixtureId === undefined ? {} : { fixtureId }), + ...(host === undefined ? {} : { host: host as RouteInvocationEventHost }), + kind: 'event', + }); + } + default: + return malformed(); } - return Object.freeze({ - ...(fixtureId === undefined ? {} : { fixtureId }), - ...(host === undefined ? {} : { host: host as RouteInvocationEventHost }), - }); }; /** Strict wire decoder used by both the HTTP boundary and unit callers. */ export const parseRouteInvocationRequest = ( value: Readonly>, ): RouteInvocationRequest => { - if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'mode', 'routeId'])) return malformed(); + if (!hasOnlyOwnKeys(value, ['correlationId', 'input', 'routeId', 'surface'])) return malformed(); const routeId = value.routeId; const correlationId = value.correlationId; - const args = value.args; - const mode = value.mode; if (!boundedString(routeId)) return malformed(); if (correlationId !== undefined && !boundedString(correlationId, 256)) return malformed(); - if (mode !== undefined && mode !== 'production' && mode !== 'unit-render') return malformed(); - if (args !== undefined && (!Array.isArray(args) || args.length > 1_024 || args.some((argument) => !boundedString(argument, 16_384)))) { - return malformed(); - } let input: JsonValue | undefined; if (Object.hasOwn(value, 'input')) { try { @@ -226,14 +246,12 @@ export const parseRouteInvocationRequest = ( return malformed(); } } - const event = value.event === undefined ? undefined : eventOptions(value.event); + const surface = value.surface === undefined ? undefined : surfaceOptions(value.surface); return deepFreeze({ - ...(args === undefined ? {} : { args: [...args] as readonly string[] }), ...(correlationId === undefined ? {} : { correlationId }), - ...(event === undefined ? {} : { event }), ...(input === undefined ? {} : { input }), - ...(mode === undefined ? {} : { mode }), routeId, + ...(surface === undefined ? {} : { surface }), }); }; @@ -307,6 +325,88 @@ const allManifestRoutes = (manifest: RouteManifest): readonly RouteManifestRoute ...manifest.scripts, ]); +const commandName = (command: RouteManifestCliCommand): string => command.path.join(' '); + +const projectedCommandForCliId = ( + manifest: RouteManifest, + routeId: string, +): RouteManifestCliCommand | undefined => { + if (!routeId.startsWith('cli:')) return undefined; + const path = routeId.slice('cli:'.length); + return manifest.cli?.commands?.find((command) => + command.projection !== undefined + && command.routeId.startsWith('tool:') + && command.path.join('/') === path); +}; + +const defaultSurface = ( + route: RouteManifestRoute, + manifest: RouteManifest, +): RouteInvocationSurface => { + switch (route.kind) { + case 'tool': + case 'resource': + case 'prompt': + return Object.freeze({ kind: 'mcp' }); + case 'event-route': + return Object.freeze({ kind: 'event' }); + case 'script': + return Object.freeze({ kind: 'script' }); + case 'cli': { + const command = manifest.cli?.commands?.find((candidate) => candidate.routeId === route.id); + if (command === undefined) return malformed(); + return Object.freeze({ args: Object.freeze([]), command: commandName(command), kind: 'cli' }); + } + case 'app': + return malformed(); + default: { + const exhaustive: never = route.kind; + return exhaustive; + } + } +}; + +const resolvedSurface = ( + route: RouteManifestRoute, + requested: RouteInvocationSurface | undefined, + manifest: RouteManifest, +): RouteInvocationSurface => { + const surface = requested ?? defaultSurface(route, manifest); + switch (surface.kind) { + case 'mcp': + if (route.kind !== 'tool' && route.kind !== 'resource' && route.kind !== 'prompt') return malformed(); + return surface; + case 'event': + if (route.kind !== 'event-route') return malformed(); + return surface; + case 'script': + if (route.kind !== 'script') return malformed(); + return surface; + case 'unit-render': + if (route.kind === 'script') return malformed(); + return surface; + case 'cli': { + if (route.kind !== 'cli' && route.kind !== 'tool') return malformed(); + const command = manifest.cli?.commands?.find((candidate) => + candidate.routeId === route.id + && commandName(candidate) === surface.command + && (route.kind === 'cli' || candidate.projection !== undefined)); + if (command === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_CLI_COMMAND_MISMATCH_CODE, + `CLI command ${JSON.stringify(surface.command)} does not project onto canonical operation ${JSON.stringify(route.id)}.`, + 400, + ); + } + return surface; + } + default: { + const exhaustive: never = surface; + return exhaustive; + } + } +}; + const diagnostic = (code: string, message: string): Diagnostic => Object.freeze({ code, message, severity: 'error' }); @@ -318,18 +418,22 @@ const unavailable = ( const contextFor = ( route: RouteManifestRoute, root: string, - host: RouteInvocationEventHost | undefined, + surface: RouteInvocationSurface, ): RequestContextProvenance => deepFreeze({ actor: unavailable('not-provided'), - host: host === undefined + host: surface.kind !== 'event' || surface.host === undefined ? unavailable('host-omitted') - : { source: 'derived', state: 'available', value: { name: host } }, + : { source: 'derived', state: 'available', value: { name: surface.host } }, invocation: { - kind: route.kind === 'event-route' + kind: surface.kind === 'event' ? 'event' - : route.kind === 'cli' ? 'cli' : route.kind === 'script' ? 'script' : 'tool', + : surface.kind === 'cli' ? 'cli' : surface.kind === 'script' ? 'script' : 'tool', operationId: route.id, - surface: route.event ?? route.id.slice(route.id.lastIndexOf('/') + 1), + surface: surface.kind === 'cli' + ? surface.command + : surface.kind === 'event' + ? route.event + : surface.kind, }, lineage: unavailable('no-shared-runtime'), session: unavailable('not-provided'), @@ -580,7 +684,7 @@ const resultExitCode = (policy: 'result' | 'zero', result: JsonValue | undefined const invocationProjection = ( route: RouteManifestRoute, - request: RouteInvocationRequest, + surface: RouteInvocationSurface, input: JsonValue, result: JsonValue | undefined, mcp: JsonObject | undefined, @@ -589,18 +693,21 @@ const invocationProjection = ( prepared: RouteInvocationPreparedProject, registry: TargetRegistry, ): RouteInvocation['projection'] => { - if (route.kind === 'tool') { - if (mcp === undefined) throw new Error('Route invocation child omitted the tool MCP projection.'); - return deepFreeze({ mcp }); - } - if (route.kind === 'resource' || route.kind === 'prompt') { + if (surface.kind === 'mcp' || (surface.kind === 'unit-render' && route.kind === 'tool')) { + if (route.kind === 'tool') { + if (mcp === undefined) throw new Error('Route invocation child omitted the tool MCP projection.'); + return deepFreeze({ mcp }); + } return deepFreeze({ ...(jsonObject(result) === undefined ? {} : { mcp: jsonObject(result) }) }); } - if (route.kind === 'cli' || route.kind === 'script') { - const command = manifest.cli?.commands?.find((candidate) => candidate.routeId === route.id); + if (surface.kind === 'cli' || surface.kind === 'script') { + const command = surface.kind === 'cli' + ? manifest.cli?.commands?.find((candidate) => + candidate.routeId === route.id && commandName(candidate) === surface.command) + : undefined; // A plain script's exit code is its process status, carried in `result`; // a rendered script exits zero like a rendered CLI command. - const policy = route.kind === 'script' + const policy = surface.kind === 'script' ? (plainScriptFor(prepared, route) === undefined ? 'zero' : 'result') : command?.exitCode ?? 'zero'; return deepFreeze({ @@ -611,8 +718,8 @@ const invocationProjection = ( }, }); } - if (route.kind === 'event-route') { - const selected = request.event?.host === undefined ? prepared.targets : [request.event.host]; + if (surface.kind === 'event') { + const selected = surface.host === undefined ? prepared.targets : [surface.host]; const hosts = selected.map((host) => { const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); if (mapped === undefined) { @@ -630,7 +737,7 @@ const invocationProjection = ( route.event as CanonicalAgentEvent, host, mapped.nativeEvent, - request.event?.host === host && isJsonRecord(input) ? input : undefined, + surface.host === host && isJsonRecord(input) ? input : undefined, ); return { diagnostics: [], host, ...(native === undefined ? {} : { native: jsonObject(native) }) }; } catch (error) { @@ -645,7 +752,9 @@ const invocationProjection = ( }); return deepFreeze({ hosts }); } - return {}; + if (surface.kind === 'unit-render') return {}; + const exhaustive: never = surface; + return exhaustive; }; const failedInvocation = (input: { @@ -658,6 +767,7 @@ const failedInvocation = (input: { readonly request: RouteInvocationRequest; readonly route: RouteManifestRoute; readonly startedAt: Date; + readonly surface: RouteInvocationSurface; }): RouteInvocation => { const renderedInput = input.request.input; const canonical = input.route.kind === 'event-route' && renderedInput !== undefined && isJsonRecord(renderedInput) @@ -680,6 +790,7 @@ const failedInvocation = (input: { sourceRevision: input.manifest.sourceRevision, startedAt: input.startedAt.toISOString(), status: 'failed', + surface: input.surface, timings: [timing('elapsed', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], }); }; @@ -741,13 +852,22 @@ export class RouteInvocationService { } const route = allManifestRoutes(queued).find((candidate) => candidate.id === request.routeId); if (route === undefined || !invocationKinds.has(route.kind as RouteInvocationKind)) { + const projected = projectedCommandForCliId(queued, request.routeId); + if (projected !== undefined) { + const command = commandName(projected); + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_PROJECTED_CLI_ID_CODE, + `CLI operation ${JSON.stringify(request.routeId)} is a projection of canonical operation ${JSON.stringify(projected.routeId)}; invoke that route with surface ${JSON.stringify({ kind: 'cli', command, args: [] })}.`, + 400, + ); + } throw new RouteInvocationRequestError( ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, `Route ${JSON.stringify(request.routeId)} is not available for invocation.`, 404, ); } - if (request.event !== undefined && route.kind !== 'event-route') return malformed(); + const surface = resolvedSurface(route, request.surface, queued); const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; const startedAt = this.#now(); const running = this.#semaphore.run(async () => { @@ -775,14 +895,7 @@ export class RouteInvocationService { 409, ); } - if ( - request.args !== undefined - && route.kind !== 'cli' - && !prepared.manifest.cliCommands.some((command) => command.routeId === route.id) - ) { - return malformed(); - } - const fixtureId = request.event?.fixtureId; + const fixtureId = surface.kind === 'event' ? surface.fixtureId : undefined; const fixture = fixtureId === undefined ? undefined : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); @@ -795,9 +908,9 @@ export class RouteInvocationService { } const rawInput = request.input ?? fixture?.input ?? {}; const input = route.kind === 'event-route' - ? eventInput(route, rawInput, request.event?.host, this.#registry) + ? eventInput(route, rawInput, surface.kind === 'event' ? surface.host : undefined, this.#registry) : rawInput; - const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); + const context = contextFor(route, prepared.manifest.projectRoot, surface); const controller = new AbortController(); this.#controllers.add(controller); if (this.#closed) { @@ -809,7 +922,6 @@ export class RouteInvocationService { try { child = plainScript === undefined ? await this.#renderChild({ - ...(request.args === undefined ? {} : { args: request.args }), ...(prepared.artifact === undefined ? {} : { @@ -817,12 +929,11 @@ export class RouteInvocationService { artifactRoot: join(prepared.manifest.projectRoot, '.agent-bundle', 'epochs', prepared.artifact.epochId), }), context, - ...(request.event?.host === undefined ? {} : { eventTarget: request.event.host }), input, manifest: prepared.manifest, - ...(request.mode === undefined ? {} : { mode: request.mode }), routeId: route.id, stateRoot: prepared.stateRoot, + surface, }, controller.signal) : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); } catch (error) { @@ -844,6 +955,7 @@ export class RouteInvocationService { request: { ...request, input }, route, startedAt, + surface, }); } finally { clearTimeout(timeout); @@ -852,7 +964,7 @@ export class RouteInvocationService { const projectionStartedAt = this.#now(); const projection = invocationProjection( route, - request, + surface, rawInput, child.result, child.mcp, @@ -878,7 +990,9 @@ export class RouteInvocationService { // event detail detached from the identical public `input`. canonical: jsonObject(canonical)!, event: route.event!, - ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), + ...(surface.kind !== 'event' || surface.host === undefined + ? {} + : { host: surface.host, native: rawInput as JsonObject }), }, } : {}), @@ -895,6 +1009,7 @@ export class RouteInvocationService { sourceRevision: manifest.sourceRevision, startedAt: startedAt.toISOString(), status: 'succeeded', + surface, ...(child.trace === undefined ? {} : { trace: child.trace }), timings: invocationTimings(child, startedAt, projectionStartedAt, completedAt), }); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts index 33c21c595..85d69eccd 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -21,29 +21,27 @@ export type RouteInvocationKind = 'cli' | 'event-route' | 'prompt' | 'resource' /** The hosts an event route can be invoked as; `canonical` submits the canonical payload directly. */ export type RouteInvocationEventHost = 'claude' | 'codex' | 'cursor'; -export interface RouteInvocationEventOptions { - /** - * When present, `input` is the host's native hook payload and the service - * canonicalizes it exactly as the emitted wrapper would (the lifecycle - * replay path); when absent, `input` is the canonical event payload. - */ - readonly host?: RouteInvocationEventHost; - /** A fixture id from the route's manifest fixtures; the service seeds `input` from it when `input` is absent. */ - readonly fixtureId?: string; -} +export type RouteInvocationSurface = + | Readonly<{ readonly kind: 'mcp' }> + | Readonly<{ readonly args: readonly string[]; readonly command: string; readonly kind: 'cli' }> + | Readonly<{ + readonly fixtureId?: string; + /** When present, `input` is the host's native hook payload; otherwise it is canonical. */ + readonly host?: RouteInvocationEventHost; + readonly kind: 'event'; + }> + | Readonly<{ readonly kind: 'script' }> + | Readonly<{ readonly kind: 'unit-render' }>; export interface RouteInvocationRequest { - /** CLI routes only: the argv the routed CLI would receive after the command path. */ - readonly args?: readonly string[]; /** Browser-minted correlation id, echoed on the envelope and on the `route.invocation` project event. */ readonly correlationId?: string; - readonly event?: RouteInvocationEventOptions; - /** Tool/prompt/script input, event payload (canonical or native — see `event.host`), or resource parameters. */ + /** Tool/prompt input, event payload (canonical or native), script input, or resource parameters. */ readonly input?: JsonValue; - /** Generated-entry parity by default; component-only rendering is an explicit fallback. */ - readonly mode?: 'production' | 'unit-render'; /** The compiled route id, for example `tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`. */ readonly routeId: string; + /** Selected execution surface. Omission selects the canonical default for the route kind. */ + readonly surface?: RouteInvocationSurface; } export type RouteInvocationStatus = 'failed' | 'succeeded'; @@ -130,6 +128,8 @@ export interface RouteInvocationSummary { readonly sourceRevision: string; readonly startedAt: string; readonly status: RouteInvocationStatus; + /** The resolved surface, including defaults when the request omitted it. */ + readonly surface: RouteInvocationSurface; readonly timings: readonly RouteInvocationTiming[]; } diff --git a/packages/agent-bundle/tests/application-tree.test.ts b/packages/agent-bundle/tests/application-tree.test.ts index bfa5f6101..894491292 100644 --- a/packages/agent-bundle/tests/application-tree.test.ts +++ b/packages/agent-bundle/tests/application-tree.test.ts @@ -34,6 +34,13 @@ const manifest: RouteManifest = { options: [], path: ['library', 'audit'], routeId: 'cli:library/audit', + }, { + aliases: [], + exitCode: 'zero', + options: [], + path: ['alpha'], + projection: { mapInput: true, module: 'src/mcp/alpha/tools/a-tool.cli.ts' }, + routeId: 'tool:alpha/a-tool', }], mode: 'generated', routes: [route('cli:library/audit', 'cli', 'src/cli/library/audit.ts')], @@ -120,6 +127,11 @@ describe('application tree derivation', () => { expect(mcp.servers[0]!.subgroups[0]!.leaves.map((leaf) => leaf.label)).toEqual([ 'a-tool', 'z-tool', ]); + expect(mcp.servers[0]!.subgroups[0]!.leaves[0]?.command).toMatchObject({ + path: ['alpha'], + routeId: 'tool:alpha/a-tool', + }); + expect(applicationLeaves(result).filter((leaf) => leaf.ref.kind === 'cli')).toHaveLength(1); expect(mcp.servers[0]!.subgroups.map((group) => group.leaves[0]!.execution)).toEqual([ 'invoke', 'invoke', 'invoke', 'preview', ]); 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 763cfbd81..3f71da7b1 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -210,6 +210,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(tool.invocation.events.at(-1)?.type).toBe('complete'); expect(tool.invocation.document).toBeDefined(); expect(tool.invocation.projection.mcp).toBeDefined(); + expect(tool.invocation.surface).toEqual({ kind: 'mcp' }); expect(tool.invocation.result).toEqual({ alias: 'aliased', define: 'defined', @@ -232,7 +233,6 @@ it('invokes compiled tool and event routes through the foreground server', { tim const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { body: JSON.stringify({ - event: { host: 'claude' }, input: { cwd: project.root, hook_event_name: 'PostToolUse', @@ -244,6 +244,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim transcript_path: join(project.root, 'transcript.json'), }, routeId: 'event:tool/after', + surface: { host: 'claude', kind: 'event' }, }), headers, method: 'POST', @@ -294,7 +295,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim ], ] as const) { const response = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ event: { host: 'claude' }, input, routeId }), + body: JSON.stringify({ input, routeId, surface: { host: 'claude', kind: 'event' } }), headers, method: 'POST', }); @@ -321,7 +322,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim } const cliResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ args: ['Ada'], routeId: 'cli:greet' }), + body: JSON.stringify({ routeId: 'cli:greet', surface: { args: ['Ada'], command: 'greet', kind: 'cli' } }), headers, method: 'POST', }); @@ -337,10 +338,14 @@ it('invokes compiled tool and event routes through the foreground server', { tim }, result: { message: 'Hello, Ada.' }, status: 'succeeded', + surface: { args: ['Ada'], command: 'greet', kind: 'cli' }, }); const projectedCliResponse = await fetch(`${server.url}/api/routes/invocations`, { - body: JSON.stringify({ args: ['--name', 'projection'], routeId: 'tool:status/report' }), + body: JSON.stringify({ + routeId: 'tool:status/report', + surface: { args: ['--name', 'projection'], command: 'report', kind: 'cli' }, + }), headers, method: 'POST', }); @@ -354,6 +359,16 @@ it('invokes compiled tool and event routes through the foreground server', { tim source: 'cli-projection', stateRoot, }); + expect(projectedCli.invocation.projection.cli).toMatchObject({ + exitCode: 0, + text: expect.stringContaining('Service projection'), + }); + expect(projectedCli.invocation.projection.mcp).toBeUndefined(); + expect(projectedCli.invocation.surface).toEqual({ + args: ['--name', 'projection'], + command: 'report', + kind: 'cli', + }); const binName = (await readdir(join(artifactRoot, 'bin'))) .find((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')); if (binName === undefined) throw new Error('Expected a generated routed CLI bin.'); @@ -368,12 +383,38 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(generatedBin.code, generatedBin.stderr).toBe(0); expect(projectedCli.invocation.result).toEqual(JSON.parse(generatedBin.stdout)); - const counter = async (mode?: 'production' | 'unit-render'): Promise => { + const mismatchedCommand = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + routeId: 'tool:status/report', + surface: { args: [], command: 'greet', kind: 'cli' }, + }), + headers, + method: 'POST', + }); + expect(mismatchedCommand.status).toBe(400); + await expect(mismatchedCommand.json()).resolves.toMatchObject({ + diagnostic: { code: 'AB8253' }, + }); + + const duplicateCliOperation = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'cli:report' }), + headers, + method: 'POST', + }); + expect(duplicateCliOperation.status).toBe(400); + await expect(duplicateCliOperation.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8254', + message: 'CLI operation "cli:report" is a projection of canonical operation "tool:status/report"; invoke that route with surface {"kind":"cli","command":"report","args":[]}.', + }, + }); + + const counter = async (unitRender = false): Promise => { const response = await fetch(`${server!.url}/api/routes/invocations`, { body: JSON.stringify({ - input: { key: mode ?? 'production' }, - ...(mode === undefined ? {} : { mode }), + input: { key: unitRender ? 'unit-render' : 'production' }, routeId: 'tool:status/counter', + ...(unitRender ? { surface: { kind: 'unit-render' } } : {}), }), headers, method: 'POST', @@ -383,7 +424,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim }; const firstCounter = await counter(); const secondCounter = await counter(); - const isolatedCounter = await counter('unit-render'); + const isolatedCounter = await counter(true); expect(firstCounter.invocation.result).toEqual({ count: 1 }); expect(secondCounter.invocation.result).toEqual({ count: 2 }); expect(isolatedCounter.invocation.result).toEqual({ count: 1 }); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 2453179eb..cd39bf33c 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -53,6 +53,7 @@ const invocation = (id: string, completedAt: string): RouteInvocation => ({ sourceRevision: 'revision', startedAt: completedAt, status: 'succeeded', + surface: { kind: 'mcp' }, timings: [], trace: [{ at: 0, @@ -79,14 +80,19 @@ it('strictly validates invocation request fields and event options', () => { routeId: 'tool:curator/search_audible', }); expect(parseRouteInvocationRequest({ - event: { fixtureId: 'starter', host: 'claude' }, - mode: 'unit-render', + surface: { fixtureId: 'starter', host: 'claude', kind: 'event' }, routeId: 'event:tool/after', })).toEqual({ - event: { fixtureId: 'starter', host: 'claude' }, - mode: 'unit-render', + surface: { fixtureId: 'starter', host: 'claude', kind: 'event' }, routeId: 'event:tool/after', }); + expect(parseRouteInvocationRequest({ + surface: { args: ['--name', 'Ada'], command: 'report', kind: 'cli' }, + routeId: 'tool:status/report', + })).toEqual({ + surface: { args: ['--name', 'Ada'], command: 'report', kind: 'cli' }, + routeId: 'tool:status/report', + }); expect(parseRouteInvocationRequest({ routeId: 'tool:curator/search_audible', })).toEqual({ @@ -98,9 +104,11 @@ it('strictly validates invocation request fields and event options', () => { { routeId: '' }, { routeId: 'tool:x/y', unknown: true }, { args: ['ok', 1], routeId: 'cli:x' }, - { event: { host: 'other' }, routeId: 'event:tool/after' }, - { event: { fixtureId: '' }, routeId: 'event:tool/after' }, + { event: { host: 'claude' }, routeId: 'event:tool/after' }, { mode: 'preview', routeId: 'tool:x/y' }, + { routeId: 'event:tool/after', surface: { host: 'other', kind: 'event' } }, + { routeId: 'event:tool/after', surface: { fixtureId: '', kind: 'event' } }, + { routeId: 'tool:x/y', surface: { command: 'x', kind: 'cli' } }, ]) { expect(() => parseRouteInvocationRequest(value)).toThrow(RouteInvocationRequestError); } @@ -197,7 +205,7 @@ it('aborts and drains a running render when the service closes', async () => { }), }); - const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: echoRoute.id }); + const pending = service.invoke({ input: {}, routeId: echoRoute.id, surface: { kind: 'unit-render' } }); await started.promise; await service.close(); @@ -394,9 +402,10 @@ const tsxSiblingProject = async (): Promise => routeProject( it('resolves a `.js` import of a `.tsx` sibling without rewriting the same string rendered as text', { timeout: 30_000 }, async () => { const project = await tsxSiblingProject(); try { - const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/report' }); + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/report', surface: { kind: 'unit-render' } }); expect(invocation.status, JSON.stringify(invocation.diagnostics)).toBe('succeeded'); + expect(invocation.surface).toEqual({ kind: 'unit-render' }); expect(invocation.document).toBeDefined(); expectDocument(invocation.document!) .toContainText('panel rendered') @@ -427,7 +436,7 @@ const recordedPids = async (project: LeakingRouteProject): Promise { const project = await leakingRouteProject('reply'); try { - const invocation = await project.service().invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/leak', surface: { kind: 'unit-render' } }); const pids = await project.pids(); expect(invocation.status).toBe('succeeded'); @@ -443,7 +452,7 @@ it('reaps the render child and its descendants when the invocation times out', { const project = await leakingRouteProject('hang'); try { const service = project.service({ timeoutMs: 8_000 }); - const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ 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); @@ -463,7 +472,7 @@ it('reaps the render child and its descendants when the service closes mid-rende const project = await leakingRouteProject('hang'); try { const service = project.service(); - const pending = service.invoke({ input: {}, mode: 'unit-render', routeId: 'tool:fixture/leak' }); + const pending = service.invoke({ 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); @@ -536,6 +545,7 @@ it('marks catalog providers unobserved when the child reports no observations', }); expect(result.status).toBe('succeeded'); + expect(result.surface).toEqual({ kind: 'mcp' }); expect(result.providers).toEqual([{ id: 'provider:clock', name: 'clock', status: 'unobserved' }]); expect(result.providers[0]).not.toHaveProperty('durationMs'); expect(result.timings.map((entry) => entry.phase)).toEqual(['render', 'projection']); diff --git a/packages/workbench/src/application/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx index b1828a761..1f32f3d86 100644 --- a/packages/workbench/src/application/event-route-workspace.tsx +++ b/packages/workbench/src/application/event-route-workspace.tsx @@ -3,7 +3,7 @@ * selector in front of it. `Canonical` submits the canonical event payload the * route's schema describes; `Claude | Codex | Cursor` submit that host's * native hook payload — seeded from the served lifecycle fixture — as - * `event: { host, fixtureId }` so the service canonicalizes it exactly as the + * `surface: { kind: 'event', host, fixtureId }` so the service canonicalizes it exactly as the * emitted wrapper would. The plugin-visible decision (the rendered document) * stays the default result; the codec panes the old Hooks page led with are * secondary tabs: canonical → host mapping, native in / out, canonical @@ -71,9 +71,16 @@ export const eventFixturesFor = (lifecycle: Lifecycle | undefined): readonly Rou export const eventRequestFor = ( host: EventHostSelection, draft: RouteInvocationDraft, + fixtureId?: string, ): RouteInvocationDraft => { - if (host === 'canonical') return draft; - return Object.freeze({ ...draft, event: Object.freeze({ host }) }); + return Object.freeze({ + ...draft, + surface: Object.freeze({ + ...(fixtureId === undefined ? {} : { fixtureId }), + ...(host === 'canonical' ? {} : { host }), + kind: 'event', + }), + }); }; const Rows = ({ rows }: { readonly rows: readonly { readonly label: string; readonly value: string }[] }): React.ReactNode =>
@@ -165,7 +172,10 @@ const ReplayTab = ({ controller, defaultHost, lifecycle }: { return; } setError(undefined); - controller.run(Object.freeze({ event: Object.freeze({ host }), input: parsed as JsonObject })); + controller.run(Object.freeze({ + input: parsed as JsonObject, + surface: Object.freeze({ host, kind: 'event' }), + })); }; return

Replay a receipt a real host produced: paste its native payload and run it through this route exactly as the emitted wrapper would.

diff --git a/packages/workbench/src/application/executable-route-workspace.tsx b/packages/workbench/src/application/executable-route-workspace.tsx index 4aa920fb2..544191cb6 100644 --- a/packages/workbench/src/application/executable-route-workspace.tsx +++ b/packages/workbench/src/application/executable-route-workspace.tsx @@ -8,7 +8,11 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { RouteInvocationRequest, RouteInvocationSummary } from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { + RouteInvocationRequest, + RouteInvocationSummary, + RouteInvocationSurface, +} 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 { ApplicationLeaf } from './application-tree-model.ts'; @@ -202,7 +206,7 @@ export interface ExecutableRouteWorkspaceProps { 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) => RouteInvocationDraft; + readonly requestFor?: (draft: RouteInvocationDraft, fixtureId?: string) => RouteInvocationDraft; readonly tab?: string; /** Rendered between the header and the editor (the event host selector). */ readonly toolbar?: React.ReactNode; @@ -238,8 +242,8 @@ const leafKindLabel = (leaf: ApplicationLeaf): string => { }; /** Title, kind, route id, and description — the header every workspace body shares. */ -export const WorkspaceHeader = ({ leaf }: { readonly leaf: ApplicationLeaf }): React.ReactNode =>
-

{leafKindLabel(leaf)}{leaf.routeId === undefined ? '' : ` · ${leaf.routeId}`}

+export const WorkspaceHeader = ({ leaf, surface }: { readonly leaf: ApplicationLeaf; readonly surface?: string }): React.ReactNode =>
+

{leafKindLabel(leaf)}{leaf.routeId === undefined ? '' : ` · ${leaf.routeId}`}{surface === undefined ? '' : ` · ${surface}`}

{leaf.label}

{leaf.description === undefined ? undefined :

{leaf.description}

}
; @@ -258,6 +262,31 @@ export const ExecutableRouteWorkspace = ({ toolbar, }: ExecutableRouteWorkspaceProps): React.ReactNode => { const editorLeaf = inputLeaf ?? leaf; + const projectedTool = leaf.ref.kind === 'tool' && leaf.command?.projection !== undefined; + const [selectedSurface, setSelectedSurface] = useState(() => { + switch (leaf.ref.kind) { + case 'cli': + return 'cli'; + case 'event': + return 'event'; + case 'script': + return 'script'; + case 'tool': + case 'resource': + case 'prompt': + return 'mcp'; + case 'app': + case 'skill': + case 'command': + case 'rule': + return 'unit-render'; + default: { + const exhaustive: never = leaf.ref; + return exhaustive; + } + } + }); + const cliSurface = selectedSurface === 'cli'; const storageKey = inputKey ?? leaf.key; const [input, setInput] = useState(() => { const last = readLastInput(storageKey); @@ -272,6 +301,15 @@ export const ExecutableRouteWorkspace = ({ const seededFrom = useRef(undefined); useEffect(() => { setResultTab(resultTabFor(tab)); }, [tab]); + useEffect(() => { + if ( + projectedTool + && invocation !== undefined + && (invocation.surface.kind === 'mcp' || invocation.surface.kind === 'cli' || invocation.surface.kind === 'unit-render') + ) { + setSelectedSurface(invocation.surface.kind); + } + }, [invocation, projectedTool]); // A snapshot loaded by id (deep link, trace entry) replaces the editor's // input with what that invocation actually rendered, once per snapshot. @@ -287,23 +325,38 @@ export const ExecutableRouteWorkspace = ({ }; const run = (): void => { - const submission = routeInputSubmission(editorLeaf, input); + const submission = routeInputSubmission(editorLeaf, input, cliSurface); if (submission.draft === undefined) { setInput(Object.freeze({ ...input, attempted: true })); return; } - const json = routeInputJson(editorLeaf, input); + const json = routeInputJson(editorLeaf, input, cliSurface); if (json !== undefined) writeLastInput(storageKey, json); - controller.run(requestFor === undefined ? submission.draft : requestFor(submission.draft)); + const surfaced = submission.draft.surface !== undefined + ? submission.draft + : Object.freeze({ + ...submission.draft, + surface: Object.freeze({ kind: selectedSurface }) as RouteInvocationSurface, + }); + controller.run(requestFor === undefined ? surfaced : requestFor(surfaced, input.fixtureId)); }; const failed = controller.state.phase === 'failed' ? controller.state : undefined; return
- + + {projectedTool ?
+ + + +
: undefined} {toolbar} = diff --git a/packages/workbench/src/application/invocation-model.ts b/packages/workbench/src/application/invocation-model.ts index 29c33b3b6..26cc4cc16 100644 --- a/packages/workbench/src/application/invocation-model.ts +++ b/packages/workbench/src/application/invocation-model.ts @@ -128,5 +128,6 @@ export const invocationSummaryOf = ( sourceRevision: invocation.sourceRevision, startedAt: invocation.startedAt, status: invocation.status, + surface: invocation.surface, timings: invocation.timings, }); diff --git a/packages/workbench/src/application/route-input-editor.tsx b/packages/workbench/src/application/route-input-editor.tsx index 86507dae7..1009da471 100644 --- a/packages/workbench/src/application/route-input-editor.tsx +++ b/packages/workbench/src/application/route-input-editor.tsx @@ -152,35 +152,54 @@ const cliDraft = (leaf: ApplicationLeaf, argumentsValue: RouteInputArguments): R const args = cliCommandArgv(leaf.command, argumentsValue); return args === undefined ? Object.freeze({ error: 'A required CLI option is missing.' }) - : Object.freeze({ draft: Object.freeze({ args }) }); + : Object.freeze({ + draft: Object.freeze({ + surface: Object.freeze({ args, command: leaf.command.path.join(' '), kind: 'cli' }), + }), + }); }; /** The validated input the current editor value submits, or why it cannot run. */ -export const routeInputSubmission = (leaf: ApplicationLeaf, value: RouteInputValue): RouteInputSubmission => { - const isCli = leaf.ref.kind === 'cli'; +export const routeInputSubmission = ( + leaf: ApplicationLeaf, + value: RouteInputValue, + cliSurface = leaf.ref.kind === 'cli', +): RouteInputSubmission => { if (value.mode === 'raw' || leaf.inputSchema === undefined) { - if (isCli) { + if (cliSurface) { const args = parseRawArgs(value.raw); - if (args !== undefined) return Object.freeze({ draft: Object.freeze({ args }) }); + if (args !== undefined && leaf.command !== undefined) { + return Object.freeze({ + draft: Object.freeze({ + surface: Object.freeze({ args, command: leaf.command.path.join(' '), kind: 'cli' }), + }), + }); + } } const validated = validateRawRouteInput(value.raw); if (validated.error !== undefined || validated.arguments === undefined) { - return Object.freeze({ error: isCli ? 'Enter a JSON array of argv strings or a JSON object of option values.' : validated.error ?? 'Enter a valid JSON object.' }); + return Object.freeze({ error: cliSurface ? 'Enter a JSON array of argv strings or a JSON object of option values.' : validated.error ?? 'Enter a valid JSON object.' }); } - return isCli ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); + return cliSurface ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); } const validated = validateRouteInput(leaf.inputSchema, value.draft); if (validated.arguments === undefined) { return Object.freeze({ error: 'Fix the highlighted fields before running.', fieldErrors: validated.errors }); } - return isCli ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); + return cliSurface ? cliDraft(leaf, validated.arguments) : Object.freeze({ draft: Object.freeze({ input: validated.arguments }) }); }; /** The JSON the workspace persists as the leaf's last input: the argv array for CLI leaves, the input object otherwise. */ -export const routeInputJson = (leaf: ApplicationLeaf, value: RouteInputValue): JsonValue | undefined => { - const submission = routeInputSubmission(leaf, value); +export const routeInputJson = ( + leaf: ApplicationLeaf, + value: RouteInputValue, + cliSurface = leaf.ref.kind === 'cli', +): JsonValue | undefined => { + const submission = routeInputSubmission(leaf, value, cliSurface); if (submission.draft === undefined) return undefined; - return submission.draft.args === undefined ? submission.draft.input : Object.freeze([...submission.draft.args]); + return submission.draft.surface?.kind === 'cli' + ? Object.freeze([...submission.draft.surface.args]) + : submission.draft.input; }; const editorId = (leafKey: string, key: string): string => @@ -229,6 +248,7 @@ const scalarControl = ( }; export interface RouteInputEditorProps { + readonly cliSurface?: boolean; readonly disabled?: boolean; readonly fixtures?: readonly RouteInputFixture[]; readonly leaf: ApplicationLeaf; @@ -242,9 +262,9 @@ const isRunShortcut = (event: React.KeyboardEvent): boolean => event.key === 'Enter' && (event.metaKey || event.ctrlKey); /** The workspace's input panel: form or raw JSON, fixtures, argv preview, and Run. */ -export const RouteInputEditor = ({ disabled = false, fixtures = [], leaf, onChange, onRun, running, value }: RouteInputEditorProps): React.ReactNode => { +export const RouteInputEditor = ({ cliSurface, disabled = false, fixtures = [], leaf, onChange, onRun, running, value }: RouteInputEditorProps): React.ReactNode => { const schema = leaf.inputSchema; - const submission = routeInputSubmission(leaf, value); + const submission = routeInputSubmission(leaf, value, cliSurface); const fieldErrors = value.attempted && submission.fieldErrors !== undefined ? submission.fieldErrors : {}; const rawError = value.attempted && value.mode === 'raw' && submission.error !== undefined ? submission.error : undefined; const locked = disabled || running; @@ -261,7 +281,7 @@ export const RouteInputEditor = ({ disabled = false, fixtures = [], leaf, onChan if (mode === value.mode) return; if (mode === 'raw') { // Carry the form over so switching never loses an edit. - const json = routeInputJson(leaf, value); + const json = routeInputJson(leaf, value, cliSurface); onChange(Object.freeze({ ...value, mode, raw: json === undefined ? value.raw : rawJson(json) })); return; } @@ -281,9 +301,9 @@ export const RouteInputEditor = ({ disabled = false, fixtures = [], leaf, onChan } onRun(); }; - const argv = leaf.command === undefined || submission.draft?.args === undefined + const argv = submission.draft?.surface?.kind !== 'cli' ? undefined - : [...leaf.command.path, ...submission.draft.args].join(' '); + : [submission.draft.surface.command, ...submission.draft.surface.args].join(' '); return
{value.mode === 'raw' || schema === undefined ?