syncmill 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/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]