"""Tracing helpers shared by every node.""" import time from functools import wraps from typing import Callable, Optional def trace_event(node: str, *, status: str = "ok", duration_ms: float = 0.0, summary: str = "", payload: Optional[dict] = None) -> dict: """Build a single TraceEvent dict matching the state schema.""" return { "node": node, "status": status, "duration_ms": round(duration_ms, 2), "summary": summary, "payload": payload or {}, } def traced(node_name: str) -> Callable: """ Decorator: time a node, swallow exceptions into a trace event, and guarantee the node always returns a dict containing a `trace` list. The wrapped function should return a partial state dict WITHOUT a `trace` field — this decorator injects the timing/status event automatically. If the wrapped function returns its own `trace` list, those events are kept and the timing event is appended. """ def decorator(fn): @wraps(fn) def wrapper(state, *args, **kwargs): t0 = time.perf_counter() try: result = fn(state, *args, **kwargs) or {} dt_ms = (time.perf_counter() - t0) * 1000 summary = result.pop("_summary", "") payload = result.pop("_payload", {}) event = trace_event( node_name, status="ok", duration_ms=dt_ms, summary=summary, payload=payload, ) existing = result.get("trace", []) result["trace"] = existing + [event] return result except Exception as e: dt_ms = (time.perf_counter() - t0) * 1000 event = trace_event( node_name, status="error", duration_ms=dt_ms, summary=f"{type(e).__name__}: {e}", payload={"error": str(e)}, ) return {"trace": [event], "error": f"{node_name}: {e}"} return wrapper return decorator