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,58 @@
|
|
|
1
|
+
"""Subscribers - long-lived components that consume the per-handle event stream.
|
|
2
|
+
|
|
3
|
+
Each subscriber is a :class:`alter_runtime.daemon.Component` that registers
|
|
4
|
+
with the daemon supervisor and is restarted with exponential backoff on
|
|
5
|
+
failure. Subscribers are the network-facing half of the runtime: they own the
|
|
6
|
+
SSE socket against ``https://mcp.truealter.com/events/{handle}/stream`` and
|
|
7
|
+
project events into local on-disk caches + the in-process :class:`EventBus`
|
|
8
|
+
that other ALTER surfaces (CC hooks, the alter-cli, downstream adapters) read
|
|
9
|
+
from.
|
|
10
|
+
|
|
11
|
+
* Wave 1 shipped :class:`InboxWriter`, the shared :class:`SSEFrame` parser,
|
|
12
|
+
and the skeleton supervisor.
|
|
13
|
+
* Wave 2 adds :class:`EventBus`, :class:`DoSseSubscriber` (primary L1 ingress),
|
|
14
|
+
and :class:`McpFallbackSubscriber` (fallback via direct MCP polling, per D-RT9).
|
|
15
|
+
* Wave 6 adds :class:`AgentFrameSubscriber` (D-AGENT-CHANNEL-1 Phase 2 §8) —
|
|
16
|
+
projects ``agent_frame`` deliveries to ``~/.cache/alter/agent-frames.jsonl``
|
|
17
|
+
and re-publishes per-kind bus topics.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from alter_runtime.subscribers.active_sessions_cron_emitter import ActiveSessionsCronEmitter
|
|
21
|
+
from alter_runtime.subscribers.active_sessions_do_publisher import ActiveSessionsDoPublisher
|
|
22
|
+
from alter_runtime.subscribers.active_sessions_gc import ActiveSessionsGc
|
|
23
|
+
from alter_runtime.subscribers.active_sessions_writer import ActiveSessionsWriter
|
|
24
|
+
from alter_runtime.subscribers.adapters_writer import AdaptersWriter
|
|
25
|
+
from alter_runtime.subscribers.agent_frames import AgentFrameSubscriber
|
|
26
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
27
|
+
from alter_runtime.subscribers.cache_writer import CacheWriter, project_state_to_cache
|
|
28
|
+
from alter_runtime.subscribers.ceremony_echo import CeremonyEchoWriter
|
|
29
|
+
from alter_runtime.subscribers.do_sse import DoSseSubscriber
|
|
30
|
+
from alter_runtime.subscribers.ebpf import EbpfSubscriber
|
|
31
|
+
from alter_runtime.subscribers.inbox_writer import InboxWriter
|
|
32
|
+
from alter_runtime.subscribers.mcp_fallback import McpFallbackSubscriber
|
|
33
|
+
from alter_runtime.subscribers.presence_writer import PresenceWriter
|
|
34
|
+
from alter_runtime.subscribers.session_presence import SessionPresenceWriter
|
|
35
|
+
from alter_runtime.subscribers.sse import SSEFrame, parse_sse_frames
|
|
36
|
+
from alter_runtime.subscribers.weave_intent_writer import WeaveIntentWriter
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ActiveSessionsCronEmitter",
|
|
40
|
+
"ActiveSessionsDoPublisher",
|
|
41
|
+
"ActiveSessionsGc",
|
|
42
|
+
"ActiveSessionsWriter",
|
|
43
|
+
"AdaptersWriter",
|
|
44
|
+
"AgentFrameSubscriber",
|
|
45
|
+
"CacheWriter",
|
|
46
|
+
"CeremonyEchoWriter",
|
|
47
|
+
"DoSseSubscriber",
|
|
48
|
+
"EbpfSubscriber",
|
|
49
|
+
"EventBus",
|
|
50
|
+
"InboxWriter",
|
|
51
|
+
"McpFallbackSubscriber",
|
|
52
|
+
"PresenceWriter",
|
|
53
|
+
"SSEFrame",
|
|
54
|
+
"SessionPresenceWriter",
|
|
55
|
+
"WeaveIntentWriter",
|
|
56
|
+
"parse_sse_frames",
|
|
57
|
+
"project_state_to_cache",
|
|
58
|
+
]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""ActiveSessionsCronEmitter - translates cron routine lifecycle events
|
|
2
|
+
into ``tool: "cron"`` active-session envelopes.
|
|
3
|
+
|
|
4
|
+
Wave D of D-COORD-D2. Sibling to :class:`ActiveSessionsWriter` (the
|
|
5
|
+
local-disk projection), :class:`ActiveSessionsGc` (the idle/terminated
|
|
6
|
+
sweeper), and :class:`ActiveSessionsDoPublisher` (the cross-host
|
|
7
|
+
publisher). This component does NOT touch disk or the network - it
|
|
8
|
+
listens for cron routine lifecycle events on the in-process
|
|
9
|
+
:class:`EventBus` and re-publishes them as ``identity.event`` envelopes
|
|
10
|
+
that the shipped writer already knows how to project.
|
|
11
|
+
|
|
12
|
+
Design contract per D-COORD-D2 §3 Wave D item 3 + §7.2 (canon schema):
|
|
13
|
+
|
|
14
|
+
* **Two emits per routine invocation.** ``cron.routine.started`` on the
|
|
15
|
+
bus produces a ``session_started`` envelope (``version=0``,
|
|
16
|
+
``status="active"``); ``cron.routine.finished`` produces a
|
|
17
|
+
``session_ended`` envelope (``version=1``, ``status="complete"``).
|
|
18
|
+
Both envelopes share the same ``session_id`` so reader dedup folds
|
|
19
|
+
them as the same lifecycle.
|
|
20
|
+
* **No heartbeat.** Cron routines are short by design; the start/end
|
|
21
|
+
pair is sufficient. Long-running routines (>5 min) MAY emit
|
|
22
|
+
heartbeats in a follow-up - out of scope for Wave D.
|
|
23
|
+
* **No per-routine code.** The SDK handles every routine
|
|
24
|
+
transparently. Routine authors never call into the active-sessions
|
|
25
|
+
contract directly.
|
|
26
|
+
* **No emit without a handle.** When ``~/.config/alter/session.json``
|
|
27
|
+
is absent (degraded daemon mode), the component idles - there is no
|
|
28
|
+
Sovereign-tier handle to bind a session envelope to. This mirrors
|
|
29
|
+
the writer's ``handle`` required-field guard.
|
|
30
|
+
|
|
31
|
+
Field mapping for ``tool: "cron"``:
|
|
32
|
+
|
|
33
|
+
* ``session_id`` - UUIDv4 minted per routine invocation. The publisher
|
|
34
|
+
of ``cron.routine.started`` is expected to mint the UUID once and
|
|
35
|
+
carry it through to the matching ``cron.routine.finished`` payload.
|
|
36
|
+
* ``working_on`` - first 200 chars of the routine name (e.g.
|
|
37
|
+
``"phone-link-knowledge-scan"``).
|
|
38
|
+
* ``files_touched`` - empty list. Cron routines do not touch files via
|
|
39
|
+
a tracked surface.
|
|
40
|
+
* ``machine_id`` - the daemon's stable host id derivation, shared with
|
|
41
|
+
:class:`TokenUsageClient` for consistency across alter-runtime
|
|
42
|
+
surfaces.
|
|
43
|
+
* ``consent_tier`` - the session's declared consent tier, unless the
|
|
44
|
+
routine config carries a per-routine override.
|
|
45
|
+
* ``started_at`` + ``last_activity`` - ISO 8601 UTC at envelope emit.
|
|
46
|
+
* ``provenance_class`` - literal ``"active_composition"``.
|
|
47
|
+
|
|
48
|
+
The component is additive - no existing cron logic is refactored.
|
|
49
|
+
When a real CronCreate fabric lands inside alter-runtime, the
|
|
50
|
+
publisher emits ``cron.routine.started`` / ``cron.routine.finished``
|
|
51
|
+
at the right call sites and this subscriber translates each into the
|
|
52
|
+
envelope contract above.
|
|
53
|
+
|
|
54
|
+
Per D-COORD-D2 Wave D - the daemon-side CronCreate emitter.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import asyncio
|
|
60
|
+
import contextlib
|
|
61
|
+
import logging
|
|
62
|
+
import uuid
|
|
63
|
+
from datetime import datetime, timezone
|
|
64
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
65
|
+
|
|
66
|
+
from alter_runtime.config import DaemonConfig
|
|
67
|
+
from alter_runtime.daemon import Component
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from alter_runtime.config import Session
|
|
71
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"CRON_TOOL_NAME",
|
|
75
|
+
"CRON_ROUTINE_STARTED_TOPIC",
|
|
76
|
+
"CRON_ROUTINE_FINISHED_TOPIC",
|
|
77
|
+
"MAX_WORKING_ON_CHARS",
|
|
78
|
+
"ActiveSessionsCronEmitter",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger("alter_runtime.subscribers.active_sessions_cron_emitter")
|
|
82
|
+
|
|
83
|
+
#: Tool name emitted in every envelope - matches the schema enum.
|
|
84
|
+
CRON_TOOL_NAME: str = "cron"
|
|
85
|
+
|
|
86
|
+
#: Bus topic published by the cron fabric on routine start.
|
|
87
|
+
CRON_ROUTINE_STARTED_TOPIC: str = "cron.routine.started"
|
|
88
|
+
|
|
89
|
+
#: Bus topic published by the cron fabric on routine finish.
|
|
90
|
+
CRON_ROUTINE_FINISHED_TOPIC: str = "cron.routine.finished"
|
|
91
|
+
|
|
92
|
+
#: Cap on the routine-name string emitted as ``working_on`` per §7.2.
|
|
93
|
+
MAX_WORKING_ON_CHARS: int = 200
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _derive_machine_id() -> str:
|
|
97
|
+
"""Return the same stable host identifier the rest of alter-runtime
|
|
98
|
+
uses for cross-host de-duplication.
|
|
99
|
+
|
|
100
|
+
Sources the singleton derivation from
|
|
101
|
+
:func:`alter_runtime.clients.token_usage_client._derive_host_id` so a
|
|
102
|
+
single host emits the same ``machine_id`` across every subscriber.
|
|
103
|
+
"""
|
|
104
|
+
# Imported lazily to avoid a hard import-time dependency on the
|
|
105
|
+
# token-usage client (which itself imports ``httpx`` and friends).
|
|
106
|
+
from alter_runtime.clients.token_usage_client import _derive_host_id
|
|
107
|
+
|
|
108
|
+
return _derive_host_id()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ActiveSessionsCronEmitter(Component):
|
|
112
|
+
"""Subscribes to cron routine lifecycle events and emits envelopes.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
config:
|
|
117
|
+
Loaded :class:`DaemonConfig`. Not consumed today; accepted for
|
|
118
|
+
symmetry with sibling components and so future config knobs
|
|
119
|
+
(heartbeat cadence, per-tool consent override) can be wired
|
|
120
|
+
without changing the supervisor's registration signature.
|
|
121
|
+
bus:
|
|
122
|
+
Shared :class:`EventBus`. Subscribes to
|
|
123
|
+
:data:`CRON_ROUTINE_STARTED_TOPIC` and
|
|
124
|
+
:data:`CRON_ROUTINE_FINISHED_TOPIC`; publishes to
|
|
125
|
+
``identity.event`` so the shipped writer projects each envelope.
|
|
126
|
+
session:
|
|
127
|
+
The alter-cli session (handle + consent tier). ``None`` ->
|
|
128
|
+
degraded mode (no envelope emit; the GC / writer / DO publisher
|
|
129
|
+
already accept this path).
|
|
130
|
+
machine_id_provider:
|
|
131
|
+
Override the machine-id derivation. Tests inject a stub so they
|
|
132
|
+
do not depend on the host's real ``/etc/machine-id``.
|
|
133
|
+
now:
|
|
134
|
+
Override the clock. Tests pass a frozen ``datetime`` provider.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
name = "active_sessions_cron_emitter"
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
config: DaemonConfig,
|
|
142
|
+
bus: EventBus,
|
|
143
|
+
session: Session | None,
|
|
144
|
+
*,
|
|
145
|
+
machine_id_provider: Callable[[], str] | None = None,
|
|
146
|
+
now: Callable[[], datetime] | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
self._config = config
|
|
149
|
+
self._bus = bus
|
|
150
|
+
self._session = session
|
|
151
|
+
self._machine_id_provider = machine_id_provider or _derive_machine_id
|
|
152
|
+
self._now: Callable[[], datetime] = now or (lambda: datetime.now(timezone.utc))
|
|
153
|
+
|
|
154
|
+
self._stop_event = asyncio.Event()
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Component lifecycle
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
async def run(self) -> None:
|
|
161
|
+
if self._session is None:
|
|
162
|
+
logger.info(
|
|
163
|
+
"active_sessions_cron_emitter: no alter-cli session - "
|
|
164
|
+
"idling. Routine lifecycle events on the bus will be "
|
|
165
|
+
"ignored until `alter login` populates session.json."
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
await self._stop_event.wait()
|
|
169
|
+
except asyncio.CancelledError:
|
|
170
|
+
raise
|
|
171
|
+
finally:
|
|
172
|
+
logger.info("active_sessions_cron_emitter stopped (degraded)")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
self._bus.subscribe(CRON_ROUTINE_STARTED_TOPIC, self.handle_routine_started)
|
|
176
|
+
self._bus.subscribe(CRON_ROUTINE_FINISHED_TOPIC, self.handle_routine_finished)
|
|
177
|
+
logger.info(
|
|
178
|
+
"active_sessions_cron_emitter started handle=%s topics=%s,%s",
|
|
179
|
+
self._session.handle,
|
|
180
|
+
CRON_ROUTINE_STARTED_TOPIC,
|
|
181
|
+
CRON_ROUTINE_FINISHED_TOPIC,
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
await self._stop_event.wait()
|
|
185
|
+
except asyncio.CancelledError:
|
|
186
|
+
raise
|
|
187
|
+
finally:
|
|
188
|
+
with contextlib.suppress(Exception):
|
|
189
|
+
self._bus.unsubscribe(CRON_ROUTINE_STARTED_TOPIC, self.handle_routine_started)
|
|
190
|
+
with contextlib.suppress(Exception):
|
|
191
|
+
self._bus.unsubscribe(CRON_ROUTINE_FINISHED_TOPIC, self.handle_routine_finished)
|
|
192
|
+
logger.info("active_sessions_cron_emitter stopped")
|
|
193
|
+
|
|
194
|
+
async def stop(self) -> None:
|
|
195
|
+
self._stop_event.set()
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Event ingest
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async def handle_routine_started(self, event: dict[str, Any]) -> None:
|
|
202
|
+
"""Translate a ``cron.routine.started`` bus event into a
|
|
203
|
+
``session_started`` envelope on ``identity.event``."""
|
|
204
|
+
await self._emit(event, kind="session_started", status="active", version=0)
|
|
205
|
+
|
|
206
|
+
async def handle_routine_finished(self, event: dict[str, Any]) -> None:
|
|
207
|
+
"""Translate a ``cron.routine.finished`` bus event into a
|
|
208
|
+
``session_ended`` envelope on ``identity.event``.
|
|
209
|
+
|
|
210
|
+
``version`` defaults to ``1`` (one bump above the matching
|
|
211
|
+
``session_started``). Publishers MAY override by carrying an
|
|
212
|
+
explicit ``version`` field in the payload - useful if a future
|
|
213
|
+
heartbeat path lands and routines are no longer guaranteed to
|
|
214
|
+
be a two-emit lifecycle.
|
|
215
|
+
"""
|
|
216
|
+
version_override = event.get("version") if isinstance(event, dict) else None
|
|
217
|
+
try:
|
|
218
|
+
version = int(version_override) if version_override is not None else 1
|
|
219
|
+
except (TypeError, ValueError):
|
|
220
|
+
version = 1
|
|
221
|
+
await self._emit(event, kind="session_ended", status="complete", version=version)
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
# Envelope construction
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async def _emit(
|
|
228
|
+
self,
|
|
229
|
+
event: dict[str, Any],
|
|
230
|
+
*,
|
|
231
|
+
kind: str,
|
|
232
|
+
status: str,
|
|
233
|
+
version: int,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Build + publish a single envelope on ``identity.event``."""
|
|
236
|
+
if not isinstance(event, dict):
|
|
237
|
+
logger.warning("active_sessions_cron_emitter: non-dict event payload - dropping")
|
|
238
|
+
return
|
|
239
|
+
if self._session is None:
|
|
240
|
+
# ``run()`` already guards against this - defensive only.
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
session_id = event.get("session_id")
|
|
244
|
+
if not isinstance(session_id, str) or not session_id:
|
|
245
|
+
logger.warning(
|
|
246
|
+
"active_sessions_cron_emitter: missing session_id on %s payload - dropping",
|
|
247
|
+
kind,
|
|
248
|
+
)
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
routine_name = event.get("routine") or event.get("name") or event.get("working_on")
|
|
252
|
+
working_on: str | None
|
|
253
|
+
if isinstance(routine_name, str) and routine_name:
|
|
254
|
+
working_on = routine_name[:MAX_WORKING_ON_CHARS]
|
|
255
|
+
else:
|
|
256
|
+
working_on = None
|
|
257
|
+
|
|
258
|
+
# Honour a per-routine consent override iff valid; otherwise
|
|
259
|
+
# fall back to the session's declared tier. The writer rejects
|
|
260
|
+
# any tier outside 1..4 so we sanity-check here too.
|
|
261
|
+
consent_override = event.get("consent_tier")
|
|
262
|
+
consent_tier: int = self._session.consent_tier
|
|
263
|
+
if consent_override is not None:
|
|
264
|
+
try:
|
|
265
|
+
consent_int = int(consent_override)
|
|
266
|
+
except (TypeError, ValueError):
|
|
267
|
+
consent_int = -1
|
|
268
|
+
if consent_int in (1, 2, 3, 4):
|
|
269
|
+
consent_tier = consent_int
|
|
270
|
+
else:
|
|
271
|
+
logger.warning(
|
|
272
|
+
"active_sessions_cron_emitter: invalid consent_tier override=%r - "
|
|
273
|
+
"falling back to session tier=%d",
|
|
274
|
+
consent_override,
|
|
275
|
+
self._session.consent_tier,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
started_at = event.get("started_at")
|
|
279
|
+
if not isinstance(started_at, str) or not started_at:
|
|
280
|
+
started_at = self._now().isoformat()
|
|
281
|
+
|
|
282
|
+
now_iso = self._now().isoformat()
|
|
283
|
+
|
|
284
|
+
# Per the spec - each envelope mints a fresh UUID id; dedup is
|
|
285
|
+
# by (id, version) on the writer side. session_id is the
|
|
286
|
+
# lifecycle key that pairs start + end.
|
|
287
|
+
envelope: dict[str, Any] = {
|
|
288
|
+
"id": str(uuid.uuid4()),
|
|
289
|
+
"version": version,
|
|
290
|
+
"kind": kind,
|
|
291
|
+
"handle": self._session.handle,
|
|
292
|
+
"tool": CRON_TOOL_NAME,
|
|
293
|
+
"session_id": session_id,
|
|
294
|
+
"machine_id": self._machine_id_provider(),
|
|
295
|
+
"started_at": started_at,
|
|
296
|
+
"last_activity": now_iso,
|
|
297
|
+
"status": status,
|
|
298
|
+
"provenance_class": "active_composition",
|
|
299
|
+
"consent_tier": consent_tier,
|
|
300
|
+
"working_on": working_on,
|
|
301
|
+
"files_touched": [],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await self._bus.publish("identity.event", envelope)
|
|
305
|
+
logger.debug(
|
|
306
|
+
"active_sessions_cron_emitter emitted kind=%s status=%s "
|
|
307
|
+
"session=%s version=%d working_on=%r",
|
|
308
|
+
kind,
|
|
309
|
+
status,
|
|
310
|
+
session_id,
|
|
311
|
+
version,
|
|
312
|
+
working_on,
|
|
313
|
+
)
|