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,461 @@
|
|
|
1
|
+
"""AgentFrameSubscriber - projects ``agent_frame`` deliveries to a local JSONL cache.
|
|
2
|
+
|
|
3
|
+
D-AGENT-CHANNEL-1 Phase 2 Wave 6 (§8 of *proposed-D-AGENT-CHANNEL-1.md*).
|
|
4
|
+
|
|
5
|
+
The subscriber is a long-lived :class:`alter_runtime.daemon.Component` that:
|
|
6
|
+
|
|
7
|
+
1. Subscribes to the in-process :class:`EventBus` on the ``identity.event``
|
|
8
|
+
topic published by :class:`DoSseSubscriber`.
|
|
9
|
+
2. Filters frames whose ``content_type`` equals ``"x-alter-agent"`` (the
|
|
10
|
+
agent-channel discriminator per D-AGENT-CHANNEL-1 §4.A).
|
|
11
|
+
3. Deduplicates on ``frame_id`` — a bounded LRU window prevents DO replay
|
|
12
|
+
bursts from writing the same frame twice.
|
|
13
|
+
4. Atomically appends one compact JSON line per frame to
|
|
14
|
+
``$XDG_CACHE_HOME/alter/agent-frames.jsonl`` (mode ``0o600``, parent dir
|
|
15
|
+
``0o700``), rotating to ``agent-frames.jsonl.1`` once the file exceeds
|
|
16
|
+
10 MiB.
|
|
17
|
+
5. Publishes to the in-process bus topic ``alter.agent.frame.{kind}`` so hook
|
|
18
|
+
subscribers (CC hooks, adapters) can react to specific frame kinds without
|
|
19
|
+
polling the JSONL cache.
|
|
20
|
+
|
|
21
|
+
The projection path is a pure subscriber — it never sends an acknowledgement
|
|
22
|
+
back to the DO. The DO replays from ``Last-Event-ID`` on reconnect, so write
|
|
23
|
+
failures simply mean the frame is re-offered on the next connection.
|
|
24
|
+
|
|
25
|
+
The component is designed to swallow and log all errors so that a single
|
|
26
|
+
malformed frame, full disk, or transient misbehaviour cannot crash the daemon
|
|
27
|
+
supervisor. The supervisor will still restart the component with exponential
|
|
28
|
+
backoff if :meth:`run` raises, but :meth:`handle_event` only ever logs and
|
|
29
|
+
returns.
|
|
30
|
+
|
|
31
|
+
Scope note (Phase 2 Wave 6):
|
|
32
|
+
- D-Bus signal (``dev.alter.agent.frame``) — Phase 3, out of scope.
|
|
33
|
+
- Multi-org federation proxy (§4.E) — Phase 3, D-STATIC-BINARY-1 Rust binary.
|
|
34
|
+
- Subscription filter enforcement (DO-side) — Wave 4, also out of scope here.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import contextlib
|
|
41
|
+
import errno
|
|
42
|
+
import fcntl
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
from collections import OrderedDict
|
|
47
|
+
from dataclasses import asdict, dataclass, field
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import TYPE_CHECKING, Any
|
|
51
|
+
|
|
52
|
+
from alter_runtime.config import cache_dir
|
|
53
|
+
from alter_runtime.daemon import Component
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
57
|
+
|
|
58
|
+
__all__ = ["AgentFrameSubscriber", "DEFAULT_ROTATION_THRESHOLD_BYTES", "InstrumentRecord"]
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger("alter_runtime.subscribers.agent_frames")
|
|
61
|
+
|
|
62
|
+
#: SSE ``content_type`` value that discriminates agent channel frames from
|
|
63
|
+
#: human Messenger frames (D-AGENT-CHANNEL-1 §4.A / §9).
|
|
64
|
+
AGENT_CONTENT_TYPE: str = "x-alter-agent"
|
|
65
|
+
|
|
66
|
+
#: Filename for the agent-frames JSONL cache (within ``cache_dir()``).
|
|
67
|
+
FRAMES_FILENAME: str = "agent-frames.jsonl"
|
|
68
|
+
|
|
69
|
+
#: Filename for the rotated tail (single generation).
|
|
70
|
+
FRAMES_ROTATED_FILENAME: str = "agent-frames.jsonl.1"
|
|
71
|
+
|
|
72
|
+
#: Rotate the JSONL file once it exceeds this many bytes (10 MiB, matching the
|
|
73
|
+
#: inbox.jsonl rotation threshold from InboxWriter).
|
|
74
|
+
DEFAULT_ROTATION_THRESHOLD_BYTES: int = 10 * 1024 * 1024
|
|
75
|
+
|
|
76
|
+
#: Maximum number of ``frame_id`` values retained for in-memory dedup. Sized
|
|
77
|
+
#: to absorb a DO replay burst (the same ceiling as do_sse.py FRAME_DEDUP_WINDOW)
|
|
78
|
+
#: while staying cheap in-memory.
|
|
79
|
+
FRAME_DEDUP_WINDOW: int = 256
|
|
80
|
+
|
|
81
|
+
#: Hard cap on the per-frame ``payload`` serialised size written to the cache.
|
|
82
|
+
#: Prevents a pathological large payload from bloating the cache file past the
|
|
83
|
+
#: rotation threshold in a single write.
|
|
84
|
+
MAX_PAYLOAD_BYTES: int = 64 * 1024
|
|
85
|
+
|
|
86
|
+
#: Bus topic pattern for per-kind agent frame events. Callers subscribe to
|
|
87
|
+
#: e.g. ``alter.agent.frame.agent_advisory``. The subscriber publishes the
|
|
88
|
+
#: full deserialised frame dict as the payload.
|
|
89
|
+
TOPIC_AGENT_FRAME_PREFIX: str = "alter.agent.frame."
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class InstrumentRecord:
|
|
94
|
+
"""A single entry in the in-memory instrument roster.
|
|
95
|
+
|
|
96
|
+
Populated from observed ``agent_frame`` sender fields. Represents one
|
|
97
|
+
agentic surface (CC session, Codex window, Cursor IDE, alter-cli verb,
|
|
98
|
+
alter-runtime daemon) that has emitted at least one frame visible to
|
|
99
|
+
this daemon instance.
|
|
100
|
+
|
|
101
|
+
Fields mirror the ``alter_agent_roster`` MCP verb shape
|
|
102
|
+
(D-AGENT-CHANNEL-1 §5) so the unix-socket ``agent/roster`` route can
|
|
103
|
+
return the same shape without a DO round-trip.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
handle: str
|
|
107
|
+
instrument: str
|
|
108
|
+
tool: str
|
|
109
|
+
session_id: str | None = None
|
|
110
|
+
started_at: str | None = None
|
|
111
|
+
last_seen_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AgentFrameSubscriber(Component):
|
|
115
|
+
"""Projects ``agent_frame`` deliveries to a local JSONL cache.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
bus:
|
|
120
|
+
The shared :class:`EventBus` instance. Used both to subscribe to
|
|
121
|
+
``identity.event`` (ingress) and to re-publish per-kind frame topics
|
|
122
|
+
(fan-out to hook subscribers).
|
|
123
|
+
frames_path:
|
|
124
|
+
Override the JSONL output path. Tests use this to redirect writes
|
|
125
|
+
to a ``tmp_path`` fixture without touching ``~/.cache/alter/``.
|
|
126
|
+
rotation_threshold_bytes:
|
|
127
|
+
Override the rotation threshold. Tests pass a small value to
|
|
128
|
+
exercise the rotation path without writing megabytes.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
name = "agent_frames"
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
bus: EventBus,
|
|
136
|
+
*,
|
|
137
|
+
frames_path: Path | None = None,
|
|
138
|
+
rotation_threshold_bytes: int = DEFAULT_ROTATION_THRESHOLD_BYTES,
|
|
139
|
+
) -> None:
|
|
140
|
+
self._bus = bus
|
|
141
|
+
self._frames_path: Path = (
|
|
142
|
+
frames_path if frames_path is not None else cache_dir() / FRAMES_FILENAME
|
|
143
|
+
)
|
|
144
|
+
self._rotation_threshold_bytes = rotation_threshold_bytes
|
|
145
|
+
self._lock = asyncio.Lock()
|
|
146
|
+
self._shutdown_event = asyncio.Event()
|
|
147
|
+
# Bounded LRU dedup window keyed on ``frame_id``. Uses OrderedDict so
|
|
148
|
+
# we can efficiently evict the oldest entry when the window is full.
|
|
149
|
+
self._seen_frame_ids: OrderedDict[str, None] = OrderedDict()
|
|
150
|
+
# In-memory instrument roster — keyed on ``(handle, instrument)`` so
|
|
151
|
+
# that the unix-socket ``agent/roster`` route can return observed
|
|
152
|
+
# instruments without a DO round-trip (D-AGENT-CHANNEL-1 §8).
|
|
153
|
+
self._roster: dict[tuple[str, str], InstrumentRecord] = {}
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Component lifecycle
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def run(self) -> None:
|
|
160
|
+
"""Subscribe to the bus and block until stop() is called.
|
|
161
|
+
|
|
162
|
+
The subscription is registered inside ``run()`` (not ``__init__``) so
|
|
163
|
+
that tests can construct the subscriber without side-effects and call
|
|
164
|
+
:meth:`handle_event` directly.
|
|
165
|
+
"""
|
|
166
|
+
self._bus.subscribe("identity.event", self.handle_event)
|
|
167
|
+
logger.info(
|
|
168
|
+
"agent_frames subscriber started frames=%s",
|
|
169
|
+
self._frames_path,
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
await self._shutdown_event.wait()
|
|
173
|
+
except asyncio.CancelledError:
|
|
174
|
+
raise
|
|
175
|
+
finally:
|
|
176
|
+
self._bus.unsubscribe("identity.event", self.handle_event)
|
|
177
|
+
logger.info("agent_frames subscriber stopped")
|
|
178
|
+
|
|
179
|
+
async def stop(self) -> None:
|
|
180
|
+
"""Cooperative shutdown — release the run loop."""
|
|
181
|
+
self._shutdown_event.set()
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Frame ingest — public surface for the bus and tests
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def handle_event(self, event: Any) -> None:
|
|
188
|
+
"""Project a single parsed identity-event dict if it is an agent_frame.
|
|
189
|
+
|
|
190
|
+
This is the unit-test seam — the test suite calls this directly with
|
|
191
|
+
synthesised dicts to avoid having to drive a real SSE socket or event
|
|
192
|
+
bus.
|
|
193
|
+
"""
|
|
194
|
+
if not isinstance(event, dict):
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# ---- 1. Filter on content_type ----------------------------------
|
|
198
|
+
if event.get("content_type") != AGENT_CONTENT_TYPE:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# ---- 2. Extract frame_id for dedup ------------------------------
|
|
202
|
+
frame_id = event.get("frame_id")
|
|
203
|
+
if not isinstance(frame_id, str) or not frame_id:
|
|
204
|
+
logger.warning(
|
|
205
|
+
"agent_frames: agent_frame missing frame_id - dropping: kind=%r",
|
|
206
|
+
event.get("kind"),
|
|
207
|
+
)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# ---- 3. Dedup on frame_id ----------------------------------------
|
|
211
|
+
async with self._lock:
|
|
212
|
+
if frame_id in self._seen_frame_ids:
|
|
213
|
+
logger.debug(
|
|
214
|
+
"agent_frames: duplicate frame_id=%s kind=%r - dropping",
|
|
215
|
+
frame_id,
|
|
216
|
+
event.get("kind"),
|
|
217
|
+
)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
self._seen_frame_ids[frame_id] = None
|
|
221
|
+
if len(self._seen_frame_ids) > FRAME_DEDUP_WINDOW:
|
|
222
|
+
self._seen_frame_ids.popitem(last=False)
|
|
223
|
+
|
|
224
|
+
# ---- 4. Project the row ---------------------------------------
|
|
225
|
+
line = self._serialise(event)
|
|
226
|
+
if line is None:
|
|
227
|
+
return # already logged
|
|
228
|
+
|
|
229
|
+
# ---- 5. Rotate if oversized -----------------------------------
|
|
230
|
+
try:
|
|
231
|
+
self._maybe_rotate()
|
|
232
|
+
except OSError as exc:
|
|
233
|
+
logger.warning("agent_frames: rotation failed: %s", exc)
|
|
234
|
+
# Continue anyway — better to grow past threshold than drop.
|
|
235
|
+
|
|
236
|
+
# ---- 6. Atomic append -----------------------------------------
|
|
237
|
+
try:
|
|
238
|
+
self._append_line(line)
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
logger.warning("agent_frames: append failed: %s - dropping frame", exc)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# ---- 7. Update the in-memory roster ---------------------------
|
|
244
|
+
self._update_roster(event)
|
|
245
|
+
|
|
246
|
+
# ---- 9. Re-publish per-kind bus topic (outside the lock) ---------
|
|
247
|
+
# Re-publish after releasing the lock so hook subscribers can
|
|
248
|
+
# consume the event without risk of deadlocking on the write lock.
|
|
249
|
+
kind = event.get("kind")
|
|
250
|
+
if isinstance(kind, str) and kind:
|
|
251
|
+
topic = TOPIC_AGENT_FRAME_PREFIX + kind
|
|
252
|
+
try:
|
|
253
|
+
await self._bus.publish(topic, event)
|
|
254
|
+
except Exception as exc: # noqa: BLE001 — defensive; bus publish should not raise
|
|
255
|
+
logger.warning(
|
|
256
|
+
"agent_frames: bus publish failed topic=%s: %s",
|
|
257
|
+
topic,
|
|
258
|
+
exc,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Roster
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def _update_roster(self, event: dict[str, Any]) -> None:
|
|
266
|
+
"""Update the in-memory instrument roster from a successfully projected frame.
|
|
267
|
+
|
|
268
|
+
Called inside the write lock so roster state stays consistent with
|
|
269
|
+
the JSONL projection. The roster is best-effort — it only reflects
|
|
270
|
+
instruments that have emitted frames observable to this daemon instance.
|
|
271
|
+
It is NOT a canonical view of all active instruments (that lives in
|
|
272
|
+
the DO/org-alter substrate).
|
|
273
|
+
"""
|
|
274
|
+
sender = event.get("sender") or event.get("from_handle") or event.get("sender_handle")
|
|
275
|
+
if not isinstance(sender, str) or not sender:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
instrument = event.get("instrument") or event.get("tool") or "unknown"
|
|
279
|
+
tool = event.get("tool") or "unknown"
|
|
280
|
+
session_id = event.get("session_id")
|
|
281
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
282
|
+
|
|
283
|
+
key = (sender, str(instrument))
|
|
284
|
+
if key in self._roster:
|
|
285
|
+
rec = self._roster[key]
|
|
286
|
+
# Mutate in-place; dataclass fields are not frozen.
|
|
287
|
+
rec.last_seen_at = now_iso
|
|
288
|
+
if session_id:
|
|
289
|
+
rec.session_id = str(session_id)
|
|
290
|
+
else:
|
|
291
|
+
self._roster[key] = InstrumentRecord(
|
|
292
|
+
handle=sender,
|
|
293
|
+
instrument=str(instrument),
|
|
294
|
+
tool=str(tool),
|
|
295
|
+
session_id=str(session_id) if session_id else None,
|
|
296
|
+
started_at=now_iso,
|
|
297
|
+
last_seen_at=now_iso,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def get_roster(self) -> list[dict[str, Any]]:
|
|
301
|
+
"""Return the current in-memory roster as a list of dicts.
|
|
302
|
+
|
|
303
|
+
Safe to call from outside the write lock because Python dict iteration
|
|
304
|
+
is GIL-protected for reads and the roster is only mutated inside
|
|
305
|
+
:meth:`_update_roster` (which is called under the write lock in the
|
|
306
|
+
async path). The returned list is a snapshot — callers should not
|
|
307
|
+
cache it.
|
|
308
|
+
"""
|
|
309
|
+
return [asdict(rec) for rec in self._roster.values()]
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
# Serialisation
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _serialise(self, event: dict[str, Any]) -> str | None:
|
|
316
|
+
"""Build the JSONL line for one ``agent_frame`` event.
|
|
317
|
+
|
|
318
|
+
Returns ``None`` if a required field is missing — the caller treats
|
|
319
|
+
that as "drop and continue".
|
|
320
|
+
"""
|
|
321
|
+
kind = event.get("kind")
|
|
322
|
+
if not isinstance(kind, str) or not kind:
|
|
323
|
+
logger.warning(
|
|
324
|
+
"agent_frames: agent_frame missing kind - dropping frame_id=%r",
|
|
325
|
+
event.get("frame_id"),
|
|
326
|
+
)
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
frame_id = event.get("frame_id")
|
|
330
|
+
sender = event.get("sender") or event.get("from_handle") or event.get("sender_handle")
|
|
331
|
+
received_at = (
|
|
332
|
+
event.get("timestamp") or event.get("sent_at") or datetime.now(timezone.utc).isoformat()
|
|
333
|
+
)
|
|
334
|
+
payload = event.get("payload")
|
|
335
|
+
|
|
336
|
+
record: dict[str, Any] = {
|
|
337
|
+
"frame_id": str(frame_id),
|
|
338
|
+
"kind": kind,
|
|
339
|
+
"sender": str(sender) if sender else None,
|
|
340
|
+
"received_at": str(received_at),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if payload is not None:
|
|
344
|
+
# Serialise the payload to check its size and guard against bloat.
|
|
345
|
+
try:
|
|
346
|
+
payload_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
347
|
+
except (TypeError, ValueError) as exc:
|
|
348
|
+
logger.warning(
|
|
349
|
+
"agent_frames: payload not serialisable for frame_id=%s: %s",
|
|
350
|
+
frame_id,
|
|
351
|
+
exc,
|
|
352
|
+
)
|
|
353
|
+
payload_str = None
|
|
354
|
+
|
|
355
|
+
if payload_str is not None:
|
|
356
|
+
payload_bytes = payload_str.encode("utf-8")
|
|
357
|
+
if len(payload_bytes) > MAX_PAYLOAD_BYTES:
|
|
358
|
+
logger.warning(
|
|
359
|
+
"agent_frames: payload exceeds %d bytes for frame_id=%s "
|
|
360
|
+
"- truncating local cache entry",
|
|
361
|
+
MAX_PAYLOAD_BYTES,
|
|
362
|
+
frame_id,
|
|
363
|
+
)
|
|
364
|
+
payload_str = (
|
|
365
|
+
payload_bytes[:MAX_PAYLOAD_BYTES].decode("utf-8", errors="ignore")
|
|
366
|
+
+ " [... payload truncated at 64 KiB]"
|
|
367
|
+
)
|
|
368
|
+
record["payload"] = payload_str
|
|
369
|
+
record["payload_truncated"] = True
|
|
370
|
+
else:
|
|
371
|
+
record["payload"] = payload
|
|
372
|
+
|
|
373
|
+
return json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
# File operations — atomic append, rotation
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def _ensure_parent(self, path: Path) -> None:
|
|
380
|
+
"""Create the parent directory with mode ``0o700`` if missing."""
|
|
381
|
+
parent = path.parent
|
|
382
|
+
if not parent.exists():
|
|
383
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
384
|
+
with contextlib.suppress(OSError):
|
|
385
|
+
os.chmod(parent, 0o700)
|
|
386
|
+
|
|
387
|
+
def _append_line(self, line: str) -> None:
|
|
388
|
+
"""Atomically append ``line + '\\n'`` to the cache file.
|
|
389
|
+
|
|
390
|
+
Uses :func:`os.open` with ``O_APPEND | O_CREAT`` and mode ``0o600``,
|
|
391
|
+
followed by an :func:`fcntl.flock` exclusive lock around the write.
|
|
392
|
+
``O_APPEND`` makes the write itself atomic against concurrent writers
|
|
393
|
+
on POSIX, and ``flock`` serialises us against a hypothetical second
|
|
394
|
+
daemon instance on the same XDG dir.
|
|
395
|
+
"""
|
|
396
|
+
self._ensure_parent(self._frames_path)
|
|
397
|
+
|
|
398
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
399
|
+
fd = os.open(self._frames_path, flags, 0o600)
|
|
400
|
+
try:
|
|
401
|
+
with contextlib.suppress(OSError):
|
|
402
|
+
os.fchmod(fd, 0o600)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
406
|
+
except OSError as exc: # pragma: no cover - exotic FS
|
|
407
|
+
if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
|
|
408
|
+
raise
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
os.write(fd, line.encode("utf-8") + b"\n")
|
|
412
|
+
os.fsync(fd)
|
|
413
|
+
finally:
|
|
414
|
+
with contextlib.suppress(OSError):
|
|
415
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
416
|
+
finally:
|
|
417
|
+
os.close(fd)
|
|
418
|
+
|
|
419
|
+
def _maybe_rotate(self) -> None:
|
|
420
|
+
"""Rotate the cache file if it exceeds the threshold.
|
|
421
|
+
|
|
422
|
+
Renames ``agent-frames.jsonl`` to ``agent-frames.jsonl.1`` (overwriting
|
|
423
|
+
any existing rotated file). The next :meth:`_append_line` call recreates
|
|
424
|
+
the primary file via ``O_CREAT``. No-op if the file does not yet exist
|
|
425
|
+
or is below the threshold.
|
|
426
|
+
"""
|
|
427
|
+
try:
|
|
428
|
+
size = self._frames_path.stat().st_size
|
|
429
|
+
except FileNotFoundError:
|
|
430
|
+
return
|
|
431
|
+
if size <= self._rotation_threshold_bytes:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
rotated = self._frames_path.parent / FRAMES_ROTATED_FILENAME
|
|
435
|
+
os.replace(self._frames_path, rotated)
|
|
436
|
+
logger.info(
|
|
437
|
+
"agent_frames: rotated %s -> %s (size=%d > threshold=%d)",
|
|
438
|
+
self._frames_path,
|
|
439
|
+
rotated,
|
|
440
|
+
size,
|
|
441
|
+
self._rotation_threshold_bytes,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
# Test introspection
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def frames_path(self) -> Path:
|
|
450
|
+
"""JSONL output path (used by tests)."""
|
|
451
|
+
return self._frames_path
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def seen_frame_ids(self) -> OrderedDict[str, None]:
|
|
455
|
+
"""Current dedup window (used by tests)."""
|
|
456
|
+
return self._seen_frame_ids
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def roster(self) -> dict[tuple[str, str], InstrumentRecord]:
|
|
460
|
+
"""Current in-memory roster (used by tests)."""
|
|
461
|
+
return self._roster
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""In-process pub/sub event bus for the alter-runtime daemon.
|
|
2
|
+
|
|
3
|
+
The bus is the coupling point between the three halves of the runtime:
|
|
4
|
+
|
|
5
|
+
* **Ingress subscribers** (``do_sse``, ``mcp_fallback``) publish identity frames
|
|
6
|
+
and connection-state transitions.
|
|
7
|
+
* **Local adapters** (``git_watcher`` and future CC-hook / shell bridges) publish
|
|
8
|
+
outbound ambient signals that the egress producer later relays to the DO.
|
|
9
|
+
* **Local fan-out surfaces** (the Unix socket server, the D-Bus service, the
|
|
10
|
+
InboxWriter projection, cache writers) subscribe to whichever topics they
|
|
11
|
+
care about.
|
|
12
|
+
|
|
13
|
+
Design goals
|
|
14
|
+
------------
|
|
15
|
+
|
|
16
|
+
1. **Tiny.** The bus is ~60 lines of Python. It owns no state beyond the
|
|
17
|
+
subscriber map and a lock. No persistence, no ordering guarantees beyond
|
|
18
|
+
"callbacks fire in registration order".
|
|
19
|
+
2. **Error-isolating.** A buggy subscriber must never kill the publisher's
|
|
20
|
+
task. All exceptions raised inside a subscriber callback are caught and
|
|
21
|
+
logged - the next subscriber still runs, and the publisher's `publish()`
|
|
22
|
+
call returns normally.
|
|
23
|
+
3. **Async-first.** Both the publisher and subscribers run inside the daemon's
|
|
24
|
+
single asyncio loop. Callbacks may be ``async`` coroutines or plain
|
|
25
|
+
callables; both are supported.
|
|
26
|
+
4. **No topic taxonomy enforcement.** Topics are free-form strings. Conventions
|
|
27
|
+
live in the daemon (currently: ``identity.frame``, ``identity.event``,
|
|
28
|
+
``identity.connected``, ``identity.disconnected``, ``local.signal``). Tests
|
|
29
|
+
that want to assert on topic names can do so against these conventions.
|
|
30
|
+
|
|
31
|
+
Topic registry (agent channel — D-AGENT-CHANNEL-1 Phase 2 Wave 6)
|
|
32
|
+
------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
``alter.agent.frame.{kind}`` — published by :class:`AgentFrameSubscriber`
|
|
35
|
+
once per successfully projected ``agent_frame`` delivery whose
|
|
36
|
+
``content_type`` equals ``"x-alter-agent"``. The ``{kind}`` suffix is the
|
|
37
|
+
15-kind catalogue value from D-AGENT-CHANNEL-1 §4.C (Phase 2 Wave 1 widened
|
|
38
|
+
``kind`` to a 15-member ``Literal``; the 12→15 extension absorbs the three
|
|
39
|
+
convergence kinds, ratified via the catalogue-extension decision), e.g.::
|
|
40
|
+
|
|
41
|
+
alter.agent.frame.agent_handover
|
|
42
|
+
alter.agent.frame.agent_advisory
|
|
43
|
+
alter.agent.frame.agent_broadcast
|
|
44
|
+
alter.agent.frame.agent_lock_request
|
|
45
|
+
alter.agent.frame.agent_lock_release
|
|
46
|
+
alter.agent.frame.agent_query
|
|
47
|
+
alter.agent.frame.agent_response
|
|
48
|
+
alter.agent.frame.agent_lease_extend
|
|
49
|
+
alter.agent.frame.agent_binding_moment
|
|
50
|
+
alter.agent.frame.agent_return_event
|
|
51
|
+
alter.agent.frame.peer_diagnostic_request
|
|
52
|
+
alter.agent.frame.peer_diagnostic_response
|
|
53
|
+
alter.agent.frame.intent_declare
|
|
54
|
+
alter.agent.frame.intent_withdraw
|
|
55
|
+
alter.agent.frame.flush_executed
|
|
56
|
+
|
|
57
|
+
The payload published on each topic is the full deserialised ``agent_frame``
|
|
58
|
+
dict as received from the DO SSE stream. Hook subscribers (CC hooks,
|
|
59
|
+
adapters) can filter by subscribing to the specific kind topics they care
|
|
60
|
+
about rather than reading from the JSONL cache on every event.
|
|
61
|
+
|
|
62
|
+
The bus is deliberately not a drop-in replacement for a durable message broker.
|
|
63
|
+
If the daemon crashes or a subscriber is slow, events are lost. The durable
|
|
64
|
+
state lives in the DO (replayable via ``Last-Event-ID``) and in the backend
|
|
65
|
+
audit DB; the bus is a transient coupling layer only.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import asyncio
|
|
71
|
+
import inspect
|
|
72
|
+
import logging
|
|
73
|
+
from collections.abc import Awaitable, Callable
|
|
74
|
+
from typing import Any, Union
|
|
75
|
+
|
|
76
|
+
__all__ = ["EventBus", "Subscriber"]
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger("alter_runtime.subscribers.bus")
|
|
79
|
+
|
|
80
|
+
#: A subscriber callback may be sync or async. The bus awaits async callbacks
|
|
81
|
+
#: and calls sync callbacks inline.
|
|
82
|
+
Subscriber = Union[
|
|
83
|
+
Callable[[Any], None],
|
|
84
|
+
Callable[[Any], Awaitable[None]],
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EventBus:
|
|
89
|
+
"""Topic-keyed asyncio pub/sub.
|
|
90
|
+
|
|
91
|
+
Thread-safety: all methods must be called from the same asyncio loop.
|
|
92
|
+
The bus does not attempt to be thread-safe.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
self._subscribers: dict[str, list[Subscriber]] = {}
|
|
97
|
+
self._lock = asyncio.Lock()
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Subscribe / unsubscribe
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def subscribe(self, topic: str, callback: Subscriber) -> None:
|
|
104
|
+
"""Register a callback to receive events published to ``topic``.
|
|
105
|
+
|
|
106
|
+
Multiple callbacks may subscribe to the same topic; they fire in
|
|
107
|
+
registration order. Calling ``subscribe`` for the same ``(topic,
|
|
108
|
+
callback)`` pair twice registers it twice - the caller is expected
|
|
109
|
+
to keep track of its own subscriptions and ``unsubscribe`` symmetrically.
|
|
110
|
+
"""
|
|
111
|
+
self._subscribers.setdefault(topic, []).append(callback)
|
|
112
|
+
logger.debug(
|
|
113
|
+
"bus subscribe topic=%s callback=%r count=%d",
|
|
114
|
+
topic,
|
|
115
|
+
_describe_callback(callback),
|
|
116
|
+
len(self._subscribers[topic]),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def unsubscribe(self, topic: str, callback: Subscriber) -> None:
|
|
120
|
+
"""Remove a previously registered callback.
|
|
121
|
+
|
|
122
|
+
No-op if the callback is not registered or the topic has no
|
|
123
|
+
subscribers - callers should not need to check first.
|
|
124
|
+
"""
|
|
125
|
+
callbacks = self._subscribers.get(topic)
|
|
126
|
+
if not callbacks:
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
callbacks.remove(callback)
|
|
130
|
+
except ValueError:
|
|
131
|
+
return
|
|
132
|
+
if not callbacks:
|
|
133
|
+
self._subscribers.pop(topic, None)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Publish
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def publish(self, topic: str, payload: Any) -> None:
|
|
140
|
+
"""Dispatch ``payload`` to every subscriber of ``topic``.
|
|
141
|
+
|
|
142
|
+
Subscribers run sequentially in registration order. Exceptions raised
|
|
143
|
+
by any one subscriber are caught and logged - the remainder still
|
|
144
|
+
run, and ``publish`` returns normally once all have been invoked.
|
|
145
|
+
"""
|
|
146
|
+
# Snapshot the subscriber list so that a subscriber mutating its own
|
|
147
|
+
# subscription mid-publish doesn't corrupt the iteration.
|
|
148
|
+
callbacks = list(self._subscribers.get(topic, ()))
|
|
149
|
+
if not callbacks:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
for callback in callbacks:
|
|
153
|
+
try:
|
|
154
|
+
result = callback(payload)
|
|
155
|
+
if inspect.isawaitable(result):
|
|
156
|
+
await result
|
|
157
|
+
except asyncio.CancelledError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
logger.warning(
|
|
161
|
+
"bus publish topic=%s subscriber=%r raised: %s - continuing",
|
|
162
|
+
topic,
|
|
163
|
+
_describe_callback(callback),
|
|
164
|
+
exc,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Test introspection
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def subscriber_count(self, topic: str) -> int:
|
|
172
|
+
"""Return the number of active subscribers on ``topic``."""
|
|
173
|
+
return len(self._subscribers.get(topic, ()))
|
|
174
|
+
|
|
175
|
+
def topics(self) -> list[str]:
|
|
176
|
+
"""Return the list of topics that currently have subscribers."""
|
|
177
|
+
return list(self._subscribers.keys())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _describe_callback(callback: Subscriber) -> str:
|
|
181
|
+
"""Best-effort human-readable name for a callback, for log lines."""
|
|
182
|
+
try:
|
|
183
|
+
name = getattr(callback, "__qualname__", None) or getattr(callback, "__name__", None)
|
|
184
|
+
if name:
|
|
185
|
+
return name
|
|
186
|
+
return repr(callback)
|
|
187
|
+
except Exception: # pragma: no cover - defensive
|
|
188
|
+
return "<callback>"
|