fix(planning): the Work and Code tabs read a plan's work back after a restart

Merged
DA
8d ago
+1302-76

Summary

Dean: "the Work tab can be empty when I restart Luke… Similar with the Code tab." Confirmed. Opening a plan after a restart, or switching to another plan and back, left both tabs empty even though the service had everything on record.

Root cause, per tab

  • Work. The host (packages/host/src/compose-planning.ts) only ever held a plan's Work turns from live plan.work frames during a call. It never read them back from anywhere. PLANNING_OPEN (every launch, every plan switch) clears them through withoutActivity, and nothing re-reads them, although the relay journals every planning turn in the plan's conversation. So: pushed live only, never re-read on launch or plan open.
  • Code. It was ephemeral by design: planning-view.ts said "It stands for the call alone", the host's callEnded cleared it, and the renderer drew code only while live. So the tab went back to its empty line after every call, not just after a restart. But show_code's lines are the call's journaled answer, so keeping them needs no new server field. Product call, made with the orchestrator/Dean: persist it. Every accepted show_code answer on the plan is read back, and the Code tab shows the newest. A stacked follow-up turns the tab into a feed of every snippet.
  • Board and Transcript don't fail this way. Both are read on every PLANNING_OPEN and PLANNING_REFRESH (readBoard, readTranscript), so a restart reads them back.
  • The persisted chosen tab is not at fault: use-side-panel.ts reads it once, and the tab redraws from whatever the view brings.

What changed

  • Server: GET /api/plans/{id}/work (apps/web/server/hosted/plan-work-store.ts), scoped to the owning account like the other plan routes (another account's plan reads as 404).
    • Returns the plan conversation's turns twenty a page, newest first, each projected by the same planWorkOf the live call uses (worker sessions included). ?before=<turn id> reads the page before that turn, and the answer names earlier while an older page stands. No cap on how far back (Dean's decision).
    • The newest page also carries every accepted show_code answer on the plan, oldest first, several in one file included. Each entry has callId, turnId, shownAt, and code (path, line range, window lines, repository).
    • Journals holding a show_code call are picked in SQL (parts @> '[{"type":"tool-show_code"}]'), so the read doesn't pull every journal of a long plan.
  • Refusal rules on read-back: only accepted answers read back. The checkout's realpath check, missing file, binary, and oversized refusals all journal as rejected and never come back. shownCodeOf now applies the .env* secret rule again, live and on read-back, so a journal written under an older rule can't put a secret file on screen. There's a test that fails without it.
  • Host:
    • Reads the newest page with the document on open and refresh.
    • Keeps every turn: the 20-turn cap in showWork is gone.
    • A turn a call told stands over the record's copy while that copy still says running. A settled record copy wins.
    • Code a call put on screen stands over the record's, since the record may hold code the call never got to show.
    • Code now survives callEnded.
    • New gateway method planning.workEarlier, and act planning.workEarlier, read the page before the oldest turn held.
  • Renderer:
    • The Work tab shows a "Show earlier work" row above its oldest turn when older turns exist. It reads the next page when it scrolls into view (IntersectionObserver), or on press. If a read loads nothing, it says "Could not read earlier work. Try again" and waits for a press instead of retrying on its own.
    • The Code tab draws the plan's code with no call standing.
    • Code counts as an arrival (the panel opening by itself) only during a call, so opening a plan that already has code doesn't pop the panel.
  • Docs: PRIVACY.md (product change, called out here): the Work tab's turns and the code shown are now read back from the service when a plan opens, held in memory only. The "cleared when the call ends" line is replaced. Also updated: docs/PLANNING.md, apps/web/server/README.md, and the planning-view.ts comments.

Reproduction

  • Host, through the real composer (compose-planning.test.ts): a fresh composer stands for a restart. Open a plan whose service holds turns and code: on main the view's work and code stay empty. Switching to another plan and back has the same result.
  • Server, on PGlite (apps/web/tests/plan-work.test.ts): real eve events replayed through the store relay into a plan's conversation, then read back through readPlanWork. Covers turn order, every code shown (one refused), 22 turns paged 20 + 2, the secret file not read back, worker sessions, and another account reading nothing.
  • Renderer, through public hooks (use-plans-tab.test.ts, desktop-plans.test.ts): with no call standing, code is drawn and no panel opens. The earlier-work row reads on scroll-in, stops after a read that loaded nothing, and reads again on press.

Before / after

Headless Chrome render of the real DesktopPlans page and side panel (stub controls, built styles.css, dark). "Before" is what a plan opened after a restart drew: nothing on record reached the tabs. "After" is the same plan once the /work read lands. The Work tab draws its turns, with the row above the oldest reading the page before it as it scrolls into view. The Code tab draws the last code shown, with no call standing.

Before (restart)After
Work
Code

Evidence

  • Platform-independent checks: ./scripts/check.sh exits 0 locally. CI runs it on Linux.
  • macOS Electron verification (./scripts/verify.sh): not run. This workspace is a Linux VM. CI could not verify the Mac UI, and the Mac pass is LUKE-159.

Visual evidence

  • Fixture screenshots inspected: not attached (no Mac). The headless Chrome renders above were inspected.

Physical-device evidence

  • Screenshot or screen recording: not attached (no Mac in this workspace)

Tests

Each fails on main (or on this branch with the fix reverted) and passes here:

  • packages/host/src/compose-planning.test.ts:
    • a plan opened after a restart or a switch draws its work and the code last shown
    • the service's read lands under what the call told
    • the Work tab's ask reads older pages until none stand, and a refresh keeps them
    • the call's end keeps its code (this replaces the old "clears" test)
    • the call keeps every turn past a page (this replaces the old cap test)
  • apps/web/tests/plan-work.test.ts (PGlite): turns oldest first plus every code shown, owner-scoped; paging; never-planned plan; secret file not read back; worker sessions.
  • apps/web/tests/plans-app.test.ts: the route is owner-scoped, refuses a bad before, and is GET only.
  • packages/hosted/src/plan-client.test.ts: readWork reads a page at a time.
  • apps/desktop/src/main/ipc/planning-acts.test.ts: planning.workEarlier reaches the host from a panel only.
  • apps/desktop/src/renderer/planning/use-plans-tab.test.ts: code drawn with no call, no panel pop; earlier work read through the act.
  • apps/desktop/src/renderer/desktop/desktop-plans.test.ts: the earlier-work row reads on scroll and stops after a read that loaded nothing.

Test count: +11 new tests, 2 rewritten for the new behaviour (code kept past a call's end; no turn cap).

Invariants

  • ./scripts/check.sh green. ./scripts/verify.sh was not run (Linux VM, no Mac): CI cannot verify the Mac UI, see LUKE-159.
  • JSON Schema goldens unchanged.
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged.
  • No new as type assertion outside the allowlisted files (the one test cast carries SAFETY:).
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch.
  • No Effect, Stream, or Layer dropped as a statement.
  • Test count delta listed above.
  • No hand-rolled file replaced.
  • Docs naming the changed parts edited (docs/PLANNING.md, apps/web/server/README.md). AGENTS.md/CLAUDE.md untouched.
  • PRIVACY.md changed: a product decision, called out above (Work turns and shown code are read back from the service when a plan opens, memory only).
  • Package deps unchanged. function-externals.json gains node:dns/promises for default.js, a Node builtin the plans route now reaches through the planning tool registry.
  • Barrel/door rule holds.

Notes

  • Follow-up (stacked, another workspace): the Code tab becomes a feed of every snippet, using the code list this read already returns.

🤖 Generated with Claude Code