@particle-academy/fancy-flow 0.72.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +22 -0
  2. package/dist/{chunk-57QTX3S3.js → chunk-3RXQGYGH.js} +3 -3
  3. package/dist/{chunk-57QTX3S3.js.map → chunk-3RXQGYGH.js.map} +1 -1
  4. package/dist/{chunk-RQP666FB.js → chunk-7DJ56PUA.js} +4 -4
  5. package/dist/{chunk-RQP666FB.js.map → chunk-7DJ56PUA.js.map} +1 -1
  6. package/dist/{chunk-C5K2DSZB.js → chunk-EX34GCKK.js} +7 -5
  7. package/dist/chunk-EX34GCKK.js.map +1 -0
  8. package/dist/{chunk-ZEDBF2V6.js → chunk-GQ5LEA7Q.js} +3 -3
  9. package/dist/{chunk-ZEDBF2V6.js.map → chunk-GQ5LEA7Q.js.map} +1 -1
  10. package/dist/{chunk-E3U3MBKI.js → chunk-K7KO3UJ3.js} +3 -3
  11. package/dist/{chunk-E3U3MBKI.js.map → chunk-K7KO3UJ3.js.map} +1 -1
  12. package/dist/{chunk-R3GQ72WA.js → chunk-LYYQO7EY.js} +4 -4
  13. package/dist/{chunk-R3GQ72WA.js.map → chunk-LYYQO7EY.js.map} +1 -1
  14. package/dist/{chunk-6TC44VCY.js → chunk-VAF2SWTC.js} +3 -3
  15. package/dist/{chunk-6TC44VCY.js.map → chunk-VAF2SWTC.js.map} +1 -1
  16. package/dist/durable/index.d.cts +138 -13
  17. package/dist/durable/index.d.ts +138 -13
  18. package/dist/durable.cjs +75 -13
  19. package/dist/durable.cjs.map +1 -1
  20. package/dist/durable.js +69 -12
  21. package/dist/durable.js.map +1 -1
  22. package/dist/engine.cjs +5 -3
  23. package/dist/engine.cjs.map +1 -1
  24. package/dist/engine.d.cts +2 -2
  25. package/dist/engine.d.ts +2 -2
  26. package/dist/engine.js +4 -4
  27. package/dist/index.cjs +5 -3
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +2 -2
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +12 -12
  32. package/dist/registry.cjs +5 -3
  33. package/dist/registry.cjs.map +1 -1
  34. package/dist/registry.js +2 -2
  35. package/dist/{run-cohort-DL9JzzPU.d.cts → run-cohort-DNpPa0mq.d.cts} +1 -1
  36. package/dist/{run-cohort-CjUBWg6X.d.ts → run-cohort-rgT_FX3V.d.ts} +1 -1
  37. package/dist/{run-flow-CxEGBOxd.d.ts → run-flow-BLP9rXfO.d.ts} +22 -1
  38. package/dist/{run-flow-B_8hgO5_.d.cts → run-flow-pW0PllaZ.d.cts} +22 -1
  39. package/dist/runtime/index.d.cts +3 -3
  40. package/dist/runtime/index.d.ts +3 -3
  41. package/dist/runtime.cjs +5 -3
  42. package/dist/runtime.cjs.map +1 -1
  43. package/dist/runtime.js +3 -3
  44. package/dist/schema.cjs +5 -3
  45. package/dist/schema.cjs.map +1 -1
  46. package/dist/schema.js +3 -3
  47. package/dist/screens.cjs +5 -3
  48. package/dist/screens.cjs.map +1 -1
  49. package/dist/screens.js +4 -4
  50. package/dist/ux.cjs +5 -3
  51. package/dist/ux.cjs.map +1 -1
  52. package/dist/ux.js +1 -1
  53. package/package.json +2 -2
  54. package/dist/chunk-C5K2DSZB.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/analysis/graph-connectivity.ts"],"names":[],"mappings":";;;AAsCO,SAAS,uBAAuB,KAAA,EAAiC;AACtE,EAAA,MAAM,EAAE,KAAA,EAAO,KAAA,EAAM,GAAI,KAAA;AACzB,EAAA,MAAM,SAAwB,EAAC;AAE/B,EAAA,MAAM,WAAA,uBAAkB,GAAA,EAAY;AACpC,EAAA,MAAM,WAAA,uBAAkB,GAAA,EAAY;AACpC,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,WAAA,CAAY,GAAA,CAAI,KAAK,MAAM,CAAA;AAC3B,IAAA,WAAA,CAAY,GAAA,CAAI,KAAK,MAAM,CAAA;AAAA,EAC7B;AAMA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,KAAW,CAAA;AAEhC,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,IAAI,MAAA,IAAU,QAAA,CAAS,IAAI,CAAA,EAAG;AAE9B,IAAA,IAAI,CAAC,WAAA,CAAY,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,IAAK,CAAC,WAAA,CAAY,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,EAAG;AAC1D,MAAA,MAAA,CAAO,IAAA,CAAK;AAAA,QACV,KAAA,EAAO,OAAA;AAAA,QACP,QAAQ,IAAA,CAAK,EAAA;AAAA,QACb,OAAA,EACE,CAAA,MAAA,EAAS,IAAA,CAAK,EAAE,CAAA,oSAAA;AAAA,OAInB,CAAA;AAAA,IACH;AAAA,EACF;AAEA,EAAA,MAAM,IAAA,GAAO,IAAI,GAAA,CAAI,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,EAAA,EAAI,CAAC,CAAC,CAAC,CAAA;AAEhD,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,MAAM,CAAA;AACnC,IAAA,IAAI,CAAC,MAAA,IAAU,CAAC,YAAA,CAAa,MAAM,CAAA,EAAG;AAEtC,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,KAAA,EAAO,OAAA;AAAA,MACP,QAAQ,IAAA,CAAK,EAAA;AAAA,MACb,OAAA,EACE,SAAS,IAAA,CAAK,EAAE,iBAAiB,IAAA,CAAK,MAAM,CAAA,6JAAA,EAET,IAAA,CAAK,MAAM,CAAA,kCAAA;AAAA,KACjD,CAAA;AAAA,EACH;AAEA,EAAA,OAAO,MAAA;AACT;AAyBO,SAAS,SAAS,IAAA,EAAyB;AAChD,EAAA,MAAM,OAAO,IAAA,CAAK,IAAA;AAClB,EAAA,IAAI,CAAC,MAAM,OAAO,KAAA;AAClB,EAAA,IAAI,IAAA,KAAS,QAAQ,OAAO,IAAA;AAE5B,EAAA,MAAM,IAAA,GAAO,YAAY,IAAI,CAAA;AAC7B,EAAA,IAAI,CAAC,MAAM,OAAO,IAAA;AAElB,EAAA,OAAO,OAAA,CAAQ,IAAI,CAAA,CAAE,QAAA,CAAS,MAAM,KAAK,IAAA,CAAK,QAAA,KAAa,YAAA,IAAgB,IAAA,CAAK,QAAA,KAAa,QAAA;AAC/F;AAWA,SAAS,aAAa,IAAA,EAAyB;AAM7C,EAAA,MAAM,GAAA,GAAO,KAAK,IAAA,EAA8C,OAAA;AAChE,EAAA,IAAI,MAAM,OAAA,CAAQ,GAAG,CAAA,EAAG,OAAO,IAAI,MAAA,KAAW,CAAA;AAE9C,EAAA,MAAM,OAAO,IAAA,CAAK,IAAA,GAAO,WAAA,CAAY,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AAKlD,EAAA,IAAI,CAAC,MAAM,OAAO,KAAA;AAElB,EAAA,OAAO,MAAM,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA,IAAK,IAAA,CAAK,QAAQ,MAAA,KAAW,CAAA;AAChE","file":"chunk-6TC44VCY.js","sourcesContent":["import { getNodeKind, kindIds } from \"../registry/registry\";\nimport type { FlowGraph, FlowNode } from \"../types\";\nimport type { ImportIssue } from \"../schema/workflow-schema\";\n\n/**\n * Refuse a graph whose nodes cannot take part in the workflow's dataflow.\n *\n * Two shapes, both of which import cleanly and then quietly do nothing. Neither\n * FAILS — which is what makes them worth refusing at authoring time, because a\n * run that reports success is the worst way for a workflow to be wrong. Both\n * were measured against the PHP twin's engine before this was written, and both\n * behave the same way here:\n *\n * ## 1. A FLOATING node — no inbound and no outbound edge\n *\n * It is NOT skipped. A node with no incoming edge is a root, so the topo sort\n * runs it: a three-node graph with one floating `log` executed `t,lonely,o`. It\n * runs disconnected — receiving nothing from the graph and reaching nobody in\n * it — which is precisely the state an author cannot see on a canvas.\n *\n * ## 2. An edge leaving a TERMINATOR\n *\n * A terminal kind — `output`, `log` — declares an EMPTY output port list. It\n * ends a chain. Measured: `t -> output -> log` imported clean and the `log` DID\n * run, with `{{ input }}` resolving to `\"\"`. `collectInputs` binds a payload\n * only when `\"<sourceId>:<handle>\"` exists, and a node publishing no ports never\n * creates that key — so the edge does not fail, it delivers nothing, and the\n * node downstream operates on a hole.\n *\n * That is the same silent-nothing the undelivered-edge diagnostic reports at run\n * time, but this one is decidable FROM THE DOCUMENT ALONE.\n *\n * ## What may float\n *\n * See {@link mayFloat}. Not only `note`: any `annotation` or `layout` kind (a\n * swimlane is never wired to anything — that is what a lane IS), and any kind\n * the registry does not know.\n */\nexport function checkGraphConnectivity(graph: FlowGraph): ImportIssue[] {\n const { nodes, edges } = graph;\n const issues: ImportIssue[] = [];\n\n const hasIncoming = new Set<string>();\n const hasOutgoing = new Set<string>();\n for (const edge of edges) {\n hasIncoming.add(edge.target);\n hasOutgoing.add(edge.source);\n }\n\n // A single-node graph is not \"floating\" — it is a graph with one step, which\n // is a legitimate (if small) workflow and what every graph looks like on the\n // way to a bigger one. Refusing it would make the editor unusable from the\n // first node placed.\n const single = nodes.length === 1;\n\n for (const node of nodes) {\n if (single || mayFloat(node)) continue;\n\n if (!hasIncoming.has(node.id) && !hasOutgoing.has(node.id)) {\n issues.push({\n level: \"error\",\n nodeId: node.id,\n message:\n `Node \"${node.id}\" is connected to nothing — no inbound edge and no outbound edge. ` +\n `It still RUNS (a node with no inbound edge is a root), but it receives nothing from ` +\n `the graph and reaches nobody in it, so it is either unwired or left behind by a ` +\n `deletion. Only a note, an annotation or a lane may float.`,\n });\n }\n }\n\n const byId = new Map(nodes.map((n) => [n.id, n]));\n\n for (const edge of edges) {\n const source = byId.get(edge.source);\n if (!source || !isTerminator(source)) continue;\n\n issues.push({\n level: \"error\",\n edgeId: edge.id,\n message:\n `Edge \"${edge.id}\" reads from \"${edge.source}\", which is a TERMINAL node and publishes ` +\n `no output ports at all. Nothing can ever travel this edge: it does not fail at run ` +\n `time, it delivers nothing, and \"${edge.target}\" runs anyway with an empty input.`,\n });\n }\n\n return issues;\n}\n\n/**\n * Which nodes are allowed to sit unconnected.\n *\n * Three answers, and the third is the one that took a second pass — it was\n * missed in the PHP twin's first release and shipped as 0.48.1:\n *\n * 1. **`note`**, across every id the kind answers to, so a graph saved with the\n * canonical `@particle-academy/note` stays an annotation rather than becoming\n * an unwireable node.\n * 2. **Any `annotation` or `layout` kind.** A host may register its own note,\n * and `@particle-academy/lane` is a swimlane the engine walks straight past.\n * Neither is a step and neither is ever wired.\n * 3. **A kind the registry has never heard of.** Not a loophole — the honest\n * answer. An unknown kind already produces its own issue, and we cannot know\n * whether it is a step, an annotation or a lane. Claiming it must be wired\n * would assert something unverifiable, and it lands hardest on the graphs\n * that deserve it least: a laned graph loaded by a runtime without `lane`\n * registered would report every swimlane twice, the second time wrongly.\n *\n * The note/annotation/layout half matches the test `run-flow` uses to skip a\n * node. The unknown-kind half deliberately does NOT — see the closing comment in\n * this file for why the two must not become one helper.\n */\nexport function mayFloat(node: FlowNode): boolean {\n const type = node.type;\n if (!type) return false;\n if (type === \"note\") return true;\n\n const kind = getNodeKind(type);\n if (!kind) return true;\n\n return kindIds(kind).includes(\"note\") || kind.category === \"annotation\" || kind.category === \"layout\";\n}\n\n/**\n * A kind that declares an EMPTY output list ends a chain.\n *\n * `[]` and `undefined` are different answers and only the first means this.\n * `undefined` is \"nobody declared what this publishes\", which resolves to `out`\n * and is most nodes in most graphs; `[]` is an explicit claim that there is\n * nothing to connect from. Reading them alike would refuse nearly every\n * workflow ever written.\n */\nfunction isTerminator(node: FlowNode): boolean {\n // A node carrying its own ports overrides its kind — the engine reads these\n // first, so an author who has said what this node publishes is believed.\n // (The PHP twin's importer DROPS node-level ports, so this branch is reachable\n // there only for a hand-built graph. Noted rather than smoothed over: the two\n // importers genuinely differ here.)\n const own = (node.data as { outputs?: unknown[] } | undefined)?.outputs;\n if (Array.isArray(own)) return own.length === 0;\n\n const kind = node.type ? getNodeKind(node.type) : null;\n\n // An unregistered kind falls back to `out` in the engine, so it is not a\n // terminator. Refusing here would break a host mid-registration, and would\n // use \"I do not know\" as evidence.\n if (!kind) return false;\n\n return Array.isArray(kind.outputs) && kind.outputs.length === 0;\n}\n\n/**\n * NOT shared with `frontier`'s / `run-flow`'s annotation test, deliberately.\n *\n * The two predicates look identical and have OPPOSITE safe defaults, which is\n * exactly how a shared helper becomes a bug:\n *\n * - **Here** an unknown kind may float. Being permissive costs nothing — the\n * unknown-kind issue already fires, and we would otherwise assert something\n * unverifiable.\n * - **In the engine** an unknown kind must NOT be treated as an annotation.\n * Being permissive there means SKIPPING a node the host meant to run, which\n * is silent and unrecoverable.\n *\n * So the note/annotation/layout half is the same question and the unknown half\n * is a different one. Collapsing them into one function would read as tidier and\n * would make a host's unregistered kind stop executing.\n */\n"]}
1
+ {"version":3,"sources":["../src/analysis/graph-connectivity.ts"],"names":[],"mappings":";;;AAsCO,SAAS,uBAAuB,KAAA,EAAiC;AACtE,EAAA,MAAM,EAAE,KAAA,EAAO,KAAA,EAAM,GAAI,KAAA;AACzB,EAAA,MAAM,SAAwB,EAAC;AAE/B,EAAA,MAAM,WAAA,uBAAkB,GAAA,EAAY;AACpC,EAAA,MAAM,WAAA,uBAAkB,GAAA,EAAY;AACpC,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,WAAA,CAAY,GAAA,CAAI,KAAK,MAAM,CAAA;AAC3B,IAAA,WAAA,CAAY,GAAA,CAAI,KAAK,MAAM,CAAA;AAAA,EAC7B;AAMA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,KAAW,CAAA;AAEhC,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,IAAI,MAAA,IAAU,QAAA,CAAS,IAAI,CAAA,EAAG;AAE9B,IAAA,IAAI,CAAC,WAAA,CAAY,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,IAAK,CAAC,WAAA,CAAY,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,EAAG;AAC1D,MAAA,MAAA,CAAO,IAAA,CAAK;AAAA,QACV,KAAA,EAAO,OAAA;AAAA,QACP,QAAQ,IAAA,CAAK,EAAA;AAAA,QACb,OAAA,EACE,CAAA,MAAA,EAAS,IAAA,CAAK,EAAE,CAAA,oSAAA;AAAA,OAInB,CAAA;AAAA,IACH;AAAA,EACF;AAEA,EAAA,MAAM,IAAA,GAAO,IAAI,GAAA,CAAI,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,EAAA,EAAI,CAAC,CAAC,CAAC,CAAA;AAEhD,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,MAAM,CAAA;AACnC,IAAA,IAAI,CAAC,MAAA,IAAU,CAAC,YAAA,CAAa,MAAM,CAAA,EAAG;AAEtC,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,KAAA,EAAO,OAAA;AAAA,MACP,QAAQ,IAAA,CAAK,EAAA;AAAA,MACb,OAAA,EACE,SAAS,IAAA,CAAK,EAAE,iBAAiB,IAAA,CAAK,MAAM,CAAA,6JAAA,EAET,IAAA,CAAK,MAAM,CAAA,kCAAA;AAAA,KACjD,CAAA;AAAA,EACH;AAEA,EAAA,OAAO,MAAA;AACT;AAyBO,SAAS,SAAS,IAAA,EAAyB;AAChD,EAAA,MAAM,OAAO,IAAA,CAAK,IAAA;AAClB,EAAA,IAAI,CAAC,MAAM,OAAO,KAAA;AAClB,EAAA,IAAI,IAAA,KAAS,QAAQ,OAAO,IAAA;AAE5B,EAAA,MAAM,IAAA,GAAO,YAAY,IAAI,CAAA;AAC7B,EAAA,IAAI,CAAC,MAAM,OAAO,IAAA;AAElB,EAAA,OAAO,OAAA,CAAQ,IAAI,CAAA,CAAE,QAAA,CAAS,MAAM,KAAK,IAAA,CAAK,QAAA,KAAa,YAAA,IAAgB,IAAA,CAAK,QAAA,KAAa,QAAA;AAC/F;AAWA,SAAS,aAAa,IAAA,EAAyB;AAM7C,EAAA,MAAM,GAAA,GAAO,KAAK,IAAA,EAA8C,OAAA;AAChE,EAAA,IAAI,MAAM,OAAA,CAAQ,GAAG,CAAA,EAAG,OAAO,IAAI,MAAA,KAAW,CAAA;AAE9C,EAAA,MAAM,OAAO,IAAA,CAAK,IAAA,GAAO,WAAA,CAAY,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AAKlD,EAAA,IAAI,CAAC,MAAM,OAAO,KAAA;AAElB,EAAA,OAAO,MAAM,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA,IAAK,IAAA,CAAK,QAAQ,MAAA,KAAW,CAAA;AAChE","file":"chunk-VAF2SWTC.js","sourcesContent":["import { getNodeKind, kindIds } from \"../registry/registry\";\nimport type { FlowGraph, FlowNode } from \"../types\";\nimport type { ImportIssue } from \"../schema/workflow-schema\";\n\n/**\n * Refuse a graph whose nodes cannot take part in the workflow's dataflow.\n *\n * Two shapes, both of which import cleanly and then quietly do nothing. Neither\n * FAILS — which is what makes them worth refusing at authoring time, because a\n * run that reports success is the worst way for a workflow to be wrong. Both\n * were measured against the PHP twin's engine before this was written, and both\n * behave the same way here:\n *\n * ## 1. A FLOATING node — no inbound and no outbound edge\n *\n * It is NOT skipped. A node with no incoming edge is a root, so the topo sort\n * runs it: a three-node graph with one floating `log` executed `t,lonely,o`. It\n * runs disconnected — receiving nothing from the graph and reaching nobody in\n * it — which is precisely the state an author cannot see on a canvas.\n *\n * ## 2. An edge leaving a TERMINATOR\n *\n * A terminal kind — `output`, `log` — declares an EMPTY output port list. It\n * ends a chain. Measured: `t -> output -> log` imported clean and the `log` DID\n * run, with `{{ input }}` resolving to `\"\"`. `collectInputs` binds a payload\n * only when `\"<sourceId>:<handle>\"` exists, and a node publishing no ports never\n * creates that key — so the edge does not fail, it delivers nothing, and the\n * node downstream operates on a hole.\n *\n * That is the same silent-nothing the undelivered-edge diagnostic reports at run\n * time, but this one is decidable FROM THE DOCUMENT ALONE.\n *\n * ## What may float\n *\n * See {@link mayFloat}. Not only `note`: any `annotation` or `layout` kind (a\n * swimlane is never wired to anything — that is what a lane IS), and any kind\n * the registry does not know.\n */\nexport function checkGraphConnectivity(graph: FlowGraph): ImportIssue[] {\n const { nodes, edges } = graph;\n const issues: ImportIssue[] = [];\n\n const hasIncoming = new Set<string>();\n const hasOutgoing = new Set<string>();\n for (const edge of edges) {\n hasIncoming.add(edge.target);\n hasOutgoing.add(edge.source);\n }\n\n // A single-node graph is not \"floating\" — it is a graph with one step, which\n // is a legitimate (if small) workflow and what every graph looks like on the\n // way to a bigger one. Refusing it would make the editor unusable from the\n // first node placed.\n const single = nodes.length === 1;\n\n for (const node of nodes) {\n if (single || mayFloat(node)) continue;\n\n if (!hasIncoming.has(node.id) && !hasOutgoing.has(node.id)) {\n issues.push({\n level: \"error\",\n nodeId: node.id,\n message:\n `Node \"${node.id}\" is connected to nothing — no inbound edge and no outbound edge. ` +\n `It still RUNS (a node with no inbound edge is a root), but it receives nothing from ` +\n `the graph and reaches nobody in it, so it is either unwired or left behind by a ` +\n `deletion. Only a note, an annotation or a lane may float.`,\n });\n }\n }\n\n const byId = new Map(nodes.map((n) => [n.id, n]));\n\n for (const edge of edges) {\n const source = byId.get(edge.source);\n if (!source || !isTerminator(source)) continue;\n\n issues.push({\n level: \"error\",\n edgeId: edge.id,\n message:\n `Edge \"${edge.id}\" reads from \"${edge.source}\", which is a TERMINAL node and publishes ` +\n `no output ports at all. Nothing can ever travel this edge: it does not fail at run ` +\n `time, it delivers nothing, and \"${edge.target}\" runs anyway with an empty input.`,\n });\n }\n\n return issues;\n}\n\n/**\n * Which nodes are allowed to sit unconnected.\n *\n * Three answers, and the third is the one that took a second pass — it was\n * missed in the PHP twin's first release and shipped as 0.48.1:\n *\n * 1. **`note`**, across every id the kind answers to, so a graph saved with the\n * canonical `@particle-academy/note` stays an annotation rather than becoming\n * an unwireable node.\n * 2. **Any `annotation` or `layout` kind.** A host may register its own note,\n * and `@particle-academy/lane` is a swimlane the engine walks straight past.\n * Neither is a step and neither is ever wired.\n * 3. **A kind the registry has never heard of.** Not a loophole — the honest\n * answer. An unknown kind already produces its own issue, and we cannot know\n * whether it is a step, an annotation or a lane. Claiming it must be wired\n * would assert something unverifiable, and it lands hardest on the graphs\n * that deserve it least: a laned graph loaded by a runtime without `lane`\n * registered would report every swimlane twice, the second time wrongly.\n *\n * The note/annotation/layout half matches the test `run-flow` uses to skip a\n * node. The unknown-kind half deliberately does NOT — see the closing comment in\n * this file for why the two must not become one helper.\n */\nexport function mayFloat(node: FlowNode): boolean {\n const type = node.type;\n if (!type) return false;\n if (type === \"note\") return true;\n\n const kind = getNodeKind(type);\n if (!kind) return true;\n\n return kindIds(kind).includes(\"note\") || kind.category === \"annotation\" || kind.category === \"layout\";\n}\n\n/**\n * A kind that declares an EMPTY output list ends a chain.\n *\n * `[]` and `undefined` are different answers and only the first means this.\n * `undefined` is \"nobody declared what this publishes\", which resolves to `out`\n * and is most nodes in most graphs; `[]` is an explicit claim that there is\n * nothing to connect from. Reading them alike would refuse nearly every\n * workflow ever written.\n */\nfunction isTerminator(node: FlowNode): boolean {\n // A node carrying its own ports overrides its kind — the engine reads these\n // first, so an author who has said what this node publishes is believed.\n // (The PHP twin's importer DROPS node-level ports, so this branch is reachable\n // there only for a hand-built graph. Noted rather than smoothed over: the two\n // importers genuinely differ here.)\n const own = (node.data as { outputs?: unknown[] } | undefined)?.outputs;\n if (Array.isArray(own)) return own.length === 0;\n\n const kind = node.type ? getNodeKind(node.type) : null;\n\n // An unregistered kind falls back to `out` in the engine, so it is not a\n // terminator. Refusing here would break a host mid-registration, and would\n // use \"I do not know\" as evidence.\n if (!kind) return false;\n\n return Array.isArray(kind.outputs) && kind.outputs.length === 0;\n}\n\n/**\n * NOT shared with `frontier`'s / `run-flow`'s annotation test, deliberately.\n *\n * The two predicates look identical and have OPPOSITE safe defaults, which is\n * exactly how a shared helper becomes a bug:\n *\n * - **Here** an unknown kind may float. Being permissive costs nothing — the\n * unknown-kind issue already fires, and we would otherwise assert something\n * unverifiable.\n * - **In the engine** an unknown kind must NOT be treated as an annotation.\n * Being permissive there means SKIPPING a node the host meant to run, which\n * is silent and unrecoverable.\n *\n * So the note/annotation/layout half is the same question and the unknown half\n * is a different one. Collapsing them into one function would read as tidier and\n * would make a host's unregistered kind stop executing.\n */\n"]}
@@ -1,5 +1,5 @@
1
1
  import { F as FlowGraph, R as RunEvent, c as RunIdentity, E as ExecutorRegistry, a as FlowNode, N as NodeExecutor, d as RunIdentityJson } from '../types-B-Syk9-M.cjs';
2
- import { a as RunResult } from '../run-flow-B_8hgO5_.cjs';
2
+ import { a as RunResult } from '../run-flow-pW0PllaZ.cjs';
3
3
  import { b as PauseSignal } from '../pause-9iT4tCEV.cjs';
4
4
  import '@xyflow/react';
5
5
 
@@ -108,6 +108,10 @@ declare class InMemoryClaimStore implements NodeClaimStore {
108
108
  /**
109
109
  * Drop a paused node's claim so a recorded answer can re-run it.
110
110
  *
111
+ * A PAUSED row holds one of the run's dispatch slots (see `selectDispatch`),
112
+ * so this is also what frees the slot: until the row is released, a serial
113
+ * run's `advance()` hands out nothing, the gate included.
114
+ *
111
115
  * Not part of the interface: resuming a human gate is the host's decision and
112
116
  * its storage's business. Provided here because the in-memory store is also
113
117
  * what the tests resume through.
@@ -180,6 +184,57 @@ declare const Frontier: {
180
184
  settleSkips(store: NodeClaimStore, runKey: string, skipped: readonly string[]): Promise<string[]>;
181
185
  };
182
186
 
187
+ /**
188
+ * How many of ONE run's nodes may be held at once, and which ready nodes go next.
189
+ *
190
+ * ## Serial is the default
191
+ *
192
+ * A queued run hands a node to the queue only once the node before it has
193
+ * settled: one node of a run held at a time, in the graph's own declaration
194
+ * order. Parallel dispatch of a ready frontier is something a host ASKS for,
195
+ * with `maxConcurrent` on the {@link Coordinator}.
196
+ *
197
+ * | `maxConcurrent` | meaning |
198
+ * |---|---|
199
+ * | unset | **1**: serial |
200
+ * | `N >= 1` | up to N of the run's nodes held at once |
201
+ * | {@link UNLIMITED_CONCURRENCY} (`0`) | the whole ready frontier |
202
+ * | anything else | refused, by name |
203
+ *
204
+ * A negative number is refused rather than read as "unlimited". Under a serial
205
+ * default, a typo that silently turned a run parallel is the failure to avoid.
206
+ *
207
+ * ## Held means claimed OR paused
208
+ *
209
+ * A node parked on a person keeps its slot. A pause does not park the whole run
210
+ * in this runtime: `advance()` is called whenever any job settles, and without
211
+ * this rule it would hand out the gate's siblings while the person is still
212
+ * deciding.
213
+ *
214
+ * ## Measured against held work, not the batch
215
+ *
216
+ * Two nodes settling at once each trigger an `advance()` on a real queue. A cap
217
+ * applied to one batch would let each of them dispatch its own quota, so the
218
+ * budget is `maxConcurrent - held`, counted off the claim rows.
219
+ *
220
+ * This is the TypeScript member of a three-runtime contract. The
221
+ * `flow/durable-dispatch` conformance suite pins it, and names
222
+ * `fancy-flow-php`'s `DispatchLimit` and the Python runtime's
223
+ * `fancy_flow.durable.select_dispatch` as the other two implementations.
224
+ */
225
+
226
+ /** Dispatch the whole ready frontier. Named so a host never writes a bare `0`. */
227
+ declare const UNLIMITED_CONCURRENCY = 0;
228
+ /**
229
+ * The ready nodes that may be dispatched now, in the order given.
230
+ *
231
+ * `ready` comes from `Frontier.compute`, in declaration order, and that order is
232
+ * kept: this slices, it never sorts. `state` must already include any skips the
233
+ * frontier just settled. Skips are never held, so that changes no count, but the
234
+ * state must describe the run as it is after the decision.
235
+ */
236
+ declare function selectDispatch(ready: readonly string[], state: Record<string, NodeState>, maxConcurrent: number): string[];
237
+
183
238
  /**
184
239
  * Run ONE node of a graph — through the real engine, not around it.
185
240
  *
@@ -199,12 +254,38 @@ declare const Frontier: {
199
254
  * - every node already completed is fed back as `resumeOutputs`, so the engine
200
255
  * republishes it on the same ports and routes exactly as it did the first
201
256
  * time;
202
- * - every node EXCEPT the target is bound, by node id, to a boundary executor
203
- * that aborts — and a node-id binding outranks every kind binding and the
204
- * `*` fallback, so the fence holds whatever a host registered;
257
+ * - every node EXCEPT the target is bound, through `RunOptions.nodeExecutors`,
258
+ * to a FENCE that runs nothing and publishes only a port no edge reads;
205
259
  * - so the engine walks its own topological order, skips its own dead branches,
206
- * collects the target's inputs its own way, runs the target — and stops at
207
- * the next thing it would have run.
260
+ * collects the target's inputs its own way, and runs the target.
261
+ *
262
+ * ## Why the fence does not stop the walk
263
+ *
264
+ * It used to abort the run. The target's own inputs never depend on a fenced
265
+ * node -- the frontier dispatches a node only once every source is settled, and
266
+ * settled sources are resumed, not fenced -- but an UNRELATED node can precede
267
+ * the target in topological order. Two siblings dispatched together are exactly
268
+ * that: when `b`'s job started while `a` was still running, the replay aborted
269
+ * at `a`, never reached `b`, and the coordinator read "the replay ended without
270
+ * running me" as "the engine decided I am unreachable". `b` was recorded
271
+ * skipped, never ran, and the run completed as a success. It did not even take
272
+ * two workers: the frontier lists ready nodes in NODE order and the engine walks
273
+ * EDGE order, so a graph where those disagree about siblings sent in-process
274
+ * `runToCompletion` down the same path.
275
+ *
276
+ * Walking past fences makes that inference honest again: when the replay
277
+ * finishes without an output for the target, it is because the engine found
278
+ * every inbound edge dead.
279
+ *
280
+ * ## Why the fences are not registry entries
281
+ *
282
+ * The registry is one flat object, and a key in it is tried as a node's id AND
283
+ * as its kind. Fencing the node called `host_kind` by writing
284
+ * `executors["host_kind"]` fenced every node of kind `host_kind` too, so a
285
+ * durable run of that graph ran nothing and reported success. And the registry
286
+ * is what `ctx.executors` hands a `subflow` child, so a child node sharing an id
287
+ * with any parent node ran the parent's fence. `nodeExecutors` matches node ids
288
+ * only and is never handed down; the registry reaches the engine untouched.
208
289
  *
209
290
  * The target's output is `result.outputs[nodeId]`, and the ports it activated
210
291
  * arrive as the engine's own `node-output` events. Nothing about routing is
@@ -220,12 +301,19 @@ declare const Frontier: {
220
301
  */
221
302
 
222
303
  /**
223
- * The abort reason the boundary executor uses.
304
+ * The abort reason a boundary used to report.
224
305
  *
225
- * Not a failure: it is the engine telling us it reached a node this job is not
226
- * responsible for.
306
+ * Nothing aborts with it any more (see "Why the fence does not stop the walk");
307
+ * {@link isBoundary} still recognises it so a caller that checks for it keeps
308
+ * working.
227
309
  */
228
310
  declare const BOUNDARY = "fancy-flow:node-boundary";
311
+ /**
312
+ * The port a fenced node publishes on. No edge reads it, so everything
313
+ * downstream of a fenced node is dark in the replay -- which never matters to
314
+ * the target, whose sources are all settled.
315
+ */
316
+ declare const FENCE_PORT = "fancy-flow:fenced";
229
317
  type ReplayResult = {
230
318
  result: RunResult;
231
319
  /** node id -> the ports its output activated, from the engine's own events. */
@@ -243,8 +331,8 @@ type ReplayOptions = {
243
331
  /**
244
332
  * Replay `graph` up to and through `nodeId`.
245
333
  *
246
- * Pass `nodeId = null` to PROBE: every node is a boundary, so nothing executes
247
- * and the engine reports only what it can determine structurally — a cycle, and
334
+ * Pass `nodeId = null` to PROBE: every node is fenced, so nothing executes and
335
+ * the engine reports only what it can determine structurally — a cycle, and
248
336
  * the ports each resumed output republishes on.
249
337
  */
250
338
  declare function replayUpTo(graph: FlowGraph, nodeId: string | null, executors: ExecutorRegistry, options?: ReplayOptions): Promise<ReplayResult>;
@@ -363,7 +451,10 @@ declare function durableApproval(submissions: Submissions): NodeExecutor;
363
451
  *
364
452
  * `advance()`
365
453
  * Ask the frontier what is unblocked, settle the skip cascade, and report the
366
- * ready node ids. A queue adapter dispatches one job per id.
454
+ * node ids that may be dispatched NOW. A queue adapter dispatches one job per
455
+ * id. By default that is at most one id: a run holds one node at a time, and
456
+ * the next node is handed out only when the one before it settles. See
457
+ * {@link CoordinatorOptions.maxConcurrent}.
367
458
  *
368
459
  * `runNode()`
369
460
  * Claim one node, replay the graph through the real engine fenced to that
@@ -387,6 +478,12 @@ declare function durableApproval(submissions: Submissions): NodeExecutor;
387
478
  * A human gate returns `paused`. `runToCompletion` returns immediately when it
388
479
  * sees one — it does not spin, sleep or poll. The run is parked in the store,
389
480
  * the process is free, and a recorded answer is what starts the next job.
481
+ *
482
+ * A paused node keeps its dispatch slot, so under any finite `maxConcurrent`
483
+ * (serial, by default) a queue adapter's `advance()` hands out nothing
484
+ * alongside a gate while the person decides. Resuming releases the row — see
485
+ * `InMemoryClaimStore.release` — which frees the slot for the gate to run
486
+ * again.
390
487
  */
391
488
 
392
489
  /** What happened to one node. */
@@ -434,6 +531,19 @@ type CoordinatorOptions = {
434
531
  initialInputs?: Record<string, Record<string, unknown>>;
435
532
  retry?: RetryPolicy;
436
533
  onEvent?: (event: RunEvent) => void;
534
+ /**
535
+ * How many of this run's nodes may be HELD at once. Held means CLAIMED by a
536
+ * worker or PAUSED on a person: a paused gate keeps its slot.
537
+ *
538
+ * **Defaults to `1`: serial.** `advance()` hands out one node, and the next
539
+ * only once that one has settled, in the graph's declaration order.
540
+ *
541
+ * A positive integer raises the cap. `UNLIMITED_CONCURRENCY` (`0`) hands out
542
+ * the whole ready frontier at once, which is what every queued run did before
543
+ * this option existed. A negative, fractional or non-numeric value throws
544
+ * here, at construction.
545
+ */
546
+ maxConcurrent?: number;
437
547
  };
438
548
  declare class Coordinator {
439
549
  readonly graph: FlowGraph;
@@ -442,12 +552,21 @@ declare class Coordinator {
442
552
  readonly store: NodeClaimStore;
443
553
  readonly initialInputs: Record<string, Record<string, unknown>>;
444
554
  readonly retry: RetryPolicy;
555
+ /** The dispatch cap `advance()` applies. `0` is unlimited. */
556
+ readonly maxConcurrent: number;
445
557
  private readonly onEvent?;
446
558
  constructor(options: CoordinatorOptions);
447
559
  get runKey(): string;
448
560
  /**
449
561
  * Which nodes may be dispatched right now.
450
562
  *
563
+ * The ready frontier, cut to the run's {@link maxConcurrent} budget: the
564
+ * first `maxConcurrent - held` ready ids in declaration order, where `held`
565
+ * counts CLAIMED and PAUSED rows. Under the serial default that is at most one
566
+ * id, and none while a node is still running or a gate is waiting on a person.
567
+ * An empty result with work held is a throttled run, not a stalled one: the
568
+ * held node's settle is what calls this again.
569
+ *
451
570
  * Also settles the skip cascade, because a skip is a decision the frontier
452
571
  * just made and a second caller must not make it again.
453
572
  *
@@ -474,6 +593,12 @@ declare class Coordinator {
474
593
  /**
475
594
  * Drive the graph here, in this process, one node at a time.
476
595
  *
596
+ * It asks `advance()` exactly as a queue adapter does, so it runs nodes in the
597
+ * order a queued run under the same `maxConcurrent` would dispatch them. A run
598
+ * that finishes produces the same outputs under every limit. A run that stops
599
+ * on a pause or a failure can have run a different set of nodes before it
600
+ * stopped, because the order among nodes that are ready together differs.
601
+ *
477
602
  * Every checkpoint is written exactly as a queued run writes it, so a crash
478
603
  * mid-loop resumes from the same place a crashed worker would.
479
604
  *
@@ -528,4 +653,4 @@ declare class Coordinator {
528
653
  private forward;
529
654
  }
530
655
 
531
- export { BOUNDARY, Coordinator, type CoordinatorOptions, type DurableRunResult, Frontier, type FrontierResult, InMemoryClaimStore, type NodeClaimStore, type NodeOutcome, NodeRunStatus, type NodeRunStatusValue, type NodeState, NotAwaitingHuman, type ReplayOptions, type ReplayResult, RetryPolicy, type RetryPolicyOptions, SETTLED, Submissions, UNSAFE_TO_REPLAY, durableApproval, durableUserInput, isBoundary, isSettled, replayUpTo };
656
+ export { BOUNDARY, Coordinator, type CoordinatorOptions, type DurableRunResult, FENCE_PORT, Frontier, type FrontierResult, InMemoryClaimStore, type NodeClaimStore, type NodeOutcome, NodeRunStatus, type NodeRunStatusValue, type NodeState, NotAwaitingHuman, type ReplayOptions, type ReplayResult, RetryPolicy, type RetryPolicyOptions, SETTLED, Submissions, UNLIMITED_CONCURRENCY, UNSAFE_TO_REPLAY, durableApproval, durableUserInput, isBoundary, isSettled, replayUpTo, selectDispatch };
@@ -1,5 +1,5 @@
1
1
  import { F as FlowGraph, R as RunEvent, c as RunIdentity, E as ExecutorRegistry, a as FlowNode, N as NodeExecutor, d as RunIdentityJson } from '../types-B-Syk9-M.js';
2
- import { a as RunResult } from '../run-flow-CxEGBOxd.js';
2
+ import { a as RunResult } from '../run-flow-BLP9rXfO.js';
3
3
  import { b as PauseSignal } from '../pause-9iT4tCEV.js';
4
4
  import '@xyflow/react';
5
5
 
@@ -108,6 +108,10 @@ declare class InMemoryClaimStore implements NodeClaimStore {
108
108
  /**
109
109
  * Drop a paused node's claim so a recorded answer can re-run it.
110
110
  *
111
+ * A PAUSED row holds one of the run's dispatch slots (see `selectDispatch`),
112
+ * so this is also what frees the slot: until the row is released, a serial
113
+ * run's `advance()` hands out nothing, the gate included.
114
+ *
111
115
  * Not part of the interface: resuming a human gate is the host's decision and
112
116
  * its storage's business. Provided here because the in-memory store is also
113
117
  * what the tests resume through.
@@ -180,6 +184,57 @@ declare const Frontier: {
180
184
  settleSkips(store: NodeClaimStore, runKey: string, skipped: readonly string[]): Promise<string[]>;
181
185
  };
182
186
 
187
+ /**
188
+ * How many of ONE run's nodes may be held at once, and which ready nodes go next.
189
+ *
190
+ * ## Serial is the default
191
+ *
192
+ * A queued run hands a node to the queue only once the node before it has
193
+ * settled: one node of a run held at a time, in the graph's own declaration
194
+ * order. Parallel dispatch of a ready frontier is something a host ASKS for,
195
+ * with `maxConcurrent` on the {@link Coordinator}.
196
+ *
197
+ * | `maxConcurrent` | meaning |
198
+ * |---|---|
199
+ * | unset | **1**: serial |
200
+ * | `N >= 1` | up to N of the run's nodes held at once |
201
+ * | {@link UNLIMITED_CONCURRENCY} (`0`) | the whole ready frontier |
202
+ * | anything else | refused, by name |
203
+ *
204
+ * A negative number is refused rather than read as "unlimited". Under a serial
205
+ * default, a typo that silently turned a run parallel is the failure to avoid.
206
+ *
207
+ * ## Held means claimed OR paused
208
+ *
209
+ * A node parked on a person keeps its slot. A pause does not park the whole run
210
+ * in this runtime: `advance()` is called whenever any job settles, and without
211
+ * this rule it would hand out the gate's siblings while the person is still
212
+ * deciding.
213
+ *
214
+ * ## Measured against held work, not the batch
215
+ *
216
+ * Two nodes settling at once each trigger an `advance()` on a real queue. A cap
217
+ * applied to one batch would let each of them dispatch its own quota, so the
218
+ * budget is `maxConcurrent - held`, counted off the claim rows.
219
+ *
220
+ * This is the TypeScript member of a three-runtime contract. The
221
+ * `flow/durable-dispatch` conformance suite pins it, and names
222
+ * `fancy-flow-php`'s `DispatchLimit` and the Python runtime's
223
+ * `fancy_flow.durable.select_dispatch` as the other two implementations.
224
+ */
225
+
226
+ /** Dispatch the whole ready frontier. Named so a host never writes a bare `0`. */
227
+ declare const UNLIMITED_CONCURRENCY = 0;
228
+ /**
229
+ * The ready nodes that may be dispatched now, in the order given.
230
+ *
231
+ * `ready` comes from `Frontier.compute`, in declaration order, and that order is
232
+ * kept: this slices, it never sorts. `state` must already include any skips the
233
+ * frontier just settled. Skips are never held, so that changes no count, but the
234
+ * state must describe the run as it is after the decision.
235
+ */
236
+ declare function selectDispatch(ready: readonly string[], state: Record<string, NodeState>, maxConcurrent: number): string[];
237
+
183
238
  /**
184
239
  * Run ONE node of a graph — through the real engine, not around it.
185
240
  *
@@ -199,12 +254,38 @@ declare const Frontier: {
199
254
  * - every node already completed is fed back as `resumeOutputs`, so the engine
200
255
  * republishes it on the same ports and routes exactly as it did the first
201
256
  * time;
202
- * - every node EXCEPT the target is bound, by node id, to a boundary executor
203
- * that aborts — and a node-id binding outranks every kind binding and the
204
- * `*` fallback, so the fence holds whatever a host registered;
257
+ * - every node EXCEPT the target is bound, through `RunOptions.nodeExecutors`,
258
+ * to a FENCE that runs nothing and publishes only a port no edge reads;
205
259
  * - so the engine walks its own topological order, skips its own dead branches,
206
- * collects the target's inputs its own way, runs the target — and stops at
207
- * the next thing it would have run.
260
+ * collects the target's inputs its own way, and runs the target.
261
+ *
262
+ * ## Why the fence does not stop the walk
263
+ *
264
+ * It used to abort the run. The target's own inputs never depend on a fenced
265
+ * node -- the frontier dispatches a node only once every source is settled, and
266
+ * settled sources are resumed, not fenced -- but an UNRELATED node can precede
267
+ * the target in topological order. Two siblings dispatched together are exactly
268
+ * that: when `b`'s job started while `a` was still running, the replay aborted
269
+ * at `a`, never reached `b`, and the coordinator read "the replay ended without
270
+ * running me" as "the engine decided I am unreachable". `b` was recorded
271
+ * skipped, never ran, and the run completed as a success. It did not even take
272
+ * two workers: the frontier lists ready nodes in NODE order and the engine walks
273
+ * EDGE order, so a graph where those disagree about siblings sent in-process
274
+ * `runToCompletion` down the same path.
275
+ *
276
+ * Walking past fences makes that inference honest again: when the replay
277
+ * finishes without an output for the target, it is because the engine found
278
+ * every inbound edge dead.
279
+ *
280
+ * ## Why the fences are not registry entries
281
+ *
282
+ * The registry is one flat object, and a key in it is tried as a node's id AND
283
+ * as its kind. Fencing the node called `host_kind` by writing
284
+ * `executors["host_kind"]` fenced every node of kind `host_kind` too, so a
285
+ * durable run of that graph ran nothing and reported success. And the registry
286
+ * is what `ctx.executors` hands a `subflow` child, so a child node sharing an id
287
+ * with any parent node ran the parent's fence. `nodeExecutors` matches node ids
288
+ * only and is never handed down; the registry reaches the engine untouched.
208
289
  *
209
290
  * The target's output is `result.outputs[nodeId]`, and the ports it activated
210
291
  * arrive as the engine's own `node-output` events. Nothing about routing is
@@ -220,12 +301,19 @@ declare const Frontier: {
220
301
  */
221
302
 
222
303
  /**
223
- * The abort reason the boundary executor uses.
304
+ * The abort reason a boundary used to report.
224
305
  *
225
- * Not a failure: it is the engine telling us it reached a node this job is not
226
- * responsible for.
306
+ * Nothing aborts with it any more (see "Why the fence does not stop the walk");
307
+ * {@link isBoundary} still recognises it so a caller that checks for it keeps
308
+ * working.
227
309
  */
228
310
  declare const BOUNDARY = "fancy-flow:node-boundary";
311
+ /**
312
+ * The port a fenced node publishes on. No edge reads it, so everything
313
+ * downstream of a fenced node is dark in the replay -- which never matters to
314
+ * the target, whose sources are all settled.
315
+ */
316
+ declare const FENCE_PORT = "fancy-flow:fenced";
229
317
  type ReplayResult = {
230
318
  result: RunResult;
231
319
  /** node id -> the ports its output activated, from the engine's own events. */
@@ -243,8 +331,8 @@ type ReplayOptions = {
243
331
  /**
244
332
  * Replay `graph` up to and through `nodeId`.
245
333
  *
246
- * Pass `nodeId = null` to PROBE: every node is a boundary, so nothing executes
247
- * and the engine reports only what it can determine structurally — a cycle, and
334
+ * Pass `nodeId = null` to PROBE: every node is fenced, so nothing executes and
335
+ * the engine reports only what it can determine structurally — a cycle, and
248
336
  * the ports each resumed output republishes on.
249
337
  */
250
338
  declare function replayUpTo(graph: FlowGraph, nodeId: string | null, executors: ExecutorRegistry, options?: ReplayOptions): Promise<ReplayResult>;
@@ -363,7 +451,10 @@ declare function durableApproval(submissions: Submissions): NodeExecutor;
363
451
  *
364
452
  * `advance()`
365
453
  * Ask the frontier what is unblocked, settle the skip cascade, and report the
366
- * ready node ids. A queue adapter dispatches one job per id.
454
+ * node ids that may be dispatched NOW. A queue adapter dispatches one job per
455
+ * id. By default that is at most one id: a run holds one node at a time, and
456
+ * the next node is handed out only when the one before it settles. See
457
+ * {@link CoordinatorOptions.maxConcurrent}.
367
458
  *
368
459
  * `runNode()`
369
460
  * Claim one node, replay the graph through the real engine fenced to that
@@ -387,6 +478,12 @@ declare function durableApproval(submissions: Submissions): NodeExecutor;
387
478
  * A human gate returns `paused`. `runToCompletion` returns immediately when it
388
479
  * sees one — it does not spin, sleep or poll. The run is parked in the store,
389
480
  * the process is free, and a recorded answer is what starts the next job.
481
+ *
482
+ * A paused node keeps its dispatch slot, so under any finite `maxConcurrent`
483
+ * (serial, by default) a queue adapter's `advance()` hands out nothing
484
+ * alongside a gate while the person decides. Resuming releases the row — see
485
+ * `InMemoryClaimStore.release` — which frees the slot for the gate to run
486
+ * again.
390
487
  */
391
488
 
392
489
  /** What happened to one node. */
@@ -434,6 +531,19 @@ type CoordinatorOptions = {
434
531
  initialInputs?: Record<string, Record<string, unknown>>;
435
532
  retry?: RetryPolicy;
436
533
  onEvent?: (event: RunEvent) => void;
534
+ /**
535
+ * How many of this run's nodes may be HELD at once. Held means CLAIMED by a
536
+ * worker or PAUSED on a person: a paused gate keeps its slot.
537
+ *
538
+ * **Defaults to `1`: serial.** `advance()` hands out one node, and the next
539
+ * only once that one has settled, in the graph's declaration order.
540
+ *
541
+ * A positive integer raises the cap. `UNLIMITED_CONCURRENCY` (`0`) hands out
542
+ * the whole ready frontier at once, which is what every queued run did before
543
+ * this option existed. A negative, fractional or non-numeric value throws
544
+ * here, at construction.
545
+ */
546
+ maxConcurrent?: number;
437
547
  };
438
548
  declare class Coordinator {
439
549
  readonly graph: FlowGraph;
@@ -442,12 +552,21 @@ declare class Coordinator {
442
552
  readonly store: NodeClaimStore;
443
553
  readonly initialInputs: Record<string, Record<string, unknown>>;
444
554
  readonly retry: RetryPolicy;
555
+ /** The dispatch cap `advance()` applies. `0` is unlimited. */
556
+ readonly maxConcurrent: number;
445
557
  private readonly onEvent?;
446
558
  constructor(options: CoordinatorOptions);
447
559
  get runKey(): string;
448
560
  /**
449
561
  * Which nodes may be dispatched right now.
450
562
  *
563
+ * The ready frontier, cut to the run's {@link maxConcurrent} budget: the
564
+ * first `maxConcurrent - held` ready ids in declaration order, where `held`
565
+ * counts CLAIMED and PAUSED rows. Under the serial default that is at most one
566
+ * id, and none while a node is still running or a gate is waiting on a person.
567
+ * An empty result with work held is a throttled run, not a stalled one: the
568
+ * held node's settle is what calls this again.
569
+ *
451
570
  * Also settles the skip cascade, because a skip is a decision the frontier
452
571
  * just made and a second caller must not make it again.
453
572
  *
@@ -474,6 +593,12 @@ declare class Coordinator {
474
593
  /**
475
594
  * Drive the graph here, in this process, one node at a time.
476
595
  *
596
+ * It asks `advance()` exactly as a queue adapter does, so it runs nodes in the
597
+ * order a queued run under the same `maxConcurrent` would dispatch them. A run
598
+ * that finishes produces the same outputs under every limit. A run that stops
599
+ * on a pause or a failure can have run a different set of nodes before it
600
+ * stopped, because the order among nodes that are ready together differs.
601
+ *
477
602
  * Every checkpoint is written exactly as a queued run writes it, so a crash
478
603
  * mid-loop resumes from the same place a crashed worker would.
479
604
  *
@@ -528,4 +653,4 @@ declare class Coordinator {
528
653
  private forward;
529
654
  }
530
655
 
531
- export { BOUNDARY, Coordinator, type CoordinatorOptions, type DurableRunResult, Frontier, type FrontierResult, InMemoryClaimStore, type NodeClaimStore, type NodeOutcome, NodeRunStatus, type NodeRunStatusValue, type NodeState, NotAwaitingHuman, type ReplayOptions, type ReplayResult, RetryPolicy, type RetryPolicyOptions, SETTLED, Submissions, UNSAFE_TO_REPLAY, durableApproval, durableUserInput, isBoundary, isSettled, replayUpTo };
656
+ export { BOUNDARY, Coordinator, type CoordinatorOptions, type DurableRunResult, FENCE_PORT, Frontier, type FrontierResult, InMemoryClaimStore, type NodeClaimStore, type NodeOutcome, NodeRunStatus, type NodeRunStatusValue, type NodeState, NotAwaitingHuman, type ReplayOptions, type ReplayResult, RetryPolicy, type RetryPolicyOptions, SETTLED, Submissions, UNLIMITED_CONCURRENCY, UNSAFE_TO_REPLAY, durableApproval, durableUserInput, isBoundary, isSettled, replayUpTo, selectDispatch };