M4-39: Run graph and control room on the desktop¶
Status: ready | Depends on: M2-51, M4-21, M4-23, M6-11 | Milestone: M4
Goal¶
After this ticket a person on the desktop sees how a run works while it works. The live box (D3) has a button "Graph" that opens the run graph D14: an indented trace view with the agent as the root, subagents, workflows and background jobs as children with their tools, each row with state as color, icon and word, duration, current tool and cost where known, and a bar per row on a shared time axis; the graph grows live and keeps its tree when old steps are summarized. "Running now" (D10) becomes the control room: live cards with agent, path, state, duration, current tool, open subagents, workflows and jobs, stall notice and cost, Stop where allowed, each opening its run graph; "List" keeps the rows of M4-23. Both views have a list form for screen readers. The derivation of the tree is one pure function in packages/bothy_viz that the mobile layouts of M5-14 reuse. Why: the owner wants to see visually what runs and the agents they start are doing right now (2026-10-24; D-RV-1, D-RV-2, D-RV-7, D-RV-8, D-RV-11, D-RV-12).
Read first¶
docs/design/run-visualization.md(all),docs/decisions.d/run-visualization.md.docs/PLAN.md: section 3; section 10 rows M4-16, M4-21, M4-23, M4-24, M4-39; section 11 row M5-14 (who builds on it); section 12 rows M6-11 and M6-12.- Dependencies: the header names M2-51, M4-21, M4-23 and M6-11; everything below them is implied.
docs/spec/01-product.md: 3.2 (rows "Overview Running now" and "Run graph"), 5.1 D3, D10, D14, 6.2, 6.7 (costs), 8 (rows "Live content", "Run graph and control room", "Motion", "Color"), 13 (R-PR-15, R-PR-16), 14 (ui.livebox_visible_steps,a11y.live_announce_min_interval).docs/spec/09-agents.md: 5.3 (summary step per parent), 5.4, 5.5 (stall), 5.6 (all), 8 (who may stop), 18.4 (what the runner reports).docs/spec/04-sync-protocol.md: 6.2 (focuswithidleandbackground_thread_ids), 11.1, 11.3 (items 1 to 7), R-SY-27;docs/spec/13-push-notifications.md4.4.docs/spec/15-client-architecture.md: theRunStoreandFocusManagerrows, principle 1 (no permission logic in the client).docs/spec/25-visualization.md: 1 (boundary paragraph of 2026-10-24), 7.1 (rowlib/src/run_graph/), 7.2, 7.3, 7.4, 10, R-VZ-14.docs/design/components.mdsection 2 (live box state colors and icons),docs/design/tokens.md.docs/api/openapi.yaml:Run,RunStep,RunSummary,RunMetrics,listActiveRuns,getRun,listRunSteps,cancelRun.- Briefs:
docs/briefs/M4-16-sync-run-documents.md,M4-21-agent-interface.md,M4-23-search-switcher.md(Running now),M6-11-viz-package.md,M6-12-viz-views.md(forms and goldens),M2-51-run-graph-data.md; merged code wins on names. - Code on the base branch:
packages/bothy_viz(marks, model, fixtures, harness),packages/sync_engine(run documents, focus),apps/client/lib/running/, the live box ofapps/client/lib(M4-21).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come frompackages/l10n, never as literals in widgets. - Limits only through named keys:
ui.livebox_visible_steps,a11y.live_announce_min_intervalandsync.max_focused_threadsfromAppConfig, never a literal limit in code. - No permission logic in the client: the views show what the server returns; Stop only where the run's capabilities allow
run.cancel(15 1).
Product requirements implemented here:
- The derivation of 09 5.6 (root, nodes,
statusmarkers, summary rows, parent, merge rule, order and time, node content, limits, live) as one pure function over the generatedRun; the client never estimates a cost share (D-RV-5). - D14: trace view with a shared time axis, collapsible rows (mouse and arrow keys), "n earlier steps" above
ui.livebox_visible_stepsfinished children, never collapsing a running node or its ancestors; the button "Graph" in the live box and on every card. - D10: switch "Cards" and "List" (cards default); cards visible on the screen are focused through the
focusmemberbackground_thread_ids(04 6.2, built by M2-51) withinsync.max_focused_threadstogether with the threads already shown, andidlekeeps following the window state of 15 section 10 (D-M4-30-3), so the cards change no notification rule of 13 4.4; a run graph opened from a card focuses its thread the same way; other cards show theRunSummarywith "live details paused"; the stall notice fromstall_warned_at. - "As list" for the graph (outline with tree semantics: level, position, state as text, duration, cost) and "List" for the control room; announcements only for the end of a node and state changes, at most once per
a11y.live_announce_min_interval; reduced motion without animation (01 8).
Scope¶
packages/bothy_viz:
lib/src/run_graph/:run_graph_model.dart(the pure derivation and its node type),run_graph_view.dart(trace view with time axis and the list form),run_card.dart(control room card), texts throughl10n; marks reused fromlib/src/marks/(bar, run card), new marks only where none fits.fixtures/:Run.<state>.jsonfor a flat run, a run with subagents and a background job, a run with a summary row, a stalled run and a large run (load), listed infixtures/README.md; the harness shows the run graph and the cards from them.
apps/client/lib:
running/: the card grid and the switch inrunning_now_view.dart, the focus of visible cards through the existing focus manager.- A route for the run graph from the live box button and the cards; the button "Graph" in the live box of M4-21.
- ARB keys in
packages/l10nfor every new text. - Fake server handlers (registry of M4-17) for the document shapes of M2-51 if missing.
Fragments: docs/decisions.d/M4-39.md (D-M4-39-<n>), changelog fragments of the touched components (<component>/changelog.d/M4-39.md, root changelog.d/M4-39.md otherwise); README of packages/bothy_viz and apps/client.
Out of scope¶
- Server changes (M2-51); phone layouts (M5-14); the web console (no run views, D-RV-11); TV mode.
- Keyboard shortcuts beyond arrow keys inside the graph (M4-24 owns the shortcut set).
- A graph across runs, a replay slider, costs per node from an estimate.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M4-39-run-graph-desktop; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(bothy-viz): ...,feat(client): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a Dart package (publish_to: nonein everypubspec.yaml). - Do not modify files outside the scope above, except the README and fragment files named in this brief.
- Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1with port 0 and make no network call; the fake server replaces the server. Temporary directories only viaDirectory.systemTemp.createTemp, removed at the end. Time comes from an injected clock, never from a fixed wait. - The Flutter SDK at the version pinned in the root
pubspec.yamlmust be onPATH; without it the gates fail by design (R-QA-02), and the ticket stops withVerdict: BLOCKED <tool> missinginstead of skipping. - Never lower a threshold, disable a gate, delete a test or extend
quality/licenses/allowed.txt; no charting library (R-VZ-10). bothy_vizkeeps its dependencies (nosync_engine, nolocal_store); the app hands the document to the package.- If the core contract or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M4 closed, in the manner of D-W-100 and D-W-114. Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
- Model first.
RunGraphModel.fromRun(Run run, DateTime now)returns the root node with children; every rule of 09 5.6 is a unit test. The model is recomputed from the document on every applied patch; keep it linear in the number of steps (map by ID, one pass for children, one for the merge rule). - Time axis. Start at
started_at(elsequeued_at), end atfinished_ator the injected now; the axis labels follow the length (seconds, minutes, hours). A running bar ends in an arrow; color, icon and word of the state come from the live box tokens. - Virtualized rows. The view builds only visible rows, so a run with
run.max_steps_persistedsteps stays smooth; the collapse rule of 09 5.6 item 7 applies before layout. - Focus budget. The control room computes the visible cards after layout and asks the focus manager for their threads as background threads; the focus manager sends them in
background_thread_ids, keeps the threads the app already shows inthread_idsfirst and gives the rest ofsync.max_focused_threads(both lists together) to cards in on-screen order; it never changesidlefor them. A card without a slot shows "live details paused". - Rulings for
docs/decisions.d/M4-39.md(D-M4-39-<n>): the axis labels, the focus order of cards, any mark added tobothy_viz.
Tests (write them first)¶
packages/bothy_viz/test/run_graph/run_graph_model_test.dart: root fields;statussteps as markers; summary rows under their parent; unknown parent under the root until it arrives; merge rule with one and with two task children; order byordinal; current tool after the merge; cost only fromdetail.usage; collapse aboveui.livebox_visible_stepsand never on the running path.packages/bothy_viz/test/run_graph/goldens_test.dart: trace view and cards from every fixture, light and dark,comfortable,compact, text scaling 1 and 2,deanden, and the contextreduced.packages/bothy_viz/test/run_graph/semantics_test.dart: the outline list has tree semantics and the same values; announcements only on node end and state change, rate-limited.apps/client/test/running/control_room_test.dart: cards and list switch; Stop only with the capability; cards beyond the budget show "live details paused"; a card whose thread the app does not show goes intobackground_thread_ids, a card whose thread is already open stays inthread_idsonly, andidlestays the window state (foreground window:idle = false).apps/client/integration_test/run_graph_test.dartagainst the fake server: a scripted run with a subagent and a background job grows live in the graph opened from the live box; the stall notice appears after the scriptedadd /stall_warned_at; a card opens the graph.
Run the added tests by name with ten repetitions (--plain-name) through the machine's test slot.
Commands that must pass, from the repository root:
cd packages/bothy_viz && flutter analyze --fatal-infos && flutter test --coveragecd apps/client && flutter analyze --fatal-infos && flutter test --coverage- on macOS and on Windows:
make apps-platform; elsewhere it prints its SKIP line make codegen-check,make arch,make lint,make fmt-check,make coverage,make api-checks,make docs,make fragments-checkmake check
Acceptance criteria¶
- The derivation follows 09 5.6 rule by rule, with a unit test per rule.
- D14 opens from the live box and from a card, grows live and keeps the tree after summaries; the list form has the same content.
- D10 shows cards by default, live where focused within the budget, "live details paused" elsewhere, the stall notice and Stop only where allowed.
- Goldens recorded for every fixture and context; semantics tests green.
- Rulings and fragments recorded;
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why and cite 01 or 09.
packages/bothy_viz/README.mdandapps/client/README.mdupdated.- Rulings in
docs/decisions.d/M4-39.md; changelog fragments as in Scope. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.