From 96e4ed9d9094e30ddd32db1ec6cde732c9e3ae88 Mon Sep 17 00:00:00 2001 From: besfeng23 <136943241+besfeng23@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:18:24 +0800 Subject: [PATCH] Add Pandora operator action center --- .../operator-actions/[id]/cancel/route.ts | 12 +++ .../operator-actions/[id]/dry-run/route.ts | 12 +++ app/api/pandora/operator-actions/route.ts | 31 ++++++++ .../pandora/OperatorActionCenterCard.tsx | 7 ++ components/pandora/OperatorActionComposer.tsx | 9 +++ components/pandora/OperatorActionEnvelope.tsx | 16 ++++ components/pandora/OperatorActionList.tsx | 9 +++ components/pandora/PandoraDashboard.tsx | 2 + components/pandora/mock-data.ts | 2 +- components/pandora/types.ts | 27 +++++++ docs/pandora-operator-action-center.md | 50 ++++++++++++ lib/services/pandora-dashboard-service.ts | 3 + .../pandora-operator-action-service.ts | 79 +++++++++++++++++++ ...3000000_pandora_operator_action_center.sql | 61 ++++++++++++++ tests/unit/pandora-dashboard-ui.test.tsx | 1 + .../pandora-operator-action-guards.test.ts | 10 +++ .../pandora-operator-action-service.test.ts | 18 +++++ .../unit/pandora-operator-action-ui.test.tsx | 14 ++++ 18 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 app/api/pandora/operator-actions/[id]/cancel/route.ts create mode 100644 app/api/pandora/operator-actions/[id]/dry-run/route.ts create mode 100644 app/api/pandora/operator-actions/route.ts create mode 100644 components/pandora/OperatorActionCenterCard.tsx create mode 100644 components/pandora/OperatorActionComposer.tsx create mode 100644 components/pandora/OperatorActionEnvelope.tsx create mode 100644 components/pandora/OperatorActionList.tsx create mode 100644 docs/pandora-operator-action-center.md create mode 100644 lib/services/pandora-operator-action-service.ts create mode 100644 supabase/migrations/20260703000000_pandora_operator_action_center.sql create mode 100644 tests/unit/pandora-operator-action-guards.test.ts create mode 100644 tests/unit/pandora-operator-action-service.test.ts create mode 100644 tests/unit/pandora-operator-action-ui.test.tsx diff --git a/app/api/pandora/operator-actions/[id]/cancel/route.ts b/app/api/pandora/operator-actions/[id]/cancel/route.ts new file mode 100644 index 0000000..f27c398 --- /dev/null +++ b/app/api/pandora/operator-actions/[id]/cancel/route.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { cancelOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service"; + +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = await resolvePandoraServerSession({ request }); + if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 }); + try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const action = await cancelOperatorAction(supabase as unknown as OperatorActionDbClient, { userId: session.session.userId, actionId: id }); return NextResponse.json({ ok: true, action }); } + catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 404 }); } +} diff --git a/app/api/pandora/operator-actions/[id]/dry-run/route.ts b/app/api/pandora/operator-actions/[id]/dry-run/route.ts new file mode 100644 index 0000000..8b2da5d --- /dev/null +++ b/app/api/pandora/operator-actions/[id]/dry-run/route.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { dryRunOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service"; + +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = await resolvePandoraServerSession({ request }); + if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 }); + try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const action = await dryRunOperatorAction(supabase as unknown as OperatorActionDbClient, { userId: session.session.userId, actionId: id }); return NextResponse.json({ ok: true, action, result: action.result }); } + catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 404 }); } +} diff --git a/app/api/pandora/operator-actions/route.ts b/app/api/pandora/operator-actions/route.ts new file mode 100644 index 0000000..b8f19ff --- /dev/null +++ b/app/api/pandora/operator-actions/route.ts @@ -0,0 +1,31 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { listOperatorActions, proposeOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const session = await resolvePandoraServerSession({ request }); + if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 }); + const supabase = await createSupabaseServerClient(); + const actions = await listOperatorActions(supabase as unknown as OperatorActionDbClient, { userId: session.session.userId, limit: 25 }); + return NextResponse.json({ ok: true, actions }); +} + +export async function POST(request: NextRequest) { + let body: unknown; + try { body = await request.json(); } catch { body = {}; } + const rejected = await assertNoClientUserIdOverride(request, body); + if (rejected) return NextResponse.json({ ok: false, blockers: rejected.blockers }, { status: 400 }); + const session = await resolvePandoraServerSession({ request }); + if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 }); + const input = body && typeof body === "object" ? body as Record : {}; + try { + const supabase = await createSupabaseServerClient(); + const action = await proposeOperatorAction(supabase as unknown as OperatorActionDbClient, { userId: session.session.userId, actionType: String(input.action_type ?? ""), namespace: typeof input.namespace === "string" ? input.namespace : null, mode: typeof input.mode === "string" ? input.mode : "dry_run", payload: input.payload && typeof input.payload === "object" ? input.payload as Record : {} }); + return NextResponse.json({ ok: true, action }); + } catch (error) { + return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Invalid operator action" }, { status: 400 }); + } +} diff --git a/components/pandora/OperatorActionCenterCard.tsx b/components/pandora/OperatorActionCenterCard.tsx new file mode 100644 index 0000000..cd36e30 --- /dev/null +++ b/components/pandora/OperatorActionCenterCard.tsx @@ -0,0 +1,7 @@ +import type { OperatorActionCenterData } from "./types"; +import { OperatorActionComposer } from "./OperatorActionComposer"; +import { OperatorActionList } from "./OperatorActionList"; + +export function OperatorActionCenterCard({ data }: { data: OperatorActionCenterData }) { + return

Operator Action Center

Controlled proposals and dry-runs

Safe operator workflow foundation: action proposals, idempotency, audit events, and visible history with zero destructive memory mutation.

Live actions gated
{data.warnings.length > 0 ?
{data.warnings.map((warning) =>

⚠ {warning}

)}
: null}
; +} diff --git a/components/pandora/OperatorActionComposer.tsx b/components/pandora/OperatorActionComposer.tsx new file mode 100644 index 0000000..ccb8d14 --- /dev/null +++ b/components/pandora/OperatorActionComposer.tsx @@ -0,0 +1,9 @@ +"use client"; +import { useState } from "react"; + +export function OperatorActionComposer() { + const [actionType, setActionType] = useState("verify_namespace_invariants"); + const [namespace, setNamespace] = useState("real_life"); + async function prepare() { await fetch("/api/pandora/operator-actions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action_type: actionType, namespace, mode: "dry_run", payload: { source: "operator_action_center" } }) }); window.location.reload(); } + return

Only dry-run or queued-only proposals are available. No core memory mutation is available from this card.

; +} diff --git a/components/pandora/OperatorActionEnvelope.tsx b/components/pandora/OperatorActionEnvelope.tsx new file mode 100644 index 0000000..e638e98 --- /dev/null +++ b/components/pandora/OperatorActionEnvelope.tsx @@ -0,0 +1,16 @@ +import type { OperatorActionSummary } from "./types"; + +export function OperatorActionEnvelope({ action }: { action: OperatorActionSummary }) { + const result = action.result ?? {}; + const noMutation = result.no_mutation_performed === true || JSON.stringify(result).includes('"no_mutation_performed":true'); + return ( +
+
+
{action.request_id}request_id
+
{action.idempotency_key.slice(0, 12)}…idempotency
+
{noMutation ? "Yes" : "Pending"}No mutation performed
+
+ {action.warnings.length > 0 ?
{action.warnings.map((warning) =>

⚠ {warning}

)}
:

No warnings recorded for this action.

} +
+ ); +} diff --git a/components/pandora/OperatorActionList.tsx b/components/pandora/OperatorActionList.tsx new file mode 100644 index 0000000..7fbc1ba --- /dev/null +++ b/components/pandora/OperatorActionList.tsx @@ -0,0 +1,9 @@ +import type { OperatorActionSummary } from "./types"; +import { OperatorActionEnvelope } from "./OperatorActionEnvelope"; + +const colors: Record = { proposed: "slate", dry_ran: "emerald", queued: "blue", blocked: "amber", completed: "emerald", failed: "red", cancelled: "slate" }; + +export function OperatorActionList({ actions }: { actions: OperatorActionSummary[] }) { + if (actions.length === 0) return
No operator actions yet.Prepare a safe dry-run proposal to create action history.
; + return
{actions.map((action) =>

{action.action_type}

{action.title}

{action.description}

{action.status}
{action.namespace ?? "global"}namespace
{action.mode}mode
{action.created_at}created
{action.updated_at}updated
)}
; +} diff --git a/components/pandora/PandoraDashboard.tsx b/components/pandora/PandoraDashboard.tsx index b805ee3..366d334 100644 --- a/components/pandora/PandoraDashboard.tsx +++ b/components/pandora/PandoraDashboard.tsx @@ -11,6 +11,7 @@ import { Sidebar } from "./Sidebar"; import { StatCard } from "./StatCard"; import { TopBar } from "./TopBar"; import { VerificationConsoleCard } from "./VerificationConsoleCard"; +import { OperatorActionCenterCard } from "./OperatorActionCenterCard"; import { WorkQueueCard } from "./WorkQueueCard"; import type { PandoraDashboardData, StatItem } from "./types"; import { useState } from "react"; @@ -43,6 +44,7 @@ export function PandoraDashboard({ dashboardData }: { dashboardData: PandoraDash {stats.map((stat) => )} +
diff --git a/components/pandora/mock-data.ts b/components/pandora/mock-data.ts index ce76719..f9ccdf7 100644 --- a/components/pandora/mock-data.ts +++ b/components/pandora/mock-data.ts @@ -17,5 +17,5 @@ export const timelineEvents: TimelineEventData[] = [{ id: "fixture", color: "sla export const coreSystems: SystemRow[] = [{ label: "Fixture", value: "No live data", state: "idle" }]; export const gatedSystems: SystemRow[] = [{ label: "Semantic retrieval", value: "Gated", state: "gated" }]; const verification = { generatedAt: "No live data", status: "not_run" as const, namespaces: [], packSupersession: { status: "not_run" as const, namespaces: [], warnings: ["Mock only"] }, retrievalEval: { status: "not_run" as const, source: "fixture", latestRunId: null, latestRunAt: null, resultLabel: "Not run", realResultAvailable: false, warnings: ["Mock only"] }, auditEvidence: [], smokeEvidence: { status: "not_run" as const, latest: null, warnings: ["Mock only"] }, invariantStatus: { exactlyOneActiveMasterPerNamespace: "not_run" as const, noCrossNamespacePackMixing: "not_run" as const, noDuplicateActiveMaster: "not_run" as const, retrievalEvalHasNoFabricatedScore: "pass" as const, smokeEvidence: "not_run" as const }, warnings: ["Mock only"] }; -export const fixtureDashboardData: PandoraDashboardData = { generatedAt: "No live data", operatorLabel: "Fixture", live: false, warnings: ["Mock only"], hero: { title: "Fixture dashboard", description: "Mock only: no live data.", primaryAction: "No live data", secondaryAction: "Semantic gated" }, evidence: "No live data", stats: [{ id: "fixture", title: "Fixture", value: "No live data", subtitle: "Mock only", color: "slate", sparklineData: [0, 0] }], memorySpaces, workQueue, profileSnapshot, timelineEvents, diagnostics: { coreSystems, gatedSystems, envelope: { title: "Fixture", description: "Mock only: no live data." } }, verification }; +export const fixtureDashboardData: PandoraDashboardData = { generatedAt: "No live data", operatorLabel: "Fixture", live: false, warnings: ["Mock only"], hero: { title: "Fixture dashboard", description: "Mock only: no live data.", primaryAction: "No live data", secondaryAction: "Semantic gated" }, evidence: "No live data", stats: [{ id: "fixture", title: "Fixture", value: "No live data", subtitle: "Mock only", color: "slate", sparklineData: [0, 0] }], memorySpaces, workQueue, profileSnapshot, timelineEvents, diagnostics: { coreSystems, gatedSystems, envelope: { title: "Fixture", description: "Mock only: no live data." } }, verification, operatorActions: { actions: [], warnings: [] } }; diff --git a/components/pandora/types.ts b/components/pandora/types.ts index b0134f9..a35e3ab 100644 --- a/components/pandora/types.ts +++ b/components/pandora/types.ts @@ -141,6 +141,32 @@ export type PandoraVerificationData = { warnings: string[]; }; + +export type OperatorActionStatus = "proposed" | "dry_ran" | "queued" | "blocked" | "completed" | "failed" | "cancelled"; +export type OperatorActionType = "verify_namespace_invariants" | "verify_pack_supersession" | "check_retrieval_eval_status" | "refresh_dashboard_snapshot" | "prepare_distill_smoke_plan"; +export type OperatorActionMode = "dry_run" | "queued_only"; + +export type OperatorActionSummary = { + id: string; + request_id: string; + idempotency_key: string; + action_type: OperatorActionType; + namespace: PandoraNamespace | null; + mode: OperatorActionMode; + status: OperatorActionStatus; + title: string; + description: string; + result: Record; + warnings: string[]; + created_at: string; + updated_at: string; +}; + +export type OperatorActionCenterData = { + actions: OperatorActionSummary[]; + warnings: string[]; +}; + export type PandoraDashboardData = { generatedAt: string; operatorLabel: string; @@ -167,4 +193,5 @@ export type PandoraDashboardData = { }; }; verification: PandoraVerificationData; + operatorActions: OperatorActionCenterData; }; diff --git a/docs/pandora-operator-action-center.md b/docs/pandora-operator-action-center.md new file mode 100644 index 0000000..cd0333c --- /dev/null +++ b/docs/pandora-operator-action-center.md @@ -0,0 +1,50 @@ +# Pandora Operator Action Center + +The Pandora Operator Action Center is a production-safe workflow foundation for authenticated operators. It lets the current server-derived Supabase user propose actions, dry-run safe verification work, inspect idempotency metadata, and review audit event history. + +## What it does + +- Creates bookkeeping rows in `pandora_operator_actions`. +- Creates audit/event rows in `pandora_operator_action_events`. +- Lists recent actions for the authenticated user only. +- Supports dry-run envelopes that summarize evidence and missing evidence. +- Records deterministic idempotency keys so repeated proposals return the existing action. + +## What it explicitly does not do + +- No model calls. +- No embeddings. +- No semantic retrieval enablement. +- No GPT Actions or MCP enablement. +- No destructive memory operations. +- No deletion, pruning application, merge, live distill, live profile rewrite, or production job execution. +- No mutation of `memory_events`, `memory_context_packs`, `memory_profiles`, or other core memory truth tables. +- No client-supplied `user_id` trust. + +## Allowed action types + +- `verify_namespace_invariants` +- `verify_pack_supersession` +- `check_retrieval_eval_status` +- `refresh_dashboard_snapshot` +- `prepare_distill_smoke_plan` + +## Status lifecycle + +Initial actions are `proposed` for `dry_run` mode or `queued` for `queued_only` mode. Dry-runs can move an action to `dry_ran` when no warnings are present or `blocked` when evidence is missing or warnings are returned. Operators can cancel an action before any future approval path. `completed` and `failed` exist for future bookkeeping but this PR does not add live execution. + +## Why dry-run comes before live actions + +Pandora memory changes must remain reviewed, source-backed, patch-backed, audit-backed, idempotent, and scoped to server-derived identity. Dry-run output gives operators a safe evidence packet before any future workflow can request explicit approval. + +## How idempotency works + +The service hashes the server-derived `userId`, action type, namespace, normalized payload, and mode. The database enforces `unique(user_id, idempotency_key)`, and the service returns an existing action instead of creating a duplicate. + +## Why no core memory mutation is allowed in this PR + +This PR only adds the operator workflow shell. Core memory truth tables continue to be controlled by existing reviewed persistence paths and RLS boundaries. The Action Center writes only bookkeeping and audit metadata about proposals, dry-runs, and cancellations. + +## Future path to approved live actions + +Future live actions would require a separate reviewed PR, explicit safety gates, protected dry-run output, human approval, route proof, database proof, and post-run verification. Until then, the dashboard exposes only safe proposal, dry-run, and cancellation workflows. diff --git a/lib/services/pandora-dashboard-service.ts b/lib/services/pandora-dashboard-service.ts index a7ec8ad..ff14f33 100644 --- a/lib/services/pandora-dashboard-service.ts +++ b/lib/services/pandora-dashboard-service.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { PandoraDashboardData } from "@/components/pandora/types"; import { loadPandoraVerificationData } from "@/lib/services/pandora-verification-service"; +import { listOperatorActions } from "@/lib/services/pandora-operator-action-service"; export type PandoraDashboardDbClient = { from: (table: string) => any }; type Namespace = "real_life" | "au"; @@ -38,6 +39,7 @@ function eventSummary(event: Row) { export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, input: { userId: string; operatorLabel?: string }): Promise { const warnings: string[] = []; const verification = await loadPandoraVerificationData(client, { userId: input.userId }); + const operatorActions = await listOperatorActions(client, { userId: input.userId, limit: 10 }); const data = await Promise.all(namespaces.map(async (namespace) => ({ namespace, events: await rows(client, "memory_events", input.userId, namespace, warnings, 500), @@ -85,5 +87,6 @@ export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, timelineEvents: events.slice(0, 6).map((event) => ({ id: String(event.id ?? `${event.namespace}-${event.created_at ?? "event"}`), title: `${event.namespace} • ${event.status ?? "unknown"}`, time: event.created_at ?? "Live read", desc: eventSummary(event), namespace: event.namespace === "au" ? "au" : "real_life", color: event.namespace === "au" ? "purple" : "emerald" })), diagnostics: { coreSystems: [{ label: "Route exposure", value: "Auth gated", state: "healthy" }, { label: "Displayed data", value: warnings.length ? "Partial live reads" : "Live reads", state: warnings.length ? "attention" : "healthy" }, { label: "Master-pack invariant", value: duplicates ? `${duplicates} duplicate` : "OK", state: duplicates ? "attention" : "healthy" }, { label: "Client user_id", value: "Rejected", state: "healthy" }], gatedSystems: [{ label: "Semantic retrieval", value: "Gated Off", state: "gated" }, { label: "Embeddings", value: "Gated Off", state: "gated" }, { label: "Model calls", value: "Gated Off", state: "gated" }, { label: "Pruning automation", value: "Review-only", state: "gated" }], envelope: { title: "Dashboard Truth Envelope", description: warnings.length ? "Unavailable reads were converted to warnings and empty UI state." : "Live loader completed from authenticated Supabase reads." } }, verification, + operatorActions: { actions: operatorActions.map((action) => ({ id: action.id, request_id: action.request_id, idempotency_key: action.idempotency_key, action_type: action.action_type, namespace: action.namespace, mode: action.mode, status: action.status, title: action.title, description: action.description, result: action.result, warnings: action.warnings, created_at: action.created_at, updated_at: action.updated_at })), warnings: operatorActions.flatMap((action) => action.warnings ?? []) }, }; } diff --git a/lib/services/pandora-operator-action-service.ts b/lib/services/pandora-operator-action-service.ts new file mode 100644 index 0000000..85674ae --- /dev/null +++ b/lib/services/pandora-operator-action-service.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createHash, randomUUID } from "node:crypto"; +import type { PandoraNamespace } from "@/components/pandora/types"; +import { loadPandoraDashboardData } from "@/lib/services/pandora-dashboard-service"; +import { loadPandoraVerificationData } from "@/lib/services/pandora-verification-service"; + +export type OperatorActionType = "verify_namespace_invariants" | "verify_pack_supersession" | "check_retrieval_eval_status" | "refresh_dashboard_snapshot" | "prepare_distill_smoke_plan"; +export type OperatorActionMode = "dry_run" | "queued_only"; +export type OperatorActionStatus = "proposed" | "dry_ran" | "queued" | "blocked" | "completed" | "failed" | "cancelled"; +export type OperatorActionRow = { id: string; user_id: string; request_id: string; idempotency_key: string; action_type: OperatorActionType; namespace: PandoraNamespace | null; mode: OperatorActionMode; status: OperatorActionStatus; title: string; description: string; payload: Record; result: Record; warnings: string[]; created_at: string; updated_at: string; approved_at?: string | null; completed_at?: string | null; failed_at?: string | null }; +export type OperatorActionEventRow = { id: string; action_id: string; user_id: string; event_type: string; message: string; metadata: Record; created_at: string }; +export type OperatorActionDbClient = { from: (table: string) => any }; + +const ACTIONS = new Set(["verify_namespace_invariants", "verify_pack_supersession", "check_retrieval_eval_status", "refresh_dashboard_snapshot", "prepare_distill_smoke_plan"]); +const MODES = new Set(["dry_run", "queued_only"]); +const NAMESPACES = new Set(["real_life", "au"]); + +function assertAction(actionType: string): asserts actionType is OperatorActionType { if (!ACTIONS.has(actionType as OperatorActionType)) throw new Error(`Unsupported Pandora operator action_type: ${actionType}`); } +function assertMode(mode: string): asserts mode is OperatorActionMode { if (!MODES.has(mode as OperatorActionMode)) throw new Error(`Unsupported Pandora operator mode: ${mode}`); } +function assertNamespace(namespace?: string | null): asserts namespace is PandoraNamespace | null | undefined { if (namespace != null && !NAMESPACES.has(namespace)) throw new Error(`Unsupported Pandora namespace: ${namespace}`); } +function stable(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") return `{${Object.keys(value as Record).sort().map((k) => `${JSON.stringify(k)}:${stable((value as Record)[k])}`).join(",")}}`; return JSON.stringify(value); } +export function operatorActionIdempotencyKey(input: { userId: string; actionType: string; namespace?: string | null; payload?: unknown; mode: string }) { return createHash("sha256").update([input.userId, input.actionType, input.namespace ?? "global", input.mode, stable(input.payload ?? {})].join("|")).digest("hex"); } +function titleFor(actionType: OperatorActionType) { return actionType.split("_").map((p) => p[0].toUpperCase() + p.slice(1)).join(" "); } +function envelope(action: OperatorActionRow, result: Record, warnings: string[]) { return { ok: warnings.length === 0, request_id: action.request_id, action_id: action.id, status: action.status, warnings, evidence_summary: result, no_mutation_performed: true }; } +async function single(query: any) { const res = await query; if (res.error) return null; return Array.isArray(res.data) ? res.data[0] ?? null : res.data ?? null; } + +export async function listOperatorActions(client: OperatorActionDbClient, input: { userId: string; limit?: number }): Promise { + const result = await client.from("pandora_operator_actions").select("*").eq("user_id", input.userId).order("created_at", { ascending: false }).limit(input.limit ?? 20); + if (result.error) return []; + return Array.isArray(result.data) ? result.data : []; +} + +export async function createActionEvent(client: OperatorActionDbClient, input: { userId: string; actionId: string; eventType: string; message: string; metadata?: Record }): Promise { + const row = { id: randomUUID(), action_id: input.actionId, user_id: input.userId, event_type: input.eventType, message: input.message, metadata: input.metadata ?? {}, created_at: new Date().toISOString() }; + return single(client.from("pandora_operator_action_events").insert(row).select("*").single()); +} + +export async function proposeOperatorAction(client: OperatorActionDbClient, input: { userId: string; actionType: string; namespace?: string | null; mode?: string; payload?: Record; user_id?: never }): Promise { + assertAction(input.actionType); const mode = input.mode ?? "dry_run"; assertMode(mode); assertNamespace(input.namespace); + const idempotencyKey = operatorActionIdempotencyKey({ userId: input.userId, actionType: input.actionType, namespace: input.namespace, payload: input.payload, mode }); + const existing = await single(client.from("pandora_operator_actions").select("*").eq("user_id", input.userId).eq("idempotency_key", idempotencyKey).limit(1)); + if (existing) return existing; + const now = new Date().toISOString(); const requestId = randomUUID(); + const row = { id: randomUUID(), user_id: input.userId, request_id: requestId, idempotency_key: idempotencyKey, action_type: input.actionType, namespace: input.namespace ?? null, mode, status: mode === "queued_only" ? "queued" : "proposed", title: titleFor(input.actionType), description: "Operator-proposed safe action. Initial implementation is dry-run or queued-only and cannot mutate core memory truth tables.", payload: input.payload ?? {}, result: {}, warnings: [], created_at: now, updated_at: now, approved_at: null, completed_at: null, failed_at: null }; + const created = await single(client.from("pandora_operator_actions").insert(row).select("*").single()); + if (!created) throw new Error("Unable to create Pandora operator action"); + await createActionEvent(client, { userId: input.userId, actionId: created.id, eventType: "proposed", message: "Operator action proposed with deterministic idempotency key.", metadata: { action_type: input.actionType, mode } }); + return created; +} + +async function buildDryRunResult(client: OperatorActionDbClient, userId: string, action: OperatorActionRow) { + const warnings: string[] = []; + const verification = await loadPandoraVerificationData(client, { userId }); + if (action.action_type === "refresh_dashboard_snapshot") { const dashboard = await loadPandoraDashboardData(client, { userId }); warnings.push(...dashboard.warnings); return { warnings, result: { checked: "dashboard_snapshot", generated_at: dashboard.generatedAt, memory_spaces: dashboard.memorySpaces.map((s) => ({ namespace: s.id, memories: s.memories, status: s.status })), gated_systems: dashboard.diagnostics.gatedSystems, no_mutation_performed: true } }; } + warnings.push(...verification.warnings); + if (action.action_type === "prepare_distill_smoke_plan") return { warnings, result: { checked: "distill_smoke_plan", plan_only: true, steps: ["Verify authenticated operator session", "Select one namespace", "Run protected endpoint with dryRun:true", "Review output before any future approval"], forbidden: ["dryRun:false", "core memory table mutation", "profile rewrite", "pruning application"], no_mutation_performed: true } }; + if (action.action_type === "verify_pack_supersession") return { warnings, result: { checked: "pack_supersession", status: verification.packSupersession.status, namespaces: verification.packSupersession.namespaces, no_mutation_performed: true } }; + if (action.action_type === "check_retrieval_eval_status") return { warnings: [...warnings, ...verification.retrievalEval.warnings], result: { checked: "retrieval_eval_status", retrieval_eval: verification.retrievalEval, no_fake_accuracy: true, no_mutation_performed: true } }; + return { warnings, result: { checked: "namespace_invariants", invariant_status: verification.invariantStatus, namespaces: verification.namespaces, no_mutation_performed: true } }; +} + +export async function dryRunOperatorAction(client: OperatorActionDbClient, input: { userId: string; actionId: string }): Promise { + const action = await single(client.from("pandora_operator_actions").select("*").eq("user_id", input.userId).eq("id", input.actionId).limit(1)); + if (!action) throw new Error("Pandora operator action not found for current user"); + const built = await buildDryRunResult(client, input.userId, action); + const status: OperatorActionStatus = built.warnings.length ? "blocked" : "dry_ran"; + const nextResult = envelope({ ...action, status }, built.result, built.warnings); + const updated = await single(client.from("pandora_operator_actions").update({ status, result: nextResult, warnings: built.warnings, updated_at: new Date().toISOString() }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: status, message: "Safe dry-run completed; no core memory mutation was performed.", metadata: nextResult }); + return updated ?? { ...action, status, result: nextResult, warnings: built.warnings }; +} + +export async function cancelOperatorAction(client: OperatorActionDbClient, input: { userId: string; actionId: string }): Promise { + const action = await single(client.from("pandora_operator_actions").select("*").eq("user_id", input.userId).eq("id", input.actionId).limit(1)); + if (!action) throw new Error("Pandora operator action not found for current user"); + const updated = await single(client.from("pandora_operator_actions").update({ status: "cancelled", updated_at: new Date().toISOString() }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "cancelled", message: "Operator action cancelled before live execution; no mutation performed.", metadata: { no_mutation_performed: true } }); + return updated ?? { ...action, status: "cancelled" }; +} diff --git a/supabase/migrations/20260703000000_pandora_operator_action_center.sql b/supabase/migrations/20260703000000_pandora_operator_action_center.sql new file mode 100644 index 0000000..9499b84 --- /dev/null +++ b/supabase/migrations/20260703000000_pandora_operator_action_center.sql @@ -0,0 +1,61 @@ +create table if not exists public.pandora_operator_actions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + request_id text not null, + idempotency_key text not null, + action_type text not null check (action_type in ('verify_namespace_invariants','verify_pack_supersession','check_retrieval_eval_status','refresh_dashboard_snapshot','prepare_distill_smoke_plan')), + namespace text null check (namespace is null or namespace in ('real_life','au')), + mode text not null default 'dry_run' check (mode in ('dry_run','queued_only')), + status text not null default 'proposed' check (status in ('proposed','dry_ran','queued','blocked','completed','failed','cancelled')), + title text not null, + description text not null, + payload jsonb not null default '{}'::jsonb, + result jsonb not null default '{}'::jsonb, + warnings text[] not null default '{}'::text[], + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + approved_at timestamptz null, + completed_at timestamptz null, + failed_at timestamptz null, + unique (user_id, idempotency_key) +); + +create index if not exists pandora_operator_actions_user_created_idx on public.pandora_operator_actions (user_id, created_at desc); +create index if not exists pandora_operator_actions_user_status_idx on public.pandora_operator_actions (user_id, status); +create index if not exists pandora_operator_actions_user_type_idx on public.pandora_operator_actions (user_id, action_type); +create index if not exists pandora_operator_actions_user_namespace_idx on public.pandora_operator_actions (user_id, namespace); + +alter table public.pandora_operator_actions enable row level security; + +drop policy if exists "pandora_operator_actions_select_own" on public.pandora_operator_actions; +create policy "pandora_operator_actions_select_own" on public.pandora_operator_actions for select to authenticated using (user_id = auth.uid()); + +drop policy if exists "pandora_operator_actions_insert_own" on public.pandora_operator_actions; +create policy "pandora_operator_actions_insert_own" on public.pandora_operator_actions for insert to authenticated with check (user_id = auth.uid()); + +drop policy if exists "pandora_operator_actions_update_own_bookkeeping" on public.pandora_operator_actions; +create policy "pandora_operator_actions_update_own_bookkeeping" on public.pandora_operator_actions for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); + +revoke update on public.pandora_operator_actions from authenticated; +grant update (status, result, warnings, updated_at, approved_at, completed_at, failed_at) on public.pandora_operator_actions to authenticated; + +create table if not exists public.pandora_operator_action_events ( + id uuid primary key default gen_random_uuid(), + action_id uuid not null references public.pandora_operator_actions(id) on delete cascade, + user_id uuid not null, + event_type text not null, + message text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists pandora_operator_action_events_action_created_idx on public.pandora_operator_action_events (action_id, created_at desc); +create index if not exists pandora_operator_action_events_user_created_idx on public.pandora_operator_action_events (user_id, created_at desc); + +alter table public.pandora_operator_action_events enable row level security; + +drop policy if exists "pandora_operator_action_events_select_own" on public.pandora_operator_action_events; +create policy "pandora_operator_action_events_select_own" on public.pandora_operator_action_events for select to authenticated using (user_id = auth.uid()); + +drop policy if exists "pandora_operator_action_events_insert_own" on public.pandora_operator_action_events; +create policy "pandora_operator_action_events_insert_own" on public.pandora_operator_action_events for insert to authenticated with check (user_id = auth.uid()); diff --git a/tests/unit/pandora-dashboard-ui.test.tsx b/tests/unit/pandora-dashboard-ui.test.tsx index 7b57d9b..bc16ac0 100644 --- a/tests/unit/pandora-dashboard-ui.test.tsx +++ b/tests/unit/pandora-dashboard-ui.test.tsx @@ -34,6 +34,7 @@ const data: PandoraDashboardData = { timelineEvents: [{ id: "event", title: "real_life • captured", time: "now", desc: "Live event summary", namespace: "real_life", color: "emerald" }], diagnostics: { coreSystems: [{ label: "Displayed data", value: "Live reads", state: "healthy" }], gatedSystems: [{ label: "Semantic retrieval", value: "Gated Off", state: "gated" }], envelope: { title: "Dashboard Truth Envelope", description: "Live loader completed" } }, verification, + operatorActions: { actions: [], warnings: [] }, }; describe("PandoraDashboard", () => { diff --git a/tests/unit/pandora-operator-action-guards.test.ts b/tests/unit/pandora-operator-action-guards.test.ts new file mode 100644 index 0000000..16d336b --- /dev/null +++ b/tests/unit/pandora-operator-action-guards.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +const files = ["components/pandora/OperatorActionCenterCard.tsx", "components/pandora/OperatorActionComposer.tsx", "components/pandora/OperatorActionList.tsx", "components/pandora/OperatorActionEnvelope.tsx"]; +describe("operator action center safety guards", () => { + it("does not render dangerous live action buttons", () => { const text = files.map((f)=>readFileSync(f,"utf8")).join("\n"); for (const bad of ["Run live", "Delete memory", "Prune now", "Merge now", "Distill now"]) expect(text).not.toContain(bad); }); + it("service does not import service-role/admin clients or mutate core memory tables", () => { const text=readFileSync("lib/services/pandora-operator-action-service.ts","utf8"); expect(text).not.toContain("service-role"); expect(text).not.toContain("createSupabaseBridgeAdminClient"); for (const table of ["memory_events","memory_context_packs","memory_profiles"]) expect(text).not.toMatch(new RegExp(`from\\(\\\"${table}\\\"\\).*\\.(insert|update|delete)`)); expect(text).toContain("no_mutation_performed: true"); }); + it("production pandora path does not import mock-data and route identity rejects client user ids", () => { expect(readFileSync("app/pandora/page.tsx","utf8")).not.toContain("mock-data"); const route=readFileSync("app/api/pandora/operator-actions/route.ts","utf8"); expect(route).toContain("assertNoClientUserIdOverride"); expect(route).not.toContain("searchParams.get(\"user_id\")"); }); + it("retrieval eval still has no fabricated accuracy", () => { const text=readFileSync("lib/services/pandora-verification-service.ts","utf8")+readFileSync("lib/services/pandora-dashboard-service.ts","utf8"); expect(text).not.toContain("94.3"); expect(text).not.toContain("fake accuracy"); }); +}); diff --git a/tests/unit/pandora-operator-action-service.test.ts b/tests/unit/pandora-operator-action-service.test.ts new file mode 100644 index 0000000..719635f --- /dev/null +++ b/tests/unit/pandora-operator-action-service.test.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from "vitest"; +import { cancelOperatorAction, dryRunOperatorAction, listOperatorActions, proposeOperatorAction } from "@/lib/services/pandora-operator-action-service"; + +const USER = "11111111-1111-4111-8111-111111111111"; +const OTHER = "22222222-2222-4222-8222-222222222222"; +type Store = Record; +function client(store: Store) { return { from(table: string) { const eqs: Record = {}; let lim = Infinity; let patch: any; let insertRow: any; const rows=()=> (store[table]??[]).filter(r=>Object.entries(eqs).every(([k,v])=>r[k]===v)).slice(0,lim); const b:any={ select(){return b}, eq(k:string,v:any){eqs[k]=v;return b}, order(){return b}, limit(n:number){lim=n;return b}, single(){return b}, insert(row:any){insertRow=Array.isArray(row)?row[0]:row; store[table]=store[table]??[]; store[table].push(insertRow); return b}, update(row:any){patch=row; return b}, then(res:any,rej:any){ if(patch){ const r=rows()[0]; if(r) Object.assign(r,patch); return Promise.resolve({data:r??null,error:r?null:{message:"missing"}}).then(res,rej)} return Promise.resolve({data:insertRow??rows(),error:null}).then(res,rej)}}; return b; }} as any; } +function store(): Store { return { pandora_operator_actions: [], pandora_operator_action_events: [], memory_context_packs: [{ id:"rl", user_id: USER, namespace:"real_life", pack_type:"master", status:"active", title:"RL", created_at:"2026-07-03" }, { id:"au", user_id: USER, namespace:"au", pack_type:"master", status:"active", title:"AU", created_at:"2026-07-03" }], audit_logs: [], retrieval_logs: [], memory_events: [], memory_profiles: [], memory_open_loops: [], memory_capture_candidates: [], memory_review_queue_items: [], memory_pruning_candidates: [] }; } + +describe("pandora operator action service", () => { + it("proposes allowed action and returns existing duplicate by idempotency", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"verify_namespace_invariants", namespace:"real_life", mode:"dry_run", payload:{a:1}}); const b=await proposeOperatorAction(c,{userId:USER, actionType:"verify_namespace_invariants", namespace:"real_life", mode:"dry_run", payload:{a:1}}); expect(a.id).toBe(b.id); expect(s.pandora_operator_actions).toHaveLength(1); }); + it("rejects unknown action_type and live mode", async () => { await expect(proposeOperatorAction(client(store()),{userId:USER, actionType:"delete_memory", mode:"dry_run"})).rejects.toThrow(/Unsupported/); await expect(proposeOperatorAction(client(store()),{userId:USER, actionType:"verify_namespace_invariants", mode:"live"})).rejects.toThrow(/Unsupported/); }); + it("never lists another user's action", async () => { const s=store(); s.pandora_operator_actions.push({id:"other", user_id:OTHER}); expect(await listOperatorActions(client(s),{userId:USER})).toEqual([]); }); + it("dry-run verify_namespace_invariants uses read-only verification and includes no_mutation_performed", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"verify_namespace_invariants", namespace:"real_life"}); const d=await dryRunOperatorAction(c,{userId:USER, actionId:a.id}); expect(JSON.stringify(d.result)).toContain("namespace_invariants"); expect(JSON.stringify(d.result)).toContain("no_mutation_performed"); expect(s.memory_context_packs).toHaveLength(2); }); + it("prepare_distill_smoke_plan returns a plan only", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"prepare_distill_smoke_plan", namespace:"au"}); const d=await dryRunOperatorAction(c,{userId:USER, actionId:a.id}); expect(d.result.no_mutation_performed).toBe(true); expect(JSON.stringify(d.result)).toContain("plan_only"); }); + it("cancel only own action", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"check_retrieval_eval_status"}); await expect(cancelOperatorAction(c,{userId:OTHER, actionId:a.id})).rejects.toThrow(/not found/); const cancelled=await cancelOperatorAction(c,{userId:USER, actionId:a.id}); expect(cancelled.status).toBe("cancelled"); }); +}); diff --git a/tests/unit/pandora-operator-action-ui.test.tsx b/tests/unit/pandora-operator-action-ui.test.tsx new file mode 100644 index 0000000..53e9edd --- /dev/null +++ b/tests/unit/pandora-operator-action-ui.test.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OperatorActionCenterCard } from "@/components/pandora/OperatorActionCenterCard"; +import { OperatorActionEnvelope } from "@/components/pandora/OperatorActionEnvelope"; +import type { OperatorActionStatus, OperatorActionSummary } from "@/components/pandora/types"; + +const base: OperatorActionSummary = { id:"a1", request_id:"req-123", idempotency_key:"abcdef1234567890", action_type:"verify_namespace_invariants", namespace:"real_life", mode:"dry_run", status:"proposed", title:"Verify Namespace Invariants", description:"Safe proposal", result:{ no_mutation_performed: true }, warnings:["Missing smoke evidence"], created_at:"2026-07-03", updated_at:"2026-07-03" }; +describe("OperatorActionCenterCard", () => { + it("renders empty state", () => { const html=renderToStaticMarkup(); expect(html).toContain("No operator actions yet"); expect(html).toContain("Prepare dry-run"); }); + it("renders proposed/dry_ran/failed/cancelled statuses", () => { const actions=["proposed","dry_ran","failed","cancelled"].map((status,i)=>({...base,id:String(i),status: status as OperatorActionStatus})); const html=renderToStaticMarkup(); for (const s of ["proposed","dry_ran","failed","cancelled"]) expect(html).toContain(s); }); + it("envelope shows request_id, warnings, and no mutation performed", () => { const html=renderToStaticMarkup(); expect(html).toContain("req-123"); expect(html).toContain("Missing smoke evidence"); expect(html).toContain("No mutation performed"); }); + it("does not render dangerous live action buttons", () => { const html=renderToStaticMarkup(); for (const bad of ["Run live","Delete memory","Prune now","Merge now","Distill now"]) expect(html).not.toContain(bad); }); +});