instant-ctrl-flow-logic 0.1.6 → 0.1.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instant-ctrl-flow-logic",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "description": "InstantCtrlFlow 引擎:xnl bundle 加载 → 行为树编译 → 无状态单次执行",
6
6
  "type": "module",
@@ -8,14 +8,16 @@
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./browser": "./src/browser.ts",
11
- "./filesystem": "./src/filesystem.ts"
11
+ "./filesystem": "./src/filesystem.ts",
12
+ "./graph": "./src/graph.ts"
12
13
  },
13
14
  "dependencies": {
14
- "depa-behavior-tree": "0.1.1",
15
+ "depa-behavior-tree": "0.1.2",
15
16
  "flow-step-space-contract": "0.1.1",
16
- "flow-step-space-logic": "0.1.1",
17
- "instant-ctrl-flow-contract": "0.1.2",
18
- "xnl-core": "^0.1.8"
17
+ "flow-step-space-logic": "0.1.2",
18
+ "instant-ctrl-flow-contract": "0.1.3",
19
+ "xnl-core": "0.1.13",
20
+ "port-control-graph-logic": "0.1.0"
19
21
  },
20
22
  "files": [
21
23
  "src"
package/src/browser.ts CHANGED
@@ -2,3 +2,4 @@ export * from './loader.js';
2
2
  export * from './compile.js';
3
3
  export * from './handlers.js';
4
4
  export * from './engine.js';
5
+ export * from './control-options.js';
package/src/compile.ts CHANGED
@@ -8,6 +8,27 @@ import {
8
8
  import type { FlowBundleSpec, FlowNodeSpec } from 'instant-ctrl-flow-contract';
9
9
 
10
10
  export type CtrlNode = BehaviorTreeNode<CtrlFlowNodeType, INodeConfig>;
11
+ const compiledOwners = new WeakMap<CtrlNode, string>();
12
+
13
+ function compiledChildren(node: CtrlNode): readonly CtrlNode[] {
14
+ if (String(node.Type) !== 'wf.Composite') return node.Children;
15
+ const config = node.Config as { bodyPlan?: CtrlNode; items?: { bodyPlan: CtrlNode }[] };
16
+ return [...node.Children, ...(config.bodyPlan ? [config.bodyPlan] : []), ...(config.items ?? []).map(item => item.bodyPlan)];
17
+ }
18
+
19
+ /** Generated actions retain their authored owner; consumers never infer it from a key prefix. */
20
+ export function projectCompiledFlowInvocationOwners(root: CtrlNode): Readonly<Record<string, string>> {
21
+ const owners = new Map<string, string>();
22
+ const visit = (node: CtrlNode): void => {
23
+ const owner = compiledOwners.get(node);
24
+ if (owner) {
25
+ if (owners.has(node.Key) && owners.get(node.Key) !== owner) throw new FlowCompileError('ambiguous-action-owner', node.Key);
26
+ owners.set(node.Key, owner);
27
+ }
28
+ compiledChildren(node).forEach(visit);
29
+ };
30
+ visit(root); return Object.freeze(Object.fromEntries(owners));
31
+ }
11
32
 
12
33
  const INTERRUPTIBLE = new Set(['ExternalJob', 'Timer', 'TaskStep']);
13
34
  const VIEW_ATTRS = new Set(['status', 'iterations', 'runStatus', 'tickNo']);
@@ -79,11 +100,30 @@ function compileStatement(
79
100
  node: FlowNodeSpec,
80
101
  extension: CompileExtension | undefined,
81
102
  path: string,
103
+ blockStack: readonly string[] = [],
104
+ inheritedOwner?: string,
82
105
  ): CtrlNode {
83
- const key = node.id ?? path;
106
+ const compiled = compileStatementBody(spec, node, extension, path, blockStack, inheritedOwner);
107
+ const owner = node.id ?? inheritedOwner;
108
+ const assign = (current: CtrlNode): void => {
109
+ if (owner && !compiledOwners.has(current)) compiledOwners.set(current, owner);
110
+ compiledChildren(current).forEach(assign);
111
+ };
112
+ assign(compiled); return compiled;
113
+ }
114
+
115
+ function compileStatementBody(
116
+ spec: FlowBundleSpec,
117
+ node: FlowNodeSpec,
118
+ extension: CompileExtension | undefined,
119
+ path: string,
120
+ blockStack: readonly string[],
121
+ inheritedOwner?: string,
122
+ ): CtrlNode {
123
+ const key = blockStack.length ? `${path}:${node.id ?? node.tag}` : node.id ?? path;
84
124
  const attrs = resolvedAttrs(spec, node);
85
125
  const compileStatements = (nodes: FlowNodeSpec[], bodyKey: string) =>
86
- sequence(bodyKey, nodes.map((child, index) => compileStatement(spec, child, extension, `${bodyKey}.${index}`)));
126
+ sequence(bodyKey, nodes.map((child, index) => compileStatement(spec, child, extension, `${bodyKey}.${index}`, blockStack, node.id ?? inheritedOwner)));
87
127
  const helpers: CompileHelpers = { meta: { key, name: node.id }, attrs, compileStatements };
88
128
 
89
129
  const extended = extension?.(spec, node, helpers);
@@ -94,6 +134,16 @@ function compileStatement(
94
134
  }
95
135
 
96
136
  switch (node.tag) {
137
+ case 'RunBlock': {
138
+ if (spec.form !== 'InstantCtrlFlow') throw new FlowCompileError('unsupported-block-profile', spec.form);
139
+ if (attrs.input !== undefined || attrs.config !== undefined) throw new FlowCompileError('block-context-override', 'RunBlock preserves caller context');
140
+ const ref = String(attrs.block ?? '');
141
+ const id = ref.startsWith('block://#') ? ref.slice(9) : '';
142
+ const body = spec.blocks?.[id];
143
+ if (!body) throw new FlowCompileError('unresolved-block-ref', ref);
144
+ if (blockStack.includes(id)) throw new FlowCompileError('recursive-block', [...blockStack, id].join(' -> '));
145
+ return sequence(key, body.map((child, index) => compileStatement(spec, child, extension, `${key}.block.${id}.${index}`, [...blockStack, id], node.id ?? inheritedOwner)));
146
+ }
97
147
  case 'Run':
98
148
  return action('flow.Run', key, { src: String(attrs.src ?? ''), config: statementConfig(attrs) });
99
149
  case 'Return':
@@ -107,7 +157,9 @@ function compileStatement(
107
157
  const ifPath = key;
108
158
  const branches = node.sections.Branches?.children ?? [];
109
159
  const choices = branches.map((branch, index) => {
110
- const branchKey = branch.id ?? `${key}.branch.${index}`;
160
+ const branchKey = blockStack.length
161
+ ? `${key}.branch.${index}:${branch.id ?? branch.tag}`
162
+ : branch.id ?? `${key}.branch.${index}`;
111
163
  if (branch.tag === 'Otherwise') {
112
164
  return sequence(`${branchKey}.__choice`, [
113
165
  action('flow.Otherwise', `${branchKey}.__otherwise`, {
@@ -145,7 +197,9 @@ function compileStatement(
145
197
  if (strategy.tag !== 'Strategy') {
146
198
  throw new FlowCompileError('invalid-fallback-strategy', `<Fallback #${key}> contains <${strategy.tag}>`);
147
199
  }
148
- const strategyKey = strategy.id ?? `${key}.strategy.${index}`;
200
+ const strategyKey = blockStack.length
201
+ ? `${key}.strategy.${index}:${strategy.id ?? strategy.tag}`
202
+ : strategy.id ?? `${key}.strategy.${index}`;
149
203
  return sequence(`${strategyKey}.__strategy`, [
150
204
  action('flow.StrategyStart', `${strategyKey}.__start`, { fallbackKey: key }),
151
205
  compileStatements(strategy.children, `${strategyKey}.__body`),
@@ -164,6 +218,7 @@ function compileStatement(
164
218
  src: String(attrs.when ?? ''),
165
219
  config: statementConfig(attrs),
166
220
  maxIterations: Number(attrs.maxIterations ?? 9999),
221
+ onExhausted: attrs.onExhausted ?? 'fail',
167
222
  };
168
223
  const initial = action('flow.UntilPredicate', `${key}.__check.initial`, { ...predicateConfig, countIteration: false });
169
224
  const repeated = action('flow.UntilPredicate', `${key}.__check.repeat`, { ...predicateConfig, countIteration: true });
@@ -0,0 +1,53 @@
1
+ import type { FlowBundleSpec, FlowNodeSpec } from 'instant-ctrl-flow-contract';
2
+
3
+ function record(value: unknown, location: string): Record<string, unknown> {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)
5
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError(`${location}: expected plain control options`);
6
+ if (Reflect.ownKeys(value).some(key => typeof key !== 'string')
7
+ || Object.values(Object.getOwnPropertyDescriptors(value)).some(descriptor => !Object.hasOwn(descriptor, 'value'))) throw new TypeError(`${location}: control options must contain data fields`);
8
+ return value as Record<string, unknown>;
9
+ }
10
+
11
+ function bounded(value: unknown, max: number, location: string): number {
12
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > max) throw new RangeError(`${location}: expected integer 1..${max}`);
13
+ return value;
14
+ }
15
+
16
+ /** 纯转换:保留模板/旧运行,拒绝未知字段、控制节点和超过64项的覆盖。 */
17
+ export function applyFlowControlOptions(spec: FlowBundleSpec, value: unknown): FlowBundleSpec {
18
+ const options = value === undefined ? {} : record(value, 'controlOptions');
19
+ if (Object.keys(options).length > 64) throw new RangeError('controlOptions: at most 64 node overrides');
20
+ const frozen = structuredClone(spec);
21
+ const nodes = new Map<string, FlowNodeSpec>();
22
+ function visit(node: FlowNodeSpec): void {
23
+ if (node.id) {
24
+ if (nodes.has(node.id)) throw new TypeError(`controlOptions: ambiguous node ${node.id}`);
25
+ nodes.set(node.id, node);
26
+ }
27
+ node.children.forEach(visit);
28
+ Object.values(node.sections).forEach(visit);
29
+ }
30
+ frozen.statements.forEach(visit);
31
+ Object.values(frozen.blocks ?? {}).forEach(statements => statements.forEach(visit));
32
+ for (const [id, input] of Object.entries(options)) {
33
+ const node = nodes.get(id);
34
+ const row = record(input, `controlOptions.${id}`);
35
+ const fields = Object.keys(row);
36
+ if (node?.tag === 'Until') {
37
+ if (!fields.length || fields.some(key => !['maxIterations', 'onExhausted'].includes(key))) throw new TypeError(`controlOptions.${id}: invalid Until fields`);
38
+ if (Object.hasOwn(row, 'maxIterations')) node.attrs.maxIterations = bounded(row.maxIterations, 1000, `${id}.maxIterations`);
39
+ if (Object.hasOwn(row, 'onExhausted')) {
40
+ if (row.onExhausted !== 'fail' && row.onExhausted !== 'complete') throw new TypeError(`${id}.onExhausted: expected fail or complete`);
41
+ node.attrs.onExhausted = row.onExhausted;
42
+ }
43
+ } else if (node?.tag === 'ForEach') {
44
+ if (!fields.length || fields.some(key => !['maxItems', 'concurrency'].includes(key))) throw new TypeError(`controlOptions.${id}: invalid ForEach fields`);
45
+ const maxItems = bounded(row.maxItems ?? node.attrs.maxItems ?? 1000, 1000, `${id}.maxItems`);
46
+ node.attrs.maxItems = maxItems;
47
+ node.attrs.concurrency = bounded(row.concurrency ?? node.attrs.concurrency ?? 1, Math.min(32, maxItems), `${id}.concurrency`);
48
+ } else {
49
+ throw new TypeError(`controlOptions.${id}: node is not an Until or ForEach`);
50
+ }
51
+ }
52
+ return frozen;
53
+ }
package/src/graph.ts ADDED
@@ -0,0 +1,309 @@
1
+ import { createHash } from "node:crypto";
2
+ import { parseXnl, type DataElementNode } from "xnl-core";
3
+ import {
4
+ lowerPortControlGraph,
5
+ type ControlShape,
6
+ type PortControlGraph,
7
+ type StructuredStatement,
8
+ } from "port-control-graph-logic";
9
+ import type { FlowBundleSpec, FlowNodeSpec } from "instant-ctrl-flow-contract";
10
+ import { loadFlowBundleFromSources } from "./loader.js";
11
+
12
+ export const CTRL_GRAPH_COMPILER = "depa.flows/port-ctrl-graph-to-tree@1";
13
+ export interface CtrlGraphDocument {
14
+ readonly root: FlowNodeSpec;
15
+ readonly graph: PortControlGraph<FlowNodeSpec, string, string, string>;
16
+ }
17
+ export interface CtrlGraphCompileReceipt {
18
+ readonly schemaVersion: "depa.flows/ctrl-graph-receipt/v1";
19
+ readonly compiler: typeof CTRL_GRAPH_COMPILER;
20
+ readonly profile: "InstantCtrlFlow";
21
+ readonly sourceDigest: string;
22
+ readonly outputDigest: string;
23
+ }
24
+ export interface CompiledCtrlGraph {
25
+ readonly source: string;
26
+ readonly spec: FlowBundleSpec;
27
+ readonly provenance: Readonly<Record<string, string>>;
28
+ readonly inferredNext: readonly {
29
+ readonly owner: string;
30
+ readonly next: string;
31
+ }[];
32
+ readonly receipt: CtrlGraphCompileReceipt;
33
+ }
34
+
35
+ function isDataElement(value: unknown): value is DataElementNode {
36
+ return Boolean(
37
+ value &&
38
+ typeof value === "object" &&
39
+ "kind" in value &&
40
+ value.kind === "DataElement",
41
+ );
42
+ }
43
+ function project(element: DataElementNode): FlowNodeSpec {
44
+ return {
45
+ tag: element.tag,
46
+ id: element.id
47
+ ? [...(element.id.namespace ?? []), element.id.name].join(".")
48
+ : undefined,
49
+ metadata: { ...element.metadata },
50
+ attrs: { ...element.attributes },
51
+ children: (element.body ?? []).filter(isDataElement).map(project),
52
+ sections: Object.fromEntries(
53
+ Object.values(element.extend?.children ?? {}).map((section) => [
54
+ section.tag,
55
+ project(section as DataElementNode),
56
+ ]),
57
+ ),
58
+ };
59
+ }
60
+ function endpoint(value: unknown): { node: string; port: string } {
61
+ const match =
62
+ typeof value === "string" && /^port:\/\/#([-\w.]+)\/([-\w.]+)$/.exec(value);
63
+ if (!match) throw new Error(`invalid port URI: ${String(value)}`);
64
+ return { node: match[1], port: match[2] };
65
+ }
66
+ export function parseCtrlFlowGraph(source: string): CtrlGraphDocument {
67
+ const parsed = parseXnl(source);
68
+ if (parsed.warnings?.length)
69
+ throw new Error(
70
+ `graph parser warnings: ${JSON.stringify(parsed.warnings)}`,
71
+ );
72
+ const roots = parsed.nodes.filter(isDataElement).map(project);
73
+ if (roots.length !== 1 || roots[0].tag !== "CtrlFlowGraph")
74
+ throw new Error("expected one CtrlFlowGraph root");
75
+ const root = roots[0];
76
+ if (root.attrs.targetForm !== "InstantCtrlFlow")
77
+ throw new Error("only InstantCtrlFlow graph profile is supported");
78
+ if (root.children.length || root.sections.Regions)
79
+ throw new Error(
80
+ "graph statements belong in Nodes; explicit Regions are unsupported",
81
+ );
82
+ const match = /^(?:ctrl-node:\/\/#)?([-\w.]+)$/.exec(
83
+ String(root.attrs.entry ?? ""),
84
+ );
85
+ if (!match) throw new Error("invalid graph entry");
86
+ if (!root.sections.Nodes || !root.sections.Edges)
87
+ throw new Error("graph requires Nodes and Edges");
88
+ return {
89
+ root,
90
+ graph: {
91
+ entry: match[1],
92
+ nodes: root.sections.Nodes.children,
93
+ edges: root.sections.Edges.children.map((edge) => {
94
+ if (edge.tag !== "Edge" || !edge.id)
95
+ throw new Error("Edges requires named Edge");
96
+ return {
97
+ id: edge.id,
98
+ from: endpoint(edge.attrs.from),
99
+ to: endpoint(edge.attrs.to),
100
+ };
101
+ }),
102
+ },
103
+ };
104
+ }
105
+ export const ctrlGraphRuntime = {
106
+ nodeId(node: FlowNodeSpec): string {
107
+ if (!node.id) throw new Error("graph node requires identity");
108
+ return node.id;
109
+ },
110
+ shape(node: FlowNodeSpec): ControlShape<string> {
111
+ if (node.children.length)
112
+ throw new Error(
113
+ `graph node #${node.id} cannot contain inline statements`,
114
+ );
115
+ for (const section of Object.keys(node.sections)) {
116
+ if (node.tag !== "If" || section !== "Ports")
117
+ throw new Error(
118
+ `unsupported graph node section ${section} #${node.id}`,
119
+ );
120
+ }
121
+ const base = { enter: "enter", next: "next" };
122
+ switch (node.tag) {
123
+ case "Run":
124
+ return { ...base, role: "step" };
125
+ case "Return":
126
+ return { ...base, role: "exit" };
127
+ case "Until":
128
+ return { ...base, role: "loop", body: "body" };
129
+ case "If":
130
+ return {
131
+ ...base,
132
+ role: "choice",
133
+ branches: (node.sections.Ports?.children ?? []).map((port) => {
134
+ if (port.tag !== "Port")
135
+ throw new Error(`If #${node.id} Ports requires Port`);
136
+ if (!port.id)
137
+ throw new Error(`If #${node.id} requires named ports`);
138
+ if (port.children.length || Object.keys(port.sections).length)
139
+ throw new Error(
140
+ `branch port #${port.id} cannot contain statements`,
141
+ );
142
+ if (
143
+ port.attrs.default !== undefined &&
144
+ typeof port.attrs.default !== "boolean"
145
+ )
146
+ throw new Error(
147
+ `branch port #${port.id} default must be boolean`,
148
+ );
149
+ if (port.attrs.default === true && port.attrs.when !== undefined)
150
+ throw new Error(
151
+ `default port #${port.id} cannot have a predicate`,
152
+ );
153
+ return {
154
+ port: port.id,
155
+ order:
156
+ typeof port.attrs.order === "number" ? port.attrs.order : NaN,
157
+ default: port.attrs.default === true,
158
+ };
159
+ }),
160
+ };
161
+ default:
162
+ throw new Error(`unsupported graph node ${node.tag} #${node.id}`);
163
+ }
164
+ },
165
+ };
166
+ function xnlValue(value: unknown): string {
167
+ if (Array.isArray(value)) return `[${value.map(xnlValue).join(", ")}]`;
168
+ if (value && typeof value === "object") {
169
+ const fields = Object.entries(value)
170
+ .filter(([, item]) => item !== undefined)
171
+ .map(([key, item]) => `${JSON.stringify(key)} = ${xnlValue(item)}`);
172
+ return `{ ${fields.join(" ")} }`;
173
+ }
174
+ return JSON.stringify(value);
175
+ }
176
+ export function serializeFlowNode(node: FlowNodeSpec): string {
177
+ const attributes = Object.entries(node.attrs)
178
+ .filter(([, value]) => value !== undefined)
179
+ .map(([key, value]) => `${key} = ${xnlValue(value)}`)
180
+ .join("\n");
181
+ const metadata = Object.entries(node.metadata)
182
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
183
+ .join(" ");
184
+ const sections = Object.values(node.sections)
185
+ .map(serializeFlowNode)
186
+ .join("\n");
187
+ return (
188
+ `<${node.tag}${node.id ? ` #${node.id}` : ""}${metadata ? ` ${metadata}` : ""}` +
189
+ `${attributes ? ` {\n${attributes}\n}` : ""}${sections ? ` (\n${sections}\n)` : ""}` +
190
+ `${node.children.length ? ` [\n${node.children.map(serializeFlowNode).join("\n")}\n]` : ""}>`
191
+ );
192
+ }
193
+ function blank(
194
+ tag: string,
195
+ id?: string,
196
+ attrs: Record<string, unknown> = {},
197
+ children: FlowNodeSpec[] = [],
198
+ ): FlowNodeSpec {
199
+ return { tag, id, attrs, children, metadata: {}, sections: {} };
200
+ }
201
+ export function ctrlGraphSourceDigest(source: string): string {
202
+ return `sha256:${createHash("sha256").update(source, "utf8").digest("hex")}`;
203
+ }
204
+ export function compileCtrlFlowGraph(
205
+ source: string,
206
+ options: { baseUri?: string } = {},
207
+ ): CompiledCtrlGraph {
208
+ const document = parseCtrlFlowGraph(source);
209
+ const result = lowerPortControlGraph(ctrlGraphRuntime, document.graph);
210
+ if (!result.ok) throw new Error(JSON.stringify(result.diagnostics));
211
+ const provenance: Record<string, string> = {};
212
+ const ids = new Set<string>();
213
+ function identity(sourceId: string, path: string): string {
214
+ let id = sourceId;
215
+ const suffix = path.replace(/[^\w]/g, "_");
216
+ let occurrence = 0;
217
+ while (ids.has(id)) id = `${sourceId}__${suffix}_${occurrence++}`;
218
+ ids.add(id);
219
+ provenance[id] = sourceId;
220
+ return id;
221
+ }
222
+ function lower(
223
+ statements: readonly StructuredStatement<FlowNodeSpec, string, string>[],
224
+ path: string,
225
+ ): FlowNodeSpec[] {
226
+ return statements.map((statement, index) => {
227
+ const location = `${path}_${index}`;
228
+ if (statement.kind === "block-ref") {
229
+ const id = identity(`${statement.source}__ref`, location);
230
+ provenance[id] = statement.source;
231
+ return blank("RunBlock", id, {
232
+ block: `block://#block_${statement.block}`,
233
+ });
234
+ }
235
+ const node: FlowNodeSpec = {
236
+ ...statement.node,
237
+ id: identity(statement.node.id!, location),
238
+ attrs: { ...statement.node.attrs },
239
+ sections: {},
240
+ children: [],
241
+ };
242
+ if (statement.kind === "choice") {
243
+ node.sections.Branches = blank(
244
+ "Branches",
245
+ undefined,
246
+ {},
247
+ statement.branches.map((branch) => {
248
+ const port = statement.node.sections.Ports.children.find(
249
+ (item) => item.id === branch.port,
250
+ )!;
251
+ const body = lower(branch.body, `${location}_${branch.port}`);
252
+ if (port.attrs.default === true)
253
+ return blank("Otherwise", undefined, {}, body);
254
+ return blank(
255
+ "Branch",
256
+ identity(`${statement.node.id}_${branch.port}`, location),
257
+ { when: port.attrs.when, config: port.attrs.config },
258
+ body,
259
+ );
260
+ }),
261
+ );
262
+ }
263
+ if (statement.kind === "loop")
264
+ node.children = lower(statement.body, `${location}_body`);
265
+ return node;
266
+ });
267
+ }
268
+ const root: FlowNodeSpec = {
269
+ ...document.root,
270
+ tag: "InstantCtrlFlow",
271
+ attrs: { title: document.root.attrs.title },
272
+ sections: { ...document.root.sections },
273
+ children: lower(result.forest.statements, "main"),
274
+ };
275
+ delete root.sections.Nodes;
276
+ delete root.sections.Edges;
277
+ root.sections.Blocks = blank(
278
+ "Blocks",
279
+ undefined,
280
+ {},
281
+ result.forest.blocks.map((block) =>
282
+ blank(
283
+ "Block",
284
+ `block_${block.id}`,
285
+ {},
286
+ lower(block.statements, `block_${block.id}`),
287
+ ),
288
+ ),
289
+ );
290
+ const output = serializeFlowNode(root);
291
+ const loaded = loadFlowBundleFromSources({ "flow.xnl": output }, options);
292
+ if (!loaded.spec || loaded.diagnostics.length)
293
+ throw new Error(
294
+ `invalid graph output: ${JSON.stringify(loaded.diagnostics)}`,
295
+ );
296
+ return {
297
+ source: output,
298
+ spec: loaded.spec,
299
+ provenance: Object.freeze(provenance),
300
+ inferredNext: result.forest.inferredNext,
301
+ receipt: Object.freeze({
302
+ schemaVersion: "depa.flows/ctrl-graph-receipt/v1",
303
+ compiler: CTRL_GRAPH_COMPILER,
304
+ profile: "InstantCtrlFlow",
305
+ sourceDigest: ctrlGraphSourceDigest(source),
306
+ outputDigest: ctrlGraphSourceDigest(output),
307
+ }),
308
+ };
309
+ }
package/src/handlers.ts CHANGED
@@ -51,6 +51,8 @@ type NodeConfig = {
51
51
  predicateKey?: string;
52
52
  countIteration?: boolean;
53
53
  maxIterations?: number;
54
+ onExhausted?: 'fail' | 'complete';
55
+ maxItems?: number;
54
56
  statements?: FlowNodeSpec[];
55
57
  items?: { id?: string; statements: FlowNodeSpec[] }[];
56
58
  flow?: string;
@@ -93,7 +95,7 @@ export interface FlowDecisionContext {
93
95
  export interface FlowDecisionHooks {
94
96
  begin(ifPath: string): FlowDecisionContext;
95
97
  record(decision: FlowBranchDecision): Promise<void> | void;
96
- runtimeForNode?(nodeId: string, runtime: InstantCtrlFlowRuntime): InstantCtrlFlowRuntime;
98
+ runtimeForNode?(nodeId: string, runtime: InstantCtrlFlowRuntime, state?: FlowExecutionState): InstantCtrlFlowRuntime;
97
99
  }
98
100
 
99
101
  class FlowFunctionLoader {
@@ -104,8 +106,8 @@ class FlowFunctionLoader {
104
106
  private runtimeHooks?: FlowDecisionHooks,
105
107
  ) {}
106
108
 
107
- runtimeForNode(nodeId: string, runtime: InstantCtrlFlowRuntime): InstantCtrlFlowRuntime {
108
- return this.runtimeHooks?.runtimeForNode?.(nodeId, runtime) ?? runtime;
109
+ runtimeForNode(nodeId: string, runtime: InstantCtrlFlowRuntime, state: FlowExecutionState): InstantCtrlFlowRuntime {
110
+ return this.runtimeHooks?.runtimeForNode?.(nodeId, runtime, state) ?? runtime;
109
111
  }
110
112
 
111
113
  load(src: string, nodeId: string): Promise<FlowCodeFunction> {
@@ -135,7 +137,7 @@ async function callCode(ctx: Ctx, loader: FlowFunctionLoader, src: string): Prom
135
137
  const state = flowStateOf(ctx);
136
138
  const fn = await loader.load(src, ctx.node.Key);
137
139
  const runtime = (ctx.runtime ?? {}) as InstantCtrlFlowRuntime;
138
- return fn(loader.runtimeForNode(ctx.node.Key, runtime), state.current, configOf(ctx).config ?? {});
140
+ return fn(loader.runtimeForNode(ctx.node.Key, runtime, state), state.current, configOf(ctx).config ?? {});
139
141
  }
140
142
 
141
143
  function fail(state: FlowExecutionState, error: unknown): BehaviorResult {
@@ -318,6 +320,10 @@ class UntilPredicateHandler implements ActionHandler<CtrlFlowNodeType, INodeConf
318
320
  const iterations = (state.untilIterations[key] ?? 0) + 1;
319
321
  state.untilIterations[key] = iterations;
320
322
  if (!result && iterations >= Number(config.maxIterations ?? 9999)) {
323
+ if (config.onExhausted === 'complete') {
324
+ state.untilValues[key] = true;
325
+ return BehaviorResult.Success;
326
+ }
321
327
  return fail(state, new Error(`Until node exceeded max iterations (${config.maxIterations})`));
322
328
  }
323
329
  }
@@ -391,6 +397,7 @@ class CompositeHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
391
397
  if (this.kind === 'ForEach') {
392
398
  const selected = await callCode(ctx, this.loader, String(config.src ?? configOf(ctx).src ?? ''));
393
399
  if (!Array.isArray(selected)) throw new TypeError('ForEach selector must return an array');
400
+ if (config.maxItems !== undefined && selected.length > config.maxItems) throw new RangeError(`ForEach #${ctx.node.Key} exceeds maxItems (${config.maxItems})`);
394
401
  const outputs: unknown[] = [];
395
402
  for (const item of selected) outputs.push(await this.executeBody(config.statements ?? [], item, runtime));
396
403
  state.current = outputs;
package/src/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from './filesystem.js';
3
3
  export * from './compile.js';
4
4
  export * from './handlers.js';
5
5
  export * from './engine.js';
6
+ export * from './control-options.js';
package/src/loader.ts CHANGED
@@ -7,7 +7,7 @@ import { parseXnl } from 'xnl-core';
7
7
  import {
8
8
  assembleDefinitionStepProfile,
9
9
  createDefinitionStepSourceRuntime,
10
- } from 'flow-step-space-logic';
10
+ } from 'flow-step-space-logic/browser';
11
11
  import type { DefinitionStepForest, DefinitionStepSourceCollection } from 'flow-step-space-contract';
12
12
  import type {
13
13
  FlowBundleSpec,
@@ -46,6 +46,7 @@ const LEGACY_PUBLIC_CONTROL_NODES = new Set(['Sequence', 'Selector', 'Condition'
46
46
  const LEGACY_PUBLIC_ACTION_NODES = new Set(['SetVariable', 'Log', 'Assert', 'Sleep', 'HttpCall', 'RunScript']);
47
47
  const COMMON_STATEMENTS = new Set([
48
48
  'Run',
49
+ 'RunBlock',
49
50
  'If',
50
51
  'Require',
51
52
  'Fallback',
@@ -337,6 +338,18 @@ function loadCtrlFlowSources(
337
338
  const statementEls = listChildren(container);
338
339
  for (const statement of statementEls) visitStatement(statement);
339
340
 
341
+ const blockSections = sectionChildren(container).filter(s => s.tag === 'Blocks');
342
+ if (blockSections.length && form !== 'InstantCtrlFlow') note('unsupported-block-profile', 'Blocks persistence is only validated for InstantCtrlFlow');
343
+ if (blockSections.length > 1) note('duplicate-blocks', 'only one Blocks section is allowed');
344
+ const blockEls = blockSections.flatMap(listChildren);
345
+ const blockIds = new Set<string>();
346
+ for (const block of blockEls) {
347
+ const id = idOf(block);
348
+ if (block.tag !== 'Block' || !id) note('invalid-block', 'Blocks requires named Block children');
349
+ else if (blockIds.has(id)) note('duplicate-block', `duplicate block #${id}`);
350
+ else blockIds.add(id);
351
+ }
352
+ const blockRefs = new Map<string, Set<string>>();
340
353
  const seenIds = new Set<string>();
341
354
  const requireCodeRef = (el: XnlEl, field: string) => {
342
355
  const value = el.attributes?.[field];
@@ -363,6 +376,14 @@ function loadCtrlFlowSources(
363
376
  else seenIds.add(id);
364
377
 
365
378
  if (node.tag === 'Run') requireCodeRef(node, 'src');
379
+ if (node.tag === 'RunBlock') {
380
+ const ref = node.attributes?.block;
381
+ if (typeof ref !== 'string' || !/^block:\/\/#[-\w.]+$/.test(ref) || !blockIds.has(ref.slice(9))) {
382
+ note('unresolved-block-ref', `<RunBlock #${id ?? ''}> requires an existing flow-local block://#id`);
383
+ }
384
+ if (listChildren(node).length) note('run-block-body', 'RunBlock cannot have an inline body');
385
+ if (node.attributes?.config !== undefined || node.attributes?.input !== undefined) note('block-context-override', 'RunBlock preserves caller context and accepts no input/config override');
386
+ }
366
387
  if (node.tag === 'Require' || node.tag === 'Until' || node.tag === 'ForEach') requireCodeRef(node, 'when');
367
388
  if (node.tag === 'Retry') requirePositiveInteger(node, 'maxAttempts', true);
368
389
  if (node.tag === 'Timeout') {
@@ -371,7 +392,20 @@ function loadCtrlFlowSources(
371
392
  note('invalid-timeout', `<Timeout #${id ?? ''}>.timeoutMs must be a non-negative number`);
372
393
  }
373
394
  }
374
- if (node.tag === 'ForEach') requirePositiveInteger(node, 'concurrency');
395
+ if (node.tag === 'Until') {
396
+ requirePositiveInteger(node, 'maxIterations');
397
+ if (node.attributes?.onExhausted !== undefined && !['fail', 'complete'].includes(String(node.attributes.onExhausted))) {
398
+ note('invalid-on-exhausted', `<Until #${id ?? ''}>.onExhausted must be fail or complete`);
399
+ }
400
+ }
401
+ if (node.tag === 'ForEach') {
402
+ requirePositiveInteger(node, 'concurrency');
403
+ requirePositiveInteger(node, 'maxItems');
404
+ const maxItems = node.attributes?.maxItems;
405
+ if (maxItems !== undefined && (typeof maxItems !== 'number' || maxItems > 1000)) note('invalid-max-items', `<ForEach #${id ?? ''}>.maxItems must be 1..1000`);
406
+ const concurrency = node.attributes?.concurrency;
407
+ if (concurrency !== undefined && typeof concurrency === 'number' && concurrency > Math.min(32, typeof maxItems === 'number' ? maxItems : 1000)) note('invalid-concurrency', `<ForEach #${id ?? ''}>.concurrency exceeds its bounded capacity`);
408
+ }
375
409
  if (node.tag === 'CallFlow') {
376
410
  const target = node.attributes?.flow;
377
411
  if (typeof target !== 'string' || !/^(?:instant-ctrl-flow|work-ctrl-flow|bp-ctrl-flow):\/\/[\w.-]+$/.test(target)) {
@@ -437,6 +471,25 @@ function loadCtrlFlowSources(
437
471
  }
438
472
  };
439
473
  validateBody(statementEls);
474
+ const refs = (nodes: XnlEl[], result = new Set<string>()): Set<string> => {
475
+ for (const n of nodes) {
476
+ if (n.tag === 'RunBlock' && typeof n.attributes?.block === 'string') result.add(n.attributes.block.slice(9));
477
+ refs(listChildren(n), result); refs(sectionChildren(n), result);
478
+ }
479
+ return result;
480
+ };
481
+ for (const block of blockEls) {
482
+ validateBody(listChildren(block));
483
+ blockRefs.set(idOf(block) ?? '', refs(listChildren(block)));
484
+ }
485
+ const visited = new Set<string>(), active = new Set<string>();
486
+ const checkBlock = (id: string): void => {
487
+ if (active.has(id)) { note('recursive-block', `block reference cycle at #${id}`); return; }
488
+ if (visited.has(id)) return;
489
+ active.add(id); for (const target of blockRefs.get(id) ?? []) checkBlock(target);
490
+ active.delete(id); visited.add(id);
491
+ };
492
+ for (const id of blockIds) checkBlock(id);
440
493
 
441
494
  for (const name of Object.keys(facets) as FacetName[]) {
442
495
  if (!ALLOWED_FACETS[form].has(name)) {
@@ -501,6 +554,7 @@ function loadCtrlFlowSources(
501
554
  baseUri: options.baseUri,
502
555
  dir: options.baseUri ?? '',
503
556
  statements: statementEls.map(toNodeSpec),
557
+ blocks: Object.fromEntries(blockEls.filter(b => idOf(b)).map(b => [idOf(b)!, listChildren(b).map(toNodeSpec)])),
504
558
  ...(definitionStepForest ? { definitionStepForest } : {}),
505
559
  flowContract,
506
560
  config,