Skip to content

Phase 5: Add dashboard, resume path, and v0.1.0 release - #9

Merged
1816x merged 11 commits into
mainfrom
claude/new-session-wh97y3
Aug 2, 2026
Merged

1816x merged 11 commits into
mainfrom
claude/new-session-wh97y3

Conversation

@1816x

@1816x 1816x commented Jul 31, 2026 •

Copy link
Copy Markdown
Owner

Closes the incident-response loop with a web dashboard and adds the ability to resume incidents left in intermediate states.

Summary

This PR completes Phase 5 of the project, delivering:

  1. A Next.js dashboard that visualizes the incident pipeline in real time
  2. A resume mechanism for incidents stranded in fix_proposed status
  3. v0.1.0 release with complete documentation

Key Changes

Dashboard (Next.js)

  • New dashboard/ directory with a full Next.js application
  • Incident list view showing pipeline progress, status badges, and metadata
  • Incident detail view with evidence panel, diagnosis trace, proposed fix diff, and validation gate results
  • Custom stylesheet with semantic color system (no framework)
  • TypeScript components for rendering diagnosis, validation, tool traces, and diffs
  • Database layer (lib/db.ts, lib/queries.ts) that reads the SQLite store in read-only mode
  • Comprehensive incident row mapper (lib/incidents.ts) that handles malformed JSON gracefully and models diagnosis/validation as discriminated unions

Resume Path

  • Agent can now claim incidents in fix_proposed status via --resume flag
  • Resumed incidents skip model re-derivation and go straight to the validation gate
  • New reclaim_stale_diagnosing method reclaims incidents whose owner process died (15-minute lease default)
  • Store API widened from claim_next_detected() to claim_next(claimable_statuses) for flexibility
  • Tests verify resumed fixes don't touch the model and terminal statuses remain terminal

Monitor & Store

  • UpsertIncident now returns UpsertOutcome enum (Inserted/Merged/Suppressed) instead of boolean, providing clearer semantics in logs
  • Updated all call sites and tests to use the new outcome type

Documentation & Tooling

  • Release notes at docs/release-notes-v0.1.0.md documenting all five phases
  • Design decisions updated with Phase 5 rationale
  • New scripts: seed_dashboard_db.py (fills store with one incident per pipeline outcome), record_cast.py (records terminal sessions as asciicast v2), capture_dashboard.mjs (screenshots dashboard), demo_script.sh (automated demo sequence)
  • Terminal cast and screenshots added to docs/assets/
  • README updated with demo animation and v0.1.0 status

Testing

  • Dashboard tests use node --test with no framework
  • Fixtures copied from real Go/Python output to test malformed JSON handling, refusals, gate rejections, and missing columns
  • CLI tests extended to cover resume path and validate that resumed fixes don't re-run the model
  • CI workflow updated to run dashboard TypeScript checks

Notable Implementation Details

  • Dashboard reads the store with readOnly: true enforced by SQLite; reruns are a CLI flag, not a button
  • Database handle is opened, queried, and closed per request (never module-level) to avoid contention with the Go daemon's writer
  • Diagnosis and validation are modeled as discriminated unions so components handle only the arm they receive
  • Empty strings (SQLite NOT NULL DEFAULT '') are treated as "absent" rather than parsed as JSON, preventing throws on unset columns
  • The resume path explicitly does not re-run the model—the stored diff is validated as-is, preserving the reasoning behind it
  • updated_at column has dual meaning (Go writes event time, Python writes wall clock); documented in code since Go never writes diagnosing

claude added 11 commits July 31, 2026 17:33
Both claim paths hardcoded `WHERE status = 'detected'`, which left two
rows unreachable.

An incident parked at `fix_proposed` by --no-validate has a stored diff
that nothing will ever validate: the status was terminal in Phase 3, and
Phase 4 put a gate behind it without widening the claim. And a process
SIGKILLed between claim() and any writer strands its row in `diagnosing`
forever — the CLI's broad excepts guarantee a terminal status on every
path it survives, but SIGKILL is not one of them.

Both claim methods now take an accepted-status set, defaulting to the
old behaviour so no existing caller changes meaning. The exclusion
guarantee is unchanged: the UPDATE still names the exact set it selected
against, so two racing agents still produce exactly one winner.

reclaim_stale_diagnosing() is the reaper for the crash case, and the
first code to read `updated_at` back. That column has mixed semantics —
Go writes event time, Python writes wall clock — and reading it as wall
clock is only sound because Go never writes `diagnosing`. Recorded in
the docstring, because the next reader of that column will not be so
lucky.

read_fix_record() is a narrow reader rather than more fields on
Incident: only the resume path needs the stored diff back, and every
other call site would otherwise carry it. It decodes '' to {}, since
every post-v1 column is NOT NULL DEFAULT '' and json.loads('') raises.

Terminal statuses stay terminal. A diff the gate rejected does not
become valid on a second reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Wires the widened claim set into the CLI. --resume adds `fix_proposed` to
what a run will pick up, and sweeps for claims held past --lease-age by a
process that died.

The important half is what happens after the claim. A resumed incident
that already carries a diff does not re-run the diagnosis loop: it goes
straight to _ship with the stored diagnosis and fix. Re-deriving them
would spend a real API call reproducing work sitting in the row, and
would overwrite the diagnosis that produced the diff with a second,
possibly different one. The test asserts this by making any model or
replay turn raise.

--resume --no-validate puts the status back to `fix_proposed` before
returning. The claim already moved the row to `diagnosing`, so without
that the flag which created this resting state would, on a rerun, strand
the incident one status further along — a worse version of the bug being
fixed here.

Ordering in _claim is deliberate: uncontested work first, stale sweep
last, so a reaper never races a live run for an incident when there was
something else to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Scaffolds the Next.js app and the layer that reads `incidents.db`, with
no UI yet.

Three constraints shape lib/db.ts, each learned elsewhere in this repo.
The handle is opened `readOnly: true`, so "the dashboard never advances
the pipeline" is enforced by SQLite rather than by reviewer attention;
reruns stay a CLI flag. Every read goes through withStore(), which opens
and closes around one query — the store is rollback-journal, not WAL, and
a long-lived reader is what killed the monitor's startup migration once
and wedged the agent's test suite once. And the schema version is
asserted with the same message the Python agent uses, because Go owns
migrations and a v3 database should say so rather than render blanks.

lib/incidents.ts is where this schema's sharp edges get paid for once.
Nothing is SQL NULL — every post-v1 column is NOT NULL DEFAULT '' — so
"not reached this stage" arrives as "" and JSON.parse would throw on it.
`diagnosis` has four shapes (model result, error, refusal, heuristic) and
`validation` has three, so both are discriminated unions a component
cannot destructure wrongly. Malformed JSON degrades to a `malformed` arm
carrying the raw text: three languages write this file, and one bad blob
must not 500 a page that would otherwise show six good incidents.

isModelOutput() exists because `replay-scripted` and `heuristic` are not
model output and the CLI already shouts about it. It fails closed on an
unrecognised source.

statusDetail() keeps `fix_validated` split in two: with `pr_error` the
fix is verified and the PR failed, without it no PR was attempted.
Flattening those would undo the Phase 4 decision that a network error is
not a failed fix.

node:sqlite over better-sqlite3: it is the standard library, so there is
no native module to build in CI, and readOnly is a real SQLite mode. The
cost is an experimental API and a Node >=22.6 floor, both contained by
keeping every reference in one file and pinning Node in .nvmrc.

Tests run under `node --test` with type stripping — no test framework at
all — against fixtures copied from what the Go and Python writers
actually produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
The list shows every incident with its pipeline position; the detail
view shows the evidence, the tool trace, the diff, and the gate record.

The pipeline widget draws five stages, not nine. The other four statuses
are ways of stopping, so a terminal one is drawn *at the stage it
stopped at* — `fix_failed` marks the validation step in red rather than
appearing as a sixth box, because that is what a rejected gate is.

Provenance is a banner above the diagnosis, not a badge beside it.
`replay-scripted` and `heuristic` drove the real tool loop against the
real repository, but no model produced those turns, and the CLI already
shouts about it. Anything quieter here would let a scripted transcript
read as a model diagnosis on the way past.

The evidence panel is shaped per detector, because a generic key/value
dump would put `p95_ms: 243` next to `slope_per_sec: 0.67` with no hint
that one breached a threshold and the other is a rate.

The gate gets its own panel, deliberately separated from the diagnosis:
one is what the model claimed, the other is what this machine checked.
`proves_a_fix` is the headline because red-before-green-after is the
only assertion in the record that means the fix works. A validated fix
whose PR failed says so in its own line rather than being coloured like
a failure.

The diff renderer is ~15 lines and hand-written. The format is
line-oriented, so classifying a line is a switch on its first character
— with `---`/`+++` checked before `-`/`+`, or every diff opens with a
phantom deleted line. A dependency would be bigger than the parser.

Verified against a real Go-created v4 database seeded with all seven
outcomes, screenshotted through a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Fills in the placeholder Phase 0 left here, now that there is a Next.js
app behind it.

Node's version comes from dashboard/.nvmrc for the same reason Go's
comes from go.mod: one source of truth, living in the project rather
than copied into this file where it would silently drift. It is a real
constraint here — the dashboard reads SQLite through node:sqlite, so an
older Node does not merely warn, it fails to import.

`npm ci` rather than `npm install`, so the lockfile decides. That is the
ruff lesson from Phase 4 applied to a second ecosystem: a build that
resolves its own dependency versions goes red on someone else's
schedule.

The build step runs with no incidents.db present, which is what CI
actually has. Both routes are force-dynamic, so nothing reads the store
at build time — verified rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
The dedup query matched only rows in `detected`, so an incident the
agent had claimed became invisible to it. Nothing noticed while nothing
ran the agent automatically: the row stayed `detected` forever and every
re-firing merged into it, exactly as Phase 2 intended.

Running the agent in the demo loop broke that. The agent moves an
incident to `diagnosing` within seconds, the condition is still firing,
and the monitor — finding no `detected` row for that key — opens a new
incident every detector cycle. A single injected bug produced 28
incidents in two minutes, which is precisely the "one incident, not
fifty" property Phase 2 exists to provide, failing in a way its own
tests could not see.

The lookup no longer filters by status, and a firing whose incident is
already claimed is now Suppressed: not merged, because rewriting
evidence under an agent mid-diagnosis is the reason the status filter
was there in the first place; not inserted, because the condition is
already on record and being worked. Suppression is bounded by the same
dedup window as merging, so a failure that recurs long after the old
incident closed still opens a new one.

Staleness is now measured on window_end rather than updated_at.
window_end is written only by this package and is always event time;
updated_at is shared with the Python agent, which writes wall clock into
it on every state change. Comparing a detector's event clock against
that mixes two timebases — a latent trap the old code avoided only by
never reading a row the agent had touched.

The bool return became a three-valued UpsertOutcome, since the daemon's
log should distinguish a new problem from an ongoing one from one
already in hand.

Verified live: same injected bug, same sustained failure, same auto-
diagnosis — one incident row, 41 suppressed firings logged quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
…with

DEFAULT_TEST_COMMAND began with a bare "python", resolved from PATH.
Whoever launches the agent has chosen an environment where the demo app
is importable; PATH resolves to whatever comes first, which need not be
that environment at all.

Surfaced by the one-command demo, where the app and the agent live in
demo-app/.venv while PATH still points at a system interpreter with no
fastapi. The gate applied the diff correctly, ran pytest, and reported
"tests failed after applying: ModuleNotFoundError: No module named
'fastapi'" — a verdict about the environment wearing the costume of a
verdict about the diff. It would have marked a working fix fix_failed.

sys.executable removes the ambiguity: the tests run under the same
interpreter as the agent. --test-command still overrides it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Phase 5's gate is that a stranger clones the repo, runs one command, and
watches the loop happen. The script stopped at detection: diagnosis
needed a separate pip install and a second command the script never even
mentioned.

It now installs the agent into the same virtualenv as the demo app and
polls for detected incidents, diagnosing each one offline as it appears.
--no-agent keeps the detection-only demo from Phases 1 and 2; --live
swaps the replay for a real model call.

The poller passes --no-validate deliberately. The gate cuts a git
worktree and runs pytest in it, which is right for a real run and wrong
to do unattended every few seconds. Incidents therefore rest at
fix_proposed — which is exactly what --resume was built to pick up, so
the banner prints the command that drives one through the gate for real.

Cleanup kills the poller's children before the poller. Killing the
subshell alone orphans the python it spawned, which then keeps a handle
on incidents.db after Ctrl-C — observed, not theorised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
Nothing here is drawn, mocked, or staged.

demo.cast is a real recording of a real session: inject_bug.sh commits
the bug, the monitor's detector fires, the agent diagnoses it offline
through its real tool chain, and --resume drives the stored diff through
the validation gate to red-before-green-after. The pauses in it are
waits for work to actually finish.

record_cast.py is the ~60 lines of pty that asciicast v2 needs. Adding
an asciinema dependency to produce one README image was the worse trade;
the output is the standard format and renders with svg-term-cli. The SVG
is animated, stays diffable text, and commits no binary blob.

The screenshots come from Chromium driven against a real Next server
reading the SQLite file that session produced — the log samples,
timestamps, tool-call sizes and gate record in them are that run's, not
fixtures. capture_dashboard.mjs discovers which incident to open from
the list rather than hardcoding an id, so it cannot silently screenshot
a 404 against a differently-populated database.

Playwright is resolved from wherever it happens to be installed rather
than added to any package.json: it is a screenshot tool, and `npm ci` in
CI should not pull a browser driver to build a dashboard.

seed_dashboard_db.py fills a store with one incident per pipeline
outcome. A demo run produces the happy path; it does not produce a
refusal, a rejected gate, or a validated fix whose pull request failed —
and those are the arms a dashboard is most likely to render wrongly.
Every row goes through the agent's own Store, so a schema change breaks
the script instead of quietly producing rows the pipeline could never
create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
design-decisions.md gets its Phase 5 section: why a resumed fix must not
re-run the model, why node:sqlite over a native module, why the dashboard
stayed read-only after Phase 4 predicted it wouldn't, why the row mapper
carries four diagnosis shapes and three validation shapes, why provenance
is a banner rather than a badge, and why there is still no index.

It also records the two defects the one-command demo surfaced, and
restates what this phase did not verify. Both credential gaps were
re-checked rather than assumed carried forward: the model keys are unset,
and api.github.com/repos returns 403 through the proxy while /user
returns 200 — scoping, not a dead token. v0.1.0 ships saying so.

README gains the recorded demo at the top, the dashboard section with
both screenshots, and a one-paragraph-each summary of the design
decisions for readers who won't open the long document. The quickstart
now reflects that diagnosis happens on its own, and documents --resume.
The two caveats stay exactly where they were; they are still true.

PLAN.md ticks F5 and records the estimate miss — planned at 4-6 commits,
landed at ten, because the deferred resume path and the run_demo.sh gate
were folded in and two defects had to be fixed before the demo was
honest. The working agreements ask for what actually happened versus what
was planned, which includes the phase that overran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
What ships per layer, the MVP gate with PR #6 as its evidence, what
Phase 5 added, and the limitations — led by the one that has been open
since Phase 3: neither outward-facing API call has ever been made for
real.

The tag itself is not cut here. A release tag on an unmerged feature
branch would point at history main has never had, so `chore(release):
tag v0.1.0` belongs on the merge commit. PLAN.md's F5 checklist is
corrected to say so rather than claiming a tag that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MpBoJ5sHCxp2WCBQPQ4bh
@1816x
1816x marked this pull request as ready for review August 2, 2026 02:01
@1816x
1816x merged commit 707b6ef into main Aug 2, 2026
10 checks passed
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.

2 participants