Skip to content

Feat/patch stream primitive#4

Open
clintberry wants to merge 10 commits into
mainfrom
feat/patch-stream-primitive
Open

Feat/patch stream primitive#4
clintberry wants to merge 10 commits into
mainfrom
feat/patch-stream-primitive

Conversation

@clintberry
Copy link
Copy Markdown
Contributor

Summary

  • Introduces the foundational `patches` event stream + dedicated table that captures every code change a session produces — broadcast over the existing per-session WebSocket subscription, with explicit supersession links and per-turn lifecycle.
  • Real producer: integrated into the existing real-agent completion path. Every successful agent turn that touches files emits a patch; failed turns also emit (flagged `failed_mid_turn`) so partial work doesn't leak into the next turn's attribution.
  • v0 demonstration UI: a read-only chat-surface marker anchored to the producing message (file/hunk counts, agent-color origin badge, supersession indicator, failed-mid-turn warning). The full Changes view, hunk threads, PR derivation, plan-step linking, and parity-by-build CI lint are deliberately deferred to follow-up brainstorms — this PR ships only the primitive they all consume.

This is idea #1 from docs/ideation/2026-05-14-changes-tab-vs-editor-ideation.md. The requirements doc is at docs/brainstorms/2026-05-14-patch-stream-primitive-requirements.md and the implementation plan at docs/plans/2026-05-14-001-feat-patch-stream-primitive-plan.md.

What landed

Backend (Go):

  • New `patches` table (migration 006) with `producing_message_id` FK, nullable `parent_patch_id` for supersession, `origin_type` enum (agent/human/system), `workspace_sha`, `committed_sha`, JSONB `hunks`, derived `file_count`/`hunk_count`. Migration 007 adds `failed_mid_turn` after the doc-review pass.
  • sqlc queries with a two-column `GetPatchBySessionAndID` that closes the cross-session ID-oracle a bare-id lookup would create.
  • New WS event `patch_created` on the existing per-session subscription. Slim broadcast payload; full hunks fetched on demand via `GET /api/sessions/{id}/patches/{patchId}`.
  • New `workspacepath` package extracts the DevPod bind-mount path resolver out of `handler/files.go` so multiple callers share it without import cycles.
  • New `workspacegit` package runs `git rev-parse HEAD` and `git diff` against the host-FS bind-mount path (per the existing bind-mount learning) and parses unified-diff output. 14 tests covering parser shapes and real-git invocations.
  • Patch emission integrated into the real-agent completion path: `executeAgent` captures HEAD before `executor.Execute`; after `finishAgent` returns the persisted message, `emitPatchForTurn` computes the diff, persists the patch, and broadcasts the slim event.
  • Seed migration 008 includes a v1→v2 supersession chain anchored to backfilled seed message IDs.

Frontend (React + Zustand):

  • `Patch`, `PatchOrigin`, `PatchHunk`, `PatchFile` types mirror the backend wire shape.
  • `api.listPatches` / `api.getPatch` follow the existing wrapper pattern.
  • Store gains `patches: Record<sessionId, Patch[]>` with `addPatch` dedup matching the `addMessage` / `addActivity` convention. Lazy-loaded on session activation.
  • WS hook handles `patch_created`; also debounces a `refreshFiles` since a new patch implies file changes.
  • New `PatchMarker` component renders adjacent to the producing message in `ChatView` with the agent's role color, file/hunk counts, supersession caption, failed-mid-turn warning, and a full `aria-label` covering all of the above.

Notable plan-time decisions (origin: brainstorm + doc-review findings)

  • Producer is the existing real-agent completion path, not a simulated handler. The brainstorm assumed today's agents were canned (per a stale CLAUDE.md); Phase 1 research found the real Claude Code executor already runs in the devcontainer. Wiring into it now means the primitive is real day-one.
  • Patches link explicitly to the producing message via a new `producing_message_id` column not in the original brainstorm. The chat marker needs an anchor; null fallback markers were scope-creep.
  • Broadcast carries slim metadata; full hunks via REST. Strict reading of brainstorm R11 would have pushed full hunks on every WS event, but the WS send buffer is bounded and large hunks risk silent drops. Slim metadata covers what the v0 marker needs.
  • Per-turn lifecycle with `failed_mid_turn` flag. Emit a patch for any non-empty diff at turn end regardless of `isError`, set the flag when the agent failed. Preserves audit-log integrity instead of leaking partial work into the next turn's diff.
  • `parent_patch_id` always null in v0. Runtime producer-set supersession requires a hint mechanism that lands with the intent-only-edits brainstorm. Seed exercises the rendering of the chain; runtime supersession comes later.

What this PR explicitly does NOT do

Each item below is named in Scope Boundaries and tracked for a separate brainstorm:

  • The Changes view UI (consumes this primitive)
  • Hunk-anchored threads (needs threading + this primitive)
  • Intent-only edits / re-prompt ergonomics (uses supersession; doesn't shape it)
  • PR header strip + derivation from a contiguous patch range
  • Plan-step ↔ patch linking
  • Parity-by-build CI lint
  • Human FS-watch producer, system-origin producers, time-travel scrubber

Commits

```
$COMMITS
```

Test plan

  • `cd server && make migrate` advances the DB to version 8 with the `patches` table including `failed_mid_turn`
  • `cd server && make migrate-down` rolls back cleanly through 008, 007, 006
  • `cd server && go test ./...` passes (14 new tests in `internal/workspacegit`)
  • `npx tsc --noEmit` passes
  • Hit `GET /api/sessions/{id}/patches` against the dev server — returns slim list (no `hunks` field)
  • Hit `GET /api/sessions/{id}/patches/{patchId}` — returns full patch with `hunks` array
  • Hit `GET /api/sessions/{wrongId}/patches/{patchId}` for a patch in a different session — returns 404 `PATCH_NOT_FOUND` (cross-session ID-oracle defense)
  • In a session with a ready workspace, @mention an agent and ask it to edit a file. After the agent reply lands, a `PatchMarker` appears under the reply showing file/hunk counts and the agent's role color
  • In a fresh DB (`make migrate` from empty), the seed patches in 008 land (visible in the auth-module seed session as three markers, with the middle one showing the supersession caption)
  • No console errors in the browser when patches stream in over WebSocket

🤖 Generated with Claude Code
EOF
echo "PR body saved to /tmp/pr-body.md ($(wc -l < /tmp/pr-body.md) lines)"

clintberry added 10 commits May 16, 2026 00:08
…imitive

Foundational primitive (#1 from the changes-tab ideation): a parallel event
stream + dedicated table that captures every change a session produces, with
explicit supersession links and per-turn lifecycle. Origin doc derives from
the brainstorm; plan derives from the origin and resolves plan-time decisions
on producer wiring, broadcast payload shape, and message anchoring.
Creates the patches table with session_id FK (cascade), nullable
producing_message_id and parent_patch_id FKs (set null), origin_type
text-with-check constraint, workspace_sha + nullable committed_sha,
hunks JSONB, and derived file_count / hunk_count for cheap marker rendering.

Indexes: (session_id, created_at DESC) for per-session list; partial
index on parent_patch_id for supersession-chain traversal.
Three queries: CreatePatch (insert all columns, RETURNING *),
ListPatchesBySession (filter by session, order newest-first, with limit),
and GetPatchBySessionAndID — a two-column lookup that requires both
session_id and id to match, closing the cross-session ID-oracle that
a bare id lookup would otherwise create on the upcoming GetPatch endpoint.
GET /api/sessions/{id}/patches returns slim patchSummary (no hunks)
ordered newest-first; GET /api/sessions/{id}/patches/{patchID} returns
the full patchResponse including hunks JSONB. The list shape is also
what the patch_created WebSocket broadcast will carry, so consumers can
render the marker without a follow-up fetch.

Adds TypePatchCreated = 'patch_created' to ws/events.go for the
broadcast event type (producer wiring lands with U5).
Resolves A1 from doc review. Plan-time decision: emit a patch for any
non-empty diff at agent turn end, regardless of isError. Set
failed_mid_turn = true when the agent reported an error so the audit
log preserves what the agent actually did before failing, without
attributing those edits to the next successful turn's diff.

The wire shape now carries failedMidTurn on patchSummary so consumers
(WebSocket subscribers and the future Changes view) can render the
flag without an extra fetch.
Extracts the host-FS workspace path resolver out of handler/files.go
into a new internal/workspacepath package so multiple callers can share
it without import cycles. handler/files.go's local helper now thinly
delegates to it; DEVPOD_AGENT_CONTENT_DIR still overrides.

Adds internal/workspacegit with CaptureHead and DiffSince. CaptureHead
runs git rev-parse HEAD against the workspace bind-mount path so the
patch primitive has a stable anchor at agent turn start. DiffSince
runs git diff --no-color --unified=3 <sha> and parses the unified-diff
output into a structured FileHunks/Hunk shape, returning convenient
file_count and hunk_count so callers don't reparse JSONB at render time.

Tests cover the parser (empty, single hunk, multi-file, add/delete,
single-line hunk header, contextual header, hunk content) and the
git invocations against a real ephemeral repo (HEAD capture, missing
workspace surfaces ErrWorkspaceNotFound, non-git directory errors,
diff with no changes, modified file, added file).
Wires the patch primitive into the existing agent completion path:

- executeAgent captures workspace HEAD via workspacegit.CaptureHead
  immediately before executor.Execute. Failure here logs and proceeds
  with patch emission disabled for the turn.
- finishAgent now returns the persisted agent message so its ID can be
  used as producing_message_id on the patch.
- After finishAgent, executeAgent calls emitPatchForTurn which runs
  workspacegit.DiffSince against the captured SHA, skips emission on
  empty diff (R6), persists the patch row, and broadcasts the
  patch_created event over the existing per-session WS subscription.
- The error path emits patches the same way, with failed_mid_turn=true,
  so partial work an agent committed before failing is preserved in
  the audit log instead of leaking into the next turn's diff (A1).
- parent_patch_id is always null in v0; the producer-set supersession
  hint lands with the intent-only-edits brainstorm (AE3 deferred).
- Emission failures are logged but do not propagate — the agent reply
  is already persisted and broadcast by the time emission runs.
…ain (U6)

Backfills explicit IDs (60... prefix) on the auth-module session's
seed messages in 002_seed_data.sql so 008_seed_patches can anchor
producing_message_id deterministically. Adds three seed patches:

  - 5...a1: anchored to message a3 (Coder, 3h ago), parent null
  - 5...a2: anchored to message a5 (Coder, 1h30m ago), supersedes a1
  - 5...a3: anchored to message a7 (Tester, 45m ago), parent null

The supersession chain (a1 -> a2) exercises the v1->v2 collapse path
the brainstorm's idea #3 (re-prompt -> supersede) will eventually
render. Hunks are compact unified-diff content with no embedded tabs
(Postgres JSONB rejects raw control chars in string values).

008 uses SELECT-INSERT guarded by WHERE EXISTS on the FK targets so
it's safe to run on a DB whose 002 seed sessions never loaded — the
inserts silently skip instead of failing the migration.
- types: add Patch, PatchOrigin, PatchHunk, PatchFile mirroring the
  backend wire shape. Slim and full payloads share one type via an
  optional hunks field.
- api: add api.listPatches and api.getPatch.
- store: add patches Record<sessionId, Patch[]> with addPatch (id
  dedup matching the addMessage/addActivity pattern) and setPatches.
  Lazy-load on setActiveSession the same way messages and activities
  load.
- ws: handle patch_created with the same snake/camel normalization
  recipe used by new_message. Also schedules a debounced files
  refresh since a new patch implies file changes the FilesView
  should pick up.
PatchMarker is the v0 read-only chat-surface marker that confirms a
patch landed: file/hunk counts, an origin badge in the producing
agent's role color, a supersession caption when parent_patch_id is
set, and a 'failed mid-turn' warning when the agent reported an
error mid-execution. No click behavior, no expand/collapse, no hover
detail — those belong to the future Changes view per plan Scope
Boundaries.

A11y: every marker carries a single aria-label that names origin,
counts, supersession, and failure state in one phrase so screen
readers get full context without relying on color or icon (D4 from
doc review).

ChatView indexes patches by producing_message_id and renders any
matching markers immediately after the producing MessageBubble. Patches
with null producing_message_id are not rendered in v0 — the only v0
producer (agent completion path) always sets the link, and the
orphan-marker fallback was scope-creep per SG4. The supersession label
is the plain phrase 'supersedes earlier change' with no version
number, honoring D6's call against deriving version state the schema
doesn't carry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant