7The hub's operator surface: send, ask, rule, inbox, asks, fleet, history.
11Plan 259 P8 and P7. This is where a human, a worker and the orchestrator
12all reach the hub. Two of its properties are the point; the rest is
15**Who the process is comes from the BACKEND, never from a flag.** There
16is no ``--as``. In file mode the actor is the operator at the control
17centre and the correlation is the process user and working directory; in
18registry mode it is the active operator slot. A flag that set provenance
19would be exactly the mechanism by which an agent relays a yes, and the
20whole point of the hub's provenance rule is that such a yes is
21distinguishable. So the flag does not exist, and asking for one is an
24**``fleet`` says "waiting" before anyone says "stalled".** One line per
25non-closed instance: what it is, what plan it carries, what state the
26registry has it in, and either ``waiting on ask <id> since <time>`` or
27``no message since <time>``. A worker blocked on a human answer is
28waiting on a decision, not going round in circles, and automated
29dispatch that could not tell the difference would halt every worker that
30ever asked a question. That is the fleet-level half of design-note
31invariant 3; the in-session stall detector's half belongs to the Tier-2
32follow-up and is not touched here.
34**This command dispatches nothing and relays nothing.** ``relay-check``
35prints the ``oc_connect relay hub`` line an operator would run, and that
36is the whole of it -- this module does not import ``subprocess``, so it
37could not do more if it tried. The same autonomy boundary
38``work_queue_manage next`` observes.
40**Two consumer seams, and nothing else outward** (plan 272 P2). This
41package holds no registry and declares no connector, so the two places
42the pre-272 module reached for one are now seams:
43:func:`main`'s ``prog``, the name the CLI gives itself in ``--help``, and
44``log_factory``, which builds the log ``--backend registry`` asks for.
45With neither supplied the package stands alone: it names itself, and
46``--backend registry`` refuses by name rather than pretending. The
47connector is the ``[telegram]`` extra, reached lazily from the two
48``rule --from-inbox`` sites and nowhere else, so a ``hub_manage asks``
49in a venv that has never heard of ``oc_connect`` runs exactly as it
54- :func:`build_parser` -- the command shape, exposed so a witness can
55 read the defaults off it.
56- :func:`main` -- argument dispatch and the exit-code policy, plus the
58- :func:`default_hooks` / :func:`hooks_for_dispatch` -- the live
59 :class:`~oc_orchestrator.dispatch.DispatchHooks` P6's caller registers,
60 which records a ``dispatch`` message and, for a PENDING item only, one
61 ``dequeue`` with ``reason="dispatched"``.
62- :func:`format_fleet_report` -- P7's lines, kept out of the command body.
68 L2 - lifecycle events (which subcommand ran, a hook firing)
69 L3 - semantic details (a refusal and its exit code)
70 L4 - deep tracing (module load)
74- EXIT CODES, matching ``work_queue_manage``: ``0`` success, ``1`` a
75 SEMANTIC refusal (an unknown address, an unknown kind, a ruling on
76 nothing), ``2`` a BACKEND refusal (a corrupt log, a missing registry).
77 Argparse's own usage errors keep argparse's ``2`` via ``SystemExit``.
78- ``--backend file`` is the DEFAULT and the mode the package's own tests
79 run in (design note §7).
80- Every path is injected: ``--queue-dir``, ``--comms-root``,
81 ``--registry``, ``--agent-registry``, ``--actor-slot``. The defaults
82 are today's values and nothing is discovered except the comms root,
83 which is walked from the working directory exactly as every other lane
85- :func:`_build_log`'s registry arm is a PLUGIN POINT, not an import.
86 Before plan 272 it reached four ``oc_chat.registry`` names on four
87 consecutive lines; they now live in the consumer's factory, exactly as
88 plan 271 did for the queue's ``_build_backend``. This module names no
89 registry at any depth, lazily or otherwise.
90- The connector is reached from :func:`_reply_from_inbox` and
91 :func:`_consume_inbound` and NOWHERE else, inside the function bodies,
92 and a missing ``[telegram]`` extra is a named refusal rather than a
93 traceback. ``tests/test_t1_leaf_guard.py`` holds both halves.
94- ``ingest`` runs implicitly before ``inbox``, ``asks`` and ``fleet``, so
95 a blocked worker is an open ask before anyone looks for one.
96- NO ``subprocess`` import, anywhere, at any depth.
100- oc_diagnostics (structured debug logging via _dbg)
101- oc_orchestrator.hub (the contract)
102- oc_orchestrator.hub_file (the default backend and the lane carrier)
103- oc_orchestrator.agent_instances (the instance registry ``fleet`` reads)
104- oc_orchestrator.work_queue (the refusal class ``main`` maps)
105- oc_orchestrator.work_queue_file (the queue the dispatch hook records into)
106- oc_connect.audit (lazy; the ``[telegram]`` extra's audit store, for ``rule --from-inbox`` only)
107- oc_connect.hub_lane (lazy; the ``[telegram]`` extra's reply-to-hub-message correlation, for ``rule --from-inbox`` only)
108- oc_connect.inbound.inbox (lazy; the ``[telegram]`` extra's inbound store, read and marked acted by ``rule --from-inbox`` only)
111from __future__
import annotations
117from datetime
import datetime, timezone
118from pathlib
import Path
119from typing
import Any, Callable
121from oc_diagnostics
import _dbg
124 AgentInstanceRegistry,
126 default_agent_registry,
129 ADDRESS_OPERATOR_LOCAL,
130 ADDRESS_ORCHESTRATOR,
155_dbg(
"OC_CHAT.MAIN",
"module",
"hub_manage loaded", 4)
166RELAY_LINE =
"python -m oc_connect relay hub"
171DEQUEUE_REASON_DISPATCHED =
"dispatched"
179 "--backend registry needs a consumer-supplied log; "
180 "oc_chat supplies one through its shim"
188 "rule --from-inbox needs the [telegram] extra: "
189 "pip install oc_orchestrator[telegram]"
197 args: argparse.Namespace,
198 log_factory:
"Callable[[argparse.Namespace], Any] | None" =
None,
200 """The log the flags ask for. ``file`` unless told otherwise.
202 The ``registry`` arm is the PLUGIN POINT plan 272 P2 built, in the
203 shape plan 271 gave the queue's ``_build_backend``. Before the move
204 this function reached four ``oc_chat.registry`` names on four
205 consecutive lines, and the plan-259 receipt modelled them as a named
206 cut precisely because a later extraction would replace them with a
207 seam. This is that seam: the package holds no registry, so a consumer
208 that wants one passes *log_factory* and the package names nothing of
212 CorruptHubLogError: SYSTEM_ERROR (L1 for the caller) --
213 ``--backend registry`` with no factory to fill it. ``main``'s
214 handler owns the stderr line and the trace, so the refusal is
215 raised bare exactly as the pre-272 registry arm raised its
216 missing-operator-slot one.
219 if args.backend ==
"file":
220 _dbg(
"OC_CHAT.MAIN", func, f
"file log at {args.queue_dir}", 2)
223 if log_factory
is None:
225 _dbg(
"OC_CHAT.MAIN", func,
"registry log from the consumer's factory", 2)
226 return log_factory(args)
230 """The bus root the flags ask for, or the walk, or ``None``.
232 ``None`` means the lane carrier is not built: the log still records
233 every message, so a hub with no reachable bus is quieter rather than
237 if getattr(args,
"comms_root",
None):
238 return Path(args.comms_root).expanduser().resolve()
240 return find_comms_root(Path.cwd())
241 except CorruptHubLogError
as exc:
244 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {exc}", 2)
249 """The hub the flags ask for, with the file lane as its one carrier.
251 The bus carrier is deliberately absent from a CLI process: nothing is
252 attached in a command that runs for a hundred milliseconds, so it
253 would never win a routing decision. A long-lived control centre
254 injects one; this tool is not that.
257 carriers = (
FileLaneCarrier(comms_root=root),)
if root
is not None else ()
258 return Hub(log=log, carriers=carriers)
262 """Who this PROCESS is, in the hub's own terms.
264 The correlation is the process user and the working directory --
265 the two things that say which terminal this ruling was typed into.
266 There is no flag anywhere that can change any of these four values;
267 see the module docstring for why.
270 actor=ADDRESS_OPERATOR_LOCAL,
271 actor_type=
"human_operator",
273 correlation={
"user":
_process_user(),
"cwd": str(Path.cwd())},
278 """The OS user, or a named stand-in when the platform will not say."""
280 return getpass.getuser()
282 return "unknown-user"
289 """One recorded message as one line of operator-facing text."""
290 message = entry.message
294 f
"{message.kind:<11}",
295 f
"{message.sender} -> {message.recipient}",
298 parts.append(f
"[{message.subject}]")
300 parts.append(f
"requires={message.requires}")
302 parts.append(f
"answer={message.answer}")
303 parts.append(message.message_id)
304 return " ".join(parts)
308 """A list of messages, with each body indented under its own header.
310 The dense form: ``history`` keeps it because that is the audit view.
311 ``inbox`` and ``asks`` render cards (:func:`format_cards`) instead.
315 lines: list[str] = []
316 for entry
in entries:
318 for line
in entry.message.body.splitlines():
319 lines.append(f
" {line}")
320 return "\n".join(lines)
324RULE_HINT =
"To answer: hub_manage rule <id> --answer YES|NO|ACK|HOLD [--body \"...\"]"
327def _age(at: str, *, now: datetime |
None =
None) -> str:
328 """``(3 days ago)`` for an ISO timestamp, or ``""`` when it will not parse."""
330 then = datetime.fromisoformat(at.replace(
"Z",
"+00:00"))
333 if then.tzinfo
is None:
334 then = then.replace(tzinfo=timezone.utc)
335 moment = now
or datetime.now(timezone.utc)
336 seconds = max(0, int((moment - then).total_seconds()))
338 return f
"({seconds // 60} min ago)"
340 return f
"({seconds // 3600} h ago)"
341 days = seconds // 86400
342 return f
"({days} day{'s' if days != 1 else ''} ago)"
346 """The body as card lines: the first line and a count, or all of it."""
347 body_lines = message.body.splitlines()
348 if full
or len(body_lines) <= 1:
349 return [f
" {line}" for line
in body_lines]
or [
" (no body)"]
350 first = next((line
for line
in body_lines
if line.strip()),
"")
351 rest = len(body_lines) - 1
352 where = f
"`history {message.subject}`" if message.subject
else "`--full`"
353 return [f
" {first} (+{rest} lines; {where} for the body)"]
357 entries: tuple[HubEntry, ...],
363 now: datetime |
None =
None,
365 """One card per message under a header that says what the list is.
367 Header: ``## <title> (<count>) <source>``. Each card opens with a
368 bracketed ordinal, the kind and the id, then one aligned field per
369 line, then the body's first line with a line count (all of it under
370 ``full``). A blank line separates cards, so the seam between two
371 items is visible without reading either. When any card is an ask
372 the footer names the verb that answers it.
374 header = f
"## {title} ({len(entries)}) {source}"
376 return f
"{header}\n\n{empty}"
377 blocks: list[str] = [header]
378 for index, entry
in enumerate(entries, start=1):
379 message = entry.message
381 f
"[{index}] {message.kind} {message.message_id} seq {entry.seq}",
382 f
" from {message.sender} -> {message.recipient}",
385 lines.append(f
" subject {message.subject}")
387 lines.append(f
" requires {message.requires}")
389 lines.append(f
" answer {message.answer}")
390 if message.in_reply_to:
391 lines.append(f
" answers {message.in_reply_to}")
392 lines.append(f
" sent {message.at} {_age(message.at, now=now)}".rstrip())
394 blocks.append(
"\n".join(lines))
395 if any(entry.message.kind == KIND_ASK
for entry
in entries):
396 blocks.append(RULE_HINT)
397 return "\n\n".join(blocks)
400def entries_as_json(entries: tuple[HubEntry, ...], *, command: str, source: str) -> str:
401 """The same list as one JSON document, for agents and tools."""
406 "count": len(entries),
408 {
"seq": entry.seq, **entry.message.as_dict()}
for entry
in entries
417 """Where the listing came from, so nobody wonders which store this is."""
418 if args.backend ==
"file":
419 return f
"backend=file log={Path(args.queue_dir) / 'hub.ops.jsonl'}"
420 return f
"backend=registry registry={args.registry or 'discovered'}"
424 args: argparse.Namespace,
425 entries: tuple[HubEntry, ...],
431 """Print a listing as cards, or as JSON when ``--json`` was given."""
433 if getattr(args,
"json",
False):
437 entries, title=title, empty=empty, source=source,
438 full=getattr(args,
"full",
False),
443 instances: list[Any],
444 entries: tuple[HubEntry, ...],
446 """P7: one line per non-closed instance, saying whether it is WAITING.
448 The distinction the design note requires, made before anyone can call
449 a worker stalled: an instance with an open ask it sent is waiting on
450 a decision. The line names the ask and when it was sent, so the
451 operator can answer it rather than investigate a silence.
453 func =
"format_fleet_report"
454 lines = [
"## Fleet",
""]
456 lines.append(
"No open instances.")
457 return "\n".join(lines)
460 entry.message.in_reply_to
462 if entry.message.kind ==
"ruling"
465 for instance
in instances:
469 for entry
in reversed(entries)
470 if entry.message.kind == KIND_ASK
471 and entry.message.sender == instance.agent_id
472 and entry.message.message_id
not in ruled
479 for entry
in reversed(entries)
480 if instance.agent_id
in (entry.message.sender, entry.message.recipient)
485 f
"- {instance.agent_id} {instance.plan_id or '-'} "
486 f
"state={instance.state}"
489 head += f
" last={last.message.kind} at {last.message.at}"
490 if open_ask
is not None:
493 f
"{head} waiting on ask {open_ask.message.message_id} since "
494 f
"{open_ask.message.at}"
496 elif last
is not None:
497 lines.append(f
"{head} no message since {last.message.at}")
499 lines.append(f
"{head} no message since it was launched")
501 lines.extend([
"", f
"{waiting} of {len(instances)} waiting on a decision."])
502 _dbg(
"OC_CHAT.MAIN", func, f
"{waiting}/{len(instances)} waiting", 3)
503 return "\n".join(lines)
509def _ingest(args: argparse.Namespace, log: Any) -> int:
510 """Fold the two existing lanes in. Returns how many were new."""
514 return len(ingest_lanes(root, log))
517def _cmd_send(args: argparse.Namespace, log: Any) -> int:
519 message = new_message(
522 recipient=args.recipient,
523 subject=args.subject,
525 requires=args.requires
if args.kind == KIND_ASK
else None,
529 print(f
"Recorded {message.kind} {message.message_id} -> {message.recipient}.")
533def _cmd_ask(args: argparse.Namespace, log: Any) -> int:
534 """A ``send`` shorthand: the kind is fixed and an answerer is required."""
539def _cmd_rule(args: argparse.Namespace, log: Any) -> int:
540 """Answer an open ask, from this process or from a stored reply."""
545 body = args.body
or f
"{answer} (from the operator's reply)"
548 print(
"rule needs an ask id, or --from-inbox", file=sys.stderr)
551 body = args.body
or f
"{answer} (ruled at the control centre)"
553 entry = hub.rule(ask_id, answer=answer, body=body, provenance=provenance)
557 f
"Ruled {answer} on {ask_id} as {entry.message.message_id} "
558 f
"(channel={provenance.channel})."
560 _dbg(
"OC_CHAT.MAIN", func, f
"{answer} on {ask_id}", 2)
565 """What is addressed to one party and not yet read. Advances the cursor."""
567 parse_address(args.address)
570 entries = log.entries(recipient=args.address)
571 _emit(args, entries, command=
"inbox", title=f
"Inbox for {args.address}, everything",
572 empty=f
"Nothing addressed to {args.address}.")
575 entries =
_unread(log, args.address)
576 _emit(args, entries, command=
"inbox", title=f
"Inbox for {args.address}, unread",
577 empty=f
"Nothing new for {args.address}.")
579 _advance(log, args.address, entries[-1].seq)
580 _dbg(
"OC_CHAT.MAIN", func,
581 f
"{args.address} cursor -> {entries[-1].seq}", 2)
585def _cmd_asks(args: argparse.Namespace, log: Any) -> int:
586 """Open questions, or every question ever asked."""
588 recipient = parse_address(args.address)
if args.address
else None
589 scope = f
" for {recipient}" if recipient
else ""
591 entries = log.open_asks(recipient=recipient)
592 title, empty = f
"Open asks{scope}",
"No open asks."
594 entries = log.entries(kind=KIND_ASK, recipient=recipient)
595 title, empty = f
"All asks{scope}",
"No asks recorded."
596 _emit(args, entries, command=
"asks", title=title, empty=empty)
601 """P7: one line per open instance, saying whether it is waiting."""
604 Path(args.agent_registry)
if args.agent_registry
else default_agent_registry()
606 if not Path(registry_path).is_file():
607 print(f
"## Fleet\n\nNo instance registry at {registry_path}.")
611 except (AgentRegistryError, ValueError)
as exc:
614 print(f
"## Fleet\n\nThe instance registry is unreadable: {exc}")
623 f
"Ingested {count} lane event(s)." if count
else "Nothing new on the lanes."
630 entries = log.entries(subject=args.subject)
635 print(
format_entries(entries, empty=f
"No recorded messages about {args.subject}."))
640 """Print the relay line an operator would run. Never run it.
642 Relaying holds a credential and reaches the transport; the autonomy
643 boundary keeps both in the operator's own process. This module does
644 not import ``subprocess``, so this could not spawn anything even by
650 for entry
in log.entries(recipient=
"operator@remote")
652 print(f
"{len(pending)} hub message(s) addressed to operator@remote.")
653 print(f
"relay: {RELAY_LINE}")
654 print(
"(this command prints the line; it does not run it)")
665 "ingest": _cmd_ingest,
666 "history": _cmd_history,
667 "relay-check": _cmd_relay_check,
675 """Send, tolerating a hub with no carrier for that recipient.
677 The log is the truth and :meth:`Hub.send` records BEFORE it routes,
678 so a message nobody could carry is still a recorded message. A CLI
679 that exited non-zero here would be reporting the absence of a
680 listener as a failure to send.
687 except CarrierError
as exc:
688 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {exc}", 2)
689 print(f
"note: {exc}", file=sys.stderr)
692def _unread(log: Any, address: str) -> tuple[HubEntry, ...]:
693 """Unread entries for *address*, or everything when the log has no cursor.
695 The registry backend has no cursor file: its log is the operation
696 log, which no reader may write to. A cursor for it is the follow-up
697 the report names; until then ``inbox --backend registry`` shows
698 everything and says so rather than pretending to track a position.
700 reader = getattr(log,
"unread_for",
None)
702 return log.entries(recipient=address)
703 return reader(address)
706def _advance(log: Any, address: str, seq: int) ->
None:
707 """Move *address*'s cursor, where the backend has one."""
708 advance = getattr(log,
"advance_cursor",
None)
709 if advance
is not None:
710 advance(address, seq)
714 """Resolve a stored inbound reply into (ask id, provenance, answer).
716 The ONE place this tool reaches the connector for a reply, and it is
717 a lazy import because the correlation being walked is the
718 connector's: a ``hub_manage asks`` must not load a package that
719 handles credentials, and since plan 272 it must not even REQUIRE one
720 to be installed. ``oc_connect`` is the ``[telegram]`` extra, so an
721 ``ImportError`` here means the operator asked for a sub-command this
722 venv was not built for, and it is a named refusal rather than a
725 The provenance built here is ``telegram`` with the three correlation
726 fields the hub's human rule demands -- and every one of them comes
727 from the STORED message, never from the command line.
730 HubError: VALIDATION_ERROR (L3) -- no comms bus, an uncorrelated
731 reply, an answer outside the vocabulary, or the
732 ``[telegram]`` extra absent.
734 func =
"_reply_from_inbox"
736 from oc_connect.audit
import AuditStore
737 from oc_connect.hub_lane
import UncorrelatedReplyError, resolve_hub_reply
738 from oc_connect.inbound.inbox
import InboxStore
739 except ImportError
as exc:
740 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {NO_TELEGRAM_EXTRA}", 3)
741 raise HubError(NO_TELEGRAM_EXTRA)
from exc
746 "--from-inbox needs a comms bus, and none was found above the "
747 "working directory; pass --comms-root"
749 audit = AuditStore(root /
"audit" /
"audit.jsonl")
750 inbox = InboxStore(root /
"inbound" /
"telegram", audit=audit)
752 reply = resolve_hub_reply(args.from_inbox, inbox=inbox, audit=audit)
753 except UncorrelatedReplyError
as exc:
755 if reply.answer
not in RULING_ANSWERS:
757 f
"inbound message {args.from_inbox!r} carries {reply.answer!r}, which "
758 f
"is not one of {', '.join(RULING_ANSWERS)}; a ruling needs a "
759 "vocabulary token, and free text is not one"
762 actor=ADDRESS_OPERATOR_LOCAL,
763 actor_type=
"human_operator",
765 correlation=dict(reply.correlation),
767 _dbg(
"OC_CHAT.MAIN", func,
768 f
"{args.from_inbox} answers {reply.hub_message_id}", 2)
769 return reply.hub_message_id, provenance, reply.answer
773 """Mark the inbound reply consumed by the ruling it produced.
775 Best-effort: the ruling is already recorded, and failing to annotate
776 the connector's copy of the operator's tap must not un-rule it. That
777 covers the ``[telegram]`` extra being absent too -- unreachable in
778 practice, because only ``--from-inbox`` calls this and that arm
779 already refused without the extra, but a best-effort annotation that
780 could raise on an import would be best-effort in name only.
782 func =
"_consume_inbound"
784 from oc_connect.audit
import AuditStore
785 from oc_connect.inbound.inbox
import InboxStore
787 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {NO_TELEGRAM_EXTRA}", 2)
794 audit = AuditStore(root /
"audit" /
"audit.jsonl")
795 InboxStore(root /
"inbound" /
"telegram", audit=audit).mark_acted(
798 except Exception
as exc:
799 _dbg(
"OC_CHAT.MAIN", func,
800 f
"EXTERNAL_ERROR: could not mark {message_id} acted: {exc}", 2)
802 f
"warning: inbound message {message_id} was not marked acted ({exc}); "
803 f
"the ruling {decision} is recorded regardless",
812 """Record a dispatch, and dequeue the item only if it was pending."""
814 def __init__(self, *, log: Any, queue: Any |
None, comms_root: Path |
None) ->
None:
820 """Record the ``dispatch`` message, then the queue operation."""
821 func =
"_HubDispatchHooks.dispatched"
827 message = new_message(
829 sender=ADDRESS_ORCHESTRATOR,
830 recipient=result.instance.agent_id,
831 subject=result.plan_id,
833 f
"Dispatched {result.plan_id} to {result.instance.agent_id} on "
834 f
"{result.branch}.\nLaunch: {result.launch_path}\n"
835 f
"Worktree: {result.worktree}\nBASE: {result.base_sha}"
838 actor=ADDRESS_ORCHESTRATOR,
839 actor_type=
"orchestrator_agent",
845 _dbg(
"OC_CHAT.MAIN", func,
846 f
"recorded dispatch of {result.plan_id} to {result.instance.agent_id}", 2)
850 """Take the item into progress -- only if the queue held it as pending.
852 A plan that is NOT on the queue is logged as a dispatch and never
853 enqueued: inventing queue membership at dispatch time is the
854 silent projection plan 258 filed as a residual, and this is the
855 side of it this plan controls.
857 func =
"_HubDispatchHooks._dequeue_if_pending"
858 if self.
queue is None:
860 pending = [item.item_id
for item
in self.
queue.status().pending]
861 if result.plan_id
not in pending:
862 _dbg(
"OC_CHAT.MAIN", func,
863 f
"{result.plan_id} is not pending; recorded, not enqueued", 2)
867 instance=result.instance.agent_id,
868 reason=DEQUEUE_REASON_DISPATCHED,
870 _dbg(
"OC_CHAT.MAIN", func,
871 f
"dequeued {result.plan_id} to {result.instance.agent_id}", 2)
877 queue: Any |
None =
None,
878 comms_root: Path |
None =
None,
880 """The live :class:`~oc_orchestrator.dispatch.DispatchHooks` for P6.
882 Registered by the CALLER -- ``plan_manage._cmd_dispatch`` -- exactly
883 as ``plan_status`` registers its state observer, so ``dispatch.py``
884 names neither the hub nor the queue. That direction survived the move
885 unchanged: the hooks live here, beside the hub they announce into,
886 and :mod:`oc_orchestrator.dispatch` still imports nothing from this
887 module (plan 272 P3; ``test_270_standalone_dispatch.py`` holds it).
895 comms_root: Path |
None =
None,
897 """:func:`default_hooks` over the FILE backends under *queue_dir*.
899 The convenience ``plan_manage`` calls, so the dispatch path names one
900 directory rather than assembling two backends. The file mode is the
901 default everywhere else and it is the default here.
903 paths =
HubPaths(queue_dir=Path(queue_dir))
907 comms_root=comms_root,
915 """``--full`` and ``--json`` on the two card-rendering listings."""
917 "--full", action=
"store_true",
918 help=
"Print every body in full instead of its first line and a count",
921 "--json", action=
"store_true", help=
"One JSON document instead of cards",
929DEFAULT_PROG =
"python -m oc_orchestrator.hub_manage"
933 """The command shape, exposed so a witness can read the defaults.
935 There is deliberately no ``--as`` and no way to set provenance from
936 the command line. See the module docstring.
938 parser = argparse.ArgumentParser(
941 "Send, answer and read hub messages. This command dispatches "
942 "nothing and relays nothing."
946 "--backend", choices=[
"file",
"registry"], default=
"file",
947 help=
"Hub log backend (default: file, the tested mode)",
950 "--queue-dir", default=str(DEFAULT_QUEUE_DIR),
951 help=f
"File-backend directory for the log and cursors (default: {DEFAULT_QUEUE_DIR})",
954 "--comms-root", help=
"Comms bus root (default: the workspace walk)"
956 parser.add_argument(
"--registry", help=
"Registry-backend SQLite path")
958 "--actor-slot", help=
"Registry actor slot (default: the active operator)"
961 "--agent-registry", help=
"agents.sqlite path, read by fleet"
963 sub = parser.add_subparsers(dest=
"command", required=
True)
965 send = sub.add_parser(
"send", help=
"Record and route one message")
966 send.add_argument(
"--kind", required=
True, choices=sorted(SUPPORTED_KINDS))
967 send.add_argument(
"--from", dest=
"sender", required=
True, metavar=
"ADDRESS")
968 send.add_argument(
"--to", dest=
"recipient", required=
True, metavar=
"ADDRESS")
969 send.add_argument(
"--subject", help=
"A work item id, such as 259-hub")
971 "--requires", choices=list(SUPPORTED_REQUIRES),
972 help=
"For an ask: who may answer it",
974 send.add_argument(
"body")
976 ask = sub.add_parser(
"ask", help=
"Record a question that expects a ruling")
977 ask.add_argument(
"--from", dest=
"sender", required=
True, metavar=
"ADDRESS")
978 ask.add_argument(
"--to", dest=
"recipient", required=
True, metavar=
"ADDRESS")
979 ask.add_argument(
"--subject", help=
"A work item id, such as 259-hub")
980 ask.add_argument(
"--requires", required=
True, choices=list(SUPPORTED_REQUIRES))
981 ask.add_argument(
"body")
983 rule = sub.add_parser(
"rule", help=
"Answer an open ask")
984 rule.add_argument(
"ask_id", nargs=
"?", help=
"The ask being answered")
986 "--answer", choices=list(RULING_ANSWERS),
987 help=
"Required unless --from-inbox, which reads the operator's own word",
989 rule.add_argument(
"--body", help=
"Free text; defaults to naming the answer")
991 "--from-inbox", metavar=
"INBOUND_ID",
993 "Build the ruling from a stored operator reply, taking the answer "
994 "and all three correlation fields from the message itself"
998 inbox = sub.add_parser(
"inbox", help=
"Read what is addressed to one party")
999 inbox.add_argument(
"--for", dest=
"address", required=
True, metavar=
"ADDRESS")
1001 "--all", action=
"store_true",
1002 help=
"Show everything and leave the cursor where it is",
1006 asks = sub.add_parser(
"asks", help=
"List questions")
1007 asks.add_argument(
"--for", dest=
"address", metavar=
"ADDRESS")
1008 asks.add_argument(
"--open", action=
"store_true", help=
"Only unanswered ones")
1011 sub.add_parser(
"fleet", help=
"One line per open instance; says who is waiting")
1012 sub.add_parser(
"ingest", help=
"Fold the worker-report and session-alert lanes in")
1014 history = sub.add_parser(
"history", help=
"Every message about one work item")
1015 history.add_argument(
"subject")
1016 history.add_argument(
1017 "--json", action=
"store_true", help=
"One JSON document instead of text",
1022 help=
"Print the oc_connect relay line for the hub lane. Never runs it.",
1028 argv: list[str] |
None =
None,
1030 prog: str = DEFAULT_PROG,
1031 log_factory:
"Callable[[argparse.Namespace], Any] | None" =
None,
1033 """Run one subcommand and return its exit code.
1035 *prog* and *log_factory* are the consumer seams (plan 272 P2). Both
1036 default to this package standing alone: it names itself, and
1037 ``--backend registry`` refuses because this package has no registry
1038 to build a log from. ``oc_chat``'s shim passes its own module path
1039 and a factory that builds ``RegistryHubLog``, which is what keeps
1040 every output and every recorded row identical across the split.
1043 for stream
in (sys.stdout, sys.stderr):
1045 stream.reconfigure(errors=
"replace")
1046 except (AttributeError, ValueError):
1050 _dbg(
"OC_CHAT.MAIN", func, f
"subcommand {args.command}", 2)
1051 if args.command ==
"rule" and not args.from_inbox
and not args.answer:
1052 print(
"rule needs --answer, or --from-inbox", file=sys.stderr)
1056 return _COMMANDS[args.command](args, log)
1057 except CorruptHubLogError
as exc:
1061 print(f
"backend refused: {exc}", file=sys.stderr)
1062 _dbg(
"OC_CHAT.MAIN", func, f
"SYSTEM_ERROR: {exc}", 1, override=
True)
1064 except HubError
as exc:
1065 print(f
"refused: {exc}", file=sys.stderr)
1066 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {exc}", 3)
1068 except QueueError
as exc:
1069 print(f
"backend refused: {exc}", file=sys.stderr)
1070 _dbg(
"OC_CHAT.MAIN", func, f
"SYSTEM_ERROR: {exc}", 1, override=
True)
1072 except (OSError, ValueError)
as exc:
1073 print(f
"backend refused: {exc}", file=sys.stderr)
1074 _dbg(
"OC_CHAT.MAIN", func, f
"SYSTEM_ERROR: {exc}", 1, override=
True)
1078if __name__ ==
"__main__":
None __init__(self, *, Any log, Any|None queue, Path|None comms_root)
None _dequeue_if_pending(self, Any result)
None dispatched(self, Any result)
argparse.ArgumentParser build_parser(str prog=DEFAULT_PROG)
int _cmd_history(argparse.Namespace args, Any log)
None _listing_flags(argparse.ArgumentParser parser)
str _source_label(argparse.Namespace args)
int _cmd_asks(argparse.Namespace args, Any log)
str format_fleet_report(list[Any] instances, tuple[HubEntry,...] entries)
None _advance(Any log, str address, int seq)
tuple[str, Provenance, str] _reply_from_inbox(argparse.Namespace args)
str _age(str at, *, datetime|None now=None)
Any hooks_for_dispatch(*, Path queue_dir, Path|None comms_root=None)
Any default_hooks(*, Any log, Any|None queue=None, Path|None comms_root=None)
int _cmd_ingest(argparse.Namespace args, Any log)
Hub _build_hub(argparse.Namespace args, Any log)
int _cmd_send(argparse.Namespace args, Any log)
Any _build_log(argparse.Namespace args, "Callable[[argparse.Namespace], Any] | None" log_factory=None)
Provenance _actor_provenance()
str format_entries(tuple[HubEntry,...] entries, *, str empty)
int _cmd_rule(argparse.Namespace args, Any log)
int _cmd_inbox(argparse.Namespace args, Any log)
Path|None _comms_root(argparse.Namespace args)
int _cmd_fleet(argparse.Namespace args, Any log)
int _ingest(argparse.Namespace args, Any log)
int _cmd_relay_check(argparse.Namespace args, Any log)
str _describe(HubEntry entry)
int _cmd_ask(argparse.Namespace args, Any log)
int main(list[str]|None argv=None, *, str prog=DEFAULT_PROG, "Callable[[argparse.Namespace], Any] | None" log_factory=None)
str entries_as_json(tuple[HubEntry,...] entries, *, str command, str source)
str format_cards(tuple[HubEntry,...] entries, *, str title, str empty, str source, bool full=False, datetime|None now=None)
None _consume_inbound(argparse.Namespace args, str message_id, str decision)
None _deliver(Hub hub, Any message)
tuple[HubEntry,...] _unread(Any log, str address)
list[str] _card_body(Any message, *, bool full)
None _emit(argparse.Namespace args, tuple[HubEntry,...] entries, *, str command, str title, str empty)