syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/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()
|