-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add Pandora read-only action runner #135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }); } | ||
| } |
| 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 }); } | ||
| } |
| 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 }); } | ||
| } |
| 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 }); } | ||
| } | ||
| 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 }); } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return
🤖 Prompt for AI Agents |
||
| catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Action must be approved before execution" }, { status: 400 }); } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.tsRepository: 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 🤖 Prompt for AI Agents |
||
| </div>; | ||
| } | ||
| 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>; | ||
| } |
| 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>; | ||
| } |
There was a problem hiding this comment.
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 to400. 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 as500.🤖 Prompt for AI Agents