mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
commit
66796c21dd
38 files changed
+2651
-45
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user