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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,616 @@
1
+ """Install / enable / disable host service units for alter-runtime.
2
+
3
+ Handles three platforms:
4
+
5
+ * **Linux** via systemd *user* units. We install to
6
+ ``~/.config/systemd/user/alter-runtime.service``, run
7
+ ``systemctl --user daemon-reload`` + ``systemctl --user enable --now``,
8
+ and tear down symmetrically on uninstall. User units avoid needing
9
+ sudo and match the per-user security model (JWT, XDG paths, etc.).
10
+ * **macOS** via launchd *LaunchAgents*. We install to
11
+ ``~/Library/LaunchAgents/com.alter.runtime.plist``, bootstrap into
12
+ the current GUI session with ``launchctl bootstrap``, and log to
13
+ ``~/Library/Logs/alter-runtime/``.
14
+ * **Windows** - stubbed out. Wave 3 stream 3b ships the ``pywin32``-based
15
+ Windows Service wrapper.
16
+
17
+ The module exposes a small, testable surface:
18
+
19
+ * :func:`render_systemd_unit` / :func:`render_launchd_plist` - pure string
20
+ templating (read the template, substitute placeholders, return the
21
+ rendered body). No filesystem writes, so tests can assert on the output.
22
+ * :func:`install` / :func:`uninstall` - platform-dispatching wrappers
23
+ that call into the concrete implementations.
24
+ * :func:`service_status` - reports whether the unit is installed /
25
+ loaded / running. Used by ``alter-runtime status``.
26
+
27
+ All writes are idempotent; re-running ``install`` overwrites the existing
28
+ unit file with a freshly rendered copy so operators can pick up template
29
+ changes by re-running a single command.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import os
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+ from typing import TYPE_CHECKING
42
+
43
+ from alter_runtime.services import (
44
+ launchd_template_path,
45
+ systemd_cf_access_dropin_template_path,
46
+ systemd_template_path,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Sequence
51
+
52
+ __all__ = [
53
+ "ServiceStatus",
54
+ "current_platform",
55
+ "install",
56
+ "launchd_plist_install_path",
57
+ "render_launchd_plist",
58
+ "render_systemd_cf_access_dropin",
59
+ "render_systemd_unit",
60
+ "resolve_runtime_binary",
61
+ "service_status",
62
+ "systemd_cf_access_dropin_install_path",
63
+ "systemd_unit_install_path",
64
+ "uninstall",
65
+ ]
66
+
67
+ logger = logging.getLogger("alter_runtime.service_install")
68
+
69
+ SYSTEMD_UNIT_NAME: str = "alter-runtime.service"
70
+ LAUNCHD_LABEL: str = "com.alter.runtime"
71
+ LAUNCHD_PLIST_NAME: str = f"{LAUNCHD_LABEL}.plist"
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Platform detection
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def current_platform() -> str:
80
+ """Return one of ``"linux"``, ``"darwin"``, ``"windows"``, or ``"other"``.
81
+
82
+ Kept as a public helper so the CLI can produce helpful error messages
83
+ on unsupported platforms without duplicating the platform-check logic.
84
+ """
85
+ if sys.platform.startswith("linux"):
86
+ return "linux"
87
+ if sys.platform == "darwin":
88
+ return "darwin"
89
+ if sys.platform == "win32":
90
+ return "windows"
91
+ return "other"
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Binary resolution
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def resolve_runtime_binary() -> Path:
100
+ """Return the absolute path of the installed ``alter-runtime`` entry point.
101
+
102
+ ``pip install alter-runtime`` drops a console script at whatever bin
103
+ directory the active Python environment is using; we find it via
104
+ ``shutil.which`` (which respects ``$PATH``) and fall back to
105
+ ``sys.executable + ` -m alter_runtime.cli``` semantics as a last
106
+ resort. Returning a ``Path`` (rather than a string) lets callers
107
+ cleanly assert on filesystem checks.
108
+ """
109
+ from_path = shutil.which("alter-runtime")
110
+ if from_path:
111
+ return Path(from_path).resolve()
112
+
113
+ # Fallback: try the same directory as the current Python interpreter.
114
+ sibling = Path(sys.executable).parent / "alter-runtime"
115
+ if sibling.exists():
116
+ return sibling.resolve()
117
+
118
+ raise FileNotFoundError(
119
+ "alter-runtime entry point not found on PATH or next to the current "
120
+ "Python interpreter. Install the package first with `pip install -e .` "
121
+ "from the packages/alter-runtime directory, or `pip install alter-runtime`."
122
+ )
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Template rendering
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _substitute(body: str, mapping: dict[str, str]) -> str:
131
+ """Replace ``@KEY@`` placeholders in ``body`` using ``mapping``."""
132
+ out = body
133
+ for key, value in mapping.items():
134
+ out = out.replace(f"@{key}@", value)
135
+ return out
136
+
137
+
138
+ def render_systemd_unit(
139
+ binary_path: Path | None = None,
140
+ home: Path | None = None,
141
+ ) -> str:
142
+ """Render the systemd unit template with the current binary path.
143
+
144
+ Parameters
145
+ ----------
146
+ binary_path:
147
+ Override for the ``@ALTER_RUNTIME_BIN@`` placeholder. Defaults to
148
+ :func:`resolve_runtime_binary`.
149
+ home:
150
+ Override for the ``@ALTER_RUNTIME_HOME@`` placeholder. Defaults to
151
+ ``Path.home()``.
152
+ """
153
+ binary_path = binary_path or resolve_runtime_binary()
154
+ home = home or Path.home()
155
+ template = systemd_template_path().read_text(encoding="utf-8")
156
+ return _substitute(
157
+ template,
158
+ {
159
+ "ALTER_RUNTIME_BIN": str(binary_path),
160
+ "ALTER_RUNTIME_HOME": str(home),
161
+ },
162
+ )
163
+
164
+
165
+ def render_systemd_cf_access_dropin() -> str:
166
+ """Render the CF Access env drop-in template.
167
+
168
+ The template is a static drop-in for the main systemd user unit —
169
+ it pulls ``CF_ACCESS_CLIENT_ID`` + ``CF_ACCESS_CLIENT_SECRET`` from
170
+ ``~/.config/alter/cf-access.env`` into the daemon's process
171
+ environment so the httpx clients can inject the CF Access
172
+ service-token headers required by the production edge at
173
+ ``mcp.truealter.com``.
174
+
175
+ Currently the template has no placeholders (it uses systemd's ``%h``
176
+ specifier for the user home), but the function follows the same
177
+ shape as :func:`render_systemd_unit` for future-proofing — adding
178
+ placeholders later does not change the call sites.
179
+ """
180
+ return systemd_cf_access_dropin_template_path().read_text(encoding="utf-8")
181
+
182
+
183
+ def render_launchd_plist(
184
+ binary_path: Path | None = None,
185
+ log_dir: Path | None = None,
186
+ home: Path | None = None,
187
+ ) -> str:
188
+ """Render the launchd plist template with the current binary path."""
189
+ binary_path = binary_path or resolve_runtime_binary()
190
+ home = home or Path.home()
191
+ log_dir = log_dir or (home / "Library" / "Logs" / "alter-runtime")
192
+ template = launchd_template_path().read_text(encoding="utf-8")
193
+ return _substitute(
194
+ template,
195
+ {
196
+ "ALTER_RUNTIME_BIN": str(binary_path),
197
+ "ALTER_RUNTIME_HOME": str(home),
198
+ "ALTER_RUNTIME_LOG_DIR": str(log_dir),
199
+ },
200
+ )
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Install paths
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ def systemd_unit_install_path() -> Path:
209
+ """Return the path where the systemd user unit should be written."""
210
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
211
+ base = Path(xdg_config) if xdg_config else Path.home() / ".config"
212
+ return base / "systemd" / "user" / SYSTEMD_UNIT_NAME
213
+
214
+
215
+ def systemd_cf_access_dropin_install_path() -> Path:
216
+ """Return the path where the CF Access env drop-in should be written.
217
+
218
+ Drop-ins for a user unit live in ``<unit>.service.d/`` next to the
219
+ main unit file. systemd merges every ``*.conf`` in that directory
220
+ into the parent unit at daemon-reload time.
221
+ """
222
+ return systemd_unit_install_path().parent / (SYSTEMD_UNIT_NAME + ".d") / "cf-access-env.conf"
223
+
224
+
225
+ def launchd_plist_install_path() -> Path:
226
+ """Return the path where the launchd LaunchAgent plist should be written."""
227
+ return Path.home() / "Library" / "LaunchAgents" / LAUNCHD_PLIST_NAME
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Install / uninstall
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ @dataclass
236
+ class InstallResult:
237
+ """Outcome of an :func:`install` call - what was written + what ran."""
238
+
239
+ platform: str
240
+ unit_path: Path
241
+ rendered: str
242
+ service_commands: list[list[str]]
243
+ command_results: list[tuple[list[str], int]]
244
+
245
+
246
+ def install(
247
+ *,
248
+ enable: bool = True,
249
+ dry_run: bool = False,
250
+ binary_path: Path | None = None,
251
+ reinstall: bool = False,
252
+ ) -> InstallResult:
253
+ """Install the host service unit for the current platform.
254
+
255
+ Parameters
256
+ ----------
257
+ enable:
258
+ When ``True`` (the default), also run ``systemctl --user enable
259
+ --now`` (Linux) or ``launchctl bootstrap`` (macOS) to start the
260
+ daemon immediately. Set to ``False`` for a file-only install.
261
+ dry_run:
262
+ When ``True``, render + return the rendered unit body but do not
263
+ touch the filesystem or invoke any service manager command. Used
264
+ by the CLI to preview without side effects.
265
+ binary_path:
266
+ Override for the resolved ``alter-runtime`` binary - tests pass a
267
+ fake path to check templating without requiring the script to be
268
+ installed.
269
+ reinstall:
270
+ When ``True``, bypass the deletion-permanence tombstone check.
271
+ Corresponds to the ``--reinstall`` CLI flag.
272
+ """
273
+ platform = current_platform()
274
+ if platform == "linux":
275
+ return _install_systemd(
276
+ enable=enable, dry_run=dry_run, binary_path=binary_path, reinstall=reinstall
277
+ )
278
+ if platform == "darwin":
279
+ return _install_launchd(
280
+ enable=enable, dry_run=dry_run, binary_path=binary_path, reinstall=reinstall
281
+ )
282
+ if platform == "windows":
283
+ raise NotImplementedError("Windows Service install lands in Wave 3 stream 3b (D-RT1).")
284
+ raise NotImplementedError(f"unsupported platform: {sys.platform}")
285
+
286
+
287
+ def uninstall() -> list[tuple[list[str], int]]:
288
+ """Disable + remove the host service unit for the current platform.
289
+
290
+ Returns a list of ``(command, exit_code)`` tuples for every shell
291
+ invocation the uninstaller ran (empty on platforms that only need a
292
+ file delete).
293
+ """
294
+ platform = current_platform()
295
+ if platform == "linux":
296
+ return _uninstall_systemd()
297
+ if platform == "darwin":
298
+ return _uninstall_launchd()
299
+ if platform == "windows":
300
+ raise NotImplementedError("Windows Service uninstall lands in Wave 3 stream 3b.")
301
+ raise NotImplementedError(f"unsupported platform: {sys.platform}")
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # systemd (Linux)
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def _install_systemd(
310
+ *,
311
+ enable: bool,
312
+ dry_run: bool,
313
+ binary_path: Path | None,
314
+ reinstall: bool = False,
315
+ ) -> InstallResult:
316
+ unit_body = render_systemd_unit(binary_path=binary_path)
317
+ unit_path = systemd_unit_install_path()
318
+
319
+ commands: list[list[str]] = [
320
+ ["systemctl", "--user", "daemon-reload"],
321
+ ]
322
+ if enable:
323
+ commands.append(
324
+ ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT_NAME],
325
+ )
326
+
327
+ if dry_run:
328
+ return InstallResult(
329
+ platform="linux",
330
+ unit_path=unit_path,
331
+ rendered=unit_body,
332
+ service_commands=commands,
333
+ command_results=[],
334
+ )
335
+
336
+ # C3 - deletion-permanence guard. Refuse to recreate a unit file the
337
+ # user previously removed unless --reinstall was passed explicitly.
338
+ if not reinstall:
339
+ from alter_runtime.consent import UserDeletedArtefact, is_tombstoned
340
+
341
+ if is_tombstoned(unit_path):
342
+ raise UserDeletedArtefact(unit_path)
343
+
344
+ unit_path.parent.mkdir(parents=True, exist_ok=True)
345
+ unit_path.write_text(unit_body, encoding="utf-8")
346
+ try:
347
+ unit_path.chmod(0o644)
348
+ except OSError:
349
+ pass
350
+ logger.info("wrote systemd unit to %s", unit_path)
351
+
352
+ # CF Access env drop-in (D-SUBSTRATE-UNIFIED-1 §2.3 Option A) — lands
353
+ # alongside the main unit so the daemon's httpx clients inherit the
354
+ # service-token env-vars from ~/.config/alter/cf-access.env on every
355
+ # restart. EnvironmentFile in the drop-in uses a leading "-" so a
356
+ # missing envfile is non-fatal; dev hosts without CF Access still
357
+ # boot cleanly.
358
+ dropin_body = render_systemd_cf_access_dropin()
359
+ dropin_path = systemd_cf_access_dropin_install_path()
360
+ dropin_path.parent.mkdir(parents=True, exist_ok=True)
361
+ dropin_path.write_text(dropin_body, encoding="utf-8")
362
+ try:
363
+ dropin_path.chmod(0o644)
364
+ except OSError:
365
+ pass
366
+ logger.info("wrote systemd CF Access drop-in to %s", dropin_path)
367
+
368
+ results = _run_commands(commands)
369
+ return InstallResult(
370
+ platform="linux",
371
+ unit_path=unit_path,
372
+ rendered=unit_body,
373
+ service_commands=commands,
374
+ command_results=results,
375
+ )
376
+
377
+
378
+ def _uninstall_systemd() -> list[tuple[list[str], int]]:
379
+ results = _run_commands(
380
+ [
381
+ ["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT_NAME],
382
+ ["systemctl", "--user", "daemon-reload"],
383
+ ]
384
+ )
385
+ unit_path = systemd_unit_install_path()
386
+ if unit_path.exists():
387
+ try:
388
+ unit_path.unlink()
389
+ logger.info("removed systemd unit %s", unit_path)
390
+ except OSError as exc:
391
+ logger.warning("could not delete %s: %s", unit_path, exc)
392
+ # Remove the CF Access drop-in we installed. The drop-in directory
393
+ # may hold other operator-authored drop-ins (memory caps, eBPF
394
+ # hardening, repo overrides); we only touch the one we wrote.
395
+ dropin_path = systemd_cf_access_dropin_install_path()
396
+ if dropin_path.exists():
397
+ try:
398
+ dropin_path.unlink()
399
+ logger.info("removed systemd CF Access drop-in %s", dropin_path)
400
+ except OSError as exc:
401
+ logger.warning("could not delete %s: %s", dropin_path, exc)
402
+ return results
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # launchd (macOS)
407
+ # ---------------------------------------------------------------------------
408
+
409
+
410
+ def _install_launchd(
411
+ *,
412
+ enable: bool,
413
+ dry_run: bool,
414
+ binary_path: Path | None,
415
+ reinstall: bool = False,
416
+ ) -> InstallResult:
417
+ plist_body = render_launchd_plist(binary_path=binary_path)
418
+ plist_path = launchd_plist_install_path()
419
+ log_dir = Path.home() / "Library" / "Logs" / "alter-runtime"
420
+
421
+ try:
422
+ uid = os.getuid() # type: ignore[attr-defined]
423
+ except AttributeError:
424
+ uid = 0 # pragma: no cover - macOS always has getuid
425
+
426
+ commands: list[list[str]] = []
427
+ if enable:
428
+ commands.append(
429
+ ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
430
+ )
431
+ commands.append(
432
+ ["launchctl", "kickstart", "-k", f"gui/{uid}/{LAUNCHD_LABEL}"],
433
+ )
434
+
435
+ if dry_run:
436
+ return InstallResult(
437
+ platform="darwin",
438
+ unit_path=plist_path,
439
+ rendered=plist_body,
440
+ service_commands=commands,
441
+ command_results=[],
442
+ )
443
+
444
+ # C3 - deletion-permanence guard (launchd).
445
+ if not reinstall:
446
+ from alter_runtime.consent import UserDeletedArtefact, is_tombstoned
447
+
448
+ if is_tombstoned(plist_path):
449
+ raise UserDeletedArtefact(plist_path)
450
+
451
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
452
+ log_dir.mkdir(parents=True, exist_ok=True)
453
+ plist_path.write_text(plist_body, encoding="utf-8")
454
+ try:
455
+ plist_path.chmod(0o644)
456
+ except OSError:
457
+ pass
458
+ logger.info("wrote launchd plist to %s", plist_path)
459
+
460
+ results = _run_commands(commands)
461
+ return InstallResult(
462
+ platform="darwin",
463
+ unit_path=plist_path,
464
+ rendered=plist_body,
465
+ service_commands=commands,
466
+ command_results=results,
467
+ )
468
+
469
+
470
+ def _uninstall_launchd() -> list[tuple[list[str], int]]:
471
+ try:
472
+ uid = os.getuid() # type: ignore[attr-defined]
473
+ except AttributeError:
474
+ uid = 0
475
+
476
+ results = _run_commands(
477
+ [
478
+ ["launchctl", "bootout", f"gui/{uid}/{LAUNCHD_LABEL}"],
479
+ ]
480
+ )
481
+ plist_path = launchd_plist_install_path()
482
+ if plist_path.exists():
483
+ try:
484
+ plist_path.unlink()
485
+ logger.info("removed launchd plist %s", plist_path)
486
+ except OSError as exc:
487
+ logger.warning("could not delete %s: %s", plist_path, exc)
488
+ return results
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # Status
493
+ # ---------------------------------------------------------------------------
494
+
495
+
496
+ @dataclass
497
+ class ServiceStatus:
498
+ """Snapshot of the host service's installation and running state."""
499
+
500
+ platform: str
501
+ installed: bool
502
+ active: bool
503
+ unit_path: Path
504
+ status_line: str
505
+
506
+
507
+ def service_status() -> ServiceStatus:
508
+ """Report whether the service unit is installed + running.
509
+
510
+ The active-state probe is a best-effort ``systemctl is-active`` /
511
+ ``launchctl print`` call - if the service manager itself is missing
512
+ (e.g. headless container without systemd) we return
513
+ ``active=False`` and a status line explaining why.
514
+ """
515
+ platform = current_platform()
516
+ if platform == "linux":
517
+ unit_path = systemd_unit_install_path()
518
+ installed = unit_path.exists()
519
+ active = False
520
+ status_line = "not installed"
521
+ if installed:
522
+ exit_code, stdout = _capture(["systemctl", "--user", "is-active", SYSTEMD_UNIT_NAME])
523
+ active = exit_code == 0 and stdout.strip() == "active"
524
+ status_line = stdout.strip() or f"systemctl exit={exit_code}"
525
+ return ServiceStatus(
526
+ platform=platform,
527
+ installed=installed,
528
+ active=active,
529
+ unit_path=unit_path,
530
+ status_line=status_line,
531
+ )
532
+
533
+ if platform == "darwin":
534
+ plist_path = launchd_plist_install_path()
535
+ installed = plist_path.exists()
536
+ active = False
537
+ status_line = "not installed"
538
+ if installed:
539
+ try:
540
+ uid = os.getuid() # type: ignore[attr-defined]
541
+ except AttributeError:
542
+ uid = 0
543
+ exit_code, stdout = _capture(["launchctl", "print", f"gui/{uid}/{LAUNCHD_LABEL}"])
544
+ active = exit_code == 0 and "state = running" in stdout
545
+ status_line = "running" if active else f"launchctl exit={exit_code}"
546
+ return ServiceStatus(
547
+ platform=platform,
548
+ installed=installed,
549
+ active=active,
550
+ unit_path=plist_path,
551
+ status_line=status_line,
552
+ )
553
+
554
+ return ServiceStatus(
555
+ platform=platform,
556
+ installed=False,
557
+ active=False,
558
+ unit_path=Path("/"),
559
+ status_line=f"unsupported platform: {sys.platform}",
560
+ )
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # Subprocess helpers
565
+ # ---------------------------------------------------------------------------
566
+
567
+
568
+ def _run_commands(commands: Sequence[Sequence[str]]) -> list[tuple[list[str], int]]:
569
+ """Run each command in order, collecting exit codes. Non-fatal on failure.
570
+
571
+ We log warnings on non-zero exits rather than raising so that a missing
572
+ ``systemctl`` (e.g. inside a non-systemd container) doesn't crash
573
+ ``alter-runtime install`` - the file write still succeeds and the
574
+ operator can enable the unit manually.
575
+ """
576
+ results: list[tuple[list[str], int]] = []
577
+ for cmd in commands:
578
+ cmd_list = list(cmd)
579
+ try:
580
+ completed = subprocess.run( # noqa: S603 - trusted args
581
+ cmd_list,
582
+ check=False,
583
+ capture_output=True,
584
+ text=True,
585
+ )
586
+ except FileNotFoundError:
587
+ logger.warning("command not found: %s - skipping", cmd_list[0])
588
+ results.append((cmd_list, 127))
589
+ continue
590
+ if completed.returncode != 0:
591
+ logger.warning(
592
+ "%s exited %d stderr=%r",
593
+ " ".join(cmd_list),
594
+ completed.returncode,
595
+ completed.stderr.strip()[:256],
596
+ )
597
+ results.append((cmd_list, completed.returncode))
598
+ return results
599
+
600
+
601
+ def _capture(cmd: Sequence[str]) -> tuple[int, str]:
602
+ """Run ``cmd`` and return ``(exit_code, stdout)``.
603
+
604
+ Missing binaries yield ``(127, "")`` so callers can treat them as
605
+ "not installed" without branching on ``FileNotFoundError``.
606
+ """
607
+ try:
608
+ completed = subprocess.run( # noqa: S603
609
+ list(cmd),
610
+ check=False,
611
+ capture_output=True,
612
+ text=True,
613
+ )
614
+ except FileNotFoundError:
615
+ return 127, ""
616
+ return completed.returncode, completed.stdout
@@ -0,0 +1,59 @@
1
+ """Host service unit templates for alter-runtime (Wave 2 stream 2c).
2
+
3
+ Contains the platform-specific unit files that make the daemon a long-lived
4
+ system service:
5
+
6
+ * ``systemd/alter-runtime.service.in`` - template for a systemd *user* unit
7
+ installed to ``~/.config/systemd/user/alter-runtime.service``. User units
8
+ are preferred over system units because the daemon holds a user-scoped
9
+ JWT and writes into ``$XDG_RUNTIME_DIR`` / ``$XDG_DATA_HOME`` - both of
10
+ which live under the user's account.
11
+ * ``launchd/com.alter.runtime.plist.in`` - template for a launchd
12
+ *LaunchAgent* installed to ``~/Library/LaunchAgents/com.alter.runtime.plist``
13
+ on macOS. Agents run at per-user login (as opposed to daemons which run
14
+ at boot and need elevated privileges).
15
+ * ``windows/`` - reserved for the Windows Service wrapper shipped in Wave 3
16
+ stream 3b.
17
+
18
+ The unit files are templates because the installed binary path is not
19
+ known until install time: ``pip install alter-runtime`` drops the entry
20
+ point at whatever ``scripts`` directory the active Python environment uses
21
+ (``~/.local/bin``, ``$VIRTUAL_ENV/bin``, ``/usr/local/bin``, …), and a
22
+ fixed ``ExecStart`` path would only work for one of those. The install
23
+ helper in :mod:`alter_runtime.service_install` resolves the current
24
+ binary via ``shutil.which`` and renders the template before writing.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+
31
+ __all__ = [
32
+ "SERVICES_DIR",
33
+ "launchd_template_path",
34
+ "systemd_cf_access_dropin_template_path",
35
+ "systemd_template_path",
36
+ ]
37
+
38
+ SERVICES_DIR: Path = Path(__file__).parent
39
+
40
+
41
+ def systemd_template_path() -> Path:
42
+ """Return the absolute path of the bundled systemd unit template."""
43
+ return SERVICES_DIR / "systemd" / "alter-runtime.service.in"
44
+
45
+
46
+ def systemd_cf_access_dropin_template_path() -> Path:
47
+ """Return the absolute path of the bundled CF Access env drop-in template.
48
+
49
+ The drop-in loads ``~/.config/alter/cf-access.env`` into the daemon's
50
+ environment so the httpx clients can inject the CF Access
51
+ service-token headers required by ``mcp.truealter.com``. Installed
52
+ alongside the main unit by :func:`alter_runtime.service_install.install`.
53
+ """
54
+ return SERVICES_DIR / "systemd" / "cf-access-env.conf.in"
55
+
56
+
57
+ def launchd_template_path() -> Path:
58
+ """Return the absolute path of the bundled launchd plist template."""
59
+ return SERVICES_DIR / "launchd" / "com.alter.runtime.plist.in"