syncmill 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/dashboard.py ADDED
@@ -0,0 +1,935 @@
1
+ """Live TUI dashboard over the persistent work-board (Phase 3, `syncmill dashboard`).
2
+
3
+ A **separate observer/editor process** — never on the orchestration thread (the
4
+ same foreground contention the tmux monitor's outer/inner split exists to solve:
5
+ orchestration is sync-on-main-thread and a Textual app owns the foreground loop).
6
+ It is not an agent and not a strategy: it touches the board, reads run-envelope
7
+ reports, and — for the TEAM pane (P3) — writes the project ``syncmill.toml`` via
8
+ the ``teamfile.py`` helpers. It still never touches memory or agents, so the
9
+ "Supervisor is the only component that touches both agents and memory" invariant
10
+ is untouched. Every board write is an in-process ``board.py`` call (the same human
11
+ path as ``board update``), every team write is the same ``teamfile`` funnel ``team
12
+ save``/``team use`` call, and reports are read through the same ``report list`` /
13
+ ``report show`` functions as the CLI; the read-only MCP gateway is never involved.
14
+
15
+ Data source is **polling board.db** (WAL — readers never block against a live
16
+ ``board run`` writer; ``Board``'s docstring anticipates exactly this reader).
17
+ Reads stay inline on Textual's event-loop thread: the sqlite connection is opened
18
+ with the default ``check_same_thread=True``, so moving Board calls into a Textual
19
+ thread worker would raise — a future async refactor needs a dedicated reader
20
+ connection, not a thread hop (the gateway precedent: quick sync reads, no offload).
21
+
22
+ Poll reads are best-effort: a transient :class:`BoardError` mid-run shows a
23
+ DEGRADED notice and keeps the last good snapshot on screen; only the *launch*
24
+ hard-fails on an unopenable board (the ``session`` no-fallback precedent).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Callable
33
+
34
+ from pydantic import ValidationError
35
+ from textual.app import App, ComposeResult
36
+ from textual.containers import Horizontal, Vertical, VerticalScroll
37
+ from textual.notifications import SeverityLevel
38
+ from textual.screen import ModalScreen
39
+ from textual.widgets import (
40
+ Button,
41
+ Checkbox,
42
+ DataTable,
43
+ Footer,
44
+ Input,
45
+ OptionList,
46
+ Select,
47
+ Static,
48
+ )
49
+ from textual.widgets.data_table import RowDoesNotExist
50
+
51
+ from syncmill.artifacts import printable_name, printable_text
52
+ from syncmill.board import (
53
+ Board,
54
+ BoardError,
55
+ BoardStats,
56
+ Evidence,
57
+ WorkItem,
58
+ WorkState,
59
+ _short,
60
+ render_board_stats,
61
+ render_item,
62
+ )
63
+ from syncmill.config import SyncmillConfig, TeamConfig
64
+ from syncmill.report import list_reports, render_report
65
+ from syncmill.strategies import available_strategies
66
+ from syncmill.teamfile import (
67
+ TeamFileError,
68
+ build_team,
69
+ config_write_lock,
70
+ load_config_at,
71
+ parse_capability_specs,
72
+ render_team,
73
+ save_team,
74
+ set_default_team,
75
+ validate_team_save,
76
+ )
77
+ from syncmill.tui_render import (
78
+ PlainLabel,
79
+ PlainOption,
80
+ PlainStatic,
81
+ error_console,
82
+ install_render_guard,
83
+ plain,
84
+ plain_select,
85
+ )
86
+
87
+ # ── constants + pure data layer ──────────────────────────────────────────────
88
+
89
+ DEFAULT_REFRESH_SECONDS = 2.0
90
+ MIN_REFRESH_SECONDS = 0.5
91
+
92
+ #: Human-path target states. Recomputed here rather than imported from cli.py
93
+ #: (cli imports this module — the import graph stays acyclic): `running` is owned
94
+ #: by the claim lease, never set by hand, same as `_BOARD_UPDATE_STATES`.
95
+ EDIT_STATES = [s.value for s in WorkState if s is not WorkState.RUNNING]
96
+
97
+ #: `f` cycles the list filter through: all → each state → all.
98
+ FILTER_CYCLE: list[str | None] = [None, *(s.value for s in WorkState)]
99
+
100
+ _TASK_TRUNC = 40
101
+
102
+
103
+ def clamp_refresh(value: float) -> float:
104
+ """Floor the poll interval at :data:`MIN_REFRESH_SECONDS` so a typo'd
105
+ ``--refresh 0.01`` can't busy-spin sqlite."""
106
+ return max(MIN_REFRESH_SECONDS, value)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class BoardSnapshot:
111
+ """Everything one poll tick reads — the seam between Board I/O and Textual
112
+ rendering, testable against a real ``Board`` with no app running."""
113
+
114
+ items: list[WorkItem]
115
+ stats: BoardStats
116
+ decisions_count: int
117
+ detail: WorkItem | None = None
118
+ detail_transitions: list[dict] = field(default_factory=list)
119
+ at: float = 0.0
120
+
121
+
122
+ def take_snapshot(
123
+ board: Board,
124
+ *,
125
+ state: str | None = None,
126
+ selected_id: str | None = None,
127
+ now: Callable[[], float] = time.time,
128
+ ) -> BoardSnapshot:
129
+ """One consistent poll tick. Raises :class:`BoardError` — the degradation
130
+ policy (keep last good data, show a notice) lives in the app, in one place."""
131
+ items = board.list(state=state)
132
+ stats = board.stats()
133
+ decisions_count = len(board.active_decisions())
134
+ detail: WorkItem | None = None
135
+ detail_transitions: list[dict] = []
136
+ if selected_id is not None:
137
+ detail = board.get(selected_id)
138
+ if detail is not None:
139
+ detail_transitions = board.transitions(selected_id)
140
+ return BoardSnapshot(
141
+ items=items,
142
+ stats=stats,
143
+ decisions_count=decisions_count,
144
+ detail=detail,
145
+ detail_transitions=detail_transitions,
146
+ at=now(),
147
+ )
148
+
149
+
150
+ def _age(now: float, ts: float) -> str:
151
+ """Compact age for the table's age column: ``42s`` / ``5m`` / ``3h`` / ``2d``."""
152
+ delta = max(0, int(now - ts))
153
+ if delta < 60:
154
+ return f"{delta}s"
155
+ if delta < 3600:
156
+ return f"{delta // 60}m"
157
+ if delta < 86400:
158
+ return f"{delta // 3600}h"
159
+ return f"{delta // 86400}d"
160
+
161
+
162
+ def _trunc_task(task: str) -> str:
163
+ return task if len(task) <= _TASK_TRUNC else task[: _TASK_TRUNC - 3] + "..."
164
+
165
+
166
+ def _item_flags(item: WorkItem) -> str:
167
+ flags = []
168
+ if item.human_required:
169
+ flags.append("H!")
170
+ if item.depends_on:
171
+ flags.append(f"deps={len(item.depends_on)}")
172
+ return " ".join(flags)
173
+
174
+
175
+ def _status_line(
176
+ snapshot: BoardSnapshot | None, state_filter: str | None, degraded: str | None
177
+ ) -> str:
178
+ """The one-line footer summary. When degraded, the last good snapshot stays on
179
+ screen and the line says so."""
180
+ if snapshot is None:
181
+ parts = ["no data yet"]
182
+ else:
183
+ by = snapshot.stats.by_state
184
+ counts = " ".join(f"{s}={by[s]}" for s in sorted(by))
185
+ items = f"items:{snapshot.stats.total}" + (f" {counts}" if counts else "")
186
+ refreshed = time.strftime("%H:%M:%S", time.localtime(snapshot.at))
187
+ parts = [items, f"decisions:{snapshot.decisions_count}", f"refreshed {refreshed}"]
188
+ parts.insert(1, f"filter:{state_filter or 'all'}")
189
+ line = " | ".join(parts)
190
+ if degraded:
191
+ line += f" | DEGRADED: {degraded} (showing last good data)"
192
+ return line
193
+
194
+
195
+ # ── Textual widgets / screens ────────────────────────────────────────────────
196
+
197
+
198
+ class EditModal(ModalScreen[dict | None]):
199
+ """The ``board update`` surface as a modal — state / note / evidence ref /
200
+ sign-off / clear human-required. Deliberately dumb: it never touches the
201
+ Board, it only ``dismiss``es the collected fields (or ``None`` on cancel)."""
202
+
203
+ BINDINGS = [("escape", "cancel", "Cancel")]
204
+
205
+ def __init__(self, item: WorkItem):
206
+ super().__init__()
207
+ self._item = item
208
+
209
+ def compose(self) -> ComposeResult:
210
+ with Vertical(id="edit-dialog"):
211
+ yield PlainLabel(f"update {_short(self._item.id)} — {_trunc_task(self._item.task)}")
212
+ yield plain_select(
213
+ (("(keep state)", ""), *((s, s) for s in EDIT_STATES)),
214
+ value="",
215
+ allow_blank=False,
216
+ id="edit-state",
217
+ )
218
+ yield Input(placeholder="note (audit)", id="edit-note")
219
+ yield Input(placeholder="evidence ref (e.g. CI url)", id="edit-evidence")
220
+ yield Checkbox("sign-off (verification for done)", id="edit-sign-off")
221
+ yield Checkbox("clear human-required", id="edit-clear-hr")
222
+ with Horizontal(id="edit-buttons"):
223
+ yield Button("Apply", variant="primary", id="edit-apply")
224
+ yield Button("Cancel", id="edit-cancel")
225
+
226
+ def on_button_pressed(self, event: Button.Pressed) -> None:
227
+ if event.button.id != "edit-apply":
228
+ self.dismiss(None)
229
+ return
230
+ state = self.query_one("#edit-state", Select).value
231
+ self.dismiss(
232
+ {
233
+ "state": state if isinstance(state, str) and state else None,
234
+ "note": self.query_one("#edit-note", Input).value,
235
+ "evidence_ref": self.query_one("#edit-evidence", Input).value or None,
236
+ "sign_off": self.query_one("#edit-sign-off", Checkbox).value,
237
+ "clear_human_required": self.query_one("#edit-clear-hr", Checkbox).value,
238
+ }
239
+ )
240
+
241
+ def action_cancel(self) -> None:
242
+ self.dismiss(None)
243
+
244
+
245
+ class StatsScreen(ModalScreen[None]):
246
+ """The stats overlay — :func:`render_board_stats` verbatim, so it reads
247
+ byte-identically to ``board stats``."""
248
+
249
+ BINDINGS = [("escape", "cancel", "Close"), ("s", "cancel", "Close")]
250
+
251
+ def __init__(self, text: str):
252
+ super().__init__()
253
+ self._text = text
254
+
255
+ def compose(self) -> ComposeResult:
256
+ yield PlainStatic(self._text, id="stats-body")
257
+
258
+ def action_cancel(self) -> None:
259
+ self.dismiss(None)
260
+
261
+
262
+ class TeamEditModal(ModalScreen[dict | None]):
263
+ """The ``team save`` surface as a modal — name / agents / strategy / goals /
264
+ capabilities. Deliberately dumb like :class:`EditModal`: it never touches a
265
+ file, it only ``dismiss``es the RAW string fields (or ``None`` on cancel) —
266
+ all parsing/validation happens in the app's ``_apply_team_save`` funnel, so
267
+ a rejected edit lands in ``last_team_error`` (the done-gate precedent)."""
268
+
269
+ BINDINGS = [("escape", "cancel", "Cancel")]
270
+
271
+ def __init__(self, name: str, team: TeamConfig | None):
272
+ super().__init__()
273
+ self._name = name
274
+ self._team = team
275
+
276
+ def compose(self) -> ComposeResult:
277
+ team = self._team
278
+ caps = "; ".join(
279
+ f"{a}={','.join(toks)}" for a, toks in (team.capabilities if team else {}).items()
280
+ )
281
+ with Vertical(id="team-edit-dialog"):
282
+ yield PlainLabel("edit team" if team else "new team")
283
+ yield Input(value=self._name, placeholder="team name", id="team-name")
284
+ yield Input(
285
+ value=", ".join(team.agents) if team else "",
286
+ placeholder="agents, priority order (e.g. codex, claude)",
287
+ id="team-agents",
288
+ )
289
+ yield plain_select(
290
+ (("(inherit default_strategy)", ""), *((s, s) for s in available_strategies())),
291
+ value=(team.strategy if team and team.strategy else ""),
292
+ allow_blank=False,
293
+ id="team-strategy",
294
+ )
295
+ yield Input(
296
+ value=", ".join(team.goals) if team else "",
297
+ placeholder="goals (e.g. backend, api)",
298
+ id="team-goals",
299
+ )
300
+ yield Input(
301
+ value=caps,
302
+ placeholder="capabilities (e.g. codex=python,cli; kimi-code=rust)",
303
+ id="team-caps",
304
+ )
305
+ with Horizontal(id="team-edit-buttons"):
306
+ yield Button("Save", variant="primary", id="team-edit-apply")
307
+ yield Button("Cancel", id="team-edit-cancel")
308
+
309
+ def on_button_pressed(self, event: Button.Pressed) -> None:
310
+ if event.button.id != "team-edit-apply":
311
+ self.dismiss(None)
312
+ return
313
+ strategy = self.query_one("#team-strategy", Select).value
314
+ self.dismiss(
315
+ {
316
+ "name": self.query_one("#team-name", Input).value,
317
+ "agents": self.query_one("#team-agents", Input).value,
318
+ "strategy": strategy if isinstance(strategy, str) and strategy else None,
319
+ "goals": self.query_one("#team-goals", Input).value,
320
+ "capabilities": self.query_one("#team-caps", Input).value,
321
+ }
322
+ )
323
+
324
+ def action_cancel(self) -> None:
325
+ self.dismiss(None)
326
+
327
+
328
+ class TeamScreen(ModalScreen[None]):
329
+ """The TEAM pane — configured profiles with the default-team marker, detail
330
+ byte-identical to ``team show`` (the shared :func:`render_team`). ``e`` edits
331
+ the highlighted team (or composes a new one when none exist), ``u`` toggles
332
+ it as the persisted default — both through the app's ``teamfile`` funnels,
333
+ the same recipe as ``team save`` / ``team use``."""
334
+
335
+ BINDINGS = [
336
+ ("escape", "cancel", "Close"),
337
+ ("t", "cancel", "Close"),
338
+ ("e", "edit_team", "Edit/new team"),
339
+ ("u", "toggle_default", "Toggle default"),
340
+ ]
341
+
342
+ def compose(self) -> ComposeResult:
343
+ with Vertical(id="team-dialog"):
344
+ yield PlainLabel("teams — e edit/new, u default, esc close", id="team-header")
345
+ yield OptionList(id="team-list", markup=False)
346
+ with VerticalScroll(id="team-detail-pane"):
347
+ yield PlainStatic("", id="team-detail")
348
+
349
+ def on_mount(self) -> None:
350
+ self.refresh_teams()
351
+
352
+ # ── rendering ────────────────────────────────────────────────────────────
353
+
354
+ def refresh_teams(self) -> None:
355
+ """(Re)populate from the app's CURRENT config snapshot — called on mount
356
+ and by the app after every successful team write (post-reload)."""
357
+ app = self.app
358
+ config: SyncmillConfig = app._config
359
+ option_list = self.query_one("#team-list", OptionList)
360
+ highlighted = self._highlighted_name()
361
+ option_list.clear_options()
362
+ for name in sorted(config.teams):
363
+ marker = " * (default)" if name == (config.default_team or None) else ""
364
+ option_list.add_option(PlainOption(f"{name}{marker}", id=name))
365
+ if not config.teams:
366
+ self._show_detail("(no teams configured — press e to compose one)")
367
+ return
368
+ names = sorted(config.teams)
369
+ target = highlighted if highlighted in config.teams else names[0]
370
+ option_list.highlighted = names.index(target)
371
+ self._show_detail(render_team(target, config.teams[target], config))
372
+
373
+ def _highlighted_name(self) -> str | None:
374
+ option_list = self.query_one("#team-list", OptionList)
375
+ idx = option_list.highlighted
376
+ if idx is None:
377
+ return None
378
+ option = option_list.get_option_at_index(idx)
379
+ return option.id
380
+
381
+ def _show_detail(self, text: str) -> None:
382
+ # Mirrored onto the APP (team_detail_text) so tests assert app-owned
383
+ # strings, never widget internals — the status_text/detail_text pattern.
384
+ self.app.team_detail_text = text
385
+ self.query_one("#team-detail", Static).update(text)
386
+
387
+ def on_option_list_option_highlighted(
388
+ self, event: OptionList.OptionHighlighted
389
+ ) -> None:
390
+ name = event.option.id
391
+ config: SyncmillConfig = self.app._config
392
+ if name in config.teams:
393
+ self._show_detail(render_team(name, config.teams[name], config))
394
+
395
+ # ── actions (funnel into the app's teamfile writes) ──────────────────────
396
+
397
+ def action_edit_team(self) -> None:
398
+ name = self._highlighted_name()
399
+ config: SyncmillConfig = self.app._config
400
+ team = config.teams.get(name) if name else None
401
+
402
+ def _on_result(fields: dict | None) -> None:
403
+ self.app._apply_team_save(fields)
404
+
405
+ self.app.push_screen(TeamEditModal(name or "", team), _on_result)
406
+
407
+ def action_toggle_default(self) -> None:
408
+ name = self._highlighted_name()
409
+ if name is None:
410
+ self.app.notify("no team highlighted", severity="warning")
411
+ return
412
+ config: SyncmillConfig = self.app._config
413
+ target = None if (config.default_team or None) == name else name
414
+ self.app._apply_default_team(target)
415
+
416
+ def action_cancel(self) -> None:
417
+ self.dismiss(None)
418
+
419
+
420
+ # ── the app ──────────────────────────────────────────────────────────────────
421
+
422
+
423
+ class ReportScreen(ModalScreen[None]):
424
+ """Read-only run-envelope browser with ``report show``-identical detail."""
425
+
426
+ BINDINGS = [
427
+ ("escape", "cancel", "Close"),
428
+ ("p", "cancel", "Close"),
429
+ ("r", "refresh_reports", "Refresh"),
430
+ ]
431
+
432
+ def __init__(self, runs_dir: str | Path):
433
+ super().__init__()
434
+ self._runs_dir = runs_dir
435
+ self._reports: list[dict] = []
436
+ # Position-based ids keep malformed duplicate/missing embedded run_ids
437
+ # from colliding inside Textual's OptionList.
438
+ self._docs_by_option: dict[str, dict] = {}
439
+
440
+ def compose(self) -> ComposeResult:
441
+ with Vertical(id="report-dialog"):
442
+ yield PlainLabel("reports — newest first; r refresh, esc close", id="report-header")
443
+ yield OptionList(id="report-list", markup=False)
444
+ with VerticalScroll(id="report-detail-pane"):
445
+ yield PlainStatic("", id="report-detail")
446
+
447
+ def on_mount(self) -> None:
448
+ self.refresh_reports()
449
+
450
+ def refresh_reports(self) -> None:
451
+ """Reload reports, preserving the selected run when it still exists."""
452
+ highlighted = self._highlighted_doc()
453
+ highlighted_run_id = highlighted.get("run_id") if highlighted else None
454
+ self._reports = list_reports(self._runs_dir)
455
+ self._docs_by_option = {}
456
+ option_list = self.query_one("#report-list", OptionList)
457
+ option_list.clear_options()
458
+ if not self._reports:
459
+ self._show_detail("(no run reports found)")
460
+ return
461
+
462
+ target = 0
463
+ for index, doc in enumerate(self._reports):
464
+ option_id = f"report-{index}"
465
+ self._docs_by_option[option_id] = doc
466
+ option_list.add_option(PlainOption(self._summary(doc), id=option_id))
467
+ if highlighted_run_id is not None and doc.get("run_id") == highlighted_run_id:
468
+ target = index
469
+ option_list.highlighted = target
470
+ self._show_detail(render_report(self._reports[target]))
471
+
472
+ @staticmethod
473
+ def _summary(doc: dict) -> str:
474
+ result = doc.get("result")
475
+ result = result if isinstance(result, dict) else {}
476
+ return (
477
+ f"{doc.get('run_id')} {doc.get('created_at')} {doc.get('strategy')} "
478
+ f"{result.get('status')} {result.get('agent_id') or '—'}"
479
+ )
480
+
481
+ def _highlighted_doc(self) -> dict | None:
482
+ option_list = self.query_one("#report-list", OptionList)
483
+ index = option_list.highlighted
484
+ if index is None:
485
+ return None
486
+ option_id = option_list.get_option_at_index(index).id
487
+ return self._docs_by_option.get(str(option_id))
488
+
489
+ def _show_detail(self, text: str) -> None:
490
+ self.app.report_detail_text = text
491
+ self.query_one("#report-detail", Static).update(text)
492
+
493
+ def on_option_list_option_highlighted(
494
+ self, event: OptionList.OptionHighlighted
495
+ ) -> None:
496
+ doc = self._docs_by_option.get(str(event.option.id))
497
+ if doc is not None:
498
+ self._show_detail(render_report(doc))
499
+
500
+ def action_refresh_reports(self) -> None:
501
+ self.refresh_reports()
502
+
503
+ def action_cancel(self) -> None:
504
+ self.dismiss(None)
505
+
506
+
507
+ class DashboardApp(App[None]):
508
+ """Board 관제: a polling item table + detail pane + stats, with the human-path
509
+ edits (transition / unblock / sign-off / annotate) bound to keys.
510
+
511
+ The ``board`` is injected and owned by the caller (the CLI opens it and closes
512
+ it in a ``finally`` — the ``serve``/memory ownership precedent); ``now`` is
513
+ injectable (the ``Board(now=)`` precedent) so ages and Evidence timestamps are
514
+ deterministic under test. The interval timer's callback IS ``action_refresh``,
515
+ the same code path as the ``r`` key and the post-edit refresh — tests construct
516
+ the app with a huge ``refresh_seconds`` and drive every tick explicitly.
517
+ """
518
+
519
+ TITLE = "syncmill board"
520
+
521
+ BINDINGS = [
522
+ ("r", "refresh", "Refresh"),
523
+ ("f", "cycle_filter", "Filter state"),
524
+ ("e", "edit", "Update item"),
525
+ ("u", "unblock", "Unblock"),
526
+ ("h", "clear_human", "Clear human-req"),
527
+ ("s", "stats", "Stats"),
528
+ ("t", "teams", "Teams"),
529
+ ("p", "reports", "Reports"),
530
+ ("q", "quit", "Quit"),
531
+ ]
532
+
533
+ CSS = """
534
+ #main { height: 1fr; }
535
+ #items { width: 3fr; }
536
+ #detail-pane { width: 2fr; border-left: solid $primary; padding: 0 1; }
537
+ #status { height: 1; padding: 0 1; background: $panel; }
538
+ EditModal { align: center middle; }
539
+ #edit-dialog {
540
+ width: 64; height: auto; border: thick $primary;
541
+ padding: 1 2; background: $surface;
542
+ }
543
+ #edit-buttons { height: auto; align-horizontal: right; }
544
+ #edit-buttons Button { margin-left: 2; }
545
+ StatsScreen { align: center middle; }
546
+ #stats-body {
547
+ width: 72; height: auto; border: thick $primary;
548
+ padding: 1 2; background: $surface;
549
+ }
550
+ TeamScreen { align: center middle; }
551
+ #team-dialog {
552
+ width: 80; height: 24; border: thick $primary;
553
+ padding: 1 2; background: $surface;
554
+ }
555
+ #team-list { height: 8; }
556
+ #team-detail-pane { height: 1fr; border-top: solid $primary; }
557
+ ReportScreen { align: center middle; }
558
+ #report-dialog {
559
+ width: 95%; height: 90%; max-width: 110; max-height: 30;
560
+ border: thick $primary;
561
+ padding: 1 2; background: $surface;
562
+ }
563
+ #report-list { height: 8; }
564
+ #report-detail-pane { height: 1fr; border-top: solid $primary; }
565
+ TeamEditModal { align: center middle; }
566
+ #team-edit-dialog {
567
+ width: 72; height: auto; border: thick $primary;
568
+ padding: 1 2; background: $surface;
569
+ }
570
+ #team-edit-buttons { height: auto; align-horizontal: right; }
571
+ #team-edit-buttons Button { margin-left: 2; }
572
+ """
573
+
574
+ def __init__(
575
+ self,
576
+ board: Board,
577
+ *,
578
+ refresh_seconds: float = DEFAULT_REFRESH_SECONDS,
579
+ now: Callable[[], float] = time.time,
580
+ config: SyncmillConfig | None = None,
581
+ toml_path: Path | None = None,
582
+ ):
583
+ super().__init__()
584
+ self.error_console = error_console()
585
+ self._board = board
586
+ self._refresh_seconds = refresh_seconds
587
+ self._now = now
588
+ #: TEAM pane inputs — both optional so a board-only construction (every
589
+ #: pre-P3 caller/test) is untouched; `t` degrades to a notice without them.
590
+ #: `toml_path` is resolved ONCE at launch (the CLI passes teamfile.toml_path())
591
+ #: and every team write AND the post-write reload (load_config_at) pin it,
592
+ #: so an in-app cwd drift is impossible (codex design-review Major).
593
+ self._config = config
594
+ self._toml_path = toml_path
595
+ self._filter: str | None = None
596
+ self._selected_id: str | None = None
597
+ self._snapshot: BoardSnapshot | None = None
598
+ self._degraded: str | None = None
599
+ #: The last board-write rejection (e.g. the done gate) — the test-assertable
600
+ #: seam; `notify` toasts are UX-only (their internals shift across Textual
601
+ #: versions).
602
+ self.last_action_error: str | None = None
603
+ #: The current status-line / detail-pane text, mirrored off the widgets so
604
+ #: tests assert on app-owned strings (widget content APIs are version-shifty).
605
+ self.status_text: str = ""
606
+ self.detail_text: str = ""
607
+ #: TEAM-pane mirrors of the same two kinds: the pane's detail text and the
608
+ #: last teamfile-write rejection.
609
+ self.team_detail_text: str = ""
610
+ self.last_team_error: str | None = None
611
+ #: REPORT-pane detail mirror — byte-identical to `render_report` and
612
+ #: therefore to `report show` (apart from Click's trailing newline).
613
+ self.report_detail_text: str = ""
614
+
615
+ def compose(self) -> ComposeResult:
616
+ with Horizontal(id="main"):
617
+ yield DataTable(id="items")
618
+ with VerticalScroll(id="detail-pane"):
619
+ yield PlainStatic("(no item selected)", id="detail")
620
+ yield PlainStatic("", id="status")
621
+ yield Footer()
622
+
623
+ def on_mount(self) -> None:
624
+ table = self.query_one("#items", DataTable)
625
+ table.cursor_type = "row"
626
+ table.add_columns(*map(plain, ("id", "state", "goal", "task", "owner", "age", "flags")))
627
+ self.action_refresh()
628
+ self.set_interval(self._refresh_seconds, self.action_refresh)
629
+
630
+ # ── polling (best-effort reads) ──────────────────────────────────────────
631
+
632
+ def notify(
633
+ self,
634
+ message: str,
635
+ *,
636
+ title: str = "",
637
+ severity: SeverityLevel = "information",
638
+ timeout: float | None = None,
639
+ markup: bool = False,
640
+ ) -> None:
641
+ """Textual's notify with markup off by default: messages quote errors
642
+ and stored names, and a ``[/x]`` in one raises while the toast renders.
643
+ The message is escaped as the CLI shows a body, the title as one line."""
644
+ super().notify(
645
+ printable_text(str(message)),
646
+ title=printable_name(title),
647
+ severity=severity,
648
+ timeout=timeout,
649
+ markup=markup,
650
+ )
651
+
652
+ def action_refresh(self) -> None:
653
+ try:
654
+ snap = take_snapshot(
655
+ self._board,
656
+ state=self._filter,
657
+ selected_id=self._selected_id,
658
+ now=self._now,
659
+ )
660
+ except BoardError as exc:
661
+ # Best-effort: keep the last good snapshot on screen, say so, live on.
662
+ self._degraded = str(exc)
663
+ self._update_status()
664
+ return
665
+ self._degraded = None
666
+ self._snapshot = snap
667
+ self._render_snapshot(snap)
668
+
669
+ def _render_snapshot(self, snap: BoardSnapshot) -> None:
670
+ table = self.query_one("#items", DataTable)
671
+ table.clear()
672
+ for it in snap.items:
673
+ table.add_row(
674
+ *map(plain, (
675
+ _short(it.id),
676
+ it.state.value,
677
+ it.goal or "-",
678
+ _trunc_task(it.task),
679
+ it.owner_agent or "-",
680
+ _age(snap.at, it.updated_at),
681
+ _item_flags(it),
682
+ )),
683
+ key=it.id, # full id — the 8-char display could collide as a key
684
+ )
685
+ if self._selected_id is not None:
686
+ try:
687
+ table.move_cursor(row=table.get_row_index(self._selected_id))
688
+ except RowDoesNotExist:
689
+ self._selected_id = None # filtered out / gone — drop the selection
690
+ if snap.detail is not None and snap.detail.id == self._selected_id:
691
+ self._update_detail(render_item(snap.detail, snap.detail_transitions))
692
+ elif self._selected_id is None:
693
+ self._update_detail("(no item selected)")
694
+ self._update_status()
695
+
696
+ def _update_status(self) -> None:
697
+ self.status_text = _status_line(self._snapshot, self._filter, self._degraded)
698
+ self.query_one("#status", Static).update(self.status_text)
699
+
700
+ def _update_detail(self, text: str) -> None:
701
+ self.detail_text = text
702
+ self.query_one("#detail", Static).update(text)
703
+
704
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
705
+ key = event.row_key.value if event.row_key is not None else None
706
+ if key == self._selected_id:
707
+ return
708
+ self._selected_id = key
709
+ # Detail-only refetch: re-rendering the whole table from inside a highlight
710
+ # handler would emit further highlight events mid-render (cursor churn).
711
+ self._refresh_detail()
712
+
713
+ def _refresh_detail(self) -> None:
714
+ if self._selected_id is None:
715
+ self._update_detail("(no item selected)")
716
+ return
717
+ try:
718
+ item = self._board.get(self._selected_id)
719
+ transitions = self._board.transitions(self._selected_id) if item else []
720
+ except BoardError as exc:
721
+ self._degraded = str(exc)
722
+ self._update_status()
723
+ return
724
+ self._update_detail(render_item(item, transitions) if item else "(item not found)")
725
+
726
+ # ── actions (reads) ──────────────────────────────────────────────────────
727
+
728
+ def action_cycle_filter(self) -> None:
729
+ idx = FILTER_CYCLE.index(self._filter)
730
+ self._filter = FILTER_CYCLE[(idx + 1) % len(FILTER_CYCLE)]
731
+ self.action_refresh()
732
+
733
+ def action_stats(self) -> None:
734
+ if self._snapshot is None:
735
+ self.notify("no stats yet", severity="warning")
736
+ return
737
+ self.push_screen(StatsScreen(render_board_stats(self._snapshot.stats)))
738
+
739
+ def action_teams(self) -> None:
740
+ if self._config is None:
741
+ self.notify(
742
+ "teams unavailable — the dashboard was launched without config",
743
+ severity="warning",
744
+ )
745
+ return
746
+ self.push_screen(TeamScreen())
747
+
748
+ def action_reports(self) -> None:
749
+ if self._config is None:
750
+ self.notify(
751
+ "reports unavailable — the dashboard was launched without config",
752
+ severity="warning",
753
+ )
754
+ return
755
+ # Viewing existing reports is intentionally NOT gated on `enabled`, the
756
+ # same read-only posture as `report list` / `report show`.
757
+ self.push_screen(ReportScreen(self._config.integration.report.runs_dir))
758
+
759
+ # ── the TEAM-pane writes (the teamfile funnels) ──────────────────────────
760
+
761
+ def _apply_team_save(self, fields: dict | None) -> bool:
762
+ """All team saves funnel through here — EXACTLY the ``team save`` CLI
763
+ recipe (parse → build → validate both views → comment-preserving write),
764
+ so the file reads identically whichever surface the human used. Any
765
+ :class:`TeamFileError` becomes an error toast + ``last_team_error`` —
766
+ the app never dies on a rejected edit (the ``_apply_update`` contract)."""
767
+ if not fields:
768
+ return False
769
+ if self._toml_path is None or self._config is None:
770
+ self.last_team_error = "teams unavailable — no syncmill.toml path"
771
+ self.notify(self.last_team_error, severity="error")
772
+ return False
773
+ try:
774
+ name = fields.get("name", "").strip()
775
+ agents = [a.strip() for a in fields.get("agents", "").split(",") if a.strip()]
776
+ goals = [g.strip() for g in fields.get("goals", "").split(",") if g.strip()]
777
+ specs = [s.strip() for s in fields.get("capabilities", "").split(";") if s.strip()]
778
+ caps = parse_capability_specs(specs)
779
+ team = build_team(
780
+ agents=agents, strategy=fields.get("strategy"), goals=goals, capabilities=caps
781
+ )
782
+ # validate→save as one transaction (the `team save` CLI recipe):
783
+ # a concurrent writer must not land between the two views and the
784
+ # write. A failed acquire is a TeamFileError like any other, so the
785
+ # existing toast/last_team_error path already covers it.
786
+ with config_write_lock(self._toml_path):
787
+ validate_team_save(self._config, name, team, path=self._toml_path)
788
+ save_team(self._toml_path, name, team)
789
+ except TeamFileError as exc:
790
+ self.last_team_error = str(exc)
791
+ self.notify(str(exc), severity="error")
792
+ return False
793
+ self.last_team_error = None
794
+ self._reload_teams()
795
+ self.notify(f"saved team '{name}'")
796
+ return True
797
+
798
+ def _apply_default_team(self, name: str | None) -> bool:
799
+ """Persist (or with ``None`` clear) ``default_team`` — the ``team use``
800
+ recipe; same error/reload contract as :meth:`_apply_team_save`."""
801
+ if self._toml_path is None or self._config is None:
802
+ self.last_team_error = "teams unavailable — no syncmill.toml path"
803
+ self.notify(self.last_team_error, severity="error")
804
+ return False
805
+ if name is not None and name not in self._config.teams:
806
+ self.last_team_error = f"unknown team: {name!r}"
807
+ self.notify(self.last_team_error, severity="error")
808
+ return False
809
+ try:
810
+ set_default_team(self._toml_path, name)
811
+ except TeamFileError as exc:
812
+ self.last_team_error = str(exc)
813
+ self.notify(str(exc), severity="error")
814
+ return False
815
+ self.last_team_error = None
816
+ self._reload_teams()
817
+ self.notify("cleared default team" if name is None else f"default team: {name}")
818
+ return True
819
+
820
+ def _reload_teams(self) -> None:
821
+ """Best-effort post-write reload — :func:`load_config_at` pins the exact
822
+ file the write just touched (never the ambient cwd), preserving env
823
+ precedence. A failed reload keeps the old snapshot + mirrors the error
824
+ (near-impossible after pre-write validation, but the app never crashes);
825
+ then the open TEAM pane, if any, re-renders from the fresh snapshot."""
826
+ try:
827
+ self._config = load_config_at(self._toml_path)
828
+ except (ValidationError, OSError, ValueError) as exc:
829
+ # ValueError covers tomllib.TOMLDecodeError: a mid-edit malformed
830
+ # toml must keep the cached view + toast, never kill the app.
831
+ self.last_team_error = f"config reload failed: {exc}"
832
+ self.notify(self.last_team_error, severity="error")
833
+ if isinstance(self.screen, TeamScreen):
834
+ self.screen.refresh_teams()
835
+
836
+ # ── actions (the human-path writes) ──────────────────────────────────────
837
+
838
+ def action_edit(self) -> None:
839
+ item = self._selected_item()
840
+ if item is None:
841
+ self.notify("no item selected", severity="warning")
842
+ return
843
+
844
+ def _on_result(result: dict | None) -> None:
845
+ if result:
846
+ self._apply_update(item.id, **result)
847
+
848
+ self.push_screen(EditModal(item), _on_result)
849
+
850
+ def action_unblock(self) -> None:
851
+ if self._selected_id is None:
852
+ self.notify("no item selected", severity="warning")
853
+ return
854
+ self._apply_update(self._selected_id, state=WorkState.PLANNED.value, note="unblocked")
855
+
856
+ def action_clear_human(self) -> None:
857
+ if self._selected_id is None:
858
+ self.notify("no item selected", severity="warning")
859
+ return
860
+ self._apply_update(self._selected_id, clear_human_required=True)
861
+
862
+ def _selected_item(self) -> WorkItem | None:
863
+ if self._snapshot is None or self._selected_id is None:
864
+ return None
865
+ for it in self._snapshot.items:
866
+ if it.id == self._selected_id:
867
+ return it
868
+ detail = self._snapshot.detail
869
+ return detail if detail is not None and detail.id == self._selected_id else None
870
+
871
+ def _apply_update(
872
+ self,
873
+ item_id: str,
874
+ *,
875
+ state: str | None = None,
876
+ note: str = "",
877
+ evidence_ref: str | None = None,
878
+ sign_off: bool = False,
879
+ clear_human_required: bool = False,
880
+ ) -> bool:
881
+ """All board writes funnel through here — EXACTLY the ``board update`` CLI
882
+ recipe (evidence/sign-off become Evidence rows, ``actor="human"``), so the
883
+ audit trail reads identically whichever surface the human used. A
884
+ :class:`BoardError` (incl. the done-gate rejection) becomes an error toast
885
+ + ``last_action_error`` — the app never dies on a rejected edit."""
886
+ if state is None and not evidence_ref and not sign_off and not clear_human_required:
887
+ self.notify(
888
+ "nothing to update — set a state, evidence/sign-off, or clear human-required",
889
+ severity="warning",
890
+ )
891
+ return False
892
+ now = self._now()
893
+ rows: list[Evidence] = []
894
+ if evidence_ref:
895
+ rows.append(Evidence(kind="manual", ref=evidence_ref, passed=True, at=now, by="human"))
896
+ if sign_off:
897
+ rows.append(Evidence(kind="human", ref="sign-off", passed=True, at=now, by="human"))
898
+ hr = False if clear_human_required else None
899
+ try:
900
+ self._board.transition(
901
+ item_id, state, actor="human", note=note,
902
+ evidence=rows or None, human_required=hr,
903
+ )
904
+ except BoardError as exc:
905
+ self.last_action_error = str(exc)
906
+ self.notify(str(exc), severity="error")
907
+ return False
908
+ self.last_action_error = None
909
+ self.notify(f"updated {_short(item_id)}")
910
+ self.action_refresh()
911
+ return True
912
+
913
+
914
+ # ── CLI entry seam ───────────────────────────────────────────────────────────
915
+
916
+
917
+ def run_dashboard(
918
+ board: Board,
919
+ *,
920
+ refresh_seconds: float = DEFAULT_REFRESH_SECONDS,
921
+ config: SyncmillConfig | None = None,
922
+ toml_path: Path | None = None,
923
+ ) -> None:
924
+ """Run the dashboard over an already-open ``board`` (the caller closes it).
925
+ The one thing cli.py imports — and the seam tests patch (at the lookup site,
926
+ ``syncmill.cli.run_dashboard``). ``config`` feeds the TEAM and REPORT panes;
927
+ ``toml_path`` is the TEAM write target. Both remain optional for backwards-
928
+ compatible board-only constructions, where ``t``/``p`` degrade to notices."""
929
+ install_render_guard()
930
+ DashboardApp(
931
+ board,
932
+ refresh_seconds=clamp_refresh(refresh_seconds),
933
+ config=config,
934
+ toml_path=toml_path,
935
+ ).run()