instant-ctrl-flow-logic 0.1.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.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "instant-ctrl-flow-logic",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "InstantCtrlFlow 引擎:xnl bundle 加载 → 行为树编译 → 无状态单次执行",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./browser": "./src/browser.ts",
11
+ "./filesystem": "./src/filesystem.ts"
12
+ },
13
+ "dependencies": {
14
+ "xnl-core": "^0.1.8",
15
+ "depa-behavior-tree": "0.1.0",
16
+ "instant-ctrl-flow-contract": "0.1.0"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.com"
24
+ }
25
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './loader.js';
2
+ export * from './compile.js';
3
+ export * from './handlers.js';
4
+ export * from './engine.js';
package/src/compile.ts ADDED
@@ -0,0 +1,232 @@
1
+ import {
2
+ BehaviorTreeNodeKind,
3
+ CtrlFlowDsl,
4
+ type BehaviorTreeNode,
5
+ type CtrlFlowNodeType,
6
+ type INodeConfig,
7
+ } from 'depa-behavior-tree';
8
+ import type { FlowBundleSpec, FlowNodeSpec } from 'instant-ctrl-flow-contract';
9
+
10
+ export type CtrlNode = BehaviorTreeNode<CtrlFlowNodeType, INodeConfig>;
11
+
12
+ const INTERRUPTIBLE = new Set(['ExternalJob', 'Timer', 'TaskStep']);
13
+ const VIEW_ATTRS = new Set(['status', 'iterations', 'runStatus', 'tickNo']);
14
+
15
+ export class FlowCompileError extends Error {
16
+ constructor(public code: string, message: string) {
17
+ super(`[${code}] ${message}`);
18
+ }
19
+ }
20
+
21
+ function resolveConfigValue(spec: FlowBundleSpec, value: unknown, where: string): unknown {
22
+ if (typeof value === 'string' && value.startsWith('config://#')) {
23
+ const [id, ...subpath] = value.slice('config://#'.length).split('/');
24
+ let current: unknown = spec.config[id];
25
+ if (current === undefined) throw new FlowCompileError('unresolved-config-ref', `${where}: ${value}`);
26
+ for (const key of subpath) {
27
+ if (!current || typeof current !== 'object' || !(key in current)) {
28
+ throw new FlowCompileError('unresolved-config-subpath', `${where}: ${value}`);
29
+ }
30
+ current = (current as Record<string, unknown>)[key];
31
+ }
32
+ return current;
33
+ }
34
+ if (Array.isArray(value)) return value.map((item) => resolveConfigValue(spec, item, where));
35
+ if (value && typeof value === 'object') {
36
+ return Object.fromEntries(
37
+ Object.entries(value).map(([key, item]) => [key, resolveConfigValue(spec, item, where)]),
38
+ );
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function resolvedAttrs(spec: FlowBundleSpec, node: FlowNodeSpec): Record<string, unknown> {
44
+ return Object.fromEntries(
45
+ Object.entries(node.attrs)
46
+ .filter(([key]) => !VIEW_ATTRS.has(key))
47
+ .map(([key, value]) => [key, resolveConfigValue(spec, value, `node #${node.id ?? node.tag}`)]),
48
+ );
49
+ }
50
+
51
+ function statementConfig(attrs: Record<string, unknown>): Readonly<Record<string, unknown>> {
52
+ const config = attrs.config;
53
+ return Object.freeze(config && typeof config === 'object' && !Array.isArray(config) ? { ...config } : {});
54
+ }
55
+
56
+ const action = (type: string, key: string, config: Record<string, unknown> = {}): CtrlNode =>
57
+ CtrlFlowDsl.node(type as CtrlFlowNodeType, { key }, config as INodeConfig);
58
+
59
+ const sequence = (key: string, children: CtrlNode[]): CtrlNode =>
60
+ CtrlFlowDsl.node('Sequence' as CtrlFlowNodeType, { key }, {}, children);
61
+
62
+ const selector = (key: string, children: CtrlNode[]): CtrlNode =>
63
+ CtrlFlowDsl.node('Selector' as CtrlFlowNodeType, { key }, {}, children);
64
+
65
+ export interface CompileHelpers {
66
+ meta: { key: string; name?: string };
67
+ attrs: Record<string, unknown>;
68
+ compileStatements(nodes: FlowNodeSpec[], key: string): CtrlNode;
69
+ }
70
+
71
+ export type CompileExtension = (
72
+ spec: FlowBundleSpec,
73
+ node: FlowNodeSpec,
74
+ helpers: CompileHelpers,
75
+ ) => CtrlNode | null;
76
+
77
+ function compileStatement(
78
+ spec: FlowBundleSpec,
79
+ node: FlowNodeSpec,
80
+ extension: CompileExtension | undefined,
81
+ path: string,
82
+ ): CtrlNode {
83
+ const key = node.id ?? path;
84
+ const attrs = resolvedAttrs(spec, node);
85
+ const compileStatements = (nodes: FlowNodeSpec[], bodyKey: string) =>
86
+ sequence(bodyKey, nodes.map((child, index) => compileStatement(spec, child, extension, `${bodyKey}.${index}`)));
87
+ const helpers: CompileHelpers = { meta: { key, name: node.id }, attrs, compileStatements };
88
+
89
+ const extended = extension?.(spec, node, helpers);
90
+ if (extended) return extended;
91
+
92
+ if (INTERRUPTIBLE.has(node.tag)) {
93
+ throw new FlowCompileError('interruptible-in-instant-ctrl-flow', `<${node.tag} #${node.id}> illegal in InstantCtrlFlow`);
94
+ }
95
+
96
+ switch (node.tag) {
97
+ case 'Run':
98
+ return action('flow.Run', key, { src: String(attrs.src ?? ''), config: statementConfig(attrs) });
99
+ case 'Return':
100
+ return action('flow.Return', key, {
101
+ src: attrs.src === undefined ? undefined : String(attrs.src),
102
+ hasValue: Object.prototype.hasOwnProperty.call(attrs, 'value'),
103
+ value: attrs.value,
104
+ config: statementConfig(attrs),
105
+ });
106
+ case 'If': {
107
+ const ifPath = key;
108
+ const branches = node.sections.Branches?.children ?? [];
109
+ const choices = branches.map((branch, index) => {
110
+ const branchKey = branch.id ?? `${key}.branch.${index}`;
111
+ if (branch.tag === 'Otherwise') {
112
+ return sequence(`${branchKey}.__choice`, [
113
+ action('flow.Otherwise', `${branchKey}.__otherwise`, {
114
+ ifPath,
115
+ branchKey,
116
+ decisionKind: 'Otherwise',
117
+ }),
118
+ compileStatements(branch.children, `${branchKey}.__body`),
119
+ ]);
120
+ }
121
+ if (branch.tag !== 'Branch') {
122
+ throw new FlowCompileError('invalid-if-branch', `<If #${key}> contains <${branch.tag}>`);
123
+ }
124
+ const branchAttrs = resolvedAttrs(spec, branch);
125
+ return sequence(`${branchKey}.__choice`, [
126
+ action('flow.Predicate', `${branchKey}.__predicate`, {
127
+ ifPath,
128
+ branchKey,
129
+ src: String(branchAttrs.when ?? ''),
130
+ config: statementConfig(branchAttrs),
131
+ }),
132
+ compileStatements(branch.children, `${branchKey}.__body`),
133
+ ]);
134
+ });
135
+ if (!branches.some((branch) => branch.tag === 'Otherwise')) {
136
+ choices.push(action('flow.Otherwise', `${key}.__no_match`, { ifPath, decisionKind: 'NoMatch' }));
137
+ }
138
+ return sequence(key, [
139
+ action('flow.IfStart', `${key}.__start`, { ifPath }),
140
+ selector(`${key}.__branches`, choices),
141
+ ]);
142
+ }
143
+ case 'Fallback': {
144
+ const strategies = node.children.map((strategy, index) => {
145
+ if (strategy.tag !== 'Strategy') {
146
+ throw new FlowCompileError('invalid-fallback-strategy', `<Fallback #${key}> contains <${strategy.tag}>`);
147
+ }
148
+ const strategyKey = strategy.id ?? `${key}.strategy.${index}`;
149
+ return sequence(`${strategyKey}.__strategy`, [
150
+ action('flow.StrategyStart', `${strategyKey}.__start`, { fallbackKey: key }),
151
+ compileStatements(strategy.children, `${strategyKey}.__body`),
152
+ ]);
153
+ });
154
+ if (!strategies.length) throw new FlowCompileError('empty-fallback', `<Fallback #${key}> has no Strategy`);
155
+ return sequence(key, [
156
+ action('flow.FallbackStart', `${key}.__start`, { fallbackKey: key }),
157
+ selector(`${key}.__strategies`, strategies),
158
+ ]);
159
+ }
160
+ case 'Until': {
161
+ const predicateKey = `${key}.__predicate_value`;
162
+ const predicateConfig = {
163
+ predicateKey,
164
+ src: String(attrs.when ?? ''),
165
+ config: statementConfig(attrs),
166
+ maxIterations: Number(attrs.maxIterations ?? 9999),
167
+ };
168
+ const initial = action('flow.UntilPredicate', `${key}.__check.initial`, { ...predicateConfig, countIteration: false });
169
+ const repeated = action('flow.UntilPredicate', `${key}.__check.repeat`, { ...predicateConfig, countIteration: true });
170
+ const body = sequence(`${key}.__body`, [compileStatements(node.children, `${key}.__statements`), repeated]);
171
+ const until = CtrlFlowDsl.node(
172
+ 'Until' as CtrlFlowNodeType,
173
+ { key: `${key}.__until` },
174
+ { expression: `getVar("__flow_execution__").untilValues[${JSON.stringify(predicateKey)}] === true` },
175
+ [body],
176
+ );
177
+ return sequence(key, [initial, until]);
178
+ }
179
+ case 'Require':
180
+ return action('flow.Require', key, {
181
+ src: String(attrs.when ?? ''),
182
+ config: statementConfig(attrs),
183
+ });
184
+ case 'Retry':
185
+ case 'Timeout':
186
+ return action(`flow.${node.tag}`, key, {
187
+ ...attrs,
188
+ config: statementConfig(attrs),
189
+ statements: node.children,
190
+ });
191
+ case 'ForEach':
192
+ return action('flow.ForEach', key, {
193
+ ...attrs,
194
+ src: String(attrs.when ?? ''),
195
+ config: statementConfig(attrs),
196
+ statements: node.children,
197
+ });
198
+ case 'Parallel': {
199
+ const lanes = node.sections.Lanes?.children ?? [];
200
+ return action('flow.Parallel', key, {
201
+ ...attrs,
202
+ items: lanes.map((lane) => ({ id: lane.id, statements: lane.children })),
203
+ });
204
+ }
205
+ case 'Race': {
206
+ const candidates = node.sections.Candidates?.children ?? [];
207
+ return action('flow.Race', key, {
208
+ ...attrs,
209
+ items: candidates.map((candidate) => ({ id: candidate.id, statements: candidate.children })),
210
+ });
211
+ }
212
+ case 'CallFlow':
213
+ return action('flow.CallFlow', key, {
214
+ flow: String(attrs.flow ?? ''),
215
+ config: statementConfig(attrs),
216
+ });
217
+ default:
218
+ throw new FlowCompileError('unknown-node-tag', `unknown statement tag <${node.tag}>`);
219
+ }
220
+ }
221
+
222
+ export function compileNode(spec: FlowBundleSpec, node: FlowNodeSpec, extension?: CompileExtension): CtrlNode {
223
+ return compileStatement(spec, node, extension, node.id ?? node.tag);
224
+ }
225
+
226
+ export function compileBundle(spec: FlowBundleSpec, extension?: CompileExtension): CtrlNode {
227
+ const root = sequence('__statements__', spec.statements.map((node, index) =>
228
+ compileStatement(spec, node, extension, `statement.${index}`),
229
+ ));
230
+ root.Kind = BehaviorTreeNodeKind.Sequence;
231
+ return root;
232
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,171 @@
1
+ import {
2
+ ActionHandlerRegistry,
3
+ BehaviorTreeEngine,
4
+ BehaviorTreeNodeStatus,
5
+ evaluateExpression,
6
+ type BehaviorTreeData,
7
+ type CtrlFlowNodeType,
8
+ type INodeConfig,
9
+ } from 'depa-behavior-tree';
10
+ import type {
11
+ FlowBundleSpec,
12
+ FlowCodeResolver,
13
+ CtrlFlowResolver,
14
+ FlowNodeSpec,
15
+ FlowSourceCollection,
16
+ InstantCtrlFlowRunOptions,
17
+ LoadFlowSourcesOptions,
18
+ } from 'instant-ctrl-flow-contract';
19
+ import { compileBundle, FlowCompileError } from './compile.js';
20
+ import { loadFlowBundleFromSources } from './loader.js';
21
+ import {
22
+ createFlowExecutionState,
23
+ createFlowHandlers,
24
+ FLOW_STATE_KEY,
25
+ } from './handlers.js';
26
+
27
+ export class FlowLoadError extends Error {
28
+ constructor(public codes: string[], message: string) {
29
+ super(message);
30
+ }
31
+ }
32
+
33
+ export class FlowExecutionError extends Error {
34
+ constructor(message: string, public cause?: unknown) {
35
+ super(message);
36
+ }
37
+ }
38
+
39
+ function requireInstantCtrlFlowSpec(spec: FlowBundleSpec | undefined, diagnostics: readonly { code: string; message: string }[]): FlowBundleSpec {
40
+ const hard = diagnostics.filter((diagnostic) => diagnostic.code !== 'parser-warning');
41
+ if (!spec || hard.length) {
42
+ throw new FlowLoadError(
43
+ diagnostics.map((diagnostic) => diagnostic.code),
44
+ `failed to load flow: ${diagnostics.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join('; ')}`,
45
+ );
46
+ }
47
+ if (spec.form !== 'InstantCtrlFlow') {
48
+ throw new FlowLoadError(['form-mismatch'], `bundle ${spec.fqn} is a ${spec.form}, not an InstantCtrlFlow`);
49
+ }
50
+ return spec;
51
+ }
52
+
53
+ export function loadInstantCtrlFlowSources(
54
+ sources: FlowSourceCollection,
55
+ options: LoadFlowSourcesOptions = {},
56
+ ): FlowBundleSpec {
57
+ const result = loadFlowBundleFromSources(sources, options);
58
+ return requireInstantCtrlFlowSpec(result.spec, result.diagnostics);
59
+ }
60
+
61
+ export interface RunInstantCtrlFlowSpecOptions extends InstantCtrlFlowRunOptions {
62
+ resolveCode: FlowCodeResolver;
63
+ resolveFlow?: CtrlFlowResolver;
64
+ }
65
+
66
+ export interface MaterializedInstantCtrlFlow<Output = unknown> {
67
+ readonly spec: FlowBundleSpec;
68
+ run(options?: InstantCtrlFlowRunOptions): Promise<Output>;
69
+ }
70
+
71
+ export function materializeInstantCtrlFlowSpec<Output = unknown>(
72
+ spec: FlowBundleSpec,
73
+ resolveCode: FlowCodeResolver,
74
+ resolveFlow?: CtrlFlowResolver,
75
+ ): MaterializedInstantCtrlFlow<Output> {
76
+ requireInstantCtrlFlowSpec(spec, spec.diagnostics);
77
+ return {
78
+ spec,
79
+ run: (options = {}) => runInstantCtrlFlowSpec<Output>(spec, { ...options, resolveCode, resolveFlow }),
80
+ };
81
+ }
82
+
83
+ export function materializeInstantCtrlFlowSources<Output = unknown>(
84
+ sources: FlowSourceCollection,
85
+ options: LoadFlowSourcesOptions & { resolveCode: FlowCodeResolver; resolveFlow?: CtrlFlowResolver },
86
+ ): MaterializedInstantCtrlFlow<Output> {
87
+ return materializeInstantCtrlFlowSpec(loadInstantCtrlFlowSources(sources, options), options.resolveCode, options.resolveFlow);
88
+ }
89
+
90
+ export async function runInstantCtrlFlowSpec<Output = unknown>(
91
+ spec: FlowBundleSpec,
92
+ options: RunInstantCtrlFlowSpecOptions,
93
+ ): Promise<Output> {
94
+ requireInstantCtrlFlowSpec(spec, spec.diagnostics);
95
+ return runStatements<Output>(spec, spec.statements, options);
96
+ }
97
+
98
+ async function runStatements<Output>(
99
+ spec: FlowBundleSpec,
100
+ statements: FlowNodeSpec[],
101
+ options: RunInstantCtrlFlowSpecOptions & { signal?: AbortSignal },
102
+ ): Promise<Output> {
103
+ if (options.signal?.aborted) throw new FlowExecutionError(`InstantCtrlFlow ${spec.fqn} was cancelled`);
104
+ const executionSpec = statements === spec.statements ? spec : { ...spec, statements };
105
+ const tree = compileBundle(executionSpec);
106
+ const state = createFlowExecutionState(options.input ?? {});
107
+ const registry = new ActionHandlerRegistry<CtrlFlowNodeType, INodeConfig>();
108
+ const runtimeWithSignal = (runtime: Record<string, unknown>, signal?: AbortSignal) => {
109
+ if (!signal) return runtime;
110
+ const overlay = Object.create(runtime) as Record<string, unknown>;
111
+ overlay.abortSignal = signal;
112
+ return overlay;
113
+ };
114
+ const executeBody = (body: FlowNodeSpec[], input: unknown, runtime: Record<string, unknown>, signal?: AbortSignal) =>
115
+ runStatements(executionSpec, body, { ...options, input, runtime: runtimeWithSignal(runtime, signal), signal });
116
+ const executeTarget = async (reference: string, input: unknown, runtime: Record<string, unknown>, signal?: AbortSignal) => {
117
+ if (!options.resolveFlow) throw new FlowExecutionError(`CallFlow ${reference} requires resolveFlow`);
118
+ const target = await options.resolveFlow(reference, spec);
119
+ const match = /^([a-z-]+):\/\/([\w.-]+)$/.exec(reference);
120
+ const expectedScheme = target.spec.form === 'InstantCtrlFlow'
121
+ ? 'instant-ctrl-flow'
122
+ : target.spec.form === 'WorkCtrlFlow'
123
+ ? 'work-ctrl-flow'
124
+ : 'bp-ctrl-flow';
125
+ if (!match || match[1] !== expectedScheme || match[2] !== target.spec.fqn) {
126
+ throw new FlowExecutionError(`CallFlow resolver returned ${target.spec.form} #${target.spec.fqn} for ${reference}`);
127
+ }
128
+ if (target.spec.form !== 'InstantCtrlFlow') {
129
+ throw new FlowExecutionError(`InstantCtrlFlow cannot call suspendable ${target.spec.form} target ${reference}`);
130
+ }
131
+ return runStatements(target.spec, target.spec.statements, {
132
+ ...options,
133
+ input,
134
+ runtime: runtimeWithSignal(runtime, signal),
135
+ signal,
136
+ resolveCode: target.resolveCode,
137
+ });
138
+ };
139
+ for (const [type, handler] of Object.entries(
140
+ createFlowHandlers(executionSpec, options.resolveCode, undefined, executeBody, executeTarget),
141
+ )) {
142
+ registry.register(type as CtrlFlowNodeType, handler);
143
+ }
144
+ const data: BehaviorTreeData<CtrlFlowNodeType, INodeConfig> = {
145
+ NodeTree: tree,
146
+ Vars: new Map([[FLOW_STATE_KEY, state]]),
147
+ CmdStack: [],
148
+ CmdHistory: [],
149
+ NodeMap: new Map(),
150
+ };
151
+ const engine = new BehaviorTreeEngine(registry, evaluateExpression);
152
+ await engine.start(data, options.runtime ?? {});
153
+ while (!engine.isComplete(data)) {
154
+ await new Promise((resolve) => setTimeout(resolve, 0));
155
+ await engine.runPendingCommands(data, options.runtime ?? {});
156
+ }
157
+ if (options.signal?.aborted) throw new FlowExecutionError(`InstantCtrlFlow ${spec.fqn} was cancelled`);
158
+ if (data.NodeTree.Status !== BehaviorTreeNodeStatus.Success || state.fault) {
159
+ throw new FlowExecutionError(`InstantCtrlFlow ${spec.fqn} failed`, state.fault);
160
+ }
161
+ return (state.returned ? state.output : state.current) as Output;
162
+ }
163
+
164
+ export async function runInstantCtrlFlowSources<Output = unknown>(
165
+ sources: FlowSourceCollection,
166
+ options: RunInstantCtrlFlowSpecOptions & LoadFlowSourcesOptions,
167
+ ): Promise<Output> {
168
+ return runInstantCtrlFlowSpec<Output>(loadInstantCtrlFlowSources(sources, options), options);
169
+ }
170
+
171
+ export { FlowCompileError };
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import type { FlowCodeFunction, FlowCodeResolver, LoadFlowSourcesOptions } from 'instant-ctrl-flow-contract';
5
+ import { loadFlowBundleFromSources, type LoadResult } from './loader.js';
6
+ import { FlowLoadError, runInstantCtrlFlowSpec } from './engine.js';
7
+ import type { InstantCtrlFlowRunOptions } from 'instant-ctrl-flow-contract';
8
+
9
+ /** Filesystem convenience only; parsing and validation belong to the named-source loader. */
10
+ export function loadFlowBundle(dir: string, options: LoadFlowSourcesOptions = {}): LoadResult {
11
+ const absoluteDir = path.resolve(dir);
12
+ const sources = fs.existsSync(absoluteDir)
13
+ ? fs.readdirSync(absoluteDir, { withFileTypes: true })
14
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.xnl'))
15
+ .sort((left, right) => left.name.localeCompare(right.name))
16
+ .map((entry) => ({
17
+ name: entry.name,
18
+ content: fs.readFileSync(path.join(absoluteDir, entry.name), 'utf8'),
19
+ }))
20
+ : [];
21
+ return loadFlowBundleFromSources(sources, { ...options, baseUri: options.baseUri ?? absoluteDir });
22
+ }
23
+
24
+ export type FlowModuleImporter = (specifier: string) => Promise<Readonly<Record<string, unknown>>>;
25
+
26
+ export function createFilesystemFlowCodeResolver<Runtime = unknown>(
27
+ importModule: FlowModuleImporter = (specifier) => import(specifier),
28
+ ): FlowCodeResolver<Runtime> {
29
+ const modules = new Map<string, Promise<Readonly<Record<string, unknown>>>>();
30
+ return async ({ reference, baseUri, flowId, nodeId }) => {
31
+ const match = /^vfs:\/\/\.\/(.+)#([A-Za-z_$][A-Za-z0-9_$]*)$/.exec(reference);
32
+ if (!match) throw new Error(`unsupported filesystem VFS reference: ${reference}`);
33
+ if (!baseUri) throw new Error(`${flowId}#${nodeId} has no baseUri for ${reference}`);
34
+ const root = path.resolve(baseUri);
35
+ const file = path.resolve(root, match[1]);
36
+ const relative = path.relative(root, file);
37
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${reference} escapes Flow baseUri`);
38
+ const specifier = pathToFileURL(file).href;
39
+ let loaded = modules.get(specifier);
40
+ if (!loaded) {
41
+ loaded = importModule(specifier);
42
+ modules.set(specifier, loaded);
43
+ }
44
+ const resolved = (await loaded)[match[2]];
45
+ if (typeof resolved !== 'function') throw new Error(`${reference} does not identify a function export`);
46
+ return resolved as FlowCodeFunction<unknown, Runtime>;
47
+ };
48
+ }
49
+
50
+ export function loadInstantCtrlFlowSpec(bundleDir: string) {
51
+ const result = loadFlowBundle(bundleDir);
52
+ const hard = result.diagnostics.filter((diagnostic) => diagnostic.code !== 'parser-warning');
53
+ if (!result.spec || hard.length) {
54
+ throw new FlowLoadError(
55
+ result.diagnostics.map((item) => item.code),
56
+ `failed to load InstantCtrlFlow ${bundleDir}: ${result.diagnostics.map((item) => `[${item.code}] ${item.message}`).join('; ')}`,
57
+ );
58
+ }
59
+ if (result.spec.form !== 'InstantCtrlFlow') {
60
+ throw new FlowLoadError(['form-mismatch'], `bundle ${result.spec.fqn} is a ${result.spec.form}, not an InstantCtrlFlow`);
61
+ }
62
+ return result.spec;
63
+ }
64
+
65
+ export async function runInstantCtrlFlow<Output = unknown>(
66
+ bundleDir: string,
67
+ options: InstantCtrlFlowRunOptions = {},
68
+ ): Promise<Output> {
69
+ return runInstantCtrlFlowSpec<Output>(loadInstantCtrlFlowSpec(bundleDir), {
70
+ ...options,
71
+ resolveCode: createFilesystemFlowCodeResolver(),
72
+ });
73
+ }
@@ -0,0 +1,464 @@
1
+ import {
2
+ BehaviorResult,
3
+ type ActionHandler,
4
+ type CtrlFlowNodeType,
5
+ type ExecutionContext,
6
+ type INodeConfig,
7
+ } from 'depa-behavior-tree';
8
+ import type {
9
+ FlowBundleSpec,
10
+ FlowCodeFunction,
11
+ FlowCodeResolver,
12
+ FlowNodeSpec,
13
+ InstantCtrlFlowRuntime,
14
+ } from 'instant-ctrl-flow-contract';
15
+
16
+ type Ctx = ExecutionContext<CtrlFlowNodeType, INodeConfig>;
17
+
18
+ export const FLOW_STATE_KEY = '__flow_execution__';
19
+
20
+ export interface FlowExecutionState {
21
+ current: unknown;
22
+ returned: boolean;
23
+ output?: unknown;
24
+ fault?: unknown;
25
+ selections: Record<string, string>;
26
+ fallbackInputs: Record<string, unknown>;
27
+ untilIterations: Record<string, number>;
28
+ untilValues: Record<string, boolean>;
29
+ }
30
+
31
+ export const createFlowExecutionState = (input: unknown): FlowExecutionState => ({
32
+ current: input,
33
+ returned: false,
34
+ selections: {},
35
+ fallbackInputs: {},
36
+ untilIterations: {},
37
+ untilValues: {},
38
+ });
39
+
40
+ export const flowStateOf = (ctx: Ctx): FlowExecutionState => ctx.getVar(FLOW_STATE_KEY) as FlowExecutionState;
41
+
42
+ type NodeConfig = {
43
+ src?: string;
44
+ config?: Readonly<Record<string, unknown>>;
45
+ hasValue?: boolean;
46
+ value?: unknown;
47
+ ifPath?: string;
48
+ branchKey?: string;
49
+ decisionKind?: FlowBranchDecisionKind;
50
+ fallbackKey?: string;
51
+ predicateKey?: string;
52
+ countIteration?: boolean;
53
+ maxIterations?: number;
54
+ statements?: FlowNodeSpec[];
55
+ items?: { id?: string; statements: FlowNodeSpec[] }[];
56
+ flow?: string;
57
+ maxAttempts?: number;
58
+ timeoutMs?: number;
59
+ concurrency?: number;
60
+ mode?: string;
61
+ };
62
+
63
+ export type FlowBodyExecutor = (
64
+ statements: FlowNodeSpec[],
65
+ input: unknown,
66
+ runtime: InstantCtrlFlowRuntime,
67
+ signal?: AbortSignal,
68
+ ) => Promise<unknown>;
69
+
70
+ export type FlowTargetExecutor = (
71
+ reference: string,
72
+ input: unknown,
73
+ runtime: InstantCtrlFlowRuntime,
74
+ signal?: AbortSignal,
75
+ ) => Promise<unknown>;
76
+
77
+ export type FlowBranchDecisionKind = 'Branch' | 'Otherwise' | 'NoMatch';
78
+
79
+ export interface FlowBranchDecision {
80
+ ifPath: string;
81
+ invocationKey: string;
82
+ kind: FlowBranchDecisionKind;
83
+ branchKey?: string;
84
+ }
85
+
86
+ export interface FlowDecisionContext {
87
+ ifPath: string;
88
+ invocationKey: string;
89
+ decision?: FlowBranchDecision;
90
+ }
91
+
92
+ /** Optional persistence boundary used by replaying profiles; InstantCtrlFlow leaves it undefined. */
93
+ export interface FlowDecisionHooks {
94
+ begin(ifPath: string): FlowDecisionContext;
95
+ record(decision: FlowBranchDecision): Promise<void> | void;
96
+ }
97
+
98
+ class FlowFunctionLoader {
99
+ private cache = new Map<string, Promise<FlowCodeFunction>>();
100
+ constructor(
101
+ private spec: FlowBundleSpec,
102
+ private resolveCode: FlowCodeResolver,
103
+ ) {}
104
+
105
+ load(src: string, nodeId: string): Promise<FlowCodeFunction> {
106
+ let loaded = this.cache.get(src);
107
+ if (!loaded) {
108
+ loaded = (async () => {
109
+ const fn = await this.resolveCode({
110
+ reference: src,
111
+ flowId: this.spec.fqn,
112
+ nodeId,
113
+ baseUri: this.spec.baseUri,
114
+ });
115
+ if (typeof fn !== 'function') throw new Error(`vfs src "${src}" does not export a function`);
116
+ return fn;
117
+ })();
118
+ this.cache.set(src, loaded);
119
+ }
120
+ return loaded;
121
+ }
122
+ }
123
+
124
+ function configOf(ctx: Ctx): NodeConfig {
125
+ return ctx.node.Config as NodeConfig;
126
+ }
127
+
128
+ async function callCode(ctx: Ctx, loader: FlowFunctionLoader, src: string): Promise<unknown> {
129
+ const state = flowStateOf(ctx);
130
+ const fn = await loader.load(src, ctx.node.Key);
131
+ return fn((ctx.runtime ?? {}) as InstantCtrlFlowRuntime, state.current, configOf(ctx).config ?? {});
132
+ }
133
+
134
+ function fail(state: FlowExecutionState, error: unknown): BehaviorResult {
135
+ state.fault = error;
136
+ return BehaviorResult.Failure;
137
+ }
138
+
139
+ class RunHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
140
+ constructor(private loader: FlowFunctionLoader) {}
141
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
142
+ const state = flowStateOf(ctx);
143
+ if (state.returned) return BehaviorResult.Success;
144
+ if (state.fault) return BehaviorResult.Failure;
145
+ try {
146
+ state.current = await callCode(ctx, this.loader, String(configOf(ctx).src ?? ''));
147
+ return BehaviorResult.Success;
148
+ } catch (error) {
149
+ return fail(state, error);
150
+ }
151
+ }
152
+ }
153
+
154
+ class ReturnHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
155
+ constructor(private loader: FlowFunctionLoader) {}
156
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
157
+ const state = flowStateOf(ctx);
158
+ if (state.returned) return BehaviorResult.Success;
159
+ if (state.fault) return BehaviorResult.Failure;
160
+ const config = configOf(ctx);
161
+ try {
162
+ const output = config.src
163
+ ? await callCode(ctx, this.loader, config.src)
164
+ : config.hasValue
165
+ ? config.value
166
+ : state.current;
167
+ state.current = output;
168
+ state.output = output;
169
+ state.returned = true;
170
+ return BehaviorResult.Success;
171
+ } catch (error) {
172
+ return fail(state, error);
173
+ }
174
+ }
175
+ }
176
+
177
+ class IfStartHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
178
+ constructor(
179
+ private decisionHooks: FlowDecisionHooks | undefined,
180
+ private activeDecisions: Map<string, FlowDecisionContext>,
181
+ ) {}
182
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
183
+ const state = flowStateOf(ctx);
184
+ if (state.returned) return BehaviorResult.Success;
185
+ if (state.fault) return BehaviorResult.Failure;
186
+ const ifPath = String(configOf(ctx).ifPath);
187
+ delete state.selections[ifPath];
188
+ if (this.decisionHooks) this.activeDecisions.set(ifPath, this.decisionHooks.begin(ifPath));
189
+ return BehaviorResult.Success;
190
+ }
191
+ }
192
+
193
+ class PredicateHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
194
+ constructor(
195
+ private loader: FlowFunctionLoader,
196
+ private decisionHooks: FlowDecisionHooks | undefined,
197
+ private activeDecisions: Map<string, FlowDecisionContext>,
198
+ ) {}
199
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
200
+ const state = flowStateOf(ctx);
201
+ if (state.returned) return BehaviorResult.Success;
202
+ if (state.fault) return BehaviorResult.Failure;
203
+ const config = configOf(ctx);
204
+ const ifPath = String(config.ifPath);
205
+ const branchKey = String(config.branchKey);
206
+ const decisionContext = this.activeDecisions.get(ifPath);
207
+ if (decisionContext?.decision) {
208
+ return decisionContext.decision.kind === 'Branch' && decisionContext.decision.branchKey === branchKey
209
+ ? BehaviorResult.Success
210
+ : BehaviorResult.Failure;
211
+ }
212
+ if (state.selections[ifPath]) return BehaviorResult.Failure;
213
+ try {
214
+ const result = await callCode(ctx, this.loader, String(config.src ?? ''));
215
+ if (typeof result !== 'boolean') throw new TypeError(`predicate ${config.src} must return boolean`);
216
+ if (!result) return BehaviorResult.Failure;
217
+ if (this.decisionHooks && decisionContext) {
218
+ const decision: FlowBranchDecision = {
219
+ ifPath,
220
+ invocationKey: decisionContext.invocationKey,
221
+ kind: 'Branch',
222
+ branchKey,
223
+ };
224
+ await this.decisionHooks.record(decision);
225
+ decisionContext.decision = decision;
226
+ }
227
+ state.selections[ifPath] = branchKey;
228
+ return BehaviorResult.Success;
229
+ } catch (error) {
230
+ return fail(state, error);
231
+ }
232
+ }
233
+ }
234
+
235
+ class OtherwiseHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
236
+ constructor(
237
+ private decisionHooks: FlowDecisionHooks | undefined,
238
+ private activeDecisions: Map<string, FlowDecisionContext>,
239
+ ) {}
240
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
241
+ const state = flowStateOf(ctx);
242
+ if (state.returned) return BehaviorResult.Success;
243
+ const config = configOf(ctx);
244
+ const ifPath = String(config.ifPath);
245
+ const kind = config.decisionKind ?? 'Otherwise';
246
+ const branchKey = config.branchKey;
247
+ const decisionContext = this.activeDecisions.get(ifPath);
248
+ if (state.fault) return BehaviorResult.Failure;
249
+ if (decisionContext?.decision) {
250
+ const selected = decisionContext.decision;
251
+ return selected.kind === kind && selected.branchKey === branchKey
252
+ ? BehaviorResult.Success
253
+ : BehaviorResult.Failure;
254
+ }
255
+ if (state.selections[ifPath]) return BehaviorResult.Failure;
256
+ if (this.decisionHooks && decisionContext) {
257
+ try {
258
+ const decision: FlowBranchDecision = {
259
+ ifPath,
260
+ invocationKey: decisionContext.invocationKey,
261
+ kind,
262
+ ...(branchKey === undefined ? {} : { branchKey }),
263
+ };
264
+ await this.decisionHooks.record(decision);
265
+ decisionContext.decision = decision;
266
+ } catch (error) {
267
+ return fail(state, error);
268
+ }
269
+ }
270
+ return BehaviorResult.Success;
271
+ }
272
+ }
273
+
274
+ class StrategyStartHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
275
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
276
+ const state = flowStateOf(ctx);
277
+ if (state.returned) return BehaviorResult.Success;
278
+ const key = String(configOf(ctx).fallbackKey);
279
+ state.current = state.fallbackInputs[key];
280
+ state.fault = undefined;
281
+ return BehaviorResult.Success;
282
+ }
283
+ }
284
+
285
+ class FallbackStartHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
286
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
287
+ const state = flowStateOf(ctx);
288
+ if (state.returned) return BehaviorResult.Success;
289
+ const key = String(configOf(ctx).fallbackKey);
290
+ state.fallbackInputs[key] = state.current;
291
+ state.fault = undefined;
292
+ return BehaviorResult.Success;
293
+ }
294
+ }
295
+
296
+ class UntilPredicateHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
297
+ constructor(private loader: FlowFunctionLoader) {}
298
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
299
+ const state = flowStateOf(ctx);
300
+ const config = configOf(ctx);
301
+ const key = String(config.predicateKey);
302
+ if (state.returned) {
303
+ state.untilValues[key] = true;
304
+ return BehaviorResult.Success;
305
+ }
306
+ if (state.fault) return BehaviorResult.Failure;
307
+ try {
308
+ const result = await callCode(ctx, this.loader, String(config.src ?? ''));
309
+ if (typeof result !== 'boolean') throw new TypeError(`predicate ${config.src} must return boolean`);
310
+ if (config.countIteration) {
311
+ const iterations = (state.untilIterations[key] ?? 0) + 1;
312
+ state.untilIterations[key] = iterations;
313
+ if (!result && iterations >= Number(config.maxIterations ?? 9999)) {
314
+ return fail(state, new Error(`Until node exceeded max iterations (${config.maxIterations})`));
315
+ }
316
+ }
317
+ state.untilValues[key] = result;
318
+ return BehaviorResult.Success;
319
+ } catch (error) {
320
+ return fail(state, error);
321
+ }
322
+ }
323
+ }
324
+
325
+ class RequireHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
326
+ constructor(private loader: FlowFunctionLoader) {}
327
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
328
+ const state = flowStateOf(ctx);
329
+ try {
330
+ const result = await callCode(ctx, this.loader, String(configOf(ctx).src ?? ''));
331
+ if (typeof result !== 'boolean') throw new TypeError('Require predicate must return boolean');
332
+ if (!result) throw new Error(`precondition failed at #${ctx.node.Key}`);
333
+ return BehaviorResult.Success;
334
+ } catch (error) {
335
+ return fail(state, error);
336
+ }
337
+ }
338
+ }
339
+
340
+ class CompositeHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
341
+ constructor(
342
+ private kind: 'Retry' | 'Timeout' | 'ForEach' | 'Parallel' | 'Race',
343
+ private loader: FlowFunctionLoader,
344
+ private executeBody: FlowBodyExecutor,
345
+ ) {}
346
+
347
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
348
+ const state = flowStateOf(ctx);
349
+ const config = configOf(ctx);
350
+ const runtime = (ctx.runtime ?? {}) as InstantCtrlFlowRuntime;
351
+ try {
352
+ if (this.kind === 'Retry') {
353
+ const attempts = Math.max(1, Number(config.maxAttempts ?? 1));
354
+ let lastError: unknown;
355
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
356
+ try {
357
+ state.current = await this.executeBody(config.statements ?? [], state.current, runtime);
358
+ return BehaviorResult.Success;
359
+ } catch (error) {
360
+ lastError = error;
361
+ }
362
+ }
363
+ throw lastError;
364
+ }
365
+ if (this.kind === 'Timeout') {
366
+ const controller = new AbortController();
367
+ const timeoutMs = Math.max(0, Number(config.timeoutMs ?? 0));
368
+ let timer: ReturnType<typeof setTimeout> | undefined;
369
+ try {
370
+ state.current = await Promise.race([
371
+ this.executeBody(config.statements ?? [], state.current, runtime, controller.signal),
372
+ new Promise<never>((_, reject) => {
373
+ timer = setTimeout(() => {
374
+ controller.abort();
375
+ reject(new Error(`Timeout #${ctx.node.Key} exceeded ${timeoutMs}ms`));
376
+ }, timeoutMs);
377
+ }),
378
+ ]);
379
+ } finally {
380
+ if (timer) clearTimeout(timer);
381
+ }
382
+ return BehaviorResult.Success;
383
+ }
384
+ if (this.kind === 'ForEach') {
385
+ const selected = await callCode(ctx, this.loader, String(config.src ?? configOf(ctx).src ?? ''));
386
+ if (!Array.isArray(selected)) throw new TypeError('ForEach selector must return an array');
387
+ const outputs: unknown[] = [];
388
+ for (const item of selected) outputs.push(await this.executeBody(config.statements ?? [], item, runtime));
389
+ state.current = outputs;
390
+ return BehaviorResult.Success;
391
+ }
392
+
393
+ const items = config.items ?? [];
394
+ const controllers = items.map(() => new AbortController());
395
+ const executions = items.map((item, index) =>
396
+ this.executeBody(item.statements, state.current, runtime, controllers[index].signal)
397
+ .then((value) => ({ id: item.id ?? String(index), value })),
398
+ );
399
+ if (this.kind === 'Parallel') {
400
+ const values = await Promise.all(executions);
401
+ state.current = Object.fromEntries(values.map(({ id, value }) => [id, value]));
402
+ return BehaviorResult.Success;
403
+ }
404
+
405
+ const mode = config.mode ?? 'first-success';
406
+ const winner = mode === 'first-completed'
407
+ ? await Promise.race(executions)
408
+ : await Promise.any(executions);
409
+ controllers.forEach((controller) => controller.abort());
410
+ state.current = { winner: winner.id, output: winner.value };
411
+ return BehaviorResult.Success;
412
+ } catch (error) {
413
+ return fail(state, error);
414
+ }
415
+ }
416
+ }
417
+
418
+ class CallFlowHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
419
+ constructor(private executeTarget?: FlowTargetExecutor) {}
420
+ async execute(ctx: Ctx): Promise<BehaviorResult> {
421
+ const state = flowStateOf(ctx);
422
+ try {
423
+ if (!this.executeTarget) throw new Error('CallFlow requires a CtrlFlow resolver');
424
+ state.current = await this.executeTarget(
425
+ String(configOf(ctx).flow ?? ''),
426
+ state.current,
427
+ (ctx.runtime ?? {}) as InstantCtrlFlowRuntime,
428
+ );
429
+ return BehaviorResult.Success;
430
+ } catch (error) {
431
+ return fail(state, error);
432
+ }
433
+ }
434
+ }
435
+
436
+ export function createFlowHandlers(
437
+ spec: FlowBundleSpec,
438
+ resolveCode: FlowCodeResolver,
439
+ decisionHooks?: FlowDecisionHooks,
440
+ executeBody?: FlowBodyExecutor,
441
+ executeTarget?: FlowTargetExecutor,
442
+ ): Record<string, ActionHandler<CtrlFlowNodeType, INodeConfig>> {
443
+ const loader = new FlowFunctionLoader(spec, resolveCode);
444
+ const activeDecisions = new Map<string, FlowDecisionContext>();
445
+ return {
446
+ 'flow.Run': new RunHandler(loader),
447
+ 'flow.Return': new ReturnHandler(loader),
448
+ 'flow.IfStart': new IfStartHandler(decisionHooks, activeDecisions),
449
+ 'flow.Predicate': new PredicateHandler(loader, decisionHooks, activeDecisions),
450
+ 'flow.Otherwise': new OtherwiseHandler(decisionHooks, activeDecisions),
451
+ 'flow.FallbackStart': new FallbackStartHandler(),
452
+ 'flow.StrategyStart': new StrategyStartHandler(),
453
+ 'flow.UntilPredicate': new UntilPredicateHandler(loader),
454
+ 'flow.Require': new RequireHandler(loader),
455
+ ...(executeBody ? {
456
+ 'flow.Retry': new CompositeHandler('Retry', loader, executeBody),
457
+ 'flow.Timeout': new CompositeHandler('Timeout', loader, executeBody),
458
+ 'flow.ForEach': new CompositeHandler('ForEach', loader, executeBody),
459
+ 'flow.Parallel': new CompositeHandler('Parallel', loader, executeBody),
460
+ 'flow.Race': new CompositeHandler('Race', loader, executeBody),
461
+ } : {}),
462
+ 'flow.CallFlow': new CallFlowHandler(executeTarget),
463
+ };
464
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './loader.js';
2
+ export * from './filesystem.js';
3
+ export * from './compile.js';
4
+ export * from './handlers.js';
5
+ export * from './engine.js';
package/src/loader.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * flow bundle 加载:多文件(域按文件根 tag 发现)/ 单文件(() 内联区段)同构。
3
+ * 规范真源:docs/flow-dsl/spec/flow-core/{files,domains}.md。
4
+ * 完整 lint 由 tools/flow-dsl-check 承担;此处只做引擎必需的结构化与硬校验。
5
+ */
6
+ import { parseXnl } from 'xnl-core';
7
+ import type {
8
+ FlowBundleSpec,
9
+ CtrlFlowContract,
10
+ FlowDiagnostic,
11
+ FlowForm,
12
+ FlowNodeSpec,
13
+ FlowSourceCollection,
14
+ LoadFlowSourcesOptions,
15
+ } from 'instant-ctrl-flow-contract';
16
+
17
+ const FORMS = ['InstantCtrlFlow', 'WorkCtrlFlow', 'BPCtrlFlow'] as const satisfies readonly FlowForm[];
18
+
19
+ const FACETS = {
20
+ 'flow.contract': { tag: 'FlowContract', uriScheme: 'flow-contract://' },
21
+ 'config': { tag: 'Config', uriScheme: 'config://' },
22
+ 'config.def': { tag: 'ConfigDef', uriScheme: 'config-def://' },
23
+ 'state.def': { tag: 'StateDef', uriScheme: 'state-def://' },
24
+ 'state.seed': { tag: 'StateSeed', uriScheme: 'state-seed://' },
25
+ 'task.space': { tag: 'TaskSpace', uriScheme: 'task-space://' },
26
+ } as const;
27
+
28
+ type FacetName = keyof typeof FACETS;
29
+
30
+ const TAG_TO_FACET = Object.fromEntries(
31
+ Object.entries(FACETS).map(([name, descriptor]) => [descriptor.tag, name]),
32
+ ) as Record<string, FacetName | undefined>;
33
+
34
+ const ALLOWED_FACETS: Record<FlowForm, ReadonlySet<FacetName>> = {
35
+ InstantCtrlFlow: new Set(['flow.contract', 'config', 'config.def']),
36
+ WorkCtrlFlow: new Set(['flow.contract', 'config', 'config.def', 'state.def', 'state.seed']),
37
+ BPCtrlFlow: new Set(['flow.contract', 'config', 'config.def', 'state.def', 'state.seed', 'task.space']),
38
+ };
39
+
40
+ const LEGACY_PUBLIC_CONTROL_NODES = new Set(['Sequence', 'Selector', 'Condition']);
41
+ const LEGACY_PUBLIC_ACTION_NODES = new Set(['SetVariable', 'Log', 'Assert', 'Sleep', 'HttpCall', 'RunScript']);
42
+ const COMMON_STATEMENTS = new Set([
43
+ 'Run',
44
+ 'If',
45
+ 'Require',
46
+ 'Fallback',
47
+ 'Retry',
48
+ 'Timeout',
49
+ 'Until',
50
+ 'ForEach',
51
+ 'Parallel',
52
+ 'Race',
53
+ 'CallFlow',
54
+ 'Return',
55
+ ]);
56
+ const PROFILE_STATEMENTS: Record<FlowForm, ReadonlySet<string>> = {
57
+ InstantCtrlFlow: new Set(COMMON_STATEMENTS),
58
+ WorkCtrlFlow: new Set([...COMMON_STATEMENTS, 'ExternalJob', 'Timer']),
59
+ BPCtrlFlow: new Set([...COMMON_STATEMENTS, 'ExternalJob', 'Timer', 'TaskStep']),
60
+ };
61
+ const CODE_REF = /^vfs:\/\/[^#\s]+#[A-Za-z_$][A-Za-z0-9_$]*$/;
62
+
63
+ interface XnlEl {
64
+ kind: string;
65
+ tag: string;
66
+ id?: { namespace: string[]; name: string };
67
+ metadata: Record<string, unknown>;
68
+ attributes?: Record<string, unknown>;
69
+ body?: unknown[];
70
+ extend?: { order?: string[]; children: Record<string, XnlEl> };
71
+ }
72
+
73
+ const isEl = (n: unknown): n is XnlEl =>
74
+ !!n && typeof n === 'object' && ((n as XnlEl).kind === 'DataElement' || (n as XnlEl).kind === 'TextElement');
75
+
76
+ const els = (arr?: unknown[]): XnlEl[] => (arr ?? []).filter(isEl);
77
+
78
+ const listChildren = (el: XnlEl): XnlEl[] => els(el.body);
79
+
80
+ const sectionChildren = (el: XnlEl): XnlEl[] => {
81
+ if (!el.extend) return [];
82
+ const order = el.extend.order ?? Object.keys(el.extend.children);
83
+ return order.map((k) => el.extend!.children[k]);
84
+ };
85
+
86
+ const idOf = (el: XnlEl): string | undefined => {
87
+ if (!el.id) return undefined;
88
+ const ns = el.id.namespace?.length ? el.id.namespace.join('.') + '.' : '';
89
+ return ns + el.id.name;
90
+ };
91
+
92
+ function toNodeSpec(el: XnlEl): FlowNodeSpec {
93
+ const sections: Record<string, FlowNodeSpec> = {};
94
+ for (const sec of sectionChildren(el)) sections[sec.tag] = toNodeSpec(sec);
95
+ return {
96
+ tag: el.tag,
97
+ id: idOf(el),
98
+ metadata: { ...(el.metadata ?? {}) },
99
+ attrs: { ...(el.attributes ?? {}) },
100
+ children: listChildren(el).map(toNodeSpec),
101
+ sections,
102
+ };
103
+ }
104
+
105
+ export interface LoadResult {
106
+ spec?: FlowBundleSpec;
107
+ diagnostics: FlowDiagnostic[];
108
+ }
109
+
110
+ export function loadFlowBundleFromSources(
111
+ sources: FlowSourceCollection,
112
+ options: LoadFlowSourcesOptions = {},
113
+ ): LoadResult {
114
+ const diagnostics: FlowDiagnostic[] = [];
115
+ const note = (code: string, message: string) => diagnostics.push({ code, message });
116
+ const noted = new Set<string>();
117
+ const noteOnce = (code: string, message: string) => {
118
+ if (noted.has(code)) return;
119
+ noted.add(code);
120
+ note(code, message);
121
+ };
122
+ const noteLegacyTag = (tag: string) => {
123
+ if (tag === 'Tree') {
124
+ noteOnce('legacy-tree-domain', 'Tree authoring is removed; product-root [] must contain statements directly');
125
+ } else if (tag === 'ActionTypes' || tag === 'ActionType') {
126
+ noteOnce('legacy-action-types-domain', 'action.types authoring is removed; code refs belong on statements');
127
+ } else if (tag === 'CtrlFlow') {
128
+ noteOnce('legacy-ctrl-flow-root', '<CtrlFlow> is removed; use product-root statements directly');
129
+ } else if (LEGACY_PUBLIC_CONTROL_NODES.has(tag)) {
130
+ noteOnce(
131
+ 'legacy-public-control-node',
132
+ 'public Sequence/Selector/Condition authoring is removed; use ordered statements, If, or Fallback',
133
+ );
134
+ } else if (LEGACY_PUBLIC_ACTION_NODES.has(tag) || tag.includes('.')) {
135
+ noteOnce(
136
+ 'legacy-public-action-node',
137
+ 'public behavior-tree and registered action nodes are removed; use Run with a direct vfs:// code ref',
138
+ );
139
+ }
140
+ };
141
+
142
+ const sourceEntries = (Array.isArray(sources)
143
+ ? sources.map(({ name, content }) => ({ name, content }))
144
+ : Object.entries(sources).map(([name, content]) => ({ name, content })))
145
+ .filter(({ name }) => name.endsWith('.xnl'))
146
+ .sort((left, right) => left.name.localeCompare(right.name));
147
+ const sourceNames = sourceEntries.map(({ name }) => name);
148
+ if (!sourceEntries.length) {
149
+ note('empty-bundle', `no .xnl sources in ${options.baseUri ?? 'named source collection'}`);
150
+ return { diagnostics };
151
+ }
152
+
153
+ const fileRoots: Record<string, { el: XnlEl; file: string }> = {};
154
+ for (const { name: f, content } of sourceEntries) {
155
+ let doc: { nodes: unknown[]; warnings?: { code?: string; message?: string }[] };
156
+ try {
157
+ doc = parseXnl(content) as typeof doc;
158
+ } catch (e) {
159
+ note('parse-error', `${f}: ${(e as Error).message}`);
160
+ continue;
161
+ }
162
+ for (const w of doc.warnings ?? []) note('parser-warning', `${f}: ${w.code ?? w.message}`);
163
+ for (const el of els(doc.nodes)) {
164
+ if (fileRoots[el.tag]) note('duplicate-file-root', `root <${el.tag}> in both ${fileRoots[el.tag].file} and ${f}`);
165
+ fileRoots[el.tag] = { el, file: f };
166
+ }
167
+ }
168
+
169
+ for (const tag of Object.keys(fileRoots)) noteLegacyTag(tag);
170
+
171
+ const form = FORMS.find((candidate) => fileRoots[candidate]);
172
+ if (!form) {
173
+ if (noted.size) return { diagnostics };
174
+ note('no-container', `no container root found in ${options.baseUri ?? 'named source collection'}`);
175
+ return { diagnostics };
176
+ }
177
+ const container = fileRoots[form].el;
178
+ const containerFile = fileRoots[form].file;
179
+
180
+ // Facet discovery is physical; toNodeSpec remains a pure XNL-to-contract projection.
181
+ const facets: Partial<Record<FacetName, XnlEl>> = {};
182
+ for (const [tag, { el, file }] of Object.entries(fileRoots)) {
183
+ const name = TAG_TO_FACET[tag];
184
+ if (!name || file === containerFile) continue;
185
+ if (file !== `${name}.xnl`) {
186
+ note(
187
+ 'domain-file-name-mismatch',
188
+ `facet "${name}" (${FACETS[name].uriScheme}) root <${tag}> in ${file}, expected ${name}.xnl`,
189
+ );
190
+ }
191
+ facets[name] = el;
192
+ }
193
+ for (const sec of sectionChildren(container)) {
194
+ noteLegacyTag(sec.tag);
195
+
196
+ const name = TAG_TO_FACET[sec.tag];
197
+ if (!name) continue;
198
+ if (facets[name]) note('domain-double-definition', `facet "${name}" defined both inline and as file`);
199
+ else facets[name] = sec;
200
+ }
201
+
202
+ const visitStatement = (el: XnlEl): void => {
203
+ noteLegacyTag(el.tag);
204
+ for (const child of listChildren(el)) visitStatement(child);
205
+ for (const section of sectionChildren(el)) visitStatement(section);
206
+ };
207
+ const statementEls = listChildren(container);
208
+ for (const statement of statementEls) visitStatement(statement);
209
+
210
+ const seenIds = new Set<string>();
211
+ const requireCodeRef = (el: XnlEl, field: string) => {
212
+ const value = el.attributes?.[field];
213
+ if (typeof value !== 'string' || !CODE_REF.test(value)) {
214
+ note('invalid-code-ref', `<${el.tag} #${idOf(el) ?? ''}>.${field} must use vfs://path#Export`);
215
+ }
216
+ };
217
+ const requirePositiveInteger = (el: XnlEl, field: string, required = false) => {
218
+ const value = el.attributes?.[field];
219
+ if (value === undefined && !required) return;
220
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
221
+ note('invalid-positive-integer', `<${el.tag} #${idOf(el) ?? ''}>.${field} must be a positive integer`);
222
+ }
223
+ };
224
+ const validateBody = (nodes: XnlEl[]): void => {
225
+ for (const node of nodes) {
226
+ const id = idOf(node);
227
+ if (!PROFILE_STATEMENTS[form].has(node.tag)) {
228
+ note('statement-not-allowed', `<${node.tag} #${id ?? ''}> is not allowed in ${form}`);
229
+ continue;
230
+ }
231
+ if (!id) note('statement-missing-id', `<${node.tag}> requires #id`);
232
+ else if (seenIds.has(id)) note('duplicate-statement-id', `statement #${id} is duplicated`);
233
+ else seenIds.add(id);
234
+
235
+ if (node.tag === 'Run') requireCodeRef(node, 'src');
236
+ if (node.tag === 'Require' || node.tag === 'Until' || node.tag === 'ForEach') requireCodeRef(node, 'when');
237
+ if (node.tag === 'Retry') requirePositiveInteger(node, 'maxAttempts', true);
238
+ if (node.tag === 'Timeout') {
239
+ const timeoutMs = node.attributes?.timeoutMs;
240
+ if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
241
+ note('invalid-timeout', `<Timeout #${id ?? ''}>.timeoutMs must be a non-negative number`);
242
+ }
243
+ }
244
+ if (node.tag === 'ForEach') requirePositiveInteger(node, 'concurrency');
245
+ if (node.tag === 'CallFlow') {
246
+ const target = node.attributes?.flow;
247
+ if (typeof target !== 'string' || !/^(?:instant-ctrl-flow|work-ctrl-flow|bp-ctrl-flow):\/\/[\w.-]+$/.test(target)) {
248
+ note('invalid-flow-ref', `<CallFlow #${id ?? ''}>.flow must use a product flow URI`);
249
+ }
250
+ }
251
+ if (node.tag === 'Return' && node.attributes?.src !== undefined) requireCodeRef(node, 'src');
252
+
253
+ if (node.tag === 'If') {
254
+ const sections = sectionChildren(node);
255
+ const branchesSection = sections.filter((section) => section.tag === 'Branches');
256
+ if (branchesSection.length !== 1) {
257
+ note('invalid-if-branches', `<If #${id ?? ''}> requires exactly one Branches section`);
258
+ } else {
259
+ const branches = listChildren(branchesSection[0]);
260
+ if (!branches.length) note('empty-if-branches', `<If #${id ?? ''}> requires at least one Branch`);
261
+ branches.forEach((branch, index) => {
262
+ if (branch.tag !== 'Branch' && branch.tag !== 'Otherwise') {
263
+ note('invalid-if-branch', `<If #${id ?? ''}> contains <${branch.tag}>`);
264
+ return;
265
+ }
266
+ if (branch.tag === 'Otherwise' && index !== branches.length - 1) {
267
+ note('otherwise-not-last', `<Otherwise> in <If #${id ?? ''}> must be last`);
268
+ }
269
+ if (branch.tag === 'Branch') requireCodeRef(branch, 'when');
270
+ validateBody(listChildren(branch));
271
+ });
272
+ }
273
+ } else if (node.tag === 'Fallback') {
274
+ const strategies = listChildren(node);
275
+ if (!strategies.length) note('empty-fallback', `<Fallback #${id ?? ''}> requires at least one Strategy`);
276
+ for (const strategy of strategies) {
277
+ if (strategy.tag !== 'Strategy') note('invalid-fallback-strategy', `<Fallback #${id ?? ''}> contains <${strategy.tag}>`);
278
+ else validateBody(listChildren(strategy));
279
+ }
280
+ } else if (node.tag === 'Parallel' || node.tag === 'Race') {
281
+ const sectionTag = node.tag === 'Parallel' ? 'Lanes' : 'Candidates';
282
+ const itemTag = node.tag === 'Parallel' ? 'Lane' : 'Candidate';
283
+ const sections = sectionChildren(node).filter((section) => section.tag === sectionTag);
284
+ if (sections.length !== 1) {
285
+ note('invalid-concurrent-domain', `<${node.tag} #${id ?? ''}> requires exactly one ${sectionTag} section`);
286
+ } else {
287
+ const items = listChildren(sections[0]);
288
+ if (!items.length) note('empty-concurrent-domain', `<${node.tag} #${id ?? ''}> requires at least one ${itemTag}`);
289
+ for (const item of items) {
290
+ if (item.tag !== itemTag) note('invalid-concurrent-item', `<${sectionTag}> contains <${item.tag}>`);
291
+ else validateBody(listChildren(item));
292
+ }
293
+ }
294
+ if (node.tag === 'Parallel' && node.attributes?.join !== undefined && node.attributes.join !== 'all') {
295
+ note('invalid-parallel-join', `<Parallel #${id ?? ''}>.join currently only supports "all"`);
296
+ }
297
+ if (
298
+ node.tag === 'Race'
299
+ && node.attributes?.mode !== 'first-success'
300
+ && node.attributes?.mode !== 'first-completed'
301
+ ) {
302
+ note('invalid-race-mode', `<Race #${id ?? ''}>.mode must be "first-success" or "first-completed"`);
303
+ }
304
+ } else {
305
+ validateBody(listChildren(node));
306
+ }
307
+ }
308
+ };
309
+ validateBody(statementEls);
310
+
311
+ for (const name of Object.keys(facets) as FacetName[]) {
312
+ if (!ALLOWED_FACETS[form].has(name)) {
313
+ noteOnce(
314
+ 'facet-not-allowed',
315
+ `${form} does not allow facet "${name}" (${FACETS[name].uriScheme})`,
316
+ );
317
+ }
318
+ }
319
+
320
+ if (noted.size) return { diagnostics };
321
+
322
+ const config: Record<string, Record<string, unknown>> = {};
323
+ if (facets.config) {
324
+ for (const e of listChildren(facets.config).filter((e) => e.tag === 'ConfigEntry')) {
325
+ config[idOf(e) ?? ''] = { ...(e.attributes ?? {}) };
326
+ }
327
+ }
328
+
329
+ const configDef: Record<string, Record<string, unknown>> = {};
330
+ if (facets['config.def']) {
331
+ for (const e of listChildren(facets['config.def']).filter((e) => e.tag === 'ConfigEntryDef')) {
332
+ configDef[idOf(e) ?? ''] = { ...(e.attributes ?? {}) };
333
+ }
334
+ }
335
+
336
+ const fqn = idOf(container) ?? '';
337
+ const contractNode = facets['flow.contract'];
338
+ const contractId = contractNode ? idOf(contractNode) : undefined;
339
+ const contractAttrs = contractNode?.attributes ?? {};
340
+ const flowContract: CtrlFlowContract = contractNode
341
+ ? {
342
+ id: contractId ?? '',
343
+ ...(typeof contractAttrs.input === 'string' ? { input: contractAttrs.input } : {}),
344
+ ...(typeof contractAttrs.output === 'string' ? { output: contractAttrs.output } : {}),
345
+ }
346
+ : { id: fqn };
347
+ if (!contractNode) note('missing-flow-contract', `${form} #${fqn} requires exactly one FlowContract`);
348
+ if (contractNode && contractId !== fqn) note('flow-contract-id-mismatch', 'FlowContract #id must equal the enclosing flow FQN');
349
+ for (const field of ['input', 'output'] as const) {
350
+ const value = contractAttrs[field];
351
+ if (value !== undefined && (typeof value !== 'string' || !CODE_REF.test(value))) {
352
+ note('invalid-flow-contract-type-ref', `FlowContract.${field} must use vfs://path#Export`);
353
+ }
354
+ }
355
+ const stateDef = facets['state.def']
356
+ ? (Object.fromEntries(
357
+ Object.entries(facets['state.def'].attributes ?? {}).map(([k, v]) => [k, String(v)]),
358
+ ) as Record<string, string>)
359
+ : undefined;
360
+ const stateSeed = facets['state.seed'] ? { ...(facets['state.seed'].attributes ?? {}) } : undefined;
361
+ const taskSpace = facets['task.space'] ? toNodeSpec(facets['task.space']) : undefined;
362
+ const spec: FlowBundleSpec = {
363
+ form,
364
+ fqn,
365
+ apiVersion: String(container.metadata?.apiVersion ?? ''),
366
+ version: String(container.metadata?.version ?? ''),
367
+ title: container.attributes?.title as string | undefined,
368
+ sourceNames,
369
+ baseUri: options.baseUri,
370
+ dir: options.baseUri ?? '',
371
+ statements: statementEls.map(toNodeSpec),
372
+ flowContract,
373
+ config,
374
+ configDef,
375
+ stateDef,
376
+ stateSeed,
377
+ taskSpace,
378
+ diagnostics,
379
+ };
380
+ if (!spec.apiVersion) note('container-missing-meta', 'container missing metadata "apiVersion"');
381
+ if (!fqn.includes('.')) note('container-id-not-fqn', `container #id must be a dotted FQN, got "${fqn}"`);
382
+ return { spec, diagnostics };
383
+ }