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,442 @@
|
|
|
1
|
+
"""Append-only resume attempts. A started coordinator is not a destructive action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import cleanup_evidence as e
|
|
10
|
+
from . import preservation as p
|
|
11
|
+
from .metadata_relocation import historical_reference
|
|
12
|
+
from .models import ControllerError
|
|
13
|
+
from .semantics import managed_record
|
|
14
|
+
from .store import MetadataStore
|
|
15
|
+
|
|
16
|
+
CONTRACT = "cleanup-resume-attempt/v1"
|
|
17
|
+
RECONCILIATION_CONTRACT = "started-no-action-recovery/v1"
|
|
18
|
+
TYPED_NO_ACTION = "typed-cleanup-no-action/v1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _typed_refusal(store: MetadataStore, journal: dict) -> dict | None:
|
|
22
|
+
path = store.completion_receipts_dir / f"{journal['transaction_id']}.json"
|
|
23
|
+
if not path.exists():
|
|
24
|
+
if journal.get("failure"):
|
|
25
|
+
raise blocked("failed typed cleanup is missing its refusal receipt")
|
|
26
|
+
return None
|
|
27
|
+
receipt, digest = e.read(path)
|
|
28
|
+
expected = {
|
|
29
|
+
"schema_version": 1, "receipt_id": journal["transaction_id"],
|
|
30
|
+
"contract": "typed-disposable-resource/v1", "result": "FAILED_OR_PARTIAL",
|
|
31
|
+
"plan": journal["cleanup_plan"], "actions": journal["actions"],
|
|
32
|
+
"action_evidence": [], "failure": journal.get("failure"),
|
|
33
|
+
}
|
|
34
|
+
checks = receipt.get("runtime_checks")
|
|
35
|
+
# A topology refusal happens after the first action's runtime check was recorded.
|
|
36
|
+
first = journal["actions"][0]["path"] if journal.get("actions") else None
|
|
37
|
+
one_check = (isinstance(checks, list) and len(checks) == 1 and isinstance(checks[0], dict)
|
|
38
|
+
and checks[0].get("resource_path") == first)
|
|
39
|
+
topology_refusal = (journal.get("failure") or {}).get("code") == "HARDLINK_TOPOLOGY_BLOCKED"
|
|
40
|
+
if _verify_refusal(journal):
|
|
41
|
+
checks_ok = one_check
|
|
42
|
+
else:
|
|
43
|
+
checks_ok = checks == [] or (topology_refusal and one_check)
|
|
44
|
+
if (not journal.get("failure") or not checks_ok
|
|
45
|
+
or any(receipt.get(key) != value for key, value in expected.items())):
|
|
46
|
+
raise blocked("typed receipt does not prove refusal before the action boundary")
|
|
47
|
+
return {"path": str(path), "sha256": digest}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _verify_refusal(journal: dict) -> bool:
|
|
51
|
+
"""A runtime before 1.28.0 marked the first action STARTED before verifying topology.
|
|
52
|
+
|
|
53
|
+
Only that exact refusal is accepted: the first action failed with
|
|
54
|
+
HARDLINK_TOPOLOGY_BLOCKED and every later action is still PENDING. Its only
|
|
55
|
+
evidence must be the BEFORE receipt of that action (see ``_verify_boundary``).
|
|
56
|
+
"""
|
|
57
|
+
plan = journal.get("cleanup_plan", {})
|
|
58
|
+
paths = plan.get("cleanup_paths", [])
|
|
59
|
+
return (bool(paths) and (journal.get("failure") or {}).get("code") == "HARDLINK_TOPOLOGY_BLOCKED"
|
|
60
|
+
and journal.get("actions") == [{"path": paths[0], "status": "FAILED_OR_PARTIAL"}]
|
|
61
|
+
+ [{"path": name, "status": "PENDING"} for name in paths[1:]])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _verify_boundary(store: MetadataStore, journal: dict) -> dict | None:
|
|
65
|
+
"""The single BEFORE receipt left by a topology refusal, bound to the plan's topology."""
|
|
66
|
+
from . import hardlinks
|
|
67
|
+
|
|
68
|
+
action_dir = store.cleanup_actions_dir / journal["transaction_id"]
|
|
69
|
+
if not _verify_refusal(journal):
|
|
70
|
+
return None
|
|
71
|
+
if not action_dir.is_dir() or sorted(p.name for p in action_dir.iterdir()) != ["0000-before.json"]:
|
|
72
|
+
raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
|
|
73
|
+
path = action_dir / "0000-before.json"
|
|
74
|
+
receipt, digest = e.read(path)
|
|
75
|
+
topology = journal["cleanup_plan"].get("hardlink_topology")
|
|
76
|
+
if (set(receipt) != {"contract", "phase", "execution_state", "resource", "topology", "timestamp"}
|
|
77
|
+
or receipt["phase"] != "BEFORE" or receipt["execution_state"] != "ACTION_STARTED"
|
|
78
|
+
or receipt["resource"] != journal["actions"][0]["path"]
|
|
79
|
+
or not isinstance(topology, dict) or receipt["topology"] != topology
|
|
80
|
+
or receipt["contract"] != hardlinks.mutation_contract(topology)):
|
|
81
|
+
raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
|
|
82
|
+
return {"path": str(path), "sha256": digest}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _typed_journal(store: MetadataStore, transaction: str, digest: str) -> dict:
|
|
86
|
+
if str(uuid.UUID(transaction)) != transaction:
|
|
87
|
+
raise blocked("canonical transaction identity required")
|
|
88
|
+
journal, _ = e.read(store.transactions_dir / f"{transaction}.json", digest)
|
|
89
|
+
plan = journal.get("cleanup_plan", {})
|
|
90
|
+
actions = [{"path": name, "status": "PENDING"} for name in plan.get("cleanup_paths", [])]
|
|
91
|
+
action_dir = store.cleanup_actions_dir / transaction
|
|
92
|
+
if (journal.get("transaction_id") != transaction or journal.get("command") != "resource-clean"
|
|
93
|
+
or journal.get("phase") != "PREPARED" or plan.get("contract") != "typed-disposable-resource/v1"
|
|
94
|
+
or journal.get("allocation_id") != plan.get("allocation_id")
|
|
95
|
+
or journal.get("target_path") != plan.get("target_path")
|
|
96
|
+
or not actions):
|
|
97
|
+
raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
|
|
98
|
+
if _verify_refusal(journal):
|
|
99
|
+
_verify_boundary(store, journal)
|
|
100
|
+
elif journal.get("actions") != actions or (action_dir.exists() and list(action_dir.iterdir())):
|
|
101
|
+
raise blocked("typed cleanup has action evidence or unknown effects; no-action recovery refused")
|
|
102
|
+
_typed_refusal(store, journal)
|
|
103
|
+
return journal
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def typed_terminal(store: MetadataStore, transaction: str) -> bool:
|
|
107
|
+
path = store.cleanup_recoveries_dir / transaction / "typed-no-action.json"
|
|
108
|
+
if not path.exists():
|
|
109
|
+
return False
|
|
110
|
+
record, _ = e.read(path)
|
|
111
|
+
if (record.get("contract") != TYPED_NO_ACTION
|
|
112
|
+
or record.get("state") != "TYPED_ATTEMPT_SUPERSEDED"
|
|
113
|
+
or record.get("plan_id") != p._sha({k: v for k, v in record.items()
|
|
114
|
+
if k not in {"plan_id", "runtime_evidence"}})
|
|
115
|
+
or record.get("transaction_id") != transaction
|
|
116
|
+
or record.get("completed_action_will_replay") is not False):
|
|
117
|
+
raise blocked("invalid typed no-action reconciliation")
|
|
118
|
+
journal = _typed_journal(store, transaction, record["transaction_sha256"])
|
|
119
|
+
if (record["allocation_id"] != journal["allocation_id"]
|
|
120
|
+
or record["original_plan_id"] != journal["cleanup_plan"]["plan_id"]
|
|
121
|
+
or record.get("refusal_receipt") != _typed_refusal(store, journal)
|
|
122
|
+
or record.get("verify_boundary_receipt") != _verify_boundary(store, journal)):
|
|
123
|
+
raise blocked("typed no-action evidence belongs to another transaction")
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def typed_plan(repo: Path, store: MetadataStore, transaction: str, digest: str) -> dict:
|
|
128
|
+
from . import hardlinks, lifecycle, transaction_scope
|
|
129
|
+
from .git_backend import inventory
|
|
130
|
+
|
|
131
|
+
journal = _typed_journal(store, transaction, digest)
|
|
132
|
+
previous = journal["cleanup_plan"]
|
|
133
|
+
repository = inventory(repo)
|
|
134
|
+
snapshot = store._load_unlocked()
|
|
135
|
+
item, record = managed_record(repository, snapshot, Path(journal["target_path"]))
|
|
136
|
+
if (transaction_scope.conflicts(store, allocation=record.allocation_id, target=item.path) != [journal]
|
|
137
|
+
or record.allocation_id != previous["allocation_id"]
|
|
138
|
+
or item.head != previous["expected_head"]):
|
|
139
|
+
raise blocked("conflicting transaction or changed allocation identity")
|
|
140
|
+
current = lifecycle._typed_cleanup_plan_locked(
|
|
141
|
+
repository, snapshot, store, item, record, owner=previous["owner"],
|
|
142
|
+
expected_record_version=previous["expected_record_version"], check_transaction=False)
|
|
143
|
+
if current["blockers"]:
|
|
144
|
+
raise blocked("typed no-action preflight blocked: " + ", ".join(row["code"] for row in current["blockers"]))
|
|
145
|
+
if (p._sha(current["resources"]) != p._sha(previous["resources"])
|
|
146
|
+
or current["cleanup_paths"] != previous["cleanup_paths"]):
|
|
147
|
+
raise blocked("typed resource baseline changed")
|
|
148
|
+
before, after = previous["hardlink_topology"], current["hardlink_topology"]
|
|
149
|
+
hardlinks.validate(before)
|
|
150
|
+
hardlinks.validate(after)
|
|
151
|
+
if (before["anchors"] != after["anchors"] or before["resources"] != after["resources"]
|
|
152
|
+
or before["groups"].keys() != after["groups"].keys()):
|
|
153
|
+
raise blocked("typed cleanup scope is no longer intact")
|
|
154
|
+
for key, old in before["groups"].items():
|
|
155
|
+
new = after["groups"][key]
|
|
156
|
+
if (old["members"] != new["members"] or old["content"] != new["content"]
|
|
157
|
+
or any(a != b for i, (a, b) in enumerate(zip(old["identity"], new["identity"], strict=True))
|
|
158
|
+
if i not in {5, 8})):
|
|
159
|
+
raise blocked("typed cleanup inode, member or content changed")
|
|
160
|
+
# Only ctime (5) and nlink (8) may differ, and only where links outside the
|
|
161
|
+
# scope exist on both sides: their churn (added, or added and removed again
|
|
162
|
+
# by another environment sharing the same files) explains the change.
|
|
163
|
+
if old["identity"] != new["identity"] and not (
|
|
164
|
+
old.get("external_links", 0) > 0 and new.get("external_links", 0) > 0
|
|
165
|
+
):
|
|
166
|
+
raise blocked("unexplained in-scope metadata change")
|
|
167
|
+
value = {
|
|
168
|
+
"contract": TYPED_NO_ACTION, "state": "TYPED_ATTEMPT_SUPERSEDED",
|
|
169
|
+
"transaction_id": transaction, "transaction_sha256": digest,
|
|
170
|
+
"allocation_id": record.allocation_id, "original_plan_id": previous["plan_id"],
|
|
171
|
+
"current_topology_sha256": after["sha256"], "in_scope": "EXACT_IDENTITY_CONTENT_MATCH",
|
|
172
|
+
"external_link_history": "NOT_PROVEN", "runtime_evidence": current["runtime_evidence"],
|
|
173
|
+
"completed_action_will_replay": False,
|
|
174
|
+
"refusal_receipt": _typed_refusal(store, journal),
|
|
175
|
+
}
|
|
176
|
+
boundary = _verify_boundary(store, journal)
|
|
177
|
+
if boundary is not None:
|
|
178
|
+
# Refused by topology verification after the action was marked started; the
|
|
179
|
+
# exact current tree and in-scope topology above prove no deletion began.
|
|
180
|
+
value["verify_boundary_receipt"] = boundary
|
|
181
|
+
# Host observation timestamps/visibility gaps are evidence, not authority for
|
|
182
|
+
# the operation key. Bind the stable current topology and allocation baseline.
|
|
183
|
+
return {**value, "plan_id": p._sha({k: v for k, v in value.items() if k != "runtime_evidence"})}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def reconcile_typed_locked(repo: Path, store: MetadataStore, transaction: str, digest: str,
|
|
187
|
+
*, dry_run: bool, expected_plan_id: str | None = None) -> dict:
|
|
188
|
+
path = store.cleanup_recoveries_dir / transaction / "typed-no-action.json"
|
|
189
|
+
if typed_terminal(store, transaction):
|
|
190
|
+
record, _ = e.read(path)
|
|
191
|
+
if record["transaction_sha256"] != digest:
|
|
192
|
+
raise blocked("reconciliation transaction digest differs")
|
|
193
|
+
return {"code": "TYPED_ATTEMPT_SUPERSEDED", "reconciliation_receipt": reference(path)}
|
|
194
|
+
plan = typed_plan(repo, store, transaction, digest)
|
|
195
|
+
if dry_run:
|
|
196
|
+
return {"code": "TYPED_NO_ACTION_RECOVERABLE", "plan": plan}
|
|
197
|
+
if expected_plan_id != plan["plan_id"]:
|
|
198
|
+
raise blocked("typed no-action reconciliation requires its exact fresh plan")
|
|
199
|
+
receipt = e.write(path.parent, path.name, plan)
|
|
200
|
+
return {"code": "TYPED_ATTEMPT_SUPERSEDED", "reconciliation_receipt": receipt}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def reconcile_typed(repo: Path, transaction: str, digest: str, *, dry_run: bool,
|
|
204
|
+
expected_plan_id: str | None = None) -> dict:
|
|
205
|
+
from .controller import status
|
|
206
|
+
from .git_backend import inventory
|
|
207
|
+
|
|
208
|
+
store = MetadataStore(inventory(repo).common_dir)
|
|
209
|
+
with store.lock(exclusive=not dry_run):
|
|
210
|
+
decision = reconcile_typed_locked(repo, store, transaction, digest, dry_run=dry_run,
|
|
211
|
+
expected_plan_id=expected_plan_id)
|
|
212
|
+
result = status(repo)
|
|
213
|
+
result.update(command="resource-recover", decision=decision)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def blocked(message: str) -> ControllerError:
|
|
218
|
+
return ControllerError("RESUME_ATTEMPT_BLOCKED", message)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def reference(path: Path) -> dict[str, str]:
|
|
222
|
+
_, digest = e.read(path)
|
|
223
|
+
return {"path": str(path), "sha256": digest}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def chain(directory: Path, record_sha: str) -> list[dict[str, Any]]:
|
|
227
|
+
"""Validate a single immutable succession, without relying on timestamps."""
|
|
228
|
+
nodes = []
|
|
229
|
+
legacy = directory / "started.json"
|
|
230
|
+
if legacy.exists():
|
|
231
|
+
value, digest = e.read(legacy)
|
|
232
|
+
if (
|
|
233
|
+
value.get("contract") != "re-preservation-recovery/v1"
|
|
234
|
+
or value.get("record_sha256") != record_sha
|
|
235
|
+
):
|
|
236
|
+
raise blocked("legacy started identity differs")
|
|
237
|
+
nodes.append(
|
|
238
|
+
{
|
|
239
|
+
"attempt_id": str(uuid.uuid5(uuid.UUID(directory.name), digest)),
|
|
240
|
+
"started": historical_reference(legacy, digest),
|
|
241
|
+
"predecessor": None,
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
parent = directory / "attempts"
|
|
245
|
+
if parent.exists():
|
|
246
|
+
for entry in parent.iterdir():
|
|
247
|
+
if str(uuid.UUID(entry.name)) != entry.name:
|
|
248
|
+
raise blocked("unknown attempt entry")
|
|
249
|
+
value, digest = e.read(entry / "started.json")
|
|
250
|
+
if (
|
|
251
|
+
value.get("contract") != CONTRACT
|
|
252
|
+
or value.get("phase") != "ATTEMPT_STARTED"
|
|
253
|
+
or value.get("record_sha256") != record_sha
|
|
254
|
+
or value.get("attempt_id") != entry.name
|
|
255
|
+
):
|
|
256
|
+
raise blocked("attempt started identity differs")
|
|
257
|
+
predecessor = None
|
|
258
|
+
if value.get("supersedes"):
|
|
259
|
+
ref = value["supersedes"]
|
|
260
|
+
reconciled, _ = e.read(Path(ref["path"]), ref["sha256"])
|
|
261
|
+
validate_reconciliation(directory, reconciled, record_sha)
|
|
262
|
+
predecessor = reconciled["attempt"]["started"]
|
|
263
|
+
nodes.append(
|
|
264
|
+
{
|
|
265
|
+
"attempt_id": entry.name,
|
|
266
|
+
"started": historical_reference(entry / "started.json", digest),
|
|
267
|
+
"predecessor": predecessor,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
ordered = []
|
|
271
|
+
previous = None
|
|
272
|
+
while nodes:
|
|
273
|
+
successors = [node for node in nodes if node["predecessor"] == previous]
|
|
274
|
+
if len(successors) != 1:
|
|
275
|
+
raise blocked("ambiguous or disconnected attempt succession")
|
|
276
|
+
node = successors[0]
|
|
277
|
+
nodes.remove(node)
|
|
278
|
+
ordered.append(node)
|
|
279
|
+
previous = node["started"]
|
|
280
|
+
return ordered
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def validate_reconciliation(directory: Path, value: dict[str, Any], record_sha: str) -> None:
|
|
284
|
+
if (
|
|
285
|
+
value.get("contract") != RECONCILIATION_CONTRACT
|
|
286
|
+
or value.get("state") != "STARTED_NO_ACTION_RECOVERABLE"
|
|
287
|
+
or value.get("record_sha256") != record_sha
|
|
288
|
+
or value.get("safe_to_supersede") is not True
|
|
289
|
+
or value.get("historical_refusal") != "NOT_RECONSTRUCTED"
|
|
290
|
+
or value.get("plan_id")
|
|
291
|
+
!= p._sha({k: v for k, v in value.items() if k not in {"plan_id", "timestamp"}})
|
|
292
|
+
or value.get("completed_action_will_replay") is not False
|
|
293
|
+
or value.get("verification")
|
|
294
|
+
!= {
|
|
295
|
+
"filesystem": "EXACT_BASELINE_MATCH",
|
|
296
|
+
"preservation": "PASS",
|
|
297
|
+
"runtime_authority": "PASS",
|
|
298
|
+
"git": "CLEAN",
|
|
299
|
+
"new_transaction": False,
|
|
300
|
+
"new_completion": False,
|
|
301
|
+
"destructive_action_evidence": False,
|
|
302
|
+
}
|
|
303
|
+
):
|
|
304
|
+
raise blocked("invalid no-action reconciliation")
|
|
305
|
+
attempt = value["attempt"]
|
|
306
|
+
e.read(Path(attempt["started"]["path"]), attempt["started"]["sha256"])
|
|
307
|
+
expected = directory / "reconciliations" / (attempt["attempt_id"] + ".json")
|
|
308
|
+
_, digest = e.read(expected)
|
|
309
|
+
if not e.matches_reference({"path": value.get("path", ""), "sha256": digest}, expected):
|
|
310
|
+
raise blocked("no-action reconciliation path differs")
|
|
311
|
+
for ref in value["evidence"]:
|
|
312
|
+
e.read(Path(ref["path"]), ref["sha256"])
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def authorization(directory: Path, record_sha: str) -> dict[str, str] | None:
|
|
316
|
+
attempts = chain(directory, record_sha)
|
|
317
|
+
if not attempts:
|
|
318
|
+
return None
|
|
319
|
+
latest = attempts[-1]
|
|
320
|
+
path = directory / "reconciliations" / (latest["attempt_id"] + ".json")
|
|
321
|
+
if not path.exists():
|
|
322
|
+
raise blocked(
|
|
323
|
+
"started attempt requires explicit no-action reconciliation before supersession"
|
|
324
|
+
)
|
|
325
|
+
value, digest = e.read(path)
|
|
326
|
+
validate_reconciliation(directory, value, record_sha)
|
|
327
|
+
if value["attempt"] != latest:
|
|
328
|
+
raise blocked("reconciliation covers a different attempt")
|
|
329
|
+
return {"path": str(path), "sha256": digest}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def no_action_evidence(store: MetadataStore, plan: dict[str, Any]) -> dict[str, Any]:
|
|
333
|
+
"""No receipt alone proves nothing: examine journals AND action evidence.
|
|
334
|
+
|
|
335
|
+
Unknown/unbound action directories fail closed. Recognized unrelated allocation
|
|
336
|
+
receipts do not grant or deny this allocation's authority.
|
|
337
|
+
"""
|
|
338
|
+
original = plan["recovery"]["original_transaction_id"]
|
|
339
|
+
known = {original}
|
|
340
|
+
for parent in (store.transactions_dir, store.completion_receipts_dir):
|
|
341
|
+
if not parent.exists():
|
|
342
|
+
continue
|
|
343
|
+
for path in parent.iterdir():
|
|
344
|
+
value, _ = e.read(path)
|
|
345
|
+
if path.stem == original:
|
|
346
|
+
continue # The caller has verified both exact historical byte hashes.
|
|
347
|
+
candidate = value.get("cleanup_plan", value.get("plan", {}))
|
|
348
|
+
if (
|
|
349
|
+
value.get("allocation_id") == plan["allocation_id"]
|
|
350
|
+
or candidate.get("allocation_id") == plan["allocation_id"]
|
|
351
|
+
or candidate.get("target_path") == plan["target_path"]
|
|
352
|
+
):
|
|
353
|
+
raise blocked(
|
|
354
|
+
"additional transaction/completion evidence forbids no-action recovery"
|
|
355
|
+
)
|
|
356
|
+
other_allocation = candidate.get("allocation_id", value.get("allocation_id"))
|
|
357
|
+
if other_allocation and other_allocation != plan["allocation_id"]:
|
|
358
|
+
known.add(path.stem)
|
|
359
|
+
actions = store.cleanup_actions_dir
|
|
360
|
+
if actions.exists():
|
|
361
|
+
for entry in actions.iterdir():
|
|
362
|
+
if entry.name not in known:
|
|
363
|
+
raise blocked("unbound destructive-action evidence forbids no-action recovery")
|
|
364
|
+
if entry.name == original and list(entry.iterdir()):
|
|
365
|
+
# This narrow legacy receipt has no action graph. Do not attribute
|
|
366
|
+
# newly discovered evidence to its already completed backend step.
|
|
367
|
+
raise blocked("unattributed original action evidence forbids no-action recovery")
|
|
368
|
+
return {"new_transaction": False, "new_completion": False, "destructive_action_evidence": False}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def action_state(store: MetadataStore, attempt_id: str) -> str | None:
|
|
372
|
+
for parent in (store.transactions_dir, store.completion_receipts_dir):
|
|
373
|
+
if not parent.exists():
|
|
374
|
+
continue
|
|
375
|
+
for path in parent.iterdir():
|
|
376
|
+
value, _ = e.read(path)
|
|
377
|
+
plan = value.get("cleanup_plan", value.get("plan", {}))
|
|
378
|
+
if plan.get("recovery", {}).get("attempt_id") == attempt_id:
|
|
379
|
+
states = [action.get("status") for action in value.get("actions", [])]
|
|
380
|
+
if states and all(state == "COMPLETED" for state in states):
|
|
381
|
+
return "ACTION_COMPLETED"
|
|
382
|
+
if any(state in {"FAILED_OR_PARTIAL", "COMPLETED"} for state in states):
|
|
383
|
+
return "PARTIAL_ACTION"
|
|
384
|
+
if "STARTED" in states:
|
|
385
|
+
return "ACTION_STARTED"
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def evidence_references(
|
|
390
|
+
store: MetadataStore, directory: Path, plan: dict[str, Any]
|
|
391
|
+
) -> list[dict[str, str]]:
|
|
392
|
+
recovery = plan["recovery"]
|
|
393
|
+
transaction = recovery["original_transaction_id"]
|
|
394
|
+
old, _ = e.read(store.completion_receipts_dir / (transaction + ".json"))
|
|
395
|
+
paths = [
|
|
396
|
+
directory / "record.json",
|
|
397
|
+
store.transactions_dir / (transaction + ".json"),
|
|
398
|
+
store.completion_receipts_dir / (transaction + ".json"),
|
|
399
|
+
]
|
|
400
|
+
for evidence in (old["plan"]["preservation_evidence"], plan["preservation_evidence"]):
|
|
401
|
+
generation = Path(evidence["receipt_path"]).parent
|
|
402
|
+
paths.extend(generation / name for name in ("receipt.json", "plan.json", "manifest.json"))
|
|
403
|
+
paths.extend(directory.glob("**/started.json"))
|
|
404
|
+
paths.extend(directory.glob("attempts/*/underlying.json"))
|
|
405
|
+
paths.extend(directory.glob("attempts/*/result.json"))
|
|
406
|
+
return [reference(path) for path in sorted(set(paths))]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def underlying(
|
|
410
|
+
result: dict[str, Any] | None = None, exception: Exception | None = None
|
|
411
|
+
) -> dict[str, Any]:
|
|
412
|
+
"""The cleanup call is in-process; no subprocess exit/stdout/stderr is fabricated.
|
|
413
|
+
|
|
414
|
+
Controller errors and plan blockers contain governance metadata, never provider
|
|
415
|
+
bytes. Arbitrary unexpected exception text is represented by type/digest only.
|
|
416
|
+
"""
|
|
417
|
+
decision = (result or {}).get("decision", {})
|
|
418
|
+
error = None
|
|
419
|
+
if exception is not None:
|
|
420
|
+
error = {
|
|
421
|
+
"code": getattr(exception, "code", "COORDINATOR_EXCEPTION"),
|
|
422
|
+
"exception_type": type(exception).__name__,
|
|
423
|
+
"errno": getattr(exception, "errno", None),
|
|
424
|
+
}
|
|
425
|
+
if isinstance(exception, ControllerError):
|
|
426
|
+
error["message"] = str(exception)
|
|
427
|
+
else:
|
|
428
|
+
error["message_sha256"] = p._sha(str(exception))
|
|
429
|
+
blockers = decision.get("plan", {}).get("blockers", [])
|
|
430
|
+
return {
|
|
431
|
+
"transport": "in-process-python",
|
|
432
|
+
"status": decision.get("code", "EXCEPTION"),
|
|
433
|
+
"exit_code": None,
|
|
434
|
+
"exit_code_reason": "no subprocess was launched",
|
|
435
|
+
"completion_receipt_id": decision.get("receipt_id"),
|
|
436
|
+
"blockers": blockers,
|
|
437
|
+
"transaction_conflicts": getattr(exception, "transaction_conflicts", []),
|
|
438
|
+
"errors": (result or {}).get("errors", []),
|
|
439
|
+
"exception": error,
|
|
440
|
+
"stdout_summary": {"decision_code": decision.get("code"), "blockers": blockers},
|
|
441
|
+
"stderr_summary": error,
|
|
442
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Exclusive, FD-relative immutable cleanup action evidence."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import hardlinks
|
|
7
|
+
from . import preservation as p
|
|
8
|
+
from .preserved_cleanup import _document
|
|
9
|
+
from .store import now_iso
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write(directory: Path, name: str, value: dict[str, Any]) -> dict[str, str]:
|
|
13
|
+
with p._absolute_directory(directory, create=True) as chain:
|
|
14
|
+
digest = p._write_once(chain[-1], name, value)
|
|
15
|
+
return {"path": str(directory / name), "sha256": digest}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read(path: Path, digest: str | None = None) -> tuple[dict[str, Any], str]:
|
|
19
|
+
from .metadata_relocation import relocated
|
|
20
|
+
|
|
21
|
+
path = relocated(path, digest)
|
|
22
|
+
with p._absolute_directory(path.parent) as chain:
|
|
23
|
+
value, actual = _document(chain[-1], path.name)
|
|
24
|
+
if digest is not None and actual != digest:
|
|
25
|
+
raise hardlinks._blocked("immutable cleanup evidence bytes changed")
|
|
26
|
+
return value, actual
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def matches_reference(ref: dict, expected: Path) -> bool:
|
|
30
|
+
from .metadata_relocation import relocated
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
bool(ref.get("sha256")) and relocated(Path(ref.get("path", "")), ref["sha256"]) == expected
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def before(directory: Path, step: int, resource: str, topology: dict[str, Any]) -> dict[str, str]:
|
|
38
|
+
return write(
|
|
39
|
+
directory,
|
|
40
|
+
f"{step:04d}-before.json",
|
|
41
|
+
{
|
|
42
|
+
"contract": hardlinks.mutation_contract(topology),
|
|
43
|
+
"phase": "BEFORE",
|
|
44
|
+
"execution_state": "ACTION_STARTED",
|
|
45
|
+
"resource": resource,
|
|
46
|
+
"topology": topology,
|
|
47
|
+
"timestamp": now_iso(),
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def after(
|
|
53
|
+
directory: Path,
|
|
54
|
+
step: int,
|
|
55
|
+
resource: str,
|
|
56
|
+
previous: dict[str, Any],
|
|
57
|
+
current: dict[str, Any],
|
|
58
|
+
before_receipt: dict[str, str],
|
|
59
|
+
external_survivors: dict[str, Any] | None = None,
|
|
60
|
+
) -> dict[str, str]:
|
|
61
|
+
mutation = hardlinks.transition(previous, current, resource, external_survivors)
|
|
62
|
+
return write(
|
|
63
|
+
directory,
|
|
64
|
+
f"{step:04d}-after.json",
|
|
65
|
+
{
|
|
66
|
+
"contract": mutation["contract"],
|
|
67
|
+
"phase": "COMPLETED",
|
|
68
|
+
"execution_state": "ACTION_COMPLETED",
|
|
69
|
+
"resource": resource,
|
|
70
|
+
"topology": current,
|
|
71
|
+
"before": before_receipt,
|
|
72
|
+
"mutation": mutation,
|
|
73
|
+
"timestamp": now_iso(),
|
|
74
|
+
},
|
|
75
|
+
)
|