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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<string>("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 (
|
||||
<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">
|
||||
@@ -156,15 +167,15 @@ export default function PcbReviewPage({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{displayError && (
|
||||
<p className="text-sm text-destructive">{displayError}</p>
|
||||
)}
|
||||
|
||||
{cancelled && (
|
||||
<p className="text-sm text-muted-foreground">PCB review cancelled.</p>
|
||||
)}
|
||||
|
||||
{finished && !cancelled && !error && (
|
||||
{finished && !cancelled && !displayError && (
|
||||
<Card>
|
||||
<CardHeader className="pb-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?.groups ?? inventory?.group_count ?? "—"} groups
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Open the layout report to expand the findings tree (IC → pin/net → cards).
|
||||
</p>
|
||||
{inventory && (
|
||||
<p className="text-muted-foreground">
|
||||
{inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user