diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py index 1ab2a06..a5f7c81 100644 --- a/backend/routers/pipeline.py +++ b/backend/routers/pipeline.py @@ -892,16 +892,16 @@ async def pcb_events(project_id: str, request: Request): if crash_detected["reason"] is not None: cur = proj_svc.get_project(storage, owner_user_id, project_id) - err = None - if cur and cur.pcb_state: - err = cur.pcb_state.get("error") + from backend.services.pcb_pipeline import pcb_sse_terminal_from_status + + 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 { - "event": "pcb_error", - "data": json.dumps({ - "error": err or crash_detected["reason"] - or "pcb worker terminated without a terminal event", - "synthetic": True, - }), + "event": ev, + "data": json.dumps(payload), } finally: watcher.cancel() diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py index 1d382a9..231af1f 100644 --- a/backend/services/pcb_pipeline.py +++ b/backend/services/pcb_pipeline.py @@ -38,6 +38,34 @@ _ANALYSIS_BUSY = frozenset({ _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( extracted_dir: Path, storage: StorageBackend | None = None, @@ -262,6 +290,11 @@ async def run_pcb_pipeline( ws._upload_file("periscope-findings.json") _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( storage, user_id, project_id, pcb_status="complete", @@ -273,11 +306,6 @@ async def run_pcb_pipeline( }, pcb_cancel_requested=False, ) - _publish(project_id, "pcb_complete", { - "findings": len(findings), - "domains": len(plan.domains), - "groups": len(plan.groups), - }) except Exception as e: logger.exception("pcb pipeline failed for %s", project_id) proj_svc.update_project( diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index aa9f4c9..fdb8f18 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ 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 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] 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. diff --git a/frontend/src/app/(app)/project/[id]/pcb/page.tsx b/frontend/src/app/(app)/project/[id]/pcb/page.tsx index 74d91b8..8841cf7 100644 --- a/frontend/src/app/(app)/project/[id]/pcb/page.tsx +++ b/frontend/src/app/(app)/project/[id]/pcb/page.tsx @@ -2,6 +2,7 @@ import { use, useEffect, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PipelineStepper } from "@/components/progress/pipeline-stepper"; @@ -26,6 +27,7 @@ export default function PcbReviewPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); + const router = useRouter(); const [projectName, setProjectName] = useState(""); const [pcbStatus, setPcbStatus] = useState("draft"); const [analysisBusy, setAnalysisBusy] = useState(false); @@ -57,8 +59,10 @@ export default function PcbReviewPage({ }, [id]); useEffect(() => { - if (done) setPcbStatus("complete"); - }, [done]); + if (done && !cancelled && !(error && !/pcb_status=complete/.test(error))) { + setPcbStatus("complete"); + } + }, [done, cancelled, error]); useEffect(() => { if (!statusLoaded) return; @@ -92,9 +96,16 @@ export default function PcbReviewPage({ }; 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; + useEffect(() => { + if (!done || cancelled || displayError) return; + router.push(`/project/${id}/report?domain=layout`); + }, [done, cancelled, displayError, id, router]); + return (
@@ -156,15 +167,15 @@ export default function PcbReviewPage({ )} - {error && ( -

{error}

+ {displayError && ( +

{displayError}

)} {cancelled && (

PCB review cancelled.

)} - {finished && !cancelled && !error && ( + {finished && !cancelled && !displayError && ( @@ -177,6 +188,9 @@ export default function PcbReviewPage({ {summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "} {summary?.groups ?? inventory?.group_count ?? "—"} groups

+

+ Open the layout report to expand the findings tree (IC → pin/net → cards). +

{inventory && (

{inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists). diff --git a/frontend/src/hooks/use-pcb-progress.ts b/frontend/src/hooks/use-pcb-progress.ts index a5c80cb..c6de1c0 100644 --- a/frontend/src/hooks/use-pcb-progress.ts +++ b/frontend/src/hooks/use-pcb-progress.ts @@ -60,6 +60,10 @@ export function usePcbProgress(projectId: string | null, enabled = true) { 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(); @@ -75,7 +79,24 @@ export function usePcbProgress(projectId: string | null, enabled = true) { } 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); terminalRef.current = true; esRef.current?.close(); diff --git a/tests/test_pcb_sse_terminal.py b/tests/test_pcb_sse_terminal.py new file mode 100644 index 0000000..aa92420 --- /dev/null +++ b/tests/test_pcb_sse_terminal.py @@ -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"