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]/approve/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
}
10 changes: 5 additions & 5 deletions app/api/pandora/operator-actions/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
}
10 changes: 5 additions & 5 deletions app/api/pandora/operator-actions/[id]/dry-run/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
}
10 changes: 10 additions & 0 deletions app/api/pandora/operator-actions/[id]/events/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

404 catch-all also masks server errors and diverges from sibling routes.

Any failure here (including a Supabase/client construction fault) returns 404, while approve/execute/cancel/dry-run map the same not-found condition to 400. Beyond hiding 5xx faults, the inconsistent not-found status across the operator-action routes will complicate client handling. Align the not-found status and let genuine infra failures surface as 500.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/pandora/operator-actions/`[id]/events/route.ts at line 9, The
catch-all in the events route is masking real server failures by always
returning a 404, and it also disagrees with the other operator-action handlers.
Update the `catch` block in the `events` route so only the expected not-found
case maps to the same status used by sibling routes like the
approve/execute/cancel/dry-run handlers, and let unexpected errors from
`NextResponse.json` or the Supabase/client setup surface as a 500 instead. Keep
the response shape consistent while distinguishing missing actions from genuine
infrastructure errors.

}
12 changes: 12 additions & 0 deletions app/api/pandora/operator-actions/[id]/execute/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return ok: false for failed executions

executeApprovedOperatorAction can return a failed action on internal errors, but this route still responds with HTTP 200 { ok: true, ... }. Callers will treat failures as success unless they inspect action.status; derive ok from the terminal status or map failed executions to a non-200 response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/pandora/operator-actions/`[id]/execute/route.ts at line 10, The
execute route in the operator-actions handler always returns ok: true from the
NextResponse.json call even when executeApprovedOperatorAction produces a failed
action. Update the route logic around executeApprovedOperatorAction and the
returned action.result so ok is derived from the terminal action.status, or
return a non-200 response when the action ends in failed, while keeping the
success path unchanged.

catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action must be approved before execution" }, { status: 400 }); }
}
4 changes: 3 additions & 1 deletion components/pandora/OperatorActionCenterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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>;
const countsByStatus = data.countsByStatus ?? emptyCounts;
return <section className="pd-card"><div className="pd-section-head"><div><p className="pd-label">Operator Action Center</p><h3>Approval-gated read-only runner</h3><p>Safe operator workflow: proposals, dry-runs, approval, read-only execution, 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><div className="pd-mini-grid">{Object.entries(countsByStatus).map(([status,count]) => <div className="pd-mini" key={status}><strong>{count}</strong><span>{status}</span></div>)}</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>;
}
17 changes: 17 additions & 0 deletions components/pandora/OperatorActionControls.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="pd-action-controls">
{action.status === "proposed" ? <button className="button-link" type="button" onClick={() => postAction(action.id, "dry-run")}>Prepare dry-run</button> : null}
{(action.status === "proposed" || action.status === "dry_ran") ? <button className="button-link button-link--primary" type="button" onClick={() => postAction(action.id, "approve")}>Approve</button> : null}
{action.status === "approved" ? <button className="button-link button-link--primary" type="button" onClick={() => postAction(action.id, "execute")}>Execute approved read-only action</button> : null}
{!terminal && action.status !== "executing" ? <button className="button-link" type="button" onClick={() => postAction(action.id, "cancel")}>Cancel</button> : null}
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B2 -A6 'TERMINAL' lib/services/pandora-operator-action-service.ts

Repository: besfeng23/Memory

Length of output: 2683


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the frontend component and any status handling around it.
ast-grep outline components/pandora/OperatorActionControls.tsx --view expanded
printf '\n---\n'
rg -n -A3 -B3 'postAction|blocked|dry_ran|executing|cancel' components/pandora/OperatorActionControls.tsx components/pandora -g '!**/node_modules/**'
printf '\n---\n'
# Inspect service-side action status handling and any relevant transitions.
sed -n '1,180p' lib/services/pandora-operator-action-service.ts
printf '\n---\n'
rg -n -A3 -B3 'Cannot cancel terminal Pandora operator action|Invalid Pandora operator action transition|blocked' lib/services/pandora-operator-action-service.ts lib/services -g '!**/node_modules/**'

Repository: besfeng23/Memory

Length of output: 50373


Exclude blocked from the Cancel affordance. cancelOperatorAction() rejects blocked via assertTransition(..., "cancelled"), but this UI still renders Cancel because terminal omits it. That leaves a dead button for blocked actions, and the fetch path doesn’t surface the failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/pandora/OperatorActionControls.tsx` around lines 10 - 15, The
Cancel button in OperatorActionControls is still shown for blocked actions, even
though cancelOperatorAction() cannot transition blocked to cancelled. Update the
terminal/status gating in OperatorActionControls so blocked is treated like a
non-cancelable state, and only render the Cancel affordance for statuses that
can actually be cancelled; keep the logic aligned with postAction and
cancelOperatorAction so the UI never exposes a dead action.

</div>;
}
3 changes: 3 additions & 0 deletions components/pandora/OperatorActionEnvelope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export function OperatorActionEnvelope({ action }: { action: OperatorActionSumma
<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 className="pd-mini"><strong>{action.approved_at ?? "Pending"}</strong><span>approved_at</span></div>
<div className="pd-mini"><strong>{action.completed_at ?? "Pending"}</strong><span>completed_at</span></div>
<div className="pd-mini"><strong>{action.failed_at ?? "Pending"}</strong><span>failed_at</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>
Expand Down
6 changes: 4 additions & 2 deletions components/pandora/OperatorActionList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { OperatorActionSummary } from "./types";
import { OperatorActionEnvelope } from "./OperatorActionEnvelope";
import { OperatorActionControls } from "./OperatorActionControls";
import { OperatorActionTimeline } from "./OperatorActionTimeline";

const colors: Record<string, string> = { proposed: "slate", dry_ran: "emerald", queued: "blue", blocked: "amber", completed: "emerald", failed: "red", cancelled: "slate" };
const colors: Record<string, string> = { 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 <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>;
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} /><OperatorActionTimeline events={action.event_preview} /><OperatorActionControls action={action} /></article>)}</div>;
}
5 changes: 5 additions & 0 deletions components/pandora/OperatorActionTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { OperatorActionEventSummary } from "./types";
export function OperatorActionTimeline({ events = [] }: { events?: OperatorActionEventSummary[] }) {
if (events.length === 0) return <p className="pd-muted">No event timeline loaded for this action.</p>;
return <ol className="pd-timeline-list">{events.map((event) => <li key={event.id}><strong>{event.event_type}</strong><span>{event.created_at}</span><p>{event.message}</p></li>)}</ol>;
}
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, 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 } } };

10 changes: 9 additions & 1 deletion components/pandora/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; created_at: string; };

export type OperatorActionSummary = {
id: string;
request_id: string;
Expand All @@ -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<OperatorActionStatus, number>;
};

export type PandoraDashboardData = {
Expand Down
Loading
Loading