oc_chat
Loading...
Searching...
No Matches
hub_bus.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/hub_bus.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/unit/test_259_hub_carriers.py"
6"""
7The in-process hub carrier: a direct call, and a published event beside it.
8
9Purpose
10-------
11Plan 259 P5, from the factory-decomposition design note ยง4. The hub has
12three carriers, and this is the first one tried: when both parties are
13in one process -- the orchestrator and an agent it is driving -- the
14message goes straight to the recipient's callback rather than out to a
15file and back. That is the direct path the operator asked for.
16
17Two things keep it honest.
18
19**It is chosen by PRESENCE, not by preference.** A party is present here
20only while something is attached for its address, so a session that has
21gone away simply falls through to the file lane and the message waits on
22disk. Nothing is dropped because a listener vanished.
23
24**Direct does not mean unlogged.** The hub records before it routes, so
25the log's entry is identical whether this carrier or the file lane moved
26the message -- design-note invariant 1, and a witness measures it by
27comparing two logs rather than reading it off this file. On top of that,
28when an event bus is injected the carrier publishes ONE
29``TurnEvent(kind="hub_message")`` per delivery, so a session's own event
30writer records the fleet traffic that crossed it for free.
31
32That published event carries ROUTING ONLY -- no body, no provenance, no
33answer. The hub's durable log is the record of what was said; this event
34is the record that something was said. A telemetry event anything on the
35bus may read must not become a second place a ruling's authority can be
36reconstructed from.
37
38Responsibilities
39----------------
40- :class:`BusCarrier` -- ``attach`` / ``detach`` / ``present`` /
41 ``deliver``, the :class:`~oc_chat.core.hub.Carrier` contract plus the
42 two attachment methods :meth:`~oc_chat.core.hub.Hub.attach` delegates
43 to.
44- :class:`NoAttachmentError` -- the carrier's named refusal.
45
46Diagnostics
47-----------
48Domain: OC_CHAT.MAIN
49Levels:
50 L2 - lifecycle events (an attach, a delivery)
51 L3 - semantic details (a refusal, a publish)
52 L4 - deep tracing (module load)
53
54Contracts
55---------
56- **The callback runs BEFORE the event is published.** Delivery is the
57 job; telemetry is the by-product, and a bus fault must never eat a
58 message that a recipient has already had.
59- ``present`` is false for ``operator@remote`` even when something is
60 attached for it. An in-process callback cannot be a phone, and saying
61 "present" would swallow a message the operator is waiting for. The
62 attach is still ALLOWED, so a control centre may watch that traffic;
63 it simply never wins the routing decision.
64- One address holds ONE callback. Re-attaching replaces; two would
65 deliver the same message twice.
66- The event is built by ``core/events.make_hub_message``, the one
67 factory in ``EVENT_FACTORIES``, so this module cannot drift from the
68 kind's declared payload shape.
69- The bus is DUCK-TYPED: anything with ``publish``. That keeps the
70 hub's closure free of the runtime's event stack while still letting a
71 live session pass its real :class:`~oc_chat.core.event_bus.EventBus`.
72
73Dependencies
74------------
75- oc_diagnostics (structured debug logging via _dbg)
76- oc_orchestrator.hub (the contract; moved there by plan 272, and named
77 directly rather than through the shim because this module is the
78 consumer-side carrier that STAYED, not a caller that always spelled
79 the old path)
80- oc_chat.core.events (the one hub_message factory)
81"""
82
83from __future__ import annotations
84
85from typing import Any, Callable
86
87from oc_diagnostics import _dbg
88
89from oc_chat.core.events import make_hub_message, new_event_id
90from oc_orchestrator.hub import (
91 ADDRESS_OPERATOR_REMOTE,
92 DeliveryReceipt,
93 HubError,
94 HubMessage,
95 parse_address,
96)
97
98_dbg("OC_CHAT.MAIN", "module", "hub_bus loaded", 4)
99
100
101#: The session a published event belongs to when the carrier was built
102#: without one. A live session injects its own; this is what a headless
103#: orchestrator process reports as.
104DEFAULT_BUS_SESSION_ID = "hub"
105
106#: The parent every published hub event hangs from. ``TurnEvent`` makes
107#: ``parent_event_id`` mandatory for every kind but ``session_started``,
108#: and a hub message has no turn to parent onto, so the carrier declares
109#: one root rather than inventing a fake turn.
110DEFAULT_BUS_PARENT_EVENT_ID = "hub-root"
111
112#: ``(message) -> None``; raising is how a reader refuses.
113HubCallback = Callable[[HubMessage], None]
114
115
116class NoAttachmentError(HubError):
117 """No in-process reader is attached for that address."""
118
119
121 """Deliver to an attached callback, and publish one event beside it."""
122
123 #: What a receipt and a trace call this carrier.
124 name = "bus"
125
127 self,
128 *,
129 bus: Any | None = None,
130 session_id: str = DEFAULT_BUS_SESSION_ID,
131 parent_event_id: str = DEFAULT_BUS_PARENT_EVENT_ID,
132 ) -> None:
133 func = "BusCarrier.__init__"
134 self._callbacks: dict[str, HubCallback] = {}
135 self.bus = bus
136 self.session_id = session_id
137 self.parent_event_id = parent_event_id
138 _dbg("OC_CHAT.MAIN", func,
139 f"bus={'injected' if bus is not None else 'none'}", 2)
140
141 # -- attachment -------------------------------------------------------
142
143 def attach(self, address: str, callback: HubCallback) -> None:
144 """Register *callback* as the in-process reader for *address*.
145
146 Replaces any existing one: two callbacks for one address would
147 deliver the same message twice, which no reader expects.
148
149 Raises:
150 AddressError: VALIDATION_ERROR (L3) -- from the grammar.
151 """
152 func = "BusCarrier.attach"
153 parse_address(address)
154 self._callbacks[address] = callback
155 _dbg("OC_CHAT.MAIN", func, f"attached {address}", 2)
156
157 def detach(self, address: str) -> None:
158 """Remove *address*'s reader.
159
160 Raises:
161 NoAttachmentError: VALIDATION_ERROR (L3) -- nothing was
162 attached. Silence here would hide a mistyped address for
163 the life of a session.
164 """
165 func = "BusCarrier.detach"
166 parse_address(address)
167 if address not in self._callbacks:
168 raise NoAttachmentError(
169 f"nothing is attached for {address}; there is no in-process "
170 "reader to detach"
171 )
172 del self._callbacks[address]
173 _dbg("OC_CHAT.MAIN", func, f"detached {address}", 2)
174
175 # -- the Carrier contract ---------------------------------------------
176
177 def present(self, address: str) -> bool:
178 """True when something is attached AND the address is reachable here.
179
180 ``operator@remote`` is never reachable in-process, whatever is
181 attached for it: the address MEANS the operator is away from the
182 control centre, and the connector is the only carrier that can
183 reach them.
184 """
185 if address == ADDRESS_OPERATOR_REMOTE:
186 return False
187 return address in self._callbacks
188
189 def deliver(self, message: HubMessage) -> DeliveryReceipt:
190 """Call the recipient's reader, then publish one event.
191
192 Raises:
193 NoAttachmentError: VALIDATION_ERROR (L3) -- nothing is
194 attached. The hub only reaches this after ``present``
195 said yes, so hitting it means the reader went away
196 between the two calls; refusing lets the log's entry
197 stand and the file lane redeliver.
198 """
199 func = "BusCarrier.deliver"
200 callback = self._callbacks.get(message.recipient)
201 if callback is None:
202 raise NoAttachmentError(
203 f"nothing is attached for {message.recipient}; the bus carrier "
204 f"cannot deliver {message.message_id}"
205 )
206 callback(message)
207 _dbg("OC_CHAT.MAIN", func,
208 f"delivered {message.message_id} to {message.recipient}", 2)
209 self._publish(message)
210 return DeliveryReceipt(
211 carrier=self.name, message_id=message.message_id,
212 detail=f"attached callback for {message.recipient}",
213 )
214
215 # -- telemetry --------------------------------------------------------
216
217 def _publish(self, message: HubMessage) -> None:
218 """Publish one ``hub_message`` event, if a bus was injected.
219
220 Never raises into the delivery path: the recipient already has
221 the message, and a telemetry failure must not be reported as a
222 delivery failure. The bus itself already isolates subscriber
223 faults; this guards the construction and the publish call.
224 """
225 func = "BusCarrier._publish"
226 if self.bus is None:
227 return
228 try:
229 self.bus.publish(make_hub_message(
230 event_id=new_event_id(),
231 parent_event_id=self.parent_event_id,
232 session_id=self.session_id,
233 ts=message.at,
234 message_id=message.message_id,
235 hub_kind=message.kind,
236 sender=message.sender,
237 recipient=message.recipient,
238 subject=message.subject,
239 in_reply_to=message.in_reply_to,
240 summary=message.summary,
241 ))
242 except Exception as exc: # noqa: BLE001 - EXTERNAL_ERROR (L2); telemetry is not delivery
243 _dbg("OC_CHAT.MAIN", func,
244 # OCT-LINT: disable=no_hardcoded_secrets reason="Diagnostic prose naming the S6 class EXTERNAL_ERROR and the hub kind; scores 4.59 bits/char and carries no credential. Operator-ruled 2026-09-13 (plan 259 escalation, line-scoped), same class as oc_connect/inbound/poller.py:429"
245 f"EXTERNAL_ERROR: could not publish hub_message for "
246 f"{message.message_id} ({type(exc).__name__}: {exc}); the "
247 f"message was delivered regardless", 2)
248 return
249 _dbg("OC_CHAT.MAIN", func, f"published hub_message {message.message_id}", 3)
250
251
252__all__ = [
253 "DEFAULT_BUS_PARENT_EVENT_ID",
254 "DEFAULT_BUS_SESSION_ID",
255 "BusCarrier",
256 "HubCallback",
257 "NoAttachmentError",
258]
None _publish(self, HubMessage message)
Definition hub_bus.py:217
DeliveryReceipt deliver(self, HubMessage message)
Definition hub_bus.py:189
None attach(self, str address, HubCallback callback)
Definition hub_bus.py:143
bool present(self, str address)
Definition hub_bus.py:177
None detach(self, str address)
Definition hub_bus.py:157
None __init__(self, *, Any|None bus=None, str session_id=DEFAULT_BUS_SESSION_ID, str parent_event_id=DEFAULT_BUS_PARENT_EVENT_ID)
Definition hub_bus.py:132