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,1228 @@
|
|
|
1
|
+
"""Versioned, locked, atomic repository-local metadata storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
14
|
+
from contextlib import contextmanager, suppress
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
from typing import Any, TypeVar
|
|
18
|
+
|
|
19
|
+
from . import metadata_codec as _codec
|
|
20
|
+
from .models import (
|
|
21
|
+
METADATA_SCHEMA_VERSION,
|
|
22
|
+
SUPPORTED_METADATA_SCHEMA_VERSIONS,
|
|
23
|
+
ControllerError,
|
|
24
|
+
IntegrationIntent,
|
|
25
|
+
IntegrationReconciliationReceipt,
|
|
26
|
+
LifecycleEvent,
|
|
27
|
+
MetadataRecord,
|
|
28
|
+
MetadataSnapshot,
|
|
29
|
+
PreparedCandidateReceipt,
|
|
30
|
+
PublicationLease,
|
|
31
|
+
PublicationReceipt,
|
|
32
|
+
ReconciliationReceipt,
|
|
33
|
+
ReleaseIntent,
|
|
34
|
+
ReleasePlan,
|
|
35
|
+
ReleaseReceipt,
|
|
36
|
+
WriterLease,
|
|
37
|
+
WriterReconciliationReceipt,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Preserve the store import interface while codecs stay independent of disk access.
|
|
41
|
+
INTEGRATION_INTENT_CONTRACT_VERSION = _codec.INTEGRATION_INTENT_CONTRACT_VERSION
|
|
42
|
+
INTEGRATION_INTENT_SCHEMA_VERSION = _codec.INTEGRATION_INTENT_SCHEMA_VERSION
|
|
43
|
+
INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION = _codec.INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION
|
|
44
|
+
LIFECYCLE_EVENT_SCHEMA_VERSION = _codec.LIFECYCLE_EVENT_SCHEMA_VERSION
|
|
45
|
+
PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION = _codec.PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION
|
|
46
|
+
PUBLICATION_LEASE_SCHEMA_VERSION = _codec.PUBLICATION_LEASE_SCHEMA_VERSION
|
|
47
|
+
PUBLICATION_RECEIPT_SCHEMA_VERSION = _codec.PUBLICATION_RECEIPT_SCHEMA_VERSION
|
|
48
|
+
PUBLICATION_RECEIPT_STATES = _codec.PUBLICATION_RECEIPT_STATES
|
|
49
|
+
RECONCILIATION_RECEIPT_SCHEMA_VERSION = _codec.RECONCILIATION_RECEIPT_SCHEMA_VERSION
|
|
50
|
+
RELEASE_INTENT_SCHEMA_VERSION = _codec.RELEASE_INTENT_SCHEMA_VERSION
|
|
51
|
+
RELEASE_PLAN_SCHEMA_VERSION = _codec.RELEASE_PLAN_SCHEMA_VERSION
|
|
52
|
+
RELEASE_RECEIPT_SCHEMA_VERSION = _codec.RELEASE_RECEIPT_SCHEMA_VERSION
|
|
53
|
+
TEARDOWN_RECEIPT_RESULTS = _codec.TEARDOWN_RECEIPT_RESULTS
|
|
54
|
+
TEARDOWN_RECEIPT_SCHEMA_VERSION = _codec.TEARDOWN_RECEIPT_SCHEMA_VERSION
|
|
55
|
+
WRITER_LEASE_SCHEMA_VERSION = _codec.WRITER_LEASE_SCHEMA_VERSION
|
|
56
|
+
WRITER_RECEIPT_SCHEMA_VERSION = _codec.WRITER_RECEIPT_SCHEMA_VERSION
|
|
57
|
+
_disposable_resources = _codec._disposable_resources
|
|
58
|
+
_nonempty_string = _codec._nonempty_string
|
|
59
|
+
_nonnegative_int = _codec._nonnegative_int
|
|
60
|
+
_optional_oid = _codec._optional_oid
|
|
61
|
+
_optional_recovery_ref = _codec._optional_recovery_ref
|
|
62
|
+
_optional_sha256 = _codec._optional_sha256
|
|
63
|
+
_optional_string = _codec._optional_string
|
|
64
|
+
_optional_uuid = _codec._optional_uuid
|
|
65
|
+
_positive_int = _codec._positive_int
|
|
66
|
+
_required_oid = _codec._required_oid
|
|
67
|
+
_required_sha256 = _codec._required_sha256
|
|
68
|
+
_scope_digest_value = _codec._scope_digest_value
|
|
69
|
+
_scope_paths = _codec._scope_paths
|
|
70
|
+
_source_allocations = _codec._source_allocations
|
|
71
|
+
_string_list = _codec._string_list
|
|
72
|
+
_validate_timestamp = _codec._validate_timestamp
|
|
73
|
+
integration_intent_from_dict = _codec.integration_intent_from_dict
|
|
74
|
+
integration_intent_to_dict = _codec.integration_intent_to_dict
|
|
75
|
+
integration_reconciliation_receipt_from_dict = _codec.integration_reconciliation_receipt_from_dict
|
|
76
|
+
integration_reconciliation_receipt_to_dict = _codec.integration_reconciliation_receipt_to_dict
|
|
77
|
+
lifecycle_event_from_dict = _codec.lifecycle_event_from_dict
|
|
78
|
+
lifecycle_event_to_dict = _codec.lifecycle_event_to_dict
|
|
79
|
+
prepared_candidate_receipt_from_dict = _codec.prepared_candidate_receipt_from_dict
|
|
80
|
+
prepared_candidate_receipt_to_dict = _codec.prepared_candidate_receipt_to_dict
|
|
81
|
+
publication_lease_from_dict = _codec.publication_lease_from_dict
|
|
82
|
+
publication_lease_to_dict = _codec.publication_lease_to_dict
|
|
83
|
+
publication_receipt_from_dict = _codec.publication_receipt_from_dict
|
|
84
|
+
publication_receipt_to_dict = _codec.publication_receipt_to_dict
|
|
85
|
+
reconciliation_receipt_from_dict = _codec.reconciliation_receipt_from_dict
|
|
86
|
+
reconciliation_receipt_to_dict = _codec.reconciliation_receipt_to_dict
|
|
87
|
+
record_from_dict = _codec.record_from_dict
|
|
88
|
+
record_to_dict = _codec.record_to_dict
|
|
89
|
+
release_intent_from_dict = _codec.release_intent_from_dict
|
|
90
|
+
release_intent_to_dict = _codec.release_intent_to_dict
|
|
91
|
+
release_plan_from_dict = _codec.release_plan_from_dict
|
|
92
|
+
release_plan_to_dict = _codec.release_plan_to_dict
|
|
93
|
+
release_receipt_from_dict = _codec.release_receipt_from_dict
|
|
94
|
+
release_receipt_to_dict = _codec.release_receipt_to_dict
|
|
95
|
+
validate_teardown_receipt = _codec.validate_teardown_receipt
|
|
96
|
+
writer_lease_from_dict = _codec.writer_lease_from_dict
|
|
97
|
+
writer_lease_to_dict = _codec.writer_lease_to_dict
|
|
98
|
+
writer_receipt_to_dict = _codec.writer_receipt_to_dict
|
|
99
|
+
|
|
100
|
+
# Persisted timestamps use the existing UTC+08:00 offset without requiring OS tzdata.
|
|
101
|
+
TIMEZONE = timezone(timedelta(hours=8))
|
|
102
|
+
ROOT_NAME = "worktree-controller"
|
|
103
|
+
VERSION_NAME = "v1"
|
|
104
|
+
# A linked registration's identity: a token written into Git's own administrative
|
|
105
|
+
# directory for that worktree and remembered under the metadata root. Git removes
|
|
106
|
+
# the administrative directory with the worktree, so a later registration that
|
|
107
|
+
# reuses the same name (and therefore the same worktree key) cannot carry the token.
|
|
108
|
+
REGISTRATION_CONTRACT = "worktree-registration/v1"
|
|
109
|
+
REGISTRATION_MARKER = "worktree-controller-registration"
|
|
110
|
+
_REGISTRATION_MARKER_LIMIT = 4096
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_Decoded = TypeVar("_Decoded")
|
|
114
|
+
# Commands loading several snapshots reuse decodes until file identity changes.
|
|
115
|
+
_DECODE_CACHE: dict[tuple[str, str], tuple[tuple[int, int, int, int], Any]] | None = None
|
|
116
|
+
_HISTORY_CACHE: dict[
|
|
117
|
+
tuple[str, str], tuple[dict[str, tuple[int, int, int, int]], dict[str, Any]]
|
|
118
|
+
] | None = None
|
|
119
|
+
# CLI decoding retains its byte-reader error contract even without reuse.
|
|
120
|
+
_BYTE_READS = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@contextmanager
|
|
124
|
+
def decode_cache(*, reuse: bool = True) -> Iterator[None]:
|
|
125
|
+
"""Reuse decoded metadata within one command; nothing survives the command.
|
|
126
|
+
|
|
127
|
+
Metadata files are only ever replaced atomically, so an unchanged (device,
|
|
128
|
+
inode, size, mtime) identity is the same content. Decoded values are frozen
|
|
129
|
+
dataclasses and are shared read-only. Single-snapshot commands can disable
|
|
130
|
+
reuse while retaining the same byte decoding and error details.
|
|
131
|
+
"""
|
|
132
|
+
global _DECODE_CACHE, _HISTORY_CACHE, _BYTE_READS
|
|
133
|
+
previous, history_previous, bytes_previous = _DECODE_CACHE, _HISTORY_CACHE, _BYTE_READS
|
|
134
|
+
_DECODE_CACHE = ({} if previous is None else previous) if reuse else None
|
|
135
|
+
_HISTORY_CACHE = ({} if history_previous is None else history_previous) if reuse else None
|
|
136
|
+
_BYTE_READS = True
|
|
137
|
+
try:
|
|
138
|
+
yield
|
|
139
|
+
finally:
|
|
140
|
+
_DECODE_CACHE = previous
|
|
141
|
+
_HISTORY_CACHE = history_previous
|
|
142
|
+
_BYTE_READS = bytes_previous
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _identity(info: os.stat_result) -> tuple[int, int, int, int]:
|
|
146
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _listing_error(directory: Path, exc: OSError) -> ControllerError:
|
|
150
|
+
if exc.errno in {errno.EACCES, errno.EPERM}:
|
|
151
|
+
return ControllerError(
|
|
152
|
+
"FILESYSTEM_PERMISSION",
|
|
153
|
+
f"metadata directory cannot be listed with current filesystem permissions: "
|
|
154
|
+
f"{directory} ({errno.errorcode.get(exc.errno, 'UNKNOWN')})",
|
|
155
|
+
)
|
|
156
|
+
return ControllerError("INVALID_METADATA", f"cannot list metadata directory {directory}: {exc}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _json_entries(directory: Path) -> list[os.DirEntry[str]]:
|
|
160
|
+
"""Flat ``*.json`` entries from a fresh scan on every load.
|
|
161
|
+
|
|
162
|
+
A missing directory has no entries. Any other listing failure is incomplete
|
|
163
|
+
evidence and fails closed: an unreadable directory must never look empty.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
with os.scandir(directory) as entries:
|
|
167
|
+
return sorted(
|
|
168
|
+
(entry for entry in entries if entry.name.endswith(".json")),
|
|
169
|
+
key=lambda entry: entry.name,
|
|
170
|
+
)
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
return []
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise _listing_error(directory, exc) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _glob_json(directory: Path) -> list[Path]:
|
|
178
|
+
"""``sorted(directory.glob("*.json"))`` without silently dropping listing errors."""
|
|
179
|
+
return [
|
|
180
|
+
Path(entry.path) for entry in _json_entries(directory) if not entry.name.startswith(".")
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _glob_nested_json(directory: Path) -> list[Path]:
|
|
185
|
+
"""``sorted(directory.glob("*/*.json"))`` without silently dropping listing errors."""
|
|
186
|
+
try:
|
|
187
|
+
with os.scandir(directory) as entries:
|
|
188
|
+
children = sorted(
|
|
189
|
+
entry.name
|
|
190
|
+
for entry in entries
|
|
191
|
+
if not entry.name.startswith(".") and entry.is_dir(follow_symlinks=True)
|
|
192
|
+
)
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
return []
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
raise _listing_error(directory, exc) from exc
|
|
197
|
+
return sorted(path for name in children for path in _glob_json(directory / name))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def now_iso() -> str:
|
|
201
|
+
return datetime.now(TIMEZONE).isoformat(timespec="seconds")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class MetadataStore:
|
|
205
|
+
"""Repository-local controller state. Read methods never create files."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, common_dir: Path, *, _migration_root: Path | None = None) -> None:
|
|
208
|
+
# Only the explicit layout preflight inspects the caller-supplied old root.
|
|
209
|
+
# Runtime commands never discover or select a previous namespace.
|
|
210
|
+
self.root = _migration_root or common_dir / ROOT_NAME / VERSION_NAME
|
|
211
|
+
self._migration_view = _migration_root is not None
|
|
212
|
+
if self.root.parent.parent != common_dir or self.root.name != VERSION_NAME:
|
|
213
|
+
raise ControllerError(
|
|
214
|
+
"INVALID_METADATA", "metadata root is outside its Git common directory"
|
|
215
|
+
)
|
|
216
|
+
self.repo_file = self.root / "repo.json"
|
|
217
|
+
self.records_dir = self.root / "records"
|
|
218
|
+
self.bindings_dir = self.root / "bindings"
|
|
219
|
+
self.transactions_dir = self.root / "transactions"
|
|
220
|
+
self.teardown_receipts_dir = self.root / "teardown-receipts"
|
|
221
|
+
self.completion_receipts_dir = self.root / "completion-receipts"
|
|
222
|
+
self.integration_intents_dir = self.root / "integration-intents"
|
|
223
|
+
self.prepared_candidate_receipts_dir = self.root / "prepared-candidate-receipts"
|
|
224
|
+
self.integration_reconciliation_receipts_dir = (
|
|
225
|
+
self.root / "integration-reconciliation-receipts"
|
|
226
|
+
)
|
|
227
|
+
self.lifecycle_events_dir = self.root / "lifecycle-events"
|
|
228
|
+
self.lifecycle_event_acks_dir = self.root / "lifecycle-event-acks"
|
|
229
|
+
self.release_intents_dir = self.root / "release-intents"
|
|
230
|
+
self.release_plans_dir = self.root / "release-plans"
|
|
231
|
+
self.publication_receipts_dir = self.root / "publication-receipts"
|
|
232
|
+
self.release_receipts_dir = self.root / "release-receipts"
|
|
233
|
+
self.registration_prune_receipts_dir = self.root / "registration-prune-receipts"
|
|
234
|
+
self.branch_retirement_plans_dir = self.root / "branch-retirement-plans"
|
|
235
|
+
self.branch_retirement_authorizations_dir = self.root / "branch-retirement-authorizations"
|
|
236
|
+
self.branch_retirement_receipts_dir = self.root / "branch-retirement-receipts"
|
|
237
|
+
self.reconciliation_receipts_dir = self.root / "reconciliation-receipts"
|
|
238
|
+
self.writer_leases_dir = self.root / "writer-leases"
|
|
239
|
+
self.writer_receipts_dir = self.root / "writer-reconciliation-receipts"
|
|
240
|
+
self.cleanup_actions_dir = self.root / "cleanup-actions"
|
|
241
|
+
self.cleanup_recoveries_dir = self.root / "cleanup-recoveries"
|
|
242
|
+
self.native_session_recoveries_dir = self.root / "native-session-recoveries"
|
|
243
|
+
self.session_closeouts_dir = self.root / "session-closeouts"
|
|
244
|
+
self.binding_registrations_dir = self.root / "binding-registrations"
|
|
245
|
+
self.publication_lease_file = self.root / "publication-lease.json"
|
|
246
|
+
self.lock_file = self.root / "repo.lock"
|
|
247
|
+
self._flock_depth = 0
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def flock_held(self) -> bool:
|
|
251
|
+
"""True only while this process actually holds the repository flock."""
|
|
252
|
+
return self._flock_depth > 0
|
|
253
|
+
|
|
254
|
+
@contextmanager
|
|
255
|
+
def lock(self, *, exclusive: bool, create: bool = False) -> Iterator[None]:
|
|
256
|
+
"""Serialize repository access; writers fail closed on any lock anomaly.
|
|
257
|
+
|
|
258
|
+
The lock file is the arbiter for every writer, so an exclusive request
|
|
259
|
+
without a usable regular-file lock never proceeds. A malformed lock
|
|
260
|
+
and a filesystem access failure both fail closed for every caller, with
|
|
261
|
+
separate error codes so doctor can identify the responsible layer.
|
|
262
|
+
Only a genuinely missing lock keeps the shared read path open, which
|
|
263
|
+
lets doctor diagnose the state it cannot lock. While a flock is
|
|
264
|
+
actually held ``_flock_depth`` records it, so terminal-evidence
|
|
265
|
+
classifiers can refuse to judge state they do not serialize against.
|
|
266
|
+
"""
|
|
267
|
+
def access_error(exc: OSError) -> ControllerError:
|
|
268
|
+
if exc.errno in {errno.EACCES, errno.EPERM, errno.EROFS}:
|
|
269
|
+
return ControllerError(
|
|
270
|
+
"FILESYSTEM_PERMISSION",
|
|
271
|
+
f"repository lock cannot be accessed with current filesystem permissions: "
|
|
272
|
+
f"{self.lock_file} ({errno.errorcode.get(exc.errno, 'UNKNOWN')})",
|
|
273
|
+
)
|
|
274
|
+
return ControllerError(
|
|
275
|
+
"REPOSITORY_LOCK_ACCESS_UNKNOWN",
|
|
276
|
+
f"repository lock access could not be classified: {self.lock_file} "
|
|
277
|
+
f"({errno.errorcode.get(exc.errno, 'UNKNOWN')})",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if create:
|
|
281
|
+
try:
|
|
282
|
+
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
283
|
+
except OSError as exc:
|
|
284
|
+
raise access_error(exc) from exc
|
|
285
|
+
descriptor: int | None = None
|
|
286
|
+
try:
|
|
287
|
+
lock_mode = os.lstat(self.lock_file).st_mode
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
lock_mode = None
|
|
290
|
+
except OSError as exc:
|
|
291
|
+
raise access_error(exc) from exc
|
|
292
|
+
if lock_mode is not None and not stat.S_ISREG(lock_mode):
|
|
293
|
+
raise ControllerError(
|
|
294
|
+
"REPOSITORY_LOCK_INVALID",
|
|
295
|
+
f"repository lock is not a regular file: {self.lock_file}",
|
|
296
|
+
)
|
|
297
|
+
if create or lock_mode is not None:
|
|
298
|
+
flags = os.O_RDWR | (os.O_CREAT if create else 0) | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
299
|
+
try:
|
|
300
|
+
candidate = os.open(self.lock_file, flags, 0o600)
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
if exc.errno not in {errno.ELOOP, errno.EISDIR}:
|
|
303
|
+
raise access_error(exc) from exc
|
|
304
|
+
raise ControllerError(
|
|
305
|
+
"REPOSITORY_LOCK_INVALID",
|
|
306
|
+
f"repository lock is no longer a regular file: {self.lock_file}",
|
|
307
|
+
) from exc
|
|
308
|
+
try:
|
|
309
|
+
candidate_mode = os.fstat(candidate).st_mode
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
os.close(candidate)
|
|
312
|
+
raise access_error(exc) from exc
|
|
313
|
+
if stat.S_ISREG(candidate_mode):
|
|
314
|
+
descriptor = candidate
|
|
315
|
+
else:
|
|
316
|
+
os.close(candidate)
|
|
317
|
+
raise ControllerError(
|
|
318
|
+
"REPOSITORY_LOCK_INVALID",
|
|
319
|
+
f"repository lock is not a regular file: {self.lock_file}",
|
|
320
|
+
)
|
|
321
|
+
elif exclusive:
|
|
322
|
+
raise ControllerError(
|
|
323
|
+
"MISSING_REPOSITORY_LOCK",
|
|
324
|
+
f"repository lock file is missing: mutations fail closed until {self.lock_file} "
|
|
325
|
+
"is recreated by a bootstrap command (adopt, acquire, enqueue-integration)",
|
|
326
|
+
)
|
|
327
|
+
if descriptor is None:
|
|
328
|
+
yield
|
|
329
|
+
return
|
|
330
|
+
held = False
|
|
331
|
+
try:
|
|
332
|
+
try:
|
|
333
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
raise access_error(exc) from exc
|
|
336
|
+
held = True
|
|
337
|
+
self._flock_depth += 1
|
|
338
|
+
try:
|
|
339
|
+
yield
|
|
340
|
+
finally:
|
|
341
|
+
self._flock_depth -= 1
|
|
342
|
+
finally:
|
|
343
|
+
try:
|
|
344
|
+
if held:
|
|
345
|
+
try:
|
|
346
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
347
|
+
except OSError as exc:
|
|
348
|
+
raise access_error(exc) from exc
|
|
349
|
+
finally:
|
|
350
|
+
os.close(descriptor)
|
|
351
|
+
|
|
352
|
+
def _decoded(
|
|
353
|
+
self, path: Path | os.DirEntry[str], decode: Callable[[Mapping[str, Any]], _Decoded]
|
|
354
|
+
) -> _Decoded:
|
|
355
|
+
cache = _DECODE_CACHE
|
|
356
|
+
if cache is None and not _BYTE_READS:
|
|
357
|
+
return decode(self._read_json(path))
|
|
358
|
+
entry = isinstance(path, os.DirEntry)
|
|
359
|
+
key = (decode.__qualname__, os.fspath(path)) if cache is not None else None
|
|
360
|
+
cached = cache.get(key) if cache is not None else None
|
|
361
|
+
if cached is not None:
|
|
362
|
+
try:
|
|
363
|
+
# Entries come from this load's scan and are never retained in the cache.
|
|
364
|
+
info = path.stat() if entry else os.stat(path)
|
|
365
|
+
if _identity(info) == cached[0]:
|
|
366
|
+
return cached[1]
|
|
367
|
+
except OSError:
|
|
368
|
+
pass
|
|
369
|
+
try:
|
|
370
|
+
with open(path, "rb") as handle:
|
|
371
|
+
identity = _identity(os.fstat(handle.fileno()))
|
|
372
|
+
raw = handle.read().decode("utf-8")
|
|
373
|
+
value = json.loads(raw)
|
|
374
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
375
|
+
raise ControllerError("INVALID_METADATA", f"cannot read {os.fspath(path)}: {exc}") from exc
|
|
376
|
+
if not isinstance(value, dict):
|
|
377
|
+
raise ControllerError("INVALID_METADATA", f"metadata must be an object: {os.fspath(path)}")
|
|
378
|
+
decoded = decode(value)
|
|
379
|
+
if cache is not None and key is not None:
|
|
380
|
+
cache[key] = (identity, decoded)
|
|
381
|
+
return decoded
|
|
382
|
+
|
|
383
|
+
def _read_json(self, path: Path | os.DirEntry[str]) -> Mapping[str, Any]:
|
|
384
|
+
try:
|
|
385
|
+
with open(path, encoding="utf-8") as handle:
|
|
386
|
+
raw = handle.read()
|
|
387
|
+
value = json.loads(raw)
|
|
388
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
389
|
+
raise ControllerError("INVALID_METADATA", f"cannot read {os.fspath(path)}: {exc}") from exc
|
|
390
|
+
if not isinstance(value, dict):
|
|
391
|
+
raise ControllerError("INVALID_METADATA", f"metadata must be an object: {os.fspath(path)}")
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
def _history(
|
|
395
|
+
self,
|
|
396
|
+
directory: Path,
|
|
397
|
+
decode: Callable[[Mapping[str, Any]], _Decoded],
|
|
398
|
+
identity: Callable[[_Decoded], str],
|
|
399
|
+
kind: str,
|
|
400
|
+
) -> dict[str, _Decoded]:
|
|
401
|
+
entries = _json_entries(directory)
|
|
402
|
+
cache = _HISTORY_CACHE
|
|
403
|
+
key = (decode.__qualname__, os.fspath(directory))
|
|
404
|
+
cached = cache.get(key) if cache is not None else None
|
|
405
|
+
if cached is not None:
|
|
406
|
+
identities, values = cached
|
|
407
|
+
try:
|
|
408
|
+
if len(entries) == len(identities) and all(
|
|
409
|
+
_identity(path.stat()) == identities.get(path.name) for path in entries
|
|
410
|
+
):
|
|
411
|
+
# Neither directory entries nor the caller's mutable mapping are retained.
|
|
412
|
+
return values.copy()
|
|
413
|
+
except OSError:
|
|
414
|
+
pass
|
|
415
|
+
values = {}
|
|
416
|
+
identities = {}
|
|
417
|
+
for path in entries:
|
|
418
|
+
value = self._decoded(path, decode)
|
|
419
|
+
identifier = identity(value)
|
|
420
|
+
if path.name != f"{identifier}.json":
|
|
421
|
+
message = (
|
|
422
|
+
f"record filename does not match allocation: {path.path}"
|
|
423
|
+
if kind == "record" else f"{kind} filename is invalid"
|
|
424
|
+
)
|
|
425
|
+
raise ControllerError("INVALID_METADATA", message)
|
|
426
|
+
if identifier in values:
|
|
427
|
+
message = (
|
|
428
|
+
f"duplicate allocation: {identifier}"
|
|
429
|
+
if kind == "record" else f"{kind} filename is invalid"
|
|
430
|
+
)
|
|
431
|
+
raise ControllerError("INVALID_METADATA", message)
|
|
432
|
+
values[identifier] = value
|
|
433
|
+
if cache is not None and _DECODE_CACHE is not None:
|
|
434
|
+
identities[path.name] = _DECODE_CACHE[(decode.__qualname__, path.path)][0]
|
|
435
|
+
if cache is not None:
|
|
436
|
+
cache[key] = (identities, values.copy())
|
|
437
|
+
return values
|
|
438
|
+
|
|
439
|
+
def _atomic_json(
|
|
440
|
+
self, path: Path, payload: Mapping[str, Any], *, compact: bool = False
|
|
441
|
+
) -> None:
|
|
442
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
443
|
+
encoded = (json.dumps(
|
|
444
|
+
payload, ensure_ascii=False, indent=None if compact else 2, sort_keys=True,
|
|
445
|
+
separators=(",", ":") if compact else None,
|
|
446
|
+
) + "\n").encode("utf-8")
|
|
447
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
448
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
449
|
+
)
|
|
450
|
+
temporary = Path(temporary_name)
|
|
451
|
+
try:
|
|
452
|
+
os.fchmod(descriptor, 0o600)
|
|
453
|
+
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
|
454
|
+
handle.write(encoded)
|
|
455
|
+
handle.flush()
|
|
456
|
+
os.fsync(handle.fileno())
|
|
457
|
+
os.replace(temporary, path)
|
|
458
|
+
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
459
|
+
try:
|
|
460
|
+
os.fsync(directory)
|
|
461
|
+
finally:
|
|
462
|
+
os.close(directory)
|
|
463
|
+
except BaseException:
|
|
464
|
+
with suppress(FileNotFoundError):
|
|
465
|
+
temporary.unlink()
|
|
466
|
+
raise
|
|
467
|
+
|
|
468
|
+
def _load_unlocked(self) -> MetadataSnapshot:
|
|
469
|
+
warnings: list[dict[str, str]] = []
|
|
470
|
+
repository_id: str | None = None
|
|
471
|
+
records: dict[str, MetadataRecord] = {}
|
|
472
|
+
bindings: dict[str, str] = {}
|
|
473
|
+
integration_intents: dict[str, IntegrationIntent] = {}
|
|
474
|
+
prepared_candidate_receipts: dict[str, PreparedCandidateReceipt] = {}
|
|
475
|
+
integration_reconciliation_receipts: dict[str, IntegrationReconciliationReceipt] = {}
|
|
476
|
+
release_intents: dict[str, ReleaseIntent] = {}
|
|
477
|
+
release_plans: dict[str, ReleasePlan] = {}
|
|
478
|
+
publication_lease: PublicationLease | None = None
|
|
479
|
+
publication_receipts: dict[str, PublicationReceipt] = {}
|
|
480
|
+
release_receipts: dict[str, ReleaseReceipt] = {}
|
|
481
|
+
reconciliation_receipts: dict[str, ReconciliationReceipt] = {}
|
|
482
|
+
if self.repo_file.exists():
|
|
483
|
+
repo = self._read_json(self.repo_file)
|
|
484
|
+
if (
|
|
485
|
+
set(repo)
|
|
486
|
+
!= {
|
|
487
|
+
"schema_version",
|
|
488
|
+
"repository_id",
|
|
489
|
+
"created_at",
|
|
490
|
+
"updated_at",
|
|
491
|
+
}
|
|
492
|
+
or repo.get("schema_version") not in SUPPORTED_METADATA_SCHEMA_VERSIONS
|
|
493
|
+
):
|
|
494
|
+
raise ControllerError("INVALID_METADATA", "repo.json schema is invalid")
|
|
495
|
+
repository_id = _nonempty_string(repo.get("repository_id"), "repository_id")
|
|
496
|
+
try:
|
|
497
|
+
uuid.UUID(repository_id)
|
|
498
|
+
except ValueError as exc:
|
|
499
|
+
raise ControllerError("INVALID_METADATA", "repository_id must be a UUID") from exc
|
|
500
|
+
_validate_timestamp(repo.get("created_at"), "created_at")
|
|
501
|
+
_validate_timestamp(repo.get("updated_at"), "updated_at")
|
|
502
|
+
elif self.root.exists():
|
|
503
|
+
warnings.append({"code": "MISSING_REPO_METADATA", "message": "repo.json is missing"})
|
|
504
|
+
|
|
505
|
+
if self.records_dir.exists():
|
|
506
|
+
records = self._history(
|
|
507
|
+
self.records_dir, record_from_dict, lambda record: record.allocation_id, "record"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
if self.bindings_dir.exists():
|
|
511
|
+
for path in _glob_json(self.bindings_dir):
|
|
512
|
+
binding = self._read_json(path)
|
|
513
|
+
if set(binding) != {"schema_version", "allocation_id"}:
|
|
514
|
+
raise ControllerError("INVALID_METADATA", f"binding schema is invalid: {path}")
|
|
515
|
+
if binding.get("schema_version") not in SUPPORTED_METADATA_SCHEMA_VERSIONS:
|
|
516
|
+
raise ControllerError("INVALID_METADATA", f"binding version is invalid: {path}")
|
|
517
|
+
worktree_key = path.stem
|
|
518
|
+
allocation_id = _nonempty_string(binding.get("allocation_id"), "allocation_id")
|
|
519
|
+
bindings[worktree_key] = allocation_id
|
|
520
|
+
for worktree_key, allocation_id in self._drifted_bindings(bindings).items():
|
|
521
|
+
# The registration this binding was written for is gone: the Git
|
|
522
|
+
# administrative directory now belongs to another worktree.
|
|
523
|
+
del bindings[worktree_key]
|
|
524
|
+
warnings.append(
|
|
525
|
+
{
|
|
526
|
+
"code": "BINDING_IDENTITY_DRIFT",
|
|
527
|
+
"message": "a different Git registration reuses this bound worktree key",
|
|
528
|
+
"worktree_key": worktree_key,
|
|
529
|
+
"allocation_id": allocation_id,
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if self.publication_lease_file.exists():
|
|
534
|
+
publication_lease = publication_lease_from_dict(
|
|
535
|
+
self._read_json(self.publication_lease_file)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if self.integration_intents_dir.exists():
|
|
539
|
+
allocations: set[str] = set()
|
|
540
|
+
for path in _glob_json(self.integration_intents_dir):
|
|
541
|
+
intent = self._decoded(path, integration_intent_from_dict)
|
|
542
|
+
if path.stem != intent.intent_id or intent.intent_id in integration_intents:
|
|
543
|
+
raise ControllerError(
|
|
544
|
+
"INVALID_METADATA", "integration intent filename or identity is invalid"
|
|
545
|
+
)
|
|
546
|
+
if intent.allocation_id is not None:
|
|
547
|
+
if intent.allocation_id in allocations:
|
|
548
|
+
raise ControllerError(
|
|
549
|
+
"INVALID_METADATA", "multiple intents bind one allocation"
|
|
550
|
+
)
|
|
551
|
+
allocations.add(intent.allocation_id)
|
|
552
|
+
integration_intents[intent.intent_id] = intent
|
|
553
|
+
|
|
554
|
+
if self.prepared_candidate_receipts_dir.exists():
|
|
555
|
+
prepared_candidate_receipts = self._history(
|
|
556
|
+
self.prepared_candidate_receipts_dir, prepared_candidate_receipt_from_dict,
|
|
557
|
+
lambda receipt: receipt.receipt_id, "prepared candidate receipt"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if self.integration_reconciliation_receipts_dir.exists():
|
|
561
|
+
for path in _glob_json(self.integration_reconciliation_receipts_dir):
|
|
562
|
+
receipt = self._decoded(path, integration_reconciliation_receipt_from_dict)
|
|
563
|
+
if (
|
|
564
|
+
path.stem != receipt.receipt_id
|
|
565
|
+
or receipt.receipt_id in integration_reconciliation_receipts
|
|
566
|
+
):
|
|
567
|
+
raise ControllerError(
|
|
568
|
+
"INVALID_METADATA",
|
|
569
|
+
"integration reconciliation receipt filename is invalid",
|
|
570
|
+
)
|
|
571
|
+
integration_reconciliation_receipts[receipt.receipt_id] = receipt
|
|
572
|
+
|
|
573
|
+
if self.lifecycle_events_dir.exists():
|
|
574
|
+
event_ids: set[str] = set()
|
|
575
|
+
event_sequences: set[int] = set()
|
|
576
|
+
for path in _glob_json(self.lifecycle_events_dir):
|
|
577
|
+
event = self._decoded(path, lifecycle_event_from_dict)
|
|
578
|
+
if (
|
|
579
|
+
path.stem != event.event_id
|
|
580
|
+
or event.event_id in event_ids
|
|
581
|
+
or event.sequence < 1
|
|
582
|
+
or event.sequence in event_sequences
|
|
583
|
+
):
|
|
584
|
+
raise ControllerError(
|
|
585
|
+
"INVALID_METADATA", "lifecycle event identity or sequence is invalid"
|
|
586
|
+
)
|
|
587
|
+
event_ids.add(event.event_id)
|
|
588
|
+
event_sequences.add(event.sequence)
|
|
589
|
+
if self.lifecycle_event_acks_dir.exists():
|
|
590
|
+
for path in _glob_nested_json(self.lifecycle_event_acks_dir):
|
|
591
|
+
ack = self._read_json(path)
|
|
592
|
+
if set(ack) != {"schema_version", "event_id", "consumer", "acked_at"}:
|
|
593
|
+
raise ControllerError("INVALID_METADATA", "lifecycle event ack is invalid")
|
|
594
|
+
if ack.get("schema_version") != LIFECYCLE_EVENT_SCHEMA_VERSION:
|
|
595
|
+
raise ControllerError(
|
|
596
|
+
"INVALID_METADATA", "lifecycle event ack version is invalid"
|
|
597
|
+
)
|
|
598
|
+
event_id = _optional_uuid(ack.get("event_id"), "event_id")
|
|
599
|
+
consumer = _nonempty_string(ack.get("consumer"), "consumer")
|
|
600
|
+
if event_id is None or path.stem != event_id:
|
|
601
|
+
raise ControllerError(
|
|
602
|
+
"INVALID_METADATA", "lifecycle event ack identity is invalid"
|
|
603
|
+
)
|
|
604
|
+
if path.parent.name != hashlib.sha256(consumer.encode("utf-8")).hexdigest():
|
|
605
|
+
raise ControllerError(
|
|
606
|
+
"INVALID_METADATA", "lifecycle event ack consumer differs"
|
|
607
|
+
)
|
|
608
|
+
_validate_timestamp(ack.get("acked_at"), "acked_at")
|
|
609
|
+
|
|
610
|
+
if self.publication_receipts_dir.exists():
|
|
611
|
+
publication_receipts = self._history(
|
|
612
|
+
self.publication_receipts_dir, publication_receipt_from_dict,
|
|
613
|
+
lambda receipt: receipt.receipt_id, "publication receipt"
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
if self.release_intents_dir.exists():
|
|
617
|
+
for path in _glob_json(self.release_intents_dir):
|
|
618
|
+
intent = self._decoded(path, release_intent_from_dict)
|
|
619
|
+
if path.stem != intent.intent_id or intent.intent_id in release_intents:
|
|
620
|
+
raise ControllerError("INVALID_METADATA", "release intent filename is invalid")
|
|
621
|
+
release_intents[intent.intent_id] = intent
|
|
622
|
+
|
|
623
|
+
if self.release_plans_dir.exists():
|
|
624
|
+
release_plans = self._history(
|
|
625
|
+
self.release_plans_dir, release_plan_from_dict, lambda plan: plan.plan_id,
|
|
626
|
+
"release plan"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
if self.release_receipts_dir.exists():
|
|
630
|
+
release_receipts = self._history(
|
|
631
|
+
self.release_receipts_dir, release_receipt_from_dict,
|
|
632
|
+
lambda receipt: receipt.receipt_id, "release receipt"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if self.reconciliation_receipts_dir.exists():
|
|
636
|
+
for path in _glob_json(self.reconciliation_receipts_dir):
|
|
637
|
+
receipt = self._decoded(path, reconciliation_receipt_from_dict)
|
|
638
|
+
if path.stem != receipt.receipt_id or receipt.receipt_id in reconciliation_receipts:
|
|
639
|
+
raise ControllerError(
|
|
640
|
+
"INVALID_METADATA", "reconciliation receipt filename is invalid"
|
|
641
|
+
)
|
|
642
|
+
reconciliation_receipts[receipt.receipt_id] = receipt
|
|
643
|
+
|
|
644
|
+
if self.teardown_receipts_dir.exists():
|
|
645
|
+
for path in _glob_json(self.teardown_receipts_dir):
|
|
646
|
+
receipt = validate_teardown_receipt(self._read_json(path))
|
|
647
|
+
if path.stem != receipt["receipt_id"]:
|
|
648
|
+
raise ControllerError(
|
|
649
|
+
"INVALID_METADATA", f"teardown receipt filename is invalid: {path}"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
writer_leases: dict[str, WriterLease] = {}
|
|
653
|
+
if self.writer_leases_dir.exists():
|
|
654
|
+
for path in _glob_json(self.writer_leases_dir):
|
|
655
|
+
value = self._read_json(path)
|
|
656
|
+
if self._migration_view and value.get("schema_version") == 1:
|
|
657
|
+
from .activation_layout import lease_bytes
|
|
658
|
+
|
|
659
|
+
# The explicit, locked layout preflight converts a read view
|
|
660
|
+
# only. Normal runtime parsing remains schema 2; the actual
|
|
661
|
+
# lease bytes and all managed-runtime gates remain unchanged.
|
|
662
|
+
value = json.loads(lease_bytes(json.dumps(value).encode()))
|
|
663
|
+
lease = writer_lease_from_dict(value)
|
|
664
|
+
if path.stem != lease.allocation_id or lease.allocation_id in writer_leases:
|
|
665
|
+
raise ControllerError("INVALID_METADATA", "writer lease filename is invalid")
|
|
666
|
+
writer_leases[lease.allocation_id] = lease
|
|
667
|
+
|
|
668
|
+
return MetadataSnapshot(
|
|
669
|
+
repository_id=repository_id,
|
|
670
|
+
records=records,
|
|
671
|
+
bindings=bindings,
|
|
672
|
+
integration_intents=integration_intents,
|
|
673
|
+
prepared_candidate_receipts=prepared_candidate_receipts,
|
|
674
|
+
integration_reconciliation_receipts=integration_reconciliation_receipts,
|
|
675
|
+
release_intents=release_intents,
|
|
676
|
+
release_plans=release_plans,
|
|
677
|
+
publication_lease=publication_lease,
|
|
678
|
+
publication_receipts=publication_receipts,
|
|
679
|
+
release_receipts=release_receipts,
|
|
680
|
+
reconciliation_receipts=reconciliation_receipts,
|
|
681
|
+
writer_leases=writer_leases,
|
|
682
|
+
warnings=tuple(warnings),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def load(self) -> MetadataSnapshot:
|
|
686
|
+
with self.lock(exclusive=False):
|
|
687
|
+
return self._load_unlocked()
|
|
688
|
+
|
|
689
|
+
@property
|
|
690
|
+
def common_dir(self) -> Path:
|
|
691
|
+
return self.root.parent.parent
|
|
692
|
+
|
|
693
|
+
def _registration_admin(self, worktree_key: str, admin_dir: object) -> Path:
|
|
694
|
+
"""The Git administrative directory that derives ``worktree_key``."""
|
|
695
|
+
parts = PurePosixPath(admin_dir).parts if isinstance(admin_dir, str) else ()
|
|
696
|
+
if (
|
|
697
|
+
len(parts) != 2
|
|
698
|
+
or parts[0] != "worktrees"
|
|
699
|
+
or parts[1] in {".", ".."}
|
|
700
|
+
or admin_dir != "/".join(parts)
|
|
701
|
+
):
|
|
702
|
+
raise ControllerError("INVALID_METADATA", "registration admin_dir is invalid")
|
|
703
|
+
digest = hashlib.sha256(str(admin_dir).encode("utf-8")).hexdigest()
|
|
704
|
+
if worktree_key != f"wt_{digest}":
|
|
705
|
+
raise ControllerError("INVALID_METADATA", "registration admin_dir differs from its key")
|
|
706
|
+
return self.common_dir / parts[0] / parts[1]
|
|
707
|
+
|
|
708
|
+
def _registrations(self) -> dict[str, dict[str, str]]:
|
|
709
|
+
registrations: dict[str, dict[str, str]] = {}
|
|
710
|
+
for path in _glob_json(self.binding_registrations_dir):
|
|
711
|
+
value = self._read_json(path)
|
|
712
|
+
if (
|
|
713
|
+
set(value) != {"schema_version", "contract", "allocation_id", "admin_dir", "token"}
|
|
714
|
+
or value.get("schema_version") not in SUPPORTED_METADATA_SCHEMA_VERSIONS
|
|
715
|
+
or value.get("contract") != REGISTRATION_CONTRACT
|
|
716
|
+
):
|
|
717
|
+
raise ControllerError("INVALID_METADATA", f"registration schema is invalid: {path}")
|
|
718
|
+
key = path.stem
|
|
719
|
+
if not key.startswith("wt_") or len(key) != 67:
|
|
720
|
+
raise ControllerError("INVALID_METADATA", f"registration filename is invalid: {path}")
|
|
721
|
+
self._registration_admin(key, value.get("admin_dir"))
|
|
722
|
+
registrations[key] = {
|
|
723
|
+
"allocation_id": _nonempty_string(value.get("allocation_id"), "allocation_id"),
|
|
724
|
+
"admin_dir": str(value["admin_dir"]),
|
|
725
|
+
"token": str(_optional_uuid(value.get("token"), "token") or ""),
|
|
726
|
+
}
|
|
727
|
+
if not registrations[key]["token"]:
|
|
728
|
+
raise ControllerError("INVALID_METADATA", f"registration token is missing: {path}")
|
|
729
|
+
return registrations
|
|
730
|
+
|
|
731
|
+
def _marker_matches(self, admin: Path, expected: Mapping[str, str]) -> bool:
|
|
732
|
+
marker = admin / REGISTRATION_MARKER
|
|
733
|
+
try:
|
|
734
|
+
descriptor = os.open(marker, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
735
|
+
except OSError:
|
|
736
|
+
return False
|
|
737
|
+
try:
|
|
738
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
739
|
+
return False
|
|
740
|
+
with os.fdopen(descriptor, "rb", closefd=False) as handle:
|
|
741
|
+
raw = handle.read(_REGISTRATION_MARKER_LIMIT + 1)
|
|
742
|
+
except OSError:
|
|
743
|
+
return False
|
|
744
|
+
finally:
|
|
745
|
+
os.close(descriptor)
|
|
746
|
+
if len(raw) > _REGISTRATION_MARKER_LIMIT:
|
|
747
|
+
return False
|
|
748
|
+
try:
|
|
749
|
+
value = json.loads(raw.decode("utf-8"))
|
|
750
|
+
except (UnicodeError, json.JSONDecodeError):
|
|
751
|
+
return False
|
|
752
|
+
return value == {
|
|
753
|
+
"contract": REGISTRATION_CONTRACT,
|
|
754
|
+
"allocation_id": expected["allocation_id"],
|
|
755
|
+
"token": expected["token"],
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
def _drifted_bindings(self, bindings: Mapping[str, str]) -> dict[str, str]:
|
|
759
|
+
"""Bindings whose live Git registration is not the one they were written for.
|
|
760
|
+
|
|
761
|
+
A binding without a matching registration document predates registration
|
|
762
|
+
identity (or belongs to the canonical checkout) and keeps its previous
|
|
763
|
+
meaning. An administrative directory that no longer exists is the existing
|
|
764
|
+
orphan-binding case, which interrupted release recovery still relies on.
|
|
765
|
+
"""
|
|
766
|
+
drifted: dict[str, str] = {}
|
|
767
|
+
for key, registration in self._registrations().items():
|
|
768
|
+
allocation_id = bindings.get(key)
|
|
769
|
+
if allocation_id is None or allocation_id != registration["allocation_id"]:
|
|
770
|
+
continue # Stale document from an older runtime's release; not authority.
|
|
771
|
+
admin = self._registration_admin(key, registration["admin_dir"])
|
|
772
|
+
if not os.path.lexists(admin):
|
|
773
|
+
continue
|
|
774
|
+
if not self._marker_matches(admin, registration):
|
|
775
|
+
drifted[key] = allocation_id
|
|
776
|
+
return drifted
|
|
777
|
+
|
|
778
|
+
def _write_registration(self, worktree_key: str, allocation_id: str, admin: Path) -> None:
|
|
779
|
+
try:
|
|
780
|
+
relative = admin.relative_to(self.common_dir).as_posix()
|
|
781
|
+
except ValueError as exc:
|
|
782
|
+
raise ControllerError(
|
|
783
|
+
"INVALID_METADATA", "registration is outside the Git common directory"
|
|
784
|
+
) from exc
|
|
785
|
+
self._registration_admin(worktree_key, relative)
|
|
786
|
+
if admin.is_symlink() or not admin.is_dir():
|
|
787
|
+
raise ControllerError("BLOCK_DRIFT", "Git worktree administrative directory is missing")
|
|
788
|
+
token = str(uuid.uuid4())
|
|
789
|
+
self._atomic_json(
|
|
790
|
+
admin / REGISTRATION_MARKER,
|
|
791
|
+
{"contract": REGISTRATION_CONTRACT, "allocation_id": allocation_id, "token": token},
|
|
792
|
+
)
|
|
793
|
+
self._atomic_json(
|
|
794
|
+
self.binding_registrations_dir / f"{worktree_key}.json",
|
|
795
|
+
{
|
|
796
|
+
"schema_version": METADATA_SCHEMA_VERSION,
|
|
797
|
+
"contract": REGISTRATION_CONTRACT,
|
|
798
|
+
"allocation_id": allocation_id,
|
|
799
|
+
"admin_dir": relative,
|
|
800
|
+
"token": token,
|
|
801
|
+
},
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
def initialize_repository(self) -> str:
|
|
805
|
+
if self.repo_file.exists():
|
|
806
|
+
snapshot = self._load_unlocked()
|
|
807
|
+
if snapshot.repository_id is None:
|
|
808
|
+
raise ControllerError("INVALID_METADATA", "repo.json is missing")
|
|
809
|
+
return snapshot.repository_id
|
|
810
|
+
timestamp = now_iso()
|
|
811
|
+
repository_id = str(uuid.uuid4())
|
|
812
|
+
self._atomic_json(
|
|
813
|
+
self.repo_file,
|
|
814
|
+
{
|
|
815
|
+
"schema_version": METADATA_SCHEMA_VERSION,
|
|
816
|
+
"repository_id": repository_id,
|
|
817
|
+
"created_at": timestamp,
|
|
818
|
+
"updated_at": timestamp,
|
|
819
|
+
},
|
|
820
|
+
)
|
|
821
|
+
return repository_id
|
|
822
|
+
|
|
823
|
+
def write_record(
|
|
824
|
+
self, worktree_key: str, record: MetadataRecord, *, registration: Path | None = None
|
|
825
|
+
) -> None:
|
|
826
|
+
"""Bind ``record`` to a worktree key.
|
|
827
|
+
|
|
828
|
+
``registration`` is the linked worktree's Git administrative directory. When
|
|
829
|
+
given, the binding is tied to that exact registration before it is written,
|
|
830
|
+
so a later registration reusing the name cannot inherit it.
|
|
831
|
+
"""
|
|
832
|
+
if not worktree_key.startswith("wt_") or len(worktree_key) != 67:
|
|
833
|
+
raise ControllerError("INVALID_METADATA", "invalid worktree key")
|
|
834
|
+
# One chokepoint for every new governed occupancy, so a retirement grant
|
|
835
|
+
# cannot be re-occupied by any verb that funnels through here. Updating a
|
|
836
|
+
# record whose binding already exists is not occupancy and stays allowed.
|
|
837
|
+
if not (self.bindings_dir / f"{worktree_key}.json").exists():
|
|
838
|
+
# A record carries no branch, so the fence here is the allocation half;
|
|
839
|
+
# the branch half is refused by acquire's own BRANCH_EXISTS rule and
|
|
840
|
+
# caught at settle by the grant's observation vector.
|
|
841
|
+
from .branch_retirement import retirement_occupancy_blocker
|
|
842
|
+
|
|
843
|
+
blocked = retirement_occupancy_blocker(self, allocation_id=record.allocation_id)
|
|
844
|
+
if blocked is not None:
|
|
845
|
+
raise blocked
|
|
846
|
+
if registration is not None:
|
|
847
|
+
# Written before the binding: an interruption leaves an unreferenced
|
|
848
|
+
# registration, never a binding that claims an unverified registration.
|
|
849
|
+
self._write_registration(worktree_key, record.allocation_id, registration)
|
|
850
|
+
self._atomic_json(self.records_dir / f"{record.allocation_id}.json", record_to_dict(record))
|
|
851
|
+
self._atomic_json(
|
|
852
|
+
self.bindings_dir / f"{worktree_key}.json",
|
|
853
|
+
{
|
|
854
|
+
"schema_version": METADATA_SCHEMA_VERSION,
|
|
855
|
+
"allocation_id": record.allocation_id,
|
|
856
|
+
},
|
|
857
|
+
)
|
|
858
|
+
repo = dict(self._read_json(self.repo_file))
|
|
859
|
+
repo["schema_version"] = METADATA_SCHEMA_VERSION
|
|
860
|
+
repo["updated_at"] = now_iso()
|
|
861
|
+
self._atomic_json(self.repo_file, repo)
|
|
862
|
+
|
|
863
|
+
def replace_record(self, record: MetadataRecord) -> None:
|
|
864
|
+
path = self.records_dir / f"{record.allocation_id}.json"
|
|
865
|
+
if not path.is_file():
|
|
866
|
+
raise ControllerError("BLOCK_DRIFT", "metadata record disappeared")
|
|
867
|
+
self._atomic_json(path, record_to_dict(record))
|
|
868
|
+
self.touch_repository()
|
|
869
|
+
|
|
870
|
+
def remove_binding(self, worktree_key: str) -> None:
|
|
871
|
+
path = self.bindings_dir / f"{worktree_key}.json"
|
|
872
|
+
try:
|
|
873
|
+
path.unlink()
|
|
874
|
+
except FileNotFoundError as exc:
|
|
875
|
+
raise ControllerError("BLOCK_DRIFT", "metadata binding disappeared") from exc
|
|
876
|
+
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
877
|
+
try:
|
|
878
|
+
os.fsync(directory)
|
|
879
|
+
finally:
|
|
880
|
+
os.close(directory)
|
|
881
|
+
registration = self.binding_registrations_dir / f"{worktree_key}.json"
|
|
882
|
+
if registration.exists():
|
|
883
|
+
# After the binding: an interruption leaves a document no binding uses.
|
|
884
|
+
registration.unlink()
|
|
885
|
+
directory = os.open(registration.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
886
|
+
try:
|
|
887
|
+
os.fsync(directory)
|
|
888
|
+
finally:
|
|
889
|
+
os.close(directory)
|
|
890
|
+
self.touch_repository()
|
|
891
|
+
|
|
892
|
+
def touch_repository(self) -> None:
|
|
893
|
+
repo = dict(self._read_json(self.repo_file))
|
|
894
|
+
repo["schema_version"] = METADATA_SCHEMA_VERSION
|
|
895
|
+
repo["updated_at"] = now_iso()
|
|
896
|
+
self._atomic_json(self.repo_file, repo)
|
|
897
|
+
|
|
898
|
+
def write_writer_lease(self, lease: WriterLease) -> None:
|
|
899
|
+
"""Persist one writer lease, keyed by allocation so single-writer is structural."""
|
|
900
|
+
self.writer_leases_dir.mkdir(parents=True, exist_ok=True)
|
|
901
|
+
self._atomic_json(
|
|
902
|
+
self.writer_leases_dir / f"{lease.allocation_id}.json", writer_lease_to_dict(lease)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
def remove_writer_lease(self, allocation_id: str) -> bool:
|
|
906
|
+
path = self.writer_leases_dir / f"{allocation_id}.json"
|
|
907
|
+
if not path.exists():
|
|
908
|
+
return False
|
|
909
|
+
path.unlink()
|
|
910
|
+
return True
|
|
911
|
+
|
|
912
|
+
def write_writer_receipt(self, receipt: WriterReconciliationReceipt) -> None:
|
|
913
|
+
self.writer_receipts_dir.mkdir(parents=True, exist_ok=True)
|
|
914
|
+
self._atomic_json(
|
|
915
|
+
self.writer_receipts_dir / f"{receipt.receipt_id}.json", writer_receipt_to_dict(receipt)
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
def write_publication_lease(self, lease: PublicationLease) -> None:
|
|
919
|
+
if self.publication_lease_file.exists():
|
|
920
|
+
raise ControllerError("PUBLICATION_LEASE_HELD", "main publication lease is occupied")
|
|
921
|
+
self._atomic_json(self.publication_lease_file, publication_lease_to_dict(lease))
|
|
922
|
+
self.touch_repository()
|
|
923
|
+
|
|
924
|
+
def write_integration_intent(self, intent: IntegrationIntent) -> None:
|
|
925
|
+
path = self.integration_intents_dir / f"{intent.intent_id}.json"
|
|
926
|
+
if path.exists():
|
|
927
|
+
raise ControllerError("INTENT_EXISTS", "integration intent already exists")
|
|
928
|
+
self._atomic_json(path, integration_intent_to_dict(intent))
|
|
929
|
+
self.touch_repository()
|
|
930
|
+
|
|
931
|
+
def replace_integration_intent(self, intent: IntegrationIntent) -> None:
|
|
932
|
+
path = self.integration_intents_dir / f"{intent.intent_id}.json"
|
|
933
|
+
if not path.is_file():
|
|
934
|
+
raise ControllerError("BLOCK_DRIFT", "integration intent disappeared")
|
|
935
|
+
self._atomic_json(path, integration_intent_to_dict(intent))
|
|
936
|
+
self.touch_repository()
|
|
937
|
+
|
|
938
|
+
def write_prepared_candidate_receipt(self, receipt: PreparedCandidateReceipt) -> None:
|
|
939
|
+
path = self.prepared_candidate_receipts_dir / f"{receipt.receipt_id}.json"
|
|
940
|
+
if path.exists():
|
|
941
|
+
current = prepared_candidate_receipt_from_dict(self._read_json(path))
|
|
942
|
+
if current != receipt:
|
|
943
|
+
raise ControllerError("RECEIPT_MISMATCH", "prepared receipt already differs")
|
|
944
|
+
return
|
|
945
|
+
self._atomic_json(path, prepared_candidate_receipt_to_dict(receipt))
|
|
946
|
+
self.touch_repository()
|
|
947
|
+
|
|
948
|
+
def write_integration_reconciliation_receipt(
|
|
949
|
+
self, receipt: IntegrationReconciliationReceipt
|
|
950
|
+
) -> None:
|
|
951
|
+
path = self.integration_reconciliation_receipts_dir / f"{receipt.receipt_id}.json"
|
|
952
|
+
if path.exists():
|
|
953
|
+
current = integration_reconciliation_receipt_from_dict(self._read_json(path))
|
|
954
|
+
if current != receipt:
|
|
955
|
+
raise ControllerError(
|
|
956
|
+
"RECEIPT_MISMATCH", "integration reconciliation receipt already differs"
|
|
957
|
+
)
|
|
958
|
+
return
|
|
959
|
+
self._atomic_json(path, integration_reconciliation_receipt_to_dict(receipt))
|
|
960
|
+
self.touch_repository()
|
|
961
|
+
|
|
962
|
+
def write_lifecycle_event(self, event: LifecycleEvent) -> None:
|
|
963
|
+
path = self.lifecycle_events_dir / f"{event.event_id}.json"
|
|
964
|
+
if path.exists():
|
|
965
|
+
current = lifecycle_event_from_dict(self._read_json(path))
|
|
966
|
+
if current != event:
|
|
967
|
+
raise ControllerError("EVENT_MISMATCH", "lifecycle event already differs")
|
|
968
|
+
return
|
|
969
|
+
self._atomic_json(path, lifecycle_event_to_dict(event))
|
|
970
|
+
self.touch_repository()
|
|
971
|
+
|
|
972
|
+
def _event_ack_path(self, consumer: str, event_id: str) -> Path:
|
|
973
|
+
normalized = _nonempty_string(consumer, "consumer")
|
|
974
|
+
if len(normalized) > 256:
|
|
975
|
+
raise ControllerError("INVALID_CONSUMER", "event consumer is too long")
|
|
976
|
+
directory = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
977
|
+
return self.lifecycle_event_acks_dir / directory / f"{event_id}.json"
|
|
978
|
+
|
|
979
|
+
def list_lifecycle_events(self, consumer: str) -> tuple[LifecycleEvent, ...]:
|
|
980
|
+
with self.lock(exclusive=False):
|
|
981
|
+
events = (
|
|
982
|
+
[
|
|
983
|
+
lifecycle_event_from_dict(self._read_json(path))
|
|
984
|
+
for path in _glob_json(self.lifecycle_events_dir)
|
|
985
|
+
]
|
|
986
|
+
if self.lifecycle_events_dir.exists()
|
|
987
|
+
else []
|
|
988
|
+
)
|
|
989
|
+
return tuple(
|
|
990
|
+
event
|
|
991
|
+
for event in sorted(events, key=lambda item: (item.sequence, item.event_id))
|
|
992
|
+
if not self._event_ack_path(consumer, event.event_id).exists()
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
def acknowledge_lifecycle_event(self, consumer: str, event_id: str) -> bool:
|
|
996
|
+
try:
|
|
997
|
+
uuid.UUID(event_id)
|
|
998
|
+
except ValueError as exc:
|
|
999
|
+
raise ControllerError("INVALID_EVENT", "event id must be a UUID") from exc
|
|
1000
|
+
event_path = self.lifecycle_events_dir / f"{event_id}.json"
|
|
1001
|
+
if not event_path.is_file():
|
|
1002
|
+
raise ControllerError("MISSING_EVENT", "lifecycle event is missing")
|
|
1003
|
+
lifecycle_event_from_dict(self._read_json(event_path))
|
|
1004
|
+
path = self._event_ack_path(consumer, event_id)
|
|
1005
|
+
if path.exists():
|
|
1006
|
+
ack = self._read_json(path)
|
|
1007
|
+
if ack.get("event_id") != event_id or ack.get("consumer") != consumer.strip():
|
|
1008
|
+
raise ControllerError("EVENT_ACK_MISMATCH", "event acknowledgement differs")
|
|
1009
|
+
return False
|
|
1010
|
+
self._atomic_json(
|
|
1011
|
+
path,
|
|
1012
|
+
{
|
|
1013
|
+
"schema_version": LIFECYCLE_EVENT_SCHEMA_VERSION,
|
|
1014
|
+
"event_id": event_id,
|
|
1015
|
+
"consumer": consumer.strip(),
|
|
1016
|
+
"acked_at": now_iso(),
|
|
1017
|
+
},
|
|
1018
|
+
)
|
|
1019
|
+
self.touch_repository()
|
|
1020
|
+
return True
|
|
1021
|
+
|
|
1022
|
+
def write_release_intent(self, intent: ReleaseIntent) -> None:
|
|
1023
|
+
path = self.release_intents_dir / f"{intent.intent_id}.json"
|
|
1024
|
+
if path.exists():
|
|
1025
|
+
current = release_intent_from_dict(self._read_json(path))
|
|
1026
|
+
if current != intent:
|
|
1027
|
+
raise ControllerError("INTENT_MISMATCH", "release intent already differs")
|
|
1028
|
+
return
|
|
1029
|
+
self._atomic_json(path, release_intent_to_dict(intent))
|
|
1030
|
+
self.touch_repository()
|
|
1031
|
+
|
|
1032
|
+
def replace_release_intent(self, intent: ReleaseIntent) -> None:
|
|
1033
|
+
path = self.release_intents_dir / f"{intent.intent_id}.json"
|
|
1034
|
+
if not path.is_file():
|
|
1035
|
+
raise ControllerError("BLOCK_DRIFT", "release intent disappeared")
|
|
1036
|
+
self._atomic_json(path, release_intent_to_dict(intent))
|
|
1037
|
+
self.touch_repository()
|
|
1038
|
+
|
|
1039
|
+
def write_release_plan(self, plan: ReleasePlan) -> None:
|
|
1040
|
+
path = self.release_plans_dir / f"{plan.plan_id}.json"
|
|
1041
|
+
if path.exists():
|
|
1042
|
+
current = release_plan_from_dict(self._read_json(path))
|
|
1043
|
+
if current != plan:
|
|
1044
|
+
raise ControllerError("PLAN_MISMATCH", "release plan already differs")
|
|
1045
|
+
return
|
|
1046
|
+
self._atomic_json(path, release_plan_to_dict(plan))
|
|
1047
|
+
self.touch_repository()
|
|
1048
|
+
|
|
1049
|
+
def remove_release_plan(self, plan_id: str) -> bool:
|
|
1050
|
+
path = self.release_plans_dir / f"{plan_id}.json"
|
|
1051
|
+
try:
|
|
1052
|
+
path.unlink()
|
|
1053
|
+
except FileNotFoundError:
|
|
1054
|
+
return False
|
|
1055
|
+
return True
|
|
1056
|
+
|
|
1057
|
+
def write_publication_receipt(self, receipt: PublicationReceipt) -> None:
|
|
1058
|
+
path = self.publication_receipts_dir / f"{receipt.receipt_id}.json"
|
|
1059
|
+
if path.exists():
|
|
1060
|
+
current = publication_receipt_from_dict(self._read_json(path))
|
|
1061
|
+
if current != receipt:
|
|
1062
|
+
raise ControllerError("RECEIPT_MISMATCH", "publication receipt already differs")
|
|
1063
|
+
return
|
|
1064
|
+
self._atomic_json(path, publication_receipt_to_dict(receipt))
|
|
1065
|
+
self.touch_repository()
|
|
1066
|
+
|
|
1067
|
+
def write_release_receipt(self, receipt: ReleaseReceipt) -> None:
|
|
1068
|
+
path = self.release_receipts_dir / f"{receipt.receipt_id}.json"
|
|
1069
|
+
if path.exists():
|
|
1070
|
+
current = release_receipt_from_dict(self._read_json(path))
|
|
1071
|
+
if current != receipt:
|
|
1072
|
+
raise ControllerError("RECEIPT_MISMATCH", "release receipt already differs")
|
|
1073
|
+
return
|
|
1074
|
+
self._atomic_json(path, release_receipt_to_dict(receipt))
|
|
1075
|
+
self.touch_repository()
|
|
1076
|
+
|
|
1077
|
+
def write_registration_prune_receipt(self, receipt_id: str, payload: Mapping[str, Any]) -> None:
|
|
1078
|
+
path = self.registration_prune_receipts_dir / f"{receipt_id}.json"
|
|
1079
|
+
if path.exists():
|
|
1080
|
+
if dict(self._read_json(path)) != dict(payload):
|
|
1081
|
+
raise ControllerError("RECEIPT_MISMATCH", "prune receipt already differs")
|
|
1082
|
+
return
|
|
1083
|
+
self._atomic_json(path, payload)
|
|
1084
|
+
self.touch_repository()
|
|
1085
|
+
|
|
1086
|
+
def write_reconciliation_receipt(self, receipt: ReconciliationReceipt) -> None:
|
|
1087
|
+
path = self.reconciliation_receipts_dir / f"{receipt.receipt_id}.json"
|
|
1088
|
+
if path.exists():
|
|
1089
|
+
current = reconciliation_receipt_from_dict(self._read_json(path))
|
|
1090
|
+
if current != receipt:
|
|
1091
|
+
raise ControllerError("RECEIPT_MISMATCH", "reconciliation receipt already differs")
|
|
1092
|
+
return
|
|
1093
|
+
self._atomic_json(path, reconciliation_receipt_to_dict(receipt))
|
|
1094
|
+
self.touch_repository()
|
|
1095
|
+
|
|
1096
|
+
def remove_publication_lease(self, lease_id: str) -> None:
|
|
1097
|
+
if not self.publication_lease_file.is_file():
|
|
1098
|
+
raise ControllerError("BLOCK_DRIFT", "publication lease disappeared")
|
|
1099
|
+
current = publication_lease_from_dict(self._read_json(self.publication_lease_file))
|
|
1100
|
+
if current.lease_id != lease_id:
|
|
1101
|
+
raise ControllerError("LEASE_MISMATCH", "publication lease id changed")
|
|
1102
|
+
self.publication_lease_file.unlink()
|
|
1103
|
+
directory = os.open(self.publication_lease_file.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
1104
|
+
try:
|
|
1105
|
+
os.fsync(directory)
|
|
1106
|
+
finally:
|
|
1107
|
+
os.close(directory)
|
|
1108
|
+
self.touch_repository()
|
|
1109
|
+
|
|
1110
|
+
def remove_branch_retirement_authorization(self, authorization_id: str) -> None:
|
|
1111
|
+
path = self.branch_retirement_authorizations_dir / f"{authorization_id}.json"
|
|
1112
|
+
if not path.is_file():
|
|
1113
|
+
raise ControllerError("BLOCK_DRIFT", "retirement authorization disappeared")
|
|
1114
|
+
path.unlink()
|
|
1115
|
+
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
1116
|
+
try:
|
|
1117
|
+
os.fsync(directory)
|
|
1118
|
+
finally:
|
|
1119
|
+
os.close(directory)
|
|
1120
|
+
self.touch_repository()
|
|
1121
|
+
|
|
1122
|
+
def write_transaction(self, transaction_id: str, payload: Mapping[str, Any]) -> None:
|
|
1123
|
+
try:
|
|
1124
|
+
uuid.UUID(transaction_id)
|
|
1125
|
+
except ValueError as exc:
|
|
1126
|
+
raise ControllerError("INVALID_TRANSACTION", "transaction id must be a UUID") from exc
|
|
1127
|
+
self._atomic_json(self.transactions_dir / f"{transaction_id}.json", payload)
|
|
1128
|
+
|
|
1129
|
+
def write_teardown_receipt(self, receipt_id: str, payload: Mapping[str, Any]) -> None:
|
|
1130
|
+
try:
|
|
1131
|
+
uuid.UUID(receipt_id)
|
|
1132
|
+
except ValueError as exc:
|
|
1133
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt id must be a UUID") from exc
|
|
1134
|
+
validated = validate_teardown_receipt(payload)
|
|
1135
|
+
if validated["receipt_id"] != receipt_id:
|
|
1136
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt filename does not match")
|
|
1137
|
+
self._atomic_json(self.teardown_receipts_dir / f"{receipt_id}.json", validated)
|
|
1138
|
+
|
|
1139
|
+
def write_completion_receipt(self, receipt_id: str, payload: Mapping[str, Any]) -> None:
|
|
1140
|
+
try:
|
|
1141
|
+
uuid.UUID(receipt_id)
|
|
1142
|
+
except ValueError as exc:
|
|
1143
|
+
raise ControllerError(
|
|
1144
|
+
"INVALID_METADATA", "completion receipt id must be a UUID"
|
|
1145
|
+
) from exc
|
|
1146
|
+
if payload.get("receipt_id") != receipt_id:
|
|
1147
|
+
raise ControllerError("INVALID_METADATA", "completion receipt id does not match")
|
|
1148
|
+
self._atomic_json(self.completion_receipts_dir / f"{receipt_id}.json", dict(payload))
|
|
1149
|
+
|
|
1150
|
+
def load_teardown_receipt(self, receipt_id: str) -> dict[str, Any]:
|
|
1151
|
+
try:
|
|
1152
|
+
uuid.UUID(receipt_id)
|
|
1153
|
+
except ValueError as exc:
|
|
1154
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt id must be a UUID") from exc
|
|
1155
|
+
path = self.teardown_receipts_dir / f"{receipt_id}.json"
|
|
1156
|
+
if not path.is_file():
|
|
1157
|
+
raise ControllerError("BLOCK_DRIFT", "teardown receipt disappeared")
|
|
1158
|
+
receipt = validate_teardown_receipt(self._read_json(path))
|
|
1159
|
+
if receipt["receipt_id"] != receipt_id:
|
|
1160
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt filename does not match")
|
|
1161
|
+
return receipt
|
|
1162
|
+
|
|
1163
|
+
def remove_transaction(self, transaction_id: str) -> None:
|
|
1164
|
+
path = self.transactions_dir / f"{transaction_id}.json"
|
|
1165
|
+
try:
|
|
1166
|
+
path.unlink()
|
|
1167
|
+
except FileNotFoundError as exc:
|
|
1168
|
+
raise ControllerError("BLOCK_DRIFT", "transaction journal disappeared") from exc
|
|
1169
|
+
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
1170
|
+
try:
|
|
1171
|
+
os.fsync(directory)
|
|
1172
|
+
finally:
|
|
1173
|
+
os.close(directory)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def new_record(
|
|
1177
|
+
*,
|
|
1178
|
+
task_key: str,
|
|
1179
|
+
authority_key: str,
|
|
1180
|
+
module: str,
|
|
1181
|
+
role: str,
|
|
1182
|
+
owner: str,
|
|
1183
|
+
lifecycle: str,
|
|
1184
|
+
base_commit: str | None = None,
|
|
1185
|
+
task_commit: str | None = None,
|
|
1186
|
+
scope_paths: tuple[str, ...] = (),
|
|
1187
|
+
source_allocations: tuple[str, ...] = (),
|
|
1188
|
+
external_bindings: tuple[str, ...] = (),
|
|
1189
|
+
initial_event: str = "ADOPTED",
|
|
1190
|
+
) -> MetadataRecord:
|
|
1191
|
+
timestamp = now_iso()
|
|
1192
|
+
event = {
|
|
1193
|
+
"at": timestamp,
|
|
1194
|
+
"event": initial_event,
|
|
1195
|
+
"from": None,
|
|
1196
|
+
"to": lifecycle,
|
|
1197
|
+
"owner": owner,
|
|
1198
|
+
}
|
|
1199
|
+
return record_from_dict(
|
|
1200
|
+
{
|
|
1201
|
+
"schema_version": METADATA_SCHEMA_VERSION,
|
|
1202
|
+
"record_version": 1,
|
|
1203
|
+
"allocation_id": str(uuid.uuid4()),
|
|
1204
|
+
"task_key": task_key,
|
|
1205
|
+
"authority_key": authority_key,
|
|
1206
|
+
"module": module,
|
|
1207
|
+
"role": role,
|
|
1208
|
+
"owner": owner,
|
|
1209
|
+
"lifecycle": lifecycle,
|
|
1210
|
+
"created_at": timestamp,
|
|
1211
|
+
"last_reviewed_at": timestamp,
|
|
1212
|
+
"handoff_commit": None,
|
|
1213
|
+
"finalizer_record_id": None,
|
|
1214
|
+
"base_commit": base_commit,
|
|
1215
|
+
"task_commit": task_commit,
|
|
1216
|
+
"finalizer_commit": None,
|
|
1217
|
+
"scope_paths": list(scope_paths),
|
|
1218
|
+
"source_allocations": list(source_allocations),
|
|
1219
|
+
"quarantine_expected_commit": None,
|
|
1220
|
+
"quarantine_observed_commit": None,
|
|
1221
|
+
"quarantine_reason": None,
|
|
1222
|
+
"recovery_allocation_id": None,
|
|
1223
|
+
"recovery_ref": None,
|
|
1224
|
+
"disposable_resources": [],
|
|
1225
|
+
"external_bindings": list(external_bindings),
|
|
1226
|
+
"history": [event],
|
|
1227
|
+
}
|
|
1228
|
+
)
|