@hexabot-ai/agentic 3.1.2-alpha.9 → 3.1.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +13 -5
  2. package/dist/cjs/action/abstract-action.js +8 -3
  3. package/dist/cjs/dsl.types.js +2 -1
  4. package/dist/cjs/errors.js +41 -0
  5. package/dist/cjs/index.js +7 -1
  6. package/dist/cjs/step-executors/parallel-executor.js +238 -24
  7. package/dist/cjs/step-executors/skip-helpers.js +25 -1
  8. package/dist/cjs/step-executors/task-executor.js +77 -10
  9. package/dist/cjs/suspension-rebuilder.js +1 -24
  10. package/dist/cjs/utils/naming.js +4 -4
  11. package/dist/cjs/utils/timeout.js +63 -8
  12. package/dist/cjs/utils/workflow-definition-resources.js +73 -0
  13. package/dist/cjs/workflow-compiler.js +0 -2
  14. package/dist/cjs/workflow-runner.js +40 -17
  15. package/dist/esm/action/abstract-action.js +8 -3
  16. package/dist/esm/dsl.types.js +2 -1
  17. package/dist/esm/errors.js +33 -0
  18. package/dist/esm/index.js +2 -0
  19. package/dist/esm/step-executors/parallel-executor.js +239 -25
  20. package/dist/esm/step-executors/skip-helpers.js +23 -0
  21. package/dist/esm/step-executors/task-executor.js +77 -10
  22. package/dist/esm/suspension-rebuilder.js +1 -24
  23. package/dist/esm/utils/naming.js +1 -1
  24. package/dist/esm/utils/timeout.js +63 -8
  25. package/dist/esm/utils/workflow-definition-resources.js +68 -0
  26. package/dist/esm/workflow-compiler.js +0 -2
  27. package/dist/esm/workflow-runner.js +40 -17
  28. package/dist/types/action/abstract-action.d.ts +1 -1
  29. package/dist/types/action/abstract-action.d.ts.map +1 -1
  30. package/dist/types/action/action.types.d.ts +2 -1
  31. package/dist/types/action/action.types.d.ts.map +1 -1
  32. package/dist/types/context.d.ts +2 -1
  33. package/dist/types/context.d.ts.map +1 -1
  34. package/dist/types/dsl.types.d.ts.map +1 -1
  35. package/dist/types/errors.d.ts +10 -0
  36. package/dist/types/errors.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +2 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/step-executors/parallel-executor.d.ts +3 -4
  40. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -1
  41. package/dist/types/step-executors/skip-helpers.d.ts +1 -0
  42. package/dist/types/step-executors/skip-helpers.d.ts.map +1 -1
  43. package/dist/types/step-executors/task-executor.d.ts.map +1 -1
  44. package/dist/types/step-executors/types.d.ts +9 -1
  45. package/dist/types/step-executors/types.d.ts.map +1 -1
  46. package/dist/types/suspension-rebuilder.d.ts.map +1 -1
  47. package/dist/types/utils/naming.d.ts +1 -0
  48. package/dist/types/utils/naming.d.ts.map +1 -1
  49. package/dist/types/utils/timeout.d.ts +4 -2
  50. package/dist/types/utils/timeout.d.ts.map +1 -1
  51. package/dist/types/utils/workflow-definition-resources.d.ts +16 -0
  52. package/dist/types/utils/workflow-definition-resources.d.ts.map +1 -0
  53. package/dist/types/workflow-compiler.d.ts.map +1 -1
  54. package/dist/types/workflow-event-emitter.d.ts +12 -13
  55. package/dist/types/workflow-event-emitter.d.ts.map +1 -1
  56. package/dist/types/workflow-runner.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/__tests__/suspension-rebuilder.test.ts +3 -42
  59. package/src/__tests__/workflow-definition-resources.test.ts +97 -0
  60. package/src/__tests__/workflow-runner.test.ts +58 -5
  61. package/src/action/__tests__/action.test.ts +2 -1
  62. package/src/action/abstract-action.ts +9 -1
  63. package/src/action/action.types.ts +2 -0
  64. package/src/context.ts +2 -0
  65. package/src/dsl.types.ts +5 -1
  66. package/src/errors.ts +43 -0
  67. package/src/index.ts +10 -0
  68. package/src/step-executors/conditional-executor.test.ts +8 -3
  69. package/src/step-executors/loop-executor.test.ts +8 -3
  70. package/src/step-executors/parallel-executor.test.ts +191 -82
  71. package/src/step-executors/parallel-executor.ts +402 -34
  72. package/src/step-executors/skip-helpers.ts +41 -0
  73. package/src/step-executors/task-executor.test.ts +85 -21
  74. package/src/step-executors/task-executor.ts +94 -10
  75. package/src/step-executors/types.ts +14 -1
  76. package/src/suspension-rebuilder.ts +1 -53
  77. package/src/utils/naming.ts +1 -1
  78. package/src/utils/timeout.ts +78 -8
  79. package/src/utils/workflow-definition-resources.ts +111 -0
  80. package/src/workflow-compiler.ts +0 -3
  81. package/src/workflow-event-emitter.ts +13 -6
  82. package/src/workflow-runner.ts +59 -19
@@ -4,67 +4,435 @@
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
6
 
7
+ import type { BaseWorkflowContext, WorkflowRuntimeControl } from '../context';
8
+ import {
9
+ getAbortReason,
10
+ ParallelSuspensionError,
11
+ throwIfAborted,
12
+ WorkflowCancellationError,
13
+ } from '../errors';
7
14
  import type {
15
+ CompiledStep,
8
16
  ExecutionState,
9
17
  ParallelStep,
10
18
  Suspension,
11
19
  } from '../workflow-types';
12
20
 
13
- import { markStepsSkipped } from './skip-helpers';
14
- import { wrapSuspensionContinuation } from './suspension-continuation';
21
+ import { markStepsCancelled } from './skip-helpers';
15
22
  import type { StepExecutorEnv } from './types';
16
23
 
24
+ type BranchResult =
25
+ | { status: 'fulfilled'; index: number; outputDelta: Record<string, unknown> }
26
+ | { status: 'failed'; index: number; error: unknown }
27
+ | { status: 'cancelled'; index: number; error: Error };
28
+
29
+ type BranchHandle = {
30
+ index: number;
31
+ step: CompiledStep;
32
+ iterationStack: number[];
33
+ controller: AbortController;
34
+ promise: Promise<BranchResult>;
35
+ };
36
+
37
+ type TrackedOutput = {
38
+ output: Record<string, unknown>;
39
+ getDelta: () => Record<string, unknown>;
40
+ };
41
+
17
42
  /**
18
- * Execute a set of steps in parallel order, respecting the configured strategy.
43
+ * Execute a set of child steps concurrently and resolve according to the configured strategy.
19
44
  *
20
45
  * @param env Executor environment providing helpers and workflow context.
21
46
  * @param step The parallel step definition including strategy and children.
22
47
  * @param state Mutable workflow execution state.
23
48
  * @param path Path tokens locating this parallel block in the workflow.
24
- * @param startIndex Index to resume execution from when continuing.
25
- * @returns A suspension if a child pauses execution, otherwise void.
49
+ * @returns Always undefined; suspensions are rejected inside parallel blocks.
26
50
  */
27
51
  export async function executeParallel(
28
52
  env: StepExecutorEnv,
29
53
  step: ParallelStep,
30
54
  state: ExecutionState,
31
55
  path: Array<number | string>,
32
- startIndex = 0,
33
56
  ): Promise<Suspension | void> {
34
- for (let index = startIndex; index < step.steps.length; index += 1) {
35
- const child = step.steps[index];
36
- const childPath = [...path, 'parallel', index];
37
- const suspension = await env.executeStep(child, state, childPath);
57
+ if (step.steps.length === 0) {
58
+ return undefined;
59
+ }
60
+
61
+ throwIfAborted(env.signal);
62
+
63
+ const baseOutput = cloneRecord(state.output);
64
+ const baseContextState = cloneRecord(env.context.state);
65
+ const branches = step.steps.map((child, index) =>
66
+ launchBranch({
67
+ env,
68
+ child,
69
+ index,
70
+ path,
71
+ parentState: state,
72
+ baseOutput,
73
+ baseContextState,
74
+ }),
75
+ );
76
+
77
+ if (step.strategy === 'wait_any') {
78
+ return executeWaitAny(env, branches, state);
79
+ }
80
+
81
+ return executeWaitAll(env, branches, state);
82
+ }
83
+
84
+ async function executeWaitAll(
85
+ env: StepExecutorEnv,
86
+ branches: BranchHandle[],
87
+ state: ExecutionState,
88
+ ): Promise<void> {
89
+ const pending = new Set(branches);
90
+ const outcomes = new Map<number, BranchResult>();
91
+
92
+ while (pending.size > 0) {
93
+ const { branch, result } = await raceNext(pending);
94
+ pending.delete(branch);
95
+ outcomes.set(result.index, result);
96
+
97
+ if (result.status === 'failed' || result.status === 'cancelled') {
98
+ cancelBranches(
99
+ env,
100
+ pending,
101
+ result.error,
102
+ 'Parallel branch execution was cancelled.',
103
+ );
104
+ await settleBranches(branches);
105
+ throw result.error;
106
+ }
107
+ }
108
+
109
+ mergeBranchOutputs(state, outcomes);
110
+ }
111
+
112
+ async function executeWaitAny(
113
+ env: StepExecutorEnv,
114
+ branches: BranchHandle[],
115
+ state: ExecutionState,
116
+ ): Promise<void> {
117
+ const pending = new Set(branches);
118
+ const outcomes = new Map<number, BranchResult>();
119
+
120
+ while (pending.size > 0) {
121
+ const { branch, result } = await raceNext(pending);
122
+ pending.delete(branch);
123
+ outcomes.set(result.index, result);
124
+
125
+ if (result.status === 'fulfilled') {
126
+ cancelBranches(
127
+ env,
128
+ pending,
129
+ new WorkflowCancellationError('Parallel wait_any winner selected.'),
130
+ 'Parallel wait_any branch lost the race.',
131
+ );
132
+ await settleBranches(branches);
133
+ mergeBranchOutputs(state, new Map([[result.index, result]]));
134
+
135
+ return;
136
+ }
137
+
138
+ cancelBranches(
139
+ env,
140
+ pending,
141
+ result.error,
142
+ 'Parallel branch execution was cancelled.',
143
+ );
144
+ await settleBranches(branches);
145
+ throw result.error;
146
+ }
147
+ }
148
+
149
+ function launchBranch({
150
+ env,
151
+ child,
152
+ index,
153
+ path,
154
+ parentState,
155
+ baseOutput,
156
+ baseContextState,
157
+ }: {
158
+ env: StepExecutorEnv;
159
+ child: CompiledStep;
160
+ index: number;
161
+ path: Array<number | string>;
162
+ parentState: ExecutionState;
163
+ baseOutput: Record<string, unknown>;
164
+ baseContextState: Record<string, unknown>;
165
+ }): BranchHandle {
166
+ const controller = createBranchController(env.signal);
167
+ const trackedOutput = createTrackedOutput(baseOutput);
168
+ const branchState: ExecutionState = {
169
+ input: parentState.input,
170
+ output: trackedOutput.output,
171
+ iteration: parentState.iteration ? { ...parentState.iteration } : undefined,
172
+ accumulator: cloneValue(parentState.accumulator),
173
+ iterationStack: [...parentState.iterationStack],
174
+ };
175
+ const branchContext = createParallelBranchContext(
176
+ env.context,
177
+ cloneRecord(baseContextState),
178
+ );
179
+ const branchEnv = env.fork({
180
+ context: branchContext,
181
+ signal: controller.signal,
182
+ setCurrentStep: () => undefined,
183
+ });
184
+ const childPath = [...path, 'parallel', index];
185
+ const promise = runBranch(
186
+ branchEnv,
187
+ child,
188
+ branchState,
189
+ childPath,
190
+ trackedOutput,
191
+ index,
192
+ );
193
+
194
+ return {
195
+ index,
196
+ step: child,
197
+ iterationStack: [...parentState.iterationStack],
198
+ controller,
199
+ promise,
200
+ };
201
+ }
202
+
203
+ async function runBranch(
204
+ env: StepExecutorEnv,
205
+ child: CompiledStep,
206
+ branchState: ExecutionState,
207
+ childPath: Array<number | string>,
208
+ trackedOutput: TrackedOutput,
209
+ index: number,
210
+ ): Promise<BranchResult> {
211
+ try {
212
+ throwIfAborted(env.signal);
213
+
214
+ const suspension = await env.executeStep(child, branchState, childPath);
38
215
  if (suspension) {
39
- return wrapSuspensionContinuation(suspension, async () => {
40
- if (step.strategy === 'wait_any') {
41
- if (index + 1 < step.steps.length) {
42
- markStepsSkipped(
43
- env,
44
- step.steps.slice(index + 1),
45
- state.iterationStack,
46
- );
47
- }
48
-
49
- return undefined;
50
- }
51
-
52
- return executeParallel(env, step, state, path, index + 1);
53
- });
216
+ throw new ParallelSuspensionError();
217
+ }
218
+
219
+ throwIfAborted(env.signal);
220
+
221
+ return {
222
+ status: 'fulfilled',
223
+ index,
224
+ outputDelta: trackedOutput.getDelta(),
225
+ };
226
+ } catch (error) {
227
+ if (env.signal.aborted) {
228
+ return { status: 'cancelled', index, error: getAbortReason(env.signal) };
54
229
  }
55
230
 
56
- if (step.strategy === 'wait_any') {
57
- if (index + 1 < step.steps.length) {
58
- markStepsSkipped(
59
- env,
60
- step.steps.slice(index + 1),
61
- state.iterationStack,
62
- );
231
+ return { status: 'failed', index, error };
232
+ }
233
+ }
234
+
235
+ async function raceNext(
236
+ pending: Set<BranchHandle>,
237
+ ): Promise<{ branch: BranchHandle; result: BranchResult }> {
238
+ return Promise.race(
239
+ [...pending].map((branch) =>
240
+ branch.promise.then((result) => ({ branch, result })),
241
+ ),
242
+ );
243
+ }
244
+
245
+ async function settleBranches(branches: BranchHandle[]): Promise<void> {
246
+ await Promise.all(branches.map((branch) => branch.promise.catch(() => null)));
247
+ }
248
+
249
+ function cancelBranches(
250
+ env: StepExecutorEnv,
251
+ branches: Iterable<BranchHandle>,
252
+ reason: unknown,
253
+ snapshotReason: string,
254
+ ): void {
255
+ for (const branch of branches) {
256
+ if (!branch.controller.signal.aborted) {
257
+ branch.controller.abort(
258
+ reason instanceof Error
259
+ ? reason
260
+ : new WorkflowCancellationError(String(reason)),
261
+ );
262
+ }
263
+
264
+ markStepsCancelled(
265
+ env,
266
+ [branch.step],
267
+ branch.iterationStack,
268
+ reason instanceof Error ? reason.message : snapshotReason,
269
+ );
270
+ }
271
+ }
272
+
273
+ function mergeBranchOutputs(
274
+ state: ExecutionState,
275
+ outcomes: Map<number, BranchResult>,
276
+ ): void {
277
+ const ordered = [...outcomes.values()].sort((left, right) => {
278
+ return left.index - right.index;
279
+ });
280
+
281
+ for (const outcome of ordered) {
282
+ if (outcome.status !== 'fulfilled') {
283
+ continue;
284
+ }
285
+
286
+ Object.assign(state.output, outcome.outputDelta);
287
+ }
288
+ }
289
+
290
+ function createBranchController(parentSignal: AbortSignal): AbortController {
291
+ const controller = new AbortController();
292
+
293
+ if (parentSignal.aborted) {
294
+ controller.abort(getAbortReason(parentSignal));
295
+
296
+ return controller;
297
+ }
298
+
299
+ parentSignal.addEventListener(
300
+ 'abort',
301
+ () => controller.abort(getAbortReason(parentSignal)),
302
+ { once: true },
303
+ );
304
+
305
+ return controller;
306
+ }
307
+
308
+ function createTrackedOutput(
309
+ baseOutput: Record<string, unknown>,
310
+ ): TrackedOutput {
311
+ const writtenKeys = new Set<string>();
312
+ const target = cloneRecord(baseOutput);
313
+ const output = new Proxy(target, {
314
+ set(record, property, value) {
315
+ if (typeof property === 'string') {
316
+ writtenKeys.add(property);
63
317
  }
64
318
 
65
- return undefined;
319
+ return Reflect.set(record, property, value);
320
+ },
321
+ deleteProperty(record, property) {
322
+ if (typeof property === 'string') {
323
+ writtenKeys.add(property);
324
+ }
325
+
326
+ return Reflect.deleteProperty(record, property);
327
+ },
328
+ });
329
+
330
+ return {
331
+ output,
332
+ getDelta: () =>
333
+ Object.fromEntries(
334
+ [...writtenKeys]
335
+ .filter((key) => Object.prototype.hasOwnProperty.call(target, key))
336
+ .map((key) => [key, target[key]]),
337
+ ),
338
+ };
339
+ }
340
+
341
+ class ParallelWorkflowRuntimeControl implements WorkflowRuntimeControl {
342
+ constructor(private readonly getParent: () => WorkflowRuntimeControl) {}
343
+
344
+ get status() {
345
+ return this.getParent().status;
346
+ }
347
+
348
+ get resumeData() {
349
+ return this.getParent().resumeData;
350
+ }
351
+
352
+ suspend<T = unknown>(): Promise<T> {
353
+ return Promise.reject(new ParallelSuspensionError());
354
+ }
355
+
356
+ resume(data?: unknown): void {
357
+ this.getParent().resume(data);
358
+ }
359
+
360
+ getSnapshot() {
361
+ return this.getParent().getSnapshot();
362
+ }
363
+ }
364
+
365
+ function createParallelBranchContext(
366
+ context: BaseWorkflowContext,
367
+ branchState: Record<string, unknown>,
368
+ ): BaseWorkflowContext {
369
+ let state = branchState;
370
+ const workflow = new ParallelWorkflowRuntimeControl(() => context.workflow);
371
+
372
+ return new Proxy(context, {
373
+ get(target, property, receiver) {
374
+ if (property === 'state') {
375
+ return state;
376
+ }
377
+
378
+ if (property === 'workflow') {
379
+ return workflow;
380
+ }
381
+
382
+ if (property === 'snapshot') {
383
+ return () => cloneRecord(state);
384
+ }
385
+
386
+ if (property === 'attachWorkflowRuntime') {
387
+ return () => undefined;
388
+ }
389
+
390
+ return Reflect.get(target, property, receiver);
391
+ },
392
+ set(target, property, value, receiver) {
393
+ if (property === 'state') {
394
+ state = isRecord(value) ? value : {};
395
+
396
+ return true;
397
+ }
398
+
399
+ return Reflect.set(target, property, value, receiver);
400
+ },
401
+ });
402
+ }
403
+
404
+ function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
405
+ return cloneValue(value) as Record<string, unknown>;
406
+ }
407
+
408
+ function cloneValue<T>(value: T): T {
409
+ if (value === undefined || value === null) {
410
+ return value;
411
+ }
412
+
413
+ if (typeof structuredClone === 'function') {
414
+ try {
415
+ return structuredClone(value);
416
+ } catch {
417
+ // Fall through to JSON clone below.
66
418
  }
67
419
  }
68
420
 
69
- return undefined;
421
+ try {
422
+ return JSON.parse(JSON.stringify(value)) as T;
423
+ } catch {
424
+ if (Array.isArray(value)) {
425
+ return [...value] as T;
426
+ }
427
+
428
+ if (isRecord(value)) {
429
+ return { ...value } as T;
430
+ }
431
+
432
+ return value;
433
+ }
434
+ }
435
+
436
+ function isRecord(value: unknown): value is Record<string, unknown> {
437
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
70
438
  }
@@ -57,6 +57,38 @@ const markStepSkipped = (
57
57
  break;
58
58
  }
59
59
  };
60
+ const markStepCancelled = (
61
+ env: SkipStepEnv,
62
+ step: CompiledStep,
63
+ iterationStack: number[],
64
+ reason?: string,
65
+ ) => {
66
+ const stepInfo = env.buildInstanceStepInfo(step, iterationStack);
67
+ env.markSnapshot(stepInfo, 'cancelled', reason);
68
+ env.emit('hook:step:cancelled', {
69
+ runId: env.runId,
70
+ step: stepInfo,
71
+ error: new Error(reason ?? 'Step execution was cancelled.'),
72
+ });
73
+
74
+ switch (step.type) {
75
+ case StepType.Parallel:
76
+ step.steps.forEach((child) =>
77
+ markStepCancelled(env, child, iterationStack, reason),
78
+ );
79
+ break;
80
+ case StepType.Conditional:
81
+ step.branches.forEach((branch) =>
82
+ branch.steps.forEach((child) =>
83
+ markStepCancelled(env, child, iterationStack, reason),
84
+ ),
85
+ );
86
+ break;
87
+ case StepType.Loop:
88
+ case StepType.Task:
89
+ break;
90
+ }
91
+ };
60
92
 
61
93
  export const markStepsSkipped = (
62
94
  env: SkipStepEnv,
@@ -66,3 +98,12 @@ export const markStepsSkipped = (
66
98
  ) => {
67
99
  steps.forEach((step) => markStepSkipped(env, step, iterationStack, reason));
68
100
  };
101
+
102
+ export const markStepsCancelled = (
103
+ env: SkipStepEnv,
104
+ steps: CompiledStep[],
105
+ iterationStack: number[],
106
+ reason?: string,
107
+ ) => {
108
+ steps.forEach((step) => markStepCancelled(env, step, iterationStack, reason));
109
+ };
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Action } from '../action/action.types';
8
- import { BaseWorkflowContext } from '../context';
8
+ import { BaseWorkflowContext, type StepExecutionRecord } from '../context';
9
9
  import type { RuntimeSuspensionRequest } from '../runner-runtime-control';
10
10
  import { createDeferred } from '../utils/deferred';
11
11
  import {
@@ -72,26 +72,59 @@ const createCompiled = (task: CompiledTask): CompiledWorkflow =>
72
72
  const createEnv = (
73
73
  compiled: CompiledWorkflow,
74
74
  stepInfo: StepInfo,
75
- ): StepExecutorEnv => ({
76
- compiled,
77
- context: new TestContext(),
78
- runId: 'run-123',
79
- buildInstanceStepInfo: jest.fn().mockReturnValue(stepInfo),
80
- markSnapshot: jest.fn(),
81
- recordStepExecution: jest.fn(),
82
- emit: jest.fn(),
83
- setCurrentStep: jest.fn(),
84
- waitForStepSuspension: jest
85
- .fn()
86
- .mockImplementation(
87
- () => new Promise<RuntimeSuspensionRequest>(() => undefined),
88
- ),
89
- clearStepSuspensions: jest.fn(),
90
- primeStepResumeData: jest.fn(),
91
- captureTaskOutput: jest.fn().mockResolvedValue(undefined),
92
- executeFlow: jest.fn(),
93
- executeStep: jest.fn(),
94
- });
75
+ ): StepExecutorEnv => {
76
+ const stepRecords: Record<string, StepExecutionRecord> = {};
77
+ const env = {
78
+ compiled,
79
+ context: new TestContext(),
80
+ signal: new AbortController().signal,
81
+ runId: 'run-123',
82
+ buildInstanceStepInfo: jest.fn().mockReturnValue(stepInfo),
83
+ markSnapshot: jest.fn(),
84
+ recordStepExecution: jest.fn((step, update) => {
85
+ const existing =
86
+ stepRecords[step.id] ??
87
+ ({
88
+ id: step.id,
89
+ name: step.name,
90
+ status: 'pending',
91
+ } satisfies StepExecutionRecord);
92
+ const context =
93
+ existing.context || update.context
94
+ ? { ...existing.context, ...update.context }
95
+ : undefined;
96
+ const nextRecord = {
97
+ ...existing,
98
+ ...update,
99
+ id: step.id,
100
+ name: step.name,
101
+ status: update.status ?? existing.status,
102
+ context,
103
+ } satisfies StepExecutionRecord;
104
+
105
+ stepRecords[step.id] = nextRecord;
106
+
107
+ return nextRecord;
108
+ }),
109
+ emit: jest.fn(),
110
+ setCurrentStep: jest.fn(),
111
+ waitForStepSuspension: jest
112
+ .fn()
113
+ .mockImplementation(
114
+ () => new Promise<RuntimeSuspensionRequest>(() => undefined),
115
+ ),
116
+ clearStepSuspensions: jest.fn(),
117
+ primeStepResumeData: jest.fn(),
118
+ captureTaskOutput: jest.fn().mockResolvedValue(undefined),
119
+ executeFlow: jest.fn(),
120
+ executeStep: jest.fn(),
121
+ fork: jest.fn(),
122
+ } as StepExecutorEnv;
123
+
124
+ env.fork = jest.fn((overrides) => ({ ...env, ...overrides }));
125
+
126
+ return env;
127
+ };
95
128
  const step: TaskStep = {
96
129
  id: '0:test_task',
97
130
  type: StepType.Task,
@@ -122,6 +155,7 @@ describe('executeTaskStep', () => {
122
155
  env.context,
123
156
  task.settings,
124
157
  task.bindings,
158
+ env.signal,
125
159
  );
126
160
  expect(env.setCurrentStep).toHaveBeenNthCalledWith(1, stepInfo);
127
161
  expect(env.markSnapshot).toHaveBeenNthCalledWith(1, stepInfo, 'running');
@@ -153,10 +187,18 @@ describe('executeTaskStep', () => {
153
187
  expect(env.emit).toHaveBeenCalledWith('hook:step:start', {
154
188
  runId: 'run-123',
155
189
  step: stepInfo,
190
+ stepExecution: expect.objectContaining({
191
+ id: stepInfo.id,
192
+ status: 'running',
193
+ }),
156
194
  });
157
195
  expect(env.emit).toHaveBeenCalledWith('hook:step:success', {
158
196
  runId: 'run-123',
159
197
  step: stepInfo,
198
+ stepExecution: expect.objectContaining({
199
+ id: stepInfo.id,
200
+ status: 'completed',
201
+ }),
160
202
  });
161
203
  expect(env.setCurrentStep).toHaveBeenLastCalledWith(undefined);
162
204
  });
@@ -215,6 +257,16 @@ describe('executeTaskStep', () => {
215
257
  'suspended',
216
258
  'awaiting_user',
217
259
  );
260
+ expect(env.emit).toHaveBeenCalledWith('hook:step:suspended', {
261
+ runId: 'run-123',
262
+ step: stepInfo,
263
+ stepExecution: expect.objectContaining({
264
+ id: stepInfo.id,
265
+ status: 'suspended',
266
+ }),
267
+ reason: 'awaiting_user',
268
+ data: { channel: 'sms' },
269
+ });
218
270
 
219
271
  await suspension?.continue({ reply: 'Sure' });
220
272
 
@@ -228,6 +280,14 @@ describe('executeTaskStep', () => {
228
280
  output: { reply: 'SURE' },
229
281
  }),
230
282
  );
283
+ expect(env.emit).toHaveBeenCalledWith('hook:step:success', {
284
+ runId: 'run-123',
285
+ step: stepInfo,
286
+ stepExecution: expect.objectContaining({
287
+ id: stepInfo.id,
288
+ status: 'completed',
289
+ }),
290
+ });
231
291
  });
232
292
 
233
293
  it('marks failure and rethrows errors from the task', async () => {
@@ -250,6 +310,10 @@ describe('executeTaskStep', () => {
250
310
  expect(env.emit).toHaveBeenCalledWith('hook:step:error', {
251
311
  runId: 'run-123',
252
312
  step: stepInfo,
313
+ stepExecution: expect.objectContaining({
314
+ id: stepInfo.id,
315
+ status: 'failed',
316
+ }),
253
317
  error: expect.any(Error),
254
318
  });
255
319
  expect(env.setCurrentStep).toHaveBeenLastCalledWith(undefined);