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,130 @@
1
+ """Compatibility adapters between consumer wire formats and the neutral schema.
2
+
3
+ Core governance speaks `retirement_execution` only. These adapters translate the
4
+ released 1.5.0/1.6.0 consumer vocabulary so existing integrations keep working
5
+ while nothing in the governance path depends on one consumer product.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import retirement_execution as execution
16
+ from .models import ControllerError
17
+
18
+ GIT_FINALIZER_CONTRACT = "git-finalizer-summary/v1"
19
+
20
+ _SUMMARY_OUTCOMES = {
21
+ "LOCAL_BRANCH_RETIRED_VERIFIED": (
22
+ execution.KIND_CAS_DELETE,
23
+ execution.REF_REMOVED_AT_EXPECTED_OID,
24
+ ),
25
+ "REMOTE_BRANCH_RETIRED_VERIFIED": (
26
+ execution.KIND_CAS_DELETE,
27
+ execution.REF_REMOVED_AT_EXPECTED_OID,
28
+ ),
29
+ "ALREADY_ABSENT_VERIFIED": (execution.KIND_ATTEST_ABSENT, execution.REF_ALREADY_ABSENT),
30
+ }
31
+
32
+
33
+ def _ref(operation: str, result: Mapping[str, Any]) -> str | None:
34
+ branch = result.get("branch")
35
+ if not isinstance(branch, str) or not branch:
36
+ return None
37
+ if operation == "local":
38
+ return f"refs/heads/{branch}"
39
+ remote = result.get("remote")
40
+ return f"refs/remotes/{remote}/{branch}" if isinstance(remote, str) and remote else None
41
+
42
+
43
+ def load_summary(path: Path) -> tuple[bytes, dict[str, Any]]:
44
+ try:
45
+ raw = path.read_bytes()
46
+ except OSError as exc:
47
+ raise ControllerError("INVALID_FINALIZER_SUMMARY", f"cannot read summary: {exc}") from exc
48
+ import json
49
+
50
+ try:
51
+ summary = json.loads(raw)
52
+ except (UnicodeError, json.JSONDecodeError) as exc:
53
+ raise ControllerError("INVALID_FINALIZER_SUMMARY", f"cannot read summary: {exc}") from exc
54
+ if not isinstance(summary, dict):
55
+ raise ControllerError("INVALID_FINALIZER_SUMMARY", "summary must be a JSON object")
56
+ return raw, summary
57
+
58
+
59
+ def from_summary(summary: Mapping[str, Any]) -> dict[str, Any]:
60
+ """Translate one released consumer summary into a neutral execution result.
61
+
62
+ Unrecognised shapes fail with the released error code so 1.5.0/1.6.0 callers
63
+ keep their existing failure contract.
64
+ """
65
+
66
+ status = summary.get("status")
67
+ mode = summary.get("mode")
68
+ result = summary.get("mode_result")
69
+ if status != "success" or not isinstance(mode, str) or not isinstance(result, Mapping):
70
+ raise ControllerError(
71
+ "FINALIZER_RESULT_MISMATCH", "Finalizer summary does not prove this plan"
72
+ )
73
+ operation = {"retire_local_branch": "local", "retire_remote_branch": "remote"}.get(mode)
74
+ if operation is None:
75
+ raise ControllerError(
76
+ "FINALIZER_RESULT_MISMATCH", "Finalizer summary does not prove this plan"
77
+ )
78
+ reported = result.get("result")
79
+ if reported not in _SUMMARY_OUTCOMES:
80
+ raise ControllerError(
81
+ "FINALIZER_RESULT_MISMATCH", "Finalizer summary does not prove this plan"
82
+ )
83
+ kind, outcome = _SUMMARY_OUTCOMES[reported]
84
+ executor_version = summary.get("finalizer_version")
85
+ return {
86
+ "schema_version": execution.RESULT_SCHEMA_VERSION,
87
+ "contract": execution.RESULT_CONTRACT,
88
+ "plan_id": result.get("retirement_plan_id"),
89
+ "operation": operation,
90
+ "kind": kind,
91
+ "outcome": outcome,
92
+ "ref": _ref(operation, result),
93
+ "expected_oid": result.get("expected_oid"),
94
+ "integrated_ref": result.get("integrated_ref"),
95
+ "expected_integrated_oid": result.get("integrated_into_oid"),
96
+ "performed_at": summary.get("finished_at") or summary.get("started_at"),
97
+ "executor": {
98
+ "name": GIT_FINALIZER_CONTRACT,
99
+ "version": executor_version if isinstance(executor_version, str) else "unknown",
100
+ },
101
+ "evidence": {"remote": result.get("remote")},
102
+ }
103
+
104
+
105
+ def arguments(plan: Mapping[str, Any], operation: str, *, repository_root: Path) -> list[str]:
106
+ """The released consumer invocation for one authorized retirement operation."""
107
+
108
+ expected_flag = "--expected-local-oid" if operation == "local" else "--expected-remote-oid"
109
+ return [
110
+ "git-finalize",
111
+ f"--retire-{operation}-branch",
112
+ str(plan["branch"]),
113
+ "--remote",
114
+ str(plan["remote"]),
115
+ "--integrated-into",
116
+ str(plan["integrated_into"]),
117
+ expected_flag,
118
+ str(plan["expected_oid"]),
119
+ "--expected-integrated-oid",
120
+ str(plan["expected_integrated_oid"]),
121
+ "--retirement-plan-id",
122
+ str(plan["plan_id"]),
123
+ "--repo",
124
+ str(repository_root),
125
+ "--summary",
126
+ ]
127
+
128
+
129
+ def summary_sha256(raw: bytes) -> str:
130
+ return hashlib.sha256(raw).hexdigest()
@@ -0,0 +1,234 @@
1
+ """Provider-neutral vocabulary for authorized retirement execution.
2
+
3
+ The Controller owns the decision; a consumer owns the ref mutation. These are the
4
+ two documents that cross that boundary: an *authorization* the Controller issues
5
+ and a *result* the consumer reports back. Neither one names a consumer product,
6
+ and neither one is trusted for what it claims about Git state — the Controller
7
+ re-observes that itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+
15
+ from .models import ControllerError
16
+
17
+ AUTHORIZATION_SCHEMA_VERSION = 1
18
+ AUTHORIZATION_CONTRACT = "branch-retirement-execution/v1"
19
+
20
+ RESULT_SCHEMA_VERSION = 1
21
+ RESULT_CONTRACT = "retirement-execution-result/v1"
22
+
23
+ #: The plan publishes this recipe so a consumer knows what it may be granted to
24
+ #: compare-and-delete without importing any Controller helper.
25
+ RECIPE_SCHEMA_VERSION = 1
26
+ RECIPE_CONTRACT = "retirement-execution-recipe/v1"
27
+
28
+ KIND_CAS_DELETE = "CAS_DELETE"
29
+ KIND_ATTEST_ABSENT = "ATTEST_ABSENT"
30
+ EXECUTION_KINDS = frozenset({KIND_CAS_DELETE, KIND_ATTEST_ABSENT})
31
+
32
+ REF_REMOVED_AT_EXPECTED_OID = "REF_REMOVED_AT_EXPECTED_OID"
33
+ REF_ALREADY_ABSENT = "REF_ALREADY_ABSENT"
34
+ REF_STILL_PRESENT = "REF_STILL_PRESENT"
35
+ EXECUTION_FAILED = "EXECUTION_FAILED"
36
+ OUTCOMES = frozenset(
37
+ {REF_REMOVED_AT_EXPECTED_OID, REF_ALREADY_ABSENT, REF_STILL_PRESENT, EXECUTION_FAILED}
38
+ )
39
+ SUCCESS_OUTCOMES = frozenset({REF_REMOVED_AT_EXPECTED_OID, REF_ALREADY_ABSENT})
40
+
41
+ #: The authorization identity is content-addressed over exactly these fields, in
42
+ #: this order. ``token`` is deliberately excluded so the secret capability cannot
43
+ #: change the authorization's own address.
44
+ AUTHORIZATION_IDENTITY_FIELDS = (
45
+ "schema_version",
46
+ "contract",
47
+ "request_id",
48
+ "plan_id",
49
+ "operation",
50
+ "kind",
51
+ "repository_id",
52
+ "allocation_id",
53
+ "branch",
54
+ "ref",
55
+ "expected_oid",
56
+ "integrated_ref",
57
+ "expected_integrated_oid",
58
+ "tracking_ref",
59
+ "observed",
60
+ "controller_state_digest",
61
+ "holder_sha256",
62
+ "issued_at",
63
+ "expires_at",
64
+ )
65
+
66
+ #: Every result document carries these; they are the values the Controller can
67
+ #: check against its own plan and against Git afterwards.
68
+ RESULT_FIELDS = frozenset(
69
+ {
70
+ "schema_version",
71
+ "contract",
72
+ "plan_id",
73
+ "operation",
74
+ "kind",
75
+ "outcome",
76
+ "ref",
77
+ "expected_oid",
78
+ "expected_integrated_oid",
79
+ "executor",
80
+ }
81
+ )
82
+ #: Optional because a released consumer summary predates them; when present they are
83
+ #: validated, and the Controller never infers a missing one.
84
+ RESULT_OPTIONAL_FIELDS = frozenset({"integrated_ref", "performed_at", "evidence"})
85
+
86
+ RECIPE_FIELDS = frozenset(
87
+ {"contract", "schema_version", "grant", "settle", "result_contract", "operations"}
88
+ )
89
+ RECIPE_OPERATION_FIELDS = frozenset(
90
+ {
91
+ "ref",
92
+ "mutation",
93
+ "expected_oid",
94
+ "integrated_ref",
95
+ "expected_integrated_oid",
96
+ "tracking_ref",
97
+ "kind",
98
+ }
99
+ )
100
+
101
+ OBSERVATION_FIELDS = frozenset(
102
+ {"refs", "checked_out_paths", "upstream_ref", "upstream_target", "classification", "blockers"}
103
+ )
104
+
105
+ _HEX = frozenset("0123456789abcdef")
106
+
107
+
108
+ def identity(document: Mapping[str, Any]) -> dict[str, Any]:
109
+ """The authorization fields that must match at settle time."""
110
+
111
+ return {field: document.get(field) for field in AUTHORIZATION_IDENTITY_FIELDS}
112
+
113
+
114
+ def _text(value: object, field: str) -> str:
115
+ if not isinstance(value, str) or not value:
116
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"{field} must be a non-empty string")
117
+ return value
118
+
119
+
120
+ def _object_id(value: object, field: str) -> str:
121
+ """Accept either Git object ID length; the repository decides which one it uses."""
122
+
123
+ text = _text(value, field)
124
+ if len(text) not in {40, 64} or not _HEX.issuperset(text):
125
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"{field} must be a Git object id")
126
+ return text
127
+
128
+
129
+ def _digest(value: object, field: str) -> str:
130
+ text = _text(value, field)
131
+ if len(text) != 64 or not _HEX.issuperset(text):
132
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"{field} must be a sha256 hex digest")
133
+ return text
134
+
135
+
136
+ def normalize_result(document: Mapping[str, Any]) -> dict[str, Any]:
137
+ """Validate one execution result document and return it with checked types.
138
+
139
+ A well-formed document proves nothing about the world: the caller must still
140
+ observe the ref. This only guarantees every field the observation is compared
141
+ against is present and shaped.
142
+ """
143
+
144
+ unknown = sorted(set(document) - RESULT_FIELDS - RESULT_OPTIONAL_FIELDS)
145
+ if unknown:
146
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"unknown result fields: {unknown}")
147
+ if not RESULT_FIELDS.issubset(document):
148
+ raise ControllerError(
149
+ "INVALID_EXECUTION_RESULT",
150
+ f"execution result is missing {sorted(RESULT_FIELDS - set(document))}",
151
+ )
152
+ if document.get("schema_version") != RESULT_SCHEMA_VERSION:
153
+ raise ControllerError(
154
+ "INVALID_EXECUTION_RESULT",
155
+ f"unsupported result schema {document.get('schema_version')!r}",
156
+ )
157
+ if document.get("contract") != RESULT_CONTRACT:
158
+ raise ControllerError(
159
+ "INVALID_EXECUTION_RESULT", f"unknown result contract {document.get('contract')!r}"
160
+ )
161
+ operation = _text(document.get("operation"), "operation")
162
+ if operation not in {"local", "remote"}:
163
+ raise ControllerError("INVALID_EXECUTION_RESULT", "operation must be local or remote")
164
+ kind = _text(document.get("kind"), "kind")
165
+ if kind not in EXECUTION_KINDS:
166
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"unknown execution kind {kind!r}")
167
+ outcome = _text(document.get("outcome"), "outcome")
168
+ if outcome not in OUTCOMES:
169
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"unknown outcome {outcome!r}")
170
+ executor = document.get("executor")
171
+ if not isinstance(executor, Mapping):
172
+ raise ControllerError("INVALID_EXECUTION_RESULT", "executor must be an object")
173
+ evidence = document.get("evidence")
174
+ if evidence is not None and not isinstance(evidence, Mapping):
175
+ raise ControllerError("INVALID_EXECUTION_RESULT", "evidence must be an object or null")
176
+ return {
177
+ "schema_version": RESULT_SCHEMA_VERSION,
178
+ "contract": RESULT_CONTRACT,
179
+ "plan_id": _digest(document.get("plan_id"), "plan_id"),
180
+ "operation": operation,
181
+ "kind": kind,
182
+ "outcome": outcome,
183
+ "ref": _text(document.get("ref"), "ref"),
184
+ "expected_oid": _object_id(document.get("expected_oid"), "expected_oid"),
185
+ "integrated_ref": (
186
+ _text(document["integrated_ref"], "integrated_ref")
187
+ if document.get("integrated_ref") is not None
188
+ else None
189
+ ),
190
+ "expected_integrated_oid": _object_id(
191
+ document.get("expected_integrated_oid"), "expected_integrated_oid"
192
+ ),
193
+ "performed_at": (
194
+ _text(document["performed_at"], "performed_at")
195
+ if document.get("performed_at") is not None
196
+ else None
197
+ ),
198
+ "executor": {
199
+ "name": _text(executor.get("name"), "executor.name"),
200
+ "version": _text(executor.get("version"), "executor.version"),
201
+ },
202
+ "evidence": dict(evidence or {}),
203
+ }
204
+
205
+
206
+ def normalize_observation(value: object) -> dict[str, Any]:
207
+ """Return an observation vector with a stable shape, or fail closed."""
208
+
209
+ if not isinstance(value, Mapping) or not OBSERVATION_FIELDS.issubset(value):
210
+ raise ControllerError("INVALID_METADATA", "retirement observation vector is incomplete")
211
+ refs = value.get("refs")
212
+ if not isinstance(refs, Mapping):
213
+ raise ControllerError("INVALID_METADATA", "retirement observation refs must be an object")
214
+ paths = value.get("checked_out_paths")
215
+ if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths):
216
+ raise ControllerError("INVALID_METADATA", "retirement observation paths must be a list")
217
+ blockers = value.get("blockers")
218
+ if not isinstance(blockers, list) or not all(isinstance(item, str) for item in blockers):
219
+ raise ControllerError("INVALID_METADATA", "retirement observation blockers must be a list")
220
+ return {
221
+ "refs": {
222
+ key: (oid if oid is None or isinstance(oid, str) else None)
223
+ for key, oid in sorted(refs.items())
224
+ },
225
+ "checked_out_paths": sorted(paths),
226
+ "upstream_ref": value.get("upstream_ref"),
227
+ "upstream_target": value.get("upstream_target"),
228
+ "classification": value.get("classification"),
229
+ "blockers": sorted(blockers),
230
+ }
231
+
232
+
233
+ def succeeded(result: Mapping[str, Any]) -> bool:
234
+ return result.get("outcome") in SUCCESS_OUTCOMES