Add parallel Placement pipeline for routing-first topology plans.
Ship a free, analysis-independent job that writes placement_plan.json (domains/satellites, no mm) with API, SSE progress, and a minimal project UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -132,6 +132,12 @@ def build_functional_groups(
|
||||
return FunctionalGroupsReport(objective="routing", domains=domains, groups=groups)
|
||||
|
||||
|
||||
# Alias used by the dedicated Placement pipeline (same topology artifact).
|
||||
build_placement_plan = build_functional_groups
|
||||
PlacementPlan = FunctionalGroupsReport
|
||||
|
||||
|
||||
|
||||
def load_capacitance_farads(comp: Component) -> float | None:
|
||||
"""Crystal CL from SimpleComponentSpecs.values, if present."""
|
||||
specs = comp.specs
|
||||
|
||||
@@ -101,8 +101,11 @@ async def _run() -> None:
|
||||
await pipeline_svc.run_regen_pipeline(
|
||||
storage, user_id, project_id, stages,
|
||||
)
|
||||
elif mode == "placement":
|
||||
from backend.services import placement_pipeline as placement_svc
|
||||
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
|
||||
else:
|
||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run' or 'regen'")
|
||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run', 'regen', or 'placement'")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -492,9 +492,204 @@ async def status(project_id: str, request: Request):
|
||||
"summary": meta.summary,
|
||||
"pipeline_state": meta.pipeline_state,
|
||||
"running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED),
|
||||
"placement_status": meta.placement_status,
|
||||
"placement_state": meta.placement_state,
|
||||
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placement pipeline (parallel — topology only, no LLM / no credits)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PLACEMENT_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
|
||||
_PLACEMENT_SSE_TERMINAL = frozenset({
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/placement/start", status_code=202)
|
||||
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
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting placement")
|
||||
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 already running or queued")
|
||||
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot start placement from placement_status={meta.placement_status}",
|
||||
)
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_status="queued",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=None,
|
||||
placement_execution_name=None,
|
||||
)
|
||||
# Clear before enqueue so the placement SSE client never stops on a
|
||||
# leftover analysis ``pipeline_complete`` in the shared event log.
|
||||
try:
|
||||
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
|
||||
except Exception:
|
||||
logger.exception("failed to clear events before placement start for %s", project_id)
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_placement_pipeline(
|
||||
project_id, owner_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_placement_pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_state={"error": "Failed to enqueue placement worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue placement worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/placement/cancel")
|
||||
async def cancel_placement(project_id: str, request: Request):
|
||||
"""Soft-cancel the Placement pipeline via ``placement_cancel_requested``."""
|
||||
from backend.services.placement_pipeline import placement_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not placement_busy(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Placement is not running (placement_status={meta.placement_status})",
|
||||
)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_cancel_requested=True,
|
||||
)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/plan")
|
||||
async def get_placement_plan(project_id: str, request: Request):
|
||||
"""Return ``placement_plan.json`` (F1 topology — no coordinates)."""
|
||||
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)}/placement_plan.json"
|
||||
if not storage.exists(key):
|
||||
# Fallback for plans written only as functional_groups during analysis.
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/functional_groups.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Placement plan not found — run placement first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/events")
|
||||
async def placement_events(project_id: str, request: Request):
|
||||
"""SSE stream for Placement pipeline progress (watches placement_* only)."""
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.placement_execution_name
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
saw_active = (meta.placement_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.placement_status or "draft"
|
||||
if pst in ("queued", "running"):
|
||||
saw_active = True
|
||||
elif saw_active and pst in ("complete", "error", "cancelled"):
|
||||
# Worker wrote terminal status; if SSE missed the event,
|
||||
# surface a synthetic terminal after a short grace.
|
||||
crash_detected["reason"] = f"placement_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=_PLACEMENT_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip leftover analysis events if the log was not cleared yet.
|
||||
if not (
|
||||
ev.startswith("placement_")
|
||||
or ev == "heartbeat"
|
||||
):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if ev in _PLACEMENT_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.placement_state:
|
||||
err = cur.placement_state.get("error")
|
||||
yield {
|
||||
"event": "placement_error",
|
||||
"data": json.dumps({
|
||||
"error": err or crash_detected["reason"]
|
||||
or "placement 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -46,6 +46,9 @@ TERMINAL_EVENTS = frozenset({
|
||||
"pipeline_error",
|
||||
"pipeline_cancelled",
|
||||
"pipeline_paused",
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@@ -131,19 +134,19 @@ async def tail_events(
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
heartbeat_interval: float = 15.0,
|
||||
terminal_events: frozenset[str] | None = None,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Yield events from the GCS-backed event log in order.
|
||||
|
||||
Stops yielding after a terminal event (``pipeline_complete``,
|
||||
``pipeline_error``, ``pipeline_cancelled``). Emits a
|
||||
``{"event": "heartbeat", "data": {}}`` synthetic event roughly every
|
||||
``heartbeat_interval`` seconds when no real events arrive, matching
|
||||
the behaviour of the in-memory broker's SSE loop.
|
||||
Stops yielding after a terminal event (default ``TERMINAL_EVENTS``).
|
||||
Emits a ``{"event": "heartbeat", "data": {}}`` synthetic event roughly
|
||||
every ``heartbeat_interval`` seconds when no real events arrive.
|
||||
|
||||
The caller is expected to handle disconnects/cancellations and
|
||||
secondary terminal-detection (``meta.status``, Cloud Run execution
|
||||
state) on top of this iterator.
|
||||
"""
|
||||
stop_on = terminal_events if terminal_events is not None else TERMINAL_EVENTS
|
||||
prefix = _events_prefix(user_id, project_id)
|
||||
last_seen_key: str | None = None
|
||||
last_emit_ts = 0.0
|
||||
@@ -166,7 +169,7 @@ async def tail_events(
|
||||
emitted_any = True
|
||||
last_seen_key = key
|
||||
last_emit_ts = asyncio.get_event_loop().time()
|
||||
if msg.get("event") in TERMINAL_EVENTS:
|
||||
if msg.get("event") in stop_on:
|
||||
return
|
||||
|
||||
now = asyncio.get_event_loop().time()
|
||||
|
||||
@@ -58,8 +58,11 @@ def _spawn_local_subprocess(
|
||||
free: bool,
|
||||
mode: str = "run",
|
||||
regen_stages: list[str] | None = None,
|
||||
proc_key: str | None = None,
|
||||
execution_name: str | None = None,
|
||||
) -> str:
|
||||
name = _local_execution_name(project_id)
|
||||
key = proc_key or project_id
|
||||
name = execution_name or _local_execution_name(project_id)
|
||||
env = os.environ.copy()
|
||||
env["PROJECT_ID"] = project_id
|
||||
env["USER_ID"] = user_id
|
||||
@@ -74,17 +77,20 @@ def _spawn_local_subprocess(
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
_write_pid(project_id, proc.pid)
|
||||
_write_pid(key, proc.pid)
|
||||
with _local_procs_lock:
|
||||
# Reap any old proc for the same project before tracking the new one.
|
||||
prior = _local_procs.pop(project_id, None)
|
||||
# Reap any old proc for the same key before tracking the new one.
|
||||
prior = _local_procs.pop(key, None)
|
||||
if prior is not None:
|
||||
try:
|
||||
prior.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
_local_procs[project_id] = proc
|
||||
logger.info("dev: spawned worker subprocess pid=%s for %s", proc.pid, project_id)
|
||||
_local_procs[key] = proc
|
||||
logger.info(
|
||||
"dev: spawned worker subprocess pid=%s for %s mode=%s",
|
||||
proc.pid, project_id, mode,
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
@@ -341,6 +347,9 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
|
||||
if execution_name.startswith("local/projects/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(project_id)
|
||||
if execution_name.startswith("local/placement/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(f"placement:{project_id}")
|
||||
return _cloud_run_state(execution_name)
|
||||
|
||||
|
||||
@@ -356,4 +365,21 @@ def cancel_execution(execution_name: str | None) -> None:
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(project_id)
|
||||
return
|
||||
if execution_name.startswith("local/placement/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(f"placement:{project_id}")
|
||||
return
|
||||
_cloud_run_cancel(execution_name)
|
||||
|
||||
|
||||
def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
|
||||
"""Dispatch the parallel Placement pipeline (topology plan, no LLM)."""
|
||||
if use_cloud_run_jobs():
|
||||
return _enqueue_cloud_run_job(
|
||||
project_id, user_id, resume=False, free=True, mode="placement",
|
||||
)
|
||||
return _spawn_local_subprocess(
|
||||
project_id, user_id, resume=False, free=True, mode="placement",
|
||||
proc_key=f"placement:{project_id}",
|
||||
execution_name=f"local/placement/{project_id}",
|
||||
)
|
||||
|
||||
@@ -259,6 +259,8 @@ class PipelineWorkspace:
|
||||
self._upload_file("design_graph.json")
|
||||
self._upload_file("layout_graph.json")
|
||||
self._upload_file("impedance_nets.json")
|
||||
self._upload_file("functional_groups.json")
|
||||
self._upload_file("placement_plan.json")
|
||||
self._upload_file("bom_summary.json")
|
||||
self._upload_file("derating.json")
|
||||
self._upload_file("report.json")
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Placement pipeline — parallel to analysis, topology only (no LLM / no mm).
|
||||
|
||||
Stages: ensure_graph → classify → write_plan.
|
||||
Writes ``placement_plan.json`` (+ refreshes ``functional_groups.json``).
|
||||
Uses ``placement_status`` so analysis ``status`` is untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.functional_groups import build_placement_plan
|
||||
from backend.pinscopex.graph import build_graph
|
||||
from backend.pinscopex.models import ComponentConstraints, DesignGraph
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.pipeline import PipelineWorkspace, broker
|
||||
from backend.services.storage import StorageBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLACEMENT_ACTIVE = frozenset({"queued", "running"})
|
||||
_ANALYSIS_BUSY = frozenset({
|
||||
proj_svc.STATUS_QUEUED,
|
||||
proj_svc.STATUS_RUNNING,
|
||||
})
|
||||
|
||||
|
||||
def _load_constraints_map(extracted_dir: Path) -> dict[str, ComponentConstraints]:
|
||||
"""Load per-MPN extractions without importing the Anthropic review path."""
|
||||
result: dict[str, ComponentConstraints] = {}
|
||||
if not extracted_dir.is_dir():
|
||||
return result
|
||||
for f in extracted_dir.glob("*.json"):
|
||||
try:
|
||||
c = ComponentConstraints.model_validate_json(
|
||||
f.read_text(encoding="utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("skipping bad extraction %s", f)
|
||||
continue
|
||||
result[c.mpn] = c
|
||||
return result
|
||||
|
||||
|
||||
def _publish(project_id: str, event: str, data: dict) -> None:
|
||||
broker.publish(project_id, event, data)
|
||||
|
||||
|
||||
def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
|
||||
payload: dict = {"stage": stage, "status": status}
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
_publish(project_id, "placement_step_update", payload)
|
||||
|
||||
|
||||
async def run_placement_pipeline(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
"""Run the placement topology pipeline (no extraction / review)."""
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
if not meta:
|
||||
raise ValueError(f"Project {project_id} not found")
|
||||
|
||||
# Boot: queued → running on placement_status only.
|
||||
if meta.placement_status not in _PLACEMENT_ACTIVE:
|
||||
logger.warning(
|
||||
"placement worker booted with placement_status=%s for %s; exiting",
|
||||
meta.placement_status, project_id,
|
||||
)
|
||||
return
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="running",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=None,
|
||||
)
|
||||
|
||||
try:
|
||||
async with PipelineWorkspace(storage, user_id, project_id) as ws:
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
graph = await _ensure_graph(ws, meta, project_id)
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "classify", "running", "domains and satellite roles")
|
||||
extracted_dir = ws.local_path("extracted")
|
||||
cmap = _load_constraints_map(extracted_dir)
|
||||
plan = build_placement_plan(graph, cmap)
|
||||
_step(
|
||||
project_id, "classify", "complete",
|
||||
f"{len(plan.domains)} domains, {len(plan.groups)} IC groups",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "write_plan", "running")
|
||||
plan_path = ws.local_path("placement_plan.json")
|
||||
plan_json = plan.model_dump_json(indent=2) + "\n"
|
||||
plan_path.write_text(plan_json)
|
||||
# Keep functional_groups.json in sync for consumers that already read it.
|
||||
fg_path = ws.local_path("functional_groups.json")
|
||||
fg_path.write_text(plan_json)
|
||||
ws._upload_file("placement_plan.json")
|
||||
ws._upload_file("functional_groups.json")
|
||||
_step(project_id, "write_plan", "complete", "placement_plan.json")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="complete",
|
||||
placement_state={
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
},
|
||||
placement_cancel_requested=False,
|
||||
)
|
||||
_publish(project_id, "placement_complete", {
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("placement pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_state={"error": str(e)},
|
||||
)
|
||||
_publish(project_id, "placement_error", {"error": str(e)})
|
||||
|
||||
|
||||
async def _ensure_graph(ws: PipelineWorkspace, meta, project_id: str) -> DesignGraph:
|
||||
"""Reuse design_graph.json when present; otherwise graph_build only."""
|
||||
graph_path = ws.local_path("design_graph.json")
|
||||
if graph_path.is_file():
|
||||
_step(project_id, "ensure_graph", "running", "reusing design_graph.json")
|
||||
graph = DesignGraph.model_validate_json(graph_path.read_text(encoding="utf-8"))
|
||||
_step(
|
||||
project_id, "ensure_graph", "complete",
|
||||
f"{len(graph.components)} components (cached)",
|
||||
)
|
||||
return graph
|
||||
|
||||
_step(project_id, "ensure_graph", "running", "building design graph")
|
||||
bom_path = ws.local_path("uploads/bom.csv")
|
||||
netlist_path = ws.netlist_local_path()
|
||||
if not bom_path.is_file() or not Path(netlist_path).is_file():
|
||||
raise FileNotFoundError("Missing BOM or netlist for placement graph_build")
|
||||
|
||||
col_map = meta.bom_columns or {}
|
||||
graph = build_graph(
|
||||
str(netlist_path),
|
||||
str(bom_path),
|
||||
str(ws.local_path("extracted")),
|
||||
str(ws.local_path("patterns")),
|
||||
str(ws.local_path("models")),
|
||||
reference_col=col_map.get("reference", "Reference"),
|
||||
mpn_col=col_map.get("mpn", "Manufacturer Part Number"),
|
||||
include_subdesigns=(
|
||||
set(meta.netlist_subdesigns)
|
||||
if meta.netlist_subdesigns is not None
|
||||
else None
|
||||
),
|
||||
pcb_path=ws.local_path("uploads/pcb.kicad_pcb"),
|
||||
)
|
||||
graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("design_graph.json")
|
||||
_step(
|
||||
project_id, "ensure_graph", "complete",
|
||||
f"{len(graph.components)} components, {len(graph.nets)} nets",
|
||||
)
|
||||
return graph
|
||||
|
||||
|
||||
def _cancelled(storage: StorageBackend, user_id: str, project_id: str) -> bool:
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
return bool(meta and meta.placement_cancel_requested)
|
||||
|
||||
|
||||
def _finish_cancelled(storage: StorageBackend, user_id: str, project_id: str) -> None:
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="cancelled",
|
||||
placement_cancel_requested=False,
|
||||
)
|
||||
_publish(project_id, "placement_cancelled", {})
|
||||
|
||||
|
||||
def placement_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return (meta.placement_status or "draft") in _PLACEMENT_ACTIVE
|
||||
|
||||
|
||||
def analysis_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return meta.status in _ANALYSIS_BUSY
|
||||
@@ -132,6 +132,13 @@ class ProjectMeta(BaseModel):
|
||||
# gate (inside _charge_for_logs) and exits cleanly.
|
||||
cancel_requested: bool = False
|
||||
|
||||
# Placement pipeline (parallel to analysis — does not overwrite status).
|
||||
# draft | queued | running | complete | error | cancelled
|
||||
placement_status: str = "draft"
|
||||
placement_state: dict[str, Any] | None = None
|
||||
placement_execution_name: str | None = None
|
||||
placement_cancel_requested: bool = False
|
||||
|
||||
|
||||
def completed_review_refs_for_retry(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
|
||||
Reference in New Issue
Block a user