From 5f2bfbb6b8b630058744c1490ac7c699a9f1af29 Mon Sep 17 00:00:00 2001 From: besfeng23 <136943241+besfeng23@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:32:39 +0800 Subject: [PATCH] Add Pandora promotion request board --- .../promotion-requests/[id]/archive/route.ts | 6 ++ .../promotion-requests/[id]/review/route.ts | 6 ++ .../pandora/promotion-requests/[id]/route.ts | 6 ++ .../promotion-requests/[id]/submit/route.ts | 6 ++ app/api/pandora/promotion-requests/route.ts | 6 ++ .../[id]/promotion-request/route.ts | 6 ++ components/pandora/OperatorActionComposer.tsx | 2 +- components/pandora/PandoraDashboard.tsx | 2 + components/pandora/PromotionPlanViewer.tsx | 2 + .../pandora/PromotionRequestBoardCard.tsx | 4 ++ .../pandora/PromotionRequestControls.tsx | 4 ++ .../pandora/PromotionRequestCreateButton.tsx | 2 + components/pandora/PromotionRequestList.tsx | 7 ++ .../pandora/PromotionRequestReviewForm.tsx | 4 ++ .../pandora/PromotionRequestRiskSnapshot.tsx | 2 + components/pandora/RollbackPlanViewer.tsx | 2 + .../pandora/ShadowPackPreflightList.tsx | 3 +- components/pandora/mock-data.ts | 2 +- components/pandora/types.ts | 10 ++- docs/pandora-operator-action-center.md | 4 ++ docs/pandora-promotion-request-board.md | 48 +++++++++++++ docs/pandora-readonly-action-runner.md | 4 ++ docs/pandora-shadow-context-pack-lab.md | 4 ++ docs/pandora-shadow-pack-preflight.md | 4 ++ lib/services/pandora-dashboard-service.ts | 10 ++- .../pandora-operator-action-service.ts | 18 ++++- .../pandora-promotion-request-service.ts | 30 ++++++++ ...020000_pandora_promotion_request_board.sql | 71 +++++++++++++++++++ .../pandora-operator-action-guards.test.ts | 11 +-- .../pandora-promotion-request-service.test.ts | 12 ++++ 30 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 app/api/pandora/promotion-requests/[id]/archive/route.ts create mode 100644 app/api/pandora/promotion-requests/[id]/review/route.ts create mode 100644 app/api/pandora/promotion-requests/[id]/route.ts create mode 100644 app/api/pandora/promotion-requests/[id]/submit/route.ts create mode 100644 app/api/pandora/promotion-requests/route.ts create mode 100644 app/api/pandora/shadow-pack-preflights/[id]/promotion-request/route.ts create mode 100644 components/pandora/PromotionPlanViewer.tsx create mode 100644 components/pandora/PromotionRequestBoardCard.tsx create mode 100644 components/pandora/PromotionRequestControls.tsx create mode 100644 components/pandora/PromotionRequestCreateButton.tsx create mode 100644 components/pandora/PromotionRequestList.tsx create mode 100644 components/pandora/PromotionRequestReviewForm.tsx create mode 100644 components/pandora/PromotionRequestRiskSnapshot.tsx create mode 100644 components/pandora/RollbackPlanViewer.tsx create mode 100644 docs/pandora-promotion-request-board.md create mode 100644 lib/services/pandora-promotion-request-service.ts create mode 100644 supabase/migrations/20260704020000_pandora_promotion_request_board.sql create mode 100644 tests/unit/pandora-promotion-request-service.test.ts diff --git a/app/api/pandora/promotion-requests/[id]/archive/route.ts b/app/api/pandora/promotion-requests/[id]/archive/route.ts new file mode 100644 index 0000000..37c5b72 --- /dev/null +++ b/app/api/pandora/promotion-requests/[id]/archive/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { archivePromotionRequest, type PromotionRequestDbClient } from "@/lib/services/pandora-promotion-request-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 }); const input = body && typeof body === "object" ? body as Record : {}; try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const promotionRequest = await archivePromotionRequest(supabase as unknown as PromotionRequestDbClient, { userId: session.session.userId, promotionRequestId: id, reason: typeof input.reason === "string" ? input.reason : undefined }); return NextResponse.json({ ok:true, promotionRequest, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Unable to archive" }, { status: 400 }); } } diff --git a/app/api/pandora/promotion-requests/[id]/review/route.ts b/app/api/pandora/promotion-requests/[id]/review/route.ts new file mode 100644 index 0000000..f8feecd --- /dev/null +++ b/app/api/pandora/promotion-requests/[id]/review/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { updatePromotionRequestReview, type PromotionRequestDbClient } from "@/lib/services/pandora-promotion-request-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 }); const input = body && typeof body === "object" ? body as Record : {}; try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const promotionRequest = await updatePromotionRequestReview(supabase as unknown as PromotionRequestDbClient, { userId: session.session.userId, promotionRequestId: id, reviewerNotes: String(input.reviewer_notes ?? input.reviewerNotes ?? ""), reviewerDecision: String(input.reviewer_decision ?? input.reviewerDecision ?? "") }); return NextResponse.json({ ok:true, promotionRequest, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Unable to review" }, { status: 400 }); } } diff --git a/app/api/pandora/promotion-requests/[id]/route.ts b/app/api/pandora/promotion-requests/[id]/route.ts new file mode 100644 index 0000000..40882bc --- /dev/null +++ b/app/api/pandora/promotion-requests/[id]/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { getPromotionRequest, type PromotionRequestDbClient } from "@/lib/services/pandora-promotion-request-service"; +export const dynamic = "force-dynamic"; +export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { const rejected = await assertNoClientUserIdOverride(request); 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 promotionRequest = await getPromotionRequest(supabase as unknown as PromotionRequestDbClient, { userId: session.session.userId, promotionRequestId: id }); return NextResponse.json({ ok:true, promotionRequest }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Not found" }, { status: 404 }); } } diff --git a/app/api/pandora/promotion-requests/[id]/submit/route.ts b/app/api/pandora/promotion-requests/[id]/submit/route.ts new file mode 100644 index 0000000..a8b82c0 --- /dev/null +++ b/app/api/pandora/promotion-requests/[id]/submit/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { submitPromotionRequest, type PromotionRequestDbClient } from "@/lib/services/pandora-promotion-request-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 promotionRequest = await submitPromotionRequest(supabase as unknown as PromotionRequestDbClient, { userId: session.session.userId, promotionRequestId: id }); return NextResponse.json({ ok:true, promotionRequest, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Unable to submit" }, { status: 400 }); } } diff --git a/app/api/pandora/promotion-requests/route.ts b/app/api/pandora/promotion-requests/route.ts new file mode 100644 index 0000000..a890c42 --- /dev/null +++ b/app/api/pandora/promotion-requests/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { listPromotionRequests, type PromotionRequestDbClient } from "@/lib/services/pandora-promotion-request-service"; +export const dynamic = "force-dynamic"; +export async function GET(request: NextRequest) { const rejected = await assertNoClientUserIdOverride(request); 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 { searchParams } = new URL(request.url); const supabase = await createSupabaseServerClient(); const requests = await listPromotionRequests(supabase as unknown as PromotionRequestDbClient, { userId: session.session.userId, namespace: searchParams.get("namespace") ?? undefined, status: searchParams.get("status") ?? undefined, limit: 25 }); return NextResponse.json({ ok:true, requests }); } diff --git a/app/api/pandora/shadow-pack-preflights/[id]/promotion-request/route.ts b/app/api/pandora/shadow-pack-preflights/[id]/promotion-request/route.ts new file mode 100644 index 0000000..9a5c943 --- /dev/null +++ b/app/api/pandora/shadow-pack-preflights/[id]/promotion-request/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { createOrRefreshPromotionRequest, type PromotionRequestDbClient } from "@/lib/services/pandora-promotion-request-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 promotionRequest = await createOrRefreshPromotionRequest(supabase as unknown as PromotionRequestDbClient, { userId: session.session.userId, preflightId: id }); return NextResponse.json({ ok:true, promotionRequest, promotion_execution_available:false, no_core_memory_mutation_performed:true, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Unable to create promotion request" }, { status: 400 }); } } diff --git a/components/pandora/OperatorActionComposer.tsx b/components/pandora/OperatorActionComposer.tsx index 5e096e8..cdb0f77 100644 --- a/components/pandora/OperatorActionComposer.tsx +++ b/components/pandora/OperatorActionComposer.tsx @@ -5,5 +5,5 @@ 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

For prepare_shadow_context_pack: creates shadow candidate only after approval/execution. For prepare_shadow_pack_preflight, payload.shadow_pack_id is required by the API/service; use a direct action payload only when the target shadow pack is known. No promotion. Only dry-run or queued-only proposals are available. No core memory mutation is available from this card.

; + return

For prepare_shadow_context_pack: creates shadow candidate only after approval/execution. For prepare_shadow_pack_preflight, payload.shadow_pack_id is required by the API/service. For prepare_promotion_request, payload.preflight_id is required by the API/service; use a direct action payload only when the target shadow pack is known. No promotion. Only dry-run or queued-only proposals are available. No core memory mutation is available from this card.

; } diff --git a/components/pandora/PandoraDashboard.tsx b/components/pandora/PandoraDashboard.tsx index 2a59bc1..90152fd 100644 --- a/components/pandora/PandoraDashboard.tsx +++ b/components/pandora/PandoraDashboard.tsx @@ -14,6 +14,7 @@ import { VerificationConsoleCard } from "./VerificationConsoleCard"; import { OperatorActionCenterCard } from "./OperatorActionCenterCard"; import { ShadowContextPackLabCard } from "./ShadowContextPackLabCard"; import { ShadowPackPreflightCard } from "./ShadowPackPreflightCard"; +import { PromotionRequestBoardCard } from "./PromotionRequestBoardCard"; import { WorkQueueCard } from "./WorkQueueCard"; import type { PandoraDashboardData, StatItem } from "./types"; import { useState } from "react"; @@ -49,6 +50,7 @@ export function PandoraDashboard({ dashboardData }: { dashboardData: PandoraDash +
diff --git a/components/pandora/PromotionPlanViewer.tsx b/components/pandora/PromotionPlanViewer.tsx new file mode 100644 index 0000000..07654db --- /dev/null +++ b/components/pandora/PromotionPlanViewer.tsx @@ -0,0 +1,2 @@ +import type { PromotionPlanSummary } from "./types"; +export function PromotionPlanViewer({ plan }: { plan: PromotionPlanSummary }) { return
Promotion plan

Operation: {plan.intended_operation}

Execution available: {String(plan.execution_available)}

    {(plan.steps ?? []).map((s)=>
  1. {s}
  2. )}

Required checks: {(plan.required_checks ?? []).join(", ") || "none"}

{plan.blockers?.length ?
{plan.blockers.map((b)=>

⛔ {b}

)}
: null}
; } diff --git a/components/pandora/PromotionRequestBoardCard.tsx b/components/pandora/PromotionRequestBoardCard.tsx new file mode 100644 index 0000000..21b13a1 --- /dev/null +++ b/components/pandora/PromotionRequestBoardCard.tsx @@ -0,0 +1,4 @@ +import type { PromotionRequestBoardData } from "./types"; +import { PromotionRequestList } from "./PromotionRequestList"; +const empty: PromotionRequestBoardData = { requests: [], warnings: [], countsByStatus: { draft:0, submitted:0, approved:0, blocked:0, archived:0 }, riskDistribution: { low:0, medium:0, high:0, blocked:0 } }; +export function PromotionRequestBoardCard({ data }: { data?: PromotionRequestBoardData }) { const d=data??empty; return

Promotion Request Board

Human-approved promotion planning

Promotion request only — execution unavailable

No promotion execution
{Object.entries(d.countsByStatus).map(([s,c])=>
{c}{s}
)}
{Object.entries(d.riskDistribution).map(([s,c])=>
{c}{s} risk
)}
{d.warnings.length ?
{d.warnings.map((w)=>

⚠ {w}

)}
: null}
; } diff --git a/components/pandora/PromotionRequestControls.tsx b/components/pandora/PromotionRequestControls.tsx new file mode 100644 index 0000000..1119321 --- /dev/null +++ b/components/pandora/PromotionRequestControls.tsx @@ -0,0 +1,4 @@ +"use client"; +import type { PromotionRequestSummary } from "./types"; +async function post(url:string, body?:Record) { await fetch(url,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body??{})}); window.location.reload(); } +export function PromotionRequestControls({ request }: { request: PromotionRequestSummary }) { return
; } diff --git a/components/pandora/PromotionRequestCreateButton.tsx b/components/pandora/PromotionRequestCreateButton.tsx new file mode 100644 index 0000000..e548dc0 --- /dev/null +++ b/components/pandora/PromotionRequestCreateButton.tsx @@ -0,0 +1,2 @@ +"use client"; +export function PromotionRequestCreateButton({ preflightId, disabled }: { preflightId: string; disabled?: boolean }) { async function create(){ await fetch(`/api/pandora/shadow-pack-preflights/${preflightId}/promotion-request`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({})}); window.location.reload(); } return ; } diff --git a/components/pandora/PromotionRequestList.tsx b/components/pandora/PromotionRequestList.tsx new file mode 100644 index 0000000..a07c47d --- /dev/null +++ b/components/pandora/PromotionRequestList.tsx @@ -0,0 +1,7 @@ +import type { PromotionRequestSummary } from "./types"; +import { PromotionPlanViewer } from "./PromotionPlanViewer"; +import { RollbackPlanViewer } from "./RollbackPlanViewer"; +import { PromotionRequestRiskSnapshot } from "./PromotionRequestRiskSnapshot"; +import { PromotionRequestReviewForm } from "./PromotionRequestReviewForm"; +import { PromotionRequestControls } from "./PromotionRequestControls"; +export function PromotionRequestList({ requests }: { requests: PromotionRequestSummary[] }) { if(!requests.length) return

No promotion requests yet. Create one from an approved preflight.

; return
{requests.map((r)=>

{r.namespace} • {r.status}

{r.title}

{r.summary}

Shadow {r.shadow_pack_id} • Preflight {r.preflight_id} • Active master {r.active_master_pack_id ?? "none"}

Reviewer decision: {r.reviewer_decision ?? "not reviewed"}

Diff snapshot

Added {r.diff_snapshot.added_keys?.length ?? 0}, removed {r.diff_snapshot.removed_keys?.length ?? 0}, changed {r.diff_snapshot.changed_keys?.length ?? 0}

Has active master: {String(r.diff_snapshot.has_active_master)}

{r.warnings.length ?
{r.warnings.map((w)=>

⚠ {w}

)}
: null}

Reviewer notes: {r.reviewer_notes || "none"}

)}
; } diff --git a/components/pandora/PromotionRequestReviewForm.tsx b/components/pandora/PromotionRequestReviewForm.tsx new file mode 100644 index 0000000..63ab783 --- /dev/null +++ b/components/pandora/PromotionRequestReviewForm.tsx @@ -0,0 +1,4 @@ +"use client"; +import { useState } from "react"; +import type { PromotionRequestSummary } from "./types"; +export function PromotionRequestReviewForm({ request }: { request: PromotionRequestSummary }) { const [notes,setNotes]=useState(request.reviewer_notes); const [decision,setDecision]=useState(request.reviewer_decision ?? "needs_changes"); async function save(){ await fetch(`/api/pandora/promotion-requests/${request.id}/review`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({reviewer_notes:notes,reviewer_decision:decision})}); window.location.reload(); } return