@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +12 -4
  3. package/dist/contracts/runs.d.ts +1 -1
  4. package/dist/contracts/runs.d.ts.map +1 -1
  5. package/dist/recipe/artifact.d.ts.map +1 -1
  6. package/dist/recipe/artifact.js +153 -20
  7. package/dist/recipe/artifact.js.map +1 -1
  8. package/dist/recipe/common.d.ts +27 -36
  9. package/dist/recipe/common.d.ts.map +1 -1
  10. package/dist/recipe/common.js +0 -3
  11. package/dist/recipe/common.js.map +1 -1
  12. package/dist/recipe/digest.d.ts +5 -0
  13. package/dist/recipe/digest.d.ts.map +1 -0
  14. package/dist/recipe/digest.js +25 -0
  15. package/dist/recipe/digest.js.map +1 -0
  16. package/dist/recipe/document.d.ts +4 -20
  17. package/dist/recipe/document.d.ts.map +1 -1
  18. package/dist/recipe/document.js +160 -123
  19. package/dist/recipe/document.js.map +1 -1
  20. package/dist/recipe/index.d.ts +5 -3
  21. package/dist/recipe/index.d.ts.map +1 -1
  22. package/dist/recipe/index.js +5 -3
  23. package/dist/recipe/index.js.map +1 -1
  24. package/dist/recipe/manifest.d.ts +0 -1
  25. package/dist/recipe/manifest.d.ts.map +1 -1
  26. package/dist/recipe/manifest.js +68 -45
  27. package/dist/recipe/manifest.js.map +1 -1
  28. package/dist/recipe/params.d.ts +9 -0
  29. package/dist/recipe/params.d.ts.map +1 -0
  30. package/dist/recipe/params.js +261 -0
  31. package/dist/recipe/params.js.map +1 -0
  32. package/dist/recipe/trust.d.ts +5 -3
  33. package/dist/recipe/trust.d.ts.map +1 -1
  34. package/dist/recipe/trust.js.map +1 -1
  35. package/dist/recipe/workflow.d.ts +13 -30
  36. package/dist/recipe/workflow.d.ts.map +1 -1
  37. package/dist/recipe/workflow.js +200 -526
  38. package/dist/recipe/workflow.js.map +1 -1
  39. package/dist/rpc/dispatch.d.ts +8 -0
  40. package/dist/rpc/dispatch.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/schemas/recipe-v1.schema.json +119 -197
@@ -1,11 +1,13 @@
1
- import { addFinding, hasOwn, isNonEmptyString, isRecord, OFFICIAL_ACTION_SET, PLAYBACK_MODES, RECIPE_PLAYBACK_SLOW_MS_MAX, RECIPE_PLAYBACK_SLOW_MS_MIN, TERMINAL_STATUSES, } from './common.js';
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
- /** Canonical flow identifier used by validation, planning, and execution. */
32
- export function normalizeRecipeFlowRef(value) {
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 isDynamicRecipeFlowRef(value) {
36
- return /\{\{params\.[A-Za-z0-9_.-]+\}\}/.test(value);
47
+ export function isDynamicRecipeRef(value) {
48
+ return /\{\{(?:params|outputs)\.[A-Za-z0-9_.-]+\}\}/u.test(value);
37
49
  }
38
- function normalizeIntent(value) {
39
- return value.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
40
- }
41
- function addInvalidIntentFinding(ctx, codePrefix, path, message) {
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 validate = recipe.validate;
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', 'validate.workflow', 'Recipe must include validate.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', 'validate.workflow.entry', 'validate.workflow.entry must be a non-empty string.');
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', 'validate.workflow.nodes', 'validate.workflow.nodes must be a non-empty object.');
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', `validate.workflow.nodes.${nodeId}`, `Workflow node ${nodeId} must be an object.`);
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 { entry, nodes: typedNodes, lifecycleNodes };
89
+ return {
90
+ entry,
91
+ nodes: typedNodes,
92
+ ...(isNonEmptyString(teardown) ? { teardown } : {}),
93
+ };
101
94
  }
102
- function collectLifecycleNodes(ctx, recipe, workflow) {
103
- const lifecycleNodes = Object.create(null);
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 collectLifecycleArray(ctx, value, path, lifecycleNodes) {
117
- if (value == null)
118
- return;
119
- if (!Array.isArray(value)) {
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
- value.forEach((entry, index) => {
124
- const nodePath = `${path}[${index}]`;
125
- if (!isRecord(entry)) {
126
- addFinding(ctx, 'error', 'workflow.invalid_lifecycle_node', nodePath, `${nodePath} must be an action node object.`);
127
- return;
128
- }
129
- lifecycleNodes[isNonEmptyString(entry.id) ? entry.id : `${path}:${index}`] = {
130
- node: entry,
131
- path: nodePath,
132
- };
133
- if (entry.next != null) {
134
- addFinding(ctx, 'error', 'workflow.lifecycle_has_transition', `${nodePath}.next`, 'setup and teardown entries are ordered arrays and must not declare next.');
135
- }
136
- });
137
- }
138
- function collectCaseTargets(node, path, ctx, emitFindings = true) {
139
- const cases = node.cases;
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
- if (emitFindings) {
180
- addFinding(ctx, 'error', 'workflow.invalid_cases', `${path}.cases`, 'cases must be an array or object.');
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, ctx, emitFindings = true) {
132
+ function collectTargets(ctx, node, path, emitFindings = true) {
185
133
  const targets = [];
186
- const next = node.next;
187
- const defaultTarget = node.default;
188
- if (next != null) {
189
- if (isNonEmptyString(next)) {
190
- targets.push(next);
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
- if (emitFindings) {
194
- addFinding(ctx, 'error', 'workflow.invalid_next', `${path}.next`, 'next must be a non-empty string.');
195
- }
196
- }
197
- }
198
- if (defaultTarget != null) {
199
- if (isNonEmptyString(defaultTarget)) {
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 validatePlayback(ctx, workflow) {
212
- if (workflow.playback == null)
213
- return;
214
- if (!isRecord(workflow.playback)) {
215
- addFinding(ctx, 'error', 'workflow.invalid_playback', 'validate.workflow.playback', 'playback must be an object when present.');
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
- const playback = workflow.playback;
219
- if (playback.mode != null &&
220
- (typeof playback.mode !== 'string' || !PLAYBACK_MODES.has(playback.mode))) {
221
- addFinding(ctx, 'error', 'workflow.invalid_playback_mode', 'validate.workflow.playback.mode', 'playback.mode must be one of off, auto, or step.');
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
- if (isRecord(recipe.startState) && isNonEmptyString(recipe.startState.action)) {
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
- const inlineFlowIds = new Set();
394
- for (const flowId of Object.keys(flows)) {
395
- const normalizedFlowId = normalizeRecipeFlowRef(flowId);
396
- if (!normalizedFlowId) {
397
- addFinding(ctx, 'error', 'flow.invalid_id', `flows.${flowId}`, 'Flow id must not be empty.');
398
- }
399
- else if (inlineFlowIds.has(normalizedFlowId)) {
400
- addFinding(ctx, 'error', 'flow.duplicate_id', `flows.${flowId}`, `Flow id ${flowId} collides with another flow after whitespace normalization.`);
401
- }
402
- else {
403
- inlineFlowIds.add(normalizedFlowId);
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
- while (queue.length > 0) {
470
- const nodeId = queue.shift();
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
- for (const nodeId of Object.keys(nodes)) {
491
- if (!reachable.has(nodeId)) {
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 validateInlineFlowCycles(ctx, callGraph) {
501
- const visiting = new Set();
205
+ function walkSubgraph(ctx, graph, root, label) {
502
206
  const visited = new Set();
503
- const stack = [];
504
- function visit(flowId) {
505
- if (visited.has(flowId))
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
- if (visiting.has(flowId)) {
508
- const cycleStart = stack.indexOf(flowId);
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.add(flowId);
514
- stack.push(flowId);
515
- for (const callee of callGraph.get(flowId) ?? [])
516
- visit(callee);
517
- stack.pop();
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
- for (const flowId of callGraph.keys())
522
- visit(flowId);
233
+ return visited;
523
234
  }
524
- export function validateWorkflowPreconditions(ctx, workflow) {
525
- const value = workflow.pre_conditions;
526
- if (value == null)
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
- value.forEach((entry, index) => {
533
- const path = `validate.workflow.pre_conditions[${index}]`;
534
- if (typeof entry === 'string') {
535
- if (!entry.trim()) {
536
- addFinding(ctx, 'error', 'workflow.invalid_pre_condition', path, `${path} must be a non-empty string or object with id.`);
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
- if (!isNonEmptyString(entry.id)) {
545
- addFinding(ctx, 'error', 'workflow.invalid_pre_condition_id', `${path}.id`, `${path}.id must be a non-empty string.`);
546
- }
547
- if (entry.description != null && typeof entry.description !== 'string') {
548
- addFinding(ctx, 'error', 'workflow.invalid_pre_condition_description', `${path}.description`, `${path}.description must be a string when present.`);
549
- }
550
- if (entry.params != null && !isRecord(entry.params)) {
551
- addFinding(ctx, 'error', 'workflow.invalid_pre_condition_params', `${path}.params`, `${path}.params must be an object when present.`);
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
- if (entry.required != null && typeof entry.required !== 'boolean') {
554
- addFinding(ctx, 'error', 'workflow.invalid_pre_condition_required', `${path}.required`, `${path}.required must be a boolean when present.`);
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
- const entries = recipe.validate.workflow.pre_conditions;
563
- if (!Array.isArray(entries))
266
+ }
267
+ export function getRecipeWorkflowNodeIds(recipe) {
268
+ if (!isRecord(recipe) || !isRecord(recipe.workflow) || !isRecord(recipe.workflow.nodes))
564
269
  return [];
565
- return entries.flatMap((entry, index) => {
566
- const path = `validate.workflow.pre_conditions[${index}]`;
567
- if (typeof entry === 'string' && entry.trim())
568
- return [{ id: entry.trim(), path }];
569
- if (isRecord(entry) && isNonEmptyString(entry.id)) {
570
- return [{ id: entry.id, path: `${path}.id` }];
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 validateFlowCalls(ctx, recipe, workflow, options) {
576
- const inlineFlows = isRecord(recipe.flows)
577
- ? new Set(Object.keys(recipe.flows).map(normalizeRecipeFlowRef))
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
- if (!isNonEmptyString(node.ref)) {
591
- addFinding(ctx, 'error', 'workflow.invalid_call_ref', `${path}.ref`, 'call.ref must be a non-empty string.');
592
- continue;
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 = normalizeRecipeFlowRef(node.ref);
599
- if (options?.skipResolution !== true &&
600
- !hasExternalCatalogs &&
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