feat: transcript tracing pipeline + gen trace viewer#12
Closed
yanmxa wants to merge 5 commits into
Closed
Conversation
The session save path previously rewrote the entire JSONL on every Save
via FileStore.Replace, producing O(file_size) writes per turn even though
each turn only adds one or two messages. This commit switches the path
to per-event append:
Store.Save now calls Start (idempotent) + AppendMessage per node
(deduped via an in-memory persistedIDs cache, populated lazily by
scanning the file once) + a single PatchState.
FileStore.Replace, recordsForTranscript, TranscriptFromSnapshot,
ReplaceCommand, and messageExistsLocked are removed — the rewrite
path no longer exists.
Bundled in this commit: fsync is now gated by a `sync` parameter on
appendRecord, batched at turn boundaries:
- sync=true: session.started, message.appended, session.compacted,
inference.responded (the turn-flush point).
- sync=false: state.patched, inference.requested, system.section.*,
tools.* — buffered in the page cache, flushed when the matching
inference.responded lands.
A typical turn now does one fsync instead of five. On crash, the
in-flight turn's telemetry may be lost, but messages and state from
prior turns are durable.
Also exports StateOpsFor + PatchTag/Mode/Worktree helpers so the new
Save path can express the projected state as a single patch list.
Naming convention (see docs/tracing.md): every record type follows
<entity>[.<sub-entity>].<past-tense-verb>, lowercase, dot-separated.
Payload key matches the first segment of `type`.
Renames:
RecordStarted/Forked/Compacted → SessionStarted/Forked/Compacted
RecordMessageAppended → MessageAppended
RecordStatePatched → StatePatched
transcript.started/forked/.compacted (JSON value) → session.*
SystemRecord (lifecycle payload) → SessionRecord (name now matches
intent — that struct never held system-prompt data anyway)
Record.System field → Record.Session, JSON tag "system"→"session"
Record.TranscriptID → Record.SessionID, JSON tag
"transcriptId"→"sessionId"
*Command.TranscriptID → SessionID (all five commands)
ForkCommand.Source/NewTranscriptID → Source/NewSessionID
ListItem.TranscriptID, fileIndexEntry.TranscriptID → SessionID
ContentBlock gains a Source field for provenance attribution
(populated in a later commit). The existing inline-image data field,
which used the json tag "source", is renamed to ImageSource with tag
"imageSource" to free up the namespace.
applyStatePatch now ignores unknown patch paths (default: continue)
instead of returning an error — keeps the projector forward-compatible
with patch paths added by later schema iterations.
Adds session.Recorder, a synchronous observer on core.Agent's event bus
that translates lifecycle events into transcript records. Wired via
core.Config.OnEvent (new BuildParams.OnEvent passthrough in
internal/agent/build.go; constructed by Session.NewRecorder at agent
start in internal/app/agent.go).
New records persisted per turn:
inference.requested — emitted in streamInfer with sha256 digests of
the rendered system prompt, canonicalized tool
list, and the active message-ID chain. Carries
provider, model, max_tokens, turn number.
inference.responded — emitted on PostInfer with stop reason, latency,
and token usage (input / output / cache read /
cache create).
System mutations now flow through:
Use(sec, caller) and Drop(name, caller) on core.System (Refresh
similarly). New System.SetObserver registers a callback that fires
on every subsequent mutation AND replays existing sections as
synthetic "added" events with caller="system:init", so observers
attached after Build still see the complete history.
catalog.go's 13 Use call sites pass caller strings: "system:init"
for default registrations, "command:identity" for SwapIdentity,
"subagent:init" for WithSubagentIdentity.
Recorder writes system.section.added / system.section.removed
records carrying name, slot, content, caller.
Tool registry events follow the same pattern:
core.Tools gains Add(tool, caller), Remove(name, caller), and
SetObserver(fn). The two wrappers (permissionTools, progressTools)
forward both observer and mutations.
MCP registrations pass caller="mcp:<toolname>". The recorder writes
tools.added (with schema) and tools.removed (with name).
Content provenance: splitTextByProvenance splits user-message content
on <system-reminder> XML boundaries and tags those blocks with
Source="reminder". Round-trip safe (extractUserContent concatenates
all text blocks back into a single string).
Telemetry events go through a new emitTelemetry path: non-blocking
outbox send with select-default fallback. System/tools observers must
not block the agent goroutine even if the TUI consumer falls behind.
Tests cover inference pair recording, system section add/replace/remove,
tools add/remove, content-provenance splitting, and Recorder nil-safety.
A localhost-only web UI for inspecting session transcripts under
~/.gen/projects/<encoded-cwd>/transcripts/. Read-only, single binary
(assets embedded via go:embed), no build step on the frontend.
Backend (internal/trace):
- HTTP server with three endpoints:
GET / SPA shell
GET /api/sessions list transcripts in the project
GET /api/sessions/{id}/records paginated record fetch
GET /api/sessions/{id}/stream SSE live tail
- The wire format is the JSONL on disk verbatim — server doesn't
reshape records. One schema, not two.
- Polling-based tailer (500ms tick); no fsnotify dependency.
- Path-traversal guard rejects sessionIDs containing slashes.
Frontend (internal/trace/ui/assets):
- Vanilla JS, ~200 lines. EventSource for SSE.
- Sessions sidebar | colored timeline | JSON detail panel.
- Per-group filter checkboxes (state, tools, system, inference,
message).
- Auto-scrolls when near the bottom; pauses on user scroll-up.
CLI (cmd/gen/trace.go):
- `gen trace` binds 127.0.0.1 on a random port by default, opens the
browser, blocks until Ctrl-C.
- --addr to pin a port; refuses any non-loopback host.
- --no-open to skip the browser launch (for headless / CI).
Tests cover the records endpoint, empty-project case, and the
path-traversal guard. Smoke-tested locally against real session files.
A first-principles description of the transcript event taxonomy, record envelope, payload shapes per group, replay algorithm, common troubleshooting recipes (jq one-liners), and the gen trace viewer's HTTP API. Companion to docs/transcriptstore.md, which covers storage layout and resume mechanics; this doc focuses on the event schema and the viewer.
Member
Author
|
Closing in favor of 5 smaller stacked PRs for easier review. Splitting into #N+1..#N+5 by C-slice. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds end-to-end session tracing: every byte that reaches the model is captured as a structured event in the session's JSONL, and a local
gen traceviewer renders the timeline. Seedocs/tracing.mdfor the first-principles design.What's in this PR
5 commits, layered:
refactor: append-only transcript persistence + fsync batching— replacesFileStore.Replace(full-file rewrite per turn) withStart+ per-eventAppendMessage+ singlePatchState. Adds an in-memorypersistedIDscache so dedup is O(1) after the first scan. fsync is now turn-boundary batched: critical writes (message.appended,inference.responded) sync; telemetry writes (system.section.*,tools.*,inference.requested,state.patched) buffer in the page cache and ride along on the next turn-boundary flush. Typical turn drops from ~5 fsync calls to 1.refactor: unify record/payload naming + ContentBlock.Source field— every record type now follows<entity>[.<sub-entity>].<past-tense-verb>. Renames:transcript.*→session.*,TranscriptID→SessionIDeverywhere (Go field + JSON tag),SystemRecord→SessionRecord(the struct never held system-prompt data; name now matches intent),Record.System→Record.Session. AddsContentBlock.Sourcefor provenance; renames the existing image-data field toImageSourceto free the namespace. Projector tolerates unknown patch paths for forward compatibility.feat: trace recorder for inference / system / tools + content provenance— newsession.Recorderwired throughcore.Config.OnEventtranslates lifecycle events into transcript records:inference.requested(digests of system prompt / tool list / message chain) andinference.responded(stop reason, latency, token usage)system.section.added/system.section.removeddriven by a new observer oncore.System;Use(sec, caller)/Drop(name, caller)carry caller info (system:init,command:identity,subagent:init, etc.).SetObserverreplays existing sections on attach so the event chain is complete from t0.tools.added/tools.removedvia the same pattern oncore.Tools. MCP registrations use callermcp:<toolname>. Both wrappers (permissionTools,progressTools) pass-through.splitTextByProvenancesplits user-message content on<system-reminder>boundaries and tags those blocks withSource="reminder". Round-trip safe —extractUserContentconcatenates all text blocks back into the originalcore.Message.Contentstring.emitTelemetryfor observer-fired events so the agent goroutine never blocks on a slow TUI consumer.feat: gen trace web viewer— newinternal/tracepackage +gen tracesubcommand. Localhost-only HTTP server, polling-based tailer (no fsnotify dependency), SSE live tail, JSONL is the wire format (server doesn't reshape records). Vanilla JS frontend, no build step, assets embedded viago:embed. CLI refuses any non-loopback--addr.docs: tracing event model— first-principles doc covering event taxonomy, payload shapes, replay algorithm, troubleshooting recipes, and the viewer's HTTP API.Architecture decisions
Use/Drop/Add/Remove, with explicitcallerstrings. Avoids digest-comparison-after-the-fact which would lose the "who changed this?" attribution.SetObserversnapshots current state and replays it as syntheticaddedevents. Lets the recorder be wired at agent-construction time without losing the initial state established bysystem.Build().core.Eventtypes (OnSystemChange,OnToolsChange) instead of introducing a separateRecorderinterface. The TUI already subscribes to the outbox; the recorder is a second consumer on the same stream./api/sessions/{id}/recordsreturns the JSONL lines untouched. One schema across disk, network, and browser.splitTextByProvenancenever trims; concatenating all returned blocks'Textfields reproduces the input byte-for-byte.extractUserContentdoes that concat. Net effect oncore.Message.Contentis zero.Test plan
go build ./...go test ./...— 51 packages pass, 0 FAILTestRecorderWritesRequestedAndRespondedPerTurnTestRecorderWritesSystemSectionEventsTestRecorderWritesToolsChangeEventsTestRecorderNilSafeTest_userContentToBlocks_splitsByProvenanceTest_userContentToBlocks_plainTextOneBlockTestServerListAndRecords(+ path-traversal guard)TestServerListNoTranscriptsTestFileStoreAppendMessageIsIdempotentTest_messagesToEntries_roundtripstill passes despite the ContentBlock splitgen trace --addr 127.0.0.1:38081 --no-open, verified/api/sessionsreturned real local sessions and SPA assets served correctlyCompatibility
transcriptId→sessionId) and record-type rename (transcript.*→session.*) are breaking.internal/session/and the in-process agent build paths use the renamed types.🤖 Generated with Claude Code