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