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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- 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()
|