Skip to content

feat(dashboard): file-mode Epic display (epic_ref + file:// links)#189

Closed
thejustinwalsh wants to merge 15 commits into
feat/file-backed-epic-storefrom
middle-issue-187
Closed

feat(dashboard): file-mode Epic display (epic_ref + file:// links)#189
thejustinwalsh wants to merge 15 commits into
feat/file-backed-epic-storefrom
middle-issue-187

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #187

Plumbs the file-mode Epic identifier (epic_ref, a slug) through the dashboard's /api/* read plane so file-mode workflows (epic_number IS NULL, epic_ref = '<slug>') render a file://planning/epics/<slug>.md link instead of a blank cell. github-mode rows render exactly as before (plain #N).

Stacked on #188 (feat/file-backed-epic-store) — this PR's base is that branch, since #187 builds on the epic_ref column it introduces (migration 008). The diff here is only the dashboard display work. When #188 merges to main, GitHub auto-retargets this PR to main.

Acceptance criteria

  • db-deps.ts selects epic_ref and the /api/* response shape gains epicRef: string | null — proven end-to-end by a real Bun.serve integration test that boots the server against a migrated db and issues a GET to /api/repos/:repo + /api/sessions/:session, asserting a file-mode row surfaces epicRef: api.test.ts
  • The UI renders the slug as a file://planning/epics/<slug>.md link when epicRef is set and epicNumber is null, with no GitHub link in file mode — EpicRef component, used in the IN-FLIGHT runner row + Inspector header; verified by epic-ref.test.tsx
  • Bridge events emit epicRef alongside epicbridge.ts repo-channel nudge; verified by a live-SSE test in sse.test.ts
  • No behavior change for github-mode rows (plain #N, byte-identical) — verified by epic-ref.test.tsx (toBe("#42")) and api.test.ts

What changed

  • packages/dispatcher/src/workflow-record.tsgetWorkflow reads the migration-008 epic_ref column into a new WorkflowRecord.epicRef (read path only; the dispatch write path is untouched — see feat(epic-store): foundation — spec + plan + parser/renderer/migrations #188 Task 3).
  • packages/dashboard/src/db-deps.ts, wire.ts — select epic_ref; RunnerSummary / RunnerPanel gain epicRef: string | null.
  • packages/dashboard/src/app/components/EpicRef.tsx (new) — github → plain #N; file → file:// slug link; blank/null → fallback. Used in RunnerRow.tsx + Inspector.tsx.
  • packages/dashboard/src/bridge.ts — the repo-channel workflow nudge emits epicRef.

Why these changes

The issue cites db-deps.ts:83 — the /api/* read plane that reads the workflows.epic_ref column. AC4 is the load-bearing constraint: github-mode rows render plain #N text today (no anchor exists), so EpicRef preserves that byte-for-byte and adds the file:// link only for file mode — adding a GitHub anchor would itself be a behavior change. The slug is encodeURIComponent-collapsed into one safe path segment so a malformed value can't traverse out of planning/epics/ or inject markup. Full reasoning in planning/issues/187/decisions.md, distilled into inline review comments on this PR.

Verification

All gates green locally:

  • bun run typecheck — clean.
  • bun run lint / bun run format — clean.
  • bun test — 1105+ pass, 0 fail (full suite). Affected: packages/dashboard/** + packages/dispatcher/test/workflow-record.test.ts → 128 pass.

Per-criterion evidence:

  • Integration (live API): packages/dashboard/test/api.test.ts boots a real Bun.serve over a migrated temp db; a seeded file-mode row (epic_number NULL, epic_ref slug) surfaces epicRef over GET /api/repos/:repo (inFlight[].epicRef) and GET /api/sessions/:session (RunnerPanel.epicRef); a github-mode row carries epic: 7, epicRef: null.
  • Component: packages/dashboard/test/epic-ref.test.tsx — all three render modes (github #N no-anchor, file file:// slug link, both-null fallback), blank/whitespace slug → fallback, slug encoding (a/../ba%2F..%2Fb, <script> neutralized), plus RunnerRow/Inspector rendering.
  • Bridge: packages/dashboard/test/sse.test.ts — a live SSE connection asserts the workflow nudge carries epicRef (github row → null; file row → the slug).
  • Read path: packages/dispatcher/test/workflow-record.test.tsgetWorkflow round-trips epic_ref (slug / number-string / null).

Self-review

Ran an adversarial clean-eyes review pass over the diff before marking ready. It confirmed the diff clean against the ACs and surfaced one in-blast-radius hardening — a blank (""/whitespace) epicRef would have rendered an empty-labelled file://…/.md link — now guarded (trimmed-truthy slug → fallback) with tests. Adjacent surfaces it flagged are out of scope and tracked below.

Stumbling points

  • The branch was stacked, not based on main. The dispatched worktree's branch already carried the entire feat/file-backed-epic-store foundation (PR feat(epic-store): foundation — spec + plan + parser/renderer/migrations #188), so the initial PR-against-main diff was ~5k lines of someone else's work. Rebased onto origin/feat/file-backed-epic-store and retargeted this PR's base to it, leaving a focused 5-commit diff. (Suggested CLAUDE.md note below.)
  • The issue's "renders it as a numeric link to GitHub" is aspirational. No GitHub anchor exists for epics today; the surfaces render plain #N. AC4 ("no behavior change") is the tie-breaker — preserved plain text for github mode.
  • createWorkflowRecord doesn't write epic_ref. Migration 008's comment claims it does; it doesn't yet. That's feat(epic-store): foundation — spec + plan + parser/renderer/migrations #188's Task 3 (write path), out of scope here — github rendering keys on epic_number, so a null epic_ref on new github rows is harmless.

Suggested CLAUDE.md updates

Consider a note (root or packages/dispatcher/CLAUDE.md) that issues which are phases of a multi-PR rollout are dispatched on a branch stacked on the foundation PR's branch, so the implementer should retarget the PR base to that branch rather than main (and let GitHub auto-retarget on the foundation's merge). This run rediscovered it by inspecting open PRs.

Follow-ups (not filed — already tracked by the #188 rollout plan)

The remaining file-mode surfaces are owned by the file-backed Epic store rollout plan (docs/superpowers/plans/2026-05-29-file-backed-epic-store.md, Tasks 3–4), so filing standalone issues would duplicate planned scope:

  • Queue tab / /control/events (main.ts broadcastWorkflowControlWorkflowFrame) still shows for file-mode rows — a separate (control-plane) data path the issue doesn't cite.
  • NEXT UP (db-deps.ts:307, recommender state-issue ranking) collapses a file-mode slug to #0 via Number(slug) || 0, and Repos.tsx keys the list on n.epic — a latent React duplicate-key collision once two file-mode rows both map to #0. Whoever wires NEXT UP for file mode should carry the raw ref through NextUpItem and key on rank.
  • createWorkflowRecord epic_ref write pathfeat(epic-store): foundation — spec + plan + parser/renderer/migrations #188 Task 3 Step 4.

Out of scope

  • File-mode dispatch endpoint via the dashboard UI (CLI mm dispatch --epic <slug> is the path for now).
  • Sub-issue rendering from the Epic file.

…hybrid)

Per-repo opt-in `epic_store = "file"` mode: one Markdown file per Epic
under `planning/epics/`, recommender state in `.middle/state.md`. PRs and
CI stay GitHub-native in both modes ("hybrid"). Workflow bodies, gates,
watchdog, hook server, poller — all unchanged: the three existing single-
seam interfaces (`GitHubGateway`, `StateIssueGateway`, `GitHubPollGateway`)
get renamed (`EpicGateway`, `StateGateway`, `PollGateway`) and gain
parallel file implementations behind the same contracts. Bootstrap picks
the implementation per-repo from `repo_config.epic_store`.

The agent's `blocked.json` flow plugs in at one DI seam (`postQuestion`).
Round-trip-pure parser/renderer absorbs #178's class (structurally
distinct `question`/`answer` markers) and #180's class (renderer is the
sole writer for strict-marker content). Phase split: file-Epic dispatch
ships first (~2 wk) with a `mm resume` escape hatch; file-watcher Q&A
resume (~1 wk) rides the existing 120s poller cron.

See: docs/superpowers/specs/2026-05-29-file-backed-epic-store-design.md
…se 2

Bite-sized TDD steps, exact files + commands + expected output for each.
Phase 1 (~2 wk): rename gateways → schema migrations → parser/renderer
→ three file gateways → bootstrap selector → postQuestion wiring → mm
init/dispatch/doctor/resume → skill refactor → parity test → smoke.
Phase 2 (~1 wk): mtime poll helper → file-signal poll on poller cron →
parity test for file-edit Q&A resume → smoke.

Spec: docs/superpowers/specs/2026-05-29-file-backed-epic-store-design.md
…/Poll)

Preparation for the file-backed Epic store: the three existing single-
seam DI'd interfaces are renamed to reflect what they abstract (an Epic
store, not GitHub specifically). Implementations (ghGitHub,
ghStateIssueGateway, ghPollGateway) keep their gh* names — they're the
GitHub implementation of the renamed interfaces. Behavior unchanged.

- GitHubGateway → EpicGateway
- StateIssueGateway → StateGateway
- GitHubPollGateway → PollGateway

Mechanical codemod across 23 files. 1068 tests pass, typecheck clean.
Additive migration: adds epic_store ('github' default), epics_dir, and
state_file columns. All existing rows default to github mode — zero
behavior change for existing repos. File-mode opt-in is a single config
edit (epic_store.mode = "file" in .middle/<slug>.toml).

The bootstrap selector reads this column to pick the gateway trio at
buildImplementationDeps time. epics_dir / state_file are nullable —
only populated when mode = 'file'.
… (008)

Additive migration with backfill: epic_ref TEXT (nullable), populated
from CAST(epic_number AS TEXT) for every existing row whose epic_number
is non-null. Recommender / documentation workflows have null epic_number
and stay null epic_ref. github mode writes both columns; file mode
writes only epic_ref (slug). Application-level enforcement in
createWorkflowRecord ensures every implementation workflow has it set.

epic_number was already nullable — no table rebuild needed.
HTML-comment markers are the structural contract for the round-trip
parser/renderer that follows. Type model describes the fully-parsed Epic
shape: meta, acceptance, sub-issues, conversation. Every field a
renderer needs is on the model so the parser preserves it — required
for the byte-identical round-trip invariant.

Marker convention mirrors state-issue v1 (<!-- AGENT-QUEUE-STATE v1 -->):
the marker IS the structural contract; the renderer is the sole writer
of strict attribute lines (closes #180's class of writer/parser drift).
…rsation)

Strict on markers + attributes (the structural contract), lenient on
prose. Throws with a named-marker error when a structural element is
malformed — operators diagnose without log-tailing.

Conversation parser distinguishes dispatch-event vs question, threads
an answer block under its question, and treats an answer's html-comment-
only body as 'placeholder empty' (no resume) vs human-written text
('replied' — the file-watcher trigger).

12 tests covering: empty epic, missing markers, every meta key, checked
+ unchecked sub-issues, provenance suffix, conversation with empty
answer, conversation with non-empty answer.
…erty test

renderEpicFile(parseEpicFile(body)) === body for every fixture (empty-
epic, codex-adapter, mid-question, all-closed) — byte-identical, first
try. Round-trip purity replaces a lock: dispatcher and human can both
edit the file (dispatcher patches conversation entries via the
renderer; human edits between markers or inside their answer block)
without corrupting each other's writes.

The renderer is the sole writer of strict-marker attribute lines.
Agents/humans write between markers but never inside the attributes —
that single-writer rule closes #180's class for the file path.
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 678802ee-f3e1-4eb3-8d93-82cec7becf31

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

getWorkflow now reads the migration-008 epic_ref column into a new
WorkflowRecord.epicRef field (slug in file mode, String(epic_number) or
null in github mode). Read accessor only — the dispatch write path is
unchanged. Unblocks the dashboard bridge emitting epicRef (#187).
…ridge

db-deps selects epic_ref and projects it onto RunnerSummary and
RunnerPanel (epicRef: string | null); the repo-channel workflow nudge in
bridge.ts emits epicRef alongside epic. github-mode rows are unaffected
(epic_number drives their rendering). (#187 AC1, AC3)
New EpicRef component: github mode → plain #N (unchanged, no anchor —
AC4); file mode → the slug as a file://planning/epics/<slug>.md link (no
GitHub link); both-null → caller fallback. Wired into the IN-FLIGHT
runner row and the Inspector header, the db-deps.ts:83-fed surfaces the
issue cites. Slug is URL-encoded into one safe path segment. (#187 AC2)
EpicRef component (all three render modes + slug encoding), RunnerRow /
Inspector rendering, the bridge file-mode emit, and the integration path
(real Bun.serve + migrated db): a file-mode workflow row surfaces epicRef
over /api/repos/:repo and /api/sessions/:session. seedWorkflow gains an
epicRef option. (#187)
@thejustinwalsh thejustinwalsh changed the base branch from main to feat/file-backed-epic-store May 29, 2026 15:54
…/ link

A present-but-blank slug (empty or whitespace) took the file-mode branch
and rendered <a href="file://planning/epics/.md"></a>. Guard on a
trimmed-truthy slug so it falls through to the fallback, and trim
surrounding whitespace in the label + href. Self-review hardening within
the EpicRef blast radius. (#187)

@thejustinwalsh thejustinwalsh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision log distilled inline (source: planning/issues/187/decisions.md). These explain the why behind the diff for the reviewer.

/** What to render when there's no Epic at all (both ids null). */
fallback?: string;
}) {
if (epicNumber !== null) return <>#{epicNumber}</>;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github mode renders plain #N, not an anchor — on purpose. The issue Context describes the current rendering as "a numeric link to GitHub", but the surfaces in fact render plain #N text today (no <a> exists). AC4 is the hard constraint — no behavior change for github-mode rows — so adding a GitHub anchor would itself be a behavior change. The literal reading wins: github mode keeps its exact pre-#187 plain-text output; file mode is the only new rendering. (toBe("#42") in the test asserts byte-identical markup.)

* path segment, URL-encoded so a malformed value can't break out of the
* `planning/epics/` directory or inject markup into the `href`.
*/
export function epicFileHref(slug: string): string {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file:// href + slug encoding. AC2 names exactly file:// + planning/epics/<slug>.md; the repo's absolute root isn't known client-side, so the path stays repo-relative as specified (yes, file://planning/... parses planning as the URL authority — it's a display affordance, not a guaranteed click-to-open). encodeURIComponent collapses the slug to one safe path segment, so a malformed value (../, quotes, angle brackets) can't traverse out of planning/epics/ or inject markup into the href; a normal kebab-case slug encodes to itself.

// null; an empty/whitespace value (no real writer produces one today, but a
// future one could) would otherwise render an empty-labelled link to
// `planning/epics/.md`, so treat it as "no Epic" and fall through.
const slug = epicRef?.trim();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank-slug guard (self-review hardening). A present-but-blank epicRef ("" / whitespace) would otherwise take the file-mode branch and render <a href="file://planning/epics/.md"></a>. No writer produces a blank value today, but guarding on a trimmed-truthy slug encodes the real intent ("a present, non-blank slug is the file-mode signal") and survives a future writer. Trimming also keeps stray whitespace out of the label and href.

kind: row.kind as WorkflowRecord["kind"],
repo: row.repo,
epicNumber: row.epic_number,
epicRef: row.epic_ref,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read path only. getWorkflow reads the migration-008 epic_ref column verbatim so the dashboard bridge (AC3) can emit it. The dispatch write path (createWorkflowRecord) is deliberately untouched — populating epic_ref on insert is the file-backed Epic store rollout's job (PR #188 plan, Task 3 Step 4), out of scope here. github rendering keys on epic_number, so a null epic_ref on new github rows is harmless.

@thejustinwalsh thejustinwalsh marked this pull request as ready for review May 29, 2026 16:03
@thejustinwalsh thejustinwalsh added the ready-for-review All phases done and verified — PR ready for final human review and merge label May 29, 2026
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

Reviewer's brief — PR #189 (closes #187)

What this is. The dashboard's /api/* read plane now carries the file-mode Epic identifier (epic_ref, a slug) and renders it as a file://planning/epics/<slug>.md link where it used to show a blank cell. github-mode rows are unchanged (plain #N).

⚠️ Stacked PR. Base is feat/file-backed-epic-store (PR #188), not main#187 builds on the epic_ref column #188 introduces. Review/merge #188 first; GitHub auto-retargets #189 to main on that merge. The diff here is only the 5 dashboard commits.

How to run it

git fetch origin && git checkout middle-issue-187
bun install
bun run typecheck          # clean
bun run lint               # clean
bun test packages/dashboard packages/dispatcher/test/workflow-record.test.ts   # 128 pass

For the live read path specifically:

bun test packages/dashboard/test/api.test.ts -t "file-mode"

What to verify (and what "correct" looks like)

  1. packages/dashboard/src/app/components/EpicRef.tsx — the rendering rule. github mode (epicNumber !== null) → plain #N, no anchor (AC4: byte-identical to before). file mode (epicNumber null, epicRef a non-blank slug) → the slug as a file://planning/epics/<slug>.md link, no GitHub link. Both null → caller fallback (#— for the runner surfaces). Confirm epic_number is what drives github rendering, so a null epic_ref on github rows is harmless.
  2. Slug safetyepicFileHref runs the slug through encodeURIComponent, so ../, quotes, angle brackets collapse to one inert path segment (tests assert a/../ba%2F..%2Fb). The file://planning/... shape is a deliberate literal reading of AC2 (repo root unknown client-side) — it's a display affordance, not guaranteed click-to-open. Worth a sanity check that you're OK with that shape.
  3. Read-path plumbinggetWorkflow (workflow-record.ts) reads epic_ref verbatim; db-deps.ts selects it into RunnerSummary/RunnerPanel; bridge.ts emits it on the repo-channel nudge. The dispatch write path is untouched (that's feat(epic-store): foundation — spec + plan + parser/renderer/migrations #188 Task 3) — confirm you agree createWorkflowRecord populating epic_ref is out of scope here.

How to review

  • Decisions are distilled into 4 inline review comments on the diff (EpicRef render rule, href/encoding, blank-slug guard, read-path-only) — start there for the why.
  • Acceptance evidence is the checklist in the PR body; each criterion links its proving test. The integration criterion is api.test.ts (real Bun.serve + migrated db).

Fragile / extra eyes

  • AC4 is the load-bearing constraint. github epics render plain #N text today (no anchor ever existed); the issue's "numeric link to GitHub" is aspirational. EpicRef preserves the plain text exactly. If you'd actually prefer a real GitHub anchor for github mode, that's a deliberate behavior change to request — it's not what shipped.
  • Adjacent surfaces still show file-mode as blank/#0 (Queue tab via /control/events; NEXT UP via the recommender's state issue, which also has a latent key={n.epic} duplicate-key collision once two slugs map to #0). These are separate data paths owned by the feat(epic-store): foundation — spec + plan + parser/renderer/migrations #188 rollout plan (Tasks 3–4), called out in the PR's Follow-ups section — not regressions introduced here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review All phases done and verified — PR ready for final human review and merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(dashboard): file-mode Epic display (epic_ref + file:// links)

1 participant