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,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
|