Treat finished PCB review as complete, not a raw SSE error.

The status watcher synthesized pcb_error with pcb_status=complete (terminal)
when the worker finished before pcb_complete arrived. Map that hatch to
pcb_complete, publish the event before flipping status, and send /pcb to
the layout report tree.
This commit is contained in:
2026-09-20 10:23:53 +02:00
parent 1f3876eadc
commit 187605763d
6 changed files with 136 additions and 22 deletions
+9 -9
View File
@@ -892,16 +892,16 @@ async def pcb_events(project_id: str, request: Request):
if crash_detected["reason"] is not None: if crash_detected["reason"] is not None:
cur = proj_svc.get_project(storage, owner_user_id, project_id) cur = proj_svc.get_project(storage, owner_user_id, project_id)
err = None from backend.services.pcb_pipeline import pcb_sse_terminal_from_status
if cur and cur.pcb_state:
err = cur.pcb_state.get("error") ev, payload = pcb_sse_terminal_from_status(
cur.pcb_status if cur else None,
cur.pcb_state if cur else None,
crash_detected["reason"],
)
yield { yield {
"event": "pcb_error", "event": ev,
"data": json.dumps({ "data": json.dumps(payload),
"error": err or crash_detected["reason"]
or "pcb worker terminated without a terminal event",
"synthetic": True,
}),
} }
finally: finally:
watcher.cancel() watcher.cancel()
+33 -5
View File
@@ -38,6 +38,34 @@ _ANALYSIS_BUSY = frozenset({
_PLACEMENT_ACTIVE = frozenset({"queued", "running"}) _PLACEMENT_ACTIVE = frozenset({"queued", "running"})
def pcb_sse_terminal_from_status(
pcb_status: str | None,
pcb_state: dict | None,
reason: str | None = None,
) -> tuple[str, dict]:
"""Map a terminal ``pcb_status`` to the SSE event the UI expects.
The events watcher used to emit ``pcb_error`` with
``pcb_status=complete (terminal)`` when the worker finished but the
log lagged — the progress page then showed that raw string.
"""
state = pcb_state if isinstance(pcb_state, dict) else {}
pst = pcb_status or "draft"
blob = f"{reason or ''} {state.get('error') or ''}"
if pst == "complete" or "pcb_status=complete" in blob:
return "pcb_complete", {
"findings": int(state.get("findings") or 0),
"domains": int(state.get("domains") or 0),
"groups": int(state.get("groups") or 0),
"synthetic": True,
}
if pst == "cancelled" or "pcb_status=cancelled" in blob:
return "pcb_cancelled", {"synthetic": True}
err = state.get("error") or reason or "pcb worker terminated without a terminal event"
return "pcb_error", {"error": err, "synthetic": True}
def _load_constraints_map( def _load_constraints_map(
extracted_dir: Path, extracted_dir: Path,
storage: StorageBackend | None = None, storage: StorageBackend | None = None,
@@ -262,6 +290,11 @@ async def run_pcb_pipeline(
ws._upload_file("periscope-findings.json") ws._upload_file("periscope-findings.json")
_step(project_id, "write_report", "complete", f"{len(findings)} findings") _step(project_id, "write_report", "complete", f"{len(findings)} findings")
_publish(project_id, "pcb_complete", {
"findings": len(findings),
"domains": len(plan.domains),
"groups": len(plan.groups),
})
proj_svc.update_project( proj_svc.update_project(
storage, user_id, project_id, storage, user_id, project_id,
pcb_status="complete", pcb_status="complete",
@@ -273,11 +306,6 @@ async def run_pcb_pipeline(
}, },
pcb_cancel_requested=False, pcb_cancel_requested=False,
) )
_publish(project_id, "pcb_complete", {
"findings": len(findings),
"domains": len(plan.domains),
"groups": len(plan.groups),
})
except Exception as e: except Exception as e:
logger.exception("pcb pipeline failed for %s", project_id) logger.exception("pcb pipeline failed for %s", project_id)
proj_svc.update_project( proj_svc.update_project(
+9 -1
View File
@@ -2,6 +2,14 @@
What's new in Periscope. What's new in Periscope.
## 2.33.2 — 2026-09-20 — PCB complete opens the findings tree
A finished PCB exam no longer shows the raw watcher string `pcb_status=complete (terminal)`. The SSE hatch emits `pcb_complete`; the PCB page then opens `/report?domain=layout` (expand-on-click tree).
- [Fixed] Synthetic PCB SSE for `pcb_status=complete` is `pcb_complete`, not `pcb_error`.
- [Fixed] `/project/[id]/pcb` navigates to the layout report when the run finishes.
- [Fixed] Restored changelog heading for 2.33.0 (version history).
## 2.33.1 — 2026-09-20 — ESD/PI filters, ERROR-first sort, findings tree ## 2.33.1 — 2026-09-20 — ESD/PI filters, ERROR-first sort, findings tree
PE-ESD-001 only on J* ∩ IC nets (no NC / unconnected / VSYS/GND/3V3 spam). PE-PI-001 treats any capacitor on the rail, including KiCad `/` names, as decoupling. Report API and UI sort ERROR then WARNING then INFO, then RULE > RISK > REVIEW > INFO. The report left list is an expand-on-click tree (IC → pin/net → cards), not a dump of open groups. PE-ESD-001 only on J* ∩ IC nets (no NC / unconnected / VSYS/GND/3V3 spam). PE-PI-001 treats any capacitor on the rail, including KiCad `/` names, as decoupling. Report API and UI sort ERROR then WARNING then INFO, then RULE > RISK > REVIEW > INFO. The report left list is an expand-on-click tree (IC → pin/net → cards), not a dump of open groups.
@@ -11,7 +19,7 @@ PE-ESD-001 only on J* ∩ IC nets (no NC / unconnected / VSYS/GND/3V3 spam). PE-
- [Changed] GET `/report` and `complete_findings` order by severity then class. - [Changed] GET `/report` and `complete_findings` order by severity then class.
- [Changed] Findings sidebar is a collapsed tree; PCB exam cards live in the same tree. - [Changed] Findings sidebar is a collapsed tree; PCB exam cards live in the same tree.
## 2.33.0 — 2026-09-20 — Fase B: hierarchy, derating bands, timing, PI, ESD/return
Usable slices (not a platform): component→pin→net→block inventory, capacitor PASS/MARGIN/RISK from Vop/Vrated, RC/strap timing only with numbers, local vs bulk PI, ESD and HS return as REVIEW. Usable slices (not a platform): component→pin→net→block inventory, capacitor PASS/MARGIN/RISK from Vop/Vrated, RC/strap timing only with numbers, local vs bulk PI, ESD and HS return as REVIEW.
@@ -2,6 +2,7 @@
import { use, useEffect, useState } from "react"; import { use, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PipelineStepper } from "@/components/progress/pipeline-stepper"; import { PipelineStepper } from "@/components/progress/pipeline-stepper";
@@ -26,6 +27,7 @@ export default function PcbReviewPage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = use(params); const { id } = use(params);
const router = useRouter();
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
const [pcbStatus, setPcbStatus] = useState<string>("draft"); const [pcbStatus, setPcbStatus] = useState<string>("draft");
const [analysisBusy, setAnalysisBusy] = useState(false); const [analysisBusy, setAnalysisBusy] = useState(false);
@@ -57,8 +59,10 @@ export default function PcbReviewPage({
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
if (done) setPcbStatus("complete"); if (done && !cancelled && !(error && !/pcb_status=complete/.test(error))) {
}, [done]); setPcbStatus("complete");
}
}, [done, cancelled, error]);
useEffect(() => { useEffect(() => {
if (!statusLoaded) return; if (!statusLoaded) return;
@@ -92,9 +96,16 @@ export default function PcbReviewPage({
}; };
const finished = alreadyDone || done; const finished = alreadyDone || done;
const isRunning = statusLoaded && active && !done && !cancelled && !error; const displayError =
error && !/pcb_status=complete/.test(error) ? error : null;
const isRunning = statusLoaded && active && !done && !cancelled && !displayError;
const isQueued = isRunning && !started; const isQueued = isRunning && !started;
useEffect(() => {
if (!done || cancelled || displayError) return;
router.push(`/project/${id}/report?domain=layout`);
}, [done, cancelled, displayError, id, router]);
return ( return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6"> <div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@@ -156,15 +167,15 @@ export default function PcbReviewPage({
</Card> </Card>
)} )}
{error && ( {displayError && (
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{displayError}</p>
)} )}
{cancelled && ( {cancelled && (
<p className="text-sm text-muted-foreground">PCB review cancelled.</p> <p className="text-sm text-muted-foreground">PCB review cancelled.</p>
)} )}
{finished && !cancelled && !error && ( {finished && !cancelled && !displayError && (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
@@ -177,6 +188,9 @@ export default function PcbReviewPage({
{summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "} {summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "}
{summary?.groups ?? inventory?.group_count ?? "—"} groups {summary?.groups ?? inventory?.group_count ?? "—"} groups
</p> </p>
<p className="text-muted-foreground">
Open the layout report to expand the findings tree (IC pin/net cards).
</p>
{inventory && ( {inventory && (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists). {inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists).
+22 -1
View File
@@ -60,6 +60,10 @@ export function usePcbProgress(projectId: string | null, enabled = true) {
domains: Number(data.domains) || 0, domains: Number(data.domains) || 0,
groups: Number(data.groups) || 0, groups: Number(data.groups) || 0,
}); });
setSteps((prev) =>
prev.map((s) => ({ ...s, status: "complete" as const, substeps: [...s.substeps] })),
);
setError(null);
setDone(true); setDone(true);
terminalRef.current = true; terminalRef.current = true;
esRef.current?.close(); esRef.current?.close();
@@ -75,7 +79,24 @@ export function usePcbProgress(projectId: string | null, enabled = true) {
} }
if (eventType === "pcb_error") { if (eventType === "pcb_error") {
setError((data.error as string) || "PCB review failed"); const err = String((data.error as string) || "PCB review failed");
// Watcher used to mis-label a finished run as pcb_error.
if (/pcb_status=complete/.test(err)) {
setSummary({
findings: Number(data.findings) || 0,
domains: Number(data.domains) || 0,
groups: Number(data.groups) || 0,
});
setSteps((prev) =>
prev.map((s) => ({ ...s, status: "complete" as const, substeps: [...s.substeps] })),
);
setError(null);
setDone(true);
terminalRef.current = true;
esRef.current?.close();
return;
}
setError(err);
setDone(true); setDone(true);
terminalRef.current = true; terminalRef.current = true;
esRef.current?.close(); esRef.current?.close();
+43
View File
@@ -0,0 +1,43 @@
"""PCB SSE terminal mapping — complete is not pcb_error."""
from backend.services.pcb_pipeline import pcb_sse_terminal_from_status
def test_complete_status_is_pcb_complete_not_error():
ev, data = pcb_sse_terminal_from_status(
"complete",
{"findings": 38, "domains": 3, "groups": 4},
"pcb_status=complete (terminal)",
)
assert ev == "pcb_complete"
assert data["findings"] == 38
assert data["domains"] == 3
assert data["groups"] == 4
assert "error" not in data
def test_complete_reason_without_status_still_completes():
ev, data = pcb_sse_terminal_from_status(
"running",
{},
"pcb_status=complete (terminal)",
)
assert ev == "pcb_complete"
assert data.get("synthetic") is True
def test_error_status_stays_pcb_error():
ev, data = pcb_sse_terminal_from_status(
"error",
{"error": "parse failed"},
"pcb_status=error (terminal)",
)
assert ev == "pcb_error"
assert data["error"] == "parse failed"
def test_cancelled_status_is_pcb_cancelled():
ev, _data = pcb_sse_terminal_from_status(
"cancelled", {}, "pcb_status=cancelled (terminal)",
)
assert ev == "pcb_cancelled"