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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""GitWatcher - ambient signal adapter for git repository activity.
|
|
2
|
+
|
|
3
|
+
Observes one or more local git repositories and publishes ``local.signal``
|
|
4
|
+
events for commits and branch switches. Uses ``watchdog`` to tail the
|
|
5
|
+
per-branch ref files under ``.git/refs/heads/`` and the symbolic ``.git/HEAD``
|
|
6
|
+
pointer, because those change exactly when the user commits (``refs/heads/<br>``
|
|
7
|
+
is rewritten) or switches branches (``HEAD`` is rewritten).
|
|
8
|
+
|
|
9
|
+
Signals
|
|
10
|
+
-------
|
|
11
|
+
|
|
12
|
+
* ``kind = "git_commit"`` - a branch ref advanced. Payload::
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"repo": "/abs/path/to/repo",
|
|
16
|
+
"branch": "main",
|
|
17
|
+
"sha": "abc123...",
|
|
18
|
+
"previous": "def456..." | None,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* ``kind = "git_branch_switch"`` - the HEAD pointer moved to a different
|
|
22
|
+
branch. Payload::
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
"repo": "/abs/path/to/repo",
|
|
26
|
+
"branch": "feature/foo",
|
|
27
|
+
"previous": "main",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Both are published on the ``local.signal`` topic for the eventual egress
|
|
31
|
+
producer. The runtime itself does *not* post them back to the DO - that is
|
|
32
|
+
the egress producer's job in W2.2d.
|
|
33
|
+
|
|
34
|
+
Design notes
|
|
35
|
+
------------
|
|
36
|
+
|
|
37
|
+
* **One observer per repo.** Each configured repo gets a dedicated watchdog
|
|
38
|
+
``Observer`` so that a misbehaving filesystem event on one repo does not
|
|
39
|
+
block another. For typical developer machines this is ~1-3 observers.
|
|
40
|
+
* **Debounce on ref changes.** Git writes refs atomically via rename, which
|
|
41
|
+
fires both ``on_created`` and ``on_modified`` in rapid succession. We
|
|
42
|
+
debounce by caching the last-seen SHA per ref and only publishing when it
|
|
43
|
+
changes.
|
|
44
|
+
* **Thread boundary.** ``watchdog`` callbacks run on the observer's own
|
|
45
|
+
thread. We marshal onto the asyncio loop via ``loop.call_soon_threadsafe``
|
|
46
|
+
before publishing to the bus - the bus is *not* thread-safe.
|
|
47
|
+
* **Autodetect CWD.** When ``repo_paths`` is empty and the current working
|
|
48
|
+
directory is a git repo, the adapter watches the CWD. This is the common
|
|
49
|
+
case on a developer laptop where the user runs ``alter-runtime daemon``
|
|
50
|
+
from inside their main workspace.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import asyncio
|
|
56
|
+
import contextlib
|
|
57
|
+
import logging
|
|
58
|
+
import os
|
|
59
|
+
import re as _re
|
|
60
|
+
import threading
|
|
61
|
+
from dataclasses import dataclass, field
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from alter_runtime.config import DaemonConfig
|
|
66
|
+
from alter_runtime.daemon import Component
|
|
67
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
68
|
+
|
|
69
|
+
__all__ = ["GitWatcher"]
|
|
70
|
+
|
|
71
|
+
logger = logging.getLogger("alter_runtime.adapters.git_watcher")
|
|
72
|
+
|
|
73
|
+
EGRESS_TOPIC: str = "local.signal"
|
|
74
|
+
|
|
75
|
+
#: Branch-name shape gate (Pentest 2026-04-26, MEDIUM). Git itself imposes
|
|
76
|
+
#: tighter rules (see ``check-ref-format(1)``), but a watchdog-driven file
|
|
77
|
+
#: rename can land arbitrary bytes here if an attacker plants a malicious
|
|
78
|
+
#: file under ``.git/refs/heads/``. We accept the conservative subset that
|
|
79
|
+
#: covers every legitimate branch name we expect on disk and reject the
|
|
80
|
+
#: rest before they reach the bus and any downstream consumers (DO ingest,
|
|
81
|
+
#: status-bar widgets, etc.).
|
|
82
|
+
|
|
83
|
+
_BRANCH_NAME_RE = _re.compile(r"^[A-Za-z0-9_./-]+$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_safe_branch_name(branch: str) -> bool:
|
|
87
|
+
"""Return True when ``branch`` matches the allowed branch shape.
|
|
88
|
+
|
|
89
|
+
Additional gates beyond the regex (mirroring ``check-ref-format(1)``):
|
|
90
|
+
reject any ``..`` sequence so a planted ref file can't traverse
|
|
91
|
+
upward, reject leading ``/`` / ``-`` / ``.``, and reject trailing
|
|
92
|
+
``/`` / ``.``.
|
|
93
|
+
"""
|
|
94
|
+
if not branch or len(branch) > 255:
|
|
95
|
+
return False
|
|
96
|
+
if not _BRANCH_NAME_RE.match(branch):
|
|
97
|
+
return False
|
|
98
|
+
if ".." in branch:
|
|
99
|
+
return False
|
|
100
|
+
if branch.startswith(("/", "-", ".")):
|
|
101
|
+
return False
|
|
102
|
+
if branch.endswith(("/", ".")):
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class _WatchedRepo:
|
|
109
|
+
"""Bookkeeping for one watched repository."""
|
|
110
|
+
|
|
111
|
+
path: Path
|
|
112
|
+
git_dir: Path
|
|
113
|
+
#: Last-seen SHAs keyed by branch name, for debouncing redundant fs events.
|
|
114
|
+
branch_shas: dict[str, str] = field(default_factory=dict)
|
|
115
|
+
#: Last-seen HEAD branch name, for detecting branch switches.
|
|
116
|
+
head_branch: str | None = None
|
|
117
|
+
observer: Any | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GitWatcher(Component):
|
|
121
|
+
"""Watches git repos and publishes commit / branch-switch signals.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
config:
|
|
126
|
+
Loaded :class:`DaemonConfig` (currently unused but kept for symmetry
|
|
127
|
+
with the other components and future knobs like ``git_watch_paths``).
|
|
128
|
+
bus:
|
|
129
|
+
Shared :class:`EventBus` - signals are published on ``local.signal``.
|
|
130
|
+
repo_paths:
|
|
131
|
+
Explicit list of repository paths to watch. If empty, the adapter
|
|
132
|
+
autodetects the current working directory when it's a git repo and
|
|
133
|
+
falls back to no-op otherwise.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
name = "git_watcher"
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
config: DaemonConfig,
|
|
141
|
+
bus: EventBus,
|
|
142
|
+
repo_paths: list[Path] | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._config = config
|
|
145
|
+
self._bus = bus
|
|
146
|
+
self._explicit_paths = repo_paths
|
|
147
|
+
self._stop_event = asyncio.Event()
|
|
148
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
149
|
+
self._repos: list[_WatchedRepo] = []
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Component lifecycle
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
async def run(self) -> None:
|
|
156
|
+
self._loop = asyncio.get_running_loop()
|
|
157
|
+
repos = self._resolve_repos()
|
|
158
|
+
if not repos:
|
|
159
|
+
logger.info("git_watcher no repositories to watch - idle")
|
|
160
|
+
await self._stop_event.wait()
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
from watchdog.events import FileSystemEventHandler # noqa: F401
|
|
165
|
+
from watchdog.observers import Observer
|
|
166
|
+
except ImportError:
|
|
167
|
+
logger.warning("watchdog not installed - git_watcher disabled")
|
|
168
|
+
await self._stop_event.wait()
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
for repo in repos:
|
|
172
|
+
self._bootstrap_repo_state(repo)
|
|
173
|
+
observer = Observer()
|
|
174
|
+
handler = _GitRefHandler(self, repo)
|
|
175
|
+
refs_dir = repo.git_dir / "refs" / "heads"
|
|
176
|
+
if refs_dir.exists():
|
|
177
|
+
observer.schedule(handler, str(refs_dir), recursive=True)
|
|
178
|
+
head_file = repo.git_dir / "HEAD"
|
|
179
|
+
if head_file.exists():
|
|
180
|
+
observer.schedule(handler, str(repo.git_dir), recursive=False)
|
|
181
|
+
observer.daemon = True
|
|
182
|
+
observer.start()
|
|
183
|
+
repo.observer = observer
|
|
184
|
+
self._repos.append(repo)
|
|
185
|
+
logger.info(
|
|
186
|
+
"git_watcher observing repo=%s initial_branch=%s initial_shas=%s",
|
|
187
|
+
repo.path,
|
|
188
|
+
repo.head_branch,
|
|
189
|
+
{k: v[:7] for k, v in repo.branch_shas.items()},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
await self._stop_event.wait()
|
|
194
|
+
finally:
|
|
195
|
+
for repo in self._repos:
|
|
196
|
+
if repo.observer is not None:
|
|
197
|
+
with contextlib.suppress(Exception):
|
|
198
|
+
repo.observer.stop()
|
|
199
|
+
repo.observer.join(timeout=2.0)
|
|
200
|
+
logger.info("git_watcher stopped")
|
|
201
|
+
|
|
202
|
+
async def stop(self) -> None:
|
|
203
|
+
self._stop_event.set()
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Repo discovery + initial state
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _resolve_repos(self) -> list[_WatchedRepo]:
|
|
210
|
+
paths: list[Path] = []
|
|
211
|
+
if self._explicit_paths:
|
|
212
|
+
paths = [Path(p).expanduser().resolve() for p in self._explicit_paths]
|
|
213
|
+
else:
|
|
214
|
+
# Autodetect from CWD - resolve() so symlinked workspaces don't
|
|
215
|
+
# confuse the symlink check below.
|
|
216
|
+
cwd = Path.cwd().resolve()
|
|
217
|
+
git_dir = cwd / ".git"
|
|
218
|
+
if git_dir.is_dir():
|
|
219
|
+
paths = [cwd]
|
|
220
|
+
|
|
221
|
+
repos: list[_WatchedRepo] = []
|
|
222
|
+
for path in paths:
|
|
223
|
+
git_dir = path / ".git"
|
|
224
|
+
# Pentest 2026-04-26 (MEDIUM): refuse to register a watch when
|
|
225
|
+
# ``.git`` is a symlink. Watchdog follows symlinks transparently,
|
|
226
|
+
# which would let an attacker drop a symlinked .git into a CWD
|
|
227
|
+
# the daemon scans and trick the watcher into observing - and
|
|
228
|
+
# publishing signals from - a directory tree outside the
|
|
229
|
+
# operator's repo set. Worktrees and gitlinks (file ``.git`` with
|
|
230
|
+
# ``gitdir: <path>`` content) are handled separately by git
|
|
231
|
+
# itself; refusing the symlink case is the conservative gate.
|
|
232
|
+
if git_dir.is_symlink():
|
|
233
|
+
logger.warning(
|
|
234
|
+
"git_watcher refusing symlinked .git path=%s -> %s",
|
|
235
|
+
git_dir,
|
|
236
|
+
git_dir.resolve(strict=False),
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
if not git_dir.is_dir():
|
|
240
|
+
logger.warning("git_watcher skipping non-repo path=%s", path)
|
|
241
|
+
continue
|
|
242
|
+
repos.append(_WatchedRepo(path=path, git_dir=git_dir))
|
|
243
|
+
return repos
|
|
244
|
+
|
|
245
|
+
def _bootstrap_repo_state(self, repo: _WatchedRepo) -> None:
|
|
246
|
+
"""Prime ``branch_shas`` and ``head_branch`` from current refs.
|
|
247
|
+
|
|
248
|
+
Without this, the first commit after startup would publish a
|
|
249
|
+
``git_commit`` for every existing ref because the ``branch_shas``
|
|
250
|
+
cache starts empty.
|
|
251
|
+
"""
|
|
252
|
+
refs_dir = repo.git_dir / "refs" / "heads"
|
|
253
|
+
if refs_dir.is_dir():
|
|
254
|
+
for ref_file in _iter_ref_files(refs_dir):
|
|
255
|
+
branch = _branch_name_from_ref(refs_dir, ref_file)
|
|
256
|
+
sha = _read_ref(ref_file)
|
|
257
|
+
if sha:
|
|
258
|
+
repo.branch_shas[branch] = sha
|
|
259
|
+
|
|
260
|
+
head_file = repo.git_dir / "HEAD"
|
|
261
|
+
if head_file.exists():
|
|
262
|
+
repo.head_branch = _read_head_branch(head_file)
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# Watchdog callbacks (thread → asyncio bridge)
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _on_ref_change(self, repo: _WatchedRepo, event_path: str) -> None:
|
|
269
|
+
"""Called on the watchdog thread when a ref file changes."""
|
|
270
|
+
loop = self._loop
|
|
271
|
+
if loop is None or loop.is_closed():
|
|
272
|
+
return
|
|
273
|
+
loop.call_soon_threadsafe(
|
|
274
|
+
lambda: asyncio.create_task(self._handle_ref_change_async(repo, event_path))
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def _handle_ref_change_async(self, repo: _WatchedRepo, event_path: str) -> None:
|
|
278
|
+
"""Runs on the asyncio loop - inspect the ref and publish if changed."""
|
|
279
|
+
path = Path(event_path)
|
|
280
|
+
refs_dir = repo.git_dir / "refs" / "heads"
|
|
281
|
+
head_file = repo.git_dir / "HEAD"
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
if path == head_file or path.name == "HEAD":
|
|
285
|
+
new_branch = _read_head_branch(head_file) if head_file.exists() else None
|
|
286
|
+
if new_branch and new_branch != repo.head_branch:
|
|
287
|
+
# Pentest 2026-04-26 (MEDIUM): branch names traverse the
|
|
288
|
+
# bus to the DO ingest path. Reject anything that doesn't
|
|
289
|
+
# match the conservative shape gate before publishing -
|
|
290
|
+
# don't update head_branch on reject so the next valid
|
|
291
|
+
# change still fires.
|
|
292
|
+
if not _is_safe_branch_name(new_branch):
|
|
293
|
+
logger.warning(
|
|
294
|
+
"git_watcher rejecting unsafe HEAD branch name=%r repo=%s",
|
|
295
|
+
new_branch,
|
|
296
|
+
repo.path,
|
|
297
|
+
)
|
|
298
|
+
return
|
|
299
|
+
previous = repo.head_branch
|
|
300
|
+
repo.head_branch = new_branch
|
|
301
|
+
await self._publish(
|
|
302
|
+
"git_branch_switch",
|
|
303
|
+
{
|
|
304
|
+
"repo": str(repo.path),
|
|
305
|
+
"branch": new_branch,
|
|
306
|
+
"previous": previous,
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
# Ref file under .git/refs/heads/
|
|
312
|
+
try:
|
|
313
|
+
path.relative_to(refs_dir)
|
|
314
|
+
except ValueError:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
if not path.exists() or not path.is_file():
|
|
318
|
+
return
|
|
319
|
+
branch = _branch_name_from_ref(refs_dir, path)
|
|
320
|
+
# Pentest 2026-04-26 (MEDIUM): sanitise the branch name before it
|
|
321
|
+
# reaches the bus. _branch_name_from_ref derives from the on-disk
|
|
322
|
+
# ref filename which a same-UID attacker can plant arbitrarily.
|
|
323
|
+
if not _is_safe_branch_name(branch):
|
|
324
|
+
logger.warning(
|
|
325
|
+
"git_watcher rejecting unsafe ref branch name=%r repo=%s",
|
|
326
|
+
branch,
|
|
327
|
+
repo.path,
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
sha = _read_ref(path)
|
|
331
|
+
if not sha:
|
|
332
|
+
return
|
|
333
|
+
previous = repo.branch_shas.get(branch)
|
|
334
|
+
if previous == sha:
|
|
335
|
+
return # debounce
|
|
336
|
+
repo.branch_shas[branch] = sha
|
|
337
|
+
await self._publish(
|
|
338
|
+
"git_commit",
|
|
339
|
+
{
|
|
340
|
+
"repo": str(repo.path),
|
|
341
|
+
"branch": branch,
|
|
342
|
+
"sha": sha,
|
|
343
|
+
"previous": previous,
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
except Exception as exc: # pragma: no cover
|
|
347
|
+
logger.warning("git_watcher ref change handling failed: %s", exc)
|
|
348
|
+
|
|
349
|
+
async def _publish(self, kind: str, payload: dict[str, Any]) -> None:
|
|
350
|
+
logger.info(
|
|
351
|
+
"git_watcher publishing kind=%s repo=%s branch=%s",
|
|
352
|
+
kind,
|
|
353
|
+
payload.get("repo"),
|
|
354
|
+
payload.get("branch"),
|
|
355
|
+
)
|
|
356
|
+
await self._bus.publish(
|
|
357
|
+
EGRESS_TOPIC,
|
|
358
|
+
{"kind": kind, "payload": payload, "source": "git_watcher"},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# ------------------------------------------------------------------
|
|
362
|
+
# Test introspection
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def watched_repos(self) -> list[_WatchedRepo]:
|
|
367
|
+
return list(self._repos)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# watchdog event handler - sits between the observer thread and the loop
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _GitRefHandler:
|
|
376
|
+
"""Tiny wrapper - watchdog's FileSystemEventHandler is imported lazily
|
|
377
|
+
inside :meth:`GitWatcher.run` so that installs without watchdog can still
|
|
378
|
+
import ``git_watcher``. This class shims the interface without subclassing
|
|
379
|
+
so type checkers don't demand the import at module-load time.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
def __init__(self, watcher: GitWatcher, repo: _WatchedRepo) -> None:
|
|
383
|
+
self._watcher = watcher
|
|
384
|
+
self._repo = repo
|
|
385
|
+
# Cache the thread ID we were constructed on for debug logging.
|
|
386
|
+
self._construct_thread = threading.get_ident()
|
|
387
|
+
|
|
388
|
+
# watchdog calls these via duck typing; no base class required.
|
|
389
|
+
def dispatch(self, event: Any) -> None:
|
|
390
|
+
if getattr(event, "is_directory", False):
|
|
391
|
+
return
|
|
392
|
+
# Drop pure-read events. Modern watchdog versions (>=2.x with
|
|
393
|
+
# IN_OPEN/IN_CLOSE_NOWRITE in the default mask) emit a
|
|
394
|
+
# FileOpenedEvent + FileClosedNoWriteEvent for every open-and-read
|
|
395
|
+
# of a watched file. ``.git/HEAD`` and the active branch ref are
|
|
396
|
+
# opened thousands of times per second by ``git status`` callers
|
|
397
|
+
# (IDE git plugins, statusline scripts, parallel CC sessions), and
|
|
398
|
+
# without this filter every read scheduled an ``asyncio.create_task``
|
|
399
|
+
# via ``_on_ref_change`` - flooding the loop with no-op handles
|
|
400
|
+
# that grew RSS by ~10MB/s and tripped OOM in <5min on a busy repo.
|
|
401
|
+
kind = type(event).__name__
|
|
402
|
+
if kind in ("FileOpenedEvent", "FileClosedNoWriteEvent"):
|
|
403
|
+
return
|
|
404
|
+
src = getattr(event, "src_path", None)
|
|
405
|
+
dest = getattr(event, "dest_path", None)
|
|
406
|
+
# Fire on whichever path exists after the event (move target for
|
|
407
|
+
# renames, src for create/modify).
|
|
408
|
+
target = dest or src
|
|
409
|
+
if not isinstance(target, str):
|
|
410
|
+
return
|
|
411
|
+
self._watcher._on_ref_change(self._repo, target)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
# Ref file helpers
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _iter_ref_files(refs_dir: Path):
|
|
420
|
+
"""Yield every ref file under ``refs_dir`` recursively."""
|
|
421
|
+
for root, _dirs, files in os.walk(refs_dir):
|
|
422
|
+
for fname in files:
|
|
423
|
+
yield Path(root) / fname
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _branch_name_from_ref(refs_dir: Path, ref_file: Path) -> str:
|
|
427
|
+
"""Return the branch name given a path under ``.git/refs/heads/``."""
|
|
428
|
+
try:
|
|
429
|
+
return str(ref_file.relative_to(refs_dir))
|
|
430
|
+
except ValueError:
|
|
431
|
+
return ref_file.name
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _read_ref(ref_file: Path) -> str | None:
|
|
435
|
+
"""Read a ref file and return the SHA, or None on read failure."""
|
|
436
|
+
try:
|
|
437
|
+
return ref_file.read_text(encoding="utf-8").strip()
|
|
438
|
+
except (OSError, UnicodeDecodeError):
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _read_head_branch(head_file: Path) -> str | None:
|
|
443
|
+
"""Return the branch HEAD points at, or None if detached / unreadable.
|
|
444
|
+
|
|
445
|
+
``.git/HEAD`` is either ``ref: refs/heads/<branch>`` for an attached head
|
|
446
|
+
or a bare SHA for a detached head.
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
content = head_file.read_text(encoding="utf-8").strip()
|
|
450
|
+
except (OSError, UnicodeDecodeError):
|
|
451
|
+
return None
|
|
452
|
+
if content.startswith("ref:"):
|
|
453
|
+
_, _, ref = content.partition("ref:")
|
|
454
|
+
ref = ref.strip()
|
|
455
|
+
if ref.startswith("refs/heads/"):
|
|
456
|
+
return ref[len("refs/heads/") :]
|
|
457
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Household substrate adapters (Phase 2 physical-substrate widening).
|
|
2
|
+
|
|
3
|
+
Scaffolded under the Wave-A Tapo pilot for ~blake's homeserver gear:
|
|
4
|
+
|
|
5
|
+
* :mod:`workshop_tools` (Eco-12) — per-plug Wh fingerprint via MQTT.
|
|
6
|
+
* :mod:`compost` (Eco-9) — pile-temp curve via MQTT, plate-tag filtered.
|
|
7
|
+
* :mod:`tapo_ecosystem` (Eco-14) — hub-level multi-parameter composition.
|
|
8
|
+
* :mod:`self_hoster` (Eco-15) — own-host systemd + filesystem polling.
|
|
9
|
+
|
|
10
|
+
All four conform to D-PROV-1 (RATIFIED 2026-05-13) and the IaI 5-clause
|
|
11
|
+
overlay; each ships a hard-coded Clause-4 banlist refused at trait-emit
|
|
12
|
+
time. Bands only — raw Wh / temp / event counts never cross the daemon
|
|
13
|
+
boundary upward.
|
|
14
|
+
|
|
15
|
+
NOT auto-wired into :mod:`alter_runtime.daemon` startup; activation is a
|
|
16
|
+
follow-up wave once the stub specs in
|
|
17
|
+
``.repos/internal/02-Technical-Strategy/phase2-wave2-stub-specs-pack-2026-05-27.md``
|
|
18
|
+
land their implementation pass.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from alter_runtime.adapters.household._base import (
|
|
22
|
+
EventBusSubscriberBase,
|
|
23
|
+
PassiveLanPollerBase,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"EventBusSubscriberBase",
|
|
28
|
+
"PassiveLanPollerBase",
|
|
29
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Shared base classes for the household adapter family.
|
|
2
|
+
|
|
3
|
+
Two adapter shapes per the Phase-2 cross-pattern catalogue
|
|
4
|
+
(``.repos/internal/02-Technical-Strategy/phase2-substrate-cross-pattern-catalogue-2026-05-19.md``):
|
|
5
|
+
|
|
6
|
+
* :class:`EventBusSubscriberBase` — subscribes to the in-process
|
|
7
|
+
:class:`~alter_runtime.subscribers.bus.EventBus` for events the
|
|
8
|
+
household-bridge subscriber (or a future HA/MQTT shim) publishes from
|
|
9
|
+
the maker's LAN. Used by ``workshop_tools``, ``compost``, and
|
|
10
|
+
``tapo_ecosystem``.
|
|
11
|
+
* :class:`PassiveLanPollerBase` — polls a local resource (own host,
|
|
12
|
+
paired LAN device) on a fixed cadence. Used by ``self_hoster``
|
|
13
|
+
(degenerate-LAN: the daemon polls its own host).
|
|
14
|
+
|
|
15
|
+
Both extend :class:`~alter_runtime.daemon.Component` so the supervisor
|
|
16
|
+
can run them as long-lived tasks once wired in. They deliberately stop
|
|
17
|
+
short of imposing topic taxonomy, payload shape, or storage choice —
|
|
18
|
+
those are per-adapter concerns.
|
|
19
|
+
|
|
20
|
+
D-PROV-1 (RATIFIED 2026-05-13) governs all consumers; IaI 5-clause map
|
|
21
|
+
sits in the per-adapter ``traits.py`` modules so the banlist is
|
|
22
|
+
co-located with the trait emit site that enforces it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
from abc import abstractmethod
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from alter_runtime.config import DaemonConfig
|
|
33
|
+
from alter_runtime.daemon import Component
|
|
34
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"EventBusSubscriberBase",
|
|
38
|
+
"PassiveLanPollerBase",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EventBusSubscriberBase(Component):
|
|
43
|
+
"""Base for adapters that subscribe to in-process bus events.
|
|
44
|
+
|
|
45
|
+
Subclasses set :attr:`subscribe_topics` (one or more topic strings)
|
|
46
|
+
and implement :meth:`handle_event`. The base wires subscribe /
|
|
47
|
+
unsubscribe symmetry around the supervisor lifecycle and shields the
|
|
48
|
+
supervisor from subscriber exceptions (the bus already does this,
|
|
49
|
+
but adapter-side logging is more useful for debugging).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str = "household_event_bus_subscriber"
|
|
53
|
+
#: Topics this adapter subscribes to. Set on the subclass.
|
|
54
|
+
subscribe_topics: tuple[str, ...] = ()
|
|
55
|
+
|
|
56
|
+
def __init__(self, config: DaemonConfig, bus: EventBus) -> None:
|
|
57
|
+
self._config = config
|
|
58
|
+
self._bus = bus
|
|
59
|
+
self._stop_event = asyncio.Event()
|
|
60
|
+
self._logger = logging.getLogger(f"alter_runtime.adapters.household.{self.name}")
|
|
61
|
+
|
|
62
|
+
async def run(self) -> None:
|
|
63
|
+
if not self.subscribe_topics:
|
|
64
|
+
self._logger.info("%s no subscribe_topics declared — idle", self.name)
|
|
65
|
+
await self._stop_event.wait()
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
for topic in self.subscribe_topics:
|
|
69
|
+
self._bus.subscribe(topic, self._dispatch)
|
|
70
|
+
self._logger.info("%s subscribed topics=%s", self.name, list(self.subscribe_topics))
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
await self._stop_event.wait()
|
|
74
|
+
finally:
|
|
75
|
+
for topic in self.subscribe_topics:
|
|
76
|
+
self._bus.unsubscribe(topic, self._dispatch)
|
|
77
|
+
self._logger.info("%s stopped", self.name)
|
|
78
|
+
|
|
79
|
+
async def stop(self) -> None:
|
|
80
|
+
self._stop_event.set()
|
|
81
|
+
|
|
82
|
+
async def _dispatch(self, payload: Any) -> None:
|
|
83
|
+
try:
|
|
84
|
+
await self.handle_event(payload)
|
|
85
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
86
|
+
self._logger.warning("%s handle_event raised: %s", self.name, exc)
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def handle_event(self, payload: Any) -> None:
|
|
90
|
+
"""Process one bus event. Subclass responsibility."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PassiveLanPollerBase(Component):
|
|
94
|
+
"""Base for adapters that poll a local resource on a fixed cadence.
|
|
95
|
+
|
|
96
|
+
Subclasses set :attr:`poll_interval_seconds` and implement
|
|
97
|
+
:meth:`poll_once`. The base supervises the poll loop, sleeps
|
|
98
|
+
between iterations, and exits cleanly on shutdown.
|
|
99
|
+
|
|
100
|
+
``self_hoster`` (Eco-15) is the canonical degenerate-LAN consumer
|
|
101
|
+
— the daemon polls its own host's systemd D-Bus + filesystem; no
|
|
102
|
+
network calls cross the loopback interface.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
name: str = "household_passive_lan_poller"
|
|
106
|
+
#: Default 6 hours per the self-hoster stub spec.
|
|
107
|
+
poll_interval_seconds: float = 6 * 60 * 60
|
|
108
|
+
|
|
109
|
+
def __init__(self, config: DaemonConfig) -> None:
|
|
110
|
+
self._config = config
|
|
111
|
+
self._stop_event = asyncio.Event()
|
|
112
|
+
self._logger = logging.getLogger(f"alter_runtime.adapters.household.{self.name}")
|
|
113
|
+
|
|
114
|
+
async def run(self) -> None:
|
|
115
|
+
self._logger.info(
|
|
116
|
+
"%s starting poll loop interval=%.0fs", self.name, self.poll_interval_seconds
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
while not self._stop_event.is_set():
|
|
120
|
+
try:
|
|
121
|
+
await self.poll_once()
|
|
122
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
123
|
+
self._logger.warning("%s poll_once raised: %s", self.name, exc)
|
|
124
|
+
try:
|
|
125
|
+
await asyncio.wait_for(
|
|
126
|
+
self._stop_event.wait(), timeout=self.poll_interval_seconds
|
|
127
|
+
)
|
|
128
|
+
except asyncio.TimeoutError:
|
|
129
|
+
pass
|
|
130
|
+
finally:
|
|
131
|
+
self._logger.info("%s stopped", self.name)
|
|
132
|
+
|
|
133
|
+
async def stop(self) -> None:
|
|
134
|
+
self._stop_event.set()
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
async def poll_once(self) -> None:
|
|
138
|
+
"""Run one poll cycle. Subclass responsibility."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Compost substrate adapter (Eco-9).
|
|
2
|
+
|
|
3
|
+
Subscribes to MQTT topic glob ``tapo/temp_humid/+/state``, filters to
|
|
4
|
+
devices whose member-attested plate tag is ``"compost"``, and computes
|
|
5
|
+
a 30-day temperature-curve patience trait (band). Persists to
|
|
6
|
+
``~/.local/share/alter-runtime/compost.db`` (mode 600).
|
|
7
|
+
|
|
8
|
+
Spec: ``.repos/internal/02-Technical-Strategy/compost-substrate-adapter-2026-05-19.md``
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from alter_runtime.adapters.household.compost.adapter import CompostAdapter
|
|
12
|
+
from alter_runtime.adapters.household.compost.traits import (
|
|
13
|
+
CLAUSE_4_BANLIST,
|
|
14
|
+
CompostTraits,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["CLAUSE_4_BANLIST", "CompostAdapter", "CompostTraits"]
|