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/report.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Run identity + the run-envelope writer (S2/S3 of the ecosystem plan).
|
|
2
|
+
|
|
3
|
+
Every execution path — direct ``run``, monitored (tmux OUTER/inner), and board
|
|
4
|
+
``run_item`` — shares ONE stable ``run_id`` (the join key of
|
|
5
|
+
``contracts/run-envelope.schema.json``). :func:`resolve_run_id` is the single
|
|
6
|
+
resolver the three paths converge on:
|
|
7
|
+
|
|
8
|
+
- **explicit** — the caller already owns an id: ``run_item`` passes its board
|
|
9
|
+
lease/scope run_id so lease, ``board-run:{run_id}`` memory scope, and envelope
|
|
10
|
+
are one identity (no double issuance).
|
|
11
|
+
- **monitor inner** — the tmux OUTER minted the id (it never builds a
|
|
12
|
+
Supervisor) and propagated it via the 0600 env snapshot as ``SYNCMILL_RUN_ID``
|
|
13
|
+
(never argv). Honored ONLY when ``inner_dir()`` confirms this process is the
|
|
14
|
+
in-tmux inner (env var + ``.syncmill-inner`` sentinel), so a stray exported
|
|
15
|
+
``SYNCMILL_RUN_ID`` can never leak into a normal run.
|
|
16
|
+
- **fresh** — a direct run mints a uuid4.
|
|
17
|
+
|
|
18
|
+
The envelope writer (S3) consumes that id: when ``[integration.report]`` is
|
|
19
|
+
enabled, :class:`RunReporter` writes ``{runs_dir}/{run_id}/envelope.json`` — a
|
|
20
|
+
schema-valid, **body-free** join artifact (digests and counts only; prompts,
|
|
21
|
+
output, patch content, and file-path lists are forbidden by contract) — plus a
|
|
22
|
+
``result.json`` copy (via ``monitor._result_to_json``, the one result payload,
|
|
23
|
+
so it can't drift from the monitor's IPC file). The whole feature is
|
|
24
|
+
best-effort/degrade like memory: a report failure warns and the run, result
|
|
25
|
+
output, and cleanup continue unchanged. Disabled (the default),
|
|
26
|
+
:func:`build_reporter` returns ``None`` and writes nothing.
|
|
27
|
+
|
|
28
|
+
Retention over ``{runs_dir}`` is this module's, with two callers: enabled, it
|
|
29
|
+
rides :meth:`RunReporter.start`; disabled but with tracing on, the Supervisor
|
|
30
|
+
calls :func:`sweep_runs_dir` — the tracer's default output dir is that same
|
|
31
|
+
``{runs_dir}/{run_id}``, so something has to own its lifetime. Only when BOTH
|
|
32
|
+
are disabled is no filesystem touched.
|
|
33
|
+
|
|
34
|
+
A flat top-level module (gateway/monitor/session/board precedent). It imports
|
|
35
|
+
from ``monitor`` (``RUN_ID_ENV``/``inner_dir`` live beside ``INNER_ENV`` — the
|
|
36
|
+
same IPC channel — and ``_result_to_json`` is the shared result contract, the
|
|
37
|
+
``session.py`` private cross-import precedent); ``monitor`` never imports back,
|
|
38
|
+
keeping the graph acyclic.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import contextlib
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import shutil
|
|
49
|
+
import tempfile
|
|
50
|
+
from datetime import datetime, timezone
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from uuid import uuid4
|
|
53
|
+
|
|
54
|
+
from syncmill.artifacts import printable_name
|
|
55
|
+
from syncmill.config import ReportConfig
|
|
56
|
+
from syncmill.models import TaskResult, TaskStatus
|
|
57
|
+
from syncmill.monitor import RUN_ID_ENV, _result_to_json, inner_dir
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
ENVELOPE_FILE = "envelope.json"
|
|
62
|
+
RESULT_FILE = "result.json"
|
|
63
|
+
SCHEMA_VERSION = 1 # int, matching tracegraph's strict `!=` major check
|
|
64
|
+
KIND = "syncmill.run-envelope"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_run_id(explicit: str | None = None) -> str:
|
|
68
|
+
"""Resolve this run's stable id: explicit > monitor-inner env > fresh uuid4.
|
|
69
|
+
|
|
70
|
+
An empty ``explicit`` counts as unset (the ``-r ""`` house rule); an inner
|
|
71
|
+
process whose snapshot lacks ``SYNCMILL_RUN_ID`` (an older outer) falls back
|
|
72
|
+
to a fresh id rather than failing.
|
|
73
|
+
"""
|
|
74
|
+
if explicit:
|
|
75
|
+
return explicit
|
|
76
|
+
if inner_dir() is not None:
|
|
77
|
+
env_id = os.environ.get(RUN_ID_ENV)
|
|
78
|
+
if env_id:
|
|
79
|
+
return env_id
|
|
80
|
+
return str(uuid4())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Envelope primitives ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _utc_now() -> str:
|
|
87
|
+
return datetime.now(timezone.utc).isoformat()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sha256_digest(data: bytes) -> str:
|
|
91
|
+
"""The contract's digest form: ``sha256:<64 hex>``."""
|
|
92
|
+
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_envelope(run_dir: Path, doc: dict) -> Path:
|
|
96
|
+
"""Atomically write ``doc`` to ``<run_dir>/envelope.json`` and return the path.
|
|
97
|
+
|
|
98
|
+
The breaker ``_save`` recipe — a unique temp file in the SAME directory (so
|
|
99
|
+
``os.replace`` stays on one filesystem and is atomic), then replace; the
|
|
100
|
+
unique name keeps a ``finish``/``patch_envelope_board`` overlap collision-safe
|
|
101
|
+
(monitor's fixed ``.tmp`` sibling is only safe with a single owner). Raises
|
|
102
|
+
``OSError``/``ValueError`` on failure — callers own the degrade.
|
|
103
|
+
"""
|
|
104
|
+
path = run_dir / ENVELOPE_FILE
|
|
105
|
+
fd, tmp = tempfile.mkstemp(dir=str(run_dir), suffix=".tmp")
|
|
106
|
+
try:
|
|
107
|
+
with os.fdopen(fd, "w") as f:
|
|
108
|
+
json.dump(doc, f)
|
|
109
|
+
os.replace(tmp, path)
|
|
110
|
+
except BaseException:
|
|
111
|
+
# Don't leak the temp file if the write/replace failed.
|
|
112
|
+
try:
|
|
113
|
+
os.unlink(tmp)
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
raise
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: Written into every run dir syncmill creates; retention's proof of ownership.
|
|
121
|
+
#: ``runs_dir`` is free-form user config, so without a check, pointing it at a
|
|
122
|
+
#: populated directory (a home dir, an artifact tree) would recursively delete
|
|
123
|
+
#: unrelated data. The ``monitor.py``/``session.py`` sweep rule.
|
|
124
|
+
RUN_DIR_SENTINEL = ".syncmill-run-dir"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_run_dir(path: Path) -> bool:
|
|
128
|
+
"""True only for a directory syncmill itself produced.
|
|
129
|
+
|
|
130
|
+
The sentinel covers everything written from this version on. Run dirs from
|
|
131
|
+
earlier versions predate it, so an envelope is accepted as equivalent proof
|
|
132
|
+
— but only when it really is one of ours: a filename alone is not ownership
|
|
133
|
+
(``result.json`` is about as generic as a name gets, and a foreign directory
|
|
134
|
+
that happens to contain one must never be deleted). The envelope has to
|
|
135
|
+
parse, carry syncmill's own ``kind``, and name the directory it sits in.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
if (path / RUN_DIR_SENTINEL).exists():
|
|
139
|
+
return True
|
|
140
|
+
raw = (path / ENVELOPE_FILE).read_text(encoding="utf-8")
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
return False
|
|
143
|
+
try:
|
|
144
|
+
doc = json.loads(raw)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return False
|
|
147
|
+
return (
|
|
148
|
+
isinstance(doc, dict)
|
|
149
|
+
and doc.get("kind") == KIND
|
|
150
|
+
and doc.get("run_id") == path.name
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _sweep_retention(runs_dir: Path, keep_last: int, current_run_id: str) -> None:
|
|
155
|
+
"""Best-effort retention: drop oldest owned run dirs beyond ``keep_last``.
|
|
156
|
+
|
|
157
|
+
The current run always survives (excluded up front) and counts toward the
|
|
158
|
+
total, so ``keep_last - 1`` OTHER runs are retained. Only directories that
|
|
159
|
+
satisfy the ownership predicate are candidates at all (see ``_is_run_dir``).
|
|
160
|
+
Each removal is independent (one stuck dir can't strand the rest — the
|
|
161
|
+
``cleanup_all`` discipline), and a per-entry ``stat`` race (a dir removed
|
|
162
|
+
concurrently, a permission hiccup) sorts that entry oldest instead of
|
|
163
|
+
raising — its ``rmtree`` then fails harmlessly if it's really gone. Listing
|
|
164
|
+
errors bubble to the caller's own guard.
|
|
165
|
+
"""
|
|
166
|
+
if not runs_dir.is_dir():
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
def _mtime(d: Path) -> float:
|
|
170
|
+
try:
|
|
171
|
+
return d.stat().st_mtime
|
|
172
|
+
except OSError:
|
|
173
|
+
return 0.0
|
|
174
|
+
|
|
175
|
+
others = [
|
|
176
|
+
d
|
|
177
|
+
for d in runs_dir.iterdir()
|
|
178
|
+
if d.is_dir() and d.name != current_run_id and _is_run_dir(d)
|
|
179
|
+
]
|
|
180
|
+
others.sort(key=_mtime, reverse=True)
|
|
181
|
+
for stale in others[max(keep_last - 1, 0) :]:
|
|
182
|
+
try:
|
|
183
|
+
shutil.rmtree(stale)
|
|
184
|
+
except OSError:
|
|
185
|
+
logger.warning("report retention: could not remove %s", stale)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def sweep_runs_dir(runs_dir: str, keep_last: int, current_run_id: str) -> None:
|
|
189
|
+
"""Retention for a producer writing into ``runs_dir`` with no RunReporter.
|
|
190
|
+
|
|
191
|
+
A trace-enabled/report-disabled run still fills ``{runs_dir}/{run_id}/`` (the
|
|
192
|
+
tracer's default output dir), but ``build_reporter`` returned ``None``, so
|
|
193
|
+
:meth:`RunReporter.start`'s sweep never runs. The Supervisor calls this
|
|
194
|
+
instead, sharing ``_sweep_retention`` so the two callers can't drift.
|
|
195
|
+
|
|
196
|
+
Degrades on every ordinary exception — retention is cleanup of OTHER runs,
|
|
197
|
+
so nothing here may cost the current run. ``RuntimeError`` covers an
|
|
198
|
+
unresolvable ``~`` (no HOME, the board lesson); the broad catch is the
|
|
199
|
+
``reporter.start`` degrade discipline. ``BaseException`` (an interrupt) is
|
|
200
|
+
deliberately NOT caught: it belongs to the caller's finalization path.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
_sweep_retention(Path(runs_dir).expanduser(), keep_last, current_run_id)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
logger.warning("trace retention sweep failed: %s", exc)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ── Per-run writer ───────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class RunReporter:
|
|
212
|
+
"""Writes one run's envelope: a schema-valid skeleton at ``start`` (so even a
|
|
213
|
+
SIGKILL'd run leaves a parseable, honest "failed" artifact for the contract's
|
|
214
|
+
strict consumers), rewritten with the real result at ``finish``. Every method
|
|
215
|
+
is best-effort — a failure degrades (warning + flag), never raises, and
|
|
216
|
+
``finish`` retries the directory setup if ``start`` failed."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, config: ReportConfig) -> None:
|
|
219
|
+
self._config = config
|
|
220
|
+
self._run_dir: Path | None = None
|
|
221
|
+
self._run_id: str | None = None
|
|
222
|
+
self._created_at: str | None = None
|
|
223
|
+
self._meta: dict = {}
|
|
224
|
+
self.degraded = False
|
|
225
|
+
self.last_error: str | None = None
|
|
226
|
+
|
|
227
|
+
def start(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
run_id: str,
|
|
231
|
+
task_id: str,
|
|
232
|
+
strategy: str,
|
|
233
|
+
team: str | None,
|
|
234
|
+
agents: list[str],
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Create ``{runs_dir}/{run_id}/``, sweep retention, write the skeleton."""
|
|
237
|
+
# Meta first, unconditionally — a failed start still leaves finish() able
|
|
238
|
+
# to rebuild the whole document from scratch.
|
|
239
|
+
self._run_id = run_id
|
|
240
|
+
self._created_at = _utc_now()
|
|
241
|
+
self._meta = {
|
|
242
|
+
"task_id": task_id,
|
|
243
|
+
"strategy": strategy,
|
|
244
|
+
"team": team,
|
|
245
|
+
"agents": list(agents),
|
|
246
|
+
}
|
|
247
|
+
try:
|
|
248
|
+
run_dir = self._ensure_run_dir()
|
|
249
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
250
|
+
self._degrade(f"failed to start run report: {exc}")
|
|
251
|
+
return
|
|
252
|
+
# Retention is a cleanup of OTHER runs — its failure (a listing race, a
|
|
253
|
+
# permission hiccup) must never cost THIS run its skeleton, so it gets
|
|
254
|
+
# its own guard rather than riding the skeleton's.
|
|
255
|
+
try:
|
|
256
|
+
_sweep_retention(run_dir.parent, self._config.keep_last, run_id)
|
|
257
|
+
except (OSError, RuntimeError) as exc:
|
|
258
|
+
logger.warning("report retention sweep failed: %s", exc)
|
|
259
|
+
try:
|
|
260
|
+
write_envelope(run_dir, self._skeleton_doc())
|
|
261
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
262
|
+
self._degrade(f"failed to start run report: {exc}")
|
|
263
|
+
|
|
264
|
+
def finish(
|
|
265
|
+
self,
|
|
266
|
+
result: TaskResult,
|
|
267
|
+
*,
|
|
268
|
+
degraded: list[str],
|
|
269
|
+
trace: dict | None = None,
|
|
270
|
+
preflight: dict | None = None,
|
|
271
|
+
) -> Path | None:
|
|
272
|
+
"""Write ``result.json`` + the final envelope; return the envelope path.
|
|
273
|
+
|
|
274
|
+
The result payload is ``monitor._result_to_json`` verbatim (the one
|
|
275
|
+
contract); the envelope carries only its digest — plus the patch digest,
|
|
276
|
+
computed over the same surrogateescape bytes ``emit_patch_file`` writes,
|
|
277
|
+
so the digest verifies the actual ``--emit-patch`` file.
|
|
278
|
+
|
|
279
|
+
``trace``/``preflight`` are the tracer/preflight envelope blocks, merged
|
|
280
|
+
over the skeleton's ``{"status": "disabled"}`` — only at finish, so the
|
|
281
|
+
skeleton (the SIGKILL path) stays honestly ``disabled``.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
run_dir = self._ensure_run_dir() # retry if start failed
|
|
285
|
+
# ensure_ascii (the default) escapes any surrogateescape'd patch bytes
|
|
286
|
+
# to \udXXX, so encoding to ascii never throws and the digest is of
|
|
287
|
+
# the exact bytes written.
|
|
288
|
+
payload = json.dumps(_result_to_json(result)).encode("ascii")
|
|
289
|
+
fd, tmp = tempfile.mkstemp(dir=str(run_dir), suffix=".tmp")
|
|
290
|
+
try:
|
|
291
|
+
with os.fdopen(fd, "wb") as f:
|
|
292
|
+
f.write(payload)
|
|
293
|
+
os.replace(tmp, run_dir / RESULT_FILE)
|
|
294
|
+
except BaseException:
|
|
295
|
+
try:
|
|
296
|
+
os.unlink(tmp)
|
|
297
|
+
except OSError:
|
|
298
|
+
pass
|
|
299
|
+
raise
|
|
300
|
+
patch_digest = (
|
|
301
|
+
sha256_digest(result.patch.encode("utf-8", errors="surrogateescape"))
|
|
302
|
+
if result.patch is not None
|
|
303
|
+
else None
|
|
304
|
+
)
|
|
305
|
+
doc = self._skeleton_doc()
|
|
306
|
+
doc["finished_at"] = _utc_now()
|
|
307
|
+
doc["degraded"] = list(degraded)
|
|
308
|
+
if trace is not None:
|
|
309
|
+
doc["trace"] = trace
|
|
310
|
+
if preflight is not None:
|
|
311
|
+
doc["preflight"] = preflight
|
|
312
|
+
completed = result.status == TaskStatus.COMPLETED
|
|
313
|
+
doc["result"] = {
|
|
314
|
+
"status": "completed" if completed else "failed",
|
|
315
|
+
"agent_id": result.agent_id,
|
|
316
|
+
"winner": result.agent_id if completed else None,
|
|
317
|
+
"exit_code": result.exit_code,
|
|
318
|
+
"duration_seconds": result.duration_seconds,
|
|
319
|
+
"gate_passed": result.gate.passed if result.gate is not None else None,
|
|
320
|
+
# Count only — the path LIST is a forbidden body by contract.
|
|
321
|
+
"files_changed_count": len(result.files_changed),
|
|
322
|
+
"artifact": RESULT_FILE, # relative to the envelope's directory
|
|
323
|
+
"digest": sha256_digest(payload),
|
|
324
|
+
"patch_digest": patch_digest,
|
|
325
|
+
}
|
|
326
|
+
return write_envelope(run_dir, doc)
|
|
327
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
328
|
+
self._degrade(f"failed to finish run report: {exc}")
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
def _ensure_run_dir(self) -> Path:
|
|
332
|
+
"""Resolve + create the run directory (memoized). ``expanduser`` sits
|
|
333
|
+
inside the callers' try — it raises ``RuntimeError`` when ``~`` can't
|
|
334
|
+
resolve (no-HOME cron/systemd; the board precedent)."""
|
|
335
|
+
if self._run_dir is None:
|
|
336
|
+
runs_dir = Path(self._config.runs_dir).expanduser()
|
|
337
|
+
run_dir = runs_dir / (self._run_id or "")
|
|
338
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
# Claim the directory before anything else lands in it, so retention
|
|
340
|
+
# can tell it from a directory that merely shares this root.
|
|
341
|
+
with contextlib.suppress(OSError):
|
|
342
|
+
(run_dir / RUN_DIR_SENTINEL).write_text("")
|
|
343
|
+
self._run_dir = run_dir
|
|
344
|
+
return self._run_dir
|
|
345
|
+
|
|
346
|
+
def _skeleton_doc(self) -> dict:
|
|
347
|
+
"""A fully schema-VALID envelope for a run that has not (yet) reported
|
|
348
|
+
finishing: honest ``result.status: "failed"``, ``finished_at`` mirroring
|
|
349
|
+
``created_at`` (the field is required non-nullable), every sub-block
|
|
350
|
+
present. ``trace``/``preflight`` stay ``disabled`` in the skeleton even
|
|
351
|
+
when those stages are on — their real blocks only exist at ``finish``,
|
|
352
|
+
so a SIGKILL'd run honestly reports neither artifact."""
|
|
353
|
+
return {
|
|
354
|
+
"schema_version": SCHEMA_VERSION,
|
|
355
|
+
"kind": KIND,
|
|
356
|
+
"run_id": self._run_id,
|
|
357
|
+
"created_at": self._created_at,
|
|
358
|
+
"finished_at": self._created_at,
|
|
359
|
+
"strategy": self._meta.get("strategy"),
|
|
360
|
+
"team": self._meta.get("team"),
|
|
361
|
+
"task_id": self._meta.get("task_id"),
|
|
362
|
+
"agents": self._meta.get("agents", []),
|
|
363
|
+
"board": None,
|
|
364
|
+
"preflight": {"status": "disabled"},
|
|
365
|
+
"trace": {"status": "disabled"},
|
|
366
|
+
"result": {
|
|
367
|
+
"status": "failed",
|
|
368
|
+
"agent_id": None,
|
|
369
|
+
"winner": None,
|
|
370
|
+
"exit_code": None,
|
|
371
|
+
"duration_seconds": None,
|
|
372
|
+
"gate_passed": None,
|
|
373
|
+
"files_changed_count": None,
|
|
374
|
+
"patch_digest": None,
|
|
375
|
+
},
|
|
376
|
+
"degraded": [],
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
def _degrade(self, msg: str) -> None:
|
|
380
|
+
self.degraded = True
|
|
381
|
+
self.last_error = msg
|
|
382
|
+
logger.warning("run report degraded: %s", msg)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def build_reporter(config: ReportConfig) -> RunReporter | None:
|
|
386
|
+
"""The reporter, or ``None`` when reporting is disabled (the default) — the
|
|
387
|
+
``build_gate``/``build_breaker`` pattern: the disabled path never touches the
|
|
388
|
+
filesystem and behavior is byte-identical to before."""
|
|
389
|
+
if not config.enabled:
|
|
390
|
+
return None
|
|
391
|
+
return RunReporter(config)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def patch_envelope_board(
|
|
395
|
+
runs_dir: str | Path,
|
|
396
|
+
run_id: str,
|
|
397
|
+
*,
|
|
398
|
+
item_id: str,
|
|
399
|
+
goal: str | None,
|
|
400
|
+
) -> bool:
|
|
401
|
+
"""Best-effort: set the finished envelope's ``board`` block (``run_item``
|
|
402
|
+
calls this after ``board.apply_update`` — the envelope is finalized by
|
|
403
|
+
``run()``'s own finally first, so this is a read-modify-write). Returns
|
|
404
|
+
``False`` (with a warning) instead of raising on any failure."""
|
|
405
|
+
try:
|
|
406
|
+
run_dir = Path(runs_dir).expanduser() / run_id
|
|
407
|
+
doc = json.loads((run_dir / ENVELOPE_FILE).read_text())
|
|
408
|
+
if not isinstance(doc, dict):
|
|
409
|
+
raise ValueError("envelope is not a JSON object")
|
|
410
|
+
doc["board"] = {"item_id": item_id, "goal": goal}
|
|
411
|
+
write_envelope(run_dir, doc)
|
|
412
|
+
return True
|
|
413
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
414
|
+
logger.warning("could not patch envelope board block for run %s: %s", run_id, exc)
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ── Read surface (CLI `report show` / `report list`) ─────────────────────────
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def load_envelope(runs_dir: str | Path, run_id: str) -> dict | None:
|
|
422
|
+
"""Parse ``{runs_dir}/{run_id}/envelope.json``; ``None`` if absent/unreadable."""
|
|
423
|
+
try:
|
|
424
|
+
doc = json.loads(
|
|
425
|
+
(Path(runs_dir).expanduser() / run_id / ENVELOPE_FILE).read_text()
|
|
426
|
+
)
|
|
427
|
+
return doc if isinstance(doc, dict) else None
|
|
428
|
+
except (OSError, ValueError, RuntimeError):
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def list_reports(runs_dir: str | Path) -> list[dict]:
|
|
433
|
+
"""Every parseable envelope under ``runs_dir``, newest first (``created_at``
|
|
434
|
+
is ISO-8601, so the string sorts chronologically). Best-effort per dir —
|
|
435
|
+
an unparseable or foreign entry is skipped, never fatal."""
|
|
436
|
+
try:
|
|
437
|
+
root = Path(runs_dir).expanduser()
|
|
438
|
+
if not root.is_dir():
|
|
439
|
+
return []
|
|
440
|
+
docs = [d for e in sorted(root.iterdir()) if (d := load_envelope(root, e.name))]
|
|
441
|
+
except (OSError, RuntimeError):
|
|
442
|
+
return []
|
|
443
|
+
docs.sort(key=lambda doc: str(doc.get("created_at") or ""), reverse=True)
|
|
444
|
+
return docs
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def render_report(doc: dict) -> str:
|
|
448
|
+
"""Human rendering of one envelope (the ``board show`` label style) — shared
|
|
449
|
+
verbatim by ``report show`` (and any later dashboard slice, the
|
|
450
|
+
``render_item`` precedent). The envelope carries no bodies by contract, so
|
|
451
|
+
neither does this.
|
|
452
|
+
|
|
453
|
+
Every value is shown through ``printable_name``: the envelope is a stored
|
|
454
|
+
file read without type validation, and a control or an LF in any field must
|
|
455
|
+
neither drive the terminal nor start a line of its own."""
|
|
456
|
+
|
|
457
|
+
def v(value: object) -> str:
|
|
458
|
+
return printable_name(str(value))
|
|
459
|
+
|
|
460
|
+
board = doc.get("board")
|
|
461
|
+
board_text = (
|
|
462
|
+
f"{v(board.get('item_id'))} (goal: {v(board.get('goal') or '—')})"
|
|
463
|
+
if isinstance(board, dict)
|
|
464
|
+
else "—"
|
|
465
|
+
)
|
|
466
|
+
result = doc.get("result") or {}
|
|
467
|
+
trace = doc.get("trace") or {}
|
|
468
|
+
stream = trace.get("stream") if isinstance(trace, dict) else None
|
|
469
|
+
degraded = doc.get("degraded") or []
|
|
470
|
+
lines = [
|
|
471
|
+
f"Run: {v(doc.get('run_id'))}",
|
|
472
|
+
f"Created: {v(doc.get('created_at'))}",
|
|
473
|
+
f"Finished: {v(doc.get('finished_at'))}",
|
|
474
|
+
f"Strategy: {v(doc.get('strategy'))}",
|
|
475
|
+
f"Team: {v(doc.get('team') or '—')}",
|
|
476
|
+
f"Task: {v(doc.get('task_id'))}",
|
|
477
|
+
f"Agents: {', '.join(v(a) for a in doc.get('agents') or []) or '—'}",
|
|
478
|
+
f"Board: {board_text}",
|
|
479
|
+
f"Result: {v(result.get('status'))}"
|
|
480
|
+
+ (f" ({v(result.get('agent_id'))})" if result.get("agent_id") else ""),
|
|
481
|
+
f" winner: {v(result.get('winner') or '—')}",
|
|
482
|
+
f" exit code: {v(result.get('exit_code'))}",
|
|
483
|
+
f" duration: {v(result.get('duration_seconds'))}s",
|
|
484
|
+
f" gate passed: {v(result.get('gate_passed'))}",
|
|
485
|
+
f" files changed: {v(result.get('files_changed_count'))}",
|
|
486
|
+
f" artifact: {v(result.get('artifact') or '—')}",
|
|
487
|
+
f" digest: {v(result.get('digest') or '—')}",
|
|
488
|
+
f" patch digest: {v(result.get('patch_digest') or '—')}",
|
|
489
|
+
f"Preflight: {v((doc.get('preflight') or {}).get('status'))}",
|
|
490
|
+
f"Trace: {v(trace.get('status'))}",
|
|
491
|
+
*(
|
|
492
|
+
[
|
|
493
|
+
f" Phoenix stream: {v(stream.get('status'))} "
|
|
494
|
+
f"({v(stream.get('exported_span_count', 0))} exported, "
|
|
495
|
+
f"{v(stream.get('dropped_span_count', 0))} dropped)"
|
|
496
|
+
]
|
|
497
|
+
if isinstance(stream, dict)
|
|
498
|
+
else []
|
|
499
|
+
),
|
|
500
|
+
f"Degraded: {', '.join(v(d) for d in degraded) or '—'}",
|
|
501
|
+
]
|
|
502
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tracegraph review-candidate contract and deterministic board projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SCHEMA_VERSION = 1
|
|
15
|
+
KIND = "tracegraph.review-candidates"
|
|
16
|
+
DEFAULT_GOAL = "tracegraph-review"
|
|
17
|
+
_ITEM_NAMESPACE = uuid.uuid5(
|
|
18
|
+
uuid.NAMESPACE_URL, "https://syncmill.dev/review-items/tracegraph"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReviewCandidateError(ValueError):
|
|
23
|
+
"""The input is not a supported Tracegraph review-candidate report."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReviewCandidate(BaseModel):
|
|
27
|
+
"""The body-free fields SyncMill consumes from one Tracegraph finding."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="ignore", strict=True)
|
|
30
|
+
|
|
31
|
+
run_id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
|
|
32
|
+
pattern_id: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
33
|
+
pattern_version: int = Field(ge=1)
|
|
34
|
+
tool_key: str = Field(min_length=4, max_length=512, pattern=r"^\S+::\S+$")
|
|
35
|
+
artifact_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReviewCandidateReport(BaseModel):
|
|
39
|
+
"""Supported v1 envelope; additive producer fields are deliberately discarded."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="ignore", strict=True)
|
|
42
|
+
|
|
43
|
+
schema_version: Literal[1]
|
|
44
|
+
kind: Literal["tracegraph.review-candidates"]
|
|
45
|
+
candidates: list[ReviewCandidate] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ReviewItemSpec:
|
|
50
|
+
"""Immutable board projection for one exact Tracegraph candidate tuple."""
|
|
51
|
+
|
|
52
|
+
item_id: str
|
|
53
|
+
task: str
|
|
54
|
+
inputs: tuple[str, ...]
|
|
55
|
+
artifact_digest: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_report(path: str | Path) -> ReviewCandidateReport:
|
|
59
|
+
"""Read and validate a complete report without retaining unknown fields."""
|
|
60
|
+
source = Path(path)
|
|
61
|
+
try:
|
|
62
|
+
raw = source.read_bytes()
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise ReviewCandidateError(f"cannot read review-candidate report: {exc}") from exc
|
|
65
|
+
try:
|
|
66
|
+
return ReviewCandidateReport.model_validate_json(raw)
|
|
67
|
+
except ValidationError as exc:
|
|
68
|
+
first = exc.errors(include_input=False)[0]
|
|
69
|
+
location = ".".join(str(part) for part in first.get("loc", ())) or "report"
|
|
70
|
+
raise ReviewCandidateError(
|
|
71
|
+
f"invalid review-candidate report at {location}: {first['msg']}"
|
|
72
|
+
) from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def item_spec(candidate: ReviewCandidate) -> ReviewItemSpec:
|
|
76
|
+
"""Project a candidate to a stable, body-free board item specification."""
|
|
77
|
+
identity = json.dumps(
|
|
78
|
+
[
|
|
79
|
+
SCHEMA_VERSION,
|
|
80
|
+
KIND,
|
|
81
|
+
candidate.run_id,
|
|
82
|
+
candidate.pattern_id,
|
|
83
|
+
candidate.pattern_version,
|
|
84
|
+
candidate.tool_key,
|
|
85
|
+
candidate.artifact_digest,
|
|
86
|
+
],
|
|
87
|
+
ensure_ascii=False,
|
|
88
|
+
separators=(",", ":"),
|
|
89
|
+
)
|
|
90
|
+
item_id = str(uuid.uuid5(_ITEM_NAMESPACE, identity))
|
|
91
|
+
pattern = f"{candidate.pattern_id}@v{candidate.pattern_version}"
|
|
92
|
+
return ReviewItemSpec(
|
|
93
|
+
item_id=item_id,
|
|
94
|
+
task=f"Review Tracegraph {pattern} finding for {candidate.tool_key}",
|
|
95
|
+
inputs=(
|
|
96
|
+
f"source:{KIND}@v{SCHEMA_VERSION}",
|
|
97
|
+
f"run_id:{candidate.run_id}",
|
|
98
|
+
f"pattern:{pattern}",
|
|
99
|
+
f"tool_key:{candidate.tool_key}",
|
|
100
|
+
f"artifact_digest:{candidate.artifact_digest}",
|
|
101
|
+
),
|
|
102
|
+
artifact_digest=candidate.artifact_digest,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def item_specs(report: ReviewCandidateReport) -> list[ReviewItemSpec]:
|
|
107
|
+
return [item_spec(candidate) for candidate in report.candidates]
|