@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.
Files changed (40) hide show
  1. package/dist/client/types.d.ts +7 -0
  2. package/dist/client/types.d.ts.map +1 -1
  3. package/dist/common/index.d.ts +1 -0
  4. package/dist/common/index.d.ts.map +1 -1
  5. package/dist/common/index.js +3 -0
  6. package/dist/common/index.js.map +1 -1
  7. package/dist/engine/node-path.d.ts +46 -0
  8. package/dist/engine/node-path.d.ts.map +1 -0
  9. package/dist/engine/node-path.js +56 -0
  10. package/dist/engine/node-path.js.map +1 -0
  11. package/dist/engine/workflow-executor.d.ts +50 -22
  12. package/dist/engine/workflow-executor.d.ts.map +1 -1
  13. package/dist/engine/workflow-executor.js +337 -393
  14. package/dist/engine/workflow-executor.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/persistence/types.d.ts +7 -0
  20. package/dist/persistence/types.d.ts.map +1 -1
  21. package/dist/router/workflow-router.d.ts.map +1 -1
  22. package/dist/router/workflow-router.js +5 -0
  23. package/dist/router/workflow-router.js.map +1 -1
  24. package/dist/runtime/types.d.ts +8 -0
  25. package/dist/runtime/types.d.ts.map +1 -1
  26. package/dist/runtime/workflow-runtime.d.ts.map +1 -1
  27. package/dist/runtime/workflow-runtime.js +98 -13
  28. package/dist/runtime/workflow-runtime.js.map +1 -1
  29. package/dist/steps/base-step.d.ts +20 -4
  30. package/dist/steps/base-step.d.ts.map +1 -1
  31. package/dist/steps/base-step.js +17 -0
  32. package/dist/steps/base-step.js.map +1 -1
  33. package/dist/steps/builtin/loop.step.d.ts.map +1 -1
  34. package/dist/steps/builtin/loop.step.js +14 -20
  35. package/dist/steps/builtin/loop.step.js.map +1 -1
  36. package/dist/steps/builtin/parallel.step.d.ts +10 -31
  37. package/dist/steps/builtin/parallel.step.d.ts.map +1 -1
  38. package/dist/steps/builtin/parallel.step.js +32 -192
  39. package/dist/steps/builtin/parallel.step.js.map +1 -1
  40. 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
- * (including inside branches), error handling (onError: continue),
16
- * retry, timeout, credential resolution, and state persistence.
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, determines if it's a fresh start
53
- * or a resume, then walks through the steps.
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
- // Resume: hydrate step context from persisted step rows
123
- let startIndex = 0;
124
- let resumeAtIndex;
125
- let branchResumePath;
126
- if (isResume) {
127
- const stepRows = await this.persistence.getStepRows(executionId);
128
- for (const row of stepRows) {
129
- const idx = parseStepIndex(row.stepName);
130
- if (idx === null)
131
- continue;
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
- else {
205
- // ─── Fresh-run path: check for rerunFromStep seed ───
206
- const rerun = context.__meta?.rerunSource;
207
- if (rerun) {
208
- startIndex = rerun.fromStepIndex;
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
- // Run — preserve transient seed fields (priorState, configOverrides, rerunSource)
212
- // that the resume/rerun paths populated above.
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} startIndex=${startIndex}`);
224
- await this.runSteps(executionId, context, config.steps, startIndex, resumeAtIndex, branchResumePath, config.settings);
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
- * The new execution starts at `fromStepIndex` (not 0). Steps before that
246
- * are pre-populated in `context.steps` so variable resolution finds them.
247
- * The step at `fromStepIndex` receives the source row's data as
248
- * `ctx.priorState` (when `inheritStepState` is true, the default), enabling
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(`step_${String(i)}`);
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(`step_${String(fromStepIndex)}`);
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 runSteps(executionId, context, steps, startIndex, resumeAtIndex, branchResumePath, settings) {
326
+ async runWorkflow(executionId, context, steps, walkCtx) {
336
327
  try {
337
- const walkResult = await this.walkSteps(steps, context, executionId, startIndex, resumeAtIndex, branchResumePath, settings);
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
- async walkSteps(steps, context, executionId, startIndex = 0, resumeAtIndex, branchResumePath, settings) {
364
- const onError = settings?.onError ?? 'stop';
365
- const retry = settings?.retry;
366
- for (let i = startIndex; i < steps.length; i++) {
367
- const step = steps[i];
368
- const stepName = `step_${String(i)}`;
369
- const isResuming = resumeAtIndex === i;
370
- // Only pass branchResumePath for the step being resumed
371
- const stepBranchResumePath = isResuming ? branchResumePath : undefined;
372
- if (!isResuming) {
373
- const executorType = CONTROL_FLOW_STEP_TYPES.has(step.type)
374
- ? 'conditional'
375
- : 'deterministic';
376
- await this.persistence.upsertStep(executionId, stepName, {
377
- status: 'RUNNING',
378
- executorType,
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
- await this.executeStepWithRetry(step, context, { executionId, stepName, isResuming, priorState: entryPriorState, configOverrides: entryConfigOverrides }, stepBranchResumePath, retry);
401
- const ctxEntry = context.steps[step.id];
402
- await this.persistence.upsertStep(executionId, stepName, {
403
- status: 'COMPLETED',
404
- executorType: CONTROL_FLOW_STEP_TYPES.has(step.type)
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 ?? { type: step.type, output: {} };
463
- const merged = pe.statePatch
464
- ? { ...base, ...pe.statePatch }
465
- : base;
466
- await this.persistence.upsertStep(executionId, stepName, {
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
- pauseState: merged['pauseState'] ?? { reason: pe.message },
494
+ nodePath: key,
495
+ pauseState: merged['pauseState'] ??
496
+ { reason: pe.message },
484
497
  },
485
498
  });
486
- return {
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, stepName, {
510
+ await this.persistence.upsertStep(executionId, key, {
504
511
  status: 'FAILED',
505
- executorType: CONTROL_FLOW_STEP_TYPES.has(step.type)
506
- ? 'conditional'
507
- : 'deterministic',
512
+ executorType,
508
513
  data: JSON.stringify({ ...(ctxEntry ?? {}), error: errMsg }),
509
514
  });
510
- this.emitEvent(executionId, {
511
- event: 'step_failed',
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, stepName, {
520
+ await this.persistence.upsertStep(executionId, key, {
518
521
  status: 'FAILED',
519
- executorType: CONTROL_FLOW_STEP_TYPES.has(step.type)
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: 'step_failed',
526
- data: { stepName: step.id, error: errMsg },
536
+ event: 'execution_paused',
537
+ data: { stepName: step.id, nodePath: key, pauseState: reviewPauseState },
527
538
  });
528
- throw err;
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
- async executeStepWithRetry(step, context, callCtx, branchResumePath, retry) {
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, branchResumePath);
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, branchResumePath);
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
- async executeStep(step, context, callCtx, branchResumePath) {
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, ctx, branchKey, perBranchResumePath) => this.walkBranchSteps(branchSteps, ctx, callCtx.executionId, step.id, branchKey, perBranchResumePath ?? branchResumePath),
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} elapsed=${String(Date.now() - t0)}ms`);
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 stepCtx = await this.buildStepContext(context, { ...callCtx, stepId: step.id }, stepImpl);
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 (callCtx.isResuming) {
600
- output = await this.invokeResume(actionImpl, callCtx.executionId, callCtx.stepName, resolvedInput, stepCtx);
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} elapsed=${String(Date.now() - t0)}ms`);
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} elapsed=${String(Date.now() - t0)}ms err=${err instanceof Error ? err.message : String(err)}`);
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
- let credentials = {};
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: context.workflow.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
- function parseStepIndex(name) {
824
- const m = /^step_(\d+)$/.exec(name);
825
- if (!m?.[1])
826
- return null;
827
- const n = Number.parseInt(m[1], 10);
828
- return Number.isInteger(n) ? n : null;
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