@xyne/workflow-sdk 3.2.5 → 3.2.7
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/dist/client/types.d.ts +7 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/common/index.js +3 -0
- package/dist/common/index.js.map +1 -1
- package/dist/engine/node-path.d.ts +46 -0
- package/dist/engine/node-path.d.ts.map +1 -0
- package/dist/engine/node-path.js +56 -0
- package/dist/engine/node-path.js.map +1 -0
- package/dist/engine/workflow-executor.d.ts +50 -22
- package/dist/engine/workflow-executor.d.ts.map +1 -1
- package/dist/engine/workflow-executor.js +337 -393
- package/dist/engine/workflow-executor.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/persistence/types.d.ts +7 -0
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/router/workflow-router.d.ts.map +1 -1
- package/dist/router/workflow-router.js +5 -0
- package/dist/router/workflow-router.js.map +1 -1
- package/dist/runtime/types.d.ts +8 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/workflow-runtime.d.ts.map +1 -1
- package/dist/runtime/workflow-runtime.js +98 -13
- package/dist/runtime/workflow-runtime.js.map +1 -1
- package/dist/steps/base-step.d.ts +20 -4
- package/dist/steps/base-step.d.ts.map +1 -1
- package/dist/steps/base-step.js +17 -0
- package/dist/steps/base-step.js.map +1 -1
- package/dist/steps/builtin/loop.step.d.ts.map +1 -1
- package/dist/steps/builtin/loop.step.js +14 -20
- package/dist/steps/builtin/loop.step.js.map +1 -1
- package/dist/steps/builtin/parallel.step.d.ts +10 -31
- package/dist/steps/builtin/parallel.step.d.ts.map +1 -1
- package/dist/steps/builtin/parallel.step.js +32 -192
- package/dist/steps/builtin/parallel.step.js.map +1 -1
- package/package.json +2 -2
|
@@ -2,18 +2,41 @@ import { CONTROL_FLOW_STEP_TYPES } from '../types/known-types.js';
|
|
|
2
2
|
import { BaseActionStep, BaseControlFlowStep, StepKind, createPauseFunction, } from '../steps/base-step.js';
|
|
3
3
|
import { VariableResolver, stripNullForOptionalKeys, coerceResolvedToSchema } from './variable-resolver.js';
|
|
4
4
|
import { PauseStep } from './pause-step.js';
|
|
5
|
+
import { recordKey, framePrefix } from './node-path.js';
|
|
5
6
|
const noopLogger = {
|
|
6
7
|
info: () => { },
|
|
7
8
|
warn: () => { },
|
|
8
9
|
error: () => { },
|
|
9
10
|
};
|
|
11
|
+
/** Internal signal: a review-gate `abort` reached the walk → cancel the run. */
|
|
12
|
+
class AbortExecution extends Error {
|
|
13
|
+
}
|
|
10
14
|
// ─── WorkflowExecutor ───
|
|
11
15
|
/**
|
|
12
16
|
* Core runtime engine for executing workflows.
|
|
13
17
|
*
|
|
14
|
-
* Orchestrates: step execution, variable resolution, pause/resume
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* Orchestrates: step execution, variable resolution, durable pause/resume at
|
|
19
|
+
* any nesting depth (content-addressed step memoization + level-triggered
|
|
20
|
+
* reconciliation), error handling (onError: continue), retry, timeout,
|
|
21
|
+
* credential resolution, and state persistence.
|
|
22
|
+
*
|
|
23
|
+
* ## Pause/resume model
|
|
24
|
+
*
|
|
25
|
+
* Every step — top-level or nested in any control-flow branch — is keyed by a
|
|
26
|
+
* **node-path** (see `node-path.ts`). The walk always starts from the root and
|
|
27
|
+
* each step self-decides from its persisted record:
|
|
28
|
+
*
|
|
29
|
+
* - `COMPLETED` → restore output, skip (memoized; never re-executes).
|
|
30
|
+
* - `EXTERNAL_WAIT` leaf → resume via `onResume` if its record carries a
|
|
31
|
+
* `resumePayload`, otherwise re-park (still waiting).
|
|
32
|
+
* - `REVIEW_WAIT` → apply the review decision (approve / retry / abort).
|
|
33
|
+
* - control step with `EXTERNAL_WAIT` → re-execute (re-enter its branches; the
|
|
34
|
+
* completed branch steps memo-skip, the parked one resumes).
|
|
35
|
+
*
|
|
36
|
+
* Pausing throws to unwind — it carries no positional path, because the records
|
|
37
|
+
* hold the position. Resume = a payload written into the target node's record +
|
|
38
|
+
* re-enqueue; the next walk reconciles. N concurrent pauses, nested arbitrarily,
|
|
39
|
+
* fall out for free.
|
|
17
40
|
*
|
|
18
41
|
* Host provides: PersistenceAdapter, StepRegistry, TriggerRegistry,
|
|
19
42
|
* ServiceRegistry, and an optional logger.
|
|
@@ -49,8 +72,9 @@ export class WorkflowExecutor {
|
|
|
49
72
|
/**
|
|
50
73
|
* Run or resume an execution by ID.
|
|
51
74
|
*
|
|
52
|
-
* Loads the execution from persistence,
|
|
53
|
-
*
|
|
75
|
+
* Loads the execution from persistence, preloads its step records into the
|
|
76
|
+
* memo map, then walks the steps from the root. Completed steps short-circuit
|
|
77
|
+
* from the memo; the one (or many) parked step(s) resume from their records.
|
|
54
78
|
*/
|
|
55
79
|
async runExecution(executionId) {
|
|
56
80
|
const exec = await this.persistence.getExecution(executionId);
|
|
@@ -119,98 +143,44 @@ export class WorkflowExecutor {
|
|
|
119
143
|
return;
|
|
120
144
|
}
|
|
121
145
|
}
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const stepConfig = config.steps[idx];
|
|
133
|
-
if (!stepConfig || !row.data)
|
|
134
|
-
continue;
|
|
135
|
-
const parsed = safeParseJson(row.data);
|
|
136
|
-
if (parsed) {
|
|
137
|
-
const entry = {
|
|
138
|
-
type: parsed['type'] ?? stepConfig.type,
|
|
139
|
-
output: parsed['output'] ?? {},
|
|
140
|
-
};
|
|
141
|
-
if (parsed['input'] !== undefined)
|
|
142
|
-
entry['input'] = parsed['input'];
|
|
143
|
-
// Preserve extra persisted fields (e.g. PARALLEL's __parallelState) so
|
|
144
|
-
// control-flow steps can restore paused-branch progress on resume.
|
|
145
|
-
for (const k of Object.keys(parsed)) {
|
|
146
|
-
if (k !== 'type' && k !== 'input' && k !== 'output') {
|
|
147
|
-
entry[k] = parsed[k];
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
context.steps[stepConfig.id] =
|
|
151
|
-
entry;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (state.pauseType === 'review') {
|
|
155
|
-
// ─── Review-gate resume ───
|
|
156
|
-
const reviewedIdx = state.currentStepIndex;
|
|
157
|
-
const reviewedStep = config.steps[reviewedIdx];
|
|
158
|
-
const resumePayload = await this.persistence.getResumePayload(executionId);
|
|
159
|
-
const action = resumePayload?.action ?? 'approve';
|
|
160
|
-
if (action === 'abort') {
|
|
161
|
-
await this.persistence.updateExecutionStatus(executionId, 'CANCELLED');
|
|
162
|
-
this.emitEvent(executionId, { event: 'execution_cancelled', data: {} });
|
|
163
|
-
this.log.info(`run ${executionId} review-gate ABORTED`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (action === 'retry' && reviewedStep) {
|
|
167
|
-
// Re-execute the reviewed step fresh, with priorState + feedback overrides.
|
|
168
|
-
startIndex = reviewedIdx;
|
|
169
|
-
const priorRow = await this.persistence.getStep(executionId, `step_${String(reviewedIdx)}`);
|
|
170
|
-
const priorState = priorRow?.data
|
|
171
|
-
? safeParseJson(priorRow.data) ?? undefined
|
|
172
|
-
: undefined;
|
|
173
|
-
context.__meta = {
|
|
174
|
-
...context.__meta,
|
|
175
|
-
...(priorState ? { __priorStepState: priorState } : {}),
|
|
176
|
-
__configOverrides: extractConfigOverrides(resumePayload),
|
|
177
|
-
};
|
|
178
|
-
// Clear the entry so it re-executes (and downstream resolvers don't see stale output)
|
|
179
|
-
delete context.steps[reviewedStep.id];
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
// approve: advance past the reviewed step
|
|
183
|
-
startIndex = reviewedIdx + 1;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
// ─── Step-level pause resume (existing PausePath logic) ───
|
|
188
|
-
const savedPausePath = state.pausePath
|
|
189
|
-
? safeParseJson(state.pausePath)
|
|
190
|
-
: null;
|
|
191
|
-
if (savedPausePath && savedPausePath.length > 0) {
|
|
192
|
-
const first = savedPausePath[0];
|
|
193
|
-
startIndex = first.stepIndex;
|
|
194
|
-
resumeAtIndex = first.stepIndex;
|
|
195
|
-
branchResumePath =
|
|
196
|
-
savedPausePath.length > 1 ? savedPausePath.slice(1) : undefined;
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
startIndex = state.currentStepIndex;
|
|
200
|
-
resumeAtIndex = state.currentStepIndex;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
146
|
+
// Preload step records into the memo map. Empty for a brand-new run;
|
|
147
|
+
// seeded with COMPLETED rows for a rerun; full for a resume. The walk
|
|
148
|
+
// consults this by node-path — no positional cursor.
|
|
149
|
+
const memo = new Map();
|
|
150
|
+
const stepRows = await this.persistence.getStepRows(executionId);
|
|
151
|
+
for (const row of stepRows) {
|
|
152
|
+
memo.set(row.stepName, {
|
|
153
|
+
status: row.status,
|
|
154
|
+
data: row.data ? safeParseJson(row.data) : null,
|
|
155
|
+
});
|
|
203
156
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
157
|
+
// The sole open gate (single non-control parked record), if any. Used for
|
|
158
|
+
// the "re-enqueue resumes the one pause" convenience and as the target for
|
|
159
|
+
// a legacy global resume payload.
|
|
160
|
+
const gateRows = stepRows.filter((r) => (r.status === 'EXTERNAL_WAIT' || r.status === 'REVIEW_WAIT') &&
|
|
161
|
+
r.executorType !== 'conditional');
|
|
162
|
+
const soleGate = gateRows.length === 1 ? gateRows[0].stepName : undefined;
|
|
163
|
+
// Back-compat bridge: a global resume payload (legacy `persistResumePayload`)
|
|
164
|
+
// is routed to the sole gate's record, so single-gate resumes that don't go
|
|
165
|
+
// through the per-node `runtime.resume()` path still work. Multi-gate runs
|
|
166
|
+
// require per-node routing (no sole gate → payload left untouched).
|
|
167
|
+
if (isResume && soleGate) {
|
|
168
|
+
const globalPayload = await this.persistence.getResumePayload(executionId);
|
|
169
|
+
if (globalPayload) {
|
|
170
|
+
const entry = memo.get(soleGate);
|
|
171
|
+
const data = { ...(entry?.data ?? {}), resumePayload: globalPayload };
|
|
172
|
+
memo.set(soleGate, { status: gateRows[0].status, data });
|
|
173
|
+
await this.persistence.upsertStep(executionId, soleGate, {
|
|
174
|
+
status: gateRows[0].status,
|
|
175
|
+
executorType: gateRows[0].executorType,
|
|
176
|
+
data: JSON.stringify(data),
|
|
177
|
+
});
|
|
209
178
|
}
|
|
210
179
|
}
|
|
211
|
-
//
|
|
212
|
-
//
|
|
180
|
+
// Preserve transient seed fields (rerun priorState/configOverrides) that
|
|
181
|
+
// the rerun path populated in __meta; reset error/chain bookkeeping.
|
|
213
182
|
const prevMeta = context.__meta ?? {};
|
|
183
|
+
const entryExtrasPending = prevMeta.__priorStepState !== undefined || prevMeta.__configOverrides !== undefined;
|
|
214
184
|
context.__meta = {
|
|
215
185
|
...prevMeta,
|
|
216
186
|
error: null,
|
|
@@ -220,8 +190,14 @@ export class WorkflowExecutor {
|
|
|
220
190
|
await this.persistence.persistState(executionId, {
|
|
221
191
|
context: JSON.stringify(context),
|
|
222
192
|
});
|
|
223
|
-
this.log.info(`run ${isResume ? 'RESUMED' : 'STARTED'} execId=${executionId} workflow=${workflow.id}
|
|
224
|
-
await this.
|
|
193
|
+
this.log.info(`run ${isResume ? 'RESUMED' : 'STARTED'} execId=${executionId} workflow=${workflow.id}`);
|
|
194
|
+
await this.runWorkflow(executionId, context, config.steps, {
|
|
195
|
+
executionId,
|
|
196
|
+
memo,
|
|
197
|
+
settings: config.settings,
|
|
198
|
+
entryExtrasPending,
|
|
199
|
+
soleGate,
|
|
200
|
+
});
|
|
225
201
|
}
|
|
226
202
|
/**
|
|
227
203
|
* Start a fresh execution for a workflow.
|
|
@@ -242,11 +218,10 @@ export class WorkflowExecutor {
|
|
|
242
218
|
* Create a new execution that reruns from `fromStepId`, using the source
|
|
243
219
|
* execution's cached outputs for all prior steps as the resolved context.
|
|
244
220
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* agents and other stateful steps to continue from where they left off.
|
|
221
|
+
* Prior steps are written as COMPLETED records on the new execution, so the
|
|
222
|
+
* memoized walk skips them. The step at `fromStepId` receives the source
|
|
223
|
+
* row's data as `ctx.priorState` (when `inheritStepState` is true, the
|
|
224
|
+
* default), enabling agents and other stateful steps to continue.
|
|
250
225
|
*
|
|
251
226
|
* Returns the new execution ID. Caller is responsible for enqueueing it.
|
|
252
227
|
*/
|
|
@@ -275,6 +250,7 @@ export class WorkflowExecutor {
|
|
|
275
250
|
}
|
|
276
251
|
const sourceContext = JSON.parse(sourceState.context);
|
|
277
252
|
// Seed the new context with the source's trigger + steps 0..N-1 outputs.
|
|
253
|
+
// Records are keyed by node-path; top-level steps key on their id.
|
|
278
254
|
const seededSteps = {};
|
|
279
255
|
const stepRows = await this.persistence.getStepRows(sourceExecutionId);
|
|
280
256
|
const rowByName = new Map();
|
|
@@ -283,7 +259,7 @@ export class WorkflowExecutor {
|
|
|
283
259
|
}
|
|
284
260
|
for (let i = 0; i < fromStepIndex; i++) {
|
|
285
261
|
const stepCfg = config.steps[i];
|
|
286
|
-
const row = rowByName.get(
|
|
262
|
+
const row = rowByName.get(stepCfg.id);
|
|
287
263
|
if (!row?.data)
|
|
288
264
|
continue;
|
|
289
265
|
const parsed = safeParseJson(row.data);
|
|
@@ -298,7 +274,7 @@ export class WorkflowExecutor {
|
|
|
298
274
|
// Priorstate for the entry step (the one being re-executed)
|
|
299
275
|
let priorStepState;
|
|
300
276
|
if (inheritStepState) {
|
|
301
|
-
const entryRow = rowByName.get(
|
|
277
|
+
const entryRow = rowByName.get(fromStepId);
|
|
302
278
|
if (entryRow?.data) {
|
|
303
279
|
priorStepState =
|
|
304
280
|
safeParseJson(entryRow.data) ?? undefined;
|
|
@@ -328,17 +304,28 @@ export class WorkflowExecutor {
|
|
|
328
304
|
context: JSON.stringify(newContext),
|
|
329
305
|
sourceExecutionId,
|
|
330
306
|
});
|
|
307
|
+
// Persist prior steps as COMPLETED on the new execution so the memoized
|
|
308
|
+
// walk skips them (records are the cursor — there is no positional skip).
|
|
309
|
+
for (let i = 0; i < fromStepIndex; i++) {
|
|
310
|
+
const stepCfg = config.steps[i];
|
|
311
|
+
const entry = seededSteps[stepCfg.id];
|
|
312
|
+
if (!entry)
|
|
313
|
+
continue;
|
|
314
|
+
await this.persistence.upsertStep(newExecutionId, stepCfg.id, {
|
|
315
|
+
status: 'COMPLETED',
|
|
316
|
+
executorType: CONTROL_FLOW_STEP_TYPES.has(stepCfg.type)
|
|
317
|
+
? 'conditional'
|
|
318
|
+
: 'deterministic',
|
|
319
|
+
data: JSON.stringify(entry),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
331
322
|
this.log.info(`rerun created exec=${newExecutionId} from=${sourceExecutionId} step=${fromStepId}`);
|
|
332
323
|
return newExecutionId;
|
|
333
324
|
}
|
|
334
325
|
// ─── Internal: run loop ───
|
|
335
|
-
async
|
|
326
|
+
async runWorkflow(executionId, context, steps, walkCtx) {
|
|
336
327
|
try {
|
|
337
|
-
|
|
338
|
-
if (walkResult.kind === 'paused') {
|
|
339
|
-
this.log.info(`run ${executionId} PAUSED at path=${JSON.stringify(walkResult.pausePath)}`);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
328
|
+
await this.walkScope(steps, context, '', walkCtx);
|
|
342
329
|
await this.persistence.updateExecutionStatus(executionId, 'COMPLETED');
|
|
343
330
|
context.__meta = { ...context.__meta, error: null };
|
|
344
331
|
await this.persistence.persistState(executionId, {
|
|
@@ -349,6 +336,25 @@ export class WorkflowExecutor {
|
|
|
349
336
|
this.log.info(`run ${executionId} COMPLETED`);
|
|
350
337
|
}
|
|
351
338
|
catch (err) {
|
|
339
|
+
if (err instanceof AbortExecution) {
|
|
340
|
+
await this.persistence.updateExecutionStatus(executionId, 'CANCELLED');
|
|
341
|
+
this.emitEvent(executionId, { event: 'execution_cancelled', data: {} });
|
|
342
|
+
this.log.info(`run ${executionId} CANCELLED (review abort)`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (PauseStep.is(err)) {
|
|
346
|
+
// walkScope already persisted the paused step record(s). Mark the
|
|
347
|
+
// execution paused and snapshot the context (top-level outputs; branch
|
|
348
|
+
// outputs live in their path-qualified records).
|
|
349
|
+
const pauseCategory = err.pauseCategory;
|
|
350
|
+
await this.persistence.persistState(executionId, {
|
|
351
|
+
context: JSON.stringify(context),
|
|
352
|
+
...(pauseCategory === 'review' ? { pauseType: 'review' } : { pauseType: 'step' }),
|
|
353
|
+
});
|
|
354
|
+
await this.persistence.updateExecutionStatus(executionId, 'EXTERNAL_WAIT');
|
|
355
|
+
this.log.info(`run ${executionId} PAUSED`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
352
358
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
353
359
|
await this.persistence.updateExecutionStatus(executionId, 'FAILED');
|
|
354
360
|
context.__meta = { ...context.__meta, error: errMsg };
|
|
@@ -360,185 +366,204 @@ export class WorkflowExecutor {
|
|
|
360
366
|
throw err;
|
|
361
367
|
}
|
|
362
368
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Walk a scope of steps (the root scope, or one control-flow branch).
|
|
371
|
+
*
|
|
372
|
+
* `prefix` is the node-path prefix for this scope (`''` at the root, a frame
|
|
373
|
+
* prefix like `parallel_x:swot#0` inside a branch). Each step is addressed by
|
|
374
|
+
* `recordKey(prefix, step.id)`. The walk always starts from the first step;
|
|
375
|
+
* memoized records short-circuit completed steps so nothing re-executes.
|
|
376
|
+
*
|
|
377
|
+
* Throws `PauseStep` to unwind when a step (leaf or control) is parked, and
|
|
378
|
+
* `AbortExecution` on a review-gate abort. Settings-driven retry / onError
|
|
379
|
+
* apply at the root only — inside a branch, errors propagate so the enclosing
|
|
380
|
+
* control step (e.g. PARALLEL) can record them.
|
|
381
|
+
*/
|
|
382
|
+
async walkScope(steps, context, prefix, walkCtx) {
|
|
383
|
+
const { executionId, memo } = walkCtx;
|
|
384
|
+
const topLevel = prefix === '';
|
|
385
|
+
const onError = topLevel ? (walkCtx.settings?.onError ?? 'stop') : 'stop';
|
|
386
|
+
const retry = topLevel ? walkCtx.settings?.retry : undefined;
|
|
387
|
+
for (const step of steps) {
|
|
388
|
+
const key = recordKey(prefix, step.id);
|
|
389
|
+
const rec = memo.get(key);
|
|
390
|
+
const stepImpl = this.stepRegistry.get(step.type);
|
|
391
|
+
const isControl = stepImpl.kind === StepKind.CONTROL;
|
|
392
|
+
const executorType = isControl ? 'conditional' : 'deterministic';
|
|
393
|
+
// 1. Memoized COMPLETED → restore output, skip (never re-executes).
|
|
394
|
+
if (rec?.status === 'COMPLETED') {
|
|
395
|
+
if (rec.data)
|
|
396
|
+
context.steps[step.id] = entryFromData(rec.data, step.type);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// Decide the execution mode for this step from its record.
|
|
400
|
+
let resumeData;
|
|
401
|
+
let priorState;
|
|
402
|
+
let configOverrides;
|
|
403
|
+
// 2. REVIEW_WAIT — post-completion `onComplete:'review'` gate.
|
|
404
|
+
if (rec?.status === 'REVIEW_WAIT') {
|
|
405
|
+
const payload = rec.data?.['resumePayload'];
|
|
406
|
+
if (!payload) {
|
|
407
|
+
// Still awaiting review — record unchanged, unwind.
|
|
408
|
+
throw reviewPause(step.id);
|
|
409
|
+
}
|
|
410
|
+
const action = payload.action ?? 'approve';
|
|
411
|
+
if (action === 'abort')
|
|
412
|
+
throw new AbortExecution();
|
|
413
|
+
if (action !== 'retry') {
|
|
414
|
+
// approve → restore output, mark COMPLETED, advance.
|
|
415
|
+
if (rec.data)
|
|
416
|
+
context.steps[step.id] = entryFromData(rec.data, step.type);
|
|
417
|
+
await this.persistStep(executionId, key, 'COMPLETED', executorType, context.steps[step.id], memo);
|
|
418
|
+
this.emitEvent(executionId, {
|
|
419
|
+
event: 'step_completed',
|
|
420
|
+
data: { stepName: step.id, output: (context.steps[step.id]?.output ?? {}) },
|
|
421
|
+
});
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// retry → re-execute this step with prior state + feedback overrides.
|
|
425
|
+
priorState = rec.data ?? undefined;
|
|
426
|
+
configOverrides = extractConfigOverrides(payload);
|
|
427
|
+
}
|
|
428
|
+
else if (rec?.status === 'EXTERNAL_WAIT' && !isControl) {
|
|
429
|
+
// 3. Parked leaf (WAIT / agent) — resume or re-park.
|
|
430
|
+
const data = rec.data ?? {};
|
|
431
|
+
const payload = data['resumePayload'];
|
|
432
|
+
// Delay-mode waits auto-resume once their deadline elapses (the host
|
|
433
|
+
// re-enqueues at `resumeAfter`); approval/webhook waits resume only when
|
|
434
|
+
// a payload has been routed to this node's record. This keeps a delay
|
|
435
|
+
// branch from resuming early when a *sibling* branch's resume re-runs
|
|
436
|
+
// the walk.
|
|
437
|
+
const delayReady = data['waitMode'] === 'delay' && payload === undefined
|
|
438
|
+
? typeof data['resumeAfter'] === 'string'
|
|
439
|
+
? Date.parse(data['resumeAfter']) <= Date.now()
|
|
440
|
+
: true
|
|
441
|
+
: false;
|
|
442
|
+
// Resume when: a payload was routed here, a delay elapsed, or this is the
|
|
443
|
+
// sole open gate (a bare re-enqueue resumes the one pause).
|
|
444
|
+
const resumeThis = payload !== undefined || delayReady || key === walkCtx.soleGate;
|
|
445
|
+
if (!resumeThis) {
|
|
446
|
+
// Still waiting — unwind without re-executing (preserves the record).
|
|
447
|
+
const reason = typeof data['reason'] === 'string' ? String(data['reason']) : 'waiting';
|
|
448
|
+
throw new PauseStep(reason);
|
|
449
|
+
}
|
|
450
|
+
resumeData = data;
|
|
451
|
+
}
|
|
452
|
+
else if (topLevel && walkCtx.entryExtrasPending) {
|
|
453
|
+
// 4a. First fresh top-level step consumes the rerun seed.
|
|
454
|
+
priorState = context.__meta?.__priorStepState;
|
|
455
|
+
configOverrides = context.__meta?.__configOverrides;
|
|
456
|
+
walkCtx.entryExtrasPending = false;
|
|
457
|
+
if (context.__meta) {
|
|
458
|
+
delete context.__meta.__priorStepState;
|
|
459
|
+
delete context.__meta.__configOverrides;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// (rec EXTERNAL_WAIT + control → resumeData undefined → re-executes below,
|
|
463
|
+
// re-entering its branches; completed branch steps memo-skip.)
|
|
464
|
+
const resuming = resumeData !== undefined;
|
|
465
|
+
if (!resuming) {
|
|
466
|
+
await this.persistence.upsertStep(executionId, key, { status: 'RUNNING', executorType });
|
|
380
467
|
this.emitEvent(executionId, {
|
|
381
468
|
event: 'step_started',
|
|
382
469
|
data: { stepName: step.id, stepType: step.type },
|
|
383
470
|
});
|
|
384
471
|
}
|
|
385
|
-
// Consume entry-step extras (priorState + configOverrides) on the first
|
|
386
|
-
// iteration only. These come from review-gate retry or rerunFromStep.
|
|
387
|
-
const isEntryStep = i === startIndex && !isResuming;
|
|
388
|
-
const entryPriorState = isEntryStep
|
|
389
|
-
? context.__meta?.__priorStepState
|
|
390
|
-
: undefined;
|
|
391
|
-
const entryConfigOverrides = isEntryStep
|
|
392
|
-
? context.__meta?.__configOverrides
|
|
393
|
-
: undefined;
|
|
394
|
-
if (isEntryStep && context.__meta) {
|
|
395
|
-
// Single-use — clear so subsequent steps don't see them.
|
|
396
|
-
delete context.__meta.__priorStepState;
|
|
397
|
-
delete context.__meta.__configOverrides;
|
|
398
|
-
}
|
|
399
472
|
try {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
? 'conditional'
|
|
406
|
-
: 'deterministic',
|
|
407
|
-
data: JSON.stringify(ctxEntry ?? { output: null }),
|
|
408
|
-
});
|
|
409
|
-
this.emitEvent(executionId, {
|
|
410
|
-
event: 'step_completed',
|
|
411
|
-
data: {
|
|
412
|
-
stepName: step.id,
|
|
413
|
-
output: (ctxEntry?.output ?? {}),
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
// ─── Review gate: pause after step completes if onComplete === 'review' ───
|
|
417
|
-
if (step.onComplete === 'review') {
|
|
418
|
-
const reviewPauseState = {
|
|
419
|
-
reason: `Review output of "${step.id}"`,
|
|
420
|
-
category: 'review',
|
|
421
|
-
display: ctxEntry?.output
|
|
422
|
-
? [{ label: 'Output', value: ctxEntry.output }]
|
|
423
|
-
: undefined,
|
|
424
|
-
actions: [
|
|
425
|
-
{ key: 'approve', label: 'Approve', style: 'primary' },
|
|
426
|
-
{ key: 'retry', label: 'Retry with Feedback', style: 'default', submitsForm: true },
|
|
427
|
-
{ key: 'abort', label: 'Reject', style: 'danger', abort: true },
|
|
428
|
-
],
|
|
429
|
-
form: {
|
|
430
|
-
fields: [
|
|
431
|
-
{
|
|
432
|
-
type: 'textarea',
|
|
433
|
-
name: 'feedback',
|
|
434
|
-
label: 'Feedback',
|
|
435
|
-
placeholder: 'Optional instructions for retry...',
|
|
436
|
-
},
|
|
437
|
-
],
|
|
438
|
-
},
|
|
439
|
-
};
|
|
440
|
-
await this.persistence.persistState(executionId, {
|
|
441
|
-
context: JSON.stringify(context),
|
|
442
|
-
currentStepIndex: i,
|
|
443
|
-
pauseType: 'review',
|
|
444
|
-
});
|
|
445
|
-
await this.persistence.updateExecutionStatus(executionId, 'EXTERNAL_WAIT');
|
|
446
|
-
this.emitEvent(executionId, {
|
|
447
|
-
event: 'execution_paused',
|
|
448
|
-
data: { stepName: step.id, pauseState: reviewPauseState },
|
|
449
|
-
});
|
|
450
|
-
this.log.info(`run ${executionId} REVIEW-GATE paused at step=${step.id}`);
|
|
451
|
-
return { kind: 'paused', pausePath: [], externalRef: undefined };
|
|
473
|
+
if (resuming) {
|
|
474
|
+
await this.executeStep(step, context, { executionId, stepName: key, isResuming: true }, prefix, walkCtx, resumeData);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
await this.executeStepWithRetry(step, context, { executionId, stepName: key, isResuming: false, priorState, configOverrides }, prefix, walkCtx, retry);
|
|
452
478
|
}
|
|
453
479
|
}
|
|
454
480
|
catch (err) {
|
|
455
481
|
if (PauseStep.is(err)) {
|
|
482
|
+
// Leaf called ctx.pause(), or a control step's branch(es) parked.
|
|
456
483
|
const pe = err;
|
|
457
|
-
const fullPath = [
|
|
458
|
-
{ stepIndex: i },
|
|
459
|
-
...(pe._pausePath ?? []),
|
|
460
|
-
];
|
|
461
484
|
const ctxEntry = context.steps[step.id];
|
|
462
|
-
const base = ctxEntry
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
await this.
|
|
467
|
-
status: 'EXTERNAL_WAIT',
|
|
468
|
-
executorType: CONTROL_FLOW_STEP_TYPES.has(step.type)
|
|
469
|
-
? 'conditional'
|
|
470
|
-
: 'deterministic',
|
|
471
|
-
data: JSON.stringify(merged),
|
|
472
|
-
});
|
|
473
|
-
await this.persistence.persistState(executionId, {
|
|
474
|
-
context: JSON.stringify(context),
|
|
475
|
-
currentStepIndex: i,
|
|
476
|
-
pausePath: JSON.stringify(fullPath),
|
|
477
|
-
});
|
|
478
|
-
await this.persistence.updateExecutionStatus(executionId, 'EXTERNAL_WAIT');
|
|
485
|
+
const base = ctxEntry
|
|
486
|
+
? { ...ctxEntry }
|
|
487
|
+
: { type: step.type, output: {} };
|
|
488
|
+
const merged = pe.statePatch ? { ...base, ...pe.statePatch } : base;
|
|
489
|
+
await this.persistStep(executionId, key, 'EXTERNAL_WAIT', executorType, merged, memo);
|
|
479
490
|
this.emitEvent(executionId, {
|
|
480
491
|
event: 'execution_paused',
|
|
481
492
|
data: {
|
|
482
493
|
stepName: step.id,
|
|
483
|
-
|
|
494
|
+
nodePath: key,
|
|
495
|
+
pauseState: merged['pauseState'] ??
|
|
496
|
+
{ reason: pe.message },
|
|
484
497
|
},
|
|
485
498
|
});
|
|
486
|
-
|
|
487
|
-
kind: 'paused',
|
|
488
|
-
pausePath: fullPath,
|
|
489
|
-
externalRef: pe.externalRef,
|
|
490
|
-
};
|
|
499
|
+
throw pe;
|
|
491
500
|
}
|
|
492
501
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
493
502
|
const ctxEntry = context.steps[step.id];
|
|
494
503
|
if (onError === 'continue') {
|
|
495
504
|
context.steps[step.id] = {
|
|
496
505
|
type: step.type,
|
|
497
|
-
...(ctxEntry?.input !== undefined
|
|
498
|
-
? { input: ctxEntry.input }
|
|
499
|
-
: {}),
|
|
506
|
+
...(ctxEntry?.input !== undefined ? { input: ctxEntry.input } : {}),
|
|
500
507
|
output: ctxEntry?.output ?? {},
|
|
501
508
|
error: errMsg,
|
|
502
509
|
};
|
|
503
|
-
await this.persistence.upsertStep(executionId,
|
|
510
|
+
await this.persistence.upsertStep(executionId, key, {
|
|
504
511
|
status: 'FAILED',
|
|
505
|
-
executorType
|
|
506
|
-
? 'conditional'
|
|
507
|
-
: 'deterministic',
|
|
512
|
+
executorType,
|
|
508
513
|
data: JSON.stringify({ ...(ctxEntry ?? {}), error: errMsg }),
|
|
509
514
|
});
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
data: { stepName: step.id, error: errMsg },
|
|
513
|
-
});
|
|
515
|
+
memo.set(key, { status: 'FAILED', data: { ...(ctxEntry ?? {}), error: errMsg } });
|
|
516
|
+
this.emitEvent(executionId, { event: 'step_failed', data: { stepName: step.id, error: errMsg } });
|
|
514
517
|
this.log.warn(`step ${step.id} failed but continuing: ${errMsg}`);
|
|
515
518
|
continue;
|
|
516
519
|
}
|
|
517
|
-
await this.persistence.upsertStep(executionId,
|
|
520
|
+
await this.persistence.upsertStep(executionId, key, {
|
|
518
521
|
status: 'FAILED',
|
|
519
|
-
executorType
|
|
520
|
-
? 'conditional'
|
|
521
|
-
: 'deterministic',
|
|
522
|
+
executorType,
|
|
522
523
|
data: JSON.stringify({ ...(ctxEntry ?? {}), error: errMsg }),
|
|
523
524
|
});
|
|
525
|
+
this.emitEvent(executionId, { event: 'step_failed', data: { stepName: step.id, error: errMsg } });
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
const ctxEntry = context.steps[step.id];
|
|
529
|
+
// Review gate: pause after a fresh success when onComplete === 'review'.
|
|
530
|
+
// (A resumed leaf already paused once; it does not re-gate.)
|
|
531
|
+
if (!resuming && step.onComplete === 'review') {
|
|
532
|
+
const reviewPauseState = buildReviewPauseState(step.id, ctxEntry);
|
|
533
|
+
const data = { ...(ctxEntry ?? { type: step.type, output: {} }), pauseState: reviewPauseState };
|
|
534
|
+
await this.persistStep(executionId, key, 'REVIEW_WAIT', executorType, data, memo);
|
|
524
535
|
this.emitEvent(executionId, {
|
|
525
|
-
event: '
|
|
526
|
-
data: { stepName: step.id,
|
|
536
|
+
event: 'execution_paused',
|
|
537
|
+
data: { stepName: step.id, nodePath: key, pauseState: reviewPauseState },
|
|
527
538
|
});
|
|
528
|
-
throw
|
|
539
|
+
throw reviewPause(step.id);
|
|
529
540
|
}
|
|
541
|
+
await this.persistStep(executionId, key, 'COMPLETED', executorType, ctxEntry, memo);
|
|
542
|
+
this.emitEvent(executionId, {
|
|
543
|
+
event: 'step_completed',
|
|
544
|
+
data: { stepName: step.id, output: (ctxEntry?.output ?? {}) },
|
|
545
|
+
});
|
|
530
546
|
}
|
|
531
|
-
return { kind: 'completed' };
|
|
532
547
|
}
|
|
533
|
-
|
|
548
|
+
/** Persist a step record + mirror it into the in-memory memo for this run. */
|
|
549
|
+
async persistStep(executionId, key, status, executorType, entry, memo) {
|
|
550
|
+
const data = entry ? { ...entry } : { output: null };
|
|
551
|
+
await this.persistence.upsertStep(executionId, key, {
|
|
552
|
+
status,
|
|
553
|
+
executorType,
|
|
554
|
+
data: JSON.stringify(data),
|
|
555
|
+
});
|
|
556
|
+
memo.set(key, { status, data });
|
|
557
|
+
}
|
|
558
|
+
async executeStepWithRetry(step, context, callCtx, prefix, walkCtx, retry) {
|
|
534
559
|
if (!retry || callCtx.isResuming) {
|
|
535
|
-
await this.executeStep(step, context, callCtx,
|
|
560
|
+
await this.executeStep(step, context, callCtx, prefix, walkCtx);
|
|
536
561
|
return;
|
|
537
562
|
}
|
|
538
563
|
let lastError;
|
|
539
564
|
for (let attempt = 1; attempt <= retry.maxAttempts; attempt++) {
|
|
540
565
|
try {
|
|
541
|
-
await this.executeStep(step, context, callCtx,
|
|
566
|
+
await this.executeStep(step, context, callCtx, prefix, walkCtx);
|
|
542
567
|
return;
|
|
543
568
|
}
|
|
544
569
|
catch (err) {
|
|
@@ -553,7 +578,13 @@ export class WorkflowExecutor {
|
|
|
553
578
|
}
|
|
554
579
|
throw lastError;
|
|
555
580
|
}
|
|
556
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Execute one step. Control steps recurse into their branches via the
|
|
583
|
+
* `walkBranch` closure (which addresses each branch with a node-path frame).
|
|
584
|
+
* Action steps resolve config then either `execute()` fresh or `onResume()`
|
|
585
|
+
* when `resumeData` (the parked record) is supplied.
|
|
586
|
+
*/
|
|
587
|
+
async executeStep(step, context, callCtx, prefix, walkCtx, resumeData) {
|
|
557
588
|
const stepImpl = this.stepRegistry.get(step.type);
|
|
558
589
|
if (stepImpl.kind === StepKind.CONTROL) {
|
|
559
590
|
const controlImpl = stepImpl;
|
|
@@ -562,17 +593,16 @@ export class WorkflowExecutor {
|
|
|
562
593
|
throw new Error(`Step "${step.id}" (${step.type}) config validation failed:\n${formatZodErrors(parsed.error)}`);
|
|
563
594
|
}
|
|
564
595
|
this.log.info(`step ${callCtx.isResuming ? 'RESUME' : 'START'} id=${step.id} type=${step.type}`);
|
|
565
|
-
const t0 = Date.now();
|
|
566
596
|
const stepCtx = await this.buildStepContext(context, { ...callCtx, stepId: step.id }, stepImpl);
|
|
567
597
|
const output = await controlImpl.execute(parsed.data, {
|
|
568
598
|
...stepCtx,
|
|
569
|
-
walkBranch: (branchSteps,
|
|
599
|
+
walkBranch: (branchSteps, branchScope, branchKey, iter) => this.walkScope(branchSteps, branchScope, framePrefix(prefix, step.id, branchKey ?? 'default', iter ?? 0), walkCtx),
|
|
570
600
|
});
|
|
571
601
|
context.steps[step.id] = { type: step.type, output };
|
|
572
|
-
this.log.info(`step OK id=${step.id} type=${step.type}
|
|
602
|
+
this.log.info(`step OK id=${step.id} type=${step.type}`);
|
|
573
603
|
return;
|
|
574
604
|
}
|
|
575
|
-
// Action step: merge configOverrides → resolve variables → coerce → validate → execute
|
|
605
|
+
// Action step: merge configOverrides → resolve variables → coerce → validate → execute/resume
|
|
576
606
|
const baseConfig = callCtx.configOverrides
|
|
577
607
|
? { ...step.config, ...callCtx.configOverrides }
|
|
578
608
|
: step.config;
|
|
@@ -591,13 +621,22 @@ export class WorkflowExecutor {
|
|
|
591
621
|
output: {},
|
|
592
622
|
};
|
|
593
623
|
const actionImpl = stepImpl;
|
|
594
|
-
const
|
|
624
|
+
const resumePayload = resumeData?.['resumePayload'];
|
|
625
|
+
const stepCtx = await this.buildStepContext(context, { ...callCtx, stepId: step.id }, stepImpl, resumePayload);
|
|
595
626
|
this.log.info(`step ${callCtx.isResuming ? 'RESUME' : 'START'} id=${step.id} type=${step.type}`);
|
|
596
|
-
const t0 = Date.now();
|
|
597
627
|
try {
|
|
598
628
|
let output;
|
|
599
|
-
if (
|
|
600
|
-
|
|
629
|
+
if (resumeData !== undefined) {
|
|
630
|
+
if (typeof actionImpl.onResume === 'function') {
|
|
631
|
+
output = await actionImpl.onResume(resumeData, resolvedInput, stepCtx);
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const fallback = resumeData['output'];
|
|
635
|
+
output =
|
|
636
|
+
fallback && typeof fallback === 'object' && !Array.isArray(fallback)
|
|
637
|
+
? fallback
|
|
638
|
+
: {};
|
|
639
|
+
}
|
|
601
640
|
}
|
|
602
641
|
else {
|
|
603
642
|
output = await actionImpl.execute(resolvedInput, stepCtx);
|
|
@@ -607,151 +646,18 @@ export class WorkflowExecutor {
|
|
|
607
646
|
input: persistedInput,
|
|
608
647
|
output,
|
|
609
648
|
};
|
|
610
|
-
this.log.info(`step OK id=${step.id} type=${step.type}
|
|
649
|
+
this.log.info(`step OK id=${step.id} type=${step.type}`);
|
|
611
650
|
}
|
|
612
651
|
catch (err) {
|
|
613
652
|
if (!PauseStep.is(err)) {
|
|
614
|
-
this.log.error(`step FAIL id=${step.id} type=${step.type}
|
|
653
|
+
this.log.error(`step FAIL id=${step.id} type=${step.type} err=${err instanceof Error ? err.message : String(err)}`);
|
|
615
654
|
}
|
|
616
655
|
throw err;
|
|
617
656
|
}
|
|
618
657
|
}
|
|
619
|
-
|
|
620
|
-
* Walk steps inside a control-flow branch. Supports nested pause.
|
|
621
|
-
*
|
|
622
|
-
* Each sub-step is persisted as its own row (keyed by config `id`) and
|
|
623
|
-
* emits SSE events, mirroring the top-level `walkSteps` behavior.
|
|
624
|
-
*
|
|
625
|
-
* When a step pauses inside a branch, the PauseStep error is enriched
|
|
626
|
-
* with branch path information and re-thrown so the top-level walkSteps
|
|
627
|
-
* can persist the full PausePath.
|
|
628
|
-
*/
|
|
629
|
-
async walkBranchSteps(steps, context, executionId, controlStepId, branchKey, branchResumePath) {
|
|
630
|
-
const resolvedBranchKey = branchKey ?? 'default';
|
|
631
|
-
// Determine start position for resume within this branch
|
|
632
|
-
let startIndex = 0;
|
|
633
|
-
let innerResuming = false;
|
|
634
|
-
let deeperResumePath;
|
|
635
|
-
if (branchResumePath && branchResumePath.length > 0) {
|
|
636
|
-
const segment = branchResumePath[0];
|
|
637
|
-
if (segment.branch?.stepId === controlStepId &&
|
|
638
|
-
segment.branch.branchKey === resolvedBranchKey) {
|
|
639
|
-
startIndex = segment.stepIndex;
|
|
640
|
-
innerResuming = true;
|
|
641
|
-
deeperResumePath =
|
|
642
|
-
branchResumePath.length > 1
|
|
643
|
-
? branchResumePath.slice(1)
|
|
644
|
-
: undefined;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
for (let j = startIndex; j < steps.length; j++) {
|
|
648
|
-
const bStep = steps[j];
|
|
649
|
-
const isResuming = innerResuming && j === startIndex;
|
|
650
|
-
const executorType = CONTROL_FLOW_STEP_TYPES.has(bStep.type)
|
|
651
|
-
? 'conditional'
|
|
652
|
-
: 'deterministic';
|
|
653
|
-
// Persist RUNNING + emit event (skip for resuming steps)
|
|
654
|
-
if (!isResuming && executionId) {
|
|
655
|
-
await this.persistence.upsertStep(executionId, bStep.id, {
|
|
656
|
-
status: 'RUNNING',
|
|
657
|
-
executorType,
|
|
658
|
-
});
|
|
659
|
-
this.emitEvent(executionId, {
|
|
660
|
-
event: 'step_started',
|
|
661
|
-
data: { stepName: bStep.id, stepType: bStep.type },
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
try {
|
|
665
|
-
await this.executeStep(bStep, context, {
|
|
666
|
-
executionId,
|
|
667
|
-
stepName: bStep.id,
|
|
668
|
-
isResuming,
|
|
669
|
-
}, isResuming ? deeperResumePath : undefined);
|
|
670
|
-
// Persist COMPLETED + emit event
|
|
671
|
-
if (executionId) {
|
|
672
|
-
const ctxEntry = context.steps[bStep.id];
|
|
673
|
-
await this.persistence.upsertStep(executionId, bStep.id, {
|
|
674
|
-
status: 'COMPLETED',
|
|
675
|
-
executorType,
|
|
676
|
-
data: JSON.stringify(ctxEntry ?? { output: null }),
|
|
677
|
-
});
|
|
678
|
-
this.emitEvent(executionId, {
|
|
679
|
-
event: 'step_completed',
|
|
680
|
-
data: {
|
|
681
|
-
stepName: bStep.id,
|
|
682
|
-
output: (ctxEntry?.output ?? {}),
|
|
683
|
-
},
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
catch (err) {
|
|
688
|
-
if (PauseStep.is(err)) {
|
|
689
|
-
const pe = err;
|
|
690
|
-
// Persist EXTERNAL_WAIT for the sub-step
|
|
691
|
-
if (executionId) {
|
|
692
|
-
const ctxEntry = context.steps[bStep.id];
|
|
693
|
-
const base = ctxEntry ?? { type: bStep.type, output: {} };
|
|
694
|
-
const merged = pe.statePatch
|
|
695
|
-
? { ...base, ...pe.statePatch }
|
|
696
|
-
: base;
|
|
697
|
-
await this.persistence.upsertStep(executionId, bStep.id, {
|
|
698
|
-
status: 'EXTERNAL_WAIT',
|
|
699
|
-
executorType,
|
|
700
|
-
data: JSON.stringify(merged),
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
pe._pausePath = [
|
|
704
|
-
{
|
|
705
|
-
stepIndex: j,
|
|
706
|
-
branch: {
|
|
707
|
-
stepId: controlStepId,
|
|
708
|
-
branchKey: resolvedBranchKey,
|
|
709
|
-
},
|
|
710
|
-
},
|
|
711
|
-
...(pe._pausePath ?? []),
|
|
712
|
-
];
|
|
713
|
-
throw pe;
|
|
714
|
-
}
|
|
715
|
-
// Persist FAILED + emit event
|
|
716
|
-
if (executionId) {
|
|
717
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
718
|
-
await this.persistence.upsertStep(executionId, bStep.id, {
|
|
719
|
-
status: 'FAILED',
|
|
720
|
-
executorType,
|
|
721
|
-
data: JSON.stringify({ error: errMsg }),
|
|
722
|
-
});
|
|
723
|
-
this.emitEvent(executionId, {
|
|
724
|
-
event: 'step_failed',
|
|
725
|
-
data: { stepName: bStep.id, error: errMsg },
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
throw err;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
async invokeResume(actionImpl, executionId, stepName, resolvedInput, ctx) {
|
|
733
|
-
const row = await this.persistence.getStep(executionId, stepName);
|
|
734
|
-
let rowData = row?.data
|
|
735
|
-
? (safeParseJson(row.data) ?? {})
|
|
736
|
-
: {};
|
|
737
|
-
// Merge user's resume payload (approve/reject/form data) into rowData
|
|
738
|
-
const resumePayload = await this.persistence.getResumePayload(executionId);
|
|
739
|
-
if (resumePayload) {
|
|
740
|
-
rowData = { ...rowData, resumePayload };
|
|
741
|
-
}
|
|
742
|
-
if (typeof actionImpl.onResume === 'function') {
|
|
743
|
-
return actionImpl.onResume(rowData, resolvedInput, ctx);
|
|
744
|
-
}
|
|
745
|
-
const fallback = rowData['output'];
|
|
746
|
-
return fallback &&
|
|
747
|
-
typeof fallback === 'object' &&
|
|
748
|
-
!Array.isArray(fallback)
|
|
749
|
-
? fallback
|
|
750
|
-
: {};
|
|
751
|
-
}
|
|
752
|
-
async buildStepContext(context, callCtx, stepImpl) {
|
|
658
|
+
async buildStepContext(context, callCtx, stepImpl, resumePayload) {
|
|
753
659
|
// Resolve credentials if step declares credentialSlots
|
|
754
|
-
|
|
660
|
+
const credentials = {};
|
|
755
661
|
if (stepImpl.credentialSlots && stepImpl.credentialSlots.length > 0) {
|
|
756
662
|
for (const slot of stepImpl.credentialSlots) {
|
|
757
663
|
const resolved = await this.persistence.resolveCredential(context.workflow.metadata, slot.name);
|
|
@@ -763,6 +669,12 @@ export class WorkflowExecutor {
|
|
|
763
669
|
}
|
|
764
670
|
}
|
|
765
671
|
}
|
|
672
|
+
// The resume payload rides on `runtime.metadata` (a shallow copy so the
|
|
673
|
+
// persisted workflow metadata isn't polluted) — the agent step reads it
|
|
674
|
+
// there, and onResume also receives it merged into `rowData`.
|
|
675
|
+
const metadata = resumePayload
|
|
676
|
+
? { ...context.workflow.metadata, resumePayload }
|
|
677
|
+
: context.workflow.metadata;
|
|
766
678
|
return {
|
|
767
679
|
workflow: context,
|
|
768
680
|
runtime: {
|
|
@@ -770,7 +682,7 @@ export class WorkflowExecutor {
|
|
|
770
682
|
stepId: callCtx.stepId,
|
|
771
683
|
stepName: callCtx.stepName,
|
|
772
684
|
isResuming: callCtx.isResuming,
|
|
773
|
-
metadata
|
|
685
|
+
metadata,
|
|
774
686
|
baseUrl: this.baseUrl,
|
|
775
687
|
},
|
|
776
688
|
...(callCtx.priorState ? { priorState: callCtx.priorState } : {}),
|
|
@@ -820,12 +732,44 @@ function safeParseJson(raw) {
|
|
|
820
732
|
return null;
|
|
821
733
|
}
|
|
822
734
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
735
|
+
/** Reconstruct a context steps entry from a persisted record's parsed data. */
|
|
736
|
+
function entryFromData(data, fallbackType) {
|
|
737
|
+
return {
|
|
738
|
+
type: data['type'] ?? fallbackType,
|
|
739
|
+
...(data['input'] !== undefined ? { input: data['input'] } : {}),
|
|
740
|
+
output: data['output'] ?? {},
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/** A PauseStep tagged as originating from a review gate (drives `pauseType`). */
|
|
744
|
+
function reviewPause(stepId) {
|
|
745
|
+
const pe = new PauseStep(`review gate at ${stepId}`);
|
|
746
|
+
pe.pauseCategory = 'review';
|
|
747
|
+
return pe;
|
|
748
|
+
}
|
|
749
|
+
/** The review-gate PauseState shown to the approver (approve / retry / abort). */
|
|
750
|
+
function buildReviewPauseState(stepId, ctxEntry) {
|
|
751
|
+
return {
|
|
752
|
+
reason: `Review output of "${stepId}"`,
|
|
753
|
+
category: 'review',
|
|
754
|
+
display: ctxEntry?.output
|
|
755
|
+
? [{ label: 'Output', value: ctxEntry.output }]
|
|
756
|
+
: undefined,
|
|
757
|
+
actions: [
|
|
758
|
+
{ key: 'approve', label: 'Approve', style: 'primary' },
|
|
759
|
+
{ key: 'retry', label: 'Retry with Feedback', style: 'default', submitsForm: true },
|
|
760
|
+
{ key: 'abort', label: 'Reject', style: 'danger', abort: true },
|
|
761
|
+
],
|
|
762
|
+
form: {
|
|
763
|
+
fields: [
|
|
764
|
+
{
|
|
765
|
+
type: 'textarea',
|
|
766
|
+
name: 'feedback',
|
|
767
|
+
label: 'Feedback',
|
|
768
|
+
placeholder: 'Optional instructions for retry...',
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
};
|
|
829
773
|
}
|
|
830
774
|
function formatZodErrors(error) {
|
|
831
775
|
return error.issues
|