Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/api/pandora/operator-actions/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
}
12 changes: 12 additions & 0 deletions app/api/pandora/operator-actions/[id]/dry-run/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
}
31 changes: 31 additions & 0 deletions app/api/pandora/operator-actions/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> : {};
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<string, unknown> : {} });
return NextResponse.json({ ok: true, action });
} catch (error) {
return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Invalid operator action" }, { status: 400 });
}
}
7 changes: 7 additions & 0 deletions components/pandora/OperatorActionCenterCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { OperatorActionCenterData } from "./types";
import { OperatorActionComposer } from "./OperatorActionComposer";
import { OperatorActionList } from "./OperatorActionList";

export function OperatorActionCenterCard({ data }: { data: OperatorActionCenterData }) {
return <section className="pd-card"><div className="pd-section-head"><div><p className="pd-label">Operator Action Center</p><h3>Controlled proposals and dry-runs</h3><p>Safe operator workflow foundation: action proposals, idempotency, audit events, and visible history with zero destructive memory mutation.</p></div><span className="pd-pill pd-pill-amber">Live actions gated</span></div><OperatorActionComposer /><OperatorActionList actions={data.actions} />{data.warnings.length > 0 ? <div className="pd-warning-list">{data.warnings.map((warning) => <p key={warning}>⚠ {warning}</p>)}</div> : null}</section>;
}
9 changes: 9 additions & 0 deletions components/pandora/OperatorActionComposer.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="pd-composer"><div className="pd-mini-grid"><label className="pd-mini"><span>Action type</span><select value={actionType} onChange={(e) => setActionType(e.target.value)}><option value="verify_namespace_invariants">verify_namespace_invariants</option><option value="verify_pack_supersession">verify_pack_supersession</option><option value="check_retrieval_eval_status">check_retrieval_eval_status</option><option value="refresh_dashboard_snapshot">refresh_dashboard_snapshot</option><option value="prepare_distill_smoke_plan">prepare_distill_smoke_plan</option></select></label><label className="pd-mini"><span>Namespace</span><select value={namespace} onChange={(e) => setNamespace(e.target.value)}><option value="real_life">real_life</option><option value="au">au</option></select></label></div><button className="button-link button-link--primary" type="button" onClick={prepare}>Prepare dry-run</button><p className="pd-muted">Only dry-run or queued-only proposals are available. No core memory mutation is available from this card.</p></div>;
}
16 changes: 16 additions & 0 deletions components/pandora/OperatorActionEnvelope.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="pd-evidence-box">
<div className="pd-mini-grid">
<div className="pd-mini"><strong>{action.request_id}</strong><span>request_id</span></div>
<div className="pd-mini"><strong>{action.idempotency_key.slice(0, 12)}…</strong><span>idempotency</span></div>
<div className="pd-mini"><strong>{noMutation ? "Yes" : "Pending"}</strong><span>No mutation performed</span></div>
</div>
{action.warnings.length > 0 ? <div className="pd-warning-list">{action.warnings.map((warning) => <p key={warning}>⚠ {warning}</p>)}</div> : <p className="pd-muted">No warnings recorded for this action.</p>}
</div>
);
}
9 changes: 9 additions & 0 deletions components/pandora/OperatorActionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { OperatorActionSummary } from "./types";
import { OperatorActionEnvelope } from "./OperatorActionEnvelope";

const colors: Record<string, string> = { 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 <div className="pd-empty"><strong>No operator actions yet.</strong><span>Prepare a safe dry-run proposal to create action history.</span></div>;
return <div className="pd-list">{actions.map((action) => <article className="pd-list-item" key={action.id}><div className="pd-section-head"><div><p className="pd-label">{action.action_type}</p><h4>{action.title}</h4><p>{action.description}</p></div><span className={`pd-pill pd-pill-${colors[action.status] ?? "slate"}`}>{action.status}</span></div><div className="pd-mini-grid"><div className="pd-mini"><strong>{action.namespace ?? "global"}</strong><span>namespace</span></div><div className="pd-mini"><strong>{action.mode}</strong><span>mode</span></div><div className="pd-mini"><strong>{action.created_at}</strong><span>created</span></div><div className="pd-mini"><strong>{action.updated_at}</strong><span>updated</span></div></div><OperatorActionEnvelope action={action} /></article>)}</div>;
}
2 changes: 2 additions & 0 deletions components/pandora/PandoraDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,6 +44,7 @@ export function PandoraDashboard({ dashboardData }: { dashboardData: PandoraDash
{stats.map((stat) => <StatCard stat={stat} key={stat.id} />)}
</section>
<VerificationConsoleCard verification={dashboardData.verification} />
<OperatorActionCenterCard data={dashboardData.operatorActions} />
<div className="pd-dashboard-grid">
<div className="pd-dashboard-col pd-dashboard-col-wide">
<MemorySpacesCard spaces={dashboardData.memorySpaces} />
Expand Down
2 changes: 1 addition & 1 deletion components/pandora/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] } };

27 changes: 27 additions & 0 deletions components/pandora/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
warnings: string[];
created_at: string;
updated_at: string;
};

export type OperatorActionCenterData = {
actions: OperatorActionSummary[];
warnings: string[];
};

export type PandoraDashboardData = {
generatedAt: string;
operatorLabel: string;
Expand All @@ -167,4 +193,5 @@ export type PandoraDashboardData = {
};
};
verification: PandoraVerificationData;
operatorActions: OperatorActionCenterData;
};
50 changes: 50 additions & 0 deletions docs/pandora-operator-action-center.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions lib/services/pandora-dashboard-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -38,6 +39,7 @@ function eventSummary(event: Row) {
export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, input: { userId: string; operatorLabel?: string }): Promise<PandoraDashboardData> {
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),
Expand Down Expand Up @@ -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 ?? []) },
};
}
Loading
Loading