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
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Textual widgets and renderers for project sessions and comparison rooms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, Checkbox, Input, Select, TextArea
|
|
11
|
+
|
|
12
|
+
from syncmill.artifacts import printable_name, printable_text
|
|
13
|
+
from syncmill.config import SyncmillConfig
|
|
14
|
+
from syncmill.room_compare import (
|
|
15
|
+
CandidateRow,
|
|
16
|
+
RoundGroup,
|
|
17
|
+
SelectionView,
|
|
18
|
+
gate_label,
|
|
19
|
+
conversation_label,
|
|
20
|
+
gate_settings_label,
|
|
21
|
+
member_conversation,
|
|
22
|
+
round_conversation_label,
|
|
23
|
+
row_start_label,
|
|
24
|
+
selection_lines,
|
|
25
|
+
starting_tree_label,
|
|
26
|
+
usage_label,
|
|
27
|
+
)
|
|
28
|
+
from syncmill.session import interactive_agents
|
|
29
|
+
from syncmill.tui_render import PlainLabel, PlainStatic, plain_select
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _CollabModal(ModalScreen[dict | bool | None]):
|
|
33
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
34
|
+
|
|
35
|
+
def action_cancel(self) -> None:
|
|
36
|
+
self.dismiss(None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionOpenModal(_CollabModal):
|
|
40
|
+
def __init__(self, config: SyncmillConfig):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self._config = config
|
|
43
|
+
|
|
44
|
+
def compose(self) -> ComposeResult:
|
|
45
|
+
choices = [
|
|
46
|
+
(agent, agent)
|
|
47
|
+
for agent in self._config.agent_priority
|
|
48
|
+
if agent in interactive_agents()
|
|
49
|
+
]
|
|
50
|
+
with Vertical(classes="form-dialog"):
|
|
51
|
+
yield PlainLabel("Open isolated native CLI session")
|
|
52
|
+
if not choices:
|
|
53
|
+
yield PlainStatic("No interactive agents are configured.", classes="warning")
|
|
54
|
+
yield Button("Cancel", id="cancel")
|
|
55
|
+
return
|
|
56
|
+
yield plain_select(choices, value=choices[0][1], allow_blank=False, id="agent")
|
|
57
|
+
yield PlainStatic(
|
|
58
|
+
"The provider runs in a persistent isolated worktree. "
|
|
59
|
+
"Attach separately; source changes require checkpoint + apply.",
|
|
60
|
+
classes="warning",
|
|
61
|
+
)
|
|
62
|
+
with Horizontal(classes="form-buttons"):
|
|
63
|
+
yield Button("Open", variant="primary", id="apply")
|
|
64
|
+
yield Button("Cancel", id="cancel")
|
|
65
|
+
|
|
66
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
67
|
+
if event.button.id != "apply":
|
|
68
|
+
self.dismiss(None)
|
|
69
|
+
return
|
|
70
|
+
value = self.query_one("#agent", Select).value
|
|
71
|
+
self.dismiss({"agent": value})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RoomCreateModal(_CollabModal):
|
|
75
|
+
def __init__(self, config: SyncmillConfig):
|
|
76
|
+
super().__init__()
|
|
77
|
+
self._config = config
|
|
78
|
+
|
|
79
|
+
def compose(self) -> ComposeResult:
|
|
80
|
+
enabled = [
|
|
81
|
+
agent
|
|
82
|
+
for agent in self._config.agent_priority
|
|
83
|
+
if self._config.agent_config(agent).enabled
|
|
84
|
+
][:3]
|
|
85
|
+
with Vertical(classes="wide-dialog"):
|
|
86
|
+
yield PlainLabel("Create comparison room")
|
|
87
|
+
yield TextArea(id="room-prompt", language=None)
|
|
88
|
+
yield Input(value=", ".join(enabled), id="room-agents")
|
|
89
|
+
yield PlainStatic(
|
|
90
|
+
"Creating a room records its prompt and members without calling a "
|
|
91
|
+
"provider. Starting a round with x makes one isolated provider call "
|
|
92
|
+
"per listed agent and preserves every candidate.",
|
|
93
|
+
classes="warning",
|
|
94
|
+
)
|
|
95
|
+
with Horizontal(classes="form-buttons"):
|
|
96
|
+
yield Button("Create", variant="primary", id="apply")
|
|
97
|
+
yield Button("Cancel", id="cancel")
|
|
98
|
+
|
|
99
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
100
|
+
if event.button.id != "apply":
|
|
101
|
+
self.dismiss(None)
|
|
102
|
+
return
|
|
103
|
+
self.dismiss(
|
|
104
|
+
{
|
|
105
|
+
"prompt": self.query_one("#room-prompt", TextArea).text,
|
|
106
|
+
"agents": self.query_one("#room-agents", Input).value,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RoomRoundModal(_CollabModal):
|
|
112
|
+
"""Per-round prompt + lineage input for the Rooms tab.
|
|
113
|
+
|
|
114
|
+
Replaces the bare confirm the round used to run through: the TextArea is
|
|
115
|
+
prefilled with the PREVIOUS round's prompt (creation prompt on a fresh
|
|
116
|
+
room) so every round can carry a new instruction, and lineage/base-candidate
|
|
117
|
+
are selectable in place. The review checkbox safety gate is preserved.
|
|
118
|
+
Dumb like the other modals — dismisses raw strings; parsing/validation
|
|
119
|
+
stays in the app funnel."""
|
|
120
|
+
|
|
121
|
+
_LINEAGES = [
|
|
122
|
+
("continue-own (resume each member)", "continue-own"),
|
|
123
|
+
("fresh (cold, from-scratch trees)", "fresh"),
|
|
124
|
+
("branch-from (seed all from one candidate)", "branch-from"),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def __init__(self, room: dict, default_prompt: str, eligible: list[dict]):
|
|
128
|
+
super().__init__()
|
|
129
|
+
self._room = room
|
|
130
|
+
self._default_prompt = default_prompt
|
|
131
|
+
self._eligible = eligible
|
|
132
|
+
|
|
133
|
+
def compose(self) -> ComposeResult:
|
|
134
|
+
members = self._room.get("members", [])
|
|
135
|
+
with Vertical(classes="wide-dialog"):
|
|
136
|
+
yield PlainLabel(
|
|
137
|
+
f"Run round {int(self._room.get('round_count') or 0) + 1} — "
|
|
138
|
+
f"{len(members)} provider call(s)"
|
|
139
|
+
)
|
|
140
|
+
area = TextArea(id="round-prompt", language=None)
|
|
141
|
+
# Escaped prefill: new prompts cannot contain controls (the
|
|
142
|
+
# coordinator rejects them), but a row stored before that
|
|
143
|
+
# validation could — never hand raw sequences to the terminal.
|
|
144
|
+
area.text = escape_controls(self._default_prompt)
|
|
145
|
+
yield area
|
|
146
|
+
yield plain_select(
|
|
147
|
+
self._LINEAGES,
|
|
148
|
+
value="continue-own",
|
|
149
|
+
allow_blank=False,
|
|
150
|
+
id="round-lineage",
|
|
151
|
+
)
|
|
152
|
+
if self._eligible:
|
|
153
|
+
yield plain_select(
|
|
154
|
+
[
|
|
155
|
+
(f"{row['id'][:8]} {row['member_id']}", row["id"])
|
|
156
|
+
for row in self._eligible
|
|
157
|
+
],
|
|
158
|
+
allow_blank=True,
|
|
159
|
+
prompt="base candidate (branch-from only)",
|
|
160
|
+
id="round-base",
|
|
161
|
+
)
|
|
162
|
+
yield PlainStatic(
|
|
163
|
+
"The detached worker keeps running if the TUI closes. An empty "
|
|
164
|
+
"prompt reuses the room creation prompt.",
|
|
165
|
+
classes="warning",
|
|
166
|
+
)
|
|
167
|
+
yield Checkbox("I reviewed this exact action", id="confirmed")
|
|
168
|
+
with Horizontal(classes="form-buttons"):
|
|
169
|
+
yield Button("Run round", variant="warning", id="apply")
|
|
170
|
+
yield Button("Cancel", id="cancel")
|
|
171
|
+
|
|
172
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
173
|
+
if event.button.id != "apply":
|
|
174
|
+
self.dismiss(None)
|
|
175
|
+
return
|
|
176
|
+
if not self.query_one("#confirmed", Checkbox).value:
|
|
177
|
+
self.app.notify("confirm the action", severity="warning")
|
|
178
|
+
return
|
|
179
|
+
lineage = self.query_one("#round-lineage", Select).value
|
|
180
|
+
base = None
|
|
181
|
+
if self._eligible:
|
|
182
|
+
value = self.query_one("#round-base", Select).value
|
|
183
|
+
# An unselected Select yields a sentinel (NULL/BLANK depending on
|
|
184
|
+
# the textual major) — only a real candidate-id string counts.
|
|
185
|
+
base = value if isinstance(value, str) and value else None
|
|
186
|
+
if lineage == "branch-from" and base is None:
|
|
187
|
+
self.app.notify(
|
|
188
|
+
"branch-from requires a base candidate", severity="warning"
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
if lineage != "branch-from":
|
|
192
|
+
base = None # a picked base is meaningful only under branch-from
|
|
193
|
+
self.dismiss(
|
|
194
|
+
{
|
|
195
|
+
"prompt": self.query_one("#round-prompt", TextArea).text,
|
|
196
|
+
"lineage": lineage,
|
|
197
|
+
"base_candidate": base,
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class CollaborationConfirmModal(_CollabModal):
|
|
203
|
+
def __init__(self, title: str, detail: str, action: str):
|
|
204
|
+
super().__init__()
|
|
205
|
+
# Callers escape each interpolated value with printable_name, so a
|
|
206
|
+
# value cannot add a line; this keeps anything else off the screen.
|
|
207
|
+
self._title = printable_name(title)
|
|
208
|
+
self._detail = printable_text(detail)
|
|
209
|
+
self._action = action
|
|
210
|
+
|
|
211
|
+
def compose(self) -> ComposeResult:
|
|
212
|
+
with Vertical(classes="wide-dialog"):
|
|
213
|
+
yield PlainLabel(self._title)
|
|
214
|
+
yield PlainStatic(self._detail)
|
|
215
|
+
yield Checkbox("I reviewed this exact action", id="confirmed")
|
|
216
|
+
with Horizontal(classes="form-buttons"):
|
|
217
|
+
yield Button(self._action, variant="warning", id="apply")
|
|
218
|
+
yield Button("Cancel", id="cancel")
|
|
219
|
+
|
|
220
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
221
|
+
if event.button.id != "apply":
|
|
222
|
+
self.dismiss(None)
|
|
223
|
+
return
|
|
224
|
+
if not self.query_one("#confirmed", Checkbox).value:
|
|
225
|
+
self.app.notify("confirm the action", severity="warning")
|
|
226
|
+
return
|
|
227
|
+
self.dismiss(True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class CandidateSelectModal(_CollabModal):
|
|
231
|
+
"""Confirm a candidate selection, with an optional rationale recorded
|
|
232
|
+
alongside it. Dumb like the other modals: it dismisses the raw text and
|
|
233
|
+
the store validates it."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, detail: str):
|
|
236
|
+
super().__init__()
|
|
237
|
+
self._detail = detail
|
|
238
|
+
|
|
239
|
+
def compose(self) -> ComposeResult:
|
|
240
|
+
with Vertical(classes="wide-dialog"):
|
|
241
|
+
yield PlainLabel("Select candidate (source remains unchanged)")
|
|
242
|
+
# The detail and rationale scroll; the confirmation and the
|
|
243
|
+
# buttons stay outside, so they are reachable at 80x24 too.
|
|
244
|
+
with VerticalScroll(id="selection-body"):
|
|
245
|
+
yield PlainStatic(self._detail)
|
|
246
|
+
yield PlainLabel("Rationale (optional) — why this candidate:")
|
|
247
|
+
yield TextArea(id="selection-rationale", language=None)
|
|
248
|
+
yield Checkbox("I reviewed this exact candidate", id="confirmed")
|
|
249
|
+
with Horizontal(classes="form-buttons"):
|
|
250
|
+
yield Button("Select", variant="warning", id="apply")
|
|
251
|
+
yield Button("Cancel", id="cancel")
|
|
252
|
+
|
|
253
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
254
|
+
if event.button.id != "apply":
|
|
255
|
+
self.dismiss(None)
|
|
256
|
+
return
|
|
257
|
+
if not self.query_one("#confirmed", Checkbox).value:
|
|
258
|
+
self.app.notify("confirm the action", severity="warning")
|
|
259
|
+
return
|
|
260
|
+
self.dismiss(
|
|
261
|
+
{"rationale": self.query_one("#selection-rationale", TextArea).text}
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _patch_is_binary(head: bytes) -> bool:
|
|
266
|
+
"""Whether a patch head carries binary content. Derived — no stored flag:
|
|
267
|
+
patches are captured with `git diff --binary`, whose binary hunks are
|
|
268
|
+
marked in-band."""
|
|
269
|
+
return (
|
|
270
|
+
b"GIT binary patch" in head
|
|
271
|
+
or b"\nBinary files " in head
|
|
272
|
+
or head.startswith(b"Binary files ")
|
|
273
|
+
or b"\x00" in head
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _p(value: object) -> str:
|
|
278
|
+
"""A stored value in a one-line field, as the CLI shows it."""
|
|
279
|
+
return printable_name(str(value))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def render_session(value: dict) -> str:
|
|
283
|
+
checkpoints = ", ".join(_p(c) for c in value.get("checkpoints", []))
|
|
284
|
+
return "\n".join(
|
|
285
|
+
[
|
|
286
|
+
f"Session: {_p(value.get('session_id'))}",
|
|
287
|
+
f"Agent: {_p(value.get('agent_id'))}",
|
|
288
|
+
f"State: {_p(value.get('state'))} | live: {_p(value.get('live', False))}",
|
|
289
|
+
f"Source: {'STALE' if value.get('source_stale') else 'clean snapshot'}",
|
|
290
|
+
f"Worktree: {_p(value.get('worktree_path'))}",
|
|
291
|
+
f"Checkpoints: {checkpoints or '-'}",
|
|
292
|
+
"",
|
|
293
|
+
"x attach | c checkpoint | k close",
|
|
294
|
+
]
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def render_room(room: dict, candidates: list[dict], events: list[dict]) -> str:
|
|
299
|
+
members = ", ".join(_p(member["member_id"]) for member in room.get("members", []))
|
|
300
|
+
tail = events[-5:]
|
|
301
|
+
lines = [
|
|
302
|
+
f"Room: {_p(room.get('id'))}",
|
|
303
|
+
f"Prompt: {_prompt_first_line(room.get('prompt'))}",
|
|
304
|
+
f"State: {_p(room.get('state'))} | revision: {_p(room.get('revision'))}",
|
|
305
|
+
f"Rounds: {_p(room.get('round_count'))} | members: {members}",
|
|
306
|
+
f"Selected: {_p(room.get('selected_candidate_id') or '-')}",
|
|
307
|
+
f"Apply run: {_p(room.get('apply_run_id') or '-')}",
|
|
308
|
+
f"Applied run: {_p(room.get('applied_run_id') or '-')}",
|
|
309
|
+
"",
|
|
310
|
+
"Recent events:",
|
|
311
|
+
*(f"{_p(event['sequence'])} {_p(event['kind'])}" for event in tail),
|
|
312
|
+
"",
|
|
313
|
+
"x run round | e select candidate | g apply selected",
|
|
314
|
+
]
|
|
315
|
+
return "\n".join(lines)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
#: C0 (minus tab/newlines) + DEL + C1 — the terminal-interpretable range.
|
|
319
|
+
#: New prompts are rejected at the coordinator boundary; this escaping is the
|
|
320
|
+
#: display-side defense for rows stored before that validation existed.
|
|
321
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def escape_controls(text: str) -> str:
|
|
325
|
+
"""Visibly escape C0/C1 controls — Textual passes sequences like OSC 52
|
|
326
|
+
through to the terminal even with ``markup=False``."""
|
|
327
|
+
return _CONTROL_CHARS.sub(lambda m: f"\\x{ord(m.group()):02x}", text)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _prompt_first_line(prompt: object, limit: int = 100) -> str:
|
|
331
|
+
"""A prompt's first line trimmed for one-line detail views ('-' when the
|
|
332
|
+
field is absent — a corrupt row must render, not crash the poll)."""
|
|
333
|
+
# Escaped first, so a CR, NEL or U+2028 stays visible: strip() would drop
|
|
334
|
+
# one at either end, and splitlines() would also end the line there.
|
|
335
|
+
shown = printable_text(prompt).strip() if isinstance(prompt, str) else ""
|
|
336
|
+
if not shown:
|
|
337
|
+
return "-"
|
|
338
|
+
line = shown.split("\n")[0]
|
|
339
|
+
return line if len(line) <= limit else line[: limit - 1] + "…"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def render_candidate_diff(
|
|
343
|
+
manifest: dict,
|
|
344
|
+
patch_head: bytes,
|
|
345
|
+
*,
|
|
346
|
+
truncated: bool,
|
|
347
|
+
binary: bool | None = None,
|
|
348
|
+
) -> str:
|
|
349
|
+
"""The candidate Diff view: the digest-bound patch window, binary-marked.
|
|
350
|
+
|
|
351
|
+
``binary`` is the loader's whole-stream verdict (a binary hunk may sit
|
|
352
|
+
beyond the displayed head); ``None`` falls back to sniffing the head.
|
|
353
|
+
"""
|
|
354
|
+
lines = [
|
|
355
|
+
f"Candidate: {_p(manifest.get('candidate_id'))} [Diff]",
|
|
356
|
+
f"Patch: {_p(manifest.get('patch_bytes'))} bytes {_p(manifest.get('patch_sha256'))}",
|
|
357
|
+
]
|
|
358
|
+
if _patch_is_binary(patch_head) if binary is None else binary:
|
|
359
|
+
lines.append("[contains binary patch content]")
|
|
360
|
+
text = printable_text(patch_head.decode("utf-8", errors="surrogateescape"))
|
|
361
|
+
lines.extend(["", text or "(empty patch)"])
|
|
362
|
+
if truncated:
|
|
363
|
+
lines.append("\n[... truncated — press d again to load more ...]")
|
|
364
|
+
return "\n".join(lines)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def render_candidate_output(manifest: dict) -> str:
|
|
368
|
+
"""The candidate Output view: state and error first, then the bounded raw
|
|
369
|
+
output — a failed candidate's review starts with why it failed. Gate,
|
|
370
|
+
usage and eligibility are interpreted in one place only (the comparison
|
|
371
|
+
read model behind Details and Compare), so this view does not repeat them."""
|
|
372
|
+
lines = [
|
|
373
|
+
f"Candidate: {_p(manifest.get('candidate_id'))} [Output]",
|
|
374
|
+
f"State: {_p(manifest.get('state'))} | exit: {_p(manifest.get('exit_code'))} | "
|
|
375
|
+
f"{_p(manifest.get('duration_seconds'))}s",
|
|
376
|
+
]
|
|
377
|
+
if manifest.get("error"):
|
|
378
|
+
lines.append(f"Error: {printable_text(str(manifest['error']))}")
|
|
379
|
+
lines.append("Gate, usage, eligibility: see Details (m) or Compare (c)")
|
|
380
|
+
if manifest.get("output_truncated"):
|
|
381
|
+
lines.append("[stored output was truncated at capture]")
|
|
382
|
+
# `or ""`: a manifest with an explicit `"output": null` must render, not
|
|
383
|
+
# TypeError out of the room poll.
|
|
384
|
+
output = printable_text(str(manifest.get("output") or ""))
|
|
385
|
+
if len(output) > 100_000:
|
|
386
|
+
output = output[:50_000] + "\n\n[... display truncated ...]\n\n" + output[-50_000:]
|
|
387
|
+
lines.extend(["", output or "(no output)"])
|
|
388
|
+
return "\n".join(lines)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def render_candidate_details(
|
|
392
|
+
row: CandidateRow,
|
|
393
|
+
*,
|
|
394
|
+
round_number: int | None,
|
|
395
|
+
round_prompt: str | None = None,
|
|
396
|
+
as_of: str | None = None,
|
|
397
|
+
selection: SelectionView | None = None,
|
|
398
|
+
group: RoundGroup | None = None,
|
|
399
|
+
) -> str:
|
|
400
|
+
"""The candidate Details view, rendered from the comparison read model's
|
|
401
|
+
row, so its gate, eligibility, usage, patch and blocker wording is the one
|
|
402
|
+
``room compare`` and the Compare view use. Every stored string is
|
|
403
|
+
escaped. ``selection`` is shown only on the selected row; ``group`` (the
|
|
404
|
+
row's round) adds its starting tree and recorded gate settings."""
|
|
405
|
+
p = printable_name
|
|
406
|
+
attempt = "?" if row.attempt is None else row.attempt
|
|
407
|
+
lines = [
|
|
408
|
+
f"Candidate: {p(row.candidate_id)} [Details]",
|
|
409
|
+
*([f"evidence as of {as_of}"] if as_of else []),
|
|
410
|
+
f"Member: {p(row.member_id)} ({p(row.provider_id)})",
|
|
411
|
+
f"Round: {round_number if round_number is not None else '?'} | attempt: {attempt}",
|
|
412
|
+
*(
|
|
413
|
+
[f"Round prompt: {_prompt_first_line(round_prompt)}"]
|
|
414
|
+
if round_prompt is not None
|
|
415
|
+
else []
|
|
416
|
+
),
|
|
417
|
+
]
|
|
418
|
+
if group is not None:
|
|
419
|
+
lines.append(f"Starting tree: {starting_tree_label(group)}")
|
|
420
|
+
if (start := row_start_label(group, row)) is not None:
|
|
421
|
+
lines.append(f"Starts from: {start}")
|
|
422
|
+
settings = group.gate_settings
|
|
423
|
+
lines.append(
|
|
424
|
+
f"Gate settings: {gate_settings_label(settings)}"
|
|
425
|
+
+ (
|
|
426
|
+
": " + "; ".join(p(c) for c in settings.commands)
|
|
427
|
+
if settings.status == "recorded" and settings.kind == "commands"
|
|
428
|
+
else ""
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
lines.append(f"Conversations: {round_conversation_label(group)}")
|
|
432
|
+
if (conversation := conversation_label(group, row)) is not None:
|
|
433
|
+
lines.append(f"Conversation: {conversation}")
|
|
434
|
+
entry = member_conversation(group, row)
|
|
435
|
+
lines.append(
|
|
436
|
+
"Checkpoint candidates: "
|
|
437
|
+
+ (", ".join(p(c) for c in entry.candidates) if entry and entry.candidates else "none")
|
|
438
|
+
)
|
|
439
|
+
# The labels above point at "see warnings": the round's own warnings
|
|
440
|
+
# (provenance reasons, report cross-check) live on the group.
|
|
441
|
+
lines.extend(f"Round warning: {p(w)}" for w in group.warnings)
|
|
442
|
+
if not row.readable:
|
|
443
|
+
lines.append(f"Unreadable: {p(row.unreadable_reason)}")
|
|
444
|
+
eligible = "yes" if row.eligible else "no"
|
|
445
|
+
if not row.eligible and row.not_eligible_reason:
|
|
446
|
+
eligible += f" ({p(row.not_eligible_reason)})"
|
|
447
|
+
duration = "?" if row.duration_seconds is None else f"{row.duration_seconds:.1f}s"
|
|
448
|
+
exit_code = "?" if row.exit_code is None else row.exit_code
|
|
449
|
+
lines.extend(
|
|
450
|
+
[
|
|
451
|
+
f"State: {p(row.state)} | eligible: {eligible}",
|
|
452
|
+
f"Exit: {exit_code} | duration: {duration}",
|
|
453
|
+
]
|
|
454
|
+
)
|
|
455
|
+
if row.error:
|
|
456
|
+
lines.append(f"Error: {p(row.error)}")
|
|
457
|
+
lines.append(f"Gate: {gate_label(row.gate)}")
|
|
458
|
+
for check in row.gate.checks:
|
|
459
|
+
check_exit = "?" if check.exit_code is None else check.exit_code
|
|
460
|
+
check_time = (
|
|
461
|
+
"?" if check.duration_seconds is None else f"{check.duration_seconds:.1f}s"
|
|
462
|
+
)
|
|
463
|
+
lines.append(
|
|
464
|
+
f" {'✓' if check.passed else '×'} {p(check.command)} "
|
|
465
|
+
f"(exit {check_exit}, {check_time})"
|
|
466
|
+
)
|
|
467
|
+
lines.append(f"Usage: {usage_label(row.usage)}")
|
|
468
|
+
files = "unknown" if row.files_changed is None else (
|
|
469
|
+
", ".join(p(name) for name in row.files_changed) or "-"
|
|
470
|
+
)
|
|
471
|
+
patch = "unknown" if row.patch_bytes is None else f"{row.patch_bytes} bytes"
|
|
472
|
+
flags = []
|
|
473
|
+
if row.patch_binary:
|
|
474
|
+
flags.append("binary")
|
|
475
|
+
if row.patch_large:
|
|
476
|
+
flags.append("large")
|
|
477
|
+
output_bytes = "?" if row.output_bytes is None else row.output_bytes
|
|
478
|
+
truncated = "?" if row.output_truncated is None else row.output_truncated
|
|
479
|
+
lines.extend(
|
|
480
|
+
[
|
|
481
|
+
f"Files: {files}",
|
|
482
|
+
f"Patch: {patch}" + (f" ({', '.join(flags)})" if flags else ""),
|
|
483
|
+
f"Patch sha256: {p(row.patch_sha256)}",
|
|
484
|
+
f"Output: {output_bytes} bytes | truncated: {truncated}",
|
|
485
|
+
f"Output sha256: {p(row.output_sha256 or '?')}",
|
|
486
|
+
f"Session: {p(row.provider_session_id or '-')}",
|
|
487
|
+
f"Created: {p(row.created_at or '?')} | base: {p(row.base_commit)}",
|
|
488
|
+
]
|
|
489
|
+
)
|
|
490
|
+
if row.apply_blockers:
|
|
491
|
+
lines.append("Apply blockers:")
|
|
492
|
+
lines.extend(f" - {p(blocker)}" for blocker in row.apply_blockers)
|
|
493
|
+
else:
|
|
494
|
+
lines.append("Apply blockers: none known (select/apply still revalidate)")
|
|
495
|
+
for warning in row.warnings:
|
|
496
|
+
lines.append(f"Warning: {p(warning)}")
|
|
497
|
+
if row.selected and selection is not None:
|
|
498
|
+
lines.extend(selection_lines(selection))
|
|
499
|
+
return "\n".join(lines)
|