Add Reprocess to retry failed IC reviews without the create wizard.

Successful reviews are kept; skipped ICs such as a DeepSeek 400 are run again. Reprocess all re-reviews every IC while still using the library cache.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 02:37:53 +02:00
co-authored by Cursor
parent 34bfe33cdf
commit e84418f975
9 changed files with 429 additions and 36 deletions
+80
View File
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Literal
from backend.routers.deps import get_storage, resolve_or_404 from backend.routers.deps import get_storage, resolve_or_404
from backend.services import event_bridge, job_runner from backend.services import event_bridge, job_runner
@@ -24,12 +25,22 @@ from backend.services import projects as proj_svc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
VALID_REGEN_STAGES = {"derating"} VALID_REGEN_STAGES = {"derating"}
_REPROCESS_OK_FROM = frozenset({
proj_svc.STATUS_COMPLETE,
proj_svc.STATUS_ERROR,
proj_svc.STATUS_CANCELLED,
})
class RegenRequest(BaseModel): class RegenRequest(BaseModel):
stages: list[str] stages: list[str]
class ReprocessRequest(BaseModel):
"""``failed`` retries skipped / errored IC reviews; ``all`` re-reviews every IC."""
mode: Literal["failed", "all"] = "failed"
router = APIRouter(tags=["pipeline"]) router = APIRouter(tags=["pipeline"])
@@ -179,6 +190,75 @@ async def resume(project_id: str, request: Request):
return {"status": "resumed", "project_id": project_id} return {"status": "resumed", "project_id": project_id}
@router.post("/pipeline/{project_id}/reprocess", status_code=202)
async def reprocess(project_id: str, request: Request, req: ReprocessRequest | None = None):
"""Re-run a finished project without the create wizard.
Keeps BOM, netlist, datasheets, and library extractions. ``failed``
(default) skips ICs that already produced a review; ``all`` re-reviews
every IC.
"""
from backend._version import PINSCOPE_VERSION
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Upload BOM and netlist before reprocessing")
if meta.status not in _REPROCESS_OK_FROM:
raise HTTPException(
409,
f"Reprocess is for complete / error / cancelled runs "
f"(status={meta.status}). Pause uses resume; a live run uses cancel.",
)
body = req or ReprocessRequest()
retry_failed = body.mode == "failed"
keep_refs = (
proj_svc.completed_review_refs_for_retry(storage, owner_user_id, project_id)
if retry_failed else []
)
try:
proj_svc.transition_status(
storage, owner_user_id, project_id,
from_status=_REPROCESS_OK_FROM,
to_status=proj_svc.STATUS_QUEUED,
cancel_requested=False,
execution_name=None,
pipeline_state=None,
pause_checkpoint=None,
pause_reason=None,
completed_review_refs=keep_refs,
pinscope_version=PINSCOPE_VERSION,
)
except proj_svc.StatusConflict:
raise HTTPException(409, "Pipeline already running or queued")
try:
execution_name = job_runner.enqueue_pipeline(
project_id, owner_user_id, resume=retry_failed, free=False,
)
except Exception:
logger.exception("enqueue_pipeline (reprocess) failed for %s", project_id)
proj_svc.update_project(
storage, owner_user_id, project_id,
status=proj_svc.STATUS_ERROR,
pipeline_state={"error": "Failed to enqueue worker"},
)
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id, execution_name=execution_name,
)
return {
"status": "reprocess_started",
"project_id": project_id,
"mode": body.mode,
"resume": retry_failed,
"kept_review_refs": keep_refs,
}
@router.post("/pipeline/{project_id}/restart", status_code=202) @router.post("/pipeline/{project_id}/restart", status_code=202)
async def restart(project_id: str, request: Request): async def restart(project_id: str, request: Request):
"""Admin-only: wipe per-project extractions and run the pipeline free.""" """Admin-only: wipe per-project extractions and run the pipeline free."""
+34 -10
View File
@@ -1644,15 +1644,17 @@ async def run_pipeline(
the project is left in ``paused_insufficient_credits`` with a the project is left in ``paused_insufficient_credits`` with a
checkpoint so it can be resumed later. checkpoint so it can be resumed later.
When ``resume=True``, prior completed review refs and spent credits are When ``resume=True``, prior completed review refs are restored so
restored from the project's ``pause_checkpoint`` so completed work is already-reviewed ICs are skipped (paused credit resume, or user
skipped on the next pass. reprocess of failed reviews).
When ``free=True`` (admin-initiated rerun), every call runs through When ``free=True`` (admin-initiated rerun), every call runs through
``ApiLogger(free=True)`` so ``credits_charged`` is zeroed, the credit ``ApiLogger(free=True)`` so ``credits_charged`` is zeroed, the credit
gate is bypassed, and ``meta.total_cost_usd`` is preserved rather than gate is bypassed, and ``meta.total_cost_usd`` is preserved rather than
incremented. The raw Anthropic cost is still captured in log entries. incremented. The raw Anthropic cost is still captured in log entries.
""" """
ctx: PipelineContext | None = None
api_logger: ApiLogger | None = None
try: try:
meta = proj_svc.get_project(storage, user_id, project_id) meta = proj_svc.get_project(storage, user_id, project_id)
if not meta: if not meta:
@@ -1826,37 +1828,59 @@ async def run_pipeline(
# asyncio.CancelledError can also arrive during local-dev # asyncio.CancelledError can also arrive during local-dev
# subprocess shutdown (SIGTERM). Both are handled the same way. # subprocess shutdown (SIGTERM). Both are handled the same way.
try: try:
extra: dict = {
"pipeline_state": {"error": "Pipeline cancelled by user"},
"cancel_requested": False,
}
if ctx is not None:
extra["completed_review_refs"] = sorted(
ctx.completed_review_refs, key=natural_sort_key,
)
extra["skipped_components"] = (
[s.to_dict() for s in ctx.skipped] or None
)
proj_svc.transition_status( proj_svc.transition_status(
storage, user_id, project_id, storage, user_id, project_id,
from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED}, from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED},
to_status=proj_svc.STATUS_CANCELLED, to_status=proj_svc.STATUS_CANCELLED,
pipeline_state={"error": "Pipeline cancelled by user"}, **extra,
cancel_requested=False,
) )
except proj_svc.StatusConflict: except proj_svc.StatusConflict:
pass pass
broker.publish(project_id, "pipeline_cancelled", {"error": "Pipeline cancelled by user"}) broker.publish(project_id, "pipeline_cancelled", {"error": "Pipeline cancelled by user"})
# Last-mile flush so partial billing is captured. # Last-mile flush so partial billing is captured.
try: try:
api_logger.flush(storage, user_id, project_id) # type: ignore[has-type] if api_logger is not None:
api_logger.flush(storage, user_id, project_id)
except Exception: except Exception:
pass pass
except Exception as e: except Exception as e:
logger.exception("Pipeline run crashed for project %s", project_id) logger.exception("Pipeline run crashed for project %s", project_id)
try: try:
extra = {
"pipeline_state": {"error": str(e)},
"cancel_requested": False,
}
if ctx is not None:
extra["completed_review_refs"] = sorted(
ctx.completed_review_refs, key=natural_sort_key,
)
extra["skipped_components"] = (
[s.to_dict() for s in ctx.skipped] or None
)
proj_svc.transition_status( proj_svc.transition_status(
storage, user_id, project_id, storage, user_id, project_id,
from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED}, from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED},
to_status=proj_svc.STATUS_ERROR, to_status=proj_svc.STATUS_ERROR,
pipeline_state={"error": str(e)}, **extra,
cancel_requested=False,
) )
except proj_svc.StatusConflict: except proj_svc.StatusConflict:
pass pass
broker.publish(project_id, "pipeline_error", {"error": str(e)}) broker.publish(project_id, "pipeline_error", {"error": str(e)})
try: try:
api_logger.flush(storage, user_id, project_id) # type: ignore[has-type] if api_logger is not None:
api_logger.flush(storage, user_id, project_id)
except Exception: except Exception:
pass pass
+31
View File
@@ -129,6 +129,37 @@ class ProjectMeta(BaseModel):
cancel_requested: bool = False cancel_requested: bool = False
def completed_review_refs_for_retry(
storage: StorageBackend, user_id: str, project_id: str,
) -> list[str]:
"""ICs that already finished review and should be skipped on reprocess.
Drops refs that failed (skipped_components / report.review_errors) so
those ICs are tried again.
"""
meta = get_project(storage, user_id, project_id)
if not meta:
return []
failed: set[str] = set()
for item in meta.skipped_components or []:
stage = (item.get("stage") or "")
ident = (item.get("identifier") or "").strip()
if ident and stage in ("validation", "review"):
failed.add(ident)
report_key = f"{_project_prefix(user_id, project_id)}/report.json"
if storage.exists(report_key):
try:
report = storage.read_json(report_key)
except Exception:
report = {}
for ref in (report.get("review_errors") or {}):
if ref:
failed.add(str(ref))
from backend.pinscopex.utils import natural_sort_key
kept = [r for r in (meta.completed_review_refs or []) if r and r not in failed]
return sorted(kept, key=natural_sort_key)
def _project_prefix(user_id: str, project_id: str) -> str: def _project_prefix(user_id: str, project_id: str) -> str:
return f"users/{user_id}/projects/{project_id}" return f"users/{user_id}/projects/{project_id}"
+78 -12
View File
@@ -16,6 +16,7 @@ import {
removeCollaborator, removeCollaborator,
makeCollaboratorOwner, makeCollaboratorOwner,
startPipeline, startPipeline,
reprocessPipeline,
resumePipeline, resumePipeline,
fetchPipelineEstimate, fetchPipelineEstimate,
} from "@/lib/api"; } from "@/lib/api";
@@ -24,6 +25,7 @@ import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow
import { import {
ArrowRight, ArrowRight,
Play, Play,
RotateCcw,
AlertTriangle, AlertTriangle,
ExternalLink, ExternalLink,
X, X,
@@ -106,7 +108,14 @@ export default function ProjectDetailPage({
const [estimate, setEstimate] = useState<CostEstimate | null>(null); const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const canRun = Boolean(project?.hasBom && project?.hasNetlist); const canRun = Boolean(project?.hasBom && project?.hasNetlist);
const canStartFresh = project?.status !== "complete" && project?.status !== "paused_insufficient_credits"; const canReprocess =
project?.status === "complete" ||
project?.status === "error" ||
project?.status === "cancelled";
const canStartFresh =
project?.status !== "complete" &&
project?.status !== "paused_insufficient_credits" &&
!canReprocess;
// Pull a cost estimate when the project is ready to run, so we can show // Pull a cost estimate when the project is ready to run, so we can show
// the "~X credits" hint under the button. No blocking — the user can // the "~X credits" hint under the button. No blocking — the user can
@@ -157,6 +166,20 @@ export default function ProjectDetailPage({
} }
}; };
const handleReprocess = async (mode: "failed" | "all") => {
if (!canRun) return;
setStarting(true);
try {
await reprocessPipeline(id, mode);
router.push(`/project/${id}/progress`);
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to reprocess");
}
};
const hasFailedReviews = Boolean(hasSkipped);
return ( return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6"> <div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -190,16 +213,38 @@ export default function ProjectDetailPage({
<Card> <Card>
<CardContent className="py-3"> <CardContent className="py-3">
{project.status === "complete" ? ( {project.status === "complete" ? (
<div className="flex items-center gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-sm text-emerald-600 dark:text-emerald-400"> <span className="text-sm text-emerald-600 dark:text-emerald-400">
Validation complete Validation complete
</span> </span>
<Link href={`/project/${id}/report`} className="ml-auto"> <div className="flex flex-wrap items-center gap-2 sm:ml-auto">
<Button size="sm"> {hasFailedReviews && (
View Report <Button
<ArrowRight className="h-4 w-4 ml-1" /> size="sm"
variant="outline"
disabled={starting}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{starting ? "Starting..." : "Retry failed reviews"}
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={() => handleReprocess("all")}
>
<RotateCcw className="h-4 w-4 mr-1" />
Reprocess all
</Button> </Button>
</Link> <Link href={`/project/${id}/report`}>
<Button size="sm">
View Report
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</Link>
</div>
</div> </div>
) : isPaused ? ( ) : isPaused ? (
<span className="text-sm text-amber-600 dark:text-amber-400"> <span className="text-sm text-amber-600 dark:text-amber-400">
@@ -207,11 +252,32 @@ export default function ProjectDetailPage({
</span> </span>
) : ( ) : (
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button size="sm" disabled={!canRun || starting} onClick={handleRunPipeline}> {(project.status === "error" || project.status === "cancelled") && canRun ? (
<Play className="h-4 w-4 mr-1" /> <>
{starting ? "Starting..." : "Run Pipeline"} <Button
</Button> size="sm"
disabled={starting}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{starting ? "Starting..." : "Reprocess"}
</Button>
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={() => handleReprocess("all")}
>
Reprocess all
</Button>
</>
) : (
<Button size="sm" disabled={!canRun || starting} onClick={handleRunPipeline}>
<Play className="h-4 w-4 mr-1" />
{starting ? "Starting..." : "Run Pipeline"}
</Button>
)}
{!canRun && ( {!canRun && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Upload BOM and netlist to enable Upload BOM and netlist to enable
@@ -20,7 +20,7 @@ import {
import { PipelineStepper } from "@/components/progress/pipeline-stepper"; import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { PausedRunBanner } from "@/components/billing/paused-run-banner"; import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress"; import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
import { cancelPipeline, fetchProject, resumePipeline } from "@/lib/api"; import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api";
import type { PauseCheckpoint } from "@/lib/types"; import type { PauseCheckpoint } from "@/lib/types";
import { import {
AlertTriangle, AlertTriangle,
@@ -31,6 +31,7 @@ import {
Loader2, Loader2,
OctagonX, OctagonX,
Ban, Ban,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
export default function ProgressPage({ export default function ProgressPage({
@@ -51,6 +52,18 @@ export default function ProgressPage({
const [projectPaused, setProjectPaused] = useState(false); const [projectPaused, setProjectPaused] = useState(false);
const [projectCheckpoint, setProjectCheckpoint] = useState<PauseCheckpoint | null>(null); const [projectCheckpoint, setProjectCheckpoint] = useState<PauseCheckpoint | null>(null);
const [resuming, setResuming] = useState(false); const [resuming, setResuming] = useState(false);
const [reprocessing, setReprocessing] = useState(false);
async function handleReprocess(mode: "failed" | "all") {
setReprocessing(true);
try {
await reprocessPipeline(id, mode);
window.location.reload();
} catch (e) {
setReprocessing(false);
alert(e instanceof Error ? e.message : "Failed to reprocess");
}
}
// Fetch project name + initial paused state. The SSE stream only reports // Fetch project name + initial paused state. The SSE stream only reports
// `pipeline_paused` if the page is open when it fires; landing on the // `pipeline_paused` if the page is open when it fires; landing on the
@@ -333,6 +346,15 @@ export default function ProgressPage({
<ArrowRight className="h-4 w-4 ml-1" /> <ArrowRight className="h-4 w-4 ml-1" />
</Button> </Button>
</Link> </Link>
<Button
size="sm"
variant="outline"
disabled={reprocessing}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{reprocessing ? "Starting..." : "Reprocess"}
</Button>
</div> </div>
)} )}
@@ -348,12 +370,28 @@ export default function ProgressPage({
Back to Dashboard Back to Dashboard
</Button> </Button>
</Link> </Link>
<Button
size="sm"
disabled={reprocessing}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{reprocessing ? "Starting..." : "Reprocess"}
</Button>
</div> </div>
)} )}
{done && error && !cancelled && ( {done && error && !cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-rose-500/30 bg-rose-500/5"> <div className="flex items-center gap-3 p-4 rounded-lg border border-rose-500/30 bg-rose-500/5">
<span className="text-sm text-rose-600 dark:text-rose-400">{error}</span> <span className="text-sm text-rose-600 dark:text-rose-400 flex-1">{error}</span>
<Button
size="sm"
disabled={reprocessing}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{reprocessing ? "Starting..." : "Reprocess"}
</Button>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,8 @@
"use client"; "use client";
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react"; import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
import { Download } from "lucide-react"; import { Download, RotateCcw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useOptionalUser } from "@/hooks/use-optional-auth"; import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReport } from "@/hooks/use-report"; import { useReport } from "@/hooks/use-report";
import { useReviewedFindings } from "@/hooks/use-reviewed-findings"; import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
@@ -12,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast"; import { Toast, useToast } from "@/components/ui/toast";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog"; import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { fetchCollaborators, fetchProject, fetchMyFeedback } from "@/lib/api"; import { fetchCollaborators, fetchProject, fetchMyFeedback, reprocessPipeline } from "@/lib/api";
import { exportReportToExcel } from "@/lib/report-export"; import { exportReportToExcel } from "@/lib/report-export";
import { cn, getFindingKey } from "@/lib/utils"; import { cn, getFindingKey } from "@/lib/utils";
import type { Finding, FindingComment, Collaborator } from "@/lib/types"; import type { Finding, FindingComment, Collaborator } from "@/lib/types";
@@ -26,6 +27,7 @@ interface FocusState {
} }
function ReportContent({ projectId }: { projectId: string }) { function ReportContent({ projectId }: { projectId: string }) {
const router = useRouter();
const { report, graph, loading, error } = useReport(projectId); const { report, graph, loading, error } = useReport(projectId);
const { user } = useOptionalUser(); const { user } = useOptionalUser();
const [focus, setFocus] = useState<FocusState | null>(null); const [focus, setFocus] = useState<FocusState | null>(null);
@@ -39,6 +41,18 @@ function ReportContent({ projectId }: { projectId: string }) {
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null); const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
const [feedbackOpen, setFeedbackOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false);
const [reportedFindingIds, setReportedFindingIds] = useState<Set<string>>(new Set()); const [reportedFindingIds, setReportedFindingIds] = useState<Set<string>>(new Set());
const [reprocessing, setReprocessing] = useState(false);
async function handleReprocessFailed() {
setReprocessing(true);
try {
await reprocessPipeline(projectId, "failed");
router.push(`/project/${projectId}/progress`);
} catch (e) {
setReprocessing(false);
alert(e instanceof Error ? e.message : "Failed to reprocess");
}
}
useEffect(() => { useEffect(() => {
fetchMyFeedback() fetchMyFeedback()
@@ -211,14 +225,25 @@ function ReportContent({ projectId }: { projectId: string }) {
{new Date(report.timestamp).toLocaleDateString()} {new Date(report.timestamp).toLocaleDateString()}
</p> </p>
</div> </div>
<Button <div className="flex items-center gap-2">
variant="outline" <Button
size="sm" variant="outline"
onClick={() => exportReportToExcel(report, graph, projectName)} size="sm"
disabled={report.findings.length === 0} disabled={reprocessing}
> onClick={handleReprocessFailed}
<Download /> Export Excel >
</Button> <RotateCcw />
{reprocessing ? "Starting..." : "Retry failed reviews"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportReportToExcel(report, graph, projectName)}
disabled={report.findings.length === 0}
>
<Download /> Export Excel
</Button>
</div>
</div> </div>
<ReportSummary <ReportSummary
summary={report.summary} summary={report.summary}
@@ -231,7 +256,8 @@ function ReportContent({ projectId }: { projectId: string }) {
{Object.keys(report.review_errors).length} IC review{Object.keys(report.review_errors).length === 1 ? "" : "s"} failed {Object.keys(report.review_errors).length} IC review{Object.keys(report.review_errors).length === 1 ? "" : "s"} failed
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
These ICs could not be reviewed against their datasheets. Re-run the pipeline to retry; if the failure repeats, share the error with support. These ICs could not be reviewed against their datasheets. Use Retry
failed reviews to run them again without repeating ICs that already succeeded.
</p> </p>
<ul className="space-y-1 text-xs"> <ul className="space-y-1 text-xs">
{Object.entries(report.review_errors).map(([ref, err]) => ( {Object.entries(report.review_errors).map(([ref, err]) => (
+16 -1
View File
@@ -63,6 +63,7 @@ function mapProject(p: Record<string, unknown>): Project {
hasBom: p.has_bom as boolean, hasBom: p.has_bom as boolean,
datasheetCount: p.datasheet_count as number, datasheetCount: p.datasheet_count as number,
skippedComponents: (p.skipped_components as SkippedComponent[] | null) ?? undefined, skippedComponents: (p.skipped_components as SkippedComponent[] | null) ?? undefined,
completedReviewRefs: (p.completed_review_refs as string[] | null) ?? undefined,
userId: p.user_id as string | undefined, userId: p.user_id as string | undefined,
collaborators: (p.collaborators as string[] | null) ?? undefined, collaborators: (p.collaborators as string[] | null) ?? undefined,
creditsSpent: (p.credits_spent as number | undefined) ?? undefined, creditsSpent: (p.credits_spent as number | undefined) ?? undefined,
@@ -439,7 +440,21 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
// --- Pipeline --- // --- Pipeline ---
export async function startPipeline(projectId: string) { export async function reprocessPipeline(
projectId: string,
mode: "failed" | "all" = "failed",
) {
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/reprocess`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Failed to reprocess" }));
throw new Error(err.detail || "Failed to reprocess pipeline");
}
return res.json();
}
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/start`, { const res = await authFetch(`${BASE}/api/pipeline/${projectId}/start`, {
method: "POST", method: "POST",
}); });
+1
View File
@@ -227,6 +227,7 @@ export interface Project {
hasBom: boolean; hasBom: boolean;
datasheetCount: number; datasheetCount: number;
skippedComponents?: SkippedComponent[]; skippedComponents?: SkippedComponent[];
completedReviewRefs?: string[];
userId?: string; userId?: string;
collaborators?: string[]; collaborators?: string[];
creditsSpent?: number; creditsSpent?: number;
+112
View File
@@ -0,0 +1,112 @@
"""Reprocess a finished project: retry failed IC reviews or re-run all."""
from __future__ import annotations
from fastapi.testclient import TestClient
from backend.services import projects as proj_svc
from backend.services.storage import LocalStorageBackend
def _client(tmp_path) -> TestClient:
from backend.main import app
app.state.storage = LocalStorageBackend(tmp_path)
return TestClient(app)
def test_completed_review_refs_drops_failed_ics(storage):
meta = proj_svc.create_project(storage, "local", "board")
storage.write_json(
f"users/local/projects/{meta.id}/report.json",
{"review_errors": {"U19": "BadRequestError"}},
)
proj_svc.update_project(
storage, "local", meta.id,
completed_review_refs=["U1", "U19", "U3"],
skipped_components=[
{"identifier": "U19", "stage": "validation", "error": "400"},
],
)
kept = proj_svc.completed_review_refs_for_retry(storage, "local", meta.id)
assert kept == ["U1", "U3"]
def test_reprocess_failed_enqueues_resume(tmp_path, monkeypatch):
captured: dict = {}
def fake_enqueue(project_id, user_id, *, resume=False, free=False):
captured.update(project_id=project_id, user_id=user_id, resume=resume, free=free)
return "local/projects/x"
monkeypatch.setattr("backend.services.job_runner.enqueue_pipeline", fake_enqueue)
client = _client(tmp_path)
meta = client.post("/api/projects", json={"name": "board"}).json()
pid = meta["id"]
storage = client.app.state.storage
proj_svc.update_project(
storage, "local", pid,
status="complete",
has_bom=True,
has_netlist=True,
completed_review_refs=["U1", "U2"],
skipped_components=[
{"identifier": "U2", "stage": "validation", "error": "400"},
],
)
resp = client.post(f"/api/pipeline/{pid}/reprocess", json={"mode": "failed"})
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["mode"] == "failed"
assert captured["resume"] is True
assert body["kept_review_refs"] == ["U1"]
fresh = proj_svc.get_project(storage, "local", pid)
assert fresh.status == "queued"
assert fresh.completed_review_refs == ["U1"]
def test_reprocess_all_clears_kept_refs(tmp_path, monkeypatch):
captured: dict = {}
def fake_enqueue(project_id, user_id, *, resume=False, free=False):
captured["resume"] = resume
return "local/projects/x"
monkeypatch.setattr("backend.services.job_runner.enqueue_pipeline", fake_enqueue)
client = _client(tmp_path)
meta = client.post("/api/projects", json={"name": "board"}).json()
pid = meta["id"]
storage = client.app.state.storage
proj_svc.update_project(
storage, "local", pid,
status="complete",
has_bom=True,
has_netlist=True,
completed_review_refs=["U1"],
)
resp = client.post(f"/api/pipeline/{pid}/reprocess", json={"mode": "all"})
assert resp.status_code == 202
assert captured["resume"] is False
fresh = proj_svc.get_project(storage, "local", pid)
assert fresh.completed_review_refs == []
def test_reprocess_rejects_running(tmp_path, monkeypatch):
monkeypatch.setattr(
"backend.services.job_runner.enqueue_pipeline",
lambda *a, **k: "x",
)
client = _client(tmp_path)
meta = client.post("/api/projects", json={"name": "board"}).json()
pid = meta["id"]
storage = client.app.state.storage
proj_svc.update_project(
storage, "local", pid, status="running", has_bom=True, has_netlist=True,
)
resp = client.post(f"/api/pipeline/{pid}/reprocess", json={"mode": "failed"})
assert resp.status_code == 409