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,912 @@
1
+ """Re-preserve current bytes, then append a new execution baseline; never infer history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import cleanup_attempts as attempts
12
+ from . import cleanup_evidence as e
13
+ from . import hardlinks
14
+ from . import preservation as p
15
+ from .git_backend import inventory
16
+ from .models import SUPPORTED_METADATA_SCHEMA_VERSIONS, ControllerError
17
+ from .preserved_cleanup import coverage
18
+ from .store import MetadataStore, now_iso
19
+
20
+ CONTRACT = "re-preservation-recovery/v1"
21
+ COMPLETED_RESOURCE = "backend/.venv"
22
+ REMAINING = ["frontend/dist", "frontend/node_modules", ".venv"]
23
+ RELEASE_INSPECTION_CONTRACT = "release-recovery-inspection/v1"
24
+
25
+
26
+ def inspect_release(repo: Path, *, transaction_id: str) -> dict[str, Any]:
27
+ """Project the persisted original request; never authorize or execute recovery."""
28
+ from .controller import envelope
29
+ from .metadata_codec import _validate_timestamp, release_plan_from_dict
30
+
31
+ try:
32
+ canonical = str(uuid.UUID(transaction_id))
33
+ except (ValueError, AttributeError) as exc:
34
+ raise ControllerError("INVALID_RELEASE_RECOVERY_QUERY", "canonical transaction UUID required") from exc
35
+ if canonical != transaction_id:
36
+ raise ControllerError("INVALID_RELEASE_RECOVERY_QUERY", "canonical transaction UUID required")
37
+ repository = inventory(repo)
38
+ store = MetadataStore(repository.common_dir)
39
+ with store.lock(exclusive=False):
40
+ journal_path = store.transactions_dir / f"{transaction_id}.json"
41
+ try:
42
+ journal, journal_sha = e.read(journal_path)
43
+ except FileNotFoundError as exc:
44
+ raise ControllerError("MISSING_RELEASE_RECOVERY_EVIDENCE", "original release journal is missing") from exc
45
+ if journal.get("command") != "release" or "release_plan_id" not in journal:
46
+ raise ControllerError("UNSUPPORTED_RELEASE_RECOVERY_EVIDENCE", "a persisted release plan is required")
47
+ fields = {
48
+ "schema_version", "transaction_id", "repository_id", "command", "phase",
49
+ "target_path", "allocation_id", "branch", "expected_head", "expected_record_version",
50
+ "created_at", "updated_at", "release_plan_id",
51
+ }
52
+ phase = journal.get("phase")
53
+ phases = {"PREPARED", "WORKTREE_REMOVED", "RELEASED_RECORDED"}
54
+ teardown_phases = {"TEARDOWN_PREPARED", "VENV_REMOVAL_STARTED", "VENV_REMOVED"}
55
+ if (
56
+ set(journal) not in (fields, fields | {"teardown_receipt_id"})
57
+ or type(journal.get("schema_version")) is not int
58
+ or journal["schema_version"] not in SUPPORTED_METADATA_SCHEMA_VERSIONS
59
+ or journal.get("transaction_id") != transaction_id
60
+ or not isinstance(phase, str)
61
+ or phase not in phases | teardown_phases
62
+ or (phase in teardown_phases and "teardown_receipt_id" not in journal)
63
+ or ("teardown_receipt_id" in journal and journal["teardown_receipt_id"] != transaction_id)
64
+ or type(journal.get("expected_record_version")) is not int
65
+ or not isinstance(journal.get("release_plan_id"), str)
66
+ ):
67
+ raise ControllerError("INVALID_TRANSACTION", "release journal identity or schema is invalid")
68
+ for field in ("created_at", "updated_at"):
69
+ _validate_timestamp(journal[field], field)
70
+ snapshot = store._load_unlocked()
71
+ plan = snapshot.release_plans.get(journal["release_plan_id"])
72
+ if plan is None:
73
+ raise ControllerError("MISSING_RELEASE_RECOVERY_EVIDENCE", "original release plan is missing")
74
+ if (
75
+ plan.repository_id != snapshot.repository_id
76
+ or any(journal[field] != getattr(plan, field) for field in (
77
+ "repository_id", "allocation_id", "target_path", "branch", "expected_head",
78
+ "expected_record_version",
79
+ ))
80
+ or bool(plan.resource_actions) != ("teardown_receipt_id" in journal)
81
+ ):
82
+ raise ControllerError("RELEASE_RECOVERY_MISMATCH", "release journal differs from the original plan")
83
+ if transaction_id in snapshot.release_receipts:
84
+ raise ControllerError("RELEASE_RECOVERY_NOT_PENDING", "use lifecycle-history for a completed release")
85
+ if plan.publication_receipt_id is not None:
86
+ receipt = snapshot.publication_receipts.get(plan.publication_receipt_id)
87
+ if receipt is None:
88
+ raise ControllerError("MISSING_PUBLICATION_RECEIPT", "original publication receipt is missing")
89
+ if (receipt.repository_id, receipt.allocation_id, receipt.candidate_oid) != (
90
+ plan.repository_id, plan.allocation_id, plan.expected_head,
91
+ ):
92
+ raise ControllerError("RELEASE_RECOVERY_MISMATCH", "publication receipt differs from the original plan")
93
+ if plan.release_intent_id is not None:
94
+ intent = snapshot.release_intents.get(plan.release_intent_id)
95
+ if intent is None:
96
+ raise ControllerError("MISSING_RELEASE_INTENT", "original release intent is missing")
97
+ if any(getattr(intent, field) != getattr(plan, field) for field in (
98
+ "repository_id", "allocation_id", "owner", "authority_key", "role",
99
+ "expected_head", "expected_record_version",
100
+ )):
101
+ raise ControllerError("RELEASE_RECOVERY_MISMATCH", "release intent differs from the original plan")
102
+ plan_path = store.release_plans_dir / f"{plan.plan_id}.json"
103
+ raw_plan, plan_sha = e.read(plan_path)
104
+ if release_plan_from_dict(raw_plan) != plan:
105
+ raise ControllerError("RELEASE_RECOVERY_MISMATCH", "release plan changed during inspection")
106
+ owner_release = plan.owner if plan.publication_receipt_id is None and plan.release_intent_id is None else None
107
+ request = {
108
+ "target_path": plan.target_path, "expected_record_version": plan.expected_record_version,
109
+ "owner_release": owner_release, "publication_receipt_id": plan.publication_receipt_id,
110
+ "release_intent_id": plan.release_intent_id,
111
+ }
112
+ arguments = ["release", "--repo", str(repository.canonical_path), "--path", plan.target_path,
113
+ "--expected-record-version", str(plan.expected_record_version)]
114
+ for option, value in (("--owner-release", owner_release),
115
+ ("--publication-receipt-id", plan.publication_receipt_id),
116
+ ("--intent-id", plan.release_intent_id)):
117
+ if value is not None:
118
+ arguments.extend((option, value))
119
+ arguments.append("--json")
120
+ decision = {
121
+ "code": "RELEASE_RECOVERY_REQUEST_INSPECTED", "contract": RELEASE_INSPECTION_CONTRACT,
122
+ "transaction_id": transaction_id, "phase": phase, "release_plan_id": plan.plan_id,
123
+ "allocation_id": plan.allocation_id, "owner": plan.owner,
124
+ "request": request, "release_argv": arguments,
125
+ "evidence": {"journal": {"path": str(journal_path), "sha256": journal_sha},
126
+ "release_plan": {"path": str(plan_path), "sha256": plan_sha}},
127
+ "release_authorized": False, "execution_guards_checked": False,
128
+ }
129
+ payload = envelope("release-recovery-inspect")
130
+ payload.update(repository={"repository_id": snapshot.repository_id,
131
+ "canonical_path": str(repository.canonical_path)}, decision=decision)
132
+ return payload
133
+
134
+
135
+ def _blocked(message: str) -> ControllerError:
136
+ return ControllerError("RE_PRESERVATION_RECOVERY_BLOCKED", message)
137
+
138
+
139
+ class RolloutTransactionError(ControllerError):
140
+ """A refused read-only check with the original error and a recovery entry hint."""
141
+
142
+ def __init__(self, cause: ControllerError | OSError, transaction: str, journal: dict):
143
+ command = journal.get("command")
144
+ phase = journal.get("phase")
145
+ command = command if isinstance(command, str) else None
146
+ phase = phase if isinstance(phase, str) else None
147
+ plan = journal.get("cleanup_plan")
148
+ entries = {
149
+ "typed-disposable-resource/v1": "resource-recover --stage reconcile-typed --dry-run",
150
+ "tracked-source-cleanup/v1": "python -m worktree_controller.source_recovery plan",
151
+ "legacy-preserved-state-multiple-cleanup/v1": (
152
+ "resource-recover --stage prepare-multiple --dry-run"
153
+ ),
154
+ "legacy-preserved-state-cleanup/v1": "resource-recover --stage prepare --dry-run",
155
+ }
156
+ entry = None
157
+ if command == "release":
158
+ entry = "release"
159
+ elif command == "resource-clean" and isinstance(plan, dict):
160
+ contract = plan.get("contract")
161
+ entry = entries.get(contract) if isinstance(contract, str) else None
162
+ code = cause.code if isinstance(cause, ControllerError) else "FILESYSTEM_ERROR"
163
+ self.transaction_diagnostic = {
164
+ "transaction_id": transaction,
165
+ "command": command,
166
+ "phase": phase,
167
+ "error": {"code": code, "message": str(cause)},
168
+ "recovery_entry": entry,
169
+ }
170
+ super().__init__(
171
+ code, f"transaction {transaction} ({command or 'unknown'}/{phase or 'unknown'}): {cause}"
172
+ )
173
+
174
+
175
+ def context(transaction: str, transaction_sha256: str, receipt_sha256: str) -> dict[str, Any]:
176
+ if str(uuid.UUID(transaction)) != transaction:
177
+ raise _blocked("canonical original transaction identity required")
178
+ if any(
179
+ len(s or "") != 64 or any(c not in "0123456789abcdef" for c in s)
180
+ for s in (transaction_sha256, receipt_sha256)
181
+ ):
182
+ raise _blocked("trusted original transaction and receipt digests required")
183
+ return {
184
+ "contract": CONTRACT,
185
+ "original_transaction_id": transaction,
186
+ "original_transaction_sha256": transaction_sha256,
187
+ "original_cleanup_receipt_sha256": receipt_sha256,
188
+ "historical_hardlink_cause": "NOT_PROVEN",
189
+ "old_transaction_remains_immutable": True,
190
+ "completed_resources": [COMPLETED_RESOURCE],
191
+ "completed_action_will_replay": False,
192
+ "remaining_resources": REMAINING,
193
+ }
194
+
195
+
196
+ def original(
197
+ store: MetadataStore,
198
+ recovery: dict[str, Any],
199
+ *,
200
+ target: Path | None = None,
201
+ allocation_id: str | None = None,
202
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
203
+ from . import multiple_cleanup_recovery as multi_recovery
204
+
205
+ if recovery.get("contract") == multi_recovery.CONTRACT:
206
+ return multi_recovery.original(
207
+ store, recovery, target=target, allocation_id=allocation_id
208
+ )
209
+ expected = context(
210
+ recovery["original_transaction_id"],
211
+ recovery["original_transaction_sha256"],
212
+ recovery["original_cleanup_receipt_sha256"],
213
+ )
214
+ if any(recovery.get(k) != v for k, v in expected.items()):
215
+ raise _blocked("recovery history assertions differ from the contract")
216
+ transaction = recovery["original_transaction_id"]
217
+ journal, _ = e.read(
218
+ store.transactions_dir / f"{transaction}.json", recovery["original_transaction_sha256"]
219
+ )
220
+ receipt, _ = e.read(
221
+ store.completion_receipts_dir / f"{transaction}.json",
222
+ recovery["original_cleanup_receipt_sha256"],
223
+ )
224
+ actions = [{"path": COMPLETED_RESOURCE, "status": "COMPLETED"}] + [
225
+ {"path": name, "status": "PENDING"} for name in REMAINING
226
+ ]
227
+ plan = receipt.get("plan", {})
228
+ if (
229
+ receipt.get("result") != "FAILED_OR_PARTIAL"
230
+ or receipt.get("receipt_id") != transaction
231
+ or receipt.get("contract") != "legacy-preserved-state-cleanup/v1"
232
+ or receipt.get("actions") != actions
233
+ or journal.get("actions") != actions
234
+ or journal.get("transaction_id") != transaction
235
+ or journal.get("command") != "resource-clean"
236
+ or journal.get("cleanup_plan") != plan
237
+ or journal.get("phase") != "PREPARED"
238
+ or plan.get("cleanup_paths") != [COMPLETED_RESOURCE, *REMAINING]
239
+ or journal.get("allocation_id") != plan.get("allocation_id")
240
+ or journal.get("target_path") != plan.get("target_path")
241
+ or (target is not None and plan.get("target_path") != str(target))
242
+ or (allocation_id is not None and plan.get("allocation_id") != allocation_id)
243
+ ):
244
+ raise _blocked("original cleanup is not the supported exact completed/pending shape")
245
+ for path in store.transactions_dir.glob("*.json"):
246
+ if path.stem != transaction:
247
+ other, _ = e.read(path)
248
+ if (
249
+ not recovery.get("recovery_id")
250
+ or other.get("command") != "resource-clean"
251
+ or other.get("cleanup_plan", {}).get("recovery") != recovery
252
+ ):
253
+ raise _blocked("another interrupted transaction requires its own recovery")
254
+ if os.path.lexists(Path(plan["target_path"]) / COMPLETED_RESOURCE):
255
+ raise _blocked("completed backend resource reappeared; replay is forbidden")
256
+ evidence = plan["preservation_evidence"]
257
+ verified, old_plan = coverage(
258
+ Path(evidence["receipt_path"]), evidence["receipt_sha256"], verify_source=False
259
+ )
260
+ if verified != evidence:
261
+ raise _blocked("original preservation evidence changed")
262
+ return receipt, old_plan
263
+
264
+
265
+ def validate_current_tree(tree: p.Tree, old: dict[str, Any]) -> None:
266
+ """Compare current state without asserting why nlink/ctime differ."""
267
+ tree.require_complete()
268
+ if tree.manifest != old["manifest"] or set(tree.identities) != set(old["source_identities"]):
269
+ raise _blocked("current content, paths or node metadata changed before re-preservation")
270
+ for path, identity in tree.identities.items():
271
+ previous = old["source_identities"][path]
272
+ if any(
273
+ a != b
274
+ for i, (a, b) in enumerate(zip(identity, previous, strict=True))
275
+ if i not in {5, 8}
276
+ ):
277
+ raise _blocked("current inode, size, mode, mtime or ownership changed")
278
+
279
+
280
+ def _directory(store: MetadataStore, transaction: str, recovery_id: str) -> Path:
281
+ for value in (transaction, recovery_id):
282
+ if str(uuid.UUID(value)) != value:
283
+ raise _blocked("noncanonical recovery identity")
284
+ return store.cleanup_recoveries_dir / transaction / recovery_id
285
+
286
+
287
+ def _plan(
288
+ repo: Path,
289
+ store: MetadataStore,
290
+ target: Path,
291
+ owner: str,
292
+ version: int,
293
+ recovery: dict[str, Any],
294
+ receipt_path: Path,
295
+ receipt_sha: str,
296
+ ) -> dict[str, Any]:
297
+ from .lifecycle import _typed_cleanup_plan_locked
298
+ from .semantics import managed_record as _managed_record
299
+
300
+ old_receipt, old_preservation = original(store, recovery, target=target)
301
+ repository = inventory(repo)
302
+ snapshot = store._load_unlocked()
303
+ item, record = _managed_record(repository, snapshot, target)
304
+ verified, new_preservation = coverage(receipt_path, receipt_sha)
305
+ if (
306
+ new_preservation.get("recovery") != recovery
307
+ or new_preservation["generation"] == old_preservation["generation"]
308
+ or not new_preservation.get("cleanup_topology")
309
+ or verified["allocation_source_identity"] != old_preservation["authority"]["identity"]
310
+ ):
311
+ raise _blocked("a new complete re-preservation baseline is required")
312
+ plan = _typed_cleanup_plan_locked(
313
+ repository,
314
+ snapshot,
315
+ store,
316
+ item,
317
+ record,
318
+ owner=owner,
319
+ expected_record_version=version,
320
+ preservation_receipt=receipt_path,
321
+ preservation_receipt_sha256=receipt_sha,
322
+ recovery=recovery,
323
+ )
324
+ if plan["blockers"] or plan["cleanup_paths"] != REMAINING:
325
+ raise _blocked("current release/resource/runtime guards are not clear")
326
+ if plan["hardlink_topology"] != new_preservation["cleanup_topology"]:
327
+ raise _blocked("topology differs from the new preservation baseline")
328
+ old_rows = {r["path"]: r for r in old_receipt["plan"]["resources"]}
329
+ for row in plan["resources"]:
330
+ if row["path"] in REMAINING[:-1] and row["evidence"] != old_rows[row["path"]]["evidence"]:
331
+ raise _blocked("remaining frontend resource identity changed")
332
+ return plan
333
+
334
+
335
+ def prepare(
336
+ repo: Path,
337
+ *,
338
+ target: Path,
339
+ owner: str,
340
+ version: int,
341
+ recovery: dict[str, Any],
342
+ receipt_path: Path,
343
+ receipt_sha: str,
344
+ dry_run: bool,
345
+ expected_plan_id: str | None = None,
346
+ ) -> dict[str, Any]:
347
+ from .controller import status
348
+
349
+ store = MetadataStore(inventory(repo).common_dir)
350
+ decision: dict[str, Any]
351
+ with store.lock(exclusive=not dry_run):
352
+ plan = _plan(repo, store, target, owner, version, recovery, receipt_path, receipt_sha)
353
+ decision = {"code": "RECOVERABLE_PARTIAL", "plan": plan, "dry_run": dry_run}
354
+ if not dry_run:
355
+ if expected_plan_id != plan["plan_id"]:
356
+ raise _blocked("exact fresh recovery dry-run plan required")
357
+ parent = store.cleanup_recoveries_dir / recovery["original_transaction_id"]
358
+ if parent.exists() and list(parent.glob("*/record.json")):
359
+ raise _blocked("an append-only recovery baseline already exists")
360
+ recovery_id = str(uuid.uuid4())
361
+ directory = _directory(store, recovery["original_transaction_id"], recovery_id)
362
+ document = {
363
+ "contract": CONTRACT,
364
+ "recovery_id": recovery_id,
365
+ "result": "RECOVERABLE_PARTIAL",
366
+ "historical_hardlink_cause": "NOT_PROVEN",
367
+ "old_transaction_remains_immutable": True,
368
+ "completed_action_will_replay": False,
369
+ "safety_basis": "CURRENT_STATE_COMPLETELY_REPRESERVED",
370
+ "baseline_timestamp": now_iso(),
371
+ "plan": plan,
372
+ "old_preservation_generation": original(store, recovery)[1]["destination"],
373
+ "new_execution_baseline": str(receipt_path.parent),
374
+ "hardlink_topology_sha256": plan["hardlink_topology"]["sha256"],
375
+ }
376
+ reference = e.write(directory, "record.json", document)
377
+ decision.update(recovery_id=recovery_id, recovery_receipt=reference)
378
+ result = status(repo)
379
+ result.update(command="resource-recover", decision=decision)
380
+ return result
381
+
382
+
383
+ def _rollout_transaction(
384
+ repo: Path, store: MetadataStore, path: Path, journal: dict,
385
+ receipt_path: Path | None, receipt_sha: str | None,
386
+ ) -> dict[str, Any]:
387
+ from .transaction_scope import terminal_state
388
+
389
+ state = terminal_state(store, path.stem, verify_generations=True)
390
+ if state is not None:
391
+ return {"transaction_id": path.stem, "state": state}
392
+ if journal.get("command") == "release":
393
+ raise ControllerError(
394
+ "ROLLOUT_RELEASE_RECOVERY_REQUIRED",
395
+ "unfinished release requires exact replay through release with the original request identity",
396
+ )
397
+ cleanup_plan = journal.get("cleanup_plan")
398
+ if journal.get("command") != "resource-clean" or not isinstance(cleanup_plan, dict):
399
+ raise ControllerError(
400
+ "ROLLOUT_TRANSACTION_UNSUPPORTED", "unfinished transaction has no supported cleanup recovery"
401
+ )
402
+ contract = cleanup_plan.get("contract")
403
+ if contract == "typed-disposable-resource/v1":
404
+ _, digest = e.read(path)
405
+ current = attempts.typed_plan(repo, store, path.stem, digest)
406
+ return {"transaction_id": path.stem, "state": "TYPED_NO_ACTION_RECOVERABLE",
407
+ "plan_id": current["plan_id"]}
408
+ if contract == "tracked-source-cleanup/v1":
409
+ from .source_recovery import plan_reconciliation
410
+
411
+ completion_path = store.completion_receipts_dir / f"{path.stem}.json"
412
+ _, completion_sha = e.read(completion_path)
413
+ current = plan_reconciliation(repo, path.stem, completion_sha)
414
+ return {
415
+ "transaction_id": path.stem,
416
+ "state": current["state"],
417
+ "plan_id": current["plan_id"],
418
+ "completion_receipt_sha256": completion_sha,
419
+ }
420
+ if contract == "legacy-preserved-state-multiple-cleanup/v1":
421
+ from . import multiple_cleanup_recovery as multi_recovery
422
+
423
+ _, journal_sha = e.read(path)
424
+ _, completion_sha = e.read(store.completion_receipts_dir / path.name)
425
+ recovery = multi_recovery.context(path.stem, journal_sha, completion_sha)
426
+ current = multi_recovery._plan_locked(
427
+ repo, store, Path(cleanup_plan["target_path"]), cleanup_plan["owner"],
428
+ cleanup_plan["expected_record_version"], recovery,
429
+ )
430
+ return {
431
+ "transaction_id": path.stem,
432
+ "state": "MULTIPLE_PREFIX_RECOVERABLE",
433
+ "plan_id": current["plan_id"],
434
+ "original_transaction_sha256": journal_sha,
435
+ "original_cleanup_receipt_sha256": completion_sha,
436
+ "hardlink_topology_sha256": current["hardlink_topology"]["sha256"],
437
+ }
438
+ if contract != "legacy-preserved-state-cleanup/v1":
439
+ raise ControllerError(
440
+ "ROLLOUT_TRANSACTION_UNSUPPORTED", "unfinished cleanup has an unsupported recovery contract"
441
+ )
442
+ if receipt_path is None or not receipt_sha:
443
+ raise _blocked("unfinished cleanup requires an exact new preservation baseline")
444
+ _, baseline = coverage(receipt_path, receipt_sha)
445
+ recovery = baseline.get("recovery", {})
446
+ if recovery.get("original_transaction_id") != path.stem:
447
+ raise _blocked("preservation baseline does not cover this transaction")
448
+ identity = baseline["authority"]["identity"]
449
+ plan = _plan(
450
+ repo, store, Path(identity["target_path"]), identity["owner"],
451
+ identity["expected_record_version"], recovery, receipt_path, receipt_sha,
452
+ )
453
+ return {
454
+ "transaction_id": path.stem,
455
+ "state": "RECOVERABLE_PARTIAL",
456
+ "original_transaction_sha256": recovery["original_transaction_sha256"],
457
+ "preservation_receipt_sha256": receipt_sha,
458
+ "hardlink_topology_sha256": plan["hardlink_topology"]["sha256"],
459
+ }
460
+
461
+
462
+ def rollout_check(
463
+ repo: Path, *, receipt_path: Path | None = None, receipt_sha: str | None = None
464
+ ) -> dict[str, Any]:
465
+ """Read-only installation compatibility, never a transaction state transition."""
466
+ from .controller import status
467
+ from .transaction_scope import is_atomic_temp
468
+
469
+ store = MetadataStore(inventory(repo).common_dir)
470
+ verified = []
471
+ with store.lock(exclusive=False):
472
+ pending = sorted(store.transactions_dir.iterdir()) if store.transactions_dir.exists() else []
473
+ for path in pending:
474
+ if is_atomic_temp(path.name):
475
+ continue
476
+ journal: dict = {}
477
+ try:
478
+ if not path.is_file() or path.suffix != ".json":
479
+ raise ControllerError(
480
+ "ROLLOUT_TRANSACTION_UNSUPPORTED", "unknown transaction entry blocks activation"
481
+ )
482
+ journal, _ = e.read(path)
483
+ verified.append(_rollout_transaction(repo, store, path, journal, receipt_path, receipt_sha))
484
+ except (ControllerError, OSError) as exc:
485
+ raise RolloutTransactionError(exc, path.stem, journal) from exc
486
+ except (ValueError, TypeError, KeyError) as exc:
487
+ cause = ControllerError(
488
+ "ROLLOUT_TRANSACTION_UNSUPPORTED", f"transaction recovery evidence is malformed: {exc}"
489
+ )
490
+ raise RolloutTransactionError(cause, path.stem, journal) from exc
491
+ result = status(repo)
492
+ result.update(
493
+ command="resource-recover",
494
+ decision={
495
+ "code": "ROLLOUT_RECOVERY_COMPATIBLE",
496
+ "dry_run": True,
497
+ "transactions": verified,
498
+ },
499
+ )
500
+ return result
501
+
502
+
503
+ def _record(
504
+ store: MetadataStore, recovery_path: Path, recovery_sha: str
505
+ ) -> tuple[dict[str, Any], Path]:
506
+ document, _ = e.read(recovery_path, recovery_sha)
507
+ plan = document["plan"]
508
+ recovery = plan["recovery"]
509
+ directory = _directory(store, recovery["original_transaction_id"], document["recovery_id"])
510
+ if (
511
+ recovery_path != directory / "record.json"
512
+ or document.get("result") != "RECOVERABLE_PARTIAL"
513
+ or document.get("contract") != CONTRACT
514
+ or document.get("historical_hardlink_cause") != "NOT_PROVEN"
515
+ or document.get("completed_action_will_replay") is not False
516
+ or document.get("safety_basis") != "CURRENT_STATE_COMPLETELY_REPRESERVED"
517
+ ):
518
+ raise _blocked("invalid immutable recovery record")
519
+ return document, directory
520
+
521
+
522
+ def _current(repo: Path, store: MetadataStore, document: dict[str, Any]) -> dict[str, Any]:
523
+ plan = document["plan"]
524
+ preserved = plan["preservation_evidence"]
525
+ current = _plan(
526
+ repo,
527
+ store,
528
+ Path(plan["target_path"]),
529
+ plan["owner"],
530
+ plan["expected_record_version"],
531
+ plan["recovery"],
532
+ Path(preserved["receipt_path"]),
533
+ preserved["receipt_sha256"],
534
+ )
535
+ if p._sha(current) != p._sha(plan):
536
+ raise _blocked("current baseline differs from the immutable recovery record")
537
+ return current
538
+
539
+
540
+ def reconcile_attempt(
541
+ repo: Path,
542
+ *,
543
+ recovery_path: Path,
544
+ recovery_sha: str,
545
+ dry_run: bool,
546
+ expected_plan_id: str | None = None,
547
+ ) -> dict[str, Any]:
548
+ from .controller import status
549
+
550
+ store = MetadataStore(inventory(repo).common_dir)
551
+ with store.lock(exclusive=not dry_run):
552
+ document, directory = _record(store, recovery_path, recovery_sha)
553
+ chain = attempts.chain(directory, recovery_sha)
554
+ if not chain or (directory / "complete.json").exists():
555
+ raise _blocked("an unfinished started attempt is required")
556
+ latest = chain[-1]
557
+ path = directory / "reconciliations" / (latest["attempt_id"] + ".json")
558
+ if path.exists():
559
+ raise _blocked("attempt already reconciled; use the new resume operation")
560
+ current = _current(repo, store, document)
561
+ proof = attempts.no_action_evidence(store, current)
562
+ plan = {
563
+ "contract": attempts.RECONCILIATION_CONTRACT,
564
+ "state": "STARTED_NO_ACTION_RECOVERABLE",
565
+ "path": str(path),
566
+ "record_sha256": recovery_sha,
567
+ "attempt": latest,
568
+ "allocation_id": current["allocation_id"],
569
+ "original_transaction_id": current["recovery"]["original_transaction_id"],
570
+ "completed_resources": [COMPLETED_RESOURCE],
571
+ "remaining_resources": REMAINING,
572
+ "completed_action_will_replay": False,
573
+ "assertion": "attempt started, no action proven, safe to supersede",
574
+ "safe_to_supersede": True,
575
+ "historical_refusal": "NOT_RECONSTRUCTED",
576
+ "verification": {
577
+ "filesystem": "EXACT_BASELINE_MATCH",
578
+ "preservation": "PASS",
579
+ "runtime_authority": "PASS",
580
+ "git": "CLEAN",
581
+ **proof,
582
+ },
583
+ "hardlink_topology_sha256": current["hardlink_topology"]["sha256"],
584
+ "baseline_plan_id": current["plan_id"],
585
+ "runtime_evidence": current["runtime_evidence"],
586
+ "warnings": current["warnings"],
587
+ "evidence": attempts.evidence_references(store, directory, current),
588
+ }
589
+ plan_id = p._sha(plan)
590
+ decision = {
591
+ "code": "STARTED_NO_ACTION_RECOVERABLE",
592
+ "dry_run": dry_run,
593
+ "plan_id": plan_id,
594
+ "plan": plan,
595
+ }
596
+ if not dry_run:
597
+ if expected_plan_id != plan_id:
598
+ raise _blocked("exact fresh no-action reconciliation plan required")
599
+ decision["reconciliation"] = e.write(
600
+ path.parent, path.name, {**plan, "plan_id": plan_id, "timestamp": now_iso()}
601
+ )
602
+ result = status(repo)
603
+ result.update(command="resource-recover", decision=decision)
604
+ return result
605
+
606
+
607
+ def _incomplete(
608
+ repo: Path,
609
+ store: MetadataStore,
610
+ document: dict[str, Any],
611
+ directory: Path,
612
+ attempt_id: str,
613
+ underlying: dict[str, Any],
614
+ coordinator_error: Exception | None = None,
615
+ ) -> dict[str, Any]:
616
+ from .controller import envelope
617
+
618
+ # Persist the primary refusal BEFORE any optional diagnostic/coordinator work.
619
+ evidence = None
620
+ evidence_error = None
621
+ try:
622
+ evidence = e.write(
623
+ directory,
624
+ "underlying.json",
625
+ {
626
+ "contract": attempts.CONTRACT,
627
+ "attempt_id": attempt_id,
628
+ "underlying": underlying,
629
+ "timestamp": now_iso(),
630
+ },
631
+ )
632
+ except Exception as exc:
633
+ evidence_error = attempts.underlying(exception=exc)["exception"]
634
+ phase = "PARTIAL_ACTION"
635
+ diagnostic = None
636
+ try:
637
+ with store.lock(exclusive=False):
638
+ _current(repo, store, document)
639
+ attempts.no_action_evidence(store, document["plan"])
640
+ phase = "ATTEMPT_FAILED_BEFORE_ACTION"
641
+ except Exception as exc:
642
+ diagnostic = attempts.underlying(exception=exc)["exception"]
643
+ with suppress(Exception): # Keep the primary refusal and failed no-action verification.
644
+ phase = attempts.action_state(store, attempt_id) or phase
645
+ decision = {
646
+ "code": "RECOVERY_ATTEMPT_INCOMPLETE",
647
+ "attempt_id": attempt_id,
648
+ "phase": phase,
649
+ "underlying": underlying,
650
+ "underlying_evidence": evidence,
651
+ "evidence_error": evidence_error,
652
+ "coordinator_error": attempts.underlying(exception=coordinator_error)["exception"]
653
+ if coordinator_error
654
+ else None,
655
+ "no_action_verification_error": diagnostic,
656
+ "receipt_id": underlying.get("completion_receipt_id"),
657
+ "allocation_released": False,
658
+ }
659
+ try:
660
+ decision["attempt_result"] = e.write(
661
+ directory,
662
+ "result.json",
663
+ {
664
+ "contract": attempts.CONTRACT,
665
+ **decision,
666
+ "timestamp": now_iso(),
667
+ },
668
+ )
669
+ except Exception as exc:
670
+ decision["result_evidence_error"] = attempts.underlying(exception=exc)["exception"]
671
+ result = envelope("resource-recover")
672
+ result.update(decision=decision, errors=underlying["errors"])
673
+ return result
674
+
675
+
676
+ def resume(repo: Path, *, recovery_path: Path, recovery_sha: str, dry_run: bool) -> dict[str, Any]:
677
+ from .controller import status
678
+ from .lifecycle import cleanup_typed_disposable
679
+
680
+ store = MetadataStore(inventory(repo).common_dir)
681
+ with store.lock(exclusive=False):
682
+ candidate, _ = e.read(recovery_path, recovery_sha)
683
+ from . import multiple_cleanup_recovery as multi_recovery
684
+
685
+ if candidate.get("contract") == multi_recovery.CONTRACT:
686
+ return multi_recovery.resume(
687
+ repo, recovery_path=recovery_path, recovery_sha=recovery_sha, dry_run=dry_run
688
+ )
689
+ with store.lock(exclusive=not dry_run):
690
+ document, directory = _record(store, recovery_path, recovery_sha)
691
+ supersedes = attempts.authorization(directory, recovery_sha)
692
+ _current(repo, store, document)
693
+ attempts.no_action_evidence(store, document["plan"])
694
+ plan = document["plan"]
695
+ attempt_id = str(uuid.uuid4())
696
+ attempt_directory = directory / "attempts" / attempt_id
697
+ if not dry_run:
698
+ started = e.write(
699
+ attempt_directory,
700
+ "started.json",
701
+ {
702
+ "contract": attempts.CONTRACT,
703
+ "phase": "ATTEMPT_STARTED",
704
+ "attempt_id": attempt_id,
705
+ "record_sha256": recovery_sha,
706
+ "supersedes": supersedes,
707
+ "timestamp": now_iso(),
708
+ },
709
+ )
710
+ recovery = plan["recovery"]
711
+ executing = {
712
+ **recovery,
713
+ "recovery_id": document["recovery_id"],
714
+ "recovery_record_sha256": recovery_sha,
715
+ "attempt_id": attempt_id,
716
+ }
717
+ preserved = plan["preservation_evidence"]
718
+ options = dict(
719
+ target=Path(plan["target_path"]),
720
+ owner=plan["owner"],
721
+ expected_record_version=plan["expected_record_version"],
722
+ preservation_receipt=Path(preserved["receipt_path"]),
723
+ preservation_receipt_sha256=preserved["receipt_sha256"],
724
+ recovery=executing,
725
+ )
726
+ try:
727
+ planned = cleanup_typed_disposable(repo, **options, dry_run=True)
728
+ if (
729
+ planned["decision"]["plan"]["blockers"]
730
+ or planned["decision"]["plan"]["cleanup_paths"] != REMAINING
731
+ ):
732
+ if dry_run:
733
+ planned["command"] = "resource-recover"
734
+ return planned
735
+ return _incomplete(
736
+ repo, store, document, attempt_directory, attempt_id, attempts.underlying(planned)
737
+ )
738
+ if dry_run:
739
+ planned["command"] = "resource-recover"
740
+ planned["decision"].update(code="RECOVERABLE_PARTIAL", attempt_id=attempt_id)
741
+ return planned
742
+ cleaned = cleanup_typed_disposable(
743
+ repo, **options, dry_run=False, expected_plan_id=planned["decision"]["plan"]["plan_id"]
744
+ )
745
+ except Exception as exc:
746
+ if not dry_run:
747
+ try:
748
+ _incomplete(
749
+ repo,
750
+ store,
751
+ document,
752
+ attempt_directory,
753
+ attempt_id,
754
+ attempts.underlying(exception=exc),
755
+ )
756
+ except Exception as secondary:
757
+ raise exc from secondary # Failure reporting must not replace the primary cause.
758
+ raise
759
+ underlying = attempts.underlying(cleaned)
760
+ completion_id = underlying["completion_receipt_id"]
761
+ if not completion_id:
762
+ return _incomplete(repo, store, document, attempt_directory, attempt_id, underlying)
763
+ try:
764
+ if str(uuid.UUID(completion_id)) != completion_id:
765
+ raise _blocked("invalid completion receipt identity")
766
+ with store.lock(exclusive=True):
767
+ original(store, recovery)
768
+ done, digest = e.read(store.completion_receipts_dir / (completion_id + ".json"))
769
+ if done.get("result") != "CLEANED" or done.get("plan", {}).get("recovery") != executing:
770
+ raise _blocked("completion receipt is not a successful exact attempt")
771
+ reference = e.write(
772
+ directory,
773
+ "complete.json",
774
+ {
775
+ "contract": CONTRACT,
776
+ "result": "CLEANED",
777
+ "original_transaction_id": recovery["original_transaction_id"],
778
+ "record_sha256": recovery_sha,
779
+ "attempt_id": attempt_id,
780
+ "attempt_started": started,
781
+ "completion_receipt_id": completion_id,
782
+ "completion_receipt_sha256": digest,
783
+ "timestamp": now_iso(),
784
+ },
785
+ )
786
+ if not completed(store, recovery["original_transaction_id"], verify_generations=True):
787
+ raise _blocked("terminal recovery evidence verification failed")
788
+ e.write(
789
+ attempt_directory,
790
+ "result.json",
791
+ {
792
+ "contract": attempts.CONTRACT,
793
+ "attempt_id": attempt_id,
794
+ "phase": "ACTION_COMPLETED",
795
+ "completion": reference,
796
+ "underlying": underlying,
797
+ "timestamp": now_iso(),
798
+ },
799
+ )
800
+ except Exception as exc:
801
+ return _incomplete(repo, store, document, attempt_directory, attempt_id, underlying, exc)
802
+ result = status(repo)
803
+ result.update(
804
+ command="resource-recover",
805
+ decision={
806
+ "code": "RECOVERY_CLEANED",
807
+ "recovery_id": document["recovery_id"],
808
+ "attempt_id": attempt_id,
809
+ "receipt_id": completion_id,
810
+ "recovery_completion": reference,
811
+ },
812
+ )
813
+ return result
814
+
815
+
816
+ def completed(store: MetadataStore, transaction: str, *, verify_generations: bool = False) -> bool:
817
+ """A retained historical journal is terminal only through exact append-only evidence."""
818
+ parent = store.cleanup_recoveries_dir / transaction
819
+ if not parent.exists():
820
+ return False
821
+ terminals = list(parent.glob("*/complete.json"))
822
+ if not terminals:
823
+ return False
824
+ if len(terminals) != 1:
825
+ raise _blocked("ambiguous recovery completion")
826
+ terminal, _ = e.read(terminals[0])
827
+ from . import multiple_cleanup_recovery as multi_recovery
828
+
829
+ if terminal.get("contract") == multi_recovery.CONTRACT:
830
+ return multi_recovery.completed(
831
+ store, transaction, verify_generations=verify_generations
832
+ )
833
+ record, _ = e.read(terminals[0].parent / "record.json", terminal["record_sha256"])
834
+ plan = record["plan"]
835
+ recovery = plan["recovery"]
836
+ if (
837
+ terminal.get("contract") != CONTRACT
838
+ or terminal.get("result") != "CLEANED"
839
+ or terminal.get("original_transaction_id") != transaction
840
+ or recovery["original_transaction_id"] != transaction
841
+ or record.get("historical_hardlink_cause") != "NOT_PROVEN"
842
+ or record.get("completed_action_will_replay") is not False
843
+ ):
844
+ raise _blocked("invalid recovery terminal binding")
845
+ old, _ = e.read(
846
+ store.completion_receipts_dir / f"{transaction}.json",
847
+ recovery["original_cleanup_receipt_sha256"],
848
+ )
849
+ e.read(store.transactions_dir / f"{transaction}.json", recovery["original_transaction_sha256"])
850
+ done, _ = e.read(
851
+ store.completion_receipts_dir / (terminal["completion_receipt_id"] + ".json"),
852
+ terminal["completion_receipt_sha256"],
853
+ )
854
+ expected_actions = [{"path": name, "status": "COMPLETED"} for name in REMAINING]
855
+ bound = {
856
+ **recovery,
857
+ "recovery_id": record["recovery_id"],
858
+ "recovery_record_sha256": terminal["record_sha256"],
859
+ }
860
+ if terminal.get("attempt_id"):
861
+ succession = attempts.chain(terminals[0].parent, terminal["record_sha256"])
862
+ if (
863
+ not succession
864
+ or succession[-1]["attempt_id"] != terminal["attempt_id"]
865
+ or succession[-1]["started"] != terminal["attempt_started"]
866
+ ):
867
+ raise _blocked("terminal does not bind the latest unique attempt")
868
+ bound["attempt_id"] = terminal["attempt_id"]
869
+ if (
870
+ done.get("result") != "CLEANED"
871
+ or done.get("actions") != expected_actions
872
+ or done["plan"].get("recovery") != bound
873
+ or done["plan"]["allocation_id"] != old["plan"]["allocation_id"]
874
+ or done["post_scan"]["blockers"]
875
+ or done["post_scan"]["cleanup_paths"]
876
+ or done["plan"]["preservation_evidence"] != plan["preservation_evidence"]
877
+ ):
878
+ raise _blocked("new completion does not cover exactly the remaining actions")
879
+ graph = plan["hardlink_topology"]
880
+ for step, action in enumerate(done.get("action_evidence", [])):
881
+ before, _ = e.read(Path(action["before"]["path"]), action["before"]["sha256"])
882
+ after, _ = e.read(Path(action["after"]["path"]), action["after"]["sha256"])
883
+ if (
884
+ before["topology"] != graph
885
+ or before["resource"] != REMAINING[step]
886
+ or after["before"] != action["before"]
887
+ ):
888
+ raise _blocked("action evidence chain differs")
889
+ if (
890
+ hardlinks.transition(
891
+ graph,
892
+ after["topology"],
893
+ REMAINING[step],
894
+ after["mutation"].get("external_survivors"),
895
+ )
896
+ != after["mutation"]
897
+ ):
898
+ raise _blocked("unexpected action effects")
899
+ graph = after["topology"]
900
+ if graph["resources"] or len(done.get("action_evidence", [])) != len(REMAINING):
901
+ raise _blocked("incomplete immutable action evidence")
902
+ target = Path(plan["target_path"])
903
+ if any(os.path.lexists(target / name) for name in [COMPLETED_RESOURCE, *REMAINING]):
904
+ raise _blocked("completed cleanup resource reappeared")
905
+ if verify_generations:
906
+ for evidence in [old["plan"]["preservation_evidence"], plan["preservation_evidence"]]:
907
+ verified, _ = coverage(
908
+ Path(evidence["receipt_path"]), evidence["receipt_sha256"], verify_source=False
909
+ )
910
+ if verified != evidence:
911
+ raise _blocked("preserved generation changed after recovery")
912
+ return True