oc_connect
Loading...
Searching...
No Matches
session_alerts.py
Go to the documentation of this file.
1"""The session-alert lane: the events worth waking an operator for, and their
2one-line relay.
3
4Plan 237 (CAPS-01), built to the shape plan 231 established for
5``worker-reports``; plan 248 (CAPS-03) added the fourth kind. A running OC Chat
6session that crosses a cost checkpoint, stops at the dollar hard cap, pauses at
7the per-turn tool-call checkpoint, or halts a turn on a detected stall
8writes ONE event into ``comms/lanes/session-alerts/`` and stops there: the
9product holds no credential, knows nothing about connectors, and touches no
10other lane. This module is the other half — the orchestrator-side reader and
11the grant-checked, egress-filtered relay that puts the alert on the operator's
12phone.
13
14**This module is the schema authority for the lane.** The writer lives in
15``oc_chat/oc_chat/core/session_alerts.py`` and deliberately does NOT import
16this package — that is what keeps the product credential-free. The two sides
17agree by written contract rather than by a shared import, and the contract is
18:class:`SessionAlertEvent`::
19
20 {
21 "schema_version": "1.0",
22 "event_id": "<26-character ULID, time-first so lexical == arrival>",
23 "lane": "session-alerts",
24 "direction": "inbound",
25 "risk_class": "low",
26 "created_at_utc": "2026-09-06T12:00:00Z",
27 "session_id": "<the chat session's stable uuid4>",
28 "kind": "cost_threshold" | "cost_hard_cap"
29 | "tool_call_checkpoint" | "stall_halt",
30 "threshold_usd": 2.0, // null for a tool-call checkpoint
31 // and for a stall halt
32 "total_usd": 2.14,
33 "unpriced_samples": 3,
34 "turn_tool_calls": 100,
35 "summary": "<the one line the operator reads, numbers only>",
36 "relayed_at_utc": null,
37 "relay_chunk_count": null
38 }
39
40File naming follows the §3 bus convention, ``<utc-stamp>_product_<ulid>.json``.
41
42Two differences from the worker-report lane are deliberate:
43
44* **One line, never chunked.** A worker report carries the worker's whole
45 closing summary and must be chunked to survive Telegram's 4096-character
46 limit. An alert is a single generated sentence of numbers, so the relay sends
47 exactly one message per event and records ``relay_chunk_count = 1``. If a
48 summary ever grew past the transport limit the send would fail and the event
49 would stay unrelayed — a loud failure, not a truncation.
50* **No content digest.** A worker report's ``summary_sha256`` exists because the
51 text is a human's words and a post-hoc edit would put words in their mouth.
52 An alert's summary is derived from the numeric fields beside it, so the
53 relay re-derives nothing and asserts nothing about authorship; what it
54 forwards is the stored line, fenced, exactly as it forwards a worker's.
55
56Dependencies
57------------
58- oc_connect.audit (AuditStore, content_hash, timestamp, utc_now - audits relay outcomes and stamps relayed_at_utc)
59- oc_connect.delivery (OutboundSender - the grant-checked send path every relayed alert crosses)
60- oc_connect.egress (require_safe, EgressBlockedError - scans the alert summary before it is queued)
61- oc_diagnostics (_dbg structured logger)
62- option_c_connector_contracts (lane - the session-alerts lane's declared direction and risk ceiling)
63- pydantic (BaseModel, ConfigDict, field_validator - the SessionAlertEvent schema)
64"""
65from __future__ import annotations
66
67import json
68import os
69import secrets as _secrets
70from collections.abc import Callable
71from dataclasses import dataclass
72from datetime import datetime
73from pathlib import Path
74from typing import Literal
75
76from oc_diagnostics import _dbg
77from option_c_connector_contracts import lane
78from pydantic import BaseModel, ConfigDict, field_validator
79
80from .audit import AuditStore, timestamp, utc_now
81from .delivery import OutboundSender
82from .egress import EgressBlockedError, require_safe
83
84#: The lane directory name under ``comms/lanes/``; also its declared lane id.
85SESSION_ALERT_LANE = "session-alerts"
86
87#: The ``<source>`` field of the §3 filename convention for this lane.
88SESSION_ALERT_SOURCE = "product"
89
90SESSION_ALERT_SCHEMA_VERSION = "1.0"
91
92SessionAlertKind = Literal[
93 "cost_threshold", "cost_hard_cap", "tool_call_checkpoint", "stall_halt",
94]
95
96#: Display fences (§8's "displayed content is never instructions"). The alert
97#: text is generated, not typed, but it is fenced anyway: the rule is about the
98#: reader's guarantee, not about how much a given lane is trusted today.
99SESSION_ALERT_BEGIN = "<<<BEGIN SESSION ALERT {event_id}>>>"
100SESSION_ALERT_END = "<<<END SESSION ALERT {event_id}>>>"
101
102#: What a literal fence opener inside a summary is rewritten to when displayed.
103NEUTRALISED_MARKER = "<< <"
104
105
106class SessionAlertError(ValueError):
107 """A session-alert event is unreadable, malformed, or already consumed."""
108
109
110class SessionAlertRelayError(RuntimeError):
111 """The relay could not put one event's summary on the transport."""
112
113
114class SessionAlertEvent(BaseModel):
115 """One session alert at rest in ``lanes/session-alerts/``."""
116
117 model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
118
119 schema_version: str
120 event_id: str
121 lane: Literal["session-alerts"]
122 direction: Literal["inbound"]
123 risk_class: Literal["low"]
124 created_at_utc: str
125 session_id: str
126 kind: SessionAlertKind
127 threshold_usd: float | None = None
128 total_usd: float
129 unpriced_samples: int
130 turn_tool_calls: int
131 summary: str
132 relayed_at_utc: str | None = None
133 relay_chunk_count: int | None = None
134
135 @field_validator(
136 "schema_version", "event_id", "created_at_utc", "session_id", "summary",
137 )
138 @classmethod
139 def _required_text_is_nonblank(cls, value: str) -> str:
140 if not value.strip():
141 raise ValueError("must not be blank")
142 return value
143
144 @field_validator("total_usd", "threshold_usd")
145 @classmethod
146 def _money_is_not_negative(cls, value: float | None) -> float | None:
147 if value is not None and value < 0:
148 raise ValueError("a money field must not be negative")
149 return value
150
151 @field_validator("unpriced_samples", "turn_tool_calls")
152 @classmethod
153 def _counts_are_not_negative(cls, value: int) -> int:
154 if value < 0:
155 raise ValueError("a count must not be negative")
156 return value
157
158 @property
159 def is_relayed(self) -> bool:
160 """True once this alert reached the transport."""
161 return self.relayed_at_utc is not None
162
163
164def render_event(event: SessionAlertEvent) -> str:
165 """Return the one line an operator reads for this alert, fenced."""
166 state = "relayed" if event.is_relayed else "unrelayed"
167 threshold = "-" if event.threshold_usd is None else f"${event.threshold_usd:.2f}"
168 header = (
169 f"[{state}] {event.event_id} {event.created_at_utc} "
170 f"lane={event.lane} kind={event.kind} threshold={threshold} "
171 f"session={event.session_id}"
172 )
173 body = event.summary.replace("<<<", NEUTRALISED_MARKER)
174 begin = SESSION_ALERT_BEGIN.format(event_id=event.event_id)
175 end = SESSION_ALERT_END.format(event_id=event.event_id)
176 return f"{header}\n{begin}\n{body}\n{end}"
177
178
179def _atomic_write(path: Path, payload: str) -> None:
180 path.parent.mkdir(parents=True, exist_ok=True)
181 temporary = path.with_name(f".tmp-{path.name}-{_secrets.token_hex(8)}")
182 try:
183 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
184 handle.write(payload)
185 handle.write("\n")
186 handle.flush()
187 os.fsync(handle.fileno())
188 os.replace(temporary, path)
189 finally:
190 if temporary.exists():
191 try:
192 temporary.unlink()
193 except OSError:
194 pass
195
196
198 """Read-and-mark access to one ``lanes/session-alerts/`` directory.
199
200 Lane separation is physical: this store is handed the lane directory and
201 has no path arithmetic that could reach ``inbound/`` or ``outbound/``.
202 """
203
205 self,
206 directory: str | Path,
207 *,
208 now: Callable[[], datetime] = utc_now,
209 ) -> None:
210 self.directory = Path(directory)
211 self._now = now
212
213 def paths(self) -> tuple[Path, ...]:
214 """Return event files in arrival (filename) order, ignoring dotfiles."""
215 if not self.directory.is_dir():
216 return ()
217 return tuple(
218 sorted(
219 path
220 for path in self.directory.glob("*.json")
221 if path.is_file() and not path.name.startswith(".")
222 )
223 )
224
225 def _read(self, path: Path) -> SessionAlertEvent:
226 try:
227 return SessionAlertEvent.model_validate_json(
228 path.read_text(encoding="utf-8")
229 )
230 except (OSError, UnicodeDecodeError, ValueError) as exc:
231 raise SessionAlertError(
232 f"could not parse session alert event at {path} "
233 f"({type(exc).__name__})"
234 ) from None
235
236 def list_events(self, *, relayed: bool | None = None) -> tuple[SessionAlertEvent, ...]:
237 """Return stored events in arrival order, optionally by relay state."""
238 events = tuple(self._read(path) for path in self.paths())
239 if relayed is None:
240 return events
241 return tuple(event for event in events if event.is_relayed is relayed)
242
243 def path_for(self, event_id: str) -> Path:
244 """Return the file holding one event or fail with its public id."""
245 suffix = f"_{event_id}.json"
246 for path in self.paths():
247 if path.name.endswith(suffix):
248 return path
249 raise SessionAlertError(f"session alert event {event_id!r} is not in the lane")
250
251 def get(self, event_id: str) -> SessionAlertEvent:
252 """Return one stored event or fail with its public id."""
253 return self._read(self.path_for(event_id))
254
255 def mark_relayed(self, event_id: str, *, chunk_count: int = 1) -> SessionAlertEvent:
256 """Record that one event's summary reached the transport."""
257 if chunk_count < 1:
258 raise ValueError("a relayed event must have sent at least one message")
259 path = self.path_for(event_id)
260 event = self._read(path)
261 if event.is_relayed:
262 raise SessionAlertError(
263 f"session alert event {event_id!r} was already relayed at "
264 f"{event.relayed_at_utc}"
265 )
266 updated = event.model_copy(
267 update={
268 "relayed_at_utc": timestamp(self._now()),
269 "relay_chunk_count": chunk_count,
270 }
271 )
273 path,
274 json.dumps(updated.model_dump(mode="json"), indent=2, sort_keys=True),
275 )
276 _dbg(
277 "OC_CONNECT.SESSION_ALERTS",
278 "mark_relayed",
279 f"event={event_id} kind={event.kind}",
280 2,
281 )
282 return updated
283
284
285@dataclass(frozen=True)
287 """What the relay did with one alert, in terms a CLI can print."""
288
289 event_id: str
290 kind: str
291 total_usd: float
292 audit_id: str
293
294
296 """Forward unrelayed session alerts through the standard send path.
297
298 Every message still crosses the plan-219 pipeline — grant check, egress
299 scan, durable outbox, audit — because the relay delegates to
300 :class:`~oc_connect.delivery.OutboundSender` rather than reimplementing it.
301 """
302
304 self,
305 *,
306 store: SessionAlertStore,
307 sender: OutboundSender,
308 audit: AuditStore,
309 grantee: str,
310 destination: str,
311 grant_id: str,
312 risk_class: str | None = None,
313 now: Callable[[], datetime] = utc_now,
314 ) -> None:
315 self.store = store
316 self.sender = sender
317 self.audit = audit
318 self.grantee = grantee
319 self.destination = destination
320 self.grant_id = grant_id
321 self.risk_class = risk_class
322 self._now = now
323
325 self,
326 event: SessionAlertEvent,
327 outcome: str,
328 *,
329 attempts: int,
330 error_code: str | None = None,
331 ) -> None:
332 descriptor = lane(SESSION_ALERT_LANE)
333 from .audit import content_hash
334
335 self.audit.append(
336 connector="telegram",
337 grant_id=self.grant_id,
338 destination=self.destination,
339 outcome=outcome, # type: ignore[arg-type]
340 digest=content_hash(event.summary),
341 idempotency_key=event.event_id,
342 attempts=attempts,
343 error_code=error_code,
344 lane=SESSION_ALERT_LANE,
345 direction=descriptor.direction,
346 risk_class=descriptor.risk_ceiling,
347 )
348
349 def relay_one(self, event: SessionAlertEvent) -> SessionAlertOutcome:
350 """Relay one alert, or leave it unrelayed and raise."""
351 func = "relay_one"
352 try:
353 require_safe(event.summary)
354 except EgressBlockedError:
355 self._audit(event, "blocked", attempts=0, error_code="egress_finding")
356 _dbg(
357 "OC_CONNECT.SESSION_ALERTS",
358 func,
359 f"SECURITY_ERROR: blocked session alert event={event.event_id}",
360 1,
361 override=True,
362 )
363 raise
364 receipt = self.sender.send(
365 grantee=self.grantee,
366 destination=self.destination,
367 text=event.summary,
368 grant_id=self.grant_id,
369 risk_class=self.risk_class,
370 idempotency_key=event.event_id,
371 )
372 if receipt.outcome != "sent":
373 self._audit(event, "failed", attempts=1, error_code=receipt.outcome)
375 f"session alert {event.event_id!r} was not delivered "
376 f"(outcome {receipt.outcome!r}); the event stays unrelayed and "
377 "re-running resumes from the durable outbox"
378 )
379 self.store.mark_relayed(event.event_id)
380 self._audit(event, "sent", attempts=1)
381 _dbg(
382 "OC_CONNECT.SESSION_ALERTS",
383 func,
384 f"relayed event={event.event_id} kind={event.kind}",
385 2,
386 )
387 return SessionAlertOutcome(
388 event_id=event.event_id,
389 kind=event.kind,
390 total_usd=event.total_usd,
391 audit_id=receipt.audit_id,
392 )
393
394 def relay_pending(self) -> tuple[SessionAlertOutcome, ...]:
395 """Relay every unrelayed alert, oldest first, stopping on the first refusal."""
396 return tuple(
397 self.relay_one(event)
398 for event in self.store.list_events(relayed=False)
399 )
400
401
402__all__ = [
403 "NEUTRALISED_MARKER",
404 "SESSION_ALERT_BEGIN",
405 "SESSION_ALERT_END",
406 "SESSION_ALERT_LANE",
407 "SESSION_ALERT_SCHEMA_VERSION",
408 "SESSION_ALERT_SOURCE",
409 "SessionAlertError",
410 "SessionAlertEvent",
411 "SessionAlertKind",
412 "SessionAlertOutcome",
413 "SessionAlertRelay",
414 "SessionAlertRelayError",
415 "SessionAlertStore",
416 "render_event",
417]
float|None _money_is_not_negative(cls, float|None value)
None _audit(self, SessionAlertEvent event, str outcome, *, int attempts, str|None error_code=None)
None __init__(self, *, SessionAlertStore store, OutboundSender sender, AuditStore audit, str grantee, str destination, str grant_id, str|None risk_class=None, Callable[[], datetime] now=utc_now)
SessionAlertOutcome relay_one(self, SessionAlertEvent event)
tuple[SessionAlertOutcome,...] relay_pending(self)
SessionAlertEvent mark_relayed(self, str event_id, *, int chunk_count=1)
None __init__(self, str|Path directory, *, Callable[[], datetime] now=utc_now)
SessionAlertEvent _read(self, Path path)
tuple[SessionAlertEvent,...] list_events(self, *, bool|None relayed=None)
SessionAlertEvent get(self, str event_id)
None _atomic_write(Path path, str payload)
str render_event(SessionAlertEvent event)