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,365 @@
|
|
|
1
|
+
"""Advisory worktree classification and reviewed release routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import asdict, replace
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .controller import _environment_first, _repository_payload, envelope
|
|
12
|
+
from .git_backend import inventory
|
|
13
|
+
from .lifecycle import RELEASABLE_STATES, build_release_plan, governance_release_plan_id, release
|
|
14
|
+
from .lifecycle_observation import observe_repository_orphans, observe_worktree
|
|
15
|
+
from .models import (
|
|
16
|
+
ControllerError,
|
|
17
|
+
MetadataRecord,
|
|
18
|
+
MetadataSnapshot,
|
|
19
|
+
RepositoryInventory,
|
|
20
|
+
WorktreeGitState,
|
|
21
|
+
WorktreePolicy,
|
|
22
|
+
)
|
|
23
|
+
from .mount_diagnostics import diagnose_mounts
|
|
24
|
+
from .policy import load_policy
|
|
25
|
+
from .registration_prune import apply_prune, build_prune_plan
|
|
26
|
+
from .semantics import record_for as _record_for
|
|
27
|
+
from .store import MetadataStore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _candidate_diagnostics(
|
|
31
|
+
repository: RepositoryInventory,
|
|
32
|
+
item: WorktreeGitState,
|
|
33
|
+
requested_diagnostics: dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
namespace_pid: int | None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Probe each registered checkout's own mounts without another Git inventory."""
|
|
38
|
+
reason = "CANDIDATE_PRUNABLE" if item.prunable else "CANDIDATE_PATH_UNAVAILABLE"
|
|
39
|
+
if not item.prunable:
|
|
40
|
+
try:
|
|
41
|
+
if stat.S_ISDIR(item.path.lstat().st_mode):
|
|
42
|
+
if item.path == repository.requested_path:
|
|
43
|
+
return requested_diagnostics
|
|
44
|
+
return diagnose_mounts(
|
|
45
|
+
replace(repository, requested_path=item.path), namespace_pid=namespace_pid
|
|
46
|
+
)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
return {
|
|
50
|
+
"environment": {"code": "UNKNOWN", "facts": [reason]},
|
|
51
|
+
"namespace": {"comparison": "UNKNOWN", "pid": namespace_pid},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _candidate(
|
|
56
|
+
item: WorktreeGitState,
|
|
57
|
+
record: MetadataRecord | None,
|
|
58
|
+
snapshot: MetadataSnapshot,
|
|
59
|
+
policy: WorktreePolicy,
|
|
60
|
+
diagnostics: dict[str, Any],
|
|
61
|
+
now_at: datetime,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
allocation_id = record.allocation_id if record else None
|
|
64
|
+
writer = snapshot.writer_leases.get(allocation_id) if allocation_id else None
|
|
65
|
+
publication = snapshot.publication_lease
|
|
66
|
+
if publication is not None and publication.allocation_id != allocation_id:
|
|
67
|
+
publication = None
|
|
68
|
+
blockers: list[str] = []
|
|
69
|
+
if item.canonical:
|
|
70
|
+
blockers.append("CANONICAL_PROTECTED")
|
|
71
|
+
if item.prunable:
|
|
72
|
+
blockers.append("PRUNABLE_REGISTRATION")
|
|
73
|
+
if record is None:
|
|
74
|
+
blockers.append("METADATA_MISSING")
|
|
75
|
+
if item.detached:
|
|
76
|
+
blockers.append("DETACHED_WORKTREE")
|
|
77
|
+
if item.locked:
|
|
78
|
+
blockers.append("LOCKED_WORKTREE")
|
|
79
|
+
if not item.clean and not item.prunable:
|
|
80
|
+
blockers.append("DIRTY_WORKTREE")
|
|
81
|
+
if record is not None:
|
|
82
|
+
if record.lifecycle not in RELEASABLE_STATES:
|
|
83
|
+
blockers.append("LIFECYCLE_NOT_RELEASABLE")
|
|
84
|
+
if record.external_bindings:
|
|
85
|
+
blockers.append("EXTERNAL_BINDING")
|
|
86
|
+
if writer is not None:
|
|
87
|
+
blockers.append("WRITER_LEASE_PRESENT")
|
|
88
|
+
if publication is not None:
|
|
89
|
+
blockers.append("PUBLICATION_LEASE_PRESENT")
|
|
90
|
+
observation = observe_worktree(
|
|
91
|
+
item,
|
|
92
|
+
record,
|
|
93
|
+
snapshot,
|
|
94
|
+
policy,
|
|
95
|
+
now_at=now_at,
|
|
96
|
+
namespace_healthy=diagnostics["environment"]["code"] == "HEALTHY",
|
|
97
|
+
)
|
|
98
|
+
return {
|
|
99
|
+
"allocation_id": allocation_id,
|
|
100
|
+
"path": str(item.path),
|
|
101
|
+
"branch": item.branch,
|
|
102
|
+
"head": item.head,
|
|
103
|
+
"record_version": record.record_version if record else None,
|
|
104
|
+
"lifecycle": record.lifecycle if record else None,
|
|
105
|
+
"owner": record.owner if record else None,
|
|
106
|
+
"session": observation["session"],
|
|
107
|
+
"dirty": None if item.prunable else not item.clean,
|
|
108
|
+
"lease": {
|
|
109
|
+
"writer": {
|
|
110
|
+
"lease_id": writer.lease_id,
|
|
111
|
+
"owner": writer.owner,
|
|
112
|
+
"holder_pid": writer.holder.pid,
|
|
113
|
+
"session_id": writer.holder.session_id,
|
|
114
|
+
"run_id": writer.holder.run_id,
|
|
115
|
+
"renewed_at": writer.renewed_at,
|
|
116
|
+
"expires_at": writer.expires_at,
|
|
117
|
+
}
|
|
118
|
+
if writer
|
|
119
|
+
else None,
|
|
120
|
+
"publication": {"lease_id": publication.lease_id} if publication else None,
|
|
121
|
+
},
|
|
122
|
+
"external_bindings": list(record.external_bindings) if record else [],
|
|
123
|
+
"lifecycle_observation": observation,
|
|
124
|
+
"stale_facts": observation["stale_facts"],
|
|
125
|
+
"orphan_facts": observation["orphan_facts"],
|
|
126
|
+
"filesystem_health": diagnostics["environment"],
|
|
127
|
+
"namespace_health": {
|
|
128
|
+
"comparison": diagnostics["namespace"]["comparison"],
|
|
129
|
+
"pid": diagnostics["namespace"]["pid"],
|
|
130
|
+
},
|
|
131
|
+
"classification": "BLOCKED" if blockers else "REVIEW_REQUIRED",
|
|
132
|
+
"candidate_reason": blockers[0] if blockers else "RELEASE_PREFLIGHT_REQUIRED",
|
|
133
|
+
"blocking_facts": list(blockers),
|
|
134
|
+
"blockers": blockers or ["RELEASE_PREFLIGHT_REQUIRED"],
|
|
135
|
+
"gc_plan_id": None,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def gc_plan(
|
|
140
|
+
repository_path: Path,
|
|
141
|
+
*,
|
|
142
|
+
target: Path | None = None,
|
|
143
|
+
owner_release: str | None = None,
|
|
144
|
+
release_intent_id: str | None = None,
|
|
145
|
+
namespace_pid: int | None = None,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Classify registered worktrees; preflight only an explicitly selected target."""
|
|
148
|
+
repository = inventory(repository_path)
|
|
149
|
+
diagnostics = diagnose_mounts(repository, namespace_pid=namespace_pid)
|
|
150
|
+
policy = load_policy(repository.canonical_path)
|
|
151
|
+
try:
|
|
152
|
+
snapshot = MetadataStore(repository.common_dir).load()
|
|
153
|
+
except ControllerError as exc:
|
|
154
|
+
raise _environment_first(exc, diagnostics["environment"]) from exc
|
|
155
|
+
result = envelope("gc-plan")
|
|
156
|
+
result["mount_diagnostics"] = diagnostics
|
|
157
|
+
result["environment_diagnosis"] = diagnostics["environment"]
|
|
158
|
+
result["repository"] = _repository_payload(repository, policy, snapshot)
|
|
159
|
+
result["orphan_observations"] = observe_repository_orphans(repository, snapshot)
|
|
160
|
+
now_at = datetime.now(UTC)
|
|
161
|
+
candidates = [
|
|
162
|
+
_candidate(
|
|
163
|
+
item,
|
|
164
|
+
_record_for(item, snapshot),
|
|
165
|
+
snapshot,
|
|
166
|
+
policy,
|
|
167
|
+
_candidate_diagnostics(repository, item, diagnostics, namespace_pid=namespace_pid),
|
|
168
|
+
now_at,
|
|
169
|
+
)
|
|
170
|
+
for item in repository.worktrees
|
|
171
|
+
]
|
|
172
|
+
result["candidates"] = candidates
|
|
173
|
+
if target is None:
|
|
174
|
+
result["decision"] = {
|
|
175
|
+
"code": "GC_CLASSIFIED",
|
|
176
|
+
"total": len(candidates),
|
|
177
|
+
"blocked": sum(row["classification"] == "BLOCKED" for row in candidates),
|
|
178
|
+
"review_required": sum(
|
|
179
|
+
row["classification"] == "REVIEW_REQUIRED" for row in candidates
|
|
180
|
+
),
|
|
181
|
+
"reason": "advisory inventory; each release requires an exact target preflight",
|
|
182
|
+
}
|
|
183
|
+
return result
|
|
184
|
+
selected = target.expanduser().resolve(strict=False)
|
|
185
|
+
row = next((candidate for candidate in candidates if Path(candidate["path"]) == selected), None)
|
|
186
|
+
if row is None:
|
|
187
|
+
raise ControllerError("UNREGISTERED_WORKTREE", f"GC target is not registered: {selected}")
|
|
188
|
+
result["candidate"] = row
|
|
189
|
+
item = next(item for item in repository.worktrees if item.path == selected)
|
|
190
|
+
if item.prunable:
|
|
191
|
+
if release_intent_id is not None:
|
|
192
|
+
result["decision"] = {"code": "GC_BLOCKED", "blockers": ["INVALID_RELEASE_INTENT"]}
|
|
193
|
+
return result
|
|
194
|
+
record = _record_for(item, snapshot)
|
|
195
|
+
if record is None:
|
|
196
|
+
result["decision"] = {"code": "GC_BLOCKED", "blockers": ["METADATA_MISSING"]}
|
|
197
|
+
return result
|
|
198
|
+
try:
|
|
199
|
+
prune_plan = build_prune_plan(repository, snapshot, policy, item, record, owner_release)
|
|
200
|
+
except ControllerError as exc:
|
|
201
|
+
row["blockers"] = [exc.code]
|
|
202
|
+
row["blocking_facts"] = list(row["blockers"])
|
|
203
|
+
row["candidate_reason"] = exc.code
|
|
204
|
+
result["decision"] = {
|
|
205
|
+
"code": "GC_BLOCKED",
|
|
206
|
+
"blockers": row["blockers"],
|
|
207
|
+
"reason": str(exc),
|
|
208
|
+
}
|
|
209
|
+
return result
|
|
210
|
+
row["classification"] = "PREFLIGHT_READY"
|
|
211
|
+
row["blockers"] = []
|
|
212
|
+
row["blocking_facts"] = []
|
|
213
|
+
row["candidate_reason"] = "MISSING_NO_CHANGE_AUDIT"
|
|
214
|
+
row["gc_plan_id"] = prune_plan["gc_plan_id"]
|
|
215
|
+
result["registration_prune_plan"] = prune_plan
|
|
216
|
+
result["decision"] = {
|
|
217
|
+
"code": "GC_PREFLIGHT_READY",
|
|
218
|
+
"gc_plan_id": prune_plan["gc_plan_id"],
|
|
219
|
+
"allocation_id": record.allocation_id,
|
|
220
|
+
"reason": "missing audit registration has owner and retained-ref evidence",
|
|
221
|
+
}
|
|
222
|
+
return result
|
|
223
|
+
if row["classification"] == "BLOCKED":
|
|
224
|
+
result["decision"] = {"code": "GC_BLOCKED", "blockers": row["blockers"]}
|
|
225
|
+
return result
|
|
226
|
+
if owner_release is None and release_intent_id is None:
|
|
227
|
+
row["blockers"] = ["OWNER_RELEASE_OR_INTENT_REQUIRED"]
|
|
228
|
+
row["blocking_facts"] = list(row["blockers"])
|
|
229
|
+
row["candidate_reason"] = row["blockers"][0]
|
|
230
|
+
result["decision"] = {"code": "GC_REVIEW_REQUIRED", "blockers": row["blockers"]}
|
|
231
|
+
return result
|
|
232
|
+
try:
|
|
233
|
+
release_plan = build_release_plan(
|
|
234
|
+
repository_path,
|
|
235
|
+
target=selected,
|
|
236
|
+
expected_record_version=row["record_version"],
|
|
237
|
+
owner_release=owner_release,
|
|
238
|
+
release_intent_id=release_intent_id,
|
|
239
|
+
)
|
|
240
|
+
except ControllerError as exc:
|
|
241
|
+
row["classification"] = "BLOCKED"
|
|
242
|
+
row["blockers"] = [exc.code]
|
|
243
|
+
row["blocking_facts"] = list(row["blockers"])
|
|
244
|
+
row["candidate_reason"] = exc.code
|
|
245
|
+
result["decision"] = {"code": "GC_BLOCKED", "blockers": row["blockers"], "reason": str(exc)}
|
|
246
|
+
return result
|
|
247
|
+
row["classification"] = "PREFLIGHT_READY"
|
|
248
|
+
row["blockers"] = []
|
|
249
|
+
row["blocking_facts"] = []
|
|
250
|
+
row["candidate_reason"] = "EXISTING_RELEASE_PREFLIGHT_PASSED"
|
|
251
|
+
row["gc_plan_id"] = governance_release_plan_id(release_plan)
|
|
252
|
+
result["release_plan"] = asdict(release_plan)
|
|
253
|
+
result["decision"] = {
|
|
254
|
+
"code": "GC_PREFLIGHT_READY",
|
|
255
|
+
"gc_plan_id": row["gc_plan_id"],
|
|
256
|
+
"allocation_id": row["allocation_id"],
|
|
257
|
+
"reason": "existing release preflight passed without mutation",
|
|
258
|
+
}
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _replay_reviewed_release(
|
|
263
|
+
repository_path: Path,
|
|
264
|
+
*,
|
|
265
|
+
target: Path,
|
|
266
|
+
expected_gc_plan_id: str,
|
|
267
|
+
owner_release: str | None,
|
|
268
|
+
release_intent_id: str | None,
|
|
269
|
+
) -> dict[str, Any] | None:
|
|
270
|
+
"""The existing receipt when the exact reviewed plan already completed elsewhere.
|
|
271
|
+
|
|
272
|
+
A background pass or an interrupted earlier apply may have executed the very plan
|
|
273
|
+
that was reviewed. Only a completed receipt whose ReleasePlan hashes to the reviewed
|
|
274
|
+
GC identity qualifies; release() then re-verifies that evidence (released record,
|
|
275
|
+
retained branch, teardown receipt) before answering ALREADY_RELEASED. A release made
|
|
276
|
+
from any other plan stays GC_PLAN_DRIFT, which names the receipt that did happen.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
repository = inventory(repository_path)
|
|
280
|
+
snapshot = MetadataStore(repository.common_dir).load()
|
|
281
|
+
selected = target.expanduser().resolve(strict=False)
|
|
282
|
+
completed = [
|
|
283
|
+
(receipt, snapshot.release_plans.get(receipt.plan_id))
|
|
284
|
+
for receipt in snapshot.release_receipts.values()
|
|
285
|
+
if receipt.result == "RELEASED" and Path(receipt.target_path) == selected
|
|
286
|
+
]
|
|
287
|
+
matches = [
|
|
288
|
+
(receipt, plan)
|
|
289
|
+
for receipt, plan in completed
|
|
290
|
+
if plan is not None and governance_release_plan_id(plan) == expected_gc_plan_id
|
|
291
|
+
]
|
|
292
|
+
if len(matches) != 1:
|
|
293
|
+
if completed:
|
|
294
|
+
raise ControllerError(
|
|
295
|
+
"GC_PLAN_DRIFT",
|
|
296
|
+
"GC target was released by a different plan: "
|
|
297
|
+
+ ", ".join(sorted(receipt.receipt_id for receipt, _ in completed)),
|
|
298
|
+
)
|
|
299
|
+
return None
|
|
300
|
+
_receipt, plan = matches[0]
|
|
301
|
+
payload = release(
|
|
302
|
+
repository_path,
|
|
303
|
+
target=selected,
|
|
304
|
+
expected_record_version=plan.expected_record_version,
|
|
305
|
+
owner_release=owner_release,
|
|
306
|
+
release_intent_id=release_intent_id,
|
|
307
|
+
)
|
|
308
|
+
payload["command"] = "gc-apply"
|
|
309
|
+
payload["decision"]["gc_plan_id"] = expected_gc_plan_id
|
|
310
|
+
return payload
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def gc_apply(
|
|
314
|
+
repository_path: Path,
|
|
315
|
+
*,
|
|
316
|
+
target: Path,
|
|
317
|
+
expected_gc_plan_id: str,
|
|
318
|
+
owner_release: str | None = None,
|
|
319
|
+
release_intent_id: str | None = None,
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
"""Revalidate review identity and delegate the only removal to release()."""
|
|
322
|
+
pruned = apply_prune(
|
|
323
|
+
repository_path,
|
|
324
|
+
target=target,
|
|
325
|
+
expected_gc_plan_id=expected_gc_plan_id,
|
|
326
|
+
owner_release=owner_release,
|
|
327
|
+
)
|
|
328
|
+
if pruned is not None:
|
|
329
|
+
return pruned
|
|
330
|
+
try:
|
|
331
|
+
planned = gc_plan(
|
|
332
|
+
repository_path,
|
|
333
|
+
target=target,
|
|
334
|
+
owner_release=owner_release,
|
|
335
|
+
release_intent_id=release_intent_id,
|
|
336
|
+
)
|
|
337
|
+
except ControllerError as exc:
|
|
338
|
+
if exc.code in {"INVALID_TARGET", "UNREGISTERED_WORKTREE"}:
|
|
339
|
+
replayed = _replay_reviewed_release(
|
|
340
|
+
repository_path,
|
|
341
|
+
target=target,
|
|
342
|
+
expected_gc_plan_id=expected_gc_plan_id,
|
|
343
|
+
owner_release=owner_release,
|
|
344
|
+
release_intent_id=release_intent_id,
|
|
345
|
+
)
|
|
346
|
+
if replayed is not None:
|
|
347
|
+
return replayed
|
|
348
|
+
raise ControllerError("GC_PLAN_DRIFT", "GC target changed since review") from exc
|
|
349
|
+
raise
|
|
350
|
+
if planned["decision"]["code"] != "GC_PREFLIGHT_READY":
|
|
351
|
+
raise ControllerError("GC_PLAN_DRIFT", "GC target no longer passes release preflight")
|
|
352
|
+
observed = planned["decision"]["gc_plan_id"]
|
|
353
|
+
if observed != expected_gc_plan_id:
|
|
354
|
+
raise ControllerError("GC_PLAN_DRIFT", "GC plan identity changed since review")
|
|
355
|
+
receipt = release(
|
|
356
|
+
repository_path,
|
|
357
|
+
target=target,
|
|
358
|
+
expected_record_version=planned["candidate"]["record_version"],
|
|
359
|
+
owner_release=owner_release,
|
|
360
|
+
release_intent_id=release_intent_id,
|
|
361
|
+
expected_governance_plan_id=expected_gc_plan_id,
|
|
362
|
+
)
|
|
363
|
+
receipt["command"] = "gc-apply"
|
|
364
|
+
receipt["decision"]["gc_plan_id"] = expected_gc_plan_id
|
|
365
|
+
return receipt
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Pre-deletion, allocation-scoped hardlink topology and exact unlink effects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import preservation as p
|
|
13
|
+
from .models import ControllerError
|
|
14
|
+
|
|
15
|
+
CONTRACT = "cleanup-hardlink-topology/v1"
|
|
16
|
+
EXTERNAL_CONTRACT = "cleanup-hardlink-topology/v2"
|
|
17
|
+
MUTATION_CONTRACT = "cleanup-induced-hardlink-mutation/v1"
|
|
18
|
+
EXTERNAL_MUTATION_CONTRACT = "cleanup-induced-hardlink-mutation/v2"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _blocked(message: str) -> ControllerError:
|
|
22
|
+
return ControllerError("HARDLINK_TOPOLOGY_BLOCKED", message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _effects(group: dict[str, Any], resources: list[str]) -> dict[str, Any]:
|
|
26
|
+
owners = {m[0] for m in group["members"]}
|
|
27
|
+
classification = (
|
|
28
|
+
"single-link"
|
|
29
|
+
if len(group["members"]) == 1
|
|
30
|
+
else "same-resource"
|
|
31
|
+
if len(owners) == 1
|
|
32
|
+
else "typed-preserved"
|
|
33
|
+
if ".venv" in owners
|
|
34
|
+
else "typed-typed"
|
|
35
|
+
)
|
|
36
|
+
surviving = group["members"]
|
|
37
|
+
steps = []
|
|
38
|
+
for resource in resources:
|
|
39
|
+
removed = [m for m in surviving if m[0] == resource]
|
|
40
|
+
if removed:
|
|
41
|
+
surviving = [m for m in surviving if m[0] != resource]
|
|
42
|
+
steps.append(
|
|
43
|
+
{
|
|
44
|
+
"resource": resource,
|
|
45
|
+
"deleted_links": removed,
|
|
46
|
+
"surviving_paths": surviving,
|
|
47
|
+
"expected_nlink": len(surviving) + group.get("external_links", 0),
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
return {"classification": classification, "expected_steps": steps}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def inventory(root: Path, resources: list[str]) -> dict[str, Any]:
|
|
54
|
+
"""Record every non-directory inode and all its in-scope names; hash opaque bytes.
|
|
55
|
+
|
|
56
|
+
Names outside the exact cleanup resources are never searched or authorized.
|
|
57
|
+
Version 2 records their link-count remainder. Every unlink of such an inode
|
|
58
|
+
requires the pinned inode's unchanged bytes and exact metadata decrement.
|
|
59
|
+
Directory identity remains bound by the FD-relative deletion primitive.
|
|
60
|
+
"""
|
|
61
|
+
if len(resources) != len(set(resources)):
|
|
62
|
+
raise _blocked("invalid or overlapping cleanup resources")
|
|
63
|
+
for name in resources:
|
|
64
|
+
p.source_relative_path(name)
|
|
65
|
+
if any(name != other and Path(name).is_relative_to(Path(other)) for other in resources):
|
|
66
|
+
raise _blocked("invalid or overlapping cleanup resources")
|
|
67
|
+
groups: dict[str, dict[str, Any]] = {}
|
|
68
|
+
anchors = {}
|
|
69
|
+
for resource in resources:
|
|
70
|
+
with p._absolute_source(root / resource) as chain:
|
|
71
|
+
tree = p.scan_tree(chain[-1], chain)
|
|
72
|
+
tree.require_complete()
|
|
73
|
+
anchors[resource] = {
|
|
74
|
+
"worktree": chain[-len(Path(resource).parts) - 1].identity,
|
|
75
|
+
"resource": chain[-1].identity,
|
|
76
|
+
}
|
|
77
|
+
for row in tree.manifest:
|
|
78
|
+
identity = tree.identities[row["path"]]
|
|
79
|
+
if stat.S_ISDIR(identity[2]):
|
|
80
|
+
continue
|
|
81
|
+
key = f"{identity[0]}:{identity[1]}"
|
|
82
|
+
content = {k: row[k] for k in ("content_sha256", "target") if k in row}
|
|
83
|
+
member = [resource, row["path"]]
|
|
84
|
+
if key in groups:
|
|
85
|
+
group = groups[key]
|
|
86
|
+
if group["identity"] != identity or group["content"] != content:
|
|
87
|
+
raise _blocked("inode identity changed during topology scan")
|
|
88
|
+
group["members"].append(member)
|
|
89
|
+
else:
|
|
90
|
+
groups[key] = {"identity": identity, "content": content, "members": [member]}
|
|
91
|
+
external = any(g["identity"][8] > len(g["members"]) for g in groups.values())
|
|
92
|
+
for group in groups.values():
|
|
93
|
+
group["members"].sort()
|
|
94
|
+
if group["identity"][8] < len(group["members"]):
|
|
95
|
+
raise _blocked("hardlink topology is unstable")
|
|
96
|
+
if external:
|
|
97
|
+
group["external_links"] = group["identity"][8] - len(group["members"])
|
|
98
|
+
group.update(_effects(group, resources))
|
|
99
|
+
payload = {
|
|
100
|
+
"contract": EXTERNAL_CONTRACT if external else CONTRACT,
|
|
101
|
+
"resources": resources,
|
|
102
|
+
"anchors": anchors,
|
|
103
|
+
"groups": groups,
|
|
104
|
+
}
|
|
105
|
+
return {**payload, "sha256": p._sha(payload)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate(graph: dict[str, Any]) -> None:
|
|
109
|
+
if graph.get("contract") not in {CONTRACT, EXTERNAL_CONTRACT} or graph.get("sha256") != p._sha(
|
|
110
|
+
{k: v for k, v in graph.items() if k != "sha256"}
|
|
111
|
+
):
|
|
112
|
+
raise _blocked("topology evidence changed")
|
|
113
|
+
for key, group in graph["groups"].items():
|
|
114
|
+
identity = group["identity"]
|
|
115
|
+
members = group["members"]
|
|
116
|
+
external = group.get("external_links", 0)
|
|
117
|
+
if (
|
|
118
|
+
key != f"{identity[0]}:{identity[1]}"
|
|
119
|
+
or type(external) is not int
|
|
120
|
+
or external < 0
|
|
121
|
+
or (graph["contract"] == CONTRACT and "external_links" in group)
|
|
122
|
+
or (graph["contract"] == EXTERNAL_CONTRACT and "external_links" not in group)
|
|
123
|
+
or len(members) + external != identity[8]
|
|
124
|
+
or len({tuple(m) for m in members}) != len(members)
|
|
125
|
+
or any(m[0] not in graph["resources"] for m in members)
|
|
126
|
+
):
|
|
127
|
+
raise _blocked("incomplete topology evidence")
|
|
128
|
+
if any(group.get(k) != v for k, v in _effects(group, graph["resources"]).items()):
|
|
129
|
+
raise _blocked("invalid expected link-count effects")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify(root: Path, expected: dict[str, Any]) -> None:
|
|
133
|
+
validate(expected)
|
|
134
|
+
try:
|
|
135
|
+
actual = inventory(root, expected["resources"])
|
|
136
|
+
except OSError as exc:
|
|
137
|
+
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP, errno.ESTALE}:
|
|
138
|
+
raise ControllerError(
|
|
139
|
+
"RESOURCE_PLAN_MISMATCH", "resource path identity changed"
|
|
140
|
+
) from exc
|
|
141
|
+
raise
|
|
142
|
+
if actual["anchors"] != expected["anchors"]:
|
|
143
|
+
raise ControllerError("RESOURCE_PLAN_MISMATCH", "resource directory identity changed")
|
|
144
|
+
if actual != expected:
|
|
145
|
+
raise _blocked("topology changed since the last recorded baseline")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def transition(
|
|
149
|
+
before: dict[str, Any],
|
|
150
|
+
after: dict[str, Any],
|
|
151
|
+
resource: str,
|
|
152
|
+
external_survivors: dict[str, Any] | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""Accept only the exact removal of one recorded resource's inode names."""
|
|
155
|
+
validate(before)
|
|
156
|
+
validate(after)
|
|
157
|
+
if resource not in before["resources"] or after["resources"] != [
|
|
158
|
+
r for r in before["resources"] if r != resource
|
|
159
|
+
]:
|
|
160
|
+
raise _blocked("unexpected cleanup action or resource set")
|
|
161
|
+
if after["anchors"] != {k: v for k, v in before["anchors"].items() if k != resource}:
|
|
162
|
+
raise _blocked("surviving directory identity changed")
|
|
163
|
+
expected_keys = set()
|
|
164
|
+
mutations = {}
|
|
165
|
+
proofs = external_survivors or {}
|
|
166
|
+
expected_proofs = set()
|
|
167
|
+
for key, old in before["groups"].items():
|
|
168
|
+
members = [m for m in old["members"] if m[0] != resource]
|
|
169
|
+
removed = len(old["members"]) - len(members)
|
|
170
|
+
external = old.get("external_links", 0)
|
|
171
|
+
if external and removed:
|
|
172
|
+
expected_proofs.add(key)
|
|
173
|
+
if not members and not external:
|
|
174
|
+
continue
|
|
175
|
+
if members:
|
|
176
|
+
expected_keys.add(key)
|
|
177
|
+
current = after["groups"].get(key)
|
|
178
|
+
else:
|
|
179
|
+
current = proofs.get(key)
|
|
180
|
+
if current is None or current["content"] != old["content"]:
|
|
181
|
+
raise _blocked("surviving path, inode, content or symlink literal changed")
|
|
182
|
+
if members and (
|
|
183
|
+
current["members"] != members or current.get("external_links", 0) != external
|
|
184
|
+
):
|
|
185
|
+
raise _blocked("surviving hardlink membership changed")
|
|
186
|
+
wanted = list(old["identity"])
|
|
187
|
+
wanted[8] -= removed
|
|
188
|
+
if removed:
|
|
189
|
+
wanted[5] = current["identity"][5]
|
|
190
|
+
if current["identity"] != wanted:
|
|
191
|
+
raise _blocked("unrecorded metadata or link-count mutation")
|
|
192
|
+
if (
|
|
193
|
+
external
|
|
194
|
+
and removed
|
|
195
|
+
and proofs.get(key) != {"identity": current["identity"], "content": current["content"]}
|
|
196
|
+
):
|
|
197
|
+
raise _blocked("pinned external inode verification is missing or differs")
|
|
198
|
+
if removed:
|
|
199
|
+
mutations[key] = {
|
|
200
|
+
"removed_links": removed,
|
|
201
|
+
"before": old["identity"],
|
|
202
|
+
"after": current["identity"],
|
|
203
|
+
"surviving_paths": members,
|
|
204
|
+
}
|
|
205
|
+
if set(after["groups"]) != expected_keys:
|
|
206
|
+
raise _blocked("unexpected surviving inode set")
|
|
207
|
+
if set(proofs) != expected_proofs:
|
|
208
|
+
raise _blocked("unexpected or missing pinned external inode evidence")
|
|
209
|
+
return {
|
|
210
|
+
"contract": mutation_contract(before),
|
|
211
|
+
"completed_resource": resource,
|
|
212
|
+
"before_sha256": before["sha256"],
|
|
213
|
+
"after_sha256": after["sha256"],
|
|
214
|
+
"mutations": mutations,
|
|
215
|
+
**({"external_survivors": proofs} if before["contract"] == EXTERNAL_CONTRACT else {}),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def mutation_contract(graph: dict[str, Any]) -> str:
|
|
220
|
+
return (
|
|
221
|
+
EXTERNAL_MUTATION_CONTRACT if graph["contract"] == EXTERNAL_CONTRACT else MUTATION_CONTRACT
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def external_contents(graph: dict[str, Any], resource: str) -> dict[str, dict[str, Any]]:
|
|
226
|
+
validate(graph)
|
|
227
|
+
return {
|
|
228
|
+
path: group["content"]
|
|
229
|
+
for group in graph["groups"].values()
|
|
230
|
+
if group.get("external_links", 0)
|
|
231
|
+
for name, path in group["members"]
|
|
232
|
+
if name == resource
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def pinned_content(pin: int, expected: list[int]) -> dict[str, Any]:
|
|
237
|
+
"""Observe the authorized inode, including after its last in-scope name is gone.
|
|
238
|
+
|
|
239
|
+
The kernel FD is the only handle: no external pathname is discovered, followed,
|
|
240
|
+
opened or changed. FIFO endpoints remain unopened. This is an observation at
|
|
241
|
+
the unlink boundary, not a claim that external writers are globally excluded.
|
|
242
|
+
"""
|
|
243
|
+
if p._identity(os.fstat(pin)) != expected:
|
|
244
|
+
raise _blocked("pinned inode changed before content verification")
|
|
245
|
+
if stat.S_ISREG(expected[2]):
|
|
246
|
+
fd = os.open(f"/proc/self/fd/{pin}", os.O_RDONLY | os.O_CLOEXEC | os.O_NOATIME)
|
|
247
|
+
try:
|
|
248
|
+
if p._identity(os.fstat(fd)) != expected:
|
|
249
|
+
raise _blocked("pinned content identity changed")
|
|
250
|
+
size, digest = p._transfer(fd)
|
|
251
|
+
if size != expected[3] or p._identity(os.fstat(fd)) != expected:
|
|
252
|
+
raise _blocked("pinned bytes changed during verification")
|
|
253
|
+
content = {"content_sha256": digest}
|
|
254
|
+
finally:
|
|
255
|
+
os.close(fd)
|
|
256
|
+
elif stat.S_ISLNK(expected[2]):
|
|
257
|
+
content = {"target": os.readlink("", dir_fd=pin)}
|
|
258
|
+
elif stat.S_ISFIFO(expected[2]):
|
|
259
|
+
content = {}
|
|
260
|
+
else:
|
|
261
|
+
raise _blocked("unsupported pinned inode type")
|
|
262
|
+
if p._identity(os.fstat(pin)) != expected:
|
|
263
|
+
raise _blocked("pinned inode changed during content verification")
|
|
264
|
+
return content
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def identities(graph: dict[str, Any], resource: str) -> dict[str, list[int]]:
|
|
268
|
+
validate(graph)
|
|
269
|
+
return {
|
|
270
|
+
path: group["identity"]
|
|
271
|
+
for group in graph["groups"].values()
|
|
272
|
+
for name, path in group["members"]
|
|
273
|
+
if name == resource
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def source_baseline(
|
|
278
|
+
manifest: dict[str, Any], graph: dict[str, Any], *, resource: str = ".venv"
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""Overlay a previously verified, recorded graph; never changes the receipt."""
|
|
281
|
+
expected = copy.deepcopy(manifest)
|
|
282
|
+
current = identities(graph, resource)
|
|
283
|
+
if resource not in graph["resources"] or set(current) != {
|
|
284
|
+
name for name, identity in manifest["source_identities"].items() if not stat.S_ISDIR(identity[2])
|
|
285
|
+
}:
|
|
286
|
+
raise _blocked("preserved source topology coverage is incomplete")
|
|
287
|
+
for path, identity in current.items():
|
|
288
|
+
old = expected["source_identities"].get(path)
|
|
289
|
+
if old is None or any(
|
|
290
|
+
a != b for i, (a, b) in enumerate(zip(old, identity, strict=True)) if i not in {5, 8}
|
|
291
|
+
):
|
|
292
|
+
raise _blocked("preserved source identity cannot be rebased")
|
|
293
|
+
expected["source_identities"][path] = identity
|
|
294
|
+
return expected
|