keystone-cli 1.0.2 → 1.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 (155) hide show
  1. package/README.md +288 -24
  2. package/package.json +8 -4
  3. package/src/cli.ts +538 -419
  4. package/src/commands/doc.ts +31 -0
  5. package/src/commands/event.ts +29 -0
  6. package/src/commands/graph.ts +37 -0
  7. package/src/commands/index.ts +14 -0
  8. package/src/commands/init.ts +185 -0
  9. package/src/commands/run.ts +124 -0
  10. package/src/commands/schema.ts +40 -0
  11. package/src/commands/utils.ts +78 -0
  12. package/src/commands/validate.ts +111 -0
  13. package/src/db/memory-db.ts +50 -2
  14. package/src/db/workflow-db.test.ts +314 -0
  15. package/src/db/workflow-db.ts +810 -210
  16. package/src/expression/evaluator-audit.test.ts +4 -2
  17. package/src/expression/evaluator.test.ts +14 -1
  18. package/src/expression/evaluator.ts +166 -19
  19. package/src/parser/config-schema.ts +18 -0
  20. package/src/parser/schema.ts +153 -22
  21. package/src/parser/test-schema.ts +6 -6
  22. package/src/parser/workflow-parser.test.ts +24 -0
  23. package/src/parser/workflow-parser.ts +65 -3
  24. package/src/runner/auto-heal.test.ts +5 -6
  25. package/src/runner/blueprint-executor.test.ts +2 -2
  26. package/src/runner/debug-repl.test.ts +5 -8
  27. package/src/runner/debug-repl.ts +59 -16
  28. package/src/runner/durable-timers.test.ts +11 -2
  29. package/src/runner/engine-executor.test.ts +1 -1
  30. package/src/runner/events.ts +57 -0
  31. package/src/runner/executors/artifact-executor.ts +166 -0
  32. package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
  33. package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
  34. package/src/runner/executors/file-executor.test.ts +48 -0
  35. package/src/runner/executors/file-executor.ts +324 -0
  36. package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
  37. package/src/runner/executors/human-executor.ts +144 -0
  38. package/src/runner/executors/join-executor.ts +75 -0
  39. package/src/runner/executors/llm-executor.ts +1266 -0
  40. package/src/runner/executors/memory-executor.ts +71 -0
  41. package/src/runner/executors/plan-executor.ts +104 -0
  42. package/src/runner/executors/request-executor.ts +265 -0
  43. package/src/runner/executors/script-executor.ts +43 -0
  44. package/src/runner/executors/shell-executor.ts +403 -0
  45. package/src/runner/executors/subworkflow-executor.ts +114 -0
  46. package/src/runner/executors/types.ts +69 -0
  47. package/src/runner/executors/wait-executor.ts +59 -0
  48. package/src/runner/join-scheduling.test.ts +197 -0
  49. package/src/runner/llm-adapter-runtime.test.ts +209 -0
  50. package/src/runner/llm-adapter.test.ts +419 -24
  51. package/src/runner/llm-adapter.ts +414 -17
  52. package/src/runner/llm-clarification.test.ts +2 -1
  53. package/src/runner/llm-executor.test.ts +532 -17
  54. package/src/runner/mcp-client-audit.test.ts +1 -2
  55. package/src/runner/mcp-client.ts +136 -46
  56. package/src/runner/mcp-manager.test.ts +4 -0
  57. package/src/runner/mcp-server.test.ts +58 -0
  58. package/src/runner/mcp-server.ts +26 -0
  59. package/src/runner/memoization.test.ts +190 -0
  60. package/src/runner/optimization-runner.ts +4 -9
  61. package/src/runner/quality-gate.test.ts +69 -0
  62. package/src/runner/reflexion.test.ts +6 -17
  63. package/src/runner/resource-pool.ts +102 -14
  64. package/src/runner/services/context-builder.ts +144 -0
  65. package/src/runner/services/secret-manager.ts +105 -0
  66. package/src/runner/services/workflow-validator.ts +131 -0
  67. package/src/runner/shell-executor.test.ts +28 -4
  68. package/src/runner/standard-tools-ast.test.ts +196 -0
  69. package/src/runner/standard-tools-execution.test.ts +27 -0
  70. package/src/runner/standard-tools-integration.test.ts +6 -10
  71. package/src/runner/standard-tools.ts +339 -102
  72. package/src/runner/step-executor.test.ts +216 -4
  73. package/src/runner/step-executor.ts +69 -941
  74. package/src/runner/stream-utils.ts +7 -3
  75. package/src/runner/test-harness.ts +20 -1
  76. package/src/runner/timeout.test.ts +10 -0
  77. package/src/runner/timeout.ts +11 -2
  78. package/src/runner/tool-integration.test.ts +1 -1
  79. package/src/runner/wait-step.test.ts +102 -0
  80. package/src/runner/workflow-runner.test.ts +208 -15
  81. package/src/runner/workflow-runner.ts +890 -818
  82. package/src/runner/workflow-scheduler.ts +75 -0
  83. package/src/runner/workflow-state.ts +269 -0
  84. package/src/runner/workflow-subflows.test.ts +13 -12
  85. package/src/scripts/generate-schemas.ts +16 -0
  86. package/src/templates/agents/explore.md +1 -0
  87. package/src/templates/agents/general.md +1 -0
  88. package/src/templates/agents/handoff-router.md +14 -0
  89. package/src/templates/agents/handoff-specialist.md +15 -0
  90. package/src/templates/agents/keystone-architect.md +13 -44
  91. package/src/templates/agents/my-agent.md +1 -0
  92. package/src/templates/agents/software-engineer.md +1 -0
  93. package/src/templates/agents/summarizer.md +1 -0
  94. package/src/templates/agents/test-agent.md +1 -0
  95. package/src/templates/agents/tester.md +1 -0
  96. package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
  97. package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
  98. package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
  99. package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
  100. package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
  101. package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
  102. package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
  103. package/src/templates/control-flow/idempotency-example.yaml +30 -0
  104. package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
  105. package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
  106. package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
  107. package/src/templates/features/artifact-example.yaml +39 -0
  108. package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
  109. package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
  110. package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
  111. package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
  112. package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
  113. package/src/templates/features/script-example.yaml +27 -0
  114. package/src/templates/patterns/agent-handoff.yaml +53 -0
  115. package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
  116. package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
  117. package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
  118. package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
  119. package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
  120. package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
  121. package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
  122. package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
  123. package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
  124. package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
  125. package/src/templates/scaffolding/review-loop.yaml +97 -0
  126. package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
  127. package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
  128. package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
  129. package/src/templates/testing/invalid.yaml +6 -0
  130. package/src/ui/dashboard.tsx +191 -33
  131. package/src/utils/auth-manager.test.ts +337 -0
  132. package/src/utils/auth-manager.ts +157 -61
  133. package/src/utils/blueprint-utils.ts +4 -6
  134. package/src/utils/config-loader.test.ts +2 -0
  135. package/src/utils/config-loader.ts +12 -3
  136. package/src/utils/constants.ts +76 -0
  137. package/src/utils/container.ts +63 -0
  138. package/src/utils/context-injector.test.ts +200 -0
  139. package/src/utils/context-injector.ts +244 -0
  140. package/src/utils/doc-generator.ts +85 -0
  141. package/src/utils/env-filter.ts +45 -0
  142. package/src/utils/json-parser.test.ts +12 -0
  143. package/src/utils/json-parser.ts +30 -5
  144. package/src/utils/logger.ts +12 -1
  145. package/src/utils/mermaid.ts +4 -0
  146. package/src/utils/paths.ts +52 -1
  147. package/src/utils/process-sandbox-worker.test.ts +46 -0
  148. package/src/utils/process-sandbox.ts +227 -14
  149. package/src/utils/redactor.test.ts +11 -6
  150. package/src/utils/redactor.ts +25 -9
  151. package/src/utils/sandbox.ts +3 -0
  152. package/src/utils/workflow-registry.test.ts +2 -2
  153. package/src/runner/llm-executor.ts +0 -638
  154. package/src/runner/shell-executor.ts +0 -366
  155. package/src/templates/invalid.yaml +0 -5
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { dirname, join } from 'node:path';
@@ -6,19 +6,26 @@ import { MemoryDb } from '../db/memory-db.ts';
6
6
  import { type RunStatus, WorkflowDb } from '../db/workflow-db.ts';
7
7
  import type { ExpressionContext } from '../expression/evaluator.ts';
8
8
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
9
- import type { Step, Workflow, WorkflowStep } from '../parser/schema.ts';
9
+ import type { LlmStep, PlanStep, Step, Workflow, WorkflowStep } from '../parser/schema.ts';
10
10
  import { WorkflowParser } from '../parser/workflow-parser.ts';
11
11
  import { StepStatus, type StepStatusType, WorkflowStatus } from '../types/status.ts';
12
12
  import { ConfigLoader } from '../utils/config-loader.ts';
13
+ import { container } from '../utils/container.ts';
13
14
  import { extractJson } from '../utils/json-parser.ts';
14
- import { Redactor } from '../utils/redactor.ts';
15
+ import { ConsoleLogger, type Logger } from '../utils/logger.ts';
16
+ import type { Redactor } from '../utils/redactor.ts';
15
17
  import { formatSchemaErrors, validateJsonSchema } from '../utils/schema-validator.ts';
16
18
  import { WorkflowRegistry } from '../utils/workflow-registry.ts';
17
- import { ForeachExecutor } from './foreach-executor.ts';
19
+ import type { EventHandler, StepPhase, WorkflowEvent } from './events.ts';
20
+ import { ForeachExecutor } from './executors/foreach-executor.ts';
21
+ import { type RunnerFactory, executeSubWorkflow } from './executors/subworkflow-executor.ts';
18
22
  import { type LLMMessage, getAdapter } from './llm-adapter.ts';
19
23
  import { MCPManager } from './mcp-manager.ts';
20
24
  import { ResourcePoolManager } from './resource-pool.ts';
21
25
  import { withRetry } from './retry.ts';
26
+ import { ContextBuilder } from './services/context-builder.ts';
27
+ import { SecretManager } from './services/secret-manager.ts';
28
+ import { WorkflowValidator } from './services/workflow-validator.ts';
22
29
  import {
23
30
  type StepResult,
24
31
  WorkflowSuspendedError,
@@ -26,8 +33,8 @@ import {
26
33
  executeStep,
27
34
  } from './step-executor.ts';
28
35
  import { withTimeout } from './timeout.ts';
29
-
30
- import { ConsoleLogger, type Logger } from '../utils/logger.ts';
36
+ import { WorkflowScheduler } from './workflow-scheduler.ts';
37
+ import { type ForeachStepContext, type StepContext, WorkflowState } from './workflow-state.ts';
31
38
 
32
39
  /**
33
40
  * A logger wrapper that redacts secrets from all log messages
@@ -74,6 +81,22 @@ function getWakeAt(output: unknown): string | undefined {
74
81
  return typeof wakeAt === 'string' ? wakeAt : undefined;
75
82
  }
76
83
 
84
+ const QUALITY_GATE_SCHEMA = {
85
+ type: 'object',
86
+ properties: {
87
+ approved: { type: 'boolean' },
88
+ issues: { type: 'array', items: { type: 'string' } },
89
+ suggestions: { type: 'array', items: { type: 'string' } },
90
+ },
91
+ required: ['approved'],
92
+ };
93
+
94
+ type QualityGateReview = {
95
+ approved: boolean;
96
+ issues?: string[];
97
+ suggestions?: string[];
98
+ };
99
+
77
100
  export interface RunOptions {
78
101
  inputs?: Record<string, unknown>;
79
102
  secrets?: Record<string, string>;
@@ -90,32 +113,20 @@ export interface RunOptions {
90
113
  dedup?: boolean;
91
114
  getAdapter?: typeof getAdapter;
92
115
  executeStep?: typeof executeStep;
116
+ executeLlmStep?: typeof import('./executors/llm-executor.ts').executeLlmStep;
93
117
  depth?: number;
94
118
  allowSuccessResume?: boolean;
95
119
  resourcePoolManager?: ResourcePoolManager;
96
120
  allowInsecure?: boolean;
97
121
  artifactRoot?: string;
122
+ db?: WorkflowDb;
123
+ memoryDb?: MemoryDb;
124
+ onEvent?: EventHandler;
125
+ memoize?: boolean;
126
+ signal?: AbortSignal;
98
127
  }
99
128
 
100
- export interface StepContext {
101
- output?: unknown;
102
- outputs?: Record<string, unknown>;
103
- status: StepStatusType;
104
- error?: string;
105
- usage?: {
106
- prompt_tokens: number;
107
- completion_tokens: number;
108
- total_tokens: number;
109
- };
110
- }
111
-
112
- // Type for foreach results - wraps array to ensure JSON serialization preserves all properties
113
- export interface ForeachStepContext extends StepContext {
114
- items: StepContext[]; // Individual iteration results
115
- // output and outputs inherited from StepContext
116
- // output: array of output values
117
- // outputs: mapped outputs object
118
- }
129
+ // Redacted StepContext and ForeachStepContext (moved to workflow-state.ts)
119
130
 
120
131
  /**
121
132
  * Main workflow execution engine
@@ -124,16 +135,18 @@ export class WorkflowRunner {
124
135
  private workflow: Workflow;
125
136
  private db: WorkflowDb;
126
137
  private memoryDb: MemoryDb;
138
+ private contextMemory: Record<string, unknown> = {};
139
+ private envOverrides: Record<string, string> = {};
127
140
  private _runId!: string;
128
- private stepContexts: Map<string, StepContext | ForeachStepContext> = new Map();
141
+ private state!: WorkflowState;
142
+ private scheduler!: WorkflowScheduler;
129
143
  private inputs!: Record<string, unknown>;
130
- private secrets: Record<string, string>;
131
- private redactor: Redactor;
144
+ private secretManager: SecretManager;
145
+ private contextBuilder!: ContextBuilder;
146
+ private validator!: WorkflowValidator;
132
147
  private rawLogger!: Logger;
133
- private secretValues: string[] = [];
134
148
  private redactAtRest = true;
135
149
  private resumeRunId?: string;
136
- private restored = false;
137
150
  private logger!: Logger;
138
151
  private mcpManager: MCPManager;
139
152
  private options: RunOptions;
@@ -147,6 +160,7 @@ export class WorkflowRunner {
147
160
  private lastFailedStep?: { id: string; error: string };
148
161
  private abortController = new AbortController();
149
162
  private resourcePool!: ResourcePoolManager;
163
+ private restored = false;
150
164
 
151
165
  /**
152
166
  * Get the abort signal for cancellation checks
@@ -162,6 +176,27 @@ export class WorkflowRunner {
162
176
  return this.abortController.signal.aborted;
163
177
  }
164
178
 
179
+ private createStepAbortController(): { controller: AbortController; cleanup: () => void } {
180
+ const controller = new AbortController();
181
+ const parentSignal = this.abortSignal;
182
+ const onAbort = () => {
183
+ if (!controller.signal.aborted) {
184
+ controller.abort();
185
+ }
186
+ };
187
+
188
+ if (parentSignal.aborted) {
189
+ controller.abort();
190
+ return { controller, cleanup: () => {} };
191
+ }
192
+
193
+ parentSignal.addEventListener('abort', onAbort, { once: true });
194
+ return {
195
+ controller,
196
+ cleanup: () => parentSignal.removeEventListener('abort', onAbort),
197
+ };
198
+ }
199
+
165
200
  constructor(workflow: Workflow, options: RunOptions = {}) {
166
201
  this.workflow = workflow;
167
202
  this.options = options;
@@ -173,23 +208,54 @@ export class WorkflowRunner {
173
208
  );
174
209
  }
175
210
 
176
- this.db = new WorkflowDb(options.dbPath);
177
- this.memoryDb = new MemoryDb(options.memoryDbPath);
178
- this.secrets = this.loadSecrets();
179
- this.redactor = new Redactor(this.secrets, { forcedSecrets: this.secretValues });
180
-
211
+ // Use injected instances or resolve from container or create new from paths
212
+ this.db =
213
+ options.db ||
214
+ (options.dbPath
215
+ ? new WorkflowDb(options.dbPath)
216
+ : container.resolveOptional<WorkflowDb>('db')) ||
217
+ new WorkflowDb(options.dbPath);
218
+
219
+ this.memoryDb =
220
+ options.memoryDb ||
221
+ (options.memoryDbPath
222
+ ? new MemoryDb(options.memoryDbPath)
223
+ : container.resolveOptional<MemoryDb>('memoryDb')) ||
224
+ new MemoryDb(options.memoryDbPath);
225
+
226
+ this.secretManager = new SecretManager(options.secrets || {});
181
227
  this.initLogger(options);
228
+ this.initRun(options);
229
+
230
+ this.validator = new WorkflowValidator(this.workflow, this.inputs);
231
+ this.contextBuilder = new ContextBuilder(
232
+ this.workflow,
233
+ this.inputs,
234
+ this.secretManager.getSecretValues(),
235
+ this.state,
236
+ this.logger
237
+ );
182
238
  this.mcpManager = options.mcpManager || new MCPManager();
183
239
  this.initResourcePool(options);
184
- this.initRun(options);
240
+
241
+ if (options.signal) {
242
+ if (options.signal.aborted) {
243
+ this.abortController.abort();
244
+ } else {
245
+ options.signal.addEventListener('abort', () => this.abortController.abort(), {
246
+ once: true,
247
+ });
248
+ }
249
+ }
185
250
 
186
251
  this.setupSignalHandlers();
187
252
  }
188
253
 
189
254
  private initLogger(options: RunOptions): void {
190
- const rawLogger = options.logger || new ConsoleLogger();
255
+ const rawLogger =
256
+ options.logger || container.resolveOptional<Logger>('logger') || new ConsoleLogger();
191
257
  this.rawLogger = rawLogger;
192
- this.logger = new RedactingLogger(rawLogger, this.redactor);
258
+ this.logger = new RedactingLogger(rawLogger, this.secretManager.getRedactor());
193
259
  }
194
260
 
195
261
  private initResourcePool(options: RunOptions): void {
@@ -201,7 +267,7 @@ export class WorkflowRunner {
201
267
  const workflowPools: Record<string, number> = {};
202
268
 
203
269
  if (this.workflow.pools) {
204
- const baseContext = this.buildContext();
270
+ const baseContext = this.contextBuilder.buildContext(this.secretManager.getSecrets());
205
271
  for (const [name, limit] of Object.entries(this.workflow.pools)) {
206
272
  if (typeof limit === 'string') {
207
273
  workflowPools[name] = Number(ExpressionEvaluator.evaluate(limit, baseContext));
@@ -227,6 +293,16 @@ export class WorkflowRunner {
227
293
  this.inputs = options.inputs || {};
228
294
  this._runId = randomUUID();
229
295
  }
296
+
297
+ this.state = new WorkflowState(
298
+ this._runId,
299
+ this.workflow,
300
+ this.db,
301
+ this.inputs,
302
+ this.secretManager.getSecrets(),
303
+ this.logger
304
+ );
305
+ this.scheduler = new WorkflowScheduler(this.workflow, this.state.getCompletedStepIds());
230
306
  }
231
307
 
232
308
  /**
@@ -252,8 +328,6 @@ export class WorkflowRunner {
252
328
  throw new Error(`Run ${this.runId} not found`);
253
329
  }
254
330
 
255
- // Only allow resuming failed, paused, canceled, or running (crash recovery) runs
256
- // Unless specifically allowed (e.g. for rollback/compensation)
257
331
  if (
258
332
  run.status !== WorkflowStatus.FAILED &&
259
333
  run.status !== WorkflowStatus.PAUSED &&
@@ -268,7 +342,7 @@ export class WorkflowRunner {
268
342
 
269
343
  if (run.status === WorkflowStatus.RUNNING) {
270
344
  this.logger.warn(
271
- `⚠️ Resuming a run marked as 'running'. This usually means the previous process crashed or was killed forcefully. Ensure no other instances are running.`
345
+ `⚠️ Resuming a run marked as 'running'. This usually means the previous process crashed or was killed forcefully.`
272
346
  );
273
347
  }
274
348
 
@@ -276,209 +350,20 @@ export class WorkflowRunner {
276
350
  this.logger.log('📋 Resuming a previously canceled run. Completed steps will be skipped.');
277
351
  }
278
352
 
279
- // Restore inputs from the previous run to ensure consistency
280
- // Merge with any resumeInputs provided (e.g. answers to human steps)
281
- try {
282
- if (!run.inputs || run.inputs === 'null' || run.inputs === '') {
283
- this.logger.warn(`Run ${this.runId} has no persisted inputs`);
284
- // Keep existing inputs
285
- } else {
286
- const storedInputs = JSON.parse(run.inputs);
287
- this.inputs = { ...storedInputs, ...this.inputs };
288
- }
289
- } catch (error) {
290
- this.logger.error(
291
- `CRITICAL: Failed to parse inputs from run ${this.runId}. Data may be corrupted. Using default/resume inputs. Error: ${error instanceof Error ? error.message : String(error)}`
292
- );
293
- // Fallback: preserve existing inputs from resume options
294
- }
295
-
296
- // Load all step executions for this run
297
- const steps = await this.db.getStepsByRun(this.runId);
298
-
299
- // Group steps by step_id to handle foreach loops (multiple executions per step_id)
300
- const stepExecutionsByStepId = new Map<string, typeof steps>();
301
- for (const step of steps) {
302
- if (!stepExecutionsByStepId.has(step.step_id)) {
303
- stepExecutionsByStepId.set(step.step_id, []);
304
- }
305
- stepExecutionsByStepId.get(step.step_id)?.push(step);
306
- }
307
-
308
- // Get topological order to ensure dependencies are restored before dependents
309
- const executionOrder = WorkflowParser.topologicalSort(this.workflow);
310
- const completedStepIds = new Set<string>();
311
-
312
- // Reconstruct step contexts in topological order
313
- for (const stepId of executionOrder) {
314
- const stepExecutions = stepExecutionsByStepId.get(stepId);
315
- if (!stepExecutions || stepExecutions.length === 0) continue;
316
-
317
- const stepDef = this.workflow.steps.find((s) => s.id === stepId);
318
- if (!stepDef) continue;
319
-
320
- const isForeach = !!stepDef.foreach;
321
-
322
- if (isForeach) {
323
- // Reconstruct foreach aggregated context
324
- const items: StepContext[] = [];
325
- const outputs: unknown[] = [];
326
- let allSuccess = true;
327
-
328
- // Sort by iteration_index to ensure correct order
329
- const sortedExecs = [...stepExecutions].sort(
330
- (a, b) => (a.iteration_index ?? 0) - (b.iteration_index ?? 0)
331
- );
332
-
333
- for (const exec of sortedExecs) {
334
- if (exec.iteration_index === null) continue; // Skip parent step record
335
-
336
- if (exec.status === StepStatus.SUCCESS || exec.status === StepStatus.SKIPPED) {
337
- let output: unknown = null;
338
- try {
339
- output = exec.output ? JSON.parse(exec.output) : null;
340
- } catch (error) {
341
- this.logger.warn(
342
- `Failed to parse output for step ${stepId} iteration ${exec.iteration_index}: ${error}`
343
- );
344
- output = { error: 'Failed to parse output' };
345
- }
346
- items[exec.iteration_index] = {
347
- output,
348
- outputs:
349
- typeof output === 'object' && output !== null && !Array.isArray(output)
350
- ? (output as Record<string, unknown>)
351
- : {},
352
- status: exec.status as typeof StepStatus.SUCCESS | typeof StepStatus.SKIPPED,
353
- error: exec.error || undefined,
354
- };
355
- outputs[exec.iteration_index] = output;
356
- } else {
357
- allSuccess = false;
358
- // Still populate with placeholder if failed
359
- items[exec.iteration_index] = {
360
- output: null,
361
- outputs: {},
362
- status: exec.status as StepStatusType,
363
- error: exec.error || undefined,
364
- };
365
- }
366
- }
367
-
368
- // Use persisted foreach items from parent step for deterministic resume
369
- // This ensures the resume uses the same array as the initial run
370
- let expectedCount = -1;
371
- const parentExec = stepExecutions.find((e) => e.iteration_index === null);
372
- if (parentExec?.output) {
373
- try {
374
- const parsed = JSON.parse(parentExec.output);
375
- if (parsed.__foreachItems && Array.isArray(parsed.__foreachItems)) {
376
- expectedCount = parsed.__foreachItems.length;
377
- }
378
- } catch (_e) {
379
- // ignore parse errors
380
- }
381
- }
382
-
383
- // Fallback to expression evaluation if persisted items not found
384
- if (expectedCount === -1) {
385
- try {
386
- const baseContext = this.buildContext();
387
- const foreachExpr = stepDef.foreach;
388
- if (foreachExpr) {
389
- const foreachItems = ExpressionEvaluator.evaluate(foreachExpr, baseContext);
390
- if (Array.isArray(foreachItems)) {
391
- expectedCount = foreachItems.length;
392
- }
393
- }
394
- } catch (e) {
395
- // If we can't evaluate yet (dependencies not met?), we can't be sure it's complete
396
- allSuccess = false;
397
- }
398
- }
399
-
400
- // Check if we have all items (no gaps)
401
- const hasAllItems =
402
- expectedCount !== -1 &&
403
- items.length === expectedCount &&
404
- !Array.from({ length: expectedCount }).some((_, i) => !items[i]);
405
-
406
- // Determine overall status based on iterations
407
- let status: StepContext['status'] = StepStatus.SUCCESS;
408
- if (allSuccess && hasAllItems) {
409
- status = StepStatus.SUCCESS;
410
- } else if (items.some((item) => item?.status === StepStatus.SUSPENDED)) {
411
- status = StepStatus.SUSPENDED;
412
- } else {
413
- status = StepStatus.FAILED;
414
- }
353
+ // Hydrate state from DB
354
+ await this.state.restore();
415
355
 
416
- // Always restore what we have to allow partial expression evaluation
417
- const mappedOutputs = ForeachExecutor.aggregateOutputs(outputs);
418
- this.stepContexts.set(stepId, {
419
- output: outputs,
420
- outputs: mappedOutputs,
421
- status,
422
- items,
423
- } as ForeachStepContext);
424
-
425
- // Only mark as fully completed if all iterations completed successfully AND we have all items
426
- if (status === StepStatus.SUCCESS) {
427
- completedStepIds.add(stepId);
428
- }
429
- } else {
430
- // Single execution step
431
- const exec = stepExecutions[0];
432
- if (
433
- exec.status === StepStatus.SUCCESS ||
434
- exec.status === StepStatus.SKIPPED ||
435
- exec.status === StepStatus.SUSPENDED ||
436
- exec.status === StepStatus.WAITING
437
- ) {
438
- let output: unknown = null;
439
- try {
440
- output = exec.output ? JSON.parse(exec.output) : null;
441
- } catch (error) {
442
- this.logger.warn(`Failed to parse output for step ${stepId}: ${error}`);
443
- output = { error: 'Failed to parse output' };
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
-
464
- this.stepContexts.set(stepId, {
465
- output,
466
- outputs:
467
- typeof output === 'object' && output !== null && !Array.isArray(output)
468
- ? (output as Record<string, unknown>)
469
- : {},
470
- status: effectiveStatus,
471
- error: effectiveError,
472
- });
473
- if (effectiveStatus !== StepStatus.SUSPENDED && effectiveStatus !== StepStatus.WAITING) {
474
- completedStepIds.add(stepId);
475
- }
476
- }
356
+ // Re-initialize scheduler with completed steps from restored state
357
+ const completedSteps = new Set<string>();
358
+ for (const [stepId, ctx] of this.state.entries()) {
359
+ if (ctx.status === StepStatus.SUCCESS || ctx.status === StepStatus.SKIPPED) {
360
+ completedSteps.add(stepId);
477
361
  }
478
362
  }
363
+ this.scheduler = new WorkflowScheduler(this.workflow, completedSteps);
479
364
 
480
365
  this.restored = true;
481
- this.logger.log(`✓ Restored state: ${completedStepIds.size} step(s) already completed`);
366
+ this.logger.log(`✓ Restored state: ${completedSteps.size} step(s) hydrated`);
482
367
  }
483
368
 
484
369
  /**
@@ -526,33 +411,40 @@ export class WorkflowRunner {
526
411
  const stepDef = JSON.parse(compRecord.definition) as Step;
527
412
  this.logger.log(` Running compensation: ${stepDef.id} (undoing ${compRecord.step_id})`);
528
413
 
529
- await this.db.updateCompensationStatus(compRecord.id, 'running');
414
+ await this.db.updateCompensationStatus(compRecord.id, StepStatus.RUNNING);
530
415
 
531
416
  // Build context for compensation
532
417
  // It has access to the original step's output via steps.<step_id>.output
533
- const context = this.buildContext();
418
+ const context = this.contextBuilder.buildContext(this.secretManager.getSecrets());
534
419
 
535
420
  try {
536
421
  // Execute the compensation step
537
422
  const result = await executeStep(stepDef, context, this.logger, {
538
423
  executeWorkflowFn: this.executeSubWorkflow.bind(this),
539
424
  mcpManager: this.mcpManager,
425
+ db: this.db,
540
426
  memoryDb: this.memoryDb,
541
427
  workflowDir: this.options.workflowDir,
542
428
  dryRun: this.options.dryRun,
543
429
  runId: this.runId,
544
430
  artifactRoot: this.options.artifactRoot,
545
431
  redactForStorage: this.redactForStorage.bind(this),
432
+ emitEvent: this.emitEvent.bind(this),
433
+ workflowName: this.workflow.name,
546
434
  });
547
435
 
548
- if (result.status === 'success') {
436
+ if (result.status === StepStatus.SUCCESS) {
549
437
  this.logger.log(` ✓ Compensation ${stepDef.id} succeeded`);
550
- await this.db.updateCompensationStatus(compRecord.id, 'success', result.output);
438
+ await this.db.updateCompensationStatus(
439
+ compRecord.id,
440
+ StepStatus.SUCCESS,
441
+ result.output
442
+ );
551
443
  } else {
552
444
  this.logger.error(` ✗ Compensation ${stepDef.id} failed: ${result.error}`);
553
445
  await this.db.updateCompensationStatus(
554
446
  compRecord.id,
555
- 'failed',
447
+ StepStatus.FAILED,
556
448
  result.output,
557
449
  result.error
558
450
  );
@@ -560,7 +452,7 @@ export class WorkflowRunner {
560
452
  } catch (err) {
561
453
  const errMsg = err instanceof Error ? err.message : String(err);
562
454
  this.logger.error(` ✗ Compensation ${stepDef.id} crashed: ${errMsg}`);
563
- await this.db.updateCompensationStatus(compRecord.id, 'failed', null, errMsg);
455
+ await this.db.updateCompensationStatus(compRecord.id, StepStatus.FAILED, null, errMsg);
564
456
  }
565
457
 
566
458
  // 2. Recursive rollback for sub-workflows
@@ -574,8 +466,10 @@ export class WorkflowRunner {
574
466
  if (subRunId) {
575
467
  await this.cascadeRollback(subRunId, errorReason);
576
468
  }
577
- } catch (_e) {
578
- // ignore parse errors
469
+ } catch (e) {
470
+ this.logger.warn(
471
+ ` ⚠️ Failed to parse sub-workflow output for rollback: ${e instanceof Error ? e.message : String(e)}`
472
+ );
579
473
  }
580
474
  }
581
475
  }
@@ -631,69 +525,36 @@ export class WorkflowRunner {
631
525
  }
632
526
  }
633
527
 
634
- /**
635
- * Load secrets from environment
636
- */
637
- private loadSecrets(): Record<string, string> {
638
- const secrets: Record<string, string> = { ...(this.options.secrets || {}) };
639
-
640
- // Common non-secret environment variables to exclude from redaction
641
- const blocklist = new Set([
642
- 'USER',
643
- 'PATH',
644
- 'SHELL',
645
- 'HOME',
646
- 'PWD',
647
- 'LOGNAME',
648
- 'LANG',
649
- 'TERM',
650
- 'EDITOR',
651
- 'VISUAL',
652
- '_',
653
- 'SHLVL',
654
- 'LC_ALL',
655
- 'DISPLAY',
656
- 'SSH_AUTH_SOCK',
657
- 'XPC_FLAGS',
658
- 'XPC_SERVICE_NAME',
659
- 'ITERM_SESSION_ID',
660
- 'ITERM_PROFILE',
661
- 'TERM_PROGRAM',
662
- 'TERM_PROGRAM_VERSION',
663
- 'COLORTERM',
664
- 'LC_TERMINAL',
665
- 'LC_TERMINAL_VERSION',
666
- 'PWD',
667
- 'OLDPWD',
668
- 'HOME',
669
- 'USER',
670
- 'SHELL',
671
- 'PATH',
672
- 'LOGNAME',
673
- 'TMPDIR',
674
- 'XDG_CONFIG_HOME',
675
- 'XDG_DATA_HOME',
676
- 'XDG_CACHE_HOME',
677
- 'XDG_RUNTIME_DIR',
678
- ]);
679
-
680
- // Bun automatically loads .env file
681
- for (const [key, value] of Object.entries(Bun.env)) {
682
- if (value && !blocklist.has(key)) {
683
- secrets[key] = value;
684
- }
685
- }
686
- return secrets;
528
+ private redactForStorage<T>(value: T): T {
529
+ if (!this.redactAtRest) return value;
530
+ return this.secretManager.getRedactor().redactValue(value) as T;
687
531
  }
688
532
 
689
- private refreshRedactor(): void {
690
- this.redactor = new Redactor(this.loadSecrets(), { forcedSecrets: this.secretValues });
691
- this.logger = new RedactingLogger(this.rawLogger, this.redactor);
692
- }
533
+ private async calculateStepCacheKey(
534
+ step: Step,
535
+ inputs: Record<string, unknown>
536
+ ): Promise<string | null> {
537
+ const memoizeEnabled = step.memoize ?? this.options.memoize ?? false;
538
+ if (!memoizeEnabled) return null;
693
539
 
694
- private redactForStorage<T>(value: T): T {
695
- if (!this.redactAtRest) return value;
696
- return this.redactor.redactValue(value) as T;
540
+ // Only memoize deterministic steps by default unless explicitly requested
541
+ const cacheableTypes = ['shell', 'file', 'script', 'request', 'engine', 'blueprint'];
542
+ if (!cacheableTypes.includes(step.type) && step.memoize !== true) return null;
543
+
544
+ const data = {
545
+ type: step.type,
546
+ inputs,
547
+ env: step.env,
548
+ version: 2, // Cache versioning
549
+ };
550
+
551
+ // Use runtime-agnostic hashing
552
+ // @ts-ignore - Check for Bun environment
553
+ const hash =
554
+ typeof Bun !== 'undefined'
555
+ ? Bun.hash(JSON.stringify(data)).toString(16)
556
+ : createHash('sha256').update(JSON.stringify(data)).digest('hex');
557
+ return hash;
697
558
  }
698
559
 
699
560
  private validateSchema(
@@ -768,6 +629,24 @@ export class WorkflowRunner {
768
629
  op: step.op,
769
630
  allowOutsideCwd: step.allowOutsideCwd,
770
631
  });
632
+ case 'artifact':
633
+ return stripUndefined({
634
+ op: step.op,
635
+ name: ExpressionEvaluator.evaluateString(
636
+ (step as import('../parser/schema.ts').ArtifactStep).name,
637
+ context
638
+ ),
639
+ paths: (step as import('../parser/schema.ts').ArtifactStep).paths?.map((p) =>
640
+ ExpressionEvaluator.evaluateString(p, context)
641
+ ),
642
+ path: (step as import('../parser/schema.ts').ArtifactStep).path
643
+ ? ExpressionEvaluator.evaluateString(
644
+ (step as import('../parser/schema.ts').ArtifactStep).path as string,
645
+ context
646
+ )
647
+ : undefined,
648
+ allowOutsideCwd: step.allowOutsideCwd,
649
+ });
771
650
  case 'request': {
772
651
  let headers: Record<string, string> | undefined;
773
652
  if (step.headers) {
@@ -804,9 +683,11 @@ export class WorkflowRunner {
804
683
  }
805
684
  case 'llm':
806
685
  return stripUndefined({
807
- agent: step.agent,
808
- provider: step.provider,
809
- model: step.model,
686
+ agent: ExpressionEvaluator.evaluateString(step.agent, context),
687
+ provider: step.provider
688
+ ? ExpressionEvaluator.evaluateString(step.provider, context)
689
+ : undefined,
690
+ model: step.model ? ExpressionEvaluator.evaluateString(step.model, context) : undefined,
810
691
  prompt: ExpressionEvaluator.evaluateString(step.prompt, context),
811
692
  tools: step.tools,
812
693
  maxIterations: step.maxIterations,
@@ -867,6 +748,14 @@ export class WorkflowRunner {
867
748
  : undefined,
868
749
  limit: step.limit,
869
750
  });
751
+ case 'wait':
752
+ return stripUndefined({
753
+ event: ExpressionEvaluator.evaluateString(
754
+ (step as import('../parser/schema.ts').WaitStep).event,
755
+ context
756
+ ),
757
+ oneShot: (step as import('../parser/schema.ts').WaitStep).oneShot,
758
+ });
870
759
  default:
871
760
  return {};
872
761
  }
@@ -875,196 +764,6 @@ export class WorkflowRunner {
875
764
  /**
876
765
  * Collect primitive secret values from structured inputs.
877
766
  */
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
-
912
- /**
913
- * Apply workflow defaults to inputs and validate types
914
- */
915
- private applyDefaultsAndValidate(): void {
916
- if (!this.workflow.inputs) return;
917
-
918
- const secretValues = new Set<string>();
919
-
920
- for (const [key, config] of Object.entries(this.workflow.inputs)) {
921
- // Apply default if missing
922
- if (this.inputs[key] === undefined && config.default !== undefined) {
923
- this.inputs[key] = config.default;
924
- }
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
-
934
- // Validate required inputs
935
- if (this.inputs[key] === undefined) {
936
- throw new Error(`Missing required input: ${key}`);
937
- }
938
-
939
- // Basic type validation
940
- const value = this.inputs[key];
941
- const type = config.type.toLowerCase();
942
-
943
- if (type === 'string' && typeof value !== 'string') {
944
- throw new Error(`Input "${key}" must be a string, got ${typeof value}`);
945
- }
946
- if (type === 'number' && typeof value !== 'number') {
947
- throw new Error(`Input "${key}" must be a number, got ${typeof value}`);
948
- }
949
- if (type === 'boolean' && typeof value !== 'boolean') {
950
- throw new Error(`Input "${key}" must be a boolean, got ${typeof value}`);
951
- }
952
- if (type === 'array' && !Array.isArray(value)) {
953
- throw new Error(`Input "${key}" must be an array, got ${typeof value}`);
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
- }
987
- }
988
-
989
- this.secretValues = Array.from(secretValues);
990
- this.refreshRedactor();
991
- }
992
-
993
- /**
994
- * Build expression context for evaluation
995
- */
996
- private buildContext(item?: unknown, index?: number): ExpressionContext {
997
- const stepsContext: Record<
998
- string,
999
- {
1000
- output?: unknown;
1001
- outputs?: Record<string, unknown>;
1002
- status?: string;
1003
- error?: string;
1004
- items?: StepContext[];
1005
- }
1006
- > = {};
1007
-
1008
- for (const [stepId, ctx] of this.stepContexts.entries()) {
1009
- // For foreach results, include items array for iteration access
1010
- if ('items' in ctx && ctx.items) {
1011
- stepsContext[stepId] = {
1012
- output: ctx.output,
1013
- outputs: ctx.outputs,
1014
- status: ctx.status,
1015
- error: ctx.error,
1016
- items: ctx.items,
1017
- };
1018
- } else {
1019
- stepsContext[stepId] = {
1020
- output: ctx.output,
1021
- outputs: ctx.outputs,
1022
- status: ctx.status,
1023
- error: ctx.error,
1024
- };
1025
- }
1026
- }
1027
-
1028
- const baseContext: ExpressionContext = {
1029
- inputs: this.inputs,
1030
- secrets: this.loadSecrets(), // Access secrets from options
1031
- secretValues: this.secretValues,
1032
- steps: stepsContext,
1033
- item,
1034
- index,
1035
- env: {},
1036
- output: item
1037
- ? undefined
1038
- : this.stepContexts.get(this.workflow.steps.find((s) => !s.foreach)?.id || '')?.output,
1039
- last_failed_step: this.lastFailedStep,
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;
1066
- }
1067
-
1068
767
  /**
1069
768
  * Evaluate a conditional expression
1070
769
  */
@@ -1082,10 +781,9 @@ export class WorkflowRunner {
1082
781
  try {
1083
782
  return !this.evaluateCondition(step.if, context);
1084
783
  } catch (error) {
1085
- this.logger.error(
1086
- `Warning: Failed to evaluate condition for step ${step.id}: ${error instanceof Error ? error.message : String(error)}`
784
+ throw new Error(
785
+ `Failed to evaluate condition for step "${step.id}": ${error instanceof Error ? error.message : String(error)}`
1087
786
  );
1088
- return true; // Skip on error
1089
787
  }
1090
788
  }
1091
789
 
@@ -1134,58 +832,30 @@ export class WorkflowRunner {
1134
832
  try {
1135
833
  await this.db.clearExpiredIdempotencyRecord(scopedKey);
1136
834
 
1137
- const existing = await this.db.getIdempotencyRecord(scopedKey);
1138
- if (existing) {
1139
- if (existing.status === StepStatus.SUCCESS) {
835
+ const result = await this.db.atomicClaimIdempotencyKey(
836
+ scopedKey,
837
+ this.runId,
838
+ stepId,
839
+ ttlSeconds
840
+ );
841
+
842
+ switch (result.status) {
843
+ case 'claimed':
844
+ return { status: 'claimed' };
845
+ case 'already-running':
846
+ return { status: 'in-flight' };
847
+ case 'completed': {
1140
848
  let output: unknown = null;
1141
849
  try {
1142
- output = existing.output ? JSON.parse(existing.output) : null;
850
+ output = result.record.output ? JSON.parse(result.record.output) : null;
1143
851
  } catch (parseError) {
1144
852
  this.logger.warn(
1145
853
  ` ⚠️ Failed to parse idempotency output for ${stepId}: ${parseError instanceof Error ? parseError.message : String(parseError)}`
1146
854
  );
1147
855
  }
1148
- return { status: 'hit', output, error: existing.error || undefined };
1149
- }
1150
- if (existing.status === StepStatus.RUNNING) {
1151
- return { status: 'in-flight' };
856
+ return { status: 'hit', output, error: result.record.error || undefined };
1152
857
  }
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
858
  }
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
859
  } catch (error) {
1190
860
  this.logger.warn(
1191
861
  ` ⚠️ Failed to claim idempotency key for ${stepId}: ${error instanceof Error ? error.message : String(error)}`
@@ -1279,7 +949,7 @@ export class WorkflowRunner {
1279
949
  stepExecId,
1280
950
  StepStatus.FAILED,
1281
951
  null,
1282
- this.redactAtRest ? this.redactor.redact(errorMsg) : errorMsg
952
+ this.secretManager.redactAtRest ? this.secretManager.redact(errorMsg) : errorMsg
1283
953
  );
1284
954
  return {
1285
955
  output: null,
@@ -1292,6 +962,26 @@ export class WorkflowRunner {
1292
962
  }
1293
963
  }
1294
964
 
965
+ // Global step caching (memoization)
966
+ const inputs = this.contextBuilder.buildStepInputs(step, context);
967
+ const cacheKey = await this.calculateStepCacheKey(step, inputs);
968
+ if (cacheKey) {
969
+ const cached = await this.db.getStepCache(cacheKey);
970
+ if (cached) {
971
+ this.logger.log(` ⚡ Step ${step.id} skipped (global cache hit)`);
972
+ const output = JSON.parse(cached.output);
973
+ await this.db.completeStep(stepExecId, StepStatus.SUCCESS, output);
974
+ return {
975
+ output,
976
+ outputs:
977
+ typeof output === 'object' && output !== null && !Array.isArray(output)
978
+ ? (output as Record<string, unknown>)
979
+ : {},
980
+ status: StepStatus.SUCCESS,
981
+ };
982
+ }
983
+ }
984
+
1295
985
  const idempotencyContextForRetry =
1296
986
  idempotencyClaimed && scopedIdempotencyKey
1297
987
  ? {
@@ -1325,21 +1015,85 @@ export class WorkflowRunner {
1325
1015
  await this.db.startStep(stepExecId);
1326
1016
  }
1327
1017
 
1328
- const operation = async (attemptContext: ExpressionContext) => {
1018
+ if (stepToExecute.breakpoint && this.options.debug && !isRecursion) {
1019
+ if (!process.stdin.isTTY) {
1020
+ const message = `Breakpoint hit before step ${stepToExecute.id}`;
1021
+ if (dedupEnabled && idempotencyClaimed) {
1022
+ await this.recordIdempotencyResult(
1023
+ scopedIdempotencyKey,
1024
+ stepToExecute.id,
1025
+ StepStatus.SUSPENDED,
1026
+ null,
1027
+ message,
1028
+ idempotencyTtlSeconds
1029
+ );
1030
+ }
1031
+ await this.db.completeStep(
1032
+ stepExecId,
1033
+ StepStatus.SUSPENDED,
1034
+ null,
1035
+ this.secretManager.redactAtRest ? this.secretManager.redact(message) : message
1036
+ );
1037
+ return {
1038
+ output: null,
1039
+ outputs: {},
1040
+ status: StepStatus.SUSPENDED,
1041
+ error: message,
1042
+ };
1043
+ }
1044
+
1045
+ try {
1046
+ const { DebugRepl } = await import('./debug-repl.ts');
1047
+ const repl = new DebugRepl(
1048
+ context,
1049
+ stepToExecute,
1050
+ undefined,
1051
+ this.logger,
1052
+ process.stdin,
1053
+ process.stdout,
1054
+ {
1055
+ mode: 'breakpoint',
1056
+ }
1057
+ );
1058
+ const action = await repl.start();
1059
+
1060
+ if (action.type === 'skip') {
1061
+ this.logger.log(` ⏭️ Skipping step ${stepToExecute.id} at breakpoint`);
1062
+ await this.db.completeStep(stepExecId, StepStatus.SKIPPED, null, undefined, undefined);
1063
+ return {
1064
+ output: null,
1065
+ outputs: {},
1066
+ status: StepStatus.SKIPPED,
1067
+ };
1068
+ }
1069
+
1070
+ if (action.type === 'continue' || action.type === 'retry') {
1071
+ stepToExecute = action.modifiedStep || stepToExecute;
1072
+ }
1073
+ } catch (replError) {
1074
+ this.logger.error(` ✗ Debug REPL error: ${replError}`);
1075
+ }
1076
+ }
1077
+
1078
+ const operation = async (attemptContext: ExpressionContext, abortSignal?: AbortSignal) => {
1329
1079
  const exec = this.options.executeStep || executeStep;
1330
- const result = await exec(stepToExecute, attemptContext, this.logger, {
1080
+ let result = await exec(stepToExecute, attemptContext, this.logger, {
1331
1081
  executeWorkflowFn: this.executeSubWorkflow.bind(this),
1332
1082
  mcpManager: this.mcpManager,
1083
+ db: this.db,
1333
1084
  memoryDb: this.memoryDb,
1334
1085
  workflowDir: this.options.workflowDir,
1335
1086
  dryRun: this.options.dryRun,
1336
- abortSignal: this.abortSignal,
1087
+ abortSignal,
1337
1088
  runId: this.runId,
1338
1089
  stepExecutionId: stepExecId,
1339
1090
  artifactRoot: this.options.artifactRoot,
1340
- redactForStorage: this.redactForStorage.bind(this),
1091
+ redactForStorage: this.secretManager.redactForStorage.bind(this.secretManager),
1341
1092
  getAdapter: this.options.getAdapter,
1342
1093
  executeStep: this.options.executeStep || executeStep,
1094
+ executeLlmStep: this.options.executeLlmStep,
1095
+ emitEvent: this.emitEvent.bind(this),
1096
+ workflowName: this.workflow.name,
1343
1097
  });
1344
1098
  if (result.status === 'failed') {
1345
1099
  throw new StepExecutionError(result);
@@ -1353,7 +1107,7 @@ export class WorkflowRunner {
1353
1107
  'summary' in result.output
1354
1108
  ? (result.output as { summary?: unknown }).summary
1355
1109
  : result.output;
1356
- this.validateSchema(
1110
+ this.validator.validateSchema(
1357
1111
  'output',
1358
1112
  stepToExecute.outputSchema,
1359
1113
  outputForValidation,
@@ -1363,6 +1117,7 @@ export class WorkflowRunner {
1363
1117
  const message = error instanceof Error ? error.message : String(error);
1364
1118
  const outputRetries = stepToExecute.outputRetries || 0;
1365
1119
  const currentAttempt = (attemptContext.outputRepairAttempts as number) || 0;
1120
+ let handled = false;
1366
1121
 
1367
1122
  // Only attempt repair for LLM steps with outputRetries configured
1368
1123
  if (stepToExecute.type === 'llm' && outputRetries > 0 && currentAttempt < outputRetries) {
@@ -1396,15 +1151,19 @@ export class WorkflowRunner {
1396
1151
  const repairResult = await exec(repairStep, repairContext, this.logger, {
1397
1152
  executeWorkflowFn: this.executeSubWorkflow.bind(this),
1398
1153
  mcpManager: this.mcpManager,
1154
+ db: this.db,
1399
1155
  memoryDb: this.memoryDb,
1400
1156
  workflowDir: this.options.workflowDir,
1401
1157
  dryRun: this.options.dryRun,
1402
- abortSignal: this.abortSignal,
1158
+ abortSignal,
1403
1159
  runId: this.runId,
1404
1160
  stepExecutionId: stepExecId,
1405
1161
  artifactRoot: this.options.artifactRoot,
1406
- redactForStorage: this.redactForStorage.bind(this),
1162
+ redactForStorage: this.secretManager.redactForStorage.bind(this.secretManager),
1407
1163
  executeStep: this.options.executeStep || executeStep,
1164
+ executeLlmStep: this.options.executeLlmStep,
1165
+ emitEvent: this.emitEvent.bind(this),
1166
+ workflowName: this.workflow.name,
1408
1167
  });
1409
1168
 
1410
1169
  if (repairResult.status === 'failed') {
@@ -1413,7 +1172,7 @@ export class WorkflowRunner {
1413
1172
 
1414
1173
  // Validate the repaired output
1415
1174
  try {
1416
- this.validateSchema(
1175
+ this.validator.validateSchema(
1417
1176
  'output',
1418
1177
  stepToExecute.outputSchema,
1419
1178
  repairResult.output,
@@ -1422,15 +1181,19 @@ export class WorkflowRunner {
1422
1181
  this.logger.log(
1423
1182
  ` ✓ Output repair successful after ${currentAttempt + 1} attempt(s)`
1424
1183
  );
1425
- return repairResult;
1184
+ result = repairResult;
1185
+ handled = true;
1426
1186
  } catch (repairError) {
1427
1187
  // If still failing, either retry again or give up
1428
1188
  if (currentAttempt + 1 < outputRetries) {
1429
1189
  // Try again with updated context
1430
- return operation({
1431
- ...attemptContext,
1432
- outputRepairAttempts: currentAttempt + 1,
1433
- });
1190
+ return operation(
1191
+ {
1192
+ ...attemptContext,
1193
+ outputRepairAttempts: currentAttempt + 1,
1194
+ },
1195
+ abortSignal
1196
+ );
1434
1197
  }
1435
1198
  const repairMessage =
1436
1199
  repairError instanceof Error ? repairError.message : String(repairError);
@@ -1442,20 +1205,29 @@ export class WorkflowRunner {
1442
1205
  }
1443
1206
  }
1444
1207
 
1445
- throw new StepExecutionError({
1446
- ...result,
1447
- status: 'failed',
1448
- error: message,
1449
- });
1208
+ if (!handled) {
1209
+ throw new StepExecutionError({
1210
+ ...result,
1211
+ status: 'failed',
1212
+ error: message,
1213
+ });
1214
+ }
1450
1215
  }
1451
1216
  }
1217
+ if (
1218
+ result.status === 'success' &&
1219
+ (stepToExecute.type === 'llm' || stepToExecute.type === 'plan') &&
1220
+ stepToExecute.qualityGate
1221
+ ) {
1222
+ result = await this.runQualityGate(stepToExecute, result, attemptContext, abortSignal);
1223
+ }
1452
1224
  return result;
1453
1225
  };
1454
1226
 
1455
1227
  try {
1456
1228
  if (stepToExecute.inputSchema) {
1457
- const inputsForValidation = this.buildStepInputs(stepToExecute, context);
1458
- this.validateSchema(
1229
+ const inputsForValidation = this.contextBuilder.buildStepInputs(stepToExecute, context);
1230
+ this.validator.validateSchema(
1459
1231
  'input',
1460
1232
  stepToExecute.inputSchema,
1461
1233
  inputsForValidation,
@@ -1464,10 +1236,18 @@ export class WorkflowRunner {
1464
1236
  }
1465
1237
 
1466
1238
  const operationWithTimeout = async () => {
1467
- if (step.timeout) {
1468
- return await withTimeout(operation(context), step.timeout, `Step ${step.id}`);
1239
+ const { controller, cleanup } = this.createStepAbortController();
1240
+ try {
1241
+ const attempt = operation(context, controller.signal);
1242
+ if (step.timeout) {
1243
+ return await withTimeout(attempt, step.timeout, `Step ${step.id}`, {
1244
+ abortController: controller,
1245
+ });
1246
+ }
1247
+ return await attempt;
1248
+ } finally {
1249
+ cleanup();
1469
1250
  }
1470
- return await operation(context);
1471
1251
  };
1472
1252
 
1473
1253
  const result = await withRetry(operationWithTimeout, step.retry, async (attempt, error) => {
@@ -1475,10 +1255,10 @@ export class WorkflowRunner {
1475
1255
  await this.db.incrementRetry(stepExecId);
1476
1256
  });
1477
1257
 
1478
- const persistedOutput = this.redactForStorage(result.output);
1258
+ const persistedOutput = this.secretManager.redactForStorage(result.output);
1479
1259
  const persistedError = result.error
1480
- ? this.redactAtRest
1481
- ? this.redactor.redact(result.error)
1260
+ ? this.secretManager.redactAtRest
1261
+ ? this.secretManager.redact(result.error)
1482
1262
  : result.error
1483
1263
  : result.error;
1484
1264
 
@@ -1504,8 +1284,8 @@ export class WorkflowRunner {
1504
1284
  stepExecId,
1505
1285
  StepStatus.SUSPENDED,
1506
1286
  persistedOutput,
1507
- this.redactAtRest
1508
- ? this.redactor.redact('Waiting for interaction')
1287
+ this.secretManager.redactAtRest
1288
+ ? this.secretManager.redact('Waiting for interaction')
1509
1289
  : 'Waiting for interaction',
1510
1290
  result.usage
1511
1291
  );
@@ -1535,7 +1315,7 @@ export class WorkflowRunner {
1535
1315
  stepExecId,
1536
1316
  StepStatus.WAITING,
1537
1317
  persistedOutput,
1538
- this.redactAtRest ? this.redactor.redact(waitError) : waitError,
1318
+ this.secretManager.redactAtRest ? this.secretManager.redact(waitError) : waitError,
1539
1319
  result.usage
1540
1320
  );
1541
1321
  result.error = waitError;
@@ -1549,7 +1329,7 @@ export class WorkflowRunner {
1549
1329
  persistedError,
1550
1330
  result.usage
1551
1331
  );
1552
- if (step.type === 'human') {
1332
+ if (result.status === StepStatus.SUCCESS) {
1553
1333
  const existingTimer = await this.db.getTimerByStep(this.runId, step.id);
1554
1334
  if (existingTimer) {
1555
1335
  await this.db.completeTimer(existingTimer.id);
@@ -1618,14 +1398,25 @@ export class WorkflowRunner {
1618
1398
  );
1619
1399
  }
1620
1400
 
1621
- return {
1401
+ const finalResult = {
1622
1402
  output: result.output,
1623
1403
  outputs,
1624
1404
  status: result.status,
1625
1405
  error: result.error,
1626
1406
  usage: result.usage,
1627
1407
  };
1408
+
1409
+ // Store in global cache if enabled
1410
+ if (cacheKey && result.status === StepStatus.SUCCESS) {
1411
+ const ttl = step.memoizeTtlSeconds;
1412
+ await this.db.storeStepCache(cacheKey, this.workflow.name, step.id, persistedOutput, ttl);
1413
+ }
1414
+
1415
+ return finalResult;
1628
1416
  } catch (error) {
1417
+ if (error instanceof WorkflowSuspendedError || error instanceof WorkflowWaitingError) {
1418
+ throw error;
1419
+ }
1629
1420
  // Reflexion (Self-Correction) logic
1630
1421
  if (step.reflexion) {
1631
1422
  const { limit = 3, hint } = step.reflexion;
@@ -1742,7 +1533,7 @@ export class WorkflowRunner {
1742
1533
  const failureResult = error instanceof StepExecutionError ? error.result : null;
1743
1534
  const errorMsg =
1744
1535
  failureResult?.error || (error instanceof Error ? error.message : String(error));
1745
- const redactedErrorMsg = this.redactor.redact(errorMsg);
1536
+ const redactedErrorMsg = this.secretManager.redact(errorMsg);
1746
1537
  const failureOutput = failureResult?.output ?? null;
1747
1538
  const failureOutputs =
1748
1539
  typeof failureOutput === 'object' && failureOutput !== null && !Array.isArray(failureOutput)
@@ -1756,8 +1547,8 @@ export class WorkflowRunner {
1756
1547
  await this.db.completeStep(
1757
1548
  stepExecId,
1758
1549
  StepStatus.SUCCESS,
1759
- this.redactForStorage(failureOutput),
1760
- this.redactAtRest ? redactedErrorMsg : errorMsg
1550
+ this.secretManager.redactForStorage(failureOutput),
1551
+ this.secretManager.redactAtRest ? redactedErrorMsg : errorMsg
1761
1552
  );
1762
1553
  if (dedupEnabled && idempotencyClaimed) {
1763
1554
  await this.recordIdempotencyResult(
@@ -1781,8 +1572,8 @@ export class WorkflowRunner {
1781
1572
  await this.db.completeStep(
1782
1573
  stepExecId,
1783
1574
  StepStatus.FAILED,
1784
- this.redactForStorage(failureOutput),
1785
- this.redactAtRest ? redactedErrorMsg : errorMsg
1575
+ this.secretManager.redactForStorage(failureOutput),
1576
+ this.secretManager.redactAtRest ? redactedErrorMsg : errorMsg
1786
1577
  );
1787
1578
  if (dedupEnabled && idempotencyClaimed) {
1788
1579
  await this.recordIdempotencyResult(
@@ -1861,9 +1652,11 @@ Do not change the 'id' or 'type' or 'auto_heal' fields.
1861
1652
  debug: this.options.debug,
1862
1653
  runId: this.runId,
1863
1654
  artifactRoot: this.options.artifactRoot,
1864
- redactForStorage: this.redactForStorage.bind(this),
1655
+ redactForStorage: this.secretManager.redactForStorage.bind(this.secretManager),
1865
1656
  allowInsecure: this.options.allowInsecure,
1866
1657
  executeStep: this.options.executeStep || executeStep,
1658
+ emitEvent: this.emitEvent.bind(this),
1659
+ workflowName: this.workflow.name,
1867
1660
  });
1868
1661
 
1869
1662
  if (result.status !== 'success' || !result.output) {
@@ -1892,11 +1685,9 @@ Do not change the 'id' or 'type' or 'auto_heal' fields.
1892
1685
  let textToEmbed = `Step ID: ${step.id} (${step.type})\n`;
1893
1686
 
1894
1687
  if (step.type === 'llm') {
1895
- // biome-ignore lint/suspicious/noExplicitAny: generic access
1896
- textToEmbed += `Task Context/Prompt:\n${(step as any).prompt}\n\n`;
1688
+ textToEmbed += `Task Context/Prompt:\n${(step as LlmStep).prompt}\n\n`;
1897
1689
  } else if (step.type === 'shell') {
1898
- // biome-ignore lint/suspicious/noExplicitAny: generic access
1899
- textToEmbed += `Command:\n${(step as any).run}\n\n`;
1690
+ textToEmbed += `Command:\n${(step as unknown as { run: string }).run}\n\n`;
1900
1691
  }
1901
1692
 
1902
1693
  textToEmbed += `Successful Outcome:\n${JSON.stringify(result.output, null, 2)}`;
@@ -1933,8 +1724,7 @@ Rules:
1933
1724
  - Creating missing directories
1934
1725
  - Adjusting flags or arguments`;
1935
1726
 
1936
- // biome-ignore lint/suspicious/noExplicitAny: generic access
1937
- const runCommand = (step as any).run;
1727
+ const runCommand = (step as unknown as { run: string }).run;
1938
1728
  const userContent = `The following step failed:
1939
1729
  \`\`\`json
1940
1730
  ${JSON.stringify({ type: step.type, run: runCommand }, null, 2)}
@@ -1952,31 +1742,14 @@ Please provide the fixed step configuration as JSON.`;
1952
1742
  { role: 'user', content: userContent },
1953
1743
  ];
1954
1744
 
1955
- try {
1956
- // Use the default model (gpt-4o) or configured default for the Mechanic
1957
- // We'll use gpt-4o as a strong default for this reasoning task
1958
- const getAdapterFn = this.options.getAdapter || getAdapter;
1959
- const { adapter, resolvedModel } = getAdapterFn('gpt-4o');
1960
- this.logger.log(` 🤖 Mechanic is analyzing the failure using ${resolvedModel}...`);
1961
-
1962
- const response = await adapter.chat(messages, {
1963
- model: resolvedModel,
1964
- });
1745
+ // Use the default model (gpt-4o) or configured default for the Mechanic
1746
+ // We'll use gpt-4o as a strong default for this reasoning task
1747
+ const getAdapterFn = this.options.getAdapter || getAdapter;
1748
+ const { adapter } = getAdapterFn('gpt-4o');
1965
1749
 
1966
- const content = response.message.content;
1967
- if (!content) {
1968
- throw new Error('Mechanic returned empty response');
1969
- }
1750
+ const response = await adapter.chat(messages);
1970
1751
 
1971
- try {
1972
- const fixedConfig = extractJson(content) as Partial<Step>;
1973
- return fixedConfig;
1974
- } catch (e) {
1975
- throw new Error(`Failed to parse Mechanic's response as JSON: ${content}`);
1976
- }
1977
- } catch (err) {
1978
- throw new Error(`Mechanic unavailable: ${err instanceof Error ? err.message : String(err)}`);
1979
- }
1752
+ return extractJson(response.message.content || '{}') as Partial<Step>;
1980
1753
  }
1981
1754
 
1982
1755
  /**
@@ -2026,18 +1799,233 @@ ${strategyInstructions[strategy]}
2026
1799
  Please provide a corrected response that exactly matches the required schema.`;
2027
1800
  }
2028
1801
 
1802
+ private buildPlanPromptFromStep(step: PlanStep, context: ExpressionContext): string {
1803
+ const goal = ExpressionEvaluator.evaluateString(step.goal, context);
1804
+ const contextText = step.context
1805
+ ? ExpressionEvaluator.evaluateString(step.context, context)
1806
+ : '';
1807
+ const constraintsText = step.constraints
1808
+ ? ExpressionEvaluator.evaluateString(step.constraints, context)
1809
+ : '';
1810
+
1811
+ return `You are a planner. Break the goal into a concise, ordered list of steps.
1812
+
1813
+ Goal:
1814
+ ${goal}
1815
+
1816
+ Context:
1817
+ ${contextText || 'None'}
1818
+
1819
+ Constraints:
1820
+ ${constraintsText || 'None'}
1821
+
1822
+ Each step should be small, specific, and independently executable.
1823
+ Include any dependencies under "needs" and optional "workflow" or "inputs" when appropriate.
1824
+
1825
+ Return only the structured JSON required by the schema.`;
1826
+ }
1827
+
1828
+ private buildQualityGateReviewPrompt(
1829
+ step: LlmStep | PlanStep,
1830
+ output: unknown,
1831
+ gatePrompt: string | undefined,
1832
+ context: ExpressionContext
1833
+ ): string {
1834
+ const reviewContext = {
1835
+ ...context,
1836
+ output,
1837
+ };
1838
+
1839
+ if (gatePrompt) {
1840
+ return ExpressionEvaluator.evaluateString(gatePrompt, reviewContext);
1841
+ }
1842
+
1843
+ const taskDescription =
1844
+ step.type === 'plan' ? this.buildPlanPromptFromStep(step, context) : step.prompt;
1845
+
1846
+ return `Review the output for correctness, completeness, and clarity.
1847
+
1848
+ Task:
1849
+ ${taskDescription}
1850
+
1851
+ Output:
1852
+ ${typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
1853
+
1854
+ Identify issues, risks, and missing details. Be specific.
1855
+ Return only the structured JSON required by the schema.`;
1856
+ }
1857
+
1858
+ private buildQualityGateRefinePrompt(
1859
+ step: LlmStep | PlanStep,
1860
+ output: unknown,
1861
+ review: QualityGateReview,
1862
+ context: ExpressionContext
1863
+ ): string {
1864
+ const basePrompt =
1865
+ step.type === 'plan' ? this.buildPlanPromptFromStep(step, context) : step.prompt;
1866
+ const reviewText = JSON.stringify(review, null, 2);
1867
+ const outputText = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
1868
+
1869
+ return `${basePrompt}
1870
+
1871
+ ---
1872
+
1873
+ QUALITY REVIEW FAILED
1874
+
1875
+ Reviewer feedback:
1876
+ ${reviewText}
1877
+
1878
+ Previous output:
1879
+ ${outputText}
1880
+
1881
+ Revise the output to address the feedback. Return only the corrected output.`;
1882
+ }
1883
+
1884
+ private async runQualityGate(
1885
+ step: LlmStep | PlanStep,
1886
+ result: StepResult,
1887
+ context: ExpressionContext,
1888
+ abortSignal?: AbortSignal
1889
+ ): Promise<StepResult> {
1890
+ const gate = step.qualityGate;
1891
+ if (!gate) return result;
1892
+
1893
+ let attempts = (context.qualityGateAttempts as number) || 0;
1894
+ const maxAttempts = gate.maxAttempts ?? 1;
1895
+ let currentResult = result;
1896
+
1897
+ while (true) {
1898
+ if (abortSignal?.aborted) {
1899
+ throw new Error('Step canceled');
1900
+ }
1901
+
1902
+ const reviewContext = {
1903
+ ...context,
1904
+ output: currentResult.output,
1905
+ qualityGateAttempts: attempts,
1906
+ };
1907
+ const reviewPrompt = this.buildQualityGateReviewPrompt(
1908
+ step,
1909
+ currentResult.output,
1910
+ gate.prompt,
1911
+ reviewContext
1912
+ );
1913
+ const reviewStep: Step = {
1914
+ id: `${step.id}-quality-review`,
1915
+ type: 'llm',
1916
+ agent: gate.agent,
1917
+ provider: gate.provider,
1918
+ model: gate.model,
1919
+ prompt: reviewPrompt,
1920
+ outputSchema: QUALITY_GATE_SCHEMA,
1921
+ } as LlmStep;
1922
+
1923
+ const exec = this.options.executeStep || executeStep;
1924
+ const reviewResult = await exec(reviewStep, reviewContext, this.logger, {
1925
+ executeWorkflowFn: this.executeSubWorkflow.bind(this),
1926
+ mcpManager: this.mcpManager,
1927
+ db: this.db,
1928
+ memoryDb: this.memoryDb,
1929
+ workflowDir: this.options.workflowDir,
1930
+ dryRun: this.options.dryRun,
1931
+ abortSignal,
1932
+ runId: this.runId,
1933
+ artifactRoot: this.options.artifactRoot,
1934
+ redactForStorage: this.secretManager.redactForStorage.bind(this.secretManager),
1935
+ getAdapter: this.options.getAdapter,
1936
+ executeStep: this.options.executeStep || executeStep,
1937
+ emitEvent: this.emitEvent.bind(this),
1938
+ workflowName: this.workflow.name,
1939
+ });
1940
+
1941
+ if (reviewResult.status !== 'success' || !reviewResult.output) {
1942
+ throw new StepExecutionError({
1943
+ ...reviewResult,
1944
+ status: 'failed',
1945
+ error: reviewResult.error || 'Quality gate review failed',
1946
+ });
1947
+ }
1948
+
1949
+ this.validator.validateSchema(
1950
+ 'output',
1951
+ QUALITY_GATE_SCHEMA,
1952
+ reviewResult.output,
1953
+ reviewStep.id
1954
+ );
1955
+
1956
+ const review = reviewResult.output as QualityGateReview;
1957
+ if (review.approved) {
1958
+ return currentResult;
1959
+ }
1960
+
1961
+ if (attempts >= maxAttempts) {
1962
+ const issues = review.issues?.join('; ') || 'Quality gate rejected output';
1963
+ throw new StepExecutionError({
1964
+ ...currentResult,
1965
+ status: 'failed',
1966
+ error: `Quality gate rejected: ${issues}`,
1967
+ });
1968
+ }
1969
+
1970
+ attempts += 1;
1971
+ this.logger.log(` 🔍 Quality gate rejected output; refining (${attempts}/${maxAttempts})`);
1972
+
1973
+ const refinePrompt = this.buildQualityGateRefinePrompt(
1974
+ step,
1975
+ currentResult.output,
1976
+ review,
1977
+ context
1978
+ );
1979
+ const refinedStep: Step = {
1980
+ ...step,
1981
+ prompt: refinePrompt,
1982
+ };
1983
+ const refinedContext = {
1984
+ ...context,
1985
+ qualityGateAttempts: attempts,
1986
+ };
1987
+
1988
+ const refinedResult = await exec(refinedStep, refinedContext, this.logger, {
1989
+ executeWorkflowFn: this.executeSubWorkflow.bind(this),
1990
+ mcpManager: this.mcpManager,
1991
+ db: this.db,
1992
+ memoryDb: this.memoryDb,
1993
+ workflowDir: this.options.workflowDir,
1994
+ dryRun: this.options.dryRun,
1995
+ abortSignal,
1996
+ runId: this.runId,
1997
+ artifactRoot: this.options.artifactRoot,
1998
+ redactForStorage: this.secretManager.redactForStorage.bind(this.secretManager),
1999
+ getAdapter: this.options.getAdapter,
2000
+ executeStep: this.options.executeStep || executeStep,
2001
+ emitEvent: this.emitEvent.bind(this),
2002
+ workflowName: this.workflow.name,
2003
+ });
2004
+
2005
+ if (refinedResult.status === 'failed') {
2006
+ throw new StepExecutionError(refinedResult);
2007
+ }
2008
+
2009
+ if (step.outputSchema) {
2010
+ this.validator.validateSchema('output', step.outputSchema, refinedResult.output, step.id);
2011
+ }
2012
+
2013
+ currentResult = refinedResult;
2014
+ }
2015
+ }
2016
+
2029
2017
  /**
2030
2018
  * Execute a step (handles foreach if present)
2031
2019
  */
2032
2020
  private async executeStepWithForeach(step: Step): Promise<void> {
2033
- const baseContext = this.buildContext();
2021
+ const baseContext = this.contextBuilder.buildContext(this.secretManager.getSecrets());
2034
2022
 
2035
2023
  if (this.shouldSkipStep(step, baseContext)) {
2036
2024
  this.logger.log(` ⊘ Skipping step ${step.id} (condition not met)`);
2037
2025
  const stepExecId = randomUUID();
2038
2026
  await this.db.createStep(stepExecId, this.runId, step.id);
2039
2027
  await this.db.completeStep(stepExecId, 'skipped', null);
2040
- this.stepContexts.set(step.id, { status: 'skipped' });
2028
+ this.state.set(step.id, { status: 'skipped' });
2041
2029
  return;
2042
2030
  }
2043
2031
 
@@ -2046,12 +2034,11 @@ Please provide a corrected response that exactly matches the required schema.`;
2046
2034
  const stepExecId = randomUUID();
2047
2035
  await this.db.createStep(stepExecId, this.runId, step.id);
2048
2036
  await this.db.completeStep(stepExecId, StepStatus.SKIPPED, null);
2049
- this.stepContexts.set(step.id, { status: StepStatus.SKIPPED });
2037
+ this.state.set(step.id, { status: StepStatus.SKIPPED });
2050
2038
  return;
2051
2039
  }
2052
2040
 
2053
2041
  if (step.foreach) {
2054
- const { ForeachExecutor } = await import('./foreach-executor.ts');
2055
2042
  const executor = new ForeachExecutor(
2056
2043
  this.db,
2057
2044
  this.logger,
@@ -2060,10 +2047,10 @@ Please provide a corrected response that exactly matches the required schema.`;
2060
2047
  this.resourcePool
2061
2048
  );
2062
2049
 
2063
- const existingContext = this.stepContexts.get(step.id) as ForeachStepContext;
2050
+ const existingContext = this.state.get(step.id) as ForeachStepContext;
2064
2051
  const result = await executor.execute(step, baseContext, this.runId, existingContext);
2065
2052
 
2066
- this.stepContexts.set(step.id, result);
2053
+ this.state.set(step.id, result);
2067
2054
  } else {
2068
2055
  // Single execution
2069
2056
  const stepExecId = randomUUID();
@@ -2072,7 +2059,7 @@ Please provide a corrected response that exactly matches the required schema.`;
2072
2059
  const result = await this.executeStepInternal(step, baseContext, stepExecId);
2073
2060
 
2074
2061
  // Update global state
2075
- this.stepContexts.set(step.id, result);
2062
+ this.state.set(step.id, result);
2076
2063
 
2077
2064
  if (result.status === 'suspended') {
2078
2065
  const inputType = step.type === 'human' ? step.inputType : 'text';
@@ -2096,99 +2083,127 @@ Please provide a corrected response that exactly matches the required schema.`;
2096
2083
  */
2097
2084
  private async executeSubWorkflow(
2098
2085
  step: WorkflowStep,
2099
- context: ExpressionContext
2086
+ context: ExpressionContext,
2087
+ abortSignal?: AbortSignal
2100
2088
  ): Promise<StepResult> {
2101
- const workflowPath = WorkflowRegistry.resolvePath(step.path, this.options.workflowDir);
2102
- const workflow = WorkflowParser.loadWorkflow(workflowPath);
2103
- const subWorkflowDir = dirname(workflowPath);
2104
-
2105
- // Evaluate inputs for the sub-workflow
2106
- const inputs: Record<string, unknown> = {};
2107
- if (step.inputs) {
2108
- for (const [key, value] of Object.entries(step.inputs)) {
2109
- inputs[key] = ExpressionEvaluator.evaluate(value, context);
2110
- }
2111
- }
2089
+ const factory: RunnerFactory = {
2090
+ create: (workflow, options) => new WorkflowRunner(workflow, options),
2091
+ };
2112
2092
 
2113
- // Create a new runner for the sub-workflow
2114
- // We pass the same dbPath to share the state database
2115
- const subRunner = new WorkflowRunner(workflow, {
2116
- inputs,
2117
- dbPath: this.db.dbPath,
2118
- logger: this.logger,
2119
- mcpManager: this.mcpManager,
2120
- workflowDir: subWorkflowDir,
2121
- depth: this.depth + 1,
2122
- dedup: this.options.dedup,
2123
- artifactRoot: this.options.artifactRoot,
2093
+ return executeSubWorkflow(step, context, {
2094
+ runnerFactory: factory,
2095
+ parentWorkflowDir: this.options.workflowDir,
2096
+ parentDbPath: this.db.dbPath,
2097
+ parentLogger: this.logger,
2098
+ parentMcpManager: this.mcpManager,
2099
+ parentDepth: this.depth,
2100
+ parentOptions: this.options,
2101
+ abortSignal,
2124
2102
  });
2103
+ }
2125
2104
 
2126
- try {
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
- }
2105
+ /**
2106
+ * Redact secrets from a value
2107
+ */
2108
+ public redact<T>(value: T): T {
2109
+ return this.secretManager.redactValue(value) as T;
2110
+ }
2145
2111
 
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}")`
2112
+ private emitEvent(event: WorkflowEvent): void {
2113
+ try {
2114
+ const redactor = this.secretManager.getRedactor();
2115
+ const redacted = redactor.redactValue(event) as WorkflowEvent;
2116
+ if (redacted.type === 'llm.thought') {
2117
+ void this.db
2118
+ .storeThoughtEvent(
2119
+ redacted.runId,
2120
+ redacted.workflow,
2121
+ redacted.stepId,
2122
+ redacted.content,
2123
+ redacted.source
2124
+ )
2125
+ .catch((error) => {
2126
+ this.logger.warn(
2127
+ ` ⚠️ Failed to store thought event: ${error instanceof Error ? error.message : String(error)}`
2153
2128
  );
2154
- }
2155
- }
2129
+ });
2130
+ }
2131
+ if (this.options.onEvent) {
2132
+ this.options.onEvent(redacted);
2156
2133
  }
2157
-
2158
- return {
2159
- output: {
2160
- ...mappedOutputs,
2161
- outputs: rawOutputs, // Namespaced raw outputs
2162
- __subRunId: subRunner.runId, // Track sub-workflow run ID for rollback
2163
- },
2164
- status: 'success',
2165
- };
2166
2134
  } catch (error) {
2167
- return {
2168
- output: null,
2169
- status: 'failed',
2170
- error: error instanceof Error ? error.message : String(error),
2171
- };
2135
+ this.logger.warn(
2136
+ ` ⚠️ Failed to emit event: ${error instanceof Error ? error.message : String(error)}`
2137
+ );
2172
2138
  }
2173
2139
  }
2174
2140
 
2175
- /**
2176
- * Redact secrets from a value
2177
- */
2178
- public redact<T>(value: T): T {
2179
- return this.redactor.redactValue(value) as T;
2141
+ private emitStepStart(
2142
+ step: Step,
2143
+ phase: StepPhase,
2144
+ stepIndex?: number,
2145
+ totalSteps?: number
2146
+ ): number {
2147
+ const startedAt = Date.now();
2148
+ this.emitEvent({
2149
+ type: 'step.start',
2150
+ timestamp: new Date(startedAt).toISOString(),
2151
+ runId: this.runId,
2152
+ workflow: this.workflow.name,
2153
+ stepId: step.id,
2154
+ stepType: step.type,
2155
+ phase,
2156
+ stepIndex,
2157
+ totalSteps,
2158
+ });
2159
+ return startedAt;
2160
+ }
2161
+
2162
+ private emitStepEnd(
2163
+ step: Step,
2164
+ phase: StepPhase,
2165
+ startedAt: number,
2166
+ error?: unknown,
2167
+ stepIndex?: number,
2168
+ totalSteps?: number
2169
+ ): void {
2170
+ const endedAt = Date.now();
2171
+ const context = this.state.get(step.id);
2172
+ const status = context?.status || StepStatus.FAILED;
2173
+ const errorMsg =
2174
+ context?.error ||
2175
+ (error instanceof Error ? error.message : error ? String(error) : undefined);
2176
+
2177
+ this.emitEvent({
2178
+ type: 'step.end',
2179
+ timestamp: new Date(endedAt).toISOString(),
2180
+ runId: this.runId,
2181
+ workflow: this.workflow.name,
2182
+ stepId: step.id,
2183
+ stepType: step.type,
2184
+ phase,
2185
+ status,
2186
+ durationMs: endedAt - startedAt,
2187
+ error: status === StepStatus.SUCCESS || status === StepStatus.SKIPPED ? undefined : errorMsg,
2188
+ stepIndex,
2189
+ totalSteps,
2190
+ });
2180
2191
  }
2181
2192
 
2182
2193
  /**
2183
2194
  * Execute the workflow
2184
2195
  */
2185
2196
  async run(): Promise<Record<string, unknown>> {
2197
+ const expressionStrict = ConfigLoader.load().expression?.strict ?? false;
2198
+ ExpressionEvaluator.setStrictMode(expressionStrict);
2199
+ let completionEvent: WorkflowEvent | null = null;
2200
+
2186
2201
  // Handle resume state restoration
2187
2202
  if (this.resumeRunId && !this.restored) {
2188
2203
  await this.restoreState();
2189
2204
  }
2190
2205
 
2191
- const isResume = !!this.resumeRunId || this.stepContexts.size > 0;
2206
+ const isResume = !!this.resumeRunId || this.state.size > 0;
2192
2207
 
2193
2208
  this.logger.log(`\n🏛️ ${isResume ? 'Resuming' : 'Running'} workflow: ${this.workflow.name}`);
2194
2209
  this.logger.log(`Run ID: ${this.runId}`);
@@ -2197,56 +2212,80 @@ Please provide a corrected response that exactly matches the required schema.`;
2197
2212
  ' Workflows can execute arbitrary shell commands and access your environment.\n'
2198
2213
  );
2199
2214
 
2200
- this.redactAtRest = ConfigLoader.load().storage?.redact_secrets_at_rest ?? true;
2215
+ this.secretManager.redactAtRest = ConfigLoader.load().storage?.redact_secrets_at_rest ?? true;
2201
2216
 
2202
2217
  // Apply defaults and validate inputs
2203
- this.applyDefaultsAndValidate();
2218
+ const validated = this.validator.applyDefaultsAndValidate();
2219
+ if (validated.secretValues.length > 0) {
2220
+ this.secretManager.setSecretValues(validated.secretValues);
2221
+ this.logger = new RedactingLogger(this.rawLogger, this.secretManager.getRedactor());
2222
+ this.contextBuilder = new ContextBuilder(
2223
+ this.workflow,
2224
+ this.inputs,
2225
+ this.secretManager.getSecretValues(),
2226
+ this.state,
2227
+ this.logger
2228
+ );
2229
+ }
2204
2230
 
2205
2231
  // Create run record (only for new runs, not for resume)
2206
2232
  if (!isResume) {
2207
- await this.db.createRun(this.runId, this.workflow.name, this.redactForStorage(this.inputs));
2233
+ await this.db.createRun(
2234
+ this.runId,
2235
+ this.workflow.name,
2236
+ this.secretManager.redactForStorage(this.inputs)
2237
+ );
2208
2238
  }
2209
2239
  await this.db.updateRunStatus(this.runId, 'running');
2240
+ this.emitEvent({
2241
+ type: 'workflow.start',
2242
+ timestamp: new Date().toISOString(),
2243
+ runId: this.runId,
2244
+ workflow: this.workflow.name,
2245
+ inputs: this.secretManager.redactValue(this.inputs),
2246
+ });
2210
2247
 
2211
2248
  try {
2212
- // Get execution order using topological sort
2213
- const executionOrder = WorkflowParser.topologicalSort(this.workflow);
2214
- const stepMap = new Map(this.workflow.steps.map((s) => [s.id, s]));
2215
-
2216
- // Initialize completedSteps with already completed steps (for resume)
2217
- // Only include steps that were successful or skipped, so failed steps are retried
2218
- const completedSteps = new Set<string>();
2219
- for (const [id, ctx] of this.stepContexts.entries()) {
2220
- if (ctx.status === 'success' || ctx.status === 'skipped') {
2221
- completedSteps.add(id);
2222
- }
2223
- }
2224
-
2225
- // Filter out already completed steps from execution order
2226
- const remainingSteps = executionOrder.filter((stepId) => !completedSteps.has(stepId));
2249
+ // Use scheduler's execution order
2250
+ const executionOrder = this.scheduler.getExecutionOrder();
2227
2251
 
2228
- if (isResume && remainingSteps.length === 0) {
2252
+ if (isResume && this.scheduler.isComplete()) {
2229
2253
  this.logger.log('All steps already completed. Nothing to resume.\n');
2230
2254
  // Evaluate outputs from completed state
2231
2255
  const outputs = this.evaluateOutputs();
2232
- await this.db.updateRunStatus(this.runId, 'success', this.redactForStorage(outputs));
2256
+ await this.db.updateRunStatus(
2257
+ this.runId,
2258
+ 'success',
2259
+ this.secretManager.redactForStorage(outputs)
2260
+ );
2233
2261
  this.logger.log('✨ Workflow already completed!\n');
2262
+ completionEvent = {
2263
+ type: 'workflow.complete',
2264
+ timestamp: new Date().toISOString(),
2265
+ runId: this.runId,
2266
+ workflow: this.workflow.name,
2267
+ status: WorkflowStatus.SUCCESS,
2268
+ outputs: this.secretManager.redactValue(outputs),
2269
+ };
2234
2270
  return outputs;
2235
2271
  }
2236
2272
 
2237
- if (isResume && completedSteps.size > 0) {
2238
- this.logger.log(`Skipping ${completedSteps.size} already completed step(s)\n`);
2273
+ const pendingCount = this.scheduler.getPendingCount();
2274
+ const totalSteps = executionOrder.length;
2275
+ const completedCount = totalSteps - pendingCount;
2276
+
2277
+ if (isResume && completedCount > 0) {
2278
+ this.logger.log(`Skipping ${completedCount} already completed step(s)\n`);
2239
2279
  }
2240
2280
 
2241
2281
  this.logger.log(`Execution order: ${executionOrder.join(' → ')}\n`);
2242
2282
 
2243
- const totalSteps = executionOrder.length;
2244
2283
  const stepIndices = new Map(executionOrder.map((id, index) => [id, index + 1]));
2245
2284
 
2246
2285
  // Evaluate global concurrency limit
2247
- let globalConcurrencyLimit = remainingSteps.length;
2286
+ let globalConcurrencyLimit = pendingCount || 10;
2248
2287
  if (this.workflow.concurrency !== undefined) {
2249
- const baseContext = this.buildContext();
2288
+ const baseContext = this.contextBuilder.buildContext(this.secretManager.getSecrets());
2250
2289
  if (typeof this.workflow.concurrency === 'string') {
2251
2290
  globalConcurrencyLimit = Number(
2252
2291
  ExpressionEvaluator.evaluate(this.workflow.concurrency, baseContext)
@@ -2267,11 +2306,10 @@ Please provide a corrected response that exactly matches the required schema.`;
2267
2306
  }
2268
2307
 
2269
2308
  // Execute steps in parallel where possible (respecting dependencies and global concurrency)
2270
- const pendingSteps = new Set(remainingSteps);
2271
2309
  const runningPromises = new Map<string, Promise<void>>();
2272
2310
 
2273
2311
  try {
2274
- while (pendingSteps.size > 0 || runningPromises.size > 0) {
2312
+ while (!this.scheduler.isComplete() || runningPromises.size > 0) {
2275
2313
  // Check for cancellation - drain in-flight steps but don't start new ones
2276
2314
  if (this.isCanceled) {
2277
2315
  if (runningPromises.size > 0) {
@@ -2283,73 +2321,71 @@ Please provide a corrected response that exactly matches the required schema.`;
2283
2321
  throw new Error('Workflow canceled by user');
2284
2322
  }
2285
2323
 
2286
- // 1. Find runnable steps (all dependencies met)
2287
- for (const stepId of pendingSteps) {
2324
+ // 1. Find runnable steps from scheduler
2325
+ const runnableSteps = this.scheduler
2326
+ .getRunnableSteps(runningPromises.size, globalConcurrencyLimit)
2327
+ .filter((step) => this.resourcePool.hasCapacity(step.pool || step.type));
2328
+
2329
+ for (const step of runnableSteps) {
2288
2330
  // Don't schedule new steps if canceled
2289
2331
  if (this.isCanceled) break;
2290
2332
 
2291
- const step = stepMap.get(stepId);
2292
- if (!step) {
2293
- throw new Error(`Step ${stepId} not found in workflow`);
2294
- }
2333
+ const stepId = step.id;
2334
+ this.scheduler.startStep(stepId);
2295
2335
 
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
- }
2336
+ // Determine pool for this step
2337
+ const poolName = step.pool || step.type;
2305
2338
 
2306
- if (dependenciesMet && runningPromises.size < globalConcurrencyLimit) {
2307
- pendingSteps.delete(stepId);
2308
-
2309
- // Determine pool for this step
2310
- const poolName = step.pool || step.type;
2311
-
2312
- // Start execution
2313
- const stepIndex = stepIndices.get(stepId);
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);
2328
- completedSteps.add(stepId);
2329
- this.logger.log(`[${stepIndex}/${totalSteps}] Step ${step.id} completed\n`);
2330
- } finally {
2331
- if (typeof release === 'function') {
2332
- release();
2333
- }
2334
- runningPromises.delete(stepId);
2339
+ // Start execution
2340
+ const stepIndex = stepIndices.get(stepId);
2341
+
2342
+ const promise = (async () => {
2343
+ let release: (() => void) | undefined;
2344
+ const startedAt = this.emitStepStart(step, 'main', stepIndex, totalSteps);
2345
+ try {
2346
+ this.logger.debug?.(
2347
+ `[${stepIndex}/${totalSteps}] ⏳ Waiting for pool: ${poolName}`
2348
+ );
2349
+ release = await this.resourcePool.acquire(poolName, { signal: this.abortSignal });
2350
+
2351
+ this.logger.log(
2352
+ `[${stepIndex}/${totalSteps}] Executing step: ${step.id} (${step.type})`
2353
+ );
2354
+
2355
+ await this.executeStepWithForeach(step);
2356
+ this.emitStepEnd(step, 'main', startedAt, undefined, stepIndex, totalSteps);
2357
+ this.scheduler.markStepComplete(stepId);
2358
+ this.logger.log(`[${stepIndex}/${totalSteps}] ✓ Step ${step.id} completed\n`);
2359
+ } catch (error) {
2360
+ this.emitStepEnd(step, 'main', startedAt, error, stepIndex, totalSteps);
2361
+ throw error;
2362
+ } finally {
2363
+ if (typeof release === 'function') {
2364
+ release();
2335
2365
  }
2336
- })();
2366
+ runningPromises.delete(stepId);
2367
+ }
2368
+ })();
2337
2369
 
2338
- runningPromises.set(stepId, promise);
2339
- }
2370
+ runningPromises.set(stepId, promise);
2340
2371
  }
2341
2372
 
2342
2373
  // 2. Detect deadlock (only if not canceled)
2343
- if (!this.isCanceled && runningPromises.size === 0 && pendingSteps.size > 0) {
2344
- const pendingList = Array.from(pendingSteps).join(', ');
2345
- throw new Error(
2346
- `Deadlock detected in workflow execution. Pending steps: ${pendingList}`
2347
- );
2374
+ if (!this.isCanceled && runningPromises.size === 0 && !this.scheduler.isComplete()) {
2375
+ // Check if there are ANY steps whose dependencies are met, even if they're blocked by capacity/concurrency
2376
+ const readySteps = this.scheduler.getRunnableSteps(0, Number.MAX_SAFE_INTEGER);
2377
+ if (readySteps.length === 0) {
2378
+ throw new Error(
2379
+ 'Deadlock detected in workflow execution. Steps remaining but none runnable (dependency cycles or missing inputs).'
2380
+ );
2381
+ }
2348
2382
  }
2349
2383
 
2350
2384
  // 3. Wait for at least one step to finish before checking again
2351
2385
  if (runningPromises.size > 0) {
2352
2386
  await Promise.race(runningPromises.values());
2387
+ // Yield to event loop to prevent tight loop if multiple steps finish in same tick
2388
+ await Bun.sleep(0);
2353
2389
  }
2354
2390
  }
2355
2391
  } catch (error) {
@@ -2369,37 +2405,61 @@ Please provide a corrected response that exactly matches the required schema.`;
2369
2405
  throw error;
2370
2406
  }
2371
2407
 
2372
- // Determine final status
2373
- const failedSteps = remainingSteps.filter(
2374
- (id) => this.stepContexts.get(id)?.status === StepStatus.FAILED
2375
- );
2376
-
2377
2408
  // Evaluate outputs
2378
2409
  const outputs = this.evaluateOutputs();
2379
2410
 
2380
2411
  // Mark run as complete
2381
- await this.db.updateRunStatus(this.runId, 'success', this.redactForStorage(outputs));
2412
+ await this.db.updateRunStatus(
2413
+ this.runId,
2414
+ 'success',
2415
+ this.secretManager.redactForStorage(outputs)
2416
+ );
2382
2417
 
2383
2418
  this.logger.log('✨ Workflow completed successfully!\n');
2384
2419
 
2420
+ completionEvent = {
2421
+ type: 'workflow.complete',
2422
+ timestamp: new Date().toISOString(),
2423
+ runId: this.runId,
2424
+ workflow: this.workflow.name,
2425
+ status: WorkflowStatus.SUCCESS,
2426
+ outputs: this.secretManager.redactValue(outputs),
2427
+ };
2428
+
2385
2429
  return outputs;
2386
2430
  } catch (error) {
2387
2431
  if (error instanceof WorkflowSuspendedError) {
2388
2432
  await this.db.updateRunStatus(this.runId, 'paused');
2389
2433
  this.logger.log(`\n⏸ Workflow paused: ${error.message}`);
2434
+ completionEvent = {
2435
+ type: 'workflow.complete',
2436
+ timestamp: new Date().toISOString(),
2437
+ runId: this.runId,
2438
+ workflow: this.workflow.name,
2439
+ status: WorkflowStatus.PAUSED,
2440
+ error: error.message,
2441
+ };
2390
2442
  throw error;
2391
2443
  }
2392
2444
 
2393
2445
  if (error instanceof WorkflowWaitingError) {
2394
2446
  await this.db.updateRunStatus(this.runId, 'paused');
2395
2447
  this.logger.log(`\n⏳ Workflow waiting: ${error.message}`);
2448
+ completionEvent = {
2449
+ type: 'workflow.complete',
2450
+ timestamp: new Date().toISOString(),
2451
+ runId: this.runId,
2452
+ workflow: this.workflow.name,
2453
+ status: WorkflowStatus.PAUSED,
2454
+ error: error.message,
2455
+ };
2396
2456
  throw error;
2397
2457
  }
2398
2458
 
2399
2459
  const errorMsg = error instanceof Error ? error.message : String(error);
2400
2460
 
2401
2461
  // Find the failed step from stepContexts
2402
- for (const [stepId, ctx] of this.stepContexts.entries()) {
2462
+ for (const [stepId, ctx] of this.state.entries()) {
2403
2463
  if (ctx.status === 'failed') {
2404
2464
  this.lastFailedStep = { id: stepId, error: ctx.error || errorMsg };
2405
2465
  break;
@@ -2414,12 +2474,23 @@ Please provide a corrected response that exactly matches the required schema.`;
2414
2474
  this.runId,
2415
2475
  'failed',
2416
2476
  undefined,
2417
- this.redactAtRest ? this.redactor.redact(errorMsg) : errorMsg
2477
+ this.secretManager.redactAtRest ? this.secretManager.redact(errorMsg) : errorMsg
2418
2478
  );
2479
+ completionEvent = {
2480
+ type: 'workflow.complete',
2481
+ timestamp: new Date().toISOString(),
2482
+ runId: this.runId,
2483
+ workflow: this.workflow.name,
2484
+ status: WorkflowStatus.FAILED,
2485
+ error: errorMsg,
2486
+ };
2419
2487
  throw error;
2420
2488
  } finally {
2421
2489
  this.removeSignalHandlers();
2422
2490
  await this.runFinally();
2491
+ if (completionEvent) {
2492
+ this.emitEvent(completionEvent);
2493
+ }
2423
2494
  if (!this.options.mcpManager) {
2424
2495
  await this.mcpManager.stopAll();
2425
2496
  }
@@ -2450,9 +2521,9 @@ Please provide a corrected response that exactly matches the required schema.`;
2450
2521
  const step = stepMap.get(stepId);
2451
2522
  if (!step) continue;
2452
2523
 
2453
- // Dependencies can be from main steps (already in this.stepContexts) or previous finally steps
2524
+ // Dependencies can be from main steps (already in this.state) or previous finally steps
2454
2525
  const dependenciesMet = step.needs.every(
2455
- (dep: string) => this.stepContexts.has(dep) || completedFinallySteps.has(dep)
2526
+ (dep: string) => this.state.has(dep) || completedFinallySteps.has(dep)
2456
2527
  );
2457
2528
 
2458
2529
  if (dependenciesMet) {
@@ -2462,8 +2533,22 @@ Please provide a corrected response that exactly matches the required schema.`;
2462
2533
  this.logger.log(
2463
2534
  `[${finallyStepIndex}/${totalFinallySteps}] ▶ Executing finally step: ${step.id} (${step.type})`
2464
2535
  );
2536
+ const startedAt = this.emitStepStart(
2537
+ step,
2538
+ 'finally',
2539
+ finallyStepIndex,
2540
+ totalFinallySteps
2541
+ );
2465
2542
  const promise = this.executeStepWithForeach(step)
2466
2543
  .then(() => {
2544
+ this.emitStepEnd(
2545
+ step,
2546
+ 'finally',
2547
+ startedAt,
2548
+ undefined,
2549
+ finallyStepIndex,
2550
+ totalFinallySteps
2551
+ );
2467
2552
  completedFinallySteps.add(stepId);
2468
2553
  runningPromises.delete(stepId);
2469
2554
  this.logger.log(
@@ -2471,6 +2556,14 @@ Please provide a corrected response that exactly matches the required schema.`;
2471
2556
  );
2472
2557
  })
2473
2558
  .catch((err) => {
2559
+ this.emitStepEnd(
2560
+ step,
2561
+ 'finally',
2562
+ startedAt,
2563
+ err,
2564
+ finallyStepIndex,
2565
+ totalFinallySteps
2566
+ );
2474
2567
  runningPromises.delete(stepId);
2475
2568
  this.logger.error(
2476
2569
  ` ✗ Finally step ${step.id} failed: ${err instanceof Error ? err.message : String(err)}`
@@ -2490,6 +2583,7 @@ Please provide a corrected response that exactly matches the required schema.`;
2490
2583
 
2491
2584
  if (runningPromises.size > 0) {
2492
2585
  await Promise.race(runningPromises.values());
2586
+ await Bun.sleep(0);
2493
2587
  }
2494
2588
  }
2495
2589
  } catch (error) {
@@ -2531,9 +2625,9 @@ Please provide a corrected response that exactly matches the required schema.`;
2531
2625
  const step = stepMap.get(stepId);
2532
2626
  if (!step) continue;
2533
2627
 
2534
- // Dependencies can be from main steps (already in this.stepContexts) or previous errors steps
2628
+ // Dependencies can be from main steps (already in this.state) or previous errors steps
2535
2629
  const dependenciesMet = step.needs.every(
2536
- (dep: string) => this.stepContexts.has(dep) || completedErrorsSteps.has(dep)
2630
+ (dep: string) => this.state.has(dep) || completedErrorsSteps.has(dep)
2537
2631
  );
2538
2632
 
2539
2633
  if (dependenciesMet) {
@@ -2543,8 +2637,17 @@ Please provide a corrected response that exactly matches the required schema.`;
2543
2637
  this.logger.log(
2544
2638
  `[${errorsStepIndex}/${totalErrorsSteps}] ▶ Executing errors step: ${step.id} (${step.type})`
2545
2639
  );
2640
+ const startedAt = this.emitStepStart(step, 'errors', errorsStepIndex, totalErrorsSteps);
2546
2641
  const promise = this.executeStepWithForeach(step)
2547
2642
  .then(() => {
2643
+ this.emitStepEnd(
2644
+ step,
2645
+ 'errors',
2646
+ startedAt,
2647
+ undefined,
2648
+ errorsStepIndex,
2649
+ totalErrorsSteps
2650
+ );
2548
2651
  completedErrorsSteps.add(stepId);
2549
2652
  runningPromises.delete(stepId);
2550
2653
  this.logger.log(
@@ -2552,6 +2655,7 @@ Please provide a corrected response that exactly matches the required schema.`;
2552
2655
  );
2553
2656
  })
2554
2657
  .catch((err) => {
2658
+ this.emitStepEnd(step, 'errors', startedAt, err, errorsStepIndex, totalErrorsSteps);
2555
2659
  runningPromises.delete(stepId);
2556
2660
  this.logger.error(
2557
2661
  ` ✗ Errors step ${step.id} failed: ${err instanceof Error ? err.message : String(err)}`
@@ -2571,6 +2675,7 @@ Please provide a corrected response that exactly matches the required schema.`;
2571
2675
 
2572
2676
  if (runningPromises.size > 0) {
2573
2677
  await Promise.race(runningPromises.values());
2678
+ await Bun.sleep(0);
2574
2679
  }
2575
2680
  }
2576
2681
  } catch (error) {
@@ -2588,7 +2693,7 @@ Please provide a corrected response that exactly matches the required schema.`;
2588
2693
  * Evaluate workflow outputs
2589
2694
  */
2590
2695
  private evaluateOutputs(): Record<string, unknown> {
2591
- const context = this.buildContext();
2696
+ const context = this.contextBuilder.buildContext(this.secretManager.getSecrets());
2592
2697
  const outputs: Record<string, unknown> = {};
2593
2698
 
2594
2699
  if (this.workflow.outputs) {
@@ -2607,7 +2712,7 @@ Please provide a corrected response that exactly matches the required schema.`;
2607
2712
  // Validate outputs against schema if provided
2608
2713
  if (this.workflow.outputSchema) {
2609
2714
  try {
2610
- this.validateSchema('output', this.workflow.outputSchema, outputs, 'workflow');
2715
+ this.validator.validateSchema('output', this.workflow.outputSchema, outputs, 'workflow');
2611
2716
  } catch (error) {
2612
2717
  throw new Error(
2613
2718
  `Workflow output validation failed: ${error instanceof Error ? error.message : String(error)}`
@@ -2618,39 +2723,6 @@ Please provide a corrected response that exactly matches the required schema.`;
2618
2723
  return outputs;
2619
2724
  }
2620
2725
 
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
2726
  /**
2655
2727
  * Register top-level compensation for the workflow
2656
2728
  */