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,928 @@
|
|
|
1
|
+
"""Owner-authorized closeout of checkouts already contained in publication.
|
|
2
|
+
|
|
3
|
+
Closeout changes lifecycle metadata only. It neither invents task completion nor
|
|
4
|
+
rewrites historical task evidence. Physical removal remains the guarded release path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .git_backend import (
|
|
18
|
+
REMOTE_NAME_PATTERN,
|
|
19
|
+
_git,
|
|
20
|
+
inventory,
|
|
21
|
+
is_ancestor,
|
|
22
|
+
resolve_commit,
|
|
23
|
+
validate_branch_name,
|
|
24
|
+
)
|
|
25
|
+
from .models import (
|
|
26
|
+
ACTIVE_AUTHORITY_STATES,
|
|
27
|
+
ControllerError,
|
|
28
|
+
MetadataRecord,
|
|
29
|
+
MetadataSnapshot,
|
|
30
|
+
RepositoryInventory,
|
|
31
|
+
WorktreeGitState,
|
|
32
|
+
)
|
|
33
|
+
from .policy import load_policy
|
|
34
|
+
from .store import MetadataStore, record_from_dict, record_to_dict
|
|
35
|
+
|
|
36
|
+
CONTRACT = "delivered-checkout-closeout/v1"
|
|
37
|
+
STATE = "RELEASE_READY"
|
|
38
|
+
EVENT = "DELIVERED_CHECKOUT_CLOSED"
|
|
39
|
+
ARCHIVE_CONTRACT = "retained-checkout-archive/v1"
|
|
40
|
+
ARCHIVE_STATE = "ARCHIVED"
|
|
41
|
+
ARCHIVE_EVENT = "RETAINED_CHECKOUT_ARCHIVED"
|
|
42
|
+
NO_CHANGE_CONTRACT = "no-change-cleanup-closeout/v1"
|
|
43
|
+
NO_CHANGE_RECOVERY_EVENT = "NO_CHANGE_CLEANUP_RECONCILED"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _no_change_fail(message: str) -> ControllerError:
|
|
47
|
+
return ControllerError("NO_CHANGE_CLOSEOUT_INVALID", message)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _no_change_time(value: str) -> datetime:
|
|
51
|
+
result = datetime.fromisoformat(value)
|
|
52
|
+
if result.tzinfo is None:
|
|
53
|
+
raise _no_change_fail("no-change evidence requires an aware timestamp")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _no_change_transition(original: MetadataRecord, event: dict[str, Any]) -> MetadataRecord:
|
|
58
|
+
if (original.lifecycle not in {"ACTIVE", "RETAINED"}
|
|
59
|
+
or original.role not in {"feature", "audit"}
|
|
60
|
+
or event.get("event") != "NO_CHANGE_COMPLETED"
|
|
61
|
+
or event.get("from") != original.lifecycle
|
|
62
|
+
or event.get("to") != "NO_CHANGE_COMPLETED"
|
|
63
|
+
or event.get("owner") != original.owner):
|
|
64
|
+
raise _no_change_fail("no-change completion is not the exact original transition")
|
|
65
|
+
if _no_change_time(event["at"]) < _no_change_time(original.last_reviewed_at):
|
|
66
|
+
raise _no_change_fail("no-change completion predates the original record")
|
|
67
|
+
return replace(original, lifecycle="NO_CHANGE_COMPLETED",
|
|
68
|
+
record_version=original.record_version + 1,
|
|
69
|
+
last_reviewed_at=event["at"], history=(*original.history, event))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _no_change_identity(receipt: dict, original: MetadataRecord,
|
|
73
|
+
item: WorktreeGitState, repository_id: str | None) -> None:
|
|
74
|
+
expected = {
|
|
75
|
+
"kind": "no_change_completion", "schema_version": 1,
|
|
76
|
+
"repository_id": repository_id, "allocation_id": original.allocation_id,
|
|
77
|
+
"task_key": original.task_key, "authority_key": original.authority_key,
|
|
78
|
+
"role": original.role, "owner": original.owner, "branch": item.branch,
|
|
79
|
+
"head": item.head, "base_commit": original.base_commit,
|
|
80
|
+
"worktree_clean": True, "handoff_required": False, "task_completed": True,
|
|
81
|
+
"source_cleanup_evidence": [], "external_bindings": [],
|
|
82
|
+
}
|
|
83
|
+
if (any(receipt.get(key) != value for key, value in expected.items())
|
|
84
|
+
or (receipt.get("contract") == NO_CHANGE_CONTRACT and receipt.get("target_path") != str(item.path))
|
|
85
|
+
or (original.base_commit is not None and original.base_commit != item.head)
|
|
86
|
+
or original.handoff_commit or original.finalizer_record_id
|
|
87
|
+
or original.finalizer_commit or original.external_bindings):
|
|
88
|
+
raise _no_change_fail("no-change completion identity changed")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _completion_document(store: MetadataStore, ref: dict[str, str]) -> dict:
|
|
92
|
+
from .cleanup_evidence import matches_reference, read
|
|
93
|
+
|
|
94
|
+
if (set(ref) != {"receipt_id", "path", "sha256"}
|
|
95
|
+
or str(uuid.UUID(ref["receipt_id"])) != ref["receipt_id"]
|
|
96
|
+
or not matches_reference(ref, store.completion_receipts_dir / f"{ref['receipt_id']}.json")):
|
|
97
|
+
raise _no_change_fail("completion reference differs from its exact canonical identity")
|
|
98
|
+
value, _ = read(Path(ref["path"]), ref["sha256"])
|
|
99
|
+
if value.get("receipt_id") != ref["receipt_id"]:
|
|
100
|
+
raise _no_change_fail("completion receipt identity changed")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _legacy_no_change(store: MetadataStore, item: WorktreeGitState, record: MetadataRecord,
|
|
105
|
+
repository_id: str | None, ref: dict[str, str]) -> MetadataRecord:
|
|
106
|
+
from .cleanup_evidence import read
|
|
107
|
+
|
|
108
|
+
event = record.history[-1]
|
|
109
|
+
if (record.lifecycle != "NO_CHANGE_COMPLETED" or event.get("no_change_completion_ref")
|
|
110
|
+
or event.get("event") != "NO_CHANGE_COMPLETED" or record.record_version < 2
|
|
111
|
+
or sum(row.get("event") == "NO_CHANGE_COMPLETED" for row in record.history) != 1
|
|
112
|
+
or any(row.get("event") == NO_CHANGE_RECOVERY_EVENT for row in record.history)):
|
|
113
|
+
raise _no_change_fail("only one unreconciled legacy completion transition is eligible")
|
|
114
|
+
original = replace(record, lifecycle=event["from"], record_version=record.record_version - 1,
|
|
115
|
+
last_reviewed_at=record.history[-2]["at"] if len(record.history) > 1 else record.created_at,
|
|
116
|
+
history=record.history[:-1])
|
|
117
|
+
if _no_change_transition(original, event) != record:
|
|
118
|
+
raise _no_change_fail("legacy completion is not the exact +1 transition")
|
|
119
|
+
receipt = _completion_document(store, ref)
|
|
120
|
+
if receipt.get("contract") or "before_record" in receipt:
|
|
121
|
+
raise _no_change_fail("new completion evidence cannot be reclassified as legacy")
|
|
122
|
+
_no_change_identity(receipt, original, item, repository_id)
|
|
123
|
+
matches = []
|
|
124
|
+
for path in sorted(store.completion_receipts_dir.glob("*.json")):
|
|
125
|
+
candidate, _ = read(path)
|
|
126
|
+
if (candidate.get("kind") == "no_change_completion"
|
|
127
|
+
and candidate.get("allocation_id") == record.allocation_id):
|
|
128
|
+
matches.append(candidate.get("receipt_id"))
|
|
129
|
+
if matches != [ref["receipt_id"]]:
|
|
130
|
+
raise _no_change_fail("legacy completion evidence is missing or ambiguous")
|
|
131
|
+
times = [_no_change_time(value) for value in (
|
|
132
|
+
original.last_reviewed_at, receipt["completed_at"], event["at"],
|
|
133
|
+
)]
|
|
134
|
+
if not times[0] <= times[1] <= times[2]:
|
|
135
|
+
raise _no_change_fail("legacy completion precedes the original recorded transition")
|
|
136
|
+
return original
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def verify_no_change_cleanup(store: MetadataStore, item: WorktreeGitState,
|
|
140
|
+
record: MetadataRecord, repository_id: str | None) -> list[dict[str, str]]:
|
|
141
|
+
"""Bridge only frozen normal transitions; cleanup keeps its original exact CAS."""
|
|
142
|
+
from .preserved_cleanup import cleanup_receipts
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
event = record.history[-1]
|
|
146
|
+
receipt = _completion_document(store, event["no_change_completion_ref"])
|
|
147
|
+
if receipt.get("contract") != NO_CHANGE_CONTRACT or record.lifecycle != "NO_CHANGE_COMPLETED":
|
|
148
|
+
raise _no_change_fail("no-change closeout contract or lifecycle changed")
|
|
149
|
+
if receipt.get("kind") == "no_change_completion":
|
|
150
|
+
original = record_from_dict(receipt["before_record"])
|
|
151
|
+
if (_no_change_transition(original, event) != record
|
|
152
|
+
or {key: value for key, value in event.items() if key != "no_change_completion_ref"}
|
|
153
|
+
!= receipt["completion_event"]):
|
|
154
|
+
raise _no_change_fail("completion after-record changed")
|
|
155
|
+
_no_change_identity(receipt, original, item, repository_id)
|
|
156
|
+
if not (_no_change_time(original.last_reviewed_at)
|
|
157
|
+
<= _no_change_time(receipt["completed_at"]) <= _no_change_time(event["at"])):
|
|
158
|
+
raise _no_change_fail("completion timestamp differs from its transition")
|
|
159
|
+
refs = receipt["preservation_cleanup_receipts"]
|
|
160
|
+
elif receipt.get("kind") == "no_change_cleanup_recovery":
|
|
161
|
+
plan = receipt["plan"]
|
|
162
|
+
legacy = record_from_dict(plan["record"])
|
|
163
|
+
exact_event = {"at": receipt["reconciled_at"], "event": NO_CHANGE_RECOVERY_EVENT,
|
|
164
|
+
"from": "NO_CHANGE_COMPLETED", "to": "NO_CHANGE_COMPLETED",
|
|
165
|
+
"owner": legacy.owner, "no_change_completion_ref": event["no_change_completion_ref"]}
|
|
166
|
+
expected = replace(legacy, record_version=legacy.record_version + 1,
|
|
167
|
+
last_reviewed_at=event["at"], history=(*legacy.history, event))
|
|
168
|
+
if (event != exact_event or event.get("event") != NO_CHANGE_RECOVERY_EVENT
|
|
169
|
+
or event.get("from") != "NO_CHANGE_COMPLETED" or event.get("to") != "NO_CHANGE_COMPLETED"
|
|
170
|
+
or event.get("owner") != legacy.owner or expected != record
|
|
171
|
+
or event["at"] != receipt["reconciled_at"]
|
|
172
|
+
or _no_change_time(event["at"]) < _no_change_time(legacy.last_reviewed_at)
|
|
173
|
+
or receipt["plan_id"] != _digest(plan)
|
|
174
|
+
or plan["repository_id"] != repository_id or plan["target_path"] != str(item.path)):
|
|
175
|
+
raise _no_change_fail("reconciliation after-record or plan changed")
|
|
176
|
+
original = _legacy_no_change(store, item, legacy, repository_id, plan["completion_ref"])
|
|
177
|
+
if record_to_dict(original) != plan["cleanup_record"]:
|
|
178
|
+
raise _no_change_fail("original cleanup record changed")
|
|
179
|
+
refs = plan["preservation_cleanup_receipts"]
|
|
180
|
+
else:
|
|
181
|
+
raise _no_change_fail("unsupported no-change completion evidence")
|
|
182
|
+
cleanup_receipts(store, item, original, repository_id, expected_refs=refs)
|
|
183
|
+
# Verified zero-effect refusals still activate release's runtime/writer guards.
|
|
184
|
+
return cleanup_receipts(store, item, original, repository_id, include_no_action_refs=True)
|
|
185
|
+
except ControllerError:
|
|
186
|
+
raise
|
|
187
|
+
except (KeyError, TypeError, ValueError, IndexError, OSError) as exc:
|
|
188
|
+
raise _no_change_fail("no-change closeout evidence is unavailable or changed") from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def reconcile_no_change_completion(repository_path: Path, *, target: Path, owner: str,
|
|
192
|
+
expected_record_version: int, completion_id: str,
|
|
193
|
+
completion_sha256: str, cleanup_ids: tuple[str, ...],
|
|
194
|
+
cleanup_sha256: tuple[str, ...], dry_run: bool,
|
|
195
|
+
expected_plan_id: str | None = None) -> dict[str, Any]:
|
|
196
|
+
try:
|
|
197
|
+
return _reconcile_no_change_completion(
|
|
198
|
+
repository_path, target=target, owner=owner,
|
|
199
|
+
expected_record_version=expected_record_version,
|
|
200
|
+
completion_id=completion_id, completion_sha256=completion_sha256,
|
|
201
|
+
cleanup_ids=cleanup_ids, cleanup_sha256=cleanup_sha256,
|
|
202
|
+
dry_run=dry_run, expected_plan_id=expected_plan_id,
|
|
203
|
+
)
|
|
204
|
+
except ControllerError:
|
|
205
|
+
raise
|
|
206
|
+
except (KeyError, TypeError, ValueError, IndexError, OSError) as exc:
|
|
207
|
+
raise _no_change_fail("reconciliation identity or evidence is unavailable or malformed") from exc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _reconcile_no_change_completion(repository_path: Path, *, target: Path, owner: str,
|
|
211
|
+
expected_record_version: int, completion_id: str,
|
|
212
|
+
completion_sha256: str, cleanup_ids: tuple[str, ...],
|
|
213
|
+
cleanup_sha256: tuple[str, ...], dry_run: bool,
|
|
214
|
+
expected_plan_id: str | None) -> dict[str, Any]:
|
|
215
|
+
from .cleanup_evidence import read, write
|
|
216
|
+
from .controller import status
|
|
217
|
+
from .lifecycle import _release_transaction
|
|
218
|
+
from .preserved_cleanup import cleanup_receipts
|
|
219
|
+
from .semantics import assert_record_version as _cas
|
|
220
|
+
from .semantics import managed_record as _managed_record
|
|
221
|
+
|
|
222
|
+
def reference(identity: str, digest: str) -> dict[str, str]:
|
|
223
|
+
if str(uuid.UUID(identity)) != identity:
|
|
224
|
+
raise _no_change_fail("canonical completion receipt ID required")
|
|
225
|
+
return {"receipt_id": identity, "path": str(store.completion_receipts_dir / f"{identity}.json"), "sha256": digest}
|
|
226
|
+
|
|
227
|
+
store = MetadataStore(inventory(repository_path).common_dir)
|
|
228
|
+
with store.lock(exclusive=not dry_run):
|
|
229
|
+
repository = inventory(repository_path)
|
|
230
|
+
snapshot = store._load_unlocked()
|
|
231
|
+
item, record = _managed_record(repository, snapshot, target)
|
|
232
|
+
if record.owner != owner:
|
|
233
|
+
raise ControllerError("OWNER_MISMATCH", "completion reconciliation owner does not match")
|
|
234
|
+
_eligible(repository, snapshot, item, record, state="NO_CHANGE_COMPLETED", require_publication=False)
|
|
235
|
+
if _release_transaction(store, target_path=str(item.path), allocation_id=record.allocation_id):
|
|
236
|
+
raise ControllerError("INTERRUPTED_TRANSACTION", "pending transaction blocks reconciliation")
|
|
237
|
+
if not cleanup_ids or len(cleanup_ids) != len(cleanup_sha256) or len(set(cleanup_ids)) != len(cleanup_ids):
|
|
238
|
+
raise _no_change_fail("distinct paired successful cleanup receipt IDs and SHA256 values required")
|
|
239
|
+
refs = sorted((reference(identity, digest) for identity, digest in zip(cleanup_ids, cleanup_sha256, strict=True)),
|
|
240
|
+
key=lambda ref: ref["receipt_id"])
|
|
241
|
+
completion_ref = reference(completion_id, completion_sha256)
|
|
242
|
+
request = {"owner": owner, "expected_record_version": expected_record_version,
|
|
243
|
+
"completion_ref": completion_ref, "cleanup_refs": refs}
|
|
244
|
+
if record.history[-1].get("event") == NO_CHANGE_RECOVERY_EVENT:
|
|
245
|
+
completed = _completion_document(store, record.history[-1]["no_change_completion_ref"])
|
|
246
|
+
if completed["plan"]["request"] != request:
|
|
247
|
+
raise _no_change_fail("reconciliation replay differs from the original request")
|
|
248
|
+
if expected_plan_id is not None and expected_plan_id != completed["plan_id"]:
|
|
249
|
+
raise _no_change_fail("reconciliation replay differs from the original plan")
|
|
250
|
+
verify_no_change_cleanup(store, item, record, snapshot.repository_id)
|
|
251
|
+
decision = {"code": "NO_CHANGE_COMPLETION_ALREADY_RECONCILED",
|
|
252
|
+
"receipt": record.history[-1]["no_change_completion_ref"], "record_version": record.record_version}
|
|
253
|
+
else:
|
|
254
|
+
_cas(record, expected_record_version)
|
|
255
|
+
original = _legacy_no_change(store, item, record, snapshot.repository_id, completion_ref)
|
|
256
|
+
cleanup_receipts(store, item, original, snapshot.repository_id, expected_refs=refs)
|
|
257
|
+
plan = {"contract": NO_CHANGE_CONTRACT, "request": request,
|
|
258
|
+
"repository_id": snapshot.repository_id, "target_path": str(item.path),
|
|
259
|
+
"record": record_to_dict(record), "cleanup_record": record_to_dict(original),
|
|
260
|
+
"completion_ref": completion_ref, "preservation_cleanup_receipts": refs}
|
|
261
|
+
plan_id = _digest(plan)
|
|
262
|
+
if dry_run:
|
|
263
|
+
decision = {"code": "NO_CHANGE_COMPLETION_RECOVERY_READY", "plan": plan, "plan_id": plan_id}
|
|
264
|
+
else:
|
|
265
|
+
if expected_plan_id != plan_id:
|
|
266
|
+
raise ControllerError("RESOURCE_PLAN_MISMATCH", "reconciliation requires the exact current dry-run plan")
|
|
267
|
+
receipt_id = str(uuid.uuid5(uuid.UUID(record.allocation_id), plan_id))
|
|
268
|
+
path = store.completion_receipts_dir / f"{receipt_id}.json"
|
|
269
|
+
from .store import now_iso
|
|
270
|
+
|
|
271
|
+
receipt = {"contract": NO_CHANGE_CONTRACT, "kind": "no_change_cleanup_recovery",
|
|
272
|
+
"receipt_id": receipt_id, "plan_id": plan_id, "plan": plan}
|
|
273
|
+
if path.exists():
|
|
274
|
+
existing, digest = read(path)
|
|
275
|
+
if any(existing.get(key) != value for key, value in receipt.items()) or set(existing) != {*receipt, "reconciled_at"}:
|
|
276
|
+
raise _no_change_fail("existing reconciliation receipt differs")
|
|
277
|
+
receipt = existing
|
|
278
|
+
ref = {"receipt_id": receipt_id, "path": str(path), "sha256": digest}
|
|
279
|
+
else:
|
|
280
|
+
receipt["reconciled_at"] = now_iso()
|
|
281
|
+
if _no_change_time(receipt["reconciled_at"]) < _no_change_time(record.last_reviewed_at):
|
|
282
|
+
raise _no_change_fail("reconciliation predates the legacy completion")
|
|
283
|
+
if not path.exists():
|
|
284
|
+
ref = {"receipt_id": receipt_id, **write(store.completion_receipts_dir, path.name, receipt)}
|
|
285
|
+
event = {"at": receipt["reconciled_at"], "event": NO_CHANGE_RECOVERY_EVENT,
|
|
286
|
+
"from": record.lifecycle, "to": record.lifecycle, "owner": record.owner,
|
|
287
|
+
"no_change_completion_ref": ref}
|
|
288
|
+
updated = replace(record, record_version=record.record_version + 1,
|
|
289
|
+
last_reviewed_at=event["at"], history=(*record.history, event))
|
|
290
|
+
verify_no_change_cleanup(store, item, updated, snapshot.repository_id)
|
|
291
|
+
store.replace_record(updated)
|
|
292
|
+
decision = {"code": "NO_CHANGE_COMPLETION_RECONCILED", "receipt": ref, "record_version": updated.record_version}
|
|
293
|
+
result = status(repository_path)
|
|
294
|
+
result.update(command="reconcile", decision=decision)
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _digest(value: dict[str, Any]) -> str:
|
|
299
|
+
return hashlib.sha256(
|
|
300
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
301
|
+
).hexdigest()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _publication(repository: RepositoryInventory) -> dict[str, str]:
|
|
305
|
+
policy = load_policy(repository.canonical_path)
|
|
306
|
+
if not policy.present or policy.policy_schema_version < 2:
|
|
307
|
+
raise ControllerError("MISSING_PUBLICATION_POLICY", "closeout requires publication policy")
|
|
308
|
+
if REMOTE_NAME_PATTERN.fullmatch(policy.publication_remote) is None:
|
|
309
|
+
raise ControllerError("INVALID_REMOTE", "closeout requires a configured remote name")
|
|
310
|
+
validate_branch_name(repository.canonical_path, policy.publication_branch)
|
|
311
|
+
ref = f"refs/heads/{policy.publication_branch}"
|
|
312
|
+
result = _git(
|
|
313
|
+
repository.canonical_path,
|
|
314
|
+
"ls-remote",
|
|
315
|
+
"--exit-code",
|
|
316
|
+
policy.publication_remote,
|
|
317
|
+
ref,
|
|
318
|
+
check=False,
|
|
319
|
+
timeout=30,
|
|
320
|
+
)
|
|
321
|
+
rows = result.stdout.decode("utf-8", "replace").splitlines()
|
|
322
|
+
fields = rows[0].split() if len(rows) == 1 else []
|
|
323
|
+
if result.returncode or len(fields) != 2 or fields[1] != ref:
|
|
324
|
+
raise ControllerError(
|
|
325
|
+
"PUBLICATION_UNVERIFIED", "live publication ref could not be verified"
|
|
326
|
+
)
|
|
327
|
+
tracking = f"refs/remotes/{policy.publication_remote}/{policy.publication_branch}"
|
|
328
|
+
oid = resolve_commit(repository.canonical_path, tracking)
|
|
329
|
+
if fields[0] != oid:
|
|
330
|
+
raise ControllerError(
|
|
331
|
+
"PUBLICATION_TRACKING_STALE", "publication tracking ref needs refresh"
|
|
332
|
+
)
|
|
333
|
+
return {"remote": policy.publication_remote, "ref": ref, "tracking_ref": tracking, "oid": oid}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _eligible(
|
|
337
|
+
repository: RepositoryInventory,
|
|
338
|
+
snapshot: MetadataSnapshot,
|
|
339
|
+
item: WorktreeGitState,
|
|
340
|
+
record: MetadataRecord,
|
|
341
|
+
*,
|
|
342
|
+
state: str,
|
|
343
|
+
require_publication: bool = True,
|
|
344
|
+
allow_registered_venv: bool = False,
|
|
345
|
+
for_typed_cleanup: bool = False,
|
|
346
|
+
) -> dict[str, str]:
|
|
347
|
+
from .lifecycle import (
|
|
348
|
+
_effective_intent_state,
|
|
349
|
+
_typed_runtime_blockers,
|
|
350
|
+
_typed_runtime_evidence,
|
|
351
|
+
_validate_release_environment,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if for_typed_cleanup and (state != "RETAINED" or require_publication):
|
|
355
|
+
raise ControllerError("INVALID_LIFECYCLE", "typed cleanup does not authorize publication")
|
|
356
|
+
if item.canonical or item.detached or item.locked or item.prunable:
|
|
357
|
+
raise ControllerError(
|
|
358
|
+
"CLOSEOUT_TARGET_INVALID", "closeout requires an attached linked checkout"
|
|
359
|
+
)
|
|
360
|
+
if record.role not in {"feature", "audit"} or record.lifecycle != state:
|
|
361
|
+
raise ControllerError(
|
|
362
|
+
"INVALID_LIFECYCLE", f"closeout requires {state} feature/audit lifecycle"
|
|
363
|
+
)
|
|
364
|
+
if (
|
|
365
|
+
state == "RETAINED"
|
|
366
|
+
and not require_publication
|
|
367
|
+
and any(
|
|
368
|
+
other.allocation_id != record.allocation_id
|
|
369
|
+
and other.lifecycle in ACTIVE_AUTHORITY_STATES
|
|
370
|
+
and (other.task_key == record.task_key or other.authority_key == record.authority_key)
|
|
371
|
+
for other in snapshot.records.values()
|
|
372
|
+
)
|
|
373
|
+
):
|
|
374
|
+
raise ControllerError(
|
|
375
|
+
"DUPLICATE_AUTHORITY", "another active task or authority blocks archive"
|
|
376
|
+
)
|
|
377
|
+
if not item.clean:
|
|
378
|
+
raise ControllerError("DIRTY_WORKTREE", "closeout requires a clean checkout")
|
|
379
|
+
if record.handoff_commit or record.finalizer_record_id or record.finalizer_commit:
|
|
380
|
+
raise ControllerError(
|
|
381
|
+
"HANDOFF_EVIDENCE_PRESENT", "frozen handoffs require their existing release flow"
|
|
382
|
+
)
|
|
383
|
+
if record.quarantine_reason or record.recovery_ref or record.recovery_allocation_id:
|
|
384
|
+
raise ControllerError(
|
|
385
|
+
"RECOVERY_STATE", "recovery lineage requires its existing recovery flow"
|
|
386
|
+
)
|
|
387
|
+
if record.external_bindings or record.allocation_id in snapshot.writer_leases:
|
|
388
|
+
raise ControllerError("MANAGED_REFERENCE", "bindings and writer leases block closeout")
|
|
389
|
+
if (
|
|
390
|
+
snapshot.publication_lease
|
|
391
|
+
and snapshot.publication_lease.allocation_id == record.allocation_id
|
|
392
|
+
):
|
|
393
|
+
raise ControllerError("PUBLICATION_LEASE_HELD", "publication is in progress")
|
|
394
|
+
registered_paths = tuple(
|
|
395
|
+
r["path"] for r in record.disposable_resources if r["state"] == "registered"
|
|
396
|
+
)
|
|
397
|
+
if any(
|
|
398
|
+
r["state"] != "cleaned"
|
|
399
|
+
and not (allow_registered_venv and r["state"] == "registered" and r["path"] == ".venv")
|
|
400
|
+
for r in record.disposable_resources
|
|
401
|
+
):
|
|
402
|
+
raise ControllerError(
|
|
403
|
+
"DISPOSABLE_RESOURCE_PRESENT", "registered resources must be cleaned first"
|
|
404
|
+
)
|
|
405
|
+
# Formal abandonment leaves historical source references in the intent. Archive
|
|
406
|
+
# also accepts reconciled release evidence; both require the frozen source handoff.
|
|
407
|
+
if any(
|
|
408
|
+
intent.allocation_id == record.allocation_id
|
|
409
|
+
or (
|
|
410
|
+
record.allocation_id in intent.source_allocations
|
|
411
|
+
and (
|
|
412
|
+
not (
|
|
413
|
+
(effective_state := _effective_intent_state(snapshot, intent)) == "ABANDONED"
|
|
414
|
+
or (not require_publication and effective_state == "RELEASED")
|
|
415
|
+
)
|
|
416
|
+
or not is_ancestor(
|
|
417
|
+
repository.canonical_path,
|
|
418
|
+
intent.source_handoffs[intent.source_allocations.index(record.allocation_id)],
|
|
419
|
+
item.head,
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
for intent in snapshot.integration_intents.values()
|
|
424
|
+
) or any(
|
|
425
|
+
other.lifecycle != "RELEASED" and record.allocation_id in other.source_allocations
|
|
426
|
+
for other in snapshot.records.values()
|
|
427
|
+
):
|
|
428
|
+
raise ControllerError(
|
|
429
|
+
"INTEGRATION_OBLIGATION", "integration lineage still references this allocation"
|
|
430
|
+
)
|
|
431
|
+
if any(
|
|
432
|
+
intent.allocation_id == record.allocation_id
|
|
433
|
+
and intent.status in {"PENDING", "BLOCKED_RETRYABLE"}
|
|
434
|
+
for intent in snapshot.release_intents.values()
|
|
435
|
+
):
|
|
436
|
+
raise ControllerError(
|
|
437
|
+
"RELEASE_INTENT_PRESENT", "an existing release intent must be resolved first"
|
|
438
|
+
)
|
|
439
|
+
if resolve_commit(repository.canonical_path, f"refs/heads/{item.branch}") != item.head:
|
|
440
|
+
raise ControllerError(
|
|
441
|
+
"MISSING_RETAINED_REF", "the checkout branch no longer retains its HEAD"
|
|
442
|
+
)
|
|
443
|
+
if not require_publication:
|
|
444
|
+
for oid in (record.base_commit, record.task_commit):
|
|
445
|
+
if oid and not is_ancestor(repository.canonical_path, oid, item.head):
|
|
446
|
+
raise ControllerError(
|
|
447
|
+
"TASK_LINEAGE_UNREACHABLE",
|
|
448
|
+
"the retained branch no longer contains recorded task lineage",
|
|
449
|
+
)
|
|
450
|
+
publication: dict[str, str] = {}
|
|
451
|
+
if require_publication:
|
|
452
|
+
publication = _publication(repository)
|
|
453
|
+
for oid in (item.head, record.base_commit, record.task_commit):
|
|
454
|
+
if oid and (
|
|
455
|
+
not is_ancestor(repository.canonical_path, oid, item.head)
|
|
456
|
+
or not is_ancestor(repository.canonical_path, oid, publication["oid"])
|
|
457
|
+
):
|
|
458
|
+
raise ControllerError(
|
|
459
|
+
"DELIVERABLE_NOT_PUBLISHED", "HEAD or historical task lineage is not published"
|
|
460
|
+
)
|
|
461
|
+
blockers = _typed_runtime_blockers(_typed_runtime_evidence(item, record, snapshot))
|
|
462
|
+
if blockers:
|
|
463
|
+
raise ControllerError(blockers[0]["code"], blockers[0]["message"])
|
|
464
|
+
if not for_typed_cleanup:
|
|
465
|
+
_validate_release_environment(
|
|
466
|
+
repository,
|
|
467
|
+
item,
|
|
468
|
+
registered_resource_paths=registered_paths if allow_registered_venv else (),
|
|
469
|
+
)
|
|
470
|
+
return publication
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _archive_receipt_binding(
|
|
474
|
+
store: MetadataStore,
|
|
475
|
+
record: MetadataRecord,
|
|
476
|
+
repository_id: str | None,
|
|
477
|
+
*,
|
|
478
|
+
target: Path,
|
|
479
|
+
branch: str | None,
|
|
480
|
+
head: str,
|
|
481
|
+
) -> dict[str, Any]:
|
|
482
|
+
"""Verify the immutable archive identity without reading preserved payloads."""
|
|
483
|
+
try:
|
|
484
|
+
event = record.history[-1]
|
|
485
|
+
receipt_id = event["archive_receipt_id"]
|
|
486
|
+
if str(uuid.UUID(receipt_id)) != receipt_id:
|
|
487
|
+
raise ValueError("invalid receipt id")
|
|
488
|
+
receipt = store._read_json(store.completion_receipts_dir / f"{receipt_id}.json")
|
|
489
|
+
plan = receipt["plan"]
|
|
490
|
+
original = record_from_dict(plan["record"])
|
|
491
|
+
expected = replace(
|
|
492
|
+
original,
|
|
493
|
+
lifecycle=ARCHIVE_STATE,
|
|
494
|
+
record_version=original.record_version + 1,
|
|
495
|
+
last_reviewed_at=event["at"],
|
|
496
|
+
history=(*original.history, event),
|
|
497
|
+
)
|
|
498
|
+
if (
|
|
499
|
+
record.lifecycle != ARCHIVE_STATE
|
|
500
|
+
or receipt["contract"] != ARCHIVE_CONTRACT
|
|
501
|
+
or receipt["receipt_id"] != receipt_id
|
|
502
|
+
or receipt["plan_id"] != _digest(plan)
|
|
503
|
+
or event["event"] != ARCHIVE_EVENT
|
|
504
|
+
or event["from"] != "RETAINED"
|
|
505
|
+
or event["to"] != ARCHIVE_STATE
|
|
506
|
+
or event["owner"] != original.owner
|
|
507
|
+
or event["archive_plan_id"] != receipt["plan_id"]
|
|
508
|
+
or event["archive_receipt_sha256"]
|
|
509
|
+
!= _digest({key: value for key, value in receipt.items() if key != "after_record"})
|
|
510
|
+
or plan["repository_id"] != repository_id
|
|
511
|
+
or original.lifecycle != "RETAINED"
|
|
512
|
+
or plan["owner_release"] != original.owner
|
|
513
|
+
or not plan["actor"].strip()
|
|
514
|
+
or plan["target_path"] != str(target)
|
|
515
|
+
or plan["head"] != head
|
|
516
|
+
or plan["branch"] != branch
|
|
517
|
+
or plan["task_completed"] is not False
|
|
518
|
+
or plan["integration_ready"] is not False
|
|
519
|
+
or plan["publication_asserted"] is not False
|
|
520
|
+
or record_to_dict(record) != receipt["after_record"]
|
|
521
|
+
or record_to_dict(expected) != receipt["after_record"]
|
|
522
|
+
):
|
|
523
|
+
raise ValueError("archive evidence changed")
|
|
524
|
+
except (KeyError, TypeError, ValueError, IndexError, OSError, ControllerError) as exc:
|
|
525
|
+
raise ControllerError(
|
|
526
|
+
"RETAINED_ARCHIVE_INVALID", "archive receipt or checkout identity changed"
|
|
527
|
+
) from exc
|
|
528
|
+
return receipt
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def verify_archive_receipt(
|
|
532
|
+
store: MetadataStore,
|
|
533
|
+
item: WorktreeGitState,
|
|
534
|
+
record: MetadataRecord,
|
|
535
|
+
repository_id: str | None,
|
|
536
|
+
) -> dict[str, Any]:
|
|
537
|
+
"""An archived task remains unfinished; its clean HEAD stays on its branch."""
|
|
538
|
+
if not item.clean:
|
|
539
|
+
raise ControllerError(
|
|
540
|
+
"RETAINED_ARCHIVE_INVALID", "archive receipt or checkout identity changed"
|
|
541
|
+
)
|
|
542
|
+
receipt = _archive_receipt_binding(
|
|
543
|
+
store, record, repository_id, target=item.path, branch=item.branch, head=item.head
|
|
544
|
+
)
|
|
545
|
+
original = record_from_dict(receipt["plan"]["record"])
|
|
546
|
+
from .preserved_cleanup import cleanup_receipts
|
|
547
|
+
|
|
548
|
+
cleanup_receipts(
|
|
549
|
+
store,
|
|
550
|
+
item,
|
|
551
|
+
original,
|
|
552
|
+
repository_id,
|
|
553
|
+
expected_refs=receipt["plan"].get("preservation_cleanup_receipts", []),
|
|
554
|
+
)
|
|
555
|
+
return receipt
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def validate_archive_release(
|
|
559
|
+
repository: RepositoryInventory,
|
|
560
|
+
snapshot: MetadataSnapshot,
|
|
561
|
+
item: WorktreeGitState,
|
|
562
|
+
record: MetadataRecord,
|
|
563
|
+
) -> None:
|
|
564
|
+
verify_archive_receipt(
|
|
565
|
+
MetadataStore(repository.common_dir), item, record, snapshot.repository_id
|
|
566
|
+
)
|
|
567
|
+
_eligible(
|
|
568
|
+
repository,
|
|
569
|
+
snapshot,
|
|
570
|
+
item,
|
|
571
|
+
record,
|
|
572
|
+
state=ARCHIVE_STATE,
|
|
573
|
+
require_publication=False,
|
|
574
|
+
allow_registered_venv=True,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def verify_receipt(
|
|
579
|
+
store: MetadataStore,
|
|
580
|
+
item: WorktreeGitState,
|
|
581
|
+
record: MetadataRecord,
|
|
582
|
+
repository_id: str | None,
|
|
583
|
+
) -> dict[str, Any]:
|
|
584
|
+
"""Validate immutable closeout evidence without network or lifecycle mutation."""
|
|
585
|
+
try:
|
|
586
|
+
event = record.history[-1]
|
|
587
|
+
receipt_id = event["closeout_receipt_id"]
|
|
588
|
+
if str(uuid.UUID(receipt_id)) != receipt_id:
|
|
589
|
+
raise ValueError("invalid receipt id")
|
|
590
|
+
receipt = store._read_json(store.completion_receipts_dir / f"{receipt_id}.json")
|
|
591
|
+
plan = receipt["plan"]
|
|
592
|
+
before, after = plan["record"], receipt["after_record"]
|
|
593
|
+
original = record_from_dict(before)
|
|
594
|
+
expected = replace(
|
|
595
|
+
original,
|
|
596
|
+
lifecycle=STATE,
|
|
597
|
+
record_version=original.record_version + 1,
|
|
598
|
+
last_reviewed_at=event["at"],
|
|
599
|
+
history=(*original.history, event),
|
|
600
|
+
)
|
|
601
|
+
if (
|
|
602
|
+
record.lifecycle != STATE
|
|
603
|
+
or not item.clean
|
|
604
|
+
or receipt["contract"] != CONTRACT
|
|
605
|
+
or receipt["receipt_id"] != receipt_id
|
|
606
|
+
or receipt["plan_id"] != _digest(plan)
|
|
607
|
+
or event["event"] != EVENT
|
|
608
|
+
or event["from"] != original.lifecycle
|
|
609
|
+
or event["to"] != STATE
|
|
610
|
+
or event["owner"] != original.owner
|
|
611
|
+
or event["closeout_plan_id"] != receipt["plan_id"]
|
|
612
|
+
or event["closeout_receipt_sha256"]
|
|
613
|
+
!= _digest({key: value for key, value in receipt.items() if key != "after_record"})
|
|
614
|
+
or plan["repository_id"] != repository_id
|
|
615
|
+
or original.lifecycle not in {"RETAINED", "PRESERVED"}
|
|
616
|
+
or plan["owner_release"] != original.owner
|
|
617
|
+
or not plan["actor"].strip()
|
|
618
|
+
or plan["target_path"] != str(item.path)
|
|
619
|
+
or plan["head"] != item.head
|
|
620
|
+
or plan["branch"] != item.branch
|
|
621
|
+
or plan["task_completed"] is not False
|
|
622
|
+
or record_to_dict(record) != after
|
|
623
|
+
or record_to_dict(expected) != after
|
|
624
|
+
):
|
|
625
|
+
raise ValueError("closeout evidence changed")
|
|
626
|
+
except (KeyError, TypeError, ValueError, IndexError, OSError, ControllerError) as exc:
|
|
627
|
+
raise ControllerError(
|
|
628
|
+
"DELIVERED_CLOSEOUT_INVALID", "closeout receipt or checkout identity changed"
|
|
629
|
+
) from exc
|
|
630
|
+
return receipt
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def validate_release(
|
|
634
|
+
repository: RepositoryInventory,
|
|
635
|
+
snapshot: MetadataSnapshot,
|
|
636
|
+
item: WorktreeGitState,
|
|
637
|
+
record: MetadataRecord,
|
|
638
|
+
) -> None:
|
|
639
|
+
receipt = verify_receipt(
|
|
640
|
+
MetadataStore(repository.common_dir), item, record, snapshot.repository_id
|
|
641
|
+
)
|
|
642
|
+
publication = _eligible(
|
|
643
|
+
repository, snapshot, item, record, state=STATE, allow_registered_venv=True
|
|
644
|
+
)
|
|
645
|
+
previous = receipt["plan"]["publication"]
|
|
646
|
+
if any(
|
|
647
|
+
previous[key] != publication[key] for key in ("remote", "ref", "tracking_ref")
|
|
648
|
+
) or not is_ancestor(repository.canonical_path, previous["oid"], publication["oid"]):
|
|
649
|
+
raise ControllerError(
|
|
650
|
+
"PUBLICATION_DRIFT", "publication no longer extends the closeout evidence"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def close_delivered(
|
|
655
|
+
repository_path: Path,
|
|
656
|
+
*,
|
|
657
|
+
target: Path,
|
|
658
|
+
expected_record_version: int,
|
|
659
|
+
owner_release: str,
|
|
660
|
+
actor: str,
|
|
661
|
+
dry_run: bool = False,
|
|
662
|
+
plan_id: str | None = None,
|
|
663
|
+
) -> dict[str, Any]:
|
|
664
|
+
from .controller import envelope
|
|
665
|
+
from .lifecycle import _updated_record
|
|
666
|
+
from .semantics import assert_record_version as _cas
|
|
667
|
+
from .semantics import managed_record as _managed_record
|
|
668
|
+
|
|
669
|
+
if not actor.strip():
|
|
670
|
+
raise ControllerError("INVALID_ACTOR", "closeout requires a named governance actor")
|
|
671
|
+
if not dry_run and not plan_id:
|
|
672
|
+
raise ControllerError(
|
|
673
|
+
"CLOSEOUT_PLAN_REQUIRED", "execute only an exact reviewed closeout plan"
|
|
674
|
+
)
|
|
675
|
+
initial = inventory(repository_path)
|
|
676
|
+
store = MetadataStore(initial.common_dir)
|
|
677
|
+
with store.lock(exclusive=not dry_run):
|
|
678
|
+
repository = inventory(repository_path)
|
|
679
|
+
snapshot = store._load_unlocked()
|
|
680
|
+
item, record = _managed_record(repository, snapshot, target)
|
|
681
|
+
if record.owner != owner_release:
|
|
682
|
+
raise ControllerError(
|
|
683
|
+
"OWNER_MISMATCH", "closeout requires the recorded owner's release authority"
|
|
684
|
+
)
|
|
685
|
+
if record.lifecycle == STATE and not dry_run:
|
|
686
|
+
receipt = verify_receipt(store, item, record, snapshot.repository_id)
|
|
687
|
+
if (
|
|
688
|
+
receipt["plan_id"] != plan_id
|
|
689
|
+
or receipt["plan"]["actor"] != actor.strip()
|
|
690
|
+
or receipt["plan"]["record"]["record_version"] != expected_record_version
|
|
691
|
+
):
|
|
692
|
+
raise ControllerError(
|
|
693
|
+
"CLOSEOUT_PLAN_DRIFT", "retry does not match the recorded closeout"
|
|
694
|
+
)
|
|
695
|
+
code = "ALREADY_CLOSED"
|
|
696
|
+
else:
|
|
697
|
+
_cas(record, expected_record_version)
|
|
698
|
+
if record.lifecycle not in {"RETAINED", "PRESERVED"}:
|
|
699
|
+
raise ControllerError(
|
|
700
|
+
"INVALID_LIFECYCLE", "only retained or legacy preserved checkouts qualify"
|
|
701
|
+
)
|
|
702
|
+
if record.lifecycle == "PRESERVED":
|
|
703
|
+
from .source_cleanup import verify_completed
|
|
704
|
+
|
|
705
|
+
if verify_completed(store, item, record):
|
|
706
|
+
raise ControllerError(
|
|
707
|
+
"PRESERVATION_FLOW_REQUIRED",
|
|
708
|
+
"verified archived source must use its existing preservation release flow",
|
|
709
|
+
)
|
|
710
|
+
publication = _eligible(
|
|
711
|
+
repository,
|
|
712
|
+
snapshot,
|
|
713
|
+
item,
|
|
714
|
+
record,
|
|
715
|
+
state=record.lifecycle,
|
|
716
|
+
allow_registered_venv=record.lifecycle == "RETAINED",
|
|
717
|
+
)
|
|
718
|
+
plan = {
|
|
719
|
+
"contract": CONTRACT,
|
|
720
|
+
"repository_id": snapshot.repository_id,
|
|
721
|
+
"target_path": str(item.path),
|
|
722
|
+
"head": item.head,
|
|
723
|
+
"branch": item.branch,
|
|
724
|
+
"record": record_to_dict(record),
|
|
725
|
+
"publication": publication,
|
|
726
|
+
"owner_release": owner_release,
|
|
727
|
+
"actor": actor.strip(),
|
|
728
|
+
"task_completed": False,
|
|
729
|
+
}
|
|
730
|
+
identity = _digest(plan)
|
|
731
|
+
if dry_run:
|
|
732
|
+
result = envelope("close-delivered")
|
|
733
|
+
result["decision"] = {
|
|
734
|
+
"code": "CLOSEOUT_READY",
|
|
735
|
+
"plan_id": identity,
|
|
736
|
+
"allocation_id": record.allocation_id,
|
|
737
|
+
"record_version": record.record_version,
|
|
738
|
+
"reason": "checkout and historical commits are contained in live publication",
|
|
739
|
+
}
|
|
740
|
+
result["closeout_plan"] = plan
|
|
741
|
+
return result
|
|
742
|
+
if plan_id != identity:
|
|
743
|
+
raise ControllerError("CLOSEOUT_PLAN_DRIFT", "closeout facts changed after review")
|
|
744
|
+
receipt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{CONTRACT}:{identity}"))
|
|
745
|
+
path = store.completion_receipts_dir / f"{receipt_id}.json"
|
|
746
|
+
if path.exists():
|
|
747
|
+
receipt = store._read_json(path)
|
|
748
|
+
if receipt.get("plan") != plan or receipt.get("plan_id") != identity:
|
|
749
|
+
raise ControllerError(
|
|
750
|
+
"CLOSEOUT_PLAN_DRIFT", "existing closeout evidence differs"
|
|
751
|
+
)
|
|
752
|
+
updated = record_from_dict(receipt["after_record"])
|
|
753
|
+
verify_receipt(store, item, updated, snapshot.repository_id)
|
|
754
|
+
else:
|
|
755
|
+
receipt = {
|
|
756
|
+
"contract": CONTRACT,
|
|
757
|
+
"receipt_id": receipt_id,
|
|
758
|
+
"plan_id": identity,
|
|
759
|
+
"plan": plan,
|
|
760
|
+
}
|
|
761
|
+
updated = _updated_record(record, EVENT, lifecycle=STATE)
|
|
762
|
+
event = {
|
|
763
|
+
**updated.history[-1],
|
|
764
|
+
"closeout_receipt_id": receipt_id,
|
|
765
|
+
"closeout_plan_id": identity,
|
|
766
|
+
"closeout_receipt_sha256": _digest(receipt),
|
|
767
|
+
}
|
|
768
|
+
updated = replace(updated, history=(*updated.history[:-1], event))
|
|
769
|
+
receipt["after_record"] = record_to_dict(updated)
|
|
770
|
+
store.write_completion_receipt(receipt_id, receipt)
|
|
771
|
+
# Git may be changed by a process outside the Controller's repository lock.
|
|
772
|
+
current, latest = _managed_record(
|
|
773
|
+
inventory(repository_path), store._load_unlocked(), target
|
|
774
|
+
)
|
|
775
|
+
if current != item or latest != record:
|
|
776
|
+
raise ControllerError(
|
|
777
|
+
"CLOSEOUT_PLAN_DRIFT", "checkout changed before metadata transition"
|
|
778
|
+
)
|
|
779
|
+
store.replace_record(updated)
|
|
780
|
+
code = "CHECKOUT_CLOSED"
|
|
781
|
+
result = envelope("close-delivered")
|
|
782
|
+
result["decision"] = {
|
|
783
|
+
"code": code,
|
|
784
|
+
"reason": "published checkout closed; ordinary guarded release remains required",
|
|
785
|
+
"allocation_id": record.allocation_id,
|
|
786
|
+
"record_version": receipt["after_record"]["record_version"],
|
|
787
|
+
"plan_id": receipt["plan_id"],
|
|
788
|
+
"receipt_id": receipt["receipt_id"],
|
|
789
|
+
"task_completed": False,
|
|
790
|
+
}
|
|
791
|
+
return result
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def archive_retained(
|
|
795
|
+
repository_path: Path,
|
|
796
|
+
*,
|
|
797
|
+
target: Path,
|
|
798
|
+
expected_record_version: int,
|
|
799
|
+
owner_release: str,
|
|
800
|
+
actor: str,
|
|
801
|
+
dry_run: bool = False,
|
|
802
|
+
plan_id: str | None = None,
|
|
803
|
+
) -> dict[str, Any]:
|
|
804
|
+
"""Freeze a clean, unfinished retained task for guarded physical release."""
|
|
805
|
+
from .controller import envelope
|
|
806
|
+
from .lifecycle import _updated_record
|
|
807
|
+
from .semantics import assert_record_version as _cas
|
|
808
|
+
from .semantics import managed_record as _managed_record
|
|
809
|
+
|
|
810
|
+
if not actor.strip():
|
|
811
|
+
raise ControllerError("INVALID_ACTOR", "archive requires a named governance actor")
|
|
812
|
+
if not dry_run and not plan_id:
|
|
813
|
+
raise ControllerError(
|
|
814
|
+
"ARCHIVE_PLAN_REQUIRED", "execute only an exact reviewed archive plan"
|
|
815
|
+
)
|
|
816
|
+
initial = inventory(repository_path)
|
|
817
|
+
store = MetadataStore(initial.common_dir)
|
|
818
|
+
with store.lock(exclusive=not dry_run):
|
|
819
|
+
repository = inventory(repository_path)
|
|
820
|
+
snapshot = store._load_unlocked()
|
|
821
|
+
item, record = _managed_record(repository, snapshot, target)
|
|
822
|
+
if record.owner != owner_release:
|
|
823
|
+
raise ControllerError("OWNER_MISMATCH", "archive requires the recorded owner")
|
|
824
|
+
if record.lifecycle == ARCHIVE_STATE and not dry_run:
|
|
825
|
+
receipt = verify_archive_receipt(store, item, record, snapshot.repository_id)
|
|
826
|
+
if (
|
|
827
|
+
receipt["plan_id"] != plan_id
|
|
828
|
+
or receipt["plan"]["actor"] != actor.strip()
|
|
829
|
+
or receipt["plan"]["record"]["record_version"] != expected_record_version
|
|
830
|
+
):
|
|
831
|
+
raise ControllerError("ARCHIVE_PLAN_DRIFT", "retry does not match the archive")
|
|
832
|
+
code = "ALREADY_ARCHIVED"
|
|
833
|
+
else:
|
|
834
|
+
_cas(record, expected_record_version)
|
|
835
|
+
from .transaction_scope import conflicts
|
|
836
|
+
|
|
837
|
+
if conflicts(store, allocation=record.allocation_id, target=item.path):
|
|
838
|
+
raise ControllerError(
|
|
839
|
+
"INTERRUPTED_TRANSACTION", "archive target has an unresolved transaction"
|
|
840
|
+
)
|
|
841
|
+
_eligible(
|
|
842
|
+
repository,
|
|
843
|
+
snapshot,
|
|
844
|
+
item,
|
|
845
|
+
record,
|
|
846
|
+
state="RETAINED",
|
|
847
|
+
require_publication=False,
|
|
848
|
+
allow_registered_venv=True,
|
|
849
|
+
)
|
|
850
|
+
from .lifecycle import _release_resource_actions
|
|
851
|
+
|
|
852
|
+
_release_resource_actions(repository, item, record)
|
|
853
|
+
from .preserved_cleanup import cleanup_receipts
|
|
854
|
+
|
|
855
|
+
cleanup_refs = cleanup_receipts(store, item, record, snapshot.repository_id)
|
|
856
|
+
plan = {
|
|
857
|
+
"contract": ARCHIVE_CONTRACT,
|
|
858
|
+
"repository_id": snapshot.repository_id,
|
|
859
|
+
"target_path": str(item.path),
|
|
860
|
+
"head": item.head,
|
|
861
|
+
"branch": item.branch,
|
|
862
|
+
"record": record_to_dict(record),
|
|
863
|
+
"owner_release": owner_release,
|
|
864
|
+
"actor": actor.strip(),
|
|
865
|
+
"task_completed": False,
|
|
866
|
+
"integration_ready": False,
|
|
867
|
+
"publication_asserted": False,
|
|
868
|
+
**({"preservation_cleanup_receipts": cleanup_refs} if cleanup_refs else {}),
|
|
869
|
+
}
|
|
870
|
+
identity = _digest(plan)
|
|
871
|
+
if dry_run:
|
|
872
|
+
result = envelope("archive-retained")
|
|
873
|
+
result["decision"] = {
|
|
874
|
+
"code": "ARCHIVE_READY",
|
|
875
|
+
"plan_id": identity,
|
|
876
|
+
"allocation_id": record.allocation_id,
|
|
877
|
+
"record_version": record.record_version,
|
|
878
|
+
"reason": "clean unfinished checkout is retained by its exact branch; no delivery is asserted",
|
|
879
|
+
}
|
|
880
|
+
result["archive_plan"] = plan
|
|
881
|
+
return result
|
|
882
|
+
if plan_id != identity:
|
|
883
|
+
raise ControllerError("ARCHIVE_PLAN_DRIFT", "archive facts changed after review")
|
|
884
|
+
receipt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{ARCHIVE_CONTRACT}:{identity}"))
|
|
885
|
+
path = store.completion_receipts_dir / f"{receipt_id}.json"
|
|
886
|
+
if path.exists():
|
|
887
|
+
receipt = store._read_json(path)
|
|
888
|
+
if receipt.get("plan") != plan or receipt.get("plan_id") != identity:
|
|
889
|
+
raise ControllerError("ARCHIVE_PLAN_DRIFT", "existing archive evidence differs")
|
|
890
|
+
updated = record_from_dict(receipt["after_record"])
|
|
891
|
+
verify_archive_receipt(store, item, updated, snapshot.repository_id)
|
|
892
|
+
else:
|
|
893
|
+
receipt = {
|
|
894
|
+
"contract": ARCHIVE_CONTRACT,
|
|
895
|
+
"receipt_id": receipt_id,
|
|
896
|
+
"plan_id": identity,
|
|
897
|
+
"plan": plan,
|
|
898
|
+
}
|
|
899
|
+
updated = _updated_record(record, ARCHIVE_EVENT, lifecycle=ARCHIVE_STATE)
|
|
900
|
+
event = {
|
|
901
|
+
**updated.history[-1],
|
|
902
|
+
"archive_receipt_id": receipt_id,
|
|
903
|
+
"archive_plan_id": identity,
|
|
904
|
+
"archive_receipt_sha256": _digest(receipt),
|
|
905
|
+
}
|
|
906
|
+
updated = replace(updated, history=(*updated.history[:-1], event))
|
|
907
|
+
receipt["after_record"] = record_to_dict(updated)
|
|
908
|
+
store.write_completion_receipt(receipt_id, receipt)
|
|
909
|
+
current, latest = _managed_record(
|
|
910
|
+
inventory(repository_path), store._load_unlocked(), target
|
|
911
|
+
)
|
|
912
|
+
if current != item or latest != record:
|
|
913
|
+
raise ControllerError(
|
|
914
|
+
"ARCHIVE_PLAN_DRIFT", "checkout changed before metadata transition"
|
|
915
|
+
)
|
|
916
|
+
store.replace_record(updated)
|
|
917
|
+
code = "ARCHIVED"
|
|
918
|
+
result = envelope("archive-retained")
|
|
919
|
+
result["decision"] = {
|
|
920
|
+
"code": code,
|
|
921
|
+
"reason": "unfinished task and branch preserved; guarded release remains required",
|
|
922
|
+
"allocation_id": record.allocation_id,
|
|
923
|
+
"record_version": receipt["after_record"]["record_version"],
|
|
924
|
+
"plan_id": receipt["plan_id"],
|
|
925
|
+
"receipt_id": receipt["receipt_id"],
|
|
926
|
+
"task_completed": False,
|
|
927
|
+
}
|
|
928
|
+
return result
|