Merge phase-b-mission-control-queue-observability (Mission Control Phase B + 7 dogfood fixes)

# Conflicts:
#	packages/daemon/src/server.ts
This commit is contained in:
mvschwarz committed 2026-05-04 02:31:18 -07:00
commit 66796c21dd
38 files changed
+2651 -45

No files matched your search

+3 -1
View File
@@ -356,7 +356,9 @@ Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep) are
**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.
**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. An earlier prototype that informed Mission Control's view/verb vocabulary was retired in PL-005 Phase B; git history preserves it as evidence.
PL-005 Phase B extends the daemon Mission Control surface with: a tailnet-bind bearer-token middleware (`packages/daemon/src/middleware/auth-bearer-token.ts` — constant-time comparison via `crypto.timingSafeEqual`; daemon refuses non-loopback bind when the bearer is empty; mounted on `POST /api/mission-control/action` and `POST /api/mission-control/notifications/test`); a notification dispatcher with two adapters (`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts` alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env; ntfy.sh recommended for the operator's phone via tailnet); a read-only audit-history browse layer (`audit-browse.ts`) over `mission_control_actions` exposed at `GET /api/mission-control/audit` with filters (`qitem_id`, `action_verb`, `actor_session`, `since`, `until`) and `(limit, before_id)` pagination cursored on SQLite `rowid` (deterministic insertion order; ULID action_id same-ms tail is non-deterministic). Two new RigEvent types (`mission_control.notification_sent`, `mission_control.notification_failed`) cover the notification audit trail. Phase B adds NO migrations — the audit table from Phase A migration `037_mission_control_actions` is the only data source. UI adds an `audit-history` tab to `MissionControlSurface` with filter UI + paginated result table; the existing 7 Phase A views and 7 verbs are unmodified.
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).
@@ -0,0 +1,142 @@
// PL-005 Phase B: read-only audit-history browse over mission_control_actions.
//
// Filters: qitem_id (exact), action_verb (exact), actor_session (exact),
// since/until (acted_at range). Pagination via before_id + limit.
// Returns {rows, has_more, next_before_id} shape so the UI can paginate.
//
// Read-only: no writers, no UPDATE/DELETE methods. Phase A's
// MissionControlActionLog is the only writer; this layer only queries.
import type Database from "better-sqlite3";
import {
type MissionControlActionEntry,
type MissionControlVerb,
MISSION_CONTROL_VERBS,
} from "./mission-control-action-log.js";
export interface AuditQueryInput {
qitemId?: string;
actionVerb?: string;
actorSession?: string;
/** ISO timestamp; rows with acted_at >= since are included. */
since?: string;
/** ISO timestamp; rows with acted_at <= until are included. */
until?: string;
/** Default 50; capped at 200. */
limit?: number;
/** Pagination cursor: returns rows whose action_id < beforeId. */
beforeId?: string;
}
export interface AuditQueryResult {
rows: MissionControlActionEntry[];
hasMore: boolean;
nextBeforeId: string | 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;
}
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
export class MissionControlAuditBrowse {
constructor(private readonly db: Database.Database) {}
query(input: AuditQueryInput): AuditQueryResult {
if (input.actionVerb && !MISSION_CONTROL_VERBS.includes(input.actionVerb as MissionControlVerb)) {
throw new Error(
`unknown action_verb '${input.actionVerb}'; supported: ${MISSION_CONTROL_VERBS.join(", ")}`,
);
}
const limit = clampLimit(input.limit);
const where: string[] = [];
const params: Array<string | number> = [];
if (input.qitemId) {
where.push("qitem_id = ?");
params.push(input.qitemId);
}
if (input.actionVerb) {
where.push("action_verb = ?");
params.push(input.actionVerb);
}
if (input.actorSession) {
where.push("actor_session = ?");
params.push(input.actorSession);
}
if (input.since) {
where.push("acted_at >= ?");
params.push(input.since);
}
if (input.until) {
where.push("acted_at <= ?");
params.push(input.until);
}
if (input.beforeId) {
// Look up the cursor row's rowid so pagination uses SQLite's
// monotonic insertion order (deterministic) rather than ULID
// action_id (whose same-ms tail is random; non-deterministic
// for tiebreaks). Filed lesson:
// feedback_ulid_tiebreaker_nondeterministic.md.
where.push("rowid < (SELECT rowid FROM mission_control_actions WHERE action_id = ?)");
params.push(input.beforeId);
}
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
// Fetch limit+1 to detect hasMore without a separate count query.
// ORDER BY acted_at DESC primary; rowid DESC tiebreak (deterministic
// insertion order; safer than ULID action_id for same-ms ties).
const sql = `SELECT * FROM mission_control_actions ${whereClause}
ORDER BY acted_at DESC, rowid DESC LIMIT ?`;
const rows = this.db.prepare(sql).all(...params, limit + 1) as ActionRow[];
const hasMore = rows.length > limit;
const trimmed = hasMore ? rows.slice(0, limit) : rows;
const nextBeforeId = hasMore && trimmed.length > 0 ? trimmed[trimmed.length - 1]!.action_id : null;
return {
rows: trimmed.map(rowToEntry),
hasMore,
nextBeforeId,
};
}
}
function clampLimit(input: number | undefined): number {
if (input === undefined || !Number.isFinite(input)) return DEFAULT_LIMIT;
if (input < 1) return 1;
if (input > MAX_LIMIT) return MAX_LIMIT;
return Math.floor(input);
}
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,
};
}
@@ -70,6 +70,9 @@ export interface CompactStatusRow {
/** Carrier metadata (id used for verb actions). */
qitemId?: string | null;
rawSourceRef?: string | null;
/** Human-readable qitem context for phone decisions. */
qitemSummary?: string | null;
qitemBody?: string | null;
}
export interface MissionControlReadResult {
@@ -272,9 +275,17 @@ function qitemToRow(
evidenceLink: null,
qitemId: q.qitemId,
rawSourceRef: q.sourceSession,
qitemSummary: summarizeBody(q.body),
qitemBody: q.body,
};
}
function summarizeBody(body: string): string {
const compact = body.replace(/\s+/g, " ").trim();
if (compact.length <= 120) return compact;
return `${compact.slice(0, 117).trimEnd()}...`;
}
function qitemStateToCompactState(state: QueueState): CompactStatusRow["state"] {
switch (state) {
case "in-progress":
@@ -0,0 +1,65 @@
// PL-005 Phase B: ntfy.sh adapter (default per planner brief).
//
// ntfy.sh contract: HTTP POST to https://ntfy.sh/<topic> with the body
// as the notification text. Headers like Title, Click, Tags shape the
// rendering. Free, self-hostable, simple HTTP POST → push notification
// on the operator's phone (ntfy mobile app subscribed to the topic).
import type {
NotificationAdapter,
NotificationDeliveryResult,
NotificationPayload,
} from "./notification-adapter-types.js";
export interface NtfyAdapterOpts {
/**
* Full topic URL, e.g., `https://ntfy.sh/my-private-topic-abc123`
* or self-hosted `https://ntfy.example.com/operator-phone`.
*/
topicUrl: string;
/** Optional fetch override for tests. */
fetchImpl?: typeof fetch;
}
export class NtfyNotificationAdapter implements NotificationAdapter {
readonly mechanism = "ntfy";
readonly target: string;
private readonly fetchImpl: typeof fetch;
constructor(opts: NtfyAdapterOpts) {
this.target = opts.topicUrl;
this.fetchImpl = opts.fetchImpl ?? fetch;
}
async send(payload: NotificationPayload): Promise<NotificationDeliveryResult> {
const headers: Record<string, string> = {
Title: truncateHeader(payload.title, 250),
};
if (payload.qitemRef) headers.Click = payload.qitemRef;
if (payload.tags && payload.tags.length > 0) {
headers.Tags = payload.tags.join(",");
}
try {
const res = await this.fetchImpl(this.target, {
method: "POST",
headers,
body: payload.body,
});
if (!res.ok) {
return { ok: false, error: `ntfy POST ${res.status}` };
}
return { ok: true, ack: `ntfy ${res.status}` };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
/** ntfy headers must be ASCII single-line; truncate + strip newlines. */
function truncateHeader(s: string, max: number): string {
const cleaned = s.replace(/[\r\n]+/g, " ").trim();
return cleaned.length > max ? cleaned.slice(0, max - 1) + "…" : cleaned;
}
@@ -0,0 +1,32 @@
// PL-005 Phase B: shared notification adapter contract.
//
// Each adapter implements `send(payload)`. The dispatcher chooses the
// adapter at construction time based on the operator's notification
// mechanism config.
export interface NotificationPayload {
/** Short human-readable title (e.g., "human-gate qitem arrived"). */
title: string;
/** Longer body. May contain qitem id, source rig, action verb. */
body: string;
/** Optional qitem reference (URL or id) for click-through. */
qitemRef?: string;
/** Operator-supplied tags for downstream routing (Slack channel, etc.). */
tags?: string[];
}
export interface NotificationDeliveryResult {
ok: boolean;
/** When ok=true: provider-side ack (httpStatus, message-id). */
ack?: string;
/** When ok=false: human-readable error. */
error?: string;
}
export interface NotificationAdapter {
/** Adapter mechanism label for events / audit. */
readonly mechanism: string;
/** Target descriptor (ntfy topic URL or webhook endpoint URL). */
readonly target: string;
send(payload: NotificationPayload): Promise<NotificationDeliveryResult>;
}
@@ -0,0 +1,73 @@
// PL-005 Phase B: generic webhook adapter (operator-routable alternate).
//
// Documented stable JSON body shape so the operator can POST through
// Slack incoming webhooks, Discord, Telegram bots, or their own infra.
import type {
NotificationAdapter,
NotificationDeliveryResult,
NotificationPayload,
} from "./notification-adapter-types.js";
export interface WebhookAdapterOpts {
/** Full webhook endpoint URL. */
endpointUrl: string;
/** Optional fetch override for tests. */
fetchImpl?: typeof fetch;
/** Optional extra headers (e.g., `X-Webhook-Signature`). */
extraHeaders?: Record<string, string>;
}
export interface WebhookBodyShape {
source: "openrig.mission-control";
schema_version: 1;
title: string;
body: string;
qitem_ref?: string;
tags?: string[];
emitted_at: string;
}
export class WebhookNotificationAdapter implements NotificationAdapter {
readonly mechanism = "webhook";
readonly target: string;
private readonly fetchImpl: typeof fetch;
private readonly extraHeaders: Record<string, string>;
constructor(opts: WebhookAdapterOpts) {
this.target = opts.endpointUrl;
this.fetchImpl = opts.fetchImpl ?? fetch;
this.extraHeaders = opts.extraHeaders ?? {};
}
async send(payload: NotificationPayload): Promise<NotificationDeliveryResult> {
const body: WebhookBodyShape = {
source: "openrig.mission-control",
schema_version: 1,
title: payload.title,
body: payload.body,
qitem_ref: payload.qitemRef,
tags: payload.tags,
emitted_at: new Date().toISOString(),
};
try {
const res = await this.fetchImpl(this.target, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.extraHeaders,
},
body: JSON.stringify(body),
});
if (!res.ok) {
return { ok: false, error: `webhook POST ${res.status}` };
}
return { ok: true, ack: `webhook ${res.status}` };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
@@ -0,0 +1,232 @@
// PL-005 Phase B: Mission Control notification dispatcher.
//
// Subscribes to relevant events on the EventBus and POSTs through the
// configured notification adapter (ntfy default; webhook alternate).
// Best-effort delivery; failure does NOT interrupt the underlying
// action being notified about (PRD invariant). Emits `mission_control
// .notification_sent` / `_failed` for the audit trail.
//
// Triggers (Phase B v0):
// - Mandatory: human-gate qitem arrival. Detected via the
// `queue.created` event (Phase A coordination event) where the
// created qitem's tier == "human-gate".
// - Optional: verb completion. When opts.includeVerbCompletion is
// true, `mission_control.action_executed` events also dispatch.
//
// Single notification target (the operator's phone). MVP context:
// no multi-user routing, no per-user opt-in matrix.
import type Database from "better-sqlite3";
import type { EventBus } from "../event-bus.js";
import type { PersistedEvent } from "../types.js";
import type {
NotificationAdapter,
NotificationPayload,
} from "./notification-adapter-types.js";
export interface NotificationDispatcherDeps {
db: Database.Database;
eventBus: EventBus;
adapter: NotificationAdapter;
/**
* When true, dispatch on `mission_control.action_executed` events
* (verb completion). Default false: only human-gate arrivals
* trigger by default (per planner brief mandatory trigger).
*/
includeVerbCompletion?: boolean;
missionControlBaseUrl?: string;
now?: () => Date;
}
interface QueueRow {
qitem_id: string;
source_session: string;
destination_session: string;
tier: string | null;
body: string;
}
export class MissionControlNotificationDispatcher {
private readonly db: Database.Database;
private readonly eventBus: EventBus;
private readonly adapter: NotificationAdapter;
private readonly includeVerbCompletion: boolean;
private readonly missionControlBaseUrl: string | null;
private readonly now: () => Date;
private unsubscribe: (() => void) | null = null;
/** Per-(qitem_id, mechanism) drop set for once-per-qitem dedup. */
private readonly dispatchedKeys = new Set<string>();
constructor(deps: NotificationDispatcherDeps) {
this.db = deps.db;
this.eventBus = deps.eventBus;
this.adapter = deps.adapter;
this.includeVerbCompletion = Boolean(deps.includeVerbCompletion);
this.missionControlBaseUrl = normalizeBaseUrl(deps.missionControlBaseUrl);
this.now = deps.now ?? (() => new Date());
}
/** Subscribe to relevant EventBus events. Idempotent. */
start(): void {
if (this.unsubscribe) return;
this.unsubscribe = this.eventBus.subscribe((event) => {
void this.handleEvent(event);
});
}
/** Unsubscribe + clear dedup set. Idempotent. */
stop(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.dispatchedKeys.clear();
}
/**
* Send a synthetic notification through the configured adapter so
* the operator can verify their setup. Used by the
* `POST /api/mission-control/notifications/test` route.
*/
async sendTest(): Promise<{
mechanism: string;
target: string;
ok: boolean;
ack?: string;
error?: string;
}> {
const payload: NotificationPayload = {
title: "Mission Control test notification",
body: `Synthetic test from OpenRig daemon at ${this.now().toISOString()}`,
tags: ["openrig", "mission-control", "test"],
};
const result = await this.adapter.send(payload);
if (result.ok) {
this.emitSent(null, "test");
} else {
this.emitFailed(null, result.error ?? "unknown");
}
return {
mechanism: this.adapter.mechanism,
target: this.adapter.target,
ok: result.ok,
ack: result.ack,
error: result.error,
};
}
private async handleEvent(event: PersistedEvent): Promise<void> {
if (event.type === "queue.created") {
const qitem = this.lookupQitem(event.qitemId);
if (!qitem || qitem.tier !== "human-gate") return;
// Mandatory trigger: human-gate qitem arrival.
await this.dispatch({
triggerKind: "human-gate-arrival",
qitemId: qitem.qitem_id,
title: "human-gate qitem arrived",
body:
`New human-gate qitem ${qitem.qitem_id} from ${qitem.source_session} → ${qitem.destination_session}\n\n` +
truncateBody(qitem.body, 280),
tags: ["openrig", "mission-control", "human-gate"],
});
return;
}
if (event.type === "mission_control.action_executed" && this.includeVerbCompletion) {
await this.dispatch({
triggerKind: "verb-completion",
qitemId: event.qitemId,
title: `verb completed: ${event.actionVerb}`,
body: `Mission Control verb ${event.actionVerb} on qitem ${event.qitemId ?? "(none)"} by ${event.actorSession}`,
tags: ["openrig", "mission-control", "verb-complete"],
});
return;
}
}
private async dispatch(input: {
/** Used as part of the dedup key so different triggers about the
* same qitem don't suppress each other (human-gate-arrival and
* verb-completion are distinct events). */
triggerKind: string;
qitemId: string | null;
title: string;
body: string;
tags?: string[];
}): Promise<void> {
const dedupKey = `${input.qitemId ?? "no-qitem"}::${input.triggerKind}::${this.adapter.mechanism}`;
if (this.dispatchedKeys.has(dedupKey)) return;
this.dispatchedKeys.add(dedupKey);
const result = await this.adapter.send({
title: input.title,
body: input.body,
qitemRef: this.qitemRef(input.qitemId),
tags: input.tags,
});
if (result.ok) {
this.emitSent(input.qitemId, result.ack ?? "ok");
} else {
this.emitFailed(input.qitemId, result.error ?? "unknown");
}
}
private emitSent(qitemId: string | null, _ack: string): void {
this.eventBus.emit({
type: "mission_control.notification_sent",
mechanism: this.adapter.mechanism,
target: this.adapter.target,
qitemId,
sentAt: this.now().toISOString(),
});
}
private emitFailed(qitemId: string | null, error: string): void {
this.eventBus.emit({
type: "mission_control.notification_failed",
mechanism: this.adapter.mechanism,
target: this.adapter.target,
qitemId,
error,
failedAt: this.now().toISOString(),
});
}
private lookupQitem(qitemId: string): QueueRow | null {
const row = this.db
.prepare(
`SELECT qitem_id, source_session, destination_session, tier, body
FROM queue_items WHERE qitem_id = ? LIMIT 1`,
)
.get(qitemId) as QueueRow | undefined;
return row ?? null;
}
private qitemRef(qitemId: string | null): string | undefined {
if (!qitemId) return undefined;
if (!this.missionControlBaseUrl) return qitemId;
const url = new URL("/mission-control", this.missionControlBaseUrl);
url.searchParams.set("view", "human-gate");
url.searchParams.set("qitem", qitemId);
return url.toString();
}
/** Test/observability: clear once-per-qitem dedup set. */
resetDedupForTest(): void {
this.dispatchedKeys.clear();
}
}
function truncateBody(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max - 1) + "…";
}
function normalizeBaseUrl(raw: string | undefined): string | null {
const trimmed = raw?.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
return url.toString();
} catch {
return null;
}
}
+5 -1
View File
@@ -199,7 +199,11 @@ export type RigEvent =
// 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 };
| { type: "mission_control.view_refreshed"; viewName: string; cause: string }
// PL-005 Phase B: notification dispatch events. Best-effort delivery;
// failure does NOT interrupt the underlying action being notified about.
| { type: "mission_control.notification_sent"; mechanism: string; target: string; qitemId: string | null; sentAt: string }
| { type: "mission_control.notification_failed"; mechanism: string; target: string; qitemId: string | null; error: string; failedAt: string };
export type PersistedEvent = RigEvent & {
seq: number;
+12 -2
View File
@@ -1,14 +1,24 @@
import { serve } from "@hono/node-server";
import { readOpenRigEnv } from "./openrig-compat.js";
import { createDaemon } from "./startup.js";
import { assertBindAuthInvariant } from "./middleware/auth-bearer-token.js";
export async function startServer(port?: number) {
const p = port ?? parseInt(readOpenRigEnv("OPENRIG_PORT", "RIGGED_PORT") ?? "7433", 10);
const dbPath = readOpenRigEnv("OPENRIG_DB", "RIGGED_DB") ?? "openrig.sqlite";
const { app, contextMonitor, deps } = await createDaemon({ dbPath });
const h = readOpenRigEnv("OPENRIG_HOST", "RIGGED_HOST") ?? "127.0.0.1";
// PL-005 Phase B: bearer token for Mission Control write verbs.
// No legacy alias (this env var is new in Phase B).
const bearerToken = process.env.OPENRIG_AUTH_BEARER_TOKEN ?? null;
// PL-005 Phase B HARD-GATE (audit row 8): refuse non-loopback bind
// when no bearer token is configured. Prevents shipping a tailnet-
// bound daemon with no auth on Mission Control write verbs.
// Throws AuthBearerTokenStartupError before any DB or HTTP work.
assertBindAuthInvariant({ host: h, bearerToken });
const { app, contextMonitor, deps } = await createDaemon({ dbPath, bearerToken });
const server = serve({ fetch: app.fetch, port: p, hostname: h }, (info) => {
console.log(`OpenRig daemon listening on http://localhost:${info.port}`);
@@ -0,0 +1,165 @@
// PL-005 Phase B: bearer-token middleware for /api/mission-control/*.
//
// Per slice IMPL § Write Set + audit hard-gates 8 + 9:
// - Constant-time bearer comparison via Node `crypto.timingSafeEqual`.
// No early-return on byte mismatch.
// - 401 with three-part error body on missing or mismatched token
// (what failed + why it matters + what to do).
// - Daemon refuses to start with a non-loopback bind interface AND
// empty bearer config — startup-side check (see startup.ts).
//
// MVP context (single-developer, single-user, single-host): one static
// bearer token sourced from a config field. NO OAuth / SSO / per-user
// routing / role-based permissions / token rotation. Token holder is
// fully privileged.
//
// Mounted on /api/mission-control/* write verbs at minimum (POST
// /action, POST /notifications/test). Reads (GET /views, /audit, etc.)
// MAY be gated under the same token at operator option (Phase B
// driver default: gate writes only; reads remain open behind tailnet
// bind for the headed-browser-from-phone case where the operator
// hasn't typed the token into mobile yet — the bearer is for write
// integrity, not view confidentiality).
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";
import type { MiddlewareHandler } from "hono";
/**
* Constant-time string equality using Node's timingSafeEqual.
* Returns false when lengths differ (timingSafeEqual throws on
* length mismatch; we pad-compare to keep a constant-ish branch
* profile while still returning the honest false).
*/
export function constantTimeEqual(a: string, b: string): boolean {
// Length check is unavoidable for timingSafeEqual to work, but the
// compare is still constant-time within the equal-length case. For
// unequal lengths we still run a fixed-size compare against a buffer
// of zeros so an attacker cannot infer "wrong length vs wrong byte"
// from response timing.
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) {
// Compare against a same-length zero buffer to keep the call site
// cost similar across mismatch paths. Result is always false here.
timingSafeEqual(aBuf, Buffer.alloc(aBuf.length));
return false;
}
return timingSafeEqual(aBuf, bBuf);
}
/**
* Three-part error body shape per OpenRig honest-error-reporting
* convention. Renders inside the route's c.json call.
*/
function unauthorizedBody(reason: string): {
error: "unauthorized";
message: string;
what_failed: string;
why_it_matters: string;
what_to_do: string;
} {
return {
error: "unauthorized",
message: `Mission Control auth failed: ${reason}`,
what_failed: reason,
why_it_matters:
"Mission Control write verbs require a bearer token because the daemon may be bound on a non-loopback interface (tailnet) where unauthenticated mutation would be unsafe.",
what_to_do:
"Set the auth.bearerToken field in daemon config (or OPENRIG_AUTH_BEARER_TOKEN env), restart the daemon, and resend the request with `Authorization: Bearer <token>`.",
};
}
export interface AuthBearerTokenOpts {
/**
* The expected bearer token. When this is null, the middleware
* lets all requests through (the daemon-level startup check
* elsewhere ensures this only happens when bound on loopback).
*/
expectedToken: string | null;
}
/**
* Hono middleware that enforces bearer-token auth on the routes it
* is mounted on. Returns 401 with a three-part error body for missing
* or mismatched tokens.
*
* When `expectedToken` is null, the middleware passes through. The
* startup check in `startup.ts` ensures null is only valid when the
* bind interface is loopback.
*/
export function authBearerTokenMiddleware(
opts: AuthBearerTokenOpts,
): MiddlewareHandler {
const { expectedToken } = opts;
return async (c, next) => {
if (expectedToken === null) {
// Loopback-only mode (no bearer configured). Pass through.
await next();
return;
}
const header = c.req.header("Authorization") ?? c.req.header("authorization");
if (!header) {
return c.json(unauthorizedBody("missing Authorization header"), 401);
}
const match = /^Bearer\s+(.+)$/i.exec(header);
if (!match) {
return c.json(
unauthorizedBody("Authorization header must be 'Bearer <token>'"),
401,
);
}
const provided = match[1]!.trim();
if (!constantTimeEqual(provided, expectedToken)) {
return c.json(unauthorizedBody("bearer token does not match"), 401);
}
await next();
};
}
/**
* Detect whether a host bind value is loopback-only (safe to skip
* bearer requirement) vs non-loopback (tailnet / public; requires
* bearer). Loopback set: `127.x.x.x`, `::1`, `localhost`. Anything
* else (including `0.0.0.0`, `::`, named hostnames, tailnet IPs) is
* treated as non-loopback.
*/
export function isLoopbackBind(host: string | undefined | null): boolean {
if (!host || host.length === 0) {
// Default Hono / @hono/node-server bind is 0.0.0.0 — treat as
// non-loopback for safety (force operator to either bind explicitly
// to 127.0.0.1 or set the bearer token).
return false;
}
const trimmed = host.trim().toLowerCase();
if (trimmed === "localhost" || trimmed === "::1" || trimmed === "[::1]") return true;
if (trimmed.startsWith("127.")) return true;
return false;
}
export class AuthBearerTokenStartupError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthBearerTokenStartupError";
}
}
/**
* Startup-side check (HARD-GATE audit row 8). Throws an explicit
* AuthBearerTokenStartupError when the bind interface is non-loopback
* AND the bearer token is empty. Called from index.ts BEFORE
* `serve()` listens, so the daemon refuses to start in the unsafe
* configuration.
*/
export function assertBindAuthInvariant(opts: {
host: string;
bearerToken: string | null;
}): void {
if (isLoopbackBind(opts.host)) return;
if (opts.bearerToken && opts.bearerToken.length > 0) return;
throw new AuthBearerTokenStartupError(
`daemon refusing to start: bind host '${opts.host}' is non-loopback but auth.bearerToken (env OPENRIG_AUTH_BEARER_TOKEN) is empty. ` +
`Either bind to 127.0.0.1 / localhost, OR set OPENRIG_AUTH_BEARER_TOKEN to a non-empty value before start. ` +
`Mission Control write verbs require bearer auth on any non-loopback bind because tailnet/public exposure without auth would let any peer on the network mutate operator state.`,
);
}
+212 -2
View File
@@ -1,6 +1,8 @@
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import type Database from "better-sqlite3";
import type { EventBus } from "../domain/event-bus.js";
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
import {
MissionControlActionLogError,
MISSION_CONTROL_VERBS,
@@ -16,6 +18,8 @@ import {
type MissionControlWriteContract,
} from "../domain/mission-control/mission-control-write-contract.js";
import type { MissionControlFleetCliCapability } from "../domain/mission-control/mission-control-fleet-cli-capability.js";
import type { MissionControlAuditBrowse } from "../domain/mission-control/audit-browse.js";
import type { MissionControlNotificationDispatcher } from "../domain/mission-control/notification-dispatcher.js";
/**
* Mission Control HTTP routes (PL-005 Phase A). Backs the integrated
@@ -30,14 +34,53 @@ import type { MissionControlFleetCliCapability } from "../domain/mission-control
* 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/destinations handoff/route destination candidates
* GET /api/mission-control/views list view names
*/
export function missionControlRoutes(): Hono {
export interface MissionControlRoutesOpts {
/**
* PL-005 Phase B: bearer token enforced on write verbs (POST /action,
* POST /notifications/test) when set. When null, the daemon is
* loopback-bound and no auth is enforced (the index.ts startup
* check guarantees this).
*/
bearerToken?: string | null;
}
export interface MissionControlDestination {
sessionName: string;
label: string;
source: "topology" | "queue";
rigName?: string | null;
logicalId?: string | null;
runtime?: string | null;
status?: string | null;
}
export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
const app = new Hono();
const bearerToken = opts?.bearerToken ?? null;
// PL-005 Phase B: bearer-token middleware mounted on write verbs.
// Reads remain open behind tailnet bind for the headed-browser-from-
// phone case where the operator hasn't typed the token into mobile
// yet — the bearer is for write integrity, not view confidentiality.
// (Operator may extend gating to reads by mounting on read paths in
// a future revision; v0 default per planner brief is gate-writes-only.)
const requireAuth = authBearerTokenMiddleware({ expectedToken: bearerToken });
function getReadLayer(c: { get: (key: string) => unknown }): MissionControlReadLayer {
return c.get("missionControlReadLayer" as never) as MissionControlReadLayer;
}
function getAuditBrowse(c: { get: (key: string) => unknown }): MissionControlAuditBrowse {
return c.get("missionControlAuditBrowse" as never) as MissionControlAuditBrowse;
}
function getNotificationDispatcher(
c: { get: (key: string) => unknown },
): MissionControlNotificationDispatcher | undefined {
return c.get("missionControlNotificationDispatcher" as never) as
| MissionControlNotificationDispatcher
| undefined;
}
function getWriteContract(c: { get: (key: string) => unknown }): MissionControlWriteContract {
return c.get("missionControlWriteContract" as never) as MissionControlWriteContract;
}
@@ -47,6 +90,105 @@ export function missionControlRoutes(): Hono {
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
return c.get("eventBus" as never) as EventBus;
}
function getDb(c: { get: (key: string) => unknown }): Database.Database | undefined {
return c.get("db" as never) as Database.Database | undefined;
}
function tableExists(db: Database.Database, tableName: string): boolean {
const row = db
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { present: number } | undefined;
return Boolean(row);
}
function listDestinations(db: Database.Database): MissionControlDestination[] {
const destinations = new Map<string, MissionControlDestination>();
const addDestination = (candidate: MissionControlDestination) => {
const sessionName = candidate.sessionName.trim();
if (!sessionName) return;
const existing = destinations.get(sessionName);
if (!existing || candidate.source === "topology") {
destinations.set(sessionName, { ...candidate, sessionName });
}
};
if (tableExists(db, "sessions") && tableExists(db, "nodes") && tableExists(db, "rigs")) {
const rows = db
.prepare(
`
SELECT
s.session_name,
s.status,
n.logical_id,
n.runtime,
r.name AS rig_name
FROM sessions s
JOIN nodes n ON n.id = s.node_id
JOIN rigs r ON r.id = n.rig_id
WHERE s.session_name IS NOT NULL AND TRIM(s.session_name) != ''
ORDER BY r.name COLLATE NOCASE, n.logical_id COLLATE NOCASE, s.session_name COLLATE NOCASE
`,
)
.all() as Array<{
session_name: string;
status: string | null;
logical_id: string | null;
runtime: string | null;
rig_name: string | null;
}>;
for (const row of rows) {
const topologyLabel =
row.logical_id && row.rig_name ? `${row.logical_id}@${row.rig_name}` : null;
addDestination({
sessionName: row.session_name,
label:
topologyLabel && topologyLabel !== row.session_name
? `${topologyLabel} - ${row.session_name}`
: row.session_name,
source: "topology",
rigName: row.rig_name,
logicalId: row.logical_id,
runtime: row.runtime,
status: row.status,
});
}
}
if (tableExists(db, "queue_items")) {
const rows = db
.prepare(
`
SELECT DISTINCT TRIM(session_name) AS session_name
FROM (
SELECT source_session AS session_name FROM queue_items
UNION
SELECT destination_session AS session_name FROM queue_items
UNION
SELECT handed_off_to AS session_name FROM queue_items
UNION
SELECT handed_off_from AS session_name FROM queue_items
)
WHERE session_name IS NOT NULL AND TRIM(session_name) != ''
ORDER BY session_name COLLATE NOCASE
`,
)
.all() as Array<{ session_name: string }>;
for (const row of rows) {
addDestination({
sessionName: row.session_name,
label: row.session_name,
source: "queue",
});
}
}
return [...destinations.values()].sort((a, b) => {
if (a.source !== b.source) return a.source === "topology" ? -1 : 1;
return a.label.localeCompare(b.label);
});
}
function errorResponse(
c: { json: (body: unknown, status?: number) => Response },
@@ -89,6 +231,14 @@ export function missionControlRoutes(): Hono {
return c.json(fleet);
});
// GET /destinations — phone-friendly route/handoff candidates. MUST precede
// /views/:view-name catchall with the other Mission Control literal routes.
app.get("/destinations", (c) => {
const db = getDb(c);
if (!db) return c.json({ destinations: [] });
return c.json({ destinations: listDestinations(db) });
});
// 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]) => {
@@ -113,6 +263,10 @@ export function missionControlRoutes(): Hono {
app.get("/sse", sseHandler);
app.get("/watch", sseHandler);
// PL-005 Phase B: bearer-token gate on write verbs.
app.post("/action", requireAuth);
app.post("/notifications/test", requireAuth);
// POST /action — execute one of 7 verbs through the atomic write contract.
app.post("/action", async (c) => {
const body = await c.req
@@ -159,8 +313,64 @@ export function missionControlRoutes(): Hono {
}
});
// PL-005 Phase B: GET /audit — read-only browse over mission_control_actions.
// MUST come BEFORE /views/:view-name catchall (route-order discipline
// per PL-004 Phase A R1 lesson).
app.get("/audit", async (c) => {
const audit = getAuditBrowse(c);
if (!audit) return c.json({ error: "audit_browse_unavailable" }, 500);
const qitemId = c.req.query("qitem_id") || undefined;
const actionVerb = c.req.query("action_verb") || undefined;
const actorSession = c.req.query("actor_session") || undefined;
const since = c.req.query("since") || undefined;
const until = c.req.query("until") || undefined;
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
const beforeId = c.req.query("before_id") || undefined;
try {
const result = audit.query({ qitemId, actionVerb, actorSession, since, until, limit, beforeId });
return c.json(result);
} catch (err) {
return c.json(
{
error: "audit_query_failed",
message: err instanceof Error ? err.message : "internal error",
},
500,
);
}
});
// PL-005 Phase B: POST /notifications/test — synthetic notification
// through the configured mechanism so the operator can verify before
// relying on it. Bearer-token gated (registered above).
app.post("/notifications/test", async (c) => {
const dispatcher = getNotificationDispatcher(c);
if (!dispatcher) {
return c.json(
{
error: "notifications_unconfigured",
message:
"notifications dispatcher is not wired; configure notifications.mechanism (ntfy|webhook) in daemon config and restart",
},
503,
);
}
try {
const result = await dispatcher.sendTest();
return c.json(result);
} catch (err) {
return c.json(
{
error: "notification_test_failed",
message: err instanceof Error ? err.message : "internal error",
},
500,
);
}
});
// GET /views/:view-name — read one of 7 views. MUST come AFTER /views,
// /cli-capabilities, /sse, /watch literal paths.
// /cli-capabilities, /sse, /watch, /audit literal paths.
app.get("/views/:view-name", async (c) => {
const viewName = c.req.param("view-name") as MissionControlViewName;
if (!MISSION_CONTROL_VIEWS.includes(viewName)) {
+14 -1
View File
@@ -139,6 +139,14 @@ export interface AppDeps {
// "slices_root_not_configured" 503 so the UI can surface a setup hint.
sliceIndexer?: import("./domain/slices/slice-indexer.js").SliceIndexer;
sliceDetailProjector?: import("./domain/slices/slice-detail-projector.js").SliceDetailProjector;
missionControlAuditBrowse?: import("./domain/mission-control/audit-browse.js").MissionControlAuditBrowse;
missionControlNotificationDispatcher?: import("./domain/mission-control/notification-dispatcher.js").MissionControlNotificationDispatcher;
/**
* PL-005 Phase B: bearer token (or null for loopback-only mode).
* The mission-control routes use this to wire the
* authBearerTokenMiddleware on write verbs.
*/
missionControlBearerToken?: string | null;
specReviewService?: SpecReviewService;
specLibraryService?: SpecLibraryService;
whoamiService?: WhoamiService;
@@ -306,6 +314,8 @@ export function createApp(deps: AppDeps): Hono {
c.set("missionControlFleetCliCapability" as never, deps.missionControlFleetCliCapability);
c.set("sliceIndexer" as never, deps.sliceIndexer);
c.set("sliceDetailProjector" as never, deps.sliceDetailProjector);
c.set("missionControlAuditBrowse" as never, deps.missionControlAuditBrowse);
c.set("missionControlNotificationDispatcher" as never, deps.missionControlNotificationDispatcher);
c.set("specReviewService" as never, deps.specReviewService);
c.set("specLibraryService" as never, deps.specLibraryService);
c.set("whoamiService" as never, deps.whoamiService);
@@ -358,7 +368,10 @@ 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/mission-control",
missionControlRoutes({ bearerToken: deps.missionControlBearerToken ?? null }),
);
// Slice Story View v0 — slice indexer + per-tab payload routes.
app.route("/api/slices", slicesRoutes());
app.route("/api/rigs/:rigId/env", envRoutes());
+60
View File
@@ -123,6 +123,11 @@ import {
MissionControlFleetCliCapability,
makeLocalCliCapabilityProbe,
} from "./domain/mission-control/mission-control-fleet-cli-capability.js";
import { MissionControlAuditBrowse } from "./domain/mission-control/audit-browse.js";
import { MissionControlNotificationDispatcher } from "./domain/mission-control/notification-dispatcher.js";
import { NtfyNotificationAdapter } from "./domain/mission-control/notification-adapter-ntfy.js";
import { WebhookNotificationAdapter } from "./domain/mission-control/notification-adapter-webhook.js";
import type { NotificationAdapter } from "./domain/mission-control/notification-adapter-types.js";
import { OPENRIG_HOME } from "./openrig-compat.js";
import {
getCompatibleOpenRigPath,
@@ -136,6 +141,14 @@ interface DaemonOptions {
cmuxExec?: ExecFn;
cmuxFactory?: CmuxTransportFactory;
cmuxTimeoutMs?: number;
/**
* PL-005 Phase B: bearer token for Mission Control write verbs.
* When null, the auth-bearer-token middleware passes through (the
* index.ts startup-side check ensures this is only valid when bound
* on loopback). When set, the middleware enforces constant-time
* comparison + 401 on missing/mismatch.
*/
bearerToken?: string | null;
}
interface DaemonResult {
@@ -606,6 +619,53 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.missionControlWriteContract = mcWriteContract;
deps.missionControlFleetCliCapability = mcFleetCliCapability;
deps.missionControlReadLayer = mcReadLayer;
// PL-005 Phase B: audit-history browse layer (read-only) +
// notification dispatcher + bearer-token plumbing.
const mcAuditBrowse = new MissionControlAuditBrowse(db);
deps.missionControlAuditBrowse = mcAuditBrowse;
// Bearer token from createDaemon options is propagated to the
// routes constructor via deps so the auth middleware is mounted
// at route mount time (not per-request).
deps.missionControlBearerToken = opts?.bearerToken ?? null;
// Notification dispatcher: chosen mechanism via env config.
// OPENRIG_NOTIFICATIONS_MECHANISM=ntfy|webhook|none (default none).
// OPENRIG_NOTIFICATIONS_TARGET=<topic url | webhook url>.
// OPENRIG_NOTIFICATIONS_INCLUDE_VERB_COMPLETION=1 to opt into the
// verb-completion trigger (default off; only human-gate arrivals
// trigger by default per planner brief).
// No legacy alias for these env vars (new in Phase B).
const mechanism = process.env.OPENRIG_NOTIFICATIONS_MECHANISM ?? "none";
const target = process.env.OPENRIG_NOTIFICATIONS_TARGET ?? "";
const missionControlBaseUrl =
process.env.OPENRIG_MISSION_CONTROL_BASE_URL ??
process.env.OPENRIG_URL ??
process.env.RIGGED_URL;
const includeVerbCompletion =
process.env.OPENRIG_NOTIFICATIONS_INCLUDE_VERB_COMPLETION === "1";
if (mechanism !== "none" && target.length > 0) {
let adapter: NotificationAdapter;
if (mechanism === "ntfy") {
adapter = new NtfyNotificationAdapter({ topicUrl: target });
} else if (mechanism === "webhook") {
adapter = new WebhookNotificationAdapter({ endpointUrl: target });
} else {
throw new Error(
`OPENRIG_NOTIFICATIONS_MECHANISM='${mechanism}' is not recognized; supported: ntfy | webhook | none`,
);
}
const dispatcher = new MissionControlNotificationDispatcher({
db,
eventBus,
adapter,
includeVerbCompletion,
missionControlBaseUrl,
});
dispatcher.start();
deps.missionControlNotificationDispatcher = dispatcher;
}
}
// Slice Story View v0 — slice indexer + per-tab projector.
+113
View File
@@ -0,0 +1,113 @@
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 } from "../src/domain/mission-control/mission-control-action-log.js";
import { MissionControlAuditBrowse } from "../src/domain/mission-control/audit-browse.js";
describe("MissionControlAuditBrowse (PL-005 Phase B; read-only)", () => {
let db: Database.Database;
let log: MissionControlActionLog;
let audit: MissionControlAuditBrowse;
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();
db.prepare(
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
VALUES ('q-2', '2026-05-04T02:00:00Z', '2026-05-04T02:00:00Z', 'a@r', 'b@r', 'pending', 'routine', 'x')`,
).run();
log = new MissionControlActionLog(db);
audit = new MissionControlAuditBrowse(db);
// Seed varied actions across q-1 and q-2 with different verbs + actors + times.
log.record({ actionVerb: "approve", qitemId: "q-1", actorSession: "alice@r", actedAt: "2026-05-04T03:00:00.000Z" });
log.record({ actionVerb: "deny", qitemId: "q-1", actorSession: "alice@r", actedAt: "2026-05-04T03:01:00.000Z" });
log.record({ actionVerb: "approve", qitemId: "q-2", actorSession: "bob@r", actedAt: "2026-05-04T03:02:00.000Z" });
log.record({ actionVerb: "annotate", qitemId: "q-2", actorSession: "alice@r", actedAt: "2026-05-04T03:03:00.000Z", annotation: "test" });
});
afterEach(() => db.close());
it("query without filters returns all rows DESC by acted_at", () => {
const result = audit.query({});
expect(result.rows).toHaveLength(4);
expect(result.rows[0]!.actedAt).toBe("2026-05-04T03:03:00.000Z");
expect(result.rows[3]!.actedAt).toBe("2026-05-04T03:00:00.000Z");
});
it("filter by qitem_id (exact)", () => {
const result = audit.query({ qitemId: "q-1" });
expect(result.rows).toHaveLength(2);
expect(result.rows.every((r) => r.qitemId === "q-1")).toBe(true);
});
it("filter by action_verb (exact)", () => {
const result = audit.query({ actionVerb: "approve" });
expect(result.rows).toHaveLength(2);
expect(result.rows.every((r) => r.actionVerb === "approve")).toBe(true);
});
it("filter by actor_session (exact)", () => {
const result = audit.query({ actorSession: "alice@r" });
expect(result.rows).toHaveLength(3);
expect(result.rows.every((r) => r.actorSession === "alice@r")).toBe(true);
});
it("filter by since (acted_at >= since)", () => {
const result = audit.query({ since: "2026-05-04T03:02:00.000Z" });
expect(result.rows).toHaveLength(2);
expect(result.rows.every((r) => r.actedAt >= "2026-05-04T03:02:00.000Z")).toBe(true);
});
it("filter by until (acted_at <= until)", () => {
const result = audit.query({ until: "2026-05-04T03:01:00.000Z" });
expect(result.rows).toHaveLength(2);
expect(result.rows.every((r) => r.actedAt <= "2026-05-04T03:01:00.000Z")).toBe(true);
});
it("filter by since AND until (range)", () => {
const result = audit.query({
since: "2026-05-04T03:01:00.000Z",
until: "2026-05-04T03:02:00.000Z",
});
expect(result.rows).toHaveLength(2);
});
it("rejects unknown action_verb with structured error", () => {
expect(() => audit.query({ actionVerb: "totally-bogus" })).toThrow(/totally-bogus/);
});
it("pagination: limit honored + has_more set + next_before_id provided", () => {
const page1 = audit.query({ limit: 2 });
expect(page1.rows).toHaveLength(2);
expect(page1.hasMore).toBe(true);
expect(page1.nextBeforeId).not.toBeNull();
const page2 = audit.query({ limit: 2, beforeId: page1.nextBeforeId! });
expect(page2.rows).toHaveLength(2);
expect(page2.hasMore).toBe(false);
expect(page2.nextBeforeId).toBeNull();
});
it("limit clamping: > 200 → 200, < 1 → 1", () => {
const r1 = audit.query({ limit: 999 });
expect(r1.rows.length).toBeLessThanOrEqual(200);
const r2 = audit.query({ limit: 0 });
expect(r2.rows.length).toBeLessThanOrEqual(1);
});
it("read-only: API surface lacks insert/update/delete methods", () => {
const proto = Object.getPrototypeOf(audit) as Record<string, unknown>;
const names = Object.getOwnPropertyNames(proto);
expect(names).not.toContain("insert");
expect(names).not.toContain("update");
expect(names).not.toContain("delete");
expect(names).not.toContain("record");
});
});
@@ -0,0 +1,137 @@
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import {
AuthBearerTokenStartupError,
assertBindAuthInvariant,
authBearerTokenMiddleware,
constantTimeEqual,
isLoopbackBind,
} from "../src/middleware/auth-bearer-token.js";
describe("auth-bearer-token middleware (PL-005 Phase B)", () => {
it("constantTimeEqual returns true for equal strings", () => {
expect(constantTimeEqual("abc", "abc")).toBe(true);
});
it("constantTimeEqual returns false for different strings (same length)", () => {
expect(constantTimeEqual("abc", "abd")).toBe(false);
});
it("constantTimeEqual returns false for different lengths", () => {
expect(constantTimeEqual("abc", "abcd")).toBe(false);
});
it("isLoopbackBind detects loopback host names", () => {
expect(isLoopbackBind("127.0.0.1")).toBe(true);
expect(isLoopbackBind("127.42.7.99")).toBe(true);
expect(isLoopbackBind("localhost")).toBe(true);
expect(isLoopbackBind("::1")).toBe(true);
expect(isLoopbackBind("[::1]")).toBe(true);
});
it("isLoopbackBind treats non-loopback hosts as non-loopback", () => {
expect(isLoopbackBind("0.0.0.0")).toBe(false);
expect(isLoopbackBind("100.64.0.5")).toBe(false);
expect(isLoopbackBind("10.0.0.1")).toBe(false);
expect(isLoopbackBind("rig.local")).toBe(false);
});
it("isLoopbackBind treats empty/undefined as non-loopback (safety default)", () => {
expect(isLoopbackBind("")).toBe(false);
expect(isLoopbackBind(undefined)).toBe(false);
expect(isLoopbackBind(null)).toBe(false);
});
// HARD-GATE audit row 8.
it("HARD-GATE: assertBindAuthInvariant throws when non-loopback bind has empty bearer", () => {
expect(() =>
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: null }),
).toThrow(AuthBearerTokenStartupError);
expect(() =>
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: "" }),
).toThrow(AuthBearerTokenStartupError);
expect(() =>
assertBindAuthInvariant({ host: "100.64.0.5", bearerToken: null }),
).toThrow(AuthBearerTokenStartupError);
});
it("HARD-GATE: assertBindAuthInvariant passes when loopback bind even with empty bearer", () => {
expect(() =>
assertBindAuthInvariant({ host: "127.0.0.1", bearerToken: null }),
).not.toThrow();
expect(() =>
assertBindAuthInvariant({ host: "localhost", bearerToken: "" }),
).not.toThrow();
});
it("HARD-GATE: assertBindAuthInvariant passes when non-loopback has non-empty bearer", () => {
expect(() =>
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: "secret" }),
).not.toThrow();
expect(() =>
assertBindAuthInvariant({ host: "100.64.0.5", bearerToken: "secret" }),
).not.toThrow();
});
describe("middleware integration", () => {
function appWithMiddleware(token: string | null): Hono {
const app = new Hono();
app.use("*", authBearerTokenMiddleware({ expectedToken: token }));
app.get("/", (c) => c.json({ ok: true }));
return app;
}
it("loopback-only mode (token=null) passes all requests", async () => {
const app = appWithMiddleware(null);
const res = await app.request("/");
expect(res.status).toBe(200);
});
it("returns 401 with three-part body when Authorization missing", async () => {
const app = appWithMiddleware("secret");
const res = await app.request("/");
expect(res.status).toBe(401);
const body = (await res.json()) as Record<string, unknown>;
expect(body.error).toBe("unauthorized");
expect(body.what_failed).toContain("missing Authorization header");
expect(body.why_it_matters).toBeDefined();
expect(body.what_to_do).toBeDefined();
});
it("returns 401 when Authorization is not Bearer scheme", async () => {
const app = appWithMiddleware("secret");
const res = await app.request("/", {
headers: { Authorization: "Basic dXNlcjpwYXNz" },
});
expect(res.status).toBe(401);
const body = (await res.json()) as { what_failed: string };
expect(body.what_failed).toContain("Bearer");
});
it("returns 401 when Bearer token does not match", async () => {
const app = appWithMiddleware("secret");
const res = await app.request("/", {
headers: { Authorization: "Bearer wrong" },
});
expect(res.status).toBe(401);
const body = (await res.json()) as { what_failed: string };
expect(body.what_failed).toContain("does not match");
});
it("returns 200 when Bearer token matches", async () => {
const app = appWithMiddleware("secret");
const res = await app.request("/", {
headers: { Authorization: "Bearer secret" },
});
expect(res.status).toBe(200);
});
it("accepts case-insensitive 'authorization' header (HTTP standard)", async () => {
const app = appWithMiddleware("secret");
const res = await app.request("/", {
headers: { authorization: "Bearer secret" },
});
expect(res.status).toBe(200);
});
});
});
@@ -125,6 +125,20 @@ describe("MissionControlReadLayer (PL-005 Phase A; 7 views)", () => {
expect(result.rows[0]?.rigOrMissionName).toBe("human-wrandom@kernel");
});
it("qitem-backed rows preserve the queue body for phone human-gate decisions", async () => {
await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human-wrandom@kernel",
body: "Approve the release candidate after checking the phone notification path.",
tier: "human-gate",
});
const result = await readLayer.readView("my-queue");
const row = result.rows[0] as Record<string, unknown>;
expect(row.qitemBody).toBe("Approve the release candidate after checking the phone notification path.");
expect(row.qitemSummary).toContain("Approve the release candidate");
});
it("recent-ships caps at 10 (founder Q4 default)", async () => {
for (let i = 0; i < 15; i++) {
const created = await queueRepo.create({
@@ -0,0 +1,291 @@
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 { MissionControlAuditBrowse } from "../src/domain/mission-control/audit-browse.js";
import { MissionControlNotificationDispatcher } from "../src/domain/mission-control/notification-dispatcher.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import type {
NotificationAdapter,
NotificationDeliveryResult,
NotificationPayload,
} from "../src/domain/mission-control/notification-adapter-types.js";
class FakeAdapter implements NotificationAdapter {
readonly mechanism = "fake";
readonly target = "fake://test";
calls: NotificationPayload[] = [];
async send(payload: NotificationPayload): Promise<NotificationDeliveryResult> {
this.calls.push(payload);
return { ok: true, ack: "fake-ok" };
}
}
function buildApp(opts: {
bus: EventBus;
queueRepo: QueueRepository;
bearerToken: string | null;
withDispatcher: boolean;
}): { app: Hono; adapter: FakeAdapter | null; auditBrowse: MissionControlAuditBrowse; actionLog: MissionControlActionLog } {
const db = (opts.bus as unknown as { db: Database.Database }).db;
const viewProjector = new ViewProjector(db, opts.bus);
const streamStore = new StreamStore(db, opts.bus);
const rigRepo = new RigRepository(db);
const fleetCli = new MissionControlFleetCliCapability({ db, eventBus: opts.bus, rigRepo });
const actionLog = new MissionControlActionLog(db);
const writeContract = new MissionControlWriteContract({
db,
eventBus: opts.bus,
queueRepo: opts.queueRepo,
actionLog,
});
const readLayer = new MissionControlReadLayer({
db,
queueRepo: opts.queueRepo,
viewProjector,
streamStore,
fleetCliCapability: fleetCli,
});
const auditBrowse = new MissionControlAuditBrowse(db);
let adapter: FakeAdapter | null = null;
let dispatcher: MissionControlNotificationDispatcher | null = null;
if (opts.withDispatcher) {
adapter = new FakeAdapter();
dispatcher = new MissionControlNotificationDispatcher({
db,
eventBus: opts.bus,
adapter,
});
}
const app = new Hono();
app.use("*", async (c, next) => {
c.set("eventBus" as never, opts.bus);
c.set("db" as never, db);
c.set("missionControlReadLayer" as never, readLayer);
c.set("missionControlWriteContract" as never, writeContract);
c.set("missionControlActionLog" as never, actionLog);
c.set("missionControlFleetCliCapability" as never, fleetCli);
c.set("missionControlAuditBrowse" as never, auditBrowse);
if (dispatcher) c.set("missionControlNotificationDispatcher" as never, dispatcher);
await next();
});
app.route(
"/api/mission-control",
missionControlRoutes({ bearerToken: opts.bearerToken }),
);
return { app, adapter, auditBrowse, actionLog };
}
describe("mission-control routes Phase B (PL-005)", () => {
let db: Database.Database;
let bus: EventBus;
let queueRepo: QueueRepository;
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);
(bus as unknown as { db: Database.Database }).db = db;
queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
});
afterEach(() => db.close());
describe("Bearer-token gate on POST /action", () => {
it("returns 401 when bearer required but Authorization missing", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: "secret", withDispatcher: false });
const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", 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: "h@r" }),
});
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("unauthorized");
});
it("returns 200 when correct bearer provided", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: "secret", withDispatcher: false });
const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x" });
const res = await app.request("/api/mission-control/action", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer secret" },
body: JSON.stringify({ verb: "approve", qitemId: created.qitemId, actorSession: "h@r" }),
});
expect(res.status).toBe(200);
});
it("returns 200 on writes when bearerToken=null (loopback-only mode passes through)", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", 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: "h@r" }),
});
expect(res.status).toBe(200);
});
it("Reads (GET /views/:name) remain UNgated even when bearerToken set (Phase B v0 default per planner brief)", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: "secret", withDispatcher: false });
const res = await app.request("/api/mission-control/views/active-work");
expect(res.status).toBe(200);
});
});
describe("GET /audit", () => {
it("returns rows + pagination metadata", async () => {
const { app, actionLog } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
// Seed an action.
const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x" });
actionLog.record({
actionVerb: "approve",
qitemId: created.qitemId,
actorSession: "alice@r",
actedAt: "2026-05-04T05:00:00.000Z",
});
const res = await app.request("/api/mission-control/audit");
expect(res.status).toBe(200);
const body = (await res.json()) as { rows: unknown[]; hasMore: boolean; nextBeforeId: string | null };
expect(body.rows).toHaveLength(1);
expect(body.hasMore).toBe(false);
});
it("filter by qitem_id", async () => {
const { app, actionLog } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const a = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "a" });
const b = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "b" });
actionLog.record({ actionVerb: "approve", qitemId: a.qitemId, actorSession: "x@r", actedAt: "2026-05-04T05:00:00Z" });
actionLog.record({ actionVerb: "approve", qitemId: b.qitemId, actorSession: "x@r", actedAt: "2026-05-04T05:01:00Z" });
const res = await app.request(`/api/mission-control/audit?qitem_id=${a.qitemId}`);
const body = (await res.json()) as { rows: Array<{ qitemId: string }> };
expect(body.rows).toHaveLength(1);
expect(body.rows[0]!.qitemId).toBe(a.qitemId);
});
it("returns 500 on unknown action_verb (audit-browse rejects with structured error)", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const res = await app.request("/api/mission-control/audit?action_verb=totally-bogus");
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("audit_query_failed");
});
it("audit GET is read-only: POST returns 404 (route not registered for POST)", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const res = await app.request("/api/mission-control/audit", { method: "POST" });
expect(res.status).toBe(404);
});
});
describe("POST /notifications/test", () => {
it("returns 503 when dispatcher not configured", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const res = await app.request("/api/mission-control/notifications/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(503);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("notifications_unconfigured");
});
it("returns 200 + dispatches synthetic notification when dispatcher wired", async () => {
const { app, adapter } = buildApp({
bus,
queueRepo,
bearerToken: null,
withDispatcher: true,
});
const res = await app.request("/api/mission-control/notifications/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; mechanism: string };
expect(body.ok).toBe(true);
expect(body.mechanism).toBe("fake");
expect(adapter!.calls).toHaveLength(1);
});
it("notifications/test requires bearer when configured", async () => {
const { app } = buildApp({
bus,
queueRepo,
bearerToken: "secret",
withDispatcher: true,
});
const res = await app.request("/api/mission-control/notifications/test", {
method: "POST",
});
expect(res.status).toBe(401);
});
});
describe("Route-order discipline", () => {
it("GET /destinations returns phone-friendly handoff candidates from queue-observed sessions", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: "secret", withDispatcher: false });
await queueRepo.create({
sourceSession: "velocity.qa@openrig-velocity",
destinationSession: "human-wrandom@kernel",
body: "x",
tier: "human-gate",
});
const res = await app.request("/api/mission-control/destinations");
expect(res.status).toBe(200);
const body = (await res.json()) as {
destinations: Array<{ sessionName: string; source: string; label: string }>;
};
expect(body.destinations.some((d) => d.sessionName === "velocity.qa@openrig-velocity")).toBe(true);
expect(body.destinations.some((d) => d.sessionName === "human-wrandom@kernel")).toBe(true);
expect(body.destinations.every((d) => d.label.length > 0)).toBe(true);
});
it("/audit literal path does NOT shadow /views/:view-name", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const auditRes = await app.request("/api/mission-control/audit");
const viewRes = await app.request("/api/mission-control/views/active-work");
expect(auditRes.status).toBe(200);
expect(viewRes.status).toBe(200);
const auditBody = (await auditRes.json()) as { rows: unknown[] };
const viewBody = (await viewRes.json()) as { viewName?: string };
expect(auditBody.rows).toBeDefined();
expect(viewBody.viewName).toBe("active-work");
});
it("/notifications/test literal does NOT shadow other POST routes", async () => {
const { app } = buildApp({ bus, queueRepo, bearerToken: null, withDispatcher: false });
const created = await queueRepo.create({ sourceSession: "s@r", destinationSession: "d@r", body: "x" });
const actionRes = 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(actionRes.status).toBe(200);
});
});
});
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import { NtfyNotificationAdapter } from "../src/domain/mission-control/notification-adapter-ntfy.js";
import { WebhookNotificationAdapter } from "../src/domain/mission-control/notification-adapter-webhook.js";
describe("NtfyNotificationAdapter (PL-005 Phase B)", () => {
it("POSTs to topic URL with title/click/tags headers + body", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
return new Response(null, { status: 200 });
}) as unknown as typeof fetch;
const adapter = new NtfyNotificationAdapter({
topicUrl: "https://ntfy.sh/test-topic",
fetchImpl: fakeFetch,
});
const result = await adapter.send({
title: "human-gate qitem arrived",
body: "qitem-XXX arrived",
qitemRef: "qitem-XXX",
tags: ["openrig", "mission-control"],
});
expect(result.ok).toBe(true);
expect(result.ack).toContain("200");
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe("https://ntfy.sh/test-topic");
expect(calls[0]!.init.method).toBe("POST");
const headers = calls[0]!.init.headers as Record<string, string>;
expect(headers.Title).toBe("human-gate qitem arrived");
expect(headers.Click).toBe("qitem-XXX");
expect(headers.Tags).toBe("openrig,mission-control");
});
it("returns ok=false on non-2xx response", async () => {
const fakeFetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch;
const adapter = new NtfyNotificationAdapter({
topicUrl: "https://ntfy.sh/x",
fetchImpl: fakeFetch,
});
const result = await adapter.send({ title: "x", body: "x" });
expect(result.ok).toBe(false);
expect(result.error).toContain("500");
});
it("returns ok=false on fetch throw", async () => {
const fakeFetch = (async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
const adapter = new NtfyNotificationAdapter({
topicUrl: "https://ntfy.sh/x",
fetchImpl: fakeFetch,
});
const result = await adapter.send({ title: "x", body: "x" });
expect(result.ok).toBe(false);
expect(result.error).toContain("network down");
});
it("sanitizes title (no newlines + max length 250)", async () => {
const calls: Array<Record<string, string>> = [];
const fakeFetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
calls.push(init?.headers as Record<string, string>);
return new Response(null, { status: 200 });
}) as unknown as typeof fetch;
const adapter = new NtfyNotificationAdapter({
topicUrl: "https://ntfy.sh/x",
fetchImpl: fakeFetch,
});
await adapter.send({ title: "line1\nline2\rline3", body: "x" });
expect(calls[0]!.Title).not.toContain("\n");
expect(calls[0]!.Title).not.toContain("\r");
await adapter.send({ title: "a".repeat(500), body: "x" });
expect(calls[1]!.Title.length).toBeLessThanOrEqual(250);
});
});
describe("WebhookNotificationAdapter (PL-005 Phase B)", () => {
it("POSTs JSON body to endpoint URL with stable schema", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
return new Response(null, { status: 200 });
}) as unknown as typeof fetch;
const adapter = new WebhookNotificationAdapter({
endpointUrl: "https://example.com/webhook",
fetchImpl: fakeFetch,
});
const result = await adapter.send({
title: "test",
body: "bodytext",
qitemRef: "qitem-1",
tags: ["a", "b"],
});
expect(result.ok).toBe(true);
expect(calls[0]!.url).toBe("https://example.com/webhook");
const body = JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
expect(body.source).toBe("openrig.mission-control");
expect(body.schema_version).toBe(1);
expect(body.title).toBe("test");
expect(body.body).toBe("bodytext");
expect(body.qitem_ref).toBe("qitem-1");
expect(body.tags).toEqual(["a", "b"]);
expect(typeof body.emitted_at).toBe("string");
});
it("includes extra headers when configured", async () => {
const calls: Array<Record<string, string>> = [];
const fakeFetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
calls.push(init?.headers as Record<string, string>);
return new Response(null, { status: 200 });
}) as unknown as typeof fetch;
const adapter = new WebhookNotificationAdapter({
endpointUrl: "https://example.com/webhook",
fetchImpl: fakeFetch,
extraHeaders: { "X-Webhook-Signature": "secret" },
});
await adapter.send({ title: "x", body: "x" });
expect(calls[0]!["X-Webhook-Signature"]).toBe("secret");
expect(calls[0]!["Content-Type"]).toBe("application/json");
});
it("returns ok=false on non-2xx", async () => {
const fakeFetch = (async () => new Response("err", { status: 502 })) as unknown as typeof fetch;
const adapter = new WebhookNotificationAdapter({
endpointUrl: "https://example.com/webhook",
fetchImpl: fakeFetch,
});
const result = await adapter.send({ title: "x", body: "x" });
expect(result.ok).toBe(false);
expect(result.error).toContain("502");
});
});
@@ -0,0 +1,201 @@
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 } from "../src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlNotificationDispatcher } from "../src/domain/mission-control/notification-dispatcher.js";
import type {
NotificationAdapter,
NotificationDeliveryResult,
NotificationPayload,
} from "../src/domain/mission-control/notification-adapter-types.js";
class FakeAdapter implements NotificationAdapter {
readonly mechanism = "fake";
readonly target = "fake://test";
calls: NotificationPayload[] = [];
nextResult: NotificationDeliveryResult = { ok: true, ack: "fake-ok" };
async send(payload: NotificationPayload): Promise<NotificationDeliveryResult> {
this.calls.push(payload);
return this.nextResult;
}
}
describe("MissionControlNotificationDispatcher (PL-005 Phase B)", () => {
let db: Database.Database;
let bus: EventBus;
let queueRepo: QueueRepository;
let adapter: FakeAdapter;
let dispatcher: MissionControlNotificationDispatcher;
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 });
adapter = new FakeAdapter();
dispatcher = new MissionControlNotificationDispatcher({ db, eventBus: bus, adapter });
dispatcher.start();
});
afterEach(() => {
dispatcher.stop();
db.close();
});
it("dispatches on human-gate qitem arrival (mandatory trigger)", async () => {
await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human-wrandom@kernel",
body: "needs human approval",
tier: "human-gate",
});
// Yield event loop so dispatcher's async handler completes.
await new Promise((r) => setTimeout(r, 10));
expect(adapter.calls).toHaveLength(1);
expect(adapter.calls[0]!.title).toContain("human-gate");
expect(adapter.calls[0]!.tags).toContain("human-gate");
});
it("includes a Mission Control deep link for human-gate qitem notifications", async () => {
dispatcher.stop();
dispatcher = new MissionControlNotificationDispatcher({
db,
eventBus: bus,
adapter,
missionControlBaseUrl: "http://100.95.124.51:18437",
});
dispatcher.start();
const created = await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human-wrandom@kernel",
body: "needs human approval",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
expect(adapter.calls[0]!.qitemRef).toBe(
`http://100.95.124.51:18437/mission-control?view=human-gate&qitem=${encodeURIComponent(created.qitemId)}`,
);
});
it("does NOT dispatch on non-human-gate qitem arrival", async () => {
await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "agent@rig",
body: "agent task",
});
await new Promise((r) => setTimeout(r, 10));
expect(adapter.calls).toHaveLength(0);
});
it("does NOT dispatch on action_executed by default (verb-completion opt-in)", async () => {
const created = await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human@rig",
body: "x",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
adapter.calls.length = 0;
const actionLog = new MissionControlActionLog(db);
const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo, actionLog });
await writeContract.act({ verb: "approve", qitemId: created.qitemId, actorSession: "human@rig" });
await new Promise((r) => setTimeout(r, 10));
expect(adapter.calls).toHaveLength(0);
});
it("DOES dispatch on action_executed when includeVerbCompletion=true", async () => {
dispatcher.stop();
const dispatcher2 = new MissionControlNotificationDispatcher({
db,
eventBus: bus,
adapter,
includeVerbCompletion: true,
});
dispatcher2.start();
const created = await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human@rig",
body: "x",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
adapter.calls.length = 0;
const actionLog = new MissionControlActionLog(db);
const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo, actionLog });
await writeContract.act({ verb: "approve", qitemId: created.qitemId, actorSession: "human@rig" });
await new Promise((r) => setTimeout(r, 10));
const verbCalls = adapter.calls.filter((c) => c.title.includes("verb completed"));
expect(verbCalls).toHaveLength(1);
dispatcher2.stop();
});
it("emits mission_control.notification_sent on success", async () => {
const events: Array<{ type: string }> = [];
bus.subscribe((e) => events.push(e));
await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human@rig",
body: "x",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
expect(events.find((e) => e.type === "mission_control.notification_sent")).toBeDefined();
});
it("emits mission_control.notification_failed when adapter returns ok=false (best-effort; underlying action proceeds)", async () => {
adapter.nextResult = { ok: false, error: "ntfy POST 503" };
const events: Array<{ type: string }> = [];
bus.subscribe((e) => events.push(e));
const created = await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human@rig",
body: "x",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
expect(events.find((e) => e.type === "mission_control.notification_failed")).toBeDefined();
// Underlying queue item is still present (notification failure doesn't undo durable mutations).
expect(queueRepo.getById(created.qitemId)).not.toBeNull();
});
it("dedups same qitem (no duplicate notification on re-emit)", async () => {
const created = await queueRepo.create({
sourceSession: "src@rig",
destinationSession: "human@rig",
body: "x",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
// Re-emit a synthetic queue.created event for same qitem (defensive dedup).
bus.emit({
type: "queue.created",
qitemId: created.qitemId,
sourceSession: "src@rig",
destinationSession: "human@rig",
priority: "routine",
tier: "human-gate",
});
await new Promise((r) => setTimeout(r, 10));
expect(adapter.calls).toHaveLength(1);
});
it("sendTest dispatches synthetic notification through adapter", async () => {
const result = await dispatcher.sendTest();
expect(result.ok).toBe(true);
expect(result.mechanism).toBe("fake");
expect(adapter.calls).toHaveLength(1);
expect(adapter.calls[0]!.title).toContain("test");
});
});
+8 -1
View File
@@ -20,7 +20,14 @@ export function WorkspaceHome() {
Use <span className="font-mono text-stone-800">Specs</span> to import a rig spec or run bootstrap,
and use the <span className="font-mono text-stone-800">Discovery</span> drawer to place running sessions.
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<div className="flex flex-wrap items-center justify-center gap-3 pt-2">
<a
href="/mission-control"
data-testid="workspace-open-mission-control"
className="border border-stone-900 bg-stone-900 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stone-50 transition-colors hover:bg-stone-800"
>
Mission Control
</a>
<button
type="button"
data-testid="workspace-open-explorer"
@@ -5,7 +5,7 @@
// 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 { useEffect, useState } from "react";
import {
MISSION_CONTROL_VIEWS,
type MissionControlViewName,
@@ -17,8 +17,16 @@ import { ActiveWorkView } from "./views/ActiveWorkView.js";
import { RecentShipsView } from "./views/RecentShipsView.js";
import { RecentlyActiveView } from "./views/RecentlyActiveView.js";
import { RecentObservationsView } from "./views/RecentObservationsView.js";
import { AuditHistoryView } from "./views/AuditHistoryView.js";
import { primeMissionControlBearerTokenFromUrl } from "./missionControlAuth.js";
const VIEW_LABELS: Record<MissionControlViewName, string> = {
// PL-005 Phase B: UI-side tab union extends Phase A's 7 daemon view
// names with an "audit-history" tab that consumes a different daemon
// endpoint (/api/mission-control/audit, not /views/:name). The daemon
// MISSION_CONTROL_VIEWS enum is unchanged (Phase A surface no-touch).
type MissionControlTabName = MissionControlViewName | "audit-history";
const VIEW_LABELS: Record<MissionControlTabName, string> = {
"my-queue": "My queue",
"human-gate": "Human gate",
fleet: "Fleet",
@@ -26,10 +34,33 @@ const VIEW_LABELS: Record<MissionControlViewName, string> = {
"recent-ships": "Recent ships",
"recently-active": "Recently active",
"recent-observations": "Observations",
"audit-history": "Audit history",
};
const ALL_TABS: MissionControlTabName[] = [...MISSION_CONTROL_VIEWS, "audit-history"];
function initialViewFromUrl(): MissionControlTabName {
if (typeof window === "undefined") return "my-queue";
const requested = new URL(window.location.href).searchParams.get("view");
if (requested && (ALL_TABS as string[]).includes(requested)) {
return requested as MissionControlTabName;
}
return new URL(window.location.href).searchParams.has("qitem") ? "human-gate" : "my-queue";
}
function highlightedQitemFromUrl(): string | null {
if (typeof window === "undefined") return null;
return new URL(window.location.href).searchParams.get("qitem");
}
export function MissionControlSurface() {
const [activeView, setActiveView] = useState<MissionControlViewName>("my-queue");
const [activeView, setActiveView] = useState<MissionControlTabName>(() => initialViewFromUrl());
const [highlightedQitemId] = useState<string | null>(() => highlightedQitemFromUrl());
useEffect(() => {
primeMissionControlBearerTokenFromUrl();
}, []);
return (
<div
data-testid="mc-surface"
@@ -43,7 +74,7 @@ export function MissionControlSurface() {
Queue observability
</h1>
<nav data-testid="mc-tab-nav" className="mt-2 flex flex-wrap gap-1">
{MISSION_CONTROL_VIEWS.map((view) => (
{ALL_TABS.map((view) => (
<button
key={view}
type="button"
@@ -62,13 +93,14 @@ export function MissionControlSurface() {
</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 === "my-queue" ? <MyQueueView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "human-gate" ? <HumanGateView highlightedQitemId={highlightedQitemId} /> : 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}
{activeView === "active-work" ? <ActiveWorkView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "recent-ships" ? <RecentShipsView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "recently-active" ? <RecentlyActiveView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "recent-observations" ? <RecentObservationsView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "audit-history" ? <AuditHistoryView /> : null}
</main>
</div>
);
@@ -15,12 +15,14 @@
// 8. confidence/freshness
// 9. evidence link
import { useState, type MouseEvent } from "react";
import type { CompactStatusRow as CompactStatusRowData } from "../hooks/useMissionControlView.js";
export interface CompactStatusRowProps {
row: CompactStatusRowData;
density?: "compact" | "expanded";
onAction?: () => void;
highlighted?: boolean;
}
const STATE_BADGES: Record<CompactStatusRowData["state"], { label: string; cls: string }> = {
@@ -35,14 +37,40 @@ export function CompactStatusRow({
row,
density = "expanded",
onAction,
highlighted = false,
}: CompactStatusRowProps) {
const badge = STATE_BADGES[row.state];
const summary = row.qitemSummary ?? summarizeQitemBody(row.qitemBody);
const fullBody = row.qitemBody?.trim() ?? "";
const hasDetails = Boolean(fullBody || row.qitemId || row.rawSourceRef);
const [isExpanded, setIsExpanded] = useState(highlighted);
const toggleDetails = () => {
if (hasDetails) setIsExpanded((current) => !current);
};
const onRowClick = (event: MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest("a,button,input,select,textarea")) return;
toggleDetails();
};
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"
data-highlighted={highlighted ? "true" : "false"}
data-expanded={isExpanded ? "true" : "false"}
id={row.qitemId ? `mc-qitem-${row.qitemId}` : undefined}
onClick={onRowClick}
className={`border p-3 hover:bg-stone-50 ${
hasDetails ? "cursor-pointer" : ""
} ${
highlighted
? "border-amber-400 bg-amber-50 ring-2 ring-amber-300"
: "border-stone-200 bg-white"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -68,6 +96,17 @@ export function CompactStatusRow({
</span>
) : null}
</div>
{hasDetails ? (
<button
type="button"
onClick={() => toggleDetails()}
data-testid="mc-row-details-toggle"
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"
aria-expanded={isExpanded}
>
{isExpanded ? "Hide" : "Details"}
</button>
) : null}
{onAction ? (
<button
type="button"
@@ -79,6 +118,14 @@ export function CompactStatusRow({
</button>
) : null}
</div>
{summary ? (
<p
data-testid="mc-qitem-summary"
className="mt-2 break-words text-sm leading-snug text-stone-900"
>
{summary}
</p>
) : null}
{density === "expanded" && (
<div className="mt-2 grid grid-cols-1 gap-1 text-[11px] text-stone-700 sm:grid-cols-2">
{row.nextAction ? (
@@ -121,6 +168,46 @@ export function CompactStatusRow({
) : null}
</div>
)}
{isExpanded && hasDetails ? (
<div
data-testid="mc-qitem-details"
className="mt-3 space-y-2 border-t border-stone-200 pt-2 text-[11px] text-stone-700"
>
{fullBody ? (
<div data-testid="mc-qitem-body" className="whitespace-pre-wrap break-words text-xs text-stone-900">
{fullBody}
</div>
) : null}
<div className="grid grid-cols-1 gap-1 font-mono text-[10px] text-stone-500 sm:grid-cols-2">
{row.qitemId ? (
<div>
qitem <span data-testid="mc-qitem-id">{row.qitemId}</span>
</div>
) : null}
{row.rawSourceRef ? (
<div>
source <span data-testid="mc-qitem-source">{row.rawSourceRef}</span>
</div>
) : null}
</div>
{row.qitemId ? (
<a
data-testid="mc-qitem-audit-link"
href={`/mission-control?view=audit-history&qitem_id=${encodeURIComponent(row.qitemId)}`}
className="inline-flex border border-stone-300 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-700 hover:bg-stone-100"
>
Audit
</a>
) : null}
</div>
) : null}
</div>
);
}
function summarizeQitemBody(body: string | null | undefined): string | null {
const compact = body?.replace(/\s+/g, " ").trim();
if (!compact) return null;
if (compact.length <= 120) return compact;
return `${compact.slice(0, 117).trimEnd()}...`;
}
@@ -10,6 +10,7 @@ import {
type MissionControlVerb,
useMissionControlAction,
} from "../hooks/useMissionControlAction.js";
import { useMissionControlDestinations } from "../hooks/useMissionControlDestinations.js";
export interface VerbActionsProps {
qitemId: string;
@@ -38,6 +39,7 @@ export function VerbActions({
const mutation = useMissionControlAction();
const [activeVerb, setActiveVerb] = useState<MissionControlVerb | null>(null);
const [destinationSession, setDestinationSession] = useState("");
const [manualDestination, setManualDestination] = useState(false);
const [annotation, setAnnotation] = useState("");
const [reason, setReason] = useState("");
@@ -48,6 +50,15 @@ export function VerbActions({
function reset() {
setActiveVerb(null);
setDestinationSession("");
setManualDestination(false);
setAnnotation("");
setReason("");
}
function selectVerb(verb: MissionControlVerb) {
setActiveVerb(verb);
setDestinationSession("");
setManualDestination(false);
setAnnotation("");
setReason("");
}
@@ -72,6 +83,14 @@ export function VerbActions({
);
}
const destinationsQuery = useMissionControlDestinations(needsDestination);
const destinationOptions = destinationsQuery.data?.destinations ?? [];
const destinationListLoading = needsDestination && destinationsQuery.isLoading;
const showDestinationSelect = needsDestination && (destinationOptions.length > 0 || destinationListLoading);
const showManualDestinationInput =
needsDestination &&
(manualDestination || destinationsQuery.isError || (destinationsQuery.isFetched && destinationOptions.length === 0));
return (
<div data-testid="mc-verb-actions" className="space-y-2">
<div className="flex flex-wrap gap-1">
@@ -80,7 +99,7 @@ export function VerbActions({
key={verb}
type="button"
data-testid={`mc-verb-${verb}`}
onClick={() => setActiveVerb(verb)}
onClick={() => selectVerb(verb)}
disabled={mutation.isPending}
className={`border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] ${
activeVerb === verb
@@ -95,14 +114,49 @@ export function VerbActions({
{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"
/>
<div className="space-y-1">
{showDestinationSelect ? (
<select
data-testid="mc-verb-destination-select"
aria-label="Destination session"
value={manualDestination ? "__manual__" : destinationSession}
disabled={destinationListLoading}
onChange={(e) => {
if (e.target.value === "__manual__") {
setManualDestination(true);
setDestinationSession("");
return;
}
setManualDestination(false);
setDestinationSession(e.target.value);
}}
className="w-full border border-stone-300 bg-white px-2 py-1 font-mono text-xs"
>
<option value="">
{destinationListLoading ? "loading destinations..." : "choose destination"}
</option>
{destinationOptions.map((destination) => (
<option key={destination.sessionName} value={destination.sessionName}>
{destination.label}
</option>
))}
<option value="__manual__">manual entry</option>
</select>
) : null}
{showManualDestinationInput ? (
<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"
/>
) : null}
{destinationsQuery.isError ? (
<div className="font-mono text-[10px] text-amber-700">destination list unavailable</div>
) : null}
</div>
)}
{needsAnnotation && (
<textarea
@@ -1,5 +1,6 @@
// PL-005 Phase A: mutation hook for the 7 Mission Control verbs.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { missionControlAuthHeaders } from "../missionControlAuth.js";
export const MISSION_CONTROL_VERBS = [
"approve",
@@ -39,7 +40,7 @@ export interface MissionControlActionResult {
async function postAction(input: MissionControlActionInput): Promise<MissionControlActionResult> {
const res = await fetch("/api/mission-control/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...missionControlAuthHeaders() },
body: JSON.stringify(input),
});
const body = await res.json();
@@ -0,0 +1,56 @@
// PL-005 Phase B: hook to fetch Mission Control audit history.
import { useQuery } from "@tanstack/react-query";
export interface AuditEntry {
actionId: string;
actionVerb: string;
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 AuditQueryFilters {
qitemId?: string;
actionVerb?: string;
actorSession?: string;
since?: string;
until?: string;
limit?: number;
beforeId?: string;
}
export interface AuditQueryResult {
rows: AuditEntry[];
hasMore: boolean;
nextBeforeId: string | null;
}
async function fetchAudit(filters: AuditQueryFilters): Promise<AuditQueryResult> {
const params = new URLSearchParams();
if (filters.qitemId) params.set("qitem_id", filters.qitemId);
if (filters.actionVerb) params.set("action_verb", filters.actionVerb);
if (filters.actorSession) params.set("actor_session", filters.actorSession);
if (filters.since) params.set("since", filters.since);
if (filters.until) params.set("until", filters.until);
if (filters.limit !== undefined) params.set("limit", String(filters.limit));
if (filters.beforeId) params.set("before_id", filters.beforeId);
const qs = params.toString();
const url = `/api/mission-control/audit${qs ? `?${qs}` : ""}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useMissionControlAudit(filters: AuditQueryFilters) {
return useQuery({
queryKey: ["mission-control", "audit", filters],
queryFn: () => fetchAudit(filters),
});
}
@@ -0,0 +1,31 @@
// PL-005 Phase B: phone-friendly destination candidates for route/handoff.
import { useQuery } from "@tanstack/react-query";
export interface MissionControlDestination {
sessionName: string;
label: string;
source: "topology" | "queue";
rigName?: string | null;
logicalId?: string | null;
runtime?: string | null;
status?: string | null;
}
export interface MissionControlDestinationsResult {
destinations: MissionControlDestination[];
}
async function fetchDestinations(): Promise<MissionControlDestinationsResult> {
const res = await fetch("/api/mission-control/destinations");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useMissionControlDestinations(enabled: boolean) {
return useQuery({
queryKey: ["mission-control", "destinations"],
queryFn: fetchDestinations,
enabled,
staleTime: 30_000,
});
}
@@ -25,6 +25,8 @@ export interface CompactStatusRow {
evidenceLink: string | null;
qitemId?: string | null;
rawSourceRef?: string | null;
qitemSummary?: string | null;
qitemBody?: string | null;
}
export interface MissionControlViewResult {
@@ -0,0 +1,36 @@
export const MISSION_CONTROL_BEARER_STORAGE_KEY = "openrig.missionControlBearerToken";
const TOKEN_QUERY_KEYS = ["mcToken", "mc_token"];
export function readMissionControlBearerToken(): string | null {
if (typeof window === "undefined") return null;
try {
const token = window.localStorage.getItem(MISSION_CONTROL_BEARER_STORAGE_KEY);
const trimmed = token?.trim();
return trimmed ? trimmed : null;
} catch {
return null;
}
}
export function missionControlAuthHeaders(): Record<string, string> {
const token = readMissionControlBearerToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export function primeMissionControlBearerTokenFromUrl(): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const tokenKey = TOKEN_QUERY_KEYS.find((key) => url.searchParams.has(key));
if (!tokenKey) return;
const token = url.searchParams.get(tokenKey)?.trim();
for (const key of TOKEN_QUERY_KEYS) {
url.searchParams.delete(key);
}
if (token) {
window.localStorage.setItem(MISSION_CONTROL_BEARER_STORAGE_KEY, token);
}
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(window.history.state, "", nextUrl);
}
@@ -1,6 +1,6 @@
import { GenericListView } from "./GenericListView.js";
export function ActiveWorkView() {
export function ActiveWorkView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="active-work"
@@ -8,6 +8,7 @@ export function ActiveWorkView() {
subtitle="Pending / in-progress / blocked, priority-first"
emptyMessage="No active work in flight."
withVerbActions
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -0,0 +1,205 @@
// PL-005 Phase B: read-only audit-history browse view.
//
// Filter UI for 4 dimensions (qitem_id, action_verb, actor_session,
// time range) + paginated result table. Calls GET /api/mission-control/audit.
// Read-only — no verb actions on rows.
import { useState } from "react";
import {
type AuditQueryFilters,
useMissionControlAudit,
} from "../hooks/useMissionControlAudit.js";
import { MISSION_CONTROL_VERBS } from "../hooks/useMissionControlAction.js";
function urlParam(name: string): string {
if (typeof window === "undefined") return "";
return new URL(window.location.href).searchParams.get(name) ?? "";
}
export function AuditHistoryView() {
const [qitemId, setQitemId] = useState(() => urlParam("qitem_id") || urlParam("qitem"));
const [actionVerb, setActionVerb] = useState(() => urlParam("action_verb") || urlParam("verb"));
const [actorSession, setActorSession] = useState(() => urlParam("actor_session") || urlParam("actor"));
const [since, setSince] = useState(() => urlParam("since"));
const [until, setUntil] = useState(() => urlParam("until"));
const [limit] = useState(50);
const [beforeIdStack, setBeforeIdStack] = useState<string[]>([]);
const filters: AuditQueryFilters = {
qitemId: qitemId || undefined,
actionVerb: actionVerb || undefined,
actorSession: actorSession || undefined,
since: since || undefined,
until: until || undefined,
limit,
beforeId: beforeIdStack[beforeIdStack.length - 1],
};
const query = useMissionControlAudit(filters);
function applyFilters() {
setBeforeIdStack([]);
}
function nextPage() {
if (query.data?.nextBeforeId) {
setBeforeIdStack([...beforeIdStack, query.data.nextBeforeId]);
}
}
function prevPage() {
setBeforeIdStack(beforeIdStack.slice(0, -1));
}
return (
<div data-testid="mc-view-audit-history" 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">
audit-history
</div>
<h2 className="font-headline text-lg text-stone-900">Audit history</h2>
<p className="text-xs text-stone-600">
Browse <code>mission_control_actions</code> by qitem, verb, actor,
and time range. Read-only.
</p>
</header>
{/* Filters */}
<div
data-testid="mc-audit-filters"
className="grid grid-cols-1 gap-2 border border-stone-200 bg-stone-50 p-2 sm:grid-cols-2"
>
<input
type="text"
data-testid="mc-audit-filter-qitem-id"
placeholder="qitem_id (exact)"
value={qitemId}
onChange={(e) => setQitemId(e.target.value)}
className="border border-stone-300 px-2 py-1 font-mono text-xs"
/>
<select
data-testid="mc-audit-filter-action-verb"
value={actionVerb}
onChange={(e) => setActionVerb(e.target.value)}
className="border border-stone-300 px-2 py-1 font-mono text-xs"
>
<option value="">all verbs</option>
{MISSION_CONTROL_VERBS.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
<input
type="text"
data-testid="mc-audit-filter-actor-session"
placeholder="actor_session (e.g., human-wrandom@kernel)"
value={actorSession}
onChange={(e) => setActorSession(e.target.value)}
className="border border-stone-300 px-2 py-1 font-mono text-xs"
/>
<div className="flex items-center gap-1">
<input
type="datetime-local"
data-testid="mc-audit-filter-since"
value={since}
onChange={(e) => setSince(e.target.value)}
className="flex-1 border border-stone-300 px-2 py-1 font-mono text-[11px]"
title="since"
/>
<input
type="datetime-local"
data-testid="mc-audit-filter-until"
value={until}
onChange={(e) => setUntil(e.target.value)}
className="flex-1 border border-stone-300 px-2 py-1 font-mono text-[11px]"
title="until"
/>
</div>
<button
type="button"
data-testid="mc-audit-filter-apply"
onClick={applyFilters}
className="border border-stone-700 bg-stone-800 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-white"
>
Apply filters
</button>
</div>
{/* Results */}
{query.isLoading ? (
<div data-testid="mc-audit-loading" className="font-mono text-[10px] text-stone-500">
loading...
</div>
) : query.isError ? (
<div data-testid="mc-audit-error" className="font-mono text-[11px] text-red-700">
error: {query.error.message}
</div>
) : query.data?.rows.length === 0 ? (
<div data-testid="mc-audit-empty" className="font-mono text-[11px] text-stone-500">
No audit entries match these filters.
</div>
) : (
<ul data-testid="mc-audit-rows" className="space-y-1">
{query.data?.rows.map((row) => (
<li
key={row.actionId}
data-testid="mc-audit-row"
data-action-id={row.actionId}
className="border border-stone-200 bg-white p-2 text-[11px]"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono uppercase tracking-[0.1em] text-stone-700">
{row.actionVerb}
</span>
<span className="font-mono text-[10px] text-stone-500">
{row.actedAt}
</span>
</div>
<div className="mt-1 text-stone-700">
qitem: <span className="font-mono">{row.qitemId ?? "—"}</span>
</div>
<div className="text-stone-700">
actor: <span className="font-mono">{row.actorSession}</span>
</div>
{row.reason ? (
<div className="text-stone-700">
reason: <span className="italic">{row.reason}</span>
</div>
) : null}
{row.annotation ? (
<div className="text-stone-700">
annotation: <span className="italic">{row.annotation}</span>
</div>
) : null}
</li>
))}
</ul>
)}
{/* Pagination */}
<footer
data-testid="mc-audit-pagination"
className="flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.12em] text-stone-500"
>
<button
type="button"
data-testid="mc-audit-prev-page"
onClick={prevPage}
disabled={beforeIdStack.length === 0}
className="border border-stone-300 px-2 py-0.5 disabled:opacity-50"
>
← prev
</button>
<span>rows: {query.data?.rows.length ?? 0}</span>
<button
type="button"
data-testid="mc-audit-next-page"
onClick={nextPage}
disabled={!query.data?.hasMore}
className="border border-stone-300 px-2 py-0.5 disabled:opacity-50"
>
next →
</button>
</footer>
</div>
);
}
@@ -25,6 +25,7 @@ export interface GenericListViewProps {
/** Show row-level verb actions. Default true for actionable views,
* false for read-only views (recent-ships, recently-active, etc.). */
withVerbActions?: boolean;
highlightedQitemId?: string | null;
}
export function GenericListView({
@@ -35,6 +36,7 @@ export function GenericListView({
title,
subtitle,
withVerbActions = false,
highlightedQitemId,
}: GenericListViewProps) {
const query = useMissionControlView(viewName, { operatorSession });
const actorSession = operatorSession ?? "human-wrandom@kernel";
@@ -64,7 +66,11 @@ export function GenericListView({
<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" />
<CompactStatusRow
row={row}
density="expanded"
highlighted={!!highlightedQitemId && row.qitemId === highlightedQitemId}
/>
{withVerbActions && row.qitemId ? (
<VerbActions
qitemId={row.qitemId}
@@ -1,7 +1,7 @@
// PL-005 Phase A: cross-fleet human-gate view.
import { GenericListView } from "./GenericListView.js";
export function HumanGateView() {
export function HumanGateView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="human-gate"
@@ -9,6 +9,7 @@ export function HumanGateView() {
subtitle="Cross-fleet items waiting on a human decision"
emptyMessage="No human-gated items across the fleet."
withVerbActions
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -8,10 +8,12 @@ import { GenericListView } from "./GenericListView.js";
export interface MyQueueViewProps {
operatorSession?: string;
highlightedQitemId?: string | null;
}
export function MyQueueView({
operatorSession = "human-wrandom@kernel",
highlightedQitemId,
}: MyQueueViewProps) {
const query = useMissionControlView("my-queue", { operatorSession });
return (
@@ -24,6 +26,7 @@ export function MyQueueView({
subtitle="Decisions that need me, ordered by read-cost"
emptyMessage="No human-gated items waiting on you."
withVerbActions
highlightedQitemId={highlightedQitemId}
/>
</div>
);
@@ -1,12 +1,13 @@
import { GenericListView } from "./GenericListView.js";
export function RecentObservationsView() {
export function RecentObservationsView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="recent-observations"
title="Recent observations"
subtitle="Recent stream observations relevant to operator attention"
emptyMessage="No recent observations."
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,13 +1,14 @@
// PL-005 Phase A: recent ships view (last 10 done items per founder Q4).
import { GenericListView } from "./GenericListView.js";
export function RecentShipsView() {
export function RecentShipsView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="recent-ships"
title="Recent ships"
subtitle="Last 10 done / handed-off items with closure reason"
emptyMessage="Nothing shipped recently."
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,12 +1,13 @@
import { GenericListView } from "./GenericListView.js";
export function RecentlyActiveView() {
export function RecentlyActiveView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="recently-active"
title="Recently active"
subtitle="Activity-sorted via PL-004 Phase B view-projector"
emptyMessage="No recent activity."
highlightedQitemId={highlightedQitemId}
/>
);
}
+173 -10
View File
@@ -1,16 +1,22 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionControlSurface } from "../src/components/mission-control/MissionControlSurface.js";
import { CompactStatusRow } from "../src/components/mission-control/components/CompactStatusRow.js";
import { VerbActions } from "../src/components/mission-control/components/VerbActions.js";
import { AuditHistoryView } from "../src/components/mission-control/views/AuditHistoryView.js";
globalThis.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({
viewName: "my-queue",
rows: [],
meta: { rowCount: 0 },
}),
})) as unknown as typeof fetch;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function renderSurface() {
const queryClient = new QueryClient({
@@ -25,7 +31,29 @@ function renderSurface() {
);
}
function renderWithQueryClient(element: React.ReactNode) {
render(
<QueryClientProvider client={queryClient()}>
{element}
</QueryClientProvider>,
);
}
describe("MissionControlSurface", () => {
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
viewName: "my-queue",
rows: [],
meta: { rowCount: 0 },
}),
});
window.localStorage.clear();
window.history.replaceState(null, "", "/mission-control");
});
it("accounts for AppShell explorer offsets so tabs stay clickable", () => {
renderSurface();
@@ -35,4 +63,139 @@ describe("MissionControlSurface", () => {
expect((screen.getByTestId("mc-tab-human-gate") as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId("mc-tab-fleet") as HTMLButtonElement).disabled).toBe(false);
});
it("stores mcToken from the URL for mobile write verbs and removes it from the address bar", async () => {
window.history.replaceState(null, "", "/mission-control?mcToken=phone-token&view=human-gate&qitem=qitem-1");
renderSurface();
await waitFor(() => {
expect(window.localStorage.getItem("openrig.missionControlBearerToken")).toBe("phone-token");
});
expect(window.location.search).not.toContain("mcToken");
expect(window.location.search).toContain("view=human-gate");
expect(window.location.search).toContain("qitem=qitem-1");
});
it("sends the stored bearer token on Mission Control write verbs", async () => {
window.localStorage.setItem("openrig.missionControlBearerToken", "phone-token");
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
actionId: "action-1",
verb: "approve",
qitemId: "qitem-1",
closedQitem: {},
createdQitemId: null,
notifyAttempted: false,
notifyResult: null,
auditedAt: "2026-05-04T00:00:00.000Z",
}),
});
renderWithQueryClient(
<VerbActions qitemId="qitem-1" actorSession="human-wrandom@kernel" enabledVerbs={["approve"]} />,
);
fireEvent.click(screen.getByTestId("mc-verb-approve"));
fireEvent.click(screen.getByTestId("mc-verb-submit"));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/mission-control/action",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer phone-token",
}),
}),
);
});
});
it("shows qitem context and expands full details when a phone row is tapped", () => {
render(
<CompactStatusRow
row={{
rigOrMissionName: "human-wrandom@kernel",
currentPhase: "human-gate",
state: "idle",
nextAction: null,
pendingHumanDecision: "urgent human-gate item",
readCost: "skim/approve",
lastUpdate: "2026-05-04T06:43:48.863Z",
confidenceFreshness: "urgent",
evidenceLink: null,
qitemId: "qitem-phone",
rawSourceRef: "velocity.qa@openrig-velocity",
qitemSummary: "Approve the release candidate",
qitemBody: "Approve the release candidate after checking the phone notification path.",
}}
/>,
);
expect(screen.getByTestId("mc-qitem-summary").textContent).toContain("Approve the release candidate");
expect(screen.queryByTestId("mc-qitem-body")).toBeNull();
fireEvent.click(screen.getByTestId("mc-status-row"));
expect(screen.getByTestId("mc-qitem-body").textContent).toContain(
"Approve the release candidate after checking the phone notification path.",
);
expect(screen.getByTestId("mc-qitem-id").textContent).toContain("qitem-phone");
expect((screen.getByTestId("mc-qitem-audit-link") as HTMLAnchorElement).href).toContain(
"/mission-control?view=audit-history&qitem_id=qitem-phone",
);
});
it("prefills audit filters from URL params for phone deep links", async () => {
window.history.replaceState(
null,
"",
"/mission-control?view=audit-history&qitem_id=qitem-phone&action_verb=approve",
);
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
rows: [],
hasMore: false,
nextBeforeId: null,
}),
});
renderWithQueryClient(<AuditHistoryView />);
expect(screen.getByTestId("mc-audit-filter-qitem-id")).toHaveProperty("value", "qitem-phone");
expect(screen.getByTestId("mc-audit-filter-action-verb")).toHaveProperty("value", "approve");
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/mission-control/audit?qitem_id=qitem-phone&action_verb=approve&limit=50",
);
});
});
it("shows handoff destination candidates instead of only a raw text field", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
destinations: [
{
sessionName: "velocity.qa@openrig-velocity",
label: "velocity.qa@openrig-velocity",
source: "queue",
},
],
}),
});
renderWithQueryClient(
<VerbActions qitemId="qitem-1" actorSession="human-wrandom@kernel" enabledVerbs={["handoff"]} />,
);
fireEvent.click(screen.getByTestId("mc-verb-handoff"));
const select = await screen.findByTestId("mc-verb-destination-select");
await waitFor(() => {
expect(select.textContent).toContain("velocity.qa@openrig-velocity");
});
});
});
+13
View File
@@ -0,0 +1,13 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { WorkspaceHome } from "../src/components/WorkspaceHome.js";
describe("WorkspaceHome", () => {
it("links to Mission Control from the home page", () => {
render(<WorkspaceHome />);
const link = screen.getByTestId("workspace-open-mission-control");
expect(link.textContent).toContain("Mission Control");
expect(link.getAttribute("href")).toBe("/mission-control");
});
});