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,361 @@
|
|
|
1
|
+
"""A public, provider-neutral publication session under the producer's lock.
|
|
2
|
+
|
|
3
|
+
The consumer performs the Git push. The Controller owns all metadata validation
|
|
4
|
+
and keeps its existing repository lock until it independently observes completion
|
|
5
|
+
or the bounded stdio session ends. No private path or digest recipe crosses this
|
|
6
|
+
boundary, and the existing lease-complete command persists the lifecycle receipt.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import selectors
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from contextlib import suppress
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, TextIO
|
|
22
|
+
|
|
23
|
+
from .git_backend import _git, inventory, is_ancestor, validate_branch_name
|
|
24
|
+
from .lifecycle import _intent_for_allocation, _validate_prepared_publication_receipt
|
|
25
|
+
from .models import ControllerError
|
|
26
|
+
from .policy import load_policy
|
|
27
|
+
from .semantics import managed_record as _managed_record
|
|
28
|
+
from .store import MetadataStore, now_iso
|
|
29
|
+
|
|
30
|
+
VERSION = 1
|
|
31
|
+
CONTRACT = "integration-publication-session/v1"
|
|
32
|
+
MAX_FRAME_BYTES = 65536
|
|
33
|
+
IDLE_TIMEOUT_SECONDS = 300
|
|
34
|
+
OID_PATTERN = re.compile(r"[0-9a-f]{40}")
|
|
35
|
+
REMOTE_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text(value: object, field: str, *, maximum: int = 512) -> str:
|
|
39
|
+
if (
|
|
40
|
+
not isinstance(value, str)
|
|
41
|
+
or not value
|
|
42
|
+
or len(value) > maximum
|
|
43
|
+
or any(ord(character) < 32 for character in value)
|
|
44
|
+
):
|
|
45
|
+
raise ControllerError("INVALID_PUBLICATION_REQUEST", f"invalid {field}")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
50
|
+
result: dict[str, Any] = {}
|
|
51
|
+
for key, value in pairs:
|
|
52
|
+
if key in result:
|
|
53
|
+
raise ControllerError("INVALID_PUBLICATION_REQUEST", "duplicate JSON key")
|
|
54
|
+
result[key] = value
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SessionInput:
|
|
59
|
+
def __init__(self, descriptor: int) -> None:
|
|
60
|
+
self.descriptor = descriptor
|
|
61
|
+
self.pending = bytearray()
|
|
62
|
+
|
|
63
|
+
def read(self) -> dict[str, Any]:
|
|
64
|
+
deadline = time.monotonic() + IDLE_TIMEOUT_SECONDS
|
|
65
|
+
with selectors.DefaultSelector() as selector:
|
|
66
|
+
selector.register(self.descriptor, selectors.EVENT_READ)
|
|
67
|
+
while b"\n" not in self.pending:
|
|
68
|
+
remaining = deadline - time.monotonic()
|
|
69
|
+
if remaining <= 0 or not selector.select(remaining):
|
|
70
|
+
raise ControllerError(
|
|
71
|
+
"PUBLICATION_SESSION_TIMEOUT", "publication session timed out"
|
|
72
|
+
)
|
|
73
|
+
chunk = os.read(self.descriptor, min(4096, MAX_FRAME_BYTES + 1 - len(self.pending)))
|
|
74
|
+
if not chunk:
|
|
75
|
+
raise ControllerError("PUBLICATION_SESSION_CLOSED", "consumer disconnected")
|
|
76
|
+
self.pending.extend(chunk)
|
|
77
|
+
if len(self.pending) > MAX_FRAME_BYTES:
|
|
78
|
+
raise ControllerError(
|
|
79
|
+
"INVALID_PUBLICATION_REQUEST", "publication frame is too large"
|
|
80
|
+
)
|
|
81
|
+
line, _, pending = self.pending.partition(b"\n")
|
|
82
|
+
self.pending = bytearray(pending)
|
|
83
|
+
value = json.loads(line.decode("utf-8"), object_pairs_hook=_unique_object)
|
|
84
|
+
if not isinstance(value, dict):
|
|
85
|
+
raise ControllerError(
|
|
86
|
+
"INVALID_PUBLICATION_REQUEST", "publication frame must be an object"
|
|
87
|
+
)
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _emit(stream: TextIO, phase: str, **fields: Any) -> None:
|
|
92
|
+
stream.write(
|
|
93
|
+
json.dumps(
|
|
94
|
+
{"schema_version": VERSION, "contract": CONTRACT, "phase": phase, **fields},
|
|
95
|
+
ensure_ascii=False,
|
|
96
|
+
separators=(",", ":"),
|
|
97
|
+
allow_nan=False,
|
|
98
|
+
)
|
|
99
|
+
+ "\n"
|
|
100
|
+
)
|
|
101
|
+
stream.flush()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PublicationSession:
|
|
105
|
+
def __init__(self, repository: Path, candidate_oid: str, lease_id: str, run_id: str) -> None:
|
|
106
|
+
if OID_PATTERN.fullmatch(candidate_oid) is None:
|
|
107
|
+
raise ControllerError(
|
|
108
|
+
"INVALID_PUBLICATION_REQUEST", "candidate must be a full commit OID"
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
if str(uuid.UUID(lease_id)) != lease_id:
|
|
112
|
+
raise ValueError
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
raise ControllerError(
|
|
115
|
+
"INVALID_PUBLICATION_REQUEST", "lease must be a canonical UUID"
|
|
116
|
+
) from exc
|
|
117
|
+
self.repository = repository.expanduser().resolve(strict=True)
|
|
118
|
+
self.candidate_oid = candidate_oid
|
|
119
|
+
self.lease_id = lease_id
|
|
120
|
+
self.run_id = _text(run_id, "run_id", maximum=256)
|
|
121
|
+
initial = inventory(self.repository)
|
|
122
|
+
self.store = MetadataStore(initial.common_dir)
|
|
123
|
+
self.identity: dict[str, Any] | None = None
|
|
124
|
+
|
|
125
|
+
def _guard(self) -> tuple[dict[str, Any], str]:
|
|
126
|
+
if not self.store.flock_held:
|
|
127
|
+
raise ControllerError(
|
|
128
|
+
"PUBLICATION_SESSION_UNLOCKED", "publication session requires its lock"
|
|
129
|
+
)
|
|
130
|
+
repository = inventory(self.repository)
|
|
131
|
+
policy = load_policy(repository.canonical_path)
|
|
132
|
+
snapshot = self.store._load_unlocked()
|
|
133
|
+
lease = snapshot.publication_lease
|
|
134
|
+
if (
|
|
135
|
+
lease is None
|
|
136
|
+
or lease.lease_id != self.lease_id
|
|
137
|
+
or lease.run_id != self.run_id
|
|
138
|
+
or lease.candidate_oid != self.candidate_oid
|
|
139
|
+
or lease.repository_id != snapshot.repository_id
|
|
140
|
+
):
|
|
141
|
+
raise ControllerError("LEASE_MISMATCH", "publication lease identity differs")
|
|
142
|
+
if (lease.remote, lease.branch) != (policy.publication_remote, policy.publication_branch):
|
|
143
|
+
raise ControllerError(
|
|
144
|
+
"PUBLICATION_POLICY_DRIFT", "publication target differs from policy"
|
|
145
|
+
)
|
|
146
|
+
item, record = _managed_record(repository, snapshot, self.repository)
|
|
147
|
+
if (
|
|
148
|
+
item.path.resolve(strict=True) != self.repository
|
|
149
|
+
or self.repository == repository.canonical_path.resolve(strict=True)
|
|
150
|
+
or item.detached
|
|
151
|
+
or item.branch == lease.target_ref
|
|
152
|
+
or not item.clean
|
|
153
|
+
or item.head != lease.candidate_oid
|
|
154
|
+
or record.allocation_id != lease.allocation_id
|
|
155
|
+
or record.owner != lease.holder
|
|
156
|
+
or record.role != "integration"
|
|
157
|
+
or record.lifecycle != "INTEGRATING"
|
|
158
|
+
or record.task_commit != lease.candidate_oid
|
|
159
|
+
or record.handoff_commit != lease.candidate_oid
|
|
160
|
+
or record.finalizer_commit != lease.candidate_oid
|
|
161
|
+
or record.finalizer_record_id != lease.validation_evidence_id
|
|
162
|
+
):
|
|
163
|
+
raise ControllerError(
|
|
164
|
+
"PUBLICATION_EVIDENCE_MISMATCH", "leased candidate evidence differs"
|
|
165
|
+
)
|
|
166
|
+
intent = _intent_for_allocation(snapshot, record.allocation_id)
|
|
167
|
+
if intent is None or (
|
|
168
|
+
intent.state != "PUBLISHING"
|
|
169
|
+
or intent.publication_identity != lease.lease_id
|
|
170
|
+
or intent.candidate_oid != lease.candidate_oid
|
|
171
|
+
or intent.validated_oid != lease.candidate_oid
|
|
172
|
+
or intent.validation_evidence_id != lease.validation_evidence_id
|
|
173
|
+
or intent.base_main_oid != lease.expected_main_oid
|
|
174
|
+
):
|
|
175
|
+
raise ControllerError(
|
|
176
|
+
"PUBLICATION_EVIDENCE_MISMATCH", "publishing intent differs from lease"
|
|
177
|
+
)
|
|
178
|
+
_validate_prepared_publication_receipt(
|
|
179
|
+
snapshot,
|
|
180
|
+
intent,
|
|
181
|
+
record,
|
|
182
|
+
item,
|
|
183
|
+
required=policy.policy_schema_version >= 4,
|
|
184
|
+
)
|
|
185
|
+
if not is_ancestor(item.path, lease.expected_main_oid, lease.candidate_oid):
|
|
186
|
+
raise ControllerError(
|
|
187
|
+
"PUBLICATION_EVIDENCE_MISMATCH", "candidate does not contain leased main"
|
|
188
|
+
)
|
|
189
|
+
if REMOTE_PATTERN.fullmatch(lease.remote) is None:
|
|
190
|
+
raise ControllerError("INVALID_REMOTE", "publication remote is unsafe")
|
|
191
|
+
validate_branch_name(item.path, lease.branch)
|
|
192
|
+
if lease.target_ref != f"refs/heads/{lease.branch}":
|
|
193
|
+
raise ControllerError("LEASE_MISMATCH", "publication target ref differs")
|
|
194
|
+
fetch = _git(item.path, "remote", "get-url", "--all", lease.remote, check=False)
|
|
195
|
+
push = _git(item.path, "remote", "get-url", "--push", "--all", lease.remote, check=False)
|
|
196
|
+
if (
|
|
197
|
+
fetch.returncode
|
|
198
|
+
or push.returncode
|
|
199
|
+
or not fetch.stdout.strip()
|
|
200
|
+
or b"\n" in fetch.stdout.strip()
|
|
201
|
+
or fetch.stdout.strip() != push.stdout.strip()
|
|
202
|
+
):
|
|
203
|
+
raise ControllerError(
|
|
204
|
+
"INVALID_REMOTE", "publication requires one identical fetch/push endpoint"
|
|
205
|
+
)
|
|
206
|
+
identity = {
|
|
207
|
+
"repository_id": lease.repository_id,
|
|
208
|
+
"allocation_id": lease.allocation_id,
|
|
209
|
+
"lease_id": lease.lease_id,
|
|
210
|
+
"run_id": lease.run_id,
|
|
211
|
+
"remote": lease.remote,
|
|
212
|
+
"target_ref": lease.target_ref,
|
|
213
|
+
"candidate_oid": lease.candidate_oid,
|
|
214
|
+
"expected_main_oid": lease.expected_main_oid,
|
|
215
|
+
"validation_evidence_id": lease.validation_evidence_id,
|
|
216
|
+
"record_version": record.record_version,
|
|
217
|
+
"expires_at": lease.expires_at,
|
|
218
|
+
}
|
|
219
|
+
if self.identity is not None and identity != self.identity:
|
|
220
|
+
raise ControllerError(
|
|
221
|
+
"PUBLICATION_SESSION_DRIFT", "publication identity changed during session"
|
|
222
|
+
)
|
|
223
|
+
return identity, self._remote_oid(identity)
|
|
224
|
+
|
|
225
|
+
def _remote_oid(self, identity: dict[str, Any]) -> str:
|
|
226
|
+
result = _git(
|
|
227
|
+
self.repository,
|
|
228
|
+
"ls-remote",
|
|
229
|
+
"--exit-code",
|
|
230
|
+
"--refs",
|
|
231
|
+
identity["remote"],
|
|
232
|
+
identity["target_ref"],
|
|
233
|
+
check=False,
|
|
234
|
+
)
|
|
235
|
+
lines = result.stdout.decode("utf-8", "strict").splitlines()
|
|
236
|
+
if result.returncode or len(lines) != 1:
|
|
237
|
+
raise ControllerError(
|
|
238
|
+
"PUBLICATION_REMOTE_UNAVAILABLE", "live publication ref was not verified"
|
|
239
|
+
)
|
|
240
|
+
parts = lines[0].split("\t")
|
|
241
|
+
if (
|
|
242
|
+
len(parts) != 2
|
|
243
|
+
or OID_PATTERN.fullmatch(parts[0]) is None
|
|
244
|
+
or parts[1] != identity["target_ref"]
|
|
245
|
+
):
|
|
246
|
+
raise ControllerError(
|
|
247
|
+
"PUBLICATION_REMOTE_UNAVAILABLE", "live publication response is invalid"
|
|
248
|
+
)
|
|
249
|
+
return parts[0]
|
|
250
|
+
|
|
251
|
+
def _action(self, identity: dict[str, Any], observed: str) -> str:
|
|
252
|
+
if observed == self.candidate_oid:
|
|
253
|
+
return "RECOVER_ALREADY_PUBLISHED"
|
|
254
|
+
if datetime.fromisoformat(identity["expires_at"]) <= datetime.fromisoformat(now_iso()):
|
|
255
|
+
raise ControllerError(
|
|
256
|
+
"PUBLICATION_LEASE_EXPIRED", "publication lease expired before mutation"
|
|
257
|
+
)
|
|
258
|
+
if observed != identity["expected_main_oid"]:
|
|
259
|
+
raise ControllerError(
|
|
260
|
+
"PUBLICATION_REMOTE_DRIFT", "live main differs from leased expected OID"
|
|
261
|
+
)
|
|
262
|
+
return "PUSH_CANDIDATE"
|
|
263
|
+
|
|
264
|
+
def verify(self) -> dict[str, Any]:
|
|
265
|
+
identity, observed = self._guard()
|
|
266
|
+
self.identity = identity
|
|
267
|
+
return {
|
|
268
|
+
"identity": identity,
|
|
269
|
+
"action": self._action(identity, observed),
|
|
270
|
+
"remote_oid": observed,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def authorize(self) -> dict[str, Any]:
|
|
274
|
+
identity, observed = self._guard()
|
|
275
|
+
return {
|
|
276
|
+
"identity": identity,
|
|
277
|
+
"action": self._action(identity, observed),
|
|
278
|
+
"remote_oid": observed,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
def complete(self, result: object) -> dict[str, Any]:
|
|
282
|
+
if not isinstance(result, dict) or set(result) != {
|
|
283
|
+
"record_id",
|
|
284
|
+
"executor",
|
|
285
|
+
"push_executed",
|
|
286
|
+
"remote_oid",
|
|
287
|
+
}:
|
|
288
|
+
raise ControllerError("INVALID_PUBLICATION_REQUEST", "execution result fields differ")
|
|
289
|
+
executor = result["executor"]
|
|
290
|
+
if not isinstance(executor, dict) or set(executor) != {"name", "version"}:
|
|
291
|
+
raise ControllerError("INVALID_PUBLICATION_REQUEST", "executor identity is required")
|
|
292
|
+
_text(executor["name"], "executor.name", maximum=128)
|
|
293
|
+
_text(executor["version"], "executor.version", maximum=128)
|
|
294
|
+
record_id = _text(result["record_id"], "record_id")
|
|
295
|
+
if type(result["push_executed"]) is not bool or result["remote_oid"] != self.candidate_oid:
|
|
296
|
+
raise ControllerError(
|
|
297
|
+
"INVALID_PUBLICATION_REQUEST", "execution result identity differs"
|
|
298
|
+
)
|
|
299
|
+
identity, observed = self._guard()
|
|
300
|
+
if observed != self.candidate_oid:
|
|
301
|
+
raise ControllerError(
|
|
302
|
+
"PUBLICATION_NOT_OBSERVED", "remote does not equal the exact leased candidate"
|
|
303
|
+
)
|
|
304
|
+
return {
|
|
305
|
+
"identity": identity,
|
|
306
|
+
"record_id": record_id,
|
|
307
|
+
"remote_verify": {"status": "verified", "oid": observed},
|
|
308
|
+
"lifecycle_completion_required": True,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _request(document: dict[str, Any], action: str, identity: dict[str, Any]) -> None:
|
|
313
|
+
fields = {"schema_version", "contract", "action", "identity"}
|
|
314
|
+
if action == "complete":
|
|
315
|
+
fields.add("result")
|
|
316
|
+
supplied_identity = document.get("identity")
|
|
317
|
+
same_identity = (
|
|
318
|
+
isinstance(supplied_identity, dict)
|
|
319
|
+
and set(supplied_identity) == set(identity)
|
|
320
|
+
and all(
|
|
321
|
+
type(supplied_identity[field]) is type(expected)
|
|
322
|
+
and supplied_identity[field] == expected
|
|
323
|
+
for field, expected in identity.items()
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
if (
|
|
327
|
+
set(document) != fields
|
|
328
|
+
or type(document.get("schema_version")) is not int
|
|
329
|
+
or document["schema_version"] != VERSION
|
|
330
|
+
or document["contract"] != CONTRACT
|
|
331
|
+
or document["action"] != action
|
|
332
|
+
or not same_identity
|
|
333
|
+
):
|
|
334
|
+
raise ControllerError(
|
|
335
|
+
"INVALID_PUBLICATION_REQUEST", "publication request identity or phase differs"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def run_session(repository: Path, *, candidate_oid: str, lease_id: str, run_id: str) -> int:
|
|
340
|
+
try:
|
|
341
|
+
session = PublicationSession(repository, candidate_oid, lease_id, run_id)
|
|
342
|
+
channel = SessionInput(sys.stdin.fileno())
|
|
343
|
+
with session.store.lock(exclusive=True):
|
|
344
|
+
verified = session.verify()
|
|
345
|
+
_emit(sys.stdout, "verified", **verified)
|
|
346
|
+
request = channel.read()
|
|
347
|
+
_request(request, "authorize", verified["identity"])
|
|
348
|
+
_emit(sys.stdout, "authorized", **session.authorize())
|
|
349
|
+
request = channel.read()
|
|
350
|
+
_request(request, "complete", verified["identity"])
|
|
351
|
+
_emit(sys.stdout, "completed", **session.complete(request["result"]))
|
|
352
|
+
return 0
|
|
353
|
+
except (ControllerError, OSError, UnicodeError, ValueError) as exc:
|
|
354
|
+
with suppress(BrokenPipeError):
|
|
355
|
+
_emit(
|
|
356
|
+
sys.stdout,
|
|
357
|
+
"blocked",
|
|
358
|
+
code=getattr(exc, "code", "PUBLICATION_SESSION_FAILED"),
|
|
359
|
+
reason=str(exc)[:512],
|
|
360
|
+
)
|
|
361
|
+
return 1
|