"""Stable identifiers shared with the frontend canvas and SSE payloads.""" from __future__ import annotations import re _NODE_RUN_ID_RE = re.compile(r"^(?P.+)-attempt(?P\d+)$") def make_node_run_id(run_id: str, node_id: str, attempt: int) -> str: """Return ``${run_id}-${node_id}-attempt{attempt}``. Random UUIDs are forbidden. The run dimension is part of the id because the node-runs table primary key is ``node_run_id`` alone: without it, two runs reusing the same plan template would collide on ``${node_id}-attempt{n}``. """ if not run_id or run_id.strip() != run_id: raise ValueError("run_id must be a non-empty trimmed string") if not node_id or node_id.strip() != node_id: raise ValueError("node_id must be a non-empty trimmed string") if attempt < 1: raise ValueError("attempt must be >= 1") return f"{run_id}-{node_id}-attempt{attempt}" def parse_node_run_id(node_run_id: str) -> tuple[str, int]: """Split a canonical node_run_id tail back into ``(..., attempt)``. The returned first element is the run-scoped node portion (``${run_id}-${node_id}``); callers that need the bare node id should strip the known run prefix themselves. """ match = _NODE_RUN_ID_RE.match(node_run_id or "") if match is None: raise ValueError(f"invalid node_run_id: {node_run_id!r}") return match.group("scope"), int(match.group("attempt"))