@farmslot/protocol 0.10.0 → 0.11.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/CHANGELOG.md +8 -1
- package/README.md +12 -4
- package/dist/contracts/runs.d.ts +1 -1
- package/dist/contracts/runs.d.ts.map +1 -1
- package/dist/recipe/artifact.d.ts.map +1 -1
- package/dist/recipe/artifact.js +153 -20
- package/dist/recipe/artifact.js.map +1 -1
- package/dist/recipe/common.d.ts +27 -36
- package/dist/recipe/common.d.ts.map +1 -1
- package/dist/recipe/common.js +0 -3
- package/dist/recipe/common.js.map +1 -1
- package/dist/recipe/digest.d.ts +5 -0
- package/dist/recipe/digest.d.ts.map +1 -0
- package/dist/recipe/digest.js +25 -0
- package/dist/recipe/digest.js.map +1 -0
- package/dist/recipe/document.d.ts +4 -20
- package/dist/recipe/document.d.ts.map +1 -1
- package/dist/recipe/document.js +160 -123
- package/dist/recipe/document.js.map +1 -1
- package/dist/recipe/index.d.ts +5 -3
- package/dist/recipe/index.d.ts.map +1 -1
- package/dist/recipe/index.js +5 -3
- package/dist/recipe/index.js.map +1 -1
- package/dist/recipe/manifest.d.ts +0 -1
- package/dist/recipe/manifest.d.ts.map +1 -1
- package/dist/recipe/manifest.js +68 -45
- package/dist/recipe/manifest.js.map +1 -1
- package/dist/recipe/params.d.ts +9 -0
- package/dist/recipe/params.d.ts.map +1 -0
- package/dist/recipe/params.js +261 -0
- package/dist/recipe/params.js.map +1 -0
- package/dist/recipe/trust.d.ts +5 -3
- package/dist/recipe/trust.d.ts.map +1 -1
- package/dist/recipe/trust.js.map +1 -1
- package/dist/recipe/workflow.d.ts +13 -30
- package/dist/recipe/workflow.d.ts.map +1 -1
- package/dist/recipe/workflow.js +200 -526
- package/dist/recipe/workflow.js.map +1 -1
- package/dist/rpc/dispatch.d.ts +8 -0
- package/dist/rpc/dispatch.d.ts.map +1 -1
- package/package.json +1 -1
- package/schemas/recipe-v1.schema.json +119 -197
package/dist/recipe/workflow.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { addFinding, hasOwn, isNonEmptyString, isRecord, OFFICIAL_ACTION_SET,
|
|
1
|
+
import { addFinding, hasOwn, isNonEmptyString, isRecord, OFFICIAL_ACTION_SET, TERMINAL_STATUSES, } from './common.js';
|
|
2
|
+
const NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
|
3
|
+
const CORE_NODE_FIELDS = new Set(['action', 'intent', 'next', 'cases', 'default', 'proves']);
|
|
4
|
+
const CALL_NODE_FIELDS = new Set(['action', 'intent', 'ref', 'params', 'proves', 'next']);
|
|
2
5
|
const GENERIC_INTENTS = new Set([
|
|
3
6
|
'executing recipe step',
|
|
4
7
|
'run',
|
|
5
8
|
'setup',
|
|
6
9
|
'ui',
|
|
7
10
|
'wallet',
|
|
8
|
-
'perps',
|
|
9
11
|
'test',
|
|
10
12
|
'step',
|
|
11
13
|
'execute',
|
|
@@ -28,601 +30,273 @@ const IMPLEMENTATION_INTENTS = new Set([
|
|
|
28
30
|
'params',
|
|
29
31
|
'payload',
|
|
30
32
|
]);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const GENERIC_INTENT_PATTERN = /^(?:do|execute|perform|run) .+ (?:for|in) (?:the )?recipe[.!]?$/u;
|
|
34
|
+
const UI_MECHANIC_INTENTS = {
|
|
35
|
+
'ui.navigate': /^(?:navigate|route)\b/u,
|
|
36
|
+
'ui.press': /^(?:click|press|tap)\b/u,
|
|
37
|
+
'ui.key_press': /^(?:press|type)\b/u,
|
|
38
|
+
'ui.set_input': /^(?:fill|set|type)\b/u,
|
|
39
|
+
'ui.scroll': /^(?:bring .* into view|scroll)\b/u,
|
|
40
|
+
'ui.wait_for': /^wait\b/u,
|
|
41
|
+
'ui.screenshot': /^(?:capture|take (?:a )?screenshot)\b/u,
|
|
42
|
+
};
|
|
43
|
+
const UI_IMPLEMENTATION_TERMS = /\b(?:css|hash|raw (?:app|extension|platform)|route params?|selector|test[_ -]?id)\b/u;
|
|
44
|
+
export function normalizeRecipeRef(value) {
|
|
33
45
|
return value.trim();
|
|
34
46
|
}
|
|
35
|
-
export function
|
|
36
|
-
return /\{\{params\.[A-Za-z0-9_.-]+\}\}
|
|
47
|
+
export function isDynamicRecipeRef(value) {
|
|
48
|
+
return /\{\{(?:params|outputs)\.[A-Za-z0-9_.-]+\}\}/u.test(value);
|
|
37
49
|
}
|
|
38
|
-
function
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
addFinding(ctx, 'error', `${codePrefix}.invalid_intent`, path, message);
|
|
43
|
-
}
|
|
44
|
-
function validateNodeIntent(ctx, codePrefix, nodeId, node, path) {
|
|
45
|
-
const intent = node.intent;
|
|
46
|
-
if (!isNonEmptyString(intent)) {
|
|
47
|
-
addInvalidIntentFinding(ctx, codePrefix, `${path}.intent`, 'Missing required node intent. Add intent as one short HUD/trace sentence describing what the agent is doing now, for example: "Open the Perps market screen".');
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const normalized = normalizeIntent(intent);
|
|
51
|
-
const action = isNonEmptyString(node.action) ? node.action : '';
|
|
52
|
-
const blockedValues = [
|
|
53
|
-
nodeId,
|
|
54
|
-
action,
|
|
55
|
-
node.selector,
|
|
56
|
-
node.test_id,
|
|
57
|
-
node.testID,
|
|
58
|
-
node.id,
|
|
59
|
-
node.ref,
|
|
60
|
-
].filter(isNonEmptyString);
|
|
61
|
-
const normalizedBlockedValues = new Set(blockedValues.map(normalizeIntent));
|
|
62
|
-
if (GENERIC_INTENTS.has(normalized) ||
|
|
63
|
-
IMPLEMENTATION_INTENTS.has(normalized) ||
|
|
64
|
-
OFFICIAL_ACTION_SET.has(intent.trim()) ||
|
|
65
|
-
normalizedBlockedValues.has(normalized)) {
|
|
66
|
-
addInvalidIntentFinding(ctx, codePrefix, `${path}.intent`, 'Invalid node intent. Expected one short HUD/trace sentence for what the agent is doing now; do not use generic text, action names, node ids, selectors, test ids, or implementation primitives.');
|
|
67
|
-
}
|
|
50
|
+
export function getRecipeActionParams(node) {
|
|
51
|
+
return Object.fromEntries(Object.entries(node).filter(([field]) => !CORE_NODE_FIELDS.has(field) &&
|
|
52
|
+
!(node.action === 'call' && (field === 'ref' || field === 'params')) &&
|
|
53
|
+
!(node.action === 'end' && field === 'status')));
|
|
68
54
|
}
|
|
69
55
|
export function extractWorkflow(ctx, recipe) {
|
|
70
|
-
const
|
|
71
|
-
if (!isRecord(validate)) {
|
|
72
|
-
addFinding(ctx, 'error', 'recipe.missing_validate', 'validate', 'Recipe must include validate.');
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
const workflow = validate.workflow;
|
|
56
|
+
const workflow = recipe.workflow;
|
|
76
57
|
if (!isRecord(workflow)) {
|
|
77
|
-
addFinding(ctx, 'error', 'recipe.missing_workflow', '
|
|
58
|
+
addFinding(ctx, 'error', 'recipe.missing_workflow', 'workflow', 'Recipe must include workflow.');
|
|
78
59
|
return null;
|
|
79
60
|
}
|
|
61
|
+
for (const field of Object.keys(workflow)) {
|
|
62
|
+
if (field !== 'entry' && field !== 'nodes' && field !== 'teardown') {
|
|
63
|
+
addFinding(ctx, 'error', 'workflow.unsupported_field', `workflow.${field}`, `Recipe Protocol v1 does not support workflow field ${field}.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
80
66
|
const entry = workflow.entry;
|
|
81
67
|
if (!isNonEmptyString(entry)) {
|
|
82
|
-
addFinding(ctx, 'error', 'workflow.invalid_entry', '
|
|
68
|
+
addFinding(ctx, 'error', 'workflow.invalid_entry', 'workflow.entry', 'workflow.entry must be a non-empty string.');
|
|
69
|
+
}
|
|
70
|
+
const teardown = workflow.teardown;
|
|
71
|
+
if (teardown != null && !isNonEmptyString(teardown)) {
|
|
72
|
+
addFinding(ctx, 'error', 'workflow.invalid_teardown', 'workflow.teardown', 'workflow.teardown must be a non-empty node id when present.');
|
|
83
73
|
}
|
|
84
74
|
const nodes = workflow.nodes;
|
|
85
75
|
if (!isRecord(nodes) || Object.keys(nodes).length === 0) {
|
|
86
|
-
addFinding(ctx, 'error', 'workflow.invalid_nodes', '
|
|
76
|
+
addFinding(ctx, 'error', 'workflow.invalid_nodes', 'workflow.nodes', 'workflow.nodes must be a non-empty object.');
|
|
87
77
|
return null;
|
|
88
78
|
}
|
|
89
79
|
const typedNodes = Object.create(null);
|
|
90
80
|
for (const [nodeId, node] of Object.entries(nodes)) {
|
|
91
|
-
if (!isRecord(node)) {
|
|
92
|
-
addFinding(ctx, 'error', 'workflow.invalid_node', `
|
|
81
|
+
if (!isNonEmptyString(nodeId) || !NODE_ID_PATTERN.test(nodeId) || !isRecord(node)) {
|
|
82
|
+
addFinding(ctx, 'error', 'workflow.invalid_node', `workflow.nodes.${nodeId}`, `Workflow node ${nodeId} must be an object whose id contains only letters, numbers, underscores, or hyphens.`);
|
|
93
83
|
continue;
|
|
94
84
|
}
|
|
95
85
|
typedNodes[nodeId] = node;
|
|
96
86
|
}
|
|
97
|
-
const lifecycleNodes = collectLifecycleNodes(ctx, recipe, workflow);
|
|
98
87
|
if (!isNonEmptyString(entry))
|
|
99
88
|
return null;
|
|
100
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
entry,
|
|
91
|
+
nodes: typedNodes,
|
|
92
|
+
...(isNonEmptyString(teardown) ? { teardown } : {}),
|
|
93
|
+
};
|
|
101
94
|
}
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
collectLifecycleArray(ctx, workflow.setup, 'validate.workflow.setup', lifecycleNodes);
|
|
105
|
-
if (recipe.startState != null) {
|
|
106
|
-
if (isRecord(recipe.startState)) {
|
|
107
|
-
lifecycleNodes.startState = { node: recipe.startState, path: 'startState' };
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
addFinding(ctx, 'error', 'workflow.invalid_start_state', 'startState', 'startState must be an action node object when present.');
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
collectLifecycleArray(ctx, workflow.teardown, 'validate.workflow.teardown', lifecycleNodes);
|
|
114
|
-
return lifecycleNodes;
|
|
95
|
+
function normalizeIntent(value) {
|
|
96
|
+
return value.trim().toLowerCase().replace(/[_-]+/gu, ' ').replace(/\s+/gu, ' ');
|
|
115
97
|
}
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
addFinding(ctx, 'error', 'workflow.invalid_lifecycle', path, `${path} must be an array.`);
|
|
98
|
+
function validateNodeIntent(ctx, nodeId, node, path) {
|
|
99
|
+
const intent = node.intent;
|
|
100
|
+
if (!isNonEmptyString(intent)) {
|
|
101
|
+
addFinding(ctx, 'error', 'workflow.invalid_intent', `${path}.intent`, 'Every non-terminal node requires one short human-facing intent sentence for HUD and trace evidence.');
|
|
121
102
|
return;
|
|
122
103
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (cases == null)
|
|
141
|
-
return [];
|
|
142
|
-
if (Array.isArray(cases)) {
|
|
143
|
-
const targets = [];
|
|
144
|
-
cases.forEach((entry, index) => {
|
|
145
|
-
if (!isRecord(entry)) {
|
|
146
|
-
if (emitFindings) {
|
|
147
|
-
addFinding(ctx, 'error', 'workflow.invalid_case', `${path}.cases[${index}]`, 'cases entries must be objects.');
|
|
148
|
-
}
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
const target = entry.next;
|
|
152
|
-
if (target == null)
|
|
153
|
-
return;
|
|
154
|
-
if (isNonEmptyString(target)) {
|
|
155
|
-
targets.push(target);
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
if (emitFindings) {
|
|
159
|
-
addFinding(ctx, 'error', 'workflow.invalid_case_target', `${path}.cases[${index}].next`, 'cases[].next must be a non-empty string when present.');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
return targets;
|
|
164
|
-
}
|
|
165
|
-
if (isRecord(cases)) {
|
|
166
|
-
const targets = [];
|
|
167
|
-
for (const [caseName, target] of Object.entries(cases)) {
|
|
168
|
-
if (isNonEmptyString(target)) {
|
|
169
|
-
targets.push(target);
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
if (emitFindings) {
|
|
173
|
-
addFinding(ctx, 'error', 'workflow.invalid_case_target', `${path}.cases.${caseName}`, 'case targets must be non-empty strings.');
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return targets;
|
|
104
|
+
const normalized = normalizeIntent(intent);
|
|
105
|
+
const blockedValues = [nodeId, node.action, node.selector, node.test_id, node.testID, node.ref]
|
|
106
|
+
.filter(isNonEmptyString)
|
|
107
|
+
.map(normalizeIntent);
|
|
108
|
+
const uiMechanicPattern = isNonEmptyString(node.action)
|
|
109
|
+
? UI_MECHANIC_INTENTS[node.action]
|
|
110
|
+
: undefined;
|
|
111
|
+
if (GENERIC_INTENTS.has(normalized) ||
|
|
112
|
+
GENERIC_INTENT_PATTERN.test(normalized) ||
|
|
113
|
+
IMPLEMENTATION_INTENTS.has(normalized) ||
|
|
114
|
+
OFFICIAL_ACTION_SET.has(intent.trim()) ||
|
|
115
|
+
blockedValues.includes(normalized) ||
|
|
116
|
+
(uiMechanicPattern?.test(normalized) ?? false) ||
|
|
117
|
+
(isNonEmptyString(node.action) &&
|
|
118
|
+
node.action.startsWith('ui.') &&
|
|
119
|
+
UI_IMPLEMENTATION_TERMS.test(normalized))) {
|
|
120
|
+
addFinding(ctx, 'error', 'workflow.invalid_intent', `${path}.intent`, 'Intent must explain the human-visible goal, not repeat an action, node id, selector, or generic verb.');
|
|
178
121
|
}
|
|
179
|
-
|
|
180
|
-
|
|
122
|
+
}
|
|
123
|
+
function validateProves(ctx, node, path) {
|
|
124
|
+
if (node.proves == null)
|
|
125
|
+
return;
|
|
126
|
+
if (!Array.isArray(node.proves) ||
|
|
127
|
+
node.proves.some((target) => !isNonEmptyString(target)) ||
|
|
128
|
+
new Set(node.proves).size !== node.proves.length) {
|
|
129
|
+
addFinding(ctx, 'error', 'workflow.invalid_proves', `${path}.proves`, 'proves must be an array of unique non-empty proof target ids.');
|
|
181
130
|
}
|
|
182
|
-
return [];
|
|
183
131
|
}
|
|
184
|
-
function collectTargets(node, path,
|
|
132
|
+
function collectTargets(ctx, node, path, emitFindings = true) {
|
|
185
133
|
const targets = [];
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
134
|
+
if (node.next != null) {
|
|
135
|
+
if (isNonEmptyString(node.next))
|
|
136
|
+
targets.push(node.next);
|
|
137
|
+
else if (emitFindings)
|
|
138
|
+
addFinding(ctx, 'error', 'workflow.invalid_next', `${path}.next`, 'next must be a non-empty node id.');
|
|
139
|
+
}
|
|
140
|
+
if (node.default != null) {
|
|
141
|
+
if (isNonEmptyString(node.default))
|
|
142
|
+
targets.push(node.default);
|
|
143
|
+
else if (emitFindings)
|
|
144
|
+
addFinding(ctx, 'error', 'workflow.invalid_default', `${path}.default`, 'default must be a non-empty node id.');
|
|
145
|
+
}
|
|
146
|
+
if (node.cases != null) {
|
|
147
|
+
if (!isRecord(node.cases) || Object.keys(node.cases).length === 0) {
|
|
148
|
+
if (emitFindings)
|
|
149
|
+
addFinding(ctx, 'error', 'workflow.invalid_cases', `${path}.cases`, 'cases must be a non-empty object mapping result cases to node ids.');
|
|
191
150
|
}
|
|
192
151
|
else {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
targets.push(defaultTarget);
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
if (emitFindings) {
|
|
204
|
-
addFinding(ctx, 'error', 'workflow.invalid_default', `${path}.default`, 'default must be a non-empty string.');
|
|
152
|
+
for (const [caseName, target] of Object.entries(node.cases)) {
|
|
153
|
+
if (!isNonEmptyString(caseName) || !isNonEmptyString(target)) {
|
|
154
|
+
if (emitFindings)
|
|
155
|
+
addFinding(ctx, 'error', 'workflow.invalid_case_target', `${path}.cases.${caseName}`, 'Each case must map a non-empty case name to a non-empty node id.');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
targets.push(target);
|
|
205
159
|
}
|
|
206
160
|
}
|
|
207
161
|
}
|
|
208
|
-
targets.push(...collectCaseTargets(node, path, ctx, emitFindings));
|
|
209
162
|
return targets;
|
|
210
163
|
}
|
|
211
|
-
function
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!
|
|
215
|
-
addFinding(ctx, 'error', 'workflow.
|
|
164
|
+
function validateNodeShape(ctx, nodeId, node) {
|
|
165
|
+
const path = `workflow.nodes.${nodeId}`;
|
|
166
|
+
const action = node.action;
|
|
167
|
+
if (!isNonEmptyString(action)) {
|
|
168
|
+
addFinding(ctx, 'error', 'workflow.invalid_action', `${path}.action`, 'Node action must be a non-empty string.');
|
|
216
169
|
return;
|
|
217
170
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
if (playback.slow_ms != null) {
|
|
224
|
-
if (typeof playback.slow_ms !== 'number' ||
|
|
225
|
-
!Number.isInteger(playback.slow_ms) ||
|
|
226
|
-
playback.slow_ms < RECIPE_PLAYBACK_SLOW_MS_MIN ||
|
|
227
|
-
playback.slow_ms > RECIPE_PLAYBACK_SLOW_MS_MAX) {
|
|
228
|
-
addFinding(ctx, 'error', 'workflow.invalid_playback_slow_ms', 'validate.workflow.playback.slow_ms', `playback.slow_ms must be an integer from ${RECIPE_PLAYBACK_SLOW_MS_MIN} through ${RECIPE_PLAYBACK_SLOW_MS_MAX}.`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
export function validateWorkflowGraph(ctx, workflow, rawWorkflow) {
|
|
233
|
-
if (!hasOwn(workflow.nodes, workflow.entry)) {
|
|
234
|
-
addFinding(ctx, 'error', 'workflow.missing_entry_node', 'validate.workflow.entry', `Entry node ${workflow.entry} does not exist in validate.workflow.nodes.`);
|
|
235
|
-
}
|
|
236
|
-
validatePlayback(ctx, rawWorkflow);
|
|
237
|
-
const reachable = new Set();
|
|
238
|
-
const queue = hasOwn(workflow.nodes, workflow.entry) ? [workflow.entry] : [];
|
|
239
|
-
let terminalCount = 0;
|
|
240
|
-
let reachableTerminalCount = 0;
|
|
241
|
-
const graphNodes = Object.entries(workflow.nodes).map(([nodeId, node]) => [nodeId, { node, path: `validate.workflow.nodes.${nodeId}` }]);
|
|
242
|
-
const lifecycleNodes = Object.entries(workflow.lifecycleNodes);
|
|
243
|
-
for (const [nodeId, entry] of [...graphNodes, ...lifecycleNodes]) {
|
|
244
|
-
const { node, path } = entry;
|
|
245
|
-
const action = node.action;
|
|
246
|
-
if (!isNonEmptyString(action)) {
|
|
247
|
-
addFinding(ctx, 'error', 'workflow.invalid_action', `${path}.action`, 'Node action must be a non-empty string.');
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
const isLifecycleNode = hasOwn(workflow.lifecycleNodes, nodeId);
|
|
251
|
-
const targets = isLifecycleNode ? [] : collectTargets(node, path, ctx);
|
|
252
|
-
if (action === 'end') {
|
|
253
|
-
terminalCount += 1;
|
|
254
|
-
if (node.status == null) {
|
|
255
|
-
addFinding(ctx, 'error', 'workflow.missing_terminal_status', `${path}.status`, 'end nodes must include status.');
|
|
256
|
-
}
|
|
257
|
-
else if (typeof node.status !== 'string' || !TERMINAL_STATUSES.has(node.status)) {
|
|
258
|
-
addFinding(ctx, 'error', 'workflow.invalid_terminal_status', `${path}.status`, 'end node status must be pass, fail, or unknown.');
|
|
171
|
+
if (action === 'end') {
|
|
172
|
+
for (const field of Object.keys(node)) {
|
|
173
|
+
if (field !== 'action' && field !== 'status') {
|
|
174
|
+
addFinding(ctx, 'error', 'workflow.invalid_terminal_field', `${path}.${field}`, `End nodes do not support ${field}.`);
|
|
259
175
|
}
|
|
260
|
-
if (targets.length > 0) {
|
|
261
|
-
addFinding(ctx, 'warning', 'workflow.terminal_has_targets', path, 'end nodes should not define transitions.');
|
|
262
|
-
}
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
validateNodeIntent(ctx, 'workflow', nodeId, node, path);
|
|
266
|
-
if (!isLifecycleNode && targets.length === 0) {
|
|
267
|
-
addFinding(ctx, 'error', 'workflow.missing_transition', path, 'Non-terminal nodes must transition via next, cases, or default.');
|
|
268
|
-
}
|
|
269
|
-
for (const target of targets) {
|
|
270
|
-
if (!hasOwn(workflow.nodes, target)) {
|
|
271
|
-
addFinding(ctx, 'error', 'workflow.missing_target', path, `Node ${nodeId} references missing target node ${target}.`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
while (queue.length > 0) {
|
|
276
|
-
const nodeId = queue.shift();
|
|
277
|
-
if (!nodeId || reachable.has(nodeId))
|
|
278
|
-
continue;
|
|
279
|
-
if (!hasOwn(workflow.nodes, nodeId))
|
|
280
|
-
continue;
|
|
281
|
-
const node = workflow.nodes[nodeId];
|
|
282
|
-
reachable.add(nodeId);
|
|
283
|
-
if (node.action === 'end')
|
|
284
|
-
reachableTerminalCount += 1;
|
|
285
|
-
for (const target of collectTargets(node, `validate.workflow.nodes.${nodeId}`, ctx, false)) {
|
|
286
|
-
if (hasOwn(workflow.nodes, target) && !reachable.has(target))
|
|
287
|
-
queue.push(target);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (terminalCount === 0) {
|
|
291
|
-
addFinding(ctx, 'error', 'workflow.missing_terminal', 'validate.workflow.nodes', 'Workflow must include at least one action:end terminal node.');
|
|
292
|
-
}
|
|
293
|
-
else if (hasOwn(workflow.nodes, workflow.entry) && reachableTerminalCount === 0) {
|
|
294
|
-
addFinding(ctx, 'error', 'workflow.no_reachable_terminal', 'validate.workflow.nodes', 'No terminal end node is reachable from the entry node.');
|
|
295
|
-
}
|
|
296
|
-
for (const nodeId of Object.keys(workflow.nodes)) {
|
|
297
|
-
if (!reachable.has(nodeId)) {
|
|
298
|
-
addFinding(ctx, 'warning', 'workflow.unreachable_node', `validate.workflow.nodes.${nodeId}`, `Node ${nodeId} is not reachable from the entry node.`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
export function getRecipeWorkflowNodeIds(recipe) {
|
|
303
|
-
if (!isRecord(recipe) || !isRecord(recipe.validate) || !isRecord(recipe.validate.workflow))
|
|
304
|
-
return [];
|
|
305
|
-
const nodes = recipe.validate.workflow.nodes;
|
|
306
|
-
if (!isRecord(nodes))
|
|
307
|
-
return [];
|
|
308
|
-
const ids = Object.keys(nodes);
|
|
309
|
-
if (isRecord(recipe.startState))
|
|
310
|
-
ids.push('startState');
|
|
311
|
-
for (const key of ['setup', 'teardown']) {
|
|
312
|
-
const lifecycle = recipe.validate.workflow[key];
|
|
313
|
-
if (!Array.isArray(lifecycle))
|
|
314
|
-
continue;
|
|
315
|
-
lifecycle.forEach((node, index) => {
|
|
316
|
-
if (isRecord(node))
|
|
317
|
-
ids.push(isNonEmptyString(node.id) ? node.id : `${key}:${index}`);
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
return ids;
|
|
321
|
-
}
|
|
322
|
-
export function getRecipeWorkflowActions(recipe) {
|
|
323
|
-
return getRecipeWorkflowActionEntries(recipe).map((entry) => entry.action);
|
|
324
|
-
}
|
|
325
|
-
export function getRecipeWorkflowActionEntries(recipe) {
|
|
326
|
-
if (!isRecord(recipe) || !isRecord(recipe.validate) || !isRecord(recipe.validate.workflow)) {
|
|
327
|
-
return [];
|
|
328
|
-
}
|
|
329
|
-
const nodes = recipe.validate.workflow.nodes;
|
|
330
|
-
if (!isRecord(nodes))
|
|
331
|
-
return [];
|
|
332
|
-
const actions = [];
|
|
333
|
-
for (const [nodeId, node] of Object.entries(nodes)) {
|
|
334
|
-
if (!isRecord(node))
|
|
335
|
-
continue;
|
|
336
|
-
if (isNonEmptyString(node.action)) {
|
|
337
|
-
actions.push({
|
|
338
|
-
nodeId,
|
|
339
|
-
action: node.action,
|
|
340
|
-
path: `validate.workflow.nodes.${nodeId}.action`,
|
|
341
|
-
});
|
|
342
176
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
actions.push({
|
|
346
|
-
nodeId: 'startState',
|
|
347
|
-
action: recipe.startState.action,
|
|
348
|
-
path: 'startState.action',
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
for (const key of ['setup', 'teardown']) {
|
|
352
|
-
const lifecycle = recipe.validate.workflow[key];
|
|
353
|
-
if (!Array.isArray(lifecycle))
|
|
354
|
-
continue;
|
|
355
|
-
lifecycle.forEach((node, index) => {
|
|
356
|
-
if (!isRecord(node) || !isNonEmptyString(node.action))
|
|
357
|
-
return;
|
|
358
|
-
actions.push({
|
|
359
|
-
nodeId: isNonEmptyString(node.id) ? node.id : `${key}:${index}`,
|
|
360
|
-
action: node.action,
|
|
361
|
-
path: `validate.workflow.${key}[${index}].action`,
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
if (isRecord(recipe.flows)) {
|
|
366
|
-
for (const [flowId, flow] of Object.entries(recipe.flows)) {
|
|
367
|
-
if (!isRecord(flow))
|
|
368
|
-
continue;
|
|
369
|
-
const workflow = isRecord(flow.workflow) ? flow.workflow : flow;
|
|
370
|
-
if (!isRecord(workflow.nodes))
|
|
371
|
-
continue;
|
|
372
|
-
for (const [nodeId, node] of Object.entries(workflow.nodes)) {
|
|
373
|
-
if (!isRecord(node) || !isNonEmptyString(node.action))
|
|
374
|
-
continue;
|
|
375
|
-
actions.push({
|
|
376
|
-
nodeId: `${flowId}/${nodeId}`,
|
|
377
|
-
action: node.action,
|
|
378
|
-
path: `flows.${flowId}.workflow.nodes.${nodeId}.action`,
|
|
379
|
-
});
|
|
380
|
-
}
|
|
177
|
+
if (typeof node.status !== 'string' || !TERMINAL_STATUSES.has(node.status)) {
|
|
178
|
+
addFinding(ctx, 'error', 'workflow.invalid_terminal_status', `${path}.status`, 'end status must be pass, fail, or unknown.');
|
|
381
179
|
}
|
|
382
|
-
}
|
|
383
|
-
return actions;
|
|
384
|
-
}
|
|
385
|
-
export function validateInlineFlows(ctx, recipe) {
|
|
386
|
-
const flows = recipe.flows;
|
|
387
|
-
if (flows == null)
|
|
388
|
-
return;
|
|
389
|
-
if (!isRecord(flows)) {
|
|
390
|
-
addFinding(ctx, 'error', 'flow.invalid_flows', 'flows', 'flows must be an object.');
|
|
391
180
|
return;
|
|
392
181
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const callGraph = new Map();
|
|
407
|
-
for (const [flowId, flow] of Object.entries(flows)) {
|
|
408
|
-
const normalizedFlowId = normalizeRecipeFlowRef(flowId);
|
|
409
|
-
const flowPath = `flows.${flowId}`;
|
|
410
|
-
if (!isRecord(flow)) {
|
|
411
|
-
addFinding(ctx, 'error', 'flow.invalid_flow', flowPath, `Flow ${flowId} must be an object.`);
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
const workflow = isRecord(flow.workflow) ? flow.workflow : flow;
|
|
415
|
-
const entry = workflow.entry;
|
|
416
|
-
const nodes = workflow.nodes;
|
|
417
|
-
if (!isNonEmptyString(entry)) {
|
|
418
|
-
addFinding(ctx, 'error', 'flow.invalid_entry', `${flowPath}.workflow.entry`, `Flow ${flowId} entry must be a non-empty string.`);
|
|
419
|
-
}
|
|
420
|
-
if (!isRecord(nodes) || Object.keys(nodes).length === 0) {
|
|
421
|
-
addFinding(ctx, 'error', 'flow.invalid_nodes', `${flowPath}.workflow.nodes`, `Flow ${flowId} nodes must be a non-empty object.`);
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
if (isNonEmptyString(entry) && !hasOwn(nodes, entry)) {
|
|
425
|
-
addFinding(ctx, 'error', 'flow.missing_entry_node', `${flowPath}.workflow.entry`, `Flow ${flowId} entry node ${entry} does not exist.`);
|
|
426
|
-
}
|
|
427
|
-
const callees = [];
|
|
428
|
-
const reachable = new Set();
|
|
429
|
-
const queue = isNonEmptyString(entry) && hasOwn(nodes, entry) ? [entry] : [];
|
|
430
|
-
let terminalCount = 0;
|
|
431
|
-
let reachableTerminalCount = 0;
|
|
432
|
-
for (const [nodeId, node] of Object.entries(nodes)) {
|
|
433
|
-
const nodePath = `${flowPath}.workflow.nodes.${nodeId}`;
|
|
434
|
-
if (!isRecord(node)) {
|
|
435
|
-
addFinding(ctx, 'error', 'flow.invalid_node', nodePath, `Flow node ${nodeId} must be an object.`);
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
if (!isNonEmptyString(node.action)) {
|
|
439
|
-
addFinding(ctx, 'error', 'flow.invalid_action', `${nodePath}.action`, 'Flow node action must be a non-empty string.');
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
if (node.action === 'call' && isNonEmptyString(node.ref)) {
|
|
443
|
-
const ref = normalizeRecipeFlowRef(node.ref);
|
|
444
|
-
if (inlineFlowIds.has(ref))
|
|
445
|
-
callees.push(ref);
|
|
446
|
-
}
|
|
447
|
-
if (node.action !== 'end') {
|
|
448
|
-
validateNodeIntent(ctx, 'flow', nodeId, node, nodePath);
|
|
449
|
-
const targets = collectTargets(node, nodePath, ctx);
|
|
450
|
-
if (targets.length === 0) {
|
|
451
|
-
addFinding(ctx, 'error', 'flow.missing_transition', nodePath, 'Non-terminal flow nodes must transition via next, cases, or default.');
|
|
452
|
-
}
|
|
453
|
-
for (const target of targets) {
|
|
454
|
-
if (!hasOwn(nodes, target)) {
|
|
455
|
-
addFinding(ctx, 'error', 'flow.missing_target', nodePath, `Flow node ${nodeId} references missing target node ${target}.`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
terminalCount += 1;
|
|
461
|
-
if (node.status == null) {
|
|
462
|
-
addFinding(ctx, 'error', 'flow.missing_terminal_status', `${nodePath}.status`, 'Flow end nodes must include status.');
|
|
463
|
-
}
|
|
464
|
-
else if (typeof node.status !== 'string' || !TERMINAL_STATUSES.has(node.status)) {
|
|
465
|
-
addFinding(ctx, 'error', 'flow.invalid_terminal_status', `${nodePath}.status`, 'Flow end node status must be pass, fail, or unknown.');
|
|
466
|
-
}
|
|
182
|
+
validateNodeIntent(ctx, nodeId, node, path);
|
|
183
|
+
validateProves(ctx, node, path);
|
|
184
|
+
const hasNext = hasOwn(node, 'next');
|
|
185
|
+
const hasCases = hasOwn(node, 'cases');
|
|
186
|
+
const hasDefault = hasOwn(node, 'default');
|
|
187
|
+
if (hasNext === hasCases || hasDefault !== hasCases) {
|
|
188
|
+
addFinding(ctx, 'error', 'workflow.invalid_transition_shape', path, 'A non-terminal node must declare exactly next, or cases together with default.');
|
|
189
|
+
}
|
|
190
|
+
collectTargets(ctx, node, path);
|
|
191
|
+
if (action === 'call') {
|
|
192
|
+
for (const field of Object.keys(node)) {
|
|
193
|
+
if (!CALL_NODE_FIELDS.has(field)) {
|
|
194
|
+
addFinding(ctx, 'error', 'workflow.invalid_call_field', `${path}.${field}`, `Call nodes do not support ${field}.`);
|
|
467
195
|
}
|
|
468
196
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (!nodeId || reachable.has(nodeId))
|
|
472
|
-
continue;
|
|
473
|
-
const node = nodes[nodeId];
|
|
474
|
-
if (!isRecord(node))
|
|
475
|
-
continue;
|
|
476
|
-
reachable.add(nodeId);
|
|
477
|
-
if (node.action === 'end')
|
|
478
|
-
reachableTerminalCount += 1;
|
|
479
|
-
for (const target of collectTargets(node, `${flowPath}.workflow.nodes.${nodeId}`, ctx, false)) {
|
|
480
|
-
if (hasOwn(nodes, target) && !reachable.has(target))
|
|
481
|
-
queue.push(target);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
if (terminalCount === 0) {
|
|
485
|
-
addFinding(ctx, 'error', 'flow.missing_terminal', `${flowPath}.workflow.nodes`, `Flow ${flowId} must include at least one action:end terminal node.`);
|
|
486
|
-
}
|
|
487
|
-
else if (isNonEmptyString(entry) && hasOwn(nodes, entry) && reachableTerminalCount === 0) {
|
|
488
|
-
addFinding(ctx, 'error', 'flow.no_reachable_terminal', `${flowPath}.workflow.nodes`, `No terminal end node is reachable from flow ${flowId} entry node.`);
|
|
197
|
+
if (!isNonEmptyString(node.ref)) {
|
|
198
|
+
addFinding(ctx, 'error', 'workflow.invalid_call_ref', `${path}.ref`, 'call.ref must be a non-empty recipe id.');
|
|
489
199
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
addFinding(ctx, 'warning', 'flow.unreachable_node', `${flowPath}.workflow.nodes.${nodeId}`, `Flow node ${nodeId} is not reachable from the entry node.`);
|
|
493
|
-
}
|
|
200
|
+
if (node.params != null && !isRecord(node.params)) {
|
|
201
|
+
addFinding(ctx, 'error', 'workflow.invalid_call_params', `${path}.params`, 'call.params must be an object.');
|
|
494
202
|
}
|
|
495
|
-
if (normalizedFlowId)
|
|
496
|
-
callGraph.set(normalizedFlowId, callees);
|
|
497
203
|
}
|
|
498
|
-
validateInlineFlowCycles(ctx, callGraph);
|
|
499
204
|
}
|
|
500
|
-
function
|
|
501
|
-
const visiting = new Set();
|
|
205
|
+
function walkSubgraph(ctx, graph, root, label) {
|
|
502
206
|
const visited = new Set();
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
207
|
+
const visiting = new Set();
|
|
208
|
+
let terminalReachable = false;
|
|
209
|
+
const visit = (nodeId) => {
|
|
210
|
+
if (visiting.has(nodeId)) {
|
|
211
|
+
addFinding(ctx, 'error', 'workflow.cycle', `workflow.nodes.${nodeId}`, `The ${label} graph contains a cycle at ${nodeId}; bounded repetition belongs inside an action.`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (visited.has(nodeId))
|
|
506
215
|
return;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const cycle = [...stack.slice(Math.max(cycleStart, 0)), flowId];
|
|
510
|
-
addFinding(ctx, 'error', 'flow.call_cycle', `flows.${flowId}`, `Inline flow call cycle detected: ${cycle.join(' -> ')}.`);
|
|
216
|
+
const node = graph.nodes[nodeId];
|
|
217
|
+
if (!node)
|
|
511
218
|
return;
|
|
219
|
+
visiting.add(nodeId);
|
|
220
|
+
visited.add(nodeId);
|
|
221
|
+
if (node.action === 'end')
|
|
222
|
+
terminalReachable = true;
|
|
223
|
+
else {
|
|
224
|
+
for (const target of collectTargets(ctx, node, `workflow.nodes.${nodeId}`, false))
|
|
225
|
+
visit(target);
|
|
512
226
|
}
|
|
513
|
-
visiting.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
visiting.delete(flowId);
|
|
519
|
-
visited.add(flowId);
|
|
227
|
+
visiting.delete(nodeId);
|
|
228
|
+
};
|
|
229
|
+
visit(root);
|
|
230
|
+
if (!terminalReachable) {
|
|
231
|
+
addFinding(ctx, 'error', 'workflow.no_reachable_terminal', `workflow.${label}`, `No action:end node is reachable from workflow.${label}.`);
|
|
520
232
|
}
|
|
521
|
-
|
|
522
|
-
visit(flowId);
|
|
233
|
+
return visited;
|
|
523
234
|
}
|
|
524
|
-
export function
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
return;
|
|
528
|
-
if (!Array.isArray(value)) {
|
|
529
|
-
addFinding(ctx, 'error', 'workflow.invalid_pre_conditions', 'validate.workflow.pre_conditions', 'validate.workflow.pre_conditions must be an array.');
|
|
530
|
-
return;
|
|
235
|
+
export function validateWorkflowGraph(ctx, workflow) {
|
|
236
|
+
if (!hasOwn(workflow.nodes, workflow.entry)) {
|
|
237
|
+
addFinding(ctx, 'error', 'workflow.missing_entry_node', 'workflow.entry', `Entry node ${workflow.entry} does not exist in workflow.nodes.`);
|
|
531
238
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
239
|
+
if (workflow.teardown && !hasOwn(workflow.nodes, workflow.teardown)) {
|
|
240
|
+
addFinding(ctx, 'error', 'workflow.missing_teardown_node', 'workflow.teardown', `Teardown node ${workflow.teardown} does not exist in workflow.nodes.`);
|
|
241
|
+
}
|
|
242
|
+
for (const [nodeId, node] of Object.entries(workflow.nodes)) {
|
|
243
|
+
validateNodeShape(ctx, nodeId, node);
|
|
244
|
+
for (const target of collectTargets(ctx, node, `workflow.nodes.${nodeId}`, false)) {
|
|
245
|
+
if (!hasOwn(workflow.nodes, target)) {
|
|
246
|
+
addFinding(ctx, 'error', 'workflow.missing_target', `workflow.nodes.${nodeId}`, `Node ${nodeId} references missing target ${target}.`);
|
|
537
247
|
}
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
if (!isRecord(entry)) {
|
|
541
|
-
addFinding(ctx, 'error', 'workflow.invalid_pre_condition', path, `${path} must be a non-empty string or object with id.`);
|
|
542
|
-
return;
|
|
543
248
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
249
|
+
}
|
|
250
|
+
const main = hasOwn(workflow.nodes, workflow.entry)
|
|
251
|
+
? walkSubgraph(ctx, workflow, workflow.entry, 'entry')
|
|
252
|
+
: new Set();
|
|
253
|
+
const teardown = workflow.teardown && hasOwn(workflow.nodes, workflow.teardown)
|
|
254
|
+
? walkSubgraph(ctx, workflow, workflow.teardown, 'teardown')
|
|
255
|
+
: new Set();
|
|
256
|
+
for (const nodeId of teardown) {
|
|
257
|
+
if (main.has(nodeId)) {
|
|
258
|
+
addFinding(ctx, 'error', 'workflow.overlapping_teardown', `workflow.nodes.${nodeId}`, 'Entry and teardown subgraphs must be disjoint so cleanup runs exactly once.');
|
|
552
259
|
}
|
|
553
|
-
|
|
554
|
-
|
|
260
|
+
}
|
|
261
|
+
for (const nodeId of Object.keys(workflow.nodes)) {
|
|
262
|
+
if (!main.has(nodeId) && !teardown.has(nodeId)) {
|
|
263
|
+
addFinding(ctx, 'error', 'workflow.unreachable_node', `workflow.nodes.${nodeId}`, `Node ${nodeId} is unreachable from workflow.entry or workflow.teardown.`);
|
|
555
264
|
}
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
export function getRecipeWorkflowPreconditionEntries(recipe) {
|
|
559
|
-
if (!isRecord(recipe) || !isRecord(recipe.validate) || !isRecord(recipe.validate.workflow)) {
|
|
560
|
-
return [];
|
|
561
265
|
}
|
|
562
|
-
|
|
563
|
-
|
|
266
|
+
}
|
|
267
|
+
export function getRecipeWorkflowNodeIds(recipe) {
|
|
268
|
+
if (!isRecord(recipe) || !isRecord(recipe.workflow) || !isRecord(recipe.workflow.nodes))
|
|
564
269
|
return [];
|
|
565
|
-
return
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
270
|
+
return Object.keys(recipe.workflow.nodes);
|
|
271
|
+
}
|
|
272
|
+
export function getRecipeCallRefs(recipe) {
|
|
273
|
+
return getRecipeWorkflowActionEntries(recipe).flatMap(({ node }) => node.action === 'call' && isNonEmptyString(node.ref) ? [normalizeRecipeRef(node.ref)] : []);
|
|
274
|
+
}
|
|
275
|
+
export function getRecipeWorkflowActions(recipe) {
|
|
276
|
+
return getRecipeWorkflowActionEntries(recipe).map(({ action }) => action);
|
|
277
|
+
}
|
|
278
|
+
export function getRecipeWorkflowActionEntries(recipe) {
|
|
279
|
+
if (!isRecord(recipe) || !isRecord(recipe.workflow) || !isRecord(recipe.workflow.nodes))
|
|
572
280
|
return [];
|
|
281
|
+
return Object.entries(recipe.workflow.nodes).flatMap(([nodeId, node]) => {
|
|
282
|
+
if (!isRecord(node) || !isNonEmptyString(node.action))
|
|
283
|
+
return [];
|
|
284
|
+
return [{ nodeId, action: node.action, node, path: `workflow.nodes.${nodeId}` }];
|
|
573
285
|
});
|
|
574
286
|
}
|
|
575
|
-
export function
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
: new Set();
|
|
579
|
-
const externalCatalogsResolvable = options?.externalCatalogsResolvable !== false;
|
|
580
|
-
const hasExternalCatalogs = externalCatalogsResolvable && Array.isArray(recipe.uses) && recipe.uses.length > 0;
|
|
581
|
-
const externalFlowIds = options?.externalFlowIds;
|
|
582
|
-
const entries = [
|
|
583
|
-
...Object.entries(workflow.nodes).map(([nodeId, node]) => [nodeId, node, `validate.workflow.nodes.${nodeId}`]),
|
|
584
|
-
...Object.entries(workflow.lifecycleNodes).map(([nodeId, entry]) => [nodeId, entry.node, entry.path]),
|
|
585
|
-
...getInlineFlowNodeEntries(recipe),
|
|
586
|
-
];
|
|
587
|
-
for (const [_nodeId, node, path] of entries) {
|
|
588
|
-
if (node.action !== 'call')
|
|
287
|
+
export function validateRecipeCalls(ctx, workflow, options) {
|
|
288
|
+
for (const [nodeId, node] of Object.entries(workflow.nodes)) {
|
|
289
|
+
if (node.action !== 'call' || !isNonEmptyString(node.ref))
|
|
589
290
|
continue;
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
if (isDynamicRecipeFlowRef(node.ref)) {
|
|
595
|
-
addFinding(ctx, 'error', 'workflow.dynamic_call_ref', `${path}.ref`, 'call.ref must be static; parameter templates are not allowed in flow identifiers.');
|
|
291
|
+
const path = `workflow.nodes.${nodeId}.ref`;
|
|
292
|
+
if (isDynamicRecipeRef(node.ref)) {
|
|
293
|
+
addFinding(ctx, 'error', 'workflow.dynamic_call_ref', path, 'call.ref must be static so dependencies can be resolved and trusted before execution.');
|
|
596
294
|
continue;
|
|
597
295
|
}
|
|
598
|
-
const ref =
|
|
599
|
-
if (options?.skipResolution !== true &&
|
|
600
|
-
|
|
601
|
-
!inlineFlows.has(ref) &&
|
|
602
|
-
!externalFlowIds?.has(ref)) {
|
|
603
|
-
addFinding(ctx, 'error', 'workflow.unresolved_call_ref', `${path}.ref`, `Flow ${node.ref} is not declared in recipe.flows and no uses catalogs are present.`);
|
|
604
|
-
}
|
|
605
|
-
if (node.params != null && !isRecord(node.params)) {
|
|
606
|
-
addFinding(ctx, 'error', 'workflow.invalid_call_params', `${path}.params`, 'call.params must be an object when present.');
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
function getInlineFlowNodeEntries(recipe) {
|
|
611
|
-
if (!isRecord(recipe.flows))
|
|
612
|
-
return [];
|
|
613
|
-
const entries = [];
|
|
614
|
-
for (const [flowId, flow] of Object.entries(recipe.flows)) {
|
|
615
|
-
if (!isRecord(flow))
|
|
616
|
-
continue;
|
|
617
|
-
const workflow = isRecord(flow.workflow) ? flow.workflow : flow;
|
|
618
|
-
if (!isRecord(workflow.nodes))
|
|
619
|
-
continue;
|
|
620
|
-
for (const [nodeId, node] of Object.entries(workflow.nodes)) {
|
|
621
|
-
if (!isRecord(node))
|
|
622
|
-
continue;
|
|
623
|
-
entries.push([`${flowId}/${nodeId}`, node, `flows.${flowId}.workflow.nodes.${nodeId}`]);
|
|
296
|
+
const ref = normalizeRecipeRef(node.ref);
|
|
297
|
+
if (options?.skipResolution !== true && !options?.externalRecipeIds?.has(ref)) {
|
|
298
|
+
addFinding(ctx, 'error', 'workflow.unresolved_call_ref', path, `Recipe ${ref} is not available from the resolved recipe library.`);
|
|
624
299
|
}
|
|
625
300
|
}
|
|
626
|
-
return entries;
|
|
627
301
|
}
|
|
628
302
|
//# sourceMappingURL=workflow.js.map
|