oc_orchestrator
Loading...
Searching...
No Matches
hub_file.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/hub_file.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_orchestrator/tests/test_259_hub_file.py"
6"""
7The hub's default backend: an append-only log, a lane carrier, and ingest.
8
9Purpose
10-------
11Plan 259 P3, from the factory-decomposition design note §4. This is the
12DEFAULT hub backend and the mode the package's own tests run in, which is
13the condition that stops it rotting into dead code kept warm for
14strangers. It is three things:
15
16``hub.ops.jsonl``
17 The append-only log of recorded messages, one JSON object per line in
18 the :class:`~oc_orchestrator.hub.HubEntry` shape. Opened for APPEND
19 only, flushed and fsynced per write, never rewritten. **This file is
20 the truth**, and every carrier moves a copy of something already in
21 it.
22
23``hub.cursors.json``
24 One last-read sequence number per address, beside the log. It is what
25 makes ``inbox`` idempotent and what lets a worker that lost its
26 session resume against an ask it has not yet answered -- design-note
27 invariant 2, an ask outliving its asker.
28
29``<comms>/lanes/hub/``
30 The durable carrier: one JSON file per message, in the SAME envelope
31 the worker-report and session-alert lanes already use, so
32 ``oc_connect relay hub`` is the relay those two lanes already have
33 rather than a second one.
34
35And one reader, :func:`ingest_lanes`, which folds the two EXISTING lanes
36into the hub by READING them. A worker report at a gate becomes a
37``report``; a blocked worker becomes an OPEN ASK, which is how a worker
38waiting on a decision becomes visible as waiting; a session alert becomes
39an ``alert`` to the operator. Neither existing writer is asked to change
40and no lane file is ever modified.
41
42Responsibilities
43----------------
44- :class:`HubPaths` -- the injected filesystem inputs, in the
45 :class:`~oc_orchestrator.work_queue_file.QueuePaths` shape.
46- :func:`read_entries` / :func:`append_entry` -- the log.
47- :class:`FileHubLog` -- the :class:`~oc_orchestrator.hub.HubLog` contract
48 plus the per-recipient cursor.
49- :func:`hub_lane_event` / :func:`write_hub_lane_event` /
50 :class:`FileLaneCarrier` -- the durable carrier.
51- :func:`ingest_lanes` -- the two existing lanes, folded in.
52- :func:`find_comms_root` -- the workspace-anchored bus root.
53
54Diagnostics
55-----------
56Domain: OC_CHAT.MAIN
57Levels:
58 L2 - lifecycle events (a record, a lane write, an ingest pass)
59 L3 - semantic details (a refusal, a mapping)
60 L4 - deep tracing (module load, per-line parse)
61
62Contracts
63---------
64- NO LOCKING, stated exactly as ``work_queue_file`` states it. Two
65 writers can lose an update: each reads the log, decides, and appends,
66 so a concurrent pair can both append against the same prefix. That is
67 accepted while the operator session is the sole writer. A lock file or
68 a daemon is forbidden by operator ruling, not merely out of scope.
69- The log is APPEND-ONLY. No code path here opens the ops file for
70 writing, truncation or replacement.
71- A corrupt log line is :class:`CorruptHubLogError` naming the file and
72 the 1-BASED line number. The reader never silently skips a line, and a
73 corrupt CURSOR file refuses for the same reason: resetting to zero in
74 silence would replay every ask a worker had already answered.
75- **Ingest never writes to a lane.** It opens the two lane directories
76 read-only, and a witness hashes every file before and after.
77- :func:`read_entries` and :func:`append_entry` are
78 ``work_queue_file.read_ops`` and ``append_op`` LIFTED over
79 :class:`~oc_orchestrator.hub.HubEntry`. Those two are typed on ``QueueOp``
80 (``work_queue_file.py:214`` and ``:300``); widening the queue's types
81 to carry hub messages would make one shape serve two contracts, so the
82 bodies are lifted and this line says so.
83- :func:`find_comms_root` is ``plan_status.find_comms_root``
84 LIFTED for the same reason in the other direction: importing it would
85 pull the status tool's pydantic, YAML and observer closure into the
86 hub's, and the extractor is what decides such questions here.
87- The lane constants MIRROR ``option_c_connector_contracts.LANES``
88 exactly as the other two lane writers mirror theirs; a witness
89 compares the two rather than trusting the copy.
90
91Dependencies
92------------
93- oc_diagnostics (structured debug logging via _dbg)
94- oc_orchestrator.hub (the contract)
95- oc_orchestrator.work_queue_file (the ONE ``DEFAULT_QUEUE_DIR``)
96"""
97
98from __future__ import annotations
99
100import json
101import os
102import tempfile
103from dataclasses import dataclass, field
104from datetime import datetime, timezone
105from pathlib import Path
106from typing import Any
107
108from oc_diagnostics import _dbg
109
110from oc_orchestrator.hub import (
111 ADDRESS_ORCHESTRATOR,
112 ADDRESS_OPERATOR_LOCAL,
113 ADDRESS_OPERATOR_REMOTE,
114 KIND_ALERT,
115 KIND_ASK,
116 KIND_REPORT,
117 KIND_RULING,
118 REQUIRES_ORCHESTRATOR,
119 DeliveryReceipt,
120 HubEntry,
121 HubError,
122 HubMessage,
123 Provenance,
124 check_kind,
125)
126from oc_orchestrator.work_queue_file import DEFAULT_QUEUE_DIR
127
128_dbg("OC_CHAT.MAIN", "module", "hub_file loaded", 4)
129
130
131# ── Constants ────────────────────────────────────────────────────────────
132
133
134# Where the two files live, relative to the caller's root; injected
135# through :class:`HubPaths`, never discovered. IMPORTED from the queue
136# rather than restated: the hub log and the queue log are the same
137# directory because both are the orchestration state of one workspace,
138# and before plan 272 that fact was two literals in two subprojects that
139# nothing held together. Now it is one, re-exported through
140# :data:`__all__` so ``hub_file.DEFAULT_QUEUE_DIR`` still resolves and a
141# monkeypatch of it still reaches :class:`HubPaths`'s default factory.
142
143#: The append-only log of recorded messages.
144HUB_OPS_FILENAME = "hub.ops.jsonl"
145
146#: Last-read sequence number per address.
147HUB_CURSORS_FILENAME = "hub.cursors.json"
148
149#: Lane constants. These mirror ``option_c_connector_contracts.LANES`` on
150#: purpose -- see the module Contracts for why the mirror, rather than an
151#: import, is the point.
152HUB_LANE = "hub"
153HUB_LANE_SOURCE = "hub"
154HUB_LANE_SCHEMA_VERSION = "1.0"
155HUB_LANE_DIRECTION = "outbound"
156HUB_LANE_RISK_CLASS = "low"
157
158#: The two lanes ingest reads. Names only -- ingest never imports either
159#: writer, exactly as the writers never import a connector.
160WORKER_REPORT_LANE = "worker-reports"
161SESSION_ALERT_LANE = "session-alerts"
162
163#: Workspace walk constants, mirroring ``plan_status.py``.
164WORKSPACE_MARKER = ".option_c_workspace.json"
165PROJECT_MARKER = ".option_c"
166COMMS_ANCHOR = "oc_chat"
167COMMS_SUBPATH = (".option_c", "comms")
168
169
171 """A stored line, a cursor file, or a workspace walk did not resolve."""
172
173
174# ── Paths ────────────────────────────────────────────────────────────────
175
176
177@dataclass(frozen=True)
179 """All caller-controlled filesystem inputs to one hub log.
180
181 The :class:`~oc_orchestrator.work_queue_file.QueuePaths` shape: a frozen
182 dataclass whose defaults are today's values, constructed at the
183 boundary and passed whole. ``queue_dir`` is late-bound through a
184 default factory so a caller that monkeypatches
185 :data:`DEFAULT_QUEUE_DIR` is still honoured.
186 """
187
188 queue_dir: Path = field(default_factory=lambda: DEFAULT_QUEUE_DIR)
189 #: Overrides the derived ``<queue_dir>/hub.ops.jsonl``.
190 ops_path_override: Path | None = None
191 #: Overrides the derived ``<queue_dir>/hub.cursors.json``.
192 cursors_path_override: Path | None = None
193
194 @property
195 def ops_path(self) -> Path:
196 """The append-only log: the override, else derived."""
197 if self.ops_path_override is not None:
198 return self.ops_path_override
199 return self.queue_dir / HUB_OPS_FILENAME
200
201 @property
202 def cursors_path(self) -> Path:
203 """The per-recipient cursors: the override, else derived."""
204 if self.cursors_path_override is not None:
205 return self.cursors_path_override
206 return self.queue_dir / HUB_CURSORS_FILENAME
207
208
209# ── The log ──────────────────────────────────────────────────────────────
210
211
212def read_entries(ops_path: Path) -> tuple[HubEntry, ...]:
213 """Read every recorded entry in *ops_path*, oldest first.
214
215 A missing file is an empty log, never a ``FileNotFoundError`` -- the
216 same tolerance ``work_queue_file.read_ops`` has, whose body this is,
217 lifted over :class:`~oc_orchestrator.hub.HubEntry`.
218
219 Raises:
220 CorruptHubLogError: SYSTEM_ERROR (L1 for the caller) -- a
221 non-blank line is not a JSON object carrying a sequence
222 number and a message with a known kind. The message names
223 the file and the 1-based line number; the reader never
224 silently skips it.
225 """
226 func = "read_entries"
227 path = Path(ops_path)
228 try:
229 text = path.read_text(encoding="utf-8")
230 except FileNotFoundError:
231 _dbg("OC_CHAT.MAIN", func, f"{path}: absent; empty log", 2)
232 return ()
233 entries: list[HubEntry] = []
234 for number, line in enumerate(text.splitlines(), start=1):
235 if not line.strip():
236 continue
237 entries.append(_decode_entry(line, path, number))
238 _dbg("OC_CHAT.MAIN", func, f"{path}: {len(entries)} entr(ies)", 2)
239 return tuple(entries)
240
241
242def _decode_entry(line: str, path: Path, number: int) -> HubEntry:
243 """One log line to a :class:`HubEntry`, or a refusal that names it."""
244 func = "_decode_entry"
245 try:
246 raw = json.loads(line)
247 except json.JSONDecodeError as exc:
248 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: line {number}: {exc}", 1, override=True)
249 raise CorruptHubLogError(
250 f"{path.name}: line {number} is not JSON: {exc}"
251 ) from exc
252 if not isinstance(raw, dict):
253 raise CorruptHubLogError(
254 f"{path.name}: line {number} is not a JSON object: {type(raw).__name__}"
255 )
256 missing = {"seq", "message"} - set(raw)
257 if missing:
258 raise CorruptHubLogError(
259 f"{path.name}: line {number} is missing {', '.join(sorted(missing))}"
260 )
261 payload = raw["message"]
262 if not isinstance(payload, dict):
263 raise CorruptHubLogError(
264 f"{path.name}: line {number} has a non-object message"
265 )
266 absent = {"message_id", "at", "kind", "sender", "recipient"} - set(payload)
267 if absent:
268 raise CorruptHubLogError(
269 f"{path.name}: line {number} message is missing "
270 f"{', '.join(sorted(absent))}"
271 )
272 try:
273 check_kind(str(payload["kind"]))
274 except HubError as exc:
275 raise CorruptHubLogError(f"{path.name}: line {number}: {exc}") from exc
276 try:
277 message = HubMessage.from_mapping(payload)
278 except (HubError, KeyError, TypeError, ValueError) as exc:
279 raise CorruptHubLogError(
280 f"{path.name}: line {number} does not decode: {exc}"
281 ) from exc
282 return HubEntry(seq=int(raw["seq"]), message=message)
283
284
285def _encode_entry(entry: HubEntry) -> str:
286 """One :class:`HubEntry` to its log line, without the newline."""
287 return json.dumps(
288 {"seq": entry.seq, "message": entry.message.as_dict()},
289 ensure_ascii=False,
290 sort_keys=True,
291 )
292
293
294def append_entry(ops_path: Path, entry: HubEntry) -> None:
295 """Append *entry* to the log, flushed and fsynced.
296
297 The ONLY write path for the log, and it opens the file in append
298 mode. ``work_queue_file.append_op``'s body, lifted: nothing in this
299 module truncates or replaces the ops file.
300 """
301 func = "append_entry"
302 path = Path(ops_path)
303 path.parent.mkdir(parents=True, exist_ok=True)
304 with path.open("a", encoding="utf-8", newline="\n") as handle:
305 handle.write(_encode_entry(entry) + "\n")
306 handle.flush()
307 os.fsync(handle.fileno())
308 _dbg("OC_CHAT.MAIN", func,
309 f"{path.name} += {entry.message.kind} (seq {entry.seq})", 2)
310
311
313 """The default log: an append-only file plus per-recipient cursors."""
314
315 def __init__(self, paths: HubPaths | None = None) -> None:
316 func = "FileHubLog.__init__"
317 self.paths = paths if paths is not None else HubPaths()
318 _dbg("OC_CHAT.MAIN", func, f"over {self.paths.queue_dir}", 2)
319
320 # -- the HubLog contract ----------------------------------------------
321
322 def record(self, message: HubMessage) -> HubEntry:
323 """Append *message* and return the entry it became."""
324 entry = HubEntry(seq=len(read_entries(self.paths.ops_path)) + 1, message=message)
325 append_entry(self.paths.ops_path, entry)
326 return entry
327
329 self,
330 *,
331 kind: str | None = None,
332 recipient: str | None = None,
333 subject: str | None = None,
334 since_seq: int | None = None,
335 ) -> tuple[HubEntry, ...]:
336 """Recorded entries, oldest first, narrowed by whatever is given."""
337 return tuple(
338 entry
339 for entry in read_entries(self.paths.ops_path)
340 if (kind is None or entry.message.kind == kind)
341 and (recipient is None or entry.message.recipient == recipient)
342 and (subject is None or entry.message.subject == subject)
343 and (since_seq is None or entry.seq > since_seq)
344 )
345
346 def open_asks(self, *, recipient: str | None = None) -> tuple[HubEntry, ...]:
347 """Asks with no ruling in the log -- the questions still waiting."""
348 entries = read_entries(self.paths.ops_path)
349 ruled = {
350 entry.message.in_reply_to
351 for entry in entries
352 if entry.message.kind == KIND_RULING
353 }
354 return tuple(
355 entry
356 for entry in entries
357 if entry.message.kind == KIND_ASK
358 and entry.message.message_id not in ruled
359 and (recipient is None or entry.message.recipient == recipient)
360 )
361
362 def ruling_for(self, ask_id: str) -> HubEntry | None:
363 """The ruling that answered *ask_id*, or ``None``."""
364 for entry in read_entries(self.paths.ops_path):
365 if entry.message.kind == KIND_RULING and entry.message.in_reply_to == ask_id:
366 return entry
367 return None
368
369 # -- the cursor -------------------------------------------------------
370
371 def cursor_for(self, address: str) -> int:
372 """The last sequence number *address* has read; ``0`` when never.
373
374 Raises:
375 CorruptHubLogError: SYSTEM_ERROR -- the cursor file does not
376 parse. Resetting to zero in silence would replay every
377 ask the reader had already answered, which is worse than
378 stopping.
379 """
380 return int(self._cursors().get(address, 0))
381
382 def unread_for(self, address: str) -> tuple[HubEntry, ...]:
383 """Entries addressed to *address* that its cursor has not passed."""
384 cursor = self.cursor_for(address)
385 return self.entries(recipient=address, since_seq=cursor)
386
387 def advance_cursor(self, address: str, seq: int) -> int:
388 """Move *address*'s cursor forward to *seq*. Never backwards.
389
390 Monotonic on purpose: two readers for one address (a resumed
391 session beside a stale one) must not be able to un-read what the
392 other consumed.
393 """
394 func = "FileHubLog.advance_cursor"
395 cursors = self._cursors()
396 current = int(cursors.get(address, 0))
397 if seq <= current:
398 _dbg("OC_CHAT.MAIN", func, f"{address}: {seq} <= {current}; unchanged", 3)
399 return current
400 cursors[address] = int(seq)
401 self._write_cursors(cursors)
402 _dbg("OC_CHAT.MAIN", func, f"{address}: {current} -> {seq}", 2)
403 return int(seq)
404
405 def _cursors(self) -> dict[str, int]:
406 func = "FileHubLog._cursors"
407 path = self.paths.cursors_path
408 try:
409 raw = json.loads(path.read_text(encoding="utf-8"))
410 except FileNotFoundError:
411 return {}
412 except json.JSONDecodeError as exc:
413 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: {path.name}: {exc}", 1, override=True)
414 raise CorruptHubLogError(
415 f"{path.name} does not parse ({exc}); refusing to reset every "
416 "cursor to zero, which would replay answered asks"
417 ) from exc
418 if not isinstance(raw, dict):
419 raise CorruptHubLogError(
420 f"{path.name} is not a JSON object: {type(raw).__name__}"
421 )
422 return {str(key): int(value) for key, value in raw.items()}
423
424 def _write_cursors(self, cursors: dict[str, int]) -> None:
425 """Rewrite the cursor file atomically. The LOG is never rewritten."""
426 path = self.paths.cursors_path
427 path.parent.mkdir(parents=True, exist_ok=True)
428 payload = json.dumps(cursors, indent=2, sort_keys=True) + "\n"
429 with tempfile.NamedTemporaryFile(
430 mode="w", encoding="utf-8", newline="\n", dir=path.parent,
431 prefix=".hub_cursors_", suffix=".tmp", delete=False,
432 ) as tmp:
433 tmp.write(payload)
434 tmp_path = Path(tmp.name)
435 tmp_path.replace(path)
436
437
438# ── The comms-bus walk ───────────────────────────────────────────────────
439
440
441def find_comms_root(start: Path) -> Path:
442 """Return the workspace-anchored comms-bus root above *start*.
443
444 ``plan_status.find_comms_root``'s body, LIFTED rather than
445 imported -- see the module Contracts. The rules are unchanged: the
446 workspace marker wins over a nearer project marker because connector
447 state is workspace-wide, and the bus sits beside the dev engine's
448 orchestration state when an ``oc_chat`` subproject is present.
449 Guessing a root would post a message somewhere nobody is listening,
450 so a failed walk raises.
451
452 Raises:
453 CorruptHubLogError: VALIDATION_ERROR (L3) -- no marker at or
454 above *start*; the message names both markers it looked for.
455 """
456 func = "find_comms_root"
457 origin = Path(start).expanduser().resolve()
458 project_fallback: Path | None = None
459 anchor: Path | None = None
460 for parent in (origin, *origin.parents):
461 if (parent / WORKSPACE_MARKER).is_file():
462 anchor = parent
463 break
464 if project_fallback is None and (parent / PROJECT_MARKER).is_dir():
465 project_fallback = parent
466 if anchor is None:
467 anchor = project_fallback
468 if anchor is None:
469 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: no marker above {origin}", 3)
470 raise CorruptHubLogError(
471 f"no Option C workspace marker ({WORKSPACE_MARKER}) or project "
472 f"marker ({PROJECT_MARKER}/) was found at or above {origin}"
473 )
474 if (anchor / COMMS_ANCHOR).is_dir():
475 return anchor.joinpath(COMMS_ANCHOR, *COMMS_SUBPATH)
476 return anchor.joinpath(*COMMS_SUBPATH)
477
478
479# ── The lane carrier ─────────────────────────────────────────────────────
480
481
482def _utc_timestamp(now: datetime) -> str:
483 if now.tzinfo is None:
484 now = now.replace(tzinfo=timezone.utc)
485 return now.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
486
487
488def utc_stamp(now: datetime) -> str:
489 """Return a filename-safe UTC stamp; colons are illegal on Windows."""
490 if now.tzinfo is None:
491 now = now.replace(tzinfo=timezone.utc)
492 return now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
493
494
495def hub_lane_event(message: HubMessage, *, now: datetime) -> dict[str, object]:
496 """Build one ``lanes/hub/`` event: the shared envelope plus the message.
497
498 The envelope is the one the worker-report and session-alert lanes
499 already carry -- ``schema_version``, ``event_id``, ``lane``,
500 ``direction``, ``risk_class``, ``created_at_utc``, ``summary``,
501 ``relayed_at_utc``, ``relay_chunk_count`` -- so the relay that reads
502 those two reads this one with the same code. ``event_id`` IS the
503 message id, which is what lets a reply correlate back to the message
504 rather than merely to a send.
505 """
506 event: dict[str, object] = {
507 "schema_version": HUB_LANE_SCHEMA_VERSION,
508 "event_id": message.message_id,
509 "lane": HUB_LANE,
510 "direction": HUB_LANE_DIRECTION,
511 "risk_class": HUB_LANE_RISK_CLASS,
512 "created_at_utc": _utc_timestamp(now),
513 "summary": message.summary,
514 "relayed_at_utc": None,
515 "relay_chunk_count": None,
516 }
517 event.update(message.as_dict())
518 return event
519
520
521def write_hub_lane_event(comms_root: Path, event: dict[str, object], *, now: datetime) -> Path:
522 """Write one event into ``lanes/hub/`` and return its path.
523
524 The atomic temp-then-replace ``core/session_alerts.write_session_alert``
525 uses. The lane directory is created when absent: a party must be able
526 to hand off from a workspace whose bus has never been initialised.
527 Nothing outside the lane is created, read, or written.
528 """
529 func = "write_hub_lane_event"
530 lane_dir = Path(comms_root) / "lanes" / HUB_LANE
531 lane_dir.mkdir(parents=True, exist_ok=True)
532 name = f"{utc_stamp(now)}_{HUB_LANE_SOURCE}_{event['event_id']}.json"
533 path = lane_dir / name
534 if path.exists():
535 raise CorruptHubLogError(
536 f"refusing to overwrite an existing hub lane event at {path}"
537 )
538 payload = json.dumps(event, indent=2, sort_keys=True) + "\n"
539 with tempfile.NamedTemporaryFile(
540 mode="w", encoding="utf-8", newline="\n", dir=lane_dir,
541 prefix=".hub_", suffix=".tmp", delete=False,
542 ) as tmp:
543 tmp.write(payload)
544 tmp_path = Path(tmp.name)
545 tmp_path.replace(path)
546 _dbg("OC_CHAT.MAIN", func, f"wrote hub lane event {path.name}", 2)
547 return path
548
549
551 """The durable carrier: one file per message under ``lanes/hub/``."""
552
553 #: What a receipt and a trace call this carrier.
554 name = "file-lane"
555
556 def __init__(self, *, comms_root: Path) -> None:
557 func = "FileLaneCarrier.__init__"
558 self.comms_root = Path(comms_root)
559 _dbg("OC_CHAT.MAIN", func, f"over {self.comms_root}", 2)
560
561 def present(self, address: str) -> bool:
562 """True for every party but the REMOTE operator.
563
564 Durable hand-off is the floor every local party has: the
565 orchestrator, each ``agent-NNN`` and the operator at the control
566 centre all read this directory (or the log beside it) with no
567 credential and no process running. ``operator@remote`` is the one
568 address a file cannot reach, by definition -- that is the
569 connector's carrier, and saying "present" here would silently
570 swallow a message the operator is waiting for on their phone.
571 """
572 return address != ADDRESS_OPERATOR_REMOTE
573
574 def deliver(self, message: HubMessage) -> DeliveryReceipt:
575 """Write *message* into the lane and report where it landed."""
576 now = datetime.now(timezone.utc).replace(microsecond=0)
577 path = write_hub_lane_event(self.comms_root, hub_lane_event(message, now=now), now=now)
578 return DeliveryReceipt(
579 carrier=self.name, message_id=message.message_id, detail=str(path)
580 )
581
582
583# ── Ingest: the two existing lanes, folded in ────────────────────────────
584
585
586def _lane_events(lane_dir: Path) -> list[dict[str, Any]]:
587 """Every JSON event in *lane_dir*, in arrival (filename) order.
588
589 Read-only by construction: this opens files and nothing else. A file
590 that does not parse is SKIPPED rather than refused, because an
591 unreadable event on somebody else's lane is not the hub's to fail
592 over -- the lane's own reader is the authority on its contents.
593 """
594 func = "_lane_events"
595 if not lane_dir.is_dir():
596 return []
597 events: list[dict[str, Any]] = []
598 for path in sorted(lane_dir.glob("*.json")):
599 if not path.is_file() or path.name.startswith("."):
600 continue
601 try:
602 raw = json.loads(path.read_text(encoding="utf-8"))
603 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
604 # EXTERNAL_ERROR (L2): another writer's file, another writer's
605 # problem. Ingest reports it and carries on.
606 _dbg("OC_CHAT.MAIN", func,
607 f"EXTERNAL_ERROR: {path.name} did not parse ({type(exc).__name__}); skipped", 2)
608 continue
609 if isinstance(raw, dict) and raw.get("event_id"):
610 events.append(raw)
611 return events
612
613
614def _message_from_worker_report(event: dict[str, Any]) -> HubMessage | None:
615 """A worker-report event as a hub message, or ``None`` if unmappable."""
616 func = "_message_from_worker_report"
617 agent = str(event.get("agent") or "")
618 status = str(event.get("status") or "")
619 summary = str(event.get("summary") or "")
620 subject = str(event.get("plan_id") or "") or None
621 provenance = Provenance(
622 actor=agent, actor_type="worker_agent", channel="lane",
623 correlation={"lane": WORKER_REPORT_LANE, "event_id": str(event["event_id"])},
624 )
625 if status == "awaiting_gate":
626 kind, requires = KIND_REPORT, None
627 elif status == "blocked":
628 # A blocked worker is WAITING ON A DECISION, not finished, so it
629 # becomes an open ask rather than a report. That is what lets
630 # ``fleet`` say "waiting" before anyone calls the worker stalled.
631 kind, requires = KIND_ASK, REQUIRES_ORCHESTRATOR
632 else:
633 _dbg("OC_CHAT.MAIN", func, f"unmapped worker-report status {status!r}", 3)
634 return None
635 return HubMessage(
636 message_id=str(event["event_id"]),
637 at=str(event.get("created_at_utc") or ""),
638 kind=kind,
639 sender=agent,
640 recipient=ADDRESS_ORCHESTRATOR,
641 subject=subject,
642 body=summary,
643 requires=requires,
644 provenance=provenance,
645 )
646
647
648def _message_from_session_alert(event: dict[str, Any]) -> HubMessage:
649 """A session-alert event as an alert to the operator at the control centre."""
650 return HubMessage(
651 message_id=str(event["event_id"]),
652 at=str(event.get("created_at_utc") or ""),
653 kind=KIND_ALERT,
654 sender=ADDRESS_ORCHESTRATOR,
655 recipient=ADDRESS_OPERATOR_LOCAL,
656 subject=None,
657 body=str(event.get("summary") or ""),
658 provenance=Provenance(
659 actor=ADDRESS_ORCHESTRATOR, actor_type="orchestrator_agent", channel="lane",
660 correlation={"lane": SESSION_ALERT_LANE, "event_id": str(event["event_id"])},
661 ),
662 )
663
664
665def ingest_lanes(comms_root: Path, log: Any) -> tuple[HubEntry, ...]:
666 """Record every lane event *log* does not already hold. Returns the new ones.
667
668 The two existing pipes join the hub by being READ: no lane file is
669 opened for writing, neither existing writer is asked to change, and
670 running this twice records nothing the second time. Idempotency is by
671 ``event_id``, which is also the recorded message's id -- one event,
672 one message, for the life of the log.
673
674 An event's OWN ``created_at_utc`` becomes the message's ``at``, so
675 ingesting history does not rewrite it to the moment it was noticed.
676 """
677 func = "ingest_lanes"
678 root = Path(comms_root)
679 seen = {
680 str(entry.message.provenance.correlation.get("event_id"))
681 for entry in log.entries()
682 if entry.message.provenance.channel == "lane"
683 }
684 recorded: list[HubEntry] = []
685
686 for event in _lane_events(root / "lanes" / WORKER_REPORT_LANE):
687 if str(event["event_id"]) in seen:
688 continue
689 message = _message_from_worker_report(event)
690 if message is None:
691 continue
692 recorded.append(log.record(message))
693
694 for event in _lane_events(root / "lanes" / SESSION_ALERT_LANE):
695 if str(event["event_id"]) in seen:
696 continue
697 recorded.append(log.record(_message_from_session_alert(event)))
698
699 if recorded:
700 _dbg("OC_CHAT.MAIN", func, f"ingested {len(recorded)} lane event(s)", 2)
701 return tuple(recorded)
702
703
704__all__ = [
705 "DEFAULT_QUEUE_DIR",
706 "HUB_CURSORS_FILENAME",
707 "HUB_LANE",
708 "HUB_LANE_DIRECTION",
709 "HUB_LANE_RISK_CLASS",
710 "HUB_LANE_SCHEMA_VERSION",
711 "HUB_LANE_SOURCE",
712 "HUB_OPS_FILENAME",
713 "SESSION_ALERT_LANE",
714 "WORKER_REPORT_LANE",
715 "CorruptHubLogError",
716 "FileHubLog",
717 "FileLaneCarrier",
718 "HubPaths",
719 "append_entry",
720 "find_comms_root",
721 "hub_lane_event",
722 "ingest_lanes",
723 "read_entries",
724 "utc_stamp",
725 "write_hub_lane_event",
726]
Definition hub.py:469
dict[str, int] _cursors(self)
Definition hub_file.py:405
tuple[HubEntry,...] unread_for(self, str address)
Definition hub_file.py:382
HubEntry record(self, HubMessage message)
Definition hub_file.py:322
int advance_cursor(self, str address, int seq)
Definition hub_file.py:387
None __init__(self, HubPaths|None paths=None)
Definition hub_file.py:315
int cursor_for(self, str address)
Definition hub_file.py:371
None _write_cursors(self, dict[str, int] cursors)
Definition hub_file.py:424
tuple[HubEntry,...] open_asks(self, *, str|None recipient=None)
Definition hub_file.py:346
HubEntry|None ruling_for(self, str ask_id)
Definition hub_file.py:362
tuple[HubEntry,...] entries(self, *, str|None kind=None, str|None recipient=None, str|None subject=None, int|None since_seq=None)
Definition hub_file.py:335
None __init__(self, *, Path comms_root)
Definition hub_file.py:556
DeliveryReceipt deliver(self, HubMessage message)
Definition hub_file.py:574
bool present(self, str address)
Definition hub_file.py:561
HubMessage _message_from_session_alert(dict[str, Any] event)
Definition hub_file.py:648
Path write_hub_lane_event(Path comms_root, dict[str, object] event, *, datetime now)
Definition hub_file.py:521
HubMessage|None _message_from_worker_report(dict[str, Any] event)
Definition hub_file.py:614
None append_entry(Path ops_path, HubEntry entry)
Definition hub_file.py:294
list[dict[str, Any]] _lane_events(Path lane_dir)
Definition hub_file.py:586
HubEntry _decode_entry(str line, Path path, int number)
Definition hub_file.py:242
str _utc_timestamp(datetime now)
Definition hub_file.py:482
str _encode_entry(HubEntry entry)
Definition hub_file.py:285
tuple[HubEntry,...] read_entries(Path ops_path)
Definition hub_file.py:212
str utc_stamp(datetime now)
Definition hub_file.py:488
tuple[HubEntry,...] ingest_lanes(Path comms_root, Any log)
Definition hub_file.py:665
Path find_comms_root(Path start)
Definition hub_file.py:441
dict[str, object] hub_lane_event(HubMessage message, *, datetime now)
Definition hub_file.py:495