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