{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"run-flow","type":"registry:ui","title":"runFlow","description":"Headless topological runner \u2014 \/engine, zero React.","package":"fancy-flow","dependencies":[],"registryDependencies":["types","registry"],"files":[{"path":"components\/fancy\/run-flow\/run-flow.ts","content":"import type {\r\n  ExecutorRegistry,\r\n  FlowEdge,\r\n  FlowGraph,\r\n  FlowNode,\r\n  NodeExecutor,\r\n  RunEvent,\r\n} from \"..\/types\";\r\n\/\/ Both modules are React-free by design \u2014 the `\/engine` entry must not pull in\r\n\/\/ React. Import them directly rather than via the `registry` barrel, which\r\n\/\/ re-exports the RegistryNode component.\r\nimport { getNodeKind, kindIds } from \"..\/registry\/registry\";\r\nimport { resolveNodePorts } from \"..\/registry\/ports\";\r\n\r\nexport type RunOptions = {\r\n  \/** Stop the run after this many ms. Default: no timeout. *\/\r\n  timeoutMs?: number;\r\n  \/** Abort signal \u2014 host can cancel the run. *\/\r\n  signal?: AbortSignal;\r\n  \/** Initial inputs supplied to entry-point nodes (no incoming edges). *\/\r\n  initialInputs?: Record<string, Record<string, unknown>>;\r\n  \/** Nesting depth \u2014 set by `subflow` when it runs a child graph. *\/\r\n  depth?: number;\r\n};\r\n\r\nexport type RunResult = {\r\n  ok: boolean;\r\n  \/** Outputs collected per node, keyed by node id. *\/\r\n  outputs: Record<string, unknown>;\r\n  \/** Error captured if any node threw. *\/\r\n  error?: string;\r\n};\r\n\r\n\/**\r\n * runFlow \u2014 topological execution of a FlowGraph against an ExecutorRegistry.\r\n *\r\n * Each node runs once, when all upstream nodes have produced outputs on the\r\n * connected ports. Decision nodes (or any executor that returns `{ branch:\r\n * 'true' }`) can short-circuit specific output ports \u2014 only edges leaving\r\n * an \"active\" port propagate to downstream nodes.\r\n *\r\n * Cycles are detected and abort the run with an error.\r\n *\r\n * The `onEvent` callback receives a stream of `RunEvent`s \u2014 wire it to a\r\n * status feed, log panel, or store.\r\n *\/\r\nexport async function runFlow(\r\n  graph: FlowGraph,\r\n  executors: ExecutorRegistry,\r\n  onEvent: (event: RunEvent) => void = () => {},\r\n  options: RunOptions = {},\r\n): Promise<RunResult> {\r\n  const { signal, initialInputs = {}, timeoutMs, depth = 0 } = options;\r\n  const outputs: Record<string, unknown> = {};\r\n  const portValues = new Map<string, unknown>(); \/\/ key: `${nodeId}:${portId}`\r\n  const completed = new Set<string>();\r\n  const errors: string[] = [];\r\n\r\n  \/\/ Topological order via Kahn's algorithm. We allow nodes to run as soon\r\n  \/\/ as their incoming edges' source ports have produced values, so the\r\n  \/\/ order here is just a deterministic baseline used for cycle detection.\r\n  const order = topoSort(graph);\r\n  if (order === null) {\r\n    const msg = \"Cycle detected in flow graph \u2014 aborting.\";\r\n    onEvent({ type: \"run-error\", error: msg });\r\n    return { ok: false, outputs, error: msg };\r\n  }\r\n\r\n  const incomingByNode = indexIncoming(graph.edges);\r\n  const timer = timeoutMs ? setTimeout(() => errors.push(`Run timed out after ${timeoutMs}ms`), timeoutMs) : null;\r\n\r\n  onEvent({ type: \"run-start\" });\r\n\r\n  try {\r\n    for (const node of order) {\r\n      if (signal?.aborted) throw new Error(\"aborted\");\r\n      if (errors.length) break;\r\n\r\n      const incoming = incomingByNode.get(node.id) ?? [];\r\n\r\n      \/\/ Run a node once any upstream branch reaches it. We iterate in\r\n      \/\/ topological order, so by the time we reach this node every upstream\r\n      \/\/ node has been processed \u2014 each incoming edge is therefore *settled*\r\n      \/\/ (active or dead, never still-pending). Requiring ALL incoming edges to\r\n      \/\/ be active wrongly skipped MERGE POINTS: when a Decision routes down one\r\n      \/\/ branch, the other branch's edge stays dead forever, so an `every` check\r\n      \/\/ skipped the shared continuation node and halted the run after the first\r\n      \/\/ branch (#1). Run when AT LEAST ONE incoming edge is active \u2014\r\n      \/\/ collectInputs() only reads from the active ones. A genuine parallel\r\n      \/\/ join still works: in topo order both of its inputs are already active.\r\n      if (incoming.length > 0) {\r\n        const anyActive = incoming.some((e) => portValues.has(`${e.source}:${e.sourceHandle ?? \"out\"}`));\r\n        if (!anyActive) {\r\n          onEvent({ type: \"node-status\", nodeId: node.id, status: \"idle\", text: \"skipped\" });\r\n          continue;\r\n        }\r\n      }\r\n\r\n      \/\/ Notes\/annotations + layout (lane\/pool) nodes are visual only \u2014 never\r\n      \/\/ executed and never fed to runners. Their config (a note's text, a lane's\r\n      \/\/ title) stays in the document for editors + MCP tools, but the engine\r\n      \/\/ walks straight past them. Edges cross lanes freely, so grouping never\r\n      \/\/ affects topology.\r\n      const visualKind = getNodeKind(node.type ?? \"\");\r\n      const isLayout = visualKind?.category === \"layout\";\r\n      const isAnnotation = node.type === \"note\" || visualKind?.category === \"annotation\";\r\n      if (isLayout || isAnnotation) {\r\n        onEvent({\r\n          type: \"node-status\",\r\n          nodeId: node.id,\r\n          status: \"idle\",\r\n          text: isLayout ? \"lane\" : \"annotation\",\r\n        });\r\n        continue;\r\n      }\r\n\r\n      onEvent({ type: \"node-status\", nodeId: node.id, status: \"running\" });\r\n\r\n      const inputs = collectInputs(node, incoming, portValues, initialInputs);\r\n      const exec = pickExecutor(executors, node);\r\n      if (!exec) {\r\n        const msg = `No executor registered for kind=${node.type}`;\r\n        errors.push(msg);\r\n        onEvent({ type: \"node-status\", nodeId: node.id, status: \"error\", text: msg });\r\n        onEvent({ type: \"log\", nodeId: node.id, level: \"error\", message: msg });\r\n        break;\r\n      }\r\n\r\n      try {\r\n        const result = await Promise.resolve(\r\n          exec({\r\n            node,\r\n            inputs,\r\n            abort: (reason) => { throw new Error(reason ?? \"aborted\"); },\r\n            emit: onEvent,\r\n            depth,\r\n          }),\r\n        );\r\n        outputs[node.id] = result;\r\n\r\n        \/\/ Decide which output ports were activated. Three conventions:\r\n        \/\/  1) If result is `{ __port: \"out\", value: x }`, only that port emits.\r\n        \/\/  2) If result has `branch: <portId>`, only that port emits (decision sugar).\r\n        \/\/  3) Otherwise, the value is published on every declared output port.\r\n        const activated = activatedPorts(node, result);\r\n        for (const portId of activated.ports) {\r\n          portValues.set(`${node.id}:${portId}`, activated.value);\r\n          onEvent({ type: \"node-output\", nodeId: node.id, portId, value: activated.value });\r\n        }\r\n        completed.add(node.id);\r\n        onEvent({ type: \"node-status\", nodeId: node.id, status: \"done\" });\r\n      } catch (e) {\r\n        const msg = e instanceof Error ? e.message : String(e);\r\n        errors.push(msg);\r\n        onEvent({ type: \"node-status\", nodeId: node.id, status: \"error\", text: msg });\r\n        onEvent({ type: \"log\", nodeId: node.id, level: \"error\", message: msg });\r\n        break;\r\n      }\r\n    }\r\n  } finally {\r\n    if (timer) clearTimeout(timer);\r\n  }\r\n\r\n  const ok = errors.length === 0;\r\n  onEvent({ type: \"run-end\", ok });\r\n  return ok ? { ok, outputs } : { ok, outputs, error: errors[0] };\r\n}\r\n\r\nfunction indexIncoming(edges: FlowEdge[]): Map<string, FlowEdge[]> {\r\n  const map = new Map<string, FlowEdge[]>();\r\n  for (const e of edges) {\r\n    const list = map.get(e.target) ?? [];\r\n    list.push(e);\r\n    map.set(e.target, list);\r\n  }\r\n  return map;\r\n}\r\n\r\nfunction topoSort(graph: FlowGraph): FlowNode[] | null {\r\n  const inDegree = new Map<string, number>();\r\n  for (const n of graph.nodes) inDegree.set(n.id, 0);\r\n  for (const e of graph.edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1);\r\n  const queue: string[] = [];\r\n  for (const [id, d] of inDegree) if (d === 0) queue.push(id);\r\n  const ordered: string[] = [];\r\n  while (queue.length) {\r\n    const id = queue.shift()!;\r\n    ordered.push(id);\r\n    for (const e of graph.edges) {\r\n      if (e.source !== id) continue;\r\n      const next = (inDegree.get(e.target) ?? 0) - 1;\r\n      inDegree.set(e.target, next);\r\n      if (next === 0) queue.push(e.target);\r\n    }\r\n  }\r\n  if (ordered.length !== graph.nodes.length) return null;\r\n  const byId = new Map(graph.nodes.map((n) => [n.id, n]));\r\n  return ordered.map((id) => byId.get(id)!).filter(Boolean);\r\n}\r\n\r\nfunction collectInputs(\r\n  node: FlowNode,\r\n  incoming: FlowEdge[],\r\n  portValues: Map<string, unknown>,\r\n  initial: Record<string, Record<string, unknown>>,\r\n): Record<string, unknown> {\r\n  const inputs: Record<string, unknown> = { ...(initial[node.id] ?? {}) };\r\n  for (const e of incoming) {\r\n    const portId = e.targetHandle ?? \"in\";\r\n    const val = portValues.get(`${e.source}:${e.sourceHandle ?? \"out\"}`);\r\n    inputs[portId] = val;\r\n  }\r\n  return inputs;\r\n}\r\n\r\nfunction pickExecutor(\r\n  executors: ExecutorRegistry,\r\n  node: FlowNode,\r\n): NodeExecutor | undefined {\r\n  if (executors[node.id]) return executors[node.id];\r\n  if (node.type && executors[node.type]) return executors[node.type];\r\n\r\n  \/\/ Try every id the kind answers to. Kinds are namespaced (`@fancy\/switch_case`)\r\n  \/\/ while a host may have bound its executor under the bare name it used before\r\n  \/\/ \u2014 or vice versa. Without this, the rename would silently stop matching and\r\n  \/\/ the node would fall through to `*` or simply not run: a breaking change\r\n  \/\/ wearing the costume of a rename.\r\n  const kind = node.type ? getNodeKind(node.type) : null;\r\n  if (kind) {\r\n    for (const id of kindIds(kind)) {\r\n      if (executors[id]) return executors[id];\r\n    }\r\n  }\r\n\r\n  return executors[\"*\"];\r\n}\r\n\r\nfunction activatedPorts(node: FlowNode, result: unknown): { ports: string[]; value: unknown } {\r\n  if (result && typeof result === \"object\") {\r\n    const r = result as Record<string, unknown>;\r\n    if (typeof r.__port === \"string\") {\r\n      return { ports: [r.__port], value: r.value };\r\n    }\r\n    if (typeof r.branch === \"string\") {\r\n      return { ports: [r.branch], value: r.value ?? r };\r\n    }\r\n  }\r\n  \/\/ Resolve through the shared helper so the ports the runtime activates are\r\n  \/\/ the same ones the canvas drew \u2014 including config-driven ports, which the\r\n  \/\/ node's `data` does not carry. Falls back to a lone `out` when a node\r\n  \/\/ declares nothing.\r\n  const kind = getNodeKind((node.data as any)?.kind ?? node.type ?? \"\") ?? undefined;\r\n  const declared = resolveNodePorts(node, kind).outputs?.map((p) => p.id);\r\n  return { ports: declared?.length ? declared : [\"out\"], value: result };\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/run-flow\/run-flow.ts"}]}