keystone-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +136 -0
  2. package/logo.png +0 -0
  3. package/package.json +45 -0
  4. package/src/cli.ts +775 -0
  5. package/src/db/workflow-db.test.ts +99 -0
  6. package/src/db/workflow-db.ts +265 -0
  7. package/src/expression/evaluator.test.ts +247 -0
  8. package/src/expression/evaluator.ts +517 -0
  9. package/src/parser/agent-parser.test.ts +123 -0
  10. package/src/parser/agent-parser.ts +59 -0
  11. package/src/parser/config-schema.ts +54 -0
  12. package/src/parser/schema.ts +157 -0
  13. package/src/parser/workflow-parser.test.ts +212 -0
  14. package/src/parser/workflow-parser.ts +228 -0
  15. package/src/runner/llm-adapter.test.ts +329 -0
  16. package/src/runner/llm-adapter.ts +306 -0
  17. package/src/runner/llm-executor.test.ts +537 -0
  18. package/src/runner/llm-executor.ts +256 -0
  19. package/src/runner/mcp-client.test.ts +122 -0
  20. package/src/runner/mcp-client.ts +123 -0
  21. package/src/runner/mcp-manager.test.ts +143 -0
  22. package/src/runner/mcp-manager.ts +85 -0
  23. package/src/runner/mcp-server.test.ts +242 -0
  24. package/src/runner/mcp-server.ts +436 -0
  25. package/src/runner/retry.test.ts +52 -0
  26. package/src/runner/retry.ts +58 -0
  27. package/src/runner/shell-executor.test.ts +123 -0
  28. package/src/runner/shell-executor.ts +166 -0
  29. package/src/runner/step-executor.test.ts +465 -0
  30. package/src/runner/step-executor.ts +354 -0
  31. package/src/runner/timeout.test.ts +20 -0
  32. package/src/runner/timeout.ts +30 -0
  33. package/src/runner/tool-integration.test.ts +198 -0
  34. package/src/runner/workflow-runner.test.ts +358 -0
  35. package/src/runner/workflow-runner.ts +955 -0
  36. package/src/ui/dashboard.tsx +165 -0
  37. package/src/utils/auth-manager.test.ts +152 -0
  38. package/src/utils/auth-manager.ts +88 -0
  39. package/src/utils/config-loader.test.ts +52 -0
  40. package/src/utils/config-loader.ts +85 -0
  41. package/src/utils/mermaid.test.ts +51 -0
  42. package/src/utils/mermaid.ts +87 -0
  43. package/src/utils/redactor.test.ts +66 -0
  44. package/src/utils/redactor.ts +60 -0
  45. package/src/utils/workflow-registry.test.ts +108 -0
  46. package/src/utils/workflow-registry.ts +121 -0
@@ -0,0 +1,955 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { WorkflowDb } from '../db/workflow-db.ts';
3
+ import type { ExpressionContext } from '../expression/evaluator.ts';
4
+ import { ExpressionEvaluator } from '../expression/evaluator.ts';
5
+ import type { Step, Workflow, WorkflowStep } from '../parser/schema.ts';
6
+ import { WorkflowParser } from '../parser/workflow-parser.ts';
7
+ import { Redactor } from '../utils/redactor.ts';
8
+ import { WorkflowRegistry } from '../utils/workflow-registry.ts';
9
+ import { MCPManager } from './mcp-manager.ts';
10
+ import { withRetry } from './retry.ts';
11
+ import { type StepResult, WorkflowSuspendedError, executeStep } from './step-executor.ts';
12
+ import { withTimeout } from './timeout.ts';
13
+
14
+ export interface Logger {
15
+ log: (msg: string) => void;
16
+ error: (msg: string) => void;
17
+ warn: (msg: string) => void;
18
+ }
19
+
20
+ /**
21
+ * A logger wrapper that redacts secrets from all log messages
22
+ */
23
+ class RedactingLogger implements Logger {
24
+ constructor(
25
+ private inner: Logger,
26
+ private redactor: Redactor
27
+ ) {}
28
+
29
+ log(msg: string): void {
30
+ this.inner.log(this.redactor.redact(msg));
31
+ }
32
+
33
+ error(msg: string): void {
34
+ this.inner.error(this.redactor.redact(msg));
35
+ }
36
+
37
+ warn(msg: string): void {
38
+ this.inner.warn(this.redactor.redact(msg));
39
+ }
40
+ }
41
+
42
+ export interface RunOptions {
43
+ inputs?: Record<string, unknown>;
44
+ dbPath?: string;
45
+ resumeRunId?: string;
46
+ logger?: Logger;
47
+ mcpManager?: MCPManager;
48
+ }
49
+
50
+ export interface StepContext {
51
+ output?: unknown;
52
+ outputs?: Record<string, unknown>;
53
+ status: 'success' | 'failed' | 'skipped';
54
+ }
55
+
56
+ // Type for foreach results - wraps array to ensure JSON serialization preserves all properties
57
+ export interface ForeachStepContext extends StepContext {
58
+ items: StepContext[]; // Individual iteration results
59
+ // output and outputs inherited from StepContext
60
+ // output: array of output values
61
+ // outputs: mapped outputs object
62
+ }
63
+
64
+ /**
65
+ * Main workflow execution engine
66
+ */
67
+ export class WorkflowRunner {
68
+ private workflow: Workflow;
69
+ private db: WorkflowDb;
70
+ private runId: string;
71
+ private stepContexts: Map<string, StepContext | ForeachStepContext> = new Map();
72
+ private inputs: Record<string, unknown>;
73
+ private secrets: Record<string, string>;
74
+ private redactor: Redactor;
75
+ private resumeRunId?: string;
76
+ private restored = false;
77
+ private logger: Logger;
78
+ private mcpManager: MCPManager;
79
+
80
+ constructor(workflow: Workflow, options: RunOptions = {}) {
81
+ this.workflow = workflow;
82
+ this.db = new WorkflowDb(options.dbPath);
83
+ this.secrets = this.loadSecrets();
84
+ this.redactor = new Redactor(this.secrets);
85
+ // Wrap the logger with a redactor to prevent secret leakage in logs
86
+ const rawLogger = options.logger || console;
87
+ this.logger = new RedactingLogger(rawLogger, this.redactor);
88
+ this.mcpManager = options.mcpManager || new MCPManager();
89
+
90
+ if (options.resumeRunId) {
91
+ // Resume existing run
92
+ this.runId = options.resumeRunId;
93
+ this.resumeRunId = options.resumeRunId;
94
+ this.inputs = {}; // Will be loaded from DB in restoreState
95
+ } else {
96
+ // Start new run
97
+ this.inputs = options.inputs || {};
98
+ this.runId = randomUUID();
99
+ }
100
+
101
+ this.setupSignalHandlers();
102
+ }
103
+
104
+ /**
105
+ * Get the current run ID
106
+ */
107
+ public getRunId(): string {
108
+ return this.runId;
109
+ }
110
+
111
+ /**
112
+ * Restore state from a previous run (for resume functionality)
113
+ */
114
+ private async restoreState(): Promise<void> {
115
+ const run = this.db.getRun(this.runId);
116
+ if (!run) {
117
+ throw new Error(`Run ${this.runId} not found`);
118
+ }
119
+
120
+ // Only allow resuming failed or paused runs
121
+ if (run.status !== 'failed' && run.status !== 'paused') {
122
+ throw new Error(
123
+ `Cannot resume run with status '${run.status}'. Only 'failed' or 'paused' runs can be resumed.`
124
+ );
125
+ }
126
+
127
+ // Restore inputs from the previous run to ensure consistency
128
+ try {
129
+ this.inputs = JSON.parse(run.inputs);
130
+ } catch (error) {
131
+ throw new Error(
132
+ `Failed to parse inputs from run: ${error instanceof Error ? error.message : String(error)}`
133
+ );
134
+ }
135
+
136
+ // Load all step executions for this run
137
+ const steps = this.db.getStepsByRun(this.runId);
138
+
139
+ // Group steps by step_id to handle foreach loops (multiple executions per step_id)
140
+ const stepExecutionsByStepId = new Map<string, typeof steps>();
141
+ for (const step of steps) {
142
+ if (!stepExecutionsByStepId.has(step.step_id)) {
143
+ stepExecutionsByStepId.set(step.step_id, []);
144
+ }
145
+ stepExecutionsByStepId.get(step.step_id)?.push(step);
146
+ }
147
+
148
+ // Get topological order to ensure dependencies are restored before dependents
149
+ const executionOrder = WorkflowParser.topologicalSort(this.workflow);
150
+ const completedStepIds = new Set<string>();
151
+
152
+ // Reconstruct step contexts in topological order
153
+ for (const stepId of executionOrder) {
154
+ const stepExecutions = stepExecutionsByStepId.get(stepId);
155
+ if (!stepExecutions || stepExecutions.length === 0) continue;
156
+
157
+ const stepDef = this.workflow.steps.find((s) => s.id === stepId);
158
+ if (!stepDef) continue;
159
+
160
+ const isForeach = !!stepDef.foreach;
161
+
162
+ if (isForeach) {
163
+ // Reconstruct foreach aggregated context
164
+ const items: StepContext[] = [];
165
+ const outputs: unknown[] = [];
166
+ let allSuccess = true;
167
+
168
+ // Sort by iteration_index to ensure correct order
169
+ const sortedExecs = [...stepExecutions].sort(
170
+ (a, b) => (a.iteration_index ?? 0) - (b.iteration_index ?? 0)
171
+ );
172
+
173
+ for (const exec of sortedExecs) {
174
+ if (exec.iteration_index === null) continue; // Skip parent step record
175
+
176
+ if (exec.status === 'success' || exec.status === 'skipped') {
177
+ const output = exec.output ? JSON.parse(exec.output) : null;
178
+ items[exec.iteration_index] = {
179
+ output,
180
+ outputs:
181
+ typeof output === 'object' && output !== null && !Array.isArray(output)
182
+ ? (output as Record<string, unknown>)
183
+ : {},
184
+ status: exec.status as 'success' | 'skipped',
185
+ };
186
+ outputs[exec.iteration_index] = output;
187
+ } else {
188
+ allSuccess = false;
189
+ // Still populate with placeholder if failed
190
+ items[exec.iteration_index] = {
191
+ output: null,
192
+ outputs: {},
193
+ status: exec.status as 'failed' | 'running' | 'pending',
194
+ };
195
+ }
196
+ }
197
+
198
+ // We need to know the total expected items to decide if the whole step is complete
199
+ // Evaluate the foreach expression again
200
+ let expectedCount = -1;
201
+ try {
202
+ const baseContext = this.buildContext();
203
+ const foreachExpr = stepDef.foreach;
204
+ if (foreachExpr) {
205
+ const foreachItems = ExpressionEvaluator.evaluate(foreachExpr, baseContext);
206
+ if (Array.isArray(foreachItems)) {
207
+ expectedCount = foreachItems.length;
208
+ }
209
+ }
210
+ } catch (e) {
211
+ // If we can't evaluate yet (dependencies not met?), we can't be sure it's complete
212
+ allSuccess = false;
213
+ }
214
+
215
+ // Check if we have all items (no gaps)
216
+ const hasAllItems =
217
+ expectedCount !== -1 &&
218
+ items.length === expectedCount &&
219
+ !Array.from({ length: expectedCount }).some((_, i) => !items[i]);
220
+
221
+ // Always restore what we have to allow partial expression evaluation
222
+ const mappedOutputs = this.aggregateOutputs(outputs);
223
+ this.stepContexts.set(stepId, {
224
+ output: outputs,
225
+ outputs: mappedOutputs,
226
+ status: allSuccess && hasAllItems ? 'success' : 'failed',
227
+ items,
228
+ } as ForeachStepContext);
229
+
230
+ // Only mark as fully completed if all iterations completed successfully AND we have all items
231
+ if (allSuccess && hasAllItems) {
232
+ completedStepIds.add(stepId);
233
+ }
234
+ } else {
235
+ // Single execution step
236
+ const exec = stepExecutions[0];
237
+ if (exec.status === 'success' || exec.status === 'skipped') {
238
+ const output = exec.output ? JSON.parse(exec.output) : null;
239
+ this.stepContexts.set(stepId, {
240
+ output,
241
+ outputs:
242
+ typeof output === 'object' && output !== null && !Array.isArray(output)
243
+ ? (output as Record<string, unknown>)
244
+ : {},
245
+ status: exec.status as 'success' | 'skipped',
246
+ });
247
+ completedStepIds.add(stepId);
248
+ }
249
+ }
250
+ }
251
+
252
+ this.restored = true;
253
+ this.logger.log(`✓ Restored state: ${completedStepIds.size} step(s) already completed`);
254
+ }
255
+
256
+ /**
257
+ * Setup signal handlers for graceful shutdown
258
+ */
259
+ private setupSignalHandlers(): void {
260
+ const handleShutdown = async (signal: string) => {
261
+ this.logger.log(`\n\n🛑 Received ${signal}. Cleaning up...`);
262
+ try {
263
+ await this.db.updateRunStatus(
264
+ this.runId,
265
+ 'failed',
266
+ undefined,
267
+ `Cancelled by user (${signal})`
268
+ );
269
+ this.logger.log('✓ Run status updated to failed');
270
+ this.db.close();
271
+ } catch (error) {
272
+ this.logger.error('Error during cleanup:', error);
273
+ }
274
+ process.exit(130); // Standard exit code for SIGINT
275
+ };
276
+
277
+ process.on('SIGINT', () => handleShutdown('SIGINT'));
278
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'));
279
+ }
280
+
281
+ /**
282
+ * Load secrets from environment
283
+ */
284
+ private loadSecrets(): Record<string, string> {
285
+ const secrets: Record<string, string> = {};
286
+
287
+ // Bun automatically loads .env file
288
+ for (const [key, value] of Object.entries(Bun.env)) {
289
+ if (value) {
290
+ secrets[key] = value;
291
+ }
292
+ }
293
+ return secrets;
294
+ }
295
+
296
+ /**
297
+ * Aggregate outputs from multiple iterations of a foreach step
298
+ */
299
+ private aggregateOutputs(outputs: unknown[]): Record<string, unknown> {
300
+ const mappedOutputs: Record<string, unknown> = { length: outputs.length };
301
+ const allKeys = new Set<string>();
302
+
303
+ for (const output of outputs) {
304
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
305
+ for (const key of Object.keys(output)) {
306
+ allKeys.add(key);
307
+ }
308
+ }
309
+ }
310
+
311
+ for (const key of allKeys) {
312
+ mappedOutputs[key] = outputs.map((o) =>
313
+ o && typeof o === 'object' && !Array.isArray(o) && key in (o as Record<string, unknown>)
314
+ ? (o as Record<string, unknown>)[key]
315
+ : null
316
+ );
317
+ }
318
+ return mappedOutputs;
319
+ }
320
+
321
+ /**
322
+ * Apply workflow defaults to inputs and validate types
323
+ */
324
+ private applyDefaultsAndValidate(): void {
325
+ if (!this.workflow.inputs) return;
326
+
327
+ for (const [key, config] of Object.entries(this.workflow.inputs)) {
328
+ // Apply default if missing
329
+ if (this.inputs[key] === undefined && config.default !== undefined) {
330
+ this.inputs[key] = config.default;
331
+ }
332
+
333
+ // Validate required inputs
334
+ if (this.inputs[key] === undefined) {
335
+ throw new Error(`Missing required input: ${key}`);
336
+ }
337
+
338
+ // Basic type validation
339
+ const value = this.inputs[key];
340
+ const type = config.type.toLowerCase();
341
+
342
+ if (type === 'string' && typeof value !== 'string') {
343
+ throw new Error(`Input "${key}" must be a string, got ${typeof value}`);
344
+ }
345
+ if (type === 'number' && typeof value !== 'number') {
346
+ throw new Error(`Input "${key}" must be a number, got ${typeof value}`);
347
+ }
348
+ if (type === 'boolean' && typeof value !== 'boolean') {
349
+ throw new Error(`Input "${key}" must be a boolean, got ${typeof value}`);
350
+ }
351
+ if (type === 'array' && !Array.isArray(value)) {
352
+ throw new Error(`Input "${key}" must be an array, got ${typeof value}`);
353
+ }
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Build expression context for evaluation
359
+ */
360
+ private buildContext(item?: unknown, index?: number): ExpressionContext {
361
+ const stepsContext: Record<
362
+ string,
363
+ {
364
+ output?: unknown;
365
+ outputs?: Record<string, unknown>;
366
+ status?: string;
367
+ items?: StepContext[];
368
+ }
369
+ > = {};
370
+
371
+ for (const [stepId, ctx] of this.stepContexts.entries()) {
372
+ // For foreach results, include items array for iteration access
373
+ if ('items' in ctx && ctx.items) {
374
+ stepsContext[stepId] = {
375
+ output: ctx.output,
376
+ outputs: ctx.outputs,
377
+ status: ctx.status,
378
+ items: ctx.items, // Allows ${{ steps.id.items[0] }} or ${{ steps.id.items.every(...) }}
379
+ };
380
+ } else {
381
+ stepsContext[stepId] = {
382
+ output: ctx.output,
383
+ outputs: ctx.outputs,
384
+ status: ctx.status,
385
+ };
386
+ }
387
+ }
388
+
389
+ return {
390
+ inputs: this.inputs,
391
+ secrets: this.secrets,
392
+ steps: stepsContext,
393
+ item,
394
+ index,
395
+ env: this.workflow.env,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Evaluate a conditional expression
401
+ */
402
+ private evaluateCondition(condition: string, context: ExpressionContext): boolean {
403
+ const result = ExpressionEvaluator.evaluate(condition, context);
404
+ return Boolean(result);
405
+ }
406
+
407
+ /**
408
+ * Check if a step should be skipped based on its condition
409
+ */
410
+ private shouldSkipStep(step: Step, context: ExpressionContext): boolean {
411
+ if (!step.if) return false;
412
+
413
+ try {
414
+ return !this.evaluateCondition(step.if, context);
415
+ } catch (error) {
416
+ this.logger.error(
417
+ `Warning: Failed to evaluate condition for step ${step.id}: ${error instanceof Error ? error.message : String(error)}`
418
+ );
419
+ return true; // Skip on error
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Execute a single step instance and return the result
425
+ * Does NOT update global stepContexts
426
+ */
427
+ private async executeStepInternal(
428
+ step: Step,
429
+ context: ExpressionContext,
430
+ stepExecId: string
431
+ ): Promise<StepContext> {
432
+ await this.db.startStep(stepExecId);
433
+
434
+ const operation = async () => {
435
+ const result = await executeStep(
436
+ step,
437
+ context,
438
+ this.logger,
439
+ this.executeSubWorkflow.bind(this),
440
+ this.mcpManager
441
+ );
442
+ if (result.status === 'failed') {
443
+ throw new Error(result.error || 'Step failed');
444
+ }
445
+ return result;
446
+ };
447
+
448
+ try {
449
+ const operationWithTimeout = async () => {
450
+ if (step.timeout) {
451
+ return await withTimeout(operation(), step.timeout, `Step ${step.id}`);
452
+ }
453
+ return await operation();
454
+ };
455
+
456
+ const result = await withRetry(operationWithTimeout, step.retry, async (attempt, error) => {
457
+ this.logger.log(` ↻ Retry ${attempt}/${step.retry?.count} for step ${step.id}`);
458
+ await this.db.incrementRetry(stepExecId);
459
+ });
460
+
461
+ if (result.status === 'suspended') {
462
+ await this.db.completeStep(stepExecId, 'pending', null, 'Waiting for human input');
463
+ return result;
464
+ }
465
+
466
+ // Redact secrets from output and error before storing
467
+ const redactedOutput = this.redactor.redactValue(result.output);
468
+ const redactedError = result.error ? this.redactor.redact(result.error) : undefined;
469
+
470
+ await this.db.completeStep(stepExecId, result.status, redactedOutput, redactedError);
471
+
472
+ // Ensure outputs is always an object for consistent access
473
+ let outputs: Record<string, unknown>;
474
+ if (
475
+ typeof result.output === 'object' &&
476
+ result.output !== null &&
477
+ !Array.isArray(result.output)
478
+ ) {
479
+ outputs = result.output as Record<string, unknown>;
480
+ } else {
481
+ // For non-object outputs (strings, numbers, etc.), provide empty object
482
+ // Users can still access the raw value via .output
483
+ outputs = {};
484
+ }
485
+
486
+ return {
487
+ output: result.output,
488
+ outputs,
489
+ status: result.status,
490
+ };
491
+ } catch (error) {
492
+ const errorMsg = error instanceof Error ? error.message : String(error);
493
+ const redactedErrorMsg = this.redactor.redact(errorMsg);
494
+ this.logger.error(` ✗ Step ${step.id} failed: ${redactedErrorMsg}`);
495
+ await this.db.completeStep(stepExecId, 'failed', null, redactedErrorMsg);
496
+
497
+ // Return failed context
498
+ return {
499
+ output: null,
500
+ outputs: {},
501
+ status: 'failed',
502
+ };
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Execute a step (handles foreach if present)
508
+ */
509
+ private async executeStepWithForeach(step: Step): Promise<void> {
510
+ const baseContext = this.buildContext();
511
+
512
+ if (this.shouldSkipStep(step, baseContext)) {
513
+ this.logger.log(` ⊘ Skipping step ${step.id} (condition not met)`);
514
+ const stepExecId = randomUUID();
515
+ await this.db.createStep(stepExecId, this.runId, step.id);
516
+ await this.db.completeStep(stepExecId, 'skipped', null);
517
+ this.stepContexts.set(step.id, { status: 'skipped' });
518
+ return;
519
+ }
520
+
521
+ if (step.foreach) {
522
+ const items = ExpressionEvaluator.evaluate(step.foreach, baseContext);
523
+ if (!Array.isArray(items)) {
524
+ throw new Error(`foreach expression must evaluate to an array: ${step.foreach}`);
525
+ }
526
+
527
+ this.logger.log(` ⤷ Executing step ${step.id} for ${items.length} items`);
528
+
529
+ // Evaluate concurrency if it's an expression, otherwise use the number directly
530
+ let concurrencyLimit = items.length;
531
+ if (step.concurrency !== undefined) {
532
+ if (typeof step.concurrency === 'string') {
533
+ concurrencyLimit = Number(ExpressionEvaluator.evaluate(step.concurrency, baseContext));
534
+ if (!Number.isInteger(concurrencyLimit) || concurrencyLimit <= 0) {
535
+ throw new Error(
536
+ `concurrency must evaluate to a positive integer, got: ${concurrencyLimit}`
537
+ );
538
+ }
539
+ } else {
540
+ concurrencyLimit = step.concurrency;
541
+ }
542
+ }
543
+
544
+ // Create parent step record in DB
545
+ const parentStepExecId = randomUUID();
546
+ await this.db.createStep(parentStepExecId, this.runId, step.id);
547
+ await this.db.startStep(parentStepExecId);
548
+
549
+ try {
550
+ // Initialize results array with existing context or empty slots
551
+ const existingContext = this.stepContexts.get(step.id) as ForeachStepContext;
552
+ const itemResults: StepContext[] = existingContext?.items || new Array(items.length);
553
+
554
+ // Ensure array is correct length if items changed (unlikely in resume but safe)
555
+ if (itemResults.length !== items.length) {
556
+ itemResults.length = items.length;
557
+ }
558
+
559
+ // Worker pool implementation for true concurrency
560
+ let currentIndex = 0;
561
+ let aborted = false;
562
+ const workers = new Array(Math.min(concurrencyLimit, items.length))
563
+ .fill(null)
564
+ .map(async () => {
565
+ while (currentIndex < items.length && !aborted) {
566
+ const i = currentIndex++; // Capture index atomically
567
+ const item = items[i];
568
+
569
+ // Skip if already successful or skipped in previous run or by another worker
570
+ if (
571
+ itemResults[i] &&
572
+ (itemResults[i].status === 'success' || itemResults[i].status === 'skipped')
573
+ ) {
574
+ continue;
575
+ }
576
+
577
+ const itemContext = this.buildContext(item, i);
578
+
579
+ // Check DB again for robustness (in case itemResults wasn't fully restored)
580
+ const existingExec = this.db.getStepByIteration(this.runId, step.id, i);
581
+ if (
582
+ existingExec &&
583
+ (existingExec.status === 'success' || existingExec.status === 'skipped')
584
+ ) {
585
+ const output = existingExec.output ? JSON.parse(existingExec.output) : null;
586
+ itemResults[i] = {
587
+ output,
588
+ outputs:
589
+ typeof output === 'object' && output !== null && !Array.isArray(output)
590
+ ? (output as Record<string, unknown>)
591
+ : {},
592
+ status: existingExec.status as 'success' | 'skipped',
593
+ };
594
+ continue;
595
+ }
596
+
597
+ const stepExecId = randomUUID();
598
+ await this.db.createStep(stepExecId, this.runId, step.id, i);
599
+
600
+ // Execute and store result at correct index
601
+ try {
602
+ itemResults[i] = await this.executeStepInternal(step, itemContext, stepExecId);
603
+ if (itemResults[i].status === 'failed') {
604
+ aborted = true;
605
+ }
606
+ } catch (error) {
607
+ aborted = true;
608
+ throw error;
609
+ }
610
+ }
611
+ });
612
+
613
+ await Promise.all(workers);
614
+
615
+ // Aggregate results to match Spec requirements
616
+ // This allows:
617
+ // 1. ${{ steps.id.output }} -> array of output values
618
+ // 2. ${{ steps.id.items[0].status }} -> 'success'
619
+ // 3. ${{ steps.id.items.every(s => s.status == 'success') }} -> works via items array
620
+ const outputs = itemResults.map((r) => r.output);
621
+ const allSuccess = itemResults.every((r) => r.status === 'success');
622
+
623
+ // Map child properties for easier access
624
+ // If outputs are [{ id: 1 }, { id: 2 }], then outputs.id = [1, 2]
625
+ const mappedOutputs = this.aggregateOutputs(outputs);
626
+
627
+ // Use proper object structure that serializes correctly
628
+ const aggregatedContext: ForeachStepContext = {
629
+ output: outputs,
630
+ outputs: mappedOutputs,
631
+ status: allSuccess ? 'success' : 'failed',
632
+ items: itemResults,
633
+ };
634
+
635
+ this.stepContexts.set(step.id, aggregatedContext);
636
+
637
+ // Update parent step record with aggregated status
638
+ await this.db.completeStep(
639
+ parentStepExecId,
640
+ allSuccess ? 'success' : 'failed',
641
+ aggregatedContext,
642
+ allSuccess ? undefined : 'One or more iterations failed'
643
+ );
644
+
645
+ if (!allSuccess) {
646
+ throw new Error(`Step ${step.id} failed: one or more iterations failed`);
647
+ }
648
+ } catch (error) {
649
+ // Mark parent step as failed
650
+ const errorMsg = error instanceof Error ? error.message : String(error);
651
+ await this.db.completeStep(parentStepExecId, 'failed', null, errorMsg);
652
+ throw error;
653
+ }
654
+ } else {
655
+ // Single execution
656
+ const stepExecId = randomUUID();
657
+ await this.db.createStep(stepExecId, this.runId, step.id);
658
+
659
+ const result = await this.executeStepInternal(step, baseContext, stepExecId);
660
+
661
+ // Update global state
662
+ this.stepContexts.set(step.id, result);
663
+
664
+ if (result.status === 'suspended') {
665
+ const inputType = step.type === 'human' ? step.inputType : 'confirm';
666
+ throw new WorkflowSuspendedError(result.error || 'Workflow suspended', step.id, inputType);
667
+ }
668
+
669
+ if (result.status === 'failed') {
670
+ throw new Error(`Step ${step.id} failed`);
671
+ }
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Execute a sub-workflow step
677
+ */
678
+ private async executeSubWorkflow(
679
+ step: WorkflowStep,
680
+ context: ExpressionContext
681
+ ): Promise<StepResult> {
682
+ const workflowPath = WorkflowRegistry.resolvePath(step.path);
683
+ const workflow = WorkflowParser.loadWorkflow(workflowPath);
684
+
685
+ // Evaluate inputs for the sub-workflow
686
+ const inputs: Record<string, unknown> = {};
687
+ if (step.inputs) {
688
+ for (const [key, value] of Object.entries(step.inputs)) {
689
+ inputs[key] = ExpressionEvaluator.evaluate(value, context);
690
+ }
691
+ }
692
+
693
+ // Create a new runner for the sub-workflow
694
+ // We pass the same dbPath to share the state database
695
+ const subRunner = new WorkflowRunner(workflow, {
696
+ inputs,
697
+ dbPath: this.db.dbPath,
698
+ logger: this.logger,
699
+ mcpManager: this.mcpManager,
700
+ });
701
+
702
+ try {
703
+ const output = await subRunner.run();
704
+ return {
705
+ output,
706
+ status: 'success',
707
+ };
708
+ } catch (error) {
709
+ return {
710
+ output: null,
711
+ status: 'failed',
712
+ error: error instanceof Error ? error.message : String(error),
713
+ };
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Redact secrets from a value
719
+ */
720
+ public redact<T>(value: T): T {
721
+ return this.redactor.redactValue(value) as T;
722
+ }
723
+
724
+ /**
725
+ * Execute the workflow
726
+ */
727
+ async run(): Promise<Record<string, unknown>> {
728
+ // Handle resume state restoration
729
+ if (this.resumeRunId && !this.restored) {
730
+ await this.restoreState();
731
+ }
732
+
733
+ const isResume = this.stepContexts.size > 0;
734
+
735
+ this.logger.log(`\n🏛️ ${isResume ? 'Resuming' : 'Running'} workflow: ${this.workflow.name}`);
736
+ this.logger.log(`Run ID: ${this.runId}`);
737
+ this.logger.log(
738
+ '\n⚠️ Security Warning: Only run workflows from trusted sources.\n' +
739
+ ' Workflows can execute arbitrary shell commands and access your environment.\n'
740
+ );
741
+
742
+ // Apply defaults and validate inputs
743
+ this.applyDefaultsAndValidate();
744
+
745
+ // Create run record (only for new runs, not for resume)
746
+ if (!isResume) {
747
+ await this.db.createRun(this.runId, this.workflow.name, this.inputs);
748
+ }
749
+ await this.db.updateRunStatus(this.runId, 'running');
750
+
751
+ try {
752
+ // Get execution order using topological sort
753
+ const executionOrder = WorkflowParser.topologicalSort(this.workflow);
754
+ const stepMap = new Map(this.workflow.steps.map((s) => [s.id, s]));
755
+
756
+ // Initialize completedSteps with already completed steps (for resume)
757
+ const completedSteps = new Set<string>(this.stepContexts.keys());
758
+
759
+ // Filter out already completed steps from execution order
760
+ const remainingSteps = executionOrder.filter((stepId) => !completedSteps.has(stepId));
761
+
762
+ if (isResume && remainingSteps.length === 0) {
763
+ this.logger.log('All steps already completed. Nothing to resume.\n');
764
+ // Evaluate outputs from completed state
765
+ const outputs = this.evaluateOutputs();
766
+ const redactedOutputs = this.redactor.redactValue(outputs) as Record<string, unknown>;
767
+ await this.db.updateRunStatus(this.runId, 'completed', redactedOutputs);
768
+ this.logger.log('✨ Workflow already completed!\n');
769
+ return outputs;
770
+ }
771
+
772
+ if (isResume && completedSteps.size > 0) {
773
+ this.logger.log(`Skipping ${completedSteps.size} already completed step(s)\n`);
774
+ }
775
+
776
+ this.logger.log(`Execution order: ${executionOrder.join(' → ')}\n`);
777
+
778
+ // Execute steps in parallel where possible (respecting dependencies)
779
+ const pendingSteps = new Set(remainingSteps);
780
+ const runningPromises = new Map<string, Promise<void>>();
781
+
782
+ try {
783
+ while (pendingSteps.size > 0 || runningPromises.size > 0) {
784
+ // 1. Find runnable steps (all dependencies met)
785
+ for (const stepId of pendingSteps) {
786
+ const step = stepMap.get(stepId);
787
+ if (!step) {
788
+ throw new Error(`Step ${stepId} not found in workflow`);
789
+ }
790
+ const dependenciesMet = step.needs.every((dep) => completedSteps.has(dep));
791
+
792
+ if (dependenciesMet) {
793
+ pendingSteps.delete(stepId);
794
+
795
+ // Start execution
796
+ this.logger.log(`▶ Executing step: ${step.id} (${step.type})`);
797
+ const promise = this.executeStepWithForeach(step)
798
+ .then(() => {
799
+ completedSteps.add(stepId);
800
+ runningPromises.delete(stepId);
801
+ this.logger.log(` ✓ Step ${step.id} completed\n`);
802
+ })
803
+ .catch((err) => {
804
+ runningPromises.delete(stepId);
805
+ throw err; // Fail fast
806
+ });
807
+
808
+ runningPromises.set(stepId, promise);
809
+ }
810
+ }
811
+
812
+ // 2. Detect deadlock
813
+ if (runningPromises.size === 0 && pendingSteps.size > 0) {
814
+ const pendingList = Array.from(pendingSteps).join(', ');
815
+ throw new Error(
816
+ `Deadlock detected in workflow execution. Pending steps: ${pendingList}`
817
+ );
818
+ }
819
+
820
+ // 3. Wait for at least one step to finish before checking again
821
+ if (runningPromises.size > 0) {
822
+ await Promise.race(runningPromises.values());
823
+ }
824
+ }
825
+ } catch (error) {
826
+ // Wait for other parallel steps to settle to avoid unhandled rejections
827
+ if (runningPromises.size > 0) {
828
+ await Promise.allSettled(runningPromises.values());
829
+ }
830
+ throw error;
831
+ }
832
+
833
+ // Evaluate outputs
834
+ const outputs = this.evaluateOutputs();
835
+
836
+ // Redact secrets from outputs before storing
837
+ const redactedOutputs = this.redactor.redactValue(outputs) as Record<string, unknown>;
838
+
839
+ // Mark run as complete
840
+ await this.db.updateRunStatus(this.runId, 'completed', redactedOutputs);
841
+
842
+ this.logger.log('✨ Workflow completed successfully!\n');
843
+
844
+ return outputs;
845
+ } catch (error) {
846
+ if (error instanceof WorkflowSuspendedError) {
847
+ await this.db.updateRunStatus(this.runId, 'paused');
848
+ this.logger.log(`\n⏸ Workflow paused: ${error.message}`);
849
+ throw error;
850
+ }
851
+ const errorMsg = error instanceof Error ? error.message : String(error);
852
+ this.logger.error(`\n✗ Workflow failed: ${errorMsg}\n`);
853
+ await this.db.updateRunStatus(this.runId, 'failed', undefined, errorMsg);
854
+ throw error;
855
+ } finally {
856
+ await this.runFinally();
857
+ await this.mcpManager.stopAll();
858
+ this.db.close();
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Execute the finally block if defined
864
+ */
865
+ private async runFinally(): Promise<void> {
866
+ if (!this.workflow.finally || this.workflow.finally.length === 0) {
867
+ return;
868
+ }
869
+
870
+ this.logger.log('\n🏁 Executing finally block...');
871
+
872
+ const stepMap = new Map(this.workflow.finally.map((s) => [s.id, s]));
873
+ const completedFinallySteps = new Set<string>();
874
+ const pendingFinallySteps = new Set(this.workflow.finally.map((s) => s.id));
875
+ const runningPromises = new Map<string, Promise<void>>();
876
+
877
+ try {
878
+ while (pendingFinallySteps.size > 0 || runningPromises.size > 0) {
879
+ for (const stepId of pendingFinallySteps) {
880
+ const step = stepMap.get(stepId);
881
+ if (!step) continue;
882
+
883
+ // Dependencies can be from main steps (already in this.stepContexts) or previous finally steps
884
+ const dependenciesMet = step.needs.every(
885
+ (dep) => this.stepContexts.has(dep) || completedFinallySteps.has(dep)
886
+ );
887
+
888
+ if (dependenciesMet) {
889
+ pendingFinallySteps.delete(stepId);
890
+
891
+ this.logger.log(`▶ Executing finally step: ${step.id} (${step.type})`);
892
+ const promise = this.executeStepWithForeach(step)
893
+ .then(() => {
894
+ completedFinallySteps.add(stepId);
895
+ runningPromises.delete(stepId);
896
+ this.logger.log(` ✓ Finally step ${step.id} completed\n`);
897
+ })
898
+ .catch((err) => {
899
+ runningPromises.delete(stepId);
900
+ this.logger.error(
901
+ ` ✗ Finally step ${step.id} failed: ${err instanceof Error ? err.message : String(err)}`
902
+ );
903
+ // We continue with other finally steps if possible
904
+ completedFinallySteps.add(stepId); // Mark as "done" (even if failed) so dependents can run
905
+ });
906
+
907
+ runningPromises.set(stepId, promise);
908
+ }
909
+ }
910
+
911
+ if (runningPromises.size === 0 && pendingFinallySteps.size > 0) {
912
+ this.logger.error('Deadlock in finally block detected');
913
+ break;
914
+ }
915
+
916
+ if (runningPromises.size > 0) {
917
+ await Promise.race(runningPromises.values());
918
+ }
919
+ }
920
+ } catch (error) {
921
+ // Wait for other parallel steps to settle to avoid unhandled rejections
922
+ if (runningPromises.size > 0) {
923
+ await Promise.allSettled(runningPromises.values());
924
+ }
925
+ this.logger.error(
926
+ `Error in finally block: ${error instanceof Error ? error.message : String(error)}`
927
+ );
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Evaluate workflow outputs
933
+ */
934
+ private evaluateOutputs(): Record<string, unknown> {
935
+ if (!this.workflow.outputs) {
936
+ return {};
937
+ }
938
+
939
+ const context = this.buildContext();
940
+ const outputs: Record<string, unknown> = {};
941
+
942
+ for (const [key, expression] of Object.entries(this.workflow.outputs)) {
943
+ try {
944
+ outputs[key] = ExpressionEvaluator.evaluate(expression, context);
945
+ } catch (error) {
946
+ this.logger.warn(
947
+ `Warning: Failed to evaluate output "${key}": ${error instanceof Error ? error.message : String(error)}`
948
+ );
949
+ outputs[key] = null;
950
+ }
951
+ }
952
+
953
+ return outputs;
954
+ }
955
+ }