From a4ec11daf5ff3560673ef92381bfb2747f345930 Mon Sep 17 00:00:00 2001 From: besfeng23 <136943241+besfeng23@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:38:32 +0800 Subject: [PATCH] Add Pandora shadow pack preflight --- .../[id]/preflight/route.ts | 6 +++ .../[id]/archive/route.ts | 6 +++ .../[id]/review/route.ts | 6 +++ .../shadow-pack-preflights/[id]/route.ts | 6 +++ .../pandora/shadow-pack-preflights/route.ts | 6 +++ components/pandora/OperatorActionComposer.tsx | 2 +- components/pandora/PandoraDashboard.tsx | 2 + .../pandora/ShadowContextPackControls.tsx | 4 +- components/pandora/ShadowPackDiffViewer.tsx | 3 ++ .../pandora/ShadowPackPreflightCard.tsx | 4 ++ .../pandora/ShadowPackPreflightControls.tsx | 4 ++ .../pandora/ShadowPackPreflightList.tsx | 6 +++ .../pandora/ShadowPackPreflightReviewForm.tsx | 5 +++ components/pandora/ShadowPackRiskSummary.tsx | 3 ++ components/pandora/mock-data.ts | 2 +- components/pandora/types.ts | 10 ++++- docs/pandora-operator-action-center.md | 4 ++ docs/pandora-readonly-action-runner.md | 4 ++ docs/pandora-shadow-context-pack-lab.md | 4 ++ docs/pandora-shadow-pack-preflight.md | 32 ++++++++++++++ lib/services/pandora-dashboard-service.ts | 11 ++++- .../pandora-operator-action-service.ts | 18 +++++++- .../pandora-shadow-pack-preflight-service.ts | 43 ++++++++++++++++++ ...04010000_pandora_shadow_pack_preflight.sql | 44 +++++++++++++++++++ ...dora-shadow-pack-preflight-service.test.ts | 18 ++++++++ 25 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 app/api/pandora/shadow-context-packs/[id]/preflight/route.ts create mode 100644 app/api/pandora/shadow-pack-preflights/[id]/archive/route.ts create mode 100644 app/api/pandora/shadow-pack-preflights/[id]/review/route.ts create mode 100644 app/api/pandora/shadow-pack-preflights/[id]/route.ts create mode 100644 app/api/pandora/shadow-pack-preflights/route.ts create mode 100644 components/pandora/ShadowPackDiffViewer.tsx create mode 100644 components/pandora/ShadowPackPreflightCard.tsx create mode 100644 components/pandora/ShadowPackPreflightControls.tsx create mode 100644 components/pandora/ShadowPackPreflightList.tsx create mode 100644 components/pandora/ShadowPackPreflightReviewForm.tsx create mode 100644 components/pandora/ShadowPackRiskSummary.tsx create mode 100644 docs/pandora-shadow-pack-preflight.md create mode 100644 lib/services/pandora-shadow-pack-preflight-service.ts create mode 100644 supabase/migrations/20260704010000_pandora_shadow_pack_preflight.sql create mode 100644 tests/unit/pandora-shadow-pack-preflight-service.test.ts diff --git a/app/api/pandora/shadow-context-packs/[id]/preflight/route.ts b/app/api/pandora/shadow-context-packs/[id]/preflight/route.ts new file mode 100644 index 0000000..1dc6113 --- /dev/null +++ b/app/api/pandora/shadow-context-packs/[id]/preflight/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 { createOrRefreshShadowPackPreflight, type ShadowPackPreflightDbClient } from "@/lib/services/pandora-shadow-pack-preflight-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const body = await request.json().catch(() => ({})); 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 preflight = await createOrRefreshShadowPackPreflight(supabase as unknown as ShadowPackPreflightDbClient, { userId: session.session.userId, shadowPackId: id, requestId: typeof body.request_id === "string" ? body.request_id : undefined }); return NextResponse.json({ ok:true, preflight, no_core_memory_mutation_performed:true, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Preflight failed" }, { status: 400 }); } } diff --git a/app/api/pandora/shadow-pack-preflights/[id]/archive/route.ts b/app/api/pandora/shadow-pack-preflights/[id]/archive/route.ts new file mode 100644 index 0000000..75553dc --- /dev/null +++ b/app/api/pandora/shadow-pack-preflights/[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 { archiveShadowPackPreflight, type ShadowPackPreflightDbClient } from "@/lib/services/pandora-shadow-pack-preflight-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const body = await request.json().catch(() => ({})); 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 preflight = await archiveShadowPackPreflight(supabase as unknown as ShadowPackPreflightDbClient, { userId: session.session.userId, preflightId: id, reason: typeof body.reason === "string" ? body.reason : undefined }); return NextResponse.json({ ok:true, preflight, no_core_memory_mutation_performed:true, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Archive failed" }, { status: 400 }); } } diff --git a/app/api/pandora/shadow-pack-preflights/[id]/review/route.ts b/app/api/pandora/shadow-pack-preflights/[id]/review/route.ts new file mode 100644 index 0000000..e9e2a59 --- /dev/null +++ b/app/api/pandora/shadow-pack-preflights/[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 { updateShadowPackPreflightReview, type ShadowPackPreflightDbClient } from "@/lib/services/pandora-shadow-pack-preflight-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const body = await request.json().catch(() => ({})); 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 input = body && typeof body === "object" ? body as Record : {}; const supabase = await createSupabaseServerClient(); const preflight = await updateShadowPackPreflightReview(supabase as unknown as ShadowPackPreflightDbClient, { userId: session.session.userId, preflightId: id, reviewerNotes: String(input.reviewer_notes ?? ""), reviewerDecision: String(input.reviewer_decision ?? "") }); return NextResponse.json({ ok:true, preflight, no_core_memory_mutation_performed:true, no_promotion_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Review failed" }, { status: 400 }); } } diff --git a/app/api/pandora/shadow-pack-preflights/[id]/route.ts b/app/api/pandora/shadow-pack-preflights/[id]/route.ts new file mode 100644 index 0000000..94ad04a --- /dev/null +++ b/app/api/pandora/shadow-pack-preflights/[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 { getShadowPackPreflight, type ShadowPackPreflightDbClient } from "@/lib/services/pandora-shadow-pack-preflight-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 preflight = await getShadowPackPreflight(supabase as unknown as ShadowPackPreflightDbClient, { userId: session.session.userId, preflightId: id }); return NextResponse.json({ ok:true, preflight }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Not found" }, { status: 404 }); } } diff --git a/app/api/pandora/shadow-pack-preflights/route.ts b/app/api/pandora/shadow-pack-preflights/route.ts new file mode 100644 index 0000000..6ab7433 --- /dev/null +++ b/app/api/pandora/shadow-pack-preflights/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 { listShadowPackPreflights, type ShadowPackPreflightDbClient } from "@/lib/services/pandora-shadow-pack-preflight-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 url = new URL(request.url); const supabase = await createSupabaseServerClient(); const preflights = await listShadowPackPreflights(supabase as unknown as ShadowPackPreflightDbClient, { userId: session.session.userId, namespace: url.searchParams.get("namespace") ?? undefined, status: url.searchParams.get("status") ?? undefined, limit: 25 }); return NextResponse.json({ ok:true, preflights }); } diff --git a/components/pandora/OperatorActionComposer.tsx b/components/pandora/OperatorActionComposer.tsx index f68c029..5e096e8 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. 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; 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 a63c5cf..2a59bc1 100644 --- a/components/pandora/PandoraDashboard.tsx +++ b/components/pandora/PandoraDashboard.tsx @@ -13,6 +13,7 @@ import { TopBar } from "./TopBar"; import { VerificationConsoleCard } from "./VerificationConsoleCard"; import { OperatorActionCenterCard } from "./OperatorActionCenterCard"; import { ShadowContextPackLabCard } from "./ShadowContextPackLabCard"; +import { ShadowPackPreflightCard } from "./ShadowPackPreflightCard"; import { WorkQueueCard } from "./WorkQueueCard"; import type { PandoraDashboardData, StatItem } from "./types"; import { useState } from "react"; @@ -47,6 +48,7 @@ export function PandoraDashboard({ dashboardData }: { dashboardData: PandoraDash +
diff --git a/components/pandora/ShadowContextPackControls.tsx b/components/pandora/ShadowContextPackControls.tsx index 66e3d22..067feca 100644 --- a/components/pandora/ShadowContextPackControls.tsx +++ b/components/pandora/ShadowContextPackControls.tsx @@ -1,4 +1,4 @@ "use client"; import type { ShadowContextPackSummary } from "./types"; -async function post(id: string, verb: "review" | "reject" | "archive") { await fetch(`/api/pandora/shadow-context-packs/${id}/${verb}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); window.location.reload(); } -export function ShadowContextPackControls({ pack }: { pack: ShadowContextPackSummary }) { return
; } +async function post(id: string, verb: "review" | "reject" | "archive" | "preflight") { await fetch(`/api/pandora/shadow-context-packs/${id}/${verb}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); window.location.reload(); } +export function ShadowContextPackControls({ pack }: { pack: ShadowContextPackSummary }) { return
; } diff --git a/components/pandora/ShadowPackDiffViewer.tsx b/components/pandora/ShadowPackDiffViewer.tsx new file mode 100644 index 0000000..bd68c1d --- /dev/null +++ b/components/pandora/ShadowPackDiffViewer.tsx @@ -0,0 +1,3 @@ +import type { ShadowPackDiffSummary } from "./types"; +function Keys({ label, keys }: { label: string; keys: string[] }) { return
{keys.length}{label}: {keys.length ? keys.join(", ") : "none"}
; } +export function ShadowPackDiffViewer({ diff }: { diff: ShadowPackDiffSummary }) { return

Active master: {diff.active_master_pack_id ?? "none"} • Shadow: {diff.shadow_pack_id}

Deltas — source events: {diff.source_event_count_delta}, open loops: {diff.open_loop_count_delta}, needs review: {diff.needs_review_count_delta}, recent summaries: {diff.top_recent_summary_count}, evidence changed: {diff.evidence_summary_changed ? "yes" : "no"}

; } diff --git a/components/pandora/ShadowPackPreflightCard.tsx b/components/pandora/ShadowPackPreflightCard.tsx new file mode 100644 index 0000000..7d33745 --- /dev/null +++ b/components/pandora/ShadowPackPreflightCard.tsx @@ -0,0 +1,4 @@ +import type { ShadowPackPreflightData } from "./types"; +import { ShadowPackPreflightList } from "./ShadowPackPreflightList"; +const empty: ShadowPackPreflightData = { preflights: [], warnings: [], countsByStatus: { draft:0, ready_for_review:0, reviewed:0, approved_for_promotion:0, blocked:0, archived:0 }, riskDistribution: { low:0, medium:0, high:0, blocked:0 } }; +export function ShadowPackPreflightCard({ data }: { data?: ShadowPackPreflightData }) { const d=data??empty; return

Shadow Pack Preflight

Diff + promotion readiness review

Preflight only — no production context pack mutation

No promotion route
{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/ShadowPackPreflightControls.tsx b/components/pandora/ShadowPackPreflightControls.tsx new file mode 100644 index 0000000..4e63145 --- /dev/null +++ b/components/pandora/ShadowPackPreflightControls.tsx @@ -0,0 +1,4 @@ +"use client"; +import type { ShadowPackPreflightSummary } from "./types"; +async function archive(id: string) { await fetch(`/api/pandora/shadow-pack-preflights/${id}/archive`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); window.location.reload(); } +export function ShadowPackPreflightControls({ preflight }: { preflight: ShadowPackPreflightSummary }) { return
; } diff --git a/components/pandora/ShadowPackPreflightList.tsx b/components/pandora/ShadowPackPreflightList.tsx new file mode 100644 index 0000000..e7debea --- /dev/null +++ b/components/pandora/ShadowPackPreflightList.tsx @@ -0,0 +1,6 @@ +import type { ShadowPackPreflightSummary } from "./types"; +import { ShadowPackDiffViewer } from "./ShadowPackDiffViewer"; +import { ShadowPackRiskSummary } from "./ShadowPackRiskSummary"; +import { ShadowPackPreflightReviewForm } from "./ShadowPackPreflightReviewForm"; +import { ShadowPackPreflightControls } from "./ShadowPackPreflightControls"; +export function ShadowPackPreflightList({ preflights }: { preflights: ShadowPackPreflightSummary[] }) { if (!preflights.length) return

No shadow pack promotion preflights yet.

; return
{preflights.map((p)=>

{p.namespace} • {p.status}

Shadow pack {p.shadow_pack_id}

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

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

)}
; } diff --git a/components/pandora/ShadowPackPreflightReviewForm.tsx b/components/pandora/ShadowPackPreflightReviewForm.tsx new file mode 100644 index 0000000..f8657a3 --- /dev/null +++ b/components/pandora/ShadowPackPreflightReviewForm.tsx @@ -0,0 +1,5 @@ +"use client"; +import { useState } from "react"; +import type { ShadowPackPreflightSummary } from "./types"; +async function save(id: string, reviewer_notes: string, reviewer_decision: string) { await fetch(`/api/pandora/shadow-pack-preflights/${id}/review`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ reviewer_notes, reviewer_decision }) }); window.location.reload(); } +export function ShadowPackPreflightReviewForm({ preflight }: { preflight: ShadowPackPreflightSummary }) { const [notes, setNotes] = useState(preflight.reviewer_notes ?? ""); return