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.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""Resume only a proven completed prefix's untouched suffix, with immutable evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import cleanup_evidence as e
|
|
13
|
+
from . import preservation as p
|
|
14
|
+
from .git_backend import inventory
|
|
15
|
+
from .models import ControllerError, MetadataRecord
|
|
16
|
+
from .preserved_cleanup import (
|
|
17
|
+
_document,
|
|
18
|
+
common_source_root,
|
|
19
|
+
completed_actions,
|
|
20
|
+
coverage,
|
|
21
|
+
source_absent,
|
|
22
|
+
)
|
|
23
|
+
from .store import MetadataStore, now_iso, record_from_dict, record_to_dict
|
|
24
|
+
|
|
25
|
+
CONTRACT = "multiple-preservation-cleanup-recovery/v1"
|
|
26
|
+
CLEANUP_CONTRACT = "legacy-preserved-state-multiple-cleanup/v1"
|
|
27
|
+
IDENTITY_KEYS = (
|
|
28
|
+
"repository_id", "allocation_id", "target_path", "expected_head",
|
|
29
|
+
"expected_record_version", "owner", "authority_key",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _blocked(message: str) -> ControllerError:
|
|
34
|
+
return ControllerError("MULTIPLE_CLEANUP_RECOVERY_BLOCKED", message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _uuid(value: str) -> None:
|
|
38
|
+
try:
|
|
39
|
+
valid = str(uuid.UUID(value)) == value
|
|
40
|
+
except (ValueError, TypeError, AttributeError):
|
|
41
|
+
valid = False
|
|
42
|
+
if not valid:
|
|
43
|
+
raise _blocked("canonical recovery transaction identity required")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def context(transaction: str, transaction_sha256: str, receipt_sha256: str) -> dict[str, Any]:
|
|
47
|
+
_uuid(transaction)
|
|
48
|
+
if any(not isinstance(value, str) or len(value) != 64 or set(value) - set("0123456789abcdef")
|
|
49
|
+
for value in (transaction_sha256, receipt_sha256)):
|
|
50
|
+
raise _blocked("trusted original journal and failed receipt digests required")
|
|
51
|
+
return {
|
|
52
|
+
"contract": CONTRACT,
|
|
53
|
+
"original_transaction_id": transaction,
|
|
54
|
+
"original_transaction_sha256": transaction_sha256,
|
|
55
|
+
"original_cleanup_receipt_sha256": receipt_sha256,
|
|
56
|
+
"old_transaction_remains_immutable": True,
|
|
57
|
+
"completed_action_will_replay": False,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _identity(plan: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
return {key: plan[key] for key in IDENTITY_KEYS}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _plan_hash(plan: dict[str, Any]) -> bool:
|
|
66
|
+
from .lifecycle import _release_plan_id
|
|
67
|
+
|
|
68
|
+
return plan.get("plan_id") == _release_plan_id({key: value for key, value in plan.items() if key != "plan_id"})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _action_files(store: MetadataStore, transaction: str, count: int) -> None:
|
|
72
|
+
with p._absolute_directory(store.cleanup_actions_dir / transaction) as chain:
|
|
73
|
+
names = set(os.listdir(chain[-1].fd))
|
|
74
|
+
required = {f"{step:04d}-{phase}.json" for step in range(count) for phase in ("before", "after")}
|
|
75
|
+
if names != required:
|
|
76
|
+
raise _blocked("pending or unbound actions have evidence, or completed action files are incomplete")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _generation_metadata(bound: dict[str, Any]) -> list[dict[str, Any]]:
|
|
80
|
+
"""Freeze completed official generations without reading unreferenced payloads."""
|
|
81
|
+
for key in ("repository_id", "allocation_id"):
|
|
82
|
+
_uuid(bound[key])
|
|
83
|
+
allocation = p.evidence_root() / bound["repository_id"] / bound["allocation_id"]
|
|
84
|
+
result = []
|
|
85
|
+
with p._absolute_directory(allocation) as chain:
|
|
86
|
+
for name in sorted(os.listdir(chain[-1].fd)):
|
|
87
|
+
_uuid(name)
|
|
88
|
+
with p._absolute_directory(allocation / name) as generation:
|
|
89
|
+
directory = generation[-1]
|
|
90
|
+
try:
|
|
91
|
+
os.stat("receipt.json", dir_fd=directory.fd, follow_symlinks=False)
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
continue # Incomplete copies retain the existing no-authority rule.
|
|
94
|
+
files = []
|
|
95
|
+
for filename in ("plan.json", "receipt.json", "manifest.json"):
|
|
96
|
+
try:
|
|
97
|
+
os.stat(filename, dir_fd=directory.fd, follow_symlinks=False)
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
if filename != "manifest.json":
|
|
100
|
+
raise _blocked("completed generation metadata is missing") from None
|
|
101
|
+
continue
|
|
102
|
+
metadata, digest = _document(directory, filename)
|
|
103
|
+
if filename == "receipt.json" and (
|
|
104
|
+
metadata.get("receipt_id") != name
|
|
105
|
+
or metadata.get("generation") != str(allocation / name)
|
|
106
|
+
or metadata.get("generation_identity") != directory.identity
|
|
107
|
+
):
|
|
108
|
+
raise _blocked("completed generation receipt or original directory identity changed")
|
|
109
|
+
files.append({"path": str(allocation / name / filename), "sha256": digest})
|
|
110
|
+
result.append({"generation": name, "files": files})
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _frozen_authority(record: dict[str, Any]) -> MetadataRecord:
|
|
115
|
+
frozen = record_from_dict(record["allocation_record"])
|
|
116
|
+
baseline = record["plan"]
|
|
117
|
+
if (frozen.allocation_id != baseline["allocation_id"] or frozen.owner != baseline["owner"]
|
|
118
|
+
or frozen.authority_key != baseline["authority_key"]
|
|
119
|
+
or frozen.record_version != baseline["expected_record_version"]
|
|
120
|
+
or not frozen.history or frozen.history[-1].get("to") != frozen.lifecycle
|
|
121
|
+
or record.get("generation_metadata") != _generation_metadata(_identity(baseline))):
|
|
122
|
+
raise _blocked("frozen allocation lineage or complete generation metadata set changed")
|
|
123
|
+
for generation in record["generation_metadata"]:
|
|
124
|
+
for ref in generation["files"]:
|
|
125
|
+
e.read(Path(ref["path"]), ref["sha256"])
|
|
126
|
+
return frozen
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _released_record(frozen: MetadataRecord, current: MetadataRecord,
|
|
130
|
+
release_plan: dict[str, Any]) -> bool:
|
|
131
|
+
event = current.history[-1]
|
|
132
|
+
if (set(event) != {"at", "event", "from", "to", "owner"}
|
|
133
|
+
or event.get("event") != "RELEASED" or event.get("from") != frozen.lifecycle
|
|
134
|
+
or event.get("to") != "RELEASED" or event.get("owner") != "unassigned"
|
|
135
|
+
or release_plan.get("lifecycle") != frozen.lifecycle
|
|
136
|
+
or release_plan.get("role") != frozen.role
|
|
137
|
+
or release_plan.get("expected_record_version") != frozen.record_version):
|
|
138
|
+
return False
|
|
139
|
+
resources = [dict(resource) for resource in frozen.disposable_resources]
|
|
140
|
+
cleaned = {action["resource_id"] for action in release_plan["resource_actions"]}
|
|
141
|
+
for resource in resources:
|
|
142
|
+
if resource["resource_id"] in cleaned:
|
|
143
|
+
resource["state"] = "cleaned"
|
|
144
|
+
expected = replace(frozen, lifecycle="RELEASED", owner="unassigned",
|
|
145
|
+
record_version=frozen.record_version + 1, last_reviewed_at=event["at"],
|
|
146
|
+
history=(*frozen.history, event), disposable_resources=tuple(resources))
|
|
147
|
+
return record_to_dict(current) == record_to_dict(expected)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _released(store: MetadataStore, transaction: str) -> bool:
|
|
151
|
+
from .transaction_scope import release_evidence_terminal
|
|
152
|
+
|
|
153
|
+
if not release_evidence_terminal(store, transaction):
|
|
154
|
+
return False
|
|
155
|
+
journal, _ = e.read(store.transactions_dir / f"{transaction}.json")
|
|
156
|
+
cleanup = journal["cleanup_plan"]
|
|
157
|
+
terminals = list((store.cleanup_recoveries_dir / transaction).glob("*/complete.json"))
|
|
158
|
+
if len(terminals) != 1:
|
|
159
|
+
return False
|
|
160
|
+
terminal, _ = e.read(terminals[0])
|
|
161
|
+
baseline, _ = e.read(terminals[0].parent / "record.json", terminal["record_sha256"])
|
|
162
|
+
frozen = _frozen_authority(baseline)
|
|
163
|
+
recovery = baseline["plan"]["recovery"]
|
|
164
|
+
if (terminal.get("contract") != CONTRACT or terminal.get("original_transaction_id") != transaction
|
|
165
|
+
or recovery["original_transaction_id"] != transaction):
|
|
166
|
+
return False
|
|
167
|
+
e.read(store.transactions_dir / f"{transaction}.json", recovery["original_transaction_sha256"])
|
|
168
|
+
e.read(store.completion_receipts_dir / f"{transaction}.json", recovery["original_cleanup_receipt_sha256"])
|
|
169
|
+
record = record_from_dict(store._read_json(store.records_dir / f"{cleanup['allocation_id']}.json"))
|
|
170
|
+
releases = [e.read(path)[0] for path in store.release_receipts_dir.glob("*.json")]
|
|
171
|
+
matches = [receipt for receipt in releases if (
|
|
172
|
+
receipt.get("allocation_id") == record.allocation_id
|
|
173
|
+
and receipt.get("released_record_version") == record.record_version
|
|
174
|
+
and receipt.get("result") == "RELEASED"
|
|
175
|
+
)]
|
|
176
|
+
if len(matches) != 1:
|
|
177
|
+
return False
|
|
178
|
+
release = matches[0]
|
|
179
|
+
release_plan, _ = e.read(store.release_plans_dir / f"{release['plan_id']}.json")
|
|
180
|
+
if (not _plan_hash(release_plan)
|
|
181
|
+
or record.authority_key != cleanup["authority_key"]
|
|
182
|
+
or release.get("released_head") != cleanup["expected_head"]
|
|
183
|
+
or release.get("branch") != journal["branch"]
|
|
184
|
+
or release.get("released_record_version") != release.get("expected_record_version", -1) + 1
|
|
185
|
+
or any(release_plan.get(key) != cleanup[key] for key in IDENTITY_KEYS if key != "expected_record_version")
|
|
186
|
+
or any(release_plan.get(key) != release.get(key) for key in (
|
|
187
|
+
"repository_id", "allocation_id", "target_path", "branch", "expected_record_version"))
|
|
188
|
+
or release_plan.get("expected_head") != release["released_head"]
|
|
189
|
+
or not record.history or record.history[-1].get("event") != "RELEASED"
|
|
190
|
+
or record.history[-1].get("from") != release_plan.get("lifecycle")
|
|
191
|
+
or record.history[-1].get("to") != "RELEASED"):
|
|
192
|
+
return False
|
|
193
|
+
if release["expected_record_version"] == cleanup["expected_record_version"]:
|
|
194
|
+
return _released_record(frozen, record, release_plan)
|
|
195
|
+
# A retained checkout may advance exactly once through an archive whose
|
|
196
|
+
# frozen cleanup references bind this successful recovery receipt.
|
|
197
|
+
from .delivered_closeout import ARCHIVE_CONTRACT, ARCHIVE_EVENT, ARCHIVE_STATE, _digest
|
|
198
|
+
|
|
199
|
+
if (release["expected_record_version"] != cleanup["expected_record_version"] + 1
|
|
200
|
+
or release_plan.get("lifecycle") != ARCHIVE_STATE or len(record.history) < 2):
|
|
201
|
+
return False
|
|
202
|
+
event = record.history[-2]
|
|
203
|
+
archive, _ = e.read(store.completion_receipts_dir / f"{event['archive_receipt_id']}.json")
|
|
204
|
+
archived_plan = archive["plan"]
|
|
205
|
+
original = record_from_dict(archived_plan["record"])
|
|
206
|
+
archived = replace(original, lifecycle=ARCHIVE_STATE, record_version=original.record_version + 1,
|
|
207
|
+
last_reviewed_at=event["at"], history=(*original.history, event))
|
|
208
|
+
completion_path = store.completion_receipts_dir / f"{terminal['completion_receipt_id']}.json"
|
|
209
|
+
references = archived_plan.get("preservation_cleanup_receipts", [])
|
|
210
|
+
if (archive.get("contract") != ARCHIVE_CONTRACT or archive.get("receipt_id") != event["archive_receipt_id"]
|
|
211
|
+
or archive.get("plan_id") != _digest(archived_plan) or event.get("event") != ARCHIVE_EVENT
|
|
212
|
+
or event.get("from") != "RETAINED" or event.get("to") != ARCHIVE_STATE
|
|
213
|
+
or event.get("owner") != cleanup["owner"] or event.get("archive_plan_id") != archive["plan_id"]
|
|
214
|
+
or event.get("archive_receipt_sha256") != _digest({key: value for key, value in archive.items() if key != "after_record"})
|
|
215
|
+
or archive.get("after_record") != record_to_dict(archived)
|
|
216
|
+
or list(archived.history) != list(record.history[:-1])
|
|
217
|
+
or record_to_dict(original) != record_to_dict(frozen)
|
|
218
|
+
or original.record_version != cleanup["expected_record_version"] or original.lifecycle != "RETAINED"
|
|
219
|
+
or original.owner != cleanup["owner"] or original.authority_key != cleanup["authority_key"]
|
|
220
|
+
or original.allocation_id != cleanup["allocation_id"]
|
|
221
|
+
or archived_plan.get("repository_id") != cleanup["repository_id"]
|
|
222
|
+
or archived_plan.get("target_path") != cleanup["target_path"]
|
|
223
|
+
or archived_plan.get("head") != cleanup["expected_head"] or archived_plan.get("branch") != journal["branch"]
|
|
224
|
+
or archived_plan.get("owner_release") != cleanup["owner"]
|
|
225
|
+
or any(archived_plan.get(key) is not False for key in ("task_completed", "integration_ready", "publication_asserted"))
|
|
226
|
+
or not any(ref.get("receipt_id") == terminal["completion_receipt_id"]
|
|
227
|
+
and ref.get("sha256") == terminal["completion_receipt_sha256"]
|
|
228
|
+
and e.matches_reference(ref, completion_path) for ref in references)):
|
|
229
|
+
return False
|
|
230
|
+
for ref in references:
|
|
231
|
+
e.read(Path(ref["path"]), ref["sha256"])
|
|
232
|
+
return _released_record(archived, record, release_plan)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _source_absent(store: MetadataStore, source_plan: dict[str, Any], target: Path,
|
|
236
|
+
transaction: str) -> bool:
|
|
237
|
+
if os.path.lexists(target):
|
|
238
|
+
return source_absent(source_plan, target)
|
|
239
|
+
return _released(store, transaction)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _common_root(store: MetadataStore, plans: list[dict[str, Any]], target: Path,
|
|
243
|
+
transaction: str) -> dict[str, int]:
|
|
244
|
+
if os.path.lexists(target):
|
|
245
|
+
return common_source_root(plans, target)
|
|
246
|
+
if not _released(store, transaction):
|
|
247
|
+
raise _blocked("absent checkout has no exact official release evidence")
|
|
248
|
+
pins = []
|
|
249
|
+
for plan in plans:
|
|
250
|
+
relative = p.source_relative_path(Path(plan["source"]).relative_to(target).as_posix())
|
|
251
|
+
if "source_ancestor_identities" in plan:
|
|
252
|
+
pin = plan["source_ancestor_identities"].get(str(target))
|
|
253
|
+
elif len(Path(relative).parts) == 1:
|
|
254
|
+
pin = plan["source_anchor"]["worktree"]
|
|
255
|
+
else:
|
|
256
|
+
pin = None
|
|
257
|
+
if pin is None or set(pin) != {"device", "inode", "file_type"} or pin["file_type"] != stat.S_IFDIR:
|
|
258
|
+
raise _blocked("missing or invalid original checkout root pin")
|
|
259
|
+
pins.append(pin)
|
|
260
|
+
if not pins or any(pin != pins[0] for pin in pins):
|
|
261
|
+
raise _blocked("preservation generations have different original checkout roots")
|
|
262
|
+
return pins[0]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _proofset(plan: dict[str, Any], target: Path) -> list[dict[str, Any]]:
|
|
266
|
+
proofs = plan["preservation_evidences"]
|
|
267
|
+
names = [row["source_relative"] for row in proofs]
|
|
268
|
+
if (not isinstance(proofs, list) or len(proofs) < 2 or names != sorted(set(names))
|
|
269
|
+
or plan.get("preservation_evidence") != proofs[0]["evidence"]):
|
|
270
|
+
raise _blocked("the complete ordered multiple preservation proofset is required")
|
|
271
|
+
preserved = []
|
|
272
|
+
for row in proofs:
|
|
273
|
+
relative = p.source_relative_path(row["source_relative"])
|
|
274
|
+
if any(Path(relative) in Path(other).parents for other in names if other != relative):
|
|
275
|
+
raise _blocked("preserved sources overlap")
|
|
276
|
+
evidence = row["evidence"]
|
|
277
|
+
verified, source_plan = coverage(
|
|
278
|
+
Path(evidence["receipt_path"]), evidence["receipt_sha256"],
|
|
279
|
+
identity=_identity(plan), verify_source=False,
|
|
280
|
+
)
|
|
281
|
+
if verified != evidence or Path(source_plan["source"]).relative_to(target).as_posix() != relative:
|
|
282
|
+
raise _blocked("original preservation proof identity changed")
|
|
283
|
+
preserved.append(source_plan)
|
|
284
|
+
return preserved
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def details(
|
|
288
|
+
store: MetadataStore, recovery: dict[str, Any],
|
|
289
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]], set[str], dict[str, Any]]:
|
|
290
|
+
"""Verify historical bytes and prefix effects; current admission is checked separately."""
|
|
291
|
+
try:
|
|
292
|
+
expected = context(recovery["original_transaction_id"], recovery["original_transaction_sha256"],
|
|
293
|
+
recovery["original_cleanup_receipt_sha256"])
|
|
294
|
+
if any(recovery.get(key) != value for key, value in expected.items()):
|
|
295
|
+
raise _blocked("recovery history differs from its supported contract")
|
|
296
|
+
if set(recovery) - (set(expected) | {"recovery_id", "recovery_record_sha256"}):
|
|
297
|
+
raise _blocked("unknown recovery history assertions are not supported")
|
|
298
|
+
if ("recovery_id" in recovery) != ("recovery_record_sha256" in recovery):
|
|
299
|
+
raise _blocked("executing recovery requires both exact immutable record bindings")
|
|
300
|
+
if "recovery_id" in recovery:
|
|
301
|
+
_uuid(recovery["recovery_id"])
|
|
302
|
+
digest = recovery["recovery_record_sha256"]
|
|
303
|
+
if not isinstance(digest, str) or len(digest) != 64 or set(digest) - set("0123456789abcdef"):
|
|
304
|
+
raise _blocked("executing recovery record digest is invalid")
|
|
305
|
+
transaction = recovery["original_transaction_id"]
|
|
306
|
+
journal, _ = e.read(store.transactions_dir / f"{transaction}.json",
|
|
307
|
+
recovery["original_transaction_sha256"])
|
|
308
|
+
receipt, _ = e.read(store.completion_receipts_dir / f"{transaction}.json",
|
|
309
|
+
recovery["original_cleanup_receipt_sha256"])
|
|
310
|
+
plan = receipt["plan"]
|
|
311
|
+
target = Path(plan["target_path"])
|
|
312
|
+
identity = _identity(plan)
|
|
313
|
+
paths = plan["cleanup_paths"]
|
|
314
|
+
actions = receipt["actions"]
|
|
315
|
+
prefix = [row["path"] for row in actions if row["status"] == "COMPLETED"]
|
|
316
|
+
shape = [{"path": name, "status": "COMPLETED"} for name in prefix] + [
|
|
317
|
+
{"path": name, "status": "PENDING"} for name in paths[len(prefix):]
|
|
318
|
+
]
|
|
319
|
+
rows = plan["resources"]
|
|
320
|
+
planned_paths = [row["path"] for row in rows if row["cleanup_action"] == "remove-tree"] + [
|
|
321
|
+
row["path"] for row in rows if row["cleanup_action"] == "remove-preserved-tree"
|
|
322
|
+
]
|
|
323
|
+
if (receipt.get("receipt_id") != transaction or receipt.get("result") != "FAILED_OR_PARTIAL"
|
|
324
|
+
or receipt.get("contract") != CLEANUP_CONTRACT or plan.get("contract") != CLEANUP_CONTRACT
|
|
325
|
+
or not _plan_hash(plan) or plan.get("blockers") != [] or not target.is_absolute()
|
|
326
|
+
or not paths or paths != planned_paths or len(set(paths)) != len(paths)
|
|
327
|
+
or not prefix or len(prefix) >= len(paths) or prefix != paths[:len(prefix)]
|
|
328
|
+
or actions != shape or journal.get("actions") != shape
|
|
329
|
+
or journal.get("transaction_id") != transaction or journal.get("command") != "resource-clean"
|
|
330
|
+
or journal.get("phase") != "PREPARED" or journal.get("schema_version") not in {1, 2, 3, 4}
|
|
331
|
+
or journal.get("cleanup_plan") != plan or not isinstance(journal.get("branch"), str)
|
|
332
|
+
or not journal["branch"] or receipt.get("failure") != journal.get("failure")
|
|
333
|
+
or any(journal.get(key) != identity[key] for key in IDENTITY_KEYS[:5])
|
|
334
|
+
or identity["repository_id"] != store._read_json(store.root / "repo.json")["repository_id"]):
|
|
335
|
+
raise _blocked("only an exact COMPLETED prefix and untouched PENDING suffix are supported")
|
|
336
|
+
for name in paths:
|
|
337
|
+
p.source_relative_path(name)
|
|
338
|
+
proofs = tuple(receipt["action_evidence"])
|
|
339
|
+
if len(proofs) != len(prefix):
|
|
340
|
+
raise _blocked("every completed original action needs exact before and after evidence")
|
|
341
|
+
done, topology = completed_actions(
|
|
342
|
+
store, transaction, proofs, initial_topology=plan["hardlink_topology"], cleanup_paths=paths,
|
|
343
|
+
)
|
|
344
|
+
_action_files(store, transaction, len(prefix))
|
|
345
|
+
if done != set(prefix):
|
|
346
|
+
raise _blocked("pending actions have evidence or original action files are incomplete")
|
|
347
|
+
preserved = _proofset(plan, target)
|
|
348
|
+
preserved_rows = {row["path"]: row for row in rows if row["cleanup_action"] == "remove-preserved-tree"}
|
|
349
|
+
if set(preserved_rows) != {row["source_relative"] for row in plan["preservation_evidences"]}:
|
|
350
|
+
raise _blocked("original resources differ from the full preservation proofset")
|
|
351
|
+
for proof, source_plan in zip(plan["preservation_evidences"], preserved, strict=True):
|
|
352
|
+
row = preserved_rows[proof["source_relative"]]
|
|
353
|
+
if row.get("classification") != "exact-preserved" or row.get("evidence") != proof["evidence"]:
|
|
354
|
+
raise _blocked("original resource preservation binding changed")
|
|
355
|
+
if proof["source_relative"] in done and not _source_absent(store, source_plan, target, transaction):
|
|
356
|
+
raise _blocked("completed preserved source reappeared")
|
|
357
|
+
if any(os.path.lexists(target / name) for name in done):
|
|
358
|
+
raise _blocked("completed original resource reappeared; replay is forbidden")
|
|
359
|
+
root = _common_root(store, preserved, target, transaction)
|
|
360
|
+
if any(anchor["worktree"] != root for anchor in plan["hardlink_topology"]["anchors"].values()):
|
|
361
|
+
raise _blocked("original graph is bound to another checkout root")
|
|
362
|
+
return receipt, preserved, done, topology
|
|
363
|
+
except ControllerError:
|
|
364
|
+
raise
|
|
365
|
+
except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
|
|
366
|
+
raise _blocked("original multi cleanup evidence is unavailable or malformed") from exc
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def original(store: MetadataStore, recovery: dict[str, Any], *, target: Path | None = None,
|
|
370
|
+
allocation_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
371
|
+
receipt, preserved, _, _ = details(store, recovery)
|
|
372
|
+
_guard(store, recovery)
|
|
373
|
+
if ((target is not None and receipt["plan"]["target_path"] != str(target))
|
|
374
|
+
or (allocation_id is not None and receipt["plan"]["allocation_id"] != allocation_id)):
|
|
375
|
+
raise _blocked("original cleanup belongs to another target or allocation")
|
|
376
|
+
return receipt, preserved[0]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _guard(store: MetadataStore, recovery: dict[str, Any], *, allow_successor: bool = True) -> None:
|
|
380
|
+
from .transaction_scope import is_atomic_temp, terminal_state
|
|
381
|
+
|
|
382
|
+
parent = store.cleanup_recoveries_dir / recovery["original_transaction_id"]
|
|
383
|
+
if recovery.get("recovery_id"):
|
|
384
|
+
directory = _directory(store, recovery["original_transaction_id"], recovery["recovery_id"])
|
|
385
|
+
record, _ = load_record(store, directory / "record.json", recovery["recovery_record_sha256"])
|
|
386
|
+
if record["plan"]["recovery"] != context(recovery["original_transaction_id"],
|
|
387
|
+
recovery["original_transaction_sha256"], recovery["original_cleanup_receipt_sha256"]):
|
|
388
|
+
raise _blocked("executing recovery does not bind its immutable baseline")
|
|
389
|
+
elif list(parent.glob("*/started.json")):
|
|
390
|
+
raise _blocked("started recovery cannot replay; exact terminal reconciliation is required")
|
|
391
|
+
for path in store.transactions_dir.iterdir():
|
|
392
|
+
if is_atomic_temp(path.name) or path.stem == recovery["original_transaction_id"]:
|
|
393
|
+
continue
|
|
394
|
+
if not path.is_file() or path.suffix != ".json":
|
|
395
|
+
raise _blocked("unknown transaction entry blocks recovery")
|
|
396
|
+
other, _ = e.read(path)
|
|
397
|
+
if (allow_successor and recovery.get("recovery_id") and other.get("command") == "resource-clean"
|
|
398
|
+
and other.get("cleanup_plan", {}).get("recovery") == recovery):
|
|
399
|
+
continue
|
|
400
|
+
if terminal_state(store, path.stem, verify_generations=True) is None:
|
|
401
|
+
raise _blocked("another interrupted transaction requires its own recovery")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _inputs(plan: dict[str, Any]) -> dict[str, Any]:
|
|
405
|
+
proofs = plan["preservation_evidences"]
|
|
406
|
+
return {
|
|
407
|
+
"preservation_receipt": Path(proofs[0]["evidence"]["receipt_path"]),
|
|
408
|
+
"preservation_receipt_sha256": proofs[0]["evidence"]["receipt_sha256"],
|
|
409
|
+
"additional_preservation_receipts": tuple(Path(row["evidence"]["receipt_path"]) for row in proofs[1:]),
|
|
410
|
+
"additional_preservation_receipt_sha256": tuple(row["evidence"]["receipt_sha256"] for row in proofs[1:]),
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _plan_locked(repo: Path, store: MetadataStore, target: Path, owner: str, version: int,
|
|
415
|
+
recovery: dict[str, Any]) -> dict[str, Any]:
|
|
416
|
+
from .lifecycle import _typed_cleanup_plan_locked
|
|
417
|
+
from .semantics import managed_record as _managed_record
|
|
418
|
+
|
|
419
|
+
old, _, done, topology = details(store, recovery)
|
|
420
|
+
_guard(store, recovery)
|
|
421
|
+
repository = inventory(repo)
|
|
422
|
+
snapshot = store._load_unlocked()
|
|
423
|
+
item, record = _managed_record(repository, snapshot, target)
|
|
424
|
+
current = _typed_cleanup_plan_locked(repository, snapshot, store, item, record,
|
|
425
|
+
owner=owner, expected_record_version=version, recovery=recovery, **_inputs(old["plan"]))
|
|
426
|
+
expected = old["plan"]
|
|
427
|
+
suffix = expected["cleanup_paths"][len(done):]
|
|
428
|
+
old_rows = {row["path"]: row for row in expected["resources"]}
|
|
429
|
+
if (current["blockers"] or _identity(current) != _identity(expected)
|
|
430
|
+
or item.branch != store._read_json(store.transactions_dir / f"{recovery['original_transaction_id']}.json")["branch"]
|
|
431
|
+
or current.get("contract") != CLEANUP_CONTRACT or current["cleanup_paths"] != suffix
|
|
432
|
+
or current.get("preservation_evidences") != expected["preservation_evidences"]
|
|
433
|
+
or current["hardlink_topology"] != topology
|
|
434
|
+
or any(row != old_rows.get(row["path"]) for row in current["resources"] if row["path"] in suffix)):
|
|
435
|
+
raise _blocked("current resource, topology, identity or runtime guards differ from the proven suffix")
|
|
436
|
+
return current
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def plan(repo: Path, *, target: Path, owner: str, version: int,
|
|
440
|
+
recovery: dict[str, Any]) -> dict[str, Any]:
|
|
441
|
+
store = MetadataStore(inventory(repo).common_dir)
|
|
442
|
+
with store.lock(exclusive=False):
|
|
443
|
+
return _plan_locked(repo, store, target, owner, version, recovery)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _directory(store: MetadataStore, transaction: str, recovery_id: str) -> Path:
|
|
447
|
+
_uuid(transaction)
|
|
448
|
+
_uuid(recovery_id)
|
|
449
|
+
return store.cleanup_recoveries_dir / transaction / recovery_id
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def prepare(repo: Path, *, target: Path, owner: str, version: int, recovery: dict[str, Any],
|
|
453
|
+
dry_run: bool, expected_plan_id: str | None = None) -> dict[str, Any]:
|
|
454
|
+
from .controller import status
|
|
455
|
+
|
|
456
|
+
store = MetadataStore(inventory(repo).common_dir)
|
|
457
|
+
with store.lock(exclusive=not dry_run):
|
|
458
|
+
current = _plan_locked(repo, store, target, owner, version, recovery)
|
|
459
|
+
decision = {"code": "MULTIPLE_CLEANUP_RECOVERABLE", "plan": current, "dry_run": dry_run}
|
|
460
|
+
if not dry_run:
|
|
461
|
+
if expected_plan_id != current["plan_id"]:
|
|
462
|
+
raise _blocked("exact fresh recovery dry-run plan required")
|
|
463
|
+
parent = store.cleanup_recoveries_dir / recovery["original_transaction_id"]
|
|
464
|
+
if parent.exists() and list(parent.glob("*/record.json")):
|
|
465
|
+
raise _blocked("an immutable recovery baseline already exists")
|
|
466
|
+
recovery_id = str(uuid.uuid4())
|
|
467
|
+
directory = _directory(store, recovery["original_transaction_id"], recovery_id)
|
|
468
|
+
old, _, done, topology = details(store, recovery)
|
|
469
|
+
allocation_record = store._load_unlocked().records[current["allocation_id"]]
|
|
470
|
+
record = {
|
|
471
|
+
"contract": CONTRACT, "result": "MULTIPLE_CLEANUP_RECOVERABLE",
|
|
472
|
+
"recovery_id": recovery_id, "plan": current,
|
|
473
|
+
"original_plan_id": old["plan"]["plan_id"],
|
|
474
|
+
"original_preservation_evidences": old["plan"]["preservation_evidences"],
|
|
475
|
+
"completed_resources": old["plan"]["cleanup_paths"][:len(done)],
|
|
476
|
+
"remaining_resources": current["cleanup_paths"],
|
|
477
|
+
"prefix_topology_sha256": topology["sha256"],
|
|
478
|
+
"allocation_record": record_to_dict(allocation_record),
|
|
479
|
+
"generation_metadata": _generation_metadata(_identity(current)),
|
|
480
|
+
"old_transaction_remains_immutable": True, "completed_action_will_replay": False,
|
|
481
|
+
"timestamp": now_iso(),
|
|
482
|
+
}
|
|
483
|
+
decision.update(recovery_id=recovery_id, recovery_receipt=e.write(directory, "record.json", record))
|
|
484
|
+
result = status(repo)
|
|
485
|
+
result.update(command="resource-recover", decision=decision)
|
|
486
|
+
return result
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def load_record(store: MetadataStore, recovery_path: Path,
|
|
490
|
+
recovery_sha: str) -> tuple[dict[str, Any], Path]:
|
|
491
|
+
try:
|
|
492
|
+
return _load_record(store, recovery_path, recovery_sha)
|
|
493
|
+
except ControllerError:
|
|
494
|
+
raise
|
|
495
|
+
except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
|
|
496
|
+
raise _blocked("immutable multiple recovery record is unavailable or malformed") from exc
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _load_record(store: MetadataStore, recovery_path: Path,
|
|
500
|
+
recovery_sha: str) -> tuple[dict[str, Any], Path]:
|
|
501
|
+
record, _ = e.read(recovery_path, recovery_sha)
|
|
502
|
+
_frozen_authority(record)
|
|
503
|
+
recovery = record["plan"]["recovery"]
|
|
504
|
+
directory = _directory(store, recovery["original_transaction_id"], record["recovery_id"])
|
|
505
|
+
old, _, done, topology = details(store, recovery)
|
|
506
|
+
baseline = record["plan"]
|
|
507
|
+
suffix = old["plan"]["cleanup_paths"][len(done):]
|
|
508
|
+
old_rows = {row["path"]: row for row in old["plan"]["resources"]}
|
|
509
|
+
if (not e.matches_reference({"path": str(recovery_path), "sha256": recovery_sha}, directory / "record.json")
|
|
510
|
+
or record.get("contract") != CONTRACT or record.get("result") != "MULTIPLE_CLEANUP_RECOVERABLE"
|
|
511
|
+
or record.get("old_transaction_remains_immutable") is not True
|
|
512
|
+
or record.get("completed_action_will_replay") is not False
|
|
513
|
+
or record.get("original_plan_id") != old["plan"]["plan_id"]
|
|
514
|
+
or record.get("original_preservation_evidences") != old["plan"]["preservation_evidences"]
|
|
515
|
+
or record.get("completed_resources") != old["plan"]["cleanup_paths"][:len(done)]
|
|
516
|
+
or record.get("remaining_resources") != old["plan"]["cleanup_paths"][len(done):]
|
|
517
|
+
or record.get("prefix_topology_sha256") != topology["sha256"]
|
|
518
|
+
or not _plan_hash(baseline) or baseline.get("contract") != CLEANUP_CONTRACT
|
|
519
|
+
or _identity(baseline) != _identity(old["plan"])
|
|
520
|
+
or baseline.get("cleanup_paths") != suffix or baseline.get("hardlink_topology") != topology
|
|
521
|
+
or baseline.get("preservation_evidences") != old["plan"]["preservation_evidences"]
|
|
522
|
+
or baseline.get("preservation_evidence") != old["plan"]["preservation_evidence"]
|
|
523
|
+
or baseline.get("blockers") != []
|
|
524
|
+
or any(row != old_rows.get(row["path"]) for row in baseline["resources"] if row["path"] in suffix)):
|
|
525
|
+
raise _blocked("invalid immutable multiple recovery record")
|
|
526
|
+
return record, directory
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def recovered_receipt(store: MetadataStore, receipt: dict[str, Any],
|
|
530
|
+
bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
531
|
+
"""Verify a successful suffix receipt against its original full proofset and prefix."""
|
|
532
|
+
try:
|
|
533
|
+
return _recovered_receipt(store, receipt, bound)
|
|
534
|
+
except ControllerError:
|
|
535
|
+
raise
|
|
536
|
+
except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
|
|
537
|
+
raise _blocked("multiple recovery completion is unavailable or malformed") from exc
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _recovered_receipt(store: MetadataStore, receipt: dict[str, Any],
|
|
541
|
+
bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
542
|
+
successor = receipt["plan"]
|
|
543
|
+
executing = successor["recovery"]
|
|
544
|
+
record_sha = executing["recovery_record_sha256"]
|
|
545
|
+
directory = _directory(store, executing["original_transaction_id"], executing["recovery_id"])
|
|
546
|
+
record, _ = load_record(store, directory / "record.json", record_sha)
|
|
547
|
+
baseline = record["plan"]
|
|
548
|
+
expected_recovery = {**baseline["recovery"], "recovery_id": record["recovery_id"],
|
|
549
|
+
"recovery_record_sha256": record_sha}
|
|
550
|
+
old, preserved, done, topology = details(store, expected_recovery)
|
|
551
|
+
suffix = old["plan"]["cleanup_paths"][len(done):]
|
|
552
|
+
post = receipt["post_scan"]
|
|
553
|
+
if (receipt.get("contract") != CLEANUP_CONTRACT or receipt.get("result") != "CLEANED"
|
|
554
|
+
or successor.get("contract") != CLEANUP_CONTRACT or executing != expected_recovery
|
|
555
|
+
or _identity(successor) != _identity(old["plan"])
|
|
556
|
+
or (bound is not None and _identity(successor) != bound)
|
|
557
|
+
or successor.get("blockers") != [] or not _plan_hash(successor)
|
|
558
|
+
or successor.get("preservation_evidences") != old["plan"]["preservation_evidences"]
|
|
559
|
+
or successor.get("preservation_evidence") != old["plan"]["preservation_evidence"]
|
|
560
|
+
or successor.get("hardlink_topology") != topology or successor.get("cleanup_paths") != suffix
|
|
561
|
+
or receipt.get("actions") != [{"path": name, "status": "COMPLETED"} for name in suffix]
|
|
562
|
+
or len(receipt.get("action_evidence", [])) != len(suffix)
|
|
563
|
+
or any(successor.get(key) != value for key, value in baseline.items() if key not in {"plan_id", "recovery", "runtime_evidence", "warnings"})
|
|
564
|
+
or post.get("contract") != CLEANUP_CONTRACT or post.get("recovery") != expected_recovery
|
|
565
|
+
or _identity(post) != _identity(successor) or not _plan_hash(post)
|
|
566
|
+
or post.get("preservation_evidences") != old["plan"]["preservation_evidences"]
|
|
567
|
+
or post.get("preservation_evidence") != old["plan"]["preservation_evidence"]
|
|
568
|
+
or post.get("blockers") != [] or post.get("cleanup_paths") != []):
|
|
569
|
+
raise _blocked("successful suffix completion differs from its immutable recovery baseline")
|
|
570
|
+
completed, final = completed_actions(store, receipt["receipt_id"], tuple(receipt["action_evidence"]),
|
|
571
|
+
initial_topology=topology, cleanup_paths=suffix)
|
|
572
|
+
_action_files(store, receipt["receipt_id"], len(suffix))
|
|
573
|
+
if completed != set(suffix) or final != post.get("hardlink_topology") or final["resources"]:
|
|
574
|
+
raise _blocked("suffix action chain is incomplete or its final scan changed")
|
|
575
|
+
target = Path(successor["target_path"])
|
|
576
|
+
if any(os.path.lexists(target / name) for name in old["plan"]["cleanup_paths"]):
|
|
577
|
+
raise _blocked("a completed cleanup resource reappeared")
|
|
578
|
+
for source_plan in preserved:
|
|
579
|
+
if not _source_absent(store, source_plan, target, executing["original_transaction_id"]):
|
|
580
|
+
raise _blocked("completed preservation source or ancestors changed")
|
|
581
|
+
return preserved
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _append_terminal(store: MetadataStore, record: dict[str, Any], directory: Path,
|
|
585
|
+
record_sha: str, receipt: dict[str, Any], receipt_sha: str) -> dict[str, str]:
|
|
586
|
+
recovered_receipt(store, receipt)
|
|
587
|
+
executing = receipt["plan"]["recovery"]
|
|
588
|
+
_guard(store, executing, allow_successor=False)
|
|
589
|
+
started, started_sha = e.read(directory / "started.json")
|
|
590
|
+
if (started.get("contract") != CONTRACT or started.get("phase") != "ATTEMPT_STARTED"
|
|
591
|
+
or started.get("record_sha256") != record_sha or started.get("recovery_id") != record["recovery_id"]):
|
|
592
|
+
raise _blocked("completion requires the exact immutable started recovery")
|
|
593
|
+
value = {
|
|
594
|
+
"contract": CONTRACT, "result": "CLEANED",
|
|
595
|
+
"original_transaction_id": record["plan"]["recovery"]["original_transaction_id"],
|
|
596
|
+
"record_sha256": record_sha,
|
|
597
|
+
"started": {"path": str(directory / "started.json"), "sha256": started_sha},
|
|
598
|
+
"completion_receipt_id": receipt["receipt_id"], "completion_receipt_sha256": receipt_sha,
|
|
599
|
+
"completed_action_will_replay": False, "timestamp": now_iso(),
|
|
600
|
+
}
|
|
601
|
+
path = directory / "complete.json"
|
|
602
|
+
if path.exists():
|
|
603
|
+
existing, digest = e.read(path)
|
|
604
|
+
if any(existing.get(key) != item for key, item in value.items() if key != "timestamp"):
|
|
605
|
+
raise _blocked("existing terminal evidence differs")
|
|
606
|
+
return {"path": str(path), "sha256": digest}
|
|
607
|
+
return e.write(directory, "complete.json", value)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def resume(repo: Path, *, recovery_path: Path, recovery_sha: str, dry_run: bool) -> dict[str, Any]:
|
|
611
|
+
from .controller import status
|
|
612
|
+
from .lifecycle import cleanup_typed_disposable
|
|
613
|
+
|
|
614
|
+
store = MetadataStore(inventory(repo).common_dir)
|
|
615
|
+
with store.lock(exclusive=not dry_run):
|
|
616
|
+
record, directory = load_record(store, recovery_path, recovery_sha)
|
|
617
|
+
baseline = record["plan"]
|
|
618
|
+
executing = {**baseline["recovery"], "recovery_id": record["recovery_id"],
|
|
619
|
+
"recovery_record_sha256": recovery_sha}
|
|
620
|
+
if (directory / "started.json").exists():
|
|
621
|
+
_guard(store, executing)
|
|
622
|
+
successful = []
|
|
623
|
+
for path in store.completion_receipts_dir.glob("*.json"):
|
|
624
|
+
receipt, digest = e.read(path)
|
|
625
|
+
if receipt.get("plan", {}).get("recovery") == executing and receipt.get("result") == "CLEANED":
|
|
626
|
+
recovered_receipt(store, receipt)
|
|
627
|
+
successful.append((receipt, digest))
|
|
628
|
+
if len(successful) != 1:
|
|
629
|
+
raise _blocked("started recovery cannot replay; partial or unknown effects require separate recovery")
|
|
630
|
+
receipt, digest = successful[0]
|
|
631
|
+
reference = {"path": str(directory / "complete.json")}
|
|
632
|
+
if not dry_run:
|
|
633
|
+
reference = _append_terminal(store, record, directory, recovery_sha, receipt, digest)
|
|
634
|
+
verified_completion(store, executing["original_transaction_id"])
|
|
635
|
+
else:
|
|
636
|
+
_guard(store, executing, allow_successor=False)
|
|
637
|
+
if (directory / "complete.json").exists():
|
|
638
|
+
verified_completion(store, executing["original_transaction_id"])
|
|
639
|
+
decision = {"code": "MULTIPLE_RECOVERY_COMPLETION_READY" if dry_run and not (directory / "complete.json").exists()
|
|
640
|
+
else "MULTIPLE_RECOVERY_CLEANED", "dry_run": dry_run,
|
|
641
|
+
"receipt_id": receipt["receipt_id"], "recovery_completion": reference,
|
|
642
|
+
"completed_action_will_replay": False}
|
|
643
|
+
else:
|
|
644
|
+
current = _plan_locked(repo, store, Path(baseline["target_path"]), baseline["owner"],
|
|
645
|
+
baseline["expected_record_version"], baseline["recovery"])
|
|
646
|
+
allocation = store._load_unlocked().records[baseline["allocation_id"]]
|
|
647
|
+
if record_to_dict(allocation) != record["allocation_record"]:
|
|
648
|
+
raise _blocked("live allocation record differs from the frozen recovery authority")
|
|
649
|
+
if p._sha(current) != p._sha(baseline):
|
|
650
|
+
raise _blocked("current state differs from the immutable recovery plan")
|
|
651
|
+
if dry_run:
|
|
652
|
+
decision = {"code": "MULTIPLE_CLEANUP_RECOVERABLE", "plan": current, "dry_run": True}
|
|
653
|
+
else:
|
|
654
|
+
e.write(directory, "started.json", {"contract": CONTRACT, "record_sha256": recovery_sha,
|
|
655
|
+
"recovery_id": record["recovery_id"], "phase": "ATTEMPT_STARTED", "timestamp": now_iso()})
|
|
656
|
+
decision = None
|
|
657
|
+
if decision is None:
|
|
658
|
+
options = dict(target=Path(baseline["target_path"]), owner=baseline["owner"],
|
|
659
|
+
expected_record_version=baseline["expected_record_version"], recovery=executing, **_inputs(baseline))
|
|
660
|
+
planned = cleanup_typed_disposable(repo, **options, dry_run=True)
|
|
661
|
+
if planned["decision"]["plan"]["blockers"]:
|
|
662
|
+
raise _blocked("recovery execution preflight is blocked; started evidence retained")
|
|
663
|
+
cleaned = cleanup_typed_disposable(repo, **options, dry_run=False,
|
|
664
|
+
expected_plan_id=planned["decision"]["plan"]["plan_id"])
|
|
665
|
+
receipt_id = cleaned["decision"]["receipt_id"]
|
|
666
|
+
if not receipt_id:
|
|
667
|
+
raise _blocked("recovery has no exact successful successor receipt")
|
|
668
|
+
with store.lock(exclusive=True):
|
|
669
|
+
receipt, digest = e.read(store.completion_receipts_dir / f"{receipt_id}.json")
|
|
670
|
+
reference = _append_terminal(store, record, directory, recovery_sha, receipt, digest)
|
|
671
|
+
verified_completion(store, executing["original_transaction_id"])
|
|
672
|
+
decision = {"code": "MULTIPLE_RECOVERY_CLEANED", "receipt_id": receipt_id,
|
|
673
|
+
"recovery_id": record["recovery_id"], "recovery_completion": reference,
|
|
674
|
+
"completed_action_will_replay": False}
|
|
675
|
+
result = status(repo)
|
|
676
|
+
result.update(command="resource-recover", decision=decision)
|
|
677
|
+
return result
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def verified_completion(store: MetadataStore, transaction: str,
|
|
681
|
+
bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
682
|
+
try:
|
|
683
|
+
return _verified_completion(store, transaction, bound)
|
|
684
|
+
except ControllerError:
|
|
685
|
+
raise
|
|
686
|
+
except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
|
|
687
|
+
raise _blocked("multiple recovery terminal is unavailable or malformed") from exc
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _verified_completion(store: MetadataStore, transaction: str,
|
|
691
|
+
bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
692
|
+
parent = store.cleanup_recoveries_dir / transaction
|
|
693
|
+
terminals = list(parent.glob("*/complete.json"))
|
|
694
|
+
if len(terminals) != 1:
|
|
695
|
+
raise _blocked("a unique immutable multiple recovery completion is required")
|
|
696
|
+
terminal, _ = e.read(terminals[0])
|
|
697
|
+
record, directory = load_record(store, terminals[0].parent / "record.json", terminal["record_sha256"])
|
|
698
|
+
recovery = record["plan"]["recovery"]
|
|
699
|
+
if (terminal.get("contract") != CONTRACT or terminal.get("result") != "CLEANED"
|
|
700
|
+
or terminal.get("original_transaction_id") != transaction
|
|
701
|
+
or recovery["original_transaction_id"] != transaction
|
|
702
|
+
or terminal.get("completed_action_will_replay") is not False
|
|
703
|
+
or not e.matches_reference(terminal["started"], directory / "started.json")):
|
|
704
|
+
raise _blocked("invalid multiple recovery terminal binding")
|
|
705
|
+
started, _ = e.read(directory / "started.json", terminal["started"]["sha256"])
|
|
706
|
+
if (started.get("contract") != CONTRACT or started.get("phase") != "ATTEMPT_STARTED"
|
|
707
|
+
or started.get("record_sha256") != terminal["record_sha256"]
|
|
708
|
+
or started.get("recovery_id") != record["recovery_id"]):
|
|
709
|
+
raise _blocked("terminal does not bind the exact started recovery")
|
|
710
|
+
_uuid(terminal["completion_receipt_id"])
|
|
711
|
+
receipt, _ = e.read(store.completion_receipts_dir / f"{terminal['completion_receipt_id']}.json",
|
|
712
|
+
terminal["completion_receipt_sha256"])
|
|
713
|
+
if receipt.get("receipt_id") != terminal["completion_receipt_id"]:
|
|
714
|
+
raise _blocked("terminal successor receipt identity differs")
|
|
715
|
+
return recovered_receipt(store, receipt, bound)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def completed(store: MetadataStore, transaction: str, *, verify_generations: bool = False) -> bool:
|
|
719
|
+
# Full generation verification remains mandatory for both classification modes.
|
|
720
|
+
parent = store.cleanup_recoveries_dir / transaction
|
|
721
|
+
terminals = list(parent.glob("*/complete.json"))
|
|
722
|
+
if not terminals:
|
|
723
|
+
return False
|
|
724
|
+
if len(terminals) != 1:
|
|
725
|
+
raise _blocked("ambiguous recovery completion")
|
|
726
|
+
terminal, _ = e.read(terminals[0])
|
|
727
|
+
if terminal.get("contract") != CONTRACT:
|
|
728
|
+
return False
|
|
729
|
+
verified_completion(store, transaction)
|
|
730
|
+
return True
|