Add MODE=pcb layout exam job with AI and deterministic checks.
Parallel pcb_status pipeline: parse board, classify domains/groups, inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review. Findings merge into the report UI. No auto-place or pcbnew write-back.
This commit is contained in:
+189
-2
@@ -463,7 +463,7 @@ async def events(project_id: str, request: Request):
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip placement events in the shared log.
|
||||
if ev.startswith("placement_"):
|
||||
if ev.startswith("placement_") or ev.startswith("pcb_"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
@@ -527,6 +527,9 @@ async def status(project_id: str, request: Request):
|
||||
"placement_status": meta.placement_status,
|
||||
"placement_state": meta.placement_state,
|
||||
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
|
||||
"pcb_status": meta.pcb_status,
|
||||
"pcb_state": meta.pcb_state,
|
||||
"pcb_running": (meta.pcb_status or "draft") in ("queued", "running"),
|
||||
"healed": healed is not None,
|
||||
}
|
||||
|
||||
@@ -548,6 +551,7 @@ _PLACEMENT_SSE_TERMINAL = frozenset({
|
||||
async def start_placement(project_id: str, request: Request):
|
||||
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
|
||||
from backend.services.placement_pipeline import analysis_busy, placement_busy
|
||||
from backend.services.pcb_pipeline import pcb_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
@@ -557,6 +561,8 @@ async def start_placement(project_id: str, request: Request):
|
||||
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||
if placement_busy(meta):
|
||||
raise HTTPException(409, "Placement pipeline already running or queued")
|
||||
if pcb_busy(meta):
|
||||
raise HTTPException(409, "PCB review is running; wait or cancel it first")
|
||||
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
|
||||
raise HTTPException(
|
||||
409,
|
||||
@@ -694,7 +700,7 @@ async def placement_events(project_id: str, request: Request):
|
||||
if not (
|
||||
ev.startswith("placement_")
|
||||
or ev == "heartbeat"
|
||||
):
|
||||
) or ev.startswith("pcb_"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
@@ -734,6 +740,187 @@ async def placement_events(project_id: str, request: Request):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PCB review pipeline (parallel — exam, not auto-place)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PCB_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
|
||||
_PCB_SSE_TERMINAL = frozenset({
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/pcb/start", status_code=202)
|
||||
async def start_pcb(project_id: str, request: Request):
|
||||
from backend.services.pcb_pipeline import analysis_busy, pcb_busy, placement_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_pcb:
|
||||
raise HTTPException(400, "Upload a .kicad_pcb before starting PCB review")
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting PCB review")
|
||||
if analysis_busy(meta):
|
||||
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||
if placement_busy(meta):
|
||||
raise HTTPException(409, "Placement pipeline is running; wait or cancel it first")
|
||||
if pcb_busy(meta):
|
||||
raise HTTPException(409, "PCB review already running or queued")
|
||||
if (meta.pcb_status or "draft") not in _PCB_START_OK:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot start PCB review from pcb_status={meta.pcb_status}",
|
||||
)
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_status="queued",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state=None,
|
||||
pcb_execution_name=None,
|
||||
)
|
||||
try:
|
||||
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
|
||||
except Exception:
|
||||
logger.exception("failed to clear events before PCB start for %s", project_id)
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pcb_pipeline(
|
||||
project_id, owner_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pcb_pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_state={"error": "Failed to enqueue PCB worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue PCB worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/pcb/cancel")
|
||||
async def cancel_pcb(project_id: str, request: Request):
|
||||
from backend.services.pcb_pipeline import pcb_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not pcb_busy(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"PCB review is not running (pcb_status={meta.pcb_status})",
|
||||
)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_cancel_requested=True,
|
||||
)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/pcb/inventory")
|
||||
async def get_pcb_inventory(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/pcb_inventory.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "PCB inventory not found — run PCB review first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/pcb/events")
|
||||
async def pcb_events(project_id: str, request: Request):
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.pcb_execution_name
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
saw_active = (meta.pcb_status or "draft") in ("queued", "running")
|
||||
while True:
|
||||
await asyncio.sleep(poll_interval)
|
||||
try:
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
except Exception:
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
pst = cur.pcb_status or "draft"
|
||||
if pst in ("queued", "running"):
|
||||
saw_active = True
|
||||
elif saw_active and pst in ("complete", "error", "cancelled"):
|
||||
crash_detected["reason"] = f"pcb_status={pst} (terminal)"
|
||||
return
|
||||
if execution_name:
|
||||
try:
|
||||
state = job_runner.get_execution_state(execution_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
if state in _EXEC_TERMINAL and (
|
||||
saw_active or pst in ("queued", "running")
|
||||
):
|
||||
crash_detected["reason"] = f"execution state={state}"
|
||||
return
|
||||
|
||||
watcher = asyncio.create_task(watch_status())
|
||||
try:
|
||||
async for msg in event_bridge.tail_events(
|
||||
storage, owner_user_id, project_id,
|
||||
terminal_events=_PCB_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
if not (ev.startswith("pcb_") or ev == "heartbeat"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if ev in _PCB_SSE_TERMINAL:
|
||||
return
|
||||
|
||||
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")
|
||||
yield {
|
||||
"event": "pcb_error",
|
||||
"data": json.dumps({
|
||||
"error": err or crash_detected["reason"]
|
||||
or "pcb worker terminated without a terminal event",
|
||||
"synthetic": True,
|
||||
}),
|
||||
}
|
||||
finally:
|
||||
watcher.cancel()
|
||||
try:
|
||||
await watcher
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
ping=15,
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -148,6 +148,9 @@ async def get_project(project_id: str, request: Request):
|
||||
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
|
||||
if healed_pl is not None:
|
||||
meta = healed_pl
|
||||
healed_pcb = proj_svc.heal_if_pcb_stuck(storage, owner_user_id, project_id)
|
||||
if healed_pcb is not None:
|
||||
meta = healed_pcb
|
||||
return meta.model_dump()
|
||||
|
||||
|
||||
|
||||
@@ -40,10 +40,16 @@ async def get_report(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
key = f"{prefix}/report.json"
|
||||
if not storage.exists(key):
|
||||
schema_key = f"{prefix}/report.json"
|
||||
pcb_key = f"{prefix}/pcb_report.json"
|
||||
schema = storage.read_json(schema_key) if storage.exists(schema_key) else None
|
||||
pcb = storage.read_json(pcb_key) if storage.exists(pcb_key) else None
|
||||
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
|
||||
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
if merged is None:
|
||||
raise HTTPException(404, "Report not found — run the pipeline first")
|
||||
return JSONResponse(storage.read_json(key))
|
||||
return JSONResponse(merged)
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/cad-bridge")
|
||||
|
||||
Reference in New Issue
Block a user