worktree-controller 1.30.4__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 (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,442 @@
1
+ """Append-only resume attempts. A started coordinator is not a destructive action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from . import cleanup_evidence as e
10
+ from . import preservation as p
11
+ from .metadata_relocation import historical_reference
12
+ from .models import ControllerError
13
+ from .semantics import managed_record
14
+ from .store import MetadataStore
15
+
16
+ CONTRACT = "cleanup-resume-attempt/v1"
17
+ RECONCILIATION_CONTRACT = "started-no-action-recovery/v1"
18
+ TYPED_NO_ACTION = "typed-cleanup-no-action/v1"
19
+
20
+
21
+ def _typed_refusal(store: MetadataStore, journal: dict) -> dict | None:
22
+ path = store.completion_receipts_dir / f"{journal['transaction_id']}.json"
23
+ if not path.exists():
24
+ if journal.get("failure"):
25
+ raise blocked("failed typed cleanup is missing its refusal receipt")
26
+ return None
27
+ receipt, digest = e.read(path)
28
+ expected = {
29
+ "schema_version": 1, "receipt_id": journal["transaction_id"],
30
+ "contract": "typed-disposable-resource/v1", "result": "FAILED_OR_PARTIAL",
31
+ "plan": journal["cleanup_plan"], "actions": journal["actions"],
32
+ "action_evidence": [], "failure": journal.get("failure"),
33
+ }
34
+ checks = receipt.get("runtime_checks")
35
+ # A topology refusal happens after the first action's runtime check was recorded.
36
+ first = journal["actions"][0]["path"] if journal.get("actions") else None
37
+ one_check = (isinstance(checks, list) and len(checks) == 1 and isinstance(checks[0], dict)
38
+ and checks[0].get("resource_path") == first)
39
+ topology_refusal = (journal.get("failure") or {}).get("code") == "HARDLINK_TOPOLOGY_BLOCKED"
40
+ if _verify_refusal(journal):
41
+ checks_ok = one_check
42
+ else:
43
+ checks_ok = checks == [] or (topology_refusal and one_check)
44
+ if (not journal.get("failure") or not checks_ok
45
+ or any(receipt.get(key) != value for key, value in expected.items())):
46
+ raise blocked("typed receipt does not prove refusal before the action boundary")
47
+ return {"path": str(path), "sha256": digest}
48
+
49
+
50
+ def _verify_refusal(journal: dict) -> bool:
51
+ """A runtime before 1.28.0 marked the first action STARTED before verifying topology.
52
+
53
+ Only that exact refusal is accepted: the first action failed with
54
+ HARDLINK_TOPOLOGY_BLOCKED and every later action is still PENDING. Its only
55
+ evidence must be the BEFORE receipt of that action (see ``_verify_boundary``).
56
+ """
57
+ plan = journal.get("cleanup_plan", {})
58
+ paths = plan.get("cleanup_paths", [])
59
+ return (bool(paths) and (journal.get("failure") or {}).get("code") == "HARDLINK_TOPOLOGY_BLOCKED"
60
+ and journal.get("actions") == [{"path": paths[0], "status": "FAILED_OR_PARTIAL"}]
61
+ + [{"path": name, "status": "PENDING"} for name in paths[1:]])
62
+
63
+
64
+ def _verify_boundary(store: MetadataStore, journal: dict) -> dict | None:
65
+ """The single BEFORE receipt left by a topology refusal, bound to the plan's topology."""
66
+ from . import hardlinks
67
+
68
+ action_dir = store.cleanup_actions_dir / journal["transaction_id"]
69
+ if not _verify_refusal(journal):
70
+ return None
71
+ if not action_dir.is_dir() or sorted(p.name for p in action_dir.iterdir()) != ["0000-before.json"]:
72
+ raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
73
+ path = action_dir / "0000-before.json"
74
+ receipt, digest = e.read(path)
75
+ topology = journal["cleanup_plan"].get("hardlink_topology")
76
+ if (set(receipt) != {"contract", "phase", "execution_state", "resource", "topology", "timestamp"}
77
+ or receipt["phase"] != "BEFORE" or receipt["execution_state"] != "ACTION_STARTED"
78
+ or receipt["resource"] != journal["actions"][0]["path"]
79
+ or not isinstance(topology, dict) or receipt["topology"] != topology
80
+ or receipt["contract"] != hardlinks.mutation_contract(topology)):
81
+ raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
82
+ return {"path": str(path), "sha256": digest}
83
+
84
+
85
+ def _typed_journal(store: MetadataStore, transaction: str, digest: str) -> dict:
86
+ if str(uuid.UUID(transaction)) != transaction:
87
+ raise blocked("canonical transaction identity required")
88
+ journal, _ = e.read(store.transactions_dir / f"{transaction}.json", digest)
89
+ plan = journal.get("cleanup_plan", {})
90
+ actions = [{"path": name, "status": "PENDING"} for name in plan.get("cleanup_paths", [])]
91
+ action_dir = store.cleanup_actions_dir / transaction
92
+ if (journal.get("transaction_id") != transaction or journal.get("command") != "resource-clean"
93
+ or journal.get("phase") != "PREPARED" or plan.get("contract") != "typed-disposable-resource/v1"
94
+ or journal.get("allocation_id") != plan.get("allocation_id")
95
+ or journal.get("target_path") != plan.get("target_path")
96
+ or not actions):
97
+ raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
98
+ if _verify_refusal(journal):
99
+ _verify_boundary(store, journal)
100
+ elif journal.get("actions") != actions or (action_dir.exists() and list(action_dir.iterdir())):
101
+ raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
102
+ _typed_refusal(store, journal)
103
+ return journal
104
+
105
+
106
+ def typed_terminal(store: MetadataStore, transaction: str) -> bool:
107
+ path = store.cleanup_recoveries_dir / transaction / "typed-no-action.json"
108
+ if not path.exists():
109
+ return False
110
+ record, _ = e.read(path)
111
+ if (record.get("contract") != TYPED_NO_ACTION
112
+ or record.get("state") != "TYPED_ATTEMPT_SUPERSEDED"
113
+ or record.get("plan_id") != p._sha({k: v for k, v in record.items()
114
+ if k not in {"plan_id", "runtime_evidence"}})
115
+ or record.get("transaction_id") != transaction
116
+ or record.get("completed_action_will_replay") is not False):
117
+ raise blocked("invalid typed no-action reconciliation")
118
+ journal = _typed_journal(store, transaction, record["transaction_sha256"])
119
+ if (record["allocation_id"] != journal["allocation_id"]
120
+ or record["original_plan_id"] != journal["cleanup_plan"]["plan_id"]
121
+ or record.get("refusal_receipt") != _typed_refusal(store, journal)
122
+ or record.get("verify_boundary_receipt") != _verify_boundary(store, journal)):
123
+ raise blocked("typed no-action evidence belongs to another transaction")
124
+ return True
125
+
126
+
127
+ def typed_plan(repo: Path, store: MetadataStore, transaction: str, digest: str) -> dict:
128
+ from . import hardlinks, lifecycle, transaction_scope
129
+ from .git_backend import inventory
130
+
131
+ journal = _typed_journal(store, transaction, digest)
132
+ previous = journal["cleanup_plan"]
133
+ repository = inventory(repo)
134
+ snapshot = store._load_unlocked()
135
+ item, record = managed_record(repository, snapshot, Path(journal["target_path"]))
136
+ if (transaction_scope.conflicts(store, allocation=record.allocation_id, target=item.path) != [journal]
137
+ or record.allocation_id != previous["allocation_id"]
138
+ or item.head != previous["expected_head"]):
139
+ raise blocked("conflicting transaction or changed allocation identity")
140
+ current = lifecycle._typed_cleanup_plan_locked(
141
+ repository, snapshot, store, item, record, owner=previous["owner"],
142
+ expected_record_version=previous["expected_record_version"], check_transaction=False)
143
+ if current["blockers"]:
144
+ raise blocked("typed no-action preflight blocked: " + ", ".join(row["code"] for row in current["blockers"]))
145
+ if (p._sha(current["resources"]) != p._sha(previous["resources"])
146
+ or current["cleanup_paths"] != previous["cleanup_paths"]):
147
+ raise blocked("typed resource baseline changed")
148
+ before, after = previous["hardlink_topology"], current["hardlink_topology"]
149
+ hardlinks.validate(before)
150
+ hardlinks.validate(after)
151
+ if (before["anchors"] != after["anchors"] or before["resources"] != after["resources"]
152
+ or before["groups"].keys() != after["groups"].keys()):
153
+ raise blocked("typed cleanup scope is no longer intact")
154
+ for key, old in before["groups"].items():
155
+ new = after["groups"][key]
156
+ if (old["members"] != new["members"] or old["content"] != new["content"]
157
+ or any(a != b for i, (a, b) in enumerate(zip(old["identity"], new["identity"], strict=True))
158
+ if i not in {5, 8})):
159
+ raise blocked("typed cleanup inode, member or content changed")
160
+ # Only ctime (5) and nlink (8) may differ, and only where links outside the
161
+ # scope exist on both sides: their churn (added, or added and removed again
162
+ # by another environment sharing the same files) explains the change.
163
+ if old["identity"] != new["identity"] and not (
164
+ old.get("external_links", 0) > 0 and new.get("external_links", 0) > 0
165
+ ):
166
+ raise blocked("unexplained in-scope metadata change")
167
+ value = {
168
+ "contract": TYPED_NO_ACTION, "state": "TYPED_ATTEMPT_SUPERSEDED",
169
+ "transaction_id": transaction, "transaction_sha256": digest,
170
+ "allocation_id": record.allocation_id, "original_plan_id": previous["plan_id"],
171
+ "current_topology_sha256": after["sha256"], "in_scope": "EXACT_IDENTITY_CONTENT_MATCH",
172
+ "external_link_history": "NOT_PROVEN", "runtime_evidence": current["runtime_evidence"],
173
+ "completed_action_will_replay": False,
174
+ "refusal_receipt": _typed_refusal(store, journal),
175
+ }
176
+ boundary = _verify_boundary(store, journal)
177
+ if boundary is not None:
178
+ # Refused by topology verification after the action was marked started; the
179
+ # exact current tree and in-scope topology above prove no deletion began.
180
+ value["verify_boundary_receipt"] = boundary
181
+ # Host observation timestamps/visibility gaps are evidence, not authority for
182
+ # the operation key. Bind the stable current topology and allocation baseline.
183
+ return {**value, "plan_id": p._sha({k: v for k, v in value.items() if k != "runtime_evidence"})}
184
+
185
+
186
+ def reconcile_typed_locked(repo: Path, store: MetadataStore, transaction: str, digest: str,
187
+ *, dry_run: bool, expected_plan_id: str | None = None) -> dict:
188
+ path = store.cleanup_recoveries_dir / transaction / "typed-no-action.json"
189
+ if typed_terminal(store, transaction):
190
+ record, _ = e.read(path)
191
+ if record["transaction_sha256"] != digest:
192
+ raise blocked("reconciliation transaction digest differs")
193
+ return {"code": "TYPED_ATTEMPT_SUPERSEDED", "reconciliation_receipt": reference(path)}
194
+ plan = typed_plan(repo, store, transaction, digest)
195
+ if dry_run:
196
+ return {"code": "TYPED_NO_ACTION_RECOVERABLE", "plan": plan}
197
+ if expected_plan_id != plan["plan_id"]:
198
+ raise blocked("typed no-action reconciliation requires its exact fresh plan")
199
+ receipt = e.write(path.parent, path.name, plan)
200
+ return {"code": "TYPED_ATTEMPT_SUPERSEDED", "reconciliation_receipt": receipt}
201
+
202
+
203
+ def reconcile_typed(repo: Path, transaction: str, digest: str, *, dry_run: bool,
204
+ expected_plan_id: str | None = None) -> dict:
205
+ from .controller import status
206
+ from .git_backend import inventory
207
+
208
+ store = MetadataStore(inventory(repo).common_dir)
209
+ with store.lock(exclusive=not dry_run):
210
+ decision = reconcile_typed_locked(repo, store, transaction, digest, dry_run=dry_run,
211
+ expected_plan_id=expected_plan_id)
212
+ result = status(repo)
213
+ result.update(command="resource-recover", decision=decision)
214
+ return result
215
+
216
+
217
+ def blocked(message: str) -> ControllerError:
218
+ return ControllerError("RESUME_ATTEMPT_BLOCKED", message)
219
+
220
+
221
+ def reference(path: Path) -> dict[str, str]:
222
+ _, digest = e.read(path)
223
+ return {"path": str(path), "sha256": digest}
224
+
225
+
226
+ def chain(directory: Path, record_sha: str) -> list[dict[str, Any]]:
227
+ """Validate a single immutable succession, without relying on timestamps."""
228
+ nodes = []
229
+ legacy = directory / "started.json"
230
+ if legacy.exists():
231
+ value, digest = e.read(legacy)
232
+ if (
233
+ value.get("contract") != "re-preservation-recovery/v1"
234
+ or value.get("record_sha256") != record_sha
235
+ ):
236
+ raise blocked("legacy started identity differs")
237
+ nodes.append(
238
+ {
239
+ "attempt_id": str(uuid.uuid5(uuid.UUID(directory.name), digest)),
240
+ "started": historical_reference(legacy, digest),
241
+ "predecessor": None,
242
+ }
243
+ )
244
+ parent = directory / "attempts"
245
+ if parent.exists():
246
+ for entry in parent.iterdir():
247
+ if str(uuid.UUID(entry.name)) != entry.name:
248
+ raise blocked("unknown attempt entry")
249
+ value, digest = e.read(entry / "started.json")
250
+ if (
251
+ value.get("contract") != CONTRACT
252
+ or value.get("phase") != "ATTEMPT_STARTED"
253
+ or value.get("record_sha256") != record_sha
254
+ or value.get("attempt_id") != entry.name
255
+ ):
256
+ raise blocked("attempt started identity differs")
257
+ predecessor = None
258
+ if value.get("supersedes"):
259
+ ref = value["supersedes"]
260
+ reconciled, _ = e.read(Path(ref["path"]), ref["sha256"])
261
+ validate_reconciliation(directory, reconciled, record_sha)
262
+ predecessor = reconciled["attempt"]["started"]
263
+ nodes.append(
264
+ {
265
+ "attempt_id": entry.name,
266
+ "started": historical_reference(entry / "started.json", digest),
267
+ "predecessor": predecessor,
268
+ }
269
+ )
270
+ ordered = []
271
+ previous = None
272
+ while nodes:
273
+ successors = [node for node in nodes if node["predecessor"] == previous]
274
+ if len(successors) != 1:
275
+ raise blocked("ambiguous or disconnected attempt succession")
276
+ node = successors[0]
277
+ nodes.remove(node)
278
+ ordered.append(node)
279
+ previous = node["started"]
280
+ return ordered
281
+
282
+
283
+ def validate_reconciliation(directory: Path, value: dict[str, Any], record_sha: str) -> None:
284
+ if (
285
+ value.get("contract") != RECONCILIATION_CONTRACT
286
+ or value.get("state") != "STARTED_NO_ACTION_RECOVERABLE"
287
+ or value.get("record_sha256") != record_sha
288
+ or value.get("safe_to_supersede") is not True
289
+ or value.get("historical_refusal") != "NOT_RECONSTRUCTED"
290
+ or value.get("plan_id")
291
+ != p._sha({k: v for k, v in value.items() if k not in {"plan_id", "timestamp"}})
292
+ or value.get("completed_action_will_replay") is not False
293
+ or value.get("verification")
294
+ != {
295
+ "filesystem": "EXACT_BASELINE_MATCH",
296
+ "preservation": "PASS",
297
+ "runtime_authority": "PASS",
298
+ "git": "CLEAN",
299
+ "new_transaction": False,
300
+ "new_completion": False,
301
+ "destructive_action_evidence": False,
302
+ }
303
+ ):
304
+ raise blocked("invalid no-action reconciliation")
305
+ attempt = value["attempt"]
306
+ e.read(Path(attempt["started"]["path"]), attempt["started"]["sha256"])
307
+ expected = directory / "reconciliations" / (attempt["attempt_id"] + ".json")
308
+ _, digest = e.read(expected)
309
+ if not e.matches_reference({"path": value.get("path", ""), "sha256": digest}, expected):
310
+ raise blocked("no-action reconciliation path differs")
311
+ for ref in value["evidence"]:
312
+ e.read(Path(ref["path"]), ref["sha256"])
313
+
314
+
315
+ def authorization(directory: Path, record_sha: str) -> dict[str, str] | None:
316
+ attempts = chain(directory, record_sha)
317
+ if not attempts:
318
+ return None
319
+ latest = attempts[-1]
320
+ path = directory / "reconciliations" / (latest["attempt_id"] + ".json")
321
+ if not path.exists():
322
+ raise blocked(
323
+ "started attempt requires explicit no-action reconciliation before supersession"
324
+ )
325
+ value, digest = e.read(path)
326
+ validate_reconciliation(directory, value, record_sha)
327
+ if value["attempt"] != latest:
328
+ raise blocked("reconciliation covers a different attempt")
329
+ return {"path": str(path), "sha256": digest}
330
+
331
+
332
+ def no_action_evidence(store: MetadataStore, plan: dict[str, Any]) -> dict[str, Any]:
333
+ """No receipt alone proves nothing: examine journals AND action evidence.
334
+
335
+ Unknown/unbound action directories fail closed. Recognized unrelated allocation
336
+ receipts do not grant or deny this allocation's authority.
337
+ """
338
+ original = plan["recovery"]["original_transaction_id"]
339
+ known = {original}
340
+ for parent in (store.transactions_dir, store.completion_receipts_dir):
341
+ if not parent.exists():
342
+ continue
343
+ for path in parent.iterdir():
344
+ value, _ = e.read(path)
345
+ if path.stem == original:
346
+ continue # The caller has verified both exact historical byte hashes.
347
+ candidate = value.get("cleanup_plan", value.get("plan", {}))
348
+ if (
349
+ value.get("allocation_id") == plan["allocation_id"]
350
+ or candidate.get("allocation_id") == plan["allocation_id"]
351
+ or candidate.get("target_path") == plan["target_path"]
352
+ ):
353
+ raise blocked(
354
+ "additional transaction/completion evidence forbids no-action recovery"
355
+ )
356
+ other_allocation = candidate.get("allocation_id", value.get("allocation_id"))
357
+ if other_allocation and other_allocation != plan["allocation_id"]:
358
+ known.add(path.stem)
359
+ actions = store.cleanup_actions_dir
360
+ if actions.exists():
361
+ for entry in actions.iterdir():
362
+ if entry.name not in known:
363
+ raise blocked("unbound destructive-action evidence forbids no-action recovery")
364
+ if entry.name == original and list(entry.iterdir()):
365
+ # This narrow legacy receipt has no action graph. Do not attribute
366
+ # newly discovered evidence to its already completed backend step.
367
+ raise blocked("unattributed original action evidence forbids no-action recovery")
368
+ return {"new_transaction": False, "new_completion": False, "destructive_action_evidence": False}
369
+
370
+
371
+ def action_state(store: MetadataStore, attempt_id: str) -> str | None:
372
+ for parent in (store.transactions_dir, store.completion_receipts_dir):
373
+ if not parent.exists():
374
+ continue
375
+ for path in parent.iterdir():
376
+ value, _ = e.read(path)
377
+ plan = value.get("cleanup_plan", value.get("plan", {}))
378
+ if plan.get("recovery", {}).get("attempt_id") == attempt_id:
379
+ states = [action.get("status") for action in value.get("actions", [])]
380
+ if states and all(state == "COMPLETED" for state in states):
381
+ return "ACTION_COMPLETED"
382
+ if any(state in {"FAILED_OR_PARTIAL", "COMPLETED"} for state in states):
383
+ return "PARTIAL_ACTION"
384
+ if "STARTED" in states:
385
+ return "ACTION_STARTED"
386
+ return None
387
+
388
+
389
+ def evidence_references(
390
+ store: MetadataStore, directory: Path, plan: dict[str, Any]
391
+ ) -> list[dict[str, str]]:
392
+ recovery = plan["recovery"]
393
+ transaction = recovery["original_transaction_id"]
394
+ old, _ = e.read(store.completion_receipts_dir / (transaction + ".json"))
395
+ paths = [
396
+ directory / "record.json",
397
+ store.transactions_dir / (transaction + ".json"),
398
+ store.completion_receipts_dir / (transaction + ".json"),
399
+ ]
400
+ for evidence in (old["plan"]["preservation_evidence"], plan["preservation_evidence"]):
401
+ generation = Path(evidence["receipt_path"]).parent
402
+ paths.extend(generation / name for name in ("receipt.json", "plan.json", "manifest.json"))
403
+ paths.extend(directory.glob("**/started.json"))
404
+ paths.extend(directory.glob("attempts/*/underlying.json"))
405
+ paths.extend(directory.glob("attempts/*/result.json"))
406
+ return [reference(path) for path in sorted(set(paths))]
407
+
408
+
409
+ def underlying(
410
+ result: dict[str, Any] | None = None, exception: Exception | None = None
411
+ ) -> dict[str, Any]:
412
+ """The cleanup call is in-process; no subprocess exit/stdout/stderr is fabricated.
413
+
414
+ Controller errors and plan blockers contain governance metadata, never provider
415
+ bytes. Arbitrary unexpected exception text is represented by type/digest only.
416
+ """
417
+ decision = (result or {}).get("decision", {})
418
+ error = None
419
+ if exception is not None:
420
+ error = {
421
+ "code": getattr(exception, "code", "COORDINATOR_EXCEPTION"),
422
+ "exception_type": type(exception).__name__,
423
+ "errno": getattr(exception, "errno", None),
424
+ }
425
+ if isinstance(exception, ControllerError):
426
+ error["message"] = str(exception)
427
+ else:
428
+ error["message_sha256"] = p._sha(str(exception))
429
+ blockers = decision.get("plan", {}).get("blockers", [])
430
+ return {
431
+ "transport": "in-process-python",
432
+ "status": decision.get("code", "EXCEPTION"),
433
+ "exit_code": None,
434
+ "exit_code_reason": "no subprocess was launched",
435
+ "completion_receipt_id": decision.get("receipt_id"),
436
+ "blockers": blockers,
437
+ "transaction_conflicts": getattr(exception, "transaction_conflicts", []),
438
+ "errors": (result or {}).get("errors", []),
439
+ "exception": error,
440
+ "stdout_summary": {"decision_code": decision.get("code"), "blockers": blockers},
441
+ "stderr_summary": error,
442
+ }
@@ -0,0 +1,75 @@
1
+ """Exclusive, FD-relative immutable cleanup action evidence."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from . import hardlinks
7
+ from . import preservation as p
8
+ from .preserved_cleanup import _document
9
+ from .store import now_iso
10
+
11
+
12
+ def write(directory: Path, name: str, value: dict[str, Any]) -> dict[str, str]:
13
+ with p._absolute_directory(directory, create=True) as chain:
14
+ digest = p._write_once(chain[-1], name, value)
15
+ return {"path": str(directory / name), "sha256": digest}
16
+
17
+
18
+ def read(path: Path, digest: str | None = None) -> tuple[dict[str, Any], str]:
19
+ from .metadata_relocation import relocated
20
+
21
+ path = relocated(path, digest)
22
+ with p._absolute_directory(path.parent) as chain:
23
+ value, actual = _document(chain[-1], path.name)
24
+ if digest is not None and actual != digest:
25
+ raise hardlinks._blocked("immutable cleanup evidence bytes changed")
26
+ return value, actual
27
+
28
+
29
+ def matches_reference(ref: dict, expected: Path) -> bool:
30
+ from .metadata_relocation import relocated
31
+
32
+ return (
33
+ bool(ref.get("sha256")) and relocated(Path(ref.get("path", "")), ref["sha256"]) == expected
34
+ )
35
+
36
+
37
+ def before(directory: Path, step: int, resource: str, topology: dict[str, Any]) -> dict[str, str]:
38
+ return write(
39
+ directory,
40
+ f"{step:04d}-before.json",
41
+ {
42
+ "contract": hardlinks.mutation_contract(topology),
43
+ "phase": "BEFORE",
44
+ "execution_state": "ACTION_STARTED",
45
+ "resource": resource,
46
+ "topology": topology,
47
+ "timestamp": now_iso(),
48
+ },
49
+ )
50
+
51
+
52
+ def after(
53
+ directory: Path,
54
+ step: int,
55
+ resource: str,
56
+ previous: dict[str, Any],
57
+ current: dict[str, Any],
58
+ before_receipt: dict[str, str],
59
+ external_survivors: dict[str, Any] | None = None,
60
+ ) -> dict[str, str]:
61
+ mutation = hardlinks.transition(previous, current, resource, external_survivors)
62
+ return write(
63
+ directory,
64
+ f"{step:04d}-after.json",
65
+ {
66
+ "contract": mutation["contract"],
67
+ "phase": "COMPLETED",
68
+ "execution_state": "ACTION_COMPLETED",
69
+ "resource": resource,
70
+ "topology": current,
71
+ "before": before_receipt,
72
+ "mutation": mutation,
73
+ "timestamp": now_iso(),
74
+ },
75
+ )