alter-runtime 0.3.0__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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
alter_runtime/consent.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Install-consent gate, artefact manifest, and deletion-permanence guard.
|
|
2
|
+
|
|
3
|
+
Implements D-SILENT-INSTALL-NEGATION countermeasures:
|
|
4
|
+
|
|
5
|
+
* **C1** - Visible install-consent gate. Before any byte lands, the user
|
|
6
|
+
sees which artefacts will be written, their sizes, SHA-256 digests, and
|
|
7
|
+
sigstore verification status. They must confirm ``[Y/n]`` (or pass
|
|
8
|
+
``--yes``).
|
|
9
|
+
* **C2** - Queryable post-install manifest. Every successfully installed
|
|
10
|
+
artefact is recorded in ``install-manifest.json`` under the XDG data
|
|
11
|
+
directory with path, SHA-256, sigstore URL, install timestamp, and
|
|
12
|
+
version. The manifest is atomically written (tmp + rename) at mode
|
|
13
|
+
0o600 and survives subsequent installs as a log.
|
|
14
|
+
* **C3** - Deletion permanence. When the user removes a service unit via
|
|
15
|
+
``alter-runtime stop`` (or manually), a tombstone entry is written to
|
|
16
|
+
``deleted-tombstones.json``. Subsequent ``alter-runtime start`` calls
|
|
17
|
+
check the tombstone table before writing the unit file; if the path is
|
|
18
|
+
tombstoned, the install is refused with an actionable message. The user
|
|
19
|
+
can explicitly clear the tombstone with ``--reinstall``.
|
|
20
|
+
|
|
21
|
+
Inspired by the Chrome / Gemini Nano silent-install pattern documented in
|
|
22
|
+
Phone-links Session 47 (2026-05-05). ALTER will not ship the same pattern.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from alter_runtime.config import data_dir
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ConsentDeclined",
|
|
40
|
+
"UserDeletedArtefact",
|
|
41
|
+
"enumerate_artefacts",
|
|
42
|
+
"is_tombstoned",
|
|
43
|
+
"manifest_path",
|
|
44
|
+
"manifest_version_for",
|
|
45
|
+
"print_consent_screen",
|
|
46
|
+
"prompt_consent",
|
|
47
|
+
"remove_tombstone",
|
|
48
|
+
"tombstone_path",
|
|
49
|
+
"write_manifest_entry",
|
|
50
|
+
"write_tombstone",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Exceptions
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConsentDeclined(Exception):
|
|
60
|
+
"""User declined the install consent prompt."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UserDeletedArtefact(Exception):
|
|
64
|
+
"""Artefact was previously tombstoned by user; refuse silent reinstall."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# SHA-256 helper
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sha256_file(path: Path) -> str:
|
|
73
|
+
"""Return the hex SHA-256 digest of a file. Returns empty string if the
|
|
74
|
+
file does not exist (artefact not yet installed - pre-consent phase)."""
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return ""
|
|
77
|
+
h = hashlib.sha256()
|
|
78
|
+
try:
|
|
79
|
+
with path.open("rb") as fh:
|
|
80
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
81
|
+
h.update(chunk)
|
|
82
|
+
except OSError:
|
|
83
|
+
return ""
|
|
84
|
+
return h.hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Artefact enumeration
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def enumerate_artefacts(binary_path: Path | None = None) -> list[dict[str, Any]]:
|
|
93
|
+
"""Return a list of artefacts that ``alter-runtime start`` will write.
|
|
94
|
+
|
|
95
|
+
Each entry is a dict::
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
"path": Path,
|
|
99
|
+
"size_bytes": int | None, # None if not yet on disk
|
|
100
|
+
"sha256": str, # empty str if not yet on disk
|
|
101
|
+
"sigstore_bundle_url": str | None,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
For v1 the set is:
|
|
105
|
+
|
|
106
|
+
* The systemd user unit *or* launchd plist (platform-appropriate).
|
|
107
|
+
* The ``alter-runtime`` binary itself (resolved via
|
|
108
|
+
:func:`~alter_runtime.service_install.resolve_runtime_binary`, with
|
|
109
|
+
a graceful fallback to ``None`` if the binary is not yet installed).
|
|
110
|
+
|
|
111
|
+
``sigstore_bundle_url`` is ``None`` in v1 - the bundle-URL lookup lands
|
|
112
|
+
post D-STATIC-BINARY-1.
|
|
113
|
+
"""
|
|
114
|
+
from alter_runtime.service_install import (
|
|
115
|
+
current_platform,
|
|
116
|
+
launchd_plist_install_path,
|
|
117
|
+
systemd_unit_install_path,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
artefacts: list[dict[str, Any]] = []
|
|
121
|
+
|
|
122
|
+
# Unit file
|
|
123
|
+
platform = current_platform()
|
|
124
|
+
if platform == "linux":
|
|
125
|
+
unit_path: Path = systemd_unit_install_path()
|
|
126
|
+
elif platform == "darwin":
|
|
127
|
+
unit_path = launchd_plist_install_path()
|
|
128
|
+
else:
|
|
129
|
+
unit_path = Path("/dev/null") # Windows stub - Wave 3
|
|
130
|
+
|
|
131
|
+
unit_size: int | None = unit_path.stat().st_size if unit_path.exists() else None
|
|
132
|
+
artefacts.append(
|
|
133
|
+
{
|
|
134
|
+
"path": unit_path,
|
|
135
|
+
"size_bytes": unit_size,
|
|
136
|
+
"sha256": _sha256_file(unit_path),
|
|
137
|
+
"sigstore_bundle_url": None,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Runtime binary
|
|
142
|
+
resolved_binary: Path | None = binary_path
|
|
143
|
+
if resolved_binary is None:
|
|
144
|
+
try:
|
|
145
|
+
from alter_runtime.service_install import resolve_runtime_binary
|
|
146
|
+
|
|
147
|
+
resolved_binary = resolve_runtime_binary()
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
resolved_binary = None
|
|
150
|
+
|
|
151
|
+
if resolved_binary is not None:
|
|
152
|
+
bin_size: int | None = resolved_binary.stat().st_size if resolved_binary.exists() else None
|
|
153
|
+
artefacts.append(
|
|
154
|
+
{
|
|
155
|
+
"path": resolved_binary,
|
|
156
|
+
"size_bytes": bin_size,
|
|
157
|
+
"sha256": _sha256_file(resolved_binary),
|
|
158
|
+
"sigstore_bundle_url": None,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return artefacts
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Consent screen
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _human_size(size_bytes: int | None) -> str:
|
|
171
|
+
"""Format bytes as a human-readable string."""
|
|
172
|
+
if size_bytes is None:
|
|
173
|
+
return "?"
|
|
174
|
+
if size_bytes < 1024:
|
|
175
|
+
return f"{size_bytes} B"
|
|
176
|
+
if size_bytes < 1024 * 1024:
|
|
177
|
+
return f"{size_bytes / 1024:.1f} KiB"
|
|
178
|
+
return f"{size_bytes / (1024 * 1024):.1f} MiB"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def print_consent_screen(artefacts: list[dict[str, Any]], uninstall_cmd: str) -> None:
|
|
182
|
+
"""Render the pre-install consent screen to stdout.
|
|
183
|
+
|
|
184
|
+
Format::
|
|
185
|
+
|
|
186
|
+
ALTER Runtime - install consent
|
|
187
|
+
================================
|
|
188
|
+
The following artefacts will be written to your system:
|
|
189
|
+
/path/to/unit (12.3 KiB) sha256:abcd1234 sigstore: unverified
|
|
190
|
+
...
|
|
191
|
+
Sigstore-keyless verification: UNVERIFIED
|
|
192
|
+
To remove everything: alter-runtime stop
|
|
193
|
+
"""
|
|
194
|
+
print("ALTER Runtime - install consent")
|
|
195
|
+
print("================================")
|
|
196
|
+
print("The following artefacts will be written to your system:")
|
|
197
|
+
|
|
198
|
+
any_sigstore = False
|
|
199
|
+
for artefact in artefacts:
|
|
200
|
+
path = artefact["path"]
|
|
201
|
+
size_str = _human_size(artefact.get("size_bytes"))
|
|
202
|
+
sha = artefact.get("sha256") or ""
|
|
203
|
+
short_sha = sha[:12] if sha else "(not yet on disk)"
|
|
204
|
+
bundle_url = artefact.get("sigstore_bundle_url")
|
|
205
|
+
sig_label = "verified" if bundle_url else "unverified"
|
|
206
|
+
if bundle_url:
|
|
207
|
+
any_sigstore = True
|
|
208
|
+
print(f" {path} ({size_str}) sha256:{short_sha} sigstore: {sig_label}")
|
|
209
|
+
|
|
210
|
+
sig_status = "PASSED" if any_sigstore else "UNVERIFIED"
|
|
211
|
+
print(f"Sigstore-keyless verification: {sig_status}")
|
|
212
|
+
print(f"To remove everything: {uninstall_cmd}")
|
|
213
|
+
print()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Consent prompt
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def prompt_consent(yes: bool = False) -> bool:
|
|
222
|
+
"""Prompt the user to confirm installation.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
yes:
|
|
227
|
+
When ``True`` the prompt is skipped and the function returns
|
|
228
|
+
immediately - intended for CI / package-manager post-install hooks
|
|
229
|
+
that cannot be interactive.
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
bool
|
|
234
|
+
Always ``True`` when the user confirms (or ``yes=True``).
|
|
235
|
+
|
|
236
|
+
Raises
|
|
237
|
+
------
|
|
238
|
+
ConsentDeclined
|
|
239
|
+
When the user answers ``n`` / ``N``, when the process is not
|
|
240
|
+
attached to a TTY and ``yes=False``, or on EOF / KeyboardInterrupt.
|
|
241
|
+
"""
|
|
242
|
+
if yes:
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
if not sys.stdin.isatty():
|
|
246
|
+
raise ConsentDeclined(
|
|
247
|
+
"stdin is not a TTY and --yes was not passed. "
|
|
248
|
+
"Use `alter-runtime start --yes` for non-interactive installs."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
answer = input("Proceed with install? [Y/n]: ").strip().lower()
|
|
253
|
+
except (EOFError, KeyboardInterrupt):
|
|
254
|
+
raise ConsentDeclined("install cancelled by user")
|
|
255
|
+
|
|
256
|
+
if answer in ("n", "no"):
|
|
257
|
+
raise ConsentDeclined("install declined by user")
|
|
258
|
+
|
|
259
|
+
# Any other input (empty = Enter, 'y', 'yes', or unrecognised) proceeds.
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Install manifest (C2)
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def manifest_path() -> Path:
|
|
269
|
+
"""Resolve the install-manifest path under ``data_dir()``."""
|
|
270
|
+
return data_dir() / "install-manifest.json"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_manifest() -> dict[str, Any]:
|
|
274
|
+
"""Load the manifest or return an empty scaffold on missing/corrupt file."""
|
|
275
|
+
path = manifest_path()
|
|
276
|
+
if not path.exists():
|
|
277
|
+
return {"schema_version": 1, "entries": {}}
|
|
278
|
+
try:
|
|
279
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
280
|
+
except (json.JSONDecodeError, OSError):
|
|
281
|
+
return {"schema_version": 1, "entries": {}}
|
|
282
|
+
if not isinstance(raw, dict):
|
|
283
|
+
return {"schema_version": 1, "entries": {}}
|
|
284
|
+
if "entries" not in raw:
|
|
285
|
+
raw["entries"] = {}
|
|
286
|
+
return raw
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _atomic_json_write(path: Path, data: Any) -> None:
|
|
290
|
+
"""Write ``data`` as JSON to ``path`` atomically (tmp + rename) at mode 0o600."""
|
|
291
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
292
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
293
|
+
|
|
294
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
295
|
+
fd = os.open(tmp_path, flags, 0o600)
|
|
296
|
+
try:
|
|
297
|
+
with contextlib.suppress(OSError):
|
|
298
|
+
os.fchmod(fd, 0o600)
|
|
299
|
+
payload = json.dumps(data, indent=2, default=str).encode("utf-8")
|
|
300
|
+
os.write(fd, payload)
|
|
301
|
+
os.fsync(fd)
|
|
302
|
+
finally:
|
|
303
|
+
os.close(fd)
|
|
304
|
+
|
|
305
|
+
os.replace(tmp_path, path)
|
|
306
|
+
with contextlib.suppress(OSError):
|
|
307
|
+
os.chmod(path, 0o600)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def write_manifest_entry(artefact: dict[str, Any], version: str) -> None:
|
|
311
|
+
"""Append / upsert an entry in ``install-manifest.json``, keyed on path.
|
|
312
|
+
|
|
313
|
+
The manifest schema::
|
|
314
|
+
|
|
315
|
+
{
|
|
316
|
+
"schema_version": 1,
|
|
317
|
+
"entries": {
|
|
318
|
+
"/absolute/path/to/unit": {
|
|
319
|
+
"path": "...",
|
|
320
|
+
"sha256": "...",
|
|
321
|
+
"sigstore_bundle_url": null,
|
|
322
|
+
"installed_at": "2026-05-07T00:00:00+00:00",
|
|
323
|
+
"version": "0.1.0"
|
|
324
|
+
},
|
|
325
|
+
...
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
Writes are atomic (tmp + rename) at mode 0o600.
|
|
330
|
+
"""
|
|
331
|
+
manifest = _read_manifest()
|
|
332
|
+
entries: dict[str, Any] = manifest.setdefault("entries", {})
|
|
333
|
+
|
|
334
|
+
path_str = str(artefact["path"])
|
|
335
|
+
entries[path_str] = {
|
|
336
|
+
"path": path_str,
|
|
337
|
+
"sha256": artefact.get("sha256") or "",
|
|
338
|
+
"sigstore_bundle_url": artefact.get("sigstore_bundle_url"),
|
|
339
|
+
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
340
|
+
"version": version,
|
|
341
|
+
}
|
|
342
|
+
manifest["entries"] = entries
|
|
343
|
+
_atomic_json_write(manifest_path(), manifest)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def manifest_version_for(path: Path) -> str | None:
|
|
347
|
+
"""Return the recorded version for *path* in the manifest, or ``None``."""
|
|
348
|
+
manifest = _read_manifest()
|
|
349
|
+
entry = manifest.get("entries", {}).get(str(path))
|
|
350
|
+
if entry is None:
|
|
351
|
+
return None
|
|
352
|
+
return entry.get("version")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Tombstone store (C3)
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def tombstone_path() -> Path:
|
|
361
|
+
"""Resolve the deleted-tombstones path under ``data_dir()``."""
|
|
362
|
+
return data_dir() / "deleted-tombstones.json"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _read_tombstones() -> dict[str, Any]:
|
|
366
|
+
"""Load tombstones or return an empty scaffold."""
|
|
367
|
+
path = tombstone_path()
|
|
368
|
+
if not path.exists():
|
|
369
|
+
return {"schema_version": 1, "tombstones": {}}
|
|
370
|
+
try:
|
|
371
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
372
|
+
except (json.JSONDecodeError, OSError):
|
|
373
|
+
return {"schema_version": 1, "tombstones": {}}
|
|
374
|
+
if not isinstance(raw, dict):
|
|
375
|
+
return {"schema_version": 1, "tombstones": {}}
|
|
376
|
+
if "tombstones" not in raw:
|
|
377
|
+
raw["tombstones"] = {}
|
|
378
|
+
return raw
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def is_tombstoned(path: Path) -> bool:
|
|
382
|
+
"""Return ``True`` if *path* has a tombstone entry.
|
|
383
|
+
|
|
384
|
+
Never raises - returns ``False`` on any read error (missing file, corrupt
|
|
385
|
+
JSON, etc.) so the install path is not blocked by a bad tombstone file.
|
|
386
|
+
"""
|
|
387
|
+
try:
|
|
388
|
+
tombstones = _read_tombstones()
|
|
389
|
+
return str(path) in tombstones.get("tombstones", {})
|
|
390
|
+
except Exception: # noqa: BLE001 - intentional broad catch for install safety
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def write_tombstone(
|
|
395
|
+
path: Path,
|
|
396
|
+
sha256_at_deletion: str | None = None,
|
|
397
|
+
source: str = "user_explicit",
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Record a tombstone entry for *path*.
|
|
400
|
+
|
|
401
|
+
Parameters
|
|
402
|
+
----------
|
|
403
|
+
path:
|
|
404
|
+
The artefact path that was deleted.
|
|
405
|
+
sha256_at_deletion:
|
|
406
|
+
The SHA-256 of the file at deletion time, if available.
|
|
407
|
+
source:
|
|
408
|
+
How the deletion happened - ``"user_explicit"`` (via
|
|
409
|
+
``alter-runtime stop``) or ``"user_manual"`` (detected out-of-band).
|
|
410
|
+
"""
|
|
411
|
+
data = _read_tombstones()
|
|
412
|
+
data["tombstones"][str(path)] = {
|
|
413
|
+
"path": str(path),
|
|
414
|
+
"sha256_at_deletion": sha256_at_deletion,
|
|
415
|
+
"deleted_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
416
|
+
"source": source,
|
|
417
|
+
}
|
|
418
|
+
_atomic_json_write(tombstone_path(), data)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def remove_tombstone(path: Path) -> None:
|
|
422
|
+
"""Remove the tombstone entry for *path* (used by ``--reinstall`` flow)."""
|
|
423
|
+
data = _read_tombstones()
|
|
424
|
+
data["tombstones"].pop(str(path), None)
|
|
425
|
+
_atomic_json_write(tombstone_path(), data)
|