@controllerai/cli 0.2.13 → 0.2.15
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/README.md +13 -4
- package/dist/commands/connection.js +98 -13
- package/dist/commands/connection.js.map +1 -1
- package/dist/commands/data.js +26 -15
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/exec.js +284 -133
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/expr.js +89 -5
- package/dist/commands/expr.js.map +1 -1
- package/dist/commands/integration.js +170 -49
- package/dist/commands/integration.js.map +1 -1
- package/dist/commands/node.js +11 -287
- package/dist/commands/node.js.map +1 -1
- package/dist/commands/organize.js +182 -219
- package/dist/commands/organize.js.map +1 -1
- package/dist/commands/run.js +342 -77
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/signup.js +10 -7
- package/dist/commands/signup.js.map +1 -1
- package/dist/commands/trigger.js +14 -8
- package/dist/commands/trigger.js.map +1 -1
- package/dist/commands/workflow.js +203 -61
- package/dist/commands/workflow.js.map +1 -1
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/nodeProps.js +1 -1
- package/dist/nodeProps.js.map +1 -1
- package/package.json +3 -3
- package/skills/controllerai-agents/SKILL.md +27 -19
- package/skills/controllerai-agents/references/instructions-and-versioning.md +2 -0
- package/skills/controllerai-ai-nodes/SKILL.md +10 -4
- package/skills/controllerai-ai-nodes/references/selection-and-testing.md +2 -0
- package/skills/controllerai-data/SKILL.md +18 -6
- package/skills/controllerai-data/references/data-rails.md +4 -2
- package/skills/controllerai-expressions/SKILL.md +24 -4
- package/skills/controllerai-expressions/references/dsl-and-recipes.md +5 -1
- package/skills/controllerai-flow-control/SKILL.md +12 -3
- package/skills/controllerai-flow-control/references/patterns.md +4 -0
- package/skills/controllerai-integration-actions/SKILL.md +21 -11
- package/skills/controllerai-node-config/SKILL.md +31 -8
- package/skills/controllerai-node-config/references/prop-and-readiness.md +9 -3
- package/skills/controllerai-runs/SKILL.md +21 -9
- package/skills/controllerai-runs/references/debugging.md +11 -7
- package/skills/controllerai-start/SKILL.md +30 -7
- package/skills/controllerai-start/references/mcp-tool-map.md +170 -0
- package/skills/controllerai-triggers/SKILL.md +14 -6
- package/skills/controllerai-triggers/references/lifecycle.md +5 -3
- package/skills/controllerai-workflows/SKILL.md +17 -9
- package/skills/controllerai-workflows/references/command-map.md +21 -6
- package/skills/manifest.json +79 -73
package/dist/commands/run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { appBaseUrl, runUrl } from '../appLinks.js';
|
|
3
3
|
import { requireContext } from '../context.js';
|
|
4
4
|
import { CaiError, userError } from '../errors.js';
|
|
@@ -55,35 +55,93 @@ const adoptObservedOutputs = async (api, workflowId, targets) => {
|
|
|
55
55
|
return results;
|
|
56
56
|
};
|
|
57
57
|
/**
|
|
58
|
-
* Test-input
|
|
58
|
+
* Test-input schema. The wire contract the backend wants is
|
|
59
59
|
* a flat `{ [inputId]: value }` map, so both the flat form and the documented
|
|
60
60
|
* `{nodeInputs:[{id,testValue}], flowInputs:[…]}` form are accepted and
|
|
61
|
-
* normalised here.
|
|
61
|
+
* normalised here. The source may be inline JSON, a file path, or stdin.
|
|
62
62
|
*/
|
|
63
|
-
export const readTestValues = (
|
|
64
|
-
|
|
63
|
+
export const readTestValues = (source) => {
|
|
64
|
+
let raw;
|
|
65
|
+
let sourceKind;
|
|
66
|
+
if (source === '-') {
|
|
67
|
+
sourceKind = 'stdin';
|
|
68
|
+
try {
|
|
69
|
+
raw = readFileSync(0, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw userError(`--inputs could not read stdin: ${error instanceof Error ? error.message : error}`, 'UNREADABLE_INPUTS');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (existsSync(source)) {
|
|
76
|
+
// Existing paths win even when their names look like JSON (for example a
|
|
77
|
+
// file literally named `{}`). This keeps path-vs-inline parsing deterministic.
|
|
78
|
+
sourceKind = 'file';
|
|
79
|
+
try {
|
|
80
|
+
raw = readFileSync(source, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw userError(`--inputs file "${source}" could not be read: ${error instanceof Error ? error.message : error}`, 'UNREADABLE_INPUTS');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (isBrokenSymlink(source)) {
|
|
87
|
+
throw userError(`--inputs path "${source}" exists but is not a readable file (broken symlink?).`, 'UNREADABLE_INPUTS');
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
sourceKind = 'inline';
|
|
91
|
+
raw = source;
|
|
92
|
+
}
|
|
65
93
|
let parsed;
|
|
66
94
|
try {
|
|
67
95
|
parsed = JSON.parse(raw);
|
|
68
96
|
}
|
|
69
97
|
catch (error) {
|
|
70
|
-
|
|
98
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
99
|
+
throw userError(sourceKind === 'inline'
|
|
100
|
+
? `--inputs "${source}" is not an existing file, and not valid JSON: ${detail}`
|
|
101
|
+
: `--inputs ${sourceKind === 'stdin' ? 'from stdin' : `file "${source}"`} is not valid JSON: ${detail}`, 'BAD_INPUTS_JSON');
|
|
71
102
|
}
|
|
72
103
|
if (Array.isArray(parsed)) {
|
|
73
104
|
throw userError('--inputs must be an object, not an array.', 'BAD_INPUTS_SHAPE');
|
|
74
105
|
}
|
|
75
|
-
if (parsed
|
|
106
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
107
|
+
throw userError('--inputs must be a JSON object.', 'BAD_INPUTS_SHAPE');
|
|
108
|
+
}
|
|
109
|
+
const hasNodeInputs = Object.hasOwn(parsed, 'nodeInputs');
|
|
110
|
+
const hasFlowInputs = Object.hasOwn(parsed, 'flowInputs');
|
|
111
|
+
if (hasNodeInputs || hasFlowInputs) {
|
|
112
|
+
if (hasNodeInputs && !Array.isArray(parsed.nodeInputs)) {
|
|
113
|
+
throw userError('`nodeInputs` must be an array.', 'BAD_INPUTS_SHAPE');
|
|
114
|
+
}
|
|
115
|
+
if (hasFlowInputs && !Array.isArray(parsed.flowInputs)) {
|
|
116
|
+
throw userError('`flowInputs` must be an array.', 'BAD_INPUTS_SHAPE');
|
|
117
|
+
}
|
|
76
118
|
const out = {};
|
|
77
119
|
for (const entry of [...(parsed.nodeInputs ?? []), ...(parsed.flowInputs ?? [])]) {
|
|
78
120
|
if (!entry?.id) {
|
|
79
121
|
throw userError('Each nodeInputs/flowInputs entry needs an `id`.', 'BAD_INPUTS_SHAPE');
|
|
80
122
|
}
|
|
81
|
-
|
|
123
|
+
// Define an own data property so an opaque id such as "__proto__" cannot
|
|
124
|
+
// invoke Object.prototype's legacy setter and disappear from preflight.
|
|
125
|
+
Object.defineProperty(out, String(entry.id), {
|
|
126
|
+
value: entry.testValue,
|
|
127
|
+
enumerable: true,
|
|
128
|
+
configurable: true,
|
|
129
|
+
writable: true,
|
|
130
|
+
});
|
|
82
131
|
}
|
|
83
132
|
return out;
|
|
84
133
|
}
|
|
85
134
|
return parsed;
|
|
86
135
|
};
|
|
136
|
+
/** `existsSync` follows symlinks, so retain a useful error for a dangling one. */
|
|
137
|
+
const isBrokenSymlink = (source) => {
|
|
138
|
+
try {
|
|
139
|
+
return lstatSync(source).isSymbolicLink();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
87
145
|
/**
|
|
88
146
|
* `run_node_test` / `run_flow_test` are ASYNC: they return
|
|
89
147
|
* `{status:'started', workflowExecutionId}` and nothing else — no output, no
|
|
@@ -93,24 +151,41 @@ export const readTestValues = (path) => {
|
|
|
93
151
|
/** Inline JSON, a file path, or `-` for stdin — shared by the probe options. */
|
|
94
152
|
export const readJsonArg = (source, flag) => {
|
|
95
153
|
let raw;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
154
|
+
let sourceKind;
|
|
155
|
+
if (source === '-') {
|
|
156
|
+
sourceKind = 'stdin';
|
|
157
|
+
try {
|
|
158
|
+
raw = readFileSync(0, 'utf8');
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
throw userError(`${flag} could not read stdin: ${error instanceof Error ? error.message : error}`, 'UNREADABLE_JSON_ARG');
|
|
162
|
+
}
|
|
103
163
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
164
|
+
else if (existsSync(source)) {
|
|
165
|
+
sourceKind = 'file';
|
|
166
|
+
try {
|
|
167
|
+
raw = readFileSync(source, 'utf8');
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw userError(`${flag} file "${source}" could not be read: ${error instanceof Error ? error.message : error}`, 'UNREADABLE_JSON_ARG');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (isBrokenSymlink(source)) {
|
|
174
|
+
throw userError(`${flag} path "${source}" exists but is not a readable file (broken symlink?).`, 'UNREADABLE_JSON_ARG');
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
sourceKind = 'inline';
|
|
178
|
+
raw = source;
|
|
107
179
|
}
|
|
108
180
|
let parsed;
|
|
109
181
|
try {
|
|
110
182
|
parsed = JSON.parse(raw);
|
|
111
183
|
}
|
|
112
184
|
catch (error) {
|
|
113
|
-
|
|
185
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
186
|
+
throw userError(sourceKind === 'inline'
|
|
187
|
+
? `${flag} "${source}" is not an existing file, and not valid JSON: ${detail}`
|
|
188
|
+
: `${flag} ${sourceKind === 'stdin' ? 'from stdin' : `file "${source}"`} is not valid JSON: ${detail}`, 'BAD_JSON_ARG');
|
|
114
189
|
}
|
|
115
190
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
116
191
|
throw userError(`${flag} must be a JSON object.`, 'BAD_JSON_ARG_SHAPE');
|
|
@@ -127,6 +202,177 @@ export const testInputRows = (data) => {
|
|
|
127
202
|
return [...(data?.nodeInputs ?? []), ...(data?.flowInputs ?? [])];
|
|
128
203
|
};
|
|
129
204
|
const ISOLATED_FLOW_CONTEXT_REQUIRED = 'ISOLATED_FLOW_CONTEXT_REQUIRED';
|
|
205
|
+
/** Mirror the expression context's display-name-to-JS-binding transform. */
|
|
206
|
+
const camelCaseBindingName = (value) => {
|
|
207
|
+
const cleaned = String(value ?? '')
|
|
208
|
+
.replace(/[^a-zA-Z0-9]+(.)/g, (_, char) => char.toUpperCase())
|
|
209
|
+
.replace(/[^a-zA-Z0-9]/g, '');
|
|
210
|
+
return cleaned ? cleaned[0].toLowerCase() + cleaned.slice(1) : 'x';
|
|
211
|
+
};
|
|
212
|
+
const exactInputAliasKey = (key) => `exact:${key}`;
|
|
213
|
+
const displayInputAliasKey = (key) => `display:${key.trim().toLowerCase()}`;
|
|
214
|
+
const flowInputDisplayName = (row) => String(row?.displayName || row?.name || '');
|
|
215
|
+
/**
|
|
216
|
+
* Exact CLI port of `uniqueBindings` in
|
|
217
|
+
* controllerai-be/src/modules/workflowAgent/services/expressionJs/context.ts.
|
|
218
|
+
* The backend implementation is the source of truth; these repositories do
|
|
219
|
+
* not share this helper.
|
|
220
|
+
* Binding allocation is ordered and list-wide; never call this for one input
|
|
221
|
+
* at a time or collision suffixes will disagree with `cai expr context`.
|
|
222
|
+
*/
|
|
223
|
+
const allocateFlowInputBindings = (inputRows) => {
|
|
224
|
+
const used = new Set();
|
|
225
|
+
const nextSuffix = new Map();
|
|
226
|
+
return inputRows.map(row => {
|
|
227
|
+
const id = String(row.id);
|
|
228
|
+
const displayName = flowInputDisplayName(row);
|
|
229
|
+
const baseName = camelCaseBindingName(displayName);
|
|
230
|
+
let binding = baseName;
|
|
231
|
+
if (used.has(binding)) {
|
|
232
|
+
let suffix = nextSuffix.get(baseName) ?? 2;
|
|
233
|
+
while (used.has(`${baseName}${suffix}`))
|
|
234
|
+
suffix += 1;
|
|
235
|
+
binding = `${baseName}${suffix}`;
|
|
236
|
+
nextSuffix.set(baseName, suffix + 1);
|
|
237
|
+
}
|
|
238
|
+
used.add(binding);
|
|
239
|
+
return { id, displayName, row, binding };
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
const addFlowInputAlias = (index, key, source, matchedBy) => {
|
|
243
|
+
const candidates = index.get(key) ?? new Map();
|
|
244
|
+
const candidate = candidates.get(source.id) ?? { ...source, matchedBy: new Set() };
|
|
245
|
+
candidate.matchedBy.add(matchedBy);
|
|
246
|
+
candidates.set(source.id, candidate);
|
|
247
|
+
index.set(key, candidates);
|
|
248
|
+
};
|
|
249
|
+
const buildFlowInputAliasTable = (inputRows, includeNonOpaqueAliases) => {
|
|
250
|
+
const sources = allocateFlowInputBindings(inputRows);
|
|
251
|
+
const table = {
|
|
252
|
+
sources,
|
|
253
|
+
aliases: new Map(),
|
|
254
|
+
};
|
|
255
|
+
for (const source of sources) {
|
|
256
|
+
addFlowInputAlias(table.aliases, exactInputAliasKey(source.id), source, 'opaque id');
|
|
257
|
+
if (!includeNonOpaqueAliases)
|
|
258
|
+
continue;
|
|
259
|
+
const storedName = typeof source.row?.name === 'string' ? source.row.name : '';
|
|
260
|
+
if (storedName) {
|
|
261
|
+
addFlowInputAlias(table.aliases, exactInputAliasKey(storedName), source, 'stored name');
|
|
262
|
+
addFlowInputAlias(table.aliases, displayInputAliasKey(storedName), source, 'stored name');
|
|
263
|
+
}
|
|
264
|
+
if (source.displayName.trim()) {
|
|
265
|
+
addFlowInputAlias(table.aliases, displayInputAliasKey(source.displayName), source, 'display name');
|
|
266
|
+
}
|
|
267
|
+
addFlowInputAlias(table.aliases, exactInputAliasKey(source.binding), source, 'camelCase JS binding');
|
|
268
|
+
}
|
|
269
|
+
return table;
|
|
270
|
+
};
|
|
271
|
+
const mergeFlowInputCandidates = (merged, candidates) => {
|
|
272
|
+
for (const candidate of candidates?.values() ?? []) {
|
|
273
|
+
const existing = merged.get(candidate.id) ?? {
|
|
274
|
+
...candidate,
|
|
275
|
+
matchedBy: new Set(),
|
|
276
|
+
};
|
|
277
|
+
for (const kind of candidate.matchedBy)
|
|
278
|
+
existing.matchedBy.add(kind);
|
|
279
|
+
merged.set(candidate.id, existing);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const FLOW_INPUT_ALIAS_KIND_ORDER = [
|
|
283
|
+
'opaque id',
|
|
284
|
+
'stored name',
|
|
285
|
+
'display name',
|
|
286
|
+
'camelCase JS binding',
|
|
287
|
+
];
|
|
288
|
+
const matchedByDescription = (candidate) => FLOW_INPUT_ALIAS_KIND_ORDER.filter(kind => candidate.matchedBy.has(kind)).join(' and ');
|
|
289
|
+
const DEV_FLOW_INPUT_ALIAS_GUARD = 'Display-name and camelCase binding aliases are disabled for this entire dev input list ' +
|
|
290
|
+
'because get_flow_test_inputs returned an input with an empty displayName and omits stored ' +
|
|
291
|
+
"names, so the compiler's list-wide binding allocation cannot be reconstructed safely. " +
|
|
292
|
+
'Pass exact opaque input ids only.';
|
|
293
|
+
const resolveFlowInputKeys = (inputRows, testValues, target) => {
|
|
294
|
+
// Current dev rows omit `name`; retaining the fallback here lets the backend
|
|
295
|
+
// contract fix restore safe aliases without changing binding allocation.
|
|
296
|
+
const devAliasesDisabled = target === 'dev' && inputRows.some(row => !row?.displayName && !row?.name);
|
|
297
|
+
const aliases = buildFlowInputAliasTable(inputRows, !devAliasesDisabled);
|
|
298
|
+
const knownIds = new Set(aliases.sources.map(source => source.id));
|
|
299
|
+
if (devAliasesDisabled)
|
|
300
|
+
warn(DEV_FLOW_INPUT_ALIAS_GUARD);
|
|
301
|
+
for (const key of Object.keys(testValues)) {
|
|
302
|
+
// Structured input JSON can represent an omitted testValue as an own
|
|
303
|
+
// property whose value is undefined. Node runs deliberately preserve that
|
|
304
|
+
// distinction for their isolated-context preflight; flow runs must reject
|
|
305
|
+
// it before JSON serialization drops the key and allows a server fallback.
|
|
306
|
+
if (testValues[key] === undefined) {
|
|
307
|
+
throw userError(`Input key "${key}" has no value. Flow inputs require a testValue; ` +
|
|
308
|
+
'explicit null, "", false, and 0 are valid.', 'BAD_INPUTS_SHAPE');
|
|
309
|
+
}
|
|
310
|
+
// The live server resolves opaque ids first. Mirror that precedence before
|
|
311
|
+
// considering any CLI alias namespace so an id can never become ambiguous.
|
|
312
|
+
if (knownIds.has(key))
|
|
313
|
+
continue;
|
|
314
|
+
// Stored names and bindings are exact. Stored names and display names also
|
|
315
|
+
// retain the CLI's trimmed, case-insensitive convenience form.
|
|
316
|
+
const candidatesById = new Map();
|
|
317
|
+
mergeFlowInputCandidates(candidatesById, aliases.aliases.get(exactInputAliasKey(key)));
|
|
318
|
+
mergeFlowInputCandidates(candidatesById, aliases.aliases.get(displayInputAliasKey(key)));
|
|
319
|
+
let candidates = [...candidatesById.values()];
|
|
320
|
+
if (candidates.length > 1 &&
|
|
321
|
+
!candidates.some(candidate => candidate.matchedBy.has('opaque id'))) {
|
|
322
|
+
// Flow mode returns every declared input, including unused inputs. When
|
|
323
|
+
// exactly one non-id alias collision is used by the flow, it is the only
|
|
324
|
+
// candidate whose value can affect this run.
|
|
325
|
+
const usedCandidates = candidates.filter(candidate => candidate.row?.used !== false);
|
|
326
|
+
if (usedCandidates.length === 1)
|
|
327
|
+
candidates = usedCandidates;
|
|
328
|
+
}
|
|
329
|
+
if (candidates.length > 1) {
|
|
330
|
+
const candidateList = candidates
|
|
331
|
+
.map(candidate => `${candidate.id} (${candidate.displayName || 'unnamed'}; ` +
|
|
332
|
+
`matched by ${matchedByDescription(candidate)})`)
|
|
333
|
+
.join(', ');
|
|
334
|
+
throw userError(`Ambiguous input key "${key}" matches multiple flow inputs across accepted aliases: ` +
|
|
335
|
+
`${candidateList}. Pass the exact opaque id for the intended input.`, 'UNKNOWN_INPUT_KEY');
|
|
336
|
+
}
|
|
337
|
+
if (candidates.length === 1) {
|
|
338
|
+
const resolved = candidates[0];
|
|
339
|
+
if (key === resolved.id)
|
|
340
|
+
continue;
|
|
341
|
+
if (Object.prototype.hasOwnProperty.call(testValues, resolved.id)) {
|
|
342
|
+
throw userError(`Input key "${key}" resolves to flow input ${resolved.id} ` +
|
|
343
|
+
`(${resolved.displayName || 'unnamed'}), but that input was supplied more than once. ` +
|
|
344
|
+
'Pass each input once using its opaque id.', 'UNKNOWN_INPUT_KEY');
|
|
345
|
+
}
|
|
346
|
+
// Opaque ids are normally generated, but imported legacy state can carry
|
|
347
|
+
// any string. Define an own property so "__proto__" cannot swallow a
|
|
348
|
+
// remapped value through Object.prototype's legacy setter.
|
|
349
|
+
Object.defineProperty(testValues, resolved.id, {
|
|
350
|
+
value: testValues[key],
|
|
351
|
+
enumerable: true,
|
|
352
|
+
configurable: true,
|
|
353
|
+
writable: true,
|
|
354
|
+
});
|
|
355
|
+
delete testValues[key];
|
|
356
|
+
warn(`Input key "${key}" matched flow input ${resolved.id} ` +
|
|
357
|
+
`(${resolved.displayName || 'unnamed'}) by ${matchedByDescription(resolved)} — using the id.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const unknown = Object.keys(testValues).filter(key => !knownIds.has(key));
|
|
361
|
+
if (unknown.length > 0) {
|
|
362
|
+
const known = aliases.sources
|
|
363
|
+
.map(source => devAliasesDisabled
|
|
364
|
+
? source.id
|
|
365
|
+
: `${source.id} (${source.displayName || 'unnamed'}; binding ${source.binding})`)
|
|
366
|
+
.join(', ');
|
|
367
|
+
throw userError(`Unknown input key${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. ` +
|
|
368
|
+
(devAliasesDisabled
|
|
369
|
+
? `This flow's opaque input ids: ${known || '(none)'}. ${DEV_FLOW_INPUT_ALIAS_GUARD} `
|
|
370
|
+
: `This flow's inputs: ${known || '(none)'}. Pass an opaque id, stored name, an ` +
|
|
371
|
+
'unambiguous display name, or its exact camelCase JS binding. ') +
|
|
372
|
+
`Without a match the run would ` +
|
|
373
|
+
`silently use the saved test values instead of yours.`, 'UNKNOWN_INPUT_KEY');
|
|
374
|
+
}
|
|
375
|
+
};
|
|
130
376
|
const missingNodeFlowInputs = (testInputs, testValues) => {
|
|
131
377
|
const flowInputs = Array.isArray(testInputs?.flowInputs) ? testInputs.flowInputs : [];
|
|
132
378
|
return flowInputs.flatMap((input) => {
|
|
@@ -209,6 +455,71 @@ const developmentWorkflowVersionId = async (api, workflowId) => {
|
|
|
209
455
|
? versionId
|
|
210
456
|
: null;
|
|
211
457
|
};
|
|
458
|
+
const LIVE_INPUT_SCHEMA_UNAVAILABLE = 'LIVE_INPUT_SCHEMA_UNAVAILABLE';
|
|
459
|
+
const isMissingLiveInputSchemaRoute = (error) => {
|
|
460
|
+
if (!(error instanceof CaiError))
|
|
461
|
+
return false;
|
|
462
|
+
if (error.code === 'ROUTE_NOT_FOUND')
|
|
463
|
+
return true;
|
|
464
|
+
if (error.httpStatus !== 404)
|
|
465
|
+
return false;
|
|
466
|
+
const routeMessage = error.message.replace(/^404\s+/, '');
|
|
467
|
+
return (/^(?:not found|cannot\s+[A-Z]+\b)/i.test(routeMessage) ||
|
|
468
|
+
/(?:route|endpoint).*not found|not found.*(?:route|endpoint)/i.test(error.message));
|
|
469
|
+
};
|
|
470
|
+
/**
|
|
471
|
+
* The live endpoint accepts exact input id/name/displayName keys, then
|
|
472
|
+
* normalizes them to the stored input name (controllerai-be
|
|
473
|
+
* workflow/services/flowInputValidation.ts). Read the published rail here so
|
|
474
|
+
* CLI-only JS binding aliases can be resolved to accepted opaque IDs without
|
|
475
|
+
* ever borrowing a newer development input list.
|
|
476
|
+
*/
|
|
477
|
+
const readLiveFlowInputRows = async (api, workflowId, flowId) => {
|
|
478
|
+
try {
|
|
479
|
+
const versions = (await api.rest(`/workflow/${workflowId}/versions`)) ?? [];
|
|
480
|
+
const liveVersion = versions.find(version => version?.isLiveVersion === true &&
|
|
481
|
+
typeof version.id === 'number' &&
|
|
482
|
+
Number.isSafeInteger(version.id) &&
|
|
483
|
+
version.id > 0);
|
|
484
|
+
if (!liveVersion?.id) {
|
|
485
|
+
throw userError('Cannot resolve --inputs for --target live because this workflow has no published live ' +
|
|
486
|
+
'version. Publish it first with `cai workflow publish`.', LIVE_INPUT_SCHEMA_UNAVAILABLE);
|
|
487
|
+
}
|
|
488
|
+
const state = await api.rest(`/workflow-version/${liveVersion.id}/states/latest`);
|
|
489
|
+
if (typeof state?.json !== 'string') {
|
|
490
|
+
throw userError(`Cannot resolve --inputs for --target live because published version ${liveVersion.id} ` +
|
|
491
|
+
'returned no readable state document.', LIVE_INPUT_SCHEMA_UNAVAILABLE);
|
|
492
|
+
}
|
|
493
|
+
let document;
|
|
494
|
+
try {
|
|
495
|
+
document = JSON.parse(state.json);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
throw userError(`Cannot resolve --inputs for --target live because published version ${liveVersion.id} ` +
|
|
499
|
+
`has invalid state JSON: ${error instanceof Error ? error.message : error}`, LIVE_INPUT_SCHEMA_UNAVAILABLE);
|
|
500
|
+
}
|
|
501
|
+
const flow = (Array.isArray(document?.flows) ? document.flows : []).find((candidate) => candidate?.id === flowId);
|
|
502
|
+
if (!flow) {
|
|
503
|
+
throw userError(`Cannot resolve --inputs for --target live because flow "${flowId}" is not in ` +
|
|
504
|
+
`published version ${liveVersion.id}. Publish the flow before running it live.`, LIVE_INPUT_SCHEMA_UNAVAILABLE);
|
|
505
|
+
}
|
|
506
|
+
if (!Array.isArray(flow.flowInputs)) {
|
|
507
|
+
throw userError(`Cannot resolve --inputs for --target live because flow "${flowId}" has no readable ` +
|
|
508
|
+
`published input list in version ${liveVersion.id}.`, LIVE_INPUT_SCHEMA_UNAVAILABLE);
|
|
509
|
+
}
|
|
510
|
+
return flow.flowInputs;
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (error instanceof CaiError && error.code === LIVE_INPUT_SCHEMA_UNAVAILABLE)
|
|
514
|
+
throw error;
|
|
515
|
+
if (isMissingLiveInputSchemaRoute(error)) {
|
|
516
|
+
throw userError(`Cannot safely resolve --inputs aliases for --target live from the published workflow: ` +
|
|
517
|
+
`${error.message}. The live endpoint accepts exact input ids, stored names, or display ` +
|
|
518
|
+
'names; this backend must be upgraded because the published-state read route is unavailable.', LIVE_INPUT_SCHEMA_UNAVAILABLE);
|
|
519
|
+
}
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
212
523
|
const TERMINAL = new Set(['completed', 'completed_with_error', 'failed', 'cancelled']);
|
|
213
524
|
export const isTerminal = (status) => typeof status === 'string' && TERMINAL.has(status.toLowerCase());
|
|
214
525
|
/** Reject invalid polling budgets before an execution can be started. */
|
|
@@ -314,7 +625,7 @@ export const registerRun = (program) => {
|
|
|
314
625
|
run
|
|
315
626
|
.command('node')
|
|
316
627
|
.argument('<nodeId>')
|
|
317
|
-
.option('--inputs <file|->', 'JSON
|
|
628
|
+
.option('--inputs <json|file|->', 'inline JSON or a JSON file; `-` reads stdin')
|
|
318
629
|
.option('--timeout <ms>', 'how long to wait for the run to finish', '120000')
|
|
319
630
|
.description('Run one node in isolation on the dev version (real external action); always surfaces resolvedConfigs')
|
|
320
631
|
.action(async (nodeId, opts, command) => {
|
|
@@ -409,7 +720,7 @@ export const registerRun = (program) => {
|
|
|
409
720
|
run
|
|
410
721
|
.command('flow')
|
|
411
722
|
.argument('<flowId>')
|
|
412
|
-
.option('--inputs <file|->', 'JSON
|
|
723
|
+
.option('--inputs <json|file|->', 'inline JSON or a JSON file; `-` reads stdin')
|
|
413
724
|
.option('--target <dev|live>', 'DEV draft or LIVE published workflow', 'dev')
|
|
414
725
|
.option('--confirm-live', 'acknowledge LIVE run side effects and usage', false)
|
|
415
726
|
.option('--timeout <ms>', 'how long to wait for the run to finish', '300000')
|
|
@@ -423,6 +734,15 @@ export const registerRun = (program) => {
|
|
|
423
734
|
'Dev isolation covers the data store only.');
|
|
424
735
|
}
|
|
425
736
|
const testValues = opts.inputs ? readTestValues(opts.inputs) : {};
|
|
737
|
+
// Resolve every accepted key form through one combined alias table before
|
|
738
|
+
// dispatch. This catches collisions across opaque ids, display names, and
|
|
739
|
+
// compiler bindings instead of allowing one namespace to shadow another.
|
|
740
|
+
if (Object.keys(testValues).length > 0) {
|
|
741
|
+
const inputRows = target === 'live'
|
|
742
|
+
? await readLiveFlowInputRows(api, workflowId, flowId)
|
|
743
|
+
: testInputRows(await api.tool(workflowId, 'get_flow_test_inputs', { flowId }));
|
|
744
|
+
resolveFlowInputKeys(inputRows, testValues, target);
|
|
745
|
+
}
|
|
426
746
|
let started;
|
|
427
747
|
if (target === 'live') {
|
|
428
748
|
warn('LIVE run executes the published workflow: external actions fire and usage is spent.');
|
|
@@ -444,61 +764,6 @@ export const registerRun = (program) => {
|
|
|
444
764
|
}
|
|
445
765
|
}
|
|
446
766
|
else {
|
|
447
|
-
// Fail on unknown input keys BEFORE running. A mistyped key (or a JS
|
|
448
|
-
// binding passed where the opaque input id belongs) used to fall
|
|
449
|
-
// through silently to the flow's saved test value — a real run against
|
|
450
|
-
// the wrong data that still looks green (dogfood round 3, S4).
|
|
451
|
-
if (Object.keys(testValues).length > 0) {
|
|
452
|
-
const inputRows = testInputRows(await api.tool(workflowId, 'get_flow_test_inputs', { flowId }));
|
|
453
|
-
const knownIds = new Set(inputRows.map(row => String(row.id)));
|
|
454
|
-
// Convenience remap: a key that misses every opaque id but exactly
|
|
455
|
-
// matches ONE input's display name is resolved to that id. Agents
|
|
456
|
-
// naturally write `name=Ada`; without this every first run fails and
|
|
457
|
-
// they retry-loop until they discover the opaque-id rule. Ambiguous
|
|
458
|
-
// labels still fall through to the fail-closed unknown-key error.
|
|
459
|
-
const idsByLabel = new Map();
|
|
460
|
-
for (const row of inputRows) {
|
|
461
|
-
for (const label of [row.displayName, row.name]) {
|
|
462
|
-
if (typeof label === 'string' && label.trim()) {
|
|
463
|
-
const key = label.trim().toLowerCase();
|
|
464
|
-
const ids = idsByLabel.get(key) ?? new Set();
|
|
465
|
-
ids.add(String(row.id));
|
|
466
|
-
idsByLabel.set(key, ids);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
for (const key of Object.keys(testValues)) {
|
|
471
|
-
if (knownIds.has(key))
|
|
472
|
-
continue;
|
|
473
|
-
let candidates = [...(idsByLabel.get(key.trim().toLowerCase()) ?? [])];
|
|
474
|
-
if (candidates.length > 1) {
|
|
475
|
-
// Flow mode now returns EVERY declared input, so a label can
|
|
476
|
-
// collide across the used/unused boundary. Only used inputs feed
|
|
477
|
-
// the run — when exactly one candidate is used, it wins.
|
|
478
|
-
const usedIds = new Set(inputRows
|
|
479
|
-
.filter(row => row.used !== false)
|
|
480
|
-
.map(row => String(row.id)));
|
|
481
|
-
const usedCandidates = candidates.filter(id => usedIds.has(id));
|
|
482
|
-
if (usedCandidates.length === 1)
|
|
483
|
-
candidates = usedCandidates;
|
|
484
|
-
}
|
|
485
|
-
if (candidates.length === 1 && !(candidates[0] in testValues)) {
|
|
486
|
-
testValues[candidates[0]] = testValues[key];
|
|
487
|
-
delete testValues[key];
|
|
488
|
-
warn(`Input key "${key}" matched flow input ${candidates[0]} by display name — using the id.`);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
const unknown = Object.keys(testValues).filter(key => !knownIds.has(key));
|
|
492
|
-
if (unknown.length > 0) {
|
|
493
|
-
const known = inputRows
|
|
494
|
-
.map(row => `${row.id} (${row.displayName ?? row.name ?? 'unnamed'})`)
|
|
495
|
-
.join(', ');
|
|
496
|
-
throw userError(`Unknown input key${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. ` +
|
|
497
|
-
`This flow's input ids: ${known || '(none)'} — pass the opaque id, not the ` +
|
|
498
|
-
`display name or JS binding. Without a match the run would silently use the ` +
|
|
499
|
-
`saved test values instead of yours.`, 'UNKNOWN_INPUT_KEY');
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
767
|
started = await api.tool(workflowId, 'run_flow_test', { flowId, testValues });
|
|
503
768
|
}
|
|
504
769
|
const workflowExecutionId = findWorkflowExecutionId(started);
|
|
@@ -582,7 +847,7 @@ export const registerRun = (program) => {
|
|
|
582
847
|
run
|
|
583
848
|
.command('discover')
|
|
584
849
|
.requiredOption('--node <slug>', 'integration node slug')
|
|
585
|
-
.requiredOption('--key <componentKey>', '
|
|
850
|
+
.requiredOption('--key <componentKey>', 'exact action key from `cai integration actions`')
|
|
586
851
|
.requiredOption('--connection <id|none>', 'connection id, or "none" for a component that needs no auth')
|
|
587
852
|
.option('--props <json|file|->', 'prop values, same per-prop shape as `node add`')
|
|
588
853
|
.option('--enable <prop>', 'optional prop to enable first; repeatable', (value, previous = []) => [...previous, value])
|