keystone-cli 0.8.0 → 1.0.1

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 (105) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +809 -90
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +469 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +489 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-integration.test.ts +36 -36
  44. package/src/runner/standard-tools.test.ts +18 -0
  45. package/src/runner/standard-tools.ts +110 -37
  46. package/src/runner/step-executor.test.ts +176 -16
  47. package/src/runner/step-executor.ts +530 -86
  48. package/src/runner/stream-utils.test.ts +14 -0
  49. package/src/runner/subflow-outputs.test.ts +103 -0
  50. package/src/runner/test-harness.ts +161 -0
  51. package/src/runner/tool-integration.test.ts +73 -79
  52. package/src/runner/workflow-runner.test.ts +492 -15
  53. package/src/runner/workflow-runner.ts +1438 -79
  54. package/src/runner/workflow-subflows.test.ts +255 -0
  55. package/src/templates/agents/keystone-architect.md +19 -14
  56. package/src/templates/agents/tester.md +21 -0
  57. package/src/templates/batch-processor.yaml +1 -1
  58. package/src/templates/child-rollback.yaml +11 -0
  59. package/src/templates/decompose-implement.yaml +53 -0
  60. package/src/templates/decompose-problem.yaml +159 -0
  61. package/src/templates/decompose-research.yaml +52 -0
  62. package/src/templates/decompose-review.yaml +51 -0
  63. package/src/templates/dev.yaml +134 -0
  64. package/src/templates/engine-example.yaml +33 -0
  65. package/src/templates/fan-out-fan-in.yaml +61 -0
  66. package/src/templates/loop-parallel.yaml +1 -1
  67. package/src/templates/memory-service.yaml +1 -1
  68. package/src/templates/parent-rollback.yaml +16 -0
  69. package/src/templates/robust-automation.yaml +1 -1
  70. package/src/templates/scaffold-feature.yaml +29 -27
  71. package/src/templates/scaffold-generate.yaml +41 -0
  72. package/src/templates/scaffold-plan.yaml +53 -0
  73. package/src/types/status.ts +3 -0
  74. package/src/ui/dashboard.tsx +4 -3
  75. package/src/utils/assets.macro.ts +36 -0
  76. package/src/utils/auth-manager.ts +585 -8
  77. package/src/utils/blueprint-utils.test.ts +49 -0
  78. package/src/utils/blueprint-utils.ts +80 -0
  79. package/src/utils/circuit-breaker.test.ts +177 -0
  80. package/src/utils/circuit-breaker.ts +160 -0
  81. package/src/utils/config-loader.test.ts +100 -13
  82. package/src/utils/config-loader.ts +44 -17
  83. package/src/utils/constants.ts +62 -0
  84. package/src/utils/error-renderer.test.ts +267 -0
  85. package/src/utils/error-renderer.ts +320 -0
  86. package/src/utils/json-parser.test.ts +4 -0
  87. package/src/utils/json-parser.ts +18 -1
  88. package/src/utils/mermaid.ts +4 -0
  89. package/src/utils/paths.test.ts +46 -0
  90. package/src/utils/paths.ts +70 -0
  91. package/src/utils/process-sandbox.test.ts +128 -0
  92. package/src/utils/process-sandbox.ts +293 -0
  93. package/src/utils/rate-limiter.test.ts +143 -0
  94. package/src/utils/rate-limiter.ts +221 -0
  95. package/src/utils/redactor.test.ts +23 -15
  96. package/src/utils/redactor.ts +65 -25
  97. package/src/utils/resource-loader.test.ts +54 -0
  98. package/src/utils/resource-loader.ts +158 -0
  99. package/src/utils/sandbox.test.ts +69 -4
  100. package/src/utils/sandbox.ts +69 -6
  101. package/src/utils/schema-validator.ts +65 -0
  102. package/src/utils/workflow-registry.test.ts +57 -0
  103. package/src/utils/workflow-registry.ts +45 -25
  104. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  105. /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
2
4
  import { dirname, join } from 'node:path';
3
5
  import { MemoryDb } from '../db/memory-db.ts';
4
6
  import { type RunStatus, WorkflowDb } from '../db/workflow-db.ts';
@@ -7,14 +9,22 @@ import { ExpressionEvaluator } from '../expression/evaluator.ts';
7
9
  import type { Step, Workflow, WorkflowStep } from '../parser/schema.ts';
8
10
  import { WorkflowParser } from '../parser/workflow-parser.ts';
9
11
  import { StepStatus, type StepStatusType, WorkflowStatus } from '../types/status.ts';
12
+ import { ConfigLoader } from '../utils/config-loader.ts';
10
13
  import { extractJson } from '../utils/json-parser.ts';
11
14
  import { Redactor } from '../utils/redactor.ts';
15
+ import { formatSchemaErrors, validateJsonSchema } from '../utils/schema-validator.ts';
12
16
  import { WorkflowRegistry } from '../utils/workflow-registry.ts';
13
17
  import { ForeachExecutor } from './foreach-executor.ts';
14
18
  import { type LLMMessage, getAdapter } from './llm-adapter.ts';
15
19
  import { MCPManager } from './mcp-manager.ts';
20
+ import { ResourcePoolManager } from './resource-pool.ts';
16
21
  import { withRetry } from './retry.ts';
17
- import { type StepResult, WorkflowSuspendedError, executeStep } from './step-executor.ts';
22
+ import {
23
+ type StepResult,
24
+ WorkflowSuspendedError,
25
+ WorkflowWaitingError,
26
+ executeStep,
27
+ } from './step-executor.ts';
18
28
  import { withTimeout } from './timeout.ts';
19
29
 
20
30
  import { ConsoleLogger, type Logger } from '../utils/logger.ts';
@@ -51,8 +61,22 @@ class RedactingLogger implements Logger {
51
61
  }
52
62
  }
53
63
 
64
+ class StepExecutionError extends Error {
65
+ constructor(public readonly result: StepResult) {
66
+ super(result.error || 'Step failed');
67
+ this.name = 'StepExecutionError';
68
+ }
69
+ }
70
+
71
+ function getWakeAt(output: unknown): string | undefined {
72
+ if (!output || typeof output !== 'object') return undefined;
73
+ const wakeAt = (output as { wakeAt?: unknown }).wakeAt;
74
+ return typeof wakeAt === 'string' ? wakeAt : undefined;
75
+ }
76
+
54
77
  export interface RunOptions {
55
78
  inputs?: Record<string, unknown>;
79
+ secrets?: Record<string, string>;
56
80
  dbPath?: string;
57
81
  memoryDbPath?: string;
58
82
  resumeRunId?: string;
@@ -63,8 +87,14 @@ export interface RunOptions {
63
87
  resumeInputs?: Record<string, unknown>;
64
88
  dryRun?: boolean;
65
89
  debug?: boolean;
90
+ dedup?: boolean;
66
91
  getAdapter?: typeof getAdapter;
92
+ executeStep?: typeof executeStep;
67
93
  depth?: number;
94
+ allowSuccessResume?: boolean;
95
+ resourcePoolManager?: ResourcePoolManager;
96
+ allowInsecure?: boolean;
97
+ artifactRoot?: string;
68
98
  }
69
99
 
70
100
  export interface StepContext {
@@ -94,14 +124,17 @@ export class WorkflowRunner {
94
124
  private workflow: Workflow;
95
125
  private db: WorkflowDb;
96
126
  private memoryDb: MemoryDb;
97
- private runId: string;
127
+ private _runId!: string;
98
128
  private stepContexts: Map<string, StepContext | ForeachStepContext> = new Map();
99
- private inputs: Record<string, unknown>;
129
+ private inputs!: Record<string, unknown>;
100
130
  private secrets: Record<string, string>;
101
131
  private redactor: Redactor;
132
+ private rawLogger!: Logger;
133
+ private secretValues: string[] = [];
134
+ private redactAtRest = true;
102
135
  private resumeRunId?: string;
103
136
  private restored = false;
104
- private logger: Logger;
137
+ private logger!: Logger;
105
138
  private mcpManager: MCPManager;
106
139
  private options: RunOptions;
107
140
  private signalHandler?: (signal: string) => void;
@@ -109,7 +142,25 @@ export class WorkflowRunner {
109
142
  private hasWarnedMemory = false;
110
143
  private static readonly MEMORY_WARNING_THRESHOLD = 1000;
111
144
  private static readonly MAX_RECURSION_DEPTH = 10;
145
+ private static readonly REDACTED_PLACEHOLDER = '***REDACTED***';
112
146
  private depth = 0;
147
+ private lastFailedStep?: { id: string; error: string };
148
+ private abortController = new AbortController();
149
+ private resourcePool!: ResourcePoolManager;
150
+
151
+ /**
152
+ * Get the abort signal for cancellation checks
153
+ */
154
+ public get abortSignal(): AbortSignal {
155
+ return this.abortController.signal;
156
+ }
157
+
158
+ /**
159
+ * Check if the workflow has been canceled
160
+ */
161
+ private get isCanceled(): boolean {
162
+ return this.abortController.signal.aborted;
163
+ }
113
164
 
114
165
  constructor(workflow: Workflow, options: RunOptions = {}) {
115
166
  this.workflow = workflow;
@@ -125,29 +176,69 @@ export class WorkflowRunner {
125
176
  this.db = new WorkflowDb(options.dbPath);
126
177
  this.memoryDb = new MemoryDb(options.memoryDbPath);
127
178
  this.secrets = this.loadSecrets();
128
- this.redactor = new Redactor(this.secrets);
129
- // Wrap the logger with a redactor to prevent secret leakage in logs
179
+ this.redactor = new Redactor(this.secrets, { forcedSecrets: this.secretValues });
180
+
181
+ this.initLogger(options);
182
+ this.mcpManager = options.mcpManager || new MCPManager();
183
+ this.initResourcePool(options);
184
+ this.initRun(options);
185
+
186
+ this.setupSignalHandlers();
187
+ }
188
+
189
+ private initLogger(options: RunOptions): void {
130
190
  const rawLogger = options.logger || new ConsoleLogger();
191
+ this.rawLogger = rawLogger;
131
192
  this.logger = new RedactingLogger(rawLogger, this.redactor);
132
- this.mcpManager = options.mcpManager || new MCPManager();
193
+ }
194
+
195
+ private initResourcePool(options: RunOptions): void {
196
+ if (options.resourcePoolManager) {
197
+ this.resourcePool = options.resourcePoolManager;
198
+ } else {
199
+ const config = ConfigLoader.load();
200
+ const globalPools = config.concurrency?.pools || {};
201
+ const workflowPools: Record<string, number> = {};
202
+
203
+ if (this.workflow.pools) {
204
+ const baseContext = this.buildContext();
205
+ for (const [name, limit] of Object.entries(this.workflow.pools)) {
206
+ if (typeof limit === 'string') {
207
+ workflowPools[name] = Number(ExpressionEvaluator.evaluate(limit, baseContext));
208
+ } else {
209
+ workflowPools[name] = limit;
210
+ }
211
+ }
212
+ }
213
+
214
+ this.resourcePool = new ResourcePoolManager(this.logger, {
215
+ defaultLimit: config.concurrency?.default || 10,
216
+ pools: { ...globalPools, ...workflowPools },
217
+ });
218
+ }
219
+ }
133
220
 
221
+ private initRun(options: RunOptions): void {
134
222
  if (options.resumeRunId) {
135
- // Resume existing run
136
- this.runId = options.resumeRunId;
223
+ this._runId = options.resumeRunId;
137
224
  this.resumeRunId = options.resumeRunId;
138
- this.inputs = options.resumeInputs || {}; // Start with resume inputs, will be merged with DB inputs in restoreState
225
+ this.inputs = options.resumeInputs || {};
139
226
  } else {
140
- // Start new run
141
227
  this.inputs = options.inputs || {};
142
- this.runId = randomUUID();
228
+ this._runId = randomUUID();
143
229
  }
144
-
145
- this.setupSignalHandlers();
146
230
  }
147
231
 
148
232
  /**
149
233
  * Get the current run ID
150
234
  */
235
+ public get runId(): string {
236
+ return this._runId;
237
+ }
238
+
239
+ /**
240
+ * Get the current run ID (method for mocking compatibility)
241
+ */
151
242
  public getRunId(): string {
152
243
  return this.runId;
153
244
  }
@@ -161,14 +252,17 @@ export class WorkflowRunner {
161
252
  throw new Error(`Run ${this.runId} not found`);
162
253
  }
163
254
 
164
- // Only allow resuming failed, paused, or running (crash recovery) runs
255
+ // Only allow resuming failed, paused, canceled, or running (crash recovery) runs
256
+ // Unless specifically allowed (e.g. for rollback/compensation)
165
257
  if (
166
258
  run.status !== WorkflowStatus.FAILED &&
167
259
  run.status !== WorkflowStatus.PAUSED &&
168
- run.status !== WorkflowStatus.RUNNING
260
+ run.status !== WorkflowStatus.RUNNING &&
261
+ run.status !== WorkflowStatus.CANCELED &&
262
+ !(this.options.allowSuccessResume && run.status === WorkflowStatus.SUCCESS)
169
263
  ) {
170
264
  throw new Error(
171
- `Cannot resume run with status '${run.status}'. Only 'failed', 'paused', or 'running' runs can be resumed.`
265
+ `Cannot resume run with status '${run.status}'. Only 'failed', 'paused', 'canceled', or 'running' runs can be resumed.`
172
266
  );
173
267
  }
174
268
 
@@ -178,6 +272,10 @@ export class WorkflowRunner {
178
272
  );
179
273
  }
180
274
 
275
+ if (run.status === WorkflowStatus.CANCELED) {
276
+ this.logger.log('📋 Resuming a previously canceled run. Completed steps will be skipped.');
277
+ }
278
+
181
279
  // Restore inputs from the previous run to ensure consistency
182
280
  // Merge with any resumeInputs provided (e.g. answers to human steps)
183
281
  try {
@@ -252,6 +350,7 @@ export class WorkflowRunner {
252
350
  ? (output as Record<string, unknown>)
253
351
  : {},
254
352
  status: exec.status as typeof StepStatus.SUCCESS | typeof StepStatus.SKIPPED,
353
+ error: exec.error || undefined,
255
354
  };
256
355
  outputs[exec.iteration_index] = output;
257
356
  } else {
@@ -261,6 +360,7 @@ export class WorkflowRunner {
261
360
  output: null,
262
361
  outputs: {},
263
362
  status: exec.status as StepStatusType,
363
+ error: exec.error || undefined,
264
364
  };
265
365
  }
266
366
  }
@@ -275,8 +375,8 @@ export class WorkflowRunner {
275
375
  if (parsed.__foreachItems && Array.isArray(parsed.__foreachItems)) {
276
376
  expectedCount = parsed.__foreachItems.length;
277
377
  }
278
- } catch {
279
- // Parse error, fall through to expression evaluation
378
+ } catch (_e) {
379
+ // ignore parse errors
280
380
  }
281
381
  }
282
382
 
@@ -332,7 +432,8 @@ export class WorkflowRunner {
332
432
  if (
333
433
  exec.status === StepStatus.SUCCESS ||
334
434
  exec.status === StepStatus.SKIPPED ||
335
- exec.status === StepStatus.SUSPENDED
435
+ exec.status === StepStatus.SUSPENDED ||
436
+ exec.status === StepStatus.WAITING
336
437
  ) {
337
438
  let output: unknown = null;
338
439
  try {
@@ -341,15 +442,35 @@ export class WorkflowRunner {
341
442
  this.logger.warn(`Failed to parse output for step ${stepId}: ${error}`);
342
443
  output = { error: 'Failed to parse output' };
343
444
  }
445
+
446
+ // If step is WAITING, check if timer has elapsed
447
+ let effectiveStatus = exec.status as StepContext['status'];
448
+ if (exec.status === StepStatus.WAITING) {
449
+ const timer = await this.db.getTimerByStep(this.runId, stepId);
450
+ const timerId = timer?.id;
451
+ const wakeAt = timer?.wake_at;
452
+ if (timerId && wakeAt && new Date(wakeAt) <= new Date()) {
453
+ // Timer elapsed!
454
+ await this.db.completeTimer(timerId);
455
+ await this.db.completeStep(exec.id, StepStatus.SUCCESS, output);
456
+ effectiveStatus = StepStatus.SUCCESS;
457
+ }
458
+ }
459
+ let effectiveError = exec.error || undefined;
460
+ if (exec.status === StepStatus.WAITING && effectiveStatus === StepStatus.SUCCESS) {
461
+ effectiveError = undefined;
462
+ }
463
+
344
464
  this.stepContexts.set(stepId, {
345
465
  output,
346
466
  outputs:
347
467
  typeof output === 'object' && output !== null && !Array.isArray(output)
348
468
  ? (output as Record<string, unknown>)
349
469
  : {},
350
- status: exec.status as StepContext['status'],
470
+ status: effectiveStatus,
471
+ error: effectiveError,
351
472
  });
352
- if (exec.status !== StepStatus.SUSPENDED) {
473
+ if (effectiveStatus !== StepStatus.SUSPENDED && effectiveStatus !== StepStatus.WAITING) {
353
474
  completedStepIds.add(stepId);
354
475
  }
355
476
  }
@@ -366,8 +487,10 @@ export class WorkflowRunner {
366
487
  private setupSignalHandlers(): void {
367
488
  const handler = async (signal: string) => {
368
489
  if (this.isStopping) return;
369
- this.logger.log(`\n\n🛑 Received ${signal}. Cleaning up...`);
370
- await this.stop(WorkflowStatus.FAILED, `Cancelled by user (${signal})`);
490
+ this.logger.log(`\n\n🛑 Received ${signal}. Canceling workflow...`);
491
+ // Signal cancellation to all running steps
492
+ this.abortController.abort();
493
+ await this.stop(WorkflowStatus.CANCELED, `Canceled by user (${signal})`);
371
494
 
372
495
  // Only exit if not embedded
373
496
  if (!this.options.preventExit) {
@@ -381,6 +504,90 @@ export class WorkflowRunner {
381
504
  process.on('SIGTERM', handler);
382
505
  }
383
506
 
507
+ /**
508
+ * Process compensations (rollback)
509
+ */
510
+ private async processCompensations(errorReason: string): Promise<void> {
511
+ this.logger.log(`\n↩️ Initiating rollback due to: ${errorReason}`);
512
+
513
+ try {
514
+ // Get all pending compensations
515
+ const compensations = await this.db.getPendingCompensations(this.runId);
516
+
517
+ if (compensations.length === 0) {
518
+ this.logger.log(' No pending compensations found.');
519
+ return;
520
+ }
521
+
522
+ this.logger.log(` Found ${compensations.length} compensation(s) to execute.`);
523
+
524
+ // Execute in reverse order (LIFO) - already sorted by query
525
+ for (const compRecord of compensations) {
526
+ const stepDef = JSON.parse(compRecord.definition) as Step;
527
+ this.logger.log(` Running compensation: ${stepDef.id} (undoing ${compRecord.step_id})`);
528
+
529
+ await this.db.updateCompensationStatus(compRecord.id, 'running');
530
+
531
+ // Build context for compensation
532
+ // It has access to the original step's output via steps.<step_id>.output
533
+ const context = this.buildContext();
534
+
535
+ try {
536
+ // Execute the compensation step
537
+ const result = await executeStep(stepDef, context, this.logger, {
538
+ executeWorkflowFn: this.executeSubWorkflow.bind(this),
539
+ mcpManager: this.mcpManager,
540
+ memoryDb: this.memoryDb,
541
+ workflowDir: this.options.workflowDir,
542
+ dryRun: this.options.dryRun,
543
+ runId: this.runId,
544
+ artifactRoot: this.options.artifactRoot,
545
+ redactForStorage: this.redactForStorage.bind(this),
546
+ });
547
+
548
+ if (result.status === 'success') {
549
+ this.logger.log(` ✓ Compensation ${stepDef.id} succeeded`);
550
+ await this.db.updateCompensationStatus(compRecord.id, 'success', result.output);
551
+ } else {
552
+ this.logger.error(` ✗ Compensation ${stepDef.id} failed: ${result.error}`);
553
+ await this.db.updateCompensationStatus(
554
+ compRecord.id,
555
+ 'failed',
556
+ result.output,
557
+ result.error
558
+ );
559
+ }
560
+ } catch (err) {
561
+ const errMsg = err instanceof Error ? err.message : String(err);
562
+ this.logger.error(` ✗ Compensation ${stepDef.id} crashed: ${errMsg}`);
563
+ await this.db.updateCompensationStatus(compRecord.id, 'failed', null, errMsg);
564
+ }
565
+
566
+ // 2. Recursive rollback for sub-workflows
567
+ // Try to find if this step was a workflow step with a subRunId
568
+ const stepExec = await this.db.getMainStep(this.runId, compRecord.step_id);
569
+ const stepOutput = stepExec?.output;
570
+ if (stepOutput) {
571
+ try {
572
+ const output = JSON.parse(stepOutput);
573
+ const subRunId = output?.__subRunId;
574
+ if (subRunId) {
575
+ await this.cascadeRollback(subRunId, errorReason);
576
+ }
577
+ } catch (_e) {
578
+ // ignore parse errors
579
+ }
580
+ }
581
+ }
582
+
583
+ this.logger.log(' Rollback completed.\n');
584
+ } catch (error) {
585
+ this.logger.error(
586
+ ` ⚠️ Error during rollback processing: ${error instanceof Error ? error.message : String(error)}`
587
+ );
588
+ }
589
+ }
590
+
384
591
  /**
385
592
  * Stop the runner and cleanup resources
386
593
  */
@@ -391,8 +598,18 @@ export class WorkflowRunner {
391
598
  try {
392
599
  this.removeSignalHandlers();
393
600
 
601
+ // Trigger rollback if failing or canceled
602
+ if (status === WorkflowStatus.FAILED || status === WorkflowStatus.CANCELED) {
603
+ await this.processCompensations(error || status);
604
+ }
605
+
394
606
  // Update run status in DB
395
- await this.db.updateRunStatus(this.runId, status, undefined, error);
607
+ await this.db.updateRunStatus(
608
+ this.runId,
609
+ status,
610
+ undefined,
611
+ error ? this.redactForStorage(error) : undefined
612
+ );
396
613
 
397
614
  // Stop all MCP clients
398
615
  await this.mcpManager.stopAll();
@@ -418,7 +635,7 @@ export class WorkflowRunner {
418
635
  * Load secrets from environment
419
636
  */
420
637
  private loadSecrets(): Record<string, string> {
421
- const secrets: Record<string, string> = {};
638
+ const secrets: Record<string, string> = { ...(this.options.secrets || {}) };
422
639
 
423
640
  // Common non-secret environment variables to exclude from redaction
424
641
  const blocklist = new Set([
@@ -469,18 +686,251 @@ export class WorkflowRunner {
469
686
  return secrets;
470
687
  }
471
688
 
689
+ private refreshRedactor(): void {
690
+ this.redactor = new Redactor(this.loadSecrets(), { forcedSecrets: this.secretValues });
691
+ this.logger = new RedactingLogger(this.rawLogger, this.redactor);
692
+ }
693
+
694
+ private redactForStorage<T>(value: T): T {
695
+ if (!this.redactAtRest) return value;
696
+ return this.redactor.redactValue(value) as T;
697
+ }
698
+
699
+ private validateSchema(
700
+ kind: 'input' | 'output',
701
+ schema: unknown,
702
+ data: unknown,
703
+ stepId: string
704
+ ): void {
705
+ try {
706
+ const result = validateJsonSchema(schema, data);
707
+ if (result.valid) return;
708
+ const details = result.errors.map((line: string) => ` - ${line}`).join('\n');
709
+ throw new Error(
710
+ `${kind === 'input' ? 'Input' : 'Output'} schema validation failed for step "${stepId}":\n${details}`
711
+ );
712
+ } catch (error) {
713
+ if (error instanceof Error) {
714
+ if (error.message.includes('schema validation failed for step')) {
715
+ throw error;
716
+ }
717
+ throw new Error(
718
+ `${kind === 'input' ? 'Input' : 'Output'} schema error for step "${stepId}": ${error.message}`
719
+ );
720
+ }
721
+ throw error;
722
+ }
723
+ }
724
+
725
+ private buildStepInputs(step: Step, context: ExpressionContext): Record<string, unknown> {
726
+ const stripUndefined = (value: Record<string, unknown>) => {
727
+ const result: Record<string, unknown> = {};
728
+ for (const [key, val] of Object.entries(value)) {
729
+ if (val !== undefined) {
730
+ result[key] = val;
731
+ }
732
+ }
733
+ return result;
734
+ };
735
+
736
+ switch (step.type) {
737
+ case 'shell': {
738
+ let env: Record<string, string> | undefined;
739
+ if (step.env) {
740
+ env = {};
741
+ for (const [key, value] of Object.entries(step.env)) {
742
+ env[key] = ExpressionEvaluator.evaluateString(value as string, context);
743
+ }
744
+ }
745
+ return stripUndefined({
746
+ run: ExpressionEvaluator.evaluateString(
747
+ (step as import('../parser/schema.ts').ShellStep).run,
748
+ context
749
+ ),
750
+ dir: step.dir ? ExpressionEvaluator.evaluateString(step.dir, context) : undefined,
751
+ env,
752
+ allowInsecure: step.allowInsecure,
753
+ });
754
+ }
755
+ case 'file':
756
+ return stripUndefined({
757
+ path: ExpressionEvaluator.evaluateString(
758
+ (step as import('../parser/schema.ts').FileStep).path,
759
+ context
760
+ ),
761
+ content:
762
+ (step as import('../parser/schema.ts').FileStep).content !== undefined
763
+ ? ExpressionEvaluator.evaluateString(
764
+ (step as import('../parser/schema.ts').FileStep).content as string,
765
+ context
766
+ )
767
+ : undefined,
768
+ op: step.op,
769
+ allowOutsideCwd: step.allowOutsideCwd,
770
+ });
771
+ case 'request': {
772
+ let headers: Record<string, string> | undefined;
773
+ if (step.headers) {
774
+ headers = {};
775
+ for (const [key, value] of Object.entries(step.headers)) {
776
+ headers[key] = ExpressionEvaluator.evaluateString(value as string, context);
777
+ }
778
+ }
779
+ return stripUndefined({
780
+ url: ExpressionEvaluator.evaluateString(
781
+ (step as import('../parser/schema.ts').RequestStep).url,
782
+ context
783
+ ),
784
+ method: step.method,
785
+ headers,
786
+ body:
787
+ step.body !== undefined
788
+ ? ExpressionEvaluator.evaluateObject(step.body, context)
789
+ : undefined,
790
+ allowInsecure: step.allowInsecure,
791
+ });
792
+ }
793
+ case 'human':
794
+ return stripUndefined({
795
+ message: ExpressionEvaluator.evaluateString(
796
+ (step as import('../parser/schema.ts').HumanStep).message,
797
+ context
798
+ ),
799
+ inputType: step.inputType,
800
+ });
801
+ case 'sleep': {
802
+ const evaluated = ExpressionEvaluator.evaluate(step.duration.toString(), context);
803
+ return { duration: Number(evaluated) };
804
+ }
805
+ case 'llm':
806
+ return stripUndefined({
807
+ agent: step.agent,
808
+ provider: step.provider,
809
+ model: step.model,
810
+ prompt: ExpressionEvaluator.evaluateString(step.prompt, context),
811
+ tools: step.tools,
812
+ maxIterations: step.maxIterations,
813
+ useGlobalMcp: step.useGlobalMcp,
814
+ allowClarification: step.allowClarification,
815
+ mcpServers: step.mcpServers,
816
+ useStandardTools: step.useStandardTools,
817
+ allowOutsideCwd: step.allowOutsideCwd,
818
+ allowInsecure: step.allowInsecure,
819
+ });
820
+ case 'workflow':
821
+ return stripUndefined({
822
+ path: (step as import('../parser/schema.ts').WorkflowStep).path,
823
+ inputs: step.inputs
824
+ ? ExpressionEvaluator.evaluateObject(step.inputs, context)
825
+ : undefined,
826
+ });
827
+ case 'script':
828
+ return stripUndefined({
829
+ run: step.run,
830
+ allowInsecure: step.allowInsecure,
831
+ });
832
+ case 'engine': {
833
+ const env: Record<string, string> = {};
834
+ for (const [key, value] of Object.entries(step.env || {})) {
835
+ env[key] = ExpressionEvaluator.evaluateString(value as string, context);
836
+ }
837
+ return stripUndefined({
838
+ command: ExpressionEvaluator.evaluateString(
839
+ (step as import('../parser/schema.ts').EngineStep).command,
840
+ context
841
+ ),
842
+ args: (step as import('../parser/schema.ts').EngineStep).args?.map((arg) =>
843
+ ExpressionEvaluator.evaluateString(arg, context)
844
+ ),
845
+ input:
846
+ (step as import('../parser/schema.ts').EngineStep).input !== undefined
847
+ ? ExpressionEvaluator.evaluateObject(
848
+ (step as import('../parser/schema.ts').EngineStep).input,
849
+ context
850
+ )
851
+ : undefined,
852
+ env,
853
+ cwd: ExpressionEvaluator.evaluateString(
854
+ (step as import('../parser/schema.ts').EngineStep).cwd,
855
+ context
856
+ ),
857
+ });
858
+ }
859
+ case 'memory':
860
+ return stripUndefined({
861
+ op: step.op,
862
+ query: step.query ? ExpressionEvaluator.evaluateString(step.query, context) : undefined,
863
+ text: step.text ? ExpressionEvaluator.evaluateString(step.text, context) : undefined,
864
+ model: step.model,
865
+ metadata: step.metadata
866
+ ? ExpressionEvaluator.evaluateObject(step.metadata, context)
867
+ : undefined,
868
+ limit: step.limit,
869
+ });
870
+ default:
871
+ return {};
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Collect primitive secret values from structured inputs.
877
+ */
878
+ private static collectSecretValues(
879
+ value: unknown,
880
+ sink: Set<string>,
881
+ seen: WeakSet<object>
882
+ ): void {
883
+ if (value === null || value === undefined) return;
884
+
885
+ if (typeof value === 'string') {
886
+ sink.add(value);
887
+ return;
888
+ }
889
+
890
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
891
+ sink.add(String(value));
892
+ return;
893
+ }
894
+
895
+ if (typeof value !== 'object') return;
896
+
897
+ if (seen.has(value)) return;
898
+ seen.add(value);
899
+
900
+ if (Array.isArray(value)) {
901
+ for (const item of value) {
902
+ WorkflowRunner.collectSecretValues(item, sink, seen);
903
+ }
904
+ return;
905
+ }
906
+
907
+ for (const item of Object.values(value as Record<string, unknown>)) {
908
+ WorkflowRunner.collectSecretValues(item, sink, seen);
909
+ }
910
+ }
911
+
472
912
  /**
473
913
  * Apply workflow defaults to inputs and validate types
474
914
  */
475
915
  private applyDefaultsAndValidate(): void {
476
916
  if (!this.workflow.inputs) return;
477
917
 
918
+ const secretValues = new Set<string>();
919
+
478
920
  for (const [key, config] of Object.entries(this.workflow.inputs)) {
479
921
  // Apply default if missing
480
922
  if (this.inputs[key] === undefined && config.default !== undefined) {
481
923
  this.inputs[key] = config.default;
482
924
  }
483
925
 
926
+ if (config.secret) {
927
+ if (this.inputs[key] === WorkflowRunner.REDACTED_PLACEHOLDER) {
928
+ throw new Error(
929
+ `Secret input "${key}" was redacted at rest. Please provide it again to resume this run.`
930
+ );
931
+ }
932
+ }
933
+
484
934
  // Validate required inputs
485
935
  if (this.inputs[key] === undefined) {
486
936
  throw new Error(`Missing required input: ${key}`);
@@ -502,7 +952,42 @@ export class WorkflowRunner {
502
952
  if (type === 'array' && !Array.isArray(value)) {
503
953
  throw new Error(`Input "${key}" must be an array, got ${typeof value}`);
504
954
  }
955
+ if (
956
+ type === 'object' &&
957
+ (typeof value !== 'object' || value === null || Array.isArray(value))
958
+ ) {
959
+ throw new Error(`Input "${key}" must be an object, got ${typeof value}`);
960
+ }
961
+
962
+ if (config.values) {
963
+ if (type !== 'string' && type !== 'number' && type !== 'boolean') {
964
+ throw new Error(`Input "${key}" cannot use enum values with type "${type}"`);
965
+ }
966
+ for (const allowed of config.values) {
967
+ const matchesType =
968
+ (type === 'string' && typeof allowed === 'string') ||
969
+ (type === 'number' && typeof allowed === 'number') ||
970
+ (type === 'boolean' && typeof allowed === 'boolean');
971
+ if (!matchesType) {
972
+ throw new Error(
973
+ `Input "${key}" enum value ${JSON.stringify(allowed)} must be a ${type}`
974
+ );
975
+ }
976
+ }
977
+ if (!config.values.includes(value as string | number | boolean)) {
978
+ throw new Error(
979
+ `Input "${key}" must be one of: ${config.values.map((v) => JSON.stringify(v)).join(', ')}`
980
+ );
981
+ }
982
+ }
983
+
984
+ if (config.secret && value !== undefined && value !== WorkflowRunner.REDACTED_PLACEHOLDER) {
985
+ WorkflowRunner.collectSecretValues(value, secretValues, new WeakSet());
986
+ }
505
987
  }
988
+
989
+ this.secretValues = Array.from(secretValues);
990
+ this.refreshRedactor();
506
991
  }
507
992
 
508
993
  /**
@@ -515,6 +1000,7 @@ export class WorkflowRunner {
515
1000
  output?: unknown;
516
1001
  outputs?: Record<string, unknown>;
517
1002
  status?: string;
1003
+ error?: string;
518
1004
  items?: StepContext[];
519
1005
  }
520
1006
  > = {};
@@ -526,6 +1012,7 @@ export class WorkflowRunner {
526
1012
  output: ctx.output,
527
1013
  outputs: ctx.outputs,
528
1014
  status: ctx.status,
1015
+ error: ctx.error,
529
1016
  items: ctx.items,
530
1017
  };
531
1018
  } else {
@@ -533,21 +1020,49 @@ export class WorkflowRunner {
533
1020
  output: ctx.output,
534
1021
  outputs: ctx.outputs,
535
1022
  status: ctx.status,
1023
+ error: ctx.error,
536
1024
  };
537
1025
  }
538
1026
  }
539
1027
 
540
- return {
1028
+ const baseContext: ExpressionContext = {
541
1029
  inputs: this.inputs,
542
- secrets: this.secrets,
1030
+ secrets: this.loadSecrets(), // Access secrets from options
1031
+ secretValues: this.secretValues,
543
1032
  steps: stepsContext,
544
1033
  item,
545
1034
  index,
546
- env: this.workflow.env,
1035
+ env: {},
547
1036
  output: item
548
1037
  ? undefined
549
1038
  : this.stepContexts.get(this.workflow.steps.find((s) => !s.foreach)?.id || '')?.output,
1039
+ last_failed_step: this.lastFailedStep,
550
1040
  };
1041
+
1042
+ const resolvedEnv: Record<string, string> = {};
1043
+ for (const [key, value] of Object.entries(process.env)) {
1044
+ if (value !== undefined) {
1045
+ resolvedEnv[key] = value;
1046
+ }
1047
+ }
1048
+
1049
+ if (this.workflow.env) {
1050
+ for (const [key, value] of Object.entries(this.workflow.env)) {
1051
+ try {
1052
+ resolvedEnv[key] = ExpressionEvaluator.evaluateString(value, {
1053
+ ...baseContext,
1054
+ env: resolvedEnv,
1055
+ });
1056
+ } catch (error) {
1057
+ this.logger.warn(
1058
+ `Warning: Failed to evaluate workflow env "${key}": ${error instanceof Error ? error.message : String(error)}`
1059
+ );
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ baseContext.env = resolvedEnv;
1065
+ return baseContext;
551
1066
  }
552
1067
 
553
1068
  /**
@@ -607,6 +1122,104 @@ export class WorkflowRunner {
607
1122
  }
608
1123
  }
609
1124
 
1125
+ private async claimIdempotencyRecord(
1126
+ scopedKey: string,
1127
+ stepId: string,
1128
+ ttlSeconds?: number
1129
+ ): Promise<
1130
+ | { status: 'hit'; output: unknown; error?: string }
1131
+ | { status: 'claimed' }
1132
+ | { status: 'in-flight' }
1133
+ > {
1134
+ try {
1135
+ await this.db.clearExpiredIdempotencyRecord(scopedKey);
1136
+
1137
+ const existing = await this.db.getIdempotencyRecord(scopedKey);
1138
+ if (existing) {
1139
+ if (existing.status === StepStatus.SUCCESS) {
1140
+ let output: unknown = null;
1141
+ try {
1142
+ output = existing.output ? JSON.parse(existing.output) : null;
1143
+ } catch (parseError) {
1144
+ this.logger.warn(
1145
+ ` ⚠️ Failed to parse idempotency output for ${stepId}: ${parseError instanceof Error ? parseError.message : String(parseError)}`
1146
+ );
1147
+ }
1148
+ return { status: 'hit', output, error: existing.error || undefined };
1149
+ }
1150
+ if (existing.status === StepStatus.RUNNING) {
1151
+ return { status: 'in-flight' };
1152
+ }
1153
+
1154
+ const claimed = await this.db.markIdempotencyRecordRunning(
1155
+ scopedKey,
1156
+ this.runId,
1157
+ stepId,
1158
+ ttlSeconds
1159
+ );
1160
+ if (claimed) {
1161
+ return { status: 'claimed' };
1162
+ }
1163
+ }
1164
+
1165
+ const inserted = await this.db.insertIdempotencyRecordIfAbsent(
1166
+ scopedKey,
1167
+ this.runId,
1168
+ stepId,
1169
+ StepStatus.RUNNING,
1170
+ ttlSeconds
1171
+ );
1172
+ if (inserted) {
1173
+ return { status: 'claimed' };
1174
+ }
1175
+
1176
+ const current = await this.db.getIdempotencyRecord(scopedKey);
1177
+ if (current?.status === StepStatus.SUCCESS) {
1178
+ let output: unknown = null;
1179
+ try {
1180
+ output = current.output ? JSON.parse(current.output) : null;
1181
+ } catch (parseError) {
1182
+ this.logger.warn(
1183
+ ` ⚠️ Failed to parse idempotency output for ${stepId}: ${parseError instanceof Error ? parseError.message : String(parseError)}`
1184
+ );
1185
+ }
1186
+ return { status: 'hit', output, error: current.error || undefined };
1187
+ }
1188
+ return { status: 'in-flight' };
1189
+ } catch (error) {
1190
+ this.logger.warn(
1191
+ ` ⚠️ Failed to claim idempotency key for ${stepId}: ${error instanceof Error ? error.message : String(error)}`
1192
+ );
1193
+ return { status: 'claimed' };
1194
+ }
1195
+ }
1196
+
1197
+ private async recordIdempotencyResult(
1198
+ scopedKey: string | undefined,
1199
+ stepId: string,
1200
+ status: StepStatusType,
1201
+ output: unknown,
1202
+ error?: string,
1203
+ ttlSeconds?: number
1204
+ ): Promise<void> {
1205
+ if (!scopedKey) return;
1206
+ try {
1207
+ await this.db.storeIdempotencyRecord(
1208
+ scopedKey,
1209
+ this.runId,
1210
+ stepId,
1211
+ status,
1212
+ output,
1213
+ error,
1214
+ ttlSeconds
1215
+ );
1216
+ } catch (err) {
1217
+ this.logger.warn(
1218
+ ` ⚠️ Failed to store idempotency record: ${err instanceof Error ? err.message : String(err)}`
1219
+ );
1220
+ }
1221
+ }
1222
+
610
1223
  /**
611
1224
  * Execute a single step instance and return the result
612
1225
  * Does NOT update global stepContexts
@@ -614,8 +1227,81 @@ export class WorkflowRunner {
614
1227
  private async executeStepInternal(
615
1228
  step: Step,
616
1229
  context: ExpressionContext,
617
- stepExecId: string
1230
+ stepExecId: string,
1231
+ idempotencyContext?: {
1232
+ rawKey: string;
1233
+ scopedKey: string;
1234
+ ttlSeconds?: number;
1235
+ claimed: boolean;
1236
+ }
618
1237
  ): Promise<StepContext> {
1238
+ // Check idempotency key for dedup (scoped per run by default)
1239
+ const dedupEnabled = this.options.dedup !== false;
1240
+ let idempotencyKey: string | undefined = idempotencyContext?.rawKey;
1241
+ let scopedIdempotencyKey: string | undefined = idempotencyContext?.scopedKey;
1242
+ let idempotencyTtlSeconds: number | undefined = idempotencyContext?.ttlSeconds;
1243
+ let idempotencyClaimed = idempotencyContext?.claimed ?? false;
1244
+ if (dedupEnabled && !idempotencyClaimed && step.idempotencyKey) {
1245
+ try {
1246
+ idempotencyKey = ExpressionEvaluator.evaluateString(step.idempotencyKey, context);
1247
+ } catch (error) {
1248
+ this.logger.warn(
1249
+ ` ⚠️ Failed to evaluate idempotencyKey for ${step.id}: ${error instanceof Error ? error.message : String(error)}`
1250
+ );
1251
+ }
1252
+ if (idempotencyKey) {
1253
+ const scope = step.idempotencyScope || 'run';
1254
+ scopedIdempotencyKey = scope === 'run' ? `${this.runId}:${idempotencyKey}` : idempotencyKey;
1255
+ idempotencyTtlSeconds = step.idempotencyTtlSeconds;
1256
+
1257
+ const claim = await this.claimIdempotencyRecord(
1258
+ scopedIdempotencyKey,
1259
+ step.id,
1260
+ idempotencyTtlSeconds
1261
+ );
1262
+ if (claim.status === 'hit') {
1263
+ this.logger.log(` ⟳ Step ${step.id} skipped (idempotency hit: ${idempotencyKey})`);
1264
+ const output = claim.output;
1265
+ await this.db.completeStep(stepExecId, 'success', output, claim.error || undefined);
1266
+ return {
1267
+ output,
1268
+ outputs:
1269
+ typeof output === 'object' && output !== null && !Array.isArray(output)
1270
+ ? (output as Record<string, unknown>)
1271
+ : {},
1272
+ status: 'success',
1273
+ error: claim.error || undefined,
1274
+ };
1275
+ }
1276
+ if (claim.status === 'in-flight') {
1277
+ const errorMsg = `Idempotency key already in-flight: ${idempotencyKey}`;
1278
+ await this.db.completeStep(
1279
+ stepExecId,
1280
+ StepStatus.FAILED,
1281
+ null,
1282
+ this.redactAtRest ? this.redactor.redact(errorMsg) : errorMsg
1283
+ );
1284
+ return {
1285
+ output: null,
1286
+ outputs: {},
1287
+ status: StepStatus.FAILED,
1288
+ error: errorMsg,
1289
+ };
1290
+ }
1291
+ idempotencyClaimed = true;
1292
+ }
1293
+ }
1294
+
1295
+ const idempotencyContextForRetry =
1296
+ idempotencyClaimed && scopedIdempotencyKey
1297
+ ? {
1298
+ rawKey: idempotencyKey || scopedIdempotencyKey,
1299
+ scopedKey: scopedIdempotencyKey,
1300
+ ttlSeconds: idempotencyTtlSeconds,
1301
+ claimed: true,
1302
+ }
1303
+ : undefined;
1304
+
619
1305
  let stepToExecute = step;
620
1306
 
621
1307
  // Inject few-shot examples if enabled
@@ -639,26 +1325,149 @@ export class WorkflowRunner {
639
1325
  await this.db.startStep(stepExecId);
640
1326
  }
641
1327
 
642
- const operation = async () => {
643
- const result = await executeStep(stepToExecute, context, this.logger, {
1328
+ const operation = async (attemptContext: ExpressionContext) => {
1329
+ const exec = this.options.executeStep || executeStep;
1330
+ const result = await exec(stepToExecute, attemptContext, this.logger, {
644
1331
  executeWorkflowFn: this.executeSubWorkflow.bind(this),
645
1332
  mcpManager: this.mcpManager,
646
1333
  memoryDb: this.memoryDb,
647
1334
  workflowDir: this.options.workflowDir,
648
1335
  dryRun: this.options.dryRun,
1336
+ abortSignal: this.abortSignal,
1337
+ runId: this.runId,
1338
+ stepExecutionId: stepExecId,
1339
+ artifactRoot: this.options.artifactRoot,
1340
+ redactForStorage: this.redactForStorage.bind(this),
1341
+ getAdapter: this.options.getAdapter,
1342
+ executeStep: this.options.executeStep || executeStep,
649
1343
  });
650
1344
  if (result.status === 'failed') {
651
- throw new Error(result.error || 'Step failed');
1345
+ throw new StepExecutionError(result);
1346
+ }
1347
+ if (result.status === 'success' && stepToExecute.outputSchema) {
1348
+ try {
1349
+ const outputForValidation =
1350
+ stepToExecute.type === 'engine' &&
1351
+ result.output &&
1352
+ typeof result.output === 'object' &&
1353
+ 'summary' in result.output
1354
+ ? (result.output as { summary?: unknown }).summary
1355
+ : result.output;
1356
+ this.validateSchema(
1357
+ 'output',
1358
+ stepToExecute.outputSchema,
1359
+ outputForValidation,
1360
+ stepToExecute.id
1361
+ );
1362
+ } catch (error) {
1363
+ const message = error instanceof Error ? error.message : String(error);
1364
+ const outputRetries = stepToExecute.outputRetries || 0;
1365
+ const currentAttempt = (attemptContext.outputRepairAttempts as number) || 0;
1366
+
1367
+ // Only attempt repair for LLM steps with outputRetries configured
1368
+ if (stepToExecute.type === 'llm' && outputRetries > 0 && currentAttempt < outputRetries) {
1369
+ const strategy = stepToExecute.repairStrategy || 'reask';
1370
+ this.logger.log(
1371
+ ` 🔄 Output validation failed, attempting ${strategy} repair (${currentAttempt + 1}/${outputRetries})`
1372
+ );
1373
+
1374
+ // Build repair context with validation errors
1375
+ const repairPrompt = this.buildOutputRepairPrompt(
1376
+ stepToExecute,
1377
+ result.output,
1378
+ message,
1379
+ strategy
1380
+ );
1381
+
1382
+ // Create a modified step with repair context
1383
+ const repairStep = {
1384
+ ...stepToExecute,
1385
+ prompt: repairPrompt,
1386
+ };
1387
+
1388
+ // Recursively execute with incremented repair attempt count
1389
+ const repairContext = {
1390
+ ...attemptContext,
1391
+ outputRepairAttempts: currentAttempt + 1,
1392
+ };
1393
+
1394
+ // Execute the repair step
1395
+ const exec = this.options.executeStep || executeStep;
1396
+ const repairResult = await exec(repairStep, repairContext, this.logger, {
1397
+ executeWorkflowFn: this.executeSubWorkflow.bind(this),
1398
+ mcpManager: this.mcpManager,
1399
+ memoryDb: this.memoryDb,
1400
+ workflowDir: this.options.workflowDir,
1401
+ dryRun: this.options.dryRun,
1402
+ abortSignal: this.abortSignal,
1403
+ runId: this.runId,
1404
+ stepExecutionId: stepExecId,
1405
+ artifactRoot: this.options.artifactRoot,
1406
+ redactForStorage: this.redactForStorage.bind(this),
1407
+ executeStep: this.options.executeStep || executeStep,
1408
+ });
1409
+
1410
+ if (repairResult.status === 'failed') {
1411
+ throw new StepExecutionError(repairResult);
1412
+ }
1413
+
1414
+ // Validate the repaired output
1415
+ try {
1416
+ this.validateSchema(
1417
+ 'output',
1418
+ stepToExecute.outputSchema,
1419
+ repairResult.output,
1420
+ stepToExecute.id
1421
+ );
1422
+ this.logger.log(
1423
+ ` ✓ Output repair successful after ${currentAttempt + 1} attempt(s)`
1424
+ );
1425
+ return repairResult;
1426
+ } catch (repairError) {
1427
+ // If still failing, either retry again or give up
1428
+ if (currentAttempt + 1 < outputRetries) {
1429
+ // Try again with updated context
1430
+ return operation({
1431
+ ...attemptContext,
1432
+ outputRepairAttempts: currentAttempt + 1,
1433
+ });
1434
+ }
1435
+ const repairMessage =
1436
+ repairError instanceof Error ? repairError.message : String(repairError);
1437
+ throw new StepExecutionError({
1438
+ ...repairResult,
1439
+ status: 'failed',
1440
+ error: `Output validation failed after ${outputRetries} repair attempts: ${repairMessage}`,
1441
+ });
1442
+ }
1443
+ }
1444
+
1445
+ throw new StepExecutionError({
1446
+ ...result,
1447
+ status: 'failed',
1448
+ error: message,
1449
+ });
1450
+ }
652
1451
  }
653
1452
  return result;
654
1453
  };
655
1454
 
656
1455
  try {
1456
+ if (stepToExecute.inputSchema) {
1457
+ const inputsForValidation = this.buildStepInputs(stepToExecute, context);
1458
+ this.validateSchema(
1459
+ 'input',
1460
+ stepToExecute.inputSchema,
1461
+ inputsForValidation,
1462
+ stepToExecute.id
1463
+ );
1464
+ }
1465
+
657
1466
  const operationWithTimeout = async () => {
658
1467
  if (step.timeout) {
659
- return await withTimeout(operation(), step.timeout, `Step ${step.id}`);
1468
+ return await withTimeout(operation(context), step.timeout, `Step ${step.id}`);
660
1469
  }
661
- return await operation();
1470
+ return await operation(context);
662
1471
  };
663
1472
 
664
1473
  const result = await withRetry(operationWithTimeout, step.retry, async (attempt, error) => {
@@ -666,24 +1475,112 @@ export class WorkflowRunner {
666
1475
  await this.db.incrementRetry(stepExecId);
667
1476
  });
668
1477
 
1478
+ const persistedOutput = this.redactForStorage(result.output);
1479
+ const persistedError = result.error
1480
+ ? this.redactAtRest
1481
+ ? this.redactor.redact(result.error)
1482
+ : result.error
1483
+ : result.error;
1484
+
669
1485
  if (result.status === StepStatus.SUSPENDED) {
1486
+ if (step.type === 'human') {
1487
+ const existingTimer = await this.db.getTimerByStep(this.runId, step.id);
1488
+ if (!existingTimer) {
1489
+ const timerId = randomUUID();
1490
+ await this.db.createTimer(timerId, this.runId, step.id, 'human');
1491
+ }
1492
+ }
1493
+ if (dedupEnabled && idempotencyClaimed) {
1494
+ await this.recordIdempotencyResult(
1495
+ scopedIdempotencyKey,
1496
+ step.id,
1497
+ StepStatus.SUSPENDED,
1498
+ result.output,
1499
+ result.error,
1500
+ idempotencyTtlSeconds
1501
+ );
1502
+ }
670
1503
  await this.db.completeStep(
671
1504
  stepExecId,
672
1505
  StepStatus.SUSPENDED,
673
- result.output,
674
- 'Waiting for interaction',
1506
+ persistedOutput,
1507
+ this.redactAtRest
1508
+ ? this.redactor.redact('Waiting for interaction')
1509
+ : 'Waiting for interaction',
675
1510
  result.usage
676
1511
  );
677
1512
  return result;
678
1513
  }
679
1514
 
1515
+ if (result.status === StepStatus.WAITING) {
1516
+ const wakeAt = getWakeAt(result.output);
1517
+ const waitError = `Waiting until ${wakeAt}`;
1518
+ // Avoid creating duplicate timers for the same step execution
1519
+ const existingTimer = await this.db.getTimerByStep(this.runId, step.id);
1520
+ if (!existingTimer) {
1521
+ const timerId = randomUUID();
1522
+ await this.db.createTimer(timerId, this.runId, step.id, 'sleep', wakeAt);
1523
+ }
1524
+ if (dedupEnabled && idempotencyClaimed) {
1525
+ await this.recordIdempotencyResult(
1526
+ scopedIdempotencyKey,
1527
+ step.id,
1528
+ StepStatus.WAITING,
1529
+ result.output,
1530
+ waitError,
1531
+ idempotencyTtlSeconds
1532
+ );
1533
+ }
1534
+ await this.db.completeStep(
1535
+ stepExecId,
1536
+ StepStatus.WAITING,
1537
+ persistedOutput,
1538
+ this.redactAtRest ? this.redactor.redact(waitError) : waitError,
1539
+ result.usage
1540
+ );
1541
+ result.error = waitError;
1542
+ return result;
1543
+ }
1544
+
680
1545
  await this.db.completeStep(
681
1546
  stepExecId,
682
1547
  result.status,
683
- result.output,
684
- result.error,
1548
+ persistedOutput,
1549
+ persistedError,
685
1550
  result.usage
686
1551
  );
1552
+ if (step.type === 'human') {
1553
+ const existingTimer = await this.db.getTimerByStep(this.runId, step.id);
1554
+ if (existingTimer) {
1555
+ await this.db.completeTimer(existingTimer.id);
1556
+ }
1557
+ }
1558
+
1559
+ // Register compensation if step succeeded and defines one
1560
+ if (result.status === StepStatus.SUCCESS && step.compensate) {
1561
+ try {
1562
+ // Ensure compensation step has an ID
1563
+ const compStep = {
1564
+ ...step.compensate,
1565
+ id: step.compensate.id || `${step.id}-compensate`,
1566
+ };
1567
+ const definition = JSON.stringify(compStep);
1568
+ const compensationId = randomUUID();
1569
+
1570
+ this.logger.log(` 📎 Registering compensation for step ${step.id}`);
1571
+ await this.db.registerCompensation(
1572
+ compensationId,
1573
+ this.runId,
1574
+ step.id,
1575
+ compStep.id,
1576
+ definition
1577
+ );
1578
+ } catch (compError) {
1579
+ this.logger.warn(
1580
+ ` ⚠️ Failed to register compensation for step ${step.id}: ${compError instanceof Error ? compError.message : String(compError)}`
1581
+ );
1582
+ }
1583
+ }
687
1584
 
688
1585
  // Auto-Learning logic
689
1586
  if (step.learn && result.status === StepStatus.SUCCESS) {
@@ -710,10 +1607,22 @@ export class WorkflowRunner {
710
1607
  outputs = {};
711
1608
  }
712
1609
 
1610
+ if (dedupEnabled && idempotencyClaimed) {
1611
+ await this.recordIdempotencyResult(
1612
+ scopedIdempotencyKey,
1613
+ step.id,
1614
+ result.status,
1615
+ result.output,
1616
+ result.error,
1617
+ idempotencyTtlSeconds
1618
+ );
1619
+ }
1620
+
713
1621
  return {
714
1622
  output: result.output,
715
1623
  outputs,
716
1624
  status: result.status,
1625
+ error: result.error,
717
1626
  usage: result.usage,
718
1627
  };
719
1628
  } catch (error) {
@@ -741,7 +1650,12 @@ export class WorkflowRunner {
741
1650
  reflexionAttempts: currentAttempt + 1,
742
1651
  };
743
1652
 
744
- return this.executeStepInternal(newStep, nextContext, stepExecId);
1653
+ return this.executeStepInternal(
1654
+ newStep,
1655
+ nextContext,
1656
+ stepExecId,
1657
+ idempotencyContextForRetry
1658
+ );
745
1659
  } catch (healError) {
746
1660
  this.logger.error(
747
1661
  ` ✗ Reflexion failed: ${healError instanceof Error ? healError.message : String(healError)}`
@@ -777,7 +1691,12 @@ export class WorkflowRunner {
777
1691
  autoHealAttempts: currentAttempt + 1,
778
1692
  };
779
1693
 
780
- return this.executeStepInternal(newStep, nextContext, stepExecId);
1694
+ return this.executeStepInternal(
1695
+ newStep,
1696
+ nextContext,
1697
+ stepExecId,
1698
+ idempotencyContextForRetry
1699
+ );
781
1700
  } catch (healError) {
782
1701
  this.logger.error(
783
1702
  ` ✗ Auto-heal failed: ${healError instanceof Error ? healError.message : String(healError)}`
@@ -798,7 +1717,12 @@ export class WorkflowRunner {
798
1717
  this.logger.log(` ↻ Retrying step ${step.id} after manual intervention`);
799
1718
  // We use the modified step if provided, else original
800
1719
  const stepToRun = action.modifiedStep || step;
801
- return this.executeStepInternal(stepToRun, context, stepExecId);
1720
+ return this.executeStepInternal(
1721
+ stepToRun,
1722
+ context,
1723
+ stepExecId,
1724
+ idempotencyContextForRetry
1725
+ );
802
1726
  }
803
1727
  if (action.type === 'skip') {
804
1728
  this.logger.log(` ⏭️ Skipping step ${step.id} manually`);
@@ -815,16 +1739,68 @@ export class WorkflowRunner {
815
1739
  }
816
1740
  }
817
1741
 
818
- const errorMsg = error instanceof Error ? error.message : String(error);
1742
+ const failureResult = error instanceof StepExecutionError ? error.result : null;
1743
+ const errorMsg =
1744
+ failureResult?.error || (error instanceof Error ? error.message : String(error));
819
1745
  const redactedErrorMsg = this.redactor.redact(errorMsg);
1746
+ const failureOutput = failureResult?.output ?? null;
1747
+ const failureOutputs =
1748
+ typeof failureOutput === 'object' && failureOutput !== null && !Array.isArray(failureOutput)
1749
+ ? (failureOutput as Record<string, unknown>)
1750
+ : {};
1751
+
1752
+ if (step.allowFailure) {
1753
+ this.logger.warn(
1754
+ ` ⚠️ Step ${step.id} failed but allowFailure is true: ${redactedErrorMsg}`
1755
+ );
1756
+ await this.db.completeStep(
1757
+ stepExecId,
1758
+ StepStatus.SUCCESS,
1759
+ this.redactForStorage(failureOutput),
1760
+ this.redactAtRest ? redactedErrorMsg : errorMsg
1761
+ );
1762
+ if (dedupEnabled && idempotencyClaimed) {
1763
+ await this.recordIdempotencyResult(
1764
+ scopedIdempotencyKey,
1765
+ step.id,
1766
+ StepStatus.SUCCESS,
1767
+ failureOutput,
1768
+ errorMsg,
1769
+ idempotencyTtlSeconds
1770
+ );
1771
+ }
1772
+ return {
1773
+ output: failureOutput,
1774
+ outputs: failureOutputs,
1775
+ status: StepStatus.SUCCESS,
1776
+ error: errorMsg,
1777
+ };
1778
+ }
1779
+
820
1780
  this.logger.error(` ✗ Step ${step.id} failed: ${redactedErrorMsg}`);
821
- await this.db.completeStep(stepExecId, 'failed', null, redactedErrorMsg);
1781
+ await this.db.completeStep(
1782
+ stepExecId,
1783
+ StepStatus.FAILED,
1784
+ this.redactForStorage(failureOutput),
1785
+ this.redactAtRest ? redactedErrorMsg : errorMsg
1786
+ );
1787
+ if (dedupEnabled && idempotencyClaimed) {
1788
+ await this.recordIdempotencyResult(
1789
+ scopedIdempotencyKey,
1790
+ step.id,
1791
+ StepStatus.FAILED,
1792
+ failureOutput,
1793
+ errorMsg,
1794
+ idempotencyTtlSeconds
1795
+ );
1796
+ }
822
1797
 
823
1798
  // Return failed context
824
1799
  return {
825
- output: null,
826
- outputs: {},
827
- status: 'failed',
1800
+ output: failureOutput,
1801
+ outputs: failureOutputs,
1802
+ status: StepStatus.FAILED,
1803
+ error: errorMsg,
828
1804
  };
829
1805
  }
830
1806
  }
@@ -864,7 +1840,7 @@ Do not change the 'id' or 'type' or 'auto_heal' fields.
864
1840
  agent: auto_heal.agent,
865
1841
  model: auto_heal.model,
866
1842
  prompt,
867
- schema: {
1843
+ outputSchema: {
868
1844
  type: 'object',
869
1845
  description: 'Partial step configuration with fixed values',
870
1846
  additionalProperties: true,
@@ -875,12 +1851,19 @@ Do not change the 'id' or 'type' or 'auto_heal' fields.
875
1851
 
876
1852
  // Execute the agent step
877
1853
  // We use a fresh context but share secrets/env
878
- const result = await executeStep(agentStep, context, this.logger, {
1854
+ const exec = this.options.executeStep || executeStep;
1855
+ const result = await exec(agentStep, context, this.logger, {
879
1856
  executeWorkflowFn: this.executeSubWorkflow.bind(this),
880
1857
  mcpManager: this.mcpManager,
881
1858
  memoryDb: this.memoryDb,
882
1859
  workflowDir: this.options.workflowDir,
883
1860
  dryRun: this.options.dryRun,
1861
+ debug: this.options.debug,
1862
+ runId: this.runId,
1863
+ artifactRoot: this.options.artifactRoot,
1864
+ redactForStorage: this.redactForStorage.bind(this),
1865
+ allowInsecure: this.options.allowInsecure,
1866
+ executeStep: this.options.executeStep || executeStep,
884
1867
  });
885
1868
 
886
1869
  if (result.status !== 'success' || !result.output) {
@@ -996,6 +1979,53 @@ Please provide the fixed step configuration as JSON.`;
996
1979
  }
997
1980
  }
998
1981
 
1982
+ /**
1983
+ * Build a repair prompt for output validation failures
1984
+ */
1985
+ private buildOutputRepairPrompt(
1986
+ step: Step,
1987
+ output: unknown,
1988
+ validationError: string,
1989
+ strategy: 'reask' | 'repair' | 'hybrid'
1990
+ ): string {
1991
+ const llmStep = step as import('../parser/schema.ts').LlmStep;
1992
+ const originalPrompt = llmStep.prompt;
1993
+ const outputSchema = step.outputSchema;
1994
+
1995
+ const strategyInstructions = {
1996
+ reask: 'Please try again, carefully following the output format requirements.',
1997
+ repair:
1998
+ 'Please fix the output to match the required schema. You may need to restructure, add missing fields, or correct data types.',
1999
+ hybrid:
2000
+ 'Please fix the output to match the required schema. If you cannot fix it, regenerate a completely new response.',
2001
+ };
2002
+
2003
+ return `${originalPrompt}
2004
+
2005
+ ---
2006
+
2007
+ **OUTPUT REPAIR REQUIRED**
2008
+
2009
+ Your previous response failed validation. Here are the details:
2010
+
2011
+ **Your Previous Output:**
2012
+ \`\`\`json
2013
+ ${typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
2014
+ \`\`\`
2015
+
2016
+ **Validation Error:**
2017
+ ${validationError}
2018
+
2019
+ **Required Output Schema:**
2020
+ \`\`\`json
2021
+ ${JSON.stringify(outputSchema, null, 2)}
2022
+ \`\`\`
2023
+
2024
+ ${strategyInstructions[strategy]}
2025
+
2026
+ Please provide a corrected response that exactly matches the required schema.`;
2027
+ }
2028
+
999
2029
  /**
1000
2030
  * Execute a step (handles foreach if present)
1001
2031
  */
@@ -1011,12 +2041,23 @@ Please provide the fixed step configuration as JSON.`;
1011
2041
  return;
1012
2042
  }
1013
2043
 
2044
+ if (this.options.dryRun && step.type !== 'shell') {
2045
+ this.logger.log(` ⊘ [DRY RUN] Skipping ${step.type} step ${step.id}`);
2046
+ const stepExecId = randomUUID();
2047
+ await this.db.createStep(stepExecId, this.runId, step.id);
2048
+ await this.db.completeStep(stepExecId, StepStatus.SKIPPED, null);
2049
+ this.stepContexts.set(step.id, { status: StepStatus.SKIPPED });
2050
+ return;
2051
+ }
2052
+
1014
2053
  if (step.foreach) {
1015
2054
  const { ForeachExecutor } = await import('./foreach-executor.ts');
1016
2055
  const executor = new ForeachExecutor(
1017
2056
  this.db,
1018
2057
  this.logger,
1019
- this.executeStepInternal.bind(this)
2058
+ this.executeStepInternal.bind(this),
2059
+ this.abortSignal,
2060
+ this.resourcePool
1020
2061
  );
1021
2062
 
1022
2063
  const existingContext = this.stepContexts.get(step.id) as ForeachStepContext;
@@ -1038,8 +2079,14 @@ Please provide the fixed step configuration as JSON.`;
1038
2079
  throw new WorkflowSuspendedError(result.error || 'Workflow suspended', step.id, inputType);
1039
2080
  }
1040
2081
 
2082
+ if (result.status === 'waiting') {
2083
+ const wakeAt = getWakeAt(result.output);
2084
+ throw new WorkflowWaitingError(result.error || `Waiting until ${wakeAt}`, step.id, wakeAt);
2085
+ }
2086
+
1041
2087
  if (result.status === 'failed') {
1042
- throw new Error(`Step ${step.id} failed`);
2088
+ const suffix = result.error ? `: ${result.error}` : '';
2089
+ throw new Error(`Step ${step.id} failed${suffix}`);
1043
2090
  }
1044
2091
  }
1045
2092
  }
@@ -1051,7 +2098,7 @@ Please provide the fixed step configuration as JSON.`;
1051
2098
  step: WorkflowStep,
1052
2099
  context: ExpressionContext
1053
2100
  ): Promise<StepResult> {
1054
- const workflowPath = WorkflowRegistry.resolvePath(step.path);
2101
+ const workflowPath = WorkflowRegistry.resolvePath(step.path, this.options.workflowDir);
1055
2102
  const workflow = WorkflowParser.loadWorkflow(workflowPath);
1056
2103
  const subWorkflowDir = dirname(workflowPath);
1057
2104
 
@@ -1072,12 +2119,48 @@ Please provide the fixed step configuration as JSON.`;
1072
2119
  mcpManager: this.mcpManager,
1073
2120
  workflowDir: subWorkflowDir,
1074
2121
  depth: this.depth + 1,
2122
+ dedup: this.options.dedup,
2123
+ artifactRoot: this.options.artifactRoot,
1075
2124
  });
1076
2125
 
1077
2126
  try {
1078
2127
  const output = await subRunner.run();
2128
+
2129
+ const rawOutputs =
2130
+ typeof output === 'object' && output !== null && !Array.isArray(output) ? output : {};
2131
+ const mappedOutputs: Record<string, unknown> = {};
2132
+
2133
+ // Handle explicit output mapping
2134
+ if (step.outputMapping) {
2135
+ for (const [alias, mapping] of Object.entries(step.outputMapping)) {
2136
+ let originalKey: string;
2137
+ let defaultValue: unknown;
2138
+
2139
+ if (typeof mapping === 'string') {
2140
+ originalKey = mapping;
2141
+ } else {
2142
+ originalKey = mapping.from;
2143
+ defaultValue = mapping.default;
2144
+ }
2145
+
2146
+ if (originalKey in rawOutputs) {
2147
+ mappedOutputs[alias] = rawOutputs[originalKey];
2148
+ } else if (defaultValue !== undefined) {
2149
+ mappedOutputs[alias] = defaultValue;
2150
+ } else {
2151
+ throw new Error(
2152
+ `Sub-workflow output "${originalKey}" not found (required by mapping "${alias}" in step "${step.id}")`
2153
+ );
2154
+ }
2155
+ }
2156
+ }
2157
+
1079
2158
  return {
1080
- output,
2159
+ output: {
2160
+ ...mappedOutputs,
2161
+ outputs: rawOutputs, // Namespaced raw outputs
2162
+ __subRunId: subRunner.runId, // Track sub-workflow run ID for rollback
2163
+ },
1081
2164
  status: 'success',
1082
2165
  };
1083
2166
  } catch (error) {
@@ -1114,12 +2197,14 @@ Please provide the fixed step configuration as JSON.`;
1114
2197
  ' Workflows can execute arbitrary shell commands and access your environment.\n'
1115
2198
  );
1116
2199
 
2200
+ this.redactAtRest = ConfigLoader.load().storage?.redact_secrets_at_rest ?? true;
2201
+
1117
2202
  // Apply defaults and validate inputs
1118
2203
  this.applyDefaultsAndValidate();
1119
2204
 
1120
2205
  // Create run record (only for new runs, not for resume)
1121
2206
  if (!isResume) {
1122
- await this.db.createRun(this.runId, this.workflow.name, this.inputs);
2207
+ await this.db.createRun(this.runId, this.workflow.name, this.redactForStorage(this.inputs));
1123
2208
  }
1124
2209
  await this.db.updateRunStatus(this.runId, 'running');
1125
2210
 
@@ -1144,7 +2229,7 @@ Please provide the fixed step configuration as JSON.`;
1144
2229
  this.logger.log('All steps already completed. Nothing to resume.\n');
1145
2230
  // Evaluate outputs from completed state
1146
2231
  const outputs = this.evaluateOutputs();
1147
- await this.db.updateRunStatus(this.runId, 'success', outputs);
2232
+ await this.db.updateRunStatus(this.runId, 'success', this.redactForStorage(outputs));
1148
2233
  this.logger.log('✨ Workflow already completed!\n');
1149
2234
  return outputs;
1150
2235
  }
@@ -1176,45 +2261,86 @@ Please provide the fixed step configuration as JSON.`;
1176
2261
  );
1177
2262
  }
1178
2263
 
2264
+ // Register top-level compensation if defined
2265
+ if (this.workflow.compensate) {
2266
+ await this.registerWorkflowCompensation();
2267
+ }
2268
+
1179
2269
  // Execute steps in parallel where possible (respecting dependencies and global concurrency)
1180
2270
  const pendingSteps = new Set(remainingSteps);
1181
2271
  const runningPromises = new Map<string, Promise<void>>();
1182
2272
 
1183
2273
  try {
1184
2274
  while (pendingSteps.size > 0 || runningPromises.size > 0) {
2275
+ // Check for cancellation - drain in-flight steps but don't start new ones
2276
+ if (this.isCanceled) {
2277
+ if (runningPromises.size > 0) {
2278
+ this.logger.log(
2279
+ `⏳ Waiting for ${runningPromises.size} in-flight step(s) to complete...`
2280
+ );
2281
+ await Promise.allSettled(runningPromises.values());
2282
+ }
2283
+ throw new Error('Workflow canceled by user');
2284
+ }
2285
+
1185
2286
  // 1. Find runnable steps (all dependencies met)
1186
2287
  for (const stepId of pendingSteps) {
2288
+ // Don't schedule new steps if canceled
2289
+ if (this.isCanceled) break;
2290
+
1187
2291
  const step = stepMap.get(stepId);
1188
2292
  if (!step) {
1189
2293
  throw new Error(`Step ${stepId} not found in workflow`);
1190
2294
  }
1191
- const dependenciesMet = step.needs.every((dep: string) => completedSteps.has(dep));
2295
+
2296
+ let dependenciesMet = false;
2297
+ if (step.type === 'join') {
2298
+ dependenciesMet = this.isJoinConditionMet(
2299
+ step as import('../parser/schema.ts').JoinStep,
2300
+ completedSteps
2301
+ );
2302
+ } else {
2303
+ dependenciesMet = step.needs.every((dep: string) => completedSteps.has(dep));
2304
+ }
1192
2305
 
1193
2306
  if (dependenciesMet && runningPromises.size < globalConcurrencyLimit) {
1194
2307
  pendingSteps.delete(stepId);
1195
2308
 
2309
+ // Determine pool for this step
2310
+ const poolName = step.pool || step.type;
2311
+
1196
2312
  // Start execution
1197
2313
  const stepIndex = stepIndices.get(stepId);
1198
- this.logger.log(
1199
- `[${stepIndex}/${totalSteps}] Executing step: ${step.id} (${step.type})`
1200
- );
1201
- const promise = this.executeStepWithForeach(step)
1202
- .then(() => {
2314
+
2315
+ const promise = (async () => {
2316
+ let release: (() => void) | undefined;
2317
+ try {
2318
+ this.logger.debug?.(
2319
+ `[${stepIndex}/${totalSteps}] ⏳ Waiting for pool: ${poolName}`
2320
+ );
2321
+ release = await this.resourcePool.acquire(poolName, { signal: this.abortSignal });
2322
+
2323
+ this.logger.log(
2324
+ `[${stepIndex}/${totalSteps}] ▶ Executing step: ${step.id} (${step.type})`
2325
+ );
2326
+
2327
+ await this.executeStepWithForeach(step);
1203
2328
  completedSteps.add(stepId);
1204
- runningPromises.delete(stepId);
1205
2329
  this.logger.log(`[${stepIndex}/${totalSteps}] ✓ Step ${step.id} completed\n`);
1206
- })
1207
- .catch((err) => {
2330
+ } finally {
2331
+ if (typeof release === 'function') {
2332
+ release();
2333
+ }
1208
2334
  runningPromises.delete(stepId);
1209
- throw err; // Fail fast
1210
- });
2335
+ }
2336
+ })();
1211
2337
 
1212
2338
  runningPromises.set(stepId, promise);
1213
2339
  }
1214
2340
  }
1215
2341
 
1216
- // 2. Detect deadlock
1217
- if (runningPromises.size === 0 && pendingSteps.size > 0) {
2342
+ // 2. Detect deadlock (only if not canceled)
2343
+ if (!this.isCanceled && runningPromises.size === 0 && pendingSteps.size > 0) {
1218
2344
  const pendingList = Array.from(pendingSteps).join(', ');
1219
2345
  throw new Error(
1220
2346
  `Deadlock detected in workflow execution. Pending steps: ${pendingList}`
@@ -1231,14 +2357,28 @@ Please provide the fixed step configuration as JSON.`;
1231
2357
  if (runningPromises.size > 0) {
1232
2358
  await Promise.allSettled(runningPromises.values());
1233
2359
  }
2360
+
2361
+ const msg = error instanceof Error ? error.message : String(error);
2362
+
2363
+ // Trigger rollback
2364
+ await this.processCompensations(msg);
2365
+
2366
+ // Re-throw to be caught by the outer block (which calls stop)
2367
+ // Actually, the outer caller usually handles this.
2368
+ // But we want to ensure rollback happens BEFORE final status update if possible.
1234
2369
  throw error;
1235
2370
  }
1236
2371
 
2372
+ // Determine final status
2373
+ const failedSteps = remainingSteps.filter(
2374
+ (id) => this.stepContexts.get(id)?.status === StepStatus.FAILED
2375
+ );
2376
+
1237
2377
  // Evaluate outputs
1238
2378
  const outputs = this.evaluateOutputs();
1239
2379
 
1240
2380
  // Mark run as complete
1241
- await this.db.updateRunStatus(this.runId, 'success', outputs);
2381
+ await this.db.updateRunStatus(this.runId, 'success', this.redactForStorage(outputs));
1242
2382
 
1243
2383
  this.logger.log('✨ Workflow completed successfully!\n');
1244
2384
 
@@ -1249,9 +2389,33 @@ Please provide the fixed step configuration as JSON.`;
1249
2389
  this.logger.log(`\n⏸ Workflow paused: ${error.message}`);
1250
2390
  throw error;
1251
2391
  }
2392
+
2393
+ if (error instanceof WorkflowWaitingError) {
2394
+ await this.db.updateRunStatus(this.runId, 'paused');
2395
+ this.logger.log(`\n⏳ Workflow waiting: ${error.message}`);
2396
+ throw error;
2397
+ }
2398
+
1252
2399
  const errorMsg = error instanceof Error ? error.message : String(error);
2400
+
2401
+ // Find the failed step from stepContexts
2402
+ for (const [stepId, ctx] of this.stepContexts.entries()) {
2403
+ if (ctx.status === 'failed') {
2404
+ this.lastFailedStep = { id: stepId, error: ctx.error || errorMsg };
2405
+ break;
2406
+ }
2407
+ }
2408
+
2409
+ // Run errors block if defined (before finally, after retries exhausted)
2410
+ await this.runErrors();
2411
+
1253
2412
  this.logger.error(`\n✗ Workflow failed: ${errorMsg}\n`);
1254
- await this.db.updateRunStatus(this.runId, 'failed', undefined, errorMsg);
2413
+ await this.db.updateRunStatus(
2414
+ this.runId,
2415
+ 'failed',
2416
+ undefined,
2417
+ this.redactAtRest ? this.redactor.redact(errorMsg) : errorMsg
2418
+ );
1255
2419
  throw error;
1256
2420
  } finally {
1257
2421
  this.removeSignalHandlers();
@@ -1340,27 +2504,222 @@ Please provide the fixed step configuration as JSON.`;
1340
2504
  }
1341
2505
 
1342
2506
  /**
1343
- * Evaluate workflow outputs
2507
+ * Execute the errors block if defined (runs after a step exhausts retries, before finally)
1344
2508
  */
1345
- private evaluateOutputs(): Record<string, unknown> {
1346
- if (!this.workflow.outputs) {
1347
- return {};
2509
+ private async runErrors(): Promise<void> {
2510
+ if (!this.workflow.errors || this.workflow.errors.length === 0) {
2511
+ return;
2512
+ }
2513
+
2514
+ if (!this.lastFailedStep) {
2515
+ this.logger.warn('Errors block defined but no failed step context available');
2516
+ return;
2517
+ }
2518
+
2519
+ this.logger.log('\n🔧 Executing errors block...');
2520
+
2521
+ const stepMap = new Map(this.workflow.errors.map((s) => [s.id, s]));
2522
+ const completedErrorsSteps = new Set<string>();
2523
+ const pendingErrorsSteps = new Set(this.workflow.errors.map((s) => s.id));
2524
+ const runningPromises = new Map<string, Promise<void>>();
2525
+ const totalErrorsSteps = this.workflow.errors.length;
2526
+ const errorsStepIndices = new Map(this.workflow.errors.map((s, index) => [s.id, index + 1]));
2527
+
2528
+ try {
2529
+ while (pendingErrorsSteps.size > 0 || runningPromises.size > 0) {
2530
+ for (const stepId of pendingErrorsSteps) {
2531
+ const step = stepMap.get(stepId);
2532
+ if (!step) continue;
2533
+
2534
+ // Dependencies can be from main steps (already in this.stepContexts) or previous errors steps
2535
+ const dependenciesMet = step.needs.every(
2536
+ (dep: string) => this.stepContexts.has(dep) || completedErrorsSteps.has(dep)
2537
+ );
2538
+
2539
+ if (dependenciesMet) {
2540
+ pendingErrorsSteps.delete(stepId);
2541
+
2542
+ const errorsStepIndex = errorsStepIndices.get(stepId);
2543
+ this.logger.log(
2544
+ `[${errorsStepIndex}/${totalErrorsSteps}] ▶ Executing errors step: ${step.id} (${step.type})`
2545
+ );
2546
+ const promise = this.executeStepWithForeach(step)
2547
+ .then(() => {
2548
+ completedErrorsSteps.add(stepId);
2549
+ runningPromises.delete(stepId);
2550
+ this.logger.log(
2551
+ `[${errorsStepIndex}/${totalErrorsSteps}] ✓ Errors step ${step.id} completed\n`
2552
+ );
2553
+ })
2554
+ .catch((err) => {
2555
+ runningPromises.delete(stepId);
2556
+ this.logger.error(
2557
+ ` ✗ Errors step ${step.id} failed: ${err instanceof Error ? err.message : String(err)}`
2558
+ );
2559
+ // We continue with other errors steps if possible
2560
+ completedErrorsSteps.add(stepId); // Mark as "done" (even if failed) so dependents can run
2561
+ });
2562
+
2563
+ runningPromises.set(stepId, promise);
2564
+ }
2565
+ }
2566
+
2567
+ if (runningPromises.size === 0 && pendingErrorsSteps.size > 0) {
2568
+ this.logger.error('Deadlock in errors block detected');
2569
+ break;
2570
+ }
2571
+
2572
+ if (runningPromises.size > 0) {
2573
+ await Promise.race(runningPromises.values());
2574
+ }
2575
+ }
2576
+ } catch (error) {
2577
+ // Wait for other parallel steps to settle to avoid unhandled rejections
2578
+ if (runningPromises.size > 0) {
2579
+ await Promise.allSettled(runningPromises.values());
2580
+ }
2581
+ this.logger.error(
2582
+ `Error in errors block: ${error instanceof Error ? error.message : String(error)}`
2583
+ );
1348
2584
  }
2585
+ }
1349
2586
 
2587
+ /**
2588
+ * Evaluate workflow outputs
2589
+ */
2590
+ private evaluateOutputs(): Record<string, unknown> {
1350
2591
  const context = this.buildContext();
1351
2592
  const outputs: Record<string, unknown> = {};
1352
2593
 
1353
- for (const [key, expression] of Object.entries(this.workflow.outputs)) {
2594
+ if (this.workflow.outputs) {
2595
+ for (const [key, expression] of Object.entries(this.workflow.outputs)) {
2596
+ try {
2597
+ outputs[key] = ExpressionEvaluator.evaluate(expression, context);
2598
+ } catch (error) {
2599
+ this.logger.warn(
2600
+ `Warning: Failed to evaluate output "${key}": ${error instanceof Error ? error.message : String(error)}`
2601
+ );
2602
+ outputs[key] = null;
2603
+ }
2604
+ }
2605
+ }
2606
+
2607
+ // Validate outputs against schema if provided
2608
+ if (this.workflow.outputSchema) {
1354
2609
  try {
1355
- outputs[key] = ExpressionEvaluator.evaluate(expression, context);
2610
+ this.validateSchema('output', this.workflow.outputSchema, outputs, 'workflow');
1356
2611
  } catch (error) {
1357
- this.logger.warn(
1358
- `Warning: Failed to evaluate output "${key}": ${error instanceof Error ? error.message : String(error)}`
2612
+ throw new Error(
2613
+ `Workflow output validation failed: ${error instanceof Error ? error.message : String(error)}`
1359
2614
  );
1360
- outputs[key] = null;
1361
2615
  }
1362
2616
  }
1363
2617
 
1364
2618
  return outputs;
1365
2619
  }
2620
+
2621
+ /**
2622
+ * Check if a join condition is met based on completed dependencies
2623
+ */
2624
+ private isJoinConditionMet(
2625
+ step: import('../parser/schema.ts').JoinStep,
2626
+ completedSteps: Set<string>
2627
+ ): boolean {
2628
+ const total = step.needs.length;
2629
+ if (total === 0) return true;
2630
+
2631
+ // Count successful/skipped dependencies
2632
+ const successCount = step.needs.filter((dep) => completedSteps.has(dep)).length;
2633
+
2634
+ // Find failed/suspended dependencies (that we've already tried)
2635
+ // If some dependencies failed (and didn't allowFailure), the whole workflow would usually fail.
2636
+ // If allowFailure was true, they are in completedSteps.
2637
+ // So completedSteps effectively represents "done successfully".
2638
+
2639
+ if (step.condition === 'all') {
2640
+ return successCount === total;
2641
+ }
2642
+ if (step.condition === 'any') {
2643
+ // Met if at least one succeeded, OR if all finished and none succeeded?
2644
+ // Actually strictly "any" means at least one success.
2645
+ return successCount > 0;
2646
+ }
2647
+ if (typeof step.condition === 'number') {
2648
+ return successCount >= step.condition;
2649
+ }
2650
+
2651
+ return successCount === total;
2652
+ }
2653
+
2654
+ /**
2655
+ * Register top-level compensation for the workflow
2656
+ */
2657
+ private async registerWorkflowCompensation(): Promise<void> {
2658
+ if (!this.workflow.compensate) return;
2659
+
2660
+ // Check if already registered (for resume)
2661
+ const existing = await this.db.getAllCompensations(this.runId);
2662
+ if (existing.some((c) => c.step_id === 'workflow')) return;
2663
+
2664
+ const compStep = {
2665
+ ...this.workflow.compensate,
2666
+ id: this.workflow.compensate.id || `${this.workflow.name}-compensate`,
2667
+ };
2668
+ const definition = JSON.stringify(compStep);
2669
+ const compensationId = randomUUID();
2670
+
2671
+ this.logger.log(` 📎 Registering top-level compensation for workflow ${this.workflow.name}`);
2672
+ await this.db.registerCompensation(
2673
+ compensationId,
2674
+ this.runId,
2675
+ 'workflow', // use 'workflow' as step_id marker
2676
+ compStep.id,
2677
+ definition
2678
+ );
2679
+ }
2680
+
2681
+ /**
2682
+ * Cascade rollback to a child sub-workflow
2683
+ */
2684
+ private async cascadeRollback(subRunId: string, errorReason: string): Promise<void> {
2685
+ this.logger.log(` 📂 Cascading rollback to sub-workflow: ${subRunId}`);
2686
+ try {
2687
+ const runRecord = await this.db.getRun(subRunId);
2688
+ if (!runRecord) {
2689
+ this.logger.warn(` ⚠️ Could not find run record for sub-workflow ${subRunId}`);
2690
+ return;
2691
+ }
2692
+
2693
+ const workflowPath = WorkflowRegistry.resolvePath(
2694
+ runRecord.workflow_name,
2695
+ this.options.workflowDir
2696
+ );
2697
+ const workflow = WorkflowParser.loadWorkflow(workflowPath);
2698
+
2699
+ const subRunner = new WorkflowRunner(workflow, {
2700
+ resumeRunId: subRunId,
2701
+ dbPath: this.db.dbPath,
2702
+ logger: this.logger,
2703
+ mcpManager: this.mcpManager,
2704
+ workflowDir: dirname(workflowPath),
2705
+ depth: this.depth + 1,
2706
+ allowSuccessResume: true, // Internal workflows might need this
2707
+ resourcePoolManager: this.resourcePool,
2708
+ allowInsecure: this.options.allowInsecure,
2709
+ });
2710
+
2711
+ // Restore sub-workflow state
2712
+ await subRunner.restoreState();
2713
+
2714
+ // Trigger its compensations
2715
+ // We call the private method directly since we're in the same class (different instance)
2716
+ // but TypeScript might complain if it's strictly private.
2717
+ // Actually, in TS, private is accessible by other instances of the same class.
2718
+ await subRunner.processCompensations(errorReason);
2719
+ } catch (error) {
2720
+ this.logger.error(
2721
+ ` ⚠️ Failed to cascade rollback to ${subRunId}: ${error instanceof Error ? error.message : String(error)}`
2722
+ );
2723
+ }
2724
+ }
1366
2725
  }