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,347 @@
1
+ """CacheWriter - projects identity state into the shared shell cache file.
2
+
3
+ This is the W2.2d *first pixel* glue. The existing shell-side tools -
4
+ ``scripts/alter-identity.sh`` (neofetch / waybar / prompt integration) and
5
+ ``.claude/hooks/alter-identity.sh`` (CC session context injection) - read
6
+ live identity state from a file at ``$XDG_CACHE_HOME/alter/identity.json``.
7
+ Before this component existed, the shell script hit the backend MCP
8
+ endpoint directly on every cache miss; that path is slow (HTTPS round-trip)
9
+ and fails offline.
10
+
11
+ With CacheWriter registered in the daemon, every ``identity.event`` that
12
+ looks like a state snapshot (whether from the live DO SSE stream or the
13
+ cold-start MCP fallback) projects into the cache file with an atomic
14
+ ``tmp → rename`` write. The shell script's existing ``cache_is_fresh``
15
+ + ``read_cache`` flow therefore transparently picks up live state as
16
+ soon as the daemon is running - no shell script changes needed for the
17
+ first pixel.
18
+
19
+ The cache schema matches the JSON already written by the legacy
20
+ ``alter-identity.sh`` API path (see ``scripts/alter-identity.sh:242``):
21
+
22
+ ::
23
+
24
+ {
25
+ "handle": "blake", - bare handle, no leading ~
26
+ "level": "3", - bare numeric tier, no leading L
27
+ "attunement": "0.42", - decimal 0..1 or integer percentage
28
+ "income": "1.23" - stringified USD earnings
29
+ }
30
+
31
+ All fields are strings to match the shell script's ``jq -r`` consumers.
32
+ When a field is missing in the source event the output key is an empty
33
+ string (``""``) rather than omitted, so ``jq -r '.handle // ""'``
34
+ produces consistent behaviour across cache misses.
35
+
36
+ **Why strip the ``~`` and ``L`` prefixes?** The shell script prepends
37
+ them at render time (``scripts/alter-identity.sh:264-265``):
38
+
39
+ .. code-block:: bash
40
+
41
+ display_handle="${handle:+~$handle}"
42
+ display_level="${level:+L$level}"
43
+
44
+ If we stored ``"~blake"`` / ``"L3"``, the script would render
45
+ ``"~~blake"`` / ``"LL3"``. The W2.2d *first pixel* design is to feed
46
+ the existing shell scripts live data with **zero script changes**, so
47
+ the CacheWriter matches the raw shape the scripts already expect.
48
+
49
+ Design notes
50
+ ------------
51
+
52
+ * The projection is *idempotent and lossy*. We keep only the four fields
53
+ the shell script cares about; anything else from the source event is
54
+ dropped. If a field is unchanged from the last write we still rewrite
55
+ the file - atomic rename is cheap and the shell script uses file mtime
56
+ as its TTL probe, so we want the mtime to bump on every refresh.
57
+ * The writer is tolerant of a variety of source shapes. State sync
58
+ events from ``mcp_fallback`` wrap their payload under ``payload``;
59
+ direct DO state frames put fields at the top level. The
60
+ :func:`_extract_state` helper checks both.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import asyncio
66
+ import contextlib
67
+ import json
68
+ import logging
69
+ import os
70
+ from pathlib import Path
71
+ from typing import TYPE_CHECKING, Any
72
+
73
+ from alter_runtime.config import cache_dir
74
+ from alter_runtime.daemon import Component
75
+
76
+ if TYPE_CHECKING:
77
+ from alter_runtime.subscribers.bus import EventBus
78
+
79
+ __all__ = ["CacheWriter", "project_state_to_cache"]
80
+
81
+ logger = logging.getLogger("alter_runtime.subscribers.cache_writer")
82
+
83
+ #: Filename inside ``cache_dir()`` that the shell script reads.
84
+ CACHE_FILENAME: str = "identity.json"
85
+
86
+ #: Event kinds we recognise as carrying identity state.
87
+ STATE_KINDS: frozenset[str] = frozenset(
88
+ {
89
+ "state_sync",
90
+ "alter_whoami",
91
+ "attunement_transition",
92
+ "identity_state",
93
+ }
94
+ )
95
+
96
+
97
+ class CacheWriter(Component):
98
+ """Writes identity state into ``$XDG_CACHE_HOME/alter/identity.json``.
99
+
100
+ Parameters
101
+ ----------
102
+ bus:
103
+ The shared :class:`EventBus`. Subscribes to ``identity.event``.
104
+ cache_path:
105
+ Override the cache file path. Tests inject a ``tmp_path`` value.
106
+ """
107
+
108
+ name = "cache_writer"
109
+
110
+ def __init__(
111
+ self,
112
+ bus: EventBus,
113
+ *,
114
+ cache_path: Path | None = None,
115
+ ) -> None:
116
+ self._bus = bus
117
+ self._cache_path: Path = (
118
+ cache_path if cache_path is not None else cache_dir() / CACHE_FILENAME
119
+ )
120
+ self._stop_event = asyncio.Event()
121
+ self._last_written: dict[str, str] | None = None
122
+
123
+ # ------------------------------------------------------------------
124
+ # Component lifecycle
125
+ # ------------------------------------------------------------------
126
+
127
+ async def run(self) -> None:
128
+ self._bus.subscribe("identity.event", self._on_event)
129
+ logger.info("cache_writer started cache=%s", self._cache_path)
130
+ try:
131
+ await self._stop_event.wait()
132
+ finally:
133
+ self._bus.unsubscribe("identity.event", self._on_event)
134
+ logger.info("cache_writer stopped")
135
+
136
+ async def stop(self) -> None:
137
+ self._stop_event.set()
138
+
139
+ # ------------------------------------------------------------------
140
+ # Bus callback
141
+ # ------------------------------------------------------------------
142
+
143
+ async def _on_event(self, event: dict[str, Any]) -> None:
144
+ """Project a bus event to the cache file (if it's a state snapshot)."""
145
+ if not isinstance(event, dict):
146
+ return
147
+ kind = event.get("kind")
148
+ if kind not in STATE_KINDS:
149
+ return
150
+
151
+ projected = project_state_to_cache(event)
152
+ if not projected:
153
+ logger.debug("cache_writer ignored event kind=%s - no extractable state", kind)
154
+ return
155
+
156
+ try:
157
+ self._atomic_write(projected)
158
+ except OSError as exc:
159
+ logger.warning("cache_writer: write failed: %s - keeping previous cache", exc)
160
+ return
161
+ self._last_written = projected
162
+ logger.info(
163
+ "cache_writer wrote handle=%s level=%s attunement=%s",
164
+ projected.get("handle"),
165
+ projected.get("level"),
166
+ projected.get("attunement"),
167
+ )
168
+
169
+ # ------------------------------------------------------------------
170
+ # Atomic write
171
+ # ------------------------------------------------------------------
172
+
173
+ def _atomic_write(self, data: dict[str, str]) -> None:
174
+ """Write ``data`` to the cache file via a tmp-and-rename sequence."""
175
+ self._cache_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
176
+ tmp_path = self._cache_path.with_suffix(self._cache_path.suffix + ".tmp")
177
+
178
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
179
+ fd = os.open(tmp_path, flags, 0o600)
180
+ try:
181
+ with contextlib.suppress(OSError):
182
+ os.fchmod(fd, 0o600)
183
+ payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
184
+ os.write(fd, payload.encode("utf-8"))
185
+ os.fsync(fd)
186
+ finally:
187
+ os.close(fd)
188
+
189
+ os.replace(tmp_path, self._cache_path)
190
+ with contextlib.suppress(OSError):
191
+ os.chmod(self._cache_path, 0o600)
192
+
193
+ # ------------------------------------------------------------------
194
+ # Test introspection
195
+ # ------------------------------------------------------------------
196
+
197
+ @property
198
+ def cache_path(self) -> Path:
199
+ return self._cache_path
200
+
201
+ @property
202
+ def last_written(self) -> dict[str, str] | None:
203
+ return self._last_written
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Pure projection helper
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def project_state_to_cache(event: dict[str, Any]) -> dict[str, str]:
212
+ """Project a bus event dict into the shell-script cache schema.
213
+
214
+ Returns an empty dict if the event doesn't carry any recognisable
215
+ identity state. The result always contains the four fields the shell
216
+ script reads (``handle``, ``level``, ``attunement``, ``income``) with
217
+ empty strings filling any missing values - this keeps ``jq -r`` consumers
218
+ happy across variant source shapes.
219
+
220
+ The function is pure (no filesystem access, no bus calls) so it can be
221
+ unit-tested independently of the component lifecycle.
222
+ """
223
+
224
+ # State can live at the top level (direct identity events from the DO)
225
+ # or nested under ``payload`` (synthetic state_sync events from the MCP
226
+ # fallback subscriber). Try both.
227
+ sources: list[dict[str, Any]] = []
228
+ payload = event.get("payload")
229
+ if isinstance(payload, dict):
230
+ sources.append(payload)
231
+ sources.append(event)
232
+
233
+ def pick(field: str) -> str:
234
+ for src in sources:
235
+ value = src.get(field)
236
+ if value is not None and value != "":
237
+ return _stringify(value)
238
+ return ""
239
+
240
+ handle = _strip_tilde(pick("handle"))
241
+ # ``level`` - accept the string label "L3" or an integer engagement_level
242
+ level = _normalise_level(pick("level"))
243
+ if not level:
244
+ engagement = ""
245
+ for src in sources:
246
+ engagement = src.get("engagement_level") or engagement
247
+ if engagement:
248
+ level = _normalise_level(engagement)
249
+ if not level:
250
+ tier = ""
251
+ for src in sources:
252
+ tier = src.get("consent_tier") or tier
253
+ if tier:
254
+ level = _normalise_level(tier)
255
+
256
+ attunement = pick("attunement")
257
+ if not attunement:
258
+ for src in sources:
259
+ value = src.get("attunement_score")
260
+ if value is not None and value != "":
261
+ attunement = _stringify(value)
262
+ break
263
+
264
+ income = pick("income")
265
+ if not income:
266
+ for src in sources:
267
+ value = src.get("total_earnings") or src.get("identity_income")
268
+ if value is not None and value != "":
269
+ income = _stringify(value)
270
+ break
271
+
272
+ # Empty projection - nothing useful to cache.
273
+ if not any((handle, level, attunement, income)):
274
+ return {}
275
+
276
+ return {
277
+ "handle": handle,
278
+ "level": level,
279
+ "attunement": attunement,
280
+ "income": income,
281
+ }
282
+
283
+
284
+ def _stringify(value: Any) -> str:
285
+ """Convert a Python value into the string form the shell script expects."""
286
+ if isinstance(value, bool):
287
+ return "true" if value else "false"
288
+ if isinstance(value, (int, float)):
289
+ return str(value)
290
+ if isinstance(value, str):
291
+ return value
292
+ # dicts / lists don't fit the shell schema - coerce to JSON and let the
293
+ # shell script decide whether to render them.
294
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
295
+
296
+
297
+ def _normalise_level(value: Any) -> str:
298
+ """Normalise any engagement-level representation to ``"1".."4"``.
299
+
300
+ Accepts integers (``1``), strings (``"L3"`` or ``"3"``), or labels
301
+ (``"augmented"``). Returns the bare numeric tier as a string so the
302
+ cache matches the raw shape ``scripts/alter-identity.sh`` already
303
+ expects (the shell script prepends ``L`` at render time). Unknown
304
+ inputs become an empty string.
305
+ """
306
+ if isinstance(value, bool):
307
+ return ""
308
+ if isinstance(value, int):
309
+ if 1 <= value <= 4:
310
+ return str(value)
311
+ return ""
312
+ if isinstance(value, str):
313
+ stripped = value.strip()
314
+ if not stripped:
315
+ return ""
316
+ # "L1".."L4"
317
+ if len(stripped) == 2 and stripped[0] in ("L", "l") and stripped[1].isdigit():
318
+ tier = int(stripped[1])
319
+ if 1 <= tier <= 4:
320
+ return str(tier)
321
+ # Raw numeric "1".."4"
322
+ try:
323
+ tier = int(stripped)
324
+ except ValueError:
325
+ # Known label fallbacks - mirrors the alter-identity types file.
326
+ label_map = {
327
+ "explorer": "1",
328
+ "learner": "2",
329
+ "augmented": "3",
330
+ "deployed": "4",
331
+ }
332
+ return label_map.get(stripped.lower(), "")
333
+ if 1 <= tier <= 4:
334
+ return str(tier)
335
+ return ""
336
+
337
+
338
+ def _strip_tilde(value: str) -> str:
339
+ """Strip a single leading ``~`` from a handle, if present.
340
+
341
+ The shell script prepends ``~`` at render time, so storing the bare
342
+ handle keeps ``scripts/alter-identity.sh`` rendering correctly without
343
+ any shell changes (see the module docstring for the full rationale).
344
+ """
345
+ if value.startswith("~"):
346
+ return value[1:]
347
+ return value
@@ -0,0 +1,290 @@
1
+ """CeremonyEchoWriter - projects recognition events into a 72h echo state.
2
+
3
+ Wave 2 of *D-CUST-1* (proposed; ratification pending). The CeremonyEchoWriter
4
+ is a long-lived :class:`alter_runtime.daemon.Component` that sits next to
5
+ :class:`InboxWriter` on the SSE bus. Where the InboxWriter projects the full
6
+ inbox JSONL, the CeremonyEchoWriter watches for recognition-class events and
7
+ maintains a tiny single-file state - the most-recent recognition event, its
8
+ declared kind, and the absolute timestamp at which the echo expires (72 h
9
+ after the event).
10
+
11
+ A consumer (``alter room`` in alter-cli, or any future shell-greeting
12
+ renderer) reads ``ceremony-echo.json`` from the runtime data directory at
13
+ each invocation and renders an echo line iff the current time is before the
14
+ expiry. The user cannot author the echo, cannot extend it, cannot dismiss it
15
+ early - "the house noticed" (brief §3 surface 21). The echo is observation,
16
+ not affordance.
17
+
18
+ Filtered content_types
19
+ * ``x-alter-recognition`` - the canonical recognition event
20
+ * ``x-alter-ceremony`` - broader ceremony class (Seat, Mirror, Discovery,
21
+ Accord) that should also surface a 72 h echo
22
+ Both are members of ``ALLOWED_CONTENT_TYPES`` in
23
+ ``backend/app/mcp/tools/messaging.py``.
24
+
25
+ The component is designed to swallow and log all errors so a single
26
+ malformed frame, full disk, or transient SSE disconnect cannot crash the
27
+ daemon supervisor. Like InboxWriter, the supervisor will restart on
28
+ :meth:`run` exceptions but :meth:`handle_event` only ever logs and returns.
29
+
30
+ Refs:
31
+ * proposed-D-CUST-1 (alter-internal #140) - surface 21 (ceremony echo)
32
+ * embedded-messenger/05-design-synthesis.md - content_type taxonomy
33
+ * .claude/handovers/2026-04-24-alter-myspace-customisation-m2-and-c-spike.md
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import contextlib
40
+ import json
41
+ import logging
42
+ import os
43
+ import tempfile
44
+ from datetime import datetime, timedelta, timezone
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ from alter_runtime.config import DaemonConfig, data_dir
49
+ from alter_runtime.daemon import Component
50
+ from alter_runtime.subscribers.sse import SSEFrame
51
+
52
+ if TYPE_CHECKING:
53
+ from alter_runtime.config import Session
54
+
55
+ __all__ = [
56
+ "CEREMONY_ECHO_FILENAME",
57
+ "DEFAULT_ECHO_DURATION",
58
+ "RECOGNITION_CONTENT_TYPES",
59
+ "CeremonyEchoWriter",
60
+ ]
61
+
62
+ logger = logging.getLogger("alter_runtime.subscribers.ceremony_echo")
63
+
64
+ #: Filename for the ceremony-echo state (within ``data_dir()``).
65
+ CEREMONY_ECHO_FILENAME: str = "ceremony-echo.json"
66
+
67
+ #: How long an echo remains visible after the event arrives. The brief
68
+ #: locks this at 72 h - long enough to catch the user across multiple
69
+ #: shell sessions and a working week's natural cadence, short enough that
70
+ #: the echo doesn't become wallpaper.
71
+ DEFAULT_ECHO_DURATION: timedelta = timedelta(hours=72)
72
+
73
+ #: Content_type values that produce a ceremony echo. Both are in
74
+ #: ``backend/app/mcp/tools/messaging.py``'s ``ALLOWED_CONTENT_TYPES``
75
+ #: frozenset; if that taxonomy expands, this set should be revisited.
76
+ RECOGNITION_CONTENT_TYPES: frozenset[str] = frozenset(
77
+ {
78
+ "x-alter-recognition",
79
+ "x-alter-ceremony",
80
+ }
81
+ )
82
+
83
+
84
+ class CeremonyEchoWriter(Component):
85
+ """Tails the per-handle SSE stream and persists the most-recent
86
+ recognition-class event to ``ceremony-echo.json`` for shell-greeting
87
+ consumers.
88
+
89
+ Parameters
90
+ ----------
91
+ config:
92
+ Loaded :class:`DaemonConfig`. Reserved for future use; not consulted
93
+ in the M2 implementation.
94
+ session:
95
+ Authenticated alter-cli session. Used for log context only - the
96
+ bus delivers the per-handle frames, the writer does not need to
97
+ construct an SSE URL itself.
98
+ echo_path:
99
+ Override the state file path. Tests use this to redirect writes
100
+ to a ``tmp_path`` fixture without touching ``$HOME``.
101
+ echo_duration:
102
+ Override the echo-visible window. Tests pass a short duration to
103
+ exercise the expiry path without wall-clock waits.
104
+ """
105
+
106
+ name = "ceremony_echo"
107
+
108
+ def __init__(
109
+ self,
110
+ config: DaemonConfig,
111
+ session: Session,
112
+ *,
113
+ echo_path: Path | None = None,
114
+ echo_duration: timedelta = DEFAULT_ECHO_DURATION,
115
+ ) -> None:
116
+ self._config = config
117
+ self._session = session
118
+ self._echo_duration = echo_duration
119
+
120
+ self._echo_path: Path = (
121
+ echo_path if echo_path is not None else data_dir() / CEREMONY_ECHO_FILENAME
122
+ )
123
+
124
+ self._lock = asyncio.Lock()
125
+ self._shutdown_event = asyncio.Event()
126
+
127
+ # ------------------------------------------------------------------
128
+ # Component lifecycle
129
+ # ------------------------------------------------------------------
130
+
131
+ async def run(self) -> None:
132
+ """Long-lived idle loop. The bus is the actual ingress path -
133
+ :meth:`handle_raw_frame` is the seam the supervisor wires into
134
+ ``identity.frame``. We just block on shutdown so the supervisor
135
+ can register us as a peer of InboxWriter without opening any
136
+ sockets of our own.
137
+ """
138
+ logger.info(
139
+ "ceremony_echo started handle=%s echo_path=%s",
140
+ self._session.handle,
141
+ self._echo_path,
142
+ )
143
+ try:
144
+ await self._shutdown_event.wait()
145
+ except asyncio.CancelledError:
146
+ raise
147
+ finally:
148
+ logger.info("ceremony_echo stopped handle=%s", self._session.handle)
149
+
150
+ async def stop(self) -> None:
151
+ """Cooperative shutdown - release the run loop."""
152
+ self._shutdown_event.set()
153
+
154
+ # ------------------------------------------------------------------
155
+ # Frame ingest - public surface for the SSE bus and tests
156
+ # ------------------------------------------------------------------
157
+
158
+ async def handle_raw_frame(self, frame: SSEFrame) -> None:
159
+ """Parse an SSE frame and dispatch to :meth:`handle_event`.
160
+
161
+ Errors are logged and swallowed - the supervisor never sees a
162
+ write failure (per the project convention "swallow and continue").
163
+ """
164
+ try:
165
+ payload = frame.json
166
+ except (ValueError, json.JSONDecodeError) as exc:
167
+ logger.warning("ceremony_echo: malformed SSE frame body: %s", exc)
168
+ return
169
+ if not isinstance(payload, dict):
170
+ logger.warning("ceremony_echo: SSE frame payload is not a dict: %r", type(payload))
171
+ return
172
+ await self.handle_event(payload)
173
+
174
+ async def handle_event(self, event: dict[str, Any]) -> None:
175
+ """Project a single parsed IdentityEvent dict.
176
+
177
+ This is the unit-test seam - the test suite calls this directly
178
+ with synthesised dicts to avoid having to drive a real SSE socket.
179
+
180
+ We accept ``alter_message`` events whose payload's ``content_type``
181
+ is in :data:`RECOGNITION_CONTENT_TYPES`. Other kinds and other
182
+ content_types are silently dropped (the InboxWriter handles the
183
+ full inbox projection - we only care about the echo subset).
184
+ """
185
+ # ---- 1. Filter on kind ---------------------------------------
186
+ if event.get("kind") != "alter_message":
187
+ return
188
+
189
+ # The DO emits the IdentityEvent envelope with the message
190
+ # payload either at the top level or nested under ``payload``.
191
+ # Tolerate both shapes during the wire-contract rollout - same
192
+ # tolerance pattern as InboxWriter.
193
+ body = event.get("payload") if isinstance(event.get("payload"), dict) else event
194
+
195
+ content_type = body.get("content_type")
196
+ if content_type not in RECOGNITION_CONTENT_TYPES:
197
+ return
198
+
199
+ # ---- 2. Extract the echo-state fields ------------------------
200
+ sender = body.get("sender_handle") or body.get("sender")
201
+ body_md = body.get("body_md")
202
+ message_id = event.get("id") or body.get("id")
203
+ received_at = (
204
+ event.get("timestamp") or body.get("sent_at") or datetime.now(timezone.utc).isoformat()
205
+ )
206
+
207
+ if not sender:
208
+ logger.warning(
209
+ "ceremony_echo: %s frame missing sender - dropping (id=%r)",
210
+ content_type,
211
+ message_id,
212
+ )
213
+ return
214
+
215
+ # ---- 3. Compute expiry --------------------------------------
216
+ # Anchor the expiry on the event's own timestamp where possible
217
+ # so a delayed delivery does not extend the user-visible window.
218
+ try:
219
+ event_time = datetime.fromisoformat(received_at.replace("Z", "+00:00"))
220
+ except (ValueError, AttributeError):
221
+ event_time = datetime.now(timezone.utc)
222
+ expires_at = (event_time + self._echo_duration).isoformat()
223
+
224
+ # ---- 4. Persist atomically ----------------------------------
225
+ state = {
226
+ "schema_version": 1,
227
+ "last_recognition": {
228
+ "sender": sender,
229
+ "kind": content_type,
230
+ "body_md": str(body_md) if body_md is not None else "",
231
+ "received_at": received_at,
232
+ },
233
+ "expires_at": expires_at,
234
+ }
235
+
236
+ async with self._lock:
237
+ try:
238
+ self._write_state(state)
239
+ except OSError as exc:
240
+ logger.warning(
241
+ "ceremony_echo: state write failed: %s - dropping event",
242
+ exc,
243
+ )
244
+ return
245
+
246
+ logger.info(
247
+ "ceremony_echo: persisted %s from %s expires_at=%s",
248
+ content_type,
249
+ sender,
250
+ expires_at,
251
+ )
252
+
253
+ # ------------------------------------------------------------------
254
+ # Persistence
255
+ # ------------------------------------------------------------------
256
+
257
+ def _write_state(self, state: dict[str, Any]) -> None:
258
+ """Atomic-rename a JSON state file with mode 0o600.
259
+
260
+ The temp file is created in the same directory as the target so
261
+ the rename is atomic (cross-device renames are not). Parent
262
+ directory is created with mode 0o700 if absent.
263
+ """
264
+ target = self._echo_path
265
+ target.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
266
+
267
+ with tempfile.NamedTemporaryFile(
268
+ mode="w",
269
+ dir=str(target.parent),
270
+ prefix=f".{target.name}.",
271
+ suffix=".tmp",
272
+ delete=False,
273
+ ) as fh:
274
+ tmp_path = Path(fh.name)
275
+ try:
276
+ json.dump(state, fh, separators=(",", ":"), ensure_ascii=False)
277
+ fh.flush()
278
+ os.fsync(fh.fileno())
279
+ except Exception:
280
+ with contextlib.suppress(OSError):
281
+ tmp_path.unlink()
282
+ raise
283
+
284
+ try:
285
+ os.chmod(tmp_path, 0o600)
286
+ os.replace(tmp_path, target)
287
+ except OSError:
288
+ with contextlib.suppress(OSError):
289
+ tmp_path.unlink()
290
+ raise