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,1865 @@
|
|
|
1
|
+
"""Plan, authorize and audit branch retirement without mutating Git refs.
|
|
2
|
+
|
|
3
|
+
The Controller owns the lifecycle decision and the execution grant. A consumer
|
|
4
|
+
owns the ref mutation and remains the only component that touches a ref; the
|
|
5
|
+
Controller re-observes the world instead of believing the consumer's account.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from uuid import UUID
|
|
22
|
+
|
|
23
|
+
from . import cleanup_evidence as e
|
|
24
|
+
from . import retirement_adapters as adapters
|
|
25
|
+
from . import retirement_execution as execution
|
|
26
|
+
from .git_backend import (
|
|
27
|
+
RepositoryInventory,
|
|
28
|
+
_git,
|
|
29
|
+
_text,
|
|
30
|
+
inventory,
|
|
31
|
+
is_ancestor,
|
|
32
|
+
public_refs,
|
|
33
|
+
resolve_commit,
|
|
34
|
+
)
|
|
35
|
+
from .models import (
|
|
36
|
+
ControllerError,
|
|
37
|
+
MetadataRecord,
|
|
38
|
+
MetadataSnapshot,
|
|
39
|
+
ReleaseIntent,
|
|
40
|
+
ReleaseReceipt,
|
|
41
|
+
WriterHolder,
|
|
42
|
+
)
|
|
43
|
+
from .policy import load_policy
|
|
44
|
+
from .resource_admission import canonical_holder_digest
|
|
45
|
+
from .semantics import integration_intent_abandoned
|
|
46
|
+
from .store import MetadataStore, now_iso
|
|
47
|
+
from .writer_lease import describe_holder, holder_is_live, resolve_holder
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _crash_point(name: str) -> None:
|
|
51
|
+
"""The shared fault-injection hook, so a retirement crash is reproducible."""
|
|
52
|
+
|
|
53
|
+
from .lifecycle import _crash_point as hook
|
|
54
|
+
|
|
55
|
+
hook(name)
|
|
56
|
+
|
|
57
|
+
PLAN_SCHEMA_VERSION = 1
|
|
58
|
+
RECEIPT_SCHEMA_VERSION = 1
|
|
59
|
+
AUTHORIZATION_TTL_DEFAULT_SECONDS = 900
|
|
60
|
+
AUTHORIZATION_TTL_MAX_SECONDS = 3600
|
|
61
|
+
AUTHORIZED_RECEIPT_SCHEMA_VERSION = RECEIPT_SCHEMA_VERSION + 1
|
|
62
|
+
RECEIPT_PROVENANCE_AUTHORIZED = "AUTHORIZED_EXECUTION"
|
|
63
|
+
RECEIPT_PROVENANCE_OBSERVED = "CONTROLLER_OBSERVED"
|
|
64
|
+
RECEIPT_PROVENANCE_CLAIM = "CONSUMER_CLAIM"
|
|
65
|
+
OPERATIONS = frozenset({"local", "remote"})
|
|
66
|
+
REMOTE_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
67
|
+
PROTECTED_BRANCHES = frozenset({"main", "master", "trunk", "production"})
|
|
68
|
+
SPECIAL_PREFIXES = ("rescue/", "archive/", "recovery/")
|
|
69
|
+
TERMINAL_INTEGRATION_STATES = frozenset({"RELEASED"})
|
|
70
|
+
TERMINAL_RELEASE_STATES = frozenset({"RELEASED", "CANCELLED"})
|
|
71
|
+
|
|
72
|
+
# Only authority-bearing Controller state participates. Branch retirement plans and
|
|
73
|
+
# receipts are deliberately excluded so local and remote completion can be recorded
|
|
74
|
+
# independently without invalidating the other half of the same plan.
|
|
75
|
+
AUTHORITY_DIRECTORIES = (
|
|
76
|
+
"records",
|
|
77
|
+
"bindings",
|
|
78
|
+
"integration-intents",
|
|
79
|
+
"release-intents",
|
|
80
|
+
"release-plans",
|
|
81
|
+
"release-receipts",
|
|
82
|
+
"publication-receipts",
|
|
83
|
+
"reconciliation-receipts",
|
|
84
|
+
"writer-leases",
|
|
85
|
+
"transactions",
|
|
86
|
+
)
|
|
87
|
+
AUTHORITY_FILES = ("publication-lease.json",)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sha256_json(value: Mapping[str, Any]) -> str:
|
|
91
|
+
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
92
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _plan_identity(plan: Mapping[str, Any]) -> dict[str, Any]:
|
|
96
|
+
"""Return the immutable/OID-bound identity of a retirement decision.
|
|
97
|
+
|
|
98
|
+
Local and remote refs are expected to move from PRESENT_EXPECTED to ABSENT as
|
|
99
|
+
each operation completes, so observed states cannot be part of the plan id.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
fields = (
|
|
103
|
+
"schema_version",
|
|
104
|
+
"repository_id",
|
|
105
|
+
"allocation_id",
|
|
106
|
+
"release_receipt_id",
|
|
107
|
+
"branch",
|
|
108
|
+
"expected_oid",
|
|
109
|
+
"remote",
|
|
110
|
+
"remote_ref",
|
|
111
|
+
"remote_tracking_ref",
|
|
112
|
+
"integrated_into",
|
|
113
|
+
"integrated_ref",
|
|
114
|
+
"expected_integrated_oid",
|
|
115
|
+
"classification",
|
|
116
|
+
"registration",
|
|
117
|
+
"controller_state_digest",
|
|
118
|
+
)
|
|
119
|
+
identity = {field: plan.get(field) for field in fields}
|
|
120
|
+
if "canonical_transition_evidence" in plan:
|
|
121
|
+
identity["canonical_transition_evidence"] = plan["canonical_transition_evidence"]
|
|
122
|
+
return identity
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _authority_relevant(
|
|
126
|
+
directory: str, value: Mapping[str, Any], *, allocation_id: str, branch: str
|
|
127
|
+
) -> bool:
|
|
128
|
+
if value.get("allocation_id") == allocation_id:
|
|
129
|
+
return True
|
|
130
|
+
if directory == "bindings":
|
|
131
|
+
return value.get("allocation_id") == allocation_id
|
|
132
|
+
if directory == "integration-intents":
|
|
133
|
+
return (
|
|
134
|
+
allocation_id in value.get("source_allocations", [])
|
|
135
|
+
or branch in value.get("source_branches", [])
|
|
136
|
+
)
|
|
137
|
+
if directory == "release-intents":
|
|
138
|
+
return value.get("integration_allocation_id") == allocation_id
|
|
139
|
+
if directory in {"release-plans", "release-receipts"}:
|
|
140
|
+
return value.get("branch") == branch
|
|
141
|
+
if directory == "publication-receipts":
|
|
142
|
+
return any(
|
|
143
|
+
isinstance(item, dict) and item.get("source_allocation_id") == allocation_id
|
|
144
|
+
for item in value.get("source_results", [])
|
|
145
|
+
)
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _state_digest(
|
|
150
|
+
store: MetadataStore, canonical_path: Path, *, allocation_id: str, branch: str
|
|
151
|
+
) -> str:
|
|
152
|
+
digest = hashlib.sha256()
|
|
153
|
+
for directory_name in AUTHORITY_DIRECTORIES:
|
|
154
|
+
directory = store.root / directory_name
|
|
155
|
+
if not directory.is_dir():
|
|
156
|
+
continue
|
|
157
|
+
for path in sorted(directory.rglob("*.json")):
|
|
158
|
+
value = store._read_json(path)
|
|
159
|
+
if not _authority_relevant(
|
|
160
|
+
directory_name, value, allocation_id=allocation_id, branch=branch
|
|
161
|
+
):
|
|
162
|
+
continue
|
|
163
|
+
relative = path.relative_to(store.root).as_posix().encode("utf-8")
|
|
164
|
+
digest.update(relative)
|
|
165
|
+
digest.update(b"\0")
|
|
166
|
+
digest.update(path.read_bytes())
|
|
167
|
+
digest.update(b"\0")
|
|
168
|
+
for file_name in AUTHORITY_FILES:
|
|
169
|
+
path = store.root / file_name
|
|
170
|
+
if path.is_file():
|
|
171
|
+
value = store._read_json(path)
|
|
172
|
+
if value.get("allocation_id") != allocation_id:
|
|
173
|
+
continue
|
|
174
|
+
digest.update(file_name.encode("utf-8"))
|
|
175
|
+
digest.update(b"\0")
|
|
176
|
+
digest.update(path.read_bytes())
|
|
177
|
+
digest.update(b"\0")
|
|
178
|
+
policy = canonical_path / ".agents" / "worktree-policy.toml"
|
|
179
|
+
digest.update(b"policy\0")
|
|
180
|
+
if policy.is_file():
|
|
181
|
+
digest.update(policy.read_bytes())
|
|
182
|
+
else:
|
|
183
|
+
digest.update(b"<default>")
|
|
184
|
+
return digest.hexdigest()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _normalize_branch(repo: Path, branch: str) -> str:
|
|
188
|
+
value = branch.removeprefix("refs/heads/")
|
|
189
|
+
if not value or value.startswith("-") or "\x00" in value:
|
|
190
|
+
raise ControllerError("INVALID_BRANCH", "branch name is empty or unsafe")
|
|
191
|
+
result = _git(repo, "check-ref-format", "--branch", value, check=False)
|
|
192
|
+
if result.returncode != 0:
|
|
193
|
+
raise ControllerError("INVALID_BRANCH", f"invalid branch name: {branch}")
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _normalize_integration_ref(remote: str, value: str) -> str:
|
|
198
|
+
if value.startswith("refs/"):
|
|
199
|
+
if not value.startswith(("refs/heads/", "refs/remotes/")):
|
|
200
|
+
raise ControllerError(
|
|
201
|
+
"INVALID_INTEGRATION_REF", "integrated_into must name a branch ref"
|
|
202
|
+
)
|
|
203
|
+
return value
|
|
204
|
+
prefix = f"{remote}/"
|
|
205
|
+
branch = value.removeprefix(prefix) if value.startswith(prefix) else value
|
|
206
|
+
if not branch or branch.startswith("-"):
|
|
207
|
+
raise ControllerError("INVALID_INTEGRATION_REF", "integrated_into is unsafe")
|
|
208
|
+
return f"refs/remotes/{remote}/{branch}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ref_state(refs: Mapping[str, str], ref: str, expected_oid: str) -> str:
|
|
212
|
+
observed = refs.get(ref)
|
|
213
|
+
if observed is None:
|
|
214
|
+
return "ABSENT"
|
|
215
|
+
return "PRESENT_EXPECTED" if observed == expected_oid else "PRESENT_DRIFTED"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_special_lifecycle_branch(branch: str) -> bool:
|
|
219
|
+
return branch in {"rescue", "archive", "recovery"} or branch.startswith(SPECIAL_PREFIXES)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _upstream_state(repo: Path, branch: str, expected: str) -> tuple[str, str | None]:
|
|
223
|
+
output = _text(
|
|
224
|
+
repo,
|
|
225
|
+
"for-each-ref",
|
|
226
|
+
"--format=%(refname)%00%(upstream)",
|
|
227
|
+
f"refs/heads/{branch}",
|
|
228
|
+
)
|
|
229
|
+
if not output:
|
|
230
|
+
return "ABSENT_LOCAL", None
|
|
231
|
+
try:
|
|
232
|
+
ref_name, upstream = output.split("\0", 1)
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
raise ControllerError("GIT_PARSE_ERROR", "cannot parse branch upstream") from exc
|
|
235
|
+
if ref_name != f"refs/heads/{branch}":
|
|
236
|
+
raise ControllerError("GIT_PARSE_ERROR", "unexpected local branch result")
|
|
237
|
+
if not upstream:
|
|
238
|
+
return "NONE", None
|
|
239
|
+
if upstream == expected:
|
|
240
|
+
return "EXPECTED", upstream
|
|
241
|
+
return "OTHER", upstream
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _classify_integration(repo: Path, expected_oid: str, integrated_oid: str) -> str:
|
|
245
|
+
if is_ancestor(repo, expected_oid, integrated_oid):
|
|
246
|
+
return "ANCESTRY"
|
|
247
|
+
non_merges = _text(repo, "rev-list", "--no-merges", f"{integrated_oid}..{expected_oid}")
|
|
248
|
+
merges = _text(repo, "rev-list", "--merges", f"{integrated_oid}..{expected_oid}")
|
|
249
|
+
if not non_merges:
|
|
250
|
+
return "MERGE_ONLY" if merges else "UNIQUE"
|
|
251
|
+
cherry = _text(repo, "cherry", integrated_oid, expected_oid)
|
|
252
|
+
signs = {line[:1] for line in cherry.splitlines() if line}
|
|
253
|
+
if signs == {"-"}:
|
|
254
|
+
return "CHERRY_EQUIVALENT"
|
|
255
|
+
if signs == {"+"}:
|
|
256
|
+
return "UNIQUE"
|
|
257
|
+
return "MIXED"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _transactions_for_allocation(store: MetadataStore, allocation_id: str) -> tuple[str, ...]:
|
|
261
|
+
# Same pending/terminal semantics as doctor and mutation admission: a
|
|
262
|
+
# journal closed by terminal evidence must not block retirement.
|
|
263
|
+
from .transaction_scope import pending
|
|
264
|
+
|
|
265
|
+
return tuple(
|
|
266
|
+
str(row["transaction_id"])
|
|
267
|
+
for row in pending(store, relevant=lambda row: row.get("allocation_id") == allocation_id)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _release_identity(snapshot: Any, allocation_id: str | None, branch: str | None) -> tuple[Any, str]:
|
|
272
|
+
receipts = list(snapshot.release_receipts.values())
|
|
273
|
+
if allocation_id is not None:
|
|
274
|
+
matches = [item for item in receipts if item.allocation_id == allocation_id]
|
|
275
|
+
if not matches:
|
|
276
|
+
return None, "NO_RELEASE_RECEIPT"
|
|
277
|
+
else:
|
|
278
|
+
matches = [item for item in receipts if item.branch == branch]
|
|
279
|
+
if not matches:
|
|
280
|
+
return None, "HISTORICAL_UNREGISTERED"
|
|
281
|
+
identities = {(item.allocation_id, item.branch, item.released_head) for item in matches}
|
|
282
|
+
if len(identities) != 1:
|
|
283
|
+
return None, "AMBIGUOUS_RELEASE_HISTORY"
|
|
284
|
+
return max(matches, key=lambda item: (item.released_at, item.receipt_id)), "RELEASE_BACKED"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _canonical_transition_identity(
|
|
288
|
+
repository: RepositoryInventory,
|
|
289
|
+
snapshot: MetadataSnapshot,
|
|
290
|
+
*,
|
|
291
|
+
allocation_id: str | None,
|
|
292
|
+
branch: str,
|
|
293
|
+
) -> tuple[MetadataRecord | None, dict[str, Any] | None, str]:
|
|
294
|
+
canonical = next(item for item in repository.worktrees if item.canonical)
|
|
295
|
+
record = snapshot.records.get(snapshot.bindings.get(canonical.key, ""))
|
|
296
|
+
if record is None or (allocation_id is not None and record.allocation_id != allocation_id):
|
|
297
|
+
return None, None, "HISTORICAL_UNREGISTERED"
|
|
298
|
+
events = [
|
|
299
|
+
event
|
|
300
|
+
for event in record.history
|
|
301
|
+
if event.get("event") == "CANONICAL_TRANSITIONED" and event.get("old_branch") == branch
|
|
302
|
+
]
|
|
303
|
+
if not events:
|
|
304
|
+
return None, None, "HISTORICAL_UNREGISTERED"
|
|
305
|
+
if len(events) != 1:
|
|
306
|
+
return record, None, "CANONICAL_TRANSITION_EVIDENCE_AMBIGUOUS"
|
|
307
|
+
event = events[0]
|
|
308
|
+
identities = ("old_owner", "old_task_key", "old_authority_key", "old_finalizer_record_id", "owner")
|
|
309
|
+
valid = (
|
|
310
|
+
event.get("from") == "HANDOFF_READY"
|
|
311
|
+
and event.get("to") == "ACTIVE"
|
|
312
|
+
and all(isinstance(event.get(key), str) and event[key].strip() for key in identities)
|
|
313
|
+
and all(
|
|
314
|
+
isinstance(event.get(key), str) and re.fullmatch(r"[0-9a-f]{40}", event[key])
|
|
315
|
+
for key in ("old_head", "main_before", "main_after")
|
|
316
|
+
)
|
|
317
|
+
and isinstance(event.get("old_scope_paths"), list)
|
|
318
|
+
and all(isinstance(path, str) for path in event["old_scope_paths"])
|
|
319
|
+
and isinstance(event.get("remote"), str)
|
|
320
|
+
and REMOTE_PATTERN.fullmatch(event["remote"]) is not None
|
|
321
|
+
)
|
|
322
|
+
try:
|
|
323
|
+
timestamp = datetime.fromisoformat(event.get("at", ""))
|
|
324
|
+
valid = valid and timestamp.tzinfo is not None
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
valid = False
|
|
327
|
+
if not valid:
|
|
328
|
+
return record, None, "CANONICAL_TRANSITION_EVIDENCE_INVALID"
|
|
329
|
+
return record, {
|
|
330
|
+
"event_sha256": _sha256_json(event),
|
|
331
|
+
"old_head": event["old_head"],
|
|
332
|
+
"main_before": event["main_before"],
|
|
333
|
+
"main_after": event["main_after"],
|
|
334
|
+
"remote": event["remote"],
|
|
335
|
+
"old_owner": event["old_owner"],
|
|
336
|
+
"old_finalizer_record_id": event["old_finalizer_record_id"],
|
|
337
|
+
}, "CANONICAL_TRANSITION_BACKED"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _integration_publication_verified(
|
|
341
|
+
snapshot: MetadataSnapshot,
|
|
342
|
+
record: MetadataRecord,
|
|
343
|
+
release_receipt: ReleaseReceipt,
|
|
344
|
+
*,
|
|
345
|
+
target_ref: str,
|
|
346
|
+
) -> bool:
|
|
347
|
+
plan = snapshot.release_plans.get(release_receipt.plan_id)
|
|
348
|
+
if plan is None:
|
|
349
|
+
return False
|
|
350
|
+
if plan.publication_receipt_id is not None:
|
|
351
|
+
publication = snapshot.publication_receipts.get(plan.publication_receipt_id)
|
|
352
|
+
elif plan.trigger == "owner-release":
|
|
353
|
+
# Owner release preserves the original plan. Its typed acknowledgement
|
|
354
|
+
# can join the exact publication without rewriting either receipt.
|
|
355
|
+
publications = [
|
|
356
|
+
publication
|
|
357
|
+
for reconciliation in snapshot.reconciliation_receipts.values()
|
|
358
|
+
if reconciliation.repository_id == snapshot.repository_id
|
|
359
|
+
and reconciliation.allocation_id == record.allocation_id
|
|
360
|
+
and reconciliation.observed_record_version
|
|
361
|
+
== release_receipt.released_record_version
|
|
362
|
+
== record.record_version
|
|
363
|
+
and reconciliation.observed_record_lifecycle == "RELEASED"
|
|
364
|
+
and reconciliation.observed_receipt_status == "REMOTE_VERIFIED"
|
|
365
|
+
and reconciliation.result == "RELEASE_STATE_ACKNOWLEDGED"
|
|
366
|
+
and reconciliation.recovered_intent_state == "RELEASED"
|
|
367
|
+
and (publication := snapshot.publication_receipts.get(reconciliation.source_receipt_id))
|
|
368
|
+
is not None
|
|
369
|
+
and publication.intent_id is not None
|
|
370
|
+
and reconciliation.recovered_intent_id == publication.intent_id
|
|
371
|
+
and publication.lease_released_at is not None
|
|
372
|
+
]
|
|
373
|
+
if len(publications) != 1:
|
|
374
|
+
return False
|
|
375
|
+
publication = publications[0]
|
|
376
|
+
else:
|
|
377
|
+
return False
|
|
378
|
+
return bool(
|
|
379
|
+
publication is not None
|
|
380
|
+
and plan.repository_id == release_receipt.repository_id == snapshot.repository_id
|
|
381
|
+
and plan.allocation_id == release_receipt.allocation_id == record.allocation_id
|
|
382
|
+
and plan.branch == release_receipt.branch
|
|
383
|
+
and plan.target_path == release_receipt.target_path
|
|
384
|
+
and plan.expected_head == release_receipt.released_head
|
|
385
|
+
and plan.expected_record_version == release_receipt.expected_record_version
|
|
386
|
+
and publication.repository_id == snapshot.repository_id
|
|
387
|
+
and publication.allocation_id == record.allocation_id
|
|
388
|
+
and publication.candidate_oid == release_receipt.released_head
|
|
389
|
+
and publication.finalizer_record_id == record.finalizer_record_id
|
|
390
|
+
and publication.target_ref == target_ref
|
|
391
|
+
and publication.status in {"REMOTE_VERIFIED", "RELEASED"}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _release_intent_resolved(
|
|
396
|
+
snapshot: MetadataSnapshot,
|
|
397
|
+
record: MetadataRecord | None,
|
|
398
|
+
receipt: ReleaseReceipt,
|
|
399
|
+
intent: ReleaseIntent,
|
|
400
|
+
) -> bool:
|
|
401
|
+
if intent.status in TERMINAL_RELEASE_STATES:
|
|
402
|
+
return True
|
|
403
|
+
plan = snapshot.release_plans.get(receipt.plan_id)
|
|
404
|
+
if record is None or plan is None:
|
|
405
|
+
return False
|
|
406
|
+
# An owner release need not name a pending intent. Match its completed
|
|
407
|
+
# receipt to the exact frozen authority instead of rewriting old history.
|
|
408
|
+
return bool(
|
|
409
|
+
receipt.result == "RELEASED"
|
|
410
|
+
and record.lifecycle == "RELEASED"
|
|
411
|
+
and plan.lifecycle == "HANDOFF_READY"
|
|
412
|
+
and record.role in {"feature", "audit"}
|
|
413
|
+
and plan.repository_id
|
|
414
|
+
== receipt.repository_id
|
|
415
|
+
== intent.repository_id
|
|
416
|
+
== snapshot.repository_id
|
|
417
|
+
and plan.allocation_id
|
|
418
|
+
== receipt.allocation_id
|
|
419
|
+
== intent.allocation_id
|
|
420
|
+
== record.allocation_id
|
|
421
|
+
and plan.branch == receipt.branch
|
|
422
|
+
and plan.target_path == receipt.target_path
|
|
423
|
+
and plan.expected_head == receipt.released_head == intent.expected_head
|
|
424
|
+
and plan.expected_record_version
|
|
425
|
+
== receipt.expected_record_version
|
|
426
|
+
== intent.expected_record_version
|
|
427
|
+
and receipt.released_record_version == record.record_version
|
|
428
|
+
and receipt.released_record_version == receipt.expected_record_version + 1
|
|
429
|
+
and plan.owner == intent.owner
|
|
430
|
+
and plan.authority_key == intent.authority_key == record.authority_key
|
|
431
|
+
and plan.role == intent.role == record.role
|
|
432
|
+
and intent.task_key == record.task_key
|
|
433
|
+
and intent.task_commit == record.task_commit
|
|
434
|
+
and intent.handoff_commit == record.handoff_commit == receipt.released_head
|
|
435
|
+
and intent.finalizer_commit == record.finalizer_commit == receipt.released_head
|
|
436
|
+
and intent.finalizer_record_id == record.finalizer_record_id
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _evaluate(
|
|
441
|
+
repository_path: Path,
|
|
442
|
+
*,
|
|
443
|
+
allocation_id: str | None,
|
|
444
|
+
branch: str | None,
|
|
445
|
+
remote: str,
|
|
446
|
+
integrated_into: str,
|
|
447
|
+
plan_id: str | None = None,
|
|
448
|
+
_locked_store: MetadataStore | None = None,
|
|
449
|
+
) -> tuple[dict[str, Any], MetadataStore]:
|
|
450
|
+
repository = inventory(repository_path)
|
|
451
|
+
# A locked caller must hand over its serialized instance; a fresh store
|
|
452
|
+
# would re-open (or wrongly refuse) the flock the caller already holds.
|
|
453
|
+
store = _locked_store or MetadataStore(repository.common_dir)
|
|
454
|
+
snapshot = store._load_unlocked()
|
|
455
|
+
if snapshot.repository_id is None:
|
|
456
|
+
raise ControllerError("UNMANAGED_REPOSITORY", "Controller repository metadata is missing")
|
|
457
|
+
if REMOTE_PATTERN.fullmatch(remote) is None:
|
|
458
|
+
raise ControllerError("INVALID_REMOTE", f"remote name is unsafe: {remote}")
|
|
459
|
+
if _git(repository.canonical_path, "remote", "get-url", remote, check=False).returncode != 0:
|
|
460
|
+
raise ControllerError("INVALID_REMOTE", f"remote is not configured: {remote}")
|
|
461
|
+
|
|
462
|
+
normalized_requested_branch = (
|
|
463
|
+
_normalize_branch(repository.canonical_path, branch) if branch is not None else None
|
|
464
|
+
)
|
|
465
|
+
release_receipt, registration = _release_identity(
|
|
466
|
+
snapshot, allocation_id, normalized_requested_branch
|
|
467
|
+
)
|
|
468
|
+
transition_record = None
|
|
469
|
+
transition_evidence = None
|
|
470
|
+
if (
|
|
471
|
+
registration in {"HISTORICAL_UNREGISTERED", "NO_RELEASE_RECEIPT"}
|
|
472
|
+
and normalized_requested_branch is not None
|
|
473
|
+
):
|
|
474
|
+
transition_record, transition_evidence, registration = _canonical_transition_identity(
|
|
475
|
+
repository, snapshot, allocation_id=allocation_id, branch=normalized_requested_branch
|
|
476
|
+
)
|
|
477
|
+
if release_receipt is not None:
|
|
478
|
+
normalized_branch = _normalize_branch(repository.canonical_path, release_receipt.branch)
|
|
479
|
+
resolved_allocation = release_receipt.allocation_id
|
|
480
|
+
expected_oid = release_receipt.released_head
|
|
481
|
+
release_receipt_id = release_receipt.receipt_id
|
|
482
|
+
elif transition_evidence is not None and transition_record is not None:
|
|
483
|
+
normalized_branch = str(normalized_requested_branch)
|
|
484
|
+
resolved_allocation = transition_record.allocation_id
|
|
485
|
+
expected_oid = transition_evidence["old_head"]
|
|
486
|
+
release_receipt_id = None
|
|
487
|
+
else:
|
|
488
|
+
if normalized_requested_branch is None:
|
|
489
|
+
normalized_branch = "<unknown>"
|
|
490
|
+
expected_oid = ""
|
|
491
|
+
else:
|
|
492
|
+
normalized_branch = normalized_requested_branch
|
|
493
|
+
ref = f"refs/heads/{normalized_branch}"
|
|
494
|
+
expected_oid = public_refs(repository.canonical_path).get(ref, "")
|
|
495
|
+
resolved_allocation = allocation_id
|
|
496
|
+
release_receipt_id = None
|
|
497
|
+
|
|
498
|
+
policy = load_policy(repository.canonical_path)
|
|
499
|
+
integrated_ref = _normalize_integration_ref(remote, integrated_into)
|
|
500
|
+
refs = public_refs(repository.canonical_path)
|
|
501
|
+
integrated_oid = refs.get(integrated_ref)
|
|
502
|
+
blockers: list[str] = []
|
|
503
|
+
if registration not in {"RELEASE_BACKED", "CANONICAL_TRANSITION_BACKED"}:
|
|
504
|
+
blockers.append(registration)
|
|
505
|
+
if transition_evidence is not None:
|
|
506
|
+
if (
|
|
507
|
+
remote != policy.publication_remote
|
|
508
|
+
or transition_evidence["remote"] != remote
|
|
509
|
+
or integrated_ref != _normalize_integration_ref(remote, policy.publication_branch)
|
|
510
|
+
):
|
|
511
|
+
blockers.append("CANONICAL_TRANSITION_TARGET_MISMATCH")
|
|
512
|
+
try:
|
|
513
|
+
transition_head = transition_evidence["main_after"]
|
|
514
|
+
if (
|
|
515
|
+
integrated_oid is None
|
|
516
|
+
or not is_ancestor(repository.canonical_path, transition_evidence["old_head"], transition_head)
|
|
517
|
+
or not is_ancestor(repository.canonical_path, transition_evidence["main_before"], transition_head)
|
|
518
|
+
or not is_ancestor(repository.canonical_path, transition_head, integrated_oid)
|
|
519
|
+
):
|
|
520
|
+
blockers.append("CANONICAL_TRANSITION_PUBLICATION_MISMATCH")
|
|
521
|
+
except ControllerError:
|
|
522
|
+
blockers.append("CANONICAL_TRANSITION_PUBLICATION_MISMATCH")
|
|
523
|
+
if normalized_branch in PROTECTED_BRANCHES or normalized_branch == policy.publication_branch:
|
|
524
|
+
blockers.append("PROTECTED_BRANCH")
|
|
525
|
+
if _is_special_lifecycle_branch(normalized_branch):
|
|
526
|
+
blockers.append("SPECIAL_LIFECYCLE_REF")
|
|
527
|
+
if integrated_oid is None:
|
|
528
|
+
blockers.append("INTEGRATION_REF_MISSING")
|
|
529
|
+
if not expected_oid:
|
|
530
|
+
blockers.append("EXPECTED_OID_MISSING")
|
|
531
|
+
classification = "HISTORICAL_UNREGISTERED"
|
|
532
|
+
elif integrated_oid is None:
|
|
533
|
+
resolve_commit(repository.canonical_path, expected_oid)
|
|
534
|
+
classification = "UNVERIFIED"
|
|
535
|
+
else:
|
|
536
|
+
resolve_commit(repository.canonical_path, expected_oid)
|
|
537
|
+
resolve_commit(repository.canonical_path, integrated_oid)
|
|
538
|
+
classification = _classify_integration(
|
|
539
|
+
repository.canonical_path, expected_oid, integrated_oid
|
|
540
|
+
)
|
|
541
|
+
if classification != "ANCESTRY":
|
|
542
|
+
blockers.append(f"CLASSIFICATION_{classification}")
|
|
543
|
+
|
|
544
|
+
local_ref = f"refs/heads/{normalized_branch}"
|
|
545
|
+
tracking_ref = f"refs/remotes/{remote}/{normalized_branch}"
|
|
546
|
+
local_state = _ref_state(refs, local_ref, expected_oid) if expected_oid else "UNKNOWN"
|
|
547
|
+
tracking_state = _ref_state(refs, tracking_ref, expected_oid) if expected_oid else "UNKNOWN"
|
|
548
|
+
if local_state == "PRESENT_DRIFTED":
|
|
549
|
+
blockers.append("LOCAL_OID_DRIFT")
|
|
550
|
+
if tracking_state == "PRESENT_DRIFTED":
|
|
551
|
+
blockers.append("UPSTREAM_OID_DRIFT")
|
|
552
|
+
upstream_state, upstream_ref = _upstream_state(
|
|
553
|
+
repository.canonical_path, normalized_branch, tracking_ref
|
|
554
|
+
)
|
|
555
|
+
if upstream_state == "OTHER":
|
|
556
|
+
blockers.append("UPSTREAM_TARGET_MISMATCH")
|
|
557
|
+
|
|
558
|
+
checked_out = sorted(
|
|
559
|
+
str(item.path) for item in repository.worktrees if item.branch == normalized_branch
|
|
560
|
+
)
|
|
561
|
+
if checked_out:
|
|
562
|
+
blockers.append("BRANCH_CHECKED_OUT")
|
|
563
|
+
|
|
564
|
+
record = snapshot.records.get(resolved_allocation or "")
|
|
565
|
+
if release_receipt is not None or transition_evidence is not None:
|
|
566
|
+
if record is None:
|
|
567
|
+
blockers.append("ALLOCATION_RECORD_MISSING")
|
|
568
|
+
else:
|
|
569
|
+
if transition_evidence is not None:
|
|
570
|
+
if (
|
|
571
|
+
record.role not in {"canonical", "control-plane"}
|
|
572
|
+
or record.lifecycle not in {"ACTIVE", "HANDOFF_READY"}
|
|
573
|
+
):
|
|
574
|
+
blockers.append("CANONICAL_TRANSITION_ALLOCATION_INVALID")
|
|
575
|
+
else:
|
|
576
|
+
if record.lifecycle != "RELEASED":
|
|
577
|
+
blockers.append("ALLOCATION_NOT_RELEASED")
|
|
578
|
+
if record.role == "integration":
|
|
579
|
+
if remote != policy.publication_remote or not _integration_publication_verified(
|
|
580
|
+
snapshot,
|
|
581
|
+
record,
|
|
582
|
+
release_receipt,
|
|
583
|
+
target_ref=f"refs/heads/{policy.publication_branch}",
|
|
584
|
+
):
|
|
585
|
+
blockers.append("INTEGRATION_PUBLICATION_NOT_VERIFIED")
|
|
586
|
+
elif record.role not in {"feature", "audit"}:
|
|
587
|
+
blockers.append("UNSUPPORTED_ALLOCATION_ROLE")
|
|
588
|
+
if record.external_bindings:
|
|
589
|
+
blockers.append("RUNTIME_BINDING_ACTIVE")
|
|
590
|
+
if record.recovery_ref or record.lifecycle in {"QUARANTINED", "RECOVERY_REQUIRED"}:
|
|
591
|
+
blockers.append("RECOVERY_STATE_EXCLUDED")
|
|
592
|
+
canonical_key = next(item.key for item in repository.worktrees if item.canonical)
|
|
593
|
+
if any(
|
|
594
|
+
value == resolved_allocation and (transition_evidence is None or key != canonical_key)
|
|
595
|
+
for key, value in snapshot.bindings.items()
|
|
596
|
+
):
|
|
597
|
+
blockers.append("ALLOCATION_BOUND")
|
|
598
|
+
if resolved_allocation in snapshot.writer_leases:
|
|
599
|
+
blockers.append("WRITER_LEASE_ACTIVE")
|
|
600
|
+
if (
|
|
601
|
+
snapshot.publication_lease is not None
|
|
602
|
+
and snapshot.publication_lease.allocation_id == resolved_allocation
|
|
603
|
+
):
|
|
604
|
+
blockers.append("PUBLICATION_LEASE_ACTIVE")
|
|
605
|
+
if any(
|
|
606
|
+
intent.allocation_id == resolved_allocation
|
|
607
|
+
and (release_receipt is None or not _release_intent_resolved(snapshot, record, release_receipt, intent))
|
|
608
|
+
for intent in snapshot.release_intents.values()
|
|
609
|
+
):
|
|
610
|
+
blockers.append("RELEASE_INTENT_UNRESOLVED")
|
|
611
|
+
if any(
|
|
612
|
+
(intent.allocation_id == resolved_allocation or resolved_allocation in intent.source_allocations)
|
|
613
|
+
and intent.state not in TERMINAL_INTEGRATION_STATES
|
|
614
|
+
and not integration_intent_abandoned(intent)
|
|
615
|
+
for intent in snapshot.integration_intents.values()
|
|
616
|
+
):
|
|
617
|
+
blockers.append("INTEGRATION_INTENT_UNRESOLVED")
|
|
618
|
+
if _transactions_for_allocation(store, resolved_allocation):
|
|
619
|
+
blockers.append("TRANSACTION_ACTIVE")
|
|
620
|
+
|
|
621
|
+
controller_digest = _state_digest(
|
|
622
|
+
store,
|
|
623
|
+
repository.canonical_path,
|
|
624
|
+
allocation_id=resolved_allocation or "",
|
|
625
|
+
branch=normalized_branch,
|
|
626
|
+
)
|
|
627
|
+
eligible = not blockers and classification == "ANCESTRY"
|
|
628
|
+
core: dict[str, Any] = {
|
|
629
|
+
"schema_version": PLAN_SCHEMA_VERSION,
|
|
630
|
+
"repository_id": snapshot.repository_id,
|
|
631
|
+
"allocation_id": resolved_allocation,
|
|
632
|
+
"release_receipt_id": release_receipt_id,
|
|
633
|
+
"branch": normalized_branch,
|
|
634
|
+
"expected_oid": expected_oid or None,
|
|
635
|
+
"remote": remote,
|
|
636
|
+
"remote_ref": f"refs/heads/{normalized_branch}",
|
|
637
|
+
"remote_tracking_ref": tracking_ref,
|
|
638
|
+
"integrated_into": integrated_into,
|
|
639
|
+
"integrated_ref": integrated_ref,
|
|
640
|
+
"expected_integrated_oid": integrated_oid,
|
|
641
|
+
"classification": classification,
|
|
642
|
+
"registration": registration,
|
|
643
|
+
"local_ref_state": local_state,
|
|
644
|
+
"remote_tracking_state": tracking_state,
|
|
645
|
+
"upstream_state": upstream_state,
|
|
646
|
+
"upstream_ref": upstream_ref,
|
|
647
|
+
"checked_out_paths": checked_out,
|
|
648
|
+
"controller_state_digest": controller_digest,
|
|
649
|
+
"eligible": eligible,
|
|
650
|
+
"blockers": sorted(set(blockers)),
|
|
651
|
+
}
|
|
652
|
+
if transition_evidence is not None:
|
|
653
|
+
core["canonical_transition_evidence"] = transition_evidence
|
|
654
|
+
observed_plan_id = _sha256_json(_plan_identity(core))
|
|
655
|
+
if plan_id is not None and observed_plan_id != plan_id:
|
|
656
|
+
core["eligible"] = False
|
|
657
|
+
core["blockers"] = sorted({*core["blockers"], "PLAN_STATE_DRIFT"})
|
|
658
|
+
core["plan_id"] = observed_plan_id
|
|
659
|
+
return core, store
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _finalizer_arguments(
|
|
663
|
+
plan: Mapping[str, Any], operation: str, *, repository_root: Path
|
|
664
|
+
) -> list[str]:
|
|
665
|
+
from .retirement_adapters import arguments
|
|
666
|
+
|
|
667
|
+
return arguments(plan, operation, repository_root=repository_root)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _payload(command: str, decision: Mapping[str, Any]) -> dict[str, Any]:
|
|
671
|
+
from .controller import envelope
|
|
672
|
+
|
|
673
|
+
payload = envelope(command)
|
|
674
|
+
payload["decision"] = dict(decision)
|
|
675
|
+
return payload
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def plan_branch_retirement(
|
|
679
|
+
repository_path: Path,
|
|
680
|
+
*,
|
|
681
|
+
allocation_id: str | None = None,
|
|
682
|
+
branch: str | None = None,
|
|
683
|
+
remote: str,
|
|
684
|
+
integrated_into: str,
|
|
685
|
+
dry_run: bool = False,
|
|
686
|
+
) -> dict[str, Any]:
|
|
687
|
+
if (allocation_id is None) == (branch is None):
|
|
688
|
+
raise ControllerError(
|
|
689
|
+
"INVALID_RETIREMENT_TARGET", "specify exactly one of allocation_id or branch"
|
|
690
|
+
)
|
|
691
|
+
repository = inventory(repository_path)
|
|
692
|
+
store = MetadataStore(repository.common_dir)
|
|
693
|
+
with store.lock(exclusive=not dry_run, create=not dry_run):
|
|
694
|
+
evaluated, _ = _evaluate(
|
|
695
|
+
repository_path,
|
|
696
|
+
allocation_id=allocation_id,
|
|
697
|
+
branch=branch,
|
|
698
|
+
remote=remote,
|
|
699
|
+
integrated_into=integrated_into,
|
|
700
|
+
_locked_store=store,
|
|
701
|
+
)
|
|
702
|
+
persisted = False
|
|
703
|
+
if evaluated["eligible"] and not dry_run:
|
|
704
|
+
persisted_plan = {**evaluated, "created_at": now_iso()}
|
|
705
|
+
path = store.branch_retirement_plans_dir / f"{evaluated['plan_id']}.json"
|
|
706
|
+
if path.exists():
|
|
707
|
+
current = store._read_json(path)
|
|
708
|
+
if _plan_identity(current) != _plan_identity(evaluated):
|
|
709
|
+
raise ControllerError("PLAN_MISMATCH", "retirement plan already differs")
|
|
710
|
+
else:
|
|
711
|
+
store._atomic_json(path, persisted_plan)
|
|
712
|
+
store.touch_repository()
|
|
713
|
+
persisted = True
|
|
714
|
+
decision = {
|
|
715
|
+
"code": (
|
|
716
|
+
"BRANCH_RETIREMENT_READY"
|
|
717
|
+
if evaluated["eligible"]
|
|
718
|
+
else "BRANCH_RETIREMENT_REPORT_ONLY"
|
|
719
|
+
if evaluated["registration"] == "HISTORICAL_UNREGISTERED"
|
|
720
|
+
or str(evaluated["classification"]) in {
|
|
721
|
+
"CHERRY_EQUIVALENT",
|
|
722
|
+
"MERGE_ONLY",
|
|
723
|
+
"UNIQUE",
|
|
724
|
+
"MIXED",
|
|
725
|
+
"HISTORICAL_UNREGISTERED",
|
|
726
|
+
}
|
|
727
|
+
else "BRANCH_RETIREMENT_BLOCKED"
|
|
728
|
+
),
|
|
729
|
+
"persisted": persisted,
|
|
730
|
+
"dry_run": dry_run,
|
|
731
|
+
"plan": evaluated,
|
|
732
|
+
}
|
|
733
|
+
if evaluated["eligible"]:
|
|
734
|
+
decision["execution"] = {
|
|
735
|
+
"contract": execution.RECIPE_CONTRACT,
|
|
736
|
+
"schema_version": execution.RECIPE_SCHEMA_VERSION,
|
|
737
|
+
"grant": "branch-retirement-authorize",
|
|
738
|
+
"settle": "branch-retirement-complete",
|
|
739
|
+
"result_contract": execution.RESULT_CONTRACT,
|
|
740
|
+
"operations": {
|
|
741
|
+
operation: {
|
|
742
|
+
"ref": (
|
|
743
|
+
f"refs/heads/{evaluated['branch']}"
|
|
744
|
+
if operation == "local"
|
|
745
|
+
else str(evaluated["remote_tracking_ref"])
|
|
746
|
+
),
|
|
747
|
+
"mutation": "expected-oid-cas-delete",
|
|
748
|
+
"expected_oid": evaluated["expected_oid"],
|
|
749
|
+
"integrated_ref": evaluated["integrated_ref"],
|
|
750
|
+
"expected_integrated_oid": evaluated["expected_integrated_oid"],
|
|
751
|
+
"tracking_ref": evaluated["remote_tracking_ref"],
|
|
752
|
+
"kind": execution.KIND_CAS_DELETE,
|
|
753
|
+
}
|
|
754
|
+
for operation in ("local", "remote")
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
decision["finalizer"] = {
|
|
758
|
+
"local": _finalizer_arguments(
|
|
759
|
+
evaluated, "local", repository_root=repository.canonical_path
|
|
760
|
+
),
|
|
761
|
+
"remote": _finalizer_arguments(
|
|
762
|
+
evaluated, "remote", repository_root=repository.canonical_path
|
|
763
|
+
),
|
|
764
|
+
}
|
|
765
|
+
return _payload("branch-retirement-plan", decision)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _load_plan(store: MetadataStore, plan_id: str) -> dict[str, Any]:
|
|
769
|
+
if not re.fullmatch(r"[0-9a-f]{64}", plan_id):
|
|
770
|
+
raise ControllerError("INVALID_PLAN_ID", "retirement plan id must be sha256")
|
|
771
|
+
path = store.branch_retirement_plans_dir / f"{plan_id}.json"
|
|
772
|
+
if not path.is_file():
|
|
773
|
+
raise ControllerError("UNKNOWN_RETIREMENT_PLAN", "retirement plan does not exist")
|
|
774
|
+
plan = dict(store._read_json(path))
|
|
775
|
+
if plan.get("schema_version") != PLAN_SCHEMA_VERSION or plan.get("plan_id") != plan_id:
|
|
776
|
+
raise ControllerError("INVALID_METADATA", "retirement plan schema is invalid")
|
|
777
|
+
return plan
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _receipt_path(store: MetadataStore, plan_id: str, operation: str) -> Path:
|
|
781
|
+
return store.branch_retirement_receipts_dir / f"{plan_id}-{operation}.json"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _completed_blockers(
|
|
785
|
+
repository_path: Path,
|
|
786
|
+
plan: Mapping[str, Any],
|
|
787
|
+
current: Mapping[str, Any],
|
|
788
|
+
*,
|
|
789
|
+
operation: str,
|
|
790
|
+
receipt: Mapping[str, Any],
|
|
791
|
+
) -> list[str]:
|
|
792
|
+
"""Revalidate a historical completion without changing its execution plan.
|
|
793
|
+
|
|
794
|
+
Only an integrated-ref fast-forward may differ, and only for an attested
|
|
795
|
+
operation whose ref remains absent. Pending execution still uses exact CAS.
|
|
796
|
+
"""
|
|
797
|
+
|
|
798
|
+
blockers = set(current["blockers"])
|
|
799
|
+
plan_id = str(plan["plan_id"])
|
|
800
|
+
if (
|
|
801
|
+
_sha256_json(_plan_identity(plan)) != plan_id
|
|
802
|
+
or type(receipt.get("schema_version")) is not int
|
|
803
|
+
or receipt.get("schema_version")
|
|
804
|
+
not in {RECEIPT_SCHEMA_VERSION, AUTHORIZED_RECEIPT_SCHEMA_VERSION}
|
|
805
|
+
or receipt.get("receipt_id")
|
|
806
|
+
!= hashlib.sha256(f"{plan_id}:{operation}".encode()).hexdigest()
|
|
807
|
+
or receipt.get("operation") != operation
|
|
808
|
+
or any(
|
|
809
|
+
receipt.get(key) != plan[key]
|
|
810
|
+
for key in ("plan_id", "branch", "expected_oid", "expected_integrated_oid")
|
|
811
|
+
)
|
|
812
|
+
):
|
|
813
|
+
return sorted({*blockers, "RETIREMENT_RECEIPT_MISMATCH"})
|
|
814
|
+
state_key = "local_ref_state" if operation == "local" else "remote_tracking_state"
|
|
815
|
+
if current[state_key] != "ABSENT":
|
|
816
|
+
blockers.add("REF_REAPPEARED_AFTER_RETIREMENT")
|
|
817
|
+
if blockers == {"PLAN_STATE_DRIFT"}:
|
|
818
|
+
original_identity = _plan_identity(current)
|
|
819
|
+
original_identity["expected_integrated_oid"] = plan["expected_integrated_oid"]
|
|
820
|
+
if (
|
|
821
|
+
_sha256_json(original_identity) == plan_id
|
|
822
|
+
and isinstance(current["expected_integrated_oid"], str)
|
|
823
|
+
and is_ancestor(
|
|
824
|
+
repository_path,
|
|
825
|
+
str(plan["expected_integrated_oid"]),
|
|
826
|
+
current["expected_integrated_oid"],
|
|
827
|
+
)
|
|
828
|
+
):
|
|
829
|
+
blockers.clear()
|
|
830
|
+
return sorted(blockers)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def branch_retirement_status(repository_path: Path, *, plan_id: str) -> dict[str, Any]:
|
|
835
|
+
repository = inventory(repository_path)
|
|
836
|
+
store = MetadataStore(repository.common_dir)
|
|
837
|
+
with store.lock(exclusive=False):
|
|
838
|
+
plan = _load_plan(store, plan_id)
|
|
839
|
+
current, _ = _evaluate(
|
|
840
|
+
repository_path,
|
|
841
|
+
allocation_id=str(plan["allocation_id"]),
|
|
842
|
+
branch=str(plan["branch"]),
|
|
843
|
+
remote=str(plan["remote"]),
|
|
844
|
+
integrated_into=str(plan["integrated_into"]),
|
|
845
|
+
plan_id=plan_id,
|
|
846
|
+
_locked_store=store,
|
|
847
|
+
)
|
|
848
|
+
receipts = {
|
|
849
|
+
operation: (
|
|
850
|
+
dict(store._read_json(_receipt_path(store, plan_id, operation)))
|
|
851
|
+
if _receipt_path(store, plan_id, operation).is_file()
|
|
852
|
+
else None
|
|
853
|
+
)
|
|
854
|
+
for operation in ("local", "remote")
|
|
855
|
+
}
|
|
856
|
+
if all(receipt is not None for receipt in receipts.values()):
|
|
857
|
+
blockers = sorted(
|
|
858
|
+
{
|
|
859
|
+
blocker
|
|
860
|
+
for operation, receipt in receipts.items()
|
|
861
|
+
for blocker in _completed_blockers(
|
|
862
|
+
repository.canonical_path,
|
|
863
|
+
plan,
|
|
864
|
+
current,
|
|
865
|
+
operation=operation,
|
|
866
|
+
receipt=receipt,
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
current = {**current, "blockers": blockers, "eligible": not blockers}
|
|
871
|
+
completed = [operation for operation, receipt in receipts.items() if receipt is not None]
|
|
872
|
+
drifted = "PLAN_STATE_DRIFT" in current["blockers"] or not current["eligible"]
|
|
873
|
+
if len(completed) == 2 and not drifted:
|
|
874
|
+
code = "BRANCH_RETIREMENT_COMPLETE"
|
|
875
|
+
elif len(completed) == 2:
|
|
876
|
+
code = "BRANCH_RETIREMENT_DRIFTED"
|
|
877
|
+
elif completed and drifted:
|
|
878
|
+
code = "BRANCH_RETIREMENT_PARTIAL_DRIFTED"
|
|
879
|
+
elif completed:
|
|
880
|
+
code = "BRANCH_RETIREMENT_PARTIAL"
|
|
881
|
+
elif drifted:
|
|
882
|
+
code = "BRANCH_RETIREMENT_DRIFTED"
|
|
883
|
+
else:
|
|
884
|
+
code = "BRANCH_RETIREMENT_READY"
|
|
885
|
+
return _payload(
|
|
886
|
+
"branch-retirement-status",
|
|
887
|
+
{
|
|
888
|
+
"code": code,
|
|
889
|
+
"plan_id": plan_id,
|
|
890
|
+
"completed_operations": completed,
|
|
891
|
+
"pending_operations": [item for item in ("local", "remote") if item not in completed],
|
|
892
|
+
"receipts": receipts,
|
|
893
|
+
"current": current,
|
|
894
|
+
},
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
#: Authority facts that must still hold when a retirement result is filed. Ref-derived
|
|
899
|
+
#: codes (EXPECTED_OID_MISSING, UPSTREAM_*, INTEGRATION_REF_MISSING) are what deleting a
|
|
900
|
+
#: branch legitimately produces, so they are not in this set.
|
|
901
|
+
AUTHORITY_BLOCKERS = frozenset(
|
|
902
|
+
{
|
|
903
|
+
"BRANCH_CHECKED_OUT",
|
|
904
|
+
"WRITER_LEASE_ACTIVE",
|
|
905
|
+
"ALLOCATION_BOUND",
|
|
906
|
+
"RUNTIME_BINDING_ACTIVE",
|
|
907
|
+
"PUBLICATION_LEASE_ACTIVE",
|
|
908
|
+
"RELEASE_INTENT_UNRESOLVED",
|
|
909
|
+
"INTEGRATION_INTENT_UNRESOLVED",
|
|
910
|
+
"TRANSACTION_ACTIVE",
|
|
911
|
+
"RECOVERY_STATE_EXCLUDED",
|
|
912
|
+
"ALLOCATION_NOT_RELEASED",
|
|
913
|
+
"ALLOCATION_RECORD_MISSING",
|
|
914
|
+
"UNSUPPORTED_ALLOCATION_ROLE",
|
|
915
|
+
"INTEGRATION_PUBLICATION_NOT_VERIFIED",
|
|
916
|
+
"CANONICAL_TRANSITION_EVIDENCE_INVALID",
|
|
917
|
+
"CANONICAL_TRANSITION_EVIDENCE_AMBIGUOUS",
|
|
918
|
+
"CANONICAL_TRANSITION_TARGET_MISMATCH",
|
|
919
|
+
"CANONICAL_TRANSITION_PUBLICATION_MISMATCH",
|
|
920
|
+
"CANONICAL_TRANSITION_ALLOCATION_INVALID",
|
|
921
|
+
"PROTECTED_BRANCH",
|
|
922
|
+
"SPECIAL_LIFECYCLE_REF",
|
|
923
|
+
"PLAN_STATE_DRIFT",
|
|
924
|
+
}
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
STALE_BLOCKERS = frozenset(
|
|
928
|
+
{
|
|
929
|
+
"PLAN_STATE_DRIFT",
|
|
930
|
+
"LOCAL_OID_DRIFT",
|
|
931
|
+
"UPSTREAM_OID_DRIFT",
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _stale_prefix(blockers: list[str]) -> bool:
|
|
937
|
+
return any(item.startswith("CLASSIFICATION_") for item in blockers)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _classify(current: Mapping[str, Any], *, operation: str, receipt: object) -> tuple[str, list[str]]:
|
|
941
|
+
"""One classifier for every pre-mutation question, so verbs cannot diverge.
|
|
942
|
+
|
|
943
|
+
``PLAN_STATE_DRIFT`` and the digest-blind ``*_DRIFT`` blockers mean the world the
|
|
944
|
+
plan described is gone, which is a different answer from "something is occupying
|
|
945
|
+
it": a stale plan needs a new plan, a blocked one needs the occupancy removed.
|
|
946
|
+
"""
|
|
947
|
+
|
|
948
|
+
blockers = list(current["blockers"])
|
|
949
|
+
state_key = "local_ref_state" if operation == "local" else "remote_tracking_state"
|
|
950
|
+
ref_state = str(current[state_key])
|
|
951
|
+
if receipt is not None:
|
|
952
|
+
return "RETIREMENT_OPERATION_COMPLETE", blockers
|
|
953
|
+
if ref_state == "ABSENT":
|
|
954
|
+
return "RETIREMENT_BLOCKED", sorted({*blockers, "ABSENT_UNATTESTED"})
|
|
955
|
+
if any(item in STALE_BLOCKERS for item in blockers) or _stale_prefix(blockers):
|
|
956
|
+
return "RETIREMENT_STALE", blockers
|
|
957
|
+
if blockers or not current["eligible"] or ref_state != "PRESENT_EXPECTED":
|
|
958
|
+
return "RETIREMENT_BLOCKED", blockers
|
|
959
|
+
return "RETIREMENT_EXECUTABLE", blockers
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _observation(repository: RepositoryInventory, current: Mapping[str, Any]) -> dict[str, Any]:
|
|
963
|
+
"""The live Git facts the state digest provably cannot see.
|
|
964
|
+
|
|
965
|
+
``controller_state_digest`` covers Controller authority and policy bytes; refs,
|
|
966
|
+
worktree occupancy and upstream configuration are Git state, reachable by any
|
|
967
|
+
plain ``git`` invocation without touching a digested file. An authorization
|
|
968
|
+
therefore binds this vector as well, and settle compares it.
|
|
969
|
+
"""
|
|
970
|
+
|
|
971
|
+
refs = public_refs(repository.canonical_path)
|
|
972
|
+
branch = str(current["branch"])
|
|
973
|
+
watched = {f"refs/heads/{branch}": refs.get(f"refs/heads/{branch}")}
|
|
974
|
+
for ref in (current.get("remote_tracking_ref"), current.get("integrated_ref")):
|
|
975
|
+
if isinstance(ref, str) and ref:
|
|
976
|
+
watched[ref] = refs.get(ref)
|
|
977
|
+
return execution.normalize_observation(
|
|
978
|
+
{
|
|
979
|
+
"refs": watched,
|
|
980
|
+
"checked_out_paths": list(current["checked_out_paths"]),
|
|
981
|
+
"upstream_ref": current.get("upstream_ref"),
|
|
982
|
+
"upstream_target": current.get("upstream_state"),
|
|
983
|
+
"classification": current.get("classification"),
|
|
984
|
+
"blockers": list(current["blockers"]),
|
|
985
|
+
}
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _authorization_path(store: MetadataStore, authorization_id: str) -> Path:
|
|
990
|
+
return store.branch_retirement_authorizations_dir / f"{authorization_id}.json"
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def outstanding_retirement_authorizations(
|
|
994
|
+
store: MetadataStore, *, branch: str | None = None, allocation_id: str | None = None
|
|
995
|
+
) -> list[dict[str, Any]]:
|
|
996
|
+
"""Every execution authorization the Controller has granted and not yet closed.
|
|
997
|
+
|
|
998
|
+
Callers under a repository lock only; the directory is small, so a full scan is
|
|
999
|
+
cheaper and safer than a secondary index that could disagree with the files.
|
|
1000
|
+
"""
|
|
1001
|
+
|
|
1002
|
+
from .transaction_scope import is_atomic_temp
|
|
1003
|
+
|
|
1004
|
+
directory = store.branch_retirement_authorizations_dir
|
|
1005
|
+
if not directory.is_dir():
|
|
1006
|
+
return []
|
|
1007
|
+
result: list[dict[str, Any]] = []
|
|
1008
|
+
for path in sorted(directory.glob("*.json")):
|
|
1009
|
+
if is_atomic_temp(path.name) or path.name.endswith("-abandoned.json"):
|
|
1010
|
+
continue
|
|
1011
|
+
try:
|
|
1012
|
+
value = dict(store._read_json(path))
|
|
1013
|
+
except ControllerError:
|
|
1014
|
+
continue
|
|
1015
|
+
if value.get("contract") != execution.AUTHORIZATION_CONTRACT:
|
|
1016
|
+
continue
|
|
1017
|
+
if branch is not None and value.get("branch") != branch:
|
|
1018
|
+
continue
|
|
1019
|
+
if allocation_id is not None and value.get("allocation_id") != allocation_id:
|
|
1020
|
+
continue
|
|
1021
|
+
result.append(value)
|
|
1022
|
+
return result
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def retirement_occupancy_blocker(
|
|
1026
|
+
store: MetadataStore, *, branch: str | None = None, allocation_id: str | None = None
|
|
1027
|
+
) -> ControllerError | None:
|
|
1028
|
+
"""Refuse to re-occupy a branch the Controller has just authorized for deletion.
|
|
1029
|
+
|
|
1030
|
+
This is the fence half of the execution contract: an authorization is a promise
|
|
1031
|
+
that the branch is going away, so handing it to a new checkout or writer lease
|
|
1032
|
+
would make the authorized mutation destroy live work instead of a retired ref.
|
|
1033
|
+
"""
|
|
1034
|
+
|
|
1035
|
+
outstanding = outstanding_retirement_authorizations(
|
|
1036
|
+
store, branch=branch, allocation_id=allocation_id
|
|
1037
|
+
)
|
|
1038
|
+
if not outstanding:
|
|
1039
|
+
return None
|
|
1040
|
+
scope = branch or allocation_id or "this target"
|
|
1041
|
+
return ControllerError(
|
|
1042
|
+
"RETIREMENT_EXECUTION_ACTIVE",
|
|
1043
|
+
f"a retirement execution authorization covers {scope!r} "
|
|
1044
|
+
f"({outstanding[0]['authorization_id']}); complete or abandon it first",
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def authorize_branch_retirement(
|
|
1049
|
+
repository_path: Path,
|
|
1050
|
+
*,
|
|
1051
|
+
plan_id: str,
|
|
1052
|
+
operation: str,
|
|
1053
|
+
request_id: str,
|
|
1054
|
+
holder: str | None = None,
|
|
1055
|
+
client_id: str = "retirement-executor",
|
|
1056
|
+
session_id: str | None = None,
|
|
1057
|
+
run_id: str | None = None,
|
|
1058
|
+
kind: str = execution.KIND_CAS_DELETE,
|
|
1059
|
+
ttl_seconds: int = AUTHORIZATION_TTL_DEFAULT_SECONDS,
|
|
1060
|
+
) -> dict[str, Any]:
|
|
1061
|
+
"""Grant one single-use execution authorization for one operation of one plan.
|
|
1062
|
+
|
|
1063
|
+
Validation happens here, under the exclusive repository lock, and the grant is
|
|
1064
|
+
durable: the consumer's next step is the Git compare-and-delete, and the
|
|
1065
|
+
Controller's final check is ``branch-retirement-complete``, which re-observes
|
|
1066
|
+
the world itself rather than believing the consumer's account of it. A caller
|
|
1067
|
+
never asserts "I hold the lock" - WC takes the lock, and the token it returns
|
|
1068
|
+
is the only capability the grant carries.
|
|
1069
|
+
"""
|
|
1070
|
+
|
|
1071
|
+
if operation not in OPERATIONS:
|
|
1072
|
+
raise ControllerError("INVALID_OPERATION", "operation must be local or remote")
|
|
1073
|
+
if kind != execution.KIND_CAS_DELETE:
|
|
1074
|
+
raise ControllerError(
|
|
1075
|
+
"INVALID_EXECUTION_KIND",
|
|
1076
|
+
f"{kind!r} is a reported outcome kind, not a grantable one; only CAS_DELETE is authorized",
|
|
1077
|
+
)
|
|
1078
|
+
if ttl_seconds <= 0 or ttl_seconds > AUTHORIZATION_TTL_MAX_SECONDS:
|
|
1079
|
+
raise ControllerError(
|
|
1080
|
+
"INVALID_AUTHORIZATION_TTL", f"ttl must be between 1 and {AUTHORIZATION_TTL_MAX_SECONDS} seconds"
|
|
1081
|
+
)
|
|
1082
|
+
try:
|
|
1083
|
+
identity = UUID(request_id)
|
|
1084
|
+
except ValueError as exc:
|
|
1085
|
+
raise ControllerError("INVALID_REQUEST_ID", "request id must be a UUID") from exc
|
|
1086
|
+
if str(identity) != request_id:
|
|
1087
|
+
raise ControllerError("INVALID_REQUEST_ID", "request id must be a canonical UUID")
|
|
1088
|
+
|
|
1089
|
+
repository = inventory(repository_path)
|
|
1090
|
+
store = MetadataStore(repository.common_dir)
|
|
1091
|
+
with store.lock(exclusive=True, create=False):
|
|
1092
|
+
plan = _load_plan(store, plan_id)
|
|
1093
|
+
current, _ = _evaluate(
|
|
1094
|
+
repository_path,
|
|
1095
|
+
allocation_id=str(plan["allocation_id"]),
|
|
1096
|
+
branch=str(plan["branch"]),
|
|
1097
|
+
remote=str(plan["remote"]),
|
|
1098
|
+
integrated_into=str(plan["integrated_into"]),
|
|
1099
|
+
plan_id=plan_id,
|
|
1100
|
+
_locked_store=store,
|
|
1101
|
+
)
|
|
1102
|
+
receipt_path = _receipt_path(store, plan_id, operation)
|
|
1103
|
+
receipt = dict(store._read_json(receipt_path)) if receipt_path.is_file() else None
|
|
1104
|
+
code, blockers = _classify(current, operation=operation, receipt=receipt)
|
|
1105
|
+
ref = (
|
|
1106
|
+
f"refs/heads/{current['branch']}"
|
|
1107
|
+
if operation == "local"
|
|
1108
|
+
else str(current["remote_tracking_ref"])
|
|
1109
|
+
)
|
|
1110
|
+
live = [
|
|
1111
|
+
item
|
|
1112
|
+
for item in outstanding_retirement_authorizations(store, branch=str(current["branch"]))
|
|
1113
|
+
if item.get("operation") == operation
|
|
1114
|
+
]
|
|
1115
|
+
replayed = next((item for item in live if item.get("request_id") == request_id), None)
|
|
1116
|
+
if replayed is not None:
|
|
1117
|
+
return _payload(
|
|
1118
|
+
"branch-retirement-authorize",
|
|
1119
|
+
{
|
|
1120
|
+
"code": "RETIREMENT_AUTHORIZED",
|
|
1121
|
+
"idempotent": True,
|
|
1122
|
+
"plan_id": plan_id,
|
|
1123
|
+
"operation": operation,
|
|
1124
|
+
"authorization": replayed,
|
|
1125
|
+
"blockers": [],
|
|
1126
|
+
},
|
|
1127
|
+
)
|
|
1128
|
+
if code != "RETIREMENT_EXECUTABLE":
|
|
1129
|
+
return _payload(
|
|
1130
|
+
"branch-retirement-authorize",
|
|
1131
|
+
{
|
|
1132
|
+
"code": code,
|
|
1133
|
+
"idempotent": False,
|
|
1134
|
+
"plan_id": plan_id,
|
|
1135
|
+
"operation": operation,
|
|
1136
|
+
"authorization": None,
|
|
1137
|
+
"blockers": blockers,
|
|
1138
|
+
},
|
|
1139
|
+
)
|
|
1140
|
+
if live:
|
|
1141
|
+
return _payload(
|
|
1142
|
+
"branch-retirement-authorize",
|
|
1143
|
+
{
|
|
1144
|
+
"code": "RETIREMENT_BLOCKED",
|
|
1145
|
+
"idempotent": False,
|
|
1146
|
+
"plan_id": plan_id,
|
|
1147
|
+
"operation": operation,
|
|
1148
|
+
"authorization": None,
|
|
1149
|
+
"blockers": sorted({*blockers, "RETIREMENT_AUTHORIZATION_ACTIVE"}),
|
|
1150
|
+
"outstanding_authorization_id": live[0]["authorization_id"],
|
|
1151
|
+
},
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
selector = holder or "self"
|
|
1155
|
+
if selector != "self" and not selector.startswith("ancestor:"):
|
|
1156
|
+
raise ControllerError(
|
|
1157
|
+
"INVALID_HOLDER",
|
|
1158
|
+
"a grant binds the calling process tree: use self or ancestor:<program>, never a "
|
|
1159
|
+
"detached pid that names somebody else's process",
|
|
1160
|
+
)
|
|
1161
|
+
resolved = (
|
|
1162
|
+
describe_holder(os.getpid(), client_id=client_id, session_id=session_id, run_id=run_id)
|
|
1163
|
+
if selector == "self"
|
|
1164
|
+
else resolve_holder(
|
|
1165
|
+
selector, client_id=client_id, session_id=session_id, run_id=run_id
|
|
1166
|
+
)
|
|
1167
|
+
)
|
|
1168
|
+
issued_at = now_iso()
|
|
1169
|
+
expires_at = (
|
|
1170
|
+
datetime.fromisoformat(issued_at)
|
|
1171
|
+
.fromtimestamp(
|
|
1172
|
+
datetime.fromisoformat(issued_at).timestamp() + ttl_seconds,
|
|
1173
|
+
tz=datetime.fromisoformat(issued_at).tzinfo,
|
|
1174
|
+
)
|
|
1175
|
+
.isoformat(timespec="seconds")
|
|
1176
|
+
)
|
|
1177
|
+
body: dict[str, Any] = {
|
|
1178
|
+
"schema_version": execution.AUTHORIZATION_SCHEMA_VERSION,
|
|
1179
|
+
"contract": execution.AUTHORIZATION_CONTRACT,
|
|
1180
|
+
"request_id": request_id,
|
|
1181
|
+
"plan_id": plan_id,
|
|
1182
|
+
"operation": operation,
|
|
1183
|
+
"kind": kind,
|
|
1184
|
+
"repository_id": current["repository_id"],
|
|
1185
|
+
"allocation_id": current["allocation_id"],
|
|
1186
|
+
"branch": current["branch"],
|
|
1187
|
+
"ref": ref,
|
|
1188
|
+
"expected_oid": current["expected_oid"],
|
|
1189
|
+
"integrated_ref": current["integrated_ref"],
|
|
1190
|
+
"expected_integrated_oid": current["expected_integrated_oid"],
|
|
1191
|
+
"tracking_ref": current["remote_tracking_ref"],
|
|
1192
|
+
"observed": _observation(repository, current),
|
|
1193
|
+
"controller_state_digest": current["controller_state_digest"],
|
|
1194
|
+
"holder_sha256": canonical_holder_digest(asdict(resolved)),
|
|
1195
|
+
"issued_at": issued_at,
|
|
1196
|
+
"expires_at": expires_at,
|
|
1197
|
+
"ttl_seconds": ttl_seconds,
|
|
1198
|
+
"holder": asdict(resolved),
|
|
1199
|
+
"token": secrets.token_hex(32),
|
|
1200
|
+
"minimum_revalidation": {
|
|
1201
|
+
"plan_id": plan_id,
|
|
1202
|
+
"operation": operation,
|
|
1203
|
+
"branch": current["branch"],
|
|
1204
|
+
"expected_oid": current["expected_oid"],
|
|
1205
|
+
"expected_integrated_oid": current["expected_integrated_oid"],
|
|
1206
|
+
"integrated_ref": current["integrated_ref"],
|
|
1207
|
+
"ref_state": current["local_ref_state" if operation == "local" else "remote_tracking_state"],
|
|
1208
|
+
"controller_state_digest": current["controller_state_digest"],
|
|
1209
|
+
},
|
|
1210
|
+
}
|
|
1211
|
+
body["authorization_id"] = _sha256_json(execution.identity(body))
|
|
1212
|
+
store._atomic_json(_authorization_path(store, body["authorization_id"]), body)
|
|
1213
|
+
_crash_point("retirement_authorization_issued")
|
|
1214
|
+
store.touch_repository()
|
|
1215
|
+
return _payload(
|
|
1216
|
+
"branch-retirement-authorize",
|
|
1217
|
+
{
|
|
1218
|
+
"code": "RETIREMENT_AUTHORIZED",
|
|
1219
|
+
"idempotent": False,
|
|
1220
|
+
"plan_id": plan_id,
|
|
1221
|
+
"operation": operation,
|
|
1222
|
+
"authorization_id": body["authorization_id"],
|
|
1223
|
+
"authorization": body,
|
|
1224
|
+
"blockers": [],
|
|
1225
|
+
},
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _load_authorization(store: MetadataStore, authorization_id: str) -> dict[str, Any]:
|
|
1230
|
+
if not re.fullmatch(r"[0-9a-f]{64}", authorization_id):
|
|
1231
|
+
raise ControllerError("INVALID_AUTHORIZATION_ID", "authorization id must be sha256")
|
|
1232
|
+
path = _authorization_path(store, authorization_id)
|
|
1233
|
+
if not path.is_file():
|
|
1234
|
+
raise ControllerError("UNKNOWN_RETIREMENT_AUTHORIZATION", "authorization does not exist")
|
|
1235
|
+
document = dict(store._read_json(path))
|
|
1236
|
+
if (
|
|
1237
|
+
document.get("schema_version") != execution.AUTHORIZATION_SCHEMA_VERSION
|
|
1238
|
+
or document.get("contract") != execution.AUTHORIZATION_CONTRACT
|
|
1239
|
+
or _sha256_json(execution.identity(document)) != document.get("authorization_id")
|
|
1240
|
+
or document.get("authorization_id") != authorization_id
|
|
1241
|
+
):
|
|
1242
|
+
raise ControllerError("INVALID_METADATA", "retirement authorization content differs")
|
|
1243
|
+
return document
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def complete_branch_retirement(
|
|
1247
|
+
repository_path: Path,
|
|
1248
|
+
*,
|
|
1249
|
+
authorization_id: str,
|
|
1250
|
+
token: str,
|
|
1251
|
+
result_path: Path,
|
|
1252
|
+
) -> dict[str, Any]:
|
|
1253
|
+
"""Close an authorized retirement using the Controller's own observation.
|
|
1254
|
+
|
|
1255
|
+
The consumer's result document is treated as a claim about what it attempted,
|
|
1256
|
+
never as proof of what happened. Under the exclusive lock the Controller
|
|
1257
|
+
re-reads the refs, re-derives the authority digest, and compares the live
|
|
1258
|
+
observation against the vector the grant bound; only the single authorized
|
|
1259
|
+
delta (the target ref moving from its expected OID to absent) is accepted.
|
|
1260
|
+
"""
|
|
1261
|
+
|
|
1262
|
+
try:
|
|
1263
|
+
raw = result_path.read_bytes()
|
|
1264
|
+
reported = json.loads(raw)
|
|
1265
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
1266
|
+
raise ControllerError("INVALID_EXECUTION_RESULT", f"cannot read result: {exc}") from exc
|
|
1267
|
+
if not isinstance(reported, dict):
|
|
1268
|
+
raise ControllerError("INVALID_EXECUTION_RESULT", "result must be a JSON object")
|
|
1269
|
+
result = execution.normalize_result(reported)
|
|
1270
|
+
|
|
1271
|
+
repository = inventory(repository_path)
|
|
1272
|
+
store = MetadataStore(repository.common_dir)
|
|
1273
|
+
with store.lock(exclusive=True, create=False):
|
|
1274
|
+
settled = _receipt_by_authorization(store, authorization_id)
|
|
1275
|
+
if settled is not None:
|
|
1276
|
+
receipt, plan_id, operation = settled
|
|
1277
|
+
if _authorization_path(store, authorization_id).is_file():
|
|
1278
|
+
# Crash between the receipt write and the grant close: the decision
|
|
1279
|
+
# is already recorded, so finishing the close needs no new one.
|
|
1280
|
+
_close_authorization(store, authorization_id)
|
|
1281
|
+
return _payload(
|
|
1282
|
+
"branch-retirement-complete",
|
|
1283
|
+
{
|
|
1284
|
+
"code": "RETIREMENT_EXECUTION_COMPLETE",
|
|
1285
|
+
"idempotent": True,
|
|
1286
|
+
"authorization_id": authorization_id,
|
|
1287
|
+
"plan_id": plan_id,
|
|
1288
|
+
"operation": operation,
|
|
1289
|
+
"receipt": receipt,
|
|
1290
|
+
"provenance": receipt.get("provenance", RECEIPT_PROVENANCE_CLAIM),
|
|
1291
|
+
},
|
|
1292
|
+
)
|
|
1293
|
+
grant = _load_authorization(store, authorization_id)
|
|
1294
|
+
if not hmac.compare_digest(str(grant["token"]), str(token)):
|
|
1295
|
+
raise ControllerError("AUTHORIZATION_TOKEN_MISMATCH", "execution token differs")
|
|
1296
|
+
for field in ("plan_id", "operation", "kind", "ref", "expected_oid", "expected_integrated_oid"):
|
|
1297
|
+
if result[field] != grant[field]:
|
|
1298
|
+
raise ControllerError(
|
|
1299
|
+
"EXECUTION_RESULT_MISMATCH", f"result {field} differs from the authorization"
|
|
1300
|
+
)
|
|
1301
|
+
if result["integrated_ref"] is not None and result["integrated_ref"] != grant["integrated_ref"]:
|
|
1302
|
+
raise ControllerError("EXECUTION_RESULT_MISMATCH", "result integrated_ref differs")
|
|
1303
|
+
plan = _load_plan(store, str(grant["plan_id"]))
|
|
1304
|
+
authorization_id_value = str(grant["authorization_id"])
|
|
1305
|
+
receipt_path = _receipt_path(store, str(grant["plan_id"]), str(grant["operation"]))
|
|
1306
|
+
if receipt_path.is_file():
|
|
1307
|
+
# A receipt without a closed grant is a crash between the two writes;
|
|
1308
|
+
# finishing the close is the only safe repair and needs no new decision.
|
|
1309
|
+
_close_authorization(store, authorization_id_value)
|
|
1310
|
+
return _payload(
|
|
1311
|
+
"branch-retirement-complete",
|
|
1312
|
+
{
|
|
1313
|
+
"code": "RETIREMENT_EXECUTION_COMPLETE",
|
|
1314
|
+
"idempotent": True,
|
|
1315
|
+
"authorization_id": authorization_id_value,
|
|
1316
|
+
"plan_id": grant["plan_id"],
|
|
1317
|
+
"operation": grant["operation"],
|
|
1318
|
+
"receipt": dict(store._read_json(receipt_path)),
|
|
1319
|
+
"provenance": RECEIPT_PROVENANCE_AUTHORIZED,
|
|
1320
|
+
"grant_expired_at_settle": False,
|
|
1321
|
+
},
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
refs = public_refs(repository.canonical_path)
|
|
1325
|
+
target_before = refs.get(str(grant["ref"]))
|
|
1326
|
+
succeeded = execution.succeeded(result)
|
|
1327
|
+
if succeeded:
|
|
1328
|
+
if target_before is not None:
|
|
1329
|
+
raise ControllerError(
|
|
1330
|
+
"EXECUTION_OBSERVATION_MISMATCH",
|
|
1331
|
+
f"the authorized ref still exists at {target_before}",
|
|
1332
|
+
)
|
|
1333
|
+
else:
|
|
1334
|
+
abandon = _abandon(
|
|
1335
|
+
store,
|
|
1336
|
+
grant,
|
|
1337
|
+
classification="EXECUTION_REPORTED_FAILURE",
|
|
1338
|
+
reason=str(result["outcome"]),
|
|
1339
|
+
repository=repository,
|
|
1340
|
+
)
|
|
1341
|
+
return _payload(
|
|
1342
|
+
"branch-retirement-complete",
|
|
1343
|
+
{
|
|
1344
|
+
"code": "RETIREMENT_EXECUTION_ABORTED",
|
|
1345
|
+
"idempotent": False,
|
|
1346
|
+
"authorization_id": authorization_id_value,
|
|
1347
|
+
"plan_id": grant["plan_id"],
|
|
1348
|
+
"operation": grant["operation"],
|
|
1349
|
+
"evidence": abandon,
|
|
1350
|
+
"blockers": [],
|
|
1351
|
+
},
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
current, _ = _evaluate(
|
|
1355
|
+
repository_path,
|
|
1356
|
+
allocation_id=str(plan["allocation_id"]),
|
|
1357
|
+
branch=str(plan["branch"]),
|
|
1358
|
+
remote=str(plan["remote"]),
|
|
1359
|
+
integrated_into=str(plan["integrated_into"]),
|
|
1360
|
+
plan_id=None,
|
|
1361
|
+
_locked_store=store,
|
|
1362
|
+
)
|
|
1363
|
+
live_observation = _observation(repository, current)
|
|
1364
|
+
blockers = _observation_delta(grant["observed"], live_observation, authorized_ref=str(grant["ref"]))
|
|
1365
|
+
if str(current["controller_state_digest"]) != str(grant["controller_state_digest"]):
|
|
1366
|
+
blockers = sorted({*blockers, "PLAN_STATE_DRIFT"})
|
|
1367
|
+
if blockers:
|
|
1368
|
+
abandon = _abandon(
|
|
1369
|
+
store,
|
|
1370
|
+
grant,
|
|
1371
|
+
classification="EXECUTION_OBSERVATION_MISMATCH",
|
|
1372
|
+
reason=",".join(blockers),
|
|
1373
|
+
repository=repository,
|
|
1374
|
+
)
|
|
1375
|
+
return _payload(
|
|
1376
|
+
"branch-retirement-complete",
|
|
1377
|
+
{
|
|
1378
|
+
"code": "RETIREMENT_EXECUTION_REFUSED",
|
|
1379
|
+
"idempotent": False,
|
|
1380
|
+
"authorization_id": authorization_id_value,
|
|
1381
|
+
"plan_id": grant["plan_id"],
|
|
1382
|
+
"operation": grant["operation"],
|
|
1383
|
+
"blockers": blockers,
|
|
1384
|
+
"observed": live_observation,
|
|
1385
|
+
"evidence": abandon,
|
|
1386
|
+
},
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
receipt = {
|
|
1390
|
+
"schema_version": RECEIPT_SCHEMA_VERSION + 1,
|
|
1391
|
+
"receipt_id": hashlib.sha256(
|
|
1392
|
+
f"{grant['plan_id']}:{grant['operation']}".encode()
|
|
1393
|
+
).hexdigest(),
|
|
1394
|
+
"plan_id": grant["plan_id"],
|
|
1395
|
+
"operation": grant["operation"],
|
|
1396
|
+
"branch": grant["branch"],
|
|
1397
|
+
"expected_oid": grant["expected_oid"],
|
|
1398
|
+
"expected_integrated_oid": grant["expected_integrated_oid"],
|
|
1399
|
+
"finalizer_version": result["executor"]["version"],
|
|
1400
|
+
"finalizer_result": result["outcome"],
|
|
1401
|
+
"summary_sha256": hashlib.sha256(raw).hexdigest(),
|
|
1402
|
+
"recorded_at": now_iso(),
|
|
1403
|
+
"authorization_id": authorization_id_value,
|
|
1404
|
+
"provenance": RECEIPT_PROVENANCE_AUTHORIZED,
|
|
1405
|
+
"grant_expired_at_settle": grant_expired(grant),
|
|
1406
|
+
"observed_ref": grant["ref"],
|
|
1407
|
+
"observed_at_settle": target_before,
|
|
1408
|
+
"executor_name": result["executor"]["name"],
|
|
1409
|
+
}
|
|
1410
|
+
store._atomic_json(receipt_path, receipt)
|
|
1411
|
+
_crash_point("retirement_receipt_written")
|
|
1412
|
+
_close_authorization(store, authorization_id_value)
|
|
1413
|
+
store.touch_repository()
|
|
1414
|
+
return _payload(
|
|
1415
|
+
"branch-retirement-complete",
|
|
1416
|
+
{
|
|
1417
|
+
"code": "RETIREMENT_EXECUTION_COMPLETE",
|
|
1418
|
+
"idempotent": False,
|
|
1419
|
+
"authorization_id": authorization_id_value,
|
|
1420
|
+
"plan_id": grant["plan_id"],
|
|
1421
|
+
"operation": grant["operation"],
|
|
1422
|
+
"receipt": receipt,
|
|
1423
|
+
"provenance": RECEIPT_PROVENANCE_AUTHORIZED,
|
|
1424
|
+
"grant_expired_at_settle": receipt["grant_expired_at_settle"],
|
|
1425
|
+
"blockers": [],
|
|
1426
|
+
},
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def _receipt_by_authorization(
|
|
1431
|
+
store: MetadataStore, authorization_id: str
|
|
1432
|
+
) -> tuple[dict[str, Any], str, str] | None:
|
|
1433
|
+
"""Find a settled retirement from its grant id, so replay answers after closure."""
|
|
1434
|
+
|
|
1435
|
+
directory = store.branch_retirement_receipts_dir
|
|
1436
|
+
if not directory.is_dir():
|
|
1437
|
+
return None
|
|
1438
|
+
for receipt_file in sorted(directory.glob("*.json")):
|
|
1439
|
+
try:
|
|
1440
|
+
receipt = dict(store._read_json(receipt_file))
|
|
1441
|
+
except ControllerError:
|
|
1442
|
+
continue
|
|
1443
|
+
if receipt.get("authorization_id") == authorization_id:
|
|
1444
|
+
return receipt, str(receipt["plan_id"]), str(receipt["operation"])
|
|
1445
|
+
return None
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _observation_delta(
|
|
1449
|
+
bound: object, live: Mapping[str, Any], *, authorized_ref: str
|
|
1450
|
+
) -> list[str]:
|
|
1451
|
+
"""Blockers for the live Git facts the authorized deletion must not have touched.
|
|
1452
|
+
|
|
1453
|
+
Only refs and occupancy are compared. ``classification`` and ``blockers`` are
|
|
1454
|
+
derived from the very ref the grant authorized deleting, so they legitimately
|
|
1455
|
+
change the moment it goes; comparing them would refuse every honest settlement.
|
|
1456
|
+
An occupied path is different: it means a live checkout is dangling at a ref
|
|
1457
|
+
that is already gone.
|
|
1458
|
+
"""
|
|
1459
|
+
|
|
1460
|
+
if not isinstance(bound, Mapping):
|
|
1461
|
+
return ["OBSERVATION_UNREADABLE"]
|
|
1462
|
+
blockers: list[str] = []
|
|
1463
|
+
bound_refs = bound.get("refs") or {}
|
|
1464
|
+
live_refs = live.get("refs") or {}
|
|
1465
|
+
for ref, oid in sorted(bound_refs.items()):
|
|
1466
|
+
if ref == authorized_ref:
|
|
1467
|
+
continue
|
|
1468
|
+
if live_refs.get(ref) != oid:
|
|
1469
|
+
blockers.append("OBSERVATION_REF_MOVED")
|
|
1470
|
+
if live.get("checked_out_paths"):
|
|
1471
|
+
blockers.append("OBSERVATION_CHECKOUT_APPEARED")
|
|
1472
|
+
return sorted(set(blockers))
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def grant_expired(grant: Mapping[str, Any], *, at: str | None = None) -> bool:
|
|
1476
|
+
"""Expiry means the claim needs verification, never that the work is done.
|
|
1477
|
+
|
|
1478
|
+
An expired grant still fences occupancy and still has to be settled or abandoned
|
|
1479
|
+
explicitly; the only thing that ends a retirement is the Controller's own
|
|
1480
|
+
observation of the authorized delta.
|
|
1481
|
+
"""
|
|
1482
|
+
|
|
1483
|
+
stamp = at or now_iso()
|
|
1484
|
+
try:
|
|
1485
|
+
return datetime.fromisoformat(str(grant["expires_at"])) < datetime.fromisoformat(stamp)
|
|
1486
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
1487
|
+
raise ControllerError(
|
|
1488
|
+
"INVALID_METADATA", "retirement authorization expiry is unreadable"
|
|
1489
|
+
) from exc
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _close_authorization(store: MetadataStore, authorization_id: str) -> None:
|
|
1493
|
+
store.remove_branch_retirement_authorization(authorization_id)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _live_refs(repository: RepositoryInventory, grant: Mapping[str, Any]) -> dict[str, str | None]:
|
|
1497
|
+
"""Every ref the grant bound, read straight from Git at the moment of writing."""
|
|
1498
|
+
|
|
1499
|
+
refs = public_refs(repository.canonical_path)
|
|
1500
|
+
watched = {str(grant["ref"]): refs.get(str(grant["ref"]))}
|
|
1501
|
+
for key in ("integrated_ref", "tracking_ref"):
|
|
1502
|
+
value = grant.get(key)
|
|
1503
|
+
if isinstance(value, str) and value:
|
|
1504
|
+
watched[value] = refs.get(value)
|
|
1505
|
+
return {key: watched[key] for key in sorted(watched)}
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _abandon(
|
|
1509
|
+
store: MetadataStore,
|
|
1510
|
+
grant: Mapping[str, Any],
|
|
1511
|
+
*,
|
|
1512
|
+
classification: str,
|
|
1513
|
+
reason: str,
|
|
1514
|
+
repository: RepositoryInventory,
|
|
1515
|
+
) -> dict[str, str]:
|
|
1516
|
+
"""Close one authorization with immutable evidence; never mint a receipt here.
|
|
1517
|
+
|
|
1518
|
+
A ref that is gone without the Controller having observed the authorized
|
|
1519
|
+
transition is an attention item, not a completed retirement: absence alone does
|
|
1520
|
+
not say who caused it.
|
|
1521
|
+
"""
|
|
1522
|
+
|
|
1523
|
+
_crash_point("retirement_abandon_started")
|
|
1524
|
+
evidence = e.write(
|
|
1525
|
+
store.branch_retirement_authorizations_dir,
|
|
1526
|
+
f"{grant['authorization_id']}-abandoned.json",
|
|
1527
|
+
{
|
|
1528
|
+
"schema_version": execution.AUTHORIZATION_SCHEMA_VERSION,
|
|
1529
|
+
"contract": execution.AUTHORIZATION_CONTRACT,
|
|
1530
|
+
"authorization_id": grant["authorization_id"],
|
|
1531
|
+
"plan_id": grant["plan_id"],
|
|
1532
|
+
"operation": grant["operation"],
|
|
1533
|
+
"branch": grant["branch"],
|
|
1534
|
+
"ref": grant["ref"],
|
|
1535
|
+
"classification": classification,
|
|
1536
|
+
"reason": reason,
|
|
1537
|
+
"bound": grant["observed"],
|
|
1538
|
+
"live_refs": _live_refs(repository, grant),
|
|
1539
|
+
"reconciled_at": now_iso(),
|
|
1540
|
+
},
|
|
1541
|
+
)
|
|
1542
|
+
_crash_point("retirement_abandon_evidence_written")
|
|
1543
|
+
_close_authorization(store, str(grant["authorization_id"]))
|
|
1544
|
+
return evidence
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def branch_retirement_executions(
|
|
1548
|
+
repository_path: Path,
|
|
1549
|
+
*,
|
|
1550
|
+
action: str,
|
|
1551
|
+
authorization_id: str | None = None,
|
|
1552
|
+
token: str | None = None,
|
|
1553
|
+
reason: str | None = None,
|
|
1554
|
+
) -> dict[str, Any]:
|
|
1555
|
+
"""Inspect or retire an outstanding execution authorization.
|
|
1556
|
+
|
|
1557
|
+
``abandon`` never mints a completion receipt. An authorization whose ref is
|
|
1558
|
+
already gone without the Controller having observed the authorized transition
|
|
1559
|
+
becomes immutable evidence and a visible attention item, not a blessed
|
|
1560
|
+
retirement - absence alone does not prove who caused it.
|
|
1561
|
+
"""
|
|
1562
|
+
|
|
1563
|
+
if action not in {"list", "inspect", "abandon"}:
|
|
1564
|
+
raise ControllerError("INVALID_ACTION", "action must be list, inspect or abandon")
|
|
1565
|
+
repository = inventory(repository_path)
|
|
1566
|
+
store = MetadataStore(repository.common_dir)
|
|
1567
|
+
exclusive = action == "abandon"
|
|
1568
|
+
with store.lock(exclusive=exclusive, create=False):
|
|
1569
|
+
if action == "list":
|
|
1570
|
+
return _payload(
|
|
1571
|
+
"branch-retirement-executions",
|
|
1572
|
+
{
|
|
1573
|
+
"code": "RETIREMENT_EXECUTIONS_LISTED",
|
|
1574
|
+
"authorizations": [
|
|
1575
|
+
{
|
|
1576
|
+
"authorization_id": item["authorization_id"],
|
|
1577
|
+
"plan_id": item["plan_id"],
|
|
1578
|
+
"operation": item["operation"],
|
|
1579
|
+
"branch": item["branch"],
|
|
1580
|
+
"ref": item["ref"],
|
|
1581
|
+
"expires_at": item["expires_at"],
|
|
1582
|
+
"holder_sha256": item["holder_sha256"],
|
|
1583
|
+
}
|
|
1584
|
+
for item in outstanding_retirement_authorizations(store)
|
|
1585
|
+
],
|
|
1586
|
+
},
|
|
1587
|
+
)
|
|
1588
|
+
if authorization_id is None:
|
|
1589
|
+
raise ControllerError("INVALID_REQUEST", "authorization id is required")
|
|
1590
|
+
grant = _load_authorization(store, authorization_id)
|
|
1591
|
+
if action == "inspect":
|
|
1592
|
+
current, _ = _evaluate(
|
|
1593
|
+
repository_path,
|
|
1594
|
+
allocation_id=str(grant["allocation_id"]),
|
|
1595
|
+
branch=str(grant["branch"]),
|
|
1596
|
+
remote=_remote_from_tracking(str(grant["tracking_ref"])),
|
|
1597
|
+
integrated_into=str(grant["integrated_ref"]),
|
|
1598
|
+
_locked_store=store,
|
|
1599
|
+
)
|
|
1600
|
+
return _payload(
|
|
1601
|
+
"branch-retirement-executions",
|
|
1602
|
+
{
|
|
1603
|
+
"code": "RETIREMENT_EXECUTION_INSPECTED",
|
|
1604
|
+
"authorization": {
|
|
1605
|
+
key: value for key, value in grant.items() if key != "token"
|
|
1606
|
+
},
|
|
1607
|
+
"token_presented": token is not None,
|
|
1608
|
+
"holder_dead": not holder_is_live(_grant_holder(grant)),
|
|
1609
|
+
"observed_now": _observation(repository, current),
|
|
1610
|
+
"ref_present_now": public_refs(repository.canonical_path).get(str(grant["ref"]))
|
|
1611
|
+
is not None,
|
|
1612
|
+
},
|
|
1613
|
+
)
|
|
1614
|
+
if token is None:
|
|
1615
|
+
raise ControllerError(
|
|
1616
|
+
"AUTHORIZATION_TOKEN_REQUIRED",
|
|
1617
|
+
"abandoning a grant needs the execution token: holder liveness is not authority, "
|
|
1618
|
+
"because flock dies with the process while the mutation may not have run at all",
|
|
1619
|
+
)
|
|
1620
|
+
if not hmac.compare_digest(str(grant["token"]), str(token)):
|
|
1621
|
+
raise ControllerError("AUTHORIZATION_TOKEN_MISMATCH", "execution token differs")
|
|
1622
|
+
present = public_refs(repository.canonical_path).get(str(grant["ref"])) is not None
|
|
1623
|
+
classification = "ABANDONED_NO_EFFECT" if present else "ABANDONED_UNATTESTED_REMOVAL"
|
|
1624
|
+
evidence = _abandon(
|
|
1625
|
+
store,
|
|
1626
|
+
grant,
|
|
1627
|
+
classification=classification,
|
|
1628
|
+
reason=reason or "operator abandon",
|
|
1629
|
+
repository=repository,
|
|
1630
|
+
)
|
|
1631
|
+
return _payload(
|
|
1632
|
+
"branch-retirement-executions",
|
|
1633
|
+
{
|
|
1634
|
+
"code": "RETIREMENT_EXECUTION_ABANDONED",
|
|
1635
|
+
"authorization_id": authorization_id,
|
|
1636
|
+
"classification": classification,
|
|
1637
|
+
"receipt_filed": False,
|
|
1638
|
+
"evidence": evidence,
|
|
1639
|
+
},
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _remote_from_tracking(tracking_ref: str) -> str:
|
|
1644
|
+
parts = tracking_ref.split("/")
|
|
1645
|
+
return parts[2] if len(parts) > 3 and parts[0] == "refs" and parts[1] == "remotes" else "origin"
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def _grant_holder(grant: Mapping[str, Any]) -> WriterHolder:
|
|
1649
|
+
holder = grant.get("holder")
|
|
1650
|
+
if not isinstance(holder, dict):
|
|
1651
|
+
raise ControllerError("INVALID_METADATA", "retirement authorization has no holder")
|
|
1652
|
+
return WriterHolder(
|
|
1653
|
+
client_id=str(holder["client_id"]),
|
|
1654
|
+
pid=int(holder["pid"]),
|
|
1655
|
+
boot_id=str(holder["boot_id"]),
|
|
1656
|
+
start_ticks=int(holder["start_ticks"]),
|
|
1657
|
+
session_id=holder.get("session_id"),
|
|
1658
|
+
run_id=holder.get("run_id"),
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def verify_branch_retirement(
|
|
1663
|
+
repository_path: Path, *, plan_id: str, operation: str
|
|
1664
|
+
) -> dict[str, Any]:
|
|
1665
|
+
"""The published pre-mutation verdict for one retirement operation.
|
|
1666
|
+
|
|
1667
|
+
A consumer obtains its decision here instead of re-deriving eligibility,
|
|
1668
|
+
and binds ``minimum_revalidation`` in the compare-and-delete that follows.
|
|
1669
|
+
Observation happens under the shared repository lock, so verify must run
|
|
1670
|
+
before the consumer takes its own exclusive retirement execution lock: a
|
|
1671
|
+
shared request against an already-held exclusive descriptor blocks.
|
|
1672
|
+
``branch-retirement-authorize`` is the versioned follow-up that turns this
|
|
1673
|
+
verdict into a single-use, Controller-observed execution grant.
|
|
1674
|
+
"""
|
|
1675
|
+
|
|
1676
|
+
if operation not in OPERATIONS:
|
|
1677
|
+
raise ControllerError("INVALID_OPERATION", "operation must be local or remote")
|
|
1678
|
+
repository = inventory(repository_path)
|
|
1679
|
+
store = MetadataStore(repository.common_dir)
|
|
1680
|
+
with store.lock(exclusive=False):
|
|
1681
|
+
plan = _load_plan(store, plan_id)
|
|
1682
|
+
current, _ = _evaluate(
|
|
1683
|
+
repository_path,
|
|
1684
|
+
allocation_id=str(plan["allocation_id"]),
|
|
1685
|
+
branch=str(plan["branch"]),
|
|
1686
|
+
remote=str(plan["remote"]),
|
|
1687
|
+
integrated_into=str(plan["integrated_into"]),
|
|
1688
|
+
plan_id=plan_id,
|
|
1689
|
+
_locked_store=store,
|
|
1690
|
+
)
|
|
1691
|
+
receipt_path = _receipt_path(store, plan_id, operation)
|
|
1692
|
+
receipt = dict(store._read_json(receipt_path)) if receipt_path.is_file() else None
|
|
1693
|
+
outstanding = [
|
|
1694
|
+
item
|
|
1695
|
+
for item in outstanding_retirement_authorizations(
|
|
1696
|
+
store, branch=str(current["branch"]), allocation_id=None
|
|
1697
|
+
)
|
|
1698
|
+
if item.get("operation") == operation
|
|
1699
|
+
]
|
|
1700
|
+
if receipt is not None:
|
|
1701
|
+
current = {
|
|
1702
|
+
**current,
|
|
1703
|
+
"blockers": _completed_blockers(
|
|
1704
|
+
repository.canonical_path, plan, current, operation=operation, receipt=receipt
|
|
1705
|
+
),
|
|
1706
|
+
}
|
|
1707
|
+
blockers = list(current["blockers"])
|
|
1708
|
+
if outstanding:
|
|
1709
|
+
blockers = sorted({*blockers, "RETIREMENT_AUTHORIZATION_ACTIVE"})
|
|
1710
|
+
code, blockers = _classify(
|
|
1711
|
+
{**current, "blockers": blockers}, operation=operation, receipt=receipt
|
|
1712
|
+
)
|
|
1713
|
+
minimum_revalidation = {
|
|
1714
|
+
"plan_id": plan_id,
|
|
1715
|
+
"operation": operation,
|
|
1716
|
+
"branch": current["branch"],
|
|
1717
|
+
"expected_oid": current["expected_oid"],
|
|
1718
|
+
"expected_integrated_oid": current["expected_integrated_oid"],
|
|
1719
|
+
"integrated_ref": current["integrated_ref"],
|
|
1720
|
+
"ref_state": current[
|
|
1721
|
+
"local_ref_state" if operation == "local" else "remote_tracking_state"
|
|
1722
|
+
],
|
|
1723
|
+
"controller_state_digest": current["controller_state_digest"],
|
|
1724
|
+
}
|
|
1725
|
+
return _payload(
|
|
1726
|
+
"branch-retirement-verify",
|
|
1727
|
+
{
|
|
1728
|
+
"code": code,
|
|
1729
|
+
"plan_id": plan_id,
|
|
1730
|
+
"operation": operation,
|
|
1731
|
+
"blockers": blockers,
|
|
1732
|
+
"receipt": receipt,
|
|
1733
|
+
"minimum_revalidation": minimum_revalidation,
|
|
1734
|
+
},
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def record_branch_retirement(
|
|
1739
|
+
repository_path: Path,
|
|
1740
|
+
*,
|
|
1741
|
+
plan_id: str,
|
|
1742
|
+
operation: str,
|
|
1743
|
+
summary_path: Path | None = None,
|
|
1744
|
+
result_path: Path | None = None,
|
|
1745
|
+
) -> dict[str, Any]:
|
|
1746
|
+
"""File one retirement result the Controller can actually observe.
|
|
1747
|
+
|
|
1748
|
+
A result document - neutral or translated from a legacy consumer summary - is
|
|
1749
|
+
a claim about an attempt. The receipt is only filed when the Controller's own
|
|
1750
|
+
read of Git agrees that the ref the plan named is gone. That closes the door
|
|
1751
|
+
where a well-shaped file with no mutation behind it became governance history.
|
|
1752
|
+
A result filed while an execution authorization is outstanding is refused: an
|
|
1753
|
+
authorized run settles through ``branch-retirement-complete``.
|
|
1754
|
+
"""
|
|
1755
|
+
|
|
1756
|
+
if operation not in OPERATIONS:
|
|
1757
|
+
raise ControllerError("INVALID_OPERATION", "operation must be local or remote")
|
|
1758
|
+
if (summary_path is None) == (result_path is None):
|
|
1759
|
+
raise ControllerError(
|
|
1760
|
+
"INVALID_REQUEST", "present exactly one of --summary-file or --result-file"
|
|
1761
|
+
)
|
|
1762
|
+
if result_path is not None:
|
|
1763
|
+
try:
|
|
1764
|
+
raw = result_path.read_bytes()
|
|
1765
|
+
reported = json.loads(raw)
|
|
1766
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
1767
|
+
raise ControllerError("INVALID_EXECUTION_RESULT", f"cannot read result: {exc}") from exc
|
|
1768
|
+
if not isinstance(reported, dict):
|
|
1769
|
+
raise ControllerError("INVALID_EXECUTION_RESULT", "result must be a JSON object")
|
|
1770
|
+
result = execution.normalize_result(reported)
|
|
1771
|
+
else:
|
|
1772
|
+
assert summary_path is not None
|
|
1773
|
+
raw, summary = adapters.load_summary(summary_path)
|
|
1774
|
+
result = execution.normalize_result(adapters.from_summary(summary))
|
|
1775
|
+
|
|
1776
|
+
repository = inventory(repository_path)
|
|
1777
|
+
store = MetadataStore(repository.common_dir)
|
|
1778
|
+
with store.lock(exclusive=True, create=False):
|
|
1779
|
+
plan = _load_plan(store, plan_id)
|
|
1780
|
+
if result["plan_id"] != plan_id or result["operation"] != operation:
|
|
1781
|
+
raise ControllerError(
|
|
1782
|
+
"EXECUTION_RESULT_MISMATCH", "result names a different plan or operation"
|
|
1783
|
+
)
|
|
1784
|
+
for field in ("expected_oid", "expected_integrated_oid"):
|
|
1785
|
+
if result[field] != plan[field]:
|
|
1786
|
+
raise ControllerError(
|
|
1787
|
+
"EXECUTION_RESULT_MISMATCH", f"result {field} differs from the plan"
|
|
1788
|
+
)
|
|
1789
|
+
authorized_ref = (
|
|
1790
|
+
str(plan["remote_ref"]) if operation == "local" else str(plan["remote_tracking_ref"])
|
|
1791
|
+
)
|
|
1792
|
+
if result["ref"] != authorized_ref:
|
|
1793
|
+
raise ControllerError("EXECUTION_RESULT_MISMATCH", "result ref differs from the plan")
|
|
1794
|
+
if not execution.succeeded(result):
|
|
1795
|
+
raise ControllerError(
|
|
1796
|
+
"EXECUTION_RESULT_MISMATCH", f"result outcome {result['outcome']} is not a completion"
|
|
1797
|
+
)
|
|
1798
|
+
outstanding = [
|
|
1799
|
+
item
|
|
1800
|
+
for item in outstanding_retirement_authorizations(store, branch=str(plan["branch"]))
|
|
1801
|
+
if item.get("operation") == operation
|
|
1802
|
+
]
|
|
1803
|
+
if outstanding:
|
|
1804
|
+
raise ControllerError(
|
|
1805
|
+
"RETIREMENT_EXECUTION_ACTIVE",
|
|
1806
|
+
"an execution authorization covers this operation; settle it with "
|
|
1807
|
+
f"branch-retirement-complete for {outstanding[0]['authorization_id']}",
|
|
1808
|
+
)
|
|
1809
|
+
observed = public_refs(repository.canonical_path).get(result["ref"])
|
|
1810
|
+
if observed is not None:
|
|
1811
|
+
raise ControllerError(
|
|
1812
|
+
"EXECUTION_OBSERVATION_MISMATCH",
|
|
1813
|
+
f"the Controller still observes {result['ref']} at {observed}; nothing was retired",
|
|
1814
|
+
)
|
|
1815
|
+
# Deleting a ref that a checkout still points at orphans that checkout, and
|
|
1816
|
+
# git gives update-ref no occupancy guard, so the authority state has to be
|
|
1817
|
+
# re-read here rather than assumed from the plan.
|
|
1818
|
+
current, _ = _evaluate(
|
|
1819
|
+
repository_path,
|
|
1820
|
+
allocation_id=str(plan["allocation_id"]),
|
|
1821
|
+
branch=str(plan["branch"]),
|
|
1822
|
+
remote=str(plan["remote"]),
|
|
1823
|
+
integrated_into=str(plan["integrated_into"]),
|
|
1824
|
+
plan_id=plan_id,
|
|
1825
|
+
_locked_store=store,
|
|
1826
|
+
)
|
|
1827
|
+
moved = sorted(AUTHORITY_BLOCKERS.intersection(current["blockers"]))
|
|
1828
|
+
if moved:
|
|
1829
|
+
raise ControllerError(
|
|
1830
|
+
"EXECUTION_GOVERNANCE_MOVED",
|
|
1831
|
+
f"authority changed after the plan: {', '.join(moved)}",
|
|
1832
|
+
)
|
|
1833
|
+
path = _receipt_path(store, plan_id, operation)
|
|
1834
|
+
if path.is_file():
|
|
1835
|
+
receipt = dict(store._read_json(path))
|
|
1836
|
+
idempotent = True
|
|
1837
|
+
else:
|
|
1838
|
+
receipt = {
|
|
1839
|
+
"schema_version": RECEIPT_SCHEMA_VERSION,
|
|
1840
|
+
"receipt_id": hashlib.sha256(f"{plan_id}:{operation}".encode()).hexdigest(),
|
|
1841
|
+
"plan_id": plan_id,
|
|
1842
|
+
"operation": operation,
|
|
1843
|
+
"branch": plan["branch"],
|
|
1844
|
+
"expected_oid": plan["expected_oid"],
|
|
1845
|
+
"expected_integrated_oid": plan["expected_integrated_oid"],
|
|
1846
|
+
"finalizer_version": result["executor"]["version"],
|
|
1847
|
+
"finalizer_result": result["outcome"],
|
|
1848
|
+
"summary_sha256": hashlib.sha256(raw).hexdigest(),
|
|
1849
|
+
"recorded_at": now_iso(),
|
|
1850
|
+
"provenance": RECEIPT_PROVENANCE_OBSERVED,
|
|
1851
|
+
"result_contract": result["contract"],
|
|
1852
|
+
"executor_name": result["executor"]["name"],
|
|
1853
|
+
}
|
|
1854
|
+
store._atomic_json(path, receipt)
|
|
1855
|
+
store.touch_repository()
|
|
1856
|
+
idempotent = False
|
|
1857
|
+
return _payload(
|
|
1858
|
+
"branch-retirement-record",
|
|
1859
|
+
{
|
|
1860
|
+
"code": "BRANCH_RETIREMENT_RESULT_RECORDED",
|
|
1861
|
+
"idempotent": idempotent,
|
|
1862
|
+
"provenance": receipt.get("provenance", RECEIPT_PROVENANCE_CLAIM),
|
|
1863
|
+
"receipt": receipt,
|
|
1864
|
+
},
|
|
1865
|
+
)
|