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,592 @@
1
+ """Append-only recovery evidence for exact interrupted tracked-source attempts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import stat
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from . import cleanup_evidence as e
13
+ from . import preservation as p
14
+ from . import source_preservation as s
15
+ from .metadata_relocation import historical_reference
16
+ from .models import ControllerError
17
+ from .store import MetadataStore, now_iso
18
+
19
+ CONTRACT = "tracked-source-no-action-recovery/v1"
20
+ STATE = "STARTED_NO_ACTION_RECOVERABLE"
21
+ PROGRESS_CONTRACT = "tracked-source-progress-recovery/v1"
22
+ PROGRESS_STATE = "SOURCE_ATTEMPT_PRESERVED"
23
+
24
+
25
+ def _blocked() -> ControllerError:
26
+ return ControllerError("SOURCE_RECOVERY_BLOCKED", "exact source recovery guard refused")
27
+
28
+
29
+ def _directory(store: MetadataStore, transaction: str) -> Path:
30
+ try:
31
+ valid = str(uuid.UUID(transaction)) == transaction
32
+ except (ValueError, TypeError, AttributeError):
33
+ valid = False
34
+ if not valid:
35
+ raise _blocked()
36
+ return store.root / "source-cleanup" / transaction
37
+
38
+
39
+ def _attempt_inputs(
40
+ store: MetadataStore, transaction: str, receipt_sha: str
41
+ ) -> tuple[dict, dict, dict, dict]:
42
+ directory = _directory(store, transaction)
43
+ receipt_path = store.completion_receipts_dir / f"{transaction}.json"
44
+ receipt, digest = e.read(receipt_path, receipt_sha)
45
+ journal_path = store.transactions_dir / f"{transaction}.json"
46
+ ref = receipt.get("journal", {})
47
+ if not e.matches_reference(ref, journal_path):
48
+ raise _blocked()
49
+ journal, _ = e.read(journal_path, ref["sha256"])
50
+ plan_ref = journal.get("cleanup_plan", {}).get("source_plan", {})
51
+ if not e.matches_reference(plan_ref, directory / "plan.json"):
52
+ raise _blocked()
53
+ cleanup, _ = e.read(directory / "plan.json", plan_ref["sha256"])
54
+ identity = cleanup["preservation_plan"]["authority"]["identity"]
55
+ if (
56
+ receipt.get("contract") != "tracked-source-cleanup/v1"
57
+ or receipt.get("receipt_id") != transaction
58
+ or receipt.get("result") != "FAILED_OR_PARTIAL"
59
+ or receipt.get("kind") != "tracked_source_cleanup"
60
+ or journal.get("transaction_id") != transaction
61
+ or journal.get("command") != "resource-clean"
62
+ or journal.get("phase") != "PREPARED"
63
+ or journal.get("actions", []) != []
64
+ or cleanup.get("contract") != "tracked-source-cleanup/v1"
65
+ or journal.get("cleanup_plan", {}).get("contract") != cleanup["contract"]
66
+ or journal.get("repository_id")
67
+ != store._read_json(store.root / "repo.json")["repository_id"]
68
+ or any(
69
+ journal.get(key) != identity[key]
70
+ for key in (
71
+ "repository_id",
72
+ "allocation_id",
73
+ "target_path",
74
+ "expected_head",
75
+ "expected_record_version",
76
+ )
77
+ )
78
+ or any(
79
+ journal["cleanup_plan"].get(key) != journal[key]
80
+ for key in (
81
+ "repository_id",
82
+ "allocation_id",
83
+ "target_path",
84
+ "expected_head",
85
+ "expected_record_version",
86
+ )
87
+ )
88
+ or cleanup.get("plan_id")
89
+ != p._sha(
90
+ {key: value for key, value in cleanup.items() if key not in {"plan_id", "result"}}
91
+ )
92
+ ):
93
+ raise _blocked()
94
+ refs = {
95
+ "journal": ref,
96
+ "cleanup_receipt": historical_reference(receipt_path, digest),
97
+ "cleanup_plan": plan_ref,
98
+ }
99
+ return receipt, journal, cleanup, refs
100
+
101
+
102
+ def _entries(store: MetadataStore, transaction: str, allowed: set[str]) -> None:
103
+ with p._absolute_directory(_directory(store, transaction)) as chain:
104
+ if not set(os.listdir(chain[-1].fd)) <= allowed:
105
+ raise _blocked()
106
+ action_directory = store.cleanup_actions_dir / transaction
107
+ if action_directory.exists() and any(action_directory.iterdir()):
108
+ raise _blocked()
109
+
110
+
111
+ def _inputs(store: MetadataStore, transaction: str, receipt_sha: str) -> tuple[dict, dict, dict]:
112
+ receipt, journal, cleanup, refs = _attempt_inputs(store, transaction, receipt_sha)
113
+ if (
114
+ receipt.get("error_code") != "INTERRUPTED_TRANSACTION"
115
+ or receipt.get("actions") != []
116
+ or receipt.get("checks") != []
117
+ ):
118
+ raise _blocked()
119
+ # BEFORE evidence precedes destructive FDs; empty receipt actions alone do not suffice.
120
+ _entries(store, transaction, {"plan.json", "reconciliation.json"})
121
+ return journal, cleanup, refs
122
+
123
+
124
+ def _core(value: dict) -> dict:
125
+ return {
126
+ key: item
127
+ for key, item in value.items()
128
+ if key not in {"plan_id", "runtime_evidence", "verified_at"}
129
+ }
130
+
131
+
132
+ def _reconciled(
133
+ store: MetadataStore, transaction: str, *, verify_generation: bool = False
134
+ ) -> dict | None:
135
+ path = _directory(store, transaction) / "reconciliation.json"
136
+ if not path.exists():
137
+ return None
138
+ value, _ = e.read(path)
139
+ journal, cleanup, refs = _inputs(
140
+ store, transaction, value["original_evidence"]["cleanup_receipt"]["sha256"]
141
+ )
142
+ plan = cleanup["preservation_plan"]
143
+ if (
144
+ value.get("contract") != CONTRACT
145
+ or value.get("state") != STATE
146
+ or value.get("original_transaction_id") != transaction
147
+ or value.get("original_evidence") != refs
148
+ or value.get("allocation_source_identity") != plan["authority"]["identity"]
149
+ or value.get("source_state_sha256") != p._sha(plan["source_tree"])
150
+ or value.get("preservation_receipt_sha256") != cleanup["preservation_receipt_sha256"]
151
+ or value.get("preservation_generation") != plan["destination"]
152
+ or value.get("plan_id") != p._sha(_core(value))
153
+ or value.get("new_attempt_id") != str(uuid.uuid5(uuid.UUID(transaction), p._sha(refs)))
154
+ or value.get("safe_to_supersede") is not True
155
+ or value.get("original_attempt_will_replay") is not False
156
+ or value.get("actions") != []
157
+ or value.get("failure_reason") != "transaction-scope coordinator refusal"
158
+ or value.get("verification")
159
+ != {
160
+ "filesystem": "EXACT_SOURCE_BASELINE_MATCH",
161
+ "index": "UNCHANGED",
162
+ "head": "UNCHANGED",
163
+ "preservation": "PASS",
164
+ "runtime_authority": "PASS",
165
+ "destructive_action_evidence": False,
166
+ }
167
+ ):
168
+ raise _blocked()
169
+ if verify_generation:
170
+ s._verify_generation(plan, cleanup["preservation_receipt_sha256"])
171
+ return value
172
+
173
+
174
+ def terminal_state(
175
+ store: MetadataStore, transaction: str, *, verify_generation: bool = False
176
+ ) -> str | None:
177
+ if _progress_reconciled(store, transaction, verify_generation=verify_generation) is not None:
178
+ return PROGRESS_STATE
179
+ if _reconciled(store, transaction, verify_generation=verify_generation) is not None:
180
+ return "SOURCE_ATTEMPT_SUPERSEDED"
181
+ return None
182
+
183
+
184
+ def terminal(store: MetadataStore, transaction: str, *, verify_generation: bool = False) -> bool:
185
+ return terminal_state(store, transaction, verify_generation=verify_generation) is not None
186
+
187
+
188
+ def authorization(store: MetadataStore, plan: dict, receipt_sha: str) -> dict | None:
189
+ """Bind the only unused successor grant; a prior attempt can never be replayed."""
190
+ grants = []
191
+ for path in sorted((store.root / "source-cleanup").glob("*/reconciliation.json")):
192
+ value = _reconciled(store, path.parent.name)
193
+ if (
194
+ value["allocation_source_identity"]["allocation_id"]
195
+ != plan["authority"]["identity"]["allocation_id"]
196
+ ):
197
+ continue
198
+ successor = _directory(store, value["new_attempt_id"])
199
+ if successor.exists():
200
+ continue # Its own journal/completion governs; never reuse this identity.
201
+ if (
202
+ value["allocation_source_identity"] != plan["authority"]["identity"]
203
+ or value["source_state_sha256"] != p._sha(plan["source_tree"])
204
+ or value["preservation_receipt_sha256"] != receipt_sha
205
+ ):
206
+ raise _blocked()
207
+ _, digest = e.read(path)
208
+ grants.append(
209
+ {"path": str(path), "sha256": digest, "new_attempt_id": value["new_attempt_id"]}
210
+ )
211
+ if len(grants) > 1:
212
+ raise _blocked()
213
+ return grants[0] if grants else None
214
+
215
+
216
+ def plan_reconciliation(
217
+ repo: Path, transaction: str, receipt_sha: str, *, _migration_store: MetadataStore | None = None
218
+ ) -> dict:
219
+ from .git_backend import inventory
220
+ from .source_cleanup import _state, _store
221
+
222
+ store = _migration_store or MetadataStore(inventory(repo).common_dir)
223
+ journal, cleanup, refs = _inputs(store, transaction, receipt_sha)
224
+ if _store(cleanup["preservation_plan"]).root.parent.parent != store.root.parent.parent:
225
+ raise _blocked()
226
+ # Both passes independently inspect source/index/generation and managed runtime.
227
+ state = _state(
228
+ cleanup, {}, journal=journal, journal_ref=refs["journal"], _migration_store=_migration_store
229
+ )
230
+ plan = cleanup["preservation_plan"]
231
+ if s._scan(plan["authority"]["identity"]) != plan["source_tree"]:
232
+ raise _blocked()
233
+ if _inputs(store, transaction, receipt_sha) != (journal, cleanup, refs):
234
+ raise _blocked()
235
+ state = _state(
236
+ cleanup, {}, journal=journal, journal_ref=refs["journal"], _migration_store=_migration_store
237
+ )
238
+ value = {
239
+ "contract": CONTRACT,
240
+ "state": STATE,
241
+ "original_transaction_id": transaction,
242
+ "new_attempt_id": str(uuid.uuid5(uuid.UUID(transaction), p._sha(refs))),
243
+ "original_evidence": refs,
244
+ "allocation_source_identity": plan["authority"]["identity"],
245
+ "source_state_sha256": p._sha(plan["source_tree"]),
246
+ "preservation_generation": plan["destination"],
247
+ "preservation_receipt_sha256": cleanup["preservation_receipt_sha256"],
248
+ "failure_reason": "transaction-scope coordinator refusal",
249
+ "actions": [],
250
+ "safe_to_supersede": True,
251
+ "original_attempt_will_replay": False,
252
+ "verification": {
253
+ "filesystem": "EXACT_SOURCE_BASELINE_MATCH",
254
+ "index": "UNCHANGED",
255
+ "head": "UNCHANGED",
256
+ "preservation": "PASS",
257
+ "runtime_authority": "PASS",
258
+ "destructive_action_evidence": False,
259
+ },
260
+ }
261
+ return {**value, "plan_id": p._sha(value), "runtime_evidence": state["runtime_evidence"]}
262
+
263
+
264
+ def reconcile(repo: Path, transaction: str, receipt_sha: str, expected_plan_id: str) -> dict:
265
+ from .lifecycle import _typed_runtime_blockers, _typed_runtime_evidence, inventory
266
+ from .semantics import assert_record_version as _cas
267
+ from .semantics import managed_record as _managed_record
268
+ from .transaction_scope import check_source
269
+
270
+ planned = plan_reconciliation(repo, transaction, receipt_sha)
271
+ if planned["plan_id"] != expected_plan_id:
272
+ raise _blocked()
273
+ store = MetadataStore(inventory(repo).common_dir)
274
+ identity = planned["allocation_source_identity"]
275
+ with store.lock(exclusive=True):
276
+ journal, cleanup, refs = _inputs(store, transaction, receipt_sha)
277
+ if refs != planned["original_evidence"]:
278
+ raise _blocked()
279
+ item, record = _managed_record(
280
+ inventory(repo), store._load_unlocked(), Path(identity["target_path"])
281
+ )
282
+ _cas(record, identity["expected_record_version"])
283
+ if (
284
+ record.lifecycle != "ACTIVE"
285
+ or record.owner != identity["owner"]
286
+ or item.head != identity["expected_head"]
287
+ ):
288
+ raise _blocked()
289
+ check_source(store, record.allocation_id, item.path, journal)
290
+ runtime = _typed_runtime_evidence(item, record, store._load_unlocked())
291
+ blockers = _typed_runtime_blockers(runtime)
292
+ if blockers:
293
+ raise s.AuthorityBlocked(blockers[0]["code"], runtime)
294
+ if s._scan(identity) != cleanup["preservation_plan"]["source_tree"]:
295
+ raise _blocked()
296
+ s._verify_generation(cleanup["preservation_plan"], cleanup["preservation_receipt_sha256"])
297
+ ref = e.write(
298
+ _directory(store, transaction),
299
+ "reconciliation.json",
300
+ {
301
+ **planned,
302
+ "runtime_evidence": runtime,
303
+ "verified_at": now_iso(),
304
+ },
305
+ )
306
+ return {"result": STATE, "reconciliation": ref, "new_attempt_id": planned["new_attempt_id"]}
307
+
308
+
309
+ def _baseline_observed(cleanup: dict) -> dict:
310
+ tree = cleanup["preservation_plan"]["source_tree"]
311
+ return {
312
+ row["path"]: {
313
+ "identity": tree["source_identities"][row["path"]],
314
+ "sha256": row["sha256"],
315
+ "mode": row["mode"],
316
+ }
317
+ for row in tree["files"]
318
+ }
319
+
320
+
321
+ def _restored_identity(actual: list[int], original: list[int], row: dict) -> None:
322
+ if (
323
+ len(actual) != 9
324
+ or actual[:2] != original[:2]
325
+ or actual[6:] != original[6:]
326
+ or actual[8] != 1
327
+ or not stat.S_ISREG(actual[2])
328
+ or stat.S_IMODE(actual[2]) != row["mode"]
329
+ or actual[3] != row["size"]
330
+ ):
331
+ raise _blocked()
332
+
333
+
334
+ def _frozen_state(state: dict) -> dict:
335
+ return {key: value for key, value in state.items() if key != "runtime_evidence"}
336
+
337
+
338
+ def _check_state(cleanup: dict, state: dict, observed: dict, restored: set[str]) -> None:
339
+ tree = cleanup["preservation_plan"]["source_tree"]
340
+ remaining = sorted(set(tree["dirty_paths"]) - restored)
341
+ if (
342
+ state.get("head") != tree["base_head"]
343
+ or state.get("index_sha256") != tree["index_sha256"]
344
+ or state.get("dirty_paths") != remaining
345
+ or state.get("observed") != observed
346
+ or state.get("dirty_digest") != p._sha({path: observed[path] for path in remaining})
347
+ or state.get("preservation") != cleanup["verification"]
348
+ or state.get("preservation", {}).get("verification") != "PASS"
349
+ or state.get("preservation", {}).get("cleanup_authorized") is not False
350
+ ):
351
+ raise _blocked()
352
+
353
+
354
+ def _progress_inputs(
355
+ store: MetadataStore, transaction: str, receipt_sha: str
356
+ ) -> tuple[dict, dict, dict, list[dict], dict]:
357
+ receipt, journal, cleanup, refs = _attempt_inputs(store, transaction, receipt_sha)
358
+ actions, checks, rows = receipt["actions"], receipt["checks"], cleanup["restore"]
359
+ tree = cleanup["preservation_plan"]["source_tree"]
360
+ if (
361
+ not actions
362
+ or len(actions) >= len(rows)
363
+ or [row["path"] for row in rows] != tree["dirty_paths"]
364
+ or any(action["path"] != rows[i]["path"] for i, action in enumerate(actions))
365
+ ):
366
+ raise _blocked()
367
+ observed, completed, linked_checks = _baseline_observed(cleanup), {}, []
368
+ allowed = {"plan.json", "progress-reconciliation.json"}
369
+ action_refs = []
370
+ for step, action in enumerate(actions):
371
+ row, path = rows[step], action["path"]
372
+ status = action.get("status")
373
+ if (
374
+ status not in {"ACTION_STARTED", "ACTION_COMPLETED"}
375
+ or (status == "ACTION_STARTED" and step != len(actions) - 1)
376
+ or set(action)
377
+ != (
378
+ {"path", "status", "before", "after"}
379
+ if status == "ACTION_COMPLETED"
380
+ else {"path", "status", "before"}
381
+ )
382
+ ):
383
+ raise _blocked()
384
+ name = f"{step:04d}-before.json"
385
+ allowed.add(name)
386
+ if not e.matches_reference(action["before"], _directory(store, transaction) / name):
387
+ raise _blocked()
388
+ before, _ = e.read(Path(action["before"]["path"]), action["before"]["sha256"])
389
+ if (
390
+ before.get("contract") != "tracked-source-cleanup/v1"
391
+ or before.get("transaction_id") != transaction
392
+ or before.get("path") != path
393
+ or before.get("restore") != row
394
+ or before.get("execution_state") != "ACTION_STARTED"
395
+ ):
396
+ raise _blocked()
397
+ _check_state(cleanup, before["state"], observed, set(completed))
398
+ linked_checks.append(before["state"])
399
+ action_refs.append({"path": path, "status": status, "before": action["before"]})
400
+ if status == "ACTION_COMPLETED":
401
+ name = f"{step:04d}-after.json"
402
+ allowed.add(name)
403
+ if not e.matches_reference(action["after"], _directory(store, transaction) / name):
404
+ raise _blocked()
405
+ after, _ = e.read(Path(action["after"]["path"]), action["after"]["sha256"])
406
+ if (
407
+ after.get("contract") != "tracked-source-cleanup/v1"
408
+ or after.get("transaction_id") != transaction
409
+ or after.get("path") != path
410
+ or after.get("before") != action["before"]
411
+ or after.get("execution_state") != "ACTION_COMPLETED"
412
+ ):
413
+ raise _blocked()
414
+ actual = after["state"]["observed"][path]["identity"]
415
+ _restored_identity(actual, tree["source_identities"][path], row)
416
+ completed[path] = actual
417
+ observed[path] = {"identity": actual, "sha256": row["sha256"], "mode": row["mode"]}
418
+ _check_state(cleanup, after["state"], observed, set(completed))
419
+ linked_checks.append(after["state"])
420
+ action_refs[-1]["after"] = action["after"]
421
+ if checks != linked_checks:
422
+ raise _blocked()
423
+ _entries(store, transaction, allowed)
424
+ return journal, cleanup, refs, action_refs, completed
425
+
426
+
427
+ def _progress_value(
428
+ cleanup: dict, transaction: str, refs: dict, actions: list, state: dict
429
+ ) -> dict:
430
+ plan = cleanup["preservation_plan"]
431
+ restored = [action["path"] for action in actions]
432
+ return {
433
+ "contract": PROGRESS_CONTRACT,
434
+ "state": PROGRESS_STATE,
435
+ "original_transaction_id": transaction,
436
+ "original_evidence": refs,
437
+ "allocation_source_identity": plan["authority"]["identity"],
438
+ "source_state_sha256": p._sha(plan["source_tree"]),
439
+ "preservation_generation": plan["destination"],
440
+ "preservation_receipt_sha256": cleanup["preservation_receipt_sha256"],
441
+ "historical_actions": actions,
442
+ "observed_restored_paths": restored,
443
+ "remaining_preserved_paths": state["dirty_paths"],
444
+ "observed_state": _frozen_state(state),
445
+ "original_attempt_will_replay": False,
446
+ "cleanup_complete": False,
447
+ "release_authorized": False,
448
+ "successor_authorized": False,
449
+ "verification": {
450
+ "filesystem": "EXACT_RESTORED_PREFIX_AND_PRESERVED_REMAINDER",
451
+ "index": "UNCHANGED",
452
+ "head": "UNCHANGED",
453
+ "preservation": "PASS",
454
+ "runtime_authority": "PASS",
455
+ "started_action": "CURRENT_BASELINE_MATCH_ONLY",
456
+ },
457
+ }
458
+
459
+
460
+ def _progress_reconciled(
461
+ store: MetadataStore, transaction: str, *, verify_generation: bool = False
462
+ ) -> dict | None:
463
+ path = _directory(store, transaction) / "progress-reconciliation.json"
464
+ if not path.exists():
465
+ return None
466
+ value, _ = e.read(path)
467
+ _, cleanup, refs, actions, completed = _progress_inputs(
468
+ store, transaction, value["original_evidence"]["cleanup_receipt"]["sha256"]
469
+ )
470
+ observed = _baseline_observed(cleanup)
471
+ rows = {row["path"]: row for row in cleanup["restore"]}
472
+ tree = cleanup["preservation_plan"]["source_tree"]
473
+ for action in actions:
474
+ name, row = action["path"], rows[action["path"]]
475
+ actual = value["observed_state"]["observed"][name]["identity"]
476
+ _restored_identity(actual, tree["source_identities"][name], row)
477
+ if name in completed and actual != completed[name]:
478
+ raise _blocked()
479
+ observed[name] = {"identity": actual, "sha256": row["sha256"], "mode": row["mode"]}
480
+ _check_state(cleanup, value["observed_state"], observed, {a["path"] for a in actions})
481
+ expected = _progress_value(cleanup, transaction, refs, actions, value["observed_state"])
482
+ if _core(value) != expected or value.get("plan_id") != p._sha(expected):
483
+ raise _blocked()
484
+ if verify_generation:
485
+ s._verify_generation(cleanup["preservation_plan"], cleanup["preservation_receipt_sha256"])
486
+ return value
487
+
488
+
489
+ def plan_progress(
490
+ repo: Path, transaction: str, receipt_sha: str, *, _migration_store: MetadataStore | None = None
491
+ ) -> dict:
492
+ from .git_backend import inventory
493
+ from .source_cleanup import _state, _store
494
+
495
+ store = _migration_store or MetadataStore(inventory(repo).common_dir)
496
+ directory = _directory(store, transaction)
497
+ if (directory / "progress-reconciliation.json").exists():
498
+ raise _blocked() # Existing immutable terminal proof must never be replaced/replayed.
499
+ inputs = _progress_inputs(store, transaction, receipt_sha)
500
+ journal, cleanup, refs, actions, completed = inputs
501
+ completed = dict(completed)
502
+ if _store(cleanup["preservation_plan"]).root != store.root:
503
+ raise _blocked()
504
+ plan = cleanup["preservation_plan"]
505
+ # A STARTED action is observed now, never retrospectively marked completed.
506
+ if actions[-1]["status"] == "ACTION_STARTED":
507
+ row = cleanup["restore"][len(actions) - 1]
508
+ with p._absolute_directory(Path(plan["authority"]["identity"]["target_path"])) as chain:
509
+ _, actual, _ = s._read(chain, row["path"])
510
+ _restored_identity(actual, plan["source_tree"]["source_identities"][row["path"]], row)
511
+ completed[row["path"]] = actual
512
+ states = [
513
+ _state(
514
+ cleanup,
515
+ completed,
516
+ journal=journal,
517
+ journal_ref=refs["journal"],
518
+ _migration_store=_migration_store,
519
+ )
520
+ for _ in range(2)
521
+ ]
522
+ if _frozen_state(states[0]) != _frozen_state(states[1]) or inputs != _progress_inputs(
523
+ store, transaction, receipt_sha
524
+ ):
525
+ raise _blocked()
526
+ value = _progress_value(cleanup, transaction, refs, actions, states[-1])
527
+ return {**value, "plan_id": p._sha(value), "runtime_evidence": states[-1]["runtime_evidence"]}
528
+
529
+
530
+ def reconcile_progress(
531
+ repo: Path, transaction: str, receipt_sha: str, expected_plan_id: str
532
+ ) -> dict:
533
+ from .git_backend import inventory
534
+
535
+ planned = plan_progress(repo, transaction, receipt_sha)
536
+ if planned["plan_id"] != expected_plan_id:
537
+ raise _blocked()
538
+ store = MetadataStore(inventory(repo).common_dir)
539
+ with store.lock(exclusive=True):
540
+ verified = plan_progress(repo, transaction, receipt_sha, _migration_store=store)
541
+ if verified["plan_id"] != expected_plan_id:
542
+ raise _blocked()
543
+ ref = e.write(
544
+ _directory(store, transaction),
545
+ "progress-reconciliation.json",
546
+ {**verified, "verified_at": now_iso()},
547
+ )
548
+ return {
549
+ "result": PROGRESS_STATE,
550
+ "reconciliation": ref,
551
+ "release_authorized": False,
552
+ "successor_authorized": False,
553
+ }
554
+
555
+
556
+ def main(argv: list[str] | None = None) -> int:
557
+ parser = argparse.ArgumentParser(description=__doc__)
558
+ parser.add_argument(
559
+ "command", choices=("plan", "reconcile", "plan-progress", "reconcile-progress")
560
+ )
561
+ parser.add_argument("--repo", required=True, type=Path)
562
+ parser.add_argument("--transaction-id", required=True)
563
+ parser.add_argument(
564
+ "--receipt-sha256", required=True, help="exact failed completion receipt bytes"
565
+ )
566
+ parser.add_argument("--expected-plan-id")
567
+ args = parser.parse_args(argv)
568
+ try:
569
+ planners = {"plan": plan_reconciliation, "plan-progress": plan_progress}
570
+ writers = {"reconcile": reconcile, "reconcile-progress": reconcile_progress}
571
+ if args.command in planners:
572
+ result = planners[args.command](args.repo, args.transaction_id, args.receipt_sha256)
573
+ else:
574
+ result = writers[args.command](
575
+ args.repo, args.transaction_id, args.receipt_sha256, args.expected_plan_id
576
+ )
577
+ print(json.dumps(result, sort_keys=True))
578
+ return 0
579
+ except (ControllerError, OSError, ValueError, TypeError, KeyError) as exc:
580
+ from .cleanup_attempts import underlying
581
+
582
+ print(
583
+ json.dumps(
584
+ {"result": "SOURCE_RECOVERY_BLOCKED", "underlying": underlying(exception=exc)},
585
+ sort_keys=True,
586
+ )
587
+ )
588
+ return 1
589
+
590
+
591
+ if __name__ == "__main__":
592
+ raise SystemExit(main())