worktree-controller 1.30.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,515 @@
1
+ """Restore only exact-receipt-covered tracked files to their bound HEAD bytes.
2
+
3
+ No index write, Git reset/stash, lifecycle transition or allocation release. An
4
+ interruption keeps the normal Controller transaction gate closed; no blind replay.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import stat
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ from . import cleanup_evidence as evidence
18
+ from . import preservation as p
19
+ from . import source_preservation as s
20
+ from .git_backend import _git
21
+ from .models import ControllerError
22
+ from .store import MetadataStore, now_iso
23
+
24
+ CONTRACT = "tracked-source-cleanup/v1"
25
+
26
+
27
+ def _store(plan: dict) -> MetadataStore:
28
+ from .lifecycle import inventory
29
+
30
+ return MetadataStore(inventory(Path(plan["repository"])).common_dir)
31
+
32
+
33
+ def _base(plan: dict) -> tuple[list[dict], dict[str, bytes]]:
34
+ target = Path(plan["authority"]["identity"]["target_path"])
35
+ rows, payloads = [], {}
36
+ for source in plan["source_tree"]["files"]:
37
+ path = source["path"]
38
+ if plan["source_tree"]["source_identities"][path][8] != 1:
39
+ raise s._fail("SOURCE_CLEANUP_HARDLINK_BLOCKED")
40
+ mode, blob = source["base_entry"]
41
+ size = int(_git(target, "cat-file", "-s", blob).stdout)
42
+ if size > s.MAX_FILE_BYTES:
43
+ raise s._fail("SOURCE_SIZE_LIMIT")
44
+ data = _git(target, "cat-file", "blob", blob).stdout
45
+ if len(data) != size or s._blob(data) != blob:
46
+ raise s._fail("SOURCE_BASE_BLOB_MISMATCH")
47
+ if s._SECRET_BYTES.search(data):
48
+ raise s._fail("SOURCE_SECRET_MATERIAL")
49
+ rows.append(
50
+ {
51
+ "path": path,
52
+ "size": size,
53
+ "sha256": hashlib.sha256(data).hexdigest(),
54
+ "mode": 0o755 if mode == "100755" else 0o644,
55
+ "git_blob": blob,
56
+ }
57
+ )
58
+ payloads[path] = data
59
+ if sum(row["size"] for row in rows) > s.MAX_TOTAL_BYTES:
60
+ raise s._fail("SOURCE_SIZE_LIMIT")
61
+ return rows, payloads
62
+
63
+
64
+ def plan_cleanup(plan: dict, receipt_sha256: str, *, state_root: Path | None = None) -> dict:
65
+ from .source_recovery import authorization
66
+
67
+ verified = s.verify_preservation(plan, receipt_sha256, state_root=state_root)
68
+ rows, _ = _base(plan)
69
+ value = {
70
+ "contract": CONTRACT,
71
+ "preservation_plan": plan,
72
+ "preservation_receipt_sha256": receipt_sha256,
73
+ "verification": verified,
74
+ "restore": rows,
75
+ "index_action": "none",
76
+ "lifecycle_action": "none",
77
+ "partial_failure": "retain-transaction-no-replay",
78
+ }
79
+ reconciliation = authorization(_store(plan), plan, receipt_sha256)
80
+ if reconciliation is not None:
81
+ value["reconciliation"] = reconciliation
82
+ return {**value, "plan_id": p._sha(value), "result": "SOURCE_CLEANUP_READY"}
83
+
84
+
85
+ def _state(
86
+ cleanup: dict,
87
+ completed: dict,
88
+ *,
89
+ journal: dict | None = None,
90
+ journal_ref: dict | None = None,
91
+ state_root: Path | None = None,
92
+ _migration_store: MetadataStore | None = None,
93
+ ) -> dict:
94
+ plan = cleanup["preservation_plan"]
95
+ identity, tree = plan["authority"]["identity"], plan["source_tree"]
96
+ if cleanup.get("reconciliation"):
97
+ ref = cleanup["reconciliation"]
98
+ e_value, _ = evidence.read(Path(ref["path"]), ref["sha256"])
99
+ if e_value["new_attempt_id"] != ref["new_attempt_id"]:
100
+ raise s._fail("SOURCE_RECOVERY_BLOCKED")
101
+ if journal_ref is not None:
102
+ actual, _ = evidence.read(Path(journal_ref["path"]), journal_ref["sha256"])
103
+ if actual != journal:
104
+ raise s._fail("SOURCE_TRANSACTION_CHANGED")
105
+ authority = s._authority(
106
+ Path(plan["repository"]),
107
+ Path(identity["target_path"]),
108
+ identity["allocation_id"],
109
+ identity["owner"],
110
+ identity["expected_record_version"],
111
+ pending_transaction=journal,
112
+ _migration_store=_migration_store,
113
+ )
114
+ if authority["identity"] != identity:
115
+ raise s._fail("SOURCE_AUTHORITY_CHANGED")
116
+ generation = s._verify_generation(plan, cleanup["preservation_receipt_sha256"], state_root)
117
+ target = Path(identity["target_path"])
118
+ git_state = s._git_state(
119
+ target, Path(identity["git_dir"]), identity["expected_head"], allow_clean=True
120
+ )
121
+ for key in ("base_head", "base_tree", "index_identity", "index_sha256", "autocrlf"):
122
+ if git_state[key] != tree[key]:
123
+ raise s._fail("SOURCE_GIT_CHANGED")
124
+ remaining = sorted(set(tree["dirty_paths"]) - set(completed))
125
+ if git_state["dirty_paths"] != remaining:
126
+ raise s._fail("SOURCE_DIRTY_SET_CHANGED")
127
+ observed = {}
128
+ with p._absolute_directory(target) as chain:
129
+ if chain[-1].identity != tree["root_identity"]:
130
+ raise s._fail("SOURCE_CHANGED")
131
+ for source in tree["files"]:
132
+ path = source["path"]
133
+ data, metadata, parents = s._read(chain, path)
134
+ expected = completed.get(path)
135
+ row = next(r for r in cleanup["restore"] if r["path"] == path) if expected else source
136
+ if (
137
+ metadata != (expected or tree["source_identities"][path])
138
+ or parents != tree["parent_identities"][path]
139
+ or len(data) != row["size"]
140
+ or hashlib.sha256(data).hexdigest() != row["sha256"]
141
+ or stat.S_IMODE(metadata[2]) != row["mode"]
142
+ ):
143
+ raise s._fail("SOURCE_CHANGED")
144
+ observed[path] = {"identity": metadata, "sha256": row["sha256"], "mode": row["mode"]}
145
+ return {
146
+ "head": git_state["base_head"],
147
+ "dirty_paths": remaining,
148
+ "dirty_digest": p._sha({path: observed[path] for path in remaining}),
149
+ "observed": observed,
150
+ "index_sha256": git_state["index_sha256"],
151
+ "preservation": generation,
152
+ "runtime_evidence": authority["runtime_evidence"],
153
+ }
154
+
155
+
156
+ def _restore(plan: dict, row: dict, data: bytes) -> list[int]:
157
+ """All writes go to one verified single-link regular FD, never a path reopen.
158
+
159
+ In-place writes avoid replacing an unverified racing directory entry. A short
160
+ write/interruption may leave this covered file partial: its exact preservation
161
+ and the outstanding transaction remain. There is deliberately no implicit undo.
162
+ """
163
+ path, tree = row["path"], plan["source_tree"]
164
+ expected = tree["source_identities"][path]
165
+ target = Path(plan["authority"]["identity"]["target_path"])
166
+ with p._absolute_directory(target) as root, s._parent(root, path) as (chain, name):
167
+ if {str(i): d.identity for i, d in enumerate(chain)} != tree["parent_identities"][path]:
168
+ raise s._fail("SOURCE_CHANGED")
169
+ pin = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=chain[-1].fd)
170
+ try:
171
+ metadata = os.fstat(pin)
172
+ if (
173
+ not stat.S_ISREG(metadata.st_mode)
174
+ or p._identity(metadata) != expected
175
+ or metadata.st_nlink != 1
176
+ ):
177
+ raise s._fail("SOURCE_CHANGED")
178
+ fd = os.open(f"/proc/self/fd/{pin}", os.O_RDWR | os.O_NOATIME | os.O_CLOEXEC)
179
+ try:
180
+ source = next(r for r in tree["files"] if r["path"] == path)
181
+ if p._identity(os.fstat(fd)) != expected or p._transfer(fd) != (
182
+ source["size"],
183
+ source["sha256"],
184
+ ):
185
+ raise s._fail("SOURCE_CHANGED")
186
+ p._check_chain(chain)
187
+ p._check_entry(chain[-1].fd, name, expected)
188
+ if p._identity(os.fstat(fd)) != expected:
189
+ raise s._fail("SOURCE_CHANGED")
190
+ os.lseek(fd, 0, os.SEEK_SET)
191
+ view = memoryview(data)
192
+ while view:
193
+ count = os.write(fd, view)
194
+ if not count:
195
+ raise s._fail("SOURCE_WRITE_FAILED")
196
+ view = view[count:]
197
+ os.ftruncate(fd, len(data))
198
+ os.fchmod(fd, row["mode"])
199
+ os.fsync(fd)
200
+ actual = p._identity(os.fstat(fd))
201
+ if (
202
+ actual[:2] != expected[:2]
203
+ or actual[6:] != expected[6:]
204
+ or stat.S_IMODE(actual[2]) != row["mode"]
205
+ ):
206
+ raise s._fail("SOURCE_CHANGED")
207
+ os.lseek(fd, 0, os.SEEK_SET)
208
+ if p._transfer(fd) != (row["size"], row["sha256"]):
209
+ raise s._fail("SOURCE_WRITE_FAILED")
210
+ p._check_chain(chain)
211
+ p._check_entry(chain[-1].fd, name, actual)
212
+ return actual
213
+ finally:
214
+ os.close(fd)
215
+ finally:
216
+ os.close(pin)
217
+
218
+
219
+ def cleanup_source(cleanup: dict, *, state_root: Path | None = None) -> dict:
220
+ plan, receipt_sha = cleanup["preservation_plan"], cleanup["preservation_receipt_sha256"]
221
+ if plan_cleanup(plan, receipt_sha, state_root=state_root) != cleanup:
222
+ raise s._fail("SOURCE_CLEANUP_PLAN_MISMATCH")
223
+ rows, payloads = _base(plan)
224
+ identity = plan["authority"]["identity"]
225
+ store = _store(plan)
226
+ reconciliation = cleanup.get("reconciliation")
227
+ transaction_id = reconciliation["new_attempt_id"] if reconciliation else str(uuid.uuid4())
228
+ directory = store.root / "source-cleanup" / transaction_id
229
+ from .lifecycle import _release_transaction, _transaction, inventory
230
+ from .semantics import assert_record_version as _cas
231
+ from .semantics import managed_record as _managed_record
232
+
233
+ with store.lock(exclusive=True):
234
+ snapshot = store._load_unlocked()
235
+ item, record = _managed_record(
236
+ inventory(Path(plan["repository"])), snapshot, Path(identity["target_path"])
237
+ )
238
+ _cas(record, identity["expected_record_version"])
239
+ if _release_transaction(
240
+ store, target_path=str(item.path), allocation_id=record.allocation_id
241
+ ):
242
+ raise s._fail("INTERRUPTED_TRANSACTION")
243
+ journal = _transaction(
244
+ command="resource-clean",
245
+ transaction_id=transaction_id,
246
+ repository_id=identity["repository_id"],
247
+ target_path=item.path,
248
+ allocation_id=record.allocation_id,
249
+ branch=item.branch,
250
+ expected_head=item.head,
251
+ expected_record_version=record.record_version,
252
+ phase="PREPARED",
253
+ created_at=now_iso(),
254
+ )
255
+ journal["cleanup_plan"] = {
256
+ "contract": CONTRACT,
257
+ **{
258
+ key: journal[key]
259
+ for key in (
260
+ "repository_id",
261
+ "allocation_id",
262
+ "target_path",
263
+ "expected_head",
264
+ "expected_record_version",
265
+ )
266
+ },
267
+ "source_plan": evidence.write(directory, "plan.json", cleanup),
268
+ }
269
+ journal_ref = evidence.write(store.transactions_dir, f"{transaction_id}.json", journal)
270
+ actions, completed, checks = [], {}, []
271
+ try:
272
+ for step, row in enumerate(rows):
273
+ before = _state(
274
+ cleanup, completed, journal=journal, journal_ref=journal_ref, state_root=state_root
275
+ )
276
+ checks.append(before)
277
+ action = {"path": row["path"], "status": "ACTION_STARTED"}
278
+ actions.append(action)
279
+ action["before"] = evidence.write(
280
+ directory,
281
+ f"{step:04d}-before.json",
282
+ {
283
+ "contract": CONTRACT,
284
+ "transaction_id": transaction_id,
285
+ "path": row["path"],
286
+ "state": before,
287
+ "restore": row,
288
+ "execution_state": "ACTION_STARTED",
289
+ "timestamp": now_iso(),
290
+ },
291
+ )
292
+ completed[row["path"]] = _restore(plan, row, payloads[row["path"]])
293
+ after = _state(
294
+ cleanup, completed, journal=journal, journal_ref=journal_ref, state_root=state_root
295
+ )
296
+ action["after"] = evidence.write(
297
+ directory,
298
+ f"{step:04d}-after.json",
299
+ {
300
+ "contract": CONTRACT,
301
+ "transaction_id": transaction_id,
302
+ "path": row["path"],
303
+ "before": action["before"],
304
+ "state": after,
305
+ "execution_state": "ACTION_COMPLETED",
306
+ "timestamp": now_iso(),
307
+ },
308
+ )
309
+ action["status"] = "ACTION_COMPLETED"
310
+ checks.append(after)
311
+ post = _state(
312
+ cleanup, completed, journal=journal, journal_ref=journal_ref, state_root=state_root
313
+ )
314
+ if post["dirty_paths"]:
315
+ raise s._fail("SOURCE_CLEANUP_INCOMPLETE")
316
+ except (ControllerError, OSError, ValueError, TypeError, KeyError) as exc:
317
+ from .cleanup_attempts import underlying
318
+
319
+ refusal = underlying(exception=exc)
320
+ failed = None
321
+ evidence_error = None
322
+ try:
323
+ failed = evidence.write(
324
+ store.completion_receipts_dir,
325
+ f"{transaction_id}.json",
326
+ {
327
+ "contract": CONTRACT,
328
+ "receipt_id": transaction_id,
329
+ "kind": "tracked_source_cleanup",
330
+ "result": "FAILED_OR_PARTIAL",
331
+ "error_code": getattr(exc, "code", "SOURCE_CLEANUP_IO_ERROR"),
332
+ **(
333
+ {"runtime_evidence": exc.runtime_evidence}
334
+ if isinstance(exc, s.AuthorityBlocked)
335
+ else {}
336
+ ),
337
+ "journal": journal_ref,
338
+ "actions": actions,
339
+ "checks": checks,
340
+ "underlying": refusal,
341
+ "timestamp": now_iso(),
342
+ },
343
+ )
344
+ except (ControllerError, OSError, ValueError, TypeError, KeyError) as write_error:
345
+ evidence_error = underlying(exception=write_error)
346
+ return {
347
+ "result": "SOURCE_CLEANUP_BLOCKED",
348
+ "transaction_id": transaction_id,
349
+ "receipt": failed,
350
+ "code": getattr(exc, "code", "SOURCE_CLEANUP_IO_ERROR"),
351
+ "underlying": refusal,
352
+ "evidence_write_error": evidence_error,
353
+ "release_authorized": False,
354
+ }
355
+ receipt = evidence.write(
356
+ store.completion_receipts_dir,
357
+ f"{transaction_id}.json",
358
+ {
359
+ "contract": CONTRACT,
360
+ "receipt_id": transaction_id,
361
+ "kind": "tracked_source_cleanup",
362
+ "result": "SOURCE_CLEANUP_COMPLETE",
363
+ "allocation_source_identity": identity,
364
+ "journal": journal_ref,
365
+ "plan": journal["cleanup_plan"]["source_plan"],
366
+ "actions": actions,
367
+ "checks": checks,
368
+ "post_scan": post,
369
+ "index_unchanged": True,
370
+ "release_authorized": False,
371
+ "timestamp": now_iso(),
372
+ },
373
+ )
374
+ with store.lock(exclusive=True):
375
+ actual, _ = evidence.read(Path(journal_ref["path"]), journal_ref["sha256"])
376
+ if actual != journal:
377
+ raise s._fail("SOURCE_TRANSACTION_CHANGED")
378
+ # Keep the exact journal as immutable evidence before the formal removal.
379
+ evidence.write(directory, "journal.json", journal)
380
+ evidence.write(directory, "completed.json", {"contract": CONTRACT, "receipt": receipt})
381
+ store.remove_transaction(transaction_id)
382
+ return {
383
+ "result": "SOURCE_CLEANUP_COMPLETE",
384
+ "receipt": receipt,
385
+ "post_scan": post,
386
+ "transaction_id": transaction_id,
387
+ "release_authorized": False,
388
+ }
389
+
390
+
391
+ def verify_completed(store: MetadataStore, item, record) -> list[dict]:
392
+ """Reverify archived bytes and restored source at completion/release boundaries.
393
+
394
+ This is not a handoff or publication assertion. It binds the current no-commit
395
+ closeout to independently recorded archival and restoration evidence.
396
+ """
397
+ verified = []
398
+ for directory in sorted((store.root / "source-cleanup").glob("*")):
399
+ cleanup, plan_digest = evidence.read(directory / "plan.json")
400
+ plan = cleanup["preservation_plan"]
401
+ identity = plan["authority"]["identity"]
402
+ if identity["allocation_id"] != record.allocation_id:
403
+ continue
404
+ from .source_recovery import terminal
405
+
406
+ if terminal(store, directory.name, verify_generation=True):
407
+ continue # Terminal historical attempts grant no cleanup completion or release.
408
+ try:
409
+ marker, _ = evidence.read(directory / "completed.json")
410
+ ref = marker["receipt"]
411
+ if (
412
+ marker["contract"] != CONTRACT
413
+ or not evidence.matches_reference(
414
+ ref, store.completion_receipts_dir / f"{directory.name}.json"
415
+ )
416
+ or identity["target_path"] != str(item.path)
417
+ or identity["expected_head"] != item.head
418
+ or identity["owner"] != record.owner
419
+ or identity["authority_key"] != record.authority_key
420
+ or identity["expected_record_version"] > record.record_version
421
+ ):
422
+ raise s._fail("SOURCE_CLEANUP_EVIDENCE_INVALID")
423
+ receipt, _ = evidence.read(Path(ref["path"]), ref["sha256"])
424
+ if cleanup.get("reconciliation"):
425
+ predecessor = cleanup["reconciliation"]
426
+ evidence.read(Path(predecessor["path"]), predecessor["sha256"])
427
+ if (
428
+ receipt["contract"] != CONTRACT
429
+ or receipt["result"] != "SOURCE_CLEANUP_COMPLETE"
430
+ or receipt["allocation_source_identity"] != identity
431
+ or receipt["plan"]["sha256"] != plan_digest
432
+ or not evidence.matches_reference(receipt["plan"], directory / "plan.json")
433
+ or receipt["post_scan"]["dirty_paths"]
434
+ or [a["path"] for a in receipt["actions"]] != plan["source_tree"]["dirty_paths"]
435
+ or any(a["status"] != "ACTION_COMPLETED" for a in receipt["actions"])
436
+ ):
437
+ raise s._fail("SOURCE_CLEANUP_EVIDENCE_INVALID")
438
+ for action in receipt["actions"]:
439
+ for key in ("before", "after"):
440
+ evidence.read(Path(action[key]["path"]), action[key]["sha256"])
441
+ s._verify_generation(plan, cleanup["preservation_receipt_sha256"])
442
+ current = s._git_state(
443
+ item.path, Path(identity["git_dir"]), item.head, allow_clean=True
444
+ )
445
+ if current["dirty_paths"] or any(
446
+ current[key] != plan["source_tree"][key]
447
+ for key in ("base_head", "base_tree", "autocrlf")
448
+ ):
449
+ raise s._fail("SOURCE_GIT_CHANGED")
450
+ # The immutable action evidence binds the unchanged index during
451
+ # restoration. After completion Git may rewrite its stat cache.
452
+ # _git_state still verifies every stage/mode/blob and index flag
453
+ # against HEAD; only this observation must remain physically stable.
454
+ with p._absolute_directory(item.path) as root:
455
+ for row in cleanup["restore"]:
456
+ data, metadata, parents = s._read(root, row["path"])
457
+ expected = receipt["post_scan"]["observed"][row["path"]]
458
+ if (
459
+ metadata != expected["identity"]
460
+ or parents != plan["source_tree"]["parent_identities"][row["path"]]
461
+ or hashlib.sha256(data).hexdigest() != row["sha256"]
462
+ or len(data) != row["size"]
463
+ or stat.S_IMODE(metadata[2]) != row["mode"]
464
+ ):
465
+ raise s._fail("SOURCE_CHANGED")
466
+ if s._git_state(
467
+ item.path, Path(identity["git_dir"]), item.head, allow_clean=True
468
+ ) != current:
469
+ raise s._fail("SOURCE_GIT_CHANGED")
470
+ verified.append(ref)
471
+ except (OSError, ValueError, KeyError, TypeError) as exc:
472
+ raise s._fail("SOURCE_CLEANUP_EVIDENCE_INVALID") from exc
473
+ return verified
474
+
475
+
476
+ def main(argv: list[str] | None = None) -> int:
477
+ parser = argparse.ArgumentParser(description=__doc__)
478
+ parser.add_argument("command", choices=("plan", "execute"))
479
+ parser.add_argument("--plan-file", required=True, type=Path, help="pinned preservation plan")
480
+ parser.add_argument("--receipt-sha256", required=True)
481
+ parser.add_argument("--expected-plan-id")
482
+ args = parser.parse_args(argv)
483
+ try:
484
+ plan, _ = evidence.read(args.plan_file)
485
+ cleanup = plan_cleanup(plan, args.receipt_sha256)
486
+ if args.command == "execute":
487
+ if args.expected_plan_id != cleanup["plan_id"]:
488
+ raise s._fail("SOURCE_CLEANUP_PLAN_MISMATCH")
489
+ result = cleanup_source(cleanup)
490
+ else:
491
+ result = cleanup
492
+ print(json.dumps(result, sort_keys=True))
493
+ return 0 if result["result"] in {"SOURCE_CLEANUP_READY", "SOURCE_CLEANUP_COMPLETE"} else 1
494
+ except (ControllerError, OSError, ValueError, TypeError, KeyError) as exc:
495
+ from .cleanup_attempts import underlying
496
+
497
+ print(
498
+ json.dumps(
499
+ {
500
+ "result": "SOURCE_CLEANUP_BLOCKED",
501
+ "code": getattr(exc, "code", "SOURCE_CLEANUP_IO_ERROR"),
502
+ "underlying": underlying(exception=exc),
503
+ **(
504
+ {"runtime_evidence": exc.runtime_evidence}
505
+ if isinstance(exc, s.AuthorityBlocked)
506
+ else {}
507
+ ),
508
+ }
509
+ )
510
+ )
511
+ return 1
512
+
513
+
514
+ if __name__ == "__main__":
515
+ raise SystemExit(main())