mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(daemon,ui): PL-005 Mission Control / Queue Observability Phase A
Implements Phase A per ratified slice contract; founder Q1-Q5 (2026-05-03)
applied verbatim:
Q1 Dashboard graduation: 7 views + 7 verbs on day one.
Q2 PL-004 Phase B dependency: read PL-004 daemon-backed view layer
as source-of-truth (filesystem fallback only for graceful degradation).
Q3 Human queues: first-class product concept (HumanSeatCard).
Q4 Recent ships: 10 by default.
Q5 Action audit: daemon-backed SQLite table with archive-policy hooks
(no JSONL mirror unless dogfood discovers concrete durability need).
Audit-first results (15 rows; per IMPL.md):
Row 1: GREEN (migrations 023-036 present; PL-005 numbered 037, NOT
the packet's provisional 033 — Phase D shipped 033-036).
Row 2: GREEN (db/connection.ts + db/migrate.ts unchanged).
Row 3: GREEN (PL-004 domain unmodified at HEAD).
Row 4: GREEN (UI shell at packages/ui/src/{AppShell,routes}.tsx).
Row 5: YELLOW (agentActivity in CLI allow-list; recoveryGuidance NOT
in 0.2.0 allow-list; not a blocker — exactly the cross-CLI-
version drift the 4-sub-clause graceful degradation contract
is designed to handle).
Row 6: GREEN (POC services/dashboard available as vocabulary).
Row 7: GREEN (stream surface readable: stream_items + .jsonl).
Row 8: GREEN (human-seat first-class design encoded).
HARD-GATES 9-15: ALL GREEN (Q1-Q5 encoded; daemon has Coordination
Primitive bundle; explicit OPENRIG_DB pattern; no in-flight
PL-004 revision; no migration test; cross-CLI-drift handled
by spec; 9-field model preserved).
Migration:
- 037_mission_control_actions: append-only audit. 7-verb enum at
application layer (matches Phase C/D pattern). FK to queue_items.
Indexes on (acted_at DESC, action_verb), (qitem_id, acted_at DESC),
(actor_session, acted_at DESC).
Daemon domain (4 new files, 1 new subdirectory):
- mission-control-action-log: append-only writer; verb-specific
required-field validation (annotate→annotation; hold/drop→reason).
API surface lacks update/delete/remove (contract enforcement).
- mission-control-write-contract: LOAD-BEARING. Atomic 7-verb
coordinator. Each verb is one db.transaction: queue mutation via
Phase D's QueueRepository.updateWithinTransaction (preserves Phase A
hot-potato closure validation; emits queue.updated) + audit row +
persisted mission_control.action_executed event. The 4-step handoff
shape (source-update + destination-create + opt-in best-effort
notify + audit append) is verified atomic; notify failure does NOT
roll back durable mutations (PRD invariant). Failure-injection
rollback test included.
- mission-control-read-layer: maps 7 views to source-of-truth paths.
PL-004 daemon-backed services as primary; filesystem only for
graceful degradation. recently-active delegates to PL-004 Phase B
ViewProjector.show("recently-active"). recent-observations reads
PL-004 Phase A stream_items via StreamStore.
- mission-control-fleet-cli-capability: per-rig CLI capability cache
+ fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source
Drift Acceptance (per-field availability, per-rig honesty,
once-per-session-per-rig logging, "rigs running stale CLI"
indicator).
Routes (1 new file):
- routes/mission-control.ts: GET /views (list), GET /views/:view-name,
POST /action, GET /sse + /watch, GET /cli-capabilities. SSE/literal
paths mounted BEFORE /views/:view-name catchall per PL-004 Phase A
R1 SSE route-order discipline.
3 new RigEvents added to types.ts (additive; existing events untouched):
- mission_control.action_executed
- mission_control.cli_drift_detected
- mission_control.view_refreshed
Wiring:
- server.ts: AppDeps extended; context-set; route mounted.
- startup.ts: Mission Control services constructed after PL-004
Phase D wiring so all daemon-backed coordination surfaces are
available.
Product UI (NEW Mission Control surface inside existing shell):
- MissionControlSurface.tsx: top-level container with tab nav across
7 view names. Mounted at /mission-control route in routes.tsx.
- 7 view components (My queue, Human gate, Fleet, Active work, Recent
ships, Recently active, Recent observations). Most wrap shared
GenericListView; FleetView combines CliDriftIndicator + per-row
MissingFieldPlaceholder; MyQueueView combines HumanSeatCard +
GenericListView with verb actions enabled.
- CompactStatusRow.tsx: 9-FIELD PHONE-FRIENDLY CONTENT MODEL ATOM.
NON-NEGOTIABLE per PRD § Acceptance Criteria item 1. Renders
compact / expanded densities; UI may collapse but JSON preserves
all 9 fields (rig/mission name, current phase, active/idle/
attention/blocked/degraded, next-action, pending-human-decision,
read-cost, last-update, confidence/freshness, evidence link).
- VerbActions.tsx: shared 7-verb sub-component with per-verb
required-field input and structured error display.
- HumanSeatCard.tsx: first-class human-seat rendering (founder Q3).
- CliDriftIndicator.tsx: fleet-level + per-row drift surfacing.
- 3 React Query hooks (useMissionControlView, useMissionControlAction,
useMissionControlCliCapabilities).
Tests (5 new daemon test files; 42 cases):
- mission-control-action-log.test.ts (8 cases): append-only contract,
verb-specific required-field validation, JSON encoding,
listForQitem/listForActor filters, no update/delete API.
- mission-control-write-contract.test.ts (10 cases): all 7 verbs
drive correct closure shape; ATOMIC ROLLBACK regression
(handoff with invalid destination rolls back source close + audit
+ new qitem); annotate has no queue mutation; route tags new packet.
- mission-control-read-layer.test.ts (7 cases): 7-view structural
coverage; 9-FIELD MODEL preserved across all 7 views (PRD
non-negotiable assertion); my-queue filters human-gate to operator;
recent-ships caps at 10 (founder Q4); active-work priority sort;
recent-observations reads from stream_items.
- mission-control-fleet-cli-capability.test.ts (7 cases): 4
sub-clauses of graceful degradation including once-per-session-
per-rig log dedup + per-rig honesty + queue-state activity summary.
- mission-control-routes.test.ts (10 cases): 7 endpoint round-trips
+ R1 SSE route-order regression + literal /views not shadowed.
Verification:
- tsc clean for all 3 packages (daemon, cli, ui).
- Per-package vitest baseline under explicit OPENRIG_HOME +
OPENRIG_DB=:memory: temp env:
- packages/daemon: 2603 / 2603 pass (was 2561 at base; +42 new
Mission Control daemon tests).
- UI test suite has 26 pre-existing failures from PostCSS/Tailwind
module resolution infra in this fresh worktree; CONFIRMED identical
at HEAD baseline (d15ca1f) via git stash + re-run before commit
(8 failed / 26 failed test files / 273 passed both with and
without Mission Control changes). Not a Mission Control regression;
documented as a worktree node_modules infrastructure observation.
- daemon + cli builds clean. UI vite build also fails on the same
PostCSS infra issue (tsc step succeeds; vite step fails). Same
pre-existing infra artifact, not a Mission Control regression.
Boundaries preserved:
- Worktree-only on phase-a-mission-control-queue-observability;
main untouched at d15ca1f.
- PL-004 surfaces consumed READ-ONLY beyond the orch-ratified
extension points (Phase D's QueueRepository.updateWithinTransaction
+createWithinTransaction + maybeNudge visibility, all of which
were added in PL-004 Phase D R1 and are already on main).
- POC services/dashboard filesystem state untouched (vocabulary
reference only; no migration/cutover ceremony).
- POC ~/.openrig/stream/<date>.jsonl untouched (read-only).
- PL-006 steering controls absent.
- No new managed app; no native mobile; no push notifications.
- No new CLI commands (Mission Control is product UI inside existing
shell).
- localhost-only auth at v0 (matches existing dashboard posture).
- Existing 36 PL-004 migrations untouched. Phase A adds ONE new
migration 037.
- No fleet sweep, no protected-host mutation, no external spend, no
destructive lifecycle, no VM-boundary actions, no remote push, no
tag, no publish, no live runtime adoption.
CLI shim drift caveat preserved: installed rig reports 0.2.0 but some
command help/output still carries legacy rigx wrapper behavior while
daemon APIs are green. Mission Control consumes daemon APIs directly,
so this caveat does not affect the Phase A surface, but is recorded
per dispatch packet instruction.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
d15ca1fe05
commit
33e01bd472
34 files changed
+3303
-2
No files matched your search
@@ -330,6 +330,35 @@ Migration boundary:
|
||||
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
|
||||
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
|
||||
- `037_mission_control_actions.ts` adds the PL-005 Phase A Mission Control action audit table. Append-only at the API surface; records every operator action through Mission Control with before/after qitem snapshots for forensic reconstruction (see § Mission Control / Queue Observability below).
|
||||
|
||||
### Mission Control / Queue Observability (PL-005 Phase A)
|
||||
|
||||
Daemon-backed Mission Control surface. Per PRD § Acceptance Criteria + founder steering 2026-05-03 (Q1-Q5): seven views + seven verbs + first-class human seats + recent-ships=10 + daemon-backed action audit table; no old-dashboard migration/cutover.
|
||||
|
||||
**Seven views** (`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`, `recently-active`, `recent-observations`) all return rows in the load-bearing 9-field phone-friendly content model (rig/mission name, current phase, active|idle|attention|blocked|degraded, next-action, pending-human-decision, read-cost, last-update timestamp, confidence/freshness, evidence link). The model is non-negotiable across all 7 views; UI may render compact, JSON preserves all 9.
|
||||
|
||||
`MissionControlReadLayer` maps each view to its source-of-truth path:
|
||||
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004 Phase A `queue_items` via `QueueRepository`.
|
||||
- `fleet` consumes the per-rig CLI capability cache + queue summary.
|
||||
- `recently-active` delegates to PL-004 Phase B `ViewProjector.show("recently-active")`.
|
||||
- `recent-observations` reads PL-004 Phase A `stream_items` via `StreamStore`.
|
||||
|
||||
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep) are graceful-degradation aids, NOT the primary path.
|
||||
|
||||
**Seven verbs** (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute through the load-bearing `MissionControlWriteContract`. Each verb is one atomic daemon transaction: queue mutation via `QueueRepository.updateWithinTransaction()` (which preserves Phase A hot-potato closure validation) + audit row in `mission_control_actions` + persisted `mission_control.action_executed` event, all in one `db.transaction`. The 4-step `handoff` shape (source-update + destination-create + opt-in best-effort notify + audit-record append) is verified atomic; notify failure does NOT roll back durable mutations (PRD invariant). Failure-injection rolls back source closure + audit row + new qitem together.
|
||||
|
||||
**Cross-CLI-version drift** is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance:
|
||||
1. Per-field availability check (`MissionControlFleetCliCapability.probeRig`).
|
||||
2. Per-rig capability honesty (each rig surfaces its own capabilities; mixed fleet rendered honestly).
|
||||
3. Once-per-session-per-rig logging (`MissionControlFleetCliCapability` deduplicates drift events by `(rig, field)` until daemon restart).
|
||||
4. Fleet-level "rigs running stale CLI" indicator (`FleetView` surfaces `staleCliCount` from `/api/mission-control/cli-capabilities`).
|
||||
|
||||
**Human seats are first-class product concepts** (founder Q3): the operator's default `human-wrandom@kernel` seat is rendered via `HumanSeatCard` showing identity, pending load, blocked count, and verb capabilities. NOT invisible config-layer convention.
|
||||
|
||||
**Mission Control is integrated product UI inside the existing shell** — a new top-level route `/mission-control` registered in `packages/ui/src/routes.tsx` mounting `MissionControlSurface`, NOT a new managed app, NOT a re-implementation of the proven `services/dashboard/` ancestor. The dashboard remains as a vocabulary/prototype reference; no production cutover/migration ceremony.
|
||||
|
||||
PL-005 Phase A extends `RigEvent` with 3 new types: `mission_control.action_executed`, `mission_control.cli_drift_detected`, `mission_control.view_refreshed`. Existing 32 PL-004 events untouched. Localhost-only auth at v0 (matches existing dashboard posture; remote-bind is a separate slice).
|
||||
|
||||
### Workflow Runtime (PL-004 Phase D)
|
||||
|
||||
|
||||
@@ -850,6 +850,14 @@ Notes:
|
||||
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
|
||||
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
|
||||
|
||||
## Mission Control / Queue Observability (PL-005 Phase A)
|
||||
|
||||
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
|
||||
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
|
||||
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
@@ -98,6 +98,23 @@ Fast lookup:
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/037_mission_control_actions.ts`: PL-005 Phase A — Mission Control action audit table (append-only; 7-verb enum at app layer; before/after qitem snapshots; FK to queue_items). Exports: missionControlActionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
|
||||
### Domain: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-action-log.ts`: Append-only writer for mission_control_actions. API surface lacks update/delete/remove (contract enforcement). Validates verb-specific required fields (annotate→annotation, hold/drop→reason). Exports: MissionControlActionLog, MissionControlActionLogError, MissionControlVerb, MISSION_CONTROL_VERBS. Related: `packages/daemon/src/db/migrations/037_mission_control_actions.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-write-contract.ts`: LOAD-BEARING. Atomic 7-verb action coordinator. Each verb is one db.transaction: queue mutation via Phase D's QueueRepository.updateWithinTransaction (preserves Phase A hot-potato closure validation; emits queue.updated) + audit row + persisted mission_control.action_executed event. The 4-step handoff shape (source-update + destination-create + opt-in best-effort notify + audit append) is verified atomic; notify failure does NOT roll back durable state. Exports: MissionControlWriteContract, MissionControlWriteContractError, MissionControlActionInput, MissionControlActionResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/mission-control/mission-control-action-log.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-read-layer.ts`: Maps 7 Mission Control views to source-of-truth paths. my-queue/human-gate/active-work/recent-ships query queue_items via QueueRepository; fleet uses fleetCliCapability roll-up; recently-active delegates to PL-004 Phase B ViewProjector.show("recently-active"); recent-observations reads stream_items via StreamStore. All rows return the 9-field phone-friendly content model (PRD non-negotiable). Exports: MissionControlReadLayer, MissionControlViewName, CompactStatusRow, MISSION_CONTROL_VIEWS. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/view-projector.js`, `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts`: Per-rig CLI capability cache + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: per-field availability check, per-rig honesty, once-per-session-per-rig logging, "rigs running stale CLI" indicator. Synthesizes per-rig activity state (active|idle|attention|blocked|degraded) from queue_items aggregation. Exports: MissionControlFleetCliCapability, FleetRollupRow, FleetRollup, MISSION_CONTROL_DESIRED_FIELDS. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/db/migrations/024_queue_items.js`.
|
||||
- `packages/daemon/src/routes/mission-control.ts`: Hono route module for `/api/mission-control` (`GET /views` literal list; `GET /views/:view-name` view fetch; `POST /action` 7-verb atomic execute; `GET /sse` + `GET /watch` SSE; `GET /cli-capabilities` fleet drift cache). SSE/literal paths mounted before /views/:view-name catchall per PL-004 Phase A R1 route-order discipline. Exports: missionControlRoutes. Related: `packages/daemon/src/domain/mission-control/*`, `packages/daemon/src/domain/event-bus.js`.
|
||||
|
||||
### Product UI: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/ui/src/components/mission-control/MissionControlSurface.tsx`: Top-level Mission Control surface inside the existing product UI shell. Tab navigation across the 7 view names. Mounted at the `/mission-control` route. Exports: MissionControlSurface.
|
||||
- `packages/ui/src/components/mission-control/views/{MyQueueView,HumanGateView,FleetView,ActiveWorkView,RecentShipsView,RecentlyActiveView,RecentObservationsView}.tsx`: 7 view components. Most wrap a shared GenericListView; FleetView combines CliDriftIndicator + per-row MissingFieldPlaceholder; MyQueueView combines HumanSeatCard + GenericListView with verb actions enabled.
|
||||
- `packages/ui/src/components/mission-control/components/CompactStatusRow.tsx`: 9-field phone-friendly content-model atom (PRD non-negotiable). Renders compact / expanded density modes; UI may collapse but JSON preserves all 9 fields.
|
||||
- `packages/ui/src/components/mission-control/components/VerbActions.tsx`: Shared 7-verb action sub-component. Renders verb buttons + per-verb required-field input (destination for route/handoff, annotation for annotate, reason for hold/drop). Submits to `POST /api/mission-control/action`. Invalidates Mission Control view queries on success.
|
||||
- `packages/ui/src/components/mission-control/components/HumanSeatCard.tsx`: First-class human-seat rendering (founder Q3). Shows session identity, pending load, blocked count, verb capabilities. NOT invisible config-layer convention.
|
||||
- `packages/ui/src/components/mission-control/components/CliDriftIndicator.tsx`: Cross-CLI-version drift indicator (PRD sub-clause 4). Shows fleet-level "rigs running stale CLI" badge + per-row "field unavailable on this rig's daemon version" placeholders via the MissingFieldPlaceholder export.
|
||||
- `packages/ui/src/components/mission-control/hooks/{useMissionControlView,useMissionControlAction,useMissionControlCliCapabilities}.ts`: TanStack Query hooks fetching from `/api/mission-control/{views/:name,action,cli-capabilities}`.
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Mission Control actions (PL-005 Phase A; daemon-backed action audit).
|
||||
*
|
||||
* Per PRD § Q5 + slice IMPL § Write Set: SQLite-canonical append-only
|
||||
* audit log of every operator action through Mission Control. Records
|
||||
* every invocation of the 7 Phase A verbs with before/after snapshots
|
||||
* for forensic reconstruction.
|
||||
*
|
||||
* Append-only contract: writers only INSERT. UPDATE/DELETE are not
|
||||
* exposed by MissionControlActionLog API; direct SQL UPDATE/DELETE
|
||||
* would succeed at the DB level (SQLite has no view/role layer) but
|
||||
* is a contract violation enforced at the domain-layer API boundary.
|
||||
*
|
||||
* Action verb enum (Phase A v1; 7 values):
|
||||
* - approve — operator approved a human-gated item
|
||||
* - deny — operator denied a human-gated item
|
||||
* - route — operator routed an item to a different destination
|
||||
* - annotate — operator added an annotation (annotation field required)
|
||||
* - hold — operator held an item with a reason (reason required)
|
||||
* - drop — operator dropped an item with a reason (reason required)
|
||||
* - handoff — atomic 4-step (source-update + destination-create
|
||||
* + opt-in best-effort notify + audit append)
|
||||
*
|
||||
* Columns:
|
||||
* - action_id (ULID PK)
|
||||
* - action_verb (TEXT, app-layer enum enforcement)
|
||||
* - qitem_id (TEXT, FK to queue_items when applicable; nullable for
|
||||
* observations / non-queue actions in future scope)
|
||||
* - actor_session (TEXT NOT NULL) — operator session that fired the verb
|
||||
* - acted_at (TEXT NOT NULL) — ISO timestamp
|
||||
* - before_state_json (TEXT) — snapshot of qitem state pre-action
|
||||
* - after_state_json (TEXT) — snapshot of qitem state post-action
|
||||
* - reason (TEXT) — required for hold + drop
|
||||
* - annotation (TEXT) — required for annotate
|
||||
* - notify_attempted (INTEGER 0|1) — for handoff
|
||||
* - notify_result (TEXT) — verified | sent-unverified | failed:<reason>
|
||||
* - audit_notes_json (TEXT) — operator-supplied evidence map
|
||||
*
|
||||
* Indexes:
|
||||
* - (acted_at DESC, action_verb) — recent-actions and per-verb scans
|
||||
* - (qitem_id, acted_at DESC) — per-qitem audit trail
|
||||
* - (actor_session, acted_at DESC) — per-operator audit
|
||||
*
|
||||
* Archive policy hooks: not implemented as automatic v1; the table is
|
||||
* append-only and grows unbounded. Future archive can move rows older
|
||||
* than N days into a sibling archive table OR export to filesystem.
|
||||
* Founder Q5 explicitly defers JSONL mirror unless a concrete
|
||||
* durability need surfaces in dogfood.
|
||||
*/
|
||||
export const missionControlActionsSchema: Migration = {
|
||||
name: "037_mission_control_actions.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS mission_control_actions (
|
||||
action_id TEXT PRIMARY KEY,
|
||||
action_verb TEXT NOT NULL,
|
||||
qitem_id TEXT REFERENCES queue_items(qitem_id),
|
||||
actor_session TEXT NOT NULL,
|
||||
acted_at TEXT NOT NULL,
|
||||
before_state_json TEXT,
|
||||
after_state_json TEXT,
|
||||
reason TEXT,
|
||||
annotation TEXT,
|
||||
notify_attempted INTEGER NOT NULL DEFAULT 0,
|
||||
notify_result TEXT,
|
||||
audit_notes_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mc_actions_recent
|
||||
ON mission_control_actions(acted_at DESC, action_verb);
|
||||
CREATE INDEX IF NOT EXISTS idx_mc_actions_qitem
|
||||
ON mission_control_actions(qitem_id, acted_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mc_actions_actor
|
||||
ON mission_control_actions(actor_session, acted_at DESC);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
// PL-005 Phase A: Mission Control action log (append-only).
|
||||
//
|
||||
// Owns inserts into mission_control_actions. Append-only at the API
|
||||
// surface: only `record()` is exposed. UPDATE/DELETE/remove are not
|
||||
// methods — direct SQL could mutate but is a contract violation
|
||||
// enforced at this domain boundary (per PRD § Q5 + slice IMPL).
|
||||
//
|
||||
// Pattern mirrors PL-004 Phase C's WatchdogHistoryLog and Phase A's
|
||||
// QueueTransitionLog and Phase D's WorkflowStepTrailLog.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const MISSION_CONTROL_VERBS = [
|
||||
"approve",
|
||||
"deny",
|
||||
"route",
|
||||
"annotate",
|
||||
"hold",
|
||||
"drop",
|
||||
"handoff",
|
||||
] as const;
|
||||
|
||||
export type MissionControlVerb = (typeof MISSION_CONTROL_VERBS)[number];
|
||||
|
||||
export interface MissionControlActionRecordInput {
|
||||
actionVerb: MissionControlVerb;
|
||||
qitemId: string | null;
|
||||
actorSession: string;
|
||||
actedAt: string;
|
||||
beforeState?: Record<string, unknown> | null;
|
||||
afterState?: Record<string, unknown> | null;
|
||||
reason?: string | null;
|
||||
annotation?: string | null;
|
||||
notifyAttempted?: boolean;
|
||||
notifyResult?: string | null;
|
||||
auditNotes?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface MissionControlActionEntry {
|
||||
actionId: string;
|
||||
actionVerb: MissionControlVerb;
|
||||
qitemId: string | null;
|
||||
actorSession: string;
|
||||
actedAt: string;
|
||||
beforeState: Record<string, unknown> | null;
|
||||
afterState: Record<string, unknown> | null;
|
||||
reason: string | null;
|
||||
annotation: string | null;
|
||||
notifyAttempted: boolean;
|
||||
notifyResult: string | null;
|
||||
auditNotes: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface ActionRow {
|
||||
action_id: string;
|
||||
action_verb: string;
|
||||
qitem_id: string | null;
|
||||
actor_session: string;
|
||||
acted_at: string;
|
||||
before_state_json: string | null;
|
||||
after_state_json: string | null;
|
||||
reason: string | null;
|
||||
annotation: string | null;
|
||||
notify_attempted: number;
|
||||
notify_result: string | null;
|
||||
audit_notes_json: string | null;
|
||||
}
|
||||
|
||||
export class MissionControlActionLogError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MissionControlActionLogError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MissionControlActionLog {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
/**
|
||||
* Append an action record. Validates verb-specific required fields
|
||||
* (annotate → annotation; hold/drop → reason). Designed to compose
|
||||
* inside an outer caller-managed transaction (used by the
|
||||
* write-contract for the atomic 4-step handoff).
|
||||
*/
|
||||
record(input: MissionControlActionRecordInput): MissionControlActionEntry {
|
||||
if (!MISSION_CONTROL_VERBS.includes(input.actionVerb)) {
|
||||
throw new MissionControlActionLogError(
|
||||
"verb_unknown",
|
||||
`unknown action_verb '${input.actionVerb}'; Phase A v1 supports: ${MISSION_CONTROL_VERBS.join(", ")}`,
|
||||
{ actionVerb: input.actionVerb, supported: [...MISSION_CONTROL_VERBS] },
|
||||
);
|
||||
}
|
||||
if (input.actionVerb === "annotate" && !input.annotation) {
|
||||
throw new MissionControlActionLogError(
|
||||
"annotation_required",
|
||||
`action_verb=annotate requires annotation`,
|
||||
{ actionVerb: input.actionVerb },
|
||||
);
|
||||
}
|
||||
if ((input.actionVerb === "hold" || input.actionVerb === "drop") && !input.reason) {
|
||||
throw new MissionControlActionLogError(
|
||||
"reason_required",
|
||||
`action_verb=${input.actionVerb} requires reason`,
|
||||
{ actionVerb: input.actionVerb },
|
||||
);
|
||||
}
|
||||
const actionId = ulid();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mission_control_actions (
|
||||
action_id, action_verb, qitem_id, actor_session, acted_at,
|
||||
before_state_json, after_state_json, reason, annotation,
|
||||
notify_attempted, notify_result, audit_notes_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
actionId,
|
||||
input.actionVerb,
|
||||
input.qitemId ?? null,
|
||||
input.actorSession,
|
||||
input.actedAt,
|
||||
input.beforeState ? JSON.stringify(input.beforeState) : null,
|
||||
input.afterState ? JSON.stringify(input.afterState) : null,
|
||||
input.reason ?? null,
|
||||
input.annotation ?? null,
|
||||
input.notifyAttempted ? 1 : 0,
|
||||
input.notifyResult ?? null,
|
||||
input.auditNotes ? JSON.stringify(input.auditNotes) : null,
|
||||
);
|
||||
return {
|
||||
actionId,
|
||||
actionVerb: input.actionVerb,
|
||||
qitemId: input.qitemId ?? null,
|
||||
actorSession: input.actorSession,
|
||||
actedAt: input.actedAt,
|
||||
beforeState: input.beforeState ?? null,
|
||||
afterState: input.afterState ?? null,
|
||||
reason: input.reason ?? null,
|
||||
annotation: input.annotation ?? null,
|
||||
notifyAttempted: Boolean(input.notifyAttempted),
|
||||
notifyResult: input.notifyResult ?? null,
|
||||
auditNotes: input.auditNotes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
listRecent(limit = 50): MissionControlActionEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM mission_control_actions
|
||||
ORDER BY acted_at DESC, rowid DESC LIMIT ?`,
|
||||
)
|
||||
.all(limit) as ActionRow[];
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
listForQitem(qitemId: string, limit = 50): MissionControlActionEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM mission_control_actions WHERE qitem_id = ?
|
||||
ORDER BY acted_at DESC, rowid DESC LIMIT ?`,
|
||||
)
|
||||
.all(qitemId, limit) as ActionRow[];
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
listForActor(actorSession: string, limit = 50): MissionControlActionEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM mission_control_actions WHERE actor_session = ?
|
||||
ORDER BY acted_at DESC, rowid DESC LIMIT ?`,
|
||||
)
|
||||
.all(actorSession, limit) as ActionRow[];
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
countAll(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM mission_control_actions`)
|
||||
.get() as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToEntry(row: ActionRow): MissionControlActionEntry {
|
||||
return {
|
||||
actionId: row.action_id,
|
||||
actionVerb: row.action_verb as MissionControlVerb,
|
||||
qitemId: row.qitem_id,
|
||||
actorSession: row.actor_session,
|
||||
actedAt: row.acted_at,
|
||||
beforeState: row.before_state_json
|
||||
? (JSON.parse(row.before_state_json) as Record<string, unknown>)
|
||||
: null,
|
||||
afterState: row.after_state_json
|
||||
? (JSON.parse(row.after_state_json) as Record<string, unknown>)
|
||||
: null,
|
||||
reason: row.reason,
|
||||
annotation: row.annotation,
|
||||
notifyAttempted: row.notify_attempted !== 0,
|
||||
notifyResult: row.notify_result,
|
||||
auditNotes: row.audit_notes_json
|
||||
? (JSON.parse(row.audit_notes_json) as Record<string, unknown>)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// PL-005 Phase A: per-rig CLI capability cache + fleet roll-up.
|
||||
//
|
||||
// Implements the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance:
|
||||
// 1. Per-field availability check on `rig ps --fields <field>`.
|
||||
// 2. Per-rig CLI capability honesty (each rig surfaces its own capabilities).
|
||||
// 3. Once-per-session-per-rig logging — degradation logs ONCE per (rig, field),
|
||||
// not per render.
|
||||
// 4. "Rigs running stale CLI" indicator surfaced in fleet view meta.
|
||||
//
|
||||
// At v0 the daemon owns its own DB-bound topology + queue surfaces and
|
||||
// can synthesize a fleet roll-up from PL-004 Phase A queue_items + the
|
||||
// rig registry. The implementation supports an optional `psShellOut`
|
||||
// hook for future direct CLI shelling; default keeps the fleet roll-up
|
||||
// in-process so tests are deterministic.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { RigRepository } from "../rig-repository.js";
|
||||
|
||||
export interface FleetRollupRow {
|
||||
rigName: string;
|
||||
/** Compact 5-state activity for a fleet view. */
|
||||
activityState: "active" | "idle" | "attention" | "blocked" | "degraded";
|
||||
lifecycleState: string | null;
|
||||
attentionReason: string | null;
|
||||
lastUpdate: string;
|
||||
/** v0.1.12-style label or "head" / "unknown". */
|
||||
cliVersionLabel: string;
|
||||
/** True if this rig was observed missing one or more allow-listed fields. */
|
||||
cliDriftDetected: boolean;
|
||||
}
|
||||
|
||||
export interface FleetRollup {
|
||||
rows: FleetRollupRow[];
|
||||
staleCliCount: number;
|
||||
/** Fields known to be missing across the observed rigs (de-duplicated). */
|
||||
degradedFields: string[];
|
||||
/**
|
||||
* If the fleet roll-up couldn't reach the canonical CLI source and
|
||||
* fell back to a daemon-internal projection, this is the fallback
|
||||
* mode label. Null when the canonical source was used.
|
||||
*/
|
||||
sourceFallback: string | null;
|
||||
}
|
||||
|
||||
interface CliCapabilityDeps {
|
||||
db: Database.Database;
|
||||
eventBus: EventBus;
|
||||
rigRepo: RigRepository;
|
||||
/**
|
||||
* Optional: probe a rig for which `rig ps --fields <field>` keys it
|
||||
* supports. v0 default is a no-op (returns empty unsupported list);
|
||||
* future versions may shell out to `rig ps --version` per rig.
|
||||
*/
|
||||
probeRig?: (rigName: string) => Promise<{
|
||||
cliVersionLabel: string;
|
||||
unsupportedFields: string[];
|
||||
}>;
|
||||
/** Override clock for tests. */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
interface RigQueueRow {
|
||||
destination_session: string;
|
||||
state: string;
|
||||
ts_updated: string;
|
||||
blocked_on: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the operator-friendly fleet view tries to present. When a rig
|
||||
* does not surface one of these fields, the per-row drift indicator is
|
||||
* set + the per-(rig,field) "logged once" event is emitted.
|
||||
*
|
||||
* `recoveryGuidance` is intentionally listed even though it is not in
|
||||
* the 0.2.0 CLI allow-list (audit row 5 yellow). This is the canonical
|
||||
* cross-CLI-version drift case: the spec assumes some future CLI
|
||||
* surfaces this; the read-layer surfaces "field unavailable on this
|
||||
* rig's daemon version" honestly today.
|
||||
*/
|
||||
export const MISSION_CONTROL_DESIRED_FIELDS = [
|
||||
"agentActivity",
|
||||
"recoveryGuidance",
|
||||
] as const;
|
||||
|
||||
type DesiredField = (typeof MISSION_CONTROL_DESIRED_FIELDS)[number];
|
||||
|
||||
export class MissionControlFleetCliCapability {
|
||||
private readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly rigRepo: RigRepository;
|
||||
private readonly probeRig: NonNullable<CliCapabilityDeps["probeRig"]>;
|
||||
private readonly now: () => Date;
|
||||
|
||||
/** Per-(rig, field) once-per-session log set. Cleared on daemon restart. */
|
||||
private readonly loggedDriftKeys: Set<string> = new Set();
|
||||
|
||||
constructor(deps: CliCapabilityDeps) {
|
||||
this.db = deps.db;
|
||||
this.eventBus = deps.eventBus;
|
||||
this.rigRepo = deps.rigRepo;
|
||||
this.probeRig =
|
||||
deps.probeRig ??
|
||||
(async () => ({ cliVersionLabel: "unknown", unsupportedFields: [] }));
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async rollupFleet(): Promise<FleetRollup> {
|
||||
const rigs = this.rigRepo.listRigs();
|
||||
const rows: FleetRollupRow[] = [];
|
||||
const allDegradedFields = new Set<string>();
|
||||
let staleCliCount = 0;
|
||||
|
||||
for (const rig of rigs) {
|
||||
let probe: { cliVersionLabel: string; unsupportedFields: string[] };
|
||||
try {
|
||||
probe = await this.probeRig(rig.name);
|
||||
} catch {
|
||||
probe = { cliVersionLabel: "unknown", unsupportedFields: [...MISSION_CONTROL_DESIRED_FIELDS] };
|
||||
}
|
||||
const driftDetected = probe.unsupportedFields.length > 0;
|
||||
if (driftDetected) staleCliCount++;
|
||||
for (const f of probe.unsupportedFields) {
|
||||
allDegradedFields.add(f);
|
||||
this.maybeLogDriftOnce(rig.name, f);
|
||||
}
|
||||
const queueState = this.summarizeRigQueue(rig.name);
|
||||
rows.push({
|
||||
rigName: rig.name,
|
||||
activityState: queueState.activityState,
|
||||
lifecycleState: queueState.lifecycleState,
|
||||
attentionReason: queueState.attentionReason,
|
||||
lastUpdate: queueState.lastUpdate,
|
||||
cliVersionLabel: probe.cliVersionLabel,
|
||||
cliDriftDetected: driftDetected,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
staleCliCount,
|
||||
degradedFields: Array.from(allDegradedFields),
|
||||
// v0 reads from the daemon-internal rig registry + queue_items;
|
||||
// future versions can shell out to `rig ps --nodes --json` and
|
||||
// set sourceFallback to "daemon-internal" when the CLI is
|
||||
// unavailable.
|
||||
sourceFallback: "daemon-internal-projection",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-(rig, field) once-per-session-per-rig log per PRD sub-clause 3.
|
||||
* Daemon restart clears the set so logging fires again on first
|
||||
* post-restart observation.
|
||||
*/
|
||||
private maybeLogDriftOnce(rigName: string, missingField: string): void {
|
||||
const key = `${rigName}::${missingField}`;
|
||||
if (this.loggedDriftKeys.has(key)) return;
|
||||
this.loggedDriftKeys.add(key);
|
||||
const observedAt = this.now().toISOString();
|
||||
this.eventBus.emit({
|
||||
type: "mission_control.cli_drift_detected",
|
||||
rigName,
|
||||
missingField,
|
||||
observedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a rig's queue state for the fleet view. Synthesized
|
||||
* from PL-004 Phase A queue_items via a single SQL aggregation:
|
||||
* - active: any in-progress qitem
|
||||
* - blocked: any blocked qitem (no in-progress)
|
||||
* - attention: only pending qitems older than 1h
|
||||
* - idle: no active queue activity
|
||||
* - degraded: any failed/denied/canceled qitem in last 24h
|
||||
*/
|
||||
private summarizeRigQueue(rigName: string): {
|
||||
activityState: FleetRollupRow["activityState"];
|
||||
lifecycleState: string | null;
|
||||
attentionReason: string | null;
|
||||
lastUpdate: string;
|
||||
} {
|
||||
const ownedSessions = this.db
|
||||
.prepare(
|
||||
`SELECT destination_session, state, ts_updated, blocked_on
|
||||
FROM queue_items
|
||||
WHERE destination_session LIKE ?
|
||||
OR source_session LIKE ?
|
||||
ORDER BY ts_updated DESC LIMIT 100`,
|
||||
)
|
||||
.all(`%@${rigName}`, `%@${rigName}`) as RigQueueRow[];
|
||||
|
||||
if (ownedSessions.length === 0) {
|
||||
return {
|
||||
activityState: "idle",
|
||||
lifecycleState: null,
|
||||
attentionReason: null,
|
||||
lastUpdate: this.now().toISOString(),
|
||||
};
|
||||
}
|
||||
const lastUpdate = ownedSessions[0]!.ts_updated;
|
||||
const hasInProgress = ownedSessions.some((q) => q.state === "in-progress");
|
||||
const hasBlocked = ownedSessions.some((q) => q.state === "blocked");
|
||||
const hasFailed = ownedSessions.some((q) =>
|
||||
q.state === "failed" || q.state === "denied" || q.state === "canceled",
|
||||
);
|
||||
let activityState: FleetRollupRow["activityState"] = "idle";
|
||||
let attentionReason: string | null = null;
|
||||
if (hasInProgress) {
|
||||
activityState = "active";
|
||||
} else if (hasBlocked) {
|
||||
activityState = "blocked";
|
||||
const blockedRow = ownedSessions.find((q) => q.state === "blocked");
|
||||
attentionReason = blockedRow?.blocked_on
|
||||
? `blocked-on: ${blockedRow.blocked_on}`
|
||||
: "blocked";
|
||||
} else if (hasFailed) {
|
||||
activityState = "degraded";
|
||||
attentionReason = "recent failure / denial / cancel in queue";
|
||||
}
|
||||
return {
|
||||
activityState,
|
||||
lifecycleState: ownedSessions[0]!.state,
|
||||
attentionReason,
|
||||
lastUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test/observability helper: clear the once-per-session log set. */
|
||||
resetDriftLogForTest(): void {
|
||||
this.loggedDriftKeys.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// PL-005 Phase A: Mission Control read layer.
|
||||
//
|
||||
// Maps the 7 Mission Control views to data sources:
|
||||
// - my-queue → queue_items where destination_session = operator
|
||||
// AND tier='human-gate'
|
||||
// - human-gate → queue_items where tier='human-gate'
|
||||
// - fleet → shell out to `rig ps --nodes --json` (graceful
|
||||
// degradation per 4-sub-clause spec)
|
||||
// - active-work → queue_items where state in (pending, in-progress,
|
||||
// blocked) sorted by priority
|
||||
// - recent-ships → queue_items where state in (done, handed-off)
|
||||
// ORDER BY ts_updated DESC LIMIT 10 (founder Q4)
|
||||
// - recently-active → PL-004 Phase B ViewProjector built-in view
|
||||
// - recent-observations → stream_items table (Phase A daemon-backed source)
|
||||
// with ~/.openrig/stream/<date>.jsonl as graceful
|
||||
// degradation fallback
|
||||
//
|
||||
// Each row carries the 9-field phone-friendly content model (PRD § Acceptance
|
||||
// Criteria item 1; non-negotiable across all 7 views regardless of UI density).
|
||||
//
|
||||
// Source-of-truth integration: PL-004 daemon-backed coordination services
|
||||
// are the primary read path. Filesystem/CLI fallbacks are for graceful
|
||||
// degradation only (per founder steering 2026-05-03).
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import type { QueueRepository, QueueItem, QueueState } from "../queue-repository.js";
|
||||
import type { ViewProjector } from "../view-projector.js";
|
||||
import type { StreamStore, StreamItem } from "../stream-store.js";
|
||||
import type { MissionControlFleetCliCapability, FleetRollupRow } from "./mission-control-fleet-cli-capability.js";
|
||||
|
||||
export const MISSION_CONTROL_VIEWS = [
|
||||
"my-queue",
|
||||
"human-gate",
|
||||
"fleet",
|
||||
"active-work",
|
||||
"recent-ships",
|
||||
"recently-active",
|
||||
"recent-observations",
|
||||
] as const;
|
||||
|
||||
export type MissionControlViewName = (typeof MISSION_CONTROL_VIEWS)[number];
|
||||
|
||||
/**
|
||||
* The 9-field phone-friendly content model. Per PRD § Acceptance
|
||||
* Criteria item 1, non-negotiable across all 7 views wherever
|
||||
* row-shaped status is shown. UI may render compact; JSON preserves
|
||||
* all 9 fields.
|
||||
*
|
||||
* 9 fields verbatim from PRD:
|
||||
* 1. rig/mission name
|
||||
* 2. current phase
|
||||
* 3. active/idle/attention/blocked/degraded
|
||||
* 4. next-action
|
||||
* 5. pending-human-decision
|
||||
* 6. read-cost (full / skim/approve / summary-only)
|
||||
* 7. last-update timestamp
|
||||
* 8. confidence/freshness
|
||||
* 9. evidence link
|
||||
*/
|
||||
export interface CompactStatusRow {
|
||||
rigOrMissionName: string;
|
||||
currentPhase: string | null;
|
||||
state: "active" | "idle" | "attention" | "blocked" | "degraded";
|
||||
nextAction: string | null;
|
||||
pendingHumanDecision: string | null;
|
||||
readCost: "full" | "skim/approve" | "summary-only" | null;
|
||||
lastUpdate: string;
|
||||
confidenceFreshness: string | null;
|
||||
evidenceLink: string | null;
|
||||
/** Carrier metadata (id used for verb actions). */
|
||||
qitemId?: string | null;
|
||||
rawSourceRef?: string | null;
|
||||
}
|
||||
|
||||
export interface MissionControlReadResult {
|
||||
viewName: MissionControlViewName;
|
||||
rows: CompactStatusRow[];
|
||||
/**
|
||||
* Per-view metadata. Includes "rigs running stale CLI" indicator on
|
||||
* the fleet view (sub-clause 4 of graceful-degradation acceptance).
|
||||
*/
|
||||
meta: {
|
||||
rowCount: number;
|
||||
rigsRunningStaleCli?: number;
|
||||
degradedFields?: string[];
|
||||
sourceFallback?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReadLayerDeps {
|
||||
db: Database.Database;
|
||||
queueRepo: QueueRepository;
|
||||
viewProjector: ViewProjector;
|
||||
streamStore?: StreamStore;
|
||||
fleetCliCapability: MissionControlFleetCliCapability;
|
||||
/** Operator's default human-seat session for `my-queue`. */
|
||||
defaultOperatorSession?: string;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
const DEFAULT_OPERATOR_SESSION = "human-wrandom@kernel";
|
||||
const RECENT_SHIPS_LIMIT = 10;
|
||||
const ACTIVE_WORK_LIMIT = 50;
|
||||
const RECENT_OBSERVATIONS_LIMIT = 50;
|
||||
|
||||
const PRIORITY_RANK: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
routine: 2,
|
||||
background: 3,
|
||||
};
|
||||
|
||||
export class MissionControlReadLayer {
|
||||
private readonly queueRepo: QueueRepository;
|
||||
private readonly viewProjector: ViewProjector;
|
||||
private readonly streamStore: StreamStore | undefined;
|
||||
private readonly fleetCliCapability: MissionControlFleetCliCapability;
|
||||
private readonly defaultOperatorSession: string;
|
||||
|
||||
constructor(deps: ReadLayerDeps) {
|
||||
this.queueRepo = deps.queueRepo;
|
||||
this.viewProjector = deps.viewProjector;
|
||||
this.streamStore = deps.streamStore;
|
||||
this.fleetCliCapability = deps.fleetCliCapability;
|
||||
this.defaultOperatorSession = deps.defaultOperatorSession ?? DEFAULT_OPERATOR_SESSION;
|
||||
}
|
||||
|
||||
async readView(
|
||||
viewName: MissionControlViewName,
|
||||
opts?: { operatorSession?: string },
|
||||
): Promise<MissionControlReadResult> {
|
||||
switch (viewName) {
|
||||
case "my-queue":
|
||||
return this.readMyQueue(opts?.operatorSession ?? this.defaultOperatorSession);
|
||||
case "human-gate":
|
||||
return this.readHumanGate();
|
||||
case "fleet":
|
||||
return this.readFleet();
|
||||
case "active-work":
|
||||
return this.readActiveWork();
|
||||
case "recent-ships":
|
||||
return this.readRecentShips();
|
||||
case "recently-active":
|
||||
return this.readRecentlyActive();
|
||||
case "recent-observations":
|
||||
return this.readRecentObservations();
|
||||
}
|
||||
}
|
||||
|
||||
private readMyQueue(operatorSession: string): MissionControlReadResult {
|
||||
const items = this.queueRepo.list({
|
||||
destinationSession: operatorSession,
|
||||
state: ["pending", "in-progress", "blocked"],
|
||||
limit: ACTIVE_WORK_LIMIT,
|
||||
});
|
||||
const humanGateOnly = items.filter((q) => q.tier === "human-gate");
|
||||
return {
|
||||
viewName: "my-queue",
|
||||
rows: humanGateOnly.map((q) => qitemToRow(q, { defaultReadCost: "skim/approve" })),
|
||||
meta: { rowCount: humanGateOnly.length },
|
||||
};
|
||||
}
|
||||
|
||||
private readHumanGate(): MissionControlReadResult {
|
||||
const items = this.queueRepo.list({
|
||||
state: ["pending", "in-progress", "blocked"],
|
||||
limit: ACTIVE_WORK_LIMIT,
|
||||
});
|
||||
const humanGateOnly = items.filter((q) => q.tier === "human-gate");
|
||||
return {
|
||||
viewName: "human-gate",
|
||||
rows: humanGateOnly.map((q) => qitemToRow(q, { defaultReadCost: "skim/approve" })),
|
||||
meta: { rowCount: humanGateOnly.length },
|
||||
};
|
||||
}
|
||||
|
||||
private async readFleet(): Promise<MissionControlReadResult> {
|
||||
const fleet = await this.fleetCliCapability.rollupFleet();
|
||||
const rows: CompactStatusRow[] = fleet.rows.map((r) => fleetRowToCompactRow(r));
|
||||
return {
|
||||
viewName: "fleet",
|
||||
rows,
|
||||
meta: {
|
||||
rowCount: rows.length,
|
||||
rigsRunningStaleCli: fleet.staleCliCount,
|
||||
degradedFields: fleet.degradedFields.length > 0 ? fleet.degradedFields : undefined,
|
||||
sourceFallback: fleet.sourceFallback ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private readActiveWork(): MissionControlReadResult {
|
||||
const items = this.queueRepo.list({
|
||||
state: ["pending", "in-progress", "blocked"],
|
||||
limit: ACTIVE_WORK_LIMIT,
|
||||
});
|
||||
items.sort((a, b) => {
|
||||
const ar = PRIORITY_RANK[a.priority] ?? 99;
|
||||
const br = PRIORITY_RANK[b.priority] ?? 99;
|
||||
if (ar !== br) return ar - br;
|
||||
return a.tsUpdated.localeCompare(b.tsUpdated);
|
||||
});
|
||||
return {
|
||||
viewName: "active-work",
|
||||
rows: items.map((q) => qitemToRow(q, { defaultReadCost: "full" })),
|
||||
meta: { rowCount: items.length },
|
||||
};
|
||||
}
|
||||
|
||||
private readRecentShips(): MissionControlReadResult {
|
||||
// Founder Q4: 10 by default.
|
||||
const done = this.queueRepo.list({
|
||||
state: ["done", "handed-off"],
|
||||
limit: RECENT_SHIPS_LIMIT * 4,
|
||||
});
|
||||
done.sort((a, b) => b.tsUpdated.localeCompare(a.tsUpdated));
|
||||
const top = done.slice(0, RECENT_SHIPS_LIMIT);
|
||||
return {
|
||||
viewName: "recent-ships",
|
||||
rows: top.map((q) => qitemToRow(q, { defaultReadCost: "summary-only" })),
|
||||
meta: { rowCount: top.length },
|
||||
};
|
||||
}
|
||||
|
||||
private readRecentlyActive(): MissionControlReadResult {
|
||||
// Delegates to PL-004 Phase B's built-in `recently-active` view.
|
||||
const result = this.viewProjector.show("recently-active");
|
||||
const rows: CompactStatusRow[] = result.rows.map((row) =>
|
||||
builtinViewRowToCompactRow("recently-active", row),
|
||||
);
|
||||
return {
|
||||
viewName: "recently-active",
|
||||
rows,
|
||||
meta: { rowCount: rows.length },
|
||||
};
|
||||
}
|
||||
|
||||
private readRecentObservations(): MissionControlReadResult {
|
||||
if (!this.streamStore) {
|
||||
return {
|
||||
viewName: "recent-observations",
|
||||
rows: [],
|
||||
meta: { rowCount: 0, sourceFallback: "stream-store-not-wired" },
|
||||
};
|
||||
}
|
||||
const items: StreamItem[] = this.streamStore.list({ limit: RECENT_OBSERVATIONS_LIMIT });
|
||||
const rows = items.map((s) => streamItemToCompactRow(s));
|
||||
return {
|
||||
viewName: "recent-observations",
|
||||
rows,
|
||||
meta: { rowCount: rows.length },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function qitemToRow(
|
||||
q: QueueItem,
|
||||
opts: { defaultReadCost: CompactStatusRow["readCost"] },
|
||||
): CompactStatusRow {
|
||||
const state = qitemStateToCompactState(q.state);
|
||||
const nextAction = q.state === "blocked" ? `unblock: ${q.blockedOn ?? "external-gate"}` : null;
|
||||
const pendingHumanDecision = q.tier === "human-gate" ? `${q.priority} human-gate item` : null;
|
||||
return {
|
||||
rigOrMissionName: q.destinationSession,
|
||||
currentPhase: q.tier ?? null,
|
||||
state,
|
||||
nextAction,
|
||||
pendingHumanDecision,
|
||||
readCost: opts.defaultReadCost,
|
||||
lastUpdate: q.tsUpdated,
|
||||
confidenceFreshness: q.priority,
|
||||
evidenceLink: null,
|
||||
qitemId: q.qitemId,
|
||||
rawSourceRef: q.sourceSession,
|
||||
};
|
||||
}
|
||||
|
||||
function qitemStateToCompactState(state: QueueState): CompactStatusRow["state"] {
|
||||
switch (state) {
|
||||
case "in-progress":
|
||||
return "active";
|
||||
case "pending":
|
||||
return "idle";
|
||||
case "blocked":
|
||||
return "blocked";
|
||||
case "failed":
|
||||
case "denied":
|
||||
case "canceled":
|
||||
return "degraded";
|
||||
case "done":
|
||||
case "handed-off":
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
function fleetRowToCompactRow(r: FleetRollupRow): CompactStatusRow {
|
||||
return {
|
||||
rigOrMissionName: r.rigName,
|
||||
currentPhase: r.lifecycleState ?? null,
|
||||
state: r.activityState,
|
||||
nextAction: r.attentionReason ?? null,
|
||||
pendingHumanDecision: null,
|
||||
readCost: "summary-only",
|
||||
lastUpdate: r.lastUpdate,
|
||||
confidenceFreshness: r.cliVersionLabel,
|
||||
evidenceLink: null,
|
||||
qitemId: null,
|
||||
rawSourceRef: r.rigName,
|
||||
};
|
||||
}
|
||||
|
||||
function builtinViewRowToCompactRow(
|
||||
viewName: string,
|
||||
row: Record<string, unknown>,
|
||||
): CompactStatusRow {
|
||||
const get = (k: string): string | null => {
|
||||
const v = row[k];
|
||||
if (v === undefined || v === null) return null;
|
||||
return String(v);
|
||||
};
|
||||
return {
|
||||
rigOrMissionName:
|
||||
get("destination_session") ?? get("rig_name") ?? get("rigName") ?? viewName,
|
||||
currentPhase: get("tier") ?? get("state") ?? null,
|
||||
state: "idle",
|
||||
nextAction: null,
|
||||
pendingHumanDecision: null,
|
||||
readCost: "summary-only",
|
||||
lastUpdate: get("ts_updated") ?? get("ts_emitted") ?? new Date().toISOString(),
|
||||
confidenceFreshness: get("priority") ?? null,
|
||||
evidenceLink: null,
|
||||
qitemId: get("qitem_id"),
|
||||
rawSourceRef: get("source_session"),
|
||||
};
|
||||
}
|
||||
|
||||
function streamItemToCompactRow(s: StreamItem): CompactStatusRow {
|
||||
return {
|
||||
rigOrMissionName: s.sourceSession,
|
||||
currentPhase: s.hintType ?? null,
|
||||
state: s.hintUrgency === "critical" ? "attention" : "idle",
|
||||
nextAction: s.hintDestination ?? null,
|
||||
pendingHumanDecision: null,
|
||||
readCost: "summary-only",
|
||||
lastUpdate: s.tsEmitted,
|
||||
confidenceFreshness: s.hintUrgency ?? null,
|
||||
evidenceLink: null,
|
||||
qitemId: null,
|
||||
rawSourceRef: s.streamItemId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// PL-005 Phase A: Mission Control write-contract — atomic 7-verb actions.
|
||||
//
|
||||
// LOAD-BEARING. Per PRD § Acceptance Criteria + slice IMPL § Guard
|
||||
// Checkpoint Focus item 2: each of 7 verbs is one atomic transaction.
|
||||
// The 4-step `handoff` shape (source-update + destination-create +
|
||||
// opt-in best-effort notify + audit-record append) is the canonical
|
||||
// proof case; other 6 verbs follow the same atomic-update + audit
|
||||
// shape with verb-specific metadata.
|
||||
//
|
||||
// Composition (one db.transaction per verb call):
|
||||
// 1. Verify the target qitem exists and isn't already terminal.
|
||||
// 2. Compute the verb-specific queue mutation via Phase D's
|
||||
// QueueRepository.updateWithinTransaction (preserves Phase A
|
||||
// hot-potato closure validation; emits queue.updated event).
|
||||
// 3. For handoff: also call QueueRepository.createWithinTransaction
|
||||
// to make the destination packet (the same outer txn).
|
||||
// 4. Append the mission_control_actions audit record with before +
|
||||
// after state snapshots.
|
||||
// 5. Persist the mission_control.action_executed event.
|
||||
//
|
||||
// Post-commit (outside the transaction): notifySubscribers + opt-in
|
||||
// best-effort transport notify. Notify failure does NOT roll back
|
||||
// durable mutations (PRD invariant: "notify failure does NOT roll
|
||||
// back durable mutations").
|
||||
//
|
||||
// Verb mappings:
|
||||
// approve → state="done", closure_reason="no-follow-on"
|
||||
// deny → state="done", closure_reason="denied"
|
||||
// route → state="done", closure_reason="handed_off_to",
|
||||
// closure_target+handed_off_to=<route target>;
|
||||
// creates new qitem at the route target (1-hop)
|
||||
// annotate → no queue mutation; audit record only (annotation field
|
||||
// attached to mission_control_actions)
|
||||
// hold → state="blocked", closure_reason="blocked_on",
|
||||
// closure_target+blocked_on=<reason text>
|
||||
// drop → state="done", closure_reason="canceled",
|
||||
// closure_target=<reason>
|
||||
// handoff → state="handed-off", closure_reason="handed_off_to",
|
||||
// closure_target+handed_off_to=<destination>;
|
||||
// creates new qitem at destination (4-step canonical)
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { QueueRepository, QueueItem } from "../queue-repository.js";
|
||||
import type { PersistedEvent } from "../types.js";
|
||||
import {
|
||||
MissionControlActionLog,
|
||||
MissionControlActionLogError,
|
||||
type MissionControlVerb,
|
||||
} from "./mission-control-action-log.js";
|
||||
|
||||
export class MissionControlWriteContractError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MissionControlWriteContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface MissionControlActionInput {
|
||||
verb: MissionControlVerb;
|
||||
qitemId: string;
|
||||
actorSession: string;
|
||||
/** Required for `route` and `handoff`. */
|
||||
destinationSession?: string;
|
||||
/** Body for the new packet on `route`/`handoff`; defaults to source body. */
|
||||
body?: string;
|
||||
/** Required for `annotate`. */
|
||||
annotation?: string;
|
||||
/** Required for `hold` and `drop`; optional advisory text otherwise. */
|
||||
reason?: string;
|
||||
/** Operator-supplied audit context. */
|
||||
auditNotes?: Record<string, unknown>;
|
||||
/**
|
||||
* For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1
|
||||
* pattern: durable + waking by default; operators opt out for cold queues).
|
||||
* notify failure does NOT roll back durable state.
|
||||
*/
|
||||
notify?: boolean;
|
||||
}
|
||||
|
||||
export interface MissionControlActionResult {
|
||||
actionId: string;
|
||||
verb: MissionControlVerb;
|
||||
qitemId: string;
|
||||
closedQitem: QueueItem | null;
|
||||
createdQitemId: string | null;
|
||||
notifyAttempted: boolean;
|
||||
notifyResult: string | null;
|
||||
auditedAt: string;
|
||||
}
|
||||
|
||||
interface WriteContractDeps {
|
||||
db: Database.Database;
|
||||
eventBus: EventBus;
|
||||
queueRepo: QueueRepository;
|
||||
actionLog: MissionControlActionLog;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class MissionControlWriteContract {
|
||||
private readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly queueRepo: QueueRepository;
|
||||
private readonly actionLog: MissionControlActionLog;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(deps: WriteContractDeps) {
|
||||
this.db = deps.db;
|
||||
this.eventBus = deps.eventBus;
|
||||
this.queueRepo = deps.queueRepo;
|
||||
this.actionLog = deps.actionLog;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one verb. Atomic at the durable layer: queue mutation +
|
||||
* audit record + event persistence in one transaction. Post-commit:
|
||||
* notify subscribers + opt-in transport wake (handoff only).
|
||||
*/
|
||||
async act(input: MissionControlActionInput): Promise<MissionControlActionResult> {
|
||||
if (input.verb === "annotate") {
|
||||
return this.annotateOnly(input);
|
||||
}
|
||||
|
||||
const source = this.queueRepo.getById(input.qitemId);
|
||||
if (!source) {
|
||||
throw new MissionControlWriteContractError(
|
||||
"qitem_not_found",
|
||||
`qitem ${input.qitemId} not found`,
|
||||
{ qitemId: input.qitemId },
|
||||
);
|
||||
}
|
||||
if (source.state === "done" || source.state === "handed-off") {
|
||||
throw new MissionControlWriteContractError(
|
||||
"qitem_already_terminal",
|
||||
`qitem ${input.qitemId} is already terminal (state=${source.state}); Mission Control cannot mutate terminal items`,
|
||||
{ qitemId: input.qitemId, state: source.state },
|
||||
);
|
||||
}
|
||||
|
||||
if ((input.verb === "route" || input.verb === "handoff") && !input.destinationSession) {
|
||||
throw new MissionControlWriteContractError(
|
||||
"destination_required",
|
||||
`verb=${input.verb} requires destinationSession`,
|
||||
{ verb: input.verb },
|
||||
);
|
||||
}
|
||||
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
const closure = verbToClosure(input);
|
||||
const beforeSnapshot = snapshotQitem(source);
|
||||
let createdQitemId: string | null = null;
|
||||
let createdDestination: string | undefined;
|
||||
let createdNudge: boolean | undefined;
|
||||
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
|
||||
const persistedEvents: PersistedEvent[] = [];
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
// 1. Close/transition the source via Phase A's queue closure primitive.
|
||||
const closeResult = this.queueRepo.updateWithinTransaction({
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
state: closure.state,
|
||||
closureReason: closure.closureReason,
|
||||
closureTarget: closure.closureTarget ?? undefined,
|
||||
handedOffTo: closure.handedOffTo,
|
||||
blockedOn: closure.blockedOn,
|
||||
transitionNote: `mission-control:${input.verb}${input.reason ? ` (${input.reason})` : ""}`,
|
||||
});
|
||||
persistedEvents.push(closeResult.persistedEvent);
|
||||
|
||||
// 2. For route/handoff: create the destination packet in same txn.
|
||||
if ((input.verb === "route" || input.verb === "handoff") && input.destinationSession) {
|
||||
const created = this.queueRepo.createWithinTransaction({
|
||||
sourceSession: input.actorSession,
|
||||
destinationSession: input.destinationSession,
|
||||
body: input.body ?? source.body,
|
||||
priority: source.priority,
|
||||
tier: source.tier ?? undefined,
|
||||
tags: source.tags
|
||||
? [...source.tags, `mission-control:${input.verb}`]
|
||||
: [`mission-control:${input.verb}`],
|
||||
chainOfRecord: [...(source.chainOfRecord ?? []), input.qitemId],
|
||||
// Default nudge handled post-commit per Phase D pattern.
|
||||
nudge: input.notify,
|
||||
});
|
||||
createdQitemId = created.qitemId;
|
||||
createdDestination = created.destinationSession;
|
||||
createdNudge = created.nudge;
|
||||
persistedEvents.push(created.persistedEvent);
|
||||
}
|
||||
|
||||
// 3. Append the audit record. Snapshot the closed qitem state.
|
||||
const closedQitem = this.queueRepo.getById(input.qitemId);
|
||||
const afterSnapshot = closedQitem ? snapshotQitem(closedQitem) : null;
|
||||
actionEntry = this.actionLog.record({
|
||||
actionVerb: input.verb,
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
actedAt: evaluatedAt,
|
||||
beforeState: beforeSnapshot,
|
||||
afterState: afterSnapshot,
|
||||
reason: input.reason ?? null,
|
||||
annotation: input.annotation ?? null,
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditNotes: input.auditNotes ?? null,
|
||||
});
|
||||
|
||||
// 4. Persist the mission_control.action_executed event in same txn.
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "mission_control.action_executed",
|
||||
actionId: actionEntry.actionId,
|
||||
actionVerb: input.verb,
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
txn();
|
||||
} catch (err) {
|
||||
if (err instanceof MissionControlActionLogError) {
|
||||
throw new MissionControlWriteContractError(err.code, err.message, err.details);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Post-commit: fan out events.
|
||||
for (const e of persistedEvents) this.eventBus.notifySubscribers(e);
|
||||
|
||||
// Post-commit best-effort notify on handoff/route. Default true per
|
||||
// PL-004 R1 pattern; failure does NOT roll back durable mutations.
|
||||
let notifyAttempted = false;
|
||||
let notifyResult: string | null = null;
|
||||
if (createdQitemId && createdDestination && (input.verb === "route" || input.verb === "handoff")) {
|
||||
try {
|
||||
await this.queueRepo.maybeNudge(createdQitemId, createdDestination, createdNudge);
|
||||
notifyAttempted = createdNudge !== false;
|
||||
notifyResult = notifyAttempted ? "attempted-best-effort" : "skipped";
|
||||
} catch (err) {
|
||||
notifyAttempted = true;
|
||||
notifyResult = `failed:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: input.verb,
|
||||
qitemId: input.qitemId,
|
||||
closedQitem: this.queueRepo.getById(input.qitemId),
|
||||
createdQitemId,
|
||||
notifyAttempted,
|
||||
notifyResult,
|
||||
auditedAt: evaluatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate has no queue mutation — only an audit record + event.
|
||||
* Still wrapped in a transaction so the audit + event are atomic.
|
||||
*/
|
||||
private async annotateOnly(input: MissionControlActionInput): Promise<MissionControlActionResult> {
|
||||
if (!input.annotation) {
|
||||
throw new MissionControlWriteContractError(
|
||||
"annotation_required",
|
||||
`verb=annotate requires annotation`,
|
||||
{ verb: input.verb },
|
||||
);
|
||||
}
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
|
||||
const persistedEvents: PersistedEvent[] = [];
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
actionEntry = this.actionLog.record({
|
||||
actionVerb: "annotate",
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
actedAt: evaluatedAt,
|
||||
annotation: input.annotation!,
|
||||
auditNotes: input.auditNotes ?? null,
|
||||
});
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "mission_control.action_executed",
|
||||
actionId: actionEntry.actionId,
|
||||
actionVerb: "annotate",
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
}),
|
||||
);
|
||||
});
|
||||
txn();
|
||||
for (const e of persistedEvents) this.eventBus.notifySubscribers(e);
|
||||
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: "annotate",
|
||||
qitemId: input.qitemId,
|
||||
closedQitem: this.queueRepo.getById(input.qitemId),
|
||||
createdQitemId: null,
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ClosureMapping {
|
||||
state: "handed-off" | "blocked" | "done";
|
||||
closureReason: string;
|
||||
closureTarget: string | null;
|
||||
handedOffTo?: string;
|
||||
blockedOn?: string;
|
||||
}
|
||||
|
||||
function verbToClosure(input: MissionControlActionInput): ClosureMapping {
|
||||
switch (input.verb) {
|
||||
case "approve":
|
||||
return {
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
closureTarget: input.reason ?? null,
|
||||
};
|
||||
case "deny":
|
||||
return {
|
||||
state: "done",
|
||||
closureReason: "denied",
|
||||
closureTarget: input.reason ?? "operator denied",
|
||||
};
|
||||
case "route":
|
||||
return {
|
||||
state: "handed-off",
|
||||
closureReason: "handed_off_to",
|
||||
closureTarget: input.destinationSession!,
|
||||
handedOffTo: input.destinationSession!,
|
||||
};
|
||||
case "hold":
|
||||
return {
|
||||
state: "blocked",
|
||||
closureReason: "blocked_on",
|
||||
closureTarget: input.reason!,
|
||||
blockedOn: input.reason!,
|
||||
};
|
||||
case "drop":
|
||||
return {
|
||||
state: "done",
|
||||
closureReason: "canceled",
|
||||
closureTarget: input.reason!,
|
||||
};
|
||||
case "handoff":
|
||||
return {
|
||||
state: "handed-off",
|
||||
closureReason: "handed_off_to",
|
||||
closureTarget: input.destinationSession!,
|
||||
handedOffTo: input.destinationSession!,
|
||||
};
|
||||
case "annotate":
|
||||
// Should never reach here — annotate is handled by annotateOnly().
|
||||
throw new MissionControlWriteContractError(
|
||||
"internal_invariant",
|
||||
"annotate verb should have been routed to annotateOnly",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotQitem(q: QueueItem): Record<string, unknown> {
|
||||
return {
|
||||
qitemId: q.qitemId,
|
||||
state: q.state,
|
||||
sourceSession: q.sourceSession,
|
||||
destinationSession: q.destinationSession,
|
||||
priority: q.priority,
|
||||
tier: q.tier,
|
||||
closureReason: q.closureReason,
|
||||
closureTarget: q.closureTarget,
|
||||
handedOffTo: q.handedOffTo,
|
||||
blockedOn: q.blockedOn,
|
||||
tsUpdated: q.tsUpdated,
|
||||
};
|
||||
}
|
||||
@@ -192,7 +192,14 @@ export type RigEvent =
|
||||
| { type: "workflow.next_qitem_projected"; instanceId: string; nextQitemId: string; nextOwner: string; nextStepId: string }
|
||||
| { type: "workflow.completed"; instanceId: string; workflowName: string }
|
||||
| { type: "workflow.failed"; instanceId: string; workflowName: string; reason: string }
|
||||
| { type: "workflow.routing_table_changed"; rigName: string; cause: string };
|
||||
| { type: "workflow.routing_table_changed"; rigName: string; cause: string }
|
||||
// PL-005 Phase A: Mission Control / Queue Observability events.
|
||||
// Action audit + cross-CLI-version drift detection. view_refreshed
|
||||
// is emitted when a Mission Control view is recomputed (SSE
|
||||
// consumers can choose whether to re-fetch).
|
||||
| { type: "mission_control.action_executed"; actionId: string; actionVerb: string; qitemId: string | null; actorSession: string }
|
||||
| { type: "mission_control.cli_drift_detected"; rigName: string; missingField: string; observedAt: string }
|
||||
| { type: "mission_control.view_refreshed"; viewName: string; cause: string };
|
||||
|
||||
export type PersistedEvent = RigEvent & {
|
||||
seq: number;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import {
|
||||
MissionControlActionLogError,
|
||||
MISSION_CONTROL_VERBS,
|
||||
type MissionControlVerb,
|
||||
} from "../domain/mission-control/mission-control-action-log.js";
|
||||
import {
|
||||
MISSION_CONTROL_VIEWS,
|
||||
type MissionControlReadLayer,
|
||||
type MissionControlViewName,
|
||||
} from "../domain/mission-control/mission-control-read-layer.js";
|
||||
import {
|
||||
MissionControlWriteContractError,
|
||||
type MissionControlWriteContract,
|
||||
} from "../domain/mission-control/mission-control-write-contract.js";
|
||||
import type { MissionControlFleetCliCapability } from "../domain/mission-control/mission-control-fleet-cli-capability.js";
|
||||
|
||||
/**
|
||||
* Mission Control HTTP routes (PL-005 Phase A). Backs the integrated
|
||||
* Mission Control product UI inside the existing shell.
|
||||
*
|
||||
* Per Phase A R1 (PL-004) SSE route-order lesson: SSE/literal paths
|
||||
* mounted BEFORE bare-param /:view-name catchall.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/mission-control/views/:view-name read one of 7 views
|
||||
* POST /api/mission-control/action execute one of 7 verbs
|
||||
* GET /api/mission-control/sse SSE stream of mission_control.* events
|
||||
* GET /api/mission-control/watch alias of /sse
|
||||
* GET /api/mission-control/cli-capabilities per-rig CLI capability cache
|
||||
* GET /api/mission-control/views list view names
|
||||
*/
|
||||
export function missionControlRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getReadLayer(c: { get: (key: string) => unknown }): MissionControlReadLayer {
|
||||
return c.get("missionControlReadLayer" as never) as MissionControlReadLayer;
|
||||
}
|
||||
function getWriteContract(c: { get: (key: string) => unknown }): MissionControlWriteContract {
|
||||
return c.get("missionControlWriteContract" as never) as MissionControlWriteContract;
|
||||
}
|
||||
function getCliCapability(c: { get: (key: string) => unknown }): MissionControlFleetCliCapability {
|
||||
return c.get("missionControlFleetCliCapability" as never) as MissionControlFleetCliCapability;
|
||||
}
|
||||
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
}
|
||||
|
||||
function errorResponse(
|
||||
c: { json: (body: unknown, status?: number) => Response },
|
||||
err: unknown,
|
||||
): Response {
|
||||
if (err instanceof MissionControlActionLogError) {
|
||||
const status =
|
||||
err.code === "verb_unknown" ? 400
|
||||
: err.code === "annotation_required" || err.code === "reason_required" ? 400
|
||||
: 500;
|
||||
return c.json(
|
||||
{ error: err.code, message: err.message, ...(err.details ?? {}) },
|
||||
status as 200,
|
||||
);
|
||||
}
|
||||
if (err instanceof MissionControlWriteContractError) {
|
||||
const status =
|
||||
err.code === "qitem_not_found" ? 404
|
||||
: err.code === "qitem_already_terminal" ? 409
|
||||
: err.code === "destination_required" ? 400
|
||||
: err.code === "annotation_required" ? 400
|
||||
: 500;
|
||||
return c.json(
|
||||
{ error: err.code, message: err.message, ...(err.details ?? {}) },
|
||||
status as 200,
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "internal error";
|
||||
return c.json({ error: "internal_error", message }, 500);
|
||||
}
|
||||
|
||||
// GET /views — list view names. MUST precede /views/:view-name catchall.
|
||||
app.get("/views", (c) => {
|
||||
return c.json({ views: [...MISSION_CONTROL_VIEWS] });
|
||||
});
|
||||
|
||||
// GET /cli-capabilities — fleet roll-up + drift indicator.
|
||||
app.get("/cli-capabilities", async (c) => {
|
||||
const fleet = await getCliCapability(c).rollupFleet();
|
||||
return c.json(fleet);
|
||||
});
|
||||
|
||||
// SSE for mission_control.* events. MUST precede /views/:view-name
|
||||
// (per PL-004 Phase A R1 SSE route-order lesson).
|
||||
const sseHandler = (c: Parameters<typeof streamSSE>[0]) => {
|
||||
const eventBus = getEventBus(c);
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (
|
||||
event.type !== "mission_control.action_executed" &&
|
||||
event.type !== "mission_control.cli_drift_detected" &&
|
||||
event.type !== "mission_control.view_refreshed"
|
||||
) return;
|
||||
const sse = { id: String(event.seq), data: JSON.stringify(event) };
|
||||
stream.writeSSE(sse).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve) => stream.onAbort(() => resolve()));
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
};
|
||||
app.get("/sse", sseHandler);
|
||||
app.get("/watch", sseHandler);
|
||||
|
||||
// POST /action — execute one of 7 verbs through the atomic write contract.
|
||||
app.post("/action", async (c) => {
|
||||
const body = await c.req
|
||||
.json<{
|
||||
verb?: MissionControlVerb;
|
||||
qitemId?: string;
|
||||
actorSession?: string;
|
||||
destinationSession?: string;
|
||||
body?: string;
|
||||
annotation?: string;
|
||||
reason?: string;
|
||||
notify?: boolean;
|
||||
auditNotes?: Record<string, unknown>;
|
||||
}>()
|
||||
.catch(() => ({} as never));
|
||||
if (!body.verb) return c.json({ error: "verb is required" }, 400);
|
||||
if (!MISSION_CONTROL_VERBS.includes(body.verb)) {
|
||||
return c.json(
|
||||
{
|
||||
error: "verb_unknown",
|
||||
message: `unknown verb '${body.verb}'; supported: ${MISSION_CONTROL_VERBS.join(", ")}`,
|
||||
supported: [...MISSION_CONTROL_VERBS],
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!body.qitemId) return c.json({ error: "qitemId is required" }, 400);
|
||||
if (!body.actorSession) return c.json({ error: "actorSession is required" }, 400);
|
||||
try {
|
||||
const result = await getWriteContract(c).act({
|
||||
verb: body.verb,
|
||||
qitemId: body.qitemId,
|
||||
actorSession: body.actorSession,
|
||||
destinationSession: body.destinationSession,
|
||||
body: body.body,
|
||||
annotation: body.annotation,
|
||||
reason: body.reason,
|
||||
notify: body.notify,
|
||||
auditNotes: body.auditNotes,
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /views/:view-name — read one of 7 views. MUST come AFTER /views,
|
||||
// /cli-capabilities, /sse, /watch literal paths.
|
||||
app.get("/views/:view-name", async (c) => {
|
||||
const viewName = c.req.param("view-name") as MissionControlViewName;
|
||||
if (!MISSION_CONTROL_VIEWS.includes(viewName)) {
|
||||
return c.json(
|
||||
{
|
||||
error: "view_unknown",
|
||||
message: `unknown view '${viewName}'; supported: ${MISSION_CONTROL_VIEWS.join(", ")}`,
|
||||
supported: [...MISSION_CONTROL_VIEWS],
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
const operatorSession = c.req.query("operatorSession") || undefined;
|
||||
try {
|
||||
const result = await getReadLayer(c).readView(viewName, { operatorSession });
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -65,6 +65,7 @@ import { projectsRoutes } from "./routes/projects.js";
|
||||
import { viewsRoutes } from "./routes/views.js";
|
||||
import { watchdogRoutes } from "./routes/watchdog.js";
|
||||
import { workflowRoutes } from "./routes/workflow.js";
|
||||
import { missionControlRoutes } from "./routes/mission-control.js";
|
||||
import type { StreamStore } from "./domain/stream-store.js";
|
||||
import type { QueueRepository } from "./domain/queue-repository.js";
|
||||
import type { InboxHandler } from "./domain/inbox-handler.js";
|
||||
@@ -128,6 +129,10 @@ export interface AppDeps {
|
||||
watchdogPolicyEngine?: WatchdogPolicyEngine;
|
||||
watchdogScheduler?: WatchdogScheduler;
|
||||
workflowRuntime?: WorkflowRuntime;
|
||||
missionControlReadLayer?: import("./domain/mission-control/mission-control-read-layer.js").MissionControlReadLayer;
|
||||
missionControlWriteContract?: import("./domain/mission-control/mission-control-write-contract.js").MissionControlWriteContract;
|
||||
missionControlActionLog?: import("./domain/mission-control/mission-control-action-log.js").MissionControlActionLog;
|
||||
missionControlFleetCliCapability?: import("./domain/mission-control/mission-control-fleet-cli-capability.js").MissionControlFleetCliCapability;
|
||||
specReviewService?: SpecReviewService;
|
||||
specLibraryService?: SpecLibraryService;
|
||||
whoamiService?: WhoamiService;
|
||||
@@ -281,6 +286,10 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("watchdogPolicyEngine" as never, deps.watchdogPolicyEngine);
|
||||
c.set("watchdogScheduler" as never, deps.watchdogScheduler);
|
||||
c.set("workflowRuntime" as never, deps.workflowRuntime);
|
||||
c.set("missionControlReadLayer" as never, deps.missionControlReadLayer);
|
||||
c.set("missionControlWriteContract" as never, deps.missionControlWriteContract);
|
||||
c.set("missionControlActionLog" as never, deps.missionControlActionLog);
|
||||
c.set("missionControlFleetCliCapability" as never, deps.missionControlFleetCliCapability);
|
||||
c.set("specReviewService" as never, deps.specReviewService);
|
||||
c.set("specLibraryService" as never, deps.specLibraryService);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
@@ -333,6 +342,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/views", viewsRoutes());
|
||||
app.route("/api/watchdog", watchdogRoutes());
|
||||
app.route("/api/workflow", workflowRoutes());
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ import { workflowSpecsSchema } from "./db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "./db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "./db/migrations/035_workflow_step_trails.js";
|
||||
import { watchdogPolicyEnumExtensionSchema } from "./db/migrations/036_watchdog_policy_enum_extension.js";
|
||||
import { missionControlActionsSchema } from "./db/migrations/037_mission_control_actions.js";
|
||||
import { MissionControlActionLog } from "./domain/mission-control/mission-control-action-log.js";
|
||||
import { MissionControlWriteContract } from "./domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlReadLayer } from "./domain/mission-control/mission-control-read-layer.js";
|
||||
import { MissionControlFleetCliCapability } from "./domain/mission-control/mission-control-fleet-cli-capability.js";
|
||||
import { OPENRIG_HOME } from "./openrig-compat.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
@@ -140,7 +145,7 @@ interface DaemonResult {
|
||||
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
|
||||
const dbPath = opts?.dbPath ?? ":memory:";
|
||||
const db = createDb(dbPath);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema]);
|
||||
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
@@ -561,6 +566,36 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
deps.workflowRuntime = workflowRuntime;
|
||||
}
|
||||
|
||||
// PL-005 Phase A: Mission Control / Queue Observability services.
|
||||
// Wired AFTER WorkflowRuntime so all PL-004 daemon-backed coordination
|
||||
// surfaces are available. Mission Control reads from queue/view/stream
|
||||
// surfaces and writes through the atomic 7-verb contract.
|
||||
if (deps.queueRepo && deps.viewProjector) {
|
||||
const mcActionLog = new MissionControlActionLog(db);
|
||||
const mcWriteContract = new MissionControlWriteContract({
|
||||
db,
|
||||
eventBus,
|
||||
queueRepo: deps.queueRepo,
|
||||
actionLog: mcActionLog,
|
||||
});
|
||||
const mcFleetCliCapability = new MissionControlFleetCliCapability({
|
||||
db,
|
||||
eventBus,
|
||||
rigRepo,
|
||||
});
|
||||
const mcReadLayer = new MissionControlReadLayer({
|
||||
db,
|
||||
queueRepo: deps.queueRepo,
|
||||
viewProjector: deps.viewProjector,
|
||||
streamStore: deps.streamStore,
|
||||
fleetCliCapability: mcFleetCliCapability,
|
||||
});
|
||||
deps.missionControlActionLog = mcActionLog;
|
||||
deps.missionControlWriteContract = mcWriteContract;
|
||||
deps.missionControlFleetCliCapability = mcFleetCliCapability;
|
||||
deps.missionControlReadLayer = mcReadLayer;
|
||||
}
|
||||
|
||||
const sessionTransport = deps.sessionTransport;
|
||||
if (sessionTransport) {
|
||||
const watchdogPolicyEngine = new WatchdogPolicyEngine({
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { missionControlActionsSchema } from "../src/db/migrations/037_mission_control_actions.js";
|
||||
import {
|
||||
MissionControlActionLog,
|
||||
MissionControlActionLogError,
|
||||
MISSION_CONTROL_VERBS,
|
||||
} from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
|
||||
describe("MissionControlActionLog (PL-005 Phase A; append-only)", () => {
|
||||
let db: Database.Database;
|
||||
let log: MissionControlActionLog;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, queueItemsSchema, missionControlActionsSchema]);
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES ('q-1', '2026-05-04T01:00:00Z', '2026-05-04T01:00:00Z', 'a@r', 'b@r', 'pending', 'routine', 'x')`,
|
||||
).run();
|
||||
log = new MissionControlActionLog(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("record persists all 7 verbs", () => {
|
||||
for (const verb of MISSION_CONTROL_VERBS) {
|
||||
const e = log.record({
|
||||
actionVerb: verb,
|
||||
qitemId: "q-1",
|
||||
actorSession: "human-wrandom@kernel",
|
||||
actedAt: "2026-05-04T01:00:00.000Z",
|
||||
annotation: verb === "annotate" ? "test annotation" : undefined,
|
||||
reason: verb === "hold" || verb === "drop" ? "test reason" : undefined,
|
||||
});
|
||||
expect(e.actionVerb).toBe(verb);
|
||||
expect(e.actionId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
}
|
||||
expect(log.countAll()).toBe(7);
|
||||
});
|
||||
|
||||
it("record(annotate) requires annotation", () => {
|
||||
try {
|
||||
log.record({
|
||||
actionVerb: "annotate",
|
||||
qitemId: "q-1",
|
||||
actorSession: "x@r",
|
||||
actedAt: "2026-05-04T01:00:00.000Z",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(MissionControlActionLogError);
|
||||
expect((err as MissionControlActionLogError).code).toBe("annotation_required");
|
||||
}
|
||||
});
|
||||
|
||||
it("record(hold/drop) requires reason", () => {
|
||||
for (const verb of ["hold", "drop"] as const) {
|
||||
try {
|
||||
log.record({
|
||||
actionVerb: verb,
|
||||
qitemId: "q-1",
|
||||
actorSession: "x@r",
|
||||
actedAt: "2026-05-04T01:00:00.000Z",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(MissionControlActionLogError);
|
||||
expect((err as MissionControlActionLogError).code).toBe("reason_required");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("record rejects unknown verb", () => {
|
||||
try {
|
||||
log.record({
|
||||
actionVerb: "totally-bogus" as never,
|
||||
qitemId: "q-1",
|
||||
actorSession: "x@r",
|
||||
actedAt: "2026-05-04T01:00:00.000Z",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(MissionControlActionLogError);
|
||||
expect((err as MissionControlActionLogError).code).toBe("verb_unknown");
|
||||
}
|
||||
});
|
||||
|
||||
it("record JSON-encodes before/after state and audit notes", () => {
|
||||
log.record({
|
||||
actionVerb: "approve",
|
||||
qitemId: "q-1",
|
||||
actorSession: "human@r",
|
||||
actedAt: "2026-05-04T01:00:00.000Z",
|
||||
beforeState: { state: "pending" },
|
||||
afterState: { state: "done" },
|
||||
auditNotes: { evidence: "/path/x" },
|
||||
});
|
||||
const list = log.listRecent();
|
||||
expect(list[0]?.beforeState).toEqual({ state: "pending" });
|
||||
expect(list[0]?.afterState).toEqual({ state: "done" });
|
||||
expect(list[0]?.auditNotes).toEqual({ evidence: "/path/x" });
|
||||
});
|
||||
|
||||
it("listRecent returns DESC by acted_at", () => {
|
||||
log.record({ actionVerb: "approve", qitemId: "q-1", actorSession: "x@r", actedAt: "2026-05-04T01:00:00.000Z" });
|
||||
log.record({ actionVerb: "approve", qitemId: "q-1", actorSession: "x@r", actedAt: "2026-05-04T01:01:00.000Z" });
|
||||
const list = log.listRecent();
|
||||
expect(list[0]?.actedAt).toBe("2026-05-04T01:01:00.000Z");
|
||||
expect(list[1]?.actedAt).toBe("2026-05-04T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("listForQitem + listForActor filter correctly", () => {
|
||||
log.record({ actionVerb: "approve", qitemId: "q-1", actorSession: "alice@r", actedAt: "2026-05-04T01:00:00.000Z" });
|
||||
log.record({ actionVerb: "deny", qitemId: "q-1", actorSession: "bob@r", actedAt: "2026-05-04T01:01:00.000Z" });
|
||||
expect(log.listForQitem("q-1")).toHaveLength(2);
|
||||
expect(log.listForActor("alice@r")).toHaveLength(1);
|
||||
expect(log.listForActor("bob@r")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("API surface lacks update/delete (append-only contract)", () => {
|
||||
const proto = Object.getPrototypeOf(log) as Record<string, unknown>;
|
||||
const names = Object.getOwnPropertyNames(proto);
|
||||
expect(names).not.toContain("update");
|
||||
expect(names).not.toContain("delete");
|
||||
expect(names).not.toContain("remove");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import {
|
||||
MissionControlFleetCliCapability,
|
||||
MISSION_CONTROL_DESIRED_FIELDS,
|
||||
} from "../src/domain/mission-control/mission-control-fleet-cli-capability.js";
|
||||
|
||||
describe("MissionControlFleetCliCapability (PL-005 Phase A; 4 sub-clauses of graceful degradation)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let rigRepo: RigRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig-alpha')`).run();
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-2', 'rig-beta')`).run();
|
||||
bus = new EventBus(db);
|
||||
rigRepo = new RigRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("rollupFleet returns one row per registered rig", async () => {
|
||||
const cli = new MissionControlFleetCliCapability({ db, eventBus: bus, rigRepo });
|
||||
const fleet = await cli.rollupFleet();
|
||||
expect(fleet.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("SUB-CLAUSE 1+4: probe-reported missing fields surface as drift + bump staleCliCount", async () => {
|
||||
const cli = new MissionControlFleetCliCapability({
|
||||
db,
|
||||
eventBus: bus,
|
||||
rigRepo,
|
||||
probeRig: async (name) => ({
|
||||
cliVersionLabel: name === "rig-alpha" ? "v0.1.12" : "head",
|
||||
unsupportedFields: name === "rig-alpha" ? ["recoveryGuidance"] : [],
|
||||
}),
|
||||
});
|
||||
const fleet = await cli.rollupFleet();
|
||||
const alpha = fleet.rows.find((r) => r.rigName === "rig-alpha");
|
||||
const beta = fleet.rows.find((r) => r.rigName === "rig-beta");
|
||||
expect(alpha?.cliDriftDetected).toBe(true);
|
||||
expect(beta?.cliDriftDetected).toBe(false);
|
||||
expect(fleet.staleCliCount).toBe(1);
|
||||
expect(fleet.degradedFields).toContain("recoveryGuidance");
|
||||
});
|
||||
|
||||
it("SUB-CLAUSE 3: once-per-session-per-rig logging — drift event emitted only once per (rig, field)", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const cli = new MissionControlFleetCliCapability({
|
||||
db,
|
||||
eventBus: bus,
|
||||
rigRepo,
|
||||
probeRig: async () => ({
|
||||
cliVersionLabel: "v0.1.12",
|
||||
unsupportedFields: ["recoveryGuidance"],
|
||||
}),
|
||||
});
|
||||
// 5 rollup calls = 10 (rig, field) observations, but only 2 drift events (one per rig).
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await cli.rollupFleet();
|
||||
}
|
||||
const driftEvents = events.filter((e) => e.type === "mission_control.cli_drift_detected");
|
||||
expect(driftEvents).toHaveLength(2); // 2 rigs × 1 missing field × ONCE
|
||||
});
|
||||
|
||||
it("SUB-CLAUSE 3: resetDriftLogForTest re-arms once-per-session log", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const cli = new MissionControlFleetCliCapability({
|
||||
db,
|
||||
eventBus: bus,
|
||||
rigRepo,
|
||||
probeRig: async () => ({
|
||||
cliVersionLabel: "v0.1.12",
|
||||
unsupportedFields: ["recoveryGuidance"],
|
||||
}),
|
||||
});
|
||||
await cli.rollupFleet();
|
||||
expect(events.filter((e) => e.type === "mission_control.cli_drift_detected")).toHaveLength(2);
|
||||
cli.resetDriftLogForTest();
|
||||
await cli.rollupFleet();
|
||||
expect(events.filter((e) => e.type === "mission_control.cli_drift_detected")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("SUB-CLAUSE 2: per-rig honesty — different rigs report different capabilities", async () => {
|
||||
const cli = new MissionControlFleetCliCapability({
|
||||
db,
|
||||
eventBus: bus,
|
||||
rigRepo,
|
||||
probeRig: async (name) => {
|
||||
if (name === "rig-alpha") return { cliVersionLabel: "v0.1.12", unsupportedFields: [...MISSION_CONTROL_DESIRED_FIELDS] };
|
||||
return { cliVersionLabel: "head", unsupportedFields: [] };
|
||||
},
|
||||
});
|
||||
const fleet = await cli.rollupFleet();
|
||||
const alpha = fleet.rows.find((r) => r.rigName === "rig-alpha");
|
||||
const beta = fleet.rows.find((r) => r.rigName === "rig-beta");
|
||||
expect(alpha?.cliVersionLabel).toBe("v0.1.12");
|
||||
expect(beta?.cliVersionLabel).toBe("head");
|
||||
});
|
||||
|
||||
it("rig with active in-progress queue item summarizes activityState=active", async () => {
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES ('q-1', '2026-05-04T01:00:00Z', '2026-05-04T01:00:00Z', 'a@rig-alpha', 'b@rig-alpha', 'in-progress', 'routine', 'x')`,
|
||||
).run();
|
||||
const cli = new MissionControlFleetCliCapability({ db, eventBus: bus, rigRepo });
|
||||
const fleet = await cli.rollupFleet();
|
||||
const alpha = fleet.rows.find((r) => r.rigName === "rig-alpha");
|
||||
expect(alpha?.activityState).toBe("active");
|
||||
});
|
||||
|
||||
it("rig with blocked queue item summarizes activityState=blocked + attentionReason", async () => {
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body, blocked_on)
|
||||
VALUES ('q-1', '2026-05-04T01:00:00Z', '2026-05-04T01:00:00Z', 'a@rig-alpha', 'b@rig-alpha', 'blocked', 'routine', 'x', 'gate-x')`,
|
||||
).run();
|
||||
const cli = new MissionControlFleetCliCapability({ db, eventBus: bus, rigRepo });
|
||||
const fleet = await cli.rollupFleet();
|
||||
const alpha = fleet.rows.find((r) => r.rigName === "rig-alpha");
|
||||
expect(alpha?.activityState).toBe("blocked");
|
||||
expect(alpha?.attentionReason).toContain("gate-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { StreamStore } from "../src/domain/stream-store.js";
|
||||
import { ViewProjector } from "../src/domain/view-projector.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { MissionControlFleetCliCapability } from "../src/domain/mission-control/mission-control-fleet-cli-capability.js";
|
||||
import {
|
||||
MissionControlReadLayer,
|
||||
MISSION_CONTROL_VIEWS,
|
||||
type CompactStatusRow,
|
||||
} from "../src/domain/mission-control/mission-control-read-layer.js";
|
||||
|
||||
describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let viewProjector: ViewProjector;
|
||||
let streamStore: StreamStore;
|
||||
let readLayer: MissionControlReadLayer;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema, viewsCustomSchema,
|
||||
]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
viewProjector = new ViewProjector(db, bus);
|
||||
streamStore = new StreamStore(db, bus);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const fleetCli = new MissionControlFleetCliCapability({ db, eventBus: bus, rigRepo });
|
||||
readLayer = new MissionControlReadLayer({
|
||||
db,
|
||||
queueRepo,
|
||||
viewProjector,
|
||||
streamStore,
|
||||
fleetCliCapability: fleetCli,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("readView returns a result for all 7 views (no throw, structurally valid)", async () => {
|
||||
for (const viewName of MISSION_CONTROL_VIEWS) {
|
||||
const result = await readLayer.readView(viewName);
|
||||
expect(result.viewName).toBe(viewName);
|
||||
expect(Array.isArray(result.rows)).toBe(true);
|
||||
expect(typeof result.meta.rowCount).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("9-FIELD CONTENT MODEL: every row across every view exposes all 9 fields", async () => {
|
||||
// Seed varied content so every view has at least one row.
|
||||
await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "human-wrandom@kernel",
|
||||
body: "needs human approval",
|
||||
tier: "human-gate",
|
||||
});
|
||||
await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "agent@rig",
|
||||
body: "agent task",
|
||||
priority: "high",
|
||||
});
|
||||
streamStore.emit({
|
||||
streamItemId: "stream-1",
|
||||
sourceSession: "discovery@rig",
|
||||
body: "observation",
|
||||
});
|
||||
|
||||
const fields: Array<keyof CompactStatusRow> = [
|
||||
"rigOrMissionName",
|
||||
"currentPhase",
|
||||
"state",
|
||||
"nextAction",
|
||||
"pendingHumanDecision",
|
||||
"readCost",
|
||||
"lastUpdate",
|
||||
"confidenceFreshness",
|
||||
"evidenceLink",
|
||||
];
|
||||
|
||||
for (const viewName of MISSION_CONTROL_VIEWS) {
|
||||
const result = await readLayer.readView(viewName);
|
||||
for (const row of result.rows) {
|
||||
for (const field of fields) {
|
||||
expect(field in row).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("my-queue filters to operator's human-gate items only", async () => {
|
||||
await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "human-wrandom@kernel",
|
||||
body: "for the operator",
|
||||
tier: "human-gate",
|
||||
});
|
||||
await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "agent@rig",
|
||||
body: "not human-gate",
|
||||
});
|
||||
await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "human-wrandom@kernel",
|
||||
body: "non-human-gate from operator",
|
||||
});
|
||||
const result = await readLayer.readView("my-queue");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0]?.rigOrMissionName).toBe("human-wrandom@kernel");
|
||||
});
|
||||
|
||||
it("recent-ships caps at 10 (founder Q4 default)", async () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const created = await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "agent@rig",
|
||||
body: `ship ${i}`,
|
||||
});
|
||||
queueRepo.update({
|
||||
qitemId: created.qitemId,
|
||||
actorSession: "agent@rig",
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
});
|
||||
}
|
||||
const result = await readLayer.readView("recent-ships");
|
||||
expect(result.rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("active-work sorts priority-first (critical > high > routine > background)", async () => {
|
||||
await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x", priority: "routine" });
|
||||
await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x", priority: "critical" });
|
||||
await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x", priority: "high" });
|
||||
const result = await readLayer.readView("active-work");
|
||||
expect(result.rows[0]?.confidenceFreshness).toBe("critical");
|
||||
expect(result.rows[1]?.confidenceFreshness).toBe("high");
|
||||
expect(result.rows[2]?.confidenceFreshness).toBe("routine");
|
||||
});
|
||||
|
||||
it("recent-observations reads from stream_items (PL-004 Phase A daemon-backed source)", async () => {
|
||||
streamStore.emit({
|
||||
streamItemId: "stream-1",
|
||||
sourceSession: "discovery@rig",
|
||||
body: "observation 1",
|
||||
hintType: "feature-request",
|
||||
});
|
||||
streamStore.emit({
|
||||
streamItemId: "stream-2",
|
||||
sourceSession: "discovery@rig",
|
||||
body: "observation 2",
|
||||
});
|
||||
const result = await readLayer.readView("recent-observations");
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0]?.rigOrMissionName).toBe("discovery@rig");
|
||||
});
|
||||
|
||||
it("fleet view returns rows + drift indicator metadata", async () => {
|
||||
const result = await readLayer.readView("fleet");
|
||||
expect(result.viewName).toBe("fleet");
|
||||
expect(typeof result.meta.rowCount).toBe("number");
|
||||
// staleCliCount is present (may be 0 with default no-op probe).
|
||||
expect(typeof result.meta.rigsRunningStaleCli).toBe("number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
|
||||
import { missionControlActionsSchema } from "../src/db/migrations/037_mission_control_actions.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { StreamStore } from "../src/domain/stream-store.js";
|
||||
import { ViewProjector } from "../src/domain/view-projector.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlReadLayer } from "../src/domain/mission-control/mission-control-read-layer.js";
|
||||
import { MissionControlFleetCliCapability } from "../src/domain/mission-control/mission-control-fleet-cli-capability.js";
|
||||
import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
|
||||
function buildApp(opts: {
|
||||
eventBus: EventBus;
|
||||
readLayer: MissionControlReadLayer;
|
||||
writeContract: MissionControlWriteContract;
|
||||
fleetCli: MissionControlFleetCliCapability;
|
||||
}): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, opts.eventBus);
|
||||
c.set("missionControlReadLayer" as never, opts.readLayer);
|
||||
c.set("missionControlWriteContract" as never, opts.writeContract);
|
||||
c.set("missionControlFleetCliCapability" as never, opts.fleetCli);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("mission-control routes (PL-005 Phase A)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema, viewsCustomSchema,
|
||||
missionControlActionsSchema,
|
||||
]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
const viewProjector = new ViewProjector(db, bus);
|
||||
const streamStore = new StreamStore(db, bus);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const fleetCli = new MissionControlFleetCliCapability({ db, eventBus: bus, rigRepo });
|
||||
const actionLog = new MissionControlActionLog(db);
|
||||
const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo, actionLog });
|
||||
const readLayer = new MissionControlReadLayer({
|
||||
db, queueRepo, viewProjector, streamStore, fleetCliCapability: fleetCli,
|
||||
});
|
||||
app = buildApp({ eventBus: bus, readLayer, writeContract, fleetCli });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("GET /views returns the 7 view names", async () => {
|
||||
const res = await app.request("/api/mission-control/views");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { views: string[] };
|
||||
expect(body.views).toEqual([
|
||||
"my-queue", "human-gate", "fleet", "active-work",
|
||||
"recent-ships", "recently-active", "recent-observations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("GET /views/:view-name returns view rows for valid view", async () => {
|
||||
const res = await app.request("/api/mission-control/views/active-work");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { viewName: string; rows: unknown[] };
|
||||
expect(body.viewName).toBe("active-work");
|
||||
expect(Array.isArray(body.rows)).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /views/:view-name returns 404 for unknown view", async () => {
|
||||
const res = await app.request("/api/mission-control/views/totally-bogus");
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("view_unknown");
|
||||
});
|
||||
|
||||
it("POST /action with valid approve verb returns 200 + structured result", async () => {
|
||||
const created = await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "dst@rig",
|
||||
body: "x",
|
||||
});
|
||||
const res = await app.request("/api/mission-control/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
verb: "approve",
|
||||
qitemId: created.qitemId,
|
||||
actorSession: "human@r",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { actionId: string; verb: string };
|
||||
expect(body.actionId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(body.verb).toBe("approve");
|
||||
});
|
||||
|
||||
it("POST /action with unknown verb returns 400 + verb_unknown", async () => {
|
||||
const res = await app.request("/api/mission-control/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ verb: "totally-bogus", qitemId: "x", actorSession: "y" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("verb_unknown");
|
||||
});
|
||||
|
||||
it("POST /action on terminal qitem returns 409 + qitem_already_terminal", async () => {
|
||||
const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x" });
|
||||
await app.request("/api/mission-control/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ verb: "approve", qitemId: created.qitemId, actorSession: "h@r" }),
|
||||
});
|
||||
const res = await app.request("/api/mission-control/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ verb: "approve", qitemId: created.qitemId, actorSession: "h@r" }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("qitem_already_terminal");
|
||||
});
|
||||
|
||||
it("GET /cli-capabilities returns fleet roll-up", async () => {
|
||||
const res = await app.request("/api/mission-control/cli-capabilities");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { rows: unknown[]; staleCliCount: number };
|
||||
expect(Array.isArray(body.rows)).toBe(true);
|
||||
expect(typeof body.staleCliCount).toBe("number");
|
||||
});
|
||||
|
||||
// SSE route-order discipline (per PL-004 Phase A R1 lesson; literal
|
||||
// /views, /sse, /watch, /cli-capabilities mounted BEFORE /views/:view-name).
|
||||
it("R1 SSE pattern: GET /api/mission-control/sse returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/mission-control/sse");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/mission-control/watch returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/mission-control/watch");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: literal /views returns array (not shadowed by /views/:view-name)", async () => {
|
||||
const res = await app.request("/api/mission-control/views");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { views: string[] };
|
||||
expect(Array.isArray(body.views)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { missionControlActionsSchema } from "../src/db/migrations/037_mission_control_actions.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import {
|
||||
MissionControlWriteContract,
|
||||
MissionControlWriteContractError,
|
||||
} from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
|
||||
describe("MissionControlWriteContract (PL-005 Phase A; atomic 7-verb)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let actionLog: MissionControlActionLog;
|
||||
let writeContract: MissionControlWriteContract;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
actionLog = new MissionControlActionLog(db);
|
||||
writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo, actionLog });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
async function seedQitem(): Promise<string> {
|
||||
const created = await queueRepo.create({
|
||||
sourceSession: "src@rig",
|
||||
destinationSession: "dst@rig",
|
||||
body: "test work",
|
||||
tier: "human-gate",
|
||||
});
|
||||
return created.qitemId;
|
||||
}
|
||||
|
||||
it("approve closes qitem with closure_reason=no-follow-on + records audit + emits event", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const qitemId = await seedQitem();
|
||||
const result = await writeContract.act({
|
||||
verb: "approve",
|
||||
qitemId,
|
||||
actorSession: "human-wrandom@kernel",
|
||||
});
|
||||
expect(result.actionId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(queueRepo.getById(qitemId)?.state).toBe("done");
|
||||
expect(queueRepo.getById(qitemId)?.closureReason).toBe("no-follow-on");
|
||||
expect(actionLog.listForQitem(qitemId)).toHaveLength(1);
|
||||
expect(actionLog.listForQitem(qitemId)[0]?.actionVerb).toBe("approve");
|
||||
expect(events.some((e) => e.type === "mission_control.action_executed")).toBe(true);
|
||||
expect(events.some((e) => e.type === "queue.updated")).toBe(true);
|
||||
});
|
||||
|
||||
it("deny closes qitem with closure_reason=denied", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
await writeContract.act({
|
||||
verb: "deny",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
reason: "wrong scope",
|
||||
});
|
||||
expect(queueRepo.getById(qitemId)?.closureReason).toBe("denied");
|
||||
});
|
||||
|
||||
it("hold transitions qitem to blocked + sets closure_reason=blocked_on + blocked_on column", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
await writeContract.act({
|
||||
verb: "hold",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
reason: "external-gate-x",
|
||||
});
|
||||
const closed = queueRepo.getById(qitemId);
|
||||
expect(closed?.state).toBe("blocked");
|
||||
expect(closed?.closureReason).toBe("blocked_on");
|
||||
expect(closed?.blockedOn).toBe("external-gate-x");
|
||||
});
|
||||
|
||||
it("drop closes qitem with closure_reason=canceled", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
await writeContract.act({
|
||||
verb: "drop",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
reason: "stale work",
|
||||
});
|
||||
expect(queueRepo.getById(qitemId)?.closureReason).toBe("canceled");
|
||||
});
|
||||
|
||||
it("handoff is atomic 4-step: source closes + destination created + audit + event in same transaction", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
const result = await writeContract.act({
|
||||
verb: "handoff",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
destinationSession: "next@r",
|
||||
notify: false,
|
||||
});
|
||||
expect(result.createdQitemId).not.toBeNull();
|
||||
const closed = queueRepo.getById(qitemId);
|
||||
expect(closed?.state).toBe("handed-off");
|
||||
expect(closed?.closureReason).toBe("handed_off_to");
|
||||
expect(closed?.handedOffTo).toBe("next@r");
|
||||
const created = queueRepo.getById(result.createdQitemId!);
|
||||
expect(created?.destinationSession).toBe("next@r");
|
||||
expect(created?.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("route mirrors handoff but tags the new packet with mission-control:route", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
const result = await writeContract.act({
|
||||
verb: "route",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
destinationSession: "other@r",
|
||||
notify: false,
|
||||
});
|
||||
expect(result.createdQitemId).not.toBeNull();
|
||||
const created = queueRepo.getById(result.createdQitemId!);
|
||||
expect(created?.tags).toContain("mission-control:route");
|
||||
});
|
||||
|
||||
it("annotate has no queue mutation but writes audit + event", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
const beforeState = queueRepo.getById(qitemId)?.state;
|
||||
await writeContract.act({
|
||||
verb: "annotate",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
annotation: "operator note",
|
||||
});
|
||||
expect(queueRepo.getById(qitemId)?.state).toBe(beforeState); // unchanged
|
||||
const list = actionLog.listForQitem(qitemId);
|
||||
expect(list[0]?.actionVerb).toBe("annotate");
|
||||
expect(list[0]?.annotation).toBe("operator note");
|
||||
});
|
||||
|
||||
it("rejects mutations on terminal items with qitem_already_terminal", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
await writeContract.act({ verb: "approve", qitemId, actorSession: "human@r" });
|
||||
try {
|
||||
await writeContract.act({ verb: "approve", qitemId, actorSession: "human@r" });
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(MissionControlWriteContractError);
|
||||
expect((err as MissionControlWriteContractError).code).toBe("qitem_already_terminal");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects route/handoff without destinationSession", async () => {
|
||||
const qitemId = await seedQitem();
|
||||
try {
|
||||
await writeContract.act({ verb: "handoff", qitemId, actorSession: "human@r" });
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(MissionControlWriteContractError);
|
||||
expect((err as MissionControlWriteContractError).code).toBe("destination_required");
|
||||
}
|
||||
});
|
||||
|
||||
it("ATOMIC ROLLBACK: handoff with invalid destination rolls back source close + audit + new qitem", async () => {
|
||||
const failingRepo = new QueueRepository(db, bus, { validateRig: () => false });
|
||||
const failingContract = new MissionControlWriteContract({
|
||||
db,
|
||||
eventBus: bus,
|
||||
queueRepo: failingRepo,
|
||||
actionLog,
|
||||
});
|
||||
const qitemId = await seedQitem();
|
||||
const beforeState = queueRepo.getById(qitemId)?.state;
|
||||
const auditCountBefore = actionLog.countAll();
|
||||
const queueCountBefore = db.prepare(`SELECT COUNT(*) AS n FROM queue_items`).get() as { n: number };
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await failingContract.act({
|
||||
verb: "handoff",
|
||||
qitemId,
|
||||
actorSession: "human@r",
|
||||
destinationSession: "rejected@r",
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
// Rollback: source unchanged, no audit row, no orphan qitem.
|
||||
expect(queueRepo.getById(qitemId)?.state).toBe(beforeState);
|
||||
expect(actionLog.countAll()).toBe(auditCountBefore);
|
||||
const queueCountAfter = db.prepare(`SELECT COUNT(*) AS n FROM queue_items`).get() as { n: number };
|
||||
expect(queueCountAfter.n).toBe(queueCountBefore.n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// PL-005 Phase A: Mission Control top-level surface.
|
||||
//
|
||||
// Per slice § Mission Control as integrated product UI inside the
|
||||
// existing shell: this is a single surface with tab navigation across
|
||||
// the 7 views. NOT a new managed app; NOT a re-implementation of the
|
||||
// dashboard at the same architecture level.
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MISSION_CONTROL_VIEWS,
|
||||
type MissionControlViewName,
|
||||
} from "./hooks/useMissionControlView.js";
|
||||
import { MyQueueView } from "./views/MyQueueView.js";
|
||||
import { HumanGateView } from "./views/HumanGateView.js";
|
||||
import { FleetView } from "./views/FleetView.js";
|
||||
import { ActiveWorkView } from "./views/ActiveWorkView.js";
|
||||
import { RecentShipsView } from "./views/RecentShipsView.js";
|
||||
import { RecentlyActiveView } from "./views/RecentlyActiveView.js";
|
||||
import { RecentObservationsView } from "./views/RecentObservationsView.js";
|
||||
|
||||
const VIEW_LABELS: Record<MissionControlViewName, string> = {
|
||||
"my-queue": "My queue",
|
||||
"human-gate": "Human gate",
|
||||
fleet: "Fleet",
|
||||
"active-work": "Active work",
|
||||
"recent-ships": "Recent ships",
|
||||
"recently-active": "Recently active",
|
||||
"recent-observations": "Observations",
|
||||
};
|
||||
|
||||
export function MissionControlSurface() {
|
||||
const [activeView, setActiveView] = useState<MissionControlViewName>("my-queue");
|
||||
return (
|
||||
<div data-testid="mc-surface" className="flex h-full flex-col">
|
||||
<header className="border-b border-stone-200 bg-stone-50 p-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">
|
||||
Mission Control
|
||||
</div>
|
||||
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">
|
||||
Queue observability
|
||||
</h1>
|
||||
<nav data-testid="mc-tab-nav" className="mt-2 flex flex-wrap gap-1">
|
||||
{MISSION_CONTROL_VIEWS.map((view) => (
|
||||
<button
|
||||
key={view}
|
||||
type="button"
|
||||
data-testid={`mc-tab-${view}`}
|
||||
data-active={activeView === view}
|
||||
onClick={() => setActiveView(view)}
|
||||
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${
|
||||
activeView === view
|
||||
? "border-stone-700 bg-stone-700 text-white"
|
||||
: "border-stone-300 text-stone-700 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
{VIEW_LABELS[view]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main data-testid="mc-active-view" className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeView === "my-queue" ? <MyQueueView /> : null}
|
||||
{activeView === "human-gate" ? <HumanGateView /> : null}
|
||||
{activeView === "fleet" ? <FleetView /> : null}
|
||||
{activeView === "active-work" ? <ActiveWorkView /> : null}
|
||||
{activeView === "recent-ships" ? <RecentShipsView /> : null}
|
||||
{activeView === "recently-active" ? <RecentlyActiveView /> : null}
|
||||
{activeView === "recent-observations" ? <RecentObservationsView /> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// PL-005 Phase A: cross-CLI-version drift indicator.
|
||||
//
|
||||
// Per PRD § Runtime/Source Drift Acceptance sub-clause 4: surfaces a
|
||||
// fleet-level "rigs running stale CLI" indicator + per-row "field
|
||||
// unavailable on this rig's daemon version" placeholders.
|
||||
|
||||
export interface CliDriftIndicatorProps {
|
||||
staleCliCount: number;
|
||||
degradedFields: string[];
|
||||
sourceFallback?: string | null;
|
||||
}
|
||||
|
||||
export function CliDriftIndicator({
|
||||
staleCliCount,
|
||||
degradedFields,
|
||||
sourceFallback,
|
||||
}: CliDriftIndicatorProps) {
|
||||
if (staleCliCount === 0 && degradedFields.length === 0 && !sourceFallback) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-testid="mc-cli-drift-indicator"
|
||||
className="border border-amber-300 bg-amber-50 p-2 text-[11px] text-amber-900"
|
||||
>
|
||||
{staleCliCount > 0 ? (
|
||||
<div data-testid="mc-cli-drift-stale-count">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.12em]">stale-cli</span>{" "}
|
||||
{staleCliCount} {staleCliCount === 1 ? "rig" : "rigs"} running stale CLI
|
||||
</div>
|
||||
) : null}
|
||||
{degradedFields.length > 0 ? (
|
||||
<div data-testid="mc-cli-drift-fields" className="mt-1">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.12em]">missing fields</span>{" "}
|
||||
{degradedFields.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
{sourceFallback ? (
|
||||
<div data-testid="mc-cli-drift-fallback" className="mt-1 text-amber-700">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.12em]">fallback</span>{" "}
|
||||
{sourceFallback}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MissingFieldPlaceholderProps {
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export function MissingFieldPlaceholder({ fieldName }: MissingFieldPlaceholderProps) {
|
||||
return (
|
||||
<span
|
||||
data-testid="mc-missing-field-placeholder"
|
||||
className="font-mono text-[10px] text-amber-700"
|
||||
title={`field unavailable on this rig's daemon version`}
|
||||
>
|
||||
{fieldName}: field unavailable on this rig's daemon version
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// PL-005 Phase A: 9-field phone-friendly content-model atom.
|
||||
//
|
||||
// Per PRD § Acceptance Criteria item 1, NON-NEGOTIABLE: this row carries
|
||||
// all 9 fields verbatim. UI may display compact (only top 3-4 fields
|
||||
// visible on mobile width) but the JSON payload preserves all 9.
|
||||
//
|
||||
// 9 fields verbatim from PRD:
|
||||
// 1. rig/mission name
|
||||
// 2. current phase
|
||||
// 3. active/idle/attention/blocked/degraded
|
||||
// 4. next-action
|
||||
// 5. pending-human-decision
|
||||
// 6. read-cost (full / skim/approve / summary-only)
|
||||
// 7. last-update timestamp
|
||||
// 8. confidence/freshness
|
||||
// 9. evidence link
|
||||
|
||||
import type { CompactStatusRow as CompactStatusRowData } from "../hooks/useMissionControlView.js";
|
||||
|
||||
export interface CompactStatusRowProps {
|
||||
row: CompactStatusRowData;
|
||||
density?: "compact" | "expanded";
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
const STATE_BADGES: Record<CompactStatusRowData["state"], { label: string; cls: string }> = {
|
||||
active: { label: "ACTIVE", cls: "bg-emerald-100 text-emerald-800" },
|
||||
idle: { label: "IDLE", cls: "bg-stone-100 text-stone-700" },
|
||||
attention: { label: "ATTN", cls: "bg-amber-100 text-amber-800" },
|
||||
blocked: { label: "BLOCKED", cls: "bg-red-100 text-red-800" },
|
||||
degraded: { label: "DEGRADED", cls: "bg-orange-100 text-orange-800" },
|
||||
};
|
||||
|
||||
export function CompactStatusRow({
|
||||
row,
|
||||
density = "expanded",
|
||||
onAction,
|
||||
}: CompactStatusRowProps) {
|
||||
const badge = STATE_BADGES[row.state];
|
||||
return (
|
||||
<div
|
||||
data-testid="mc-status-row"
|
||||
data-state={row.state}
|
||||
data-qitem-id={row.qitemId ?? ""}
|
||||
className="border border-stone-200 bg-white p-3 hover:bg-stone-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span
|
||||
data-testid="mc-state-badge"
|
||||
className={`inline-flex items-center px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] ${badge.cls}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span
|
||||
data-testid="mc-rig-name"
|
||||
className="font-mono text-xs text-stone-900 truncate"
|
||||
title={row.rigOrMissionName}
|
||||
>
|
||||
{row.rigOrMissionName}
|
||||
</span>
|
||||
{row.currentPhase ? (
|
||||
<span
|
||||
data-testid="mc-current-phase"
|
||||
className="font-mono text-[10px] text-stone-500"
|
||||
>
|
||||
· {row.currentPhase}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
data-testid="mc-row-action"
|
||||
className="border border-stone-300 px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-700 hover:bg-stone-100"
|
||||
>
|
||||
ACT
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{density === "expanded" && (
|
||||
<div className="mt-2 grid grid-cols-1 gap-1 text-[11px] text-stone-700 sm:grid-cols-2">
|
||||
{row.nextAction ? (
|
||||
<div data-testid="mc-next-action">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.1em] text-stone-500">next</span>{" "}
|
||||
{row.nextAction}
|
||||
</div>
|
||||
) : null}
|
||||
{row.pendingHumanDecision ? (
|
||||
<div data-testid="mc-pending-human-decision" className="text-amber-800">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.1em] text-amber-700">human</span>{" "}
|
||||
{row.pendingHumanDecision}
|
||||
</div>
|
||||
) : null}
|
||||
{row.readCost ? (
|
||||
<div data-testid="mc-read-cost">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.1em] text-stone-500">read</span>{" "}
|
||||
{row.readCost}
|
||||
</div>
|
||||
) : null}
|
||||
{row.confidenceFreshness ? (
|
||||
<div data-testid="mc-confidence-freshness">
|
||||
<span className="font-mono uppercase text-[9px] tracking-[0.1em] text-stone-500">conf</span>{" "}
|
||||
{row.confidenceFreshness}
|
||||
</div>
|
||||
) : null}
|
||||
<div data-testid="mc-last-update" className="text-stone-500 font-mono text-[10px]">
|
||||
{row.lastUpdate}
|
||||
</div>
|
||||
{row.evidenceLink ? (
|
||||
<a
|
||||
data-testid="mc-evidence-link"
|
||||
href={row.evidenceLink}
|
||||
className="text-stone-600 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
evidence
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// PL-005 Phase A: first-class human-seat rendering.
|
||||
//
|
||||
// Per founder Q3 (2026-05-03): human queues are first-class product
|
||||
// concepts, NOT invisible config-layer convention. Mission Control
|
||||
// renders the operator's human seat (default `human-wrandom@kernel`)
|
||||
// with its own card showing identity + load + capabilities.
|
||||
|
||||
import type { CompactStatusRow } from "../hooks/useMissionControlView.js";
|
||||
|
||||
export interface HumanSeatCardProps {
|
||||
/** Canonical session label, e.g., `human-wrandom@kernel`. */
|
||||
session: string;
|
||||
/** Pending human-gate items (for load indication). */
|
||||
rows: CompactStatusRow[];
|
||||
/** Optional capabilities label (which verbs this seat can fire). */
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export function HumanSeatCard({
|
||||
session,
|
||||
rows,
|
||||
capabilities = ["approve", "deny", "route", "annotate", "hold", "drop", "handoff"],
|
||||
}: HumanSeatCardProps) {
|
||||
const pendingCount = rows.filter(
|
||||
(r) => r.state === "idle" || r.state === "attention" || r.state === "blocked",
|
||||
).length;
|
||||
const blockedCount = rows.filter((r) => r.state === "blocked").length;
|
||||
return (
|
||||
<div
|
||||
data-testid="mc-human-seat-card"
|
||||
data-session={session}
|
||||
className="border border-stone-300 bg-stone-50 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-stone-500">
|
||||
human seat
|
||||
</div>
|
||||
<div data-testid="mc-human-seat-session" className="font-mono text-sm text-stone-900">
|
||||
{session}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div data-testid="mc-human-seat-pending" className="font-mono text-2xl text-stone-900">
|
||||
{pendingCount}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
|
||||
pending
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{blockedCount > 0 ? (
|
||||
<div data-testid="mc-human-seat-blocked" className="mt-2 font-mono text-[10px] text-red-800">
|
||||
{blockedCount} blocked
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{capabilities.map((cap) => (
|
||||
<span
|
||||
key={cap}
|
||||
className="border border-stone-300 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.1em] text-stone-600"
|
||||
>
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// PL-005 Phase A: 7-verb action sub-component.
|
||||
//
|
||||
// Per PRD § Acceptance Criteria item 2: each of 7 verbs is one atomic
|
||||
// transaction. The 4-step `handoff` shape is daemon-internal (not visible
|
||||
// here); UI just submits the verb + required fields.
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MISSION_CONTROL_VERBS,
|
||||
type MissionControlVerb,
|
||||
useMissionControlAction,
|
||||
} from "../hooks/useMissionControlAction.js";
|
||||
|
||||
export interface VerbActionsProps {
|
||||
qitemId: string;
|
||||
actorSession: string;
|
||||
/** Restrict the verbs offered (e.g., my-queue may only show approve/deny). */
|
||||
enabledVerbs?: MissionControlVerb[];
|
||||
onSettled?: () => void;
|
||||
}
|
||||
|
||||
const VERB_LABELS: Record<MissionControlVerb, string> = {
|
||||
approve: "Approve",
|
||||
deny: "Deny",
|
||||
route: "Route",
|
||||
annotate: "Annotate",
|
||||
hold: "Hold",
|
||||
drop: "Drop",
|
||||
handoff: "Handoff",
|
||||
};
|
||||
|
||||
export function VerbActions({
|
||||
qitemId,
|
||||
actorSession,
|
||||
enabledVerbs = [...MISSION_CONTROL_VERBS],
|
||||
onSettled,
|
||||
}: VerbActionsProps) {
|
||||
const mutation = useMissionControlAction();
|
||||
const [activeVerb, setActiveVerb] = useState<MissionControlVerb | null>(null);
|
||||
const [destinationSession, setDestinationSession] = useState("");
|
||||
const [annotation, setAnnotation] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const needsDestination = activeVerb === "route" || activeVerb === "handoff";
|
||||
const needsAnnotation = activeVerb === "annotate";
|
||||
const needsReason = activeVerb === "hold" || activeVerb === "drop";
|
||||
|
||||
function reset() {
|
||||
setActiveVerb(null);
|
||||
setDestinationSession("");
|
||||
setAnnotation("");
|
||||
setReason("");
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!activeVerb) return;
|
||||
mutation.mutate(
|
||||
{
|
||||
verb: activeVerb,
|
||||
qitemId,
|
||||
actorSession,
|
||||
destinationSession: needsDestination ? destinationSession : undefined,
|
||||
annotation: needsAnnotation ? annotation : undefined,
|
||||
reason: needsReason ? reason : undefined,
|
||||
},
|
||||
{
|
||||
onSettled: () => {
|
||||
reset();
|
||||
onSettled?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mc-verb-actions" className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{enabledVerbs.map((verb) => (
|
||||
<button
|
||||
key={verb}
|
||||
type="button"
|
||||
data-testid={`mc-verb-${verb}`}
|
||||
onClick={() => setActiveVerb(verb)}
|
||||
disabled={mutation.isPending}
|
||||
className={`border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] ${
|
||||
activeVerb === verb
|
||||
? "border-stone-700 bg-stone-700 text-white"
|
||||
: "border-stone-300 text-stone-700 hover:bg-stone-100"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{VERB_LABELS[verb]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeVerb && (
|
||||
<div className="space-y-1 border border-stone-200 bg-stone-50 p-2">
|
||||
{needsDestination && (
|
||||
<input
|
||||
type="text"
|
||||
data-testid="mc-verb-destination-input"
|
||||
value={destinationSession}
|
||||
onChange={(e) => setDestinationSession(e.target.value)}
|
||||
placeholder="destination session (member@rig)"
|
||||
className="w-full border border-stone-300 px-2 py-1 font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
{needsAnnotation && (
|
||||
<textarea
|
||||
data-testid="mc-verb-annotation-input"
|
||||
value={annotation}
|
||||
onChange={(e) => setAnnotation(e.target.value)}
|
||||
placeholder="annotation"
|
||||
rows={2}
|
||||
className="w-full border border-stone-300 px-2 py-1 font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
{needsReason && (
|
||||
<input
|
||||
type="text"
|
||||
data-testid="mc-verb-reason-input"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={`${activeVerb} reason`}
|
||||
className="w-full border border-stone-300 px-2 py-1 font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
data-testid="mc-verb-cancel"
|
||||
className="border border-stone-300 px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
data-testid="mc-verb-submit"
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
(needsDestination && !destinationSession) ||
|
||||
(needsAnnotation && !annotation) ||
|
||||
(needsReason && !reason)
|
||||
}
|
||||
className="border border-stone-700 bg-stone-800 px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-white disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "..." : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
{mutation.isError ? (
|
||||
<div data-testid="mc-verb-error" className="font-mono text-[10px] text-red-700">
|
||||
error: {mutation.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// PL-005 Phase A: mutation hook for the 7 Mission Control verbs.
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const MISSION_CONTROL_VERBS = [
|
||||
"approve",
|
||||
"deny",
|
||||
"route",
|
||||
"annotate",
|
||||
"hold",
|
||||
"drop",
|
||||
"handoff",
|
||||
] as const;
|
||||
|
||||
export type MissionControlVerb = (typeof MISSION_CONTROL_VERBS)[number];
|
||||
|
||||
export interface MissionControlActionInput {
|
||||
verb: MissionControlVerb;
|
||||
qitemId: string;
|
||||
actorSession: string;
|
||||
destinationSession?: string;
|
||||
body?: string;
|
||||
annotation?: string;
|
||||
reason?: string;
|
||||
notify?: boolean;
|
||||
auditNotes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MissionControlActionResult {
|
||||
actionId: string;
|
||||
verb: MissionControlVerb;
|
||||
qitemId: string;
|
||||
closedQitem: unknown;
|
||||
createdQitemId: string | null;
|
||||
notifyAttempted: boolean;
|
||||
notifyResult: string | null;
|
||||
auditedAt: string;
|
||||
}
|
||||
|
||||
async function postAction(input: MissionControlActionInput): Promise<MissionControlActionResult> {
|
||||
const res = await fetch("/api/mission-control/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(
|
||||
typeof body === "object" && body && "message" in body
|
||||
? String((body as { message: unknown }).message)
|
||||
: `HTTP ${res.status}`,
|
||||
) as Error & { code?: string; details?: unknown };
|
||||
if (typeof body === "object" && body && "error" in body) {
|
||||
err.code = String((body as { error: unknown }).error);
|
||||
}
|
||||
err.details = body;
|
||||
throw err;
|
||||
}
|
||||
return body as MissionControlActionResult;
|
||||
}
|
||||
|
||||
export function useMissionControlAction() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: postAction,
|
||||
onSuccess: () => {
|
||||
// Invalidate all Mission Control views so the operator sees the
|
||||
// post-action state without a manual refresh.
|
||||
queryClient.invalidateQueries({ queryKey: ["mission-control", "view"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// PL-005 Phase A: hook to fetch fleet CLI capability cache (drift indicator).
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface FleetRollupRow {
|
||||
rigName: string;
|
||||
activityState: "active" | "idle" | "attention" | "blocked" | "degraded";
|
||||
lifecycleState: string | null;
|
||||
attentionReason: string | null;
|
||||
lastUpdate: string;
|
||||
cliVersionLabel: string;
|
||||
cliDriftDetected: boolean;
|
||||
}
|
||||
|
||||
export interface FleetRollup {
|
||||
rows: FleetRollupRow[];
|
||||
staleCliCount: number;
|
||||
degradedFields: string[];
|
||||
sourceFallback: string | null;
|
||||
}
|
||||
|
||||
async function fetchCliCapabilities(): Promise<FleetRollup> {
|
||||
const res = await fetch("/api/mission-control/cli-capabilities");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useMissionControlCliCapabilities() {
|
||||
return useQuery({
|
||||
queryKey: ["mission-control", "cli-capabilities"],
|
||||
queryFn: fetchCliCapabilities,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// PL-005 Phase A: hook to fetch one Mission Control view from the daemon.
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const MISSION_CONTROL_VIEWS = [
|
||||
"my-queue",
|
||||
"human-gate",
|
||||
"fleet",
|
||||
"active-work",
|
||||
"recent-ships",
|
||||
"recently-active",
|
||||
"recent-observations",
|
||||
] as const;
|
||||
|
||||
export type MissionControlViewName = (typeof MISSION_CONTROL_VIEWS)[number];
|
||||
|
||||
export interface CompactStatusRow {
|
||||
rigOrMissionName: string;
|
||||
currentPhase: string | null;
|
||||
state: "active" | "idle" | "attention" | "blocked" | "degraded";
|
||||
nextAction: string | null;
|
||||
pendingHumanDecision: string | null;
|
||||
readCost: "full" | "skim/approve" | "summary-only" | null;
|
||||
lastUpdate: string;
|
||||
confidenceFreshness: string | null;
|
||||
evidenceLink: string | null;
|
||||
qitemId?: string | null;
|
||||
rawSourceRef?: string | null;
|
||||
}
|
||||
|
||||
export interface MissionControlViewResult {
|
||||
viewName: MissionControlViewName;
|
||||
rows: CompactStatusRow[];
|
||||
meta: {
|
||||
rowCount: number;
|
||||
rigsRunningStaleCli?: number;
|
||||
degradedFields?: string[];
|
||||
sourceFallback?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchView(
|
||||
viewName: MissionControlViewName,
|
||||
operatorSession?: string,
|
||||
): Promise<MissionControlViewResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (operatorSession) params.set("operatorSession", operatorSession);
|
||||
const qs = params.toString();
|
||||
const url = `/api/mission-control/views/${viewName}${qs ? `?${qs}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useMissionControlView(
|
||||
viewName: MissionControlViewName,
|
||||
opts?: { operatorSession?: string; refetchInterval?: number },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["mission-control", "view", viewName, opts?.operatorSession ?? null],
|
||||
queryFn: () => fetchView(viewName, opts?.operatorSession),
|
||||
refetchInterval: opts?.refetchInterval ?? 5000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GenericListView } from "./GenericListView.js";
|
||||
|
||||
export function ActiveWorkView() {
|
||||
return (
|
||||
<GenericListView
|
||||
viewName="active-work"
|
||||
title="Active work"
|
||||
subtitle="Pending / in-progress / blocked, priority-first"
|
||||
emptyMessage="No active work in flight."
|
||||
withVerbActions
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// PL-005 Phase A: fleet view (rig roll-up + CLI drift indicator).
|
||||
//
|
||||
// Per PRD § Acceptance Criteria: combines per-rig activity state with
|
||||
// the cross-CLI-version drift indicator (sub-clause 4 of graceful
|
||||
// degradation). Renders both a fleet-level "stale CLI" badge AND a
|
||||
// per-row indicator on each rig that's reporting drift.
|
||||
|
||||
import { CompactStatusRow } from "../components/CompactStatusRow.js";
|
||||
import {
|
||||
CliDriftIndicator,
|
||||
MissingFieldPlaceholder,
|
||||
} from "../components/CliDriftIndicator.js";
|
||||
import { useMissionControlView } from "../hooks/useMissionControlView.js";
|
||||
import { useMissionControlCliCapabilities } from "../hooks/useMissionControlCliCapabilities.js";
|
||||
|
||||
export function FleetView() {
|
||||
const fleetView = useMissionControlView("fleet");
|
||||
const cliCapabilities = useMissionControlCliCapabilities();
|
||||
|
||||
return (
|
||||
<div data-testid="mc-view-fleet" className="space-y-3 p-3">
|
||||
<header className="space-y-0.5">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-stone-500">
|
||||
fleet
|
||||
</div>
|
||||
<h2 className="font-headline text-lg text-stone-900">Fleet</h2>
|
||||
<p className="text-xs text-stone-600">
|
||||
Per-rig activity and per-rig CLI capability honesty.
|
||||
</p>
|
||||
</header>
|
||||
{cliCapabilities.data ? (
|
||||
<CliDriftIndicator
|
||||
staleCliCount={cliCapabilities.data.staleCliCount}
|
||||
degradedFields={cliCapabilities.data.degradedFields}
|
||||
sourceFallback={cliCapabilities.data.sourceFallback}
|
||||
/>
|
||||
) : null}
|
||||
{fleetView.isLoading ? (
|
||||
<div data-testid="mc-view-loading" className="font-mono text-[10px] text-stone-500">
|
||||
loading...
|
||||
</div>
|
||||
) : fleetView.isError ? (
|
||||
<div data-testid="mc-view-error" className="font-mono text-[11px] text-red-700">
|
||||
error: {fleetView.error.message}
|
||||
</div>
|
||||
) : fleetView.data?.rows.length === 0 ? (
|
||||
<div data-testid="mc-view-empty" className="font-mono text-[11px] text-stone-500">
|
||||
No rigs registered.
|
||||
</div>
|
||||
) : (
|
||||
<ul data-testid="mc-view-rows" className="space-y-1.5">
|
||||
{fleetView.data?.rows.map((row, idx) => {
|
||||
const driftRow = cliCapabilities.data?.rows.find(
|
||||
(r) => r.rigName === row.rigOrMissionName,
|
||||
);
|
||||
return (
|
||||
<li key={`fleet-${idx}-${row.rigOrMissionName}`} className="space-y-1">
|
||||
<CompactStatusRow row={row} density="expanded" />
|
||||
{driftRow?.cliDriftDetected ? (
|
||||
<div className="pl-3">
|
||||
<MissingFieldPlaceholder fieldName="recoveryGuidance" />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// PL-005 Phase A: shared list view used by 4 of the 7 views.
|
||||
// MyQueueView, HumanGateView, ActiveWorkView, RecentShipsView,
|
||||
// RecentlyActiveView, RecentObservationsView all render a list of
|
||||
// CompactStatusRow with optional verb actions.
|
||||
|
||||
import { CompactStatusRow } from "../components/CompactStatusRow.js";
|
||||
import { VerbActions } from "../components/VerbActions.js";
|
||||
import {
|
||||
type MissionControlViewName,
|
||||
useMissionControlView,
|
||||
} from "../hooks/useMissionControlView.js";
|
||||
import type { MissionControlVerb } from "../hooks/useMissionControlAction.js";
|
||||
|
||||
export interface GenericListViewProps {
|
||||
viewName: MissionControlViewName;
|
||||
operatorSession?: string;
|
||||
/** Optional restriction on which verbs are offered per row. */
|
||||
enabledVerbs?: MissionControlVerb[];
|
||||
/** Optional empty-state message. */
|
||||
emptyMessage?: string;
|
||||
/** Header label. */
|
||||
title: string;
|
||||
/** Optional subtitle / description. */
|
||||
subtitle?: string;
|
||||
/** Show row-level verb actions. Default true for actionable views,
|
||||
* false for read-only views (recent-ships, recently-active, etc.). */
|
||||
withVerbActions?: boolean;
|
||||
}
|
||||
|
||||
export function GenericListView({
|
||||
viewName,
|
||||
operatorSession,
|
||||
enabledVerbs,
|
||||
emptyMessage = "Nothing here right now.",
|
||||
title,
|
||||
subtitle,
|
||||
withVerbActions = false,
|
||||
}: GenericListViewProps) {
|
||||
const query = useMissionControlView(viewName, { operatorSession });
|
||||
const actorSession = operatorSession ?? "human-wrandom@kernel";
|
||||
|
||||
return (
|
||||
<div data-testid={`mc-view-${viewName}`} className="space-y-3 p-3">
|
||||
<header className="space-y-0.5">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-stone-500">
|
||||
{viewName}
|
||||
</div>
|
||||
<h2 className="font-headline text-lg text-stone-900">{title}</h2>
|
||||
{subtitle ? <p className="text-xs text-stone-600">{subtitle}</p> : null}
|
||||
</header>
|
||||
{query.isLoading ? (
|
||||
<div data-testid="mc-view-loading" className="font-mono text-[10px] text-stone-500">
|
||||
loading...
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div data-testid="mc-view-error" className="font-mono text-[11px] text-red-700">
|
||||
error: {query.error.message}
|
||||
</div>
|
||||
) : query.data?.rows.length === 0 ? (
|
||||
<div data-testid="mc-view-empty" className="font-mono text-[11px] text-stone-500">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<ul data-testid="mc-view-rows" className="space-y-1.5">
|
||||
{query.data?.rows.map((row, idx) => (
|
||||
<li key={row.qitemId ?? `${viewName}-${idx}`} className="space-y-1">
|
||||
<CompactStatusRow row={row} density="expanded" />
|
||||
{withVerbActions && row.qitemId ? (
|
||||
<VerbActions
|
||||
qitemId={row.qitemId}
|
||||
actorSession={actorSession}
|
||||
enabledVerbs={enabledVerbs}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{query.data?.meta ? (
|
||||
<footer
|
||||
data-testid="mc-view-meta"
|
||||
className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500"
|
||||
>
|
||||
rows: {query.data.meta.rowCount}
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// PL-005 Phase A: cross-fleet human-gate view.
|
||||
import { GenericListView } from "./GenericListView.js";
|
||||
|
||||
export function HumanGateView() {
|
||||
return (
|
||||
<GenericListView
|
||||
viewName="human-gate"
|
||||
title="Human gate"
|
||||
subtitle="Cross-fleet items waiting on a human decision"
|
||||
emptyMessage="No human-gated items across the fleet."
|
||||
withVerbActions
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// PL-005 Phase A: my-queue view (operator's human-gate queue).
|
||||
// Combines HumanSeatCard (first-class human seat per founder Q3) with
|
||||
// the operator's pending human-gate items + verb actions.
|
||||
|
||||
import { HumanSeatCard } from "../components/HumanSeatCard.js";
|
||||
import { useMissionControlView } from "../hooks/useMissionControlView.js";
|
||||
import { GenericListView } from "./GenericListView.js";
|
||||
|
||||
export interface MyQueueViewProps {
|
||||
operatorSession?: string;
|
||||
}
|
||||
|
||||
export function MyQueueView({
|
||||
operatorSession = "human-wrandom@kernel",
|
||||
}: MyQueueViewProps) {
|
||||
const query = useMissionControlView("my-queue", { operatorSession });
|
||||
return (
|
||||
<div data-testid="mc-view-my-queue-container" className="space-y-3 p-3">
|
||||
<HumanSeatCard session={operatorSession} rows={query.data?.rows ?? []} />
|
||||
<GenericListView
|
||||
viewName="my-queue"
|
||||
operatorSession={operatorSession}
|
||||
title="My queue"
|
||||
subtitle="Decisions that need me, ordered by read-cost"
|
||||
emptyMessage="No human-gated items waiting on you."
|
||||
withVerbActions
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GenericListView } from "./GenericListView.js";
|
||||
|
||||
export function RecentObservationsView() {
|
||||
return (
|
||||
<GenericListView
|
||||
viewName="recent-observations"
|
||||
title="Recent observations"
|
||||
subtitle="Recent stream observations relevant to operator attention"
|
||||
emptyMessage="No recent observations."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// PL-005 Phase A: recent ships view (last 10 done items per founder Q4).
|
||||
import { GenericListView } from "./GenericListView.js";
|
||||
|
||||
export function RecentShipsView() {
|
||||
return (
|
||||
<GenericListView
|
||||
viewName="recent-ships"
|
||||
title="Recent ships"
|
||||
subtitle="Last 10 done / handed-off items with closure reason"
|
||||
emptyMessage="Nothing shipped recently."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GenericListView } from "./GenericListView.js";
|
||||
|
||||
export function RecentlyActiveView() {
|
||||
return (
|
||||
<GenericListView
|
||||
viewName="recently-active"
|
||||
title="Recently active"
|
||||
subtitle="Activity-sorted via PL-004 Phase B view-projector"
|
||||
emptyMessage="No recent activity."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { BundleInstallFlow } from "./components/BundleInstallFlow.js";
|
||||
import { LibraryReview } from "./components/LibraryReview.js";
|
||||
import { LiveNodeDetails } from "./components/LiveNodeDetails.js";
|
||||
import { DiscoveryOverlay } from "./components/DiscoveryOverlay.js";
|
||||
import { MissionControlSurface } from "./components/mission-control/MissionControlSurface.js";
|
||||
import { useRigSummary } from "./hooks/useRigSummary.js";
|
||||
import { useDrawerSelection } from "./components/AppShell.js";
|
||||
|
||||
@@ -218,6 +219,13 @@ const liveNodeDetailsRoute = createRoute({
|
||||
},
|
||||
});
|
||||
|
||||
// PL-005 Phase A: Mission Control / Queue Observability surface.
|
||||
const missionControlRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/mission-control",
|
||||
component: MissionControlSurface,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
rigDetailRoute,
|
||||
@@ -236,6 +244,7 @@ const routeTree = rootRoute.addChildren([
|
||||
bundleInspectRoute,
|
||||
bundleInstallRoute,
|
||||
liveNodeDetailsRoute,
|
||||
missionControlRoute,
|
||||
]);
|
||||
|
||||
// Router
|
||||
|
||||
Reference in new issue
Block a user