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/tui.py ADDED
@@ -0,0 +1,3537 @@
1
+ """Seven-tab SyncMill command center (`syncmill tui`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import signal
10
+ import sqlite3
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from pydantic import ValidationError
16
+ from textual.app import App, ComposeResult
17
+ from textual.containers import Horizontal, Vertical, VerticalScroll
18
+ from textual.css.query import NoMatches
19
+ from textual.notifications import SeverityLevel
20
+ from textual.screen import ModalScreen
21
+ from textual.widgets import (
22
+ Button,
23
+ Checkbox,
24
+ DataTable,
25
+ Header,
26
+ Input,
27
+ Log,
28
+ OptionList,
29
+ Select,
30
+ Static,
31
+ TabbedContent,
32
+ TabPane,
33
+ TextArea,
34
+ )
35
+
36
+ from syncmill.board import (
37
+ Board,
38
+ BoardError,
39
+ Evidence,
40
+ WorkItem,
41
+ WorkState,
42
+ build_board,
43
+ render_board_stats,
44
+ render_decisions,
45
+ render_item,
46
+ )
47
+ from syncmill.artifacts import (
48
+ ArtifactError,
49
+ ArtifactListing,
50
+ ArtifactStore,
51
+ SkippedRun,
52
+ is_room_bound,
53
+ printable_name,
54
+ printable_text,
55
+ )
56
+ from syncmill.project import (
57
+ ProjectError,
58
+ git_root,
59
+ project_id,
60
+ resolve_project_state_paths,
61
+ )
62
+ from syncmill.config import SyncmillConfig
63
+ from syncmill.dashboard import DEFAULT_REFRESH_SECONDS, MIN_REFRESH_SECONDS
64
+ from syncmill.isolated_session import IsolatedSessionError, IsolatedSessionStore
65
+ from syncmill.report import list_reports, render_report
66
+ from syncmill.room_viewer import (
67
+ RoomViewerError,
68
+ attach_viewer,
69
+ ensure_viewer,
70
+ in_tmux,
71
+ sweep_orphan_viewers,
72
+ )
73
+ from syncmill.room_compare import (
74
+ RoomComparison,
75
+ build_room_comparison,
76
+ find_round,
77
+ find_row,
78
+ render_round_table,
79
+ )
80
+ from syncmill.rooms import RoomCoordinator, RoomError, RoomStore, launch_room_worker
81
+ from syncmill.session import SessionLaunchError
82
+ from syncmill.review_candidates import (
83
+ DEFAULT_GOAL as REVIEW_DEFAULT_GOAL,
84
+ ReviewCandidateError,
85
+ item_specs as review_item_specs,
86
+ load_report as load_review_report,
87
+ )
88
+ from syncmill.strategies import available_strategies
89
+ from syncmill.teamfile import (
90
+ TeamFileError,
91
+ build_team,
92
+ config_write_lock,
93
+ load_config_at,
94
+ parse_capability_specs,
95
+ render_team,
96
+ save_team,
97
+ set_default_team,
98
+ validate_team_save,
99
+ )
100
+ from syncmill.terminal import printable_command
101
+ from syncmill.tui_jobs import (
102
+ ArtifactApplySpec,
103
+ BoardJobSpec,
104
+ JobError,
105
+ JobStore,
106
+ RoomApplySpec,
107
+ canonical_board_path,
108
+ )
109
+ from syncmill.tui_render import (
110
+ PlainLabel,
111
+ PlainOption,
112
+ PlainStatic,
113
+ error_console,
114
+ install_render_guard,
115
+ plain,
116
+ plain_select,
117
+ )
118
+ from syncmill.worktree import WorkspaceError, repository_fingerprint
119
+ from syncmill.tui_collaboration import (
120
+ CandidateSelectModal,
121
+ CollaborationConfirmModal,
122
+ RoomCreateModal,
123
+ RoomRoundModal,
124
+ SessionOpenModal,
125
+ _patch_is_binary,
126
+ render_candidate_details,
127
+ render_candidate_diff,
128
+ render_candidate_output,
129
+ render_room,
130
+ render_session,
131
+ )
132
+
133
+ _CLI_SHIM = "from syncmill.cli import cli\ncli(prog_name='syncmill')"
134
+ _FILTERS: list[str | None] = [None, *(state.value for state in WorkState)]
135
+ _LOG_DISPLAY_BYTES = 1024 * 1024
136
+
137
+
138
+ def _log_signature(directory: Path) -> tuple[tuple[int, int, int] | None, ...]:
139
+ values = []
140
+ for name in ("console.log.1", "console.log"):
141
+ try:
142
+ info = (directory / name).stat()
143
+ values.append((info.st_ino, info.st_size, info.st_mtime_ns))
144
+ except OSError:
145
+ values.append(None)
146
+ return tuple(values)
147
+
148
+
149
+ def _read_log_tail(directory: Path, limit: int = _LOG_DISPLAY_BYTES) -> bytes:
150
+ parts: list[bytes] = []
151
+ remaining = limit
152
+ for name in ("console.log", "console.log.1"):
153
+ if remaining <= 0:
154
+ break
155
+ path = directory / name
156
+ try:
157
+ with path.open("rb") as fh:
158
+ size = path.stat().st_size
159
+ fh.seek(max(0, size - remaining))
160
+ chunk = fh.read(remaining)
161
+ except OSError:
162
+ continue
163
+ parts.insert(0, chunk)
164
+ remaining -= len(chunk)
165
+ return b"".join(parts)
166
+
167
+
168
+ def _job_label(row: dict) -> str:
169
+ """One Runs-tab row label. Board-run rows keep the historical shape; apply
170
+ ops show the op plus the fenced target's id prefix."""
171
+ request = row.get("request", {})
172
+ op = row.get("op") or "board-run" # legacy schema-1 rows carry no op
173
+ if op == "board-run":
174
+ detail = request.get("mode")
175
+ else:
176
+ target = str(
177
+ request.get("workflow_id")
178
+ or request.get("room_id")
179
+ or request.get("run_id")
180
+ or ""
181
+ )
182
+ detail = f"{op} {target[:8]}".rstrip()
183
+ return f"{row['job_id'][:8]} {row.get('status')} {detail}"
184
+
185
+
186
+ _DIFF_DISPLAY_BYTES = 256 * 1024
187
+ # result.json must be parsed whole (it is one JSON document); the display
188
+ # itself truncates output at 100k, so this cap only guards against a
189
+ # pathological file.
190
+ _RESULT_LOAD_BYTES = 8 * 1024 * 1024
191
+ _CANDIDATE_OUTPUT_BYTES = 100_000
192
+ #: Candidate views rendered from the Rooms evidence snapshot rather than from
193
+ #: a per-candidate artifact read.
194
+ _EVIDENCE_VIEWS = ("compare", "details")
195
+ # Polling is best-effort by invariant (the _refresh_board precedent): a
196
+ # mid-session store failure degrades that surface and keeps the last snapshot,
197
+ # never killing the app. RoomStore leaks raw sqlite3.Error from bare SQL and
198
+ # ValueError (json.JSONDecodeError) / KeyError / TypeError / AttributeError
199
+ # from corrupt members_json / payload_json rows (null or a scalar decodes
200
+ # fine, then fails on attribute/key access or in the renderer);
201
+ # RuntimeError covers Path.resolve() on a symlink loop in the store paths AND
202
+ # RecursionError (its subclass) from pathologically-nested JSON; the session
203
+ # store reads run-dir files.
204
+ _ROOM_POLL_ERRORS = (
205
+ RoomError,
206
+ sqlite3.Error,
207
+ ValueError,
208
+ KeyError,
209
+ TypeError,
210
+ AttributeError,
211
+ RuntimeError,
212
+ )
213
+ _SESSION_POLL_ERRORS = (
214
+ IsolatedSessionError,
215
+ OSError,
216
+ ValueError,
217
+ KeyError,
218
+ TypeError,
219
+ AttributeError,
220
+ RuntimeError,
221
+ )
222
+ # The jobs poll reads a state directory and, when a manager released its lock,
223
+ # rewrites job state — so it can fail the same ways, plus KeyError/TypeError on a
224
+ # foreign or half-written spec.json. Unguarded, any of these escapes the interval
225
+ # callback and kills the whole app.
226
+ _JOB_POLL_ERRORS = (
227
+ JobError,
228
+ OSError,
229
+ ValueError,
230
+ KeyError,
231
+ TypeError,
232
+ AttributeError,
233
+ RuntimeError,
234
+ )
235
+
236
+
237
+ def _artifact_empty_text(skipped: list[SkippedRun]) -> str:
238
+ if not skipped:
239
+ return "No run artifacts. Session checkpoints and run winners land here."
240
+ noun = "directory" if len(skipped) == 1 else "directories"
241
+ lines = [
242
+ f"No readable run artifacts. {len(skipped)} run {noun} could not be read:",
243
+ *(f" {printable_name(entry.name)} — {entry.reason}" for entry in skipped),
244
+ "",
245
+ "Inspect with: syncmill runs show <run-id>. Nothing was changed.",
246
+ ]
247
+ return "\n".join(lines)
248
+
249
+
250
+ def _artifact_label(run_id: str, manifest: dict) -> str:
251
+ """One Artifacts-pane row label: id, state, agent, and origin (room-bound
252
+ runs are marked — they are applied/reconciled via the room, not here)."""
253
+ origin = "room" if is_room_bound(manifest) else (manifest.get("strategy") or "?")
254
+ agent = manifest.get("agent_id") or "?"
255
+ return f"{run_id[:8]} {manifest.get('state', '?'):9.9} {agent:8.8} {origin}"
256
+
257
+
258
+ def _read_artifact_result(run_id: str, *, store: ArtifactStore) -> dict | None:
259
+ """The run's result.json sidecar (status/output/error/exit/duration).
260
+
261
+ ``None`` means MISSING; an oversized or corrupt file returns a sentinel
262
+ status instead, so the view never mislabels a recorded result as
263
+ "no result recorded". Degrades, never raises.
264
+ """
265
+ try:
266
+ raw = store.read_result(run_id, limit=_RESULT_LOAD_BYTES + 1, missing_ok=True)
267
+ except FileNotFoundError:
268
+ return None
269
+ except (OSError, ArtifactError) as exc:
270
+ # A directory, permission failure, or I/O error is NOT "missing".
271
+ return {"status": f"(result unreadable: {exc.__class__.__name__})"}
272
+ if raw is None:
273
+ return None
274
+ if len(raw) > _RESULT_LOAD_BYTES:
275
+ return {"status": f"(result too large to display: >{_RESULT_LOAD_BYTES} bytes)"}
276
+ try:
277
+ value = json.loads(raw)
278
+ except ValueError:
279
+ return {"status": "(result unreadable: corrupt result.json)"}
280
+ if not isinstance(value, dict):
281
+ return {"status": "(result unreadable: unexpected shape)"}
282
+ return value
283
+
284
+
285
+ def _one_line(value: object) -> str:
286
+ """A stored value in a one-line field, as the CLI shows it."""
287
+ return printable_name(str(value))
288
+
289
+
290
+ def render_artifact(manifest: dict, result: dict | None) -> str:
291
+ """The Artifacts summary view: manifest metadata first, then the recorded
292
+ result with error before output (review reads failures first)."""
293
+ p = _one_line
294
+ plan = manifest.get("plan") or {}
295
+ origin = plan.get("origin") if isinstance(plan, dict) else None
296
+ files = ", ".join(p(name) for name in manifest.get("files_changed") or [])
297
+ lines = [
298
+ f"Run: {p(manifest.get('run_id'))}",
299
+ f"State: {p(manifest.get('state'))}",
300
+ f"Strategy: {p(manifest.get('strategy'))} | agent: {p(manifest.get('agent_id'))}"
301
+ + (f" | origin: {p(origin)}" if origin else ""),
302
+ f"Created: {p(manifest.get('created_at'))}",
303
+ f"Patch: {p(manifest.get('patch_bytes'))} bytes {p(manifest.get('patch_sha256'))}",
304
+ f"Files: {files or '-'}",
305
+ ]
306
+ if manifest.get("recovery_reason"):
307
+ lines.append(f"Recovery: {p(manifest['recovery_reason'])}")
308
+ lines.append("")
309
+ if result is None:
310
+ lines.append("(no result recorded)")
311
+ elif set(result) == {"status"}:
312
+ # The reader's oversized/corrupt sentinel — status only, no fake
313
+ # exit/duration fields.
314
+ lines.append(f"Result: {p(result['status'])}")
315
+ else:
316
+ lines.append(
317
+ f"Result: {p(result.get('status'))} | exit: {p(result.get('exit_code'))} | "
318
+ f"{p(result.get('duration_seconds'))}s"
319
+ )
320
+ if result.get("error"):
321
+ lines.append(f"Error: {printable_text(str(result['error']))}")
322
+ output = printable_text(str(result.get("output") or ""))
323
+ if len(output) > 100_000:
324
+ output = (
325
+ output[:50_000]
326
+ + "\n\n[... display truncated ...]\n\n"
327
+ + output[-50_000:]
328
+ )
329
+ if output:
330
+ lines.extend(["", output])
331
+ return "\n".join(lines)
332
+
333
+
334
+ TAB_CHOICES = (
335
+ "auto",
336
+ "board",
337
+ "runs",
338
+ "reports",
339
+ "teams",
340
+ "status",
341
+ "sessions",
342
+ "rooms",
343
+ )
344
+
345
+ # The tab whose list gets focus after a switch. Status has no list — nothing
346
+ # to focus there.
347
+ _PRIMARY_WIDGETS = {
348
+ "board-tab": "#board-table",
349
+ "runs-tab": "#job-list",
350
+ "reports-tab": "#report-list",
351
+ "teams-tab": "#team-list",
352
+ "sessions-tab": "#session-list",
353
+ "rooms-tab": "#room-list",
354
+ }
355
+
356
+ _ACTION_BAR_COMMON = "1-7 tabs | r refresh | ? help | q quit"
357
+
358
+ _ACTION_BARS = {
359
+ "board-tab": (
360
+ "a add | i import | d decisions | e edit | u unblock | h clear-human | "
361
+ "s stats | x run item | g run queue | f filter"
362
+ ),
363
+ "runs-tab": "v artifacts | f follow | k stop | K force | R recover",
364
+ "runs-tab:artifacts-pane": "v operations | d diff/summary | g apply | k discard | R recover",
365
+ "reports-tab": "",
366
+ "teams-tab": "a add | e edit | u default",
367
+ "status-tab": "r probes agents+memory",
368
+ "sessions-tab": "a open | x attach | c checkpoint | k close",
369
+ "rooms-tab": (
370
+ "a create | x round | t terminal | d/o/m/c diff/output/details/compare | "
371
+ "e select | g apply | R recover | k close"
372
+ ),
373
+ }
374
+
375
+
376
+ def _action_bar_text(
377
+ tab: str, *, runs_pane: str = "operations-pane", available: bool = True
378
+ ) -> str:
379
+ """The contextual action bar for one tab — only that tab's live keys.
380
+
381
+ The global Footer showed every binding with one static caption regardless
382
+ of the active tab (misleading: `f` is filter on Board but follow on Runs),
383
+ so the bar is derived per tab (and, for Runs, per sub-pane) instead. The
384
+ per-tab keys and the common keys are separate lines and the bar wraps
385
+ (height auto), so a narrow terminal never clips `? help | q quit`. When
386
+ the tab's store is unavailable only the common keys are live — the bar
387
+ must not advertise actions that silently do nothing.
388
+ """
389
+ if not available:
390
+ return _ACTION_BAR_COMMON
391
+ key = tab
392
+ if tab == "runs-tab" and runs_pane == "artifacts-pane":
393
+ key = "runs-tab:artifacts-pane"
394
+ keys = _ACTION_BARS.get(key, "")
395
+ return f"{keys}\n{_ACTION_BAR_COMMON}" if keys else _ACTION_BAR_COMMON
396
+
397
+
398
+ def _resolve_initial_tab(
399
+ requested: str, *, rooms_ok: bool, board_ok: bool, jobs_ok: bool
400
+ ) -> str:
401
+ """Map the CLI `--tab` value to a TabPane id.
402
+
403
+ An explicit tab is always honored — an unavailable choice shows that tab's
404
+ existing degraded/limited banner. `auto` lands on the first surface that is
405
+ enabled AND actually opened (Rooms → Board → Runs), else Status.
406
+ """
407
+ if requested != "auto":
408
+ return f"{requested}-tab"
409
+ if rooms_ok:
410
+ return "rooms-tab"
411
+ if board_ok:
412
+ return "board-tab"
413
+ if jobs_ok:
414
+ return "runs-tab"
415
+ return "status-tab"
416
+
417
+
418
+ class _FormModal(ModalScreen[dict | None]):
419
+ BINDINGS = [("escape", "cancel", "Cancel")]
420
+
421
+ def action_cancel(self) -> None:
422
+ self.dismiss(None)
423
+
424
+
425
+ class AddItemModal(_FormModal):
426
+ def compose(self) -> ComposeResult:
427
+ with Vertical(classes="form-dialog"):
428
+ yield PlainLabel("Add board item")
429
+ yield TextArea(id="task", language=None)
430
+ yield Input(placeholder="goal (optional)", id="goal")
431
+ yield Input(placeholder="dependency ids, comma-separated", id="deps")
432
+ yield Checkbox("human required", id="human")
433
+ with Horizontal(classes="form-buttons"):
434
+ yield Button("Add", variant="primary", id="apply")
435
+ yield Button("Cancel", id="cancel")
436
+
437
+ def on_button_pressed(self, event: Button.Pressed) -> None:
438
+ if event.button.id != "apply":
439
+ self.dismiss(None)
440
+ return
441
+ self.dismiss(
442
+ {
443
+ "task": self.query_one("#task", TextArea).text,
444
+ "goal": self.query_one("#goal", Input).value,
445
+ "deps": self.query_one("#deps", Input).value,
446
+ "human": self.query_one("#human", Checkbox).value,
447
+ }
448
+ )
449
+
450
+
451
+ class ImportModal(_FormModal):
452
+ def compose(self) -> ComposeResult:
453
+ with Vertical(classes="form-dialog"):
454
+ yield PlainLabel("Import Tracegraph review candidates")
455
+ yield Input(placeholder="report path", id="path")
456
+ yield Input(value=REVIEW_DEFAULT_GOAL, placeholder="goal", id="goal")
457
+ with Horizontal(classes="form-buttons"):
458
+ yield Button("Import", variant="primary", id="apply")
459
+ yield Button("Cancel", id="cancel")
460
+
461
+ def on_button_pressed(self, event: Button.Pressed) -> None:
462
+ if event.button.id != "apply":
463
+ self.dismiss(None)
464
+ return
465
+ self.dismiss(
466
+ {
467
+ "path": self.query_one("#path", Input).value,
468
+ "goal": self.query_one("#goal", Input).value,
469
+ }
470
+ )
471
+
472
+
473
+ class DecisionModal(_FormModal):
474
+ def __init__(self, board: Board, goal: str | None):
475
+ super().__init__()
476
+ self._board = board
477
+ self._goal = goal
478
+ self._decisions = board.active_decisions(goal=goal)
479
+
480
+ def compose(self) -> ComposeResult:
481
+ with Vertical(classes="wide-dialog"):
482
+ yield PlainStatic(render_decisions(self._decisions), id="decision-list")
483
+ yield TextArea(id="decision-text")
484
+ yield Input(value=self._goal or "", placeholder="scope (blank = global)", id="scope")
485
+ supersedable = [
486
+ decision
487
+ for decision in self._decisions
488
+ if decision.scope == (self._goal or "")
489
+ ]
490
+ choices = [
491
+ ("(none)", ""),
492
+ *((f"{d.id[:8]} {d.text[:50]}", d.id) for d in supersedable),
493
+ ]
494
+ yield plain_select(choices, value="", allow_blank=False, id="supersedes")
495
+ with Horizontal(classes="form-buttons"):
496
+ yield Button("Record", variant="primary", id="apply")
497
+ yield Button("Close", id="cancel")
498
+
499
+ def on_button_pressed(self, event: Button.Pressed) -> None:
500
+ if event.button.id != "apply":
501
+ self.dismiss(None)
502
+ return
503
+ supersedes = self.query_one("#supersedes", Select).value
504
+ self.dismiss(
505
+ {
506
+ "text": self.query_one("#decision-text", TextArea).text,
507
+ "scope": self.query_one("#scope", Input).value,
508
+ "supersedes": supersedes if isinstance(supersedes, str) and supersedes else None,
509
+ }
510
+ )
511
+
512
+
513
+ class BoardUpdateModal(_FormModal):
514
+ def __init__(self, item: WorkItem):
515
+ super().__init__()
516
+ self._item = item
517
+
518
+ def compose(self) -> ComposeResult:
519
+ states = [
520
+ ("(keep state)", ""),
521
+ *((s.value, s.value) for s in WorkState if s is not WorkState.RUNNING),
522
+ ]
523
+ with Vertical(classes="form-dialog"):
524
+ yield PlainLabel(f"Update {self._item.id[:8]}")
525
+ yield plain_select(states, value="", allow_blank=False, id="state")
526
+ yield Input(placeholder="audit note", id="note")
527
+ yield Input(placeholder="evidence ref", id="evidence")
528
+ yield Checkbox("sign off", id="sign-off")
529
+ yield Checkbox("clear human-required", id="clear-human")
530
+ with Horizontal(classes="form-buttons"):
531
+ yield Button("Apply", variant="primary", id="apply")
532
+ yield Button("Cancel", id="cancel")
533
+
534
+ def on_button_pressed(self, event: Button.Pressed) -> None:
535
+ if event.button.id != "apply":
536
+ self.dismiss(None)
537
+ return
538
+ state = self.query_one("#state", Select).value
539
+ self.dismiss(
540
+ {
541
+ "state": state if isinstance(state, str) and state else None,
542
+ "note": self.query_one("#note", Input).value,
543
+ "evidence": self.query_one("#evidence", Input).value,
544
+ "sign_off": self.query_one("#sign-off", Checkbox).value,
545
+ "clear_human": self.query_one("#clear-human", Checkbox).value,
546
+ }
547
+ )
548
+
549
+
550
+ class JobModal(_FormModal):
551
+ def __init__(self, config: SyncmillConfig, *, item: WorkItem | None = None):
552
+ super().__init__()
553
+ self._config = config
554
+ self._item = item
555
+
556
+ def compose(self) -> ComposeResult:
557
+ teams = [("Auto", ""), *((name, name) for name in sorted(self._config.teams))]
558
+ strategies = [("Inherit", ""), *((name, name) for name in available_strategies())]
559
+ agents = [("Auto", ""), *((name, name) for name in self._config.agent_priority)]
560
+ with Vertical(classes="wide-dialog"):
561
+ if self._item is not None:
562
+ yield PlainStatic(
563
+ f"Run selected item {self._item.id[:8]}\n{printable_text(self._item.task)}\n\n"
564
+ "A targeted run may bypass dependency/human-required queue gates.",
565
+ id="job-summary",
566
+ )
567
+ else:
568
+ yield PlainStatic(
569
+ "Drain the board queue. Choose an exact goal or leave blank for All.",
570
+ id="job-summary",
571
+ )
572
+ yield Input(placeholder="goal (blank = All)", id="job-goal")
573
+ yield plain_select(teams, value="", allow_blank=False, id="job-team")
574
+ yield plain_select(strategies, value="", allow_blank=False, id="job-strategy")
575
+ yield plain_select(agents, value="", allow_blank=False, id="job-agent")
576
+ yield PlainStatic(
577
+ "This starts real agent CLIs and may consume quota or incur cost. "
578
+ "Programmatic runs use isolated worktrees and finish in review/blocked.",
579
+ classes="warning",
580
+ )
581
+ yield Checkbox("I understand and want to start", id="confirmed")
582
+ with Horizontal(classes="form-buttons"):
583
+ yield Button("Start", variant="primary", id="apply")
584
+ yield Button("Cancel", id="cancel")
585
+
586
+ def on_button_pressed(self, event: Button.Pressed) -> None:
587
+ if event.button.id != "apply":
588
+ self.dismiss(None)
589
+ return
590
+ if not self.query_one("#confirmed", Checkbox).value:
591
+ self.app.notify("confirm the cost and execution warning", severity="warning")
592
+ return
593
+
594
+ def selected(name: str) -> str | None:
595
+ value = self.query_one(name, Select).value
596
+ return value if isinstance(value, str) and value else None
597
+
598
+ self.dismiss(
599
+ {
600
+ "goal": (
601
+ None
602
+ if self._item is not None
603
+ else self.query_one("#job-goal", Input).value
604
+ ),
605
+ "team": selected("#job-team"),
606
+ "strategy": selected("#job-strategy"),
607
+ "agent": selected("#job-agent"),
608
+ }
609
+ )
610
+
611
+
612
+ class TeamEditModal(_FormModal):
613
+ def __init__(self, name: str, config: SyncmillConfig):
614
+ super().__init__()
615
+ self._name = name
616
+ self._team = config.teams.get(name)
617
+
618
+ def compose(self) -> ComposeResult:
619
+ team = self._team
620
+ caps = "; ".join(
621
+ f"{agent}={','.join(values)}"
622
+ for agent, values in (team.capabilities if team else {}).items()
623
+ )
624
+ with Vertical(classes="wide-dialog"):
625
+ yield Input(value=self._name, placeholder="team name", id="name")
626
+ yield Input(
627
+ value=", ".join(team.agents) if team else "",
628
+ placeholder="agents",
629
+ id="agents",
630
+ )
631
+ yield plain_select(
632
+ [("Inherit", ""), *((name, name) for name in available_strategies())],
633
+ value=team.strategy if team and team.strategy else "",
634
+ allow_blank=False,
635
+ id="strategy",
636
+ )
637
+ yield Input(
638
+ value=", ".join(team.goals) if team else "",
639
+ placeholder="goals",
640
+ id="goals",
641
+ )
642
+ yield Input(value=caps, placeholder="codex=python,cli; claude=docs", id="caps")
643
+ with Horizontal(classes="form-buttons"):
644
+ yield Button("Save", variant="primary", id="apply")
645
+ yield Button("Cancel", id="cancel")
646
+
647
+ def on_button_pressed(self, event: Button.Pressed) -> None:
648
+ if event.button.id != "apply":
649
+ self.dismiss(None)
650
+ return
651
+ strategy = self.query_one("#strategy", Select).value
652
+ self.dismiss(
653
+ {
654
+ "name": self.query_one("#name", Input).value,
655
+ "agents": self.query_one("#agents", Input).value,
656
+ "strategy": strategy if isinstance(strategy, str) and strategy else None,
657
+ "goals": self.query_one("#goals", Input).value,
658
+ "caps": self.query_one("#caps", Input).value,
659
+ }
660
+ )
661
+
662
+
663
+ def build_room_evidence(
664
+ store_root: Path, output_max_bytes: int, repo_root: Path, room_id: str, now: float
665
+ ) -> tuple[RoomComparison, int, frozenset]:
666
+ """Build one room's evidence snapshot. Runs in a thread worker: SQLite
667
+ connections are thread-affine, so it opens its own store and closes it.
668
+ Returns the comparison plus the room revision and candidate ids read
669
+ BEFORE the build, so anything that changes during it counts as a change."""
670
+ store = RoomStore(store_root, output_max_bytes)
671
+ try:
672
+ room = store.get_room(room_id)
673
+ candidate_ids = frozenset(c["id"] for c in store.list_candidates(room_id))
674
+ comparison = build_room_comparison(
675
+ store,
676
+ room_id,
677
+ all_rounds=True,
678
+ source_check=lambda: repository_fingerprint(repo_root),
679
+ now=now,
680
+ )
681
+ finally:
682
+ store.close()
683
+ return comparison, int(room["revision"]), candidate_ids
684
+
685
+
686
+ def select_room_candidate(
687
+ store_root: Path,
688
+ output_max_bytes: int,
689
+ room_id: str,
690
+ candidate_id: str,
691
+ *,
692
+ expected_revision: int,
693
+ expected_patch_sha256: str,
694
+ rationale: str | None,
695
+ ) -> None:
696
+ """Verify and select a candidate. Runs in a thread worker — it streams the
697
+ whole artifact — so it opens its own store (SQLite is thread-affine)."""
698
+ store = RoomStore(store_root, output_max_bytes)
699
+ try:
700
+ store.select_candidate(
701
+ room_id,
702
+ candidate_id,
703
+ expected_revision=expected_revision,
704
+ expected_patch_sha256=expected_patch_sha256,
705
+ rationale=rationale,
706
+ surface="tui",
707
+ )
708
+ finally:
709
+ store.close()
710
+
711
+
712
+ class SyncmillTuiApp(App[None]):
713
+ TITLE = "syncmill tui"
714
+ BINDINGS = [
715
+ ("1", "tab('board-tab')", "Board"),
716
+ ("2", "tab('runs-tab')", "Runs"),
717
+ ("3", "tab('reports-tab')", "Reports"),
718
+ ("4", "tab('teams-tab')", "Teams"),
719
+ ("5", "tab('status-tab')", "Status"),
720
+ ("6", "tab('sessions-tab')", "Sessions"),
721
+ ("7", "tab('rooms-tab')", "Rooms"),
722
+ ("r", "refresh_current", "Refresh"),
723
+ ("f", "context_f", "Filter/follow"),
724
+ ("a", "context_add", "Add"),
725
+ ("i", "import_candidates", "Import"),
726
+ ("d", "context_d", "Diff/Decisions"),
727
+ ("o", "candidate_output", "Output"),
728
+ ("m", "candidate_details", "Details"),
729
+ ("v", "runs_view", "Runs view"),
730
+ ("e", "edit", "Edit"),
731
+ ("u", "unblock", "Unblock"),
732
+ ("h", "clear_human", "Clear human"),
733
+ ("s", "stats", "Stats"),
734
+ ("x", "run_selected", "Run selected"),
735
+ ("g", "run_loop", "Run queue"),
736
+ ("t", "terminal", "Terminal"),
737
+ ("k", "safe_stop", "Safe stop"),
738
+ ("c", "checkpoint", "Checkpoint"),
739
+ ("K", "force_stop", "Force stop"),
740
+ ("R", "recover_runs", "Recover"),
741
+ ("?", "help", "Help"),
742
+ ("q", "quit", "Quit UI"),
743
+ ]
744
+
745
+ CSS = """
746
+ #tabs { height: 1fr; }
747
+ #runs-subtabs { height: 1fr; }
748
+ .split { height: 1fr; }
749
+ .left { width: 3fr; }
750
+ .right { width: 2fr; border-left: solid $primary; padding: 0 1; }
751
+ .status-line { height: 2; padding: 0 1; background: $panel; }
752
+ #action-bar { height: auto; padding: 0 1; background: $panel; }
753
+ .form-dialog {
754
+ width: 72; height: auto; border: thick $primary;
755
+ padding: 1 2; background: $surface;
756
+ }
757
+ .wide-dialog {
758
+ width: 90%; max-width: 100; height: 85%; border: thick $primary;
759
+ padding: 1 2; background: $surface;
760
+ }
761
+ .form-buttons { height: auto; align-horizontal: right; }
762
+ #selection-body { height: 1fr; }
763
+ #selection-rationale { height: 6; }
764
+ .form-buttons Button { margin-left: 2; }
765
+ _FormModal { align: center middle; }
766
+ .warning { color: $warning; margin: 1 0; }
767
+ #board-table, #job-list, #artifact-list, #report-list, #team-list, #session-list, #room-list { height: 1fr; }
768
+ #candidate-list { height: 2fr; border-top: solid $primary; }
769
+ #job-log { height: 1fr; }
770
+ """
771
+
772
+ def __init__(
773
+ self,
774
+ config: SyncmillConfig,
775
+ *,
776
+ board: Board | None,
777
+ board_error: str | None,
778
+ toml_path: Path,
779
+ refresh_seconds: float = DEFAULT_REFRESH_SECONDS,
780
+ initial_tab: str = "auto",
781
+ ):
782
+ super().__init__()
783
+ self.error_console = error_console()
784
+ self._config = config
785
+ self._board = board
786
+ self._board_error = board_error
787
+ self._toml_path = toml_path
788
+ self._refresh_seconds = max(MIN_REFRESH_SECONDS, refresh_seconds)
789
+ self._initial_tab = initial_tab
790
+ # Set at the end of on_mount; the TabActivated handler ignores events
791
+ # until then (the initial activation must not double-refresh), and
792
+ # _handled_tab dedupes re-activations of the already-handled tab.
793
+ self._ui_ready = False
794
+ self._handled_tab: str | None = None
795
+ self._handled_runs_pane = "operations-pane"
796
+ self.action_bar_text = ""
797
+ self.artifact_detail_text = ""
798
+ self._selected_run_id: str | None = None
799
+ self._artifact_view = "summary"
800
+ self.candidate_detail_text = ""
801
+ # App-owned seam mirroring the "#room-detail" Static — tests assert on
802
+ # this, never on widget internals (the candidate_detail_text pattern).
803
+ self.room_detail_text = ""
804
+ # None → per-candidate default in _show_candidate: Diff for a
805
+ # completed candidate, Output for a failed/timeout one.
806
+ self._candidate_view: str | None = None
807
+ self._diff_pages = 1
808
+ self._diff_truncated = False
809
+ self._candidate_detail_key: tuple | None = None
810
+ self._filter: str | None = None
811
+ self._selected_item_id: str | None = None
812
+ self._selected_job_dir: str | None = None
813
+ self._selected_report_id: str | None = None
814
+ self._selected_team: str | None = None
815
+ self._selected_session_id: str | None = None
816
+ self._selected_room_id: str | None = None
817
+ self._selected_candidate_id: str | None = None
818
+ self._follow_log = True
819
+ self._refreshing_jobs = False
820
+ self._checkpoint_running = False
821
+ self._job_log_signature: tuple[tuple[int, int, int] | None, ...] | None = None
822
+ self._job_log_dir: str | None = None
823
+ self._memory_status = "(press r to probe memory)"
824
+ self._jobs: JobStore | None = None
825
+ self._jobs_error: str | None = None
826
+ self._session_store: IsolatedSessionStore | None = None
827
+ self._rooms: RoomCoordinator | None = None
828
+ self._collaboration_error: str | None = None
829
+ self._artifacts: ArtifactStore | None = None
830
+ self._artifacts_error: str | None = None
831
+ # Artifacts listing runs in a thread worker (#168): one in flight at a
832
+ # time, results tagged with the generation that started them so a
833
+ # late result can never overwrite a newer one.
834
+ self._artifact_listing_generation = 0
835
+ self._artifact_listing_inflight = False
836
+ # A refresh or invalidation that arrived while a listing was running:
837
+ # coalesced into ONE rerun when that listing's thread reports back.
838
+ self._artifact_listing_dirty = False
839
+ # Rooms evidence snapshot (#172): one RoomComparison of every round of
840
+ # one room, built only on request (c, first m, r in those views) in a
841
+ # thread worker with its own RoomStore connection, and shown with its
842
+ # build time — never refreshed by polling, never presented as live.
843
+ self._evidence: dict | None = None
844
+ self._evidence_error: tuple[str, str] | None = None
845
+ self._evidence_generation = 0
846
+ # The build running now and the one queued behind it, each bound to
847
+ # the (room id, store root) it was requested for. No boolean flags:
848
+ # "building" on screen is derived from these, so a view can never sit
849
+ # on "building" with no worker behind it.
850
+ self._evidence_inflight: tuple[str, str, int] | None = None
851
+ #: (room, candidate, store root) of the selection a worker is verifying.
852
+ self._select_inflight: tuple[str, str, str] | None = None
853
+ self._evidence_pending: tuple[str, str] | None = None
854
+ # Bumped on every accepted build result (success or failure), so an
855
+ # open Compare/Details re-renders even when nothing else changed.
856
+ self._evidence_build_id = 0
857
+ # The store the snapshot (and any build in flight) belongs to, tracked
858
+ # apart from the snapshot so a same-root rebind during the FIRST build
859
+ # does not retire it.
860
+ self._evidence_store_root: str | None = None
861
+ self._runtime_config: SyncmillConfig | None = None
862
+ # Content signatures per widget id (lists + the board table) and last
863
+ # texts per detail Static: an unchanged poll tick must not rebuild a
864
+ # widget, so highlight, scroll, and focus survive polling.
865
+ self._option_signatures: dict[str, tuple] = {}
866
+ self._static_texts: dict[str, str] = {}
867
+ self._rebind_jobs()
868
+ self._rebind_collaboration()
869
+
870
+ def _project_root(self) -> Path:
871
+ config_root = git_root(self._toml_path.parent.resolve())
872
+ value = Path(self._config.repo_root).expanduser()
873
+ if not value.is_absolute():
874
+ value = config_root / value
875
+ return git_root(value.resolve())
876
+
877
+ def _job_project_root(self) -> Path:
878
+ """The job store's project anchor. Resolve the CONFIGURED repo_root against
879
+ the config location (never cwd), then git-canonicalize when it is a git
880
+ repo; otherwise use the resolved target verbatim (syncmill supports
881
+ non-git repos). Never discards the configured repo_root."""
882
+ anchor = self._toml_path.parent.resolve()
883
+ value = Path(self._config.repo_root).expanduser()
884
+ target = (value if value.is_absolute() else anchor / value).resolve()
885
+ try:
886
+ return git_root(target)
887
+ except ProjectError:
888
+ return target
889
+
890
+ def _rebind_collaboration(self) -> None:
891
+ if self._rooms is not None:
892
+ self._rooms.close()
893
+ self._session_store = None
894
+ self._rooms = None
895
+ self._artifacts = None
896
+ # A listing still running against the previous store must not land
897
+ # in the new store's view: retire its generation (#168).
898
+ self._invalidate_artifact_listing()
899
+ self._runtime_config = None
900
+ # The stores may now address different content — drop the rebuild-skip
901
+ # caches so the next refresh renders from scratch. That includes the
902
+ # candidate-detail key: after an unavailable interval blanked the
903
+ # pane, a reconnect to the same store must re-render, not hit the
904
+ # stale key and stay blank.
905
+ self._option_signatures.clear()
906
+ self._static_texts.clear()
907
+ self._candidate_detail_key = None
908
+ try:
909
+ config_root = git_root(self._toml_path.parent.resolve())
910
+ root = self._project_root()
911
+ runtime_config = self._config.model_copy(deep=True)
912
+ runtime_config.repo_root = str(root)
913
+ resolve_project_state_paths(runtime_config, config_root)
914
+ except Exception as exc:
915
+ self._collaboration_error = str(exc)
916
+ self._artifacts_error = str(exc)
917
+ self._reconcile_evidence_store()
918
+ return
919
+ self._runtime_config = runtime_config
920
+ # Bind the three stores INDEPENDENTLY: a corrupt/locked Rooms SQLite
921
+ # store must not disable the filesystem-only Artifacts surface, and
922
+ # vice versa.
923
+ errors: list[str] = []
924
+ try:
925
+ self._session_store = IsolatedSessionStore(runtime_config, root)
926
+ except Exception as exc:
927
+ errors.append(str(exc))
928
+ try:
929
+ self._rooms = (
930
+ RoomCoordinator(runtime_config, root)
931
+ if runtime_config.rooms.enabled
932
+ else None
933
+ )
934
+ except Exception as exc:
935
+ self._rooms = None
936
+ errors.append(str(exc))
937
+ self._collaboration_error = "; ".join(errors) or None
938
+ self._reconcile_evidence_store()
939
+ try:
940
+ # The session store's exact identity recipe, so a checkpoint's run
941
+ # is visible here; also what `syncmill runs list` addresses.
942
+ self._artifacts = ArtifactStore(
943
+ Path(runtime_config.runs.root),
944
+ project_id(root, runtime_config.project.id),
945
+ )
946
+ self._artifacts_error = None
947
+ except Exception as exc:
948
+ self._artifacts = None
949
+ self._artifacts_error = str(exc)
950
+
951
+ def _rebind_jobs(self) -> None:
952
+ previous = self._jobs
953
+ try:
954
+ db_path = (
955
+ self._board.db_path
956
+ if self._board is not None
957
+ else canonical_board_path(self._config.board.db_path)
958
+ )
959
+ # The store anchors on the canonical project root (its project_key) —
960
+ # the SAME root rooms/sessions use — NOT the board db, so room/artifact
961
+ # ops (which need not have a board) key coherently; the board db is
962
+ # store context for board-run keying/env. syncmill supports non-git
963
+ # repos (worktree.py's tempdir fallback): resolve the CONFIGURED
964
+ # repo_root against the config anchor, git-canonicalize when possible,
965
+ # else use that resolved target (never cwd, never a discarded config).
966
+ root = self._job_project_root()
967
+ replacement = JobStore(self._config.tui, root, board_db=db_path)
968
+ except (JobError, ProjectError, OSError, RuntimeError, ValueError) as exc:
969
+ # OSError/RuntimeError cover an unresolvable configured repo_root
970
+ # (symlink loop, dead HOME) surfacing from Path.resolve/expanduser
971
+ # — a valid-toml reload must degrade Runs to limited mode with the
972
+ # error, never propagate through Textual and kill the TUI.
973
+ replacement = None
974
+ self._jobs_error = str(exc)
975
+ else:
976
+ self._jobs_error = None
977
+ changed = (
978
+ previous is None
979
+ or replacement is None
980
+ or previous.project_root != replacement.project_root
981
+ or previous.board_db != replacement.board_db
982
+ )
983
+ self._jobs = replacement
984
+ if changed:
985
+ self._selected_job_dir = None
986
+ self._job_log_dir = None
987
+ self._job_log_signature = None
988
+ self._option_signatures.pop("job-list", None)
989
+ self._static_texts.pop("#job-status", None)
990
+
991
+ def compose(self) -> ComposeResult:
992
+ yield Header()
993
+ with TabbedContent(id="tabs"):
994
+ with TabPane("Board", id="board-tab"):
995
+ with Horizontal(classes="split"):
996
+ yield DataTable(id="board-table", classes="left")
997
+ with VerticalScroll(classes="right"):
998
+ yield PlainStatic("", id="board-detail")
999
+ yield PlainStatic("", id="board-status", classes="status-line")
1000
+ with TabPane("Runs", id="runs-tab"):
1001
+ with TabbedContent(id="runs-subtabs"):
1002
+ with TabPane("Operations", id="operations-pane"):
1003
+ with Horizontal(classes="split"):
1004
+ yield OptionList(id="job-list", classes="left", markup=False)
1005
+ yield Log(id="job-log", classes="right", highlight=False)
1006
+ yield PlainStatic("", id="job-status", classes="status-line")
1007
+ with TabPane("Artifacts", id="artifacts-pane"):
1008
+ with Horizontal(classes="split"):
1009
+ yield OptionList(id="artifact-list", classes="left", markup=False)
1010
+ with VerticalScroll(classes="right"):
1011
+ # Raw agent output and diffs — content like
1012
+ # `[/oops]` must never hit the markup parser.
1013
+ yield PlainStatic("", id="artifact-detail")
1014
+ yield PlainStatic("", id="artifact-status", classes="status-line")
1015
+ with TabPane("Reports", id="reports-tab"):
1016
+ with Horizontal(classes="split"):
1017
+ yield OptionList(id="report-list", classes="left", markup=False)
1018
+ with VerticalScroll(classes="right"):
1019
+ yield PlainStatic("", id="report-detail")
1020
+ with TabPane("Teams", id="teams-tab"):
1021
+ with Horizontal(classes="split"):
1022
+ yield OptionList(id="team-list", classes="left", markup=False)
1023
+ with VerticalScroll(classes="right"):
1024
+ yield PlainStatic("", id="team-detail")
1025
+ with TabPane("Status", id="status-tab"):
1026
+ yield PlainStatic("", id="agent-status")
1027
+ yield PlainStatic("", id="memory-status")
1028
+ with TabPane("Sessions", id="sessions-tab"):
1029
+ with Horizontal(classes="split"):
1030
+ yield OptionList(id="session-list", classes="left", markup=False)
1031
+ with VerticalScroll(classes="right"):
1032
+ yield PlainStatic("", id="session-detail")
1033
+ yield PlainStatic("", id="session-status", classes="status-line")
1034
+ with TabPane("Rooms", id="rooms-tab"):
1035
+ with Horizontal(classes="split"):
1036
+ with Vertical(classes="left"):
1037
+ yield OptionList(id="room-list", markup=False)
1038
+ yield OptionList(id="candidate-list", markup=False)
1039
+ with VerticalScroll(classes="right"):
1040
+ # Carries the user-authored room prompt verbatim, so
1041
+ # markup must stay OFF: a prompt containing `[/]` would
1042
+ # otherwise raise MarkupError and kill the poll.
1043
+ yield PlainStatic("", id="room-detail")
1044
+ # Raw candidate output and diffs (see artifact-detail).
1045
+ yield PlainStatic("", id="candidate-detail")
1046
+ yield PlainStatic("", id="room-status", classes="status-line")
1047
+ yield PlainStatic("", id="action-bar")
1048
+
1049
+ def on_mount(self) -> None:
1050
+ table = self.query_one("#board-table", DataTable)
1051
+ table.cursor_type = "row"
1052
+ table.add_columns(*map(plain, ("id", "state", "goal", "task", "owner", "flags")))
1053
+ initial = _resolve_initial_tab(
1054
+ self._initial_tab,
1055
+ rooms_ok=self._rooms is not None,
1056
+ board_ok=self._board is not None,
1057
+ jobs_ok=self._jobs is not None,
1058
+ )
1059
+ self.query_one("#tabs", TabbedContent).active = initial
1060
+ self._refresh_all()
1061
+ self._update_action_bar()
1062
+ self._focus_primary()
1063
+ self.set_interval(self._refresh_seconds, self._poll)
1064
+ self._handled_tab = initial
1065
+ self._ui_ready = True
1066
+
1067
+ def on_unmount(self) -> None:
1068
+ if self._board is not None:
1069
+ # The CLI also closes its latest connection; this one may have been
1070
+ # replaced by retry, and Board.close() is intentionally idempotent.
1071
+ self._board.close()
1072
+ if self._rooms is not None:
1073
+ self._rooms.close()
1074
+
1075
+ def _active(self) -> str:
1076
+ return self.query_one("#tabs", TabbedContent).active
1077
+
1078
+ def _runs_pane(self) -> str:
1079
+ return self.query_one("#runs-subtabs", TabbedContent).active
1080
+
1081
+ def action_tab(self, tab: str) -> None:
1082
+ tabs = self.query_one("#tabs", TabbedContent)
1083
+ if tabs.active == tab:
1084
+ self._refresh_current()
1085
+ return
1086
+ # The TabActivated handler refreshes, updates the bar, and focuses —
1087
+ # it also covers mouse clicks on tab headers, which bypass this action.
1088
+ tabs.active = tab
1089
+
1090
+ def on_tabbed_content_tab_activated(
1091
+ self, event: TabbedContent.TabActivated
1092
+ ) -> None:
1093
+ if not self._ui_ready:
1094
+ return
1095
+ if event.tabbed_content.id == "runs-subtabs":
1096
+ pane = event.pane.id
1097
+ if pane is None or pane == self._handled_runs_pane:
1098
+ return
1099
+ # Track the pane even for background switches (checkpoint
1100
+ # navigation flips the sub-tab before the main tab switch, which
1101
+ # then does the refresh/focus) so a later `v` is never deduped
1102
+ # against a stale value.
1103
+ self._handled_runs_pane = pane
1104
+ if self._active() != "runs-tab":
1105
+ return
1106
+ self._update_action_bar()
1107
+ self._refresh_current()
1108
+ self._focus_primary()
1109
+ return
1110
+ if event.tabbed_content.id != "tabs":
1111
+ return
1112
+ tab = event.pane.id
1113
+ # Skip already-handled tabs AND stale queued events (the initial
1114
+ # first-pane activation can be delivered after on_mount already moved
1115
+ # to the resolved initial tab).
1116
+ if tab is None or tab == self._handled_tab or tab != self._active():
1117
+ return
1118
+ self._handled_tab = tab
1119
+ self._refresh_current()
1120
+ self._update_action_bar()
1121
+ self._focus_primary()
1122
+
1123
+ def _tab_available(self, tab: str) -> bool:
1124
+ if tab == "board-tab":
1125
+ return self._board is not None
1126
+ if tab == "runs-tab":
1127
+ store = (
1128
+ self._artifacts
1129
+ if self._runs_pane() == "artifacts-pane"
1130
+ else self._jobs
1131
+ )
1132
+ return store is not None
1133
+ if tab == "sessions-tab":
1134
+ return self._session_store is not None
1135
+ if tab == "rooms-tab":
1136
+ return self._rooms is not None
1137
+ return True
1138
+
1139
+ def _update_action_bar(self) -> None:
1140
+ tab = self._active()
1141
+ self.action_bar_text = _action_bar_text(
1142
+ tab, runs_pane=self._runs_pane(), available=self._tab_available(tab)
1143
+ )
1144
+ self.query_one("#action-bar", Static).update(self.action_bar_text)
1145
+
1146
+ def _focus_primary(self) -> None:
1147
+ """Auto-focus the active tab's primary list. Called only on tab switch
1148
+ and at mount — never from _poll/_refresh_* (polling must not steal
1149
+ focus)."""
1150
+ active = self._active()
1151
+ if active == "runs-tab":
1152
+ selector = (
1153
+ "#artifact-list"
1154
+ if self._runs_pane() == "artifacts-pane"
1155
+ else "#job-list"
1156
+ )
1157
+ else:
1158
+ selector = _PRIMARY_WIDGETS.get(active)
1159
+ if selector:
1160
+ self.query_one(selector).focus()
1161
+ else:
1162
+ # Status has no focusable list. Leaving focus on a widget inside
1163
+ # the now-hidden pane makes Textual reactivate that pane (its
1164
+ # TabPane.Focused handling), silently undoing the switch.
1165
+ self.screen.set_focus(None)
1166
+
1167
+ def action_runs_view(self) -> None:
1168
+ if self._active() != "runs-tab":
1169
+ return
1170
+ subtabs = self.query_one("#runs-subtabs", TabbedContent)
1171
+ subtabs.active = (
1172
+ "artifacts-pane"
1173
+ if subtabs.active == "operations-pane"
1174
+ else "operations-pane"
1175
+ )
1176
+
1177
+ def _sync_options(
1178
+ self,
1179
+ options: OptionList,
1180
+ entries: list[tuple[str, str]],
1181
+ selected_id: str | None,
1182
+ ) -> None:
1183
+ """Rebuild an OptionList only when its content changed.
1184
+
1185
+ An unchanged poll tick returns without touching the widget, so
1186
+ highlight, scroll, and focus survive polling. A rebuild restores the
1187
+ logical selection by id and clamps the previous scroll offset.
1188
+ """
1189
+ signature = tuple(entries)
1190
+ widget_id = options.id or ""
1191
+ if self._option_signatures.get(widget_id) == signature:
1192
+ return
1193
+ scroll_y = options.scroll_offset.y
1194
+ options.clear_options()
1195
+ for label, option_id in entries:
1196
+ options.add_option(PlainOption(label, id=option_id))
1197
+ self._option_signatures[widget_id] = signature
1198
+ if not entries:
1199
+ return
1200
+ ids = [option_id for _, option_id in entries]
1201
+ options.highlighted = ids.index(selected_id) if selected_id in ids else 0
1202
+ options.scroll_to(y=min(scroll_y, max(0, len(entries) - 1)), animate=False)
1203
+
1204
+ def _update_static(self, selector: str, text: str) -> None:
1205
+ """update() a detail Static only when its text changed, so a poll tick
1206
+ never resets the surrounding pane's scroll position."""
1207
+ if self._static_texts.get(selector) == text:
1208
+ return
1209
+ self._static_texts[selector] = text
1210
+ self.query_one(selector, Static).update(text)
1211
+
1212
+ def notify(
1213
+ self,
1214
+ message: str,
1215
+ *,
1216
+ title: str = "",
1217
+ severity: SeverityLevel = "information",
1218
+ timeout: float | None = None,
1219
+ markup: bool = False,
1220
+ ) -> None:
1221
+ """Textual's notify with markup off by default: messages quote errors
1222
+ and stored names, and a ``[/x]`` in one raises while the toast renders.
1223
+ The message is escaped as the CLI shows a body, the title as one line."""
1224
+ super().notify(
1225
+ printable_text(str(message)),
1226
+ title=printable_name(title),
1227
+ severity=severity,
1228
+ timeout=timeout,
1229
+ markup=markup,
1230
+ )
1231
+
1232
+ def action_quit(self) -> None:
1233
+ if self._jobs is not None and self._jobs.active_job() is not None:
1234
+ self.notify("TUI closed; the worker continues in the background")
1235
+ self.exit()
1236
+
1237
+ def action_help(self) -> None:
1238
+ self.notify(
1239
+ "1-7 tabs; Sessions a/x/c/k; Rooms a/x/t/d/o/m/c/e/g/R/k; "
1240
+ "Board a/i/d/e/u/h/s/x/g; Runs v/f/k/K/R (Artifacts: d/g/k); "
1241
+ "q detaches"
1242
+ )
1243
+
1244
+ def _poll(self) -> None:
1245
+ active = self._active()
1246
+ if active == "board-tab":
1247
+ self._refresh_board()
1248
+ elif active == "runs-tab":
1249
+ self._refresh_runs_pane()
1250
+ elif active == "sessions-tab":
1251
+ self._refresh_sessions()
1252
+ elif active == "rooms-tab":
1253
+ self._refresh_rooms()
1254
+
1255
+ def _refresh_runs_pane(self) -> None:
1256
+ """Refresh only the visible Runs sub-pane (Artifacts reads one
1257
+ manifest per run dir — don't pay that while Operations is showing)."""
1258
+ if self._runs_pane() == "artifacts-pane":
1259
+ self._refresh_artifacts()
1260
+ else:
1261
+ self._refresh_jobs()
1262
+
1263
+ def _refresh_all(self) -> None:
1264
+ self._refresh_board()
1265
+ self._refresh_runs_pane()
1266
+ self._refresh_reports()
1267
+ self._refresh_teams()
1268
+ self._refresh_agents()
1269
+ self._refresh_sessions()
1270
+ self._refresh_rooms()
1271
+
1272
+ def action_refresh_current(self) -> None:
1273
+ """The `r` key. Only an explicit refresh rebuilds a Rooms evidence
1274
+ snapshot; tab activation reuses the same refresh without it."""
1275
+ self._refresh_current(explicit=True)
1276
+
1277
+ def _refresh_current(self, *, explicit: bool = False) -> None:
1278
+ active = self._active()
1279
+ if active == "board-tab":
1280
+ if self._board is None:
1281
+ self._retry_board()
1282
+ self._refresh_board()
1283
+ elif active == "runs-tab":
1284
+ self._refresh_runs_pane()
1285
+ elif active == "reports-tab":
1286
+ self._refresh_reports()
1287
+ elif active == "teams-tab":
1288
+ self._reload_config()
1289
+ self._refresh_teams()
1290
+ elif active == "sessions-tab":
1291
+ self._refresh_sessions()
1292
+ elif active == "rooms-tab":
1293
+ if not self._reload_config():
1294
+ return
1295
+ self._refresh_rooms()
1296
+ # After the refresh: a rebound store may have changed the room on
1297
+ # screen, and the request must be for that room.
1298
+ if explicit and self._candidate_view in _EVIDENCE_VIEWS:
1299
+ self._request_evidence()
1300
+ else:
1301
+ # The guide documents Status `r` as re-reading configuration (the
1302
+ # teams/rooms refresh behavior): without the reload the displayed
1303
+ # priority/enabled/timeouts stay cached and can disagree with the
1304
+ # probe. Best-effort like teams — a broken toml keeps the cached
1305
+ # view plus the error toast.
1306
+ self._reload_config()
1307
+ self._refresh_agents()
1308
+ self._start_memory_probe()
1309
+ # Retries/rebinds above can change store availability — the bar's
1310
+ # advertised keys must track it, not just tab switches.
1311
+ if self._ui_ready:
1312
+ self._update_action_bar()
1313
+
1314
+ def _board_unavailable(self) -> str | None:
1315
+ if self._board is not None:
1316
+ return None
1317
+ return self._board_error or (
1318
+ "the work-board is disabled — enable it with SYNCMILL_BOARD__ENABLED=true "
1319
+ "or [board] enabled=true in syncmill.toml; press r to retry"
1320
+ )
1321
+
1322
+ def _refresh_board(self) -> None:
1323
+ table = self.query_one("#board-table", DataTable)
1324
+ error = self._board_unavailable()
1325
+ if error:
1326
+ table.clear()
1327
+ self._option_signatures.pop("board-table", None)
1328
+ self._update_static("#board-detail", error)
1329
+ self._update_static("#board-status", "LIMITED MODE | Board unavailable")
1330
+ return
1331
+ assert self._board is not None
1332
+ try:
1333
+ items = self._board.list(state=self._filter)
1334
+ stats = self._board.stats()
1335
+ except BoardError as exc:
1336
+ self._board_error = str(exc)
1337
+ self._update_static("#board-status", f"DEGRADED: {exc}")
1338
+ return
1339
+ rows = []
1340
+ for item in items:
1341
+ flags = "H!" if item.human_required else ""
1342
+ if item.depends_on:
1343
+ flags += f" deps={len(item.depends_on)}"
1344
+ rows.append(
1345
+ (
1346
+ item.id,
1347
+ (
1348
+ item.id[:8], item.state.value, item.goal or "-",
1349
+ item.task[:50], item.owner_agent or "-", flags.strip(),
1350
+ ),
1351
+ )
1352
+ )
1353
+ signature = tuple(rows)
1354
+ if self._option_signatures.get("board-table") != signature:
1355
+ # Content changed: rebuild, then restore the cursor by item id and
1356
+ # clamp the previous scroll (an unchanged tick never gets here, so
1357
+ # cursor/scroll/focus survive polling).
1358
+ scroll_y = table.scroll_offset.y
1359
+ table.clear()
1360
+ for item_id, cells in rows:
1361
+ table.add_row(*map(plain, cells), key=item_id)
1362
+ self._option_signatures["board-table"] = signature
1363
+ if rows:
1364
+ index = next(
1365
+ (
1366
+ i
1367
+ for i, (item_id, _) in enumerate(rows)
1368
+ if item_id == self._selected_item_id
1369
+ ),
1370
+ 0,
1371
+ )
1372
+ table.move_cursor(row=index)
1373
+ table.scroll_to(y=min(scroll_y, max(0, len(rows) - 1)), animate=False)
1374
+ if self._selected_item_id and self._board.get(self._selected_item_id):
1375
+ self._show_item(self._selected_item_id)
1376
+ self._update_static(
1377
+ "#board-status",
1378
+ f"items:{stats.total} | filter:{self._filter or 'all'} | "
1379
+ f"decisions:{len(self._board.active_decisions())}",
1380
+ )
1381
+
1382
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
1383
+ if event.data_table.id != "board-table" or event.row_key is None:
1384
+ return
1385
+ self._selected_item_id = str(event.row_key.value)
1386
+ self._show_item(self._selected_item_id)
1387
+
1388
+ def _show_item(self, item_id: str) -> None:
1389
+ if self._board is None:
1390
+ return
1391
+ try:
1392
+ item = self._board.get(item_id)
1393
+ text = render_item(item, self._board.transitions(item_id)) if item else "(not found)"
1394
+ except BoardError as exc:
1395
+ text = str(exc)
1396
+ self._update_static("#board-detail", text)
1397
+
1398
+ def _selected_item(self) -> WorkItem | None:
1399
+ if self._board is None or self._selected_item_id is None:
1400
+ return None
1401
+ return self._board.get(self._selected_item_id)
1402
+
1403
+ def action_context_f(self) -> None:
1404
+ if self._active() == "runs-tab":
1405
+ if self._runs_pane() != "operations-pane":
1406
+ return
1407
+ self._follow_log = not self._follow_log
1408
+ self.notify(f"log follow {'on' if self._follow_log else 'off'}")
1409
+ elif self._active() == "board-tab":
1410
+ index = _FILTERS.index(self._filter)
1411
+ self._filter = _FILTERS[(index + 1) % len(_FILTERS)]
1412
+ self._refresh_board()
1413
+
1414
+ def action_context_add(self) -> None:
1415
+ active = self._active()
1416
+ if active == "board-tab":
1417
+ if self._board is None:
1418
+ self.notify(self._board_unavailable() or "Board unavailable", severity="warning")
1419
+ return
1420
+ self.push_screen(AddItemModal(), self._apply_add)
1421
+ elif active == "teams-tab":
1422
+ self.push_screen(TeamEditModal("", self._config), self._apply_team)
1423
+ elif active == "sessions-tab":
1424
+ if self._session_store is None:
1425
+ self.notify(
1426
+ self._collaboration_error or "Sessions are unavailable",
1427
+ severity="warning",
1428
+ )
1429
+ return
1430
+ if not self._config.agent_priority:
1431
+ self.notify("no interactive agents are configured", severity="warning")
1432
+ return
1433
+ self.push_screen(SessionOpenModal(self._config), self._apply_session_open)
1434
+ elif active == "rooms-tab":
1435
+ if self._rooms is None:
1436
+ self.notify(
1437
+ self._collaboration_error or "Rooms are unavailable",
1438
+ severity="warning",
1439
+ )
1440
+ return
1441
+ self.push_screen(RoomCreateModal(self._config), self._apply_room_create)
1442
+
1443
+ def _apply_add(self, fields: dict | None) -> None:
1444
+ if not fields or self._board is None:
1445
+ return
1446
+ task = fields["task"].strip()
1447
+ if not task:
1448
+ self.notify("task must not be empty", severity="error")
1449
+ return
1450
+ try:
1451
+ item = self._board.add(
1452
+ task,
1453
+ goal=fields["goal"].strip(),
1454
+ depends_on=[part.strip() for part in fields["deps"].split(",") if part.strip()],
1455
+ human_required=fields["human"],
1456
+ )
1457
+ except BoardError as exc:
1458
+ self.notify(str(exc), severity="error")
1459
+ return
1460
+ self._selected_item_id = item.id
1461
+ self._refresh_board()
1462
+
1463
+ def action_import_candidates(self) -> None:
1464
+ if self._active() != "board-tab" or self._board is None:
1465
+ return
1466
+ self.push_screen(ImportModal(), self._apply_import)
1467
+
1468
+ def _apply_import(self, fields: dict | None) -> None:
1469
+ if not fields or self._board is None:
1470
+ return
1471
+ try:
1472
+ path = Path(fields["path"]).expanduser()
1473
+ if not path.is_absolute():
1474
+ path = Path.cwd() / path
1475
+ report = load_review_report(path)
1476
+ result = self._board.import_review_candidates(
1477
+ review_item_specs(report), goal=fields["goal"].strip() or REVIEW_DEFAULT_GOAL
1478
+ )
1479
+ except (ReviewCandidateError, BoardError, OSError) as exc:
1480
+ self.notify(str(exc), severity="error")
1481
+ return
1482
+ self.notify(f"imported {result.imported}; skipped {result.skipped}")
1483
+ self._refresh_board()
1484
+
1485
+ def action_context_d(self) -> None:
1486
+ active = self._active()
1487
+ if active == "board-tab":
1488
+ self.action_decisions()
1489
+ elif active == "runs-tab" and self._runs_pane() == "artifacts-pane":
1490
+ if not self._selected_run_id:
1491
+ return
1492
+ self._artifact_view = "diff" if self._artifact_view != "diff" else "summary"
1493
+ self._show_artifact(self._selected_run_id)
1494
+ elif active == "rooms-tab":
1495
+ if not self._selected_candidate_id:
1496
+ return
1497
+ if self._candidate_view == "diff":
1498
+ # Already on Diff: page in more of a truncated patch.
1499
+ if not self._diff_truncated:
1500
+ return
1501
+ pages = self._diff_pages + 1
1502
+ else:
1503
+ pages = 1
1504
+ # Proposed view state commits only after staging succeeds — a
1505
+ # failed artifact read must not advance pages/view under the
1506
+ # still-visible old pane.
1507
+ self._show_candidate_as(self._selected_candidate_id, "diff", pages)
1508
+
1509
+ def action_candidate_output(self) -> None:
1510
+ self._set_candidate_view("output")
1511
+
1512
+ def action_candidate_details(self) -> None:
1513
+ if self._active() != "rooms-tab" or not self._selected_candidate_id:
1514
+ return
1515
+ if self._room_evidence() is None:
1516
+ self._request_evidence()
1517
+ self._set_candidate_view("details")
1518
+
1519
+ def _action_room_compare(self) -> None:
1520
+ # Works with no candidate selected: a round that kept no candidates
1521
+ # is exactly when its missing-member evidence matters.
1522
+ if self._rooms is None or not self._selected_room_id:
1523
+ return
1524
+ self._request_evidence()
1525
+ self._show_candidate_as(self._selected_candidate_id, "compare", self._diff_pages)
1526
+
1527
+ def _set_candidate_view(self, view: str) -> None:
1528
+ if self._active() != "rooms-tab" or not self._selected_candidate_id:
1529
+ return
1530
+ self._show_candidate_as(self._selected_candidate_id, view, self._diff_pages)
1531
+
1532
+ def action_decisions(self) -> None:
1533
+ if self._active() != "board-tab" or self._board is None:
1534
+ return
1535
+ item = self._selected_item()
1536
+ self.push_screen(
1537
+ DecisionModal(self._board, item.goal if item else None),
1538
+ self._apply_decision,
1539
+ )
1540
+
1541
+ def _apply_decision(self, fields: dict | None) -> None:
1542
+ if not fields or self._board is None:
1543
+ return
1544
+ text = fields["text"].strip()
1545
+ if not text:
1546
+ self.notify("decision text must not be empty", severity="error")
1547
+ return
1548
+ try:
1549
+ self._board.add_decision(
1550
+ text, scope=fields["scope"].strip(), supersedes=fields["supersedes"]
1551
+ )
1552
+ except BoardError as exc:
1553
+ self.notify(str(exc), severity="error")
1554
+ return
1555
+ self._refresh_board()
1556
+
1557
+ def action_edit(self) -> None:
1558
+ if self._active() == "board-tab":
1559
+ item = self._selected_item()
1560
+ if item:
1561
+ self.push_screen(BoardUpdateModal(item), self._apply_update)
1562
+ elif self._active() == "teams-tab":
1563
+ self.push_screen(
1564
+ TeamEditModal(self._selected_team or "", self._config),
1565
+ self._apply_team,
1566
+ )
1567
+ elif self._active() == "rooms-tab":
1568
+ self._confirm_candidate_selection()
1569
+
1570
+ def _apply_update(self, fields: dict | None) -> None:
1571
+ if not fields or self._board is None or not self._selected_item_id:
1572
+ return
1573
+ evidence: list[Evidence] = []
1574
+ now = time.time()
1575
+ if fields["evidence"]:
1576
+ evidence.append(
1577
+ Evidence(
1578
+ kind="manual",
1579
+ ref=fields["evidence"],
1580
+ passed=True,
1581
+ at=now,
1582
+ by="human",
1583
+ )
1584
+ )
1585
+ if fields["sign_off"]:
1586
+ evidence.append(Evidence(kind="human", ref="sign-off", passed=True, at=now, by="human"))
1587
+ try:
1588
+ self._board.transition(
1589
+ self._selected_item_id,
1590
+ fields["state"],
1591
+ actor="human",
1592
+ note=fields["note"],
1593
+ evidence=evidence or None,
1594
+ human_required=False if fields["clear_human"] else None,
1595
+ )
1596
+ except BoardError as exc:
1597
+ self.notify(str(exc), severity="error")
1598
+ return
1599
+ self._refresh_board()
1600
+
1601
+ def action_unblock(self) -> None:
1602
+ if self._active() == "teams-tab":
1603
+ self.action_toggle_default_team()
1604
+ return
1605
+ self._quick_update(to_state=WorkState.PLANNED.value, note="unblocked")
1606
+
1607
+ def action_clear_human(self) -> None:
1608
+ self._quick_update(human_required=False)
1609
+
1610
+ def _quick_update(self, *, to_state: str | None = None, **values: object) -> None:
1611
+ if self._active() != "board-tab" or self._board is None or not self._selected_item_id:
1612
+ return
1613
+ try:
1614
+ self._board.transition(
1615
+ self._selected_item_id, to_state, actor="human", **values
1616
+ )
1617
+ except BoardError as exc:
1618
+ self.notify(str(exc), severity="error")
1619
+ return
1620
+ self._refresh_board()
1621
+
1622
+ def action_stats(self) -> None:
1623
+ if self._active() == "board-tab" and self._board is not None:
1624
+ self.notify(render_board_stats(self._board.stats()))
1625
+
1626
+ def action_run_selected(self) -> None:
1627
+ if self._active() == "sessions-tab":
1628
+ self._attach_selected_session()
1629
+ return
1630
+ if self._active() == "rooms-tab":
1631
+ self._confirm_room_round()
1632
+ return
1633
+ if self._active() != "board-tab":
1634
+ return
1635
+ item = self._selected_item()
1636
+ if item is None or item.state is not WorkState.PLANNED:
1637
+ self.notify("select a planned item", severity="warning")
1638
+ return
1639
+ self.push_screen(
1640
+ JobModal(self._config, item=item),
1641
+ lambda fields: self._launch_job(fields, item),
1642
+ )
1643
+
1644
+ def action_terminal(self) -> None:
1645
+ """Rooms `t`: open the read-only tmux viewer for the selected room's
1646
+ latest round. Manual (a round never auto-attaches), and shares the
1647
+ room_viewer validation/transition layer with `syncmill room attach`.
1648
+ """
1649
+ if self._active() != "rooms-tab":
1650
+ return
1651
+ if self._rooms is None or not self._selected_room_id:
1652
+ self.notify("select a room", severity="warning")
1653
+ return
1654
+ room_id = self._selected_room_id
1655
+ project_id = self._rooms.project_id
1656
+ try:
1657
+ # Reclaim viewers left by since-closed/pruned rooms (best-effort).
1658
+ with contextlib.suppress(Exception):
1659
+ sweep_orphan_viewers(self._rooms.store, project_id)
1660
+ name = ensure_viewer(self._rooms.store, project_id, room_id)
1661
+ except (RoomViewerError, RoomError) as exc:
1662
+ self.notify(str(exc), severity="error")
1663
+ return
1664
+ try:
1665
+ if in_tmux():
1666
+ # Non-blocking switch — no Textual suspend needed; the user
1667
+ # returns to the TUI with Prefix + L.
1668
+ rc = attach_viewer(name)
1669
+ else:
1670
+ # External foreground attach blocks; hand the terminal over.
1671
+ with self.suspend():
1672
+ rc = attach_viewer(name)
1673
+ if rc != 0:
1674
+ self.notify(f"could not attach to viewer (tmux exit {rc})", severity="error")
1675
+ finally:
1676
+ self._refresh_all()
1677
+
1678
+ def action_run_loop(self) -> None:
1679
+ if self._active() == "rooms-tab":
1680
+ self._confirm_room_apply()
1681
+ return
1682
+ if self._active() == "runs-tab":
1683
+ if self._runs_pane() == "artifacts-pane":
1684
+ self._confirm_artifact_apply()
1685
+ return
1686
+ if self._active() != "board-tab" or self._board is None:
1687
+ return
1688
+ self.push_screen(JobModal(self._config), lambda fields: self._launch_job(fields, None))
1689
+
1690
+ def _confirm_artifact_apply(self) -> None:
1691
+ if self._artifacts is None or not self._selected_run_id:
1692
+ self.notify("select a run artifact", severity="warning")
1693
+ return
1694
+ try:
1695
+ artifact = self._artifacts.load(self._selected_run_id)
1696
+ except (ArtifactError, OSError) as exc:
1697
+ self.notify(str(exc), severity="error")
1698
+ return
1699
+ manifest = artifact.manifest
1700
+ if is_room_bound(manifest):
1701
+ self.notify(
1702
+ "room-bound run — apply it from the Rooms tab (g)", severity="warning"
1703
+ )
1704
+ return
1705
+ if manifest.get("state") != "pending":
1706
+ self.notify(
1707
+ f"only a pending run can be applied (state: {manifest.get('state')})",
1708
+ severity="warning",
1709
+ )
1710
+ return
1711
+ # _project_root shells out to git twice; a repository that vanished under
1712
+ # a long-running TUI raises ProjectError, and an unguarded raise inside an
1713
+ # event handler takes the app down with it.
1714
+ try:
1715
+ target = self._project_root()
1716
+ except ProjectError as exc:
1717
+ self.notify(str(exc), severity="error")
1718
+ return
1719
+ self.push_screen(
1720
+ CollaborationConfirmModal(
1721
+ "Apply run artifact to source",
1722
+ f"Run: {_one_line(artifact.run_id)}\n"
1723
+ f"State: {_one_line(manifest.get('state'))}\n"
1724
+ f"Patch digest: {_one_line(manifest.get('patch_sha256'))}\n"
1725
+ f"Target: {_one_line(target)}\n\n"
1726
+ "Source fingerprint and patch applicability will be checked again.\n"
1727
+ "The detached worker will continue if the TUI closes.",
1728
+ "Apply",
1729
+ ),
1730
+ lambda confirmed, run_id=artifact.run_id: self._apply_artifact_apply(
1731
+ confirmed, run_id
1732
+ ),
1733
+ )
1734
+
1735
+ def _apply_artifact_apply(self, confirmed: dict | bool | None, run_id: str) -> None:
1736
+ if not confirmed:
1737
+ return
1738
+ try:
1739
+ if self._jobs is None:
1740
+ raise JobError(self._jobs_error or "TUI job registry is unavailable")
1741
+ if self._runtime_config is None:
1742
+ raise JobError(
1743
+ self._artifacts_error or "resolved project config is unavailable"
1744
+ )
1745
+ # Orchestration never runs on Textual's event loop: the apply runs
1746
+ # in a durable detached JobStore worker, pinned to the resolved
1747
+ # config captured at rebind time (the room-apply pattern; the
1748
+ # worker re-verifies state/fingerprint/digest before mutating).
1749
+ spec = ArtifactApplySpec(
1750
+ run_id=run_id, verify=bool(self._config.gate.commands)
1751
+ )
1752
+ row = self._jobs.launch(spec, resolved_config=self._runtime_config)
1753
+ except (JobError, ValueError) as exc:
1754
+ self.notify(str(exc), severity="error")
1755
+ return
1756
+ self.notify(f"detached artifact-apply job {row['job_id'][:8]}")
1757
+ if row.get("job_dir"):
1758
+ self._selected_job_dir = row["job_dir"]
1759
+ self.query_one("#runs-subtabs", TabbedContent).active = "operations-pane"
1760
+
1761
+ def _confirm_artifact_discard(self) -> None:
1762
+ if self._artifacts is None or not self._selected_run_id:
1763
+ self.notify("select a run artifact", severity="warning")
1764
+ return
1765
+ run_id = self._selected_run_id
1766
+ self.push_screen(
1767
+ CollaborationConfirmModal(
1768
+ "Discard run artifact",
1769
+ f"Run: {_one_line(run_id)}\n\n"
1770
+ "Marks the pending run as intentionally discarded; the patch "
1771
+ "stays on disk until pruned.",
1772
+ "Discard",
1773
+ ),
1774
+ lambda confirmed, run_id=run_id: self._apply_artifact_discard(
1775
+ confirmed, run_id
1776
+ ),
1777
+ )
1778
+
1779
+ def _apply_artifact_discard(
1780
+ self, confirmed: dict | bool | None, run_id: str
1781
+ ) -> None:
1782
+ if not confirmed or self._artifacts is None:
1783
+ return
1784
+ store = self._artifacts
1785
+
1786
+ def work() -> None:
1787
+ # discard takes the BLOCKING repository apply lock, which a live
1788
+ # detached apply may hold for its whole run — never block the
1789
+ # event loop on it. ArtifactStore is file/flock-based (no sqlite
1790
+ # thread affinity).
1791
+ try:
1792
+ store.discard(run_id)
1793
+ except (ArtifactError, OSError) as exc:
1794
+ self.call_from_thread(self.notify, str(exc), severity="error")
1795
+ return
1796
+ self.call_from_thread(self._after_artifact_discard, run_id)
1797
+
1798
+ self.run_worker(work, thread=True, group="artifact-discard", exclusive=True)
1799
+
1800
+ def _after_artifact_discard(self, run_id: str) -> None:
1801
+ self.notify(f"discarded run {run_id[:8]}")
1802
+ # A listing started before the discard would show the old state.
1803
+ self._invalidate_artifact_listing()
1804
+ if self._active() == "runs-tab" and self._runs_pane() == "artifacts-pane":
1805
+ self._refresh_artifacts()
1806
+
1807
+ def _launch_job(self, fields: dict | None, item: WorkItem | None) -> None:
1808
+ if not fields:
1809
+ return
1810
+ try:
1811
+ if self._jobs is None:
1812
+ raise JobError(self._jobs_error or "TUI job registry is unavailable")
1813
+ spec = BoardJobSpec(
1814
+ mode="selected" if item else "loop",
1815
+ item_id=item.id if item else None,
1816
+ goal=(fields["goal"].strip() or None) if fields["goal"] is not None else None,
1817
+ team=fields["team"],
1818
+ strategy=fields["strategy"],
1819
+ agent=fields["agent"],
1820
+ )
1821
+ self._jobs.launch(spec)
1822
+ except (JobError, ValueError) as exc:
1823
+ self.notify(str(exc), severity="error")
1824
+ return
1825
+ self.query_one("#runs-subtabs", TabbedContent).active = "operations-pane"
1826
+ self.action_tab("runs-tab")
1827
+
1828
+ def _refresh_jobs(self) -> None:
1829
+ options = self.query_one("#job-list", OptionList)
1830
+ if self._jobs is None:
1831
+ self._sync_options(options, [], None)
1832
+ self.query_one("#job-log", Log).clear()
1833
+ self._update_static(
1834
+ "#job-status",
1835
+ f"DEGRADED | Runs unavailable: {self._jobs_error or 'unknown error'}",
1836
+ )
1837
+ return
1838
+ # Best-effort like the room and session polls. The guard covers reading
1839
+ # and interpreting the store — rows come from files this process does not
1840
+ # own, so labelling a malformed or foreign row raises as readily as
1841
+ # listing it — but stops there: widget updates below are OUR code, and a
1842
+ # bug in them must surface as a crash rather than be reported forever as
1843
+ # a degraded store.
1844
+ try:
1845
+ rows = self._jobs.list_jobs()
1846
+ if rows:
1847
+ # Preserve an existing valid selection (so a user can inspect or
1848
+ # stop ANY job among several active targets); only auto-select an
1849
+ # active job when there is no valid current selection.
1850
+ highlighted = self._selected_job_dir
1851
+ if highlighted not in {row["job_dir"] for row in rows}:
1852
+ active = self._jobs.active_job()
1853
+ highlighted = active["job_dir"] if active else None
1854
+ target = next(
1855
+ (i for i, row in enumerate(rows) if row["job_dir"] == highlighted),
1856
+ 0,
1857
+ )
1858
+ selected = rows[target]
1859
+ entries = [(_job_label(row), row["job_dir"]) for row in rows]
1860
+ except _JOB_POLL_ERRORS as exc:
1861
+ self._update_static("#job-status", f"DEGRADED: {exc}")
1862
+ return
1863
+ if not rows:
1864
+ self._sync_options(options, [], None)
1865
+ self._update_static("#job-status", "no TUI jobs")
1866
+ self.query_one("#job-log", Log).clear()
1867
+ return
1868
+ self._refreshing_jobs = True
1869
+ try:
1870
+ self._sync_options(options, entries, selected["job_dir"])
1871
+ finally:
1872
+ self._refreshing_jobs = False
1873
+ self._selected_job_dir = selected["job_dir"]
1874
+ # _show_job reads the job's log and receipt off disk, so it keeps a guard
1875
+ # of its own for the same reason the block above has one.
1876
+ try:
1877
+ self._show_job(selected)
1878
+ except _JOB_POLL_ERRORS as exc:
1879
+ self._update_static("#job-status", f"DEGRADED: {exc}")
1880
+
1881
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
1882
+ if event.option_list.id == "job-list":
1883
+ if self._refreshing_jobs or self._jobs is None:
1884
+ return
1885
+ job_dir = str(event.option.id)
1886
+ if job_dir == self._selected_job_dir:
1887
+ # A rebuild's re-highlight is DELIVERED after _refreshing_jobs
1888
+ # is already reset; _refresh_jobs showed this row itself, so
1889
+ # re-reading list_jobs here would double every rebuild's I/O.
1890
+ return
1891
+ self._selected_job_dir = job_dir
1892
+ # Guarded like the poll: the state directory can disappear between
1893
+ # the refresh that drew this list and the keypress that selects a
1894
+ # row, and an arrow key must not be able to end the session.
1895
+ try:
1896
+ row = next(
1897
+ (
1898
+ row
1899
+ for row in self._jobs.list_jobs()
1900
+ if row["job_dir"] == self._selected_job_dir
1901
+ ),
1902
+ None,
1903
+ )
1904
+ if row:
1905
+ self._show_job(row)
1906
+ except _JOB_POLL_ERRORS as exc:
1907
+ self._update_static("#job-status", f"DEGRADED: {exc}")
1908
+ elif event.option_list.id == "artifact-list":
1909
+ run_id = str(event.option.id)
1910
+ if run_id != self._selected_run_id:
1911
+ self._artifact_view = "summary"
1912
+ self._selected_run_id = run_id
1913
+ self._show_artifact(run_id)
1914
+ elif event.option_list.id == "report-list":
1915
+ self._selected_report_id = str(event.option.id)
1916
+ self._show_report(self._selected_report_id)
1917
+ elif event.option_list.id == "team-list":
1918
+ self._selected_team = str(event.option.id)
1919
+ self._show_team(self._selected_team)
1920
+ elif event.option_list.id == "session-list":
1921
+ session_id = str(event.option.id)
1922
+ if session_id == self._selected_session_id:
1923
+ # A rebuild's re-highlight of the already-shown selection (the
1924
+ # job-list rule): the refresh just rendered this row from its
1925
+ # own snapshot — a second store read here could tear an atomic
1926
+ # refresh (or degrade a surface the refresh showed healthy).
1927
+ return
1928
+ # Commit the selection only after the detail rendered — a failed
1929
+ # stage must not leave actions targeting an invisible session.
1930
+ if self._show_session(session_id):
1931
+ self._selected_session_id = session_id
1932
+ else:
1933
+ self._restore_highlight(event.option_list, self._selected_session_id)
1934
+ elif event.option_list.id == "room-list":
1935
+ room_id = str(event.option.id)
1936
+ if room_id == self._selected_room_id:
1937
+ # Same rule: the refresh already rendered this room.
1938
+ return
1939
+ if self._rooms is None:
1940
+ self._selected_room_id = room_id
1941
+ return
1942
+ # A LOGICAL room change proposes a candidate reset — staged, and
1943
+ # committed (selection included) only after every read succeeds,
1944
+ # so a failed stage leaves selection matching the visible panes.
1945
+ try:
1946
+ staged = self._stage_room(room_id, reset_candidate=True)
1947
+ except _ROOM_POLL_ERRORS as exc:
1948
+ self._update_static("#room-status", f"DEGRADED: {exc}")
1949
+ self._restore_highlight(event.option_list, self._selected_room_id)
1950
+ return
1951
+ self._selected_room_id = room_id
1952
+ self._commit_room(staged)
1953
+ elif event.option_list.id == "candidate-list":
1954
+ candidate_id = str(event.option.id)
1955
+ if candidate_id == self._selected_candidate_id:
1956
+ # Same rule: the refresh already rendered this candidate.
1957
+ return
1958
+ if self._rooms is None:
1959
+ self._selected_candidate_id = candidate_id
1960
+ return
1961
+ # view=None/pages=1: the reset proposal for a new candidate —
1962
+ # except the snapshot views, which follow the cursor (moving
1963
+ # through a round's candidates in Compare is the point of it).
1964
+ view = (
1965
+ self._candidate_view
1966
+ if self._candidate_view in _EVIDENCE_VIEWS
1967
+ else None
1968
+ )
1969
+ try:
1970
+ staged = self._stage_candidate(candidate_id, view, 1)
1971
+ except _ROOM_POLL_ERRORS as exc:
1972
+ self._update_static("#room-status", f"DEGRADED: {exc}")
1973
+ self._restore_highlight(
1974
+ event.option_list, self._selected_candidate_id
1975
+ )
1976
+ return
1977
+ self._selected_candidate_id = candidate_id
1978
+ self._commit_candidate(staged)
1979
+
1980
+ def _restore_highlight(self, options: OptionList, option_id: str | None) -> None:
1981
+ """Move the highlight back to the previous selection after a failed
1982
+ stage; the resulting re-highlight event short-circuits on the
1983
+ same-id guard, so no second store read follows."""
1984
+ if option_id is not None:
1985
+ for index in range(options.option_count):
1986
+ if options.get_option_at_index(index).id == option_id:
1987
+ options.highlighted = index
1988
+ return
1989
+ # The previous selection was never set or its row is gone: clear the
1990
+ # highlight so the failed destination is not left looking selected
1991
+ # while the committed selection points elsewhere.
1992
+ options.highlighted = None
1993
+
1994
+ def _show_job(self, row: dict) -> None:
1995
+ """Render one job row. Reading the row and its files can raise on data
1996
+ this process does not own; rendering is ours and must not be guarded, so
1997
+ every read happens up front and the widget work below cannot fail on
1998
+ anything but a bug of ours."""
1999
+ directory = Path(row["job_dir"])
2000
+ signature = _log_signature(directory)
2001
+ status_line = (
2002
+ f"{_one_line(row.get('status'))} | exit:{_one_line(row.get('exit_code'))}"
2003
+ f" | cwd:{_one_line(row.get('cwd'))}"
2004
+ )
2005
+ unchanged = (
2006
+ self._job_log_dir == str(directory)
2007
+ and self._job_log_signature == signature
2008
+ )
2009
+ text = (
2010
+ None
2011
+ if unchanged
2012
+ else printable_text(
2013
+ _read_log_tail(directory).decode("utf-8", errors="surrogateescape")
2014
+ )
2015
+ )
2016
+ self._update_static("#job-status", status_line)
2017
+ if text is None:
2018
+ return
2019
+ log = self.query_one("#job-log", Log)
2020
+ log.clear()
2021
+ # Escaped first, so only LF is left to split on: splitlines() would
2022
+ # also break at CR, NEL or U+2028 and hide what they were.
2023
+ lines = text.split("\n")
2024
+ if len(lines) > 1 and lines[-1] == "":
2025
+ lines.pop()
2026
+ for line in lines:
2027
+ log.write_line(line)
2028
+ if self._follow_log:
2029
+ log.scroll_end(animate=False)
2030
+ self._job_log_dir = str(directory)
2031
+ self._job_log_signature = signature
2032
+
2033
+ def _refresh_artifacts(self) -> None:
2034
+ options = self.query_one("#artifact-list", OptionList)
2035
+ if self._artifacts is None:
2036
+ self._sync_options(options, [], None)
2037
+ self._update_static("#artifact-detail", "")
2038
+ self._update_static(
2039
+ "#artifact-status",
2040
+ "DEGRADED | Artifacts unavailable: "
2041
+ f"{self._artifacts_error or 'unknown error'}",
2042
+ )
2043
+ return
2044
+ if self._artifact_listing_inflight:
2045
+ # Single flight: never a second scan while a thread is running —
2046
+ # remember the request, the running listing's completion reruns
2047
+ # once (however many requests arrived meanwhile).
2048
+ self._artifact_listing_dirty = True
2049
+ return
2050
+ self._artifact_listing_generation += 1
2051
+ generation = self._artifact_listing_generation
2052
+ self._artifact_listing_inflight = True
2053
+ self._artifact_listing_dirty = False
2054
+ store = self._artifacts
2055
+
2056
+ def work() -> None:
2057
+ # Off the event loop: list_detailed stats every run (and reads
2058
+ # only changed manifests), which must not block input on a big
2059
+ # store. Anything raised degrades the pane rather than killing
2060
+ # the app; the result goes back through call_from_thread, which
2061
+ # fails once the app is gone — then there is nothing to apply to.
2062
+ try:
2063
+ outcome = (generation, store.list_detailed(), None)
2064
+ except Exception as exc: # noqa: BLE001 — degrade, never crash
2065
+ outcome = (generation, None, f"{exc}" or type(exc).__name__)
2066
+ try:
2067
+ self.call_from_thread(self._apply_artifact_listing, *outcome)
2068
+ except RuntimeError:
2069
+ pass
2070
+
2071
+ # Not exclusive: a second worker is never started while one runs, and
2072
+ # exclusive's cancel could drop a not-yet-started worker, leaving the
2073
+ # in-flight flag set forever.
2074
+ self.run_worker(work, thread=True, group="artifact-listing")
2075
+
2076
+ def _invalidate_artifact_listing(self) -> None:
2077
+ """Retire the running listing's result (it fails the generation
2078
+ check) and ask for a fresh one. The in-flight flag stays owned by the
2079
+ running thread: clearing it here would let a second scan start
2080
+ beside the first."""
2081
+ self._artifact_listing_generation += 1
2082
+ if self._artifact_listing_inflight:
2083
+ self._artifact_listing_dirty = True
2084
+
2085
+ def _apply_artifact_listing(
2086
+ self, generation: int, listing: ArtifactListing | None, error: str | None
2087
+ ) -> None:
2088
+ """Completion of one listing thread, on the event loop: the only
2089
+ place the in-flight flag is released. A current result is rendered,
2090
+ a stale one dropped; then one coalesced rerun if requests arrived."""
2091
+ self._artifact_listing_inflight = False
2092
+ if generation == self._artifact_listing_generation:
2093
+ self._render_artifact_listing(listing, error)
2094
+ if self._artifact_listing_dirty:
2095
+ self._artifact_listing_dirty = False
2096
+ if self._active() == "runs-tab" and self._runs_pane() == "artifacts-pane":
2097
+ self._refresh_artifacts()
2098
+
2099
+ def _render_artifact_listing(
2100
+ self, listing: ArtifactListing | None, error: str | None
2101
+ ) -> None:
2102
+ if listing is None:
2103
+ self._update_static(
2104
+ "#artifact-status", f"DEGRADED | Artifacts unavailable: {error}"
2105
+ )
2106
+ return
2107
+ options = self.query_one("#artifact-list", OptionList)
2108
+ artifacts, skipped = listing.artifacts, listing.skipped
2109
+ status = f"artifacts:{len(artifacts)}"
2110
+ if skipped:
2111
+ # Unreadable entries are reported, never hidden as a healthy empty
2112
+ # store (#170); they are not selectable, so the detail pane can
2113
+ # never land on one.
2114
+ status += f" | DEGRADED: {len(skipped)} unreadable"
2115
+ if not artifacts:
2116
+ self._selected_run_id = None
2117
+ self._sync_options(options, [], None)
2118
+ self._update_static("#artifact-detail", _artifact_empty_text(skipped))
2119
+ self._update_static("#artifact-status", status)
2120
+ return
2121
+ ids = [artifact.run_id for artifact in artifacts]
2122
+ if self._selected_run_id not in ids:
2123
+ self._selected_run_id = ids[0]
2124
+ self._artifact_view = "summary"
2125
+ entries = [
2126
+ (_artifact_label(artifact.run_id, artifact.manifest), artifact.run_id)
2127
+ for artifact in artifacts
2128
+ ]
2129
+ self._sync_options(options, entries, self._selected_run_id)
2130
+ self._update_static("#artifact-status", status)
2131
+ self._show_artifact(self._selected_run_id)
2132
+
2133
+ def _show_artifact(self, run_id: str) -> None:
2134
+ if self._artifacts is None:
2135
+ return
2136
+ # Rendered fresh on every tick: a handful of reads for ONE run, and
2137
+ # no render-skip state that a transient error could get stuck in
2138
+ # (#168 deliberately caches only the N-run listing).
2139
+ try:
2140
+ artifact = self._artifacts.load(run_id)
2141
+ except (ArtifactError, OSError) as exc:
2142
+ self._update_static("#artifact-detail", str(exc))
2143
+ return
2144
+ if self._artifact_view == "diff":
2145
+ text = self._artifact_diff_text(run_id, artifact.manifest)
2146
+ else:
2147
+ text = render_artifact(
2148
+ artifact.manifest, _read_artifact_result(run_id, store=self._artifacts)
2149
+ )
2150
+ self.artifact_detail_text = text
2151
+ self._update_static("#artifact-detail", text)
2152
+
2153
+ def _artifact_diff_text(self, run_id: str, manifest: dict) -> str:
2154
+ try:
2155
+ patch = self._artifacts.patch(run_id)
2156
+ except (ArtifactError, OSError) as exc:
2157
+ return f"diff unavailable: {_one_line(exc)}"
2158
+ head = patch[:_DIFF_DISPLAY_BYTES]
2159
+ lines = [
2160
+ f"Diff {_one_line(run_id[:8])} — {len(patch)} bytes "
2161
+ f"{_one_line(manifest.get('patch_sha256'))}",
2162
+ ]
2163
+ # Scan the WHOLE patch (it is already in memory): a binary hunk after
2164
+ # a large textual diff must still earn the banner.
2165
+ if _patch_is_binary(patch):
2166
+ lines.append("[contains binary patch content]")
2167
+ lines.extend(["", printable_text(head.decode("utf-8", errors="surrogateescape"))])
2168
+ if len(patch) > _DIFF_DISPLAY_BYTES:
2169
+ command = printable_command("syncmill", "diff", args=[run_id])
2170
+ lines.append(
2171
+ f"\n[... display truncated at {_DIFF_DISPLAY_BYTES} bytes — "
2172
+ f"`{command}` prints the full patch ...]"
2173
+ )
2174
+ return "\n".join(lines)
2175
+
2176
+ def _refresh_sessions(self) -> None:
2177
+ options = self.query_one("#session-list", OptionList)
2178
+ if self._session_store is None:
2179
+ self._sync_options(options, [], None)
2180
+ self._update_static(
2181
+ "#session-detail", self._collaboration_error or "Sessions unavailable"
2182
+ )
2183
+ self._update_static("#session-status", "DEGRADED")
2184
+ return
2185
+ # Stage every store read, row access, AND the selected detail render
2186
+ # before touching a widget: a failure — even a record valid enough for
2187
+ # the list but malformed for render_session — keeps the whole last
2188
+ # snapshot and only flips the status to DEGRADED (the _refresh_board
2189
+ # invariant). List and detail commit together or not at all.
2190
+ try:
2191
+ values = self._session_store.list()
2192
+ ids = [value["session_id"] for value in values]
2193
+ entries = []
2194
+ for value in values:
2195
+ live = "live" if value.get("live") else "stopped"
2196
+ stale = " STALE" if value.get("source_stale") else ""
2197
+ entries.append(
2198
+ (
2199
+ f"{value['session_id'][:8]} {value['agent_id']} "
2200
+ f"{value['state']} {live}{stale}",
2201
+ value["session_id"],
2202
+ )
2203
+ )
2204
+ selected = self._selected_session_id
2205
+ if selected not in ids:
2206
+ selected = ids[0] if ids else None
2207
+ detail = None
2208
+ if selected is not None:
2209
+ value = next(
2210
+ (item for item in values if item["session_id"] == selected),
2211
+ None,
2212
+ )
2213
+ detail = render_session(value) if value else "(session unavailable)"
2214
+ except _SESSION_POLL_ERRORS as exc:
2215
+ self._update_static("#session-status", f"DEGRADED: {exc}")
2216
+ return
2217
+ if not values:
2218
+ self._selected_session_id = None
2219
+ self._sync_options(options, [], None)
2220
+ self._update_static(
2221
+ "#session-detail", "No project-scoped sessions. Press a to open one."
2222
+ )
2223
+ self._update_static("#session-status", "sessions:0")
2224
+ return
2225
+ self._selected_session_id = selected
2226
+ self._sync_options(options, entries, selected)
2227
+ self._update_static("#session-detail", detail)
2228
+ self._update_static("#session-status", f"sessions:{len(values)}")
2229
+
2230
+ def _show_session(
2231
+ self, session_id: str, values: list[dict] | None = None
2232
+ ) -> bool:
2233
+ if self._session_store is None:
2234
+ return True
2235
+ try:
2236
+ if values is None:
2237
+ values = self._session_store.list()
2238
+ value = next(
2239
+ (item for item in values if item["session_id"] == session_id),
2240
+ None,
2241
+ )
2242
+ detail = render_session(value) if value else "(session unavailable)"
2243
+ except _SESSION_POLL_ERRORS as exc:
2244
+ # Touch nothing but the status: the last detail snapshot survives.
2245
+ self._update_static("#session-status", f"DEGRADED: {exc}")
2246
+ return False
2247
+ self._update_static("#session-detail", detail)
2248
+ return True
2249
+
2250
+ def _apply_session_open(self, fields: dict | bool | None) -> None:
2251
+ if not isinstance(fields, dict) or self._session_store is None:
2252
+ return
2253
+ try:
2254
+ value = self._session_store.create(str(fields["agent"]))
2255
+ except (IsolatedSessionError, SessionLaunchError, WorkspaceError, OSError) as exc:
2256
+ self.notify(str(exc), severity="error")
2257
+ return
2258
+ self._selected_session_id = value["session_id"]
2259
+ self._refresh_sessions()
2260
+ self.notify(f"isolated session {value['session_id'][:8]} started")
2261
+
2262
+ def _attach_selected_session(self) -> None:
2263
+ if self._session_store is None or not self._selected_session_id:
2264
+ self.notify("select a session", severity="warning")
2265
+ return
2266
+ try:
2267
+ with self.suspend():
2268
+ self._session_store.attach(self._selected_session_id)
2269
+ except (IsolatedSessionError, OSError) as exc:
2270
+ self.notify(str(exc), severity="error")
2271
+ finally:
2272
+ self._refresh_all()
2273
+
2274
+ def action_checkpoint(self) -> None:
2275
+ if self._active() == "rooms-tab":
2276
+ self._action_room_compare()
2277
+ return
2278
+ if self._active() != "sessions-tab":
2279
+ return
2280
+ if self._session_store is None or not self._selected_session_id:
2281
+ self.notify("select a session", severity="warning")
2282
+ return
2283
+ # In-flight guard: `exclusive=True` cancels only the asyncio wrapper —
2284
+ # it cannot stop a callable already running in the executor thread, so
2285
+ # a second `c` mid-checkpoint would serialize on the store lock and
2286
+ # mint a DUPLICATE artifact. One checkpoint at a time.
2287
+ if self._checkpoint_running:
2288
+ self.notify("checkpoint already running", severity="warning")
2289
+ return
2290
+ self._checkpoint_running = True
2291
+ store = self._session_store
2292
+ session_id = self._selected_session_id
2293
+
2294
+ def work() -> None:
2295
+ # checkpoint takes a BLOCKING flock, shells out for the diff, and
2296
+ # writes the artifact store — on a large worktree that freezes the
2297
+ # UI for the duration, so never run it on the event loop.
2298
+ # IsolatedSessionStore is file/flock-based (no sqlite thread
2299
+ # affinity), the same justification as the artifact discard worker.
2300
+ # The guard is cleared ONLY in the event-loop callbacks below —
2301
+ # clearing it here (worker thread) opened a window where the store
2302
+ # write was done but _after_checkpoint still queued, and a queued
2303
+ # keypress could mint the duplicate the guard exists to prevent.
2304
+ try:
2305
+ run_id = store.checkpoint(session_id)
2306
+ except (IsolatedSessionError, OSError) as exc:
2307
+ self.call_from_thread(self._checkpoint_failed, str(exc))
2308
+ return
2309
+ except BaseException as exc:
2310
+ # Unexpected — still release the guard on the app thread, then
2311
+ # let the worker surface the error.
2312
+ self.call_from_thread(self._checkpoint_failed, str(exc))
2313
+ raise
2314
+ self.call_from_thread(self._after_checkpoint, run_id)
2315
+
2316
+ self.run_worker(work, thread=True, group="session-checkpoint", exclusive=True)
2317
+
2318
+ def _checkpoint_failed(self, message: str) -> None:
2319
+ self._checkpoint_running = False
2320
+ self.notify(message, severity="error")
2321
+
2322
+ def _after_checkpoint(self, run_id: str) -> None:
2323
+ self._checkpoint_running = False
2324
+ self.notify(f"checkpoint {run_id[:8]} ready; review in Runs → Artifacts")
2325
+ self._refresh_sessions()
2326
+ # Land the review where the artifact is: Runs → Artifacts with the new
2327
+ # run selected (the sub-tab flip is deduped-tracked by the handler; the
2328
+ # main tab switch performs the refresh + focus). A listing already in
2329
+ # flight predates the checkpoint: without retiring it, its result
2330
+ # lacks the new run, resets the selection to an older one, and every
2331
+ # later refresh would faithfully preserve that wrong selection.
2332
+ self._invalidate_artifact_listing()
2333
+ self._selected_run_id = run_id
2334
+ self._artifact_view = "summary"
2335
+ self.query_one("#runs-subtabs", TabbedContent).active = "artifacts-pane"
2336
+ self.action_tab("runs-tab")
2337
+
2338
+ def _confirm_session_close(self) -> None:
2339
+ if self._session_store is None or not self._selected_session_id:
2340
+ self.notify("select a session", severity="warning")
2341
+ return
2342
+ if self._checkpoint_running:
2343
+ # close_session runs synchronously on the event loop and would
2344
+ # block on the SAME session lock the checkpoint thread holds —
2345
+ # freezing the UI for the checkpoint's whole duration.
2346
+ self.notify("checkpoint running — wait for it to finish", severity="warning")
2347
+ return
2348
+ try:
2349
+ value = self._session_store.load(self._selected_session_id)
2350
+ except IsolatedSessionError as exc:
2351
+ self.notify(str(exc), severity="error")
2352
+ return
2353
+ self.push_screen(
2354
+ CollaborationConfirmModal(
2355
+ "Close isolated session",
2356
+ f"Session: {_one_line(value['session_id'])}\n"
2357
+ f"Agent: {_one_line(value['agent_id'])}\n"
2358
+ f"Worktree: {_one_line(value['worktree_path'])}\n\n"
2359
+ "Uncheckpointed changes will be discarded with the worktree.",
2360
+ "Close",
2361
+ ),
2362
+ self._apply_session_close,
2363
+ )
2364
+
2365
+ def _apply_session_close(self, confirmed: dict | bool | None) -> None:
2366
+ if not confirmed or self._session_store is None or not self._selected_session_id:
2367
+ return
2368
+ if self._checkpoint_running:
2369
+ # A checkpoint may have started while the confirm modal was open.
2370
+ self.notify("checkpoint running — wait for it to finish", severity="warning")
2371
+ return
2372
+ try:
2373
+ self._session_store.close_session(self._selected_session_id)
2374
+ except (IsolatedSessionError, OSError) as exc:
2375
+ self.notify(str(exc), severity="error")
2376
+ return
2377
+ self._refresh_sessions()
2378
+
2379
+ def _refresh_rooms(self) -> None:
2380
+ room_options = self.query_one("#room-list", OptionList)
2381
+ candidate_options = self.query_one("#candidate-list", OptionList)
2382
+ if self._rooms is None:
2383
+ message = self._collaboration_error or (
2384
+ "Rooms are disabled. Enable [rooms] enabled=true and press r."
2385
+ )
2386
+ self._sync_options(room_options, [], None)
2387
+ self._sync_options(candidate_options, [], None)
2388
+ self.room_detail_text = message
2389
+ self._update_static("#room-detail", message)
2390
+ self._candidate_detail_key = None # pane replaced outside commit
2391
+ self._update_static("#candidate-detail", "")
2392
+ self._update_static("#room-status", "LIMITED MODE")
2393
+ return
2394
+ # Stage every store read, row access, selection resolution, and render
2395
+ # — including the selected room's detail and its selected candidate's
2396
+ # detail — in LOCALS before touching any app or widget state: a late
2397
+ # failure must leave selection state matching the still-visible panes
2398
+ # (else room/candidate actions would target an invisible selection).
2399
+ try:
2400
+ rooms = self._rooms.store.list_rooms()
2401
+ ids = [room["id"] for room in rooms]
2402
+ entries = [
2403
+ (f"{room['id'][:8]} {room['state']} r{room['round_count']}", room["id"])
2404
+ for room in rooms
2405
+ ]
2406
+ selected_room = self._selected_room_id
2407
+ if rooms and selected_room not in ids:
2408
+ selected_room = ids[0]
2409
+ staged_room = self._stage_room(selected_room) if rooms else None
2410
+ except _ROOM_POLL_ERRORS as exc:
2411
+ self._update_static("#room-status", f"DEGRADED: {exc}")
2412
+ return
2413
+ if not rooms:
2414
+ self._selected_room_id = None
2415
+ self._sync_options(room_options, [], None)
2416
+ self._sync_options(candidate_options, [], None)
2417
+ self._update_static(
2418
+ "#room-detail", "No rooms. Press a to create a comparison room."
2419
+ )
2420
+ self._candidate_detail_key = None # pane replaced outside commit
2421
+ self._update_static("#candidate-detail", "")
2422
+ self._update_static("#room-status", "rooms:0")
2423
+ return
2424
+ self._selected_room_id = selected_room
2425
+ self._sync_options(room_options, entries, selected_room)
2426
+ self._commit_room(staged_room)
2427
+ self._update_static("#room-status", f"rooms:{len(rooms)}")
2428
+
2429
+ def _stage_room(self, room_id: str, *, reset_candidate: bool = False) -> tuple:
2430
+ """Stage room detail + candidate list + selected candidate detail.
2431
+
2432
+ Pure: reads the store and renders into locals, mutating NOTHING —
2433
+ raises ``_ROOM_POLL_ERRORS`` on failure so the caller can degrade
2434
+ with app state still matching the on-screen panes.
2435
+ ``reset_candidate`` stages a room CHANGE: the candidate selection and
2436
+ view are proposed fresh instead of carried from the current room.
2437
+ """
2438
+ assert self._rooms is not None
2439
+ room = self._rooms.store.get_room(room_id)
2440
+ candidates = self._rooms.store.list_candidates(room_id)
2441
+ events = self._rooms.store.events(room_id)
2442
+ detail = render_room(room, candidates, events)
2443
+ ids = [candidate["id"] for candidate in candidates]
2444
+ entries = [
2445
+ (
2446
+ f"{candidate['id'][:8]} {candidate['member_id']} "
2447
+ f"{candidate['state']} {'✓' if candidate['eligible'] else '×'}",
2448
+ candidate["id"],
2449
+ )
2450
+ for candidate in candidates
2451
+ ]
2452
+ staged_candidate = None
2453
+ selected = None if reset_candidate else self._selected_candidate_id
2454
+ if candidates:
2455
+ view = None if reset_candidate else self._candidate_view
2456
+ pages = 1 if reset_candidate else self._diff_pages
2457
+ if selected not in ids:
2458
+ # Proposed selection reset — staged in locals, committed later.
2459
+ # A snapshot view survives it (a first candidate arriving in a
2460
+ # room shown in Compare must not flip to Diff and read the
2461
+ # artifact on the event loop).
2462
+ selected = ids[0]
2463
+ if view not in _EVIDENCE_VIEWS:
2464
+ view = None
2465
+ pages = 1
2466
+ staged_candidate = self._stage_candidate(
2467
+ selected, view, pages, room_id=room_id
2468
+ )
2469
+ elif not reset_candidate and self._candidate_view == "compare":
2470
+ # Compare needs no candidate: a round that retained none still has
2471
+ # members and evidence to show.
2472
+ selected = None
2473
+ staged_candidate = self._stage_candidate(
2474
+ None, "compare", 1, room_id=room_id
2475
+ )
2476
+ return detail, entries, selected, staged_candidate, reset_candidate
2477
+
2478
+ def _commit_room(self, staged: tuple) -> None:
2479
+ detail, entries, selected, staged_candidate, reset_candidate = staged
2480
+ self.room_detail_text = detail
2481
+ self._update_static("#room-detail", detail)
2482
+ options = self.query_one("#candidate-list", OptionList)
2483
+ if staged_candidate is None: # no candidates
2484
+ self._selected_candidate_id = None
2485
+ self._candidate_detail_key = None # pane replaced outside commit
2486
+ if reset_candidate:
2487
+ # A committed room CHANGE lands on an empty candidate list:
2488
+ # apply the staged reset so no view/paging state leaks from
2489
+ # the previous room (the old reset-before-show semantics).
2490
+ self._reset_candidate_view()
2491
+ self._sync_options(options, [], None)
2492
+ self._update_static("#candidate-detail", "(no candidates yet)")
2493
+ return
2494
+ self._selected_candidate_id = selected
2495
+ self._sync_options(options, entries, selected)
2496
+ self._commit_candidate(staged_candidate)
2497
+
2498
+ def _show_room(self, room_id: str) -> bool:
2499
+ if self._rooms is None:
2500
+ return True
2501
+ # Stage everything before committing anything: a late candidate-read
2502
+ # failure keeps BOTH panes (and all selection state) and only flips
2503
+ # the status to DEGRADED.
2504
+ try:
2505
+ staged = self._stage_room(room_id)
2506
+ except _ROOM_POLL_ERRORS as exc:
2507
+ self._update_static("#room-status", f"DEGRADED: {exc}")
2508
+ return False
2509
+ self._commit_room(staged)
2510
+ return True
2511
+
2512
+ def _reset_candidate_view(self) -> None:
2513
+ self._candidate_view = None
2514
+ self._diff_pages = 1
2515
+ self._diff_truncated = False
2516
+
2517
+ def _stage_candidate(
2518
+ self,
2519
+ candidate_id: str | None,
2520
+ view: str | None,
2521
+ pages: int,
2522
+ *,
2523
+ room_id: str | None = None,
2524
+ ) -> tuple:
2525
+ """Read + render the candidate detail WITHOUT mutating anything.
2526
+
2527
+ ``view``/``pages`` are the PROPOSED view state (``view=None`` derives
2528
+ the default from the candidate's state — the reset case). Returns
2529
+ ``(key, text, view, pages, truncated)`` for ``_commit_candidate``;
2530
+ ``text is None`` means the cached key is current and there is nothing
2531
+ to re-render. Raises ``_ROOM_POLL_ERRORS`` on a store/render failure
2532
+ so the caller can degrade atomically.
2533
+ """
2534
+ assert self._rooms is not None
2535
+ if view in _EVIDENCE_VIEWS:
2536
+ # The PROPOSED room (a refresh may be staging a replacement for a
2537
+ # room that was pruned), never an ambient read of the selection.
2538
+ return self._stage_evidence_view(
2539
+ room_id if room_id is not None else self._selected_room_id,
2540
+ candidate_id,
2541
+ view,
2542
+ pages,
2543
+ )
2544
+ assert candidate_id is not None
2545
+ reset = view is None
2546
+ row = self._rooms.store.get_candidate(candidate_id)
2547
+ if view is None:
2548
+ view = "diff" if row.get("state") == "completed" else "output"
2549
+ # Candidates are immutable (digest-bound), so this key makes a
2550
+ # poll tick a no-op instead of re-streaming the patch.
2551
+ key = (
2552
+ candidate_id,
2553
+ view,
2554
+ pages,
2555
+ row.get("patch_sha256"),
2556
+ row.get("output_sha256"),
2557
+ row.get("state"),
2558
+ )
2559
+ truncated = False if reset else self._diff_truncated
2560
+ if key == self._candidate_detail_key:
2561
+ return key, None, view, pages, truncated
2562
+ if view == "diff":
2563
+ manifest = self._rooms.store.load_candidate_patch_head(
2564
+ candidate_id, pages * _DIFF_DISPLAY_BYTES
2565
+ )
2566
+ truncated = bool(manifest["patch_truncated"])
2567
+ text = render_candidate_diff(
2568
+ manifest,
2569
+ manifest["patch_head"],
2570
+ truncated=truncated,
2571
+ binary=manifest.get("patch_binary"),
2572
+ )
2573
+ else:
2574
+ manifest = self._rooms.store.load_candidate_checkpoint(
2575
+ candidate_id, _CANDIDATE_OUTPUT_BYTES
2576
+ )
2577
+ text = render_candidate_output(manifest)
2578
+ return key, text, view, pages, truncated
2579
+
2580
+ # ------------------------------------------------ rooms evidence snapshot
2581
+
2582
+ @property
2583
+ def _selected_room_id(self) -> str | None:
2584
+ return self.__dict__.get("_selected_room_value")
2585
+
2586
+ @_selected_room_id.setter
2587
+ def _selected_room_id(self, value: str | None) -> None:
2588
+ # Every site that changes the selected room goes through here, so a
2589
+ # room change always discards the snapshot: its rows, any error, and a
2590
+ # queued request. A build still running finishes (single flight) but
2591
+ # its generation is retired, so its result is never shown.
2592
+ if value != self.__dict__.get("_selected_room_value") and hasattr(
2593
+ self, "_evidence_generation"
2594
+ ):
2595
+ self._evidence = None
2596
+ self._evidence_error = None
2597
+ self._evidence_pending = None
2598
+ self._evidence_generation += 1
2599
+ self.__dict__["_selected_room_value"] = value
2600
+
2601
+ def _room_evidence(self, room_id: str | None = None) -> dict | None:
2602
+ """The snapshot for ``room_id`` (default: the selected room), if the
2603
+ one slot holds it."""
2604
+ room_id = self._selected_room_id if room_id is None else room_id
2605
+ evidence = self._evidence
2606
+ if evidence is None or evidence["room_id"] != room_id:
2607
+ return None
2608
+ return evidence
2609
+
2610
+ def _reconcile_evidence_store(self) -> None:
2611
+ """Tab activation and `r` rebind the stores, so a rebind alone must not
2612
+ discard the snapshot. It is dropped — and any build in flight retired —
2613
+ only when the store root changed or the store is gone."""
2614
+ root = str(self._rooms.store.root) if self._rooms is not None else None
2615
+ if root is not None and root == self._evidence_store_root:
2616
+ return
2617
+ self._evidence_store_root = root
2618
+ self._evidence = None
2619
+ self._evidence_error = None
2620
+ self._evidence_generation += 1
2621
+
2622
+ def _evidence_target(self) -> tuple[str, str] | None:
2623
+ if self._rooms is None or not self._selected_room_id:
2624
+ return None
2625
+ return self._selected_room_id, str(self._rooms.store.root)
2626
+
2627
+ def _request_evidence(self) -> None:
2628
+ """Ask for a snapshot of the selected room. Single flight: a request
2629
+ made during a build queues (the latest wins) and starts when that
2630
+ build reports back — if it still applies to the selected room."""
2631
+ target = self._evidence_target()
2632
+ if target is None:
2633
+ return
2634
+ if self._evidence_inflight is not None:
2635
+ self._evidence_pending = target
2636
+ else:
2637
+ self._start_evidence(target)
2638
+ if self._candidate_view in _EVIDENCE_VIEWS:
2639
+ # The "building" state is part of the render: show it now.
2640
+ self._show_candidate_as(
2641
+ self._selected_candidate_id, self._candidate_view, self._diff_pages
2642
+ )
2643
+
2644
+ def _start_evidence(self, target: tuple[str, str]) -> None:
2645
+ assert self._rooms is not None
2646
+ room_id, root = target
2647
+ generation = self._evidence_generation
2648
+ self._evidence_inflight = (room_id, root, generation)
2649
+ store_root = self._rooms.store.root
2650
+ output_max = self._rooms.store.candidates.output_max_bytes
2651
+ repo_root = Path(self._rooms.repo_root)
2652
+
2653
+ def work() -> None:
2654
+ built_at = time.time()
2655
+ try:
2656
+ result = build_room_evidence(
2657
+ store_root, output_max, repo_root, room_id, built_at
2658
+ )
2659
+ outcome = (generation, room_id, root, result, None, built_at)
2660
+ except Exception as exc: # degrade, never crash
2661
+ outcome = (
2662
+ generation, room_id, root, None,
2663
+ str(exc) or type(exc).__name__, built_at,
2664
+ )
2665
+ try:
2666
+ self.call_from_thread(self._apply_evidence, *outcome)
2667
+ except RuntimeError:
2668
+ pass # the app is gone
2669
+
2670
+ self.run_worker(work, thread=True, group="room-evidence")
2671
+
2672
+ def _apply_evidence(
2673
+ self,
2674
+ generation: int,
2675
+ room_id: str,
2676
+ root: str,
2677
+ result: tuple[RoomComparison, int, frozenset] | None,
2678
+ error: str | None,
2679
+ built_at: float,
2680
+ ) -> None:
2681
+ # The in-flight slot is released only here, on the event loop.
2682
+ self._evidence_inflight = None
2683
+ pending, self._evidence_pending = self._evidence_pending, None
2684
+ current = self._evidence_target()
2685
+ if pending is not None and pending == current:
2686
+ # A newer request for the room on screen: never flash this result;
2687
+ # build again. A queued request for a room since left is dropped.
2688
+ self._start_evidence(pending)
2689
+ elif (
2690
+ generation == self._evidence_generation
2691
+ and current is not None
2692
+ and (room_id, root) == current
2693
+ ):
2694
+ if result is not None:
2695
+ comparison, revision, candidate_ids = result
2696
+ self._evidence = {
2697
+ "root": root,
2698
+ "room_id": room_id,
2699
+ "comparison": comparison,
2700
+ "revision": revision,
2701
+ "candidate_ids": candidate_ids,
2702
+ "as_of": time.strftime("%H:%M:%S", time.localtime(built_at)),
2703
+ }
2704
+ self._evidence_error = None
2705
+ else:
2706
+ # Keep the last good snapshot of this room; say the rebuild failed.
2707
+ self._evidence_error = (room_id, error or "unknown error")
2708
+ self._evidence_build_id += 1
2709
+ # Otherwise the result belongs to a room or store no longer shown and
2710
+ # is dropped. Either way the "building" state may have changed.
2711
+ if self._candidate_view in _EVIDENCE_VIEWS:
2712
+ self._show_candidate_as(
2713
+ self._selected_candidate_id, self._candidate_view, self._diff_pages
2714
+ )
2715
+
2716
+ def _stage_evidence_view(
2717
+ self, room_id: str | None, candidate_id: str | None, view: str, pages: int
2718
+ ) -> tuple:
2719
+ """Compare/Details text from the snapshot: SQL reads only (the room
2720
+ row and candidate ids for the change hint, the round prompt), never an
2721
+ artifact read."""
2722
+ assert self._rooms is not None
2723
+ evidence = self._room_evidence(room_id)
2724
+ error = (
2725
+ self._evidence_error[1]
2726
+ if self._evidence_error and self._evidence_error[0] == room_id
2727
+ else None
2728
+ )
2729
+ building = (
2730
+ self._evidence_inflight is not None
2731
+ and self._evidence_inflight[0] == room_id
2732
+ ) or (
2733
+ self._evidence_pending is not None
2734
+ and self._evidence_pending[0] == room_id
2735
+ )
2736
+ target_round = None
2737
+ stored_rounds = 0
2738
+ if view == "compare" and candidate_id is None and room_id:
2739
+ listed = self._rooms.store.list_rounds(room_id)
2740
+ stored_rounds = len(listed)
2741
+ numbers = [
2742
+ r["number"]
2743
+ for r in listed
2744
+ if type(r.get("number")) is int and r["number"] > 0
2745
+ ]
2746
+ target_round = max(numbers) if numbers else None
2747
+ changed = False
2748
+ if evidence is not None and room_id:
2749
+ room = self._rooms.store.get_room(room_id)
2750
+ current_ids = frozenset(
2751
+ c["id"] for c in self._rooms.store.list_candidates(room_id)
2752
+ )
2753
+ changed = (
2754
+ int(room["revision"]) != evidence["revision"]
2755
+ or current_ids != evidence["candidate_ids"]
2756
+ )
2757
+ key = (
2758
+ "evidence",
2759
+ room_id,
2760
+ candidate_id,
2761
+ view,
2762
+ self._evidence_build_id,
2763
+ error,
2764
+ changed,
2765
+ building,
2766
+ target_round,
2767
+ stored_rounds,
2768
+ )
2769
+ if key == self._candidate_detail_key:
2770
+ return key, None, view, pages, False
2771
+ lines: list[str] = []
2772
+ if error is not None:
2773
+ lines.append(f"evidence unavailable: {printable_name(error)}")
2774
+ if building:
2775
+ lines.append("building evidence snapshot...")
2776
+ if evidence is None:
2777
+ if not building and error is None:
2778
+ lines.append("no evidence snapshot for this room — press c or r")
2779
+ return key, "\n".join(lines), view, pages, False
2780
+ if changed:
2781
+ lines.append("room changed since this snapshot — press r")
2782
+ comparison: RoomComparison = evidence["comparison"]
2783
+ as_of = evidence["as_of"]
2784
+ found = find_row(comparison, candidate_id) if candidate_id else None
2785
+ if candidate_id and found is None:
2786
+ # A candidate missing from the snapshot may sit in a damaged round.
2787
+ lines.extend(
2788
+ f"Damaged round {printable_name(entry['round_id'])}: "
2789
+ f"{printable_name(entry['reason'])}"
2790
+ for entry in comparison.damaged_rounds
2791
+ )
2792
+ lines.append(
2793
+ f"this candidate is not in the evidence snapshot (as of {as_of})"
2794
+ " — press r"
2795
+ )
2796
+ return key, "\n".join(lines), view, pages, False
2797
+ if view == "details" and candidate_id is None:
2798
+ # The selection can vanish under an open Details (a rebind to a
2799
+ # room without candidates): say so instead of rendering a row.
2800
+ lines.append("no candidate selected — Details needs a candidate")
2801
+ return key, "\n".join(lines), view, pages, False
2802
+ # Damage is evidence: the early returns below list it themselves
2803
+ # (render_round_table does on the normal path).
2804
+ damage = [
2805
+ f"Damaged round {printable_name(entry['round_id'])}: "
2806
+ f"{printable_name(entry['reason'])}"
2807
+ for entry in comparison.damaged_rounds
2808
+ ]
2809
+ if view == "compare":
2810
+ if found is not None:
2811
+ round_number = found[0].number
2812
+ elif target_round is None:
2813
+ lines.extend(damage)
2814
+ lines.append(
2815
+ (
2816
+ "no readable rounds in this room"
2817
+ if stored_rounds
2818
+ else "no rounds have run in this room yet"
2819
+ )
2820
+ + f" (evidence as of {as_of})"
2821
+ )
2822
+ return key, "\n".join(lines), view, pages, False
2823
+ elif find_round(comparison, target_round) is None:
2824
+ # The latest round per the store is not in this snapshot (it
2825
+ # arrived later, or its record is damaged): say so, never show
2826
+ # an older round as if it were the latest.
2827
+ lines.extend(damage)
2828
+ lines.append(
2829
+ f"round {target_round} is not in the evidence snapshot "
2830
+ f"(as of {as_of}) — press r"
2831
+ )
2832
+ return key, "\n".join(lines), view, pages, False
2833
+ else:
2834
+ round_number = target_round
2835
+ lines.append(
2836
+ render_round_table(
2837
+ comparison,
2838
+ round_number,
2839
+ highlighted_id=candidate_id,
2840
+ as_of=as_of,
2841
+ )
2842
+ )
2843
+ else:
2844
+ assert found is not None
2845
+ group, row = found
2846
+ round_prompt = None
2847
+ try:
2848
+ round_prompt = self._rooms.store.get_round(group.round_id).get("prompt")
2849
+ except (RoomError, KeyError, TypeError):
2850
+ pass
2851
+ lines.append(
2852
+ render_candidate_details(
2853
+ row,
2854
+ round_number=group.number,
2855
+ round_prompt=round_prompt,
2856
+ as_of=as_of,
2857
+ selection=comparison.selection,
2858
+ group=group,
2859
+ )
2860
+ )
2861
+ return key, "\n".join(lines), view, pages, False
2862
+
2863
+ def _commit_candidate(self, staged: tuple | None) -> None:
2864
+ if staged is None:
2865
+ return
2866
+ key, text, view, pages, truncated = staged
2867
+ self._candidate_view = view
2868
+ self._diff_pages = pages
2869
+ self._diff_truncated = truncated
2870
+ if text is None:
2871
+ return # cached key is current — nothing to re-render
2872
+ self._candidate_detail_key = key
2873
+ self.candidate_detail_text = text
2874
+ self._update_static("#candidate-detail", text)
2875
+
2876
+ def _show_candidate(self, candidate_id: str) -> bool:
2877
+ return self._show_candidate_as(
2878
+ candidate_id, self._candidate_view, self._diff_pages
2879
+ )
2880
+
2881
+ def _show_candidate_as(
2882
+ self, candidate_id: str | None, view: str | None, pages: int
2883
+ ) -> bool:
2884
+ """Stage-then-commit the candidate detail under a PROPOSED view state.
2885
+
2886
+ On failure nothing is committed — not the widgets, not the view/pages
2887
+ proposal — so the old pane never sits over advanced view state.
2888
+ """
2889
+ if self._rooms is None:
2890
+ return True
2891
+ try:
2892
+ staged = self._stage_candidate(candidate_id, view, pages)
2893
+ except _ROOM_POLL_ERRORS as exc:
2894
+ # Touch nothing but the status: the last detail snapshot (and the
2895
+ # cache key describing it) stays accurate for what is on screen.
2896
+ self._update_static("#room-status", f"DEGRADED: {exc}")
2897
+ return False
2898
+ self._commit_candidate(staged)
2899
+ return True
2900
+
2901
+ def _apply_room_create(self, fields: dict | bool | None) -> None:
2902
+ if not isinstance(fields, dict) or self._rooms is None:
2903
+ return
2904
+ agents = [part.strip() for part in str(fields["agents"]).split(",") if part.strip()]
2905
+ try:
2906
+ room = self._rooms.create(str(fields["prompt"]), agents)
2907
+ except RoomError as exc:
2908
+ self.notify(str(exc), severity="error")
2909
+ return
2910
+ self._selected_room_id = room["id"]
2911
+ self._refresh_rooms()
2912
+
2913
+ def _confirm_room_round(self) -> None:
2914
+ if self._rooms is None or not self._selected_room_id:
2915
+ self.notify("select a room", severity="warning")
2916
+ return
2917
+ try:
2918
+ room = self._rooms.store.get_room(self._selected_room_id)
2919
+ # Prefill with the PREVIOUS round's prompt (creation prompt on a
2920
+ # fresh room) — the modal is where per-round instructions land now.
2921
+ rounds = self._rooms.store.list_rounds(self._selected_room_id)
2922
+ default_prompt = rounds[-1]["prompt"] if rounds else room["prompt"]
2923
+ eligible = [
2924
+ row
2925
+ for row in self._rooms.store.list_candidates(self._selected_room_id)
2926
+ if row.get("eligible")
2927
+ ]
2928
+ except _ROOM_POLL_ERRORS as exc:
2929
+ self.notify(str(exc), severity="error")
2930
+ return
2931
+ self.push_screen(
2932
+ RoomRoundModal(room, default_prompt, eligible),
2933
+ self._apply_room_round,
2934
+ )
2935
+
2936
+ def _apply_room_round(self, fields: dict | bool | None) -> None:
2937
+ if not isinstance(fields, dict) or self._rooms is None or not self._selected_room_id:
2938
+ return
2939
+ # Preserve the prompt VERBATIM (the raw-user-text contract the stored
2940
+ # rounds.prompt keeps, and what --prompt-file does): strip only to
2941
+ # decide whether an all-whitespace entry means "no prompt".
2942
+ raw_prompt = str(fields.get("prompt") or "")
2943
+ prompt = raw_prompt if raw_prompt.strip() else None
2944
+ lineage = str(fields.get("lineage") or "continue-own")
2945
+ base_candidate = fields.get("base_candidate")
2946
+ if prompt is None:
2947
+ self.notify(
2948
+ "no round prompt — reusing the room creation prompt",
2949
+ severity="warning",
2950
+ )
2951
+ try:
2952
+ self._rooms.preflight_round(
2953
+ self._selected_room_id,
2954
+ prompt,
2955
+ lineage=lineage,
2956
+ base_candidate_id=base_candidate,
2957
+ )
2958
+ pid = launch_room_worker(
2959
+ self._project_root(),
2960
+ self._selected_room_id,
2961
+ prompt,
2962
+ lineage=lineage,
2963
+ base_candidate_id=base_candidate,
2964
+ resolved_config=self._rooms.config,
2965
+ config_base=self._toml_path.parent,
2966
+ )
2967
+ self._rooms.store.record_event(
2968
+ self._selected_room_id, "worker.launched", {"pid": pid}
2969
+ )
2970
+ # ProjectError covers a repository that disappeared under us: git_root
2971
+ # reports an unusable path that way rather than letting subprocess's
2972
+ # OSError through, and an unguarded raise here kills the whole app.
2973
+ except (RoomError, ArtifactError, WorkspaceError, ProjectError, OSError) as exc:
2974
+ self.notify(str(exc), severity="error")
2975
+ return
2976
+ self.notify(f"room worker started (pid {pid})")
2977
+ self._refresh_rooms()
2978
+
2979
+ def _confirm_candidate_selection(self) -> None:
2980
+ if (
2981
+ self._rooms is None
2982
+ or not self._selected_room_id
2983
+ or not self._selected_candidate_id
2984
+ ):
2985
+ self.notify("select a candidate", severity="warning")
2986
+ return
2987
+ if self._select_inflight is not None:
2988
+ self.notify("a selection is still being verified", severity="warning")
2989
+ return
2990
+ try:
2991
+ room = self._rooms.store.get_room(self._selected_room_id)
2992
+ row = self._rooms.store.get_candidate(self._selected_candidate_id)
2993
+ # Metadata only: the full artifact is verified by the select
2994
+ # itself, off the event loop.
2995
+ manifest = self._rooms.store.load_candidate_manifest(row["id"])
2996
+ except RoomError as exc:
2997
+ self.notify(str(exc), severity="error")
2998
+ return
2999
+ p = printable_name
3000
+ self.push_screen(
3001
+ CandidateSelectModal(
3002
+ f"Candidate: {p(str(row['id']))}\n"
3003
+ f"Member: {p(str(manifest.get('member_id')))}\n"
3004
+ f"Patch sha256: {p(str(row['patch_sha256']))}\n"
3005
+ f"Eligible: {bool(row['eligible'])}\n"
3006
+ f"Room revision: {room['revision']}\n\n"
3007
+ "The artifact is verified against this digest before it is "
3008
+ "selected; apply verifies it again."
3009
+ ),
3010
+ lambda confirmed, room_id=room["id"], candidate_id=row["id"], revision=int(
3011
+ room["revision"]
3012
+ ), digest=row["patch_sha256"]: self._apply_candidate_selection(
3013
+ confirmed, room_id, candidate_id, revision, digest
3014
+ ),
3015
+ )
3016
+
3017
+ def _apply_candidate_selection(
3018
+ self,
3019
+ confirmed: dict | bool | None,
3020
+ room_id: str,
3021
+ candidate_id: str,
3022
+ expected_revision: int,
3023
+ expected_patch_sha256: str,
3024
+ ) -> None:
3025
+ if not confirmed or self._rooms is None:
3026
+ return
3027
+ if self._select_inflight is not None:
3028
+ self.notify("a selection is still being verified", severity="warning")
3029
+ return
3030
+ rationale = confirmed.get("rationale") if isinstance(confirmed, dict) else None
3031
+ store_root = Path(self._rooms.store.root)
3032
+ output_max = self._rooms.store.candidates.output_max_bytes
3033
+ root = str(store_root)
3034
+ self._select_inflight = (room_id, candidate_id, root)
3035
+
3036
+ def work() -> None:
3037
+ try:
3038
+ select_room_candidate(
3039
+ store_root,
3040
+ output_max,
3041
+ room_id,
3042
+ candidate_id,
3043
+ expected_revision=expected_revision,
3044
+ expected_patch_sha256=expected_patch_sha256,
3045
+ rationale=rationale,
3046
+ )
3047
+ error = None
3048
+ except Exception as exc: # report, never crash the app
3049
+ error = str(exc) or type(exc).__name__
3050
+ try:
3051
+ self.call_from_thread(
3052
+ self._after_candidate_selection, candidate_id, root, error
3053
+ )
3054
+ except RuntimeError:
3055
+ pass # the app is gone
3056
+
3057
+ self.run_worker(work, thread=True, group="room-select")
3058
+
3059
+ def _after_candidate_selection(
3060
+ self, candidate_id: str, root: str, error: str | None
3061
+ ) -> None:
3062
+ # Released only here, on the event loop (the evidence-worker rule).
3063
+ self._select_inflight = None
3064
+ if error is not None:
3065
+ self.notify(printable_name(error), severity="error")
3066
+ return
3067
+ self.notify(f"selected {printable_name(candidate_id[:8])}")
3068
+ if self._rooms is None or str(self._rooms.store.root) != root:
3069
+ return # the store shown now is not the one the selection went to
3070
+ self._refresh_rooms()
3071
+
3072
+ def _confirm_room_apply(self) -> None:
3073
+ if self._rooms is None or not self._selected_room_id:
3074
+ self.notify("select a room", severity="warning")
3075
+ return
3076
+ if self._jobs is None:
3077
+ self.notify(
3078
+ self._jobs_error or "TUI job registry is unavailable", severity="error"
3079
+ )
3080
+ return
3081
+ try:
3082
+ room = self._rooms.store.get_room(self._selected_room_id)
3083
+ candidate_id = room.get("selected_candidate_id")
3084
+ if not candidate_id:
3085
+ raise RoomError("select an eligible candidate first")
3086
+ row = self._rooms.store.get_candidate(candidate_id)
3087
+ manifest = self._rooms.store.load_candidate(row["id"])
3088
+ except RoomError as exc:
3089
+ self.notify(str(exc), severity="error")
3090
+ return
3091
+ # Same guard as the artifact-apply confirmation: resolving the target can
3092
+ # raise ProjectError, which would escape this handler and kill the app.
3093
+ try:
3094
+ target = self._project_root()
3095
+ except ProjectError as exc:
3096
+ self.notify(str(exc), severity="error")
3097
+ return
3098
+ self.push_screen(
3099
+ CollaborationConfirmModal(
3100
+ "Apply selected candidate to source",
3101
+ f"Room: {_one_line(room['id'])}\nCandidate: {_one_line(candidate_id)}\n"
3102
+ f"Patch digest: {_one_line(manifest['patch_sha256'])}\n"
3103
+ f"Target: {_one_line(target)}\n\n"
3104
+ "Source fingerprint and patch applicability will be checked again.\n"
3105
+ "The detached worker will continue if the TUI closes.",
3106
+ "Apply",
3107
+ ),
3108
+ lambda confirmed, room_id=room["id"], candidate_id=candidate_id, revision=int(
3109
+ room["revision"]
3110
+ ): self._apply_room_apply(confirmed, room_id, candidate_id, revision),
3111
+ )
3112
+
3113
+ def _apply_room_apply(
3114
+ self,
3115
+ confirmed: dict | bool | None,
3116
+ room_id: str,
3117
+ candidate_id: str,
3118
+ expected_revision: int,
3119
+ ) -> None:
3120
+ if not confirmed or self._rooms is None:
3121
+ return
3122
+ try:
3123
+ if self._jobs is None:
3124
+ raise JobError(self._jobs_error or "TUI job registry is unavailable")
3125
+ # Orchestration never runs on Textual's event loop (the R-recover
3126
+ # invariant): the apply runs in a durable detached JobStore worker,
3127
+ # fenced to the candidate/revision captured at confirmation time and
3128
+ # pinned to the coordinator's resolved config (the room-round freeze).
3129
+ spec = RoomApplySpec(
3130
+ room_id=room_id,
3131
+ verify=bool(self._config.gate.commands),
3132
+ expected_candidate_id=candidate_id,
3133
+ expected_revision=expected_revision,
3134
+ )
3135
+ row = self._jobs.launch(spec, resolved_config=self._rooms.config)
3136
+ except (JobError, ValueError) as exc:
3137
+ self.notify(str(exc), severity="error")
3138
+ return
3139
+ self.notify(f"detached room-apply job {row['job_id'][:8]}")
3140
+ self.query_one("#runs-subtabs", TabbedContent).active = "operations-pane"
3141
+ self.action_tab("runs-tab")
3142
+
3143
+ def _selected_job(self) -> dict | None:
3144
+ """The Runs-tab highlighted row, resolved through list_jobs."""
3145
+ if self._jobs is None or self._selected_job_dir is None:
3146
+ return None
3147
+ for row in self._jobs.list_jobs():
3148
+ if row.get("job_dir") == self._selected_job_dir:
3149
+ return row
3150
+ return None
3151
+
3152
+ def _selected_job_id(self) -> str | None:
3153
+ """The selected job id, kept as a compatibility seam for tests."""
3154
+ row = self._selected_job()
3155
+ return str(row["job_id"]) if row and row.get("job_id") else None
3156
+
3157
+ def _control_job(self, job_id: str, action: str) -> None:
3158
+ try:
3159
+ if self._jobs is None:
3160
+ raise JobError(self._jobs_error or "TUI job registry is unavailable")
3161
+ self._jobs.control(job_id, action)
3162
+ except JobError as exc:
3163
+ self.notify(str(exc), severity="error")
3164
+
3165
+ def _control_selected_job(self, action: str) -> None:
3166
+ job_id = self._selected_job_id()
3167
+ if job_id is None:
3168
+ self.notify("select a job in the Runs tab first", severity="error")
3169
+ return
3170
+ self._control_job(job_id, action)
3171
+
3172
+ def action_safe_stop(self) -> None:
3173
+ if self._active() == "sessions-tab":
3174
+ self._confirm_session_close()
3175
+ return
3176
+ if self._active() == "rooms-tab":
3177
+ self._confirm_room_close()
3178
+ return
3179
+ if self._active() != "runs-tab":
3180
+ return
3181
+ if self._runs_pane() == "artifacts-pane":
3182
+ self._confirm_artifact_discard()
3183
+ return
3184
+ self._control_selected_job("stop")
3185
+
3186
+ def _confirm_room_close(self) -> None:
3187
+ if self._rooms is None or not self._selected_room_id:
3188
+ self.notify("select a room", severity="warning")
3189
+ return
3190
+ room_id = self._selected_room_id
3191
+ self.push_screen(
3192
+ CollaborationConfirmModal(
3193
+ "Close comparison room",
3194
+ f"Room: {_one_line(room_id)}\n\n"
3195
+ "Candidates and receipts are preserved; the room's isolated "
3196
+ "workspaces and read-only viewers are removed.",
3197
+ "Close",
3198
+ ),
3199
+ lambda confirmed, room_id=room_id: self._apply_room_close(
3200
+ confirmed, room_id
3201
+ ),
3202
+ )
3203
+
3204
+ def _apply_room_close(self, confirmed: dict | bool | None, room_id: str) -> None:
3205
+ if not confirmed or self._rooms is None:
3206
+ return
3207
+ try:
3208
+ # The coordinator-level close (the CLI `room close` path): durable
3209
+ # claim, workspace cleanup, and viewer reclaim in-process — SQLite
3210
+ # + filesystem only, no orchestration.
3211
+ self._rooms.close_room(room_id)
3212
+ except RoomError as exc:
3213
+ self.notify(str(exc), severity="error")
3214
+ return
3215
+ self.notify(f"closed room {room_id[:8]}")
3216
+ self._refresh_rooms()
3217
+
3218
+ def action_force_stop(self) -> None:
3219
+ # Operations-pane only: from Artifacts this would force-stop the
3220
+ # persisted, currently HIDDEN Operations selection.
3221
+ if self._active() != "runs-tab" or self._runs_pane() != "operations-pane":
3222
+ return
3223
+ row = self._selected_job()
3224
+ if row is None or not row.get("job_id"):
3225
+ self.notify("select a job in the Runs tab first", severity="error")
3226
+ return
3227
+ job_id = str(row["job_id"])
3228
+ op = str(row.get("op") or "unknown")
3229
+ # Schema-2 rows keep operation fields nested under "request" (the
3230
+ # _job_label contract); legacy rows may carry them at the top level.
3231
+ request = row.get("request") or {}
3232
+ target = "-"
3233
+ for key in ("item_id", "goal", "workflow_id", "run_id", "room_id"):
3234
+ value = request.get(key) or row.get(key)
3235
+ if value:
3236
+ target = str(value)
3237
+ break
3238
+ self.push_screen(
3239
+ CollaborationConfirmModal(
3240
+ "Force-stop exact job",
3241
+ f"Job: {_one_line(job_id)}\nOperation: {_one_line(op)}\n"
3242
+ f"Target: {_one_line(target)}\n\n"
3243
+ "SIGKILL is accepted only after the 10-second safe-stop grace "
3244
+ "period. It can leave a lease or dirty isolated worktree that "
3245
+ "requires later recovery.",
3246
+ "Force stop",
3247
+ ),
3248
+ lambda confirmed, job_id=job_id: self._apply_force_stop(
3249
+ confirmed, job_id
3250
+ ),
3251
+ )
3252
+
3253
+ def _apply_force_stop(
3254
+ self, confirmed: dict | bool | None, job_id: str
3255
+ ) -> None:
3256
+ if confirmed:
3257
+ self._control_job(job_id, "force")
3258
+
3259
+ def action_recover_runs(self) -> None:
3260
+ active = self._active()
3261
+ if active == "rooms-tab":
3262
+ if self._rooms is None:
3263
+ self.notify("Rooms are unavailable", severity="warning")
3264
+ return
3265
+ self.run_worker(
3266
+ self._recover_via_cli("room", "recover"),
3267
+ group="recover-runs",
3268
+ exclusive=True,
3269
+ )
3270
+ return
3271
+ if active != "runs-tab":
3272
+ return
3273
+ self.run_worker(
3274
+ self._recover_via_cli("recover"), group="recover-runs", exclusive=True
3275
+ )
3276
+
3277
+ async def _recover_via_cli(self, *argv: str) -> None:
3278
+ # Reconcile via a detached CLI subprocess (`syncmill recover` /
3279
+ # `syncmill room recover`), never in process: recovery takes the
3280
+ # repository apply lock, and orchestration must never run on Textual's
3281
+ # event loop. The subprocess also reuses the exact CLI
3282
+ # project-identity path, so it addresses the same runs an ``apply``
3283
+ # would. A live apply/worker owner is skipped before the lock, so
3284
+ # this can never block on a healthy detached worker.
3285
+ root = self._job_project_root()
3286
+ proc = await asyncio.create_subprocess_exec(
3287
+ sys.executable,
3288
+ "-c",
3289
+ _CLI_SHIM,
3290
+ *argv,
3291
+ "--repo",
3292
+ str(root),
3293
+ stdout=asyncio.subprocess.PIPE,
3294
+ stderr=asyncio.subprocess.STDOUT,
3295
+ start_new_session=True,
3296
+ )
3297
+ try:
3298
+ raw, _ = await proc.communicate()
3299
+ except asyncio.CancelledError:
3300
+ try:
3301
+ os.killpg(proc.pid, signal.SIGINT)
3302
+ except ProcessLookupError:
3303
+ pass
3304
+ try:
3305
+ await asyncio.wait_for(asyncio.shield(proc.wait()), 2)
3306
+ except asyncio.TimeoutError:
3307
+ try:
3308
+ os.killpg(proc.pid, signal.SIGKILL)
3309
+ except ProcessLookupError:
3310
+ pass
3311
+ await proc.wait()
3312
+ raise
3313
+ summary = raw.decode("utf-8", errors="replace").strip() or "nothing to recover"
3314
+ self.notify(
3315
+ summary,
3316
+ severity="error" if proc.returncode else "information",
3317
+ timeout=8,
3318
+ )
3319
+ # Refresh what the user is actually looking at (Artifacts manifests /
3320
+ # room states change on recovery, not just the job list).
3321
+ active = self._active()
3322
+ if active == "runs-tab":
3323
+ self._refresh_runs_pane()
3324
+ elif active == "rooms-tab":
3325
+ self._refresh_rooms()
3326
+ else:
3327
+ self._refresh_jobs()
3328
+
3329
+ def _refresh_reports(self) -> None:
3330
+ docs = list_reports(self._config.integration.report.runs_dir)
3331
+ options = self.query_one("#report-list", OptionList)
3332
+ selected = self._selected_report_id
3333
+ options.clear_options()
3334
+ if not docs:
3335
+ self.query_one("#report-detail", Static).update("(no run reports found)")
3336
+ return
3337
+ for doc in docs:
3338
+ result = doc.get("result") if isinstance(doc.get("result"), dict) else {}
3339
+ run_id = str(doc.get("run_id"))
3340
+ options.add_option(
3341
+ PlainOption(
3342
+ f"{run_id} {doc.get('strategy')} {result.get('status')}",
3343
+ id=run_id,
3344
+ )
3345
+ )
3346
+ ids = [str(doc.get("run_id")) for doc in docs]
3347
+ self._selected_report_id = selected if selected in ids else ids[0]
3348
+ options.highlighted = ids.index(self._selected_report_id)
3349
+ self._show_report(self._selected_report_id)
3350
+
3351
+ def _show_report(self, run_id: str) -> None:
3352
+ doc = next(
3353
+ (
3354
+ doc
3355
+ for doc in list_reports(self._config.integration.report.runs_dir)
3356
+ if str(doc.get("run_id")) == run_id
3357
+ ),
3358
+ None,
3359
+ )
3360
+ detail = render_report(doc) if doc else "(report unavailable)"
3361
+ self.query_one("#report-detail", Static).update(detail)
3362
+
3363
+ def _refresh_teams(self) -> None:
3364
+ options = self.query_one("#team-list", OptionList)
3365
+ selected = self._selected_team
3366
+ options.clear_options()
3367
+ names = sorted(self._config.teams)
3368
+ if not names:
3369
+ self.query_one("#team-detail", Static).update("(no teams configured; press a)")
3370
+ return
3371
+ for name in names:
3372
+ marker = " *" if name == self._config.default_team else ""
3373
+ options.add_option(PlainOption(f"{name}{marker}", id=name))
3374
+ self._selected_team = selected if selected in names else names[0]
3375
+ options.highlighted = names.index(self._selected_team)
3376
+ self._show_team(self._selected_team)
3377
+
3378
+ def _show_team(self, name: str) -> None:
3379
+ team = self._config.teams.get(name)
3380
+ detail = render_team(name, team, self._config) if team else ""
3381
+ self.query_one("#team-detail", Static).update(detail)
3382
+
3383
+ def _apply_team(self, fields: dict | None) -> None:
3384
+ if not fields:
3385
+ return
3386
+ try:
3387
+ name = fields["name"].strip()
3388
+ agents = [part.strip() for part in fields["agents"].split(",") if part.strip()]
3389
+ goals = [part.strip() for part in fields["goals"].split(",") if part.strip()]
3390
+ caps = parse_capability_specs(
3391
+ [part.strip() for part in fields["caps"].split(";") if part.strip()]
3392
+ )
3393
+ team = build_team(
3394
+ agents=agents,
3395
+ strategy=fields["strategy"],
3396
+ goals=goals,
3397
+ capabilities=caps,
3398
+ )
3399
+ # validate→save as one transaction (the `team save` CLI recipe).
3400
+ with config_write_lock(self._toml_path):
3401
+ validate_team_save(self._config, name, team, path=self._toml_path)
3402
+ save_team(self._toml_path, name, team)
3403
+ self._reload_config()
3404
+ except TeamFileError as exc:
3405
+ self.notify(str(exc), severity="error")
3406
+ return
3407
+ self._selected_team = name
3408
+ self._refresh_teams()
3409
+
3410
+ def action_toggle_default_team(self) -> None:
3411
+ if self._active() != "teams-tab" or not self._selected_team:
3412
+ return
3413
+ target = None if self._config.default_team == self._selected_team else self._selected_team
3414
+ try:
3415
+ set_default_team(self._toml_path, target)
3416
+ self._reload_config()
3417
+ except TeamFileError as exc:
3418
+ self.notify(str(exc), severity="error")
3419
+ self._refresh_teams()
3420
+
3421
+ def _reload_config(self) -> bool:
3422
+ try:
3423
+ config = load_config_at(self._toml_path)
3424
+ except (ValidationError, OSError, ValueError) as exc:
3425
+ # ValueError covers tomllib.TOMLDecodeError: a mid-edit malformed
3426
+ # toml must keep the cached view + toast, never kill the TUI.
3427
+ self.notify(f"config reload failed: {exc}", severity="error")
3428
+ return False
3429
+ self._config = config
3430
+ self._rebind_jobs()
3431
+ self._rebind_collaboration()
3432
+ return True
3433
+
3434
+ def _retry_board(self) -> None:
3435
+ try:
3436
+ config = load_config_at(self._toml_path)
3437
+ except (ValidationError, OSError, ValueError) as exc:
3438
+ self.notify(f"config reload failed: {exc}", severity="error")
3439
+ return
3440
+ if self._board is not None:
3441
+ self._board.close()
3442
+ self._config = config
3443
+ self._board = None
3444
+ if not config.board.enabled:
3445
+ self._board_error = None
3446
+ self._rebind_jobs()
3447
+ return
3448
+ try:
3449
+ self._board = build_board(config.board)
3450
+ self._board_error = None
3451
+ except BoardError as exc:
3452
+ self._board_error = str(exc)
3453
+ self._rebind_jobs()
3454
+ self._rebind_collaboration()
3455
+
3456
+ def _refresh_agents(self) -> None:
3457
+ lines = ["Agents (priority order):"]
3458
+ for index, agent_id in enumerate(self._config.agent_priority, 1):
3459
+ cfg = self._config.agent_config(agent_id)
3460
+ lines.append(
3461
+ f"{index}. {agent_id} [{'enabled' if cfg.enabled else 'disabled'}] "
3462
+ f"timeout={cfg.timeout_seconds}s max_turns={cfg.max_turns}"
3463
+ )
3464
+ # A status subprocess can finish while the screen is being removed.
3465
+ # Keep its result, but do not turn an advisory refresh into a worker
3466
+ # failure after the target widgets have gone away.
3467
+ try:
3468
+ agents = self.query_one("#agent-status", Static)
3469
+ memory = self.query_one("#memory-status", Static)
3470
+ except NoMatches:
3471
+ return
3472
+ agents.update("\n".join(lines))
3473
+ memory.update(f"\nMemory:\n{self._memory_status}")
3474
+
3475
+ def _start_memory_probe(self) -> None:
3476
+ if self._jobs is not None and self._jobs.active_job() is not None:
3477
+ self._memory_status = "probe deferred while a TUI worker is active"
3478
+ self._refresh_agents()
3479
+ return
3480
+ self._memory_status = "probing…"
3481
+ self._refresh_agents()
3482
+ self.run_worker(self._probe_memory(), group="memory-status", exclusive=True)
3483
+
3484
+ async def _probe_memory(self) -> None:
3485
+ # `syncmill status` has no --repo and discovers syncmill.toml relative
3486
+ # to its cwd — pin the child to the TUI's config location, or the probe
3487
+ # reports whatever project the ambient cwd happens to hold.
3488
+ proc = await asyncio.create_subprocess_exec(
3489
+ sys.executable,
3490
+ "-c",
3491
+ _CLI_SHIM,
3492
+ "status",
3493
+ cwd=str(self._toml_path.parent.resolve()),
3494
+ stdout=asyncio.subprocess.PIPE,
3495
+ stderr=asyncio.subprocess.STDOUT,
3496
+ start_new_session=True,
3497
+ )
3498
+ try:
3499
+ raw, _ = await proc.communicate()
3500
+ except asyncio.CancelledError:
3501
+ try:
3502
+ os.killpg(proc.pid, signal.SIGINT)
3503
+ except ProcessLookupError:
3504
+ pass
3505
+ try:
3506
+ await asyncio.wait_for(asyncio.shield(proc.wait()), 2)
3507
+ except asyncio.TimeoutError:
3508
+ try:
3509
+ os.killpg(proc.pid, signal.SIGKILL)
3510
+ except ProcessLookupError:
3511
+ pass
3512
+ await proc.wait()
3513
+ raise
3514
+ self._memory_status = raw.decode("utf-8", errors="replace").strip()
3515
+ if proc.returncode:
3516
+ self._memory_status = f"[exit {proc.returncode}]\n{self._memory_status}"
3517
+ self._refresh_agents()
3518
+
3519
+
3520
+ def run_tui(
3521
+ config: SyncmillConfig,
3522
+ *,
3523
+ board: Board | None,
3524
+ board_error: str | None,
3525
+ toml_path: Path,
3526
+ refresh_seconds: float = DEFAULT_REFRESH_SECONDS,
3527
+ initial_tab: str = "auto",
3528
+ ) -> None:
3529
+ install_render_guard()
3530
+ SyncmillTuiApp(
3531
+ config,
3532
+ board=board,
3533
+ board_error=board_error,
3534
+ toml_path=toml_path,
3535
+ refresh_seconds=refresh_seconds,
3536
+ initial_tab=initial_tab,
3537
+ ).run()