cli4ai 1.2.4 → 1.2.6
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/cli.js +31 -0
- package/dist/commands/add.js +5 -1
- package/dist/commands/browse.js +65 -52
- package/dist/commands/download.d.ts +7 -0
- package/dist/commands/download.js +36 -0
- package/dist/commands/routines.js +10 -3
- package/dist/commands/scheduler.js +3 -0
- package/dist/commands/secrets.js +37 -24
- package/dist/commands/serve.d.ts +4 -0
- package/dist/commands/serve.js +9 -0
- package/dist/commands/update.js +6 -2
- package/dist/core/execute.d.ts +4 -0
- package/dist/core/execute.js +37 -12
- package/dist/core/remote-client.d.ts +8 -0
- package/dist/core/remote-client.js +67 -0
- package/dist/core/routine-engine.d.ts +90 -6
- package/dist/core/routine-engine.js +769 -221
- package/dist/core/routines.d.ts +5 -0
- package/dist/core/routines.js +20 -0
- package/dist/core/scheduler.d.ts +19 -0
- package/dist/core/scheduler.js +79 -1
- package/dist/dashboard/api/endpoints.d.ts +14 -0
- package/dist/dashboard/api/endpoints.js +562 -0
- package/dist/dashboard/api/websocket.d.ts +133 -0
- package/dist/dashboard/api/websocket.js +278 -0
- package/dist/dashboard/db/index.d.ts +33 -0
- package/dist/dashboard/db/index.js +69 -0
- package/dist/dashboard/db/runs.d.ts +170 -0
- package/dist/dashboard/db/runs.js +475 -0
- package/dist/dashboard/db/schema.d.ts +64 -0
- package/dist/dashboard/db/schema.js +157 -0
- package/dist/mcp/adapter.js +3 -0
- package/dist/mcp/server.js +3 -1
- package/dist/server/service.d.ts +8 -0
- package/dist/server/service.js +192 -6
- package/package.json +11 -3
- package/src/dashboard/public/assets/index-DN1hIAMO.css +1 -0
- package/src/dashboard/public/assets/index-pZeAAQwj.js +331 -0
- package/src/dashboard/public/index.html +14 -0
|
@@ -3,8 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { readFileSync } from 'fs';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
|
-
import {
|
|
6
|
+
import { platform } from 'os';
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import { createInterface } from 'readline';
|
|
9
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
10
|
+
const isWindows = platform() === 'win32';
|
|
7
11
|
import { executeTool, ExecuteToolError } from './execute.js';
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
// ROUTINE EVENTS
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
/**
|
|
16
|
+
* Event emitter for routine execution events.
|
|
17
|
+
* Used by the dashboard for real-time updates.
|
|
18
|
+
*/
|
|
19
|
+
export const routineEvents = new EventEmitter();
|
|
8
20
|
import { remoteRunTool, RemoteConnectionError, RemoteApiError } from './remote-client.js';
|
|
9
21
|
import { getRemote } from './remotes.js';
|
|
10
22
|
export class RoutineParseError extends Error {
|
|
@@ -50,6 +62,15 @@ export function loadRoutineDefinition(path) {
|
|
|
50
62
|
}
|
|
51
63
|
return validateRoutineDefinition(data, path);
|
|
52
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Serialize a routine definition to YAML or JSON string.
|
|
67
|
+
*/
|
|
68
|
+
export function serializeRoutineDefinition(def, format = 'yaml') {
|
|
69
|
+
if (format === 'json') {
|
|
70
|
+
return JSON.stringify(def, null, 2);
|
|
71
|
+
}
|
|
72
|
+
return stringifyYaml(def, { indent: 2 });
|
|
73
|
+
}
|
|
53
74
|
const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
|
|
54
75
|
/**
|
|
55
76
|
* Validate a schedule configuration.
|
|
@@ -112,111 +133,197 @@ export function validateScheduleConfig(schedule, source) {
|
|
|
112
133
|
}
|
|
113
134
|
return s;
|
|
114
135
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
137
|
+
// TYPE GUARDS
|
|
138
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
139
|
+
export function isParallelGroup(entry) {
|
|
140
|
+
return (entry !== null &&
|
|
141
|
+
typeof entry === 'object' &&
|
|
142
|
+
'parallel' in entry &&
|
|
143
|
+
Array.isArray(entry.parallel));
|
|
144
|
+
}
|
|
145
|
+
export function isTryBlock(entry) {
|
|
146
|
+
return (entry !== null &&
|
|
147
|
+
typeof entry === 'object' &&
|
|
148
|
+
'try' in entry &&
|
|
149
|
+
Array.isArray(entry.try));
|
|
150
|
+
}
|
|
151
|
+
function isRegularStep(entry) {
|
|
152
|
+
return (entry !== null &&
|
|
153
|
+
typeof entry === 'object' &&
|
|
154
|
+
'type' in entry &&
|
|
155
|
+
'id' in entry);
|
|
156
|
+
}
|
|
157
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
158
|
+
// STEP VALIDATION HELPER
|
|
159
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
160
|
+
function validateSingleStep(s, ids, source, context) {
|
|
161
|
+
const contextPrefix = context ? `${context} ` : '';
|
|
162
|
+
if (typeof s.id !== 'string' || s.id.length === 0) {
|
|
163
|
+
throw new RoutineValidationError(`${contextPrefix}Step missing "id"`, { source });
|
|
118
164
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
throw new RoutineValidationError('Invalid or missing "version" (must be 1)', { source, got: obj.version });
|
|
165
|
+
if (ids.has(s.id)) {
|
|
166
|
+
throw new RoutineValidationError(`Duplicate step id: ${s.id}`, { source, id: s.id });
|
|
122
167
|
}
|
|
123
|
-
|
|
124
|
-
|
|
168
|
+
ids.add(s.id);
|
|
169
|
+
if (typeof s.type !== 'string' || !['cli4ai', 'set', 'exec'].includes(s.type)) {
|
|
170
|
+
throw new RoutineValidationError(`Invalid step type for "${s.id}"`, { source, id: s.id, got: s.type });
|
|
125
171
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
validateScheduleConfig(obj.schedule, source);
|
|
172
|
+
if (s.timeout !== undefined && typeof s.timeout !== 'number') {
|
|
173
|
+
throw new RoutineValidationError(`Invalid timeout for "${s.id}" (must be number ms)`, { source, id: s.id, got: s.timeout });
|
|
129
174
|
}
|
|
130
|
-
if (
|
|
131
|
-
throw new RoutineValidationError(
|
|
175
|
+
if (s.continueOnError !== undefined && typeof s.continueOnError !== 'boolean') {
|
|
176
|
+
throw new RoutineValidationError(`Invalid continueOnError for "${s.id}" (must be boolean)`, { source, id: s.id, got: s.continueOnError });
|
|
132
177
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
178
|
+
if (s.type === 'cli4ai') {
|
|
179
|
+
if (typeof s.package !== 'string' || s.package.length === 0) {
|
|
180
|
+
throw new RoutineValidationError(`Step "${s.id}" missing "package"`, { source, id: s.id });
|
|
181
|
+
}
|
|
182
|
+
if (s.command !== undefined && typeof s.command !== 'string') {
|
|
183
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "command"`, { source, id: s.id });
|
|
138
184
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
185
|
+
if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
|
|
186
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
|
|
187
|
+
}
|
|
188
|
+
if (s.env !== undefined) {
|
|
189
|
+
if (typeof s.env !== 'object' || s.env === null) {
|
|
190
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
|
|
191
|
+
}
|
|
192
|
+
for (const [k, v] of Object.entries(s.env)) {
|
|
193
|
+
if (typeof v !== 'string') {
|
|
194
|
+
throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
142
197
|
}
|
|
143
|
-
if (
|
|
144
|
-
throw new RoutineValidationError(`
|
|
198
|
+
if (s.stdin !== undefined && typeof s.stdin !== 'string') {
|
|
199
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
|
|
145
200
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
throw new RoutineValidationError(`Invalid step type for "${s.id}"`, { source, id: s.id, got: s.type });
|
|
201
|
+
if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
|
|
202
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
|
|
149
203
|
}
|
|
150
|
-
if (s.
|
|
151
|
-
throw new RoutineValidationError(`
|
|
204
|
+
if (s.remote !== undefined && typeof s.remote !== 'string') {
|
|
205
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "remote" (must be string)`, { source, id: s.id, got: s.remote });
|
|
152
206
|
}
|
|
153
|
-
|
|
154
|
-
|
|
207
|
+
}
|
|
208
|
+
if (s.type === 'set') {
|
|
209
|
+
if (typeof s.vars !== 'object' || s.vars === null) {
|
|
210
|
+
throw new RoutineValidationError(`Step "${s.id}" missing "vars"`, { source, id: s.id });
|
|
155
211
|
}
|
|
156
|
-
|
|
157
|
-
if (typeof
|
|
158
|
-
throw new RoutineValidationError(`Step "${s.id}"
|
|
159
|
-
}
|
|
160
|
-
if (s.command !== undefined && typeof s.command !== 'string') {
|
|
161
|
-
throw new RoutineValidationError(`Step "${s.id}" has invalid "command"`, { source, id: s.id });
|
|
212
|
+
for (const [k, v] of Object.entries(s.vars)) {
|
|
213
|
+
if (typeof v !== 'string') {
|
|
214
|
+
throw new RoutineValidationError(`Step "${s.id}" vars.${k} must be a string template`, { source, id: s.id });
|
|
162
215
|
}
|
|
163
|
-
|
|
164
|
-
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (s.type === 'exec') {
|
|
219
|
+
if (typeof s.cmd !== 'string' || s.cmd.length === 0) {
|
|
220
|
+
throw new RoutineValidationError(`Step "${s.id}" missing "cmd"`, { source, id: s.id });
|
|
221
|
+
}
|
|
222
|
+
if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
|
|
223
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
|
|
224
|
+
}
|
|
225
|
+
if (s.env !== undefined) {
|
|
226
|
+
if (typeof s.env !== 'object' || s.env === null) {
|
|
227
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
|
|
165
228
|
}
|
|
166
|
-
|
|
167
|
-
if (typeof
|
|
168
|
-
throw new RoutineValidationError(`Step "${s.id}"
|
|
169
|
-
}
|
|
170
|
-
for (const [k, v] of Object.entries(s.env)) {
|
|
171
|
-
if (typeof v !== 'string') {
|
|
172
|
-
throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
|
|
173
|
-
}
|
|
229
|
+
for (const [k, v] of Object.entries(s.env)) {
|
|
230
|
+
if (typeof v !== 'string') {
|
|
231
|
+
throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
|
|
174
232
|
}
|
|
175
233
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
234
|
+
}
|
|
235
|
+
if (s.stdin !== undefined && typeof s.stdin !== 'string') {
|
|
236
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
|
|
237
|
+
}
|
|
238
|
+
if (s.capture !== undefined && !['text', 'json'].includes(String(s.capture))) {
|
|
239
|
+
throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function validateStepEntry(entry, ids, source, index) {
|
|
244
|
+
if (!entry || typeof entry !== 'object') {
|
|
245
|
+
throw new RoutineValidationError('Invalid step entry (must be an object)', { source, index });
|
|
246
|
+
}
|
|
247
|
+
// Check if it's a parallel group
|
|
248
|
+
if (isParallelGroup(entry)) {
|
|
249
|
+
const pg = entry;
|
|
250
|
+
if (pg.parallel.length < 1) {
|
|
251
|
+
throw new RoutineValidationError('Parallel group must have at least one step', { source, index });
|
|
252
|
+
}
|
|
253
|
+
if (pg.failFast !== undefined && typeof pg.failFast !== 'boolean') {
|
|
254
|
+
throw new RoutineValidationError('Parallel group "failFast" must be a boolean', { source, index, got: pg.failFast });
|
|
255
|
+
}
|
|
256
|
+
for (const step of pg.parallel) {
|
|
257
|
+
if (!step || typeof step !== 'object') {
|
|
258
|
+
throw new RoutineValidationError('Invalid step in parallel group', { source, index });
|
|
181
259
|
}
|
|
182
|
-
|
|
183
|
-
|
|
260
|
+
const s = step;
|
|
261
|
+
// Validate that parallel steps are not 'set' type (race condition)
|
|
262
|
+
if (s.type === 'set') {
|
|
263
|
+
throw new RoutineValidationError(`Step "${s.id}" of type "set" cannot be in a parallel group (race condition)`, { source, id: String(s.id) });
|
|
184
264
|
}
|
|
265
|
+
validateSingleStep(s, ids, source, 'parallel');
|
|
185
266
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Check if it's a try block
|
|
270
|
+
if (isTryBlock(entry)) {
|
|
271
|
+
const tb = entry;
|
|
272
|
+
if (tb.try.length < 1) {
|
|
273
|
+
throw new RoutineValidationError('Try block must have at least one step', { source, index });
|
|
274
|
+
}
|
|
275
|
+
for (const step of tb.try) {
|
|
276
|
+
if (!step || typeof step !== 'object') {
|
|
277
|
+
throw new RoutineValidationError('Invalid step in try block', { source, index });
|
|
189
278
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
279
|
+
validateSingleStep(step, ids, source, 'try');
|
|
280
|
+
}
|
|
281
|
+
if (tb.catch) {
|
|
282
|
+
for (const step of tb.catch) {
|
|
283
|
+
if (!step || typeof step !== 'object') {
|
|
284
|
+
throw new RoutineValidationError('Invalid step in catch block', { source, index });
|
|
193
285
|
}
|
|
286
|
+
validateSingleStep(step, ids, source, 'catch');
|
|
194
287
|
}
|
|
195
288
|
}
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
|
|
201
|
-
throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
|
|
202
|
-
}
|
|
203
|
-
if (s.env !== undefined) {
|
|
204
|
-
if (typeof s.env !== 'object' || s.env === null) {
|
|
205
|
-
throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
|
|
206
|
-
}
|
|
207
|
-
for (const [k, v] of Object.entries(s.env)) {
|
|
208
|
-
if (typeof v !== 'string') {
|
|
209
|
-
throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
|
|
210
|
-
}
|
|
289
|
+
if (tb.finally) {
|
|
290
|
+
for (const step of tb.finally) {
|
|
291
|
+
if (!step || typeof step !== 'object') {
|
|
292
|
+
throw new RoutineValidationError('Invalid step in finally block', { source, index });
|
|
211
293
|
}
|
|
212
|
-
|
|
213
|
-
if (s.stdin !== undefined && typeof s.stdin !== 'string') {
|
|
214
|
-
throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
|
|
215
|
-
}
|
|
216
|
-
if (s.capture !== undefined && !['text', 'json'].includes(String(s.capture))) {
|
|
217
|
-
throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
|
|
294
|
+
validateSingleStep(step, ids, source, 'finally');
|
|
218
295
|
}
|
|
219
296
|
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Regular step
|
|
300
|
+
if (!isRegularStep(entry)) {
|
|
301
|
+
throw new RoutineValidationError('Invalid step entry (must have type and id, or be parallel/try block)', { source, index });
|
|
302
|
+
}
|
|
303
|
+
validateSingleStep(entry, ids, source);
|
|
304
|
+
}
|
|
305
|
+
function validateRoutineDefinition(value, source) {
|
|
306
|
+
if (!value || typeof value !== 'object') {
|
|
307
|
+
throw new RoutineValidationError('Routine must be an object', { source });
|
|
308
|
+
}
|
|
309
|
+
const obj = value;
|
|
310
|
+
if (obj.version !== 1) {
|
|
311
|
+
throw new RoutineValidationError('Invalid or missing "version" (must be 1)', { source, got: obj.version });
|
|
312
|
+
}
|
|
313
|
+
if (typeof obj.name !== 'string' || obj.name.length === 0) {
|
|
314
|
+
throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
|
|
315
|
+
}
|
|
316
|
+
// Validate schedule if present
|
|
317
|
+
if (obj.schedule !== undefined) {
|
|
318
|
+
validateScheduleConfig(obj.schedule, source);
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(obj.steps)) {
|
|
321
|
+
throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
|
|
322
|
+
}
|
|
323
|
+
const steps = obj.steps;
|
|
324
|
+
const ids = new Set();
|
|
325
|
+
for (const [idx, entry] of steps.entries()) {
|
|
326
|
+
validateStepEntry(entry, ids, source, idx);
|
|
220
327
|
}
|
|
221
328
|
return obj;
|
|
222
329
|
}
|
|
@@ -358,8 +465,9 @@ function collectStream(stream) {
|
|
|
358
465
|
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
359
466
|
});
|
|
360
467
|
}
|
|
361
|
-
async function runExecStep(step, ctx, invocationDir) {
|
|
468
|
+
async function runExecStep(step, ctx, invocationDir, callbacks = {}) {
|
|
362
469
|
const startTime = Date.now();
|
|
470
|
+
const { onStdoutLine, onStderrLine } = callbacks;
|
|
363
471
|
const cmd = renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd' });
|
|
364
472
|
const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
|
|
365
473
|
const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
|
|
@@ -367,6 +475,7 @@ async function runExecStep(step, ctx, invocationDir) {
|
|
|
367
475
|
const child = spawn(cmd, args, {
|
|
368
476
|
cwd: invocationDir,
|
|
369
477
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
478
|
+
shell: isWindows,
|
|
370
479
|
env: {
|
|
371
480
|
...process.env,
|
|
372
481
|
...(env ?? {})
|
|
@@ -377,10 +486,30 @@ async function runExecStep(step, ctx, invocationDir) {
|
|
|
377
486
|
}
|
|
378
487
|
if (child.stdin)
|
|
379
488
|
child.stdin.end();
|
|
380
|
-
|
|
381
|
-
const
|
|
489
|
+
// Use line-by-line streaming for callbacks
|
|
490
|
+
const stdoutLines = [];
|
|
491
|
+
const stderrLines = [];
|
|
492
|
+
const readlineInterfaces = [];
|
|
493
|
+
if (child.stdout) {
|
|
494
|
+
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
495
|
+
readlineInterfaces.push(rl);
|
|
496
|
+
rl.on('line', (line) => {
|
|
497
|
+
stdoutLines.push(line);
|
|
498
|
+
if (onStdoutLine) {
|
|
499
|
+
onStdoutLine(line);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
382
503
|
if (child.stderr) {
|
|
383
|
-
child.stderr
|
|
504
|
+
const rl = createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
505
|
+
readlineInterfaces.push(rl);
|
|
506
|
+
rl.on('line', (line) => {
|
|
507
|
+
stderrLines.push(line);
|
|
508
|
+
process.stderr.write(line + '\n');
|
|
509
|
+
if (onStderrLine) {
|
|
510
|
+
onStderrLine(line);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
384
513
|
}
|
|
385
514
|
let timeout;
|
|
386
515
|
if (step.timeout && step.timeout > 0) {
|
|
@@ -398,13 +527,18 @@ async function runExecStep(step, ctx, invocationDir) {
|
|
|
398
527
|
}, step.timeout);
|
|
399
528
|
}
|
|
400
529
|
const exitCode = await new Promise((resolve, reject) => {
|
|
401
|
-
child.
|
|
402
|
-
child.
|
|
530
|
+
child.once('close', (code) => resolve(code ?? 0));
|
|
531
|
+
child.once('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
|
|
403
532
|
}).finally(() => {
|
|
404
533
|
if (timeout)
|
|
405
534
|
clearTimeout(timeout);
|
|
535
|
+
// Close all readline interfaces
|
|
536
|
+
for (const rl of readlineInterfaces) {
|
|
537
|
+
rl.close();
|
|
538
|
+
}
|
|
406
539
|
});
|
|
407
|
-
const
|
|
540
|
+
const stdout = stdoutLines.length > 0 ? stdoutLines.join('\n') + '\n' : '';
|
|
541
|
+
const stderr = stderrLines.length > 0 ? stderrLines.join('\n') + '\n' : '';
|
|
408
542
|
const capture = step.capture ?? 'text';
|
|
409
543
|
let json;
|
|
410
544
|
if (capture === 'json') {
|
|
@@ -442,7 +576,7 @@ export async function dryRunRoutine(def, vars, invocationDir) {
|
|
|
442
576
|
steps: {}
|
|
443
577
|
};
|
|
444
578
|
const steps = [];
|
|
445
|
-
|
|
579
|
+
function dryRunSingleStep(step) {
|
|
446
580
|
if (step.type === 'set') {
|
|
447
581
|
const renderedVars = {};
|
|
448
582
|
for (const [k, v] of Object.entries(step.vars)) {
|
|
@@ -451,8 +585,7 @@ export async function dryRunRoutine(def, vars, invocationDir) {
|
|
|
451
585
|
// Apply to context for subsequent steps
|
|
452
586
|
for (const [k, v] of Object.entries(renderedVars))
|
|
453
587
|
ctxVars[k] = v;
|
|
454
|
-
|
|
455
|
-
continue;
|
|
588
|
+
return { id: step.id, type: step.type, rendered: { vars: renderedVars } };
|
|
456
589
|
}
|
|
457
590
|
if (step.type === 'cli4ai') {
|
|
458
591
|
const rendered = {
|
|
@@ -463,8 +596,7 @@ export async function dryRunRoutine(def, vars, invocationDir) {
|
|
|
463
596
|
stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
|
|
464
597
|
timeout: step.timeout
|
|
465
598
|
};
|
|
466
|
-
|
|
467
|
-
continue;
|
|
599
|
+
return { id: step.id, type: step.type, rendered };
|
|
468
600
|
}
|
|
469
601
|
if (step.type === 'exec') {
|
|
470
602
|
const rendered = {
|
|
@@ -474,9 +606,38 @@ export async function dryRunRoutine(def, vars, invocationDir) {
|
|
|
474
606
|
stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
|
|
475
607
|
timeout: step.timeout
|
|
476
608
|
};
|
|
477
|
-
|
|
609
|
+
return { id: step.id, type: step.type, rendered };
|
|
610
|
+
}
|
|
611
|
+
// Exhaustive check - this should never be reached
|
|
612
|
+
const _exhaustive = step;
|
|
613
|
+
throw new Error(`Unknown step type: ${_exhaustive.type}`);
|
|
614
|
+
}
|
|
615
|
+
for (const entry of def.steps) {
|
|
616
|
+
// Handle parallel groups
|
|
617
|
+
if (isParallelGroup(entry)) {
|
|
618
|
+
const parallelSteps = entry.parallel.map(s => dryRunSingleStep(s));
|
|
619
|
+
steps.push({
|
|
620
|
+
type: 'parallel',
|
|
621
|
+
steps: parallelSteps,
|
|
622
|
+
failFast: entry.failFast
|
|
623
|
+
});
|
|
478
624
|
continue;
|
|
479
625
|
}
|
|
626
|
+
// Handle try blocks
|
|
627
|
+
if (isTryBlock(entry)) {
|
|
628
|
+
const trySteps = entry.try.map(s => dryRunSingleStep(s));
|
|
629
|
+
const catchSteps = entry.catch?.map(s => dryRunSingleStep(s));
|
|
630
|
+
const finallySteps = entry.finally?.map(s => dryRunSingleStep(s));
|
|
631
|
+
steps.push({
|
|
632
|
+
type: 'try',
|
|
633
|
+
try: trySteps,
|
|
634
|
+
catch: catchSteps,
|
|
635
|
+
finally: finallySteps
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
// Regular step
|
|
640
|
+
steps.push(dryRunSingleStep(entry));
|
|
480
641
|
}
|
|
481
642
|
const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result', strict: false }) : undefined;
|
|
482
643
|
return {
|
|
@@ -487,150 +648,279 @@ export async function dryRunRoutine(def, vars, invocationDir) {
|
|
|
487
648
|
result
|
|
488
649
|
};
|
|
489
650
|
}
|
|
490
|
-
|
|
651
|
+
async function executeSingleStep(step, execCtx) {
|
|
652
|
+
const { ctx, ctxVars, stepsById, invocationDir, onStdoutLine, onStderrLine, onStepStart, onStepFinish } = execCtx;
|
|
653
|
+
// Notify step start
|
|
654
|
+
if (onStepStart) {
|
|
655
|
+
onStepStart(step.id, step.type);
|
|
656
|
+
}
|
|
657
|
+
if (step.type === 'set') {
|
|
658
|
+
const assigned = {};
|
|
659
|
+
for (const [k, v] of Object.entries(step.vars)) {
|
|
660
|
+
assigned[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}` });
|
|
661
|
+
}
|
|
662
|
+
for (const [k, v] of Object.entries(assigned))
|
|
663
|
+
ctxVars[k] = v;
|
|
664
|
+
const res = {
|
|
665
|
+
id: step.id,
|
|
666
|
+
type: step.type,
|
|
667
|
+
status: 'success',
|
|
668
|
+
exitCode: 0,
|
|
669
|
+
durationMs: 0,
|
|
670
|
+
json: assigned
|
|
671
|
+
};
|
|
672
|
+
stepsById[step.id] = res;
|
|
673
|
+
if (onStepFinish) {
|
|
674
|
+
onStepFinish(res);
|
|
675
|
+
}
|
|
676
|
+
return res;
|
|
677
|
+
}
|
|
678
|
+
if (step.type === 'exec') {
|
|
679
|
+
const res = await runExecStep(step, ctx, invocationDir, {
|
|
680
|
+
onStdoutLine: onStdoutLine ? (line) => onStdoutLine(step.id, line) : undefined,
|
|
681
|
+
onStderrLine: onStderrLine ? (line) => onStderrLine(step.id, line) : undefined,
|
|
682
|
+
});
|
|
683
|
+
stepsById[step.id] = res;
|
|
684
|
+
if (onStepFinish) {
|
|
685
|
+
onStepFinish(res);
|
|
686
|
+
}
|
|
687
|
+
return res;
|
|
688
|
+
}
|
|
689
|
+
if (step.type === 'cli4ai') {
|
|
690
|
+
const pkg = renderScalarString(step.package, ctx, { stepId: step.id, field: 'package' });
|
|
691
|
+
const cmd = step.command !== undefined ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command' }) : undefined;
|
|
692
|
+
const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
|
|
693
|
+
const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
|
|
694
|
+
const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
|
|
695
|
+
const remoteName = step.remote !== undefined ? renderScalarString(step.remote, ctx, { stepId: step.id, field: 'remote' }) : undefined;
|
|
696
|
+
const capture = step.capture ?? 'text';
|
|
697
|
+
let execResult;
|
|
698
|
+
if (remoteName) {
|
|
699
|
+
const remote = getRemote(remoteName);
|
|
700
|
+
if (!remote) {
|
|
701
|
+
throw new ExecuteToolError('NOT_FOUND', `Remote "${remoteName}" not found`, {
|
|
702
|
+
step: step.id,
|
|
703
|
+
hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
const remoteRes = await remoteRunTool(remoteName, {
|
|
707
|
+
package: pkg,
|
|
708
|
+
command: cmd,
|
|
709
|
+
args,
|
|
710
|
+
env: Object.keys(env).length > 0 ? env : undefined,
|
|
711
|
+
stdin,
|
|
712
|
+
timeout: step.timeout
|
|
713
|
+
});
|
|
714
|
+
execResult = {
|
|
715
|
+
exitCode: remoteRes.exitCode,
|
|
716
|
+
durationMs: remoteRes.durationMs,
|
|
717
|
+
stdout: remoteRes.stdout,
|
|
718
|
+
stderr: remoteRes.stderr
|
|
719
|
+
};
|
|
720
|
+
if (remoteRes.stderr) {
|
|
721
|
+
process.stderr.write(remoteRes.stderr);
|
|
722
|
+
}
|
|
723
|
+
if (remoteRes.error && !remoteRes.success) {
|
|
724
|
+
throw new ExecuteToolError(remoteRes.error.code, remoteRes.error.message, remoteRes.error.details);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
const localRes = await executeTool({
|
|
729
|
+
packageName: pkg,
|
|
730
|
+
command: cmd,
|
|
731
|
+
args,
|
|
732
|
+
cwd: invocationDir,
|
|
733
|
+
env,
|
|
734
|
+
stdin,
|
|
735
|
+
capture: 'pipe',
|
|
736
|
+
timeoutMs: step.timeout,
|
|
737
|
+
teeStderr: true,
|
|
738
|
+
onStdoutLine: onStdoutLine ? (line) => onStdoutLine(step.id, line) : undefined,
|
|
739
|
+
onStderrLine: onStderrLine ? (line) => onStderrLine(step.id, line) : undefined,
|
|
740
|
+
});
|
|
741
|
+
execResult = {
|
|
742
|
+
exitCode: localRes.exitCode,
|
|
743
|
+
durationMs: localRes.durationMs,
|
|
744
|
+
stdout: localRes.stdout,
|
|
745
|
+
stderr: localRes.stderr
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
const res = {
|
|
749
|
+
id: step.id,
|
|
750
|
+
type: step.type,
|
|
751
|
+
status: execResult.exitCode === 0 ? 'success' : 'failed',
|
|
752
|
+
exitCode: execResult.exitCode,
|
|
753
|
+
durationMs: execResult.durationMs,
|
|
754
|
+
stdout: execResult.stdout,
|
|
755
|
+
stderr: execResult.stderr
|
|
756
|
+
};
|
|
757
|
+
if (capture === 'json') {
|
|
758
|
+
try {
|
|
759
|
+
res.json = execResult.stdout ? JSON.parse(execResult.stdout) : null;
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
|
|
763
|
+
step: step.id,
|
|
764
|
+
stdout: (execResult.stdout ?? '').slice(0, 200)
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
stepsById[step.id] = res;
|
|
769
|
+
if (onStepFinish) {
|
|
770
|
+
onStepFinish(res);
|
|
771
|
+
}
|
|
772
|
+
return res;
|
|
773
|
+
}
|
|
774
|
+
throw new Error(`Unknown step type: ${step.type}`);
|
|
775
|
+
}
|
|
776
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
777
|
+
// PARALLEL EXECUTION
|
|
778
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
779
|
+
async function runParallelGroup(group, execCtx) {
|
|
491
780
|
const startTime = Date.now();
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
781
|
+
const failFast = group.failFast !== false; // default true
|
|
782
|
+
// Run all steps in parallel
|
|
783
|
+
const promises = group.parallel.map(async (step) => {
|
|
784
|
+
try {
|
|
785
|
+
return await executeSingleStep(step, execCtx);
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
const error = normalizeError(err);
|
|
789
|
+
const res = {
|
|
790
|
+
id: step.id,
|
|
791
|
+
type: step.type,
|
|
792
|
+
status: 'failed',
|
|
793
|
+
exitCode: 1,
|
|
794
|
+
error
|
|
795
|
+
};
|
|
796
|
+
execCtx.stepsById[step.id] = res;
|
|
797
|
+
return res;
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
// Wait for all to complete (always use allSettled for consistent behavior)
|
|
801
|
+
const settled = await Promise.allSettled(promises);
|
|
802
|
+
const results = [];
|
|
803
|
+
let failed = false;
|
|
804
|
+
for (const outcome of settled) {
|
|
805
|
+
if (outcome.status === 'fulfilled') {
|
|
806
|
+
results.push(outcome.value);
|
|
807
|
+
if (outcome.value.status === 'failed') {
|
|
808
|
+
failed = true;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
// This shouldn't happen since we catch errors above, but handle it
|
|
813
|
+
failed = true;
|
|
814
|
+
}
|
|
496
815
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
816
|
+
// Add results to the steps array
|
|
817
|
+
for (const res of results) {
|
|
818
|
+
execCtx.steps.push(res);
|
|
819
|
+
}
|
|
820
|
+
return { results, failed };
|
|
821
|
+
}
|
|
822
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
823
|
+
// TRY/CATCH EXECUTION
|
|
824
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
825
|
+
async function runTryBlock(block, execCtx) {
|
|
826
|
+
const { ctx, stepsById, steps } = execCtx;
|
|
827
|
+
const results = [];
|
|
828
|
+
let failed = false;
|
|
829
|
+
let caught = false;
|
|
830
|
+
let errorContext;
|
|
831
|
+
// Execute try steps
|
|
832
|
+
for (const step of block.try) {
|
|
833
|
+
try {
|
|
834
|
+
const res = await executeSingleStep(step, execCtx);
|
|
835
|
+
results.push(res);
|
|
836
|
+
steps.push(res);
|
|
837
|
+
if (res.status === 'failed') {
|
|
838
|
+
failed = true;
|
|
839
|
+
errorContext = {
|
|
840
|
+
stepId: step.id,
|
|
841
|
+
stepType: step.type,
|
|
842
|
+
code: res.error?.code ?? 'EXIT_CODE',
|
|
843
|
+
message: res.error?.message ?? `Step "${step.id}" failed with exit code ${res.exitCode}`,
|
|
844
|
+
exitCode: res.exitCode,
|
|
845
|
+
stdout: res.stdout,
|
|
846
|
+
stderr: res.stderr
|
|
847
|
+
};
|
|
848
|
+
// Mark as caught since we have a catch block
|
|
849
|
+
if (block.catch) {
|
|
850
|
+
res.status = 'caught';
|
|
851
|
+
caught = true;
|
|
852
|
+
}
|
|
853
|
+
break;
|
|
510
854
|
}
|
|
511
|
-
|
|
512
|
-
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
const error = normalizeError(err);
|
|
513
858
|
const res = {
|
|
514
859
|
id: step.id,
|
|
515
860
|
type: step.type,
|
|
516
|
-
status: '
|
|
517
|
-
exitCode:
|
|
518
|
-
|
|
519
|
-
json: assigned
|
|
861
|
+
status: block.catch ? 'caught' : 'failed',
|
|
862
|
+
exitCode: 1,
|
|
863
|
+
error
|
|
520
864
|
};
|
|
521
865
|
stepsById[step.id] = res;
|
|
866
|
+
results.push(res);
|
|
522
867
|
steps.push(res);
|
|
523
|
-
|
|
868
|
+
failed = true;
|
|
869
|
+
if (block.catch) {
|
|
870
|
+
caught = true;
|
|
871
|
+
}
|
|
872
|
+
errorContext = {
|
|
873
|
+
stepId: step.id,
|
|
874
|
+
stepType: step.type,
|
|
875
|
+
code: error.code,
|
|
876
|
+
message: error.message,
|
|
877
|
+
exitCode: 1
|
|
878
|
+
};
|
|
879
|
+
break;
|
|
524
880
|
}
|
|
525
|
-
|
|
881
|
+
}
|
|
882
|
+
// Execute catch steps if there was an error and we have a catch block
|
|
883
|
+
if (failed && block.catch && errorContext) {
|
|
884
|
+
// Add error context to the template context
|
|
885
|
+
const catchCtx = { ...ctx, error: errorContext };
|
|
886
|
+
for (const step of block.catch) {
|
|
526
887
|
try {
|
|
527
|
-
|
|
528
|
-
|
|
888
|
+
// Create a modified exec context with the error context
|
|
889
|
+
const catchExecCtx = {
|
|
890
|
+
...execCtx,
|
|
891
|
+
ctx: catchCtx
|
|
892
|
+
};
|
|
893
|
+
const res = await executeSingleStep(step, catchExecCtx);
|
|
894
|
+
results.push(res);
|
|
529
895
|
steps.push(res);
|
|
530
|
-
if (res.
|
|
531
|
-
|
|
896
|
+
if (res.status === 'failed' && !step.continueOnError) {
|
|
897
|
+
// Catch block itself failed
|
|
898
|
+
break;
|
|
532
899
|
}
|
|
533
|
-
continue;
|
|
534
900
|
}
|
|
535
901
|
catch (err) {
|
|
536
902
|
const error = normalizeError(err);
|
|
537
|
-
const res = { id: step.id, type: step.type, status: 'failed', exitCode: 1, error };
|
|
538
|
-
stepsById[step.id] = res;
|
|
539
|
-
steps.push(res);
|
|
540
|
-
if (!step.continueOnError) {
|
|
541
|
-
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
542
|
-
}
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
if (step.type === 'cli4ai') {
|
|
547
|
-
const pkg = renderScalarString(step.package, ctx, { stepId: step.id, field: 'package' });
|
|
548
|
-
const cmd = step.command !== undefined ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command' }) : undefined;
|
|
549
|
-
const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
|
|
550
|
-
const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
|
|
551
|
-
const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
|
|
552
|
-
const remoteName = step.remote !== undefined ? renderScalarString(step.remote, ctx, { stepId: step.id, field: 'remote' }) : undefined;
|
|
553
|
-
const capture = step.capture ?? 'text';
|
|
554
|
-
try {
|
|
555
|
-
let execResult;
|
|
556
|
-
if (remoteName) {
|
|
557
|
-
// Remote execution
|
|
558
|
-
const remote = getRemote(remoteName);
|
|
559
|
-
if (!remote) {
|
|
560
|
-
throw new ExecuteToolError('NOT_FOUND', `Remote "${remoteName}" not found`, {
|
|
561
|
-
step: step.id,
|
|
562
|
-
hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
const remoteRes = await remoteRunTool(remoteName, {
|
|
566
|
-
package: pkg,
|
|
567
|
-
command: cmd,
|
|
568
|
-
args,
|
|
569
|
-
env: Object.keys(env).length > 0 ? env : undefined,
|
|
570
|
-
stdin,
|
|
571
|
-
timeout: step.timeout
|
|
572
|
-
});
|
|
573
|
-
execResult = {
|
|
574
|
-
exitCode: remoteRes.exitCode,
|
|
575
|
-
durationMs: remoteRes.durationMs,
|
|
576
|
-
stdout: remoteRes.stdout,
|
|
577
|
-
stderr: remoteRes.stderr
|
|
578
|
-
};
|
|
579
|
-
// Log stderr from remote
|
|
580
|
-
if (remoteRes.stderr) {
|
|
581
|
-
process.stderr.write(remoteRes.stderr);
|
|
582
|
-
}
|
|
583
|
-
// Handle remote-level errors
|
|
584
|
-
if (remoteRes.error && !remoteRes.success) {
|
|
585
|
-
throw new ExecuteToolError(remoteRes.error.code, remoteRes.error.message, remoteRes.error.details);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
else {
|
|
589
|
-
// Local execution
|
|
590
|
-
const localRes = await executeTool({
|
|
591
|
-
packageName: pkg,
|
|
592
|
-
command: cmd,
|
|
593
|
-
args,
|
|
594
|
-
cwd: invocationDir,
|
|
595
|
-
env,
|
|
596
|
-
stdin,
|
|
597
|
-
capture: 'pipe',
|
|
598
|
-
timeoutMs: step.timeout,
|
|
599
|
-
teeStderr: true
|
|
600
|
-
});
|
|
601
|
-
execResult = {
|
|
602
|
-
exitCode: localRes.exitCode,
|
|
603
|
-
durationMs: localRes.durationMs,
|
|
604
|
-
stdout: localRes.stdout,
|
|
605
|
-
stderr: localRes.stderr
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
903
|
const res = {
|
|
609
904
|
id: step.id,
|
|
610
905
|
type: step.type,
|
|
611
|
-
status:
|
|
612
|
-
exitCode:
|
|
613
|
-
|
|
614
|
-
stdout: execResult.stdout,
|
|
615
|
-
stderr: execResult.stderr
|
|
906
|
+
status: 'failed',
|
|
907
|
+
exitCode: 1,
|
|
908
|
+
error
|
|
616
909
|
};
|
|
617
|
-
if (capture === 'json') {
|
|
618
|
-
try {
|
|
619
|
-
res.json = execResult.stdout ? JSON.parse(execResult.stdout) : null;
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
|
|
623
|
-
step: step.id,
|
|
624
|
-
stdout: (execResult.stdout ?? '').slice(0, 200)
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
910
|
stepsById[step.id] = res;
|
|
911
|
+
results.push(res);
|
|
912
|
+
steps.push(res);
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Execute finally steps (always)
|
|
918
|
+
if (block.finally) {
|
|
919
|
+
for (const step of block.finally) {
|
|
920
|
+
try {
|
|
921
|
+
const res = await executeSingleStep(step, execCtx);
|
|
922
|
+
results.push(res);
|
|
629
923
|
steps.push(res);
|
|
630
|
-
if (execResult.exitCode !== 0 && !step.continueOnError) {
|
|
631
|
-
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execResult.exitCode);
|
|
632
|
-
}
|
|
633
|
-
continue;
|
|
634
924
|
}
|
|
635
925
|
catch (err) {
|
|
636
926
|
const error = normalizeError(err);
|
|
@@ -638,15 +928,82 @@ export async function runRoutine(def, vars, invocationDir) {
|
|
|
638
928
|
id: step.id,
|
|
639
929
|
type: step.type,
|
|
640
930
|
status: 'failed',
|
|
641
|
-
exitCode:
|
|
931
|
+
exitCode: 1,
|
|
642
932
|
error
|
|
643
933
|
};
|
|
644
934
|
stepsById[step.id] = res;
|
|
935
|
+
results.push(res);
|
|
645
936
|
steps.push(res);
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// If error was caught, we don't propagate failure
|
|
941
|
+
return { results, failed: failed && !caught, caught };
|
|
942
|
+
}
|
|
943
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
944
|
+
// MAIN ROUTINE RUNNER
|
|
945
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
946
|
+
export async function runRoutine(def, vars, invocationDir) {
|
|
947
|
+
const startTime = Date.now();
|
|
948
|
+
const ctxVars = {};
|
|
949
|
+
for (const [k, v] of Object.entries(def.vars ?? {})) {
|
|
950
|
+
if (v.default !== undefined)
|
|
951
|
+
ctxVars[k] = v.default;
|
|
952
|
+
}
|
|
953
|
+
for (const [k, v] of Object.entries(vars))
|
|
954
|
+
ctxVars[k] = v;
|
|
955
|
+
const stepsById = {};
|
|
956
|
+
const ctx = {
|
|
957
|
+
vars: ctxVars,
|
|
958
|
+
steps: stepsById
|
|
959
|
+
};
|
|
960
|
+
const steps = [];
|
|
961
|
+
const execCtx = {
|
|
962
|
+
ctx,
|
|
963
|
+
ctxVars,
|
|
964
|
+
stepsById,
|
|
965
|
+
steps,
|
|
966
|
+
invocationDir
|
|
967
|
+
};
|
|
968
|
+
for (const entry of def.steps) {
|
|
969
|
+
// Handle parallel groups
|
|
970
|
+
if (isParallelGroup(entry)) {
|
|
971
|
+
const { failed } = await runParallelGroup(entry, execCtx);
|
|
972
|
+
if (failed && (entry.failFast !== false)) {
|
|
973
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
974
|
+
}
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
// Handle try/catch blocks
|
|
978
|
+
if (isTryBlock(entry)) {
|
|
979
|
+
const { failed } = await runTryBlock(entry, execCtx);
|
|
980
|
+
if (failed) {
|
|
981
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
982
|
+
}
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
// Regular step
|
|
986
|
+
const step = entry;
|
|
987
|
+
try {
|
|
988
|
+
const res = await executeSingleStep(step, execCtx);
|
|
989
|
+
steps.push(res);
|
|
990
|
+
if (res.status === 'failed' && !step.continueOnError) {
|
|
991
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, res.exitCode ?? 1);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
const error = normalizeError(err);
|
|
996
|
+
const res = {
|
|
997
|
+
id: step.id,
|
|
998
|
+
type: step.type,
|
|
999
|
+
status: 'failed',
|
|
1000
|
+
exitCode: 1,
|
|
1001
|
+
error
|
|
1002
|
+
};
|
|
1003
|
+
stepsById[step.id] = res;
|
|
1004
|
+
steps.push(res);
|
|
1005
|
+
if (!step.continueOnError) {
|
|
1006
|
+
return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
650
1007
|
}
|
|
651
1008
|
}
|
|
652
1009
|
}
|
|
@@ -697,3 +1054,194 @@ function finalizeFailure(def, vars, steps, durationMs, exitCode) {
|
|
|
697
1054
|
steps
|
|
698
1055
|
};
|
|
699
1056
|
}
|
|
1057
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1058
|
+
// TRACKED ROUTINE RUNNER (for dashboard)
|
|
1059
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1060
|
+
import { randomUUID } from 'crypto';
|
|
1061
|
+
/**
|
|
1062
|
+
* Run a routine with event emissions for real-time tracking.
|
|
1063
|
+
* Emits events: run:started, run:log, run:step, run:finished
|
|
1064
|
+
*/
|
|
1065
|
+
export async function runRoutineTracked(def, vars, invocationDir, options = {}) {
|
|
1066
|
+
const runId = options.runId ?? randomUUID();
|
|
1067
|
+
const triggerType = options.triggerType ?? 'manual';
|
|
1068
|
+
const startTime = Date.now();
|
|
1069
|
+
// Emit run started event
|
|
1070
|
+
routineEvents.emit('run:started', {
|
|
1071
|
+
runId,
|
|
1072
|
+
routineName: def.name,
|
|
1073
|
+
triggerType,
|
|
1074
|
+
});
|
|
1075
|
+
// Set up context variables
|
|
1076
|
+
const ctxVars = {};
|
|
1077
|
+
for (const [k, v] of Object.entries(def.vars ?? {})) {
|
|
1078
|
+
if (v.default !== undefined)
|
|
1079
|
+
ctxVars[k] = v.default;
|
|
1080
|
+
}
|
|
1081
|
+
for (const [k, v] of Object.entries(vars))
|
|
1082
|
+
ctxVars[k] = v;
|
|
1083
|
+
const stepsById = {};
|
|
1084
|
+
const ctx = {
|
|
1085
|
+
vars: ctxVars,
|
|
1086
|
+
steps: stepsById
|
|
1087
|
+
};
|
|
1088
|
+
const steps = [];
|
|
1089
|
+
// Create execution context with live event callbacks
|
|
1090
|
+
const execCtx = {
|
|
1091
|
+
ctx,
|
|
1092
|
+
ctxVars,
|
|
1093
|
+
stepsById,
|
|
1094
|
+
steps,
|
|
1095
|
+
invocationDir,
|
|
1096
|
+
// Emit log events for each output line
|
|
1097
|
+
onStdoutLine: (stepId, line) => {
|
|
1098
|
+
routineEvents.emit('run:log', {
|
|
1099
|
+
runId,
|
|
1100
|
+
stream: 'stdout',
|
|
1101
|
+
line,
|
|
1102
|
+
stepId,
|
|
1103
|
+
});
|
|
1104
|
+
},
|
|
1105
|
+
onStderrLine: (stepId, line) => {
|
|
1106
|
+
routineEvents.emit('run:log', {
|
|
1107
|
+
runId,
|
|
1108
|
+
stream: 'stderr',
|
|
1109
|
+
line,
|
|
1110
|
+
stepId,
|
|
1111
|
+
});
|
|
1112
|
+
},
|
|
1113
|
+
// Emit step start event
|
|
1114
|
+
onStepStart: (stepId, stepType) => {
|
|
1115
|
+
routineEvents.emit('run:step', {
|
|
1116
|
+
runId,
|
|
1117
|
+
stepId,
|
|
1118
|
+
stepType,
|
|
1119
|
+
status: 'running',
|
|
1120
|
+
});
|
|
1121
|
+
},
|
|
1122
|
+
// Emit step finish event
|
|
1123
|
+
onStepFinish: (result) => {
|
|
1124
|
+
routineEvents.emit('run:step', {
|
|
1125
|
+
runId,
|
|
1126
|
+
stepId: result.id,
|
|
1127
|
+
stepType: result.type,
|
|
1128
|
+
status: result.status,
|
|
1129
|
+
exitCode: result.exitCode,
|
|
1130
|
+
durationMs: result.durationMs,
|
|
1131
|
+
stdout: result.stdout,
|
|
1132
|
+
stderr: result.stderr,
|
|
1133
|
+
});
|
|
1134
|
+
},
|
|
1135
|
+
};
|
|
1136
|
+
try {
|
|
1137
|
+
// Execute steps inline (similar to runRoutine but with callbacks)
|
|
1138
|
+
for (const entry of def.steps) {
|
|
1139
|
+
// Handle parallel groups
|
|
1140
|
+
if (isParallelGroup(entry)) {
|
|
1141
|
+
const { failed } = await runParallelGroup(entry, execCtx);
|
|
1142
|
+
if (failed && (entry.failFast !== false)) {
|
|
1143
|
+
const result = finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
1144
|
+
routineEvents.emit('run:finished', {
|
|
1145
|
+
runId,
|
|
1146
|
+
status: result.status,
|
|
1147
|
+
exitCode: result.exitCode,
|
|
1148
|
+
durationMs: result.durationMs,
|
|
1149
|
+
});
|
|
1150
|
+
return { ...result, runId };
|
|
1151
|
+
}
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
// Handle try/catch blocks
|
|
1155
|
+
if (isTryBlock(entry)) {
|
|
1156
|
+
const { failed } = await runTryBlock(entry, execCtx);
|
|
1157
|
+
if (failed) {
|
|
1158
|
+
const result = finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
1159
|
+
routineEvents.emit('run:finished', {
|
|
1160
|
+
runId,
|
|
1161
|
+
status: result.status,
|
|
1162
|
+
exitCode: result.exitCode,
|
|
1163
|
+
durationMs: result.durationMs,
|
|
1164
|
+
});
|
|
1165
|
+
return { ...result, runId };
|
|
1166
|
+
}
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
// Regular step
|
|
1170
|
+
const step = entry;
|
|
1171
|
+
try {
|
|
1172
|
+
const res = await executeSingleStep(step, execCtx);
|
|
1173
|
+
steps.push(res);
|
|
1174
|
+
if (res.status === 'failed' && !step.continueOnError) {
|
|
1175
|
+
const result = finalizeFailure(def, ctxVars, steps, Date.now() - startTime, res.exitCode ?? 1);
|
|
1176
|
+
routineEvents.emit('run:finished', {
|
|
1177
|
+
runId,
|
|
1178
|
+
status: result.status,
|
|
1179
|
+
exitCode: result.exitCode,
|
|
1180
|
+
durationMs: result.durationMs,
|
|
1181
|
+
});
|
|
1182
|
+
return { ...result, runId };
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
catch (err) {
|
|
1186
|
+
const error = normalizeError(err);
|
|
1187
|
+
const res = {
|
|
1188
|
+
id: step.id,
|
|
1189
|
+
type: step.type,
|
|
1190
|
+
status: 'failed',
|
|
1191
|
+
exitCode: 1,
|
|
1192
|
+
error
|
|
1193
|
+
};
|
|
1194
|
+
stepsById[step.id] = res;
|
|
1195
|
+
steps.push(res);
|
|
1196
|
+
// Emit step finish event for error
|
|
1197
|
+
routineEvents.emit('run:step', {
|
|
1198
|
+
runId,
|
|
1199
|
+
stepId: step.id,
|
|
1200
|
+
stepType: step.type,
|
|
1201
|
+
status: 'failed',
|
|
1202
|
+
exitCode: 1,
|
|
1203
|
+
});
|
|
1204
|
+
if (!step.continueOnError) {
|
|
1205
|
+
const result = finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
|
|
1206
|
+
routineEvents.emit('run:finished', {
|
|
1207
|
+
runId,
|
|
1208
|
+
status: result.status,
|
|
1209
|
+
exitCode: result.exitCode,
|
|
1210
|
+
durationMs: result.durationMs,
|
|
1211
|
+
});
|
|
1212
|
+
return { ...result, runId };
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const resultValue = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result' }) : undefined;
|
|
1217
|
+
const result = {
|
|
1218
|
+
routine: def.name,
|
|
1219
|
+
version: def.version,
|
|
1220
|
+
status: 'success',
|
|
1221
|
+
exitCode: 0,
|
|
1222
|
+
durationMs: Date.now() - startTime,
|
|
1223
|
+
vars: ctxVars,
|
|
1224
|
+
steps,
|
|
1225
|
+
result: resultValue
|
|
1226
|
+
};
|
|
1227
|
+
// Emit run finished event
|
|
1228
|
+
routineEvents.emit('run:finished', {
|
|
1229
|
+
runId,
|
|
1230
|
+
status: result.status,
|
|
1231
|
+
exitCode: result.exitCode,
|
|
1232
|
+
durationMs: result.durationMs,
|
|
1233
|
+
});
|
|
1234
|
+
return { ...result, runId };
|
|
1235
|
+
}
|
|
1236
|
+
catch (err) {
|
|
1237
|
+
const durationMs = Date.now() - startTime;
|
|
1238
|
+
// Emit failure event
|
|
1239
|
+
routineEvents.emit('run:finished', {
|
|
1240
|
+
runId,
|
|
1241
|
+
status: 'failed',
|
|
1242
|
+
exitCode: 1,
|
|
1243
|
+
durationMs,
|
|
1244
|
+
});
|
|
1245
|
+
throw err;
|
|
1246
|
+
}
|
|
1247
|
+
}
|