@controllerai/cli 0.2.9 → 0.2.10
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/dist/api/client.js +20 -5
- package/dist/api/client.js.map +1 -1
- package/dist/argv.js +330 -29
- package/dist/argv.js.map +1 -1
- package/dist/commands/agent.js +208 -8
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/auth.js +21 -5
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/doctor.js +63 -4
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/exec.js +161 -49
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/expr.js +284 -53
- package/dist/commands/expr.js.map +1 -1
- package/dist/commands/flow.js +58 -14
- package/dist/commands/flow.js.map +1 -1
- package/dist/commands/node.js +746 -173
- package/dist/commands/node.js.map +1 -1
- package/dist/commands/organize.js +1 -1
- package/dist/commands/organize.js.map +1 -1
- package/dist/commands/run.js +165 -33
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/schema.js +8 -7
- package/dist/commands/schema.js.map +1 -1
- package/dist/commands/workflow.js +151 -24
- package/dist/commands/workflow.js.map +1 -1
- package/dist/config.js +152 -6
- package/dist/config.js.map +1 -1
- package/dist/context.js +66 -5
- package/dist/context.js.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/nodeProps.js +333 -10
- package/dist/nodeProps.js.map +1 -1
- package/dist/output.js +6 -1
- package/dist/output.js.map +1 -1
- package/package.json +1 -1
- package/skills/controllerai-agents/SKILL.md +27 -9
- package/skills/controllerai-agents/references/instructions-and-versioning.md +1 -1
- package/skills/controllerai-data/SKILL.md +17 -0
- package/skills/controllerai-data/references/data-rails.md +1 -1
- package/skills/controllerai-expressions/SKILL.md +7 -3
- package/skills/controllerai-flow-control/SKILL.md +2 -0
- package/skills/controllerai-node-config/SKILL.md +33 -6
- package/skills/controllerai-node-config/references/prop-and-readiness.md +34 -0
- package/skills/controllerai-runs/SKILL.md +14 -4
- package/skills/controllerai-runs/references/debugging.md +1 -1
- package/skills/controllerai-start/SKILL.md +1 -0
- package/skills/controllerai-workflows/SKILL.md +13 -8
- package/skills/controllerai-workflows/references/command-map.md +3 -2
- package/skills/manifest.json +58 -58
package/dist/commands/node.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { requireContext } from '../context.js';
|
|
3
|
-
import { validationFailed, validationMessage } from './expr.js';
|
|
3
|
+
import { prepareWritableProp, requireReadbackProp, validationFailed, validationMessage, } from './expr.js';
|
|
4
4
|
import { CaiError, userError } from '../errors.js';
|
|
5
5
|
import { resolveJsSource } from '../jsSource.js';
|
|
6
|
-
import { buildNodeReadiness,
|
|
6
|
+
import { arrayExpressionReplacementWarning, assertArrayExpressionWriteSupported, buildNodeReadiness, expressionPersistenceMismatchMessage, nodePropsFromPayload, normalizeNodePropWrites, remoteOptionsCompiledLiteralNote, subflowTargetFromPayload, verifyExpressionPersistence, } from '../nodeProps.js';
|
|
7
7
|
import { emit, printKeyValues, printTable, truncate, warn } from '../output.js';
|
|
8
8
|
const readUtf8 = (source, encoding) => readFileSync(source, encoding);
|
|
9
9
|
const loadProps = async (api, workflowId, nodeId) => {
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const payload = await api.tool(workflowId, 'get_node_props', { nodeId });
|
|
11
|
+
const subflowTarget = subflowTargetFromPayload(payload);
|
|
12
|
+
return {
|
|
13
|
+
props: nodePropsFromPayload(payload),
|
|
14
|
+
...(subflowTarget ? { subflowTarget } : {}),
|
|
15
|
+
};
|
|
12
16
|
};
|
|
13
17
|
/**
|
|
14
18
|
* get_node exposes processOutput after a successful typed run or documented
|
|
@@ -35,19 +39,16 @@ const addResolvedOutputType = async (api, workflowId, record) => {
|
|
|
35
39
|
return record;
|
|
36
40
|
}
|
|
37
41
|
};
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`\`cai node enable-prop ${nodeId} --prop ${propName}\`, then re-read props.`, 'PROP_NOT_ENABLED');
|
|
49
|
-
}
|
|
50
|
-
return prop;
|
|
42
|
+
const loadNodeReadiness = async (api, workflowId, nodeId) => {
|
|
43
|
+
const [record, loadedProps] = await Promise.all([
|
|
44
|
+
api.tool(workflowId, 'get_node', { nodeId }),
|
|
45
|
+
loadProps(api, workflowId, nodeId),
|
|
46
|
+
]);
|
|
47
|
+
const resolvedRecord = await addResolvedOutputType(api, workflowId, record);
|
|
48
|
+
return {
|
|
49
|
+
record: resolvedRecord,
|
|
50
|
+
readiness: buildNodeReadiness(nodeId, resolvedRecord, loadedProps.props, loadedProps.subflowTarget),
|
|
51
|
+
};
|
|
51
52
|
};
|
|
52
53
|
/**
|
|
53
54
|
* Prop types whose stored shape pairs a value with the human label the UI displayed.
|
|
@@ -63,6 +64,9 @@ const isOptionBackedProp = (prop) => ['DROPDOWN', 'MULTI_SELECT', 'MULTI_SELECT_
|
|
|
63
64
|
* Getting this wrong saves cleanly and shows blank chips in the UI (found by CFG-012).
|
|
64
65
|
*/
|
|
65
66
|
const isMultiSelectProp = (prop) => ['MULTI_SELECT', 'MULTI_SELECT_DROPDOWN'].includes(String(prop.inputType ?? ''));
|
|
67
|
+
const optionValueLabel = (prop, options) => isMultiSelectProp(prop)
|
|
68
|
+
? options.map(option => ({ value: option.value, label: option.label }))
|
|
69
|
+
: options[0]?.label;
|
|
66
70
|
/**
|
|
67
71
|
* Remote option providers commonly search human labels rather than opaque ids.
|
|
68
72
|
* A supplied --label is therefore the lookup query, never an unverified value
|
|
@@ -93,12 +97,69 @@ const isTextExpressionBackedProp = (prop) => ['JSON', 'LONG_TEXT', 'SHORT_TEXT',
|
|
|
93
97
|
const fetchPropOptions = async (api, workflowId, nodeId, propName, query) => {
|
|
94
98
|
const data = await api.tool(workflowId, 'get_prop_options', { nodeId, propName, ...(query ? { query } : {}) });
|
|
95
99
|
const rows = Array.isArray(data) ? data : (data?.options ?? []);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const error = !Array.isArray(data) && typeof data?.error === 'string' ? data.error : undefined;
|
|
101
|
+
return {
|
|
102
|
+
options: rows
|
|
103
|
+
.map(o => ({
|
|
104
|
+
value: String(o.value ?? o.id ?? ''),
|
|
105
|
+
label: String(o.label ?? o.name ?? o.value ?? o.id ?? ''),
|
|
106
|
+
}))
|
|
107
|
+
.filter(o => o.value !== ''),
|
|
108
|
+
...(error ? { error } : {}),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
export const parseNodeOptionsPrevContext = (raw) => {
|
|
112
|
+
let value;
|
|
113
|
+
try {
|
|
114
|
+
value = JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw userError(`--prev-context must be a JSON object: ${error instanceof Error ? error.message : error}`, 'BAD_OPTION_CONTEXT');
|
|
118
|
+
}
|
|
119
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
120
|
+
throw userError('--prev-context must be a JSON object.', 'BAD_OPTION_CONTEXT');
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
};
|
|
124
|
+
export const parseNodeOptionOverrides = (entries) => {
|
|
125
|
+
const overrides = new Map();
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const separator = entry.indexOf('=');
|
|
128
|
+
const name = separator >= 0 ? entry.slice(0, separator).trim() : '';
|
|
129
|
+
if (separator < 0 || !name) {
|
|
130
|
+
throw userError(`--for must use <prop>=<value>; received ${JSON.stringify(entry)}.`, 'BAD_OPTION_OVERRIDE');
|
|
131
|
+
}
|
|
132
|
+
const rawValue = entry.slice(separator + 1);
|
|
133
|
+
let value;
|
|
134
|
+
try {
|
|
135
|
+
value = JSON.parse(rawValue);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
value = rawValue;
|
|
139
|
+
}
|
|
140
|
+
overrides.set(name, value);
|
|
141
|
+
}
|
|
142
|
+
return Object.fromEntries(overrides);
|
|
143
|
+
};
|
|
144
|
+
const collectNodeOptionOverride = (value, previous = []) => [
|
|
145
|
+
...previous,
|
|
146
|
+
value,
|
|
147
|
+
];
|
|
148
|
+
const dynamicUpstreamOptionError = (value) => typeof value === 'string' &&
|
|
149
|
+
/^Upstream prop "[^"]+" holds a dynamic value, so no option list exists\./i.test(value.trim());
|
|
150
|
+
const DYNAMIC_UPSTREAM_RAW_VALUE_NOTE = 'Value stored WITHOUT validation against provider options because an upstream prop holds a dynamic value.';
|
|
151
|
+
const dynamicUpstreamOptionNext = (nodeId, propName) => `cai node set ${nodeId} --prop ${propName} --value <exact-id>; ` +
|
|
152
|
+
'options cannot be listed while an upstream prop is dynamic (or use --js for an expression)';
|
|
153
|
+
const blockedUpstreamOptionMessage = (value) => {
|
|
154
|
+
if (!(value instanceof CaiError))
|
|
155
|
+
return undefined;
|
|
156
|
+
const match = /Cannot load options for "([^"]+)": earlier prop "([^"]+)" is unset\b/i.exec(value.message);
|
|
157
|
+
return match ? { propName: match[1], blockedBy: match[2] } : undefined;
|
|
158
|
+
};
|
|
159
|
+
const throwBlockedUpstreamOption = (nodeId, propName, blockedBy) => {
|
|
160
|
+
const next = `cai node set ${nodeId} --prop ${blockedBy} --value <value>`;
|
|
161
|
+
throw userError(`Cannot load options for "${propName}": earlier prop "${blockedBy}" is unset. ` +
|
|
162
|
+
`Set it first with \`${next}\`; nothing was written.`, 'OPTIONS_UNAVAILABLE');
|
|
102
163
|
};
|
|
103
164
|
const readObjectOption = (source, flag) => {
|
|
104
165
|
const raw = source.trimStart().startsWith('{') ? source : readFileSync(source, 'utf8');
|
|
@@ -131,8 +192,10 @@ export const readNodeOutputSample = (source, reader = readUtf8) => {
|
|
|
131
192
|
catch (error) {
|
|
132
193
|
throw userError(`--sample-file must contain valid JSON: ${error instanceof Error ? error.message : error}`, 'BAD_SAMPLE');
|
|
133
194
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
195
|
+
if (sample === null ||
|
|
196
|
+
typeof sample !== 'object' ||
|
|
197
|
+
(Array.isArray(sample) && sample.length === 0)) {
|
|
198
|
+
throw userError('--sample-file must contain documented provider response data: a JSON object or a non-empty JSON array of items.', 'BAD_SAMPLE');
|
|
136
199
|
}
|
|
137
200
|
return sample;
|
|
138
201
|
};
|
|
@@ -140,14 +203,14 @@ const nodeProcessOutput = (record) => record.processOutput ?? record.data?.proce
|
|
|
140
203
|
const renderNodeOutputType = (dataType, declared) => declared && typeof dataType === 'string'
|
|
141
204
|
? `${dataType} (declared — unverified)`
|
|
142
205
|
: dataType;
|
|
143
|
-
const
|
|
144
|
-
const requireCompletedPropReload = (result, nodeId, propName, retryCommand) => {
|
|
206
|
+
const requireCompletedPropReload = (result, nodeId, propName) => {
|
|
145
207
|
const reload = result;
|
|
146
208
|
if (reload?.propsReloaded !== false)
|
|
147
209
|
return;
|
|
148
210
|
const reloadError = typeof reload.reloadError === 'string' && reload.reloadError.trim()
|
|
149
211
|
? reload.reloadError.trim()
|
|
150
212
|
: 'The backend did not provide a reload error.';
|
|
213
|
+
const retryCommand = `cai node reload ${nodeId}`;
|
|
151
214
|
const inspectCommand = `cai node props ${nodeId} --json`;
|
|
152
215
|
throw new CaiError(`Prop "${propName}" was updated, but its dependent props were not reloaded: ` +
|
|
153
216
|
`${reloadError}\nThe node may still contain stale dependent values. Retry the reload with ` +
|
|
@@ -162,6 +225,36 @@ const requireCompletedPropReload = (result, nodeId, propName, retryCommand) => {
|
|
|
162
225
|
},
|
|
163
226
|
});
|
|
164
227
|
};
|
|
228
|
+
const requireCompletedExplicitPropReload = (result, nodeId) => {
|
|
229
|
+
const reload = result;
|
|
230
|
+
if (reload?.reconcileSkipped === 'unset_selector')
|
|
231
|
+
return;
|
|
232
|
+
if (reload?.propsReloaded !== false)
|
|
233
|
+
return;
|
|
234
|
+
const reloadError = typeof reload.reloadError === 'string' && reload.reloadError.trim()
|
|
235
|
+
? reload.reloadError.trim()
|
|
236
|
+
: 'The backend did not provide a reload error.';
|
|
237
|
+
const retryCommand = `cai node reload ${nodeId}`;
|
|
238
|
+
const inspectCommand = `cai node props ${nodeId} --json`;
|
|
239
|
+
throw new CaiError(`Node "${nodeId}" dependent props were not reloaded: ${reloadError}\n` +
|
|
240
|
+
`The node may still contain stale dependent values. Retry with \`${retryCommand}\`, then ` +
|
|
241
|
+
`inspect \`${inspectCommand}\` before configuring or running it.`, {
|
|
242
|
+
code: 'PROPS_RELOAD_FAILED',
|
|
243
|
+
detail: {
|
|
244
|
+
nodeId,
|
|
245
|
+
propsReloaded: false,
|
|
246
|
+
reloadError,
|
|
247
|
+
recoveryCommands: [retryCommand, inspectCommand],
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
const nodeReadinessDelta = (before, after) => ({
|
|
252
|
+
before: before.readinessState,
|
|
253
|
+
after: after.readinessState,
|
|
254
|
+
blockersResolved: before.blockedBy.filter(blocker => !after.blockedBy.includes(blocker)),
|
|
255
|
+
blockersAdded: after.blockedBy.filter(blocker => !before.blockedBy.includes(blocker)),
|
|
256
|
+
nextChanged: before.next === after.next ? null : { before: before.next, after: after.next },
|
|
257
|
+
});
|
|
165
258
|
const searchScore = (intent, component) => {
|
|
166
259
|
const normalize = (value) => value
|
|
167
260
|
.toLowerCase()
|
|
@@ -169,14 +262,22 @@ const searchScore = (intent, component) => {
|
|
|
169
262
|
.trim();
|
|
170
263
|
const query = normalize(intent);
|
|
171
264
|
const terms = query.split(/\s+/).filter(Boolean);
|
|
265
|
+
if (terms.length === 0)
|
|
266
|
+
return 0;
|
|
172
267
|
const name = normalize(component.name ?? '');
|
|
268
|
+
const nodeName = normalize(component.nodeName ?? '');
|
|
173
269
|
const description = normalize(component.description ?? '');
|
|
174
270
|
const props = normalize((component.propNames ?? []).join(' '));
|
|
175
|
-
|
|
271
|
+
const searchableText = [name, nodeName, description];
|
|
272
|
+
let score = searchableText.some(value => value === query)
|
|
273
|
+
? 100
|
|
274
|
+
: searchableText.some(value => value.includes(query))
|
|
275
|
+
? 50
|
|
276
|
+
: 0;
|
|
176
277
|
for (const term of terms) {
|
|
177
|
-
if (name.split(' ').includes(term))
|
|
278
|
+
if ([name, nodeName].some(value => value.split(' ').includes(term)))
|
|
178
279
|
score += 12;
|
|
179
|
-
else if (name.includes(term))
|
|
280
|
+
else if ([name, nodeName].some(value => value.includes(term)))
|
|
180
281
|
score += 7;
|
|
181
282
|
if (description.includes(term))
|
|
182
283
|
score += 3;
|
|
@@ -185,6 +286,79 @@ const searchScore = (intent, component) => {
|
|
|
185
286
|
}
|
|
186
287
|
return score;
|
|
187
288
|
};
|
|
289
|
+
const conceptHintDefinitions = [
|
|
290
|
+
{
|
|
291
|
+
concept: 'approval',
|
|
292
|
+
skill: 'controllerai-agents',
|
|
293
|
+
phrases: [
|
|
294
|
+
'approval',
|
|
295
|
+
'approvals',
|
|
296
|
+
'confirmation',
|
|
297
|
+
'confirmations',
|
|
298
|
+
'hitl',
|
|
299
|
+
'human in the loop',
|
|
300
|
+
],
|
|
301
|
+
message: 'Configure confirmation on agent tools, then approve or deny pending requests in the conversation.',
|
|
302
|
+
commands: [
|
|
303
|
+
'cai agent tools <agentId>',
|
|
304
|
+
'cai agent add-action <agentId> --integration <slug> --action <key> --connection <id> --require-confirmation',
|
|
305
|
+
'cai agent add-workflow <agentId> --workflow-id <workflowId> --flow <flowId> --require-confirmation',
|
|
306
|
+
'cai agent update-tool <agentId> --tool <toolId> --require-confirmation',
|
|
307
|
+
'cai agent conversation approve <conversationId> --request <requestId>',
|
|
308
|
+
'cai agent conversation deny <conversationId> --request <requestId>',
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
concept: 'memory',
|
|
313
|
+
skill: 'controllerai-data',
|
|
314
|
+
phrases: ['memory', 'memories'],
|
|
315
|
+
message: 'Use workflow data for durable structured state; the CLI has no separate agent-memory command.',
|
|
316
|
+
commands: [
|
|
317
|
+
'cai schema list',
|
|
318
|
+
'cai data collections',
|
|
319
|
+
'cai data query --type <dataType>',
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
concept: 'knowledge',
|
|
324
|
+
skill: 'controllerai-agents',
|
|
325
|
+
phrases: ['knowledge'],
|
|
326
|
+
message: 'List or add files in an agent knowledge space.',
|
|
327
|
+
commands: [
|
|
328
|
+
'cai agent files <agentId> --space knowledge',
|
|
329
|
+
'cai agent add-file <agentId> --file <path> --space knowledge',
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
concept: 'trigger',
|
|
334
|
+
skill: 'controllerai-triggers',
|
|
335
|
+
phrases: ['trigger', 'triggers', 'webhook', 'webhooks', 'schedule', 'schedules'],
|
|
336
|
+
message: 'If you are looking for trigger nodes rather than actions, use `cai trigger search`.',
|
|
337
|
+
commands: ['cai trigger search <query>'],
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
const conceptHintsFor = (query) => {
|
|
341
|
+
const normalized = query
|
|
342
|
+
.toLowerCase()
|
|
343
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
344
|
+
.trim();
|
|
345
|
+
if (!normalized)
|
|
346
|
+
return [];
|
|
347
|
+
const padded = ` ${normalized} `;
|
|
348
|
+
return conceptHintDefinitions
|
|
349
|
+
.filter(definition => definition.phrases.some(phrase => padded.includes(` ${phrase} `)))
|
|
350
|
+
.map(({ phrases: _phrases, ...hint }) => hint);
|
|
351
|
+
};
|
|
352
|
+
const printConceptHints = (hints) => {
|
|
353
|
+
if (hints.length === 0)
|
|
354
|
+
return;
|
|
355
|
+
process.stdout.write('\nConcept hints:\n');
|
|
356
|
+
for (const hint of hints) {
|
|
357
|
+
process.stdout.write(`${hint.concept} (${hint.skill}): ${hint.message}\n`);
|
|
358
|
+
for (const command of hint.commands)
|
|
359
|
+
process.stdout.write(` ${command}\n`);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
188
362
|
/**
|
|
189
363
|
* `add_node` needs BOTH nodeSlug and nodeKey, but component keys returned by
|
|
190
364
|
* search are already `<nodeSlug>-<component>` — so the slug is recoverable.
|
|
@@ -212,6 +386,95 @@ const resolveSlug = async (api, workflowId, componentKey, explicit) => {
|
|
|
212
386
|
throw userError(`Cannot infer the node slug from component key "${componentKey}". Pass --node <slug> ` +
|
|
213
387
|
'(the `nodeSlug` column from `cai node components`).', 'UNRESOLVED_SLUG');
|
|
214
388
|
};
|
|
389
|
+
const rethrowNodeAddOptionalPropError = (error) => {
|
|
390
|
+
if (error instanceof CaiError) {
|
|
391
|
+
const match = /^Optional prop "([^"]+)" not found on this component — available optional props:\s*(.*)$/s.exec(error.message);
|
|
392
|
+
if (match) {
|
|
393
|
+
const propName = match[1];
|
|
394
|
+
const available = match[2].trim() || '(empty)';
|
|
395
|
+
throw new CaiError(`Optional prop "${propName}" is not in this component's STATIC catalog; ` +
|
|
396
|
+
`available optional props: ${available}. Dynamically-generated props only materialize ` +
|
|
397
|
+
'after creation. Create the node without that --enable-prop, then run ' +
|
|
398
|
+
`\`cai node set <newNodeId> --prop ${propName} --value <value>\` (which auto-enables ` +
|
|
399
|
+
`it) or \`cai node enable-prop <newNodeId> --prop ${propName}\`.`, {
|
|
400
|
+
exitCode: error.exitCode,
|
|
401
|
+
code: 'OPTIONAL_PROP_NOT_STATIC',
|
|
402
|
+
detail: error.detail,
|
|
403
|
+
scrubbed: error.scrubbed,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
};
|
|
409
|
+
const textExpressionsIn = (value) => {
|
|
410
|
+
if (Array.isArray(value))
|
|
411
|
+
return value.flatMap(textExpressionsIn);
|
|
412
|
+
if (!value || typeof value !== 'object')
|
|
413
|
+
return [];
|
|
414
|
+
const record = value;
|
|
415
|
+
if (record.type === 'TextExpression')
|
|
416
|
+
return [record];
|
|
417
|
+
return Object.values(record).flatMap(textExpressionsIn);
|
|
418
|
+
};
|
|
419
|
+
const textExpressionTerminalType = (expression) => {
|
|
420
|
+
const chips = (Array.isArray(expression.entries) ? expression.entries : [])
|
|
421
|
+
.flatMap(row => (Array.isArray(row) ? row : []))
|
|
422
|
+
.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry));
|
|
423
|
+
let terminal = chips.at(-1);
|
|
424
|
+
let inferredType;
|
|
425
|
+
while (terminal) {
|
|
426
|
+
if (typeof terminal.returnDataType === 'string')
|
|
427
|
+
inferredType = terminal.returnDataType;
|
|
428
|
+
terminal =
|
|
429
|
+
terminal.next && typeof terminal.next === 'object' && !Array.isArray(terminal.next)
|
|
430
|
+
? terminal.next
|
|
431
|
+
: undefined;
|
|
432
|
+
}
|
|
433
|
+
return inferredType;
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* Protect older backends from advanced add_node expression writes when the
|
|
437
|
+
* component's static catalog is available. Dynamic props and catalog lookup
|
|
438
|
+
* failures deliberately fall through to the backend's authoritative guard.
|
|
439
|
+
*/
|
|
440
|
+
const guardInitialArrayExpressionWrites = async (api, workflowId, nodeSlug, nodeKey, writes) => {
|
|
441
|
+
const expressionWrites = Object.entries(writes ?? {}).filter(([, write]) => {
|
|
442
|
+
const valueExpressions = textExpressionsIn(write.value);
|
|
443
|
+
return (typeof write.valueJs === 'string' ||
|
|
444
|
+
typeof write.expressionValueJs === 'string' ||
|
|
445
|
+
write.expressionValue !== undefined ||
|
|
446
|
+
valueExpressions.length > 0 ||
|
|
447
|
+
write.useExpression === true);
|
|
448
|
+
});
|
|
449
|
+
if (expressionWrites.length === 0)
|
|
450
|
+
return;
|
|
451
|
+
let component;
|
|
452
|
+
try {
|
|
453
|
+
component = await api.tool(workflowId, 'get_node_component', { nodeSlug, nodeKey });
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const catalogProps = nodePropsFromPayload(component?.props ?? component?.component?.props ?? []);
|
|
459
|
+
for (const [propName, write] of expressionWrites) {
|
|
460
|
+
const prop = catalogProps.find(row => (row.name ?? row.key) === propName);
|
|
461
|
+
if (!prop || !String(prop.inputType ?? '').endsWith('_ARRAY'))
|
|
462
|
+
continue;
|
|
463
|
+
if (typeof write.valueJs === 'string') {
|
|
464
|
+
assertArrayExpressionWriteSupported('<newNodeId>', propName, prop, undefined);
|
|
465
|
+
}
|
|
466
|
+
if (typeof write.expressionValueJs === 'string' ||
|
|
467
|
+
write.expressionValue !== undefined ||
|
|
468
|
+
write.useExpression === true) {
|
|
469
|
+
assertArrayExpressionWriteSupported('<newNodeId>', propName, prop, undefined, 'expressionValue');
|
|
470
|
+
}
|
|
471
|
+
const valueExpressions = textExpressionsIn(write.value);
|
|
472
|
+
for (const expression of valueExpressions) {
|
|
473
|
+
assertArrayExpressionWriteSupported('<newNodeId>', propName, prop, textExpressionTerminalType(expression), 'value');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const isUnknownToolError = (error) => error instanceof CaiError && /unknown tool|method not found/i.test(error.message);
|
|
215
478
|
export const registerNode = (program) => {
|
|
216
479
|
const node = program.command('node').description('Nodes — search, add, configure, wire');
|
|
217
480
|
node
|
|
@@ -223,6 +486,7 @@ export const registerNode = (program) => {
|
|
|
223
486
|
.description('Search registry + catalog nodes (ranked by featuredWeight, not relevance)')
|
|
224
487
|
.action(async (query, opts, command) => {
|
|
225
488
|
const { api, workflowId } = requireContext(command);
|
|
489
|
+
const conceptHints = conceptHintsFor(query);
|
|
226
490
|
const result = await api.tool(workflowId, 'search_nodes', {
|
|
227
491
|
query,
|
|
228
492
|
limit: Number(opts.limit),
|
|
@@ -230,15 +494,18 @@ export const registerNode = (program) => {
|
|
|
230
494
|
...(opts.events ? { hasEvents: true } : {}),
|
|
231
495
|
});
|
|
232
496
|
const rows = result?.data ?? [];
|
|
233
|
-
emit({ items: rows, pagination: result?.pagination ?? null
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
497
|
+
emit({ items: rows, pagination: result?.pagination ?? null, conceptHints }, () => {
|
|
498
|
+
printTable(['slug', 'name', 'source', 'actions', 'events', 'auth', 'weight'], rows.map((n) => [
|
|
499
|
+
n.slug,
|
|
500
|
+
n.name,
|
|
501
|
+
n.source,
|
|
502
|
+
n.actionsCount,
|
|
503
|
+
n.eventsCount,
|
|
504
|
+
n.authRequired ? 'yes' : 'no',
|
|
505
|
+
n.featuredWeight,
|
|
506
|
+
]));
|
|
507
|
+
printConceptHints(conceptHints);
|
|
508
|
+
});
|
|
242
509
|
});
|
|
243
510
|
node
|
|
244
511
|
.command('find')
|
|
@@ -248,6 +515,7 @@ export const registerNode = (program) => {
|
|
|
248
515
|
.description('Find ranked action components and return exact nodeSlug/nodeKey pairs')
|
|
249
516
|
.action(async (intent, opts, command) => {
|
|
250
517
|
const { api, workflowId } = requireContext(command);
|
|
518
|
+
const conceptHints = conceptHintsFor(intent);
|
|
251
519
|
let slugs;
|
|
252
520
|
if (opts.node) {
|
|
253
521
|
slugs = [opts.node];
|
|
@@ -266,23 +534,56 @@ export const registerNode = (program) => {
|
|
|
266
534
|
query: intent,
|
|
267
535
|
limit: 20,
|
|
268
536
|
});
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
537
|
+
return {
|
|
538
|
+
rows: (result?.data ?? []).map((component) => ({
|
|
539
|
+
...component,
|
|
540
|
+
nodeSlug: component.nodeSlug ?? nodeSlug,
|
|
541
|
+
})),
|
|
542
|
+
hasMore: result?.pagination?.hasMore === true,
|
|
543
|
+
};
|
|
273
544
|
}));
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
.
|
|
277
|
-
.
|
|
278
|
-
.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
545
|
+
const hasMore = batches.some(batch => batch.hasMore);
|
|
546
|
+
const scoredRows = batches
|
|
547
|
+
.flatMap(batch => batch.rows)
|
|
548
|
+
.filter(component => component.type === undefined ||
|
|
549
|
+
component.type === null ||
|
|
550
|
+
String(component.type).toLowerCase() === 'action')
|
|
551
|
+
.map(component => ({ ...component, score: searchScore(intent, component) }));
|
|
552
|
+
const hasPositiveScore = scoredRows.some(component => component.score > 0);
|
|
553
|
+
const rankedRows = hasPositiveScore
|
|
554
|
+
? scoredRows
|
|
555
|
+
.filter(component => component.score > 0)
|
|
556
|
+
.sort((left, right) => right.score - left.score || left.key.localeCompare(right.key))
|
|
557
|
+
: scoredRows;
|
|
558
|
+
const rows = rankedRows.slice(0, Math.max(1, Number(opts.limit) || 8));
|
|
559
|
+
const rankingSource = scoredRows.length === 0 ? 'none' : hasPositiveScore ? 'local-literal' : 'server';
|
|
560
|
+
emit({
|
|
561
|
+
intent,
|
|
562
|
+
items: rows,
|
|
563
|
+
rankingSource,
|
|
564
|
+
pagination: { hasMore },
|
|
565
|
+
conceptHints,
|
|
566
|
+
}, () => {
|
|
567
|
+
if (rows.length === 0) {
|
|
568
|
+
process.stdout.write('No relevant components matched this intent.\n');
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
printTable(['nodeSlug', 'nodeKey', 'action', 'auth', 'score'], rows.map(row => [
|
|
572
|
+
row.nodeSlug,
|
|
573
|
+
row.key,
|
|
574
|
+
row.name,
|
|
575
|
+
row.nodeAuthRequired ? 'yes' : 'no',
|
|
576
|
+
row.score,
|
|
577
|
+
]));
|
|
578
|
+
}
|
|
579
|
+
if (rankingSource === 'server') {
|
|
580
|
+
process.stdout.write('Note: no local literal match; results remain in the server-ranked order.\n');
|
|
581
|
+
}
|
|
582
|
+
if (hasMore) {
|
|
583
|
+
process.stdout.write('Note: the server has more component results beyond this page; narrow the intent or use --node.\n');
|
|
584
|
+
}
|
|
585
|
+
printConceptHints(conceptHints);
|
|
586
|
+
});
|
|
286
587
|
});
|
|
287
588
|
node
|
|
288
589
|
.command('components')
|
|
@@ -319,24 +620,32 @@ export const registerNode = (program) => {
|
|
|
319
620
|
const props = opts.props
|
|
320
621
|
? normalizeNodePropWrites(readObjectOption(opts.props, '--props'))
|
|
321
622
|
: undefined;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
? {
|
|
332
|
-
: {}),
|
|
333
|
-
|
|
334
|
-
|
|
623
|
+
await guardInitialArrayExpressionWrites(api, workflowId, nodeSlug, opts.key, props);
|
|
624
|
+
let result;
|
|
625
|
+
try {
|
|
626
|
+
result = await api.mutate(workflowId, () => api.tool(workflowId, 'add_node', {
|
|
627
|
+
flowId: opts.flow,
|
|
628
|
+
nodeSlug,
|
|
629
|
+
nodeKey: opts.key,
|
|
630
|
+
connectionId: opts.connection ? Number(opts.connection) : null,
|
|
631
|
+
...(opts.name ? { displayName: opts.name } : {}),
|
|
632
|
+
...(props ? { props } : {}),
|
|
633
|
+
...(opts.enableProp?.length ? { enableOptionalProps: opts.enableProp } : {}),
|
|
634
|
+
...(opts.after
|
|
635
|
+
? { connectFrom: { sourceNodeId: opts.after, sourceOutput: opts.output } }
|
|
636
|
+
: {}),
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
rethrowNodeAddOptionalPropError(error);
|
|
641
|
+
}
|
|
642
|
+
const [rawRecord, loadedProps] = await Promise.all([
|
|
335
643
|
api.tool(workflowId, 'get_node', { nodeId: result.nodeId }),
|
|
336
644
|
loadProps(api, workflowId, result.nodeId),
|
|
337
645
|
]);
|
|
646
|
+
const currentProps = loadedProps.props;
|
|
338
647
|
const record = await addResolvedOutputType(api, workflowId, rawRecord);
|
|
339
|
-
const readiness = buildNodeReadiness(result.nodeId, record, currentProps);
|
|
648
|
+
const readiness = buildNodeReadiness(result.nodeId, record, currentProps, loadedProps.subflowTarget);
|
|
340
649
|
const requestedPropNames = Object.keys(props ?? {});
|
|
341
650
|
const propsReadback = currentProps.filter(prop => requestedPropNames.includes(prop.name ?? prop.key ?? ''));
|
|
342
651
|
emit({ ...result, normalizedProps: props ?? {}, propsReadback, readiness }, () => printKeyValues([
|
|
@@ -457,15 +766,29 @@ export const registerNode = (program) => {
|
|
|
457
766
|
node
|
|
458
767
|
.command('declare-output')
|
|
459
768
|
.argument('<nodeId>')
|
|
460
|
-
.requiredOption('--sample-file <file|->', 'documented response data JSON object; `-` reads stdin')
|
|
769
|
+
.requiredOption('--sample-file <file|->', 'documented provider response data: a JSON object or a non-empty JSON array of items; `-` reads stdin')
|
|
461
770
|
.description('Declare an unverified dynamic output contract from documented response data')
|
|
462
771
|
.action(async (nodeId, opts, command) => {
|
|
463
772
|
const sample = readNodeOutputSample(opts.sampleFile);
|
|
464
773
|
const { api, workflowId } = requireContext(command);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
774
|
+
let result;
|
|
775
|
+
try {
|
|
776
|
+
result = await api.mutate(workflowId, () => api.tool(workflowId, 'declare_node_output_type', {
|
|
777
|
+
nodeId,
|
|
778
|
+
sample,
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
if (Array.isArray(sample) && error instanceof CaiError) {
|
|
783
|
+
throw new CaiError(`${error.message}\nArray samples require a newer backend with list-root output declaration support.`, {
|
|
784
|
+
exitCode: error.exitCode,
|
|
785
|
+
code: error.code,
|
|
786
|
+
detail: error.detail,
|
|
787
|
+
scrubbed: error.scrubbed,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
469
792
|
emit(result, () => {
|
|
470
793
|
printKeyValues([
|
|
471
794
|
['dataTypeName', result.dataTypeName],
|
|
@@ -484,7 +807,17 @@ export const registerNode = (program) => {
|
|
|
484
807
|
const { api, workflowId } = requireContext(command);
|
|
485
808
|
const data = await api.tool(workflowId, 'get_node_props', { nodeId });
|
|
486
809
|
const props = nodePropsFromPayload(data);
|
|
487
|
-
|
|
810
|
+
const hasAvailabilityMetadata = props.some(prop => Object.hasOwn(prop, 'optionsAvailability'));
|
|
811
|
+
emit(data, () => printTable([
|
|
812
|
+
'name',
|
|
813
|
+
'inputType',
|
|
814
|
+
'required',
|
|
815
|
+
'enabled',
|
|
816
|
+
'value',
|
|
817
|
+
'valueJs',
|
|
818
|
+
'expressionValueJs',
|
|
819
|
+
...(hasAvailabilityMetadata ? ['optionsAvailability', 'blockedBy'] : []),
|
|
820
|
+
], props.map(p => [
|
|
488
821
|
p.name ?? p.key,
|
|
489
822
|
p.inputType,
|
|
490
823
|
p.required ? 'yes' : '',
|
|
@@ -492,49 +825,99 @@ export const registerNode = (program) => {
|
|
|
492
825
|
truncate(p.value, 40),
|
|
493
826
|
truncate(p.valueJs, 40),
|
|
494
827
|
truncate(p.expressionValueJs, 40),
|
|
828
|
+
...(hasAvailabilityMetadata ? [p.optionsAvailability ?? '', p.blockedBy ?? ''] : []),
|
|
495
829
|
])));
|
|
496
830
|
});
|
|
831
|
+
node
|
|
832
|
+
.command('reload')
|
|
833
|
+
.argument('<nodeId>')
|
|
834
|
+
.description('Reload and reconcile dependent props, then report the readiness change')
|
|
835
|
+
.action(async (nodeId, _opts, command) => {
|
|
836
|
+
const { api, workflowId } = requireContext(command);
|
|
837
|
+
const before = await loadNodeReadiness(api, workflowId, nodeId);
|
|
838
|
+
let result;
|
|
839
|
+
try {
|
|
840
|
+
result = await api.mutate(workflowId, () => api.tool(workflowId, 'reload_node_props', { nodeId }));
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
if (isUnknownToolError(error)) {
|
|
844
|
+
const recoveryCommand = `cai node set ${nodeId} --prop <selector> --value <value>`;
|
|
845
|
+
throw userError('`cai node reload` is not supported by this backend. Re-set a selector value with ' +
|
|
846
|
+
`\`${recoveryCommand}\` to trigger the implicit reload.`, 'NODE_RELOAD_UNSUPPORTED');
|
|
847
|
+
}
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
requireCompletedExplicitPropReload(result, nodeId);
|
|
851
|
+
const after = await loadNodeReadiness(api, workflowId, nodeId);
|
|
852
|
+
const readinessDelta = nodeReadinessDelta(before.readiness, after.readiness);
|
|
853
|
+
const resultFields = result && typeof result === 'object' && !Array.isArray(result)
|
|
854
|
+
? result
|
|
855
|
+
: { result };
|
|
856
|
+
emit({ ...resultFields, readiness: after.readiness, readinessDelta }, () => printKeyValues([
|
|
857
|
+
['propsReloaded', resultFields.propsReloaded ?? 'not reported'],
|
|
858
|
+
['propsReconciled', resultFields.propsReconciled ?? 'not reported'],
|
|
859
|
+
...(resultFields.reconcileSkipped !== undefined
|
|
860
|
+
? [['reconcileSkipped', resultFields.reconcileSkipped]]
|
|
861
|
+
: []),
|
|
862
|
+
...(resultFields.note !== undefined
|
|
863
|
+
? [['note', resultFields.note]]
|
|
864
|
+
: []),
|
|
865
|
+
['readiness', `${readinessDelta.before} -> ${readinessDelta.after}`],
|
|
866
|
+
['blockers resolved', readinessDelta.blockersResolved.join(', ') || 'none'],
|
|
867
|
+
['blockers added', readinessDelta.blockersAdded.join(', ') || 'none'],
|
|
868
|
+
['next', after.readiness.next],
|
|
869
|
+
]));
|
|
870
|
+
});
|
|
497
871
|
node
|
|
498
872
|
.command('status')
|
|
499
873
|
.argument('<nodeId>')
|
|
500
874
|
.description('Return deterministic configuration readiness and exact blockers')
|
|
501
875
|
.action(async (nodeId, _opts, command) => {
|
|
502
876
|
const { api, workflowId } = requireContext(command);
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
loadProps(api, workflowId, nodeId),
|
|
506
|
-
]);
|
|
507
|
-
const resolvedRecord = await addResolvedOutputType(api, workflowId, record);
|
|
508
|
-
const readiness = buildNodeReadiness(nodeId, resolvedRecord, props);
|
|
509
|
-
const outputTypeDeclared = nodeProcessOutput(resolvedRecord)?.declared === true;
|
|
877
|
+
const { record, readiness } = await loadNodeReadiness(api, workflowId, nodeId);
|
|
878
|
+
const outputTypeDeclared = nodeProcessOutput(record)?.declared === true;
|
|
510
879
|
const payload = {
|
|
511
880
|
...readiness,
|
|
512
881
|
...(outputTypeDeclared ? { outputTypeDeclared: true } : {}),
|
|
513
882
|
};
|
|
514
|
-
emit(payload, () =>
|
|
515
|
-
[
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
883
|
+
emit(payload, () => {
|
|
884
|
+
printKeyValues([
|
|
885
|
+
['nodeId', nodeId],
|
|
886
|
+
[
|
|
887
|
+
'connection',
|
|
888
|
+
payload.missingConnection ? 'missing' : (payload.connectionId ?? 'not required'),
|
|
889
|
+
],
|
|
890
|
+
['required props', payload.missingRequiredProps.join(', ') || 'complete'],
|
|
891
|
+
[
|
|
892
|
+
'required disabled props',
|
|
893
|
+
payload.disabledRequiredProps.join(', ') || 'none',
|
|
894
|
+
],
|
|
895
|
+
['reload selectors', payload.pendingReloadProps.join(', ') || 'complete'],
|
|
896
|
+
[
|
|
897
|
+
'output type',
|
|
898
|
+
`${payload.outputTypeStatus}${payload.outputTypes.length
|
|
899
|
+
? ` (${payload.outputTypes
|
|
900
|
+
.map(type => renderNodeOutputType(type, outputTypeDeclared && payload.outputTypeSource === 'processOutput'))
|
|
901
|
+
.join(', ')})`
|
|
902
|
+
: ''} via ${payload.outputTypeSource}`,
|
|
903
|
+
],
|
|
904
|
+
['readiness state', payload.readinessState],
|
|
905
|
+
['blockers', payload.blockedBy.join(', ') || 'none'],
|
|
906
|
+
['ready for expressions', payload.readyForExpressions ? 'yes' : 'no'],
|
|
907
|
+
['ready to run', payload.readyForRun ? 'yes' : 'no'],
|
|
908
|
+
[
|
|
909
|
+
'ready for downstream expressions',
|
|
910
|
+
payload.readyForDownstreamExpressions ? 'yes' : 'no',
|
|
911
|
+
],
|
|
912
|
+
['next', payload.next],
|
|
913
|
+
]);
|
|
914
|
+
if (payload.dynamicSelectorNotes.length) {
|
|
915
|
+
process.stdout.write('\ndynamic selector notes\n');
|
|
916
|
+
for (const note of payload.dynamicSelectorNotes) {
|
|
917
|
+
process.stdout.write(` ${note}\n`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
});
|
|
538
921
|
});
|
|
539
922
|
node
|
|
540
923
|
.command('set')
|
|
@@ -555,32 +938,52 @@ export const registerNode = (program) => {
|
|
|
555
938
|
if (js !== undefined && opts.value !== undefined) {
|
|
556
939
|
throw userError('--value is mutually exclusive with --js and --js-file.', 'CONFLICTING_VALUE');
|
|
557
940
|
}
|
|
558
|
-
|
|
559
|
-
const
|
|
941
|
+
let compiledJs = js;
|
|
942
|
+
const prop = (await prepareWritableProp(api, workflowId, nodeId, opts.prop, candidate => {
|
|
943
|
+
if (compiledJs === undefined && String(candidate.inputType ?? '') === 'CUSTOM_DATA') {
|
|
944
|
+
const literal = parseLiteral(opts.value);
|
|
945
|
+
const supported = typeof literal === 'string' ||
|
|
946
|
+
typeof literal === 'boolean' ||
|
|
947
|
+
(typeof literal === 'number' && Number.isFinite(literal));
|
|
948
|
+
if (!supported) {
|
|
949
|
+
throw userError(`--value for CUSTOM_DATA only supports string, finite number, or boolean literals. ` +
|
|
950
|
+
'Use a typed --js expression for null, arrays, or objects; nothing was written.', 'CUSTOM_DATA_LITERAL_UNSUPPORTED');
|
|
951
|
+
}
|
|
952
|
+
compiledJs = JSON.stringify(literal);
|
|
953
|
+
}
|
|
954
|
+
if (compiledJs !== undefined) {
|
|
955
|
+
assertArrayExpressionWriteSupported(nodeId, opts.prop, candidate, undefined);
|
|
956
|
+
}
|
|
957
|
+
}));
|
|
560
958
|
if (opts.expression) {
|
|
561
959
|
warn('`--expression` is no longer needed; the CLI selects the correct storage channel.');
|
|
562
960
|
}
|
|
563
|
-
|
|
961
|
+
let validation;
|
|
962
|
+
if (compiledJs !== undefined) {
|
|
564
963
|
// Compile FIRST. update_node_prop would also compile, but validating as
|
|
565
964
|
// a separate read-only call guarantees a failed expression writes
|
|
566
965
|
// nothing and lets us print the compiler error verbatim.
|
|
567
|
-
|
|
568
|
-
js,
|
|
966
|
+
validation = await api.tool(workflowId, 'validate_expression', {
|
|
967
|
+
js: compiledJs,
|
|
569
968
|
nodeId,
|
|
570
969
|
propName: opts.prop,
|
|
571
970
|
});
|
|
572
971
|
if (validationFailed(validation)) {
|
|
573
972
|
throw userError(`Expression did not compile — nothing was written.\n\n${validationMessage(validation)}`, 'EXPRESSION_COMPILE_ERROR');
|
|
574
973
|
}
|
|
974
|
+
assertArrayExpressionWriteSupported(nodeId, opts.prop, prop, validation?.inferredType);
|
|
975
|
+
const replacementWarning = arrayExpressionReplacementWarning(opts.prop, prop);
|
|
976
|
+
if (replacementWarning)
|
|
977
|
+
warn(replacementWarning);
|
|
575
978
|
for (const w of validation?.warnings ?? [])
|
|
576
979
|
warn(String(w));
|
|
577
980
|
}
|
|
578
981
|
const payload = { nodeId, key: opts.prop };
|
|
579
|
-
if (
|
|
982
|
+
if (compiledJs !== undefined) {
|
|
580
983
|
// valueJs is intentionally the one public write channel. The backend
|
|
581
984
|
// resolves the prop's real compile target and routes the compiled value
|
|
582
985
|
// into value or expressionValue as appropriate.
|
|
583
|
-
payload.valueJs =
|
|
986
|
+
payload.valueJs = compiledJs;
|
|
584
987
|
}
|
|
585
988
|
else if (isTextExpressionBackedProp(prop)) {
|
|
586
989
|
// These props persist as a TextExpression wrapping the raw TEXT. The backend does
|
|
@@ -606,73 +1009,146 @@ export const registerNode = (program) => {
|
|
|
606
1009
|
// half-configured prop (CFG-004, CFG-012). When a provider does not index opaque ids,
|
|
607
1010
|
// caller-supplied --label is used only as the search query; the returned value must still
|
|
608
1011
|
// match before anything is persisted.
|
|
609
|
-
|
|
1012
|
+
let skippedDynamicOptionValidation = false;
|
|
1013
|
+
if (compiledJs === undefined) {
|
|
610
1014
|
const selected = Array.isArray(payload.value) ? payload.value : [payload.value];
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
value: String(option.value ?? option.id ?? ''),
|
|
617
|
-
label: String(option.label ?? option.name ?? option.value ?? option.id ?? ''),
|
|
618
|
-
})),
|
|
619
|
-
];
|
|
620
|
-
const options = [
|
|
621
|
-
...new Map(optionBatches.flat().map(option => [option.value, option])).values(),
|
|
622
|
-
];
|
|
623
|
-
if (prop.remoteOptions && options.length === 0) {
|
|
624
|
-
throw userError(`No live options were returned for ${opts.prop}. Check the connection and earlier ` +
|
|
625
|
-
'selector props. If you copied an opaque value from `cai node options`, also pass ' +
|
|
626
|
-
'its displayed label with `--label`; nothing was written.', 'OPTIONS_UNAVAILABLE');
|
|
1015
|
+
const metadataBlockedBy = typeof prop.blockedBy === 'string' && prop.blockedBy.trim()
|
|
1016
|
+
? prop.blockedBy.trim()
|
|
1017
|
+
: undefined;
|
|
1018
|
+
if (prop.optionsAvailability === 'blocked_upstream' && metadataBlockedBy) {
|
|
1019
|
+
throwBlockedUpstreamOption(nodeId, opts.prop, metadataBlockedBy);
|
|
627
1020
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1021
|
+
skippedDynamicOptionValidation = prop.optionsAvailability === 'dynamic_upstream';
|
|
1022
|
+
if (!skippedDynamicOptionValidation) {
|
|
1023
|
+
const queries = optionLookupQueries(selected, suppliedLabels);
|
|
1024
|
+
let fetched = [];
|
|
1025
|
+
if (prop.remoteOptions) {
|
|
1026
|
+
try {
|
|
1027
|
+
fetched = await Promise.all(queries.map(query => fetchPropOptions(api, workflowId, nodeId, opts.prop, query)));
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
const blocked = blockedUpstreamOptionMessage(error);
|
|
1031
|
+
if (blocked) {
|
|
1032
|
+
throwBlockedUpstreamOption(nodeId, blocked.propName, blocked.blockedBy);
|
|
1033
|
+
}
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
skippedDynamicOptionValidation = fetched.some(batch => dynamicUpstreamOptionError(batch.error));
|
|
1038
|
+
const optionBatches = prop.remoteOptions
|
|
1039
|
+
? fetched.map(batch => batch.options)
|
|
1040
|
+
: [
|
|
1041
|
+
(prop.options ?? []).map(option => ({
|
|
1042
|
+
value: String(option.value ?? option.id ?? ''),
|
|
1043
|
+
label: String(option.label ?? option.name ?? option.value ?? option.id ?? ''),
|
|
1044
|
+
})),
|
|
1045
|
+
];
|
|
1046
|
+
const options = [
|
|
1047
|
+
...new Map(optionBatches.flat().map(option => [option.value, option])).values(),
|
|
1048
|
+
];
|
|
1049
|
+
if (!skippedDynamicOptionValidation && prop.remoteOptions && options.length === 0) {
|
|
1050
|
+
throw userError(`No live options were returned for ${opts.prop}. Check the connection and earlier ` +
|
|
1051
|
+
'selector props. If you copied an opaque value from `cai node options`, also pass ' +
|
|
1052
|
+
'its displayed label with `--label`; nothing was written.', 'OPTIONS_UNAVAILABLE');
|
|
636
1053
|
}
|
|
637
|
-
if (
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1054
|
+
if (!skippedDynamicOptionValidation && options.length) {
|
|
1055
|
+
const labelFor = (value) => options.find(option => option.value === String(value));
|
|
1056
|
+
const unknown = selected.filter(value => !labelFor(value)).map(value => String(value));
|
|
1057
|
+
if (unknown.length) {
|
|
1058
|
+
throw userError(`${unknown.map(value => `"${value}"`).join(', ')} is not a valid option for ` +
|
|
1059
|
+
`${opts.prop}. Run \`cai node options ${nodeId} --prop ${opts.prop} --query ` +
|
|
1060
|
+
`"<display label>"\`, then retry with the exact returned --value and --label. ` +
|
|
1061
|
+
'Nothing was written.', 'UNKNOWN_OPTION');
|
|
1062
|
+
}
|
|
1063
|
+
if (isOptionBackedProp(prop)) {
|
|
1064
|
+
const resolved = selected.map(value => labelFor(value));
|
|
1065
|
+
payload.valueLabel = optionValueLabel(prop, resolved);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (skippedDynamicOptionValidation && prop.withLabel === true) {
|
|
1070
|
+
const canBuildValueLabel = !isMultiSelectProp(prop) ||
|
|
1071
|
+
(Array.isArray(payload.value) &&
|
|
1072
|
+
payload.value.every(value => typeof value === 'string' ||
|
|
1073
|
+
(typeof value === 'number' && Number.isFinite(value))));
|
|
1074
|
+
if (canBuildValueLabel) {
|
|
1075
|
+
const labels = optionLookupQueries(selected, suppliedLabels);
|
|
1076
|
+
payload.valueLabel = optionValueLabel(prop, selected.map((value, index) => ({
|
|
1077
|
+
value,
|
|
1078
|
+
label: labels[index],
|
|
1079
|
+
})));
|
|
642
1080
|
}
|
|
643
1081
|
}
|
|
644
1082
|
}
|
|
645
1083
|
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'update_node_prop', payload));
|
|
646
|
-
|
|
647
|
-
(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1084
|
+
if (skippedDynamicOptionValidation)
|
|
1085
|
+
warn(DYNAMIC_UPSTREAM_RAW_VALUE_NOTE);
|
|
1086
|
+
requireCompletedPropReload(result, nodeId, opts.prop);
|
|
1087
|
+
const readback = (await requireReadbackProp(api, workflowId, nodeId, opts.prop));
|
|
1088
|
+
let persistedChannel = 'value';
|
|
1089
|
+
let persistenceKind;
|
|
1090
|
+
let persistedValue;
|
|
1091
|
+
let note;
|
|
1092
|
+
if (compiledJs !== undefined) {
|
|
1093
|
+
const verification = verifyExpressionPersistence(prop, readback, validation?.expression);
|
|
1094
|
+
if (!verification.verified) {
|
|
1095
|
+
throw new CaiError(expressionPersistenceMismatchMessage(opts.prop, verification), {
|
|
1096
|
+
code: 'READBACK_MISMATCH',
|
|
1097
|
+
detail: {
|
|
1098
|
+
nodeId,
|
|
1099
|
+
prop: opts.prop,
|
|
1100
|
+
expected: verification.expected,
|
|
1101
|
+
observed: verification.observed,
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
persistedChannel = verification.channel;
|
|
1106
|
+
persistenceKind = verification.kind;
|
|
1107
|
+
persistedValue = verification.persistedValue;
|
|
1108
|
+
note = remoteOptionsCompiledLiteralNote(prop, verification);
|
|
1109
|
+
}
|
|
1110
|
+
if (compiledJs === undefined && readback?.value === undefined) {
|
|
660
1111
|
throw userError(`The write returned successfully but ${opts.prop}.value did not read back.`, 'READBACK_MISMATCH');
|
|
661
1112
|
}
|
|
1113
|
+
const mode = js !== undefined ? 'js' : compiledJs !== undefined ? 'literal (compiled)' : 'static';
|
|
1114
|
+
const humanMode = js !== undefined ? 'expression (compiled)' : mode;
|
|
1115
|
+
const source = js !== undefined
|
|
1116
|
+
? 'explicit --js'
|
|
1117
|
+
: compiledJs !== undefined
|
|
1118
|
+
? 'synthesized from --value'
|
|
1119
|
+
: 'explicit --value';
|
|
1120
|
+
const rawDynamicSelectorNote = result && typeof result === 'object' && !Array.isArray(result)
|
|
1121
|
+
? result.dynamicSelectorNote
|
|
1122
|
+
: undefined;
|
|
1123
|
+
const dynamicSelectorNote = typeof rawDynamicSelectorNote === 'string' && rawDynamicSelectorNote.trim()
|
|
1124
|
+
? rawDynamicSelectorNote
|
|
1125
|
+
: undefined;
|
|
662
1126
|
emit({
|
|
663
1127
|
nodeId,
|
|
664
1128
|
prop: opts.prop,
|
|
665
|
-
mode
|
|
1129
|
+
mode,
|
|
1130
|
+
source,
|
|
666
1131
|
persistedChannel,
|
|
1132
|
+
...(persistenceKind ? { kind: persistenceKind, persistedValue } : {}),
|
|
1133
|
+
...(note ? { note } : {}),
|
|
1134
|
+
...(dynamicSelectorNote ? { dynamicSelectorNote } : {}),
|
|
667
1135
|
readback,
|
|
668
1136
|
result,
|
|
669
|
-
}, () =>
|
|
670
|
-
[
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1137
|
+
}, () => {
|
|
1138
|
+
printKeyValues([
|
|
1139
|
+
['nodeId', nodeId],
|
|
1140
|
+
['prop', opts.prop],
|
|
1141
|
+
['mode', humanMode],
|
|
1142
|
+
['source', source],
|
|
1143
|
+
['persisted channel', persistedChannel],
|
|
1144
|
+
...(persistenceKind ? [['kind', persistenceKind]] : []),
|
|
1145
|
+
['verified', 'yes'],
|
|
1146
|
+
]);
|
|
1147
|
+
if (note)
|
|
1148
|
+
process.stdout.write(`\n${note}\n`);
|
|
1149
|
+
if (dynamicSelectorNote)
|
|
1150
|
+
process.stdout.write(`\n${dynamicSelectorNote}\n`);
|
|
1151
|
+
});
|
|
676
1152
|
});
|
|
677
1153
|
node
|
|
678
1154
|
.command('set-connection')
|
|
@@ -785,6 +1261,59 @@ export const registerNode = (program) => {
|
|
|
785
1261
|
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'enable_optional_prop', { nodeId, propName: opts.prop }));
|
|
786
1262
|
emit(result, () => printKeyValues([['enabled', `${nodeId}.${opts.prop}`]]));
|
|
787
1263
|
});
|
|
1264
|
+
node
|
|
1265
|
+
.command('disable-prop')
|
|
1266
|
+
.argument('<nodeId>')
|
|
1267
|
+
.requiredOption('--prop <name>', 'enabled optional prop key from `cai node props`')
|
|
1268
|
+
.description('Disable an optional prop. Its stored value is removed when present; a reload prop may prune dependent props')
|
|
1269
|
+
.action(async (nodeId, opts, command) => {
|
|
1270
|
+
const { api, workflowId } = requireContext(command);
|
|
1271
|
+
let result;
|
|
1272
|
+
try {
|
|
1273
|
+
result = await api.mutate(workflowId, () => api.tool(workflowId, 'disable_optional_prop', { nodeId, propName: opts.prop }));
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
if (isUnknownToolError(error)) {
|
|
1277
|
+
throw new CaiError(`${error.message}\nThis backend predates disable_optional_prop. Disable ` +
|
|
1278
|
+
`"${opts.prop}" on ${nodeId} in the builder instead.`, {
|
|
1279
|
+
exitCode: error.exitCode,
|
|
1280
|
+
code: error.code,
|
|
1281
|
+
detail: error.detail,
|
|
1282
|
+
scrubbed: error.scrubbed,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
1287
|
+
const { props } = await loadProps(api, workflowId, nodeId);
|
|
1288
|
+
const readback = props.find(row => (row.name ?? row.key) === opts.prop);
|
|
1289
|
+
const disabled = readback === undefined || readback.source === 'propsCatalog' || readback.enabled === false;
|
|
1290
|
+
if (!disabled) {
|
|
1291
|
+
throw new CaiError(`Prop "${opts.prop}" did not read back as disabled after the backend accepted the ` +
|
|
1292
|
+
'request; its stored value may still be present. ' +
|
|
1293
|
+
`Inspect \`cai node props ${nodeId} --json\` before continuing.`, {
|
|
1294
|
+
code: 'READBACK_MISMATCH',
|
|
1295
|
+
detail: { nodeId, prop: opts.prop, readback },
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
const resultFields = result && typeof result === 'object' && !Array.isArray(result)
|
|
1299
|
+
? result
|
|
1300
|
+
: { result };
|
|
1301
|
+
emit({
|
|
1302
|
+
...resultFields,
|
|
1303
|
+
nodeId,
|
|
1304
|
+
prop: opts.prop,
|
|
1305
|
+
enabled: false,
|
|
1306
|
+
verified: true,
|
|
1307
|
+
readback: readback ?? null,
|
|
1308
|
+
}, () => printKeyValues([
|
|
1309
|
+
['disabled', `${nodeId}.${opts.prop}`],
|
|
1310
|
+
[
|
|
1311
|
+
'stored value',
|
|
1312
|
+
resultFields.storedValueRemoved === true ? 'removed' : 'no stored value existed',
|
|
1313
|
+
],
|
|
1314
|
+
['verified', 'yes'],
|
|
1315
|
+
]));
|
|
1316
|
+
});
|
|
788
1317
|
node
|
|
789
1318
|
.command('options')
|
|
790
1319
|
.argument('<nodeId>')
|
|
@@ -792,18 +1321,62 @@ export const registerNode = (program) => {
|
|
|
792
1321
|
// keep in sync with fetchPropOptions() below, which reuses this tool
|
|
793
1322
|
.option('--query <q>', 'search term — strongly preferred over paging')
|
|
794
1323
|
.option('--page <n>', '0-based page when not searching')
|
|
1324
|
+
.option('--prev-context <json>', 'pagination context returned by a previous options response')
|
|
1325
|
+
.option('--for <prop>=<value>', 'override an earlier prop value for option resolution; JSON literals are accepted, bare text is passed as a string; repeatable', collectNodeOptionOverride)
|
|
795
1326
|
.description('Resolved remote dropdown options for a prop')
|
|
796
1327
|
.action(async (nodeId, opts, command) => {
|
|
797
|
-
const { api, workflowId } = requireContext(command);
|
|
798
1328
|
const page = parseNodeOptionPage(opts.page);
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1329
|
+
const prevContext = opts.prevContext !== undefined
|
|
1330
|
+
? parseNodeOptionsPrevContext(opts.prevContext)
|
|
1331
|
+
: undefined;
|
|
1332
|
+
const formValueOverrides = parseNodeOptionOverrides(opts.for ?? []);
|
|
1333
|
+
const { api, workflowId } = requireContext(command);
|
|
1334
|
+
let data;
|
|
1335
|
+
try {
|
|
1336
|
+
data = await api.tool(workflowId, 'get_prop_options', {
|
|
1337
|
+
nodeId,
|
|
1338
|
+
propName: opts.prop,
|
|
1339
|
+
...(opts.query ? { query: opts.query } : {}),
|
|
1340
|
+
...(page !== undefined ? { page } : {}),
|
|
1341
|
+
...(prevContext ? { prevContext } : {}),
|
|
1342
|
+
...(Object.keys(formValueOverrides).length ? { formValueOverrides } : {}),
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
const blocked = blockedUpstreamOptionMessage(error);
|
|
1347
|
+
if (blocked) {
|
|
1348
|
+
throwBlockedUpstreamOption(nodeId, blocked.propName, blocked.blockedBy);
|
|
1349
|
+
}
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
const dynamicUpstreamNext = dynamicUpstreamOptionError(data?.error)
|
|
1353
|
+
? dynamicUpstreamOptionNext(nodeId, opts.prop)
|
|
1354
|
+
: undefined;
|
|
1355
|
+
const outputData = dynamicUpstreamNext && data && typeof data === 'object' && !Array.isArray(data)
|
|
1356
|
+
? { ...data, next: dynamicUpstreamNext }
|
|
1357
|
+
: data;
|
|
805
1358
|
const rows = data?.options ?? (Array.isArray(data) ? data : []);
|
|
806
|
-
emit(
|
|
1359
|
+
emit(outputData, () => {
|
|
1360
|
+
if (!Array.isArray(data) && typeof data?.error === 'string' && data.error.trim()) {
|
|
1361
|
+
printKeyValues([
|
|
1362
|
+
['error', data.error],
|
|
1363
|
+
...(dynamicUpstreamNext
|
|
1364
|
+
? [['next', dynamicUpstreamNext]]
|
|
1365
|
+
: []),
|
|
1366
|
+
]);
|
|
1367
|
+
process.stdout.write('\n');
|
|
1368
|
+
}
|
|
1369
|
+
printTable(['value', 'label'], rows.map(o => [o.value ?? o.id, o.label ?? o.name]));
|
|
1370
|
+
if (!Array.isArray(data) &&
|
|
1371
|
+
data &&
|
|
1372
|
+
typeof data === 'object' &&
|
|
1373
|
+
Object.hasOwn(data, 'context') &&
|
|
1374
|
+
data.context !== undefined &&
|
|
1375
|
+
data.context !== null) {
|
|
1376
|
+
process.stdout.write(`\ncontext ${JSON.stringify(data.context)}\n`);
|
|
1377
|
+
process.stdout.write('Pass this context back with --prev-context to continue.\n');
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
807
1380
|
});
|
|
808
1381
|
node
|
|
809
1382
|
.command('continue-on-error')
|