instant-ctrl-flow-logic 0.1.5 → 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.5",
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',
@@ -64,6 +65,9 @@ const PROFILE_STATEMENTS: Record<FlowForm, ReadonlySet<string>> = {
64
65
  BPCtrlFlow: new Set([...COMMON_STATEMENTS, 'ExternalJob', 'Timer', 'TaskStep']),
65
66
  };
66
67
  const CODE_REF = /^vfs:\/\/[^#\s]+#[A-Za-z_$][A-Za-z0-9_$]*$/;
68
+ const HALF_CODE_RESOURCE_ENVELOPE_VERSION = 'halfcode.resource-envelope/v1';
69
+ const DEPA_FLOW_SEMANTIC_API_VERSION = 'depa.flows/v1';
70
+ const DEPA_FLOW_RESOURCE_SPEC_VERSION = 1;
67
71
 
68
72
  interface XnlEl {
69
73
  kind: string;
@@ -75,6 +79,64 @@ interface XnlEl {
75
79
  extend?: { order?: string[]; children: Record<string, XnlEl> };
76
80
  }
77
81
 
82
+ interface FlowRootIdentityProjection {
83
+ apiVersion: string;
84
+ version: string;
85
+ diagnostic?: { code: string; message: string };
86
+ }
87
+
88
+ function projectFlowRootIdentity(metadata: Readonly<Record<string, unknown>>): FlowRootIdentityProjection {
89
+ const legacyApiVersion = typeof metadata.apiVersion === 'string' ? metadata.apiVersion : undefined;
90
+ const legacyVersion = typeof metadata.version === 'string' ? metadata.version : undefined;
91
+ const hasLegacyMetadata = metadata.apiVersion !== undefined || metadata.version !== undefined;
92
+ const hasResourceMetadata = metadata.envelopeVersion !== undefined || metadata.specVersion !== undefined;
93
+ if (hasLegacyMetadata && hasResourceMetadata) {
94
+ return {
95
+ apiVersion: '',
96
+ version: '',
97
+ diagnostic: {
98
+ code: 'mixed-flow-root-metadata',
99
+ message: 'flow root must use either depa apiVersion/version or Halfcode envelopeVersion/specVersion metadata',
100
+ },
101
+ };
102
+ }
103
+ if (hasResourceMetadata) {
104
+ if (metadata.envelopeVersion !== HALF_CODE_RESOURCE_ENVELOPE_VERSION) {
105
+ return {
106
+ apiVersion: '',
107
+ version: '',
108
+ diagnostic: {
109
+ code: 'unsupported-flow-resource-envelope',
110
+ message: `flow resource root requires envelopeVersion "${HALF_CODE_RESOURCE_ENVELOPE_VERSION}"`,
111
+ },
112
+ };
113
+ }
114
+ if (metadata.specVersion !== DEPA_FLOW_RESOURCE_SPEC_VERSION) {
115
+ return {
116
+ apiVersion: '',
117
+ version: '',
118
+ diagnostic: {
119
+ code: 'unsupported-flow-resource-spec-version',
120
+ message: `flow resource root requires numeric specVersion ${DEPA_FLOW_RESOURCE_SPEC_VERSION}`,
121
+ },
122
+ };
123
+ }
124
+ return {
125
+ apiVersion: DEPA_FLOW_SEMANTIC_API_VERSION,
126
+ version: String(DEPA_FLOW_RESOURCE_SPEC_VERSION),
127
+ };
128
+ }
129
+ if (legacyApiVersion && legacyVersion) return { apiVersion: legacyApiVersion, version: legacyVersion };
130
+ return {
131
+ apiVersion: legacyApiVersion ?? '',
132
+ version: legacyVersion ?? '',
133
+ diagnostic: {
134
+ code: 'container-missing-meta',
135
+ message: 'container missing metadata "apiVersion" or "version"',
136
+ },
137
+ };
138
+ }
139
+
78
140
  const isEl = (n: unknown): n is XnlEl =>
79
141
  !!n && typeof n === 'object' && ((n as XnlEl).kind === 'DataElement' || (n as XnlEl).kind === 'TextElement');
80
142
 
@@ -276,6 +338,18 @@ function loadCtrlFlowSources(
276
338
  const statementEls = listChildren(container);
277
339
  for (const statement of statementEls) visitStatement(statement);
278
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>>();
279
353
  const seenIds = new Set<string>();
280
354
  const requireCodeRef = (el: XnlEl, field: string) => {
281
355
  const value = el.attributes?.[field];
@@ -302,6 +376,14 @@ function loadCtrlFlowSources(
302
376
  else seenIds.add(id);
303
377
 
304
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
+ }
305
387
  if (node.tag === 'Require' || node.tag === 'Until' || node.tag === 'ForEach') requireCodeRef(node, 'when');
306
388
  if (node.tag === 'Retry') requirePositiveInteger(node, 'maxAttempts', true);
307
389
  if (node.tag === 'Timeout') {
@@ -310,7 +392,20 @@ function loadCtrlFlowSources(
310
392
  note('invalid-timeout', `<Timeout #${id ?? ''}>.timeoutMs must be a non-negative number`);
311
393
  }
312
394
  }
313
- 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
+ }
314
409
  if (node.tag === 'CallFlow') {
315
410
  const target = node.attributes?.flow;
316
411
  if (typeof target !== 'string' || !/^(?:instant-ctrl-flow|work-ctrl-flow|bp-ctrl-flow):\/\/[\w.-]+$/.test(target)) {
@@ -376,6 +471,25 @@ function loadCtrlFlowSources(
376
471
  }
377
472
  };
378
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);
379
493
 
380
494
  for (const name of Object.keys(facets) as FacetName[]) {
381
495
  if (!ALLOWED_FACETS[form].has(name)) {
@@ -428,16 +542,19 @@ function loadCtrlFlowSources(
428
542
  : undefined;
429
543
  const stateSeed = facets['state.seed'] ? { ...(facets['state.seed'].attributes ?? {}) } : undefined;
430
544
  const taskSpace = facets['task.space'] ? toNodeSpec(facets['task.space']) : undefined;
545
+ const rootIdentity = projectFlowRootIdentity(container.metadata ?? {});
546
+ if (rootIdentity.diagnostic) note(rootIdentity.diagnostic.code, rootIdentity.diagnostic.message);
431
547
  const spec: FlowBundleSpec = {
432
548
  form,
433
549
  fqn,
434
- apiVersion: String(container.metadata?.apiVersion ?? ''),
435
- version: String(container.metadata?.version ?? ''),
550
+ apiVersion: rootIdentity.apiVersion,
551
+ version: rootIdentity.version,
436
552
  title: container.attributes?.title as string | undefined,
437
553
  sourceNames,
438
554
  baseUri: options.baseUri,
439
555
  dir: options.baseUri ?? '',
440
556
  statements: statementEls.map(toNodeSpec),
557
+ blocks: Object.fromEntries(blockEls.filter(b => idOf(b)).map(b => [idOf(b)!, listChildren(b).map(toNodeSpec)])),
441
558
  ...(definitionStepForest ? { definitionStepForest } : {}),
442
559
  flowContract,
443
560
  config,
@@ -447,7 +564,6 @@ function loadCtrlFlowSources(
447
564
  taskSpace,
448
565
  diagnostics,
449
566
  };
450
- if (!spec.apiVersion) note('container-missing-meta', 'container missing metadata "apiVersion"');
451
567
  if (!fqn.includes('.')) note('container-id-not-fqn', `container #id must be a dotted FQN, got "${fqn}"`);
452
568
  return { spec, diagnostics };
453
569
  }