agentflow-proof 1.0.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.
agentflow/aggregate.py ADDED
@@ -0,0 +1,1015 @@
1
+ """Cross-worktree ledger aggregation analysis (read-only dry-run surface).
2
+
3
+ #30 impl 1/3. This module never writes to disk. It parses input worktrees,
4
+ detects fail-closed collisions, and computes the planned id rewrites for a
5
+ dry-run report. The write path (#111) and provenance (#112) build on top.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import tempfile
14
+ from collections import Counter
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ from .artifacts import read_jsonl, try_read_json, write_json
20
+ from .contracts import AGGREGATION_SCHEMA_VERSION, ARTIFACT_PATHS, BASE_ARTIFACT_PATHS, EXECUTION_ARTIFACT_PATHS
21
+ from .git import run_git
22
+ from .packs import _safe_relative_path
23
+ from .receipts import sha256_path
24
+
25
+ SOURCE_ID_RE = re.compile(r"^[a-z0-9]{1,16}$")
26
+
27
+ _ALL_PATHS = {**BASE_ARTIFACT_PATHS, **EXECUTION_ARTIFACT_PATHS}
28
+
29
+ MUST_MATCH = (
30
+ "plan-lock",
31
+ "execution-contract",
32
+ "workflow-contract",
33
+ "assumptions",
34
+ "runtime-config",
35
+ )
36
+ CONCAT_LEDGERS = (
37
+ "evidence",
38
+ "context-receipts",
39
+ "failures",
40
+ "amendments",
41
+ "capability-receipts",
42
+ "runtime-snapshot",
43
+ )
44
+
45
+ # Strict local counter-id formats for the rewrite-class ledgers. Inputs are raw
46
+ # per-worktree ledgers, so their ids must be plain (never namespaced, never
47
+ # containing path separators); a violation is a hostile/corrupt foreign ledger
48
+ # and fails closed before the write path rebuilds any receipt path from an id.
49
+ _LOCAL_ID_RULES = {
50
+ "step-runs": (("attempt_id", "A"), ("amends_attempt", "A"), ("superseded_by", "A")),
51
+ "command-receipts": (("id", "CR"), ("attempt_id", "A")),
52
+ "file-receipts": (("id", "FR"), ("attempt_id", "A")),
53
+ "verification-runs": (("id", "VR"), ("attempt_id", "A")),
54
+ }
55
+
56
+ # JSONL ledgers read by the detectors. A malformed line in any of these would
57
+ # otherwise crash a detector mid-analysis; analyze() pre-flights them so a
58
+ # corrupt foreign ledger fails closed as a `malformed_ledger` collision.
59
+ _JSONL_LEDGERS = (
60
+ "step-runs",
61
+ "file-receipts",
62
+ "command-receipts",
63
+ "verification-runs",
64
+ "review-runs",
65
+ ) + CONCAT_LEDGERS
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Source:
70
+ root: Path
71
+ source_id: str
72
+ label: str
73
+
74
+ @property
75
+ def prefix(self) -> str:
76
+ return f"WT{self.source_id}-"
77
+
78
+
79
+ def parse_sources(
80
+ inputs: List[str],
81
+ source_ids: List[str],
82
+ labels: Optional[List[str]],
83
+ ) -> Tuple[List[Source], List[str]]:
84
+ errors: List[str] = []
85
+ if len(inputs) != len(source_ids):
86
+ errors.append(
87
+ f"--input count ({len(inputs)}) must equal --source-id count ({len(source_ids)})"
88
+ )
89
+ return [], errors
90
+ labels = labels or []
91
+ if labels and len(labels) != len(inputs):
92
+ errors.append(
93
+ f"--label count ({len(labels)}) must equal --input count ({len(inputs)})"
94
+ )
95
+ return [], errors
96
+ seen: set = set()
97
+ sources: List[Source] = []
98
+ for index, (inp, sid) in enumerate(zip(inputs, source_ids)):
99
+ if not SOURCE_ID_RE.fullmatch(sid):
100
+ errors.append(f"invalid --source-id {sid!r}: must match [a-z0-9]{{1,16}}")
101
+ continue
102
+ if sid in seen:
103
+ errors.append(f"duplicate --source-id {sid!r}")
104
+ continue
105
+ seen.add(sid)
106
+ root = Path(inp).resolve()
107
+ label = labels[index] if labels else root.name
108
+ sources.append(Source(root=root, source_id=sid, label=label))
109
+ return sources, errors
110
+
111
+
112
+ def _collision(kind: str, **detail: Any) -> Dict[str, Any]:
113
+ return {"kind": kind, **detail}
114
+
115
+
116
+ def _artifact_rel(name: str) -> str:
117
+ return _ALL_PATHS[name]
118
+
119
+
120
+ def _read_bytes(path: Path) -> bytes:
121
+ if not path.exists():
122
+ return b""
123
+ return path.read_bytes()
124
+
125
+
126
+ def _rows(source: Source, rel: str) -> List[Dict[str, Any]]:
127
+ """Ledger rows for one source, tolerating unreadable ledgers.
128
+
129
+ Detectors and mergers must not crash on a version-incompatible or
130
+ malformed source ledger (worktrees are the realistic multi-version case);
131
+ _malformed_ledger_collisions is the single reporter for those, and its
132
+ collision fails aggregation closed before any write.
133
+ """
134
+ try:
135
+ return read_jsonl(source.root / rel)
136
+ except ValueError:
137
+ return []
138
+
139
+
140
+ def _contained_path(root: Path, rel_path: str) -> Optional[Path]:
141
+ candidate = root / rel_path
142
+ try:
143
+ candidate.resolve(strict=False).relative_to(root.resolve())
144
+ except ValueError:
145
+ return None
146
+ return candidate
147
+
148
+
149
+ def _must_match_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
150
+ collisions: List[Dict[str, Any]] = []
151
+ first = sources[0]
152
+ for name in MUST_MATCH:
153
+ rel = _artifact_rel(name)
154
+ base = _read_bytes(first.root / rel)
155
+ for other in sources[1:]:
156
+ if _read_bytes(other.root / rel) != base:
157
+ collisions.append(
158
+ _collision(
159
+ "must_match_mismatch",
160
+ artifact=name,
161
+ sources=[first.source_id, other.source_id],
162
+ )
163
+ )
164
+ return collisions
165
+
166
+
167
+ def _step_rows_by_step(source: Source) -> Dict[str, List[str]]:
168
+ rel = EXECUTION_ARTIFACT_PATHS["step-runs"]
169
+ rows: Dict[str, List[str]] = {}
170
+ for row in _rows(source, rel):
171
+ step_id = row.get("step_id")
172
+ if isinstance(step_id, str):
173
+ rows.setdefault(step_id, []).append(json.dumps(row, sort_keys=True))
174
+ return rows
175
+
176
+
177
+ def _step_overlap_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
178
+ collisions: List[Dict[str, Any]] = []
179
+ seen: Dict[str, Tuple[str, List[str]]] = {}
180
+ for source in sources:
181
+ for step_id, rows in sorted(_step_rows_by_step(source).items()):
182
+ if step_id in seen:
183
+ first_source, first_rows = seen[step_id]
184
+ if rows != first_rows:
185
+ collisions.append(_collision("step_overlap", step_id=step_id, sources=[first_source, source.source_id]))
186
+ else:
187
+ seen[step_id] = (source.source_id, rows)
188
+ return collisions
189
+
190
+
191
+ def _file_rows_by_path(source: Source) -> Dict[str, List[str]]:
192
+ rel = EXECUTION_ARTIFACT_PATHS["file-receipts"]
193
+ rows: Dict[str, List[str]] = {}
194
+ for row in _rows(source, rel):
195
+ path = row.get("path")
196
+ if isinstance(path, str):
197
+ rows.setdefault(path, []).append(json.dumps(row, sort_keys=True))
198
+ return rows
199
+
200
+
201
+ def _file_overlap_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
202
+ collisions: List[Dict[str, Any]] = []
203
+ seen: Dict[str, Tuple[str, List[str]]] = {}
204
+ for source in sources:
205
+ for path, rows in sorted(_file_rows_by_path(source).items()):
206
+ if path in seen:
207
+ first_source, first_rows = seen[path]
208
+ if rows != first_rows: # differing rows => concurrent edit; identical => shared baseline
209
+ collisions.append(_collision("file_overlap", path=path, sources=[first_source, source.source_id]))
210
+ else:
211
+ seen[path] = (source.source_id, rows)
212
+ return collisions
213
+
214
+
215
+ def _concat_dup_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
216
+ collisions: List[Dict[str, Any]] = []
217
+ for name in CONCAT_LEDGERS:
218
+ rel = _artifact_rel(name)
219
+ canon_by_id: Dict[str, str] = {}
220
+ src_by_id: Dict[str, str] = {}
221
+ for source in sources:
222
+ for row in _rows(source, rel):
223
+ rid = row.get("id")
224
+ if not isinstance(rid, str):
225
+ continue
226
+ canon = json.dumps(row, sort_keys=True)
227
+ if rid in canon_by_id:
228
+ if canon_by_id[rid] != canon:
229
+ collisions.append(
230
+ _collision(
231
+ "concat_dup_mismatch",
232
+ ledger=name,
233
+ id=rid,
234
+ sources=[src_by_id[rid], source.source_id],
235
+ )
236
+ )
237
+ else:
238
+ canon_by_id[rid] = canon
239
+ src_by_id[rid] = source.source_id
240
+ return collisions
241
+
242
+
243
+ def _receipt_file_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
244
+ collisions: List[Dict[str, Any]] = []
245
+ rel = EXECUTION_ARTIFACT_PATHS["command-receipts"]
246
+ for source in sources:
247
+ for row in _rows(source, rel):
248
+ for path_key, sha_key in (("stdout_path", "stdout_sha256"), ("stderr_path", "stderr_sha256")):
249
+ rel_path = row.get(path_key)
250
+ expected = row.get(sha_key)
251
+ if not rel_path:
252
+ continue
253
+ if not _safe_relative_path(rel_path):
254
+ collisions.append(_collision("unsafe_path", source=source.source_id, path=rel_path, field=path_key))
255
+ continue
256
+ fpath = _contained_path(source.root, rel_path)
257
+ if fpath is None:
258
+ collisions.append(_collision("unsafe_path", source=source.source_id, path=rel_path, field=path_key))
259
+ continue
260
+ if not fpath.exists():
261
+ collisions.append(_collision("receipt_file_missing", source=source.source_id, path=rel_path))
262
+ continue
263
+ if expected and sha256_path(fpath) != expected:
264
+ collisions.append(_collision("receipt_hash_mismatch", source=source.source_id, path=rel_path))
265
+ return collisions
266
+
267
+
268
+ def _review_artifact_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
269
+ from .review import sha256_file
270
+
271
+ collisions: List[Dict[str, Any]] = []
272
+ rel = BASE_ARTIFACT_PATHS["review-runs"]
273
+ for source in sources:
274
+ for row in _rows(source, rel):
275
+ manifest = row.get("manifest_path")
276
+ manifest_sha = row.get("manifest_sha256")
277
+ if isinstance(manifest, str):
278
+ if not _safe_relative_path(manifest):
279
+ collisions.append(_collision("unsafe_path", source=source.source_id, path=manifest, field="manifest_path"))
280
+ else:
281
+ path = _contained_path(source.root, manifest)
282
+ if path is None:
283
+ collisions.append(_collision("unsafe_path", source=source.source_id, path=manifest, field="manifest_path"))
284
+ continue
285
+ if not path.exists():
286
+ collisions.append(_collision("review_manifest_missing", source=source.source_id, path=manifest))
287
+ elif isinstance(manifest_sha, str) and sha256_file(path) != manifest_sha:
288
+ collisions.append(_collision("review_manifest_hash_mismatch", source=source.source_id, path=manifest))
289
+ for entry in row.get("artifacts", []) if isinstance(row.get("artifacts"), list) else []:
290
+ art = entry.get("path") if isinstance(entry, dict) else None
291
+ expected = entry.get("sha256") if isinstance(entry, dict) else None
292
+ if not isinstance(art, str):
293
+ continue
294
+ if not _safe_relative_path(art):
295
+ collisions.append(_collision("unsafe_path", source=source.source_id, path=art, field="artifact_path"))
296
+ continue
297
+ path = _contained_path(source.root, art)
298
+ if path is None:
299
+ collisions.append(_collision("unsafe_path", source=source.source_id, path=art, field="artifact_path"))
300
+ continue
301
+ if not path.exists():
302
+ collisions.append(_collision("review_artifact_missing", source=source.source_id, path=art))
303
+ elif isinstance(expected, str) and sha256_file(path) != expected:
304
+ collisions.append(_collision("review_artifact_hash_mismatch", source=source.source_id, path=art))
305
+ return collisions
306
+
307
+
308
+ def _review_dup_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
309
+ collisions: List[Dict[str, Any]] = []
310
+ rel = BASE_ARTIFACT_PATHS["review-runs"]
311
+ seen: Dict[str, Tuple[str, str]] = {}
312
+ for source in sources:
313
+ for row in _rows(source, rel):
314
+ rid = row.get("review_run_id")
315
+ if not isinstance(rid, str):
316
+ continue
317
+ canon = json.dumps(row, sort_keys=True)
318
+ if rid in seen:
319
+ first_source, first_canon = seen[rid]
320
+ if canon != first_canon: # differing dup shadows in review_summary()'s by-id map
321
+ collisions.append(_collision("review_dup_id", review_run_id=rid, sources=[first_source, source.source_id]))
322
+ else:
323
+ seen[rid] = (source.source_id, canon)
324
+ return collisions
325
+
326
+
327
+ def _head(root: Path) -> Optional[str]:
328
+ code, out, _ = run_git(root, ["rev-parse", "HEAD"])
329
+ return out.strip() if code == 0 and out.strip() else None
330
+
331
+
332
+ def _rev_parse(root: Path, ref: str) -> Optional[str]:
333
+ code, out, _ = run_git(root, ["rev-parse", ref])
334
+ return out.strip() if code == 0 and out.strip() else None
335
+
336
+
337
+ def _base_commit(source: Source, resolved_base: Optional[str], output_root: Path) -> Optional[str]:
338
+ src_head = _head(source.root)
339
+ if not src_head:
340
+ return None
341
+ if resolved_base is None:
342
+ out_head = _head(output_root)
343
+ if not out_head:
344
+ return None
345
+ code, out, _ = run_git(source.root, ["merge-base", out_head, src_head])
346
+ return out.strip() if code == 0 and out.strip() else None
347
+ code, out, _ = run_git(source.root, ["merge-base", src_head, resolved_base])
348
+ if code != 0 or out.strip() != resolved_base:
349
+ return None
350
+ return out.strip() if code == 0 and out.strip() else None
351
+
352
+
353
+ def _source_bases(sources: List[Source], base_ref: Optional[str], output_root: Path) -> Dict[str, Optional[str]]:
354
+ resolved_base = _rev_parse(output_root, base_ref) if base_ref else None
355
+ if base_ref and resolved_base is None:
356
+ return {s.source_id: None for s in sources}
357
+ return {s.source_id: _base_commit(s, resolved_base, output_root) for s in sources}
358
+
359
+
360
+ def _base_commit_collisions(bases: Dict[str, Optional[str]]) -> List[Dict[str, Any]]:
361
+ collisions: List[Dict[str, Any]] = []
362
+ distinct = {b for b in bases.values() if b is not None}
363
+ if len(distinct) > 1:
364
+ collisions.append(_collision("base_commit_mismatch", bases=dict(bases)))
365
+ if any(b is None for b in bases.values()):
366
+ collisions.append(_collision("base_commit_unresolved", bases=dict(bases)))
367
+ return collisions
368
+
369
+
370
+ def _latest_file_receipts(sources: List[Source]) -> Dict[str, Tuple[str, Optional[str], str, Optional[str]]]:
371
+ latest: Dict[str, Tuple[str, Optional[str], str, Optional[str]]] = {}
372
+ rel = EXECUTION_ARTIFACT_PATHS["file-receipts"]
373
+ for source in sources:
374
+ for row in _rows(source, rel):
375
+ path = row.get("path")
376
+ if not isinstance(path, str):
377
+ continue
378
+ ts = row.get("recorded_at", "")
379
+ if not isinstance(ts, str):
380
+ ts = ""
381
+ if path not in latest or ts >= latest[path][0]:
382
+ latest[path] = (ts, row.get("after_sha256"), source.source_id, row.get("change_kind"))
383
+ return latest
384
+
385
+
386
+ def _precondition_collisions(sources: List[Source], output_root: Path) -> List[Dict[str, Any]]:
387
+ collisions: List[Dict[str, Any]] = []
388
+ for path, (_, after, sid, change_kind) in sorted(_latest_file_receipts(sources).items()):
389
+ if not _safe_relative_path(path):
390
+ collisions.append(_collision("unsafe_path", source=sid, path=path, field="file_receipt_path"))
391
+ continue
392
+ target = _contained_path(output_root, path)
393
+ if target is None:
394
+ collisions.append(_collision("unsafe_path", source=sid, path=path, field="file_receipt_path"))
395
+ continue
396
+ if change_kind == "deleted":
397
+ if target.exists():
398
+ collisions.append(_collision("precondition_deleted_present", path=path, source=sid))
399
+ continue
400
+ if after is None:
401
+ # A non-deleted receipt must record an after_sha256; a null one is a
402
+ # malformed/foreign row, so fail closed rather than skip the check.
403
+ collisions.append(_collision("precondition_malformed", path=path, source=sid))
404
+ continue
405
+ if not target.exists():
406
+ collisions.append(_collision("precondition_missing", path=path, source=sid))
407
+ continue
408
+ if sha256_path(target) != after:
409
+ collisions.append(_collision("precondition_hash_mismatch", path=path, source=sid))
410
+ return collisions
411
+
412
+
413
+ def _ids(source: Source, name: str, key: str) -> set:
414
+ rel = _artifact_rel(name)
415
+ return {row[key] for row in _rows(source, rel) if isinstance(row.get(key), str)}
416
+
417
+
418
+ def _verification_ids(source: Source) -> set:
419
+ rel = EXECUTION_ARTIFACT_PATHS["verification-runs"]
420
+ return {
421
+ row["id"]
422
+ for row in _rows(source, rel)
423
+ if row.get("scope") == "step" and isinstance(row.get("id"), str)
424
+ }
425
+
426
+
427
+ def _planned_rewrites(sources: List[Source], bases: Dict[str, Optional[str]]) -> Dict[str, Any]:
428
+ rewrites: Dict[str, Any] = {}
429
+ for source in sources:
430
+ prefix = source.prefix
431
+ rewrites[source.source_id] = {
432
+ "attempt_ids": {a: prefix + a for a in sorted(_ids(source, "step-runs", "attempt_id"))},
433
+ "command_receipt_ids": {c: prefix + c for c in sorted(_ids(source, "command-receipts", "id"))},
434
+ "file_receipt_ids": {f: prefix + f for f in sorted(_ids(source, "file-receipts", "id"))},
435
+ "verification_run_ids": {v: prefix + v for v in sorted(_verification_ids(source))},
436
+ }
437
+ return {"sources": _source_summaries(sources, bases), "rewrites": rewrites}
438
+
439
+
440
+ def _malformed_ledger_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
441
+ collisions: List[Dict[str, Any]] = []
442
+ for source in sources:
443
+ for name in _JSONL_LEDGERS:
444
+ try:
445
+ rows = read_jsonl(source.root / _artifact_rel(name))
446
+ except ValueError as exc:
447
+ collisions.append(_collision("malformed_ledger", source=source.source_id, ledger=name, error=str(exc)))
448
+ continue
449
+ for index, row in enumerate(rows, start=1):
450
+ if not isinstance(row, dict):
451
+ collisions.append(
452
+ _collision(
453
+ "malformed_ledger",
454
+ source=source.source_id,
455
+ ledger=name,
456
+ error=f"{_artifact_rel(name)}:{index}: row must be a JSON object",
457
+ )
458
+ )
459
+ continue
460
+ if name == "file-receipts" and isinstance(row.get("path"), str) and not isinstance(row.get("recorded_at"), str):
461
+ collisions.append(
462
+ _collision(
463
+ "malformed_ledger",
464
+ source=source.source_id,
465
+ ledger=name,
466
+ error=f"{_artifact_rel(name)}:{index}: recorded_at must be a string",
467
+ )
468
+ )
469
+ return collisions
470
+
471
+
472
+ def _malformed_id_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
473
+ collisions: List[Dict[str, Any]] = []
474
+ for source in sources:
475
+ for name, rules in _LOCAL_ID_RULES.items():
476
+ rel = _artifact_rel(name)
477
+ for row in _rows(source, rel):
478
+ for field, tag in rules:
479
+ value = row.get(field)
480
+ if isinstance(value, str) and not re.fullmatch(rf"{tag}[0-9]+", value):
481
+ collisions.append(
482
+ _collision("malformed_id", source=source.source_id, ledger=name, field=field, value=value)
483
+ )
484
+ return collisions
485
+
486
+
487
+ def _intra_source_dup_id_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
488
+ """A repeated counter ``id`` within ONE source's own rewrite-class ledger.
489
+
490
+ Well-formed ledgers mint monotonic ids, so a repeat means a corrupt ledger
491
+ (a double-append or crash mid-write). After namespacing both rows collapse
492
+ to the same id and, for ``by_attempt`` command receipts, the same rebuilt
493
+ physical path — a silent overwrite that would leave a ledger row pointing at
494
+ another row's file. Fail closed before the write path can relocate anything.
495
+ """
496
+ collisions: List[Dict[str, Any]] = []
497
+ for source in sources:
498
+ for name in ("command-receipts", "file-receipts", "verification-runs"):
499
+ rel = _artifact_rel(name)
500
+ seen: set = set()
501
+ for row in _rows(source, rel):
502
+ rid = row.get("id")
503
+ if not isinstance(rid, str):
504
+ continue
505
+ if rid in seen:
506
+ collisions.append(_collision("intra_source_dup_id", source=source.source_id, ledger=name, id=rid))
507
+ seen.add(rid)
508
+ return collisions
509
+
510
+
511
+ def _malformed_contract_collisions(sources: List[Source]) -> List[Dict[str, Any]]:
512
+ """A present-but-unparseable execution-contract.
513
+
514
+ The contract's ``command_policy.receipt_store`` drives the physical receipt
515
+ layout the write path emits, so a contract that exists but does not parse to
516
+ a JSON object must fail closed rather than let ``_receipt_store`` silently
517
+ fall back to ``by_attempt`` and mis-lay-out the canonical receipts tree.
518
+ """
519
+ collisions: List[Dict[str, Any]] = []
520
+ rel = EXECUTION_ARTIFACT_PATHS["execution-contract"]
521
+ for source in sources:
522
+ path = source.root / rel
523
+ if not path.exists():
524
+ continue
525
+ _data, error = try_read_json(path)
526
+ if error is not None:
527
+ collisions.append(_collision("malformed_contract", source=source.source_id, error=error))
528
+ return collisions
529
+
530
+
531
+ def _source_summaries(sources: List[Source], bases: Dict[str, Optional[str]]) -> List[Dict[str, Any]]:
532
+ return [
533
+ {
534
+ "source_id": source.source_id,
535
+ "root_label": source.label,
536
+ "base_commit": bases.get(source.source_id),
537
+ "head_commit": _head(source.root),
538
+ "namespaced_prefix": source.prefix,
539
+ }
540
+ for source in sources
541
+ ]
542
+
543
+
544
+ def analyze(sources: List[Source], output_root: Path, base_ref: Optional[str] = None) -> Dict[str, Any]:
545
+ # Shared entry for analyze/plan_write/write_canonical; several helpers index
546
+ # sources[0], so guard the empty case here rather than crash with IndexError.
547
+ if not sources:
548
+ return {"status": "collision", "sources": [], "collisions": [_collision("no_sources")], "planned": {}}
549
+ bases = _source_bases(sources, base_ref, output_root)
550
+ # Pre-flight: a malformed JSONL ledger would crash a detector mid-scan, so a
551
+ # corrupt foreign input must fail closed before any detector reads rows.
552
+ malformed = _malformed_ledger_collisions(sources)
553
+ if malformed:
554
+ return {
555
+ "status": "collision",
556
+ "sources": _source_summaries(sources, bases),
557
+ "collisions": malformed,
558
+ "planned": {},
559
+ }
560
+ collisions: List[Dict[str, Any]] = []
561
+ collisions += _must_match_collisions(sources)
562
+ collisions += _malformed_contract_collisions(sources)
563
+ collisions += _malformed_id_collisions(sources)
564
+ collisions += _intra_source_dup_id_collisions(sources)
565
+ collisions += _base_commit_collisions(bases)
566
+ collisions += _step_overlap_collisions(sources)
567
+ collisions += _file_overlap_collisions(sources)
568
+ collisions += _concat_dup_collisions(sources)
569
+ collisions += _receipt_file_collisions(sources)
570
+ collisions += _review_artifact_collisions(sources)
571
+ collisions += _review_dup_collisions(sources)
572
+ collisions += _precondition_collisions(sources, output_root)
573
+ planned = _planned_rewrites(sources, bases)
574
+ return {
575
+ "status": "collision" if collisions else "ok",
576
+ "sources": planned["sources"],
577
+ "collisions": collisions,
578
+ "planned": planned["rewrites"],
579
+ }
580
+
581
+
582
+ # --- write path (#111) ---------------------------------------------------
583
+
584
+ REWRITE_LEDGERS = ("step-runs", "command-receipts", "file-receipts", "verification-runs")
585
+
586
+
587
+ def _canon(row: Dict[str, Any]) -> str:
588
+ """Canonical row bytes — the same byte-identity discriminator the overlap
589
+ detectors use to tell shared baseline from a concurrent edit."""
590
+ return json.dumps(row, sort_keys=True)
591
+
592
+
593
+ def _row_timestamp(row: Dict[str, Any]) -> str:
594
+ """The row's natural timestamp for deterministic ordering (§6).
595
+
596
+ ``last_verified`` is the evidence ledger's natural timestamp; without it,
597
+ evidence concat rows would all fall back to "" and order by position only.
598
+ """
599
+ for key in ("recorded_at", "created_at", "started_at", "last_verified"):
600
+ value = row.get(key)
601
+ if isinstance(value, str):
602
+ return value
603
+ return ""
604
+
605
+
606
+ def _baseline_canon(sources: List[Source], rel: str) -> set:
607
+ """Canonical rows present in >=2 sources for ledger ``rel``.
608
+
609
+ A same-key row that *differed* across sources already fired a collision in
610
+ ``analyze()``; so any row whose bytes appear in >=2 sources is byte-identical
611
+ shared baseline (§ shared-baseline). It is emitted once, un-namespaced.
612
+ """
613
+ counts: Counter = Counter()
614
+ for source in sources:
615
+ seen_in_source: set = set()
616
+ for row in _rows(source, rel):
617
+ canon = _canon(row)
618
+ if canon not in seen_in_source:
619
+ seen_in_source.add(canon)
620
+ counts[canon] += 1
621
+ return {canon for canon, n in counts.items() if n >= 2}
622
+
623
+
624
+ def _attempt_map(source: Source, step_baseline: set) -> Dict[str, str]:
625
+ """Map tree-local attempt ids of ``source`` to their namespaced form.
626
+
627
+ An attempt is tree-local iff it owns a tree-local step-run row (its
628
+ ``attempt_id`` on a row whose bytes are not shared baseline). Baseline
629
+ attempts (the completed prerequisite inherited by every tree) are absent
630
+ from the map, so ``.get(a, a)`` leaves them un-namespaced.
631
+ """
632
+ rel = EXECUTION_ARTIFACT_PATHS["step-runs"]
633
+ local: set = set()
634
+ for row in _rows(source, rel):
635
+ if _canon(row) in step_baseline:
636
+ continue
637
+ attempt = row.get("attempt_id")
638
+ if isinstance(attempt, str):
639
+ local.add(attempt)
640
+ return {attempt: source.prefix + attempt for attempt in sorted(local)}
641
+
642
+
643
+ def _receipt_store(sources: List[Source]) -> str:
644
+ """Resolve ``command_policy.receipt_store`` from the must-match contract.
645
+
646
+ The execution-contract is byte-identical across all inputs (a collision
647
+ otherwise), so the first source is representative.
648
+ """
649
+ contract_path = sources[0].root / EXECUTION_ARTIFACT_PATHS["execution-contract"]
650
+ if not contract_path.exists():
651
+ return "by_attempt" # absent uniformly (else must-match fires); default policy
652
+ contract, _ = try_read_json(contract_path)
653
+ policy = (contract or {}).get("command_policy", {})
654
+ store = policy.get("receipt_store") if isinstance(policy, dict) else None
655
+ return store if store in ("by_attempt", "content_addressed") else "by_attempt"
656
+
657
+
658
+ def _emit_baseline_then_local(
659
+ sources: List[Source],
660
+ rel: str,
661
+ rewrite_local: Any,
662
+ keep_row: Any = None,
663
+ baseline: Optional[set] = None,
664
+ ) -> List[Dict[str, Any]]:
665
+ """Shared shape for the rewrite ledgers.
666
+
667
+ Emits shared-baseline rows once (first source's input order, un-namespaced),
668
+ then each source's tree-local rows in source order and original input order
669
+ — preserving per-source transition order (§6 exception). ``rewrite_local``
670
+ namespaces a tree-local row; ``keep_row`` (optional) filters rows out
671
+ entirely (used to drop ``scope="run"`` verification rows).
672
+ """
673
+ baseline = baseline if baseline is not None else _baseline_canon(sources, rel)
674
+ emitted: set = set()
675
+ out: List[Dict[str, Any]] = []
676
+ for source in sources:
677
+ for row in _rows(source, rel):
678
+ if keep_row is not None and not keep_row(row):
679
+ continue
680
+ canon = _canon(row)
681
+ if canon in baseline and canon not in emitted:
682
+ emitted.add(canon)
683
+ out.append(row)
684
+ for source in sources:
685
+ for row in _rows(source, rel):
686
+ if keep_row is not None and not keep_row(row):
687
+ continue
688
+ if _canon(row) in baseline:
689
+ continue
690
+ out.append(rewrite_local(row, source))
691
+ return out
692
+
693
+
694
+ def _merge_step_runs(
695
+ sources: List[Source], step_baseline: set, attempt_maps: Dict[str, Dict[str, str]]
696
+ ) -> List[Dict[str, Any]]:
697
+ rel = EXECUTION_ARTIFACT_PATHS["step-runs"]
698
+
699
+ def rewrite(row: Dict[str, Any], source: Source) -> Dict[str, Any]:
700
+ amap = attempt_maps[source.source_id]
701
+ out = dict(row)
702
+ for key in ("attempt_id", "amends_attempt", "superseded_by"):
703
+ value = out.get(key)
704
+ if isinstance(value, str) and value in amap:
705
+ out[key] = amap[value]
706
+ return out
707
+
708
+ return _emit_baseline_then_local(sources, rel, rewrite, baseline=step_baseline)
709
+
710
+
711
+ def _merge_file_receipts(
712
+ sources: List[Source], attempt_maps: Dict[str, Dict[str, str]]
713
+ ) -> List[Dict[str, Any]]:
714
+ rel = EXECUTION_ARTIFACT_PATHS["file-receipts"]
715
+
716
+ def rewrite(row: Dict[str, Any], source: Source) -> Dict[str, Any]:
717
+ amap = attempt_maps[source.source_id]
718
+ out = dict(row)
719
+ rid = out.get("id")
720
+ if isinstance(rid, str):
721
+ out["id"] = source.prefix + rid
722
+ attempt = out.get("attempt_id")
723
+ if isinstance(attempt, str):
724
+ out["attempt_id"] = amap.get(attempt, attempt)
725
+ return out
726
+
727
+ return _emit_baseline_then_local(sources, rel, rewrite)
728
+
729
+
730
+ def _merge_verification_runs(
731
+ sources: List[Source], attempt_maps: Dict[str, Dict[str, str]]
732
+ ) -> List[Dict[str, Any]]:
733
+ rel = EXECUTION_ARTIFACT_PATHS["verification-runs"]
734
+
735
+ def keep(row: Dict[str, Any]) -> bool:
736
+ return row.get("scope") == "step" # drop run-level rows (§ledger-merge-rules)
737
+
738
+ def rewrite(row: Dict[str, Any], source: Source) -> Dict[str, Any]:
739
+ amap = attempt_maps[source.source_id]
740
+ out = dict(row)
741
+ rid = out.get("id")
742
+ if isinstance(rid, str):
743
+ out["id"] = source.prefix + rid
744
+ attempt = out.get("attempt_id")
745
+ if isinstance(attempt, str):
746
+ out["attempt_id"] = amap.get(attempt, attempt)
747
+ return out
748
+
749
+ return _emit_baseline_then_local(sources, rel, rewrite, keep_row=keep)
750
+
751
+
752
+ def _cr_copies(
753
+ source: Source, old_row: Dict[str, Any], new_row: Dict[str, Any]
754
+ ) -> List[Tuple[Path, str, Optional[str]]]:
755
+ """Physical files to copy for one command-receipt row.
756
+
757
+ ``by_attempt`` copies each stream file to its rebuilt canonical path;
758
+ ``content_addressed`` copies the content-hashed file to the same rel path.
759
+ Only rows already validated by ``analyze()`` reach here, so ``old_row``'s
760
+ stored paths are known-contained.
761
+ """
762
+ copies: List[Tuple[Path, str, Optional[str]]] = []
763
+ for path_key, sha_key in (("stdout_path", "stdout_sha256"), ("stderr_path", "stderr_sha256")):
764
+ old_rel = old_row.get(path_key)
765
+ if not isinstance(old_rel, str) or not old_rel:
766
+ continue
767
+ expected = old_row.get(sha_key)
768
+ expected = expected if isinstance(expected, str) else None
769
+ dst_rel = new_row.get(path_key)
770
+ if not isinstance(dst_rel, str):
771
+ continue
772
+ copies.append((source.root / old_rel, dst_rel, expected))
773
+ return copies
774
+
775
+
776
+ def _merge_command_receipts(
777
+ sources: List[Source], attempt_maps: Dict[str, Dict[str, str]], store: str
778
+ ) -> Tuple[List[Dict[str, Any]], List[Tuple[Path, str, Optional[str]]]]:
779
+ rel = EXECUTION_ARTIFACT_PATHS["command-receipts"]
780
+ baseline = _baseline_canon(sources, rel)
781
+ emitted: set = set()
782
+ rows: List[Dict[str, Any]] = []
783
+ copies: List[Tuple[Path, str, Optional[str]]] = []
784
+
785
+ for source in sources:
786
+ for row in _rows(source, rel):
787
+ canon = _canon(row)
788
+ if canon in baseline and canon not in emitted:
789
+ emitted.add(canon)
790
+ rows.append(row)
791
+ copies += _cr_copies(source, row, row)
792
+
793
+ for source in sources:
794
+ amap = attempt_maps[source.source_id]
795
+ for row in _rows(source, rel):
796
+ canon = _canon(row)
797
+ if canon in baseline:
798
+ continue
799
+ out = dict(row)
800
+ rid = out.get("id")
801
+ if isinstance(rid, str):
802
+ out["id"] = source.prefix + rid
803
+ attempt = out.get("attempt_id")
804
+ if isinstance(attempt, str):
805
+ out["attempt_id"] = amap.get(attempt, attempt)
806
+ if store == "by_attempt":
807
+ for path_key, stream in (("stdout_path", "stdout"), ("stderr_path", "stderr")):
808
+ if isinstance(out.get(path_key), str) and isinstance(out.get("id"), str) and isinstance(out.get("attempt_id"), str):
809
+ out[path_key] = f".agent/receipts/{out['attempt_id']}/{out['id']}.{stream}.txt"
810
+ rows.append(out)
811
+ copies += _cr_copies(source, row, out)
812
+ return rows, copies
813
+
814
+
815
+ def _merge_concat(sources: List[Source], rel: str) -> List[Dict[str, Any]]:
816
+ """Union concat-ledger rows, dedupe byte-identical duplicates (shared
817
+ baseline emits once), order by ``(timestamp, source_order, input_index)``."""
818
+ entries: List[Tuple[str, int, int, Dict[str, Any]]] = []
819
+ seen: set = set()
820
+ for order, source in enumerate(sources):
821
+ for index, row in enumerate(_rows(source, rel)):
822
+ canon = _canon(row)
823
+ if canon in seen:
824
+ continue
825
+ seen.add(canon)
826
+ entries.append((_row_timestamp(row), order, index, row))
827
+ entries.sort(key=lambda entry: (entry[0], entry[1], entry[2]))
828
+ return [entry[3] for entry in entries]
829
+
830
+
831
+ def _review_copy_path(canonical_state: str, state_dir: Any, path: str) -> str:
832
+ if isinstance(state_dir, str) and path.startswith(state_dir + "/"):
833
+ return f"{canonical_state}/{path[len(state_dir) + 1:]}"
834
+ return f"{canonical_state}/{path}"
835
+
836
+
837
+ def _merge_review_runs(
838
+ sources: List[Source],
839
+ ) -> Tuple[List[Dict[str, Any]], List[Tuple[Path, str, Optional[str]]]]:
840
+ rel = BASE_ARTIFACT_PATHS["review-runs"]
841
+ entries: List[Tuple[str, int, int, Dict[str, Any], Source]] = []
842
+ seen: set = set()
843
+ for order, source in enumerate(sources):
844
+ for index, row in enumerate(_rows(source, rel)):
845
+ canon = _canon(row)
846
+ if canon in seen: # byte-identical review_run_id row emits once (differing => collision)
847
+ continue
848
+ seen.add(canon)
849
+ entries.append((_row_timestamp(row), order, index, row, source))
850
+ entries.sort(key=lambda entry: (entry[0], entry[1], entry[2]))
851
+ rows: List[Dict[str, Any]] = []
852
+ copies: List[Tuple[Path, str, Optional[str]]] = []
853
+ for _, _, _, row, source in entries:
854
+ out = dict(row)
855
+ state_dir = row.get("state_dir")
856
+ review_run_id = row.get("review_run_id")
857
+ canonical_state = f".agent/reviews/{review_run_id}" if _safe_relative_path(review_run_id) else ".agent/reviews/review"
858
+ out["state_dir"] = canonical_state
859
+ manifest = row.get("manifest_path")
860
+ if isinstance(manifest, str):
861
+ sha = row.get("manifest_sha256")
862
+ new_manifest = _review_copy_path(canonical_state, state_dir, manifest)
863
+ out["manifest_path"] = new_manifest
864
+ copies.append((source.root / manifest, new_manifest, sha if isinstance(sha, str) else None))
865
+ artifacts = row.get("artifacts")
866
+ out_artifacts: List[Any] = []
867
+ for entry in artifacts if isinstance(artifacts, list) else []:
868
+ art = entry.get("path") if isinstance(entry, dict) else None
869
+ sha = entry.get("sha256") if isinstance(entry, dict) else None
870
+ if isinstance(art, str):
871
+ new_art = _review_copy_path(canonical_state, state_dir, art)
872
+ entry = dict(entry)
873
+ entry["path"] = new_art
874
+ copies.append((source.root / art, new_art, sha if isinstance(sha, str) else None))
875
+ out_artifacts.append(entry)
876
+ if isinstance(artifacts, list):
877
+ out["artifacts"] = out_artifacts
878
+ rows.append(out)
879
+ return rows, copies
880
+
881
+
882
+ def plan_write(sources: List[Source]) -> Dict[str, Any]:
883
+ """Pure planning pass: compute every canonical ledger row + every physical
884
+ file to copy, without writing. Assumes ``analyze()`` already passed."""
885
+ step_rel = EXECUTION_ARTIFACT_PATHS["step-runs"]
886
+ step_baseline = _baseline_canon(sources, step_rel)
887
+ attempt_maps = {source.source_id: _attempt_map(source, step_baseline) for source in sources}
888
+ store = _receipt_store(sources)
889
+
890
+ ledgers: Dict[str, List[Dict[str, Any]]] = {}
891
+ ledgers[step_rel] = _merge_step_runs(sources, step_baseline, attempt_maps)
892
+ ledgers[EXECUTION_ARTIFACT_PATHS["file-receipts"]] = _merge_file_receipts(sources, attempt_maps)
893
+ ledgers[EXECUTION_ARTIFACT_PATHS["verification-runs"]] = _merge_verification_runs(sources, attempt_maps)
894
+ cr_rows, receipt_copies = _merge_command_receipts(sources, attempt_maps, store)
895
+ ledgers[EXECUTION_ARTIFACT_PATHS["command-receipts"]] = cr_rows
896
+ for name in CONCAT_LEDGERS:
897
+ ledgers[_artifact_rel(name)] = _merge_concat(sources, _artifact_rel(name))
898
+ review_rows, review_copies = _merge_review_runs(sources)
899
+ ledgers[BASE_ARTIFACT_PATHS["review-runs"]] = review_rows
900
+
901
+ must_match: Dict[str, bytes] = {}
902
+ for name in MUST_MATCH:
903
+ rel = _artifact_rel(name)
904
+ path = sources[0].root / rel
905
+ if path.exists():
906
+ must_match[rel] = path.read_bytes()
907
+
908
+ return {
909
+ "ledgers": ledgers,
910
+ "receipt_copies": receipt_copies,
911
+ "review_copies": review_copies,
912
+ "must_match": must_match,
913
+ }
914
+
915
+
916
+ def _write_row_file(path: Path, rows: List[Dict[str, Any]]) -> None:
917
+ path.parent.mkdir(parents=True, exist_ok=True)
918
+ with path.open("w", encoding="utf-8") as handle:
919
+ for row in rows:
920
+ handle.write(json.dumps(row, sort_keys=True) + "\n")
921
+
922
+
923
+ def _stage_canonical(plan: Dict[str, Any], agent_dir: Path) -> None:
924
+ """Materialise the canonical .agent/ under ``agent_dir`` (a fresh dir).
925
+
926
+ Raises on any unsafe destination, copy hash mismatch, or missing source so
927
+ the caller aborts before swapping anything into place (fail closed, no
928
+ partial output).
929
+ """
930
+ staging_root = agent_dir.parent
931
+ for rel, data in plan["must_match"].items():
932
+ target = staging_root / rel
933
+ target.parent.mkdir(parents=True, exist_ok=True)
934
+ target.write_bytes(data)
935
+ for rel, rows in plan["ledgers"].items():
936
+ if not rows:
937
+ continue # an empty ledger is simply absent (matches single-tree runs)
938
+ _write_row_file(staging_root / rel, rows)
939
+ for src_abs, dst_rel, expected in plan["receipt_copies"] + plan["review_copies"]:
940
+ # Defense in depth: analyze() already fails closed on malformed input ids
941
+ # (so a rebuilt receipt dst_rel cannot contain a traversal), but the write
942
+ # consumer must never join an escaping relative path to the output either.
943
+ if not _safe_relative_path(dst_rel) or _contained_path(staging_root, dst_rel) is None:
944
+ raise ValueError(f"aggregate: unsafe canonical destination path: {dst_rel}")
945
+ dst = staging_root / dst_rel
946
+ dst.parent.mkdir(parents=True, exist_ok=True)
947
+ if not src_abs.exists():
948
+ raise FileNotFoundError(f"aggregate: source artifact vanished mid-write: {src_abs}")
949
+ shutil.copyfile(src_abs, dst)
950
+ if expected is not None and sha256_path(dst) != expected:
951
+ raise ValueError(f"aggregate: copied artifact hash mismatch: {dst_rel}")
952
+
953
+
954
+ def write_canonical(
955
+ sources: List[Source], output_root: Path, base_ref: Optional[str] = None
956
+ ) -> Dict[str, Any]:
957
+ """Validate, then atomically emit one canonical .agent/ into ``output_root``.
958
+
959
+ Returns the ``analyze()`` collision report (nothing written) when validation
960
+ fails, else ``{"status": "ok", "sources": [...], "written": {...}}``.
961
+
962
+ Precondition (design "Immutable inputs"): the caller guarantees the input
963
+ trees are quiescent for the duration of the call. ``analyze()`` and the
964
+ ``plan_write()`` pass each read the source ledgers independently; a source
965
+ mutated between them would bypass the validation gate. Aggregation never
966
+ writes to inputs, but it does not lock them — the orchestrator must only
967
+ aggregate completed, no-longer-active worktrees.
968
+ """
969
+ report = analyze(sources, output_root, base_ref=base_ref)
970
+ if report["status"] != "ok":
971
+ return report
972
+
973
+ plan = plan_write(sources)
974
+ output_root = Path(output_root)
975
+ staging = Path(tempfile.mkdtemp(prefix=".agent.aggregate.", dir=output_root))
976
+ final = output_root / ".agent"
977
+ backup: Optional[Path] = None
978
+ try:
979
+ agent_dir = staging / ".agent"
980
+ agent_dir.mkdir(parents=True, exist_ok=True)
981
+ _stage_canonical(plan, agent_dir)
982
+ # Provenance is generated fresh each run, never read/merged from inputs
983
+ # (contracts keeps "aggregation" out of the merge-path lists).
984
+ provenance = {
985
+ "schema_version": AGGREGATION_SCHEMA_VERSION,
986
+ "mode": "cross_worktree",
987
+ "source_count": len(sources),
988
+ "sources": report["sources"],
989
+ }
990
+ write_json(staging / ARTIFACT_PATHS["aggregation"], provenance)
991
+ if final.exists():
992
+ backup = output_root / (staging.name + ".bak")
993
+ os.replace(final, backup)
994
+ os.replace(agent_dir, final)
995
+ if backup is not None:
996
+ shutil.rmtree(backup, ignore_errors=True) # success: drop the superseded tree
997
+ backup = None
998
+ except BaseException:
999
+ if backup is not None and not final.exists():
1000
+ try:
1001
+ os.replace(backup, final) # restore the pre-existing tree
1002
+ backup = None
1003
+ except OSError:
1004
+ pass # restore failed: leave the .bak on disk for manual recovery, never delete it
1005
+ raise
1006
+ finally:
1007
+ # Only the staging dir is unconditionally removed. A surviving ``backup``
1008
+ # here means a restore failed, so it is the operator's last copy of the
1009
+ # pre-existing tree and must be preserved.
1010
+ shutil.rmtree(staging, ignore_errors=True)
1011
+
1012
+ written = {rel: len(rows) for rel, rows in plan["ledgers"].items() if rows}
1013
+ written["receipts"] = len(plan["receipt_copies"])
1014
+ written["review_files"] = len(plan["review_copies"])
1015
+ return {"status": "ok", "sources": report["sources"], "written": written}