diff --git a/apps/api/src/cora/agent/fleet_readiness.py b/apps/api/src/cora/agent/fleet_readiness.py new file mode 100644 index 00000000000..0376644075d --- /dev/null +++ b/apps/api/src/cora/agent/fleet_readiness.py @@ -0,0 +1,169 @@ +"""Whether the shipped fleet can actually act, as a value and as a verdict. + +`_seeded_fleet` made the fleet a value so a gesture could be made over +it. This asks the question that value exists for, and says the answer out +loud at boot. + +## Why this is a warning and not a metric + +A `Defined` Agent is registered and inert: the subscribers' lifecycle +gate fires on `Versioned` only, and refuses anything less WITHOUT +SAYING SO. On the 2-BM pilot that stranded seventeen agents for three +months behind a log that looked clean the entire time. The remedy +(`promote_seeded_fleet`) has existed since; what did not exist was +anything that told an operator the remedy was needed. + +So the point here is not the number. It is that a deployment whose fleet +cannot act now says so on every boot, at `warning`, naming the members. +An absence has to be its own loud verdict, because the alternative is +what already happened: a correct system, a clean log, and nothing +running. + +## Four not-ready reasons, kept apart + +They are not interchangeable and collapsing them would restore the +silence at one remove: + + `not_ready` `Defined`. Never promoted. THE silent case, and the only + one this treats as a fault, because it is the only one + nobody chose. + `held` `Suspended`. A live operator decision. Reported, never + warned about; a deployment pausing an agent on purpose + should not be nagged for it. + `retired` `Deprecated`. Terminal and deliberate. + `absent` Not in the record at all. Worth seeing on a partly-seeded + deployment rather than counting as a silent zero, the same + reasoning `promote_seeded_fleet` applies to it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from cora.agent._seeded_fleet import SEEDED_FLEET +from cora.agent.aggregates.agent import AgentStatus, load_agent +from cora.infrastructure.logging import get_logger + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from uuid import UUID + + from cora.agent._seeded_fleet import SeededAgent + from cora.infrastructure.ports import EventStore + +_log = get_logger(__name__) + + +@dataclass(frozen=True) +class FleetReadiness: + """Fleet members by whether they can act, named rather than counted. + + Names and not ids: this is read by an operator deciding whether to + run a promotion, and `RunWitness` answers that question where + `01900000-0000-7000-8000-0000aaaa0010` does not. The ids stay + reachable through `SEEDED_FLEET` for anything that needs to act on a + member. + """ + + ready: tuple[str, ...] + not_ready: tuple[str, ...] + held: tuple[str, ...] + retired: tuple[str, ...] + absent: tuple[str, ...] + + @property + def total(self) -> int: + return ( + len(self.ready) + + len(self.not_ready) + + len(self.held) + + len(self.retired) + + len(self.absent) + ) + + @property + def stranded(self) -> bool: + """Whether any member is inert for a reason nobody chose.""" + return bool(self.not_ready) + + +def fleet_readiness( + statuses: Mapping[UUID, AgentStatus | None], + fleet: Sequence[SeededAgent] = SEEDED_FLEET, +) -> FleetReadiness: + """Sort the fleet by readiness. Pure, so the unit tier drives every + branch without a record. + + Ranges over `fleet`, never over `statuses`: a member the caller + failed to look up has to land in `absent`, and a map missing that key + would otherwise drop it from the count entirely. The total is then a + property of the shipped fleet, which is the only thing that makes + "4 of 20" mean anything. + """ + buckets: dict[str, list[str]] = { + "ready": [], + "not_ready": [], + "held": [], + "retired": [], + "absent": [], + } + for member in fleet: + match statuses.get(member.agent_id): + case AgentStatus.VERSIONED: + buckets["ready"].append(member.name) + case AgentStatus.DEFINED: + buckets["not_ready"].append(member.name) + case AgentStatus.SUSPENDED: + buckets["held"].append(member.name) + case AgentStatus.DEPRECATED: + buckets["retired"].append(member.name) + case None: + buckets["absent"].append(member.name) + return FleetReadiness( + ready=tuple(buckets["ready"]), + not_ready=tuple(buckets["not_ready"]), + held=tuple(buckets["held"]), + retired=tuple(buckets["retired"]), + absent=tuple(buckets["absent"]), + ) + + +async def read_fleet_readiness(event_store: EventStore) -> FleetReadiness: + """Load every shipped member and sort it. Read-only.""" + statuses: dict[UUID, AgentStatus | None] = {} + for member in SEEDED_FLEET: + agent = await load_agent(event_store, member.agent_id) + statuses[member.agent_id] = None if agent is None else agent.status + return fleet_readiness(statuses) + + +def log_fleet_readiness(readiness: FleetReadiness) -> None: + """Say it out loud, at a level that matches whether anything is wrong. + + `warning` when a member is stranded, because that is a deployment + that will quietly do nothing, and the whole reason this function + exists is that the previous behaviour was indistinguishable from a + healthy boot. `info` otherwise: a fleet nobody needs to act on should + not train an operator to scroll past this line. + """ + if readiness.stranded: + _log.warning( + "agent_fleet.stranded", + ready=len(readiness.ready), + total=readiness.total, + not_ready=list(readiness.not_ready), + remedy="promote_seeded_fleet", + ) + else: + _log.info("agent_fleet.ready", ready=len(readiness.ready), total=readiness.total) + if readiness.absent: + _log.warning("agent_fleet.absent", absent=list(readiness.absent)) + + +__all__ = [ + "FleetReadiness", + "fleet_readiness", + "log_fleet_readiness", + "read_fleet_readiness", +] diff --git a/apps/api/src/cora/api/_status_push.py b/apps/api/src/cora/api/_status_push.py index 9b850162301..44ae1c72a85 100644 --- a/apps/api/src/cora/api/_status_push.py +++ b/apps/api/src/cora/api/_status_push.py @@ -177,6 +177,7 @@ from websockets.asyncio.client import connect from websockets.exceptions import ConnectionClosed, InvalidStatus +from cora.agent.fleet_readiness import read_fleet_readiness from cora.agent.seed_status_publisher import STATUS_PUBLISHER_AGENT_ID from cora.campaign.errors import UnauthorizedError as _CampaignUnauthorizedError from cora.campaign.features.list_campaigns import ListCampaigns @@ -273,6 +274,20 @@ REWIND timeline refreshed at the same 2Hz cadence; the terminal push (see `_RunHistoryTail.poll`) guarantees the final version is always complete regardless of where this clock happened to be when the run closed.""" +_FLEET_READINESS_REFRESH_TICKS = 150 +"""How often (in ticks) the agent fleet's readiness is re-read: 5 minutes at +the 2.0s default tick. + +Reading it costs one event-store load per shipped agent, and the answer only +changes when an operator promotes, suspends or deprecates one, which is a rare +deliberate gesture rather than beamline traffic. Re-reading twenty streams at +2Hz to watch a value that moves monthly would be the most expensive thing in +the tick loop by a wide margin. + +Not cached for the process's lifetime either, which is the other obvious +choice and the wrong one: an operator who has just run `promote_seeded_fleet` +is looking at this page to see whether it worked, and "restart the API to find +out" is not an answer.""" _ACTIVITY_PAGE_LIMIT = 500 """Per-`read_since` call cap for `_ActivityTail`. Measured against the real 2-BM deployment (2026-08-28): 13,822 events total, ever, across 25 stream @@ -681,6 +696,47 @@ async def _drain_active_enclosures( return rows, raw_enclosure_ids +class _FleetReadinessTail: + """Holds the fleet's readiness across ticks, re-reading it rarely. + + Unlike every other domain here this is not beamline traffic: it is a + standing fact about the deployment, true between operator gestures + and expensive to ask (one stream load per shipped agent). So it is + read on the first tick and then only every + `_FLEET_READINESS_REFRESH_TICKS`, and the held value rides every + snapshot in between. + + Deliberately NOT skipped when the fleet is healthy. A page that shows + the row only when something is wrong teaches its reader that an + absent row means nothing to see, which is the same lesson that let a + stranded fleet sit unnoticed for three months. "20 of 20 ready" is + the row earning trust for the one day it says 4. + """ + + def __init__(self) -> None: + self._held: dict[str, Any] | None = None + self._ticks_since_read = 0 + + async def poll(self, deps: Kernel) -> dict[str, Any]: + due = self._held is None or self._ticks_since_read >= _FLEET_READINESS_REFRESH_TICKS + if not due: + self._ticks_since_read += 1 + return self._held if self._held is not None else {} + readiness = await read_fleet_readiness(deps.event_store) + self._ticks_since_read = 0 + # Names, never ids: this row exists to tell an operator which + # agents will not act, and `RunWitness` answers that where a uuid + # does not. Nothing on the page can resolve an agent id anyway. + self._held = { + "ready": len(readiness.ready), + "total": readiness.total, + "not_ready": list(readiness.not_ready), + "held": list(readiness.held), + "absent": list(readiness.absent), + } + return self._held + + class _DecisionTail: """Tail-follows `list_decisions` since this instance was created, keeping only the most recent `_DECISION_RING_SIZE`. @@ -1214,6 +1270,7 @@ def build_snapshot( clearances: list[dict[str, Any]], enclosures: list[dict[str, Any]], decisions: list[dict[str, Any]], + agents: dict[str, Any], sequence: int, generated_at: str, producer_id: str, @@ -1234,6 +1291,7 @@ def build_snapshot( "clearances": clearances, "enclosures": enclosures, "decisions": decisions, + "agents": agents, } @@ -1475,6 +1533,7 @@ async def _build_payload_fields( list_plans: ListPlansHandler, list_enclosures: ListEnclosuresHandler, decision_tail: _DecisionTail, + fleet_tail: _FleetReadinessTail, list_decisions: ListDecisionsHandler, run_history_tail: _RunHistoryTail, get_run_history: GetRunHistoryHandler, @@ -1485,7 +1544,7 @@ async def _build_payload_fields( witness_recorder: RunWitnessRecorder | None, generated_at: str, producer_id: str, -) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]: +) -> tuple[dict[str, Any], list[dict[str, Any]]]: """Every domain's rows for one tick, plus any extra messages (run history, enclosure timelines, activity) that are new this tick. Each drain is independently guarded by the caller's @@ -1493,6 +1552,13 @@ async def _build_payload_fields( missing grant on one command blinds only that section of the page, never the whole tick. + `fields` is `dict[str, Any]` rather than a dict of row lists because + `agents` is not rows. Every other field answers "which instances are + open"; that one answers "can this deployment's fleet act", which is a + single standing fact and would be a lie as a list (a healthy fleet + would have to be an empty one, and an empty list is exactly how every + other field spells "none"). + Returns `(fields, extra_messages)`, not one dict: none of run history, enclosure timelines, or activity is a snapshot field (see `_RunHistoryTail`'s, `_EnclosureTimelineTail`'s, and `_ActivityTail`'s @@ -1514,6 +1580,7 @@ async def _build_payload_fields( "clearances": await _drain_active_clearances(list_clearances, deps), "enclosures": enclosures, "decisions": await decision_tail.poll(list_decisions, deps), + "agents": await fleet_tail.poll(deps), } extra_messages = await run_history_tail.poll( get_run_history, @@ -1584,6 +1651,7 @@ async def _push_loop( token = deps.settings.status_push_token headers = {"Authorization": f"Bearer {token.get_secret_value()}"} if token is not None else {} + fleet_tail = _FleetReadinessTail() decision_tail = _DecisionTail( started_at_cursor=encode_cursor(created_at=deps.clock.now(), item_id=_MIN_UUID) ) @@ -1627,6 +1695,7 @@ async def _push_loop( list_plans=list_plans, list_enclosures=list_enclosures, decision_tail=decision_tail, + fleet_tail=fleet_tail, list_decisions=list_decisions, run_history_tail=run_history_tail, get_run_history=get_run_history, diff --git a/apps/api/src/cora/api/beamline_staff_seed.py b/apps/api/src/cora/api/beamline_staff_seed.py index f0a942ea6d0..d2269942adf 100644 --- a/apps/api/src/cora/api/beamline_staff_seed.py +++ b/apps/api/src/cora/api/beamline_staff_seed.py @@ -28,8 +28,9 @@ ## Where the names come from Each pinned slot resolves its display name from a CLI flag, defaulting -to a same-named environment variable (`BEAMLINE_STAFF_OPERATOR_A_NAME` / -`BEAMLINE_STAFF_OPERATOR_B_NAME`) that the deploy host sets outside this +to a same-named environment variable (`BEAMLINE_STAFF_ADMIN_NAME` / +`BEAMLINE_STAFF_GROUP_MANAGER_NAME` / `BEAMLINE_STAFF_STAFF_NAME`) that +the deploy host sets outside this repository. Neither name is read into `Settings`: promoting them to the shared configuration schema would put a 2-BM-specific PII concern in front of every other deployment's config surface. `_require_all_names_ @@ -43,9 +44,26 @@ event payload, matching the PII vault pattern documented on `cora.access.aggregates.actor.events.ActorRegistered`. +## Slots are ROLES, not seats + +The labels name what a holder is at this facility (`2-bm-admin`, +`2-bm-group-manager`, `2-bm-staff`) rather than an anonymous seat letter. +A role is not personal data, so it is safe in a public repo, and it is +the thing a Policy grant should be read against: `operator-a` tells a +future reader nothing about why that principal may do anything. + +What a role does NOT carry today is SCOPE. Policy holds +`(principal, command)` pairs gated by a Conduit and a Surface, with no +beamline dimension, so "manages three beamlines" and "staffs one" are +the same grant here. That costs nothing while CORA runs at one beamline +and becomes real at the second; it is a gap in the Policy model, not +something a slot label can fix, and pretending otherwise by handing the +two roles different COMMANDS would encode a scope difference as a +capability difference and be wrong in a way that is hard to unpick. + ## Identity -Two pinned ids, one per operator slot, under a namespace distinct from +Three pinned ids, one per role slot, under a namespace distinct from the seeded-agent range (`01900000-0000-7000-8000-...`): agent ids and staff-actor ids must never collide, and using a visibly different top segment plus a different fourth-group nibble (`9000` here vs `8000` @@ -125,6 +143,17 @@ class BeamlineStaffSlot: event_id: UUID correlation_id: UUID env_var: str + flag: str + """The CLI flag this slot's name may be given on directly. + + Carried here rather than derived from `slot`, and rather than + hand-written next to the parser: the parser and the slot->name map + are both BUILT from this tuple, so a new slot cannot be added + without one, and cannot be added with a flag that only reaches one + of the two places. The previous shape listed every slot three times + (here, an `add_argument` call, and a dict literal), which is the + hand-copied-list shape that drops an entry the third time somebody + edits it.""" #: Distinct from the seeded-agent range (`01900000-0000-7000-8000-...`) @@ -132,23 +161,39 @@ class BeamlineStaffSlot: #: `8000`), so a human-staff id is visibly not an agent id on sight. #: Verified against every literal UUID checked into the repo before #: being picked (see the module docstring's Identity section). -OPERATOR_A_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000a0010") -OPERATOR_B_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000b0010") +#: +#: The `a` / `b` / `c` nibble is minting order and nothing else. It is +#: deliberately NOT re-lettered when a slot's role label changes: the id +#: IS the person as far as the record is concerned, and every grant made +#: to them hangs off it. Renaming a slot must never mint a new one. +ADMIN_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000a0010") +GROUP_MANAGER_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000b0010") +STAFF_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000c0010") BEAMLINE_STAFF_SLOTS: Final[tuple[BeamlineStaffSlot, ...]] = ( BeamlineStaffSlot( - slot="2-bm-operator-a", - actor_id=OPERATOR_A_ACTOR_ID, + slot="2-bm-admin", + actor_id=ADMIN_ACTOR_ID, event_id=UUID("02900000-0000-7000-9000-0000000a0012"), correlation_id=UUID("02900000-0000-7000-9000-0000000a0014"), - env_var="BEAMLINE_STAFF_OPERATOR_A_NAME", + env_var="BEAMLINE_STAFF_ADMIN_NAME", + flag="--admin-name", ), BeamlineStaffSlot( - slot="2-bm-operator-b", - actor_id=OPERATOR_B_ACTOR_ID, + slot="2-bm-group-manager", + actor_id=GROUP_MANAGER_ACTOR_ID, event_id=UUID("02900000-0000-7000-9000-0000000b0012"), correlation_id=UUID("02900000-0000-7000-9000-0000000b0014"), - env_var="BEAMLINE_STAFF_OPERATOR_B_NAME", + env_var="BEAMLINE_STAFF_GROUP_MANAGER_NAME", + flag="--group-manager-name", + ), + BeamlineStaffSlot( + slot="2-bm-staff", + actor_id=STAFF_ACTOR_ID, + event_id=UUID("02900000-0000-7000-9000-0000000c0012"), + correlation_id=UUID("02900000-0000-7000-9000-0000000c0014"), + env_var="BEAMLINE_STAFF_STAFF_NAME", + flag="--staff-name", ), ) @@ -307,7 +352,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m cora.api.beamline_staff_seed", description=( - "Register the two 2-BM beamline staff as CORA human Actors under " + "Register 2-BM's named human roles as CORA Actors under " "pinned, deployment-stable ids, so they exist as usable principals " "for hands-on testing. Display names come from the flags below " "(or their matching environment variables) and land only in the " @@ -316,26 +361,26 @@ def build_parser() -> argparse.ArgumentParser: "nothing." ), ) - parser.add_argument( - "--operator-a-name", - default=os.environ.get("BEAMLINE_STAFF_OPERATOR_A_NAME"), - help="Display name for slot '2-bm-operator-a' (default: $BEAMLINE_STAFF_OPERATOR_A_NAME).", - ) - parser.add_argument( - "--operator-b-name", - default=os.environ.get("BEAMLINE_STAFF_OPERATOR_B_NAME"), - help="Display name for slot '2-bm-operator-b' (default: $BEAMLINE_STAFF_OPERATOR_B_NAME).", - ) + for member in BEAMLINE_STAFF_SLOTS: + parser.add_argument( + member.flag, + dest=_dest(member), + default=os.environ.get(member.env_var), + help=f"Display name for slot '{member.slot}' (default: ${member.env_var}).", + ) parser.add_argument("--dry-run", action="store_true") return parser +def _dest(member: BeamlineStaffSlot) -> str: + """Argparse destination for a slot's flag, derived once so the parser + and the reader below cannot disagree about where a value landed.""" + return member.flag.removeprefix("--").replace("-", "_") + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) - names_by_slot = { - "2-bm-operator-a": args.operator_a_name, - "2-bm-operator-b": args.operator_b_name, - } + names_by_slot = {member.slot: getattr(args, _dest(member)) for member in BEAMLINE_STAFF_SLOTS} return asyncio.run(seed_beamline_staff(names_by_slot=names_by_slot, dry_run=args.dry_run)) diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 9081dadbffa..7b522ba3f4d 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -82,6 +82,7 @@ wire_agent, ) from cora.agent.adapters import BudgetSpendGuard, PostgresLanguageModelLookup +from cora.agent.fleet_readiness import log_fleet_readiness, read_fleet_readiness from cora.api._bleps_supply_observer import BlepsChannel, BlepsSupplyObserver from cora.api._calibration_watcher import calibration_watcher_lifespan from cora.api._campaign_watcher import campaign_watcher_lifespan @@ -1265,6 +1266,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # touching any other seeded agent's authority). await seed_status_publisher_agent(deps) + # Every seed above lands a member; this says whether the fleet + # that resulted can act. A deployment seeded before the bootstrap + # promoted carries agents stuck at `Defined`, and the subscribers + # refuse those without a word, so the only difference between a + # healthy boot and seventeen inert agents used to be nothing at + # all. Read-only, and the last thing the agent block does. + log_fleet_readiness(await read_fleet_readiness(deps.event_store)) + # Drain Federation-owned projections so the Postgres-backed # FacilityLookup.list_active() resolves the self-Facility row # written by bootstrap_federation above. The projection worker diff --git a/apps/api/tests/integration/test_beamline_staff_seed_postgres.py b/apps/api/tests/integration/test_beamline_staff_seed_postgres.py index 17304f8adf3..bb3e5be27cd 100644 --- a/apps/api/tests/integration/test_beamline_staff_seed_postgres.py +++ b/apps/api/tests/integration/test_beamline_staff_seed_postgres.py @@ -20,9 +20,10 @@ from testcontainers.postgres import PostgresContainer from cora.api.beamline_staff_seed import ( + ADMIN_ACTOR_ID, BEAMLINE_STAFF_SLOTS, - OPERATOR_A_ACTOR_ID, - OPERATOR_B_ACTOR_ID, + GROUP_MANAGER_ACTOR_ID, + STAFF_ACTOR_ID, seed_beamline_staff, ) from cora.infrastructure.postgres.pool import create_pool @@ -33,8 +34,9 @@ SeedDatabase = tuple[asyncpg.Pool, str] _NAMES: dict[str, str | None] = { - "2-bm-operator-a": "Test Operator A", - "2-bm-operator-b": "Test Operator B", + "2-bm-admin": "Test Admin", + "2-bm-group-manager": "Test Manager", + "2-bm-staff": "Test Staff", } @@ -84,7 +86,7 @@ async def test_ceremony_seeds_both_actors_with_pinned_ids_and_human_kind( exit_code = await _run_ceremony(url) assert exit_code == 2 - for actor_id in (OPERATOR_A_ACTOR_ID, OPERATOR_B_ACTOR_ID): + for actor_id in (ADMIN_ACTOR_ID, GROUP_MANAGER_ACTOR_ID, STAFF_ACTOR_ID): row = await pool.fetchrow( "SELECT event_type, payload FROM events WHERE stream_id = $1", actor_id ) @@ -112,7 +114,7 @@ async def test_seeded_event_payload_carries_no_name(seed_database: SeedDatabase) pool, url = seed_database assert await _run_ceremony(url) == 2 - for actor_id in (OPERATOR_A_ACTOR_ID, OPERATOR_B_ACTOR_ID): + for actor_id in (ADMIN_ACTOR_ID, GROUP_MANAGER_ACTOR_ID, STAFF_ACTOR_ID): payload = await pool.fetchval("SELECT payload FROM events WHERE stream_id = $1", actor_id) assert "name" not in payload @@ -121,17 +123,15 @@ async def test_seeded_name_lands_only_in_the_profile_vault(seed_database: SeedDa pool, url = seed_database assert await _run_ceremony(url) == 2 - row = await pool.fetchrow( - "SELECT name FROM actor_profile WHERE actor_id = $1", OPERATOR_A_ACTOR_ID - ) + row = await pool.fetchrow("SELECT name FROM actor_profile WHERE actor_id = $1", ADMIN_ACTOR_ID) assert row is not None - assert row["name"] == "Test Operator A" + assert row["name"] == "Test Admin" row_b = await pool.fetchrow( - "SELECT name FROM actor_profile WHERE actor_id = $1", OPERATOR_B_ACTOR_ID + "SELECT name FROM actor_profile WHERE actor_id = $1", GROUP_MANAGER_ACTOR_ID ) assert row_b is not None - assert row_b["name"] == "Test Operator B" + assert row_b["name"] == "Test Manager" async def test_dry_run_writes_nothing(seed_database: SeedDatabase) -> None: @@ -155,7 +155,7 @@ async def test_dry_run_writes_nothing(seed_database: SeedDatabase) -> None: async def test_missing_name_fails_loudly_and_writes_nothing(seed_database: SeedDatabase) -> None: pool, url = seed_database - exit_code = await _run_ceremony(url, names={"2-bm-operator-a": "Test Operator A"}) + exit_code = await _run_ceremony(url, names={"2-bm-admin": "Test Admin"}) assert exit_code == 1 stream_ids = [slot.actor_id for slot in BEAMLINE_STAFF_SLOTS] @@ -169,7 +169,7 @@ async def test_blank_name_fails_loudly_and_writes_nothing(seed_database: SeedDat _, url = seed_database exit_code = await _run_ceremony( - url, names={"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": " "} + url, names={"2-bm-admin": "Test Admin", "2-bm-group-manager": " "} ) assert exit_code == 1 diff --git a/apps/api/tests/unit/agent/test_fleet_readiness.py b/apps/api/tests/unit/agent/test_fleet_readiness.py new file mode 100644 index 00000000000..9d31f58f18d --- /dev/null +++ b/apps/api/tests/unit/agent/test_fleet_readiness.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +import structlog.testing + +from cora.agent._seeded_fleet import SEEDED_FLEET, SeededAgent +from cora.agent.aggregates.agent import AgentStatus +from cora.agent.fleet_readiness import ( + FleetReadiness, + fleet_readiness, + log_fleet_readiness, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +_A = SeededAgent(UUID("00000000-0000-0000-0000-0000000000a1"), "AlphaWatcher") +_B = SeededAgent(UUID("00000000-0000-0000-0000-0000000000b2"), "BetaWatcher") +_C = SeededAgent(UUID("00000000-0000-0000-0000-0000000000c3"), "GammaWatcher") +_FLEET = (_A, _B, _C) + + +def test_fleet_readiness_versioned_member_counts_as_ready() -> None: + result = fleet_readiness({_A.agent_id: AgentStatus.VERSIONED}, (_A,)) + assert result.ready == ("AlphaWatcher",) + assert result.stranded is False + + +def test_fleet_readiness_defined_member_reports_stranded() -> None: + result = fleet_readiness({_A.agent_id: AgentStatus.DEFINED}, (_A,)) + assert result.not_ready == ("AlphaWatcher",) + assert result.stranded is True + + +@pytest.mark.parametrize( + ("status", "bucket"), + [ + (AgentStatus.SUSPENDED, "held"), + (AgentStatus.DEPRECATED, "retired"), + ], +) +def test_fleet_readiness_deliberately_stopped_member_is_not_stranded( + status: AgentStatus, bucket: str +) -> None: + result = fleet_readiness({_A.agent_id: status}, (_A,)) + assert getattr(result, bucket) == ("AlphaWatcher",) + assert result.stranded is False + + +def test_fleet_readiness_member_missing_from_the_record_lands_in_absent() -> None: + result = fleet_readiness({_A.agent_id: None}, (_A,)) + assert result.absent == ("AlphaWatcher",) + + +def test_fleet_readiness_member_missing_from_the_lookup_still_counts() -> None: + """A member the caller never looked up must not vanish from the total. + + Ranging over the status map instead of the fleet would silently + shrink the denominator, so "2 of 2 ready" would be reported for a + fleet of three with one unread. That is the same silent-incompleteness + shape the fleet value was introduced to close. + """ + result = fleet_readiness({_A.agent_id: AgentStatus.VERSIONED}, _FLEET) + assert result.total == 3 + assert result.absent == ("BetaWatcher", "GammaWatcher") + + +def test_fleet_readiness_sorts_a_mixed_fleet_into_every_bucket() -> None: + result = fleet_readiness( + { + _A.agent_id: AgentStatus.VERSIONED, + _B.agent_id: AgentStatus.DEFINED, + _C.agent_id: AgentStatus.SUSPENDED, + }, + _FLEET, + ) + assert (result.ready, result.not_ready, result.held) == ( + ("AlphaWatcher",), + ("BetaWatcher",), + ("GammaWatcher",), + ) + assert result.total == 3 + + +def test_fleet_readiness_ignores_a_status_for_an_unshipped_agent() -> None: + result = fleet_readiness( + {_A.agent_id: AgentStatus.VERSIONED, uuid4(): AgentStatus.DEFINED}, (_A,) + ) + assert result.total == 1 + assert result.stranded is False + + +def test_fleet_readiness_reports_names_in_the_fleet_s_own_order() -> None: + forward = fleet_readiness( + dict.fromkeys((m.agent_id for m in _FLEET), AgentStatus.DEFINED), _FLEET + ) + assert forward.not_ready == ("AlphaWatcher", "BetaWatcher", "GammaWatcher") + + +def test_fleet_readiness_over_the_real_shipped_fleet_counts_every_member() -> None: + """The denominator has to be the fleet CORA actually ships. + + Pinning it against `SEEDED_FLEET` rather than a fixture is what makes + a future agent added without a readiness story show up here as well as + in the completeness test. + """ + result = fleet_readiness({}) + assert result.total == len(SEEDED_FLEET) + assert len(result.absent) == len(SEEDED_FLEET) + + +def _emit(readiness: FleetReadiness) -> Sequence[Mapping[str, Any]]: + with structlog.testing.capture_logs() as logs: + log_fleet_readiness(readiness) + return logs + + +def test_log_fleet_readiness_stranded_fleet_warns_and_names_the_remedy() -> None: + entry = next( + e + for e in _emit(FleetReadiness((), ("RunWitness",), (), (), ())) + if e["event"] == "agent_fleet.stranded" + ) + assert entry["log_level"] == "warning" + assert entry["not_ready"] == ["RunWitness"] + assert entry["remedy"] == "promote_seeded_fleet" + + +def test_log_fleet_readiness_healthy_fleet_does_not_warn() -> None: + logs = _emit(FleetReadiness(("RunWitness",), (), (), (), ())) + assert [e["event"] for e in logs] == ["agent_fleet.ready"] + assert not [e for e in logs if e["log_level"] == "warning"] + + +def test_log_fleet_readiness_suspended_member_alone_does_not_warn() -> None: + """A paused agent is somebody's decision, not a fault. + + Warning about it would train an operator to ignore the line, which + costs exactly the signal this function exists to add. + """ + assert not [ + e for e in _emit(FleetReadiness(("A",), (), ("B",), (), ())) if e["log_level"] == "warning" + ] + + +def test_log_fleet_readiness_absent_member_warns_on_its_own_line() -> None: + logs = _emit(FleetReadiness(("A",), (), (), (), ("B",))) + assert [e["event"] for e in logs] == ["agent_fleet.ready", "agent_fleet.absent"] + assert next(e for e in logs if e["event"] == "agent_fleet.absent")["log_level"] == "warning" diff --git a/apps/api/tests/unit/api/test_beamline_staff_seed.py b/apps/api/tests/unit/api/test_beamline_staff_seed.py index 7fe506ac24a..1108fbeda96 100644 --- a/apps/api/tests/unit/api/test_beamline_staff_seed.py +++ b/apps/api/tests/unit/api/test_beamline_staff_seed.py @@ -3,7 +3,7 @@ The database-touching flow lives in the integration tier (test_beamline_staff_seed_postgres); this tier pins what must never -drift without ceremony: the two pinned actor ids (a re-pin orphans +drift without ceremony: the three pinned actor ids (a re-pin orphans every deployment that already ran this ceremony), the loud refusal to seed a blank display name, and the CLI/env-var defaulting. @@ -17,9 +17,10 @@ import pytest from cora.api.beamline_staff_seed import ( + ADMIN_ACTOR_ID, BEAMLINE_STAFF_SLOTS, - OPERATOR_A_ACTOR_ID, - OPERATOR_B_ACTOR_ID, + GROUP_MANAGER_ACTOR_ID, + STAFF_ACTOR_ID, _BeamlineStaffNameMissingError, # pyright: ignore[reportPrivateUsage] _Report, # pyright: ignore[reportPrivateUsage] _require_all_names_configured, # pyright: ignore[reportPrivateUsage] @@ -29,28 +30,41 @@ pytestmark = pytest.mark.unit -def test_operator_actor_ids_are_the_locked_constants() -> None: - assert UUID("02900000-0000-7000-9000-0000000a0010") == OPERATOR_A_ACTOR_ID - assert UUID("02900000-0000-7000-9000-0000000b0010") == OPERATOR_B_ACTOR_ID +def test_role_actor_ids_are_the_locked_constants() -> None: + """The ids outlive the labels. + + `2-bm-admin` became `2-bm-admin` without minting anything: the + id IS the person as far as the record is concerned, and every grant + already made hangs off it. A re-pin here would orphan those and + create a second actor for the same human, which has already happened + once at this deployment and took an hour to find. + """ + assert UUID("02900000-0000-7000-9000-0000000a0010") == ADMIN_ACTOR_ID + assert UUID("02900000-0000-7000-9000-0000000b0010") == GROUP_MANAGER_ACTOR_ID + assert UUID("02900000-0000-7000-9000-0000000c0010") == STAFF_ACTOR_ID def test_operator_actor_ids_are_distinct_from_the_seeded_agent_range() -> None: """Seeded agents live under `01900000-0000-7000-8000-...`; a human staff id must never fall in that range, or a UUID alone can no longer tell a human actor from a seeded agent.""" - for actor_id in (OPERATOR_A_ACTOR_ID, OPERATOR_B_ACTOR_ID): + for actor_id in (ADMIN_ACTOR_ID, GROUP_MANAGER_ACTOR_ID, STAFF_ACTOR_ID): assert str(actor_id).startswith("02900000-") assert "-8000-" not in str(actor_id) -def test_beamline_staff_slots_has_exactly_two_slots_with_distinct_ids() -> None: - assert len(BEAMLINE_STAFF_SLOTS) == 2 +def test_beamline_staff_slots_has_one_slot_per_role_with_distinct_ids() -> None: + assert len(BEAMLINE_STAFF_SLOTS) == 3 actor_ids = {slot.actor_id for slot in BEAMLINE_STAFF_SLOTS} event_ids = {slot.event_id for slot in BEAMLINE_STAFF_SLOTS} correlation_ids = {slot.correlation_id for slot in BEAMLINE_STAFF_SLOTS} - assert len(actor_ids) == 2 - assert len(event_ids) == 2 - assert len(correlation_ids) == 2 + # Against the slot count, not a literal. Two slots sharing an id is + # the failure worth catching, and a hardcoded 2 stops catching it the + # moment a third slot is added -- which is exactly when the risk of a + # copied-and-not-edited id is highest. + assert len(actor_ids) == len(BEAMLINE_STAFF_SLOTS) + assert len(event_ids) == len(BEAMLINE_STAFF_SLOTS) + assert len(correlation_ids) == len(BEAMLINE_STAFF_SLOTS) def test_beamline_staff_slots_env_vars_are_distinct() -> None: @@ -58,29 +72,29 @@ def test_beamline_staff_slots_env_vars_are_distinct() -> None: assert len(env_vars) == len(BEAMLINE_STAFF_SLOTS) -def test_require_all_names_configured_passes_when_both_names_present() -> None: - _require_all_names_configured( - {"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": "Test Operator B"} - ) +def test_require_all_names_configured_passes_when_every_slot_is_named() -> None: + """Built from the slot tuple, so a new slot cannot pass this by default. + + A hand-written dict of names would keep passing after a fourth slot + arrived, and the ceremony would then refuse at deploy time against a + green suite. + """ + _require_all_names_configured({slot.slot: f"Test {slot.slot}" for slot in BEAMLINE_STAFF_SLOTS}) def test_require_all_names_configured_rejects_missing_slot() -> None: with pytest.raises(_BeamlineStaffNameMissingError): - _require_all_names_configured({"2-bm-operator-a": "Test Operator A"}) + _require_all_names_configured({"2-bm-admin": "Test Admin"}) def test_require_all_names_configured_rejects_blank_name() -> None: with pytest.raises(_BeamlineStaffNameMissingError): - _require_all_names_configured( - {"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": " "} - ) + _require_all_names_configured({"2-bm-admin": "Test Admin", "2-bm-group-manager": " "}) def test_require_all_names_configured_rejects_none_name() -> None: with pytest.raises(_BeamlineStaffNameMissingError): - _require_all_names_configured( - {"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": None} - ) + _require_all_names_configured({"2-bm-admin": "Test Admin", "2-bm-group-manager": None}) def test_require_all_names_configured_error_names_every_missing_slot() -> None: @@ -89,10 +103,10 @@ def test_require_all_names_configured_error_names_every_missing_slot() -> None: with pytest.raises(_BeamlineStaffNameMissingError) as excinfo: _require_all_names_configured({}) message = str(excinfo.value) - assert "2-bm-operator-a" in message - assert "2-bm-operator-b" in message - assert "BEAMLINE_STAFF_OPERATOR_A_NAME" in message - assert "BEAMLINE_STAFF_OPERATOR_B_NAME" in message + assert "2-bm-admin" in message + assert "2-bm-group-manager" in message + assert "BEAMLINE_STAFF_ADMIN_NAME" in message + assert "BEAMLINE_STAFF_GROUP_MANAGER_NAME" in message def test_require_all_names_configured_error_never_carries_a_name() -> None: @@ -100,73 +114,76 @@ def test_require_all_names_configured_error_never_carries_a_name() -> None: slots and env vars, never a value; assert the one name that WAS supplied does not leak into the message about the other slot.""" with pytest.raises(_BeamlineStaffNameMissingError) as excinfo: - _require_all_names_configured({"2-bm-operator-a": "Test Operator A"}) - assert "Test Operator A" not in str(excinfo.value) + _require_all_names_configured({"2-bm-admin": "Test Admin"}) + assert "Test Admin" not in str(excinfo.value) def test_report_all_exists_leaves_seeded_and_failed_unset() -> None: report = _Report(lines=[]) - report.note("exists", "actor 2-bm-operator-a") - report.note("exists", "actor 2-bm-operator-b") + report.note("exists", "actor 2-bm-admin") + report.note("exists", "actor 2-bm-group-manager") assert report.seeded is False assert report.failed is False def test_report_any_seed_marks_seeded() -> None: report = _Report(lines=[]) - report.note("exists", "actor 2-bm-operator-a") - report.note("seeded", "actor 2-bm-operator-b") + report.note("exists", "actor 2-bm-admin") + report.note("seeded", "actor 2-bm-group-manager") assert report.seeded is True assert report.failed is False def test_report_any_error_marks_failed() -> None: report = _Report(lines=[]) - report.note("seeded", "actor 2-bm-operator-a") + report.note("seeded", "actor 2-bm-admin") report.note("error", "ceremony", "synthetic failure") assert report.failed is True def test_parser_defaults_read_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BEAMLINE_STAFF_OPERATOR_A_NAME", "Test Operator A") - monkeypatch.setenv("BEAMLINE_STAFF_OPERATOR_B_NAME", "Test Operator B") + monkeypatch.setenv("BEAMLINE_STAFF_ADMIN_NAME", "Test Admin") + monkeypatch.setenv("BEAMLINE_STAFF_GROUP_MANAGER_NAME", "Test Manager") from importlib import reload from cora.api import beamline_staff_seed reload(beamline_staff_seed) args = beamline_staff_seed.build_parser().parse_args([]) - assert args.operator_a_name == "Test Operator A" - assert args.operator_b_name == "Test Operator B" + assert args.admin_name == "Test Admin" + assert args.group_manager_name == "Test Manager" reload(beamline_staff_seed) def test_parser_defaults_are_none_without_environment(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("BEAMLINE_STAFF_OPERATOR_A_NAME", raising=False) - monkeypatch.delenv("BEAMLINE_STAFF_OPERATOR_B_NAME", raising=False) + monkeypatch.delenv("BEAMLINE_STAFF_ADMIN_NAME", raising=False) + monkeypatch.delenv("BEAMLINE_STAFF_GROUP_MANAGER_NAME", raising=False) from importlib import reload from cora.api import beamline_staff_seed reload(beamline_staff_seed) args = beamline_staff_seed.build_parser().parse_args([]) - assert args.operator_a_name is None - assert args.operator_b_name is None + assert args.admin_name is None + assert args.group_manager_name is None assert args.dry_run is False def test_parser_accepts_cli_overrides() -> None: args = build_parser().parse_args( [ - "--operator-a-name", - "Test Operator A", - "--operator-b-name", - "Test Operator B", + "--admin-name", + "Test Admin", + "--group-manager-name", + "Test Manager", + "--staff-name", + "Test Staff", "--dry-run", ] ) - assert args.operator_a_name == "Test Operator A" - assert args.operator_b_name == "Test Operator B" + assert args.admin_name == "Test Admin" + assert args.group_manager_name == "Test Manager" + assert args.staff_name == "Test Staff" assert args.dry_run is True @@ -185,17 +202,20 @@ async def fake_ceremony(**kwargs: object) -> int: exit_code = beamline_staff_seed.main( [ - "--operator-a-name", - "Test Operator A", - "--operator-b-name", - "Test Operator B", + "--admin-name", + "Test Admin", + "--group-manager-name", + "Test Manager", + "--staff-name", + "Test Staff", "--dry-run", ] ) assert exit_code == 2 assert received["names_by_slot"] == { - "2-bm-operator-a": "Test Operator A", - "2-bm-operator-b": "Test Operator B", + "2-bm-admin": "Test Admin", + "2-bm-group-manager": "Test Manager", + "2-bm-staff": "Test Staff", } assert received["dry_run"] is True diff --git a/apps/api/tests/unit/api/test_status_push.py b/apps/api/tests/unit/api/test_status_push.py index 44c012f28c4..56bcc6aee92 100644 --- a/apps/api/tests/unit/api/test_status_push.py +++ b/apps/api/tests/unit/api/test_status_push.py @@ -110,6 +110,7 @@ def test_build_snapshot_shape() -> None: clearances=[], enclosures=[], decisions=[], + agents={"ready": 2, "total": 2, "not_ready": [], "held": [], "absent": []}, sequence=3, generated_at="2026-06-22T12:00:00+00:00", producer_id="p1", @@ -128,6 +129,7 @@ def test_build_snapshot_shape() -> None: "clearances": [], "enclosures": [], "decisions": [], + "agents": {"ready": 2, "total": 2, "not_ready": [], "held": [], "absent": []}, } diff --git a/docs/deployments/2-bm/governance.md b/docs/deployments/2-bm/governance.md index c44251076a4..7dbd1eff323 100644 --- a/docs/deployments/2-bm/governance.md +++ b/docs/deployments/2-bm/governance.md @@ -5,19 +5,32 @@ the per-run [decisions](experiment.md) operators and agents make are live, not h ## Who acts -Two beamline staff hold operator principals at 2-BM today, seeded as `human` Actors by the +Three named roles hold human principals at 2-BM today, seeded as `human` Actors by the `cora.api.beamline_staff_seed` ceremony under pinned, deployment-stable ids. Their display names are personal data: the ceremony writes each name only to the `actor_profile` PII vault, supplied at deploy time from the host -environment. The repository carries the pinned seat ids and nothing else about these two people: an id is opaque, -and no name, badge, ORCID, or address is checked in anywhere. Facility-process -principals (proposal PIs, the safety review board, the beamline scientist acting in a review-chain capacity) are -facility-wide and live at [APS](../aps/index.md#safety-and-governance). See [Model](../../architecture/model.md) -for the aggregate shape. +environment. The repository carries the pinned ids and the role labels and nothing else about these people: an id +is opaque, a role is not personal data, and no name, badge, ORCID, or address is checked in anywhere. +Facility-process principals (proposal PIs, the safety review board, the beamline scientist acting in a +review-chain capacity) are facility-wide and live at [APS](../aps/index.md#safety-and-governance). See +[Model](../../architecture/model.md) for the aggregate shape. -| Actor | Kind | -| --- | --- | -| 2-BM operator (seat A) | `human` | -| 2-BM operator (seat B) | `human` | +| Slot | Kind | Holds | +| --- | --- | --- | +| `2-bm-admin` | `human` | deployment administration | +| `2-bm-group-manager` | `human` | the imaging group, across more than one beamline | +| `2-bm-staff` | `human` | 2-BM itself | + +The slots were `2-bm-operator-a` / `2-bm-operator-b` until the third role arrived. Only the labels changed: the +pinned ids are literals and did not move, because an id is what every grant already made hangs off, and re-pinning +one would leave a second Actor for the same person. That has happened here once already. + +**A role carries no scope, and today that is a gap.** A `Policy` holds `(principal, command)` pairs gated by a +Conduit and a Surface; there is no beamline dimension in a grant. So "manages the imaging group across several +beamlines" and "staffs this one" are indistinguishable to authorization, and the group manager and the beamline +staff hold the same commands here. That costs nothing while CORA runs at a single beamline and becomes real at +the second. It is a gap in the Policy model rather than something a slot label can close, and it must not be +papered over by giving the two roles different COMMANDS: that would record a difference of scope as a difference +of capability, which is both false and hard to unpick later. ## The trust boundary diff --git a/infra/status-relay/design/page-preview.html b/infra/status-relay/design/page-preview.html index 8a5347c0eea..8ca573d2e82 100644 --- a/infra/status-relay/design/page-preview.html +++ b/infra/status-relay/design/page-preview.html @@ -321,6 +321,24 @@ { enclosure_id: "enc1", name: "2-BM-B hutch", permit_status: "Permitted", facility_code: "aps" }, ], + // The fleet readiness row. Deliberately STRANDED here, matching the + // real 2-BM deployment on the day this shipped (16 of 20 registered + // but never promoted). A fixture with a healthy fleet would render + // the "ready to act" row and silently exercise none of the reporting + // that exists for the case worth catching. + agents: { + ready: 4, + total: 20, + not_ready: [ + "AuthorityRevocationHolder", "CalibrationWatcher", "CampaignWatcher", + "CaptureBaselineReader", "CaptureProgressFeeder", "CautionDrafter", + "CautionPromoter", "ClearanceExpirer", "ClearanceWatcher", + "ExperimentSteerer", "ProcedureWatcher", "RatificationEnforcer", + "RunDebriefer", "RunInitiator", "RunSupervisor", "RunWitness", + ], + held: [], + absent: [], + }, decisions: [ { decision_id: "d1", choice: "continue", confidence_band: "High", decided_by: "00000000-0000-4000-8000-000000000042", created_at: iso(14 * 60 * 1000) }, diff --git a/infra/status-relay/page.html b/infra/status-relay/page.html index 244fd269933..cf19c88b8a5 100644 --- a/infra/status-relay/page.html +++ b/infra/status-relay/page.html @@ -853,6 +853,43 @@

Live activity

// Guarded against a null snapshot, not just a missing list: the page paints // once before the first one lands, which is the whole reason `buildRows` // reads every list through an accessor rather than off the object. + // An Agent registered but never promoted is INERT: the subscribers refuse + // anything below Versioned and say nothing when they do, which is how a + // fleet sat stranded at this beamline for three months behind a clean log. + // The page is the surface an operator already has open, so it says so here. + function fleetRows(snap) { + var f = snap && snap.agents; + if (!f || typeof f.total !== "number") return []; + // Drawn whether or not anything is wrong. A row that appears only on a + // bad day teaches its reader that no row means nothing to check, which is + // the same lesson that hid the problem in the first place. + var out = [["ready to act", f.ready + " of " + f.total]]; + if ((f.not_ready || []).length) { + out.push(["registered but inert", String(f.not_ready.length) + ", run promote_seeded_fleet"]); + } + // Paused is somebody's decision and absent is a half-seeded deployment. + // Neither is the inert case, and folding them into it would report a + // fault where a person made a choice. + if ((f.held || []).length) out.push(["paused by an operator", String(f.held.length)]); + if ((f.absent || []).length) out.push(["never seeded", String(f.absent.length)]); + return out; + } + + function agentLines(snap) { + var f = snap && snap.agents; + if (!f) return null; + return (f.not_ready || []) + .map(function (n) { return [n, "inert"]; }) + .concat((f.held || []).map(function (n) { return [n, "paused"]; })) + .concat((f.absent || []).map(function (n) { return [n, "not seeded"]; })); + } + + function fleetNote(snap) { + var f = snap && snap.agents; + if (!f || !(f.not_ready || []).length) return ""; + return f.not_ready.length + " of " + f.total + " agents registered but inert"; + } + function datasetLines(snap) { return ((snap && snap.datasets) || []).map(function (d) { return [d.name, d.status]; @@ -909,7 +946,13 @@

Live activity

// A lane whatever happens, so its zone is never a bare caption and so an // empty one reads as "nothing happened here" rather than as a row nobody // drew. Every other domain appears only when it holds something. - var ALWAYS_ON = ["datasets", "decisions", "cautions", "other"]; + // `actors` earns its place here for a different reason from the rest. The + // others are always drawn so an empty one reads as "nothing happened" rather + // than as a row nobody drew. This one is always drawn because its CARD + // carries whether the agent fleet can act at all, and that answer matters + // most on exactly the days no agent has emitted anything, which is when a + // presence-gated lane would be missing. + var ALWAYS_ON = ["datasets", "decisions", "cautions", "other", "actors"]; // Domains that get instance TRACKS above their flat lane. Only for these // can a flat lane mean "the ones with no row of their own"; for the rest it // is the domain's only home, and calling it elsewhere would be a lie. @@ -1171,7 +1214,9 @@

Live activity

? datasetLines(snap) : key === "decisions" ? decisionLines(snap) - : null, + : key === "actors" + ? agentLines(snap) + : null, note: tracked ? "Events whose instance has no row of its own above: it ended before " + "this window opened, or it fell past the " + MAX_TRACK_ROWS + "-row cap. " + @@ -1187,11 +1232,14 @@

Live activity

(DOMAIN_TYPES[key] || []).join(", ") || "anything this page cannot file", ], ["events on this lane", String(points.length)], + ] + .concat(key === "actors" ? fleetRows(snap) : []) + .concat([ [ "zone", DOMAIN_ZONE[key] ? ZONES[DOMAIN_ZONE[key]] : "none, by design", ], - ], + ]), }, }); }); @@ -1437,6 +1485,12 @@

Live activity

// here, the same blind spot the flowing view has for anything older. var note = retentionNote(buffer); if (note) subtitle += " \u00b7 " + note; + // In the subtitle and not only on the lane card, because the card needs a + // hover and this is the sentence that should reach someone who never + // thought to ask. Silent when the fleet is fine: an always-on warning is + // one nobody reads. + var fleet = fleetNote(flowing.snapshot); + if (fleet) subtitle += " \u00b7 " + fleet; if (runStarts > 0) { subtitle += " ยท " + runStarts + " run" + (runStarts === 1 ? "" : "s") + " started"; }