@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.
- package/LICENSE.md +120 -0
- package/README.md +231 -0
- package/dist/cjs/action/abstract-action.js +141 -0
- package/dist/cjs/action/action.js +47 -0
- package/dist/cjs/action/action.types.js +7 -0
- package/dist/cjs/bindings/base-binding.js +278 -0
- package/dist/cjs/context.js +79 -0
- package/dist/cjs/dsl.types.js +318 -0
- package/dist/cjs/index.js +52 -0
- package/dist/cjs/runner-runtime-control.js +343 -0
- package/dist/cjs/step-executors/conditional-executor.js +63 -0
- package/dist/cjs/step-executors/loop-executor.js +187 -0
- package/dist/cjs/step-executors/parallel-executor.js +51 -0
- package/dist/cjs/step-executors/skip-helpers.js +29 -0
- package/dist/cjs/step-executors/task-executor.js +155 -0
- package/dist/cjs/step-executors/types.js +7 -0
- package/dist/cjs/suspension-rebuilder.js +301 -0
- package/dist/cjs/utils/deferred.js +17 -0
- package/dist/cjs/utils/naming.js +48 -0
- package/dist/cjs/utils/timeout.js +42 -0
- package/dist/cjs/utils/workflow-definition.js +102 -0
- package/dist/cjs/workflow-compiler.js +212 -0
- package/dist/cjs/workflow-event-emitter.js +40 -0
- package/dist/cjs/workflow-runner.js +448 -0
- package/dist/cjs/workflow-types.js +7 -0
- package/dist/cjs/workflow-values.js +99 -0
- package/dist/cjs/workflow.js +243 -0
- package/dist/esm/action/abstract-action.js +137 -0
- package/dist/esm/action/action.js +44 -0
- package/dist/esm/action/action.types.js +6 -0
- package/dist/esm/bindings/base-binding.js +273 -0
- package/dist/esm/context.js +75 -0
- package/dist/esm/dsl.types.js +309 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/runner-runtime-control.js +338 -0
- package/dist/esm/step-executors/conditional-executor.js +60 -0
- package/dist/esm/step-executors/loop-executor.js +182 -0
- package/dist/esm/step-executors/parallel-executor.js +48 -0
- package/dist/esm/step-executors/skip-helpers.js +25 -0
- package/dist/esm/step-executors/task-executor.js +152 -0
- package/dist/esm/step-executors/types.js +6 -0
- package/dist/esm/suspension-rebuilder.js +296 -0
- package/dist/esm/utils/deferred.js +14 -0
- package/dist/esm/utils/naming.js +42 -0
- package/dist/esm/utils/timeout.js +37 -0
- package/dist/esm/utils/workflow-definition.js +98 -0
- package/dist/esm/workflow-compiler.js +208 -0
- package/dist/esm/workflow-event-emitter.js +36 -0
- package/dist/esm/workflow-runner.js +444 -0
- package/dist/esm/workflow-types.js +6 -0
- package/dist/esm/workflow-values.js +89 -0
- package/dist/esm/workflow.js +236 -0
- package/dist/types/action/abstract-action.d.ts +54 -0
- package/dist/types/action/abstract-action.d.ts.map +1 -0
- package/dist/types/action/action.d.ts +23 -0
- package/dist/types/action/action.d.ts.map +1 -0
- package/dist/types/action/action.types.d.ts +55 -0
- package/dist/types/action/action.types.d.ts.map +1 -0
- package/dist/types/bindings/base-binding.d.ts +52 -0
- package/dist/types/bindings/base-binding.d.ts.map +1 -0
- package/dist/types/context.d.ts +105 -0
- package/dist/types/context.d.ts.map +1 -0
- package/dist/types/dsl.types.d.ts +220 -0
- package/dist/types/dsl.types.d.ts.map +1 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/runner-runtime-control.d.ts +64 -0
- package/dist/types/runner-runtime-control.d.ts.map +1 -0
- package/dist/types/step-executors/conditional-executor.d.ts +13 -0
- package/dist/types/step-executors/conditional-executor.d.ts.map +1 -0
- package/dist/types/step-executors/loop-executor.d.ts +33 -0
- package/dist/types/step-executors/loop-executor.d.ts.map +1 -0
- package/dist/types/step-executors/parallel-executor.d.ts +14 -0
- package/dist/types/step-executors/parallel-executor.d.ts.map +1 -0
- package/dist/types/step-executors/skip-helpers.d.ts +12 -0
- package/dist/types/step-executors/skip-helpers.d.ts.map +1 -0
- package/dist/types/step-executors/task-executor.d.ts +13 -0
- package/dist/types/step-executors/task-executor.d.ts.map +1 -0
- package/dist/types/step-executors/types.d.ts +24 -0
- package/dist/types/step-executors/types.d.ts.map +1 -0
- package/dist/types/suspension-rebuilder.d.ts +67 -0
- package/dist/types/suspension-rebuilder.d.ts.map +1 -0
- package/dist/types/utils/deferred.d.ts +7 -0
- package/dist/types/utils/deferred.d.ts.map +1 -0
- package/dist/types/utils/naming.d.ts +24 -0
- package/dist/types/utils/naming.d.ts.map +1 -0
- package/dist/types/utils/timeout.d.ts +17 -0
- package/dist/types/utils/timeout.d.ts.map +1 -0
- package/dist/types/utils/workflow-definition.d.ts +3 -0
- package/dist/types/utils/workflow-definition.d.ts.map +1 -0
- package/dist/types/workflow-compiler.d.ts +12 -0
- package/dist/types/workflow-compiler.d.ts.map +1 -0
- package/dist/types/workflow-event-emitter.d.ts +70 -0
- package/dist/types/workflow-event-emitter.d.ts.map +1 -0
- package/dist/types/workflow-runner.d.ts +179 -0
- package/dist/types/workflow-runner.d.ts.map +1 -0
- package/dist/types/workflow-types.d.ts +195 -0
- package/dist/types/workflow-types.d.ts.map +1 -0
- package/dist/types/workflow-values.d.ts +34 -0
- package/dist/types/workflow-values.d.ts.map +1 -0
- package/dist/types/workflow.d.ts +75 -0
- package/dist/types/workflow.d.ts.map +1 -0
- package/examples/defs-bindings-agent/actions/ai-agent.ts +110 -0
- package/examples/defs-bindings-agent/actions/caculate-score.ts +49 -0
- package/examples/defs-bindings-agent/actions/index.ts +15 -0
- package/examples/defs-bindings-agent/bindings.ts +20 -0
- package/examples/defs-bindings-agent/context.ts +32 -0
- package/examples/defs-bindings-agent/workflow.ts +67 -0
- package/examples/defs-bindings-agent/workflow.yml +28 -0
- package/examples/full/actions/await-user-input.ts +46 -0
- package/examples/full/actions/call-llm.ts +133 -0
- package/examples/full/actions/create-ticket.ts +46 -0
- package/examples/full/actions/decision-router.ts +83 -0
- package/examples/full/actions/get-calendar-events.ts +55 -0
- package/examples/full/actions/get-user-profile.ts +72 -0
- package/examples/full/actions/index.ts +33 -0
- package/examples/full/actions/query-memory.ts +45 -0
- package/examples/full/actions/search-web.ts +53 -0
- package/examples/full/actions/send-email.ts +50 -0
- package/examples/full/context.ts +41 -0
- package/examples/full/workflow.ts +75 -0
- package/examples/full/workflow.yml +359 -0
- package/examples/loop/actions/await-reply.ts +47 -0
- package/examples/loop/actions/index.ts +19 -0
- package/examples/loop/actions/send-text-message.ts +40 -0
- package/examples/loop/context.ts +32 -0
- package/examples/loop/workflow.ts +66 -0
- package/examples/loop/workflow.yml +66 -0
- package/examples/suspend-resume/actions/format-reply.ts +43 -0
- package/examples/suspend-resume/actions/index.ts +13 -0
- package/examples/suspend-resume/actions/wait-for-user.ts +51 -0
- package/examples/suspend-resume/context.ts +32 -0
- package/examples/suspend-resume/workflow.ts +79 -0
- package/examples/suspend-resume/workflow.yml +29 -0
- package/package.json +60 -0
- package/src/__tests__/parser.test.ts +95 -0
- package/src/__tests__/suspension-rebuilder.test.ts +361 -0
- package/src/__tests__/test-helpers.ts +36 -0
- package/src/__tests__/validation.test.ts +526 -0
- package/src/__tests__/workflow-compiler.test.ts +715 -0
- package/src/__tests__/workflow-definition-path.test.ts +381 -0
- package/src/__tests__/workflow-event-emitter.test.ts +50 -0
- package/src/__tests__/workflow-runner.test.ts +1397 -0
- package/src/__tests__/workflow-values.test.ts +132 -0
- package/src/__tests__/workflow.test.ts +320 -0
- package/src/action/__tests__/abstract-action-timing.test.ts +183 -0
- package/src/action/__tests__/action.test.ts +473 -0
- package/src/action/abstract-action.ts +215 -0
- package/src/action/action.ts +83 -0
- package/src/action/action.types.ts +93 -0
- package/src/bindings/base-binding.ts +508 -0
- package/src/context.ts +179 -0
- package/src/dsl.types.ts +473 -0
- package/src/index.ts +114 -0
- package/src/runner-runtime-control.ts +544 -0
- package/src/step-executors/conditional-executor.test.ts +192 -0
- package/src/step-executors/conditional-executor.ts +83 -0
- package/src/step-executors/loop-executor.test.ts +303 -0
- package/src/step-executors/loop-executor.ts +299 -0
- package/src/step-executors/parallel-executor.test.ts +182 -0
- package/src/step-executors/parallel-executor.ts +77 -0
- package/src/step-executors/skip-helpers.ts +68 -0
- package/src/step-executors/task-executor.test.ts +257 -0
- package/src/step-executors/task-executor.ts +248 -0
- package/src/step-executors/types.ts +70 -0
- package/src/suspension-rebuilder.ts +504 -0
- package/src/utils/deferred.ts +23 -0
- package/src/utils/naming.ts +55 -0
- package/src/utils/timeout.ts +48 -0
- package/src/utils/workflow-definition.ts +173 -0
- package/src/workflow-compiler.ts +317 -0
- package/src/workflow-event-emitter.ts +86 -0
- package/src/workflow-runner.ts +603 -0
- package/src/workflow-types.ts +207 -0
- package/src/workflow-values.ts +159 -0
- 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
|
+
});
|