worktree-controller 1.30.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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