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:
|
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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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