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.
Files changed (39) hide show
  1. package/dist/cli.js +31 -0
  2. package/dist/commands/add.js +5 -1
  3. package/dist/commands/browse.js +65 -52
  4. package/dist/commands/download.d.ts +7 -0
  5. package/dist/commands/download.js +36 -0
  6. package/dist/commands/routines.js +10 -3
  7. package/dist/commands/scheduler.js +3 -0
  8. package/dist/commands/secrets.js +37 -24
  9. package/dist/commands/serve.d.ts +4 -0
  10. package/dist/commands/serve.js +9 -0
  11. package/dist/commands/update.js +6 -2
  12. package/dist/core/execute.d.ts +4 -0
  13. package/dist/core/execute.js +37 -12
  14. package/dist/core/remote-client.d.ts +8 -0
  15. package/dist/core/remote-client.js +67 -0
  16. package/dist/core/routine-engine.d.ts +90 -6
  17. package/dist/core/routine-engine.js +769 -221
  18. package/dist/core/routines.d.ts +5 -0
  19. package/dist/core/routines.js +20 -0
  20. package/dist/core/scheduler.d.ts +19 -0
  21. package/dist/core/scheduler.js +79 -1
  22. package/dist/dashboard/api/endpoints.d.ts +14 -0
  23. package/dist/dashboard/api/endpoints.js +562 -0
  24. package/dist/dashboard/api/websocket.d.ts +133 -0
  25. package/dist/dashboard/api/websocket.js +278 -0
  26. package/dist/dashboard/db/index.d.ts +33 -0
  27. package/dist/dashboard/db/index.js +69 -0
  28. package/dist/dashboard/db/runs.d.ts +170 -0
  29. package/dist/dashboard/db/runs.js +475 -0
  30. package/dist/dashboard/db/schema.d.ts +64 -0
  31. package/dist/dashboard/db/schema.js +157 -0
  32. package/dist/mcp/adapter.js +3 -0
  33. package/dist/mcp/server.js +3 -1
  34. package/dist/server/service.d.ts +8 -0
  35. package/dist/server/service.js +192 -6
  36. package/package.json +11 -3
  37. package/src/dashboard/public/assets/index-DN1hIAMO.css +1 -0
  38. package/src/dashboard/public/assets/index-pZeAAQwj.js +331 -0
  39. 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 { parse as parseYaml } from 'yaml';
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
- function validateRoutineDefinition(value, source) {
116
- if (!value || typeof value !== 'object') {
117
- throw new RoutineValidationError('Routine must be an object', { source });
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
- const obj = value;
120
- if (obj.version !== 1) {
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
- if (typeof obj.name !== 'string' || obj.name.length === 0) {
124
- throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
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
- // Validate schedule if present
127
- if (obj.schedule !== undefined) {
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 (!Array.isArray(obj.steps)) {
131
- throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
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
- const steps = obj.steps;
134
- const ids = new Set();
135
- for (const [idx, step] of steps.entries()) {
136
- if (!step || typeof step !== 'object') {
137
- throw new RoutineValidationError('Invalid step (must be an object)', { source, index: idx });
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
- const s = step;
140
- if (typeof s.id !== 'string' || s.id.length === 0) {
141
- throw new RoutineValidationError('Step missing "id"', { source, index: idx });
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 (ids.has(s.id)) {
144
- throw new RoutineValidationError(`Duplicate step id: ${s.id}`, { source, id: s.id });
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
- ids.add(s.id);
147
- if (typeof s.type !== 'string' || !['cli4ai', 'set', 'exec'].includes(s.type)) {
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.timeout !== undefined && typeof s.timeout !== 'number') {
151
- throw new RoutineValidationError(`Invalid timeout for "${s.id}" (must be number ms)`, { source, id: s.id, got: s.timeout });
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
- if (s.continueOnError !== undefined && typeof s.continueOnError !== 'boolean') {
154
- throw new RoutineValidationError(`Invalid continueOnError for "${s.id}" (must be boolean)`, { source, id: s.id, got: s.continueOnError });
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
- if (s.type === 'cli4ai') {
157
- if (typeof s.package !== 'string' || s.package.length === 0) {
158
- throw new RoutineValidationError(`Step "${s.id}" missing "package"`, { source, id: 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
- if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
164
- throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
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
- if (s.env !== undefined) {
167
- if (typeof s.env !== 'object' || s.env === null) {
168
- throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: 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
- if (s.stdin !== undefined && typeof s.stdin !== 'string') {
177
- throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
178
- }
179
- if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
180
- throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
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
- if (s.remote !== undefined && typeof s.remote !== 'string') {
183
- throw new RoutineValidationError(`Step "${s.id}" has invalid "remote" (must be string)`, { source, id: s.id, got: s.remote });
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
- if (s.type === 'set') {
187
- if (typeof s.vars !== 'object' || s.vars === null) {
188
- throw new RoutineValidationError(`Step "${s.id}" missing "vars"`, { source, id: s.id });
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
- for (const [k, v] of Object.entries(s.vars)) {
191
- if (typeof v !== 'string') {
192
- throw new RoutineValidationError(`Step "${s.id}" vars.${k} must be a string template`, { source, id: s.id });
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 (s.type === 'exec') {
197
- if (typeof s.cmd !== 'string' || s.cmd.length === 0) {
198
- throw new RoutineValidationError(`Step "${s.id}" missing "cmd"`, { source, id: s.id });
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
- const stdoutPromise = child.stdout ? collectStream(child.stdout) : Promise.resolve('');
381
- const stderrPromise = child.stderr ? collectStream(child.stderr) : Promise.resolve('');
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.on('data', (chunk) => process.stderr.write(chunk));
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.on('close', (code) => resolve(code ?? 0));
402
- child.on('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
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 [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
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
- for (const step of def.steps) {
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
- steps.push({ id: step.id, type: step.type, rendered: { vars: renderedVars } });
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
- steps.push({ id: step.id, type: step.type, rendered });
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
- steps.push({ id: step.id, type: step.type, rendered });
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
- export async function runRoutine(def, vars, invocationDir) {
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 ctxVars = {};
493
- for (const [k, v] of Object.entries(def.vars ?? {})) {
494
- if (v.default !== undefined)
495
- ctxVars[k] = v.default;
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
- for (const [k, v] of Object.entries(vars))
498
- ctxVars[k] = v;
499
- const stepsById = {};
500
- const ctx = {
501
- vars: ctxVars,
502
- steps: stepsById
503
- };
504
- const steps = [];
505
- for (const step of def.steps) {
506
- if (step.type === 'set') {
507
- const assigned = {};
508
- for (const [k, v] of Object.entries(step.vars)) {
509
- assigned[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}` });
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
- for (const [k, v] of Object.entries(assigned))
512
- ctxVars[k] = v;
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: 'success',
517
- exitCode: 0,
518
- durationMs: 0,
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
- continue;
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
- if (step.type === 'exec') {
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
- const res = await runExecStep(step, ctx, invocationDir);
528
- stepsById[step.id] = res;
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.exitCode !== 0 && !step.continueOnError) {
531
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, res.exitCode ?? 1);
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: execResult.exitCode === 0 ? 'success' : 'failed',
612
- exitCode: execResult.exitCode,
613
- durationMs: execResult.durationMs,
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: error.code === 'INTEGRITY_ERROR' ? 1 : 1,
931
+ exitCode: 1,
642
932
  error
643
933
  };
644
934
  stepsById[step.id] = res;
935
+ results.push(res);
645
936
  steps.push(res);
646
- if (!step.continueOnError) {
647
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
648
- }
649
- continue;
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
+ }