Electron Stagewrightdocs

ADR-009: Trace artifact format and dispatch-observer seam

Status: Accepted (capture + token report + replay + token budget + offline viewer all shipped)

Context

The server can drive an app but cannot record what it did. An agent (or a human debugging one) has no portable record of which tools ran, in what order, with what inputs and outputs, or where the token budget went. The trace capability is the first product differentiator and the first genuine consumer of the plugin model (ADR-004): it must observe the WHOLE session, not just add isolated tools. Two new decisions are needed — how a plugin observes every tool call, and what the recorded artifact looks like.

Decision

1. A dispatch-observer seam on the dispatcher

The dispatcher (ADR-008) gains a set of best-effort observers. After every dispatch() resolves — success, validation failure, or unknown tool — it notifies each observer exactly once, through a single internal funnel, with a DispatchRecord:

{ tool, args, result, startedAt, finishedAt }

args is the parsed input (or the raw input when validation failed); result is the exact agent-facing envelope (so estimated_tokens is read from result._meta). Observers register via Dispatcher.addObserver(observer): () => void (returns an unsubscribe), also surfaced to tool handlers as ToolContext.addDispatchObserver so a plugin tool (e.g. trace_start) can attach a sink without the core depending on the plugin.

Observers are best-effort and must not throw: a throw is caught and logged, never propagated to the agent (same contract as the transport's console/dialog listeners). With zero observers the funnel is a single size > 0 check — no per-call overhead.

2. The trace artifact is JSONL, schema version 1

One JSON record per line. The first line is a meta header; each subsequent line is a call:

{ "v": 1, "kind": "meta", "started_at", "core_version" }
{ "kind": "call", "tool", "ok", "code"?, "started_at", "finished_at", "elapsed_ms",
  "estimated_tokens", "args", "result" }

JSONL was chosen over zstd/protobuf/OpenTelemetry: it is append-friendly, human-readable, and parses with JSON.parse per line — no dependency, no schema compiler. The explicit v lets replay, budget metadata, and viewer features evolve the shape compatibly.

3. Trace is a plugin, recording is opt-in

Per the lean-core thesis (ADR-007), trace ships as @electron-stagewright/plugin-trace, not core. It records only between trace_start and trace_stop, to an operator-chosen path, and skips its own trace_* calls. Records are buffered in memory (bounded by maxRecords) and written on stop, so the observer stays a cheap array push on the dispatch hot path; a crash before stop loses the buffer (streaming is a forthcoming improvement).

Rationale

Alternatives considered

Consequences

References

Status Update (2026-06-04) — the seam becomes bidirectional; trace_replay

The original seam made plugins OBSERVE every dispatch. Replay needs the inverse — to RE-DISPATCH recorded calls — so the seam gains an active half:

New references: packages/plugin-trace/src/replay.ts (engine); ToolContext.dispatch / validate and the REDISPATCH_DEPTH guard in dispatcher.ts.

Status Update (2026-06-04) — token budget + a pre-dispatch guard seam

The trace plugin gains a token budget, and the dispatch seam gains a third capability — a pre-dispatch VETO — alongside observe and re-dispatch:

New references: DispatchGuard / DispatchGuardCall / ToolContext.addDispatchGuard and the #guards set in dispatcher.ts; BudgetStatus / budgetStatusOf and budget tracking in packages/plugin-trace/src/recorder.ts.

Status Update (2026-06-06) — the offline viewer

The last forthcoming piece — a visual viewer — ships as trace_view. The format decision the original scope left open ("viewer format chosen before implementation") is resolved as a single self-contained HTML document: inline CSS and JS, no external assets, no CDN, no runtime server, no build step. The operator opens it by double-clicking; it works fully offline and is trivial to attach to a bug report. This was chosen over a hosted/served viewer (needs a process and a port), a framework SPA (build step + bundle), or a terminal viewer (not shareable) because a trace is a portable record — the viewer should be as portable as the artifact.

New references: packages/plugin-trace/src/viewer.ts (renderTraceHtml / escapeHtml); the trace_view tool in packages/plugin-trace/src/index.ts.

Status Update (2026-07-13) — streaming JSONL v2 and crash recovery

The original v1 buffer-on-stop tradeoff is replaced for new recordings. Version 2 keeps JSONL's human-readable, dependency-free format while making trace capture recoverable after a process or host failure:

New references: packages/plugin-trace/src/recorder.ts (streaming writer, v1/v2 readers); packages/plugin-trace/src/index.ts (start/stop lifecycle); packages/plugin-trace/src/viewer.ts (partial-artifact disclosure).

Status Update (2026-07-13) — promoted replay specifications

Raw replay intentionally remains a diagnostic comparison of stable ok/error-code outcomes. A new trace_promote path turns selected trace calls into a compact stagewright-replay v1 JSON specification for review and eventual automation:

New references: packages/plugin-trace/src/spec.ts (schema, promotion, oracle engine) and the trace_promote tool in packages/plugin-trace/src/index.ts.

Status Update (2026-07-13) — headless replay runner

@electron-stagewright/plugin-trace now publishes an electron-stagewright-replay executable for CI. It reads one promoted spec, starts a fresh core server with the requested plugin/eval/profile configuration, runs the existing dispatcher path, and emits either a compact human report or one machine-readable JSON object. Exit codes distinguish checkpoint mismatch, malformed spec, failed app launch, infrastructure, and CLI usage. Filters preserve the session creator of a selected dependent step, so narrowing a run never leaves a stale placeholder. The package smoke packs the plugin, installs it outside the monorepo beside core, and proves the published bin against a real Electron app.