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/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
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}
|