@xyne/workflow-sdk 3.2.5 → 3.2.6

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.
@@ -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,203 @@ 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
+ pauseState: merged['pauseState'] ??
495
+ { reason: pe.message },
484
496
  },
485
497
  });
486
- return {
487
- kind: 'paused',
488
- pausePath: fullPath,
489
- externalRef: pe.externalRef,
490
- };
498
+ throw pe;
491
499
  }
492
500
  const errMsg = err instanceof Error ? err.message : String(err);
493
501
  const ctxEntry = context.steps[step.id];
494
502
  if (onError === 'continue') {
495
503
  context.steps[step.id] = {
496
504
  type: step.type,
497
- ...(ctxEntry?.input !== undefined
498
- ? { input: ctxEntry.input }
499
- : {}),
505
+ ...(ctxEntry?.input !== undefined ? { input: ctxEntry.input } : {}),
500
506
  output: ctxEntry?.output ?? {},
501
507
  error: errMsg,
502
508
  };
503
- await this.persistence.upsertStep(executionId, stepName, {
509
+ await this.persistence.upsertStep(executionId, key, {
504
510
  status: 'FAILED',
505
- executorType: CONTROL_FLOW_STEP_TYPES.has(step.type)
506
- ? 'conditional'
507
- : 'deterministic',
511
+ executorType,
508
512
  data: JSON.stringify({ ...(ctxEntry ?? {}), error: errMsg }),
509
513
  });
510
- this.emitEvent(executionId, {
511
- event: 'step_failed',
512
- data: { stepName: step.id, error: errMsg },
513
- });
514
+ memo.set(key, { status: 'FAILED', data: { ...(ctxEntry ?? {}), error: errMsg } });
515
+ this.emitEvent(executionId, { event: 'step_failed', data: { stepName: step.id, error: errMsg } });
514
516
  this.log.warn(`step ${step.id} failed but continuing: ${errMsg}`);
515
517
  continue;
516
518
  }
517
- await this.persistence.upsertStep(executionId, stepName, {
519
+ await this.persistence.upsertStep(executionId, key, {
518
520
  status: 'FAILED',
519
- executorType: CONTROL_FLOW_STEP_TYPES.has(step.type)
520
- ? 'conditional'
521
- : 'deterministic',
521
+ executorType,
522
522
  data: JSON.stringify({ ...(ctxEntry ?? {}), error: errMsg }),
523
523
  });
524
+ this.emitEvent(executionId, { event: 'step_failed', data: { stepName: step.id, error: errMsg } });
525
+ throw err;
526
+ }
527
+ const ctxEntry = context.steps[step.id];
528
+ // Review gate: pause after a fresh success when onComplete === 'review'.
529
+ // (A resumed leaf already paused once; it does not re-gate.)
530
+ if (!resuming && step.onComplete === 'review') {
531
+ const reviewPauseState = buildReviewPauseState(step.id, ctxEntry);
532
+ const data = { ...(ctxEntry ?? { type: step.type, output: {} }), pauseState: reviewPauseState };
533
+ await this.persistStep(executionId, key, 'REVIEW_WAIT', executorType, data, memo);
524
534
  this.emitEvent(executionId, {
525
- event: 'step_failed',
526
- data: { stepName: step.id, error: errMsg },
535
+ event: 'execution_paused',
536
+ data: { stepName: step.id, pauseState: reviewPauseState },
527
537
  });
528
- throw err;
538
+ throw reviewPause(step.id);
529
539
  }
540
+ await this.persistStep(executionId, key, 'COMPLETED', executorType, ctxEntry, memo);
541
+ this.emitEvent(executionId, {
542
+ event: 'step_completed',
543
+ data: { stepName: step.id, output: (ctxEntry?.output ?? {}) },
544
+ });
530
545
  }
531
- return { kind: 'completed' };
532
546
  }
533
- async executeStepWithRetry(step, context, callCtx, branchResumePath, retry) {
547
+ /** Persist a step record + mirror it into the in-memory memo for this run. */
548
+ async persistStep(executionId, key, status, executorType, entry, memo) {
549
+ const data = entry ? { ...entry } : { output: null };
550
+ await this.persistence.upsertStep(executionId, key, {
551
+ status,
552
+ executorType,
553
+ data: JSON.stringify(data),
554
+ });
555
+ memo.set(key, { status, data });
556
+ }
557
+ async executeStepWithRetry(step, context, callCtx, prefix, walkCtx, retry) {
534
558
  if (!retry || callCtx.isResuming) {
535
- await this.executeStep(step, context, callCtx, branchResumePath);
559
+ await this.executeStep(step, context, callCtx, prefix, walkCtx);
536
560
  return;
537
561
  }
538
562
  let lastError;
539
563
  for (let attempt = 1; attempt <= retry.maxAttempts; attempt++) {
540
564
  try {
541
- await this.executeStep(step, context, callCtx, branchResumePath);
565
+ await this.executeStep(step, context, callCtx, prefix, walkCtx);
542
566
  return;
543
567
  }
544
568
  catch (err) {
@@ -553,7 +577,13 @@ export class WorkflowExecutor {
553
577
  }
554
578
  throw lastError;
555
579
  }
556
- async executeStep(step, context, callCtx, branchResumePath) {
580
+ /**
581
+ * Execute one step. Control steps recurse into their branches via the
582
+ * `walkBranch` closure (which addresses each branch with a node-path frame).
583
+ * Action steps resolve config then either `execute()` fresh or `onResume()`
584
+ * when `resumeData` (the parked record) is supplied.
585
+ */
586
+ async executeStep(step, context, callCtx, prefix, walkCtx, resumeData) {
557
587
  const stepImpl = this.stepRegistry.get(step.type);
558
588
  if (stepImpl.kind === StepKind.CONTROL) {
559
589
  const controlImpl = stepImpl;
@@ -562,17 +592,16 @@ export class WorkflowExecutor {
562
592
  throw new Error(`Step "${step.id}" (${step.type}) config validation failed:\n${formatZodErrors(parsed.error)}`);
563
593
  }
564
594
  this.log.info(`step ${callCtx.isResuming ? 'RESUME' : 'START'} id=${step.id} type=${step.type}`);
565
- const t0 = Date.now();
566
595
  const stepCtx = await this.buildStepContext(context, { ...callCtx, stepId: step.id }, stepImpl);
567
596
  const output = await controlImpl.execute(parsed.data, {
568
597
  ...stepCtx,
569
- walkBranch: (branchSteps, ctx, branchKey, perBranchResumePath) => this.walkBranchSteps(branchSteps, ctx, callCtx.executionId, step.id, branchKey, perBranchResumePath ?? branchResumePath),
598
+ walkBranch: (branchSteps, branchScope, branchKey, iter) => this.walkScope(branchSteps, branchScope, framePrefix(prefix, step.id, branchKey ?? 'default', iter ?? 0), walkCtx),
570
599
  });
571
600
  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`);
601
+ this.log.info(`step OK id=${step.id} type=${step.type}`);
573
602
  return;
574
603
  }
575
- // Action step: merge configOverrides → resolve variables → coerce → validate → execute
604
+ // Action step: merge configOverrides → resolve variables → coerce → validate → execute/resume
576
605
  const baseConfig = callCtx.configOverrides
577
606
  ? { ...step.config, ...callCtx.configOverrides }
578
607
  : step.config;
@@ -591,13 +620,22 @@ export class WorkflowExecutor {
591
620
  output: {},
592
621
  };
593
622
  const actionImpl = stepImpl;
594
- const stepCtx = await this.buildStepContext(context, { ...callCtx, stepId: step.id }, stepImpl);
623
+ const resumePayload = resumeData?.['resumePayload'];
624
+ const stepCtx = await this.buildStepContext(context, { ...callCtx, stepId: step.id }, stepImpl, resumePayload);
595
625
  this.log.info(`step ${callCtx.isResuming ? 'RESUME' : 'START'} id=${step.id} type=${step.type}`);
596
- const t0 = Date.now();
597
626
  try {
598
627
  let output;
599
- if (callCtx.isResuming) {
600
- output = await this.invokeResume(actionImpl, callCtx.executionId, callCtx.stepName, resolvedInput, stepCtx);
628
+ if (resumeData !== undefined) {
629
+ if (typeof actionImpl.onResume === 'function') {
630
+ output = await actionImpl.onResume(resumeData, resolvedInput, stepCtx);
631
+ }
632
+ else {
633
+ const fallback = resumeData['output'];
634
+ output =
635
+ fallback && typeof fallback === 'object' && !Array.isArray(fallback)
636
+ ? fallback
637
+ : {};
638
+ }
601
639
  }
602
640
  else {
603
641
  output = await actionImpl.execute(resolvedInput, stepCtx);
@@ -607,151 +645,18 @@ export class WorkflowExecutor {
607
645
  input: persistedInput,
608
646
  output,
609
647
  };
610
- this.log.info(`step OK id=${step.id} type=${step.type} elapsed=${String(Date.now() - t0)}ms`);
648
+ this.log.info(`step OK id=${step.id} type=${step.type}`);
611
649
  }
612
650
  catch (err) {
613
651
  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)}`);
652
+ this.log.error(`step FAIL id=${step.id} type=${step.type} err=${err instanceof Error ? err.message : String(err)}`);
615
653
  }
616
654
  throw err;
617
655
  }
618
656
  }
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) {
657
+ async buildStepContext(context, callCtx, stepImpl, resumePayload) {
753
658
  // Resolve credentials if step declares credentialSlots
754
- let credentials = {};
659
+ const credentials = {};
755
660
  if (stepImpl.credentialSlots && stepImpl.credentialSlots.length > 0) {
756
661
  for (const slot of stepImpl.credentialSlots) {
757
662
  const resolved = await this.persistence.resolveCredential(context.workflow.metadata, slot.name);
@@ -763,6 +668,12 @@ export class WorkflowExecutor {
763
668
  }
764
669
  }
765
670
  }
671
+ // The resume payload rides on `runtime.metadata` (a shallow copy so the
672
+ // persisted workflow metadata isn't polluted) — the agent step reads it
673
+ // there, and onResume also receives it merged into `rowData`.
674
+ const metadata = resumePayload
675
+ ? { ...context.workflow.metadata, resumePayload }
676
+ : context.workflow.metadata;
766
677
  return {
767
678
  workflow: context,
768
679
  runtime: {
@@ -770,7 +681,7 @@ export class WorkflowExecutor {
770
681
  stepId: callCtx.stepId,
771
682
  stepName: callCtx.stepName,
772
683
  isResuming: callCtx.isResuming,
773
- metadata: context.workflow.metadata,
684
+ metadata,
774
685
  baseUrl: this.baseUrl,
775
686
  },
776
687
  ...(callCtx.priorState ? { priorState: callCtx.priorState } : {}),
@@ -820,12 +731,44 @@ function safeParseJson(raw) {
820
731
  return null;
821
732
  }
822
733
  }
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;
734
+ /** Reconstruct a context steps entry from a persisted record's parsed data. */
735
+ function entryFromData(data, fallbackType) {
736
+ return {
737
+ type: data['type'] ?? fallbackType,
738
+ ...(data['input'] !== undefined ? { input: data['input'] } : {}),
739
+ output: data['output'] ?? {},
740
+ };
741
+ }
742
+ /** A PauseStep tagged as originating from a review gate (drives `pauseType`). */
743
+ function reviewPause(stepId) {
744
+ const pe = new PauseStep(`review gate at ${stepId}`);
745
+ pe.pauseCategory = 'review';
746
+ return pe;
747
+ }
748
+ /** The review-gate PauseState shown to the approver (approve / retry / abort). */
749
+ function buildReviewPauseState(stepId, ctxEntry) {
750
+ return {
751
+ reason: `Review output of "${stepId}"`,
752
+ category: 'review',
753
+ display: ctxEntry?.output
754
+ ? [{ label: 'Output', value: ctxEntry.output }]
755
+ : undefined,
756
+ actions: [
757
+ { key: 'approve', label: 'Approve', style: 'primary' },
758
+ { key: 'retry', label: 'Retry with Feedback', style: 'default', submitsForm: true },
759
+ { key: 'abort', label: 'Reject', style: 'danger', abort: true },
760
+ ],
761
+ form: {
762
+ fields: [
763
+ {
764
+ type: 'textarea',
765
+ name: 'feedback',
766
+ label: 'Feedback',
767
+ placeholder: 'Optional instructions for retry...',
768
+ },
769
+ ],
770
+ },
771
+ };
829
772
  }
830
773
  function formatZodErrors(error) {
831
774
  return error.issues