Skip to content

Commit 87857c9

Browse files
authored
Add Pandora read-only runner
Merge read-only action runner.
1 parent d5482a1 commit 87857c9

20 files changed

Lines changed: 263 additions & 53 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
3+
import { createSupabaseServerClient } from "@/lib/supabase/server";
4+
import { approveOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service";
5+
export const dynamic = "force-dynamic";
6+
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
7+
let body: unknown; try { body = await request.json(); } catch { body = {}; }
8+
const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok: false, blockers: rejected.blockers }, { status: 400 });
9+
const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
10+
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 }); }
11+
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action cannot be approved" }, { status: 400 }); }
12+
}
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { NextResponse, type NextRequest } from "next/server";
2-
import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
2+
import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
33
import { createSupabaseServerClient } from "@/lib/supabase/server";
44
import { cancelOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service";
5-
65
export const dynamic = "force-dynamic";
76
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
8-
const session = await resolvePandoraServerSession({ request });
9-
if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
7+
let body: unknown; try { body = await request.json(); } catch { body = {}; }
8+
const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok: false, blockers: rejected.blockers }, { status: 400 });
9+
const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
1010
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 }); }
11-
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 404 }); }
11+
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 400 }); }
1212
}
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { NextResponse, type NextRequest } from "next/server";
2-
import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
2+
import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
33
import { createSupabaseServerClient } from "@/lib/supabase/server";
44
import { dryRunOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service";
5-
65
export const dynamic = "force-dynamic";
76
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
8-
const session = await resolvePandoraServerSession({ request });
9-
if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
7+
let body: unknown; try { body = await request.json(); } catch { body = {}; }
8+
const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok: false, blockers: rejected.blockers }, { status: 400 });
9+
const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
1010
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 }); }
11-
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 404 }); }
11+
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 400 }); }
1212
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
3+
import { createSupabaseServerClient } from "@/lib/supabase/server";
4+
import { listOperatorActionEvents, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service";
5+
export const dynamic = "force-dynamic";
6+
export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) {
7+
const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
8+
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 }); }
9+
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action not found" }, { status: 404 }); }
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver";
3+
import { createSupabaseServerClient } from "@/lib/supabase/server";
4+
import { executeApprovedOperatorAction, type OperatorActionDbClient } from "@/lib/services/pandora-operator-action-service";
5+
export const dynamic = "force-dynamic";
6+
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
7+
let body: unknown; try { body = await request.json(); } catch { body = {}; }
8+
const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok: false, blockers: rejected.blockers }, { status: 400 });
9+
const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok: false, blockers: session.blockers }, { status: 401 });
10+
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 }); }
11+
catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action must be approved before execution" }, { status: 400 }); }
12+
}

components/pandora/OperatorActionCenterCard.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { OperatorActionCenterData } from "./types";
22
import { OperatorActionComposer } from "./OperatorActionComposer";
33
import { OperatorActionList } from "./OperatorActionList";
44

5+
const emptyCounts = { proposed: 0, dry_ran: 0, approved: 0, executing: 0, completed: 0, blocked: 0, failed: 0, cancelled: 0 };
56
export function OperatorActionCenterCard({ data }: { data: OperatorActionCenterData }) {
6-
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>;
7+
const countsByStatus = data.countsByStatus ?? emptyCounts;
8+
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>;
79
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use client";
2+
import type { OperatorActionSummary } from "./types";
3+
4+
async function postAction(actionId: string, verb: "dry-run" | "approve" | "execute" | "cancel") {
5+
await fetch(`/api/pandora/operator-actions/${actionId}/${verb}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
6+
window.location.reload();
7+
}
8+
9+
export function OperatorActionControls({ action }: { action: OperatorActionSummary }) {
10+
const terminal = ["completed", "failed", "cancelled"].includes(action.status);
11+
return <div className="pd-action-controls">
12+
{action.status === "proposed" ? <button className="button-link" type="button" onClick={() => postAction(action.id, "dry-run")}>Prepare dry-run</button> : null}
13+
{(action.status === "proposed" || action.status === "dry_ran") ? <button className="button-link button-link--primary" type="button" onClick={() => postAction(action.id, "approve")}>Approve</button> : null}
14+
{action.status === "approved" ? <button className="button-link button-link--primary" type="button" onClick={() => postAction(action.id, "execute")}>Execute approved read-only action</button> : null}
15+
{!terminal && action.status !== "executing" ? <button className="button-link" type="button" onClick={() => postAction(action.id, "cancel")}>Cancel</button> : null}
16+
</div>;
17+
}

components/pandora/OperatorActionEnvelope.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export function OperatorActionEnvelope({ action }: { action: OperatorActionSumma
99
<div className="pd-mini"><strong>{action.request_id}</strong><span>request_id</span></div>
1010
<div className="pd-mini"><strong>{action.idempotency_key.slice(0, 12)}</strong><span>idempotency</span></div>
1111
<div className="pd-mini"><strong>{noMutation ? "Yes" : "Pending"}</strong><span>No mutation performed</span></div>
12+
<div className="pd-mini"><strong>{action.approved_at ?? "Pending"}</strong><span>approved_at</span></div>
13+
<div className="pd-mini"><strong>{action.completed_at ?? "Pending"}</strong><span>completed_at</span></div>
14+
<div className="pd-mini"><strong>{action.failed_at ?? "Pending"}</strong><span>failed_at</span></div>
1215
</div>
1316
{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>}
1417
</div>
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { OperatorActionSummary } from "./types";
22
import { OperatorActionEnvelope } from "./OperatorActionEnvelope";
3+
import { OperatorActionControls } from "./OperatorActionControls";
4+
import { OperatorActionTimeline } from "./OperatorActionTimeline";
35

4-
const colors: Record<string, string> = { proposed: "slate", dry_ran: "emerald", queued: "blue", blocked: "amber", completed: "emerald", failed: "red", cancelled: "slate" };
6+
const colors: Record<string, string> = { proposed: "slate", dry_ran: "emerald", approved: "blue", executing: "amber", blocked: "amber", completed: "emerald", failed: "red", cancelled: "slate" };
57

68
export function OperatorActionList({ actions }: { actions: OperatorActionSummary[] }) {
79
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>;
8-
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>;
10+
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>;
911
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { OperatorActionEventSummary } from "./types";
2+
export function OperatorActionTimeline({ events = [] }: { events?: OperatorActionEventSummary[] }) {
3+
if (events.length === 0) return <p className="pd-muted">No event timeline loaded for this action.</p>;
4+
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>;
5+
}

0 commit comments

Comments
 (0)