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,291 @@
1
+ """Owner-reviewed recovery of published frozen checkouts without rewriting evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, replace
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import NAMESPACE_URL, uuid5
9
+
10
+ from .controller import status
11
+ from .git_backend import (
12
+ branch_exists,
13
+ git_write,
14
+ inventory,
15
+ is_ancestor,
16
+ resolve_commit,
17
+ validate_branch_name,
18
+ )
19
+ from .lifecycle import (
20
+ _cancel_release_intents,
21
+ _typed_runtime_blockers,
22
+ _typed_runtime_evidence,
23
+ _updated_record,
24
+ )
25
+ from .models import ControllerError
26
+ from .policy import load_policy
27
+ from .semantics import assert_record_version as _cas
28
+ from .semantics import integration_intent_abandoned, publication_ref
29
+ from .semantics import managed_record as _managed_record
30
+ from .store import MetadataStore, record_to_dict
31
+ from .transaction_scope import conflicts
32
+ from .writer_lease import holder_is_live, process_group_is_live
33
+
34
+ CONTRACT = "published-checkout-recovery/v1"
35
+
36
+
37
+ def recover_frozen_checkout(
38
+ repository_path: Path,
39
+ *,
40
+ target: Path,
41
+ expected_record_version: int,
42
+ expected_head: str,
43
+ owner: str,
44
+ branch: str | None = None,
45
+ ) -> dict[str, Any]:
46
+ """Recover a published source advance, or reattach a published integration.
47
+
48
+ The immutable preparation receipt makes interruption safe: a retry can finish
49
+ only the same record/HEAD/ref transition. No working files, index, historical
50
+ publication receipts or commits are rewritten. Normal release gates still run.
51
+ """
52
+ command = "recover-published" if branch is not None else "recover-handoff"
53
+ initial = inventory(repository_path)
54
+ store = MetadataStore(initial.common_dir)
55
+ with store.lock(exclusive=True):
56
+ repository = inventory(repository_path)
57
+ snapshot = store._load_unlocked()
58
+ item, record = _managed_record(repository, snapshot, target)
59
+ _cas(record, expected_record_version)
60
+ if owner != record.owner:
61
+ raise ControllerError("OWNER_MISMATCH", "recovery requires the recorded owner")
62
+ if item.head != expected_head:
63
+ raise ControllerError("IDENTITY_MISMATCH", "recovery HEAD differs from expected HEAD")
64
+ if item.canonical or item.locked or item.prunable or item.staged:
65
+ raise ControllerError("BLOCK_DRIFT", "canonical, locked, prunable or staged checkout")
66
+ if conflicts(store, allocation=record.allocation_id, target=item.path):
67
+ raise ControllerError(
68
+ "INTERRUPTED_TRANSACTION", "resolve the allocation transaction first"
69
+ )
70
+ formal_ref = publication_ref(item.path, load_policy(repository.canonical_path))
71
+ if formal_ref is None or not is_ancestor(item.path, item.head, formal_ref):
72
+ raise ControllerError(
73
+ "UNPUBLISHED_HEAD", "observed HEAD must already be on the publication ref"
74
+ )
75
+ for intent in snapshot.integration_intents.values():
76
+ if (
77
+ record.allocation_id in (*intent.source_allocations, intent.allocation_id)
78
+ and intent.state not in {"REMOTE_VERIFIED", "RELEASED"}
79
+ and not integration_intent_abandoned(intent)
80
+ ):
81
+ raise ControllerError(
82
+ "ACTIVE_INTEGRATION", "an unfinished integration still consumes this allocation"
83
+ )
84
+
85
+ lease = snapshot.writer_leases.get(record.allocation_id)
86
+ if lease and (holder_is_live(lease.holder) or process_group_is_live(lease.holder)):
87
+ raise ControllerError("LIVE_WRITER", "a live writer or process group blocks recovery")
88
+ runtime_snapshot = replace(
89
+ snapshot,
90
+ writer_leases={
91
+ key: value
92
+ for key, value in snapshot.writer_leases.items()
93
+ if key != record.allocation_id
94
+ },
95
+ )
96
+ blockers = _typed_runtime_blockers(_typed_runtime_evidence(item, record, runtime_snapshot))
97
+ if blockers:
98
+ raise ControllerError(blockers[0]["code"], blockers[0]["message"])
99
+
100
+ receipt_id = str(
101
+ uuid5(
102
+ NAMESPACE_URL,
103
+ ":".join(
104
+ (
105
+ CONTRACT,
106
+ str(snapshot.repository_id),
107
+ record.allocation_id,
108
+ str(expected_record_version),
109
+ expected_head,
110
+ command,
111
+ branch or "",
112
+ )
113
+ ),
114
+ )
115
+ )
116
+ receipt_path = store.completion_receipts_dir / f"{receipt_id}.json"
117
+ prepared = store._read_json(receipt_path) if receipt_path.exists() else None
118
+ if prepared and (
119
+ prepared.get("contract") != CONTRACT
120
+ or prepared.get("receipt_id") != receipt_id
121
+ or prepared.get("record") != record_to_dict(record)
122
+ or prepared.get("expected_head") != expected_head
123
+ or prepared.get("branch") != branch
124
+ or prepared.get("target_path") != str(item.path)
125
+ or prepared.get("repository_id") != snapshot.repository_id
126
+ or prepared.get("command") != command
127
+ or (lease is not None and prepared.get("writer_lease") != asdict(lease))
128
+ or (branch is None and prepared.get("observed_branch") != item.branch)
129
+ ):
130
+ raise ControllerError("BLOCK_DRIFT", "immutable recovery preparation differs")
131
+
132
+ receipts = [
133
+ receipt
134
+ for receipt in snapshot.publication_receipts.values()
135
+ if receipt.repository_id == snapshot.repository_id
136
+ and receipt.status == "REMOTE_VERIFIED"
137
+ and receipt.lease_released_at
138
+ and is_ancestor(item.path, receipt.candidate_oid, formal_ref)
139
+ ]
140
+ if branch is None:
141
+ frozen = record.handoff_commit
142
+ if (
143
+ record.role not in {"feature", "audit", "hotfix"}
144
+ or record.lifecycle not in {"HANDOFF_READY", "PRESERVED"}
145
+ or item.detached
146
+ or not item.branch
147
+ or not frozen
148
+ or not record.finalizer_record_id
149
+ or frozen != record.task_commit
150
+ or frozen != record.finalizer_commit
151
+ or frozen == item.head
152
+ or not is_ancestor(item.path, frozen, item.head)
153
+ ):
154
+ raise ControllerError(
155
+ "INVALID_FROZEN_SOURCE",
156
+ "recovery requires an attached, published advance of a frozen source",
157
+ )
158
+ receipts = [
159
+ receipt
160
+ for receipt in receipts
161
+ if any(
162
+ result.get("source_allocation_id") == record.allocation_id
163
+ and result.get("source_handoff_oid") == frozen
164
+ and result.get("absorbed_oid") == frozen
165
+ and result.get("mode") == "ancestry"
166
+ and is_ancestor(item.path, frozen, receipt.candidate_oid)
167
+ for result in receipt.source_results
168
+ )
169
+ ]
170
+ else:
171
+ validate_branch_name(item.path, branch)
172
+ if (
173
+ record.role != "integration"
174
+ or record.lifecycle != "PUBLISHED"
175
+ or not item.clean
176
+ or not record.finalizer_record_id
177
+ or record.task_commit != item.head
178
+ or record.finalizer_commit != item.head
179
+ or (not item.detached and not (prepared and item.branch == branch))
180
+ ):
181
+ raise ControllerError(
182
+ "INVALID_PUBLISHED_CHECKOUT",
183
+ "recovery requires a clean, detached published integration",
184
+ )
185
+ if any(
186
+ other.key != item.key and other.branch == branch for other in repository.worktrees
187
+ ):
188
+ raise ControllerError("BRANCH_IN_USE", "recovery branch is checked out elsewhere")
189
+ if branch_exists(item.path, branch) and (
190
+ not prepared or resolve_commit(item.path, f"refs/heads/{branch}") != item.head
191
+ ):
192
+ raise ControllerError(
193
+ "BRANCH_EXISTS", "recovery must create a fresh retained branch"
194
+ )
195
+ receipts = [
196
+ receipt
197
+ for receipt in receipts
198
+ if receipt.allocation_id == record.allocation_id
199
+ and receipt.candidate_oid == item.head
200
+ and receipt.finalizer_record_id == record.finalizer_record_id
201
+ ]
202
+ if not receipts:
203
+ raise ControllerError(
204
+ "MISSING_PUBLICATION_EVIDENCE", "exact released publication evidence is required"
205
+ )
206
+ publication = sorted(receipts, key=lambda value: value.receipt_id)[0]
207
+ if prepared and prepared.get("publication_receipt_id") not in {
208
+ r.receipt_id for r in receipts
209
+ }:
210
+ raise ControllerError("BLOCK_DRIFT", "prepared publication evidence is no longer valid")
211
+ if prepared is None:
212
+ prepared = {
213
+ "contract": CONTRACT,
214
+ "receipt_id": receipt_id,
215
+ "repository_id": snapshot.repository_id,
216
+ "command": command,
217
+ "target_path": str(item.path),
218
+ "record": record_to_dict(record),
219
+ "expected_head": item.head,
220
+ "branch": branch,
221
+ "observed_branch": item.branch,
222
+ "publication_receipt_id": publication.receipt_id,
223
+ "publication_ref": formal_ref,
224
+ "publication_oid": resolve_commit(item.path, formal_ref),
225
+ "writer_lease": asdict(lease) if lease else None,
226
+ "staged": item.staged,
227
+ "modified": item.modified,
228
+ "untracked": item.untracked,
229
+ }
230
+ store.write_completion_receipt(receipt_id, prepared)
231
+
232
+ if branch is not None:
233
+ if not branch_exists(item.path, branch):
234
+ git_write(
235
+ item.path,
236
+ ("update-ref", f"refs/heads/{branch}", item.head, "0" * len(item.head)),
237
+ )
238
+ latest, _ = _managed_record(inventory(repository_path), snapshot, target)
239
+ if latest.head != expected_head or not latest.clean:
240
+ raise ControllerError(
241
+ "BLOCK_DRIFT", "checkout changed during recovery; retained ref is preserved"
242
+ )
243
+ if latest.detached:
244
+ # Repoint only HEAD; unlike checkout this never rewrites files or runs hooks.
245
+ git_write(item.path, ("symbolic-ref", "HEAD", f"refs/heads/{branch}"))
246
+ elif latest.branch != branch:
247
+ raise ControllerError("BLOCK_DRIFT", "checkout branch changed during recovery")
248
+ updated = _updated_record(record, "PUBLISHED_CHECKOUT_REATTACHED")
249
+ else:
250
+ _cancel_release_intents(
251
+ store,
252
+ snapshot,
253
+ record.allocation_id,
254
+ event="FROZEN_SOURCE_RECOVERED",
255
+ blocker="REOPENED",
256
+ )
257
+ updated = _updated_record(
258
+ record,
259
+ "PUBLISHED_SOURCE_RECOVERED",
260
+ lifecycle="ACTIVE",
261
+ base_commit=item.head,
262
+ handoff_commit=None,
263
+ task_commit=None,
264
+ finalizer_commit=None,
265
+ finalizer_record_id=None,
266
+ )
267
+ if lease:
268
+ store.remove_writer_lease(record.allocation_id)
269
+ updated = replace(
270
+ updated,
271
+ history=(
272
+ *updated.history[:-1],
273
+ {
274
+ **updated.history[-1],
275
+ "recovery_receipt_id": receipt_id,
276
+ },
277
+ ),
278
+ )
279
+ store.replace_record(updated)
280
+ payload = status(repository_path)
281
+ payload["command"] = command
282
+ payload["decision"] = {
283
+ "code": "FROZEN_CHECKOUT_RECOVERED",
284
+ "allocation_id": updated.allocation_id,
285
+ "record_version": updated.record_version,
286
+ "lifecycle": updated.lifecycle,
287
+ "receipt_id": receipt_id,
288
+ "head": expected_head,
289
+ "branch": branch,
290
+ }
291
+ return payload