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:
mvschwarzandClaude Opus 4.7 committed 2026-05-03 18:35:08 -07:00
1 parent d15ca1fe05
commit 33e01bd472
34 files changed
+3303 -2

No files matched your search

+29
View File
@@ -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)
+8
View File
@@ -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:
+17
View File
@@ -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,
};
}
+8 -1
View File
@@ -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;
}
+10
View File
@@ -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);
+36 -1
View File
@@ -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."
/>
);
}
+9
View File
@@ -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