@hexabot-ai/agentic 3.0.1-alpha.0

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 (176) hide show
  1. package/LICENSE.md +120 -0
  2. package/README.md +231 -0
  3. package/dist/cjs/action/abstract-action.js +141 -0
  4. package/dist/cjs/action/action.js +47 -0
  5. package/dist/cjs/action/action.types.js +7 -0
  6. package/dist/cjs/bindings/base-binding.js +278 -0
  7. package/dist/cjs/context.js +79 -0
  8. package/dist/cjs/dsl.types.js +318 -0
  9. package/dist/cjs/index.js +52 -0
  10. package/dist/cjs/runner-runtime-control.js +343 -0
  11. package/dist/cjs/step-executors/conditional-executor.js +63 -0
  12. package/dist/cjs/step-executors/loop-executor.js +187 -0
  13. package/dist/cjs/step-executors/parallel-executor.js +51 -0
  14. package/dist/cjs/step-executors/skip-helpers.js +29 -0
  15. package/dist/cjs/step-executors/task-executor.js +155 -0
  16. package/dist/cjs/step-executors/types.js +7 -0
  17. package/dist/cjs/suspension-rebuilder.js +301 -0
  18. package/dist/cjs/utils/deferred.js +17 -0
  19. package/dist/cjs/utils/naming.js +48 -0
  20. package/dist/cjs/utils/timeout.js +42 -0
  21. package/dist/cjs/utils/workflow-definition.js +102 -0
  22. package/dist/cjs/workflow-compiler.js +212 -0
  23. package/dist/cjs/workflow-event-emitter.js +40 -0
  24. package/dist/cjs/workflow-runner.js +448 -0
  25. package/dist/cjs/workflow-types.js +7 -0
  26. package/dist/cjs/workflow-values.js +99 -0
  27. package/dist/cjs/workflow.js +243 -0
  28. package/dist/esm/action/abstract-action.js +137 -0
  29. package/dist/esm/action/action.js +44 -0
  30. package/dist/esm/action/action.types.js +6 -0
  31. package/dist/esm/bindings/base-binding.js +273 -0
  32. package/dist/esm/context.js +75 -0
  33. package/dist/esm/dsl.types.js +309 -0
  34. package/dist/esm/index.js +16 -0
  35. package/dist/esm/runner-runtime-control.js +338 -0
  36. package/dist/esm/step-executors/conditional-executor.js +60 -0
  37. package/dist/esm/step-executors/loop-executor.js +182 -0
  38. package/dist/esm/step-executors/parallel-executor.js +48 -0
  39. package/dist/esm/step-executors/skip-helpers.js +25 -0
  40. package/dist/esm/step-executors/task-executor.js +152 -0
  41. package/dist/esm/step-executors/types.js +6 -0
  42. package/dist/esm/suspension-rebuilder.js +296 -0
  43. package/dist/esm/utils/deferred.js +14 -0
  44. package/dist/esm/utils/naming.js +42 -0
  45. package/dist/esm/utils/timeout.js +37 -0
  46. package/dist/esm/utils/workflow-definition.js +98 -0
  47. package/dist/esm/workflow-compiler.js +208 -0
  48. package/dist/esm/workflow-event-emitter.js +36 -0
  49. package/dist/esm/workflow-runner.js +444 -0
  50. package/dist/esm/workflow-types.js +6 -0
  51. package/dist/esm/workflow-values.js +89 -0
  52. package/dist/esm/workflow.js +236 -0
  53. package/dist/types/action/abstract-action.d.ts +54 -0
  54. package/dist/types/action/abstract-action.d.ts.map +1 -0
  55. package/dist/types/action/action.d.ts +23 -0
  56. package/dist/types/action/action.d.ts.map +1 -0
  57. package/dist/types/action/action.types.d.ts +55 -0
  58. package/dist/types/action/action.types.d.ts.map +1 -0
  59. package/dist/types/bindings/base-binding.d.ts +52 -0
  60. package/dist/types/bindings/base-binding.d.ts.map +1 -0
  61. package/dist/types/context.d.ts +105 -0
  62. package/dist/types/context.d.ts.map +1 -0
  63. package/dist/types/dsl.types.d.ts +220 -0
  64. package/dist/types/dsl.types.d.ts.map +1 -0
  65. package/dist/types/index.d.ts +18 -0
  66. package/dist/types/index.d.ts.map +1 -0
  67. package/dist/types/runner-runtime-control.d.ts +64 -0
  68. package/dist/types/runner-runtime-control.d.ts.map +1 -0
  69. package/dist/types/step-executors/conditional-executor.d.ts +13 -0
  70. package/dist/types/step-executors/conditional-executor.d.ts.map +1 -0
  71. package/dist/types/step-executors/loop-executor.d.ts +33 -0
  72. package/dist/types/step-executors/loop-executor.d.ts.map +1 -0
  73. package/dist/types/step-executors/parallel-executor.d.ts +14 -0
  74. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -0
  75. package/dist/types/step-executors/skip-helpers.d.ts +12 -0
  76. package/dist/types/step-executors/skip-helpers.d.ts.map +1 -0
  77. package/dist/types/step-executors/task-executor.d.ts +13 -0
  78. package/dist/types/step-executors/task-executor.d.ts.map +1 -0
  79. package/dist/types/step-executors/types.d.ts +24 -0
  80. package/dist/types/step-executors/types.d.ts.map +1 -0
  81. package/dist/types/suspension-rebuilder.d.ts +67 -0
  82. package/dist/types/suspension-rebuilder.d.ts.map +1 -0
  83. package/dist/types/utils/deferred.d.ts +7 -0
  84. package/dist/types/utils/deferred.d.ts.map +1 -0
  85. package/dist/types/utils/naming.d.ts +24 -0
  86. package/dist/types/utils/naming.d.ts.map +1 -0
  87. package/dist/types/utils/timeout.d.ts +17 -0
  88. package/dist/types/utils/timeout.d.ts.map +1 -0
  89. package/dist/types/utils/workflow-definition.d.ts +3 -0
  90. package/dist/types/utils/workflow-definition.d.ts.map +1 -0
  91. package/dist/types/workflow-compiler.d.ts +12 -0
  92. package/dist/types/workflow-compiler.d.ts.map +1 -0
  93. package/dist/types/workflow-event-emitter.d.ts +70 -0
  94. package/dist/types/workflow-event-emitter.d.ts.map +1 -0
  95. package/dist/types/workflow-runner.d.ts +179 -0
  96. package/dist/types/workflow-runner.d.ts.map +1 -0
  97. package/dist/types/workflow-types.d.ts +195 -0
  98. package/dist/types/workflow-types.d.ts.map +1 -0
  99. package/dist/types/workflow-values.d.ts +34 -0
  100. package/dist/types/workflow-values.d.ts.map +1 -0
  101. package/dist/types/workflow.d.ts +75 -0
  102. package/dist/types/workflow.d.ts.map +1 -0
  103. package/examples/defs-bindings-agent/actions/ai-agent.ts +110 -0
  104. package/examples/defs-bindings-agent/actions/caculate-score.ts +49 -0
  105. package/examples/defs-bindings-agent/actions/index.ts +15 -0
  106. package/examples/defs-bindings-agent/bindings.ts +20 -0
  107. package/examples/defs-bindings-agent/context.ts +32 -0
  108. package/examples/defs-bindings-agent/workflow.ts +67 -0
  109. package/examples/defs-bindings-agent/workflow.yml +28 -0
  110. package/examples/full/actions/await-user-input.ts +46 -0
  111. package/examples/full/actions/call-llm.ts +133 -0
  112. package/examples/full/actions/create-ticket.ts +46 -0
  113. package/examples/full/actions/decision-router.ts +83 -0
  114. package/examples/full/actions/get-calendar-events.ts +55 -0
  115. package/examples/full/actions/get-user-profile.ts +72 -0
  116. package/examples/full/actions/index.ts +33 -0
  117. package/examples/full/actions/query-memory.ts +45 -0
  118. package/examples/full/actions/search-web.ts +53 -0
  119. package/examples/full/actions/send-email.ts +50 -0
  120. package/examples/full/context.ts +41 -0
  121. package/examples/full/workflow.ts +75 -0
  122. package/examples/full/workflow.yml +359 -0
  123. package/examples/loop/actions/await-reply.ts +47 -0
  124. package/examples/loop/actions/index.ts +19 -0
  125. package/examples/loop/actions/send-text-message.ts +40 -0
  126. package/examples/loop/context.ts +32 -0
  127. package/examples/loop/workflow.ts +66 -0
  128. package/examples/loop/workflow.yml +66 -0
  129. package/examples/suspend-resume/actions/format-reply.ts +43 -0
  130. package/examples/suspend-resume/actions/index.ts +13 -0
  131. package/examples/suspend-resume/actions/wait-for-user.ts +51 -0
  132. package/examples/suspend-resume/context.ts +32 -0
  133. package/examples/suspend-resume/workflow.ts +79 -0
  134. package/examples/suspend-resume/workflow.yml +29 -0
  135. package/package.json +60 -0
  136. package/src/__tests__/parser.test.ts +95 -0
  137. package/src/__tests__/suspension-rebuilder.test.ts +361 -0
  138. package/src/__tests__/test-helpers.ts +36 -0
  139. package/src/__tests__/validation.test.ts +526 -0
  140. package/src/__tests__/workflow-compiler.test.ts +715 -0
  141. package/src/__tests__/workflow-definition-path.test.ts +381 -0
  142. package/src/__tests__/workflow-event-emitter.test.ts +50 -0
  143. package/src/__tests__/workflow-runner.test.ts +1397 -0
  144. package/src/__tests__/workflow-values.test.ts +132 -0
  145. package/src/__tests__/workflow.test.ts +320 -0
  146. package/src/action/__tests__/abstract-action-timing.test.ts +183 -0
  147. package/src/action/__tests__/action.test.ts +473 -0
  148. package/src/action/abstract-action.ts +215 -0
  149. package/src/action/action.ts +83 -0
  150. package/src/action/action.types.ts +93 -0
  151. package/src/bindings/base-binding.ts +508 -0
  152. package/src/context.ts +179 -0
  153. package/src/dsl.types.ts +473 -0
  154. package/src/index.ts +114 -0
  155. package/src/runner-runtime-control.ts +544 -0
  156. package/src/step-executors/conditional-executor.test.ts +192 -0
  157. package/src/step-executors/conditional-executor.ts +83 -0
  158. package/src/step-executors/loop-executor.test.ts +303 -0
  159. package/src/step-executors/loop-executor.ts +299 -0
  160. package/src/step-executors/parallel-executor.test.ts +182 -0
  161. package/src/step-executors/parallel-executor.ts +77 -0
  162. package/src/step-executors/skip-helpers.ts +68 -0
  163. package/src/step-executors/task-executor.test.ts +257 -0
  164. package/src/step-executors/task-executor.ts +248 -0
  165. package/src/step-executors/types.ts +70 -0
  166. package/src/suspension-rebuilder.ts +504 -0
  167. package/src/utils/deferred.ts +23 -0
  168. package/src/utils/naming.ts +55 -0
  169. package/src/utils/timeout.ts +48 -0
  170. package/src/utils/workflow-definition.ts +173 -0
  171. package/src/workflow-compiler.ts +317 -0
  172. package/src/workflow-event-emitter.ts +86 -0
  173. package/src/workflow-runner.ts +603 -0
  174. package/src/workflow-types.ts +207 -0
  175. package/src/workflow-values.ts +159 -0
  176. package/src/workflow.ts +387 -0
@@ -0,0 +1,1397 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ import { defineAction } from '../action/action';
10
+ import { BaseWorkflowContext } from '../context';
11
+ import type { Settings, WorkflowDefinition } from '../dsl.types';
12
+ import { compileWorkflow } from '../workflow-compiler';
13
+ import {
14
+ StepType,
15
+ WorkflowEventEmitter,
16
+ type WorkflowEventEmitterLike,
17
+ type WorkflowEventMap,
18
+ } from '../workflow-event-emitter';
19
+ import { WorkflowRunner } from '../workflow-runner';
20
+ import type { ExecutionState } from '../workflow-types';
21
+
22
+ import { createTaskDefs } from './test-helpers';
23
+
24
+ class MemoryEmitter implements WorkflowEventEmitterLike<{}> {
25
+ private listeners: Partial<{
26
+ [K in keyof WorkflowEventMap]: Array<
27
+ (payload: WorkflowEventMap[K]) => void
28
+ >;
29
+ }> = {};
30
+
31
+ emit<K extends keyof WorkflowEventMap>(
32
+ event: K,
33
+ payload: WorkflowEventMap[K],
34
+ ) {
35
+ this.listeners[event]?.forEach((listener) => listener(payload));
36
+
37
+ return true;
38
+ }
39
+
40
+ on<K extends keyof WorkflowEventMap>(
41
+ event: K,
42
+ listener: (payload: WorkflowEventMap[K]) => void,
43
+ ) {
44
+ if (!this.listeners[event]) {
45
+ this.listeners[event] = [];
46
+ }
47
+ this.listeners[event]?.push(listener);
48
+
49
+ return this;
50
+ }
51
+ }
52
+
53
+ class TestMemoryContext extends BaseWorkflowContext<
54
+ Record<string, unknown>,
55
+ MemoryEmitter
56
+ > {
57
+ public eventEmitter: WorkflowEventEmitterLike<MemoryEmitter> =
58
+ new MemoryEmitter();
59
+
60
+ constructor(initial: Record<string, unknown>) {
61
+ super(initial);
62
+ }
63
+ }
64
+
65
+ class TestContext extends BaseWorkflowContext<
66
+ Record<string, unknown>,
67
+ WorkflowEventEmitter
68
+ > {
69
+ public eventEmitter: WorkflowEventEmitterLike<WorkflowEventEmitter> =
70
+ new WorkflowEventEmitter();
71
+
72
+ constructor(initial: Record<string, unknown>) {
73
+ super(initial);
74
+ }
75
+ }
76
+
77
+ const baseRetries = {
78
+ enabled: true,
79
+ max_attempts: 1,
80
+ backoff_ms: 0,
81
+ max_delay_ms: 0,
82
+ jitter: 0,
83
+ multiplier: 1,
84
+ };
85
+ describe('WorkflowRunner', () => {
86
+ it('applies global defaults and per-task overrides to runtime settings', async () => {
87
+ const runtimeSettings: Settings[] = [];
88
+ const inspectExecute = jest.fn(async ({ input, settings }) => {
89
+ runtimeSettings.push(settings);
90
+
91
+ return { label: input.label };
92
+ });
93
+ const inspectAction = defineAction<
94
+ { label: string },
95
+ { label: string },
96
+ TestContext,
97
+ Settings
98
+ >({
99
+ name: 'inspect_settings_action',
100
+ inputSchema: z.object({ label: z.string() }),
101
+ outputSchema: z.object({ label: z.string() }),
102
+ execute: inspectExecute,
103
+ });
104
+ const defaultRetries = {
105
+ enabled: true,
106
+ max_attempts: 4,
107
+ backoff_ms: 25,
108
+ max_delay_ms: 2000,
109
+ jitter: 0,
110
+ multiplier: 1.2,
111
+ };
112
+ const definition: WorkflowDefinition = {
113
+ defaults: {
114
+ settings: {
115
+ timeout_ms: 200,
116
+ retries: defaultRetries,
117
+ },
118
+ },
119
+ defs: createTaskDefs({
120
+ inherit_task: {
121
+ action: 'inspect_settings_action',
122
+ inputs: { label: '="inherit"' },
123
+ },
124
+ override_timeout_task: {
125
+ action: 'inspect_settings_action',
126
+ inputs: { label: '="timeout_override"' },
127
+ settings: {
128
+ timeout_ms: 50,
129
+ },
130
+ },
131
+ override_retries_task: {
132
+ action: 'inspect_settings_action',
133
+ inputs: { label: '="retries_override"' },
134
+ settings: {
135
+ retries: {
136
+ ...defaultRetries,
137
+ max_attempts: 1,
138
+ multiplier: 1,
139
+ },
140
+ },
141
+ },
142
+ }),
143
+ flow: [
144
+ { do: 'inherit_task' },
145
+ { do: 'override_timeout_task' },
146
+ { do: 'override_retries_task' },
147
+ ],
148
+ outputs: {
149
+ last: '=$output.override_retries_task.label',
150
+ },
151
+ };
152
+ const compiled = compileWorkflow(definition, {
153
+ actions: { inspect_settings_action: inspectAction },
154
+ });
155
+ const runner = new WorkflowRunner(compiled, { runId: 'run-settings' });
156
+ const result = await runner.start({
157
+ inputData: {},
158
+ context: new TestContext({}),
159
+ });
160
+
161
+ expect(result.status).toBe('finished');
162
+ expect(inspectExecute).toHaveBeenCalledTimes(3);
163
+ expect(runtimeSettings[0]).toEqual({
164
+ timeout_ms: 200,
165
+ retries: defaultRetries,
166
+ });
167
+ expect(runtimeSettings[1]).toEqual({
168
+ timeout_ms: 50,
169
+ retries: defaultRetries,
170
+ });
171
+ expect(runtimeSettings[2]).toEqual({
172
+ timeout_ms: 200,
173
+ retries: {
174
+ ...defaultRetries,
175
+ max_attempts: 1,
176
+ multiplier: 1,
177
+ },
178
+ });
179
+ });
180
+
181
+ it('executes parallel, conditional, and loop steps while tracking snapshots', async () => {
182
+ const firstExecute = jest.fn(async () => ({ value: 'alpha' }));
183
+ const secondExecute = jest.fn(async () => ({ value: 'beta' }));
184
+ const echoExecute = jest.fn(async ({ input }) => ({
185
+ message: String(input.message),
186
+ }));
187
+ const firstAction = defineAction<
188
+ unknown,
189
+ { value: string },
190
+ TestContext,
191
+ Settings
192
+ >({
193
+ name: 'first_action',
194
+ inputSchema: z.any(),
195
+ outputSchema: z.object({ value: z.string() }),
196
+ execute: firstExecute,
197
+ });
198
+ const secondAction = defineAction<
199
+ unknown,
200
+ { value: string },
201
+ TestContext,
202
+ Settings
203
+ >({
204
+ name: 'second_action',
205
+ inputSchema: z.any(),
206
+ outputSchema: z.object({ value: z.string() }),
207
+ execute: secondExecute,
208
+ });
209
+ const echoAction = defineAction<
210
+ { message?: unknown },
211
+ { message: string },
212
+ TestContext,
213
+ Settings
214
+ >({
215
+ name: 'echo_action',
216
+ inputSchema: z.object({ message: z.any() }),
217
+ outputSchema: z.object({ message: z.string() }),
218
+ execute: echoExecute,
219
+ });
220
+ const definition: WorkflowDefinition = {
221
+ defaults: { settings: { timeout_ms: 0, retries: baseRetries } },
222
+ defs: createTaskDefs({
223
+ first_task: {
224
+ action: 'first_action',
225
+ },
226
+ second_task: {
227
+ action: 'second_action',
228
+ },
229
+ branch_task: {
230
+ action: 'echo_action',
231
+ inputs: {
232
+ message: '=$exists($iteration) ? $iteration.item : "conditional"',
233
+ },
234
+ },
235
+ loop_task: {
236
+ action: 'echo_action',
237
+ inputs: { message: '=$iteration.item' },
238
+ },
239
+ }),
240
+ flow: [
241
+ {
242
+ parallel: {
243
+ strategy: 'wait_any',
244
+ steps: [{ do: 'first_task' }, { do: 'second_task' }],
245
+ },
246
+ },
247
+ {
248
+ conditional: {
249
+ when: [
250
+ {
251
+ condition: '=$exists($output.first_task)',
252
+ steps: [{ do: 'branch_task' }],
253
+ },
254
+ { else: true, steps: [{ do: 'second_task' }] },
255
+ ],
256
+ },
257
+ },
258
+ {
259
+ loop: {
260
+ type: 'for_each',
261
+ name: 'collector',
262
+ for_each: { item: 'entry', in: '=$input.items' },
263
+ until: '=$iteration.index >= 1',
264
+ accumulate: {
265
+ as: 'sum',
266
+ initial: 0,
267
+ merge: '=$accumulator + $iteration.index',
268
+ },
269
+ steps: [{ do: 'loop_task' }],
270
+ },
271
+ },
272
+ ],
273
+ outputs: {
274
+ summary: '=$output',
275
+ total: '=$output.collector.sum',
276
+ },
277
+ inputs: {
278
+ schema: {
279
+ items: { type: 'array', items: { type: 'number' } },
280
+ },
281
+ },
282
+ };
283
+ const emitter = new WorkflowEventEmitter();
284
+ const eventLog: string[] = [];
285
+ emitter.on('hook:step:start', ({ step }) =>
286
+ eventLog.push(`start:${step.name}`),
287
+ );
288
+ emitter.on('hook:step:success', ({ step }) =>
289
+ eventLog.push(`success:${step.name}`),
290
+ );
291
+
292
+ const compiled = compileWorkflow(definition, {
293
+ actions: {
294
+ first_action: firstAction,
295
+ second_action: secondAction,
296
+ echo_action: echoAction,
297
+ },
298
+ });
299
+ const runner = new WorkflowRunner(compiled, { runId: 'run-1' });
300
+ const context = new TestContext({});
301
+ const result = await runner.start({
302
+ inputData: { items: [10, 20] },
303
+ context,
304
+ });
305
+
306
+ expect(result.status).toBe('finished');
307
+ if (result.status === 'finished') {
308
+ const summary = result.output.summary as Record<string, any>;
309
+ expect(result.output.total).toBe(1);
310
+ expect(summary.first_task.value).toBe('alpha');
311
+ expect(summary.collector.sum).toBe(1);
312
+ expect(summary.loop_task.message).toBe('20');
313
+ }
314
+
315
+ expect(firstExecute).toHaveBeenCalledTimes(1);
316
+ expect(secondExecute).not.toHaveBeenCalled();
317
+ expect(echoExecute).toHaveBeenCalledTimes(3);
318
+ expect(
319
+ eventLog.filter((entry) => entry.includes('second_task')),
320
+ ).toHaveLength(0);
321
+ expect(runner.getSnapshot().status).toBe('finished');
322
+ const snapshots = runner.getSnapshot().actions;
323
+ expect(snapshots['0.parallel.0:first_task']?.status).toBe('completed');
324
+ expect(snapshots['0.parallel.1:second_task']?.status).toBe('skipped');
325
+ expect(snapshots['1.branch.0.0:branch_task']?.status).toBe('completed');
326
+ expect(snapshots['1.branch.1.0:second_task']?.status).toBe('skipped');
327
+ });
328
+
329
+ it('supports conditional, loop, and nested parallel children inside a parallel step', async () => {
330
+ const firstExecute = jest.fn(async () => ({ value: 'alpha' }));
331
+ const secondExecute = jest.fn(async () => ({ value: 'beta' }));
332
+ const echoExecute = jest.fn(async ({ input }) => ({
333
+ message: String(input.message),
334
+ }));
335
+ const firstAction = defineAction<
336
+ unknown,
337
+ { value: string },
338
+ TestContext,
339
+ Settings
340
+ >({
341
+ name: 'first_action',
342
+ inputSchema: z.any(),
343
+ outputSchema: z.object({ value: z.string() }),
344
+ execute: firstExecute,
345
+ });
346
+ const secondAction = defineAction<
347
+ unknown,
348
+ { value: string },
349
+ TestContext,
350
+ Settings
351
+ >({
352
+ name: 'second_action',
353
+ inputSchema: z.any(),
354
+ outputSchema: z.object({ value: z.string() }),
355
+ execute: secondExecute,
356
+ });
357
+ const echoAction = defineAction<
358
+ { message?: unknown },
359
+ { message: string },
360
+ TestContext,
361
+ Settings
362
+ >({
363
+ name: 'echo_action',
364
+ inputSchema: z.object({ message: z.any() }),
365
+ outputSchema: z.object({ message: z.string() }),
366
+ execute: echoExecute,
367
+ });
368
+ const definition: WorkflowDefinition = {
369
+ defaults: { settings: { timeout_ms: 0, retries: baseRetries } },
370
+ defs: createTaskDefs({
371
+ first_task: {
372
+ action: 'first_action',
373
+ },
374
+ second_task: {
375
+ action: 'second_action',
376
+ },
377
+ loop_task: {
378
+ action: 'echo_action',
379
+ inputs: { message: '=$iteration.item' },
380
+ },
381
+ }),
382
+ flow: [
383
+ {
384
+ parallel: {
385
+ strategy: 'wait_all',
386
+ steps: [
387
+ { do: 'first_task' },
388
+ {
389
+ conditional: {
390
+ when: [
391
+ { condition: '=true', steps: [{ do: 'second_task' }] },
392
+ ],
393
+ },
394
+ },
395
+ {
396
+ loop: {
397
+ type: 'for_each',
398
+ name: 'inner_loop',
399
+ for_each: { item: 'item', in: '=["one","two"]' },
400
+ steps: [
401
+ {
402
+ parallel: {
403
+ strategy: 'wait_all',
404
+ steps: [{ do: 'loop_task' }],
405
+ },
406
+ },
407
+ ],
408
+ },
409
+ },
410
+ ],
411
+ },
412
+ },
413
+ ],
414
+ outputs: {
415
+ first: '=$output.first_task.value',
416
+ second: '=$output.second_task.value',
417
+ loop_last: '=$output.loop_task.message',
418
+ },
419
+ };
420
+ const compiled = compileWorkflow(definition, {
421
+ actions: {
422
+ first_action: firstAction,
423
+ second_action: secondAction,
424
+ echo_action: echoAction,
425
+ },
426
+ });
427
+ const runner = new WorkflowRunner(compiled, {
428
+ runId: 'run-nested-parallel',
429
+ });
430
+ const context = new TestContext({});
431
+ const result = await runner.start({
432
+ inputData: {},
433
+ context,
434
+ });
435
+
436
+ expect(result.status).toBe('finished');
437
+ if (result.status === 'finished') {
438
+ expect(result.output.first).toBe('alpha');
439
+ expect(result.output.second).toBe('beta');
440
+ expect(result.output.loop_last).toBe('two');
441
+ }
442
+
443
+ expect(firstExecute).toHaveBeenCalledTimes(1);
444
+ expect(secondExecute).toHaveBeenCalledTimes(1);
445
+ expect(echoExecute).toHaveBeenCalledTimes(2);
446
+ const snapshots = runner.getSnapshot().actions;
447
+ expect(snapshots['0.parallel.0:first_task']?.status).toBe('completed');
448
+ expect(snapshots['0.parallel.1.branch.0.0:second_task']?.status).toBe(
449
+ 'completed',
450
+ );
451
+ expect(
452
+ snapshots['0.parallel.2.inner_loop.0.parallel.0:loop_task[0]']?.status,
453
+ ).toBe('completed');
454
+ expect(
455
+ snapshots['0.parallel.2.inner_loop.0.parallel.0:loop_task[1]']?.status,
456
+ ).toBe('completed');
457
+ });
458
+
459
+ it('supports while loops with pre-check semantics and zero iterations', async () => {
460
+ const echoExecute = jest.fn(async ({ input }) => ({
461
+ message: String(input.message),
462
+ }));
463
+ const echoAction = defineAction<
464
+ { message?: unknown },
465
+ { message: string },
466
+ TestContext,
467
+ Settings
468
+ >({
469
+ name: 'echo_action',
470
+ inputSchema: z.object({ message: z.any() }),
471
+ outputSchema: z.object({ message: z.string() }),
472
+ execute: echoExecute,
473
+ });
474
+ const definition: WorkflowDefinition = {
475
+ defs: createTaskDefs({
476
+ loop_task: {
477
+ action: 'echo_action',
478
+ inputs: { message: '="ran"' },
479
+ },
480
+ }),
481
+ flow: [
482
+ {
483
+ loop: {
484
+ type: 'while',
485
+ name: 'while_loop',
486
+ while: '=$exists($output.loop_task)',
487
+ steps: [{ do: 'loop_task' }],
488
+ },
489
+ },
490
+ ],
491
+ outputs: {
492
+ ran: '=$exists($output.loop_task)',
493
+ },
494
+ };
495
+ const compiled = compileWorkflow(definition, {
496
+ actions: {
497
+ echo_action: echoAction,
498
+ },
499
+ });
500
+ const runner = new WorkflowRunner(compiled, {
501
+ runId: 'run-while-zero-iteration',
502
+ });
503
+ const context = new TestContext({});
504
+ const result = await runner.start({
505
+ inputData: {},
506
+ context,
507
+ });
508
+
509
+ expect(result.status).toBe('finished');
510
+ if (result.status === 'finished') {
511
+ expect(result.output.ran).toBe(false);
512
+ }
513
+ expect(echoExecute).not.toHaveBeenCalled();
514
+ });
515
+
516
+ it('supports suspension and resume in while loops', async () => {
517
+ const suspendAction = defineAction<
518
+ { prompt: string },
519
+ { reply: string; done: boolean },
520
+ TestContext,
521
+ Settings
522
+ >({
523
+ name: 'loop_suspend_action',
524
+ inputSchema: z.object({ prompt: z.string() }),
525
+ outputSchema: z.object({ reply: z.string(), done: z.boolean() }),
526
+ execute: async ({ input, context }) => {
527
+ const resumeData = (await context.workflow.suspend({
528
+ reason: 'awaiting_reply',
529
+ data: { prompt: input.prompt },
530
+ })) as { reply: string };
531
+
532
+ return {
533
+ reply: resumeData.reply,
534
+ done: resumeData.reply === 'stop',
535
+ };
536
+ },
537
+ });
538
+ const definition: WorkflowDefinition = {
539
+ defs: createTaskDefs({
540
+ wait_step: {
541
+ action: 'loop_suspend_action',
542
+ inputs: { prompt: '="Attempt " & $string($iteration.index)' },
543
+ },
544
+ }),
545
+ flow: [
546
+ {
547
+ loop: {
548
+ type: 'while',
549
+ name: 'wait_until_stop',
550
+ while:
551
+ '=$not($exists($output.wait_step.done) and $output.wait_step.done = true)',
552
+ steps: [{ do: 'wait_step' }],
553
+ },
554
+ },
555
+ ],
556
+ outputs: { reply: '=$output.wait_step.reply' },
557
+ };
558
+ const compiled = compileWorkflow(definition, {
559
+ actions: {
560
+ loop_suspend_action: suspendAction,
561
+ },
562
+ });
563
+ const runner = new WorkflowRunner(compiled, { runId: 'run-while-suspend' });
564
+ const context = new TestContext({});
565
+ const startResult = await runner.start({ inputData: {}, context });
566
+
567
+ expect(startResult.status).toBe('suspended');
568
+ if (startResult.status !== 'suspended') {
569
+ throw new Error('Workflow did not suspend as expected');
570
+ }
571
+ expect(startResult.step.id).toBe('0.wait_until_stop.0:wait_step[0]');
572
+
573
+ const firstResume = await runner.resume({
574
+ resumeData: { reply: 'continue' },
575
+ });
576
+ expect(firstResume.status).toBe('suspended');
577
+ if (firstResume.status !== 'suspended') {
578
+ throw new Error('Workflow did not suspend on second iteration');
579
+ }
580
+ expect(firstResume.step.id).toBe('0.wait_until_stop.0:wait_step[1]');
581
+
582
+ const secondResume = await runner.resume({
583
+ resumeData: { reply: 'stop' },
584
+ });
585
+ expect(secondResume.status).toBe('finished');
586
+ if (secondResume.status === 'finished') {
587
+ expect(secondResume.output.reply).toBe('stop');
588
+ }
589
+ });
590
+
591
+ it('handles suspension and resumes with provided data', async () => {
592
+ const suspendAction = defineAction<
593
+ { prompt: string },
594
+ { reply: string },
595
+ TestContext,
596
+ Settings
597
+ >({
598
+ name: 'suspend_action',
599
+ inputSchema: z.object({ prompt: z.string() }),
600
+ outputSchema: z.object({ reply: z.string() }),
601
+ execute: async ({ context }) => {
602
+ const resumeData = (await context.workflow.suspend({
603
+ reason: 'awaiting_user',
604
+ data: { channel: 'sms' },
605
+ })) as { reply: string };
606
+
607
+ return { reply: resumeData.reply };
608
+ },
609
+ });
610
+ const definition: WorkflowDefinition = {
611
+ defs: createTaskDefs({
612
+ wait_step: {
613
+ action: 'suspend_action',
614
+ inputs: { prompt: '="Ping"' },
615
+ },
616
+ }),
617
+ flow: [{ do: 'wait_step' }],
618
+ outputs: { reply: '=$output.wait_step.reply' },
619
+ };
620
+ const compiled = compileWorkflow(definition, {
621
+ actions: {
622
+ suspend_action: suspendAction,
623
+ },
624
+ });
625
+ const runner = new WorkflowRunner(compiled, { runId: 'run-2' });
626
+ const context = new TestContext({});
627
+ const startResult = await runner.start({ inputData: {}, context });
628
+ expect(startResult.status).toBe('suspended');
629
+ if (startResult.status === 'suspended') {
630
+ expect(startResult.reason).toBe('awaiting_user');
631
+ expect(runner.getStatus()).toBe('suspended');
632
+ expect(runner.getSnapshot().actions[startResult.step.id]?.status).toBe(
633
+ 'suspended',
634
+ );
635
+ }
636
+
637
+ const resume = await runner.resume({ resumeData: { reply: 'Sure' } });
638
+ expect(resume.status).toBe('finished');
639
+ if (resume.status === 'finished') {
640
+ expect(resume.output.reply).toBe('Sure');
641
+ expect(runner.getLastResumeData()).toEqual({ reply: 'Sure' });
642
+ }
643
+
644
+ expect(() => context.workflow).toThrow();
645
+ });
646
+
647
+ it('accepts emitter-like implementations without Node EventEmitter', async () => {
648
+ const pingAction = defineAction<
649
+ unknown,
650
+ { ok: boolean },
651
+ TestContext,
652
+ Settings
653
+ >({
654
+ name: 'ping_action',
655
+ inputSchema: z.any(),
656
+ outputSchema: z.object({ ok: z.boolean() }),
657
+ execute: async () => ({ ok: true }),
658
+ });
659
+ const definition: WorkflowDefinition = {
660
+ defs: createTaskDefs({
661
+ ping_step: { action: 'ping_action' },
662
+ }),
663
+ flow: [{ do: 'ping_step' }],
664
+ outputs: { ok: '=$output.ping_step.ok' },
665
+ };
666
+ const compiled = compileWorkflow(definition, {
667
+ actions: { ping_action: pingAction },
668
+ });
669
+ const emitter = new MemoryEmitter();
670
+ const events: Array<{
671
+ event: keyof WorkflowEventMap;
672
+ payload: WorkflowEventMap[keyof WorkflowEventMap];
673
+ }> = [];
674
+
675
+ emitter.on('hook:workflow:start', (payload) =>
676
+ events.push({ event: 'hook:workflow:start', payload }),
677
+ );
678
+ emitter.on('hook:step:start', (payload) =>
679
+ events.push({ event: 'hook:step:start', payload }),
680
+ );
681
+ emitter.on('hook:step:success', (payload) =>
682
+ events.push({ event: 'hook:step:success', payload }),
683
+ );
684
+ emitter.on('hook:workflow:finish', (payload) =>
685
+ events.push({ event: 'hook:workflow:finish', payload }),
686
+ );
687
+
688
+ const runner = new WorkflowRunner(compiled, { runId: 'custom-run' });
689
+ const context = new TestMemoryContext({});
690
+ context.eventEmitter = emitter;
691
+ const result = await runner.start({ inputData: {}, context });
692
+
693
+ expect(result.status).toBe('finished');
694
+ expect(events.map((entry) => entry.event)).toEqual([
695
+ 'hook:workflow:start',
696
+ 'hook:step:start',
697
+ 'hook:step:success',
698
+ 'hook:workflow:finish',
699
+ ]);
700
+ const finish = events.find(
701
+ (entry) => entry.event === 'hook:workflow:finish',
702
+ );
703
+ expect(finish?.payload.runId).toBe('custom-run');
704
+ });
705
+
706
+ it('exposes the configured event emitter on the context for actions', async () => {
707
+ let observedEmitter:
708
+ | WorkflowEventEmitterLike<WorkflowEventEmitter>
709
+ | undefined;
710
+ const emitter = new WorkflowEventEmitter();
711
+ const skippedEvents: Array<{ stepId: string; reason?: string }> = [];
712
+
713
+ emitter.on('hook:step:skipped', ({ step, reason }) =>
714
+ skippedEvents.push({ stepId: step.id, reason }),
715
+ );
716
+
717
+ const emitAction = defineAction<
718
+ { note: string },
719
+ { note: string },
720
+ TestContext,
721
+ Settings
722
+ >({
723
+ name: 'emit_action',
724
+ inputSchema: z.object({ note: z.string() }),
725
+ outputSchema: z.object({ note: z.string() }),
726
+ execute: async ({ input, context }) => {
727
+ observedEmitter = context.eventEmitter;
728
+ context.eventEmitter?.emit('hook:step:skipped', {
729
+ runId: 'ctx-run',
730
+ step: { id: 'manual', name: 'emit_action', type: StepType.Task },
731
+ reason: input.note,
732
+ });
733
+
734
+ return { note: input.note };
735
+ },
736
+ });
737
+ const definition: WorkflowDefinition = {
738
+ defs: createTaskDefs({
739
+ emit_step: {
740
+ action: 'emit_action',
741
+ inputs: { note: '=$input.note' },
742
+ },
743
+ }),
744
+ flow: [{ do: 'emit_step' }],
745
+ outputs: { note: '=$output.emit_step.note' },
746
+ inputs: {
747
+ schema: {
748
+ note: { type: 'string' },
749
+ },
750
+ },
751
+ };
752
+ const compiled = compileWorkflow(definition, {
753
+ actions: { emit_action: emitAction },
754
+ });
755
+ const runner = new WorkflowRunner(compiled, { runId: 'ctx-run' });
756
+ const context = new TestContext({});
757
+ context.eventEmitter = emitter;
758
+ const result = await runner.start({
759
+ inputData: { note: 'custom-event' },
760
+ context,
761
+ });
762
+
763
+ expect(result.status).toBe('finished');
764
+ expect(observedEmitter).toBe(emitter);
765
+ expect(skippedEvents).toEqual([
766
+ { stepId: 'manual', reason: 'custom-event' },
767
+ ]);
768
+ });
769
+
770
+ it('bubbles action errors and prevents resume when not suspended', async () => {
771
+ const failingAction = defineAction<unknown, unknown, TestContext, Settings>(
772
+ {
773
+ name: 'failing_action',
774
+ inputSchema: z.any(),
775
+ outputSchema: z.any(),
776
+ execute: async () => {
777
+ throw new Error('boom');
778
+ },
779
+ },
780
+ );
781
+ const definition: WorkflowDefinition = {
782
+ defs: createTaskDefs({
783
+ fail_step: {
784
+ action: 'failing_action',
785
+ inputs: { value: 1 },
786
+ },
787
+ }),
788
+ flow: [{ do: 'fail_step' }],
789
+ outputs: { value: '=$output.fail_step.value' },
790
+ };
791
+ const compiled = compileWorkflow(definition, {
792
+ actions: {
793
+ failing_action: failingAction,
794
+ },
795
+ });
796
+ const runner = new WorkflowRunner(compiled);
797
+ const result = await runner.start({
798
+ inputData: {},
799
+ context: new TestContext({}),
800
+ });
801
+ expect(result.status).toBe('failed');
802
+ if (result.status === 'failed') {
803
+ expect((result.error as Error).message).toBe('boom');
804
+ expect(runner.getSnapshot().actions['0:fail_step']?.status).toBe(
805
+ 'failed',
806
+ );
807
+ }
808
+
809
+ await expect(runner.resume({ resumeData: {} })).rejects.toThrow(
810
+ 'Cannot resume a workflow that is not suspended.',
811
+ );
812
+ });
813
+
814
+ it('resumes a suspended step and continues executing the remaining flow', async () => {
815
+ const suspendAction = defineAction<
816
+ { prompt: string },
817
+ { reply: string },
818
+ TestContext,
819
+ Settings
820
+ >({
821
+ name: 'resume_suspend_action',
822
+ inputSchema: z.object({ prompt: z.string() }),
823
+ outputSchema: z.object({ reply: z.string() }),
824
+ execute: async ({ context }) => {
825
+ const resumeData = (await context.workflow.suspend({
826
+ reason: 'need_reply',
827
+ })) as { reply: string };
828
+
829
+ return { reply: resumeData.reply };
830
+ },
831
+ });
832
+ const followExecute = jest.fn(async ({ input }) => ({
833
+ formatted: input.message.toUpperCase(),
834
+ }));
835
+ const followAction = defineAction<
836
+ { message: string },
837
+ { formatted: string },
838
+ TestContext,
839
+ Settings
840
+ >({
841
+ name: 'follow_action',
842
+ inputSchema: z.object({ message: z.string() }),
843
+ outputSchema: z.object({ formatted: z.string() }),
844
+ execute: followExecute,
845
+ });
846
+ const definition: WorkflowDefinition = {
847
+ defs: createTaskDefs({
848
+ wait_step: {
849
+ action: 'resume_suspend_action',
850
+ inputs: { prompt: '="Ping?"' },
851
+ },
852
+ follow_step: {
853
+ action: 'follow_action',
854
+ inputs: { message: '=$output.wait_step.reply' },
855
+ },
856
+ }),
857
+ flow: [{ do: 'wait_step' }, { do: 'follow_step' }],
858
+ outputs: {
859
+ reply: '=$output.wait_step.reply',
860
+ formatted: '=$output.follow_step.formatted',
861
+ },
862
+ };
863
+ const compiled = compileWorkflow(definition, {
864
+ actions: {
865
+ resume_suspend_action: suspendAction,
866
+ follow_action: followAction,
867
+ },
868
+ });
869
+ const runner = new WorkflowRunner(compiled);
870
+ const startResult = await runner.start({
871
+ inputData: {},
872
+ context: new TestContext({}),
873
+ });
874
+
875
+ expect(startResult.status).toBe('suspended');
876
+ if (startResult.status !== 'suspended') {
877
+ throw new Error('workflow did not suspend');
878
+ }
879
+
880
+ expect(startResult.step.id).toBe('0:wait_step');
881
+ expect(runner.getSnapshot().actions['0:wait_step']?.status).toBe(
882
+ 'suspended',
883
+ );
884
+
885
+ const resumeResult = await runner.resume({ resumeData: { reply: 'ok' } });
886
+ expect(resumeResult.status).toBe('finished');
887
+ if (resumeResult.status === 'finished') {
888
+ expect(resumeResult.output.reply).toBe('ok');
889
+ expect(resumeResult.output.formatted).toBe('OK');
890
+ expect(followExecute).toHaveBeenCalledTimes(1);
891
+ expect(runner.getSnapshot().actions['0:wait_step']?.status).toBe(
892
+ 'completed',
893
+ );
894
+ expect(runner.getSnapshot().actions['1:follow_step']?.status).toBe(
895
+ 'completed',
896
+ );
897
+ }
898
+ });
899
+
900
+ it('stores the raw task result under $output', async () => {
901
+ const rawAction = defineAction<
902
+ { value: string },
903
+ string,
904
+ TestContext,
905
+ Settings
906
+ >({
907
+ name: 'raw_action',
908
+ inputSchema: z.object({ value: z.string() }),
909
+ outputSchema: z.string(),
910
+ execute: async ({ input }) => `echo:${input.value}`,
911
+ });
912
+ const definition: WorkflowDefinition = {
913
+ defs: createTaskDefs({
914
+ raw_step: { action: 'raw_action', inputs: { value: '="hello"' } },
915
+ }),
916
+ flow: [{ do: 'raw_step' }],
917
+ outputs: { final: '=$output.raw_step' },
918
+ };
919
+ const compiled = compileWorkflow(definition, {
920
+ actions: { raw_action: rawAction },
921
+ });
922
+ const runner = new WorkflowRunner(compiled);
923
+ const result = await runner.start({
924
+ inputData: {},
925
+ context: new TestContext({}),
926
+ });
927
+
928
+ expect(result.status).toBe('finished');
929
+ if (result.status === 'finished') {
930
+ expect(result.output.final).toBe('echo:hello');
931
+ const runtimeState = (runner as unknown as { state: ExecutionState })
932
+ .state;
933
+ expect(runtimeState?.output.raw_step).toBe('echo:hello');
934
+ expect(runner.getSnapshot().actions['0:raw_step']?.status).toBe(
935
+ 'completed',
936
+ );
937
+ }
938
+ });
939
+
940
+ it('throws when a persisted suspension cannot be rebuilt', async () => {
941
+ const noopAction = defineAction<
942
+ unknown,
943
+ { ok: boolean },
944
+ TestContext,
945
+ Settings
946
+ >({
947
+ name: 'noop_action',
948
+ inputSchema: z.any(),
949
+ outputSchema: z.object({ ok: z.boolean() }),
950
+ execute: async () => ({ ok: true }),
951
+ });
952
+ const definition: WorkflowDefinition = {
953
+ defs: createTaskDefs({
954
+ only_step: { action: 'noop_action', inputs: {} },
955
+ }),
956
+ flow: [{ do: 'only_step' }],
957
+ outputs: { ok: '=$output.only_step.ok' },
958
+ };
959
+ const compiled = compileWorkflow(definition, {
960
+ actions: { noop_action: noopAction },
961
+ });
962
+ const persistedState: ExecutionState = {
963
+ input: {},
964
+ output: {},
965
+ iterationStack: [],
966
+ };
967
+
968
+ await expect(
969
+ WorkflowRunner.fromPersistedState(compiled, {
970
+ state: persistedState,
971
+ context: new TestContext({}),
972
+ snapshot: { status: 'suspended', actions: {} },
973
+ suspension: { stepId: 'unknown.step', reason: null, data: undefined },
974
+ }),
975
+ ).rejects.toThrow('Unable to rebuild suspension for step unknown.step');
976
+ });
977
+
978
+ it('rebuilds loop suspension using the iteration suffix when iterationStack is missing', async () => {
979
+ const suspendAction = defineAction<
980
+ { prompt: string },
981
+ { reply: string },
982
+ TestContext,
983
+ Settings
984
+ >({
985
+ name: 'loop_suspend_action',
986
+ inputSchema: z.object({ prompt: z.string() }),
987
+ outputSchema: z.object({ reply: z.string() }),
988
+ execute: async ({ input, context }) => {
989
+ const resumeData = (await context.workflow.suspend({
990
+ reason: 'awaiting_reply',
991
+ data: { prompt: input.prompt },
992
+ })) as { reply: string };
993
+
994
+ return { reply: `ack:${resumeData.reply}` };
995
+ },
996
+ });
997
+ const definition: WorkflowDefinition = {
998
+ defs: createTaskDefs({
999
+ wait_step: {
1000
+ action: 'loop_suspend_action',
1001
+ inputs: { prompt: '="Ping"' },
1002
+ },
1003
+ }),
1004
+ flow: [
1005
+ {
1006
+ loop: {
1007
+ type: 'for_each',
1008
+ name: 'iterate',
1009
+ for_each: { item: 'entry', in: '=$input.items' },
1010
+ steps: [{ do: 'wait_step' }],
1011
+ },
1012
+ },
1013
+ ],
1014
+ outputs: { reply: '=$output.wait_step.reply' },
1015
+ inputs: {
1016
+ schema: {
1017
+ items: { type: 'array', items: { type: 'string' } },
1018
+ },
1019
+ },
1020
+ };
1021
+ const compiled = compileWorkflow(definition, {
1022
+ actions: {
1023
+ loop_suspend_action: suspendAction,
1024
+ },
1025
+ });
1026
+ const runner = new WorkflowRunner(compiled);
1027
+ const context = new TestContext({});
1028
+ const startResult = await runner.start({
1029
+ inputData: { items: ['first'] },
1030
+ context,
1031
+ });
1032
+
1033
+ expect(startResult.status).toBe('suspended');
1034
+ if (startResult.status !== 'suspended') {
1035
+ throw new Error('Workflow did not suspend as expected');
1036
+ }
1037
+
1038
+ expect(startResult.step.id).toBe('0.iterate.0:wait_step[0]');
1039
+
1040
+ const runtimeState = (runner as unknown as { state: ExecutionState }).state;
1041
+ const persistedState: ExecutionState = {
1042
+ input: { ...runtimeState.input },
1043
+ output: { ...runtimeState.output },
1044
+ iteration: runtimeState.iteration,
1045
+ accumulator: runtimeState.accumulator,
1046
+ iterationStack: [],
1047
+ };
1048
+ const rebuilt = await WorkflowRunner.fromPersistedState(compiled, {
1049
+ state: persistedState,
1050
+ context: new TestContext({}),
1051
+ snapshot: startResult.snapshot,
1052
+ suspension: {
1053
+ stepId: startResult.step.id,
1054
+ reason: startResult.reason ?? null,
1055
+ data: startResult.data,
1056
+ },
1057
+ });
1058
+ const resumeResult = await rebuilt.resume({
1059
+ resumeData: { reply: 'Pong' },
1060
+ });
1061
+ expect(resumeResult.status).toBe('finished');
1062
+ if (resumeResult.status === 'finished') {
1063
+ expect(resumeResult.output.reply).toBe('ack:Pong');
1064
+ }
1065
+ });
1066
+
1067
+ it('rebuilds a task suspended on a later suspend() call using suspend metadata and await history', async () => {
1068
+ const multiSuspendAction = defineAction<
1069
+ unknown,
1070
+ { firstReply: string; secondReply: string },
1071
+ TestContext,
1072
+ Settings
1073
+ >({
1074
+ name: 'multi_suspend_action',
1075
+ inputSchema: z.any(),
1076
+ outputSchema: z.object({
1077
+ firstReply: z.string(),
1078
+ secondReply: z.string(),
1079
+ }),
1080
+ execute: async ({ context }) => {
1081
+ const first = (await context.workflow.suspend({
1082
+ reason: 'first_pause',
1083
+ data: { order: 1 },
1084
+ })) as { reply: string };
1085
+ const second = (await context.workflow.suspend({
1086
+ reason: 'second_pause',
1087
+ data: { order: 2 },
1088
+ })) as { reply: string };
1089
+
1090
+ return {
1091
+ firstReply: first.reply,
1092
+ secondReply: second.reply,
1093
+ };
1094
+ },
1095
+ });
1096
+ const definition: WorkflowDefinition = {
1097
+ defs: createTaskDefs({
1098
+ wait_step: {
1099
+ action: 'multi_suspend_action',
1100
+ inputs: {},
1101
+ },
1102
+ }),
1103
+ flow: [{ do: 'wait_step' }],
1104
+ outputs: {
1105
+ firstReply: '=$output.wait_step.firstReply',
1106
+ secondReply: '=$output.wait_step.secondReply',
1107
+ },
1108
+ };
1109
+ const compiled = compileWorkflow(definition, {
1110
+ actions: {
1111
+ multi_suspend_action: multiSuspendAction,
1112
+ },
1113
+ });
1114
+ const runner = new WorkflowRunner(compiled);
1115
+ const context = new TestContext({});
1116
+ const firstSuspension = await runner.start({ inputData: {}, context });
1117
+ expect(firstSuspension.status).toBe('suspended');
1118
+ if (firstSuspension.status !== 'suspended') {
1119
+ throw new Error('Workflow did not suspend on first await point');
1120
+ }
1121
+
1122
+ expect(firstSuspension.suspendIndex).toBe(1);
1123
+ expect(firstSuspension.suspendKey).toBe('index:1');
1124
+ expect(firstSuspension.awaitResults).toEqual({});
1125
+
1126
+ const secondSuspension = await runner.resume({
1127
+ resumeData: { reply: 'first-answer' },
1128
+ });
1129
+ expect(secondSuspension.status).toBe('suspended');
1130
+ if (secondSuspension.status !== 'suspended') {
1131
+ throw new Error('Workflow did not suspend on second await point');
1132
+ }
1133
+
1134
+ expect(secondSuspension.suspendIndex).toBe(2);
1135
+ expect(secondSuspension.suspendKey).toBe('index:2');
1136
+ expect(secondSuspension.awaitResults).toEqual({
1137
+ 'index:1': { reply: 'first-answer' },
1138
+ });
1139
+
1140
+ const runtimeState = runner.getState() as ExecutionState;
1141
+ const persistedState: ExecutionState = {
1142
+ input: { ...runtimeState.input },
1143
+ output: { ...runtimeState.output },
1144
+ iteration: runtimeState.iteration,
1145
+ accumulator: runtimeState.accumulator,
1146
+ iterationStack: [...runtimeState.iterationStack],
1147
+ };
1148
+ const rebuilt = await WorkflowRunner.fromPersistedState(compiled, {
1149
+ state: persistedState,
1150
+ context: new TestContext({}),
1151
+ snapshot: secondSuspension.snapshot,
1152
+ suspension: {
1153
+ stepId: secondSuspension.step.id,
1154
+ reason: secondSuspension.reason ?? null,
1155
+ data: secondSuspension.data,
1156
+ stepExecId: secondSuspension.stepExecId,
1157
+ suspendIndex: secondSuspension.suspendIndex,
1158
+ suspendKey: secondSuspension.suspendKey,
1159
+ awaitResults: secondSuspension.awaitResults,
1160
+ },
1161
+ });
1162
+ const finalResult = await rebuilt.resume({
1163
+ resumeData: { reply: 'second-answer' },
1164
+ });
1165
+
1166
+ expect(finalResult.status).toBe('finished');
1167
+ if (finalResult.status === 'finished') {
1168
+ expect(finalResult.output.firstReply).toBe('first-answer');
1169
+ expect(finalResult.output.secondReply).toBe('second-answer');
1170
+ }
1171
+ });
1172
+
1173
+ it('fails resume when replay suspension metadata does not match the workflow code path', async () => {
1174
+ const suspendAction = defineAction<
1175
+ unknown,
1176
+ { reply: string },
1177
+ TestContext,
1178
+ Settings
1179
+ >({
1180
+ name: 'single_suspend_action',
1181
+ inputSchema: z.any(),
1182
+ outputSchema: z.object({ reply: z.string() }),
1183
+ execute: async ({ context }) => {
1184
+ const resumeData = (await context.workflow.suspend({
1185
+ reason: 'actual_reason',
1186
+ })) as { reply: string };
1187
+
1188
+ return { reply: resumeData.reply };
1189
+ },
1190
+ });
1191
+ const definition: WorkflowDefinition = {
1192
+ defs: createTaskDefs({
1193
+ wait_step: {
1194
+ action: 'single_suspend_action',
1195
+ inputs: {},
1196
+ },
1197
+ }),
1198
+ flow: [{ do: 'wait_step' }],
1199
+ outputs: { reply: '=$output.wait_step.reply' },
1200
+ };
1201
+ const compiled = compileWorkflow(definition, {
1202
+ actions: { single_suspend_action: suspendAction },
1203
+ });
1204
+ const runner = new WorkflowRunner(compiled);
1205
+ const startResult = await runner.start({
1206
+ inputData: {},
1207
+ context: new TestContext({}),
1208
+ });
1209
+ expect(startResult.status).toBe('suspended');
1210
+ if (startResult.status !== 'suspended') {
1211
+ throw new Error('Workflow did not suspend as expected');
1212
+ }
1213
+
1214
+ const runtimeState = runner.getState() as ExecutionState;
1215
+ const rebuilt = await WorkflowRunner.fromPersistedState(compiled, {
1216
+ state: {
1217
+ input: { ...runtimeState.input },
1218
+ output: { ...runtimeState.output },
1219
+ iteration: runtimeState.iteration,
1220
+ accumulator: runtimeState.accumulator,
1221
+ iterationStack: [...runtimeState.iterationStack],
1222
+ },
1223
+ context: new TestContext({}),
1224
+ snapshot: startResult.snapshot,
1225
+ suspension: {
1226
+ stepId: startResult.step.id,
1227
+ reason: 'different_reason',
1228
+ data: startResult.data,
1229
+ stepExecId: startResult.stepExecId,
1230
+ suspendIndex: startResult.suspendIndex,
1231
+ suspendKey: startResult.suspendKey,
1232
+ awaitResults: startResult.awaitResults,
1233
+ },
1234
+ });
1235
+ const resumeResult = await rebuilt.resume({
1236
+ resumeData: { reply: 'value' },
1237
+ });
1238
+
1239
+ expect(resumeResult.status).toBe('failed');
1240
+ if (resumeResult.status === 'failed') {
1241
+ expect((resumeResult.error as Error).name).toBe(
1242
+ 'NonDeterministicWorkflowError',
1243
+ );
1244
+ expect(String((resumeResult.error as Error).message)).toContain(
1245
+ 'expected reason \"different_reason\"',
1246
+ );
1247
+ }
1248
+ });
1249
+
1250
+ it('registers custom JSONata functions during compilation', async () => {
1251
+ const translate = jest.fn((text: string) => `i18n:${text}`);
1252
+ const sendAction = defineAction<
1253
+ { text: string },
1254
+ { delivered: string },
1255
+ TestContext,
1256
+ Settings
1257
+ >({
1258
+ name: 'send_action',
1259
+ inputSchema: z.object({ text: z.string() }),
1260
+ outputSchema: z.object({ delivered: z.string() }),
1261
+ execute: async ({ input }) => ({ delivered: input.text }),
1262
+ });
1263
+ const definition: WorkflowDefinition = {
1264
+ defs: createTaskDefs({
1265
+ send_goodbye: {
1266
+ action: 'send_action',
1267
+ inputs: { text: "=$i18n('Bye bye')" },
1268
+ },
1269
+ }),
1270
+ flow: [{ do: 'send_goodbye' }],
1271
+ outputs: { message: '=$output.send_goodbye.delivered' },
1272
+ };
1273
+ const compiled = compileWorkflow(definition, {
1274
+ actions: { send_action: sendAction },
1275
+ jsonataFunctions: { i18n: translate },
1276
+ });
1277
+ const runner = new WorkflowRunner(compiled);
1278
+ const context = new TestContext({});
1279
+ const result = await runner.start({ inputData: {}, context });
1280
+
1281
+ expect(result.status).toBe('finished');
1282
+ if (result.status === 'finished') {
1283
+ expect(result.output.message).toBe('i18n:Bye bye');
1284
+ }
1285
+ expect(translate).toHaveBeenCalledWith('Bye bye');
1286
+ });
1287
+
1288
+ it('records step execution details with context snapshots', async () => {
1289
+ const countAction = defineAction<
1290
+ { amount: number },
1291
+ { total: number },
1292
+ TestContext,
1293
+ Settings
1294
+ >({
1295
+ name: 'count_action',
1296
+ inputSchema: z.object({ amount: z.number() }),
1297
+ outputSchema: z.object({ total: z.number() }),
1298
+ execute: async ({ input, context }) => {
1299
+ const current = Number(context.state.count ?? 0);
1300
+ const total = current + input.amount;
1301
+ context.state.count = total;
1302
+
1303
+ return { total };
1304
+ },
1305
+ });
1306
+ const definition: WorkflowDefinition = {
1307
+ defs: createTaskDefs({
1308
+ count_step: {
1309
+ action: 'count_action',
1310
+ inputs: { amount: '=$input.amount' },
1311
+ },
1312
+ }),
1313
+ flow: [{ do: 'count_step' }],
1314
+ outputs: { total: '=$output.count_step.total' },
1315
+ inputs: {
1316
+ schema: {
1317
+ amount: { type: 'number' },
1318
+ },
1319
+ },
1320
+ };
1321
+ const compiled = compileWorkflow(definition, {
1322
+ actions: { count_action: countAction },
1323
+ });
1324
+ const runner = new WorkflowRunner(compiled);
1325
+ const context = new TestContext({ count: 1 });
1326
+ const result = await runner.start({ inputData: { amount: 2 }, context });
1327
+
1328
+ expect(result.status).toBe('finished');
1329
+ const record = runner.getStepLog()['0:count_step'];
1330
+ expect(record).toMatchObject({
1331
+ id: '0:count_step',
1332
+ name: 'count_step',
1333
+ action: 'count_action',
1334
+ status: 'completed',
1335
+ input: { amount: 2 },
1336
+ output: { total: 3 },
1337
+ context: { before: { count: 1 }, after: { count: 3 } },
1338
+ });
1339
+ expect(record?.startedAt).toEqual(expect.any(Number));
1340
+ expect(record?.endedAt).toEqual(expect.any(Number));
1341
+ expect(record.endedAt as number).toBeGreaterThanOrEqual(
1342
+ record.startedAt as number,
1343
+ );
1344
+ });
1345
+
1346
+ it('uses the context snapshot hook when capturing step context', async () => {
1347
+ class FilteredContext extends BaseWorkflowContext<
1348
+ Record<string, unknown>,
1349
+ WorkflowEventEmitter
1350
+ > {
1351
+ public eventEmitter: WorkflowEventEmitterLike<WorkflowEventEmitter> =
1352
+ new WorkflowEventEmitter();
1353
+
1354
+ snapshot(): Record<string, unknown> {
1355
+ return { safe: this.state.safe };
1356
+ }
1357
+ }
1358
+
1359
+ const mutateAction = defineAction<
1360
+ unknown,
1361
+ { ok: boolean },
1362
+ FilteredContext,
1363
+ Settings
1364
+ >({
1365
+ name: 'mutate_action',
1366
+ inputSchema: z.any(),
1367
+ outputSchema: z.object({ ok: z.boolean() }),
1368
+ execute: async ({ context }) => {
1369
+ context.state.safe = 'after';
1370
+ context.state.secret = 'should-not-leak';
1371
+
1372
+ return { ok: true };
1373
+ },
1374
+ });
1375
+ const definition: WorkflowDefinition = {
1376
+ defs: createTaskDefs({
1377
+ mutate_step: { action: 'mutate_action' },
1378
+ }),
1379
+ flow: [{ do: 'mutate_step' }],
1380
+ outputs: { ok: '=$output.mutate_step.ok' },
1381
+ };
1382
+ const compiled = compileWorkflow(definition, {
1383
+ actions: { mutate_action: mutateAction },
1384
+ });
1385
+ const runner = new WorkflowRunner(compiled);
1386
+ const context = new FilteredContext({ safe: 'before', secret: 'hidden' });
1387
+ const result = await runner.start({ inputData: {}, context });
1388
+
1389
+ expect(result.status).toBe('finished');
1390
+ const record = runner.getStepLog()['0:mutate_step'];
1391
+ expect(record?.action).toBe('mutate_action');
1392
+ expect(record?.context?.before).toEqual({ safe: 'before' });
1393
+ expect(record?.context?.after).toEqual({ safe: 'after' });
1394
+ expect(record?.context?.before).not.toHaveProperty('secret');
1395
+ expect(record?.context?.after).not.toHaveProperty('secret');
1396
+ });
1397
+ });