diff --git a/app/api/pandora/operator-actions/[id]/approve/route.ts b/app/api/pandora/operator-actions/[id]/approve/route.ts new file mode 100644 index 0000000..dd3eebf --- /dev/null +++ b/app/api/pandora/operator-actions/[id]/approve/route.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { approveOperatorAction, 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 }> }) { + 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 }); + try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const action = await approveOperatorAction(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 cannot be approved" }, { status: 400 }); } +} diff --git a/app/api/pandora/operator-actions/[id]/cancel/route.ts b/app/api/pandora/operator-actions/[id]/cancel/route.ts index f27c398..970d7b2 100644 --- a/app/api/pandora/operator-actions/[id]/cancel/route.ts +++ b/app/api/pandora/operator-actions/[id]/cancel/route.ts @@ -1,12 +1,12 @@ import { NextResponse, type NextRequest } from "next/server"; -import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { assertNoClientUserIdOverride, 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 }); + 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 }); 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 }); } + catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 400 }); } } diff --git a/app/api/pandora/operator-actions/[id]/dry-run/route.ts b/app/api/pandora/operator-actions/[id]/dry-run/route.ts index 8b2da5d..3ca0201 100644 --- a/app/api/pandora/operator-actions/[id]/dry-run/route.ts +++ b/app/api/pandora/operator-actions/[id]/dry-run/route.ts @@ -1,12 +1,12 @@ import { NextResponse, type NextRequest } from "next/server"; -import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { assertNoClientUserIdOverride, 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 }); + 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 }); 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 }); } + catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 400 }); } } diff --git a/app/api/pandora/operator-actions/[id]/events/route.ts b/app/api/pandora/operator-actions/[id]/events/route.ts new file mode 100644 index 0000000..4ff7808 --- /dev/null +++ b/app/api/pandora/operator-actions/[id]/events/route.ts @@ -0,0 +1,10 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { listOperatorActionEvents, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service"; +export const dynamic = "force-dynamic"; +export async function GET(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 events = await listOperatorActionEvents(supabase as unknown as OperatorActionDbClient, { userId: session.session.userId, actionId: id }); return NextResponse.json({ ok: true, events }); } + 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]/execute/route.ts b/app/api/pandora/operator-actions/[id]/execute/route.ts new file mode 100644 index 0000000..7da31e9 --- /dev/null +++ b/app/api/pandora/operator-actions/[id]/execute/route.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { executeApprovedOperatorAction, 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 }> }) { + 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 }); + try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const action = await executeApprovedOperatorAction(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 must be approved before execution" }, { status: 400 }); } +} diff --git a/components/pandora/OperatorActionCenterCard.tsx b/components/pandora/OperatorActionCenterCard.tsx index cd36e30..8d3bea0 100644 --- a/components/pandora/OperatorActionCenterCard.tsx +++ b/components/pandora/OperatorActionCenterCard.tsx @@ -2,6 +2,8 @@ import type { OperatorActionCenterData } from "./types"; import { OperatorActionComposer } from "./OperatorActionComposer"; import { OperatorActionList } from "./OperatorActionList"; +const emptyCounts = { proposed: 0, dry_ran: 0, approved: 0, executing: 0, completed: 0, blocked: 0, failed: 0, cancelled: 0 }; 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}
; + const countsByStatus = data.countsByStatus ?? emptyCounts; + return

Operator Action Center

Approval-gated read-only runner

Safe operator workflow: proposals, dry-runs, approval, read-only execution, idempotency, audit events, and visible history with zero destructive memory mutation.

Live actions gated
{Object.entries(countsByStatus).map(([status,count]) =>
{count}{status}
)}
{data.warnings.length > 0 ?
{data.warnings.map((warning) =>

⚠ {warning}

)}
: null}
; } diff --git a/components/pandora/OperatorActionControls.tsx b/components/pandora/OperatorActionControls.tsx new file mode 100644 index 0000000..0f2bef5 --- /dev/null +++ b/components/pandora/OperatorActionControls.tsx @@ -0,0 +1,17 @@ +"use client"; +import type { OperatorActionSummary } from "./types"; + +async function postAction(actionId: string, verb: "dry-run" | "approve" | "execute" | "cancel") { + await fetch(`/api/pandora/operator-actions/${actionId}/${verb}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); + window.location.reload(); +} + +export function OperatorActionControls({ action }: { action: OperatorActionSummary }) { + const terminal = ["completed", "failed", "cancelled"].includes(action.status); + return
+ {action.status === "proposed" ? : null} + {(action.status === "proposed" || action.status === "dry_ran") ? : null} + {action.status === "approved" ? : null} + {!terminal && action.status !== "executing" ? : null} +
; +} diff --git a/components/pandora/OperatorActionEnvelope.tsx b/components/pandora/OperatorActionEnvelope.tsx index e638e98..44c21ba 100644 --- a/components/pandora/OperatorActionEnvelope.tsx +++ b/components/pandora/OperatorActionEnvelope.tsx @@ -9,6 +9,9 @@ export function OperatorActionEnvelope({ action }: { action: OperatorActionSumma
{action.request_id}request_id
{action.idempotency_key.slice(0, 12)}…idempotency
{noMutation ? "Yes" : "Pending"}No mutation performed
+
{action.approved_at ?? "Pending"}approved_at
+
{action.completed_at ?? "Pending"}completed_at
+
{action.failed_at ?? "Pending"}failed_at
{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 index 7fbc1ba..4497144 100644 --- a/components/pandora/OperatorActionList.tsx +++ b/components/pandora/OperatorActionList.tsx @@ -1,9 +1,11 @@ import type { OperatorActionSummary } from "./types"; import { OperatorActionEnvelope } from "./OperatorActionEnvelope"; +import { OperatorActionControls } from "./OperatorActionControls"; +import { OperatorActionTimeline } from "./OperatorActionTimeline"; -const colors: Record = { proposed: "slate", dry_ran: "emerald", queued: "blue", blocked: "amber", completed: "emerald", failed: "red", cancelled: "slate" }; +const colors: Record = { proposed: "slate", dry_ran: "emerald", approved: "blue", executing: "amber", 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
)}
; + 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/OperatorActionTimeline.tsx b/components/pandora/OperatorActionTimeline.tsx new file mode 100644 index 0000000..18c000a --- /dev/null +++ b/components/pandora/OperatorActionTimeline.tsx @@ -0,0 +1,5 @@ +import type { OperatorActionEventSummary } from "./types"; +export function OperatorActionTimeline({ events = [] }: { events?: OperatorActionEventSummary[] }) { + if (events.length === 0) return

No event timeline loaded for this action.

; + return
    {events.map((event) =>
  1. {event.event_type}{event.created_at}

    {event.message}

  2. )}
; +} diff --git a/components/pandora/mock-data.ts b/components/pandora/mock-data.ts index f9ccdf7..3fc1aa3 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, operatorActions: { actions: [], warnings: [] } }; +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: [], countsByStatus: { proposed: 0, dry_ran: 0, approved: 0, executing: 0, completed: 0, blocked: 0, failed: 0, cancelled: 0 } } }; diff --git a/components/pandora/types.ts b/components/pandora/types.ts index a35e3ab..0f825e4 100644 --- a/components/pandora/types.ts +++ b/components/pandora/types.ts @@ -142,10 +142,12 @@ export type PandoraVerificationData = { }; -export type OperatorActionStatus = "proposed" | "dry_ran" | "queued" | "blocked" | "completed" | "failed" | "cancelled"; +export type OperatorActionStatus = "proposed" | "dry_ran" | "approved" | "executing" | "completed" | "blocked" | "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 OperatorActionEventSummary = { id: string; action_id: string; user_id: string; event_type: string; message: string; metadata: Record; created_at: string; }; + export type OperatorActionSummary = { id: string; request_id: string; @@ -160,11 +162,17 @@ export type OperatorActionSummary = { warnings: string[]; created_at: string; updated_at: string; + approved_at?: string | null; + completed_at?: string | null; + failed_at?: string | null; + event_count?: number; + event_preview?: OperatorActionEventSummary[]; }; export type OperatorActionCenterData = { actions: OperatorActionSummary[]; warnings: string[]; + countsByStatus: Record; }; export type PandoraDashboardData = { diff --git a/docs/pandora-operator-action-center.md b/docs/pandora-operator-action-center.md index cd0333c..62f3b87 100644 --- a/docs/pandora-operator-action-center.md +++ b/docs/pandora-operator-action-center.md @@ -1,24 +1,21 @@ # 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. +The Pandora Operator Action Center is a production-safe workflow for authenticated operators. It lets the current server-derived Supabase user propose actions, dry-run safe verification work, approve read-only execution, 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`. +- Writes bookkeeping rows only in `pandora_operator_actions`. +- Writes audit/event rows only in `pandora_operator_action_events`. - Lists recent actions for the authenticated user only. -- Supports dry-run envelopes that summarize evidence and missing evidence. +- Supports dry-run and approved read-only execution envelopes. - 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 model calls, embeddings, semantic retrieval enablement, 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 mutation of `memory_events`, `memory_context_packs`, `memory_profiles`, `memory_capture_candidates`, `memory_pruning_candidates`, or other core memory truth tables. - No client-supplied `user_id` trust. ## Allowed action types @@ -31,20 +28,20 @@ The Pandora Operator Action Center is a production-safe workflow foundation for ## 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. +| From | To | +| --- | --- | +| `proposed` | `dry_ran`, `approved`, `cancelled` | +| `dry_ran` | `approved`, `cancelled` | +| `approved` | `executing`, `cancelled` | +| `executing` | `completed`, `failed` | +| `completed`, `failed`, `cancelled` | terminal | -## Why dry-run comes before live actions +Invalid transitions fail safely and do not create misleading completion states. -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. +## Read-only execution -## How idempotency works +Approved actions execute verification loaders only. Results include `no_mutation_performed: true`, request/action identifiers, status, evidence summary, warnings, and execution timestamp. -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. +## Idempotency and events -## 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. +The service hashes the server-derived `userId`, action type, namespace, normalized payload, and mode. The database enforces `unique(user_id, idempotency_key)`. Every proposed, dry-run, approved, executing, completed, failed, and cancelled transition writes an event row. diff --git a/docs/pandora-readonly-action-runner.md b/docs/pandora-readonly-action-runner.md new file mode 100644 index 0000000..c4399ea --- /dev/null +++ b/docs/pandora-readonly-action-runner.md @@ -0,0 +1,63 @@ +# Pandora Read-Only Action Runner + +## Summary + +The read-only action runner upgrades the Operator Action Center from proposals and dry-runs into approval-gated verification execution. It remains non-destructive: execution reads evidence and writes only action bookkeeping and action event rows. + +## Lifecycle states + +| State | Meaning | +| --- | --- | +| `proposed` | Operator created an idempotent safe action proposal. | +| `dry_ran` | Read-only dry-run evidence was generated. | +| `approved` | Operator approved read-only execution. | +| `executing` | Runner started an approved read-only verification routine. | +| `completed` | Runner stored a real read-only result. | +| `failed` | Runner stored loader failure warnings without fabricating success. | +| `cancelled` | Operator cancelled before terminal execution. | +| `blocked` | Reserved safety state for blocked workflows. | + +## State transitions + +`proposed -> dry_ran -> approved -> executing -> completed` + +Additional allowed transitions: `proposed -> approved`, `proposed/dry_ran/approved -> cancelled`, and `executing -> failed`. Terminal states are immutable by the runner. + +## Allowed read-only executions + +- `verify_namespace_invariants`: summarizes namespace invariant verification. +- `verify_pack_supersession`: summarizes pack supersession verification. +- `check_retrieval_eval_status`: reports real eval/log evidence if present, otherwise unavailable/not run without fabricated accuracy. +- `refresh_dashboard_snapshot`: stores compact dashboard counts and warnings. +- `prepare_distill_smoke_plan`: prepares a plan only and never calls distill routes or mutates packs. + +## Safety boundary + +Live mutation remains gated. The runner does not call models, create embeddings, run semantic retrieval, distill, prune, merge, delete, or rewrite profiles. It does not insert, update, or delete core memory truth tables. + +## `no_mutation_performed` + +Completed and failed execution envelopes include `no_mutation_performed: true`. This means the runner wrote only `pandora_operator_actions` and `pandora_operator_action_events` rows and performed read-only checks against memory data. + +## Idempotency + +Proposal idempotency is deterministic per server-derived user id, action type, namespace, mode, and normalized payload. A repeated proposal returns the existing action row. + +## Event history + +Each state transition creates an event row with user scope, action id, event type, message, metadata, and timestamp. Dashboard cards show event previews and counts. + +## Manual smoke-test checklist + +1. Open `/pandora` while authenticated. +2. Propose each allowed action type for one namespace at a time. +3. Prepare a dry-run and verify warnings are visible. +4. Approve the action. +5. Execute approved read-only action. +6. Confirm the result shows `no_mutation_performed: true`. +7. Confirm no core memory rows changed. +8. Confirm cross-namespace data is not mixed. + +## Future path to human-approved live actions + +Any future live action needs a separate reviewed PR, protected dry-run output, explicit human approval, production readiness checks, database verification, and post-run audit evidence. This runner is not that live-mutation path. diff --git a/lib/services/pandora-dashboard-service.ts b/lib/services/pandora-dashboard-service.ts index ff14f33..39f1798 100644 --- a/lib/services/pandora-dashboard-service.ts +++ b/lib/services/pandora-dashboard-service.ts @@ -39,7 +39,21 @@ 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 actionWarnings: string[] = []; const operatorActions = await listOperatorActions(client, { userId: input.userId, limit: 10 }); + const actionEvents = await Promise.all(operatorActions.map(async (action) => { + try { + const result = await client.from("pandora_operator_action_events").select("*").eq("user_id", input.userId).eq("action_id", action.id).order("created_at", { ascending: false }).limit(3); + if (result.error) { actionWarnings.push(`operator action events unavailable for ${action.id}; showing action without timeline.`); return { actionId: action.id, events: [] as Row[] }; } + return { actionId: action.id, events: Array.isArray(result.data) ? result.data : [] }; + } catch { + actionWarnings.push(`operator action events unavailable for ${action.id}; showing action without timeline.`); + return { actionId: action.id, events: [] as Row[] }; + } + })); + const eventsByAction = new Map(actionEvents.map((item) => [item.actionId, item.events])); + const actionStatuses = ["proposed", "dry_ran", "approved", "executing", "completed", "blocked", "failed", "cancelled"] as const; + const countsByStatus = Object.fromEntries(actionStatuses.map((status) => [status, operatorActions.filter((action) => action.status === status).length])) as PandoraDashboardData["operatorActions"]["countsByStatus"]; const data = await Promise.all(namespaces.map(async (namespace) => ({ namespace, events: await rows(client, "memory_events", input.userId, namespace, warnings, 500), @@ -87,6 +101,9 @@ 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 ?? []) }, + operatorActions: { actions: operatorActions.map((action) => { + const eventPreview = eventsByAction.get(action.id) ?? []; + return { 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, approved_at: action.approved_at, completed_at: action.completed_at, failed_at: action.failed_at, event_count: eventPreview.length, event_preview: eventPreview.map((event: Row) => ({ id: String(event.id), action_id: String(event.action_id), user_id: String(event.user_id), event_type: String(event.event_type), message: String(event.message), metadata: event.metadata ?? {}, created_at: String(event.created_at) })) }; + }), warnings: [...actionWarnings, ...operatorActions.flatMap((action) => action.warnings ?? [])], countsByStatus }, }; } diff --git a/lib/services/pandora-operator-action-service.ts b/lib/services/pandora-operator-action-service.ts index 85674ae..d4ada23 100644 --- a/lib/services/pandora-operator-action-service.ts +++ b/lib/services/pandora-operator-action-service.ts @@ -6,7 +6,7 @@ import { loadPandoraVerificationData } from "@/lib/services/pandora-verification 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 OperatorActionStatus = "proposed" | "dry_ran" | "approved" | "executing" | "completed" | "blocked" | "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 }; @@ -21,7 +21,9 @@ function assertNamespace(namespace?: string | null): asserts namespace is Pandor 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 }; } +function envelope(action: OperatorActionRow, result: Record, warnings: string[]) { return { ok: warnings.length === 0, request_id: action.request_id, action_id: action.id, action_type: action.action_type, status: action.status, evidence_summary: result, warnings, no_mutation_performed: true, executed_at: new Date().toISOString() }; } +const TERMINAL = new Set(["completed", "failed", "cancelled"]); +function assertTransition(from: OperatorActionStatus, to: OperatorActionStatus) { const allowed: Record = { proposed: ["dry_ran", "approved", "cancelled"], dry_ran: ["approved", "cancelled"], approved: ["executing", "cancelled"], executing: ["completed", "failed"], blocked: [], completed: [], failed: [], cancelled: [] }; if (!allowed[from]?.includes(to)) throw new Error(`Invalid Pandora operator action transition: ${from} -> ${to}`); } 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 { @@ -30,6 +32,19 @@ export async function listOperatorActions(client: OperatorActionDbClient, input: return Array.isArray(result.data) ? result.data : []; } +export async function getOperatorAction(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"); + return action; +} + +export async function listOperatorActionEvents(client: OperatorActionDbClient, input: { userId: string; actionId: string }): Promise { + await getOperatorAction(client, input); + const result = await client.from("pandora_operator_action_events").select("*").eq("user_id", input.userId).eq("action_id", input.actionId).order("created_at", { ascending: true }); + 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()); @@ -41,7 +56,7 @@ export async function proposeOperatorAction(client: OperatorActionDbClient, inpu 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 row = { id: randomUUID(), user_id: input.userId, request_id: requestId, idempotency_key: idempotencyKey, action_type: input.actionType, namespace: input.namespace ?? null, mode, status: "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 } }); @@ -60,19 +75,56 @@ async function buildDryRunResult(client: OperatorActionDbClient, userId: string, } 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 action = await getOperatorAction(client, input); + assertTransition(action.status, "dry_ran"); const built = await buildDryRunResult(client, input.userId, action); - const status: OperatorActionStatus = built.warnings.length ? "blocked" : "dry_ran"; + const status: OperatorActionStatus = "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 approveOperatorAction(client: OperatorActionDbClient, input: { userId: string; actionId: string }): Promise { + const action = await getOperatorAction(client, input); + assertTransition(action.status, "approved"); + assertAction(action.action_type); + assertMode(action.mode); + const now = new Date().toISOString(); + const updated = await single(client.from("pandora_operator_actions").update({ status: "approved", approved_at: now, updated_at: now }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "approved", message: "Operator approved safe read-only execution. No live memory mutation was authorized.", metadata: { no_mutation_performed: true } }); + return updated ?? { ...action, status: "approved", approved_at: now, updated_at: now }; +} + +export async function executeApprovedOperatorAction(client: OperatorActionDbClient, input: { userId: string; actionId: string }): Promise { + const action = await getOperatorAction(client, input); + assertTransition(action.status, "executing"); + assertAction(action.action_type); + assertMode(action.mode); + const executingAt = new Date().toISOString(); + const executing = await single(client.from("pandora_operator_actions").update({ status: "executing", updated_at: executingAt }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()) ?? { ...action, status: "executing" as const, updated_at: executingAt }; + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "executing", message: "Approved read-only verification execution started.", metadata: { no_mutation_performed: true } }); + try { + const built = await buildDryRunResult(client, input.userId, executing); + const completedAt = new Date().toISOString(); + const result = envelope({ ...executing, status: "completed" }, built.result, built.warnings); + const completed = await single(client.from("pandora_operator_actions").update({ status: "completed", result, warnings: built.warnings, completed_at: completedAt, updated_at: completedAt }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "completed", message: "Approved read-only execution completed. No core memory mutation was performed.", metadata: result }); + return completed ?? { ...executing, status: "completed", result, warnings: built.warnings, completed_at: completedAt, updated_at: completedAt }; + } catch (error) { + const failedAt = new Date().toISOString(); + const warnings = [error instanceof Error ? error.message : "Read-only execution failed"]; + const result = envelope({ ...executing, status: "failed" }, { error: warnings[0] }, warnings); + const failed = await single(client.from("pandora_operator_actions").update({ status: "failed", result, warnings, failed_at: failedAt, updated_at: failedAt }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "failed", message: "Approved read-only execution failed; no completion was fabricated.", metadata: result }); + return failed ?? { ...executing, status: "failed", result, warnings, failed_at: failedAt, updated_at: failedAt }; + } +} + 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 action = await getOperatorAction(client, input); + if (TERMINAL.has(action.status)) throw new Error(`Cannot cancel terminal Pandora operator action: ${action.status}`); + assertTransition(action.status, "cancelled"); 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/20260703010000_pandora_operator_action_runner_statuses.sql b/supabase/migrations/20260703010000_pandora_operator_action_runner_statuses.sql new file mode 100644 index 0000000..0443dae --- /dev/null +++ b/supabase/migrations/20260703010000_pandora_operator_action_runner_statuses.sql @@ -0,0 +1,6 @@ +alter table public.pandora_operator_actions + drop constraint if exists pandora_operator_actions_status_check; + +alter table public.pandora_operator_actions + add constraint pandora_operator_actions_status_check + check (status in ('proposed','dry_ran','approved','executing','completed','blocked','failed','cancelled')); diff --git a/tests/unit/pandora-operator-action-guards.test.ts b/tests/unit/pandora-operator-action-guards.test.ts index 16d336b..97e1aea 100644 --- a/tests/unit/pandora-operator-action-guards.test.ts +++ b/tests/unit/pandora-operator-action-guards.test.ts @@ -1,10 +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"]; +const files = ["components/pandora/OperatorActionCenterCard.tsx", "components/pandora/OperatorActionComposer.tsx", "components/pandora/OperatorActionList.tsx", "components/pandora/OperatorActionEnvelope.tsx", "components/pandora/OperatorActionControls.tsx", "components/pandora/OperatorActionTimeline.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("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"); expect(text).not.toContain(".delete("); for (const table of ["memory_events","memory_context_packs","memory_profiles","memory_capture_candidates","memory_pruning_candidates"]) 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"); }); + 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"); expect(text).not.toContain("retrieval accuracy: 100%"); }); }); diff --git a/tests/unit/pandora-operator-action-service.test.ts b/tests/unit/pandora-operator-action-service.test.ts index 719635f..9cdc6e3 100644 --- a/tests/unit/pandora-operator-action-service.test.ts +++ b/tests/unit/pandora-operator-action-service.test.ts @@ -1,6 +1,6 @@ /* 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"; +import { approveOperatorAction, cancelOperatorAction, dryRunOperatorAction, executeApprovedOperatorAction, listOperatorActionEvents, listOperatorActions, proposeOperatorAction } from "@/lib/services/pandora-operator-action-service"; const USER = "11111111-1111-4111-8111-111111111111"; const OTHER = "22222222-2222-4222-8222-222222222222"; @@ -15,4 +15,7 @@ describe("pandora operator action service", () => { 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"); }); + it("approves proposed and dry_ran actions, then executes approved read-only action", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"verify_pack_supersession", namespace:"real_life"}); const approved=await approveOperatorAction(c,{userId:USER, actionId:a.id}); expect(approved.status).toBe("approved"); const completed=await executeApprovedOperatorAction(c,{userId:USER, actionId:a.id}); expect(completed.status).toBe("completed"); expect(completed.result.no_mutation_performed).toBe(true); expect(JSON.stringify(completed.result)).toContain("pack_supersession"); expect(s.memory_context_packs).toHaveLength(2); }); + it("blocks invalid transitions and cross-user events", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"refresh_dashboard_snapshot"}); await expect(executeApprovedOperatorAction(c,{userId:USER, actionId:a.id})).rejects.toThrow(/Invalid/); await expect(approveOperatorAction(c,{userId:OTHER, actionId:a.id})).rejects.toThrow(/not found/); await expect(listOperatorActionEvents(c,{userId:OTHER, actionId:a.id})).rejects.toThrow(/not found/); const cancelled=await cancelOperatorAction(c,{userId:USER, actionId:a.id}); await expect(cancelOperatorAction(c,{userId:USER, actionId:cancelled.id})).rejects.toThrow(/terminal/); }); + it("check_retrieval_eval_status reports unavailable evidence without fabricated accuracy", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"check_retrieval_eval_status"}); await approveOperatorAction(c,{userId:USER, actionId:a.id}); const completed=await executeApprovedOperatorAction(c,{userId:USER, actionId:a.id}); expect(JSON.stringify(completed.result)).toContain("retrieval_eval_status"); expect(JSON.stringify(completed.result)).not.toContain("94.3"); }); }); diff --git a/tests/unit/pandora-operator-action-ui.test.tsx b/tests/unit/pandora-operator-action-ui.test.tsx index 53e9edd..3a0c731 100644 --- a/tests/unit/pandora-operator-action-ui.test.tsx +++ b/tests/unit/pandora-operator-action-ui.test.tsx @@ -5,10 +5,11 @@ import { OperatorActionCenterCard } from "@/components/pandora/OperatorActionCen 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" }; +const countsByStatus = { proposed:1, dry_ran:0, approved:0, executing:0, completed:0, blocked:0, failed:0, cancelled:0 }; +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", event_preview:[{id:"e1", action_id:"a1", user_id:"u1", event_type:"proposed", message:"created", metadata:{}, created_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("renders empty state", () => { const html=renderToStaticMarkup(); expect(html).toContain("No operator actions yet"); expect(html).toContain("Prepare dry-run"); }); + it("renders approved/executing/completed/failed/cancelled statuses", () => { const actions=["approved","executing","completed","failed","cancelled"].map((status,i)=>({...base,id:String(i),status: status as OperatorActionStatus})); const html=renderToStaticMarkup(); for (const s of ["approved","executing","completed","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); }); + 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); }); });