From fa0f0eec33025ed98c009c4d9c93d518472121b6 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Fri, 29 May 2026 11:45:27 -0400 Subject: [PATCH 1/7] docs(dashboard): plan for #187 file-mode Epic display --- planning/issues/187/plan.md | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 planning/issues/187/plan.md diff --git a/planning/issues/187/plan.md b/planning/issues/187/plan.md new file mode 100644 index 00000000..02ab904e --- /dev/null +++ b/planning/issues/187/plan.md @@ -0,0 +1,65 @@ +# Issue #187: feat(dashboard): file-mode Epic display (epic_ref + file:// links) + +**Link:** https://github.com/thejustinwalsh/middle/issues/187 +**Branch:** middle-issue-187 + +## Goal +Make the dashboard's `/api/*` plane carry and render the file-mode Epic +identifier (`epic_ref`, a slug) so file-mode workflows — `epic_number IS NULL`, +`epic_ref = ''` — show a `file://planning/epics/.md` link instead of +a blank cell, with github-mode rows rendering exactly as they do today. + +## Approach +- Plumb `epic_ref` through the read path the dashboard already uses: the + workflows-table column (added by migration 008) → `getWorkflow` (for the + bridge) and `db-deps` (for the `/api/*` responses) → the wire types → the UI. +- Add one small `EpicRef` component that owns the github-vs-file rendering rule, + and use it in the surfaces the issue cites (the `db-deps.ts:83`-fed plane): + the IN-FLIGHT runner row and the Inspector panel. +- Render rule: `epicNumber !== null` → `#N` (unchanged, no link — preserves + today's exact github-mode output); else `epicRef !== null` → the slug as a + `file://planning/epics/.md` link; else the surface's existing fallback. +- Bridge: `bridgeWorkflowsToBus` emits `epicRef` alongside `epic`. +- Read-only change — no dispatch write path is touched (`createWorkflowRecord` + still doesn't set `epic_ref`; that's file-mode dispatch, out of scope). + +## Phases +This is a standalone issue (no sub-issues) — one phase, verified as a unit. +1. file-mode Epic display — plumb `epic_ref` (dispatcher read path + db-deps + + wire), add `EpicRef`, render in RunnerRow + Inspector, emit in the bridge, + with unit + integration (real `Bun.serve` + migrated db) coverage. + +## Files likely to change +- `packages/dispatcher/src/workflow-record.ts` — `WorkflowRecord.epicRef`, + internal `WorkflowRow.epic_ref`, map it in `getWorkflow` (bridge read path). +- `packages/dashboard/src/db-deps.ts` — select `epic_ref`, add to + `WorkflowRow` + `WORKFLOW_COLUMNS`, project into RunnerSummary/RunnerPanel. +- `packages/dashboard/src/wire.ts` — `epicRef: string | null` on `RunnerSummary` + and `RunnerPanel`. +- `packages/dashboard/src/app/components/EpicRef.tsx` — new shared renderer. +- `packages/dashboard/src/app/components/RunnerRow.tsx`, + `.../Inspector.tsx` — render via `EpicRef`. +- `packages/dashboard/src/bridge.ts` — emit `epicRef`. +- `packages/dashboard/test/helpers.ts` — `seedWorkflow` gains an `epicRef` opt. +- Tests: `workflow-record`, `db-deps` (deps), `sse` (bridge), a new + `epic-ref.test.tsx` (component), and `api.test.ts` (integration: file-mode + row → `/api/repos/:repo` + `/api/sessions/:session` carry `epicRef`). + +## Out of scope +- File-mode dispatch endpoint via the dashboard UI (per the issue). +- Sub-issue rendering from the Epic file (per the issue). +- The `/control/events` plane (`main.ts` `broadcastWorkflow` → `Queue` tab): + a separate data path the issue does not cite (it names `db-deps.ts:83`). The + Queue tab keeps showing `—` for file-mode rows until a follow-up plumbs + `epicRef` through the control feed. Noted as a discovery follow-up. +- Populating `epic_ref` on insert (`createWorkflowRecord`): the migration's + design says github-mode should write `epic_ref = String(epic_number)`, but no + AC requires it and github rendering keys on `epic_number`. File-mode dispatch + owns the write path. Noted as a follow-up. + +## Open questions +- "no GitHub link in file mode" + "github-mode rows keep their numeric link": + github epics render as plain `#N` text today (no anchor). The hard constraint + is AC4 "no behavior change for github-mode rows", so `EpicRef` preserves the + plain `#N` text for github mode and adds the `file://` anchor only for file + mode. Resolved by honoring the no-behavior-change constraint literally. From fc1bc2b41a1c2b280e20bf04ec694489df0d1245 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Fri, 29 May 2026 11:52:04 -0400 Subject: [PATCH 2/7] feat(dispatcher): expose epic_ref on the workflow read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/dispatcher/src/workflow-record.ts | 9 ++++++ .../dispatcher/test/workflow-record.test.ts | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/dispatcher/src/workflow-record.ts b/packages/dispatcher/src/workflow-record.ts index b98a2f25..11762a71 100644 --- a/packages/dispatcher/src/workflow-record.ts +++ b/packages/dispatcher/src/workflow-record.ts @@ -28,6 +28,13 @@ export type WorkflowRecord = { kind: "implementation" | "recommender" | "documentation"; repo: string; epicNumber: number | null; + /** + * The canonical Epic reference (migration 008): `String(epicNumber)` for + * github-mode rows, a slug for file-mode rows, null when there's no Epic + * (recommender / documentation). Read straight from the column — the dispatch + * write path, not this read accessor, is what populates it. + */ + epicRef: string | null; adapter: string; state: WorkflowState; createdAt: number; @@ -540,6 +547,7 @@ type WorkflowRow = { kind: string; repo: string; epic_number: number | null; + epic_ref: string | null; adapter: string; state: string; created_at: number; @@ -781,6 +789,7 @@ export function getWorkflow(db: Database, id: string): WorkflowRecord | null { kind: row.kind as WorkflowRecord["kind"], repo: row.repo, epicNumber: row.epic_number, + epicRef: row.epic_ref, adapter: row.adapter, state: row.state as WorkflowState, createdAt: row.created_at, diff --git a/packages/dispatcher/test/workflow-record.test.ts b/packages/dispatcher/test/workflow-record.test.ts index d64e49f2..f67895fb 100644 --- a/packages/dispatcher/test/workflow-record.test.ts +++ b/packages/dispatcher/test/workflow-record.test.ts @@ -40,6 +40,38 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); +describe("getWorkflow epic_ref (#187)", () => { + test("reads back epic_ref straight from the column (slug, number-string, or null)", () => { + // github-mode: createWorkflowRecord writes only epic_number; epic_ref stays null + // until the dispatch write path populates it. getWorkflow reflects the column verbatim. + createWorkflowRecord(db, { + id: "gh", + kind: "implementation", + repo: "o/r", + epicNumber: 7, + adapter: "claude", + }); + expect(getWorkflow(db, "gh")!.epicRef).toBeNull(); + + // A row whose epic_ref is set (file-mode slug, or a github-mode backfill) round-trips. + db.run("UPDATE workflows SET epic_ref = ? WHERE id = ?", ["rollout-epic-store", "gh"]); + expect(getWorkflow(db, "gh")!.epicRef).toBe("rollout-epic-store"); + + // file-mode shape: null epic_number, slug epic_ref. + createWorkflowRecord(db, { + id: "file", + kind: "implementation", + repo: "o/r", + epicNumber: null, + adapter: "claude", + }); + db.run("UPDATE workflows SET epic_ref = ? WHERE id = ?", ["another-slug", "file"]); + const file = getWorkflow(db, "file")!; + expect(file.epicNumber).toBeNull(); + expect(file.epicRef).toBe("another-slug"); + }); +}); + describe("dispatch source (#53)", () => { test("records and reads back source 'manual' / 'auto'; null when unset", () => { createWorkflowRecord(db, { From 1912003a0ccc94323888c3cf97c0a9ef82dd7ef1 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Fri, 29 May 2026 11:52:10 -0400 Subject: [PATCH 3/7] feat(dashboard): plumb epicRef through db-deps, wire types, and the bridge 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) --- packages/dashboard/src/bridge.ts | 2 +- packages/dashboard/src/db-deps.ts | 5 ++++- packages/dashboard/src/wire.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/bridge.ts b/packages/dashboard/src/bridge.ts index 12a671a8..4208dc88 100644 --- a/packages/dashboard/src/bridge.ts +++ b/packages/dashboard/src/bridge.ts @@ -58,7 +58,7 @@ export function bridgeWorkflowsToBus(bus: DashboardEventBus, db: Database): () = if (!row) return; bus.broadcastRepo(row.repo, { type: WORKFLOW_EVENT, - data: { id, repo: row.repo, epic: row.epicNumber, state: row.state }, + data: { id, repo: row.repo, epic: row.epicNumber, epicRef: row.epicRef, state: row.state }, }); }); } diff --git a/packages/dashboard/src/db-deps.ts b/packages/dashboard/src/db-deps.ts index d9a90e7d..e6ee390a 100644 --- a/packages/dashboard/src/db-deps.ts +++ b/packages/dashboard/src/db-deps.ts @@ -81,6 +81,7 @@ type WorkflowRow = { id: string; repo: string; epic_number: number | null; + epic_ref: string | null; adapter: string; state: string; controlled_by: string; @@ -94,7 +95,7 @@ type WorkflowRow = { }; const WORKFLOW_COLUMNS = - "id, repo, epic_number, adapter, state, controlled_by, session_name, transcript_path, worktree_path, current_sub_issue, pr_number, pr_branch, last_heartbeat"; + "id, repo, epic_number, epic_ref, adapter, state, controlled_by, session_name, transcript_path, worktree_path, current_sub_issue, pr_number, pr_branch, last_heartbeat"; /** Lifecycle states a workflow has finished in — excluded from "in flight". */ const TERMINAL_STATES = ["completed", "compensated", "failed", "cancelled"] as const; @@ -117,6 +118,7 @@ function toRunnerSummary(row: WorkflowRow): RunnerSummary { session: row.session_name ?? row.id, workflowId: row.id, epic: row.epic_number, + epicRef: row.epic_ref, adapter: row.adapter, progress: progressOf(row), state: row.state, @@ -437,6 +439,7 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps { workflowId: row.id, repo: row.repo, epic: row.epic_number, + epicRef: row.epic_ref, adapter: row.adapter, state: row.state, controlledBy: row.controlled_by === "human" ? "human" : "middle", diff --git a/packages/dashboard/src/wire.ts b/packages/dashboard/src/wire.ts index d00468fa..f5b2cb78 100644 --- a/packages/dashboard/src/wire.ts +++ b/packages/dashboard/src/wire.ts @@ -62,7 +62,14 @@ export type RunnerSummary = { /** The tmux session name — the key for `/api/sessions/:session/*`. */ session: string; workflowId: string; + /** The github-mode Epic issue number, or null in file mode. */ epic: number | null; + /** + * The canonical Epic reference: a slug in file mode (`epic` is then null), or + * `String(epic)` / null in github mode. Renders as a `file://` link when set + * and `epic` is null; github-mode rows render from `epic`. + */ + epicRef: string | null; adapter: string; /** `sub-issue 2/4` or `running`. */ progress: string; @@ -104,7 +111,14 @@ export type RunnerPanel = { session: string; workflowId: string; repo: string; + /** The github-mode Epic issue number, or null in file mode. */ epic: number | null; + /** + * The canonical Epic reference: a slug in file mode (`epic` is then null), or + * `String(epic)` / null in github mode. Renders as a `file://` link when set + * and `epic` is null; github-mode rows render from `epic`. + */ + epicRef: string | null; adapter: string; state: string; controlledBy: "middle" | "human"; From defd0bfd33ba6262ab0a2e48ae7d4e25a573e455 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Fri, 29 May 2026 11:52:16 -0400 Subject: [PATCH 4/7] feat(dashboard): render file-mode Epic slugs as file:// links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New EpicRef component: github mode → plain #N (unchanged, no anchor — AC4); file mode → the slug as a file://planning/epics/.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) --- .../dashboard/src/app/components/EpicRef.tsx | 46 +++++++++++++++++++ .../src/app/components/Inspector.tsx | 3 +- .../src/app/components/RunnerRow.tsx | 5 +- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/dashboard/src/app/components/EpicRef.tsx diff --git a/packages/dashboard/src/app/components/EpicRef.tsx b/packages/dashboard/src/app/components/EpicRef.tsx new file mode 100644 index 00000000..6cb0af45 --- /dev/null +++ b/packages/dashboard/src/app/components/EpicRef.tsx @@ -0,0 +1,46 @@ +/** + * Renders an Epic reference per the dispatch mode the workflow row carries: + * + * - **github mode** (`epicNumber !== null`) → plain `#N` text, byte-for-byte + * what the surfaces rendered before file mode existed. Deliberately not an + * anchor — AC4 of #187 is "no behavior change for github-mode rows". + * - **file mode** (`epicNumber === null`, `epicRef` a slug) → the slug as a + * `file://planning/epics/.md` link, the on-disk Epic file. No GitHub + * link in file mode (the Epic isn't a GitHub issue). + * - **no Epic** (both null — a recommender / documentation row) → the caller's + * `fallback`, defaulting to the em dash the surfaces already showed. + * + * The `fallback` prop exists because the two callers differ in their pre-#187 + * empty rendering (the runner surfaces showed `#—`, others a bare `—`); keeping + * it configurable preserves each surface's exact output. + */ + +/** + * Build the `file://` href for an Epic file from its slug. The slug is a single + * 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 { + return `file://planning/epics/${encodeURIComponent(slug)}.md`; +} + +export function EpicRef({ + epicNumber, + epicRef, + fallback = "—", +}: { + epicNumber: number | null; + epicRef: string | null; + /** What to render when there's no Epic at all (both ids null). */ + fallback?: string; +}) { + if (epicNumber !== null) return <>#{epicNumber}; + if (epicRef !== null) { + return ( + + {epicRef} + + ); + } + return <>{fallback}; +} diff --git a/packages/dashboard/src/app/components/Inspector.tsx b/packages/dashboard/src/app/components/Inspector.tsx index 818131fc..e3650e49 100644 --- a/packages/dashboard/src/app/components/Inspector.tsx +++ b/packages/dashboard/src/app/components/Inspector.tsx @@ -7,6 +7,7 @@ import type { RunnerPanel, SessionEvent } from "../../wire.ts"; import { ago } from "../format.ts"; import { CopyCommand } from "./CopyCommand.tsx"; +import { EpicRef } from "./EpicRef.tsx"; /** Events that record a verification gate outcome — pulled out as evidence. */ function isVerificationEvent(type: string): boolean { @@ -37,7 +38,7 @@ export function Inspector({