7The hub's default backend: an append-only log, a lane carrier, and ingest.
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:
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
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.
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.
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.
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.
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)
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.
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``)
98from __future__
import annotations
103from dataclasses
import dataclass, field
104from datetime
import datetime, timezone
105from pathlib
import Path
106from typing
import Any
108from oc_diagnostics
import _dbg
111 ADDRESS_ORCHESTRATOR,
112 ADDRESS_OPERATOR_LOCAL,
113 ADDRESS_OPERATOR_REMOTE,
118 REQUIRES_ORCHESTRATOR,
128_dbg(
"OC_CHAT.MAIN",
"module",
"hub_file loaded", 4)
144HUB_OPS_FILENAME =
"hub.ops.jsonl"
147HUB_CURSORS_FILENAME =
"hub.cursors.json"
153HUB_LANE_SOURCE =
"hub"
154HUB_LANE_SCHEMA_VERSION =
"1.0"
155HUB_LANE_DIRECTION =
"outbound"
156HUB_LANE_RISK_CLASS =
"low"
160WORKER_REPORT_LANE =
"worker-reports"
161SESSION_ALERT_LANE =
"session-alerts"
164WORKSPACE_MARKER =
".option_c_workspace.json"
165PROJECT_MARKER =
".option_c"
166COMMS_ANCHOR =
"oc_chat"
167COMMS_SUBPATH = (
".option_c",
"comms")
171 """A stored line, a cursor file, or a workspace walk did not resolve."""
177@dataclass(frozen=
True)
179 """All caller-controlled filesystem inputs to one hub log.
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.
188 queue_dir: Path = field(default_factory=
lambda: DEFAULT_QUEUE_DIR)
190 ops_path_override: Path |
None =
None
192 cursors_path_override: Path |
None =
None
196 """The append-only log: the override, else derived."""
203 """The per-recipient cursors: the override, else derived."""
206 return self.
queue_dir / HUB_CURSORS_FILENAME
213 """Read every recorded entry in *ops_path*, oldest first.
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`.
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
226 func =
"read_entries"
227 path = Path(ops_path)
229 text = path.read_text(encoding=
"utf-8")
230 except FileNotFoundError:
231 _dbg(
"OC_CHAT.MAIN", func, f
"{path}: absent; empty log", 2)
233 entries: list[HubEntry] = []
234 for number, line
in enumerate(text.splitlines(), start=1):
238 _dbg(
"OC_CHAT.MAIN", func, f
"{path}: {len(entries)} entr(ies)", 2)
239 return tuple(entries)
243 """One log line to a :class:`HubEntry`, or a refusal that names it."""
244 func =
"_decode_entry"
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)
250 f
"{path.name}: line {number} is not JSON: {exc}"
252 if not isinstance(raw, dict):
254 f
"{path.name}: line {number} is not a JSON object: {type(raw).__name__}"
256 missing = {
"seq",
"message"} - set(raw)
259 f
"{path.name}: line {number} is missing {', '.join(sorted(missing))}"
261 payload = raw[
"message"]
262 if not isinstance(payload, dict):
264 f
"{path.name}: line {number} has a non-object message"
266 absent = {
"message_id",
"at",
"kind",
"sender",
"recipient"} - set(payload)
269 f
"{path.name}: line {number} message is missing "
270 f
"{', '.join(sorted(absent))}"
273 check_kind(str(payload[
"kind"]))
274 except HubError
as exc:
277 message = HubMessage.from_mapping(payload)
278 except (HubError, KeyError, TypeError, ValueError)
as exc:
280 f
"{path.name}: line {number} does not decode: {exc}"
282 return HubEntry(seq=int(raw[
"seq"]), message=message)
286 """One :class:`HubEntry` to its log line, without the newline."""
288 {
"seq": entry.seq,
"message": entry.message.as_dict()},
295 """Append *entry* to the log, flushed and fsynced.
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.
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:
307 os.fsync(handle.fileno())
308 _dbg(
"OC_CHAT.MAIN", func,
309 f
"{path.name} += {entry.message.kind} (seq {entry.seq})", 2)
313 """The default log: an append-only file plus per-recipient cursors."""
315 def __init__(self, paths: HubPaths |
None =
None) ->
None:
316 func =
"FileHubLog.__init__"
318 _dbg(
"OC_CHAT.MAIN", func, f
"over {self.paths.queue_dir}", 2)
322 def record(self, message: HubMessage) -> HubEntry:
323 """Append *message* and return the entry it became."""
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."""
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)
346 def open_asks(self, *, recipient: str |
None =
None) -> tuple[HubEntry, ...]:
347 """Asks with no ruling in the log -- the questions still waiting."""
350 entry.message.in_reply_to
352 if entry.message.kind == KIND_RULING
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)
363 """The ruling that answered *ask_id*, or ``None``."""
365 if entry.message.kind == KIND_RULING
and entry.message.in_reply_to == ask_id:
372 """The last sequence number *address* has read; ``0`` when never.
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
380 return int(self.
_cursors().get(address, 0))
383 """Entries addressed to *address* that its cursor has not passed."""
385 return self.
entries(recipient=address, since_seq=cursor)
388 """Move *address*'s cursor forward to *seq*. Never backwards.
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
394 func =
"FileHubLog.advance_cursor"
396 current = int(cursors.get(address, 0))
398 _dbg(
"OC_CHAT.MAIN", func, f
"{address}: {seq} <= {current}; unchanged", 3)
400 cursors[address] = int(seq)
402 _dbg(
"OC_CHAT.MAIN", func, f
"{address}: {current} -> {seq}", 2)
406 func =
"FileHubLog._cursors"
407 path = self.
paths.cursors_path
409 raw = json.loads(path.read_text(encoding=
"utf-8"))
410 except FileNotFoundError:
412 except json.JSONDecodeError
as exc:
413 _dbg(
"OC_CHAT.MAIN", func, f
"SYSTEM_ERROR: {path.name}: {exc}", 1, override=
True)
415 f
"{path.name} does not parse ({exc}); refusing to reset every "
416 "cursor to zero, which would replay answered asks"
418 if not isinstance(raw, dict):
420 f
"{path.name} is not a JSON object: {type(raw).__name__}"
422 return {str(key): int(value)
for key, value
in raw.items()}
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,
434 tmp_path = Path(tmp.name)
435 tmp_path.replace(path)
442 """Return the workspace-anchored comms-bus root above *start*.
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.
453 CorruptHubLogError: VALIDATION_ERROR (L3) -- no marker at or
454 above *start*; the message names both markers it looked for.
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():
464 if project_fallback
is None and (parent / PROJECT_MARKER).is_dir():
465 project_fallback = parent
467 anchor = project_fallback
469 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: no marker above {origin}", 3)
471 f
"no Option C workspace marker ({WORKSPACE_MARKER}) or project "
472 f
"marker ({PROJECT_MARKER}/) was found at or above {origin}"
474 if (anchor / COMMS_ANCHOR).is_dir():
475 return anchor.joinpath(COMMS_ANCHOR, *COMMS_SUBPATH)
476 return anchor.joinpath(*COMMS_SUBPATH)
483 if now.tzinfo
is None:
484 now = now.replace(tzinfo=timezone.utc)
485 return now.astimezone(timezone.utc).isoformat().replace(
"+00:00",
"Z")
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")
496 """Build one ``lanes/hub/`` event: the shared envelope plus the message.
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.
506 event: dict[str, object] = {
507 "schema_version": HUB_LANE_SCHEMA_VERSION,
508 "event_id": message.message_id,
510 "direction": HUB_LANE_DIRECTION,
511 "risk_class": HUB_LANE_RISK_CLASS,
513 "summary": message.summary,
514 "relayed_at_utc":
None,
515 "relay_chunk_count":
None,
517 event.update(message.as_dict())
522 """Write one event into ``lanes/hub/`` and return its path.
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.
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
536 f
"refusing to overwrite an existing hub lane event at {path}"
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,
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)
551 """The durable carrier: one file per message under ``lanes/hub/``."""
557 func =
"FileLaneCarrier.__init__"
559 _dbg(
"OC_CHAT.MAIN", func, f
"over {self.comms_root}", 2)
562 """True for every party but the REMOTE operator.
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.
572 return address != ADDRESS_OPERATOR_REMOTE
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)
579 carrier=self.
name, message_id=message.message_id, detail=str(path)
587 """Every JSON event in *lane_dir*, in arrival (filename) order.
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.
594 func =
"_lane_events"
595 if not lane_dir.is_dir():
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(
"."):
602 raw = json.loads(path.read_text(encoding=
"utf-8"))
603 except (OSError, UnicodeDecodeError, json.JSONDecodeError)
as exc:
606 _dbg(
"OC_CHAT.MAIN", func,
607 f
"EXTERNAL_ERROR: {path.name} did not parse ({type(exc).__name__}); skipped", 2)
609 if isinstance(raw, dict)
and raw.get(
"event_id"):
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
622 actor=agent, actor_type=
"worker_agent", channel=
"lane",
623 correlation={
"lane": WORKER_REPORT_LANE,
"event_id": str(event[
"event_id"])},
625 if status ==
"awaiting_gate":
626 kind, requires = KIND_REPORT,
None
627 elif status ==
"blocked":
631 kind, requires = KIND_ASK, REQUIRES_ORCHESTRATOR
633 _dbg(
"OC_CHAT.MAIN", func, f
"unmapped worker-report status {status!r}", 3)
636 message_id=str(event[
"event_id"]),
637 at=str(event.get(
"created_at_utc")
or ""),
640 recipient=ADDRESS_ORCHESTRATOR,
644 provenance=provenance,
649 """A session-alert event as an alert to the operator at the control centre."""
651 message_id=str(event[
"event_id"]),
652 at=str(event.get(
"created_at_utc")
or ""),
654 sender=ADDRESS_ORCHESTRATOR,
655 recipient=ADDRESS_OPERATOR_LOCAL,
657 body=str(event.get(
"summary")
or ""),
659 actor=ADDRESS_ORCHESTRATOR, actor_type=
"orchestrator_agent", channel=
"lane",
660 correlation={
"lane": SESSION_ALERT_LANE,
"event_id": str(event[
"event_id"])},
666 """Record every lane event *log* does not already hold. Returns the new ones.
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.
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.
677 func =
"ingest_lanes"
678 root = Path(comms_root)
680 str(entry.message.provenance.correlation.get(
"event_id"))
681 for entry
in log.entries()
682 if entry.message.provenance.channel ==
"lane"
684 recorded: list[HubEntry] = []
686 for event
in _lane_events(root /
"lanes" / WORKER_REPORT_LANE):
687 if str(event[
"event_id"])
in seen:
692 recorded.append(log.record(message))
694 for event
in _lane_events(root /
"lanes" / SESSION_ALERT_LANE):
695 if str(event[
"event_id"])
in seen:
700 _dbg(
"OC_CHAT.MAIN", func, f
"ingested {len(recorded)} lane event(s)", 2)
701 return tuple(recorded)
706 "HUB_CURSORS_FILENAME",
708 "HUB_LANE_DIRECTION",
709 "HUB_LANE_RISK_CLASS",
710 "HUB_LANE_SCHEMA_VERSION",
713 "SESSION_ALERT_LANE",
714 "WORKER_REPORT_LANE",
715 "CorruptHubLogError",
725 "write_hub_lane_event",
dict[str, int] _cursors(self)
tuple[HubEntry,...] unread_for(self, str address)
HubEntry record(self, HubMessage message)
int advance_cursor(self, str address, int seq)
None __init__(self, HubPaths|None paths=None)
int cursor_for(self, str address)
None _write_cursors(self, dict[str, int] cursors)
tuple[HubEntry,...] open_asks(self, *, str|None recipient=None)
HubEntry|None ruling_for(self, str ask_id)
tuple[HubEntry,...] entries(self, *, str|None kind=None, str|None recipient=None, str|None subject=None, int|None since_seq=None)
None __init__(self, *, Path comms_root)
DeliveryReceipt deliver(self, HubMessage message)
bool present(self, str address)
Path cursors_path_override
HubMessage _message_from_session_alert(dict[str, Any] event)
Path write_hub_lane_event(Path comms_root, dict[str, object] event, *, datetime now)
HubMessage|None _message_from_worker_report(dict[str, Any] event)
None append_entry(Path ops_path, HubEntry entry)
list[dict[str, Any]] _lane_events(Path lane_dir)
HubEntry _decode_entry(str line, Path path, int number)
str _utc_timestamp(datetime now)
str _encode_entry(HubEntry entry)
tuple[HubEntry,...] read_entries(Path ops_path)
str utc_stamp(datetime now)
tuple[HubEntry,...] ingest_lanes(Path comms_root, Any log)
Path find_comms_root(Path start)
dict[str, object] hub_lane_event(HubMessage message, *, datetime now)