@farmslot/recipe-cli 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -11
- package/dist/harness/catalog.d.ts +71 -0
- package/dist/harness/catalog.d.ts.map +1 -0
- package/dist/harness/catalog.js +280 -0
- package/dist/harness/catalog.js.map +1 -0
- package/dist/harness/commands/call.d.ts +18 -0
- package/dist/harness/commands/call.d.ts.map +1 -0
- package/dist/harness/commands/call.js +663 -0
- package/dist/harness/commands/call.js.map +1 -0
- package/dist/harness/commands/discover.d.ts +29 -0
- package/dist/harness/commands/discover.d.ts.map +1 -0
- package/dist/harness/commands/discover.js +563 -0
- package/dist/harness/commands/discover.js.map +1 -0
- package/dist/harness/commands/run.d.ts +40 -0
- package/dist/harness/commands/run.d.ts.map +1 -0
- package/dist/harness/commands/run.js +727 -0
- package/dist/harness/commands/run.js.map +1 -0
- package/dist/harness/heal-bounds.d.ts +8 -0
- package/dist/harness/heal-bounds.d.ts.map +1 -1
- package/dist/harness/heal-bounds.js +10 -0
- package/dist/harness/heal-bounds.js.map +1 -1
- package/dist/harness/host.d.ts +5 -0
- package/dist/harness/host.d.ts.map +1 -1
- package/dist/harness/host.js +7 -0
- package/dist/harness/host.js.map +1 -1
- package/dist/harness/index.d.ts +8 -0
- package/dist/harness/index.d.ts.map +1 -1
- package/dist/harness/index.js +8 -0
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/network-observation.d.ts +19 -0
- package/dist/harness/network-observation.d.ts.map +1 -0
- package/dist/harness/network-observation.js +202 -0
- package/dist/harness/network-observation.js.map +1 -0
- package/dist/harness/recipe-library.d.ts +65 -0
- package/dist/harness/recipe-library.d.ts.map +1 -0
- package/dist/harness/recipe-library.js +182 -0
- package/dist/harness/recipe-library.js.map +1 -0
- package/dist/harness/recipe-validation.d.ts +27 -0
- package/dist/harness/recipe-validation.d.ts.map +1 -0
- package/dist/harness/recipe-validation.js +249 -0
- package/dist/harness/recipe-validation.js.map +1 -0
- package/dist/harness/run-engine.d.ts +79 -0
- package/dist/harness/run-engine.d.ts.map +1 -0
- package/dist/harness/run-engine.js +632 -0
- package/dist/harness/run-engine.js.map +1 -0
- package/dist/harness/run-observers.d.ts +13 -0
- package/dist/harness/run-observers.d.ts.map +1 -0
- package/dist/harness/run-observers.js +21 -0
- package/dist/harness/run-observers.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
// call — run one action through the real engine path: resolve the action name,
|
|
2
|
+
// synthesize a one-node recipe, validate it, and execute it with bounded
|
|
3
|
+
// healing and observation.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { getRecipeActionManifestActionNames } from '@farmslot/protocol';
|
|
8
|
+
import { fuzzyResolveActions, resolveActionCapabilityRefusal } from '../../action-catalog.js';
|
|
9
|
+
import { adapterPortFlags, harnessAdapter } from '../adapters.js';
|
|
10
|
+
import { actionExampleCommand, actionLibraryContextArgs, describeManifestActions, renderActionDetail, resolveActionCapabilityMatrix, } from '../catalog.js';
|
|
11
|
+
import { acquireCheckoutLock } from '../checkout-lock.js';
|
|
12
|
+
import { color } from '../cli-color.js';
|
|
13
|
+
import { isSensitiveKey, recordCommandEvidence, redactStructuredValue, } from '../command-journal.js';
|
|
14
|
+
import { ProvenanceDriftError } from '../execution-provenance.js';
|
|
15
|
+
import { conciseFailureForHuman, recipeRunning, recipeRunningRefusal } from '../heal-bounds.js';
|
|
16
|
+
import { harnessHost, invokedHostCommand } from '../host.js';
|
|
17
|
+
import { isRecord, optionFlag, optionString, parseArgs, resolveAdapter, shellQuote, usageError, } from '../parse-args.js';
|
|
18
|
+
import { resolveCommandManifest } from '../recipe-library.js';
|
|
19
|
+
import { validateRecipeAdapterAware } from '../recipe-validation.js';
|
|
20
|
+
import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument, } from '../run-diagnostics.js';
|
|
21
|
+
import { emitHealViolation, executeWithHealBounds, persistRunEffects, preflightRecipe, prepareHeal, runRecipe, synthesizeOneNodeRecipe, } from '../run-engine.js';
|
|
22
|
+
import { startRunObservers } from '../run-observers.js';
|
|
23
|
+
import { recipeRunOptionsFromCli } from '../run-options.js';
|
|
24
|
+
import { checkoutBusyOut, EXIT, usageOut } from '../shared.js';
|
|
25
|
+
import { closest } from '../suggest.js';
|
|
26
|
+
import { recipeTrustFailure } from '../trust.js';
|
|
27
|
+
import { handleListExecutables } from './discover.js';
|
|
28
|
+
import { provenanceFailure, reportTrustFailure } from './run.js';
|
|
29
|
+
export async function handleCall(argv, commandOptions) {
|
|
30
|
+
const { engine } = commandOptions;
|
|
31
|
+
const host = harnessHost().name;
|
|
32
|
+
// --list: actions accepted by `call` for the detected adapter (no <action>
|
|
33
|
+
// required). Intercept before the "action first" grammar check.
|
|
34
|
+
if (argv.includes('--list')) {
|
|
35
|
+
const { options } = parseArgs(argv);
|
|
36
|
+
return handleListExecutables('call', options, { catalog: engine });
|
|
37
|
+
}
|
|
38
|
+
if (argv.length > 0 && argv[0].startsWith('--')) {
|
|
39
|
+
// The public wrapper catches this with the structured grammar. Keep the
|
|
40
|
+
// guard for direct callers: without it, parseCallArgs can mistake an
|
|
41
|
+
// option value (for example `core`) for the action.
|
|
42
|
+
const message = `call requires <action> first: ${host} call <action> [key=value ...] [--arg k=v ...] [flags]`;
|
|
43
|
+
console.error(message);
|
|
44
|
+
return EXIT.usage;
|
|
45
|
+
}
|
|
46
|
+
const { action: shortName, args, rest } = parseCallArgs(argv);
|
|
47
|
+
const { options } = parseArgs(rest);
|
|
48
|
+
if (['domain', 'source', 'sort'].some((name) => optionString(options, name) !== undefined)) {
|
|
49
|
+
throw usageError('--domain, --source, and --sort require call --list.');
|
|
50
|
+
}
|
|
51
|
+
const json = optionFlag(options, 'json');
|
|
52
|
+
if (!shortName) {
|
|
53
|
+
let example = `${host} call <action>`;
|
|
54
|
+
let discovery = `${host} actions`;
|
|
55
|
+
try {
|
|
56
|
+
const { adapter } = resolveAdapter(options);
|
|
57
|
+
const { manifest } = await resolveCommandManifest(engine, adapter, options);
|
|
58
|
+
const names = getRecipeActionManifestActionNames(manifest);
|
|
59
|
+
const exampleAction = commandOptions.exampleAction?.(names) ??
|
|
60
|
+
(names.includes('command') ? 'command' : (names[0] ?? 'command'));
|
|
61
|
+
example = `${host} call ${exampleAction} --adapter ${adapter}`;
|
|
62
|
+
discovery = `${host} actions --adapter ${adapter}`;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* adapter/manifest unavailable — keep the generic example */
|
|
66
|
+
}
|
|
67
|
+
const message = `call requires <action>. Example: ${example}`;
|
|
68
|
+
const userAction = `${example} # see the vocabulary: ${discovery}`;
|
|
69
|
+
if (json)
|
|
70
|
+
console.log(JSON.stringify({
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
command: 'call',
|
|
73
|
+
status: 'fail',
|
|
74
|
+
error: { code: 'CLI_MISSING_POSITIONAL', message, userAction },
|
|
75
|
+
exitCode: EXIT.usage,
|
|
76
|
+
}, null, 2));
|
|
77
|
+
else
|
|
78
|
+
console.error(`${message}\n See the vocabulary: ${discovery}`);
|
|
79
|
+
return EXIT.usage;
|
|
80
|
+
}
|
|
81
|
+
const { adapter, target } = resolveAdapter(options);
|
|
82
|
+
if (!fs.existsSync(target)) {
|
|
83
|
+
return usageOut(json, 'call', `target does not exist: ${target}`, `pass --target <${harnessHost().product.toLowerCase()}-checkout> pointing to an existing checkout`);
|
|
84
|
+
}
|
|
85
|
+
harnessAdapter(adapter).resolveSlotPorts(target);
|
|
86
|
+
// Resolve/gate the device target before the engine reads process.env.
|
|
87
|
+
const device = commandOptions.targetDevice?.('call', adapter, options);
|
|
88
|
+
if (device && !device.ok) {
|
|
89
|
+
if (json)
|
|
90
|
+
console.log(JSON.stringify({
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
command: 'call',
|
|
93
|
+
adapter,
|
|
94
|
+
error: { code: device.code, message: device.message, userAction: device.userAction },
|
|
95
|
+
}, null, 2));
|
|
96
|
+
else
|
|
97
|
+
console.error(`✗ call: ${device.message}\n Next: ${device.userAction}`);
|
|
98
|
+
return EXIT.usage;
|
|
99
|
+
}
|
|
100
|
+
if (recipeRunning(target)) {
|
|
101
|
+
const { message: msg, userAction } = recipeRunningRefusal(shellQuote(target));
|
|
102
|
+
if (json) {
|
|
103
|
+
console.log(JSON.stringify({
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
command: 'call',
|
|
106
|
+
status: 'fail',
|
|
107
|
+
recoverable: false,
|
|
108
|
+
error: { code: 'RECIPE_RUNNING', message: msg, userAction },
|
|
109
|
+
}, null, 2));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
console.error(`✗ ${host} call: ${msg}\n Next: ${userAction}`);
|
|
113
|
+
}
|
|
114
|
+
return EXIT.bounded;
|
|
115
|
+
}
|
|
116
|
+
const actionManifestOverride = optionString(options, 'actionManifest');
|
|
117
|
+
const { manifest, actionSources, librarySources } = await resolveCommandManifest(engine, adapter, options);
|
|
118
|
+
const names = getRecipeActionManifestActionNames(manifest);
|
|
119
|
+
const resolution = resolveActionName(shortName, names);
|
|
120
|
+
if (resolution.status === 'unknown') {
|
|
121
|
+
const refusal = actionManifestOverride
|
|
122
|
+
? undefined
|
|
123
|
+
: resolveActionCapabilityRefusal(shortName, adapter, await resolveActionCapabilityMatrix(engine, librarySources));
|
|
124
|
+
if (refusal) {
|
|
125
|
+
const satisfying = refusal.satisfyingAdapters.join(', ');
|
|
126
|
+
const message = `missing action capability "${refusal.capability}" for the ${adapter} adapter.`;
|
|
127
|
+
const userAction = `Satisfying adapters for "${refusal.capability}": ${satisfying}. ` +
|
|
128
|
+
`Inspect: ${host} actions --matrix ` +
|
|
129
|
+
`--action ${shellQuote(refusal.capability)}` +
|
|
130
|
+
`${actionLibraryContextArgs(engine, librarySources)} --json`;
|
|
131
|
+
const error = {
|
|
132
|
+
code: 'ACTION_CAPABILITY_UNAVAILABLE',
|
|
133
|
+
message,
|
|
134
|
+
capability: refusal.capability,
|
|
135
|
+
satisfyingAdapters: refusal.satisfyingAdapters,
|
|
136
|
+
userAction,
|
|
137
|
+
};
|
|
138
|
+
if (json) {
|
|
139
|
+
console.log(JSON.stringify({
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
command: 'call',
|
|
142
|
+
adapter,
|
|
143
|
+
action: shortName,
|
|
144
|
+
error,
|
|
145
|
+
}, null, 2));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.error(`✗ call: ${message}\n Next: ${userAction}`);
|
|
149
|
+
}
|
|
150
|
+
return EXIT.usage;
|
|
151
|
+
}
|
|
152
|
+
const message = `unknown action "${shortName}" for the ${adapter} adapter.`;
|
|
153
|
+
const userAction = `${host} actions --adapter ${adapter} --json`;
|
|
154
|
+
if (json)
|
|
155
|
+
console.log(JSON.stringify({
|
|
156
|
+
schemaVersion: 1,
|
|
157
|
+
command: 'call',
|
|
158
|
+
adapter,
|
|
159
|
+
action: shortName,
|
|
160
|
+
error: { code: 'ACTION_UNKNOWN', message, userAction },
|
|
161
|
+
}, null, 2));
|
|
162
|
+
else
|
|
163
|
+
console.error(`✗ call: ${message}\n Next: ${userAction}`);
|
|
164
|
+
return EXIT.usage;
|
|
165
|
+
}
|
|
166
|
+
if (resolution.status === 'ambiguous') {
|
|
167
|
+
const message = `"${shortName}" is ambiguous: ${resolution.candidates.join(', ')} — use the full name.`;
|
|
168
|
+
const userAction = `${host} actions --action ${resolution.candidates[0]} --adapter ${adapter} --json`;
|
|
169
|
+
if (json)
|
|
170
|
+
console.log(JSON.stringify({
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
command: 'call',
|
|
173
|
+
adapter,
|
|
174
|
+
action: shortName,
|
|
175
|
+
error: {
|
|
176
|
+
code: 'ACTION_AMBIGUOUS',
|
|
177
|
+
message,
|
|
178
|
+
candidates: resolution.candidates,
|
|
179
|
+
userAction,
|
|
180
|
+
},
|
|
181
|
+
}, null, 2));
|
|
182
|
+
else
|
|
183
|
+
console.error(`✗ call: ${message}\n Next: ${userAction}`);
|
|
184
|
+
return EXIT.usage;
|
|
185
|
+
}
|
|
186
|
+
const resolvedAction = resolution.resolved;
|
|
187
|
+
const describedAction = describeManifestActions(engine, manifest, actionSources).find((entry) => entry.name === resolvedAction);
|
|
188
|
+
const defaultsUsed = actionDefaultsUsed(describedAction, args);
|
|
189
|
+
const depsBlock = (await harnessAdapter(adapter).run?.dependencyBlock?.(target, { action: resolvedAction })) ??
|
|
190
|
+
null;
|
|
191
|
+
if (depsBlock) {
|
|
192
|
+
if (json) {
|
|
193
|
+
console.log(JSON.stringify({
|
|
194
|
+
schemaVersion: 1,
|
|
195
|
+
command: 'call',
|
|
196
|
+
adapter,
|
|
197
|
+
status: 'fail',
|
|
198
|
+
exitCode: EXIT.usage,
|
|
199
|
+
error: depsBlock,
|
|
200
|
+
}, null, 2));
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
console.error(`✗ call: ${depsBlock.message}`);
|
|
204
|
+
console.error(` Next: ${depsBlock.userAction}`);
|
|
205
|
+
}
|
|
206
|
+
return EXIT.usage;
|
|
207
|
+
}
|
|
208
|
+
const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
|
|
209
|
+
const validation = await validateRecipeAdapterAware(adapter, recipe, manifest, librarySources);
|
|
210
|
+
if (validation.status === 'invalid') {
|
|
211
|
+
const message = `call ${resolvedAction}: recipe validation failed`;
|
|
212
|
+
const parameterHelp = parameterValidationHelp(describedAction, validation.findings, args);
|
|
213
|
+
const actionsCommand = `${host} actions --action ${resolvedAction} --adapter ${adapter}`;
|
|
214
|
+
const userAction = describedAction
|
|
215
|
+
? (actionExampleCommand(describedAction, adapter, target, invokedHostCommand(), args) ??
|
|
216
|
+
actionsCommand)
|
|
217
|
+
: actionsCommand;
|
|
218
|
+
if (json)
|
|
219
|
+
console.log(JSON.stringify({
|
|
220
|
+
schemaVersion: 1,
|
|
221
|
+
command: 'call',
|
|
222
|
+
adapter,
|
|
223
|
+
action: shortName,
|
|
224
|
+
resolvedAction,
|
|
225
|
+
args: redactCallValue(args),
|
|
226
|
+
findings: validation.findings,
|
|
227
|
+
...(parameterHelp.length > 0 ? { parameterHelp } : {}),
|
|
228
|
+
error: { code: 'RECIPE_VALIDATION_FAILED', message, userAction },
|
|
229
|
+
}, null, 2));
|
|
230
|
+
else {
|
|
231
|
+
console.error(`✗ ${message}`);
|
|
232
|
+
const taught = new Set(parameterHelp.map((entry) => `${entry.issue}:${entry.name}`));
|
|
233
|
+
for (const entry of parameterHelp)
|
|
234
|
+
console.error(renderParameterValidationHelp(entry));
|
|
235
|
+
for (const finding of validation.findings) {
|
|
236
|
+
const name = finding.path.split('.').at(-1);
|
|
237
|
+
const issue = parameterIssue(finding.code);
|
|
238
|
+
if (issue && name && taught.has(`${issue}:${name}`))
|
|
239
|
+
continue;
|
|
240
|
+
console.error(` ${finding.code} ${finding.path} — ${finding.message}`);
|
|
241
|
+
}
|
|
242
|
+
console.error(` Next: ${userAction}`);
|
|
243
|
+
}
|
|
244
|
+
return EXIT.validation;
|
|
245
|
+
}
|
|
246
|
+
const artifactsDir = optionString(options, 'artifactsDir') ?? defaultCallArtifactsDir(target, resolvedAction);
|
|
247
|
+
recordCommandEvidence(artifactsDir);
|
|
248
|
+
const requestedRuntimeOptions = recipeRunOptionsFromCli(adapter, options);
|
|
249
|
+
const inheritedSource = process.env.FARMSLOT_RECIPE_SOURCE_TRUST ||
|
|
250
|
+
process.env.FARMSLOT_RECIPE_SOURCE_KIND ||
|
|
251
|
+
process.env.FARMSLOT_RECIPE_SOURCE_NAME ||
|
|
252
|
+
process.env.FARMSLOT_RECIPE_SOURCE_DIGEST;
|
|
253
|
+
let observers;
|
|
254
|
+
const callRuntimeOptions = {
|
|
255
|
+
...requestedRuntimeOptions,
|
|
256
|
+
cli: options,
|
|
257
|
+
librarySources,
|
|
258
|
+
autoHud: false,
|
|
259
|
+
suppressLibraryResolutionLogs: true,
|
|
260
|
+
stdoutIsMachineContract: json,
|
|
261
|
+
onActionEvent: ({ nodeId, action, status }) => {
|
|
262
|
+
observers?.onActionEvent({ nodeId, action, status });
|
|
263
|
+
},
|
|
264
|
+
...(requestedRuntimeOptions.source
|
|
265
|
+
? { source: requestedRuntimeOptions.source }
|
|
266
|
+
: inheritedSource
|
|
267
|
+
? {}
|
|
268
|
+
: {
|
|
269
|
+
source: {
|
|
270
|
+
kind: 'operator',
|
|
271
|
+
trust: 'trusted',
|
|
272
|
+
name: `${host} call`,
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
let preflightedExecution;
|
|
277
|
+
try {
|
|
278
|
+
preflightedExecution = await preflightRecipe(engine, adapter, recipe, artifactsDir, target, actionManifestOverride, callRuntimeOptions);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
const trustFailure = recipeTrustFailure(error);
|
|
282
|
+
if (!trustFailure)
|
|
283
|
+
throw error;
|
|
284
|
+
const planDigest = trustFailure.details?.recipeDigest;
|
|
285
|
+
if (planDigest) {
|
|
286
|
+
trustFailure.userAction =
|
|
287
|
+
`review the plan, then rerun this call with --artifacts-dir ${shellQuote(artifactsDir)} ` +
|
|
288
|
+
`--approve-plan ${shellQuote(planDigest)}; managed callers must keep the same artifact ` +
|
|
289
|
+
`directory and execution environment, then set ` +
|
|
290
|
+
`FARMSLOT_RECIPE_APPROVE_PLAN=${shellQuote(planDigest)}`;
|
|
291
|
+
}
|
|
292
|
+
reportTrustFailure('call', trustFailure, json);
|
|
293
|
+
return EXIT.validation;
|
|
294
|
+
}
|
|
295
|
+
const lock = acquireCheckoutLock(target, 'call');
|
|
296
|
+
if ('message' in lock) {
|
|
297
|
+
return checkoutBusyOut(json, 'call', lock.message, lock.path);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const prepared = await prepareHeal(adapter, target, options, json, {
|
|
301
|
+
appRestartAuthored: resolvedAction === 'app.lifecycle' && args.command === 'restart',
|
|
302
|
+
});
|
|
303
|
+
if (typeof prepared === 'number')
|
|
304
|
+
return prepared;
|
|
305
|
+
const { state } = prepared;
|
|
306
|
+
preflightedExecution = await preflightRecipe(engine, adapter, recipe, artifactsDir, target, actionManifestOverride, callRuntimeOptions);
|
|
307
|
+
const ports = {
|
|
308
|
+
cdpPort: callRuntimeOptions.cdpPort,
|
|
309
|
+
watcherPort: callRuntimeOptions.watcherPort,
|
|
310
|
+
};
|
|
311
|
+
const started = await startRunObservers(adapter, target, artifactsDir, ports);
|
|
312
|
+
observers = started;
|
|
313
|
+
let executionResult;
|
|
314
|
+
try {
|
|
315
|
+
executionResult = await executeWithHealBounds(() => {
|
|
316
|
+
const execution = preflightedExecution;
|
|
317
|
+
preflightedExecution = undefined;
|
|
318
|
+
return runRecipe(engine, adapter, recipe, artifactsDir, target, actionManifestOverride, callRuntimeOptions, execution, state);
|
|
319
|
+
}, target, state);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
await started.abandon();
|
|
323
|
+
observers = undefined;
|
|
324
|
+
if (error instanceof ProvenanceDriftError) {
|
|
325
|
+
const failure = provenanceFailure(error);
|
|
326
|
+
if (json) {
|
|
327
|
+
console.log(JSON.stringify({
|
|
328
|
+
schemaVersion: 1,
|
|
329
|
+
command: 'call',
|
|
330
|
+
adapter,
|
|
331
|
+
action: shortName,
|
|
332
|
+
resolvedAction,
|
|
333
|
+
status: 'fail',
|
|
334
|
+
error: failure,
|
|
335
|
+
exitCode: error.exitCode,
|
|
336
|
+
}, null, 2));
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
console.error(`✗ ${host} call: ${error.message}`);
|
|
340
|
+
console.error(` Next: ${error.userAction}`);
|
|
341
|
+
}
|
|
342
|
+
return error.exitCode;
|
|
343
|
+
}
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
const { result, violation } = executionResult;
|
|
347
|
+
await started.finalize(result.artifactManifestPath);
|
|
348
|
+
observers = undefined;
|
|
349
|
+
persistRunEffects(result.summaryPath, result.artifactManifestPath, state);
|
|
350
|
+
if (violation !== null) {
|
|
351
|
+
const conciseFailure = violation.originalError
|
|
352
|
+
? conciseFailureForHuman(violation.originalError)
|
|
353
|
+
: '';
|
|
354
|
+
const taughtViolation = violation.code === 'APP_LOGIC_FAILURE' &&
|
|
355
|
+
conciseFailure.includes(' requires ') &&
|
|
356
|
+
describedAction
|
|
357
|
+
? {
|
|
358
|
+
...violation,
|
|
359
|
+
userAction: `${host} actions ${resolvedAction} --adapter ${adapter} --json`,
|
|
360
|
+
}
|
|
361
|
+
: violation;
|
|
362
|
+
return emitHealViolation(json, 'call', result, taughtViolation, state, adapter);
|
|
363
|
+
}
|
|
364
|
+
const callOutput = readCallOutput(result.tracePath);
|
|
365
|
+
const safeArgs = redactCallValue(args);
|
|
366
|
+
const safeCallOutput = redactCallValue(callOutput);
|
|
367
|
+
const failureUserAction = `${host} last --target ${shellQuote(target)} --json`;
|
|
368
|
+
if (json) {
|
|
369
|
+
console.log(JSON.stringify({
|
|
370
|
+
schemaVersion: 1,
|
|
371
|
+
command: 'call',
|
|
372
|
+
adapter,
|
|
373
|
+
action: shortName,
|
|
374
|
+
resolvedAction,
|
|
375
|
+
args: safeArgs,
|
|
376
|
+
...(Object.keys(defaultsUsed).length > 0 ? { defaultsUsed } : {}),
|
|
377
|
+
status: result.status,
|
|
378
|
+
summaryPath: result.summaryPath,
|
|
379
|
+
tracePath: result.tracePath,
|
|
380
|
+
artifactManifestPath: result.artifactManifestPath,
|
|
381
|
+
...(result.diagnosticsPath ? { diagnosticsPath: result.diagnosticsPath } : {}),
|
|
382
|
+
...(result.sideFindings ? { sideFindings: result.sideFindings } : {}),
|
|
383
|
+
...(callOutput !== undefined ? { output: safeCallOutput } : {}),
|
|
384
|
+
recovered: state.recovered,
|
|
385
|
+
mutations: state.mutations,
|
|
386
|
+
exitCode: result.status === 'pass' ? EXIT.ok : EXIT.runtime,
|
|
387
|
+
...(result.status === 'fail'
|
|
388
|
+
? {
|
|
389
|
+
error: {
|
|
390
|
+
code: 'ACTION_EXECUTION_FAILED',
|
|
391
|
+
message: `${resolvedAction} failed; inspect the persisted result and evidence paths`,
|
|
392
|
+
userAction: failureUserAction,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
: {}),
|
|
396
|
+
}, null, 2));
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
const renderedInputs = Object.keys(args).length > 0 ? formatCallOutput(safeArgs) : '';
|
|
400
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
401
|
+
const diagnostics = formatRunDiagnosticsForHuman(readRunDiagnosticsDocument(result.diagnosticsPath), adapter);
|
|
402
|
+
const renderedDiagnostics = `${out('label', 'Diagnostics:')}\n${diagnostics.map((line) => ` ${line}`).join('\n')}\n`;
|
|
403
|
+
console.log(`${out('label', 'call')} ${out('cmd', resolvedAction)}: ${out(result.status === 'pass' ? 'ok' : 'err', result.status)}` +
|
|
404
|
+
`${renderedInputs ? `\n${out('label', 'Inputs:')}\n${renderedInputs}` : ''}` +
|
|
405
|
+
`${Object.keys(defaultsUsed).length > 0 ? `\n${renderDefaultsUsed(defaultsUsed, process.stdout)}` : ''}` +
|
|
406
|
+
`${callOutput !== undefined ? `\n${out('label', 'Result:')}\n${formatCallOutput(safeCallOutput)}` : ''}\n` +
|
|
407
|
+
renderedDiagnostics +
|
|
408
|
+
`${out('label', 'Artifacts:')} ${out('path', result.artifactManifestPath)}` +
|
|
409
|
+
(result.status === 'fail' ? `\n Next: ${failureUserAction}` : ''));
|
|
410
|
+
}
|
|
411
|
+
return result.status === 'pass' ? EXIT.ok : EXIT.runtime;
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
lock.release();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function parameterValidationHelp(action, findings, args) {
|
|
418
|
+
const schema = isRecord(action?.schema) ? action.schema : {};
|
|
419
|
+
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
420
|
+
return findings.flatMap((finding) => {
|
|
421
|
+
const issue = parameterIssue(finding.code);
|
|
422
|
+
if (!issue)
|
|
423
|
+
return [];
|
|
424
|
+
const name = finding.path.split('.').at(-1);
|
|
425
|
+
const property = name && isRecord(properties[name]) ? properties[name] : undefined;
|
|
426
|
+
if (!name || !property)
|
|
427
|
+
return [];
|
|
428
|
+
const validValues = Array.isArray(property.enum) ? property.enum : undefined;
|
|
429
|
+
const type = typeof property.type === 'string'
|
|
430
|
+
? property.type
|
|
431
|
+
: Array.isArray(property.type)
|
|
432
|
+
? property.type.filter((value) => typeof value === 'string').join('|')
|
|
433
|
+
: undefined;
|
|
434
|
+
const received = Object.hasOwn(args, name) ? args[name] : undefined;
|
|
435
|
+
const suggestion = issue === 'invalid' && received !== undefined && validValues
|
|
436
|
+
? closest(String(received), validValues.map(String))
|
|
437
|
+
: undefined;
|
|
438
|
+
return [
|
|
439
|
+
{
|
|
440
|
+
issue,
|
|
441
|
+
name,
|
|
442
|
+
...(type ? { type } : {}),
|
|
443
|
+
...(validValues ? { validValues } : {}),
|
|
444
|
+
...(typeof property.description === 'string' ? { description: property.description } : {}),
|
|
445
|
+
...(issue === 'invalid' && received !== undefined ? { received } : {}),
|
|
446
|
+
...(suggestion ? { suggestion } : {}),
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
function parameterIssue(code) {
|
|
452
|
+
if (code === 'recipe.missing_param')
|
|
453
|
+
return 'missing';
|
|
454
|
+
if (code === 'recipe.invalid_param_value_enum')
|
|
455
|
+
return 'invalid';
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
function renderParameterValidationHelp(parameter) {
|
|
459
|
+
const out = (style, text) => color(style, text, { stream: process.stderr });
|
|
460
|
+
const heading = parameter.issue === 'missing'
|
|
461
|
+
? `${out('cmd', parameter.name)}${parameter.type ? ` (${parameter.type}, required)` : ' (required)'}`
|
|
462
|
+
: `${out('cmd', parameter.name)} received ${out('err', JSON.stringify(parameter.received) ?? String(parameter.received))}`;
|
|
463
|
+
const values = parameter.validValues?.length
|
|
464
|
+
? ` Valid values: ${parameter.validValues.map((value) => out('accent', String(value))).join(', ')}.`
|
|
465
|
+
: '';
|
|
466
|
+
const suggestion = parameter.suggestion
|
|
467
|
+
? ` Did you mean ${out('accent', JSON.stringify(parameter.suggestion))}?`
|
|
468
|
+
: '';
|
|
469
|
+
const description = parameter.description ? ` ${parameter.description}` : '';
|
|
470
|
+
return ` ${heading}.${values}${suggestion}${description}`;
|
|
471
|
+
}
|
|
472
|
+
function actionDefaultsUsed(action, args) {
|
|
473
|
+
const schema = isRecord(action?.schema) ? action.schema : {};
|
|
474
|
+
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
475
|
+
return Object.fromEntries(Object.entries(properties)
|
|
476
|
+
.filter(([name, entry]) => !Object.hasOwn(args, name) && isRecord(entry) && Object.hasOwn(entry, 'default'))
|
|
477
|
+
.map(([name, entry]) => [name, entry.default]));
|
|
478
|
+
}
|
|
479
|
+
function renderDefaultsUsed(defaults, stream) {
|
|
480
|
+
const out = (style, text) => color(style, text, { stream });
|
|
481
|
+
const values = Object.entries(defaults)
|
|
482
|
+
.map(([name, value]) => `${out('cmd', name)}=${out('accent', JSON.stringify(value) ?? String(value))}`)
|
|
483
|
+
.join(', ');
|
|
484
|
+
return `${out('label', 'Defaults used:')} ${values}`;
|
|
485
|
+
}
|
|
486
|
+
/** A fresh artifacts directory per call, under the checkout's recipe calls. */
|
|
487
|
+
export function defaultCallArtifactsDir(target, action) {
|
|
488
|
+
const actionStem = action.replace(/[^a-zA-Z0-9._-]/gu, '_');
|
|
489
|
+
return path.join(target, 'temp', 'recipe', 'calls', `${actionStem}-${randomUUID()}`);
|
|
490
|
+
}
|
|
491
|
+
function readCallOutput(tracePath) {
|
|
492
|
+
try {
|
|
493
|
+
const trace = JSON.parse(fs.readFileSync(tracePath, 'utf8'));
|
|
494
|
+
const entries = Array.isArray(trace)
|
|
495
|
+
? trace
|
|
496
|
+
: isRecord(trace) && Array.isArray(trace.entries)
|
|
497
|
+
? trace.entries
|
|
498
|
+
: [];
|
|
499
|
+
const entry = entries.find((item) => isRecord(item) && item.nodeId === 'call');
|
|
500
|
+
return isRecord(entry) && Object.prototype.hasOwnProperty.call(entry, 'output')
|
|
501
|
+
? entry.output
|
|
502
|
+
: undefined;
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function formatCallOutput(value) {
|
|
509
|
+
if (typeof value === 'string')
|
|
510
|
+
return value;
|
|
511
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
512
|
+
return String(value);
|
|
513
|
+
return JSON.stringify(value, null, 2);
|
|
514
|
+
}
|
|
515
|
+
/** Call inputs and output with every sensitive-keyed value redacted. */
|
|
516
|
+
export function redactCallValue(value, key = '') {
|
|
517
|
+
// Redact by semantic field name. Value-shape guesses are unsafe here: a
|
|
518
|
+
// 32-byte transaction hash is indistinguishable from a private key, and a
|
|
519
|
+
// mnemonic-shaped sentence can be legitimate proof output.
|
|
520
|
+
return key && isSensitiveKey(key) ? '<redacted>' : redactStructuredValue(value);
|
|
521
|
+
}
|
|
522
|
+
// `call <action> --help` renders the named action's detail (the same block
|
|
523
|
+
// `actions --action <name>` prints) above the generic call flags, so parameter
|
|
524
|
+
// help for the action the user asked about is not hidden behind the generic call
|
|
525
|
+
// help. An unresolvable name falls back to the generic help plus a pointer to
|
|
526
|
+
// the vocabulary. Help is never an error: exit 0.
|
|
527
|
+
export async function handleCallHelp(argv, genericHelp, commandOptions) {
|
|
528
|
+
const { catalog } = commandOptions;
|
|
529
|
+
const host = harnessHost().name;
|
|
530
|
+
const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== '--help' && arg !== '-h'));
|
|
531
|
+
const { options } = parseArgs(rest);
|
|
532
|
+
let adapter;
|
|
533
|
+
let target;
|
|
534
|
+
let manifest;
|
|
535
|
+
let actionSources = new Map();
|
|
536
|
+
try {
|
|
537
|
+
({ adapter, target } = resolveAdapter(options));
|
|
538
|
+
const resolution = await resolveCommandManifest(catalog, adapter, options);
|
|
539
|
+
manifest = resolution.manifest;
|
|
540
|
+
actionSources = resolution.actionSources;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// No checkout/adapter context — the action schema is unavailable, so degrade to
|
|
544
|
+
// the generic call help rather than failing a help request.
|
|
545
|
+
process.stdout.write(`${genericHelp}\n`);
|
|
546
|
+
return EXIT.ok;
|
|
547
|
+
}
|
|
548
|
+
const described = describeManifestActions(catalog, manifest, actionSources);
|
|
549
|
+
const matches = shortName ? fuzzyResolveActions(described, shortName) : [];
|
|
550
|
+
if (matches.length === 0) {
|
|
551
|
+
process.stdout.write(`${genericHelp}\n`);
|
|
552
|
+
if (shortName) {
|
|
553
|
+
process.stdout.write(`\nNo action matches "${shortName}" for the ${adapter} adapter.\n` +
|
|
554
|
+
` Next: ${host} actions --adapter ${adapter} # list the action vocabulary\n`);
|
|
555
|
+
}
|
|
556
|
+
return EXIT.ok;
|
|
557
|
+
}
|
|
558
|
+
for (const entry of matches) {
|
|
559
|
+
const detail = renderActionDetail(entry, adapter, target, invokedHostCommand(), described.map(({ name }) => name));
|
|
560
|
+
process.stdout.write(`${detail}\n\n`);
|
|
561
|
+
}
|
|
562
|
+
process.stdout.write(`${genericHelp}\n`);
|
|
563
|
+
return EXIT.ok;
|
|
564
|
+
}
|
|
565
|
+
function parseCallArgs(argv) {
|
|
566
|
+
const args = {};
|
|
567
|
+
const rest = [];
|
|
568
|
+
let action;
|
|
569
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
570
|
+
const arg = argv[i];
|
|
571
|
+
if (arg === '--arg' || arg.startsWith('--arg=')) {
|
|
572
|
+
const pair = arg === '--arg' ? argv[(i += 1)] : arg.slice('--arg='.length);
|
|
573
|
+
if (pair === undefined)
|
|
574
|
+
throw usageError('--arg requires k=v.');
|
|
575
|
+
const eq = pair.indexOf('=');
|
|
576
|
+
if (eq === -1)
|
|
577
|
+
throw usageError(`--arg must be k=v: ${pair}`);
|
|
578
|
+
args[pair.slice(0, eq)] = parseCallValue(pair.slice(eq + 1));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (!arg.startsWith('--') && action === undefined) {
|
|
582
|
+
action = arg;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (action !== undefined && !isForwardedOptionValue(argv, i) && isArgPair(arg)) {
|
|
586
|
+
const eq = arg.indexOf('=');
|
|
587
|
+
args[arg.slice(0, eq)] = parseCallValue(arg.slice(eq + 1));
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
rest.push(arg);
|
|
591
|
+
}
|
|
592
|
+
return { action, args, rest };
|
|
593
|
+
}
|
|
594
|
+
function parseCallValue(value) {
|
|
595
|
+
if (value === 'true')
|
|
596
|
+
return true;
|
|
597
|
+
if (value === 'false')
|
|
598
|
+
return false;
|
|
599
|
+
if (value === 'null')
|
|
600
|
+
return null;
|
|
601
|
+
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value)) {
|
|
602
|
+
const number = Number(value);
|
|
603
|
+
if (Number.isFinite(number))
|
|
604
|
+
return number;
|
|
605
|
+
}
|
|
606
|
+
if ((value.startsWith('[') && value.endsWith(']')) ||
|
|
607
|
+
(value.startsWith('{') && value.endsWith('}')) ||
|
|
608
|
+
(value.startsWith('"') && value.endsWith('"'))) {
|
|
609
|
+
try {
|
|
610
|
+
return JSON.parse(value);
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* ordinary string containing brackets/braces */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return value;
|
|
617
|
+
}
|
|
618
|
+
function isArgPair(value) {
|
|
619
|
+
const eq = value.indexOf('=');
|
|
620
|
+
return eq > 0;
|
|
621
|
+
}
|
|
622
|
+
function isForwardedOptionValue(argv, index) {
|
|
623
|
+
if (index === 0)
|
|
624
|
+
return false;
|
|
625
|
+
const previous = argv[index - 1];
|
|
626
|
+
if (!previous.startsWith('--') || previous.includes('='))
|
|
627
|
+
return false;
|
|
628
|
+
const flag = previous.replace(/^--/u, '');
|
|
629
|
+
return (VALUE_TAKING_CALL_FLAGS.has(flag) || adapterPortFlags().some((name) => kebabCase(name) === flag));
|
|
630
|
+
}
|
|
631
|
+
// Option flags whose next argument is their value, not a key=value input. The
|
|
632
|
+
// registered adapters' dev-server port flags take a value too.
|
|
633
|
+
const VALUE_TAKING_CALL_FLAGS = new Set([
|
|
634
|
+
'action-manifest',
|
|
635
|
+
'approve-plan',
|
|
636
|
+
'adapter',
|
|
637
|
+
'artifacts-dir',
|
|
638
|
+
'cdp-port',
|
|
639
|
+
'device',
|
|
640
|
+
'heal',
|
|
641
|
+
'library',
|
|
642
|
+
'platform',
|
|
643
|
+
'slot',
|
|
644
|
+
'source-digest',
|
|
645
|
+
'source-kind',
|
|
646
|
+
'source-name',
|
|
647
|
+
'source-trust',
|
|
648
|
+
'target',
|
|
649
|
+
'validation-runtime-dir',
|
|
650
|
+
'watcher-port',
|
|
651
|
+
]);
|
|
652
|
+
function kebabCase(name) {
|
|
653
|
+
return name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
|
|
654
|
+
}
|
|
655
|
+
function resolveActionName(shortName, names) {
|
|
656
|
+
const tier = fuzzyResolveActions(names.map((name) => ({ name })), shortName).map(({ name }) => name);
|
|
657
|
+
if (tier.length === 1)
|
|
658
|
+
return { status: 'ok', resolved: tier[0], candidates: tier };
|
|
659
|
+
if (tier.length > 1)
|
|
660
|
+
return { status: 'ambiguous', resolved: '', candidates: tier };
|
|
661
|
+
return { status: 'unknown', resolved: '', candidates: [] };
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=call.js.map
|