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