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 +8 -6
- package/src/browser.ts +1 -0
- package/src/compile.ts +59 -4
- package/src/control-options.ts +53 -0
- package/src/graph.ts +309 -0
- package/src/handlers.ts +11 -4
- package/src/index.ts +1 -0
- package/src/loader.ts +121 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "instant-ctrl-flow-logic",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
15
|
+
"depa-behavior-tree": "0.1.2",
|
|
15
16
|
"flow-step-space-contract": "0.1.1",
|
|
16
|
-
"flow-step-space-logic": "0.1.
|
|
17
|
-
"instant-ctrl-flow-contract": "0.1.
|
|
18
|
-
"xnl-core": "
|
|
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
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
|
|
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 =
|
|
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 =
|
|
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
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 === '
|
|
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:
|
|
435
|
-
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
|
}
|