@checkstack/automation-backend 0.2.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/CHANGELOG.md +453 -0
- package/drizzle/0000_acoustic_diamondback.sql +80 -0
- package/drizzle/0001_mute_vindicator.sql +12 -0
- package/drizzle/0002_silky_omega_red.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +688 -0
- package/drizzle/meta/0001_snapshot.json +785 -0
- package/drizzle/meta/0002_snapshot.json +861 -0
- package/drizzle/meta/_journal.json +27 -0
- package/drizzle.config.ts +12 -0
- package/package.json +41 -0
- package/src/action-registry.ts +83 -0
- package/src/action-types.ts +324 -0
- package/src/artifact-store.ts +140 -0
- package/src/artifact-type-registry.ts +64 -0
- package/src/automation-store.ts +227 -0
- package/src/builtin-actions.test.ts +185 -0
- package/src/builtin-actions.ts +132 -0
- package/src/builtin-triggers.test.ts +264 -0
- package/src/builtin-triggers.ts +365 -0
- package/src/dispatch/action-kind.ts +44 -0
- package/src/dispatch/condition.ts +61 -0
- package/src/dispatch/delay-queue.ts +91 -0
- package/src/dispatch/engine.test.ts +1198 -0
- package/src/dispatch/engine.ts +1672 -0
- package/src/dispatch/path-nav.ts +65 -0
- package/src/dispatch/render.test.ts +75 -0
- package/src/dispatch/render.ts +136 -0
- package/src/dispatch/run-state-store.ts +143 -0
- package/src/dispatch/run-state.ts +298 -0
- package/src/dispatch/scope.test.ts +40 -0
- package/src/dispatch/scope.ts +125 -0
- package/src/dispatch/stalled-sweeper.ts +164 -0
- package/src/dispatch/test-fixtures.ts +558 -0
- package/src/dispatch/trigger-subscriber.ts +397 -0
- package/src/dispatch/types.ts +259 -0
- package/src/extension-points.ts +88 -0
- package/src/index.ts +379 -0
- package/src/migration/from-webhook-subscriptions.test.ts +237 -0
- package/src/migration/from-webhook-subscriptions.ts +398 -0
- package/src/registries.test.ts +357 -0
- package/src/router.test.ts +724 -0
- package/src/router.ts +556 -0
- package/src/schema.ts +310 -0
- package/src/trigger-registry.ts +99 -0
- package/src/validate-definition.test.ts +306 -0
- package/src/validate-definition.ts +304 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import { AutomationDefinitionSchema } from "@checkstack/automation-common";
|
|
5
|
+
import { createActionRegistry } from "../action-registry";
|
|
6
|
+
import { createArtifactTypeRegistry } from "../artifact-type-registry";
|
|
7
|
+
import { dispatchTrigger, resumeRun } from "./engine";
|
|
8
|
+
import {
|
|
9
|
+
makeDispatchDeps,
|
|
10
|
+
makeFailingAction,
|
|
11
|
+
makeRecordingAction,
|
|
12
|
+
testPlugin,
|
|
13
|
+
} from "./test-fixtures";
|
|
14
|
+
import type { LoadedAutomation } from "./types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a minimal automation around a list of actions for tests.
|
|
18
|
+
*
|
|
19
|
+
* We accept `unknown[]` so test literals can omit `enabled` /
|
|
20
|
+
* `continue_on_error` — zod fills the defaults during `parse()`.
|
|
21
|
+
*/
|
|
22
|
+
function automation(actions: unknown[]): LoadedAutomation {
|
|
23
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
24
|
+
name: "Test",
|
|
25
|
+
triggers: [{ event: "test.event" }],
|
|
26
|
+
conditions: [],
|
|
27
|
+
actions,
|
|
28
|
+
mode: "single",
|
|
29
|
+
max_runs: 10,
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
id: "auto-1",
|
|
33
|
+
name: "Test",
|
|
34
|
+
status: "enabled",
|
|
35
|
+
definition,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── action ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("dispatch engine — action primitive", () => {
|
|
42
|
+
it("executes a registered action with templated config", async () => {
|
|
43
|
+
const actionsReg = createActionRegistry();
|
|
44
|
+
const rec = makeRecordingAction({ produces: true });
|
|
45
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
46
|
+
|
|
47
|
+
const { deps, runs, artifacts } = makeDispatchDeps({ actions: actionsReg });
|
|
48
|
+
|
|
49
|
+
const result = await dispatchTrigger(deps, {
|
|
50
|
+
automation: automation([
|
|
51
|
+
{
|
|
52
|
+
id: "rec_step",
|
|
53
|
+
action: "test.record",
|
|
54
|
+
config: { value: "{{ trigger.payload.id }}" },
|
|
55
|
+
},
|
|
56
|
+
]),
|
|
57
|
+
triggerId: "test_event",
|
|
58
|
+
triggerEventId: "test.event",
|
|
59
|
+
payload: { id: "incident-42" },
|
|
60
|
+
contextKey: "incident-42",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.status).toBe("success");
|
|
64
|
+
expect(rec.calls).toHaveLength(1);
|
|
65
|
+
expect(rec.calls[0]?.value).toBe("incident-42");
|
|
66
|
+
expect(artifacts.artifacts).toHaveLength(1);
|
|
67
|
+
expect(artifacts.artifacts[0]?.artifactType).toBe("test.recorded");
|
|
68
|
+
expect(artifacts.artifacts[0]?.actionId).toBe("rec_step");
|
|
69
|
+
expect(runs.steps).toHaveLength(1);
|
|
70
|
+
expect(runs.steps[0]?.status).toBe("success");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("nests the produced artifact under artifacts.<id>.<localName> and resolves it downstream", async () => {
|
|
74
|
+
const actionsReg = createActionRegistry();
|
|
75
|
+
const rec = makeRecordingAction({ produces: true });
|
|
76
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
77
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
78
|
+
|
|
79
|
+
const result = await dispatchTrigger(deps, {
|
|
80
|
+
automation: automation([
|
|
81
|
+
{
|
|
82
|
+
id: "produce_it",
|
|
83
|
+
action: "test.record",
|
|
84
|
+
config: { value: "PROJ-1" },
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// This action also produces (it reuses `test.record`), so it must
|
|
88
|
+
// carry an id too — the engine fails producers without one.
|
|
89
|
+
id: "consume_it",
|
|
90
|
+
action: "test.record",
|
|
91
|
+
// The local artifact name for `test.recorded` is `recorded`; the
|
|
92
|
+
// recording action's artifact shape is `{ recorded: <value> }`.
|
|
93
|
+
config: {
|
|
94
|
+
value: "{{ artifacts.produce_it.recorded.recorded }}",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]),
|
|
98
|
+
triggerId: "test_event",
|
|
99
|
+
triggerEventId: "test.event",
|
|
100
|
+
payload: {},
|
|
101
|
+
contextKey: "ck-1",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.status).toBe("success");
|
|
105
|
+
expect(rec.calls).toHaveLength(2);
|
|
106
|
+
// The downstream action's templated config resolved to the produced
|
|
107
|
+
// artifact field.
|
|
108
|
+
expect(rec.calls[1]?.value).toBe("PROJ-1");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fails the run when the action is unknown", async () => {
|
|
112
|
+
const { deps, runs } = makeDispatchDeps();
|
|
113
|
+
const result = await dispatchTrigger(deps, {
|
|
114
|
+
automation: automation([
|
|
115
|
+
{ action: "missing.action", config: {} },
|
|
116
|
+
]),
|
|
117
|
+
triggerId: "test_event",
|
|
118
|
+
triggerEventId: "test.event",
|
|
119
|
+
payload: {},
|
|
120
|
+
contextKey: null,
|
|
121
|
+
});
|
|
122
|
+
expect(result.status).toBe("failed");
|
|
123
|
+
expect(runs.steps[0]?.status).toBe("failed");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("honours continue_on_error", async () => {
|
|
127
|
+
const actionsReg = createActionRegistry();
|
|
128
|
+
actionsReg.register(makeFailingAction(), testPlugin);
|
|
129
|
+
const rec = makeRecordingAction();
|
|
130
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
131
|
+
|
|
132
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
133
|
+
const result = await dispatchTrigger(deps, {
|
|
134
|
+
automation: automation([
|
|
135
|
+
{
|
|
136
|
+
action: "test.fail",
|
|
137
|
+
config: { reason: "expected" },
|
|
138
|
+
continue_on_error: true,
|
|
139
|
+
},
|
|
140
|
+
{ action: "test.record", config: { value: "after-fail" } },
|
|
141
|
+
]),
|
|
142
|
+
triggerId: "test_event",
|
|
143
|
+
triggerEventId: "test.event",
|
|
144
|
+
payload: {},
|
|
145
|
+
contextKey: null,
|
|
146
|
+
});
|
|
147
|
+
expect(result.status).toBe("success");
|
|
148
|
+
expect(rec.calls).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("stops the run on failure without continue_on_error", async () => {
|
|
152
|
+
const actionsReg = createActionRegistry();
|
|
153
|
+
actionsReg.register(makeFailingAction(), testPlugin);
|
|
154
|
+
const rec = makeRecordingAction();
|
|
155
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
156
|
+
|
|
157
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
158
|
+
const result = await dispatchTrigger(deps, {
|
|
159
|
+
automation: automation([
|
|
160
|
+
{ action: "test.fail", config: { reason: "expected" } },
|
|
161
|
+
{ action: "test.record", config: { value: "should not run" } },
|
|
162
|
+
]),
|
|
163
|
+
triggerId: "test_event",
|
|
164
|
+
triggerEventId: "test.event",
|
|
165
|
+
payload: {},
|
|
166
|
+
contextKey: null,
|
|
167
|
+
});
|
|
168
|
+
expect(result.status).toBe("failed");
|
|
169
|
+
expect(rec.calls).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("resolves consumed artifacts via the artifact store", async () => {
|
|
173
|
+
const actionsReg = createActionRegistry();
|
|
174
|
+
// Producer
|
|
175
|
+
actionsReg.register(
|
|
176
|
+
makeRecordingAction({ produces: true }).definition,
|
|
177
|
+
testPlugin,
|
|
178
|
+
);
|
|
179
|
+
// Consumer
|
|
180
|
+
const consumed: Array<Record<string, unknown>> = [];
|
|
181
|
+
actionsReg.register(
|
|
182
|
+
{
|
|
183
|
+
id: "consumer",
|
|
184
|
+
displayName: "Consumer",
|
|
185
|
+
config: new Versioned({ version: 1, schema: z.object({}) }),
|
|
186
|
+
// Local artifact id; resolved against this plugin's namespace
|
|
187
|
+
// (`test.recorded`) and keyed back by the local id.
|
|
188
|
+
consumes: ["recorded"],
|
|
189
|
+
execute: async (ctx) => {
|
|
190
|
+
consumed.push(ctx.consumedArtifacts);
|
|
191
|
+
return { success: true };
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
testPlugin,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
198
|
+
await dispatchTrigger(deps, {
|
|
199
|
+
automation: automation([
|
|
200
|
+
{ id: "producer", action: "test.record", config: { value: "x" } },
|
|
201
|
+
{ action: "test.consumer", config: {} },
|
|
202
|
+
]),
|
|
203
|
+
triggerId: "test_event",
|
|
204
|
+
triggerEventId: "test.event",
|
|
205
|
+
payload: {},
|
|
206
|
+
contextKey: "ck-1",
|
|
207
|
+
});
|
|
208
|
+
expect(consumed[0]?.["recorded"]).toEqual({ recorded: "x" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("skips disabled actions", async () => {
|
|
212
|
+
const actionsReg = createActionRegistry();
|
|
213
|
+
const rec = makeRecordingAction();
|
|
214
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
215
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
216
|
+
|
|
217
|
+
const result = await dispatchTrigger(deps, {
|
|
218
|
+
automation: automation([
|
|
219
|
+
{
|
|
220
|
+
action: "test.record",
|
|
221
|
+
config: { value: "should-skip" },
|
|
222
|
+
enabled: false,
|
|
223
|
+
},
|
|
224
|
+
]),
|
|
225
|
+
triggerId: "test_event",
|
|
226
|
+
triggerEventId: "test.event",
|
|
227
|
+
payload: {},
|
|
228
|
+
contextKey: null,
|
|
229
|
+
});
|
|
230
|
+
expect(result.status).toBe("success");
|
|
231
|
+
expect(rec.calls).toHaveLength(0);
|
|
232
|
+
expect(runs.steps[0]?.status).toBe("skipped");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ─── choose ──────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe("dispatch engine — choose primitive", () => {
|
|
239
|
+
it("runs the matched branch", async () => {
|
|
240
|
+
const actionsReg = createActionRegistry();
|
|
241
|
+
const rec = makeRecordingAction();
|
|
242
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
243
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
244
|
+
|
|
245
|
+
const result = await dispatchTrigger(deps, {
|
|
246
|
+
automation: automation([
|
|
247
|
+
{
|
|
248
|
+
choose: [
|
|
249
|
+
{
|
|
250
|
+
when: "trigger.payload.severity == 'critical'",
|
|
251
|
+
sequence: [{ action: "test.record", config: { value: "high" } }],
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
when: "trigger.payload.severity == 'info'",
|
|
255
|
+
sequence: [{ action: "test.record", config: { value: "low" } }],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
]),
|
|
260
|
+
triggerId: "test_event",
|
|
261
|
+
triggerEventId: "test.event",
|
|
262
|
+
payload: { severity: "critical" },
|
|
263
|
+
contextKey: null,
|
|
264
|
+
});
|
|
265
|
+
expect(result.status).toBe("success");
|
|
266
|
+
expect(rec.calls).toEqual([
|
|
267
|
+
{ value: "high", consumedArtifacts: {} },
|
|
268
|
+
]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("falls through to else when no branch matches", async () => {
|
|
272
|
+
const actionsReg = createActionRegistry();
|
|
273
|
+
const rec = makeRecordingAction();
|
|
274
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
275
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
276
|
+
|
|
277
|
+
const result = await dispatchTrigger(deps, {
|
|
278
|
+
automation: automation([
|
|
279
|
+
{
|
|
280
|
+
choose: [
|
|
281
|
+
{
|
|
282
|
+
when: "false",
|
|
283
|
+
sequence: [{ action: "test.record", config: { value: "no" } }],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
else: [{ action: "test.record", config: { value: "fallback" } }],
|
|
287
|
+
},
|
|
288
|
+
]),
|
|
289
|
+
triggerId: "test_event",
|
|
290
|
+
triggerEventId: "test.event",
|
|
291
|
+
payload: {},
|
|
292
|
+
contextKey: null,
|
|
293
|
+
});
|
|
294
|
+
expect(result.status).toBe("success");
|
|
295
|
+
expect(rec.calls).toEqual([
|
|
296
|
+
{ value: "fallback", consumedArtifacts: {} },
|
|
297
|
+
]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns success with null branch when no match and no else", async () => {
|
|
301
|
+
const { deps } = makeDispatchDeps();
|
|
302
|
+
const result = await dispatchTrigger(deps, {
|
|
303
|
+
automation: automation([
|
|
304
|
+
{
|
|
305
|
+
choose: [{ when: "false", sequence: [{ action: "x.y", config: {} }] }],
|
|
306
|
+
},
|
|
307
|
+
]),
|
|
308
|
+
triggerId: "test_event",
|
|
309
|
+
triggerEventId: "test.event",
|
|
310
|
+
payload: {},
|
|
311
|
+
contextKey: null,
|
|
312
|
+
});
|
|
313
|
+
expect(result.status).toBe("success");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ─── parallel ────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
describe("dispatch engine — parallel primitive", () => {
|
|
320
|
+
it("runs branches concurrently", async () => {
|
|
321
|
+
const actionsReg = createActionRegistry();
|
|
322
|
+
const rec = makeRecordingAction();
|
|
323
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
324
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
325
|
+
|
|
326
|
+
const result = await dispatchTrigger(deps, {
|
|
327
|
+
automation: automation([
|
|
328
|
+
{
|
|
329
|
+
parallel: [
|
|
330
|
+
{ action: "test.record", config: { value: "a" } },
|
|
331
|
+
{ action: "test.record", config: { value: "b" } },
|
|
332
|
+
{ action: "test.record", config: { value: "c" } },
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
]),
|
|
336
|
+
triggerId: "test_event",
|
|
337
|
+
triggerEventId: "test.event",
|
|
338
|
+
payload: {},
|
|
339
|
+
contextKey: null,
|
|
340
|
+
});
|
|
341
|
+
expect(result.status).toBe("success");
|
|
342
|
+
expect(rec.calls.map((c) => c.value).toSorted()).toEqual(["a", "b", "c"]);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ─── delay (queue-backed) ────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
describe("dispatch engine — delay primitive (queue-backed)", () => {
|
|
349
|
+
it("suspends the run and enqueues a scheduled job", async () => {
|
|
350
|
+
const actionsReg = createActionRegistry();
|
|
351
|
+
const rec = makeRecordingAction();
|
|
352
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
353
|
+
const { deps, runs, queue } = makeDispatchDeps({ actions: actionsReg });
|
|
354
|
+
|
|
355
|
+
const auto = automation([
|
|
356
|
+
{ action: "test.record", config: { value: "before-delay" } },
|
|
357
|
+
{ delay: { seconds: 30 } },
|
|
358
|
+
{ action: "test.record", config: { value: "after-delay" } },
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
const result = await dispatchTrigger(deps, {
|
|
362
|
+
automation: auto,
|
|
363
|
+
triggerId: "test_event",
|
|
364
|
+
triggerEventId: "test.event",
|
|
365
|
+
payload: {},
|
|
366
|
+
contextKey: "ck-1",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
expect(result.status).toBe("waiting");
|
|
370
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["before-delay"]);
|
|
371
|
+
expect(runs.waitLocks.size).toBe(1);
|
|
372
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
373
|
+
expect(lock.kind).toBe("delay");
|
|
374
|
+
expect(lock.actionPath).toBe("actions[1]");
|
|
375
|
+
expect(queue.jobs).toHaveLength(1);
|
|
376
|
+
expect(queue.jobs[0]?.queue).toBe("automation-delay");
|
|
377
|
+
expect(queue.jobs[0]?.startDelay).toBe(30);
|
|
378
|
+
|
|
379
|
+
// Simulate the scheduled job firing — call resumeRun directly as the
|
|
380
|
+
// delay-queue consumer would.
|
|
381
|
+
const { resumeRun } = await import("./engine");
|
|
382
|
+
await resumeRun(deps, {
|
|
383
|
+
runId: result.runId,
|
|
384
|
+
automation: auto,
|
|
385
|
+
waitedAtPath: lock.actionPath,
|
|
386
|
+
});
|
|
387
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["before-delay", "after-delay"]);
|
|
388
|
+
expect(runs.runs.get(result.runId)?.status).toBe("success");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("supports delay inside parallel branch", async () => {
|
|
392
|
+
const { deps, queue, runs } = makeDispatchDeps();
|
|
393
|
+
const auto = automation([
|
|
394
|
+
{
|
|
395
|
+
parallel: [{ delay: { seconds: 30 } }],
|
|
396
|
+
},
|
|
397
|
+
]);
|
|
398
|
+
const result = await dispatchTrigger(deps, {
|
|
399
|
+
automation: auto,
|
|
400
|
+
triggerId: "test_event",
|
|
401
|
+
triggerEventId: "test.event",
|
|
402
|
+
payload: {},
|
|
403
|
+
contextKey: null,
|
|
404
|
+
});
|
|
405
|
+
expect(result.status).toBe("waiting");
|
|
406
|
+
expect(queue.jobs).toHaveLength(1);
|
|
407
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
408
|
+
expect(lock.kind).toBe("delay");
|
|
409
|
+
|
|
410
|
+
const { resumeRun } = await import("./engine");
|
|
411
|
+
const resumed = await resumeRun(deps, {
|
|
412
|
+
runId: result.runId,
|
|
413
|
+
automation: auto,
|
|
414
|
+
waitedAtPath: lock.actionPath,
|
|
415
|
+
});
|
|
416
|
+
expect(resumed.status).toBe("success");
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ─── repeat ──────────────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
describe("dispatch engine — repeat primitive", () => {
|
|
423
|
+
it("count mode runs N times", async () => {
|
|
424
|
+
const actionsReg = createActionRegistry();
|
|
425
|
+
const rec = makeRecordingAction();
|
|
426
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
427
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
428
|
+
|
|
429
|
+
await dispatchTrigger(deps, {
|
|
430
|
+
automation: automation([
|
|
431
|
+
{
|
|
432
|
+
repeat: {
|
|
433
|
+
count: 3,
|
|
434
|
+
sequence: [
|
|
435
|
+
{
|
|
436
|
+
action: "test.record",
|
|
437
|
+
config: { value: "{{ repeat.index }}" },
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
]),
|
|
443
|
+
triggerId: "test_event",
|
|
444
|
+
triggerEventId: "test.event",
|
|
445
|
+
payload: {},
|
|
446
|
+
contextKey: null,
|
|
447
|
+
});
|
|
448
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["0", "1", "2"]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("for_each mode iterates a templated array", async () => {
|
|
452
|
+
const actionsReg = createActionRegistry();
|
|
453
|
+
const rec = makeRecordingAction();
|
|
454
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
455
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
456
|
+
|
|
457
|
+
await dispatchTrigger(deps, {
|
|
458
|
+
automation: automation([
|
|
459
|
+
{
|
|
460
|
+
repeat: {
|
|
461
|
+
for_each: "trigger.payload.systems",
|
|
462
|
+
sequence: [
|
|
463
|
+
{
|
|
464
|
+
action: "test.record",
|
|
465
|
+
config: { value: "{{ repeat.item }}" },
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
]),
|
|
471
|
+
triggerId: "test_event",
|
|
472
|
+
triggerEventId: "test.event",
|
|
473
|
+
payload: { systems: ["s1", "s2", "s3"] },
|
|
474
|
+
contextKey: null,
|
|
475
|
+
});
|
|
476
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["s1", "s2", "s3"]);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("while mode honours max_iterations safety", async () => {
|
|
480
|
+
const actionsReg = createActionRegistry();
|
|
481
|
+
actionsReg.register(makeRecordingAction().definition, testPlugin);
|
|
482
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
483
|
+
|
|
484
|
+
const result = await dispatchTrigger(deps, {
|
|
485
|
+
automation: automation([
|
|
486
|
+
{
|
|
487
|
+
repeat: {
|
|
488
|
+
while: "true",
|
|
489
|
+
max_iterations: 5,
|
|
490
|
+
sequence: [{ action: "test.record", config: { value: "x" } }],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
]),
|
|
494
|
+
triggerId: "test_event",
|
|
495
|
+
triggerEventId: "test.event",
|
|
496
|
+
payload: {},
|
|
497
|
+
contextKey: null,
|
|
498
|
+
});
|
|
499
|
+
expect(result.status).toBe("success");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ─── variables ───────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
describe("dispatch engine — variables primitive", () => {
|
|
506
|
+
it("makes locals available in subsequent actions", async () => {
|
|
507
|
+
const actionsReg = createActionRegistry();
|
|
508
|
+
const rec = makeRecordingAction();
|
|
509
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
510
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
511
|
+
|
|
512
|
+
const result = await dispatchTrigger(deps, {
|
|
513
|
+
automation: automation([
|
|
514
|
+
{
|
|
515
|
+
variables: {
|
|
516
|
+
greeting: "Hello, {{ trigger.payload.name }}!",
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
action: "test.record",
|
|
521
|
+
config: { value: "{{ variables.greeting }}" },
|
|
522
|
+
},
|
|
523
|
+
]),
|
|
524
|
+
triggerId: "test_event",
|
|
525
|
+
triggerEventId: "test.event",
|
|
526
|
+
payload: { name: "Nico" },
|
|
527
|
+
contextKey: null,
|
|
528
|
+
});
|
|
529
|
+
expect(result.status).toBe("success");
|
|
530
|
+
expect(rec.calls[0]?.value).toBe("Hello, Nico!");
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ─── condition (guard) ──────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
describe("dispatch engine — condition guard primitive", () => {
|
|
537
|
+
it("passes when condition is truthy", async () => {
|
|
538
|
+
const actionsReg = createActionRegistry();
|
|
539
|
+
const rec = makeRecordingAction();
|
|
540
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
541
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
542
|
+
|
|
543
|
+
const result = await dispatchTrigger(deps, {
|
|
544
|
+
automation: automation([
|
|
545
|
+
{ condition: "true" },
|
|
546
|
+
{ action: "test.record", config: { value: "after" } },
|
|
547
|
+
]),
|
|
548
|
+
triggerId: "test_event",
|
|
549
|
+
triggerEventId: "test.event",
|
|
550
|
+
payload: {},
|
|
551
|
+
contextKey: null,
|
|
552
|
+
});
|
|
553
|
+
expect(result.status).toBe("success");
|
|
554
|
+
expect(rec.calls).toHaveLength(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("halts the run when condition is falsy", async () => {
|
|
558
|
+
const actionsReg = createActionRegistry();
|
|
559
|
+
const rec = makeRecordingAction();
|
|
560
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
561
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
562
|
+
|
|
563
|
+
const result = await dispatchTrigger(deps, {
|
|
564
|
+
automation: automation([
|
|
565
|
+
{ condition: "false" },
|
|
566
|
+
{ action: "test.record", config: { value: "blocked" } },
|
|
567
|
+
]),
|
|
568
|
+
triggerId: "test_event",
|
|
569
|
+
triggerEventId: "test.event",
|
|
570
|
+
payload: {},
|
|
571
|
+
contextKey: null,
|
|
572
|
+
});
|
|
573
|
+
expect(result.status).toBe("success"); // halt without error_flag
|
|
574
|
+
expect(rec.calls).toHaveLength(0);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ─── stop ────────────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
describe("dispatch engine — stop primitive", () => {
|
|
581
|
+
it("ends the run with success when error is false", async () => {
|
|
582
|
+
const actionsReg = createActionRegistry();
|
|
583
|
+
const rec = makeRecordingAction();
|
|
584
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
585
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
586
|
+
|
|
587
|
+
const result = await dispatchTrigger(deps, {
|
|
588
|
+
automation: automation([
|
|
589
|
+
{ stop: { reason: "done", error: false } },
|
|
590
|
+
{ action: "test.record", config: { value: "after-stop" } },
|
|
591
|
+
]),
|
|
592
|
+
triggerId: "test_event",
|
|
593
|
+
triggerEventId: "test.event",
|
|
594
|
+
payload: {},
|
|
595
|
+
contextKey: null,
|
|
596
|
+
});
|
|
597
|
+
expect(result.status).toBe("success");
|
|
598
|
+
expect(rec.calls).toHaveLength(0);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("ends the run with failure when error is true", async () => {
|
|
602
|
+
const { deps } = makeDispatchDeps();
|
|
603
|
+
const result = await dispatchTrigger(deps, {
|
|
604
|
+
automation: automation([
|
|
605
|
+
{ stop: { reason: "boom", error: true } },
|
|
606
|
+
]),
|
|
607
|
+
triggerId: "test_event",
|
|
608
|
+
triggerEventId: "test.event",
|
|
609
|
+
payload: {},
|
|
610
|
+
contextKey: null,
|
|
611
|
+
});
|
|
612
|
+
expect(result.status).toBe("failed");
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// ─── wait_for_trigger ────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
describe("dispatch engine — wait_for_trigger primitive", () => {
|
|
619
|
+
it("suspends the run and records a wait lock", async () => {
|
|
620
|
+
const actionsReg = createActionRegistry();
|
|
621
|
+
const rec = makeRecordingAction();
|
|
622
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
623
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
624
|
+
|
|
625
|
+
const auto = automation([
|
|
626
|
+
{ action: "test.record", config: { value: "before-wait" } },
|
|
627
|
+
{
|
|
628
|
+
wait_for_trigger: {
|
|
629
|
+
event: "incident.resolved",
|
|
630
|
+
timeout_seconds: 3600,
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
{ action: "test.record", config: { value: "after-wait" } },
|
|
634
|
+
]);
|
|
635
|
+
|
|
636
|
+
const result = await dispatchTrigger(deps, {
|
|
637
|
+
automation: auto,
|
|
638
|
+
triggerId: "test_event",
|
|
639
|
+
triggerEventId: "test.event",
|
|
640
|
+
payload: {},
|
|
641
|
+
contextKey: "incident-1",
|
|
642
|
+
});
|
|
643
|
+
expect(result.status).toBe("waiting");
|
|
644
|
+
expect(runs.waitLocks.size).toBe(1);
|
|
645
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["before-wait"]);
|
|
646
|
+
|
|
647
|
+
// ── Simulate the wake-up event arriving ──
|
|
648
|
+
const resumed = await resumeRun(deps, {
|
|
649
|
+
runId: result.runId,
|
|
650
|
+
automation: auto,
|
|
651
|
+
waitedAtPath: "actions[1]",
|
|
652
|
+
payload: { incidentId: "incident-1" },
|
|
653
|
+
});
|
|
654
|
+
expect(resumed.status).toBe("success");
|
|
655
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["before-wait", "after-wait"]);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// ─── Nested waits inside choose ──────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
describe("dispatch engine — nested wait inside choose", () => {
|
|
662
|
+
it("suspends and resumes a wait inside a choose branch", async () => {
|
|
663
|
+
const actionsReg = createActionRegistry();
|
|
664
|
+
const rec = makeRecordingAction();
|
|
665
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
666
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
667
|
+
|
|
668
|
+
const auto = automation([
|
|
669
|
+
{
|
|
670
|
+
choose: [
|
|
671
|
+
{
|
|
672
|
+
when: "trigger.payload.severity == 'critical'",
|
|
673
|
+
sequence: [
|
|
674
|
+
{ action: "test.record", config: { value: "open-jira" } },
|
|
675
|
+
{
|
|
676
|
+
wait_for_trigger: {
|
|
677
|
+
event: "incident.resolved",
|
|
678
|
+
timeout_seconds: 3600,
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
{ action: "test.record", config: { value: "close-jira" } },
|
|
682
|
+
],
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
},
|
|
686
|
+
]);
|
|
687
|
+
|
|
688
|
+
const result = await dispatchTrigger(deps, {
|
|
689
|
+
automation: auto,
|
|
690
|
+
triggerId: "test_event",
|
|
691
|
+
triggerEventId: "test.event",
|
|
692
|
+
payload: { severity: "critical" },
|
|
693
|
+
contextKey: "incident-7",
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
expect(result.status).toBe("waiting");
|
|
697
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["open-jira"]);
|
|
698
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
699
|
+
expect(lock.actionPath).toBe("actions[0].choose[0].sequence[1]");
|
|
700
|
+
|
|
701
|
+
const { resumeRun } = await import("./engine");
|
|
702
|
+
await resumeRun(deps, {
|
|
703
|
+
runId: result.runId,
|
|
704
|
+
automation: auto,
|
|
705
|
+
waitedAtPath: lock.actionPath,
|
|
706
|
+
payload: { resolved: true },
|
|
707
|
+
});
|
|
708
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["open-jira", "close-jira"]);
|
|
709
|
+
expect(runs.runs.get(result.runId)?.status).toBe("success");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// ─── Waits inside parallel ────────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
describe("dispatch engine — wait_for_trigger inside parallel", () => {
|
|
717
|
+
it("suspends the parallel, resumes the suspended branch, then completes", async () => {
|
|
718
|
+
const actionsReg = createActionRegistry();
|
|
719
|
+
const rec = makeRecordingAction();
|
|
720
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
721
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
722
|
+
|
|
723
|
+
const auto = automation([
|
|
724
|
+
{
|
|
725
|
+
parallel: [
|
|
726
|
+
{ action: "test.record", config: { value: "fast-branch" } },
|
|
727
|
+
{
|
|
728
|
+
wait_for_trigger: { event: "incident.resolved" },
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
},
|
|
732
|
+
{ action: "test.record", config: { value: "after-parallel" } },
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
const result = await dispatchTrigger(deps, {
|
|
736
|
+
automation: auto,
|
|
737
|
+
triggerId: "test_event",
|
|
738
|
+
triggerEventId: "test.event",
|
|
739
|
+
payload: {},
|
|
740
|
+
contextKey: "ck-1",
|
|
741
|
+
});
|
|
742
|
+
expect(result.status).toBe("waiting");
|
|
743
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["fast-branch"]);
|
|
744
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
745
|
+
// Single-action parallel branches are walked as wrapped sequences,
|
|
746
|
+
// so the wait's path includes the inner [0] index.
|
|
747
|
+
expect(lock.actionPath).toBe("actions[0].parallel[1][0]");
|
|
748
|
+
|
|
749
|
+
const { resumeRun } = await import("./engine");
|
|
750
|
+
const resumed = await resumeRun(deps, {
|
|
751
|
+
runId: result.runId,
|
|
752
|
+
automation: auto,
|
|
753
|
+
waitedAtPath: lock.actionPath,
|
|
754
|
+
});
|
|
755
|
+
expect(resumed.status).toBe("success");
|
|
756
|
+
expect(rec.calls.map((c) => c.value)).toEqual([
|
|
757
|
+
"fast-branch",
|
|
758
|
+
"after-parallel",
|
|
759
|
+
]);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("supports a multi-action sequence branch with create + wait + close", async () => {
|
|
763
|
+
const actionsReg = createActionRegistry();
|
|
764
|
+
const rec = makeRecordingAction();
|
|
765
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
766
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
767
|
+
|
|
768
|
+
// Mirrors the close-Jira-on-resolve case the user explicitly asked
|
|
769
|
+
// for: one parallel branch is a `sequence` that creates a ticket,
|
|
770
|
+
// waits for resolve, then closes — while a sibling branch posts a
|
|
771
|
+
// Slack note. After both branches finish, the run continues.
|
|
772
|
+
const auto = automation([
|
|
773
|
+
{
|
|
774
|
+
parallel: [
|
|
775
|
+
{
|
|
776
|
+
sequence: [
|
|
777
|
+
{ action: "test.record", config: { value: "jira-create" } },
|
|
778
|
+
{
|
|
779
|
+
wait_for_trigger: { event: "incident.resolved" },
|
|
780
|
+
},
|
|
781
|
+
{ action: "test.record", config: { value: "jira-close" } },
|
|
782
|
+
],
|
|
783
|
+
},
|
|
784
|
+
{ action: "test.record", config: { value: "slack-post" } },
|
|
785
|
+
],
|
|
786
|
+
},
|
|
787
|
+
{ action: "test.record", config: { value: "after-parallel" } },
|
|
788
|
+
]);
|
|
789
|
+
|
|
790
|
+
const result = await dispatchTrigger(deps, {
|
|
791
|
+
automation: auto,
|
|
792
|
+
triggerId: "test_event",
|
|
793
|
+
triggerEventId: "test.event",
|
|
794
|
+
payload: {},
|
|
795
|
+
contextKey: "ck-1",
|
|
796
|
+
});
|
|
797
|
+
expect(result.status).toBe("waiting");
|
|
798
|
+
expect(rec.calls.map((c) => c.value).toSorted()).toEqual([
|
|
799
|
+
"jira-create",
|
|
800
|
+
"slack-post",
|
|
801
|
+
]);
|
|
802
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
803
|
+
// Path goes parallel[0] → sequence wrapper → inner index 1 (the wait).
|
|
804
|
+
expect(lock.actionPath).toBe("actions[0].parallel[0][0].sequence[1]");
|
|
805
|
+
|
|
806
|
+
const { resumeRun } = await import("./engine");
|
|
807
|
+
const resumed = await resumeRun(deps, {
|
|
808
|
+
runId: result.runId,
|
|
809
|
+
automation: auto,
|
|
810
|
+
waitedAtPath: lock.actionPath,
|
|
811
|
+
});
|
|
812
|
+
expect(resumed.status).toBe("success");
|
|
813
|
+
expect(rec.calls.map((c) => c.value)).toContain("jira-close");
|
|
814
|
+
expect(rec.calls.map((c) => c.value)).toContain("after-parallel");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("handles two branches that each suspend on different events", async () => {
|
|
818
|
+
const actionsReg = createActionRegistry();
|
|
819
|
+
const rec = makeRecordingAction();
|
|
820
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
821
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
822
|
+
|
|
823
|
+
const auto = automation([
|
|
824
|
+
{
|
|
825
|
+
parallel: [
|
|
826
|
+
{
|
|
827
|
+
wait_for_trigger: { event: "ticket.closed" },
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
wait_for_trigger: { event: "alert.acked" },
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
},
|
|
834
|
+
{ action: "test.record", config: { value: "done" } },
|
|
835
|
+
]);
|
|
836
|
+
|
|
837
|
+
const result = await dispatchTrigger(deps, {
|
|
838
|
+
automation: auto,
|
|
839
|
+
triggerId: "test_event",
|
|
840
|
+
triggerEventId: "test.event",
|
|
841
|
+
payload: {},
|
|
842
|
+
contextKey: "ck-1",
|
|
843
|
+
});
|
|
844
|
+
expect(result.status).toBe("waiting");
|
|
845
|
+
expect(runs.waitLocks.size).toBe(2);
|
|
846
|
+
|
|
847
|
+
const locks = [...runs.waitLocks.values()];
|
|
848
|
+
const { resumeRun } = await import("./engine");
|
|
849
|
+
// Resume the first branch. Other branch is still suspended.
|
|
850
|
+
const first = await resumeRun(deps, {
|
|
851
|
+
runId: result.runId,
|
|
852
|
+
automation: auto,
|
|
853
|
+
waitedAtPath: locks[0]!.actionPath,
|
|
854
|
+
});
|
|
855
|
+
expect(first.status).toBe("waiting");
|
|
856
|
+
expect(rec.calls).toHaveLength(0); // post-parallel hasn't run yet
|
|
857
|
+
|
|
858
|
+
// Resume the second branch. Now parallel completes and we continue.
|
|
859
|
+
const second = await resumeRun(deps, {
|
|
860
|
+
runId: result.runId,
|
|
861
|
+
automation: auto,
|
|
862
|
+
waitedAtPath: locks[1]!.actionPath,
|
|
863
|
+
});
|
|
864
|
+
expect(second.status).toBe("success");
|
|
865
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["done"]);
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// ─── Waits + delays inside repeat ─────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
describe("dispatch engine — wait_for_trigger inside repeat", () => {
|
|
872
|
+
it("count mode: suspends mid-iteration, resumes, completes remaining iterations", async () => {
|
|
873
|
+
const actionsReg = createActionRegistry();
|
|
874
|
+
const rec = makeRecordingAction();
|
|
875
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
876
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
877
|
+
|
|
878
|
+
const auto = automation([
|
|
879
|
+
{
|
|
880
|
+
repeat: {
|
|
881
|
+
count: 3,
|
|
882
|
+
sequence: [
|
|
883
|
+
{
|
|
884
|
+
action: "test.record",
|
|
885
|
+
config: { value: "iter-{{ repeat.index }}-a" },
|
|
886
|
+
},
|
|
887
|
+
...(0 === 0
|
|
888
|
+
? []
|
|
889
|
+
: []),
|
|
890
|
+
{
|
|
891
|
+
wait_for_trigger: { event: "tick" },
|
|
892
|
+
enabled: false, // first run: no suspend
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
action: "test.record",
|
|
896
|
+
config: { value: "iter-{{ repeat.index }}-b" },
|
|
897
|
+
},
|
|
898
|
+
],
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
]);
|
|
902
|
+
const result = await dispatchTrigger(deps, {
|
|
903
|
+
automation: auto,
|
|
904
|
+
triggerId: "test_event",
|
|
905
|
+
triggerEventId: "test.event",
|
|
906
|
+
payload: {},
|
|
907
|
+
contextKey: null,
|
|
908
|
+
});
|
|
909
|
+
expect(result.status).toBe("success");
|
|
910
|
+
expect(rec.calls.map((c) => c.value)).toEqual([
|
|
911
|
+
"iter-0-a",
|
|
912
|
+
"iter-0-b",
|
|
913
|
+
"iter-1-a",
|
|
914
|
+
"iter-1-b",
|
|
915
|
+
"iter-2-a",
|
|
916
|
+
"iter-2-b",
|
|
917
|
+
]);
|
|
918
|
+
// Sanity: no wait locks were created (the wait was disabled).
|
|
919
|
+
expect(runs.waitLocks.size).toBe(0);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("count mode: real wait suspends iteration 1, resumes, finishes iterations 1 + 2", async () => {
|
|
923
|
+
const actionsReg = createActionRegistry();
|
|
924
|
+
const rec = makeRecordingAction();
|
|
925
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
926
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
927
|
+
|
|
928
|
+
// Iteration 0 runs through; iteration 1 hits the wait.
|
|
929
|
+
const auto = automation([
|
|
930
|
+
{
|
|
931
|
+
repeat: {
|
|
932
|
+
count: 3,
|
|
933
|
+
sequence: [
|
|
934
|
+
{
|
|
935
|
+
action: "test.record",
|
|
936
|
+
config: { value: "i{{ repeat.index }}-pre" },
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
// Only suspend in iteration 1.
|
|
940
|
+
choose: [
|
|
941
|
+
{
|
|
942
|
+
when: "repeat.index == 1",
|
|
943
|
+
sequence: [
|
|
944
|
+
{
|
|
945
|
+
wait_for_trigger: { event: "go" },
|
|
946
|
+
},
|
|
947
|
+
],
|
|
948
|
+
},
|
|
949
|
+
],
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
action: "test.record",
|
|
953
|
+
config: { value: "i{{ repeat.index }}-post" },
|
|
954
|
+
},
|
|
955
|
+
],
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
]);
|
|
959
|
+
|
|
960
|
+
const result = await dispatchTrigger(deps, {
|
|
961
|
+
automation: auto,
|
|
962
|
+
triggerId: "test_event",
|
|
963
|
+
triggerEventId: "test.event",
|
|
964
|
+
payload: {},
|
|
965
|
+
contextKey: null,
|
|
966
|
+
});
|
|
967
|
+
expect(result.status).toBe("waiting");
|
|
968
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["i0-pre", "i0-post", "i1-pre"]);
|
|
969
|
+
|
|
970
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
971
|
+
expect(lock.actionPath).toBe(
|
|
972
|
+
"actions[0].repeat[1].sequence[1].choose[0].sequence[0]",
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
const { resumeRun } = await import("./engine");
|
|
976
|
+
const resumed = await resumeRun(deps, {
|
|
977
|
+
runId: result.runId,
|
|
978
|
+
automation: auto,
|
|
979
|
+
waitedAtPath: lock.actionPath,
|
|
980
|
+
});
|
|
981
|
+
expect(resumed.status).toBe("success");
|
|
982
|
+
expect(rec.calls.map((c) => c.value)).toEqual([
|
|
983
|
+
"i0-pre",
|
|
984
|
+
"i0-post",
|
|
985
|
+
"i1-pre",
|
|
986
|
+
"i1-post",
|
|
987
|
+
"i2-pre",
|
|
988
|
+
"i2-post",
|
|
989
|
+
]);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("for_each: caches the list so resume sees the same iteration sequence", async () => {
|
|
993
|
+
const actionsReg = createActionRegistry();
|
|
994
|
+
const rec = makeRecordingAction();
|
|
995
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
996
|
+
const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
|
|
997
|
+
|
|
998
|
+
const auto = automation([
|
|
999
|
+
{
|
|
1000
|
+
repeat: {
|
|
1001
|
+
for_each: "trigger.payload.systems",
|
|
1002
|
+
sequence: [
|
|
1003
|
+
{
|
|
1004
|
+
action: "test.record",
|
|
1005
|
+
config: { value: "{{ repeat.item }}" },
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
choose: [
|
|
1009
|
+
{
|
|
1010
|
+
when: "repeat.item == 's2'",
|
|
1011
|
+
sequence: [
|
|
1012
|
+
{
|
|
1013
|
+
wait_for_trigger: { event: "ack" },
|
|
1014
|
+
},
|
|
1015
|
+
],
|
|
1016
|
+
},
|
|
1017
|
+
],
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
]);
|
|
1023
|
+
|
|
1024
|
+
const result = await dispatchTrigger(deps, {
|
|
1025
|
+
automation: auto,
|
|
1026
|
+
triggerId: "test_event",
|
|
1027
|
+
triggerEventId: "test.event",
|
|
1028
|
+
payload: { systems: ["s1", "s2", "s3"] },
|
|
1029
|
+
contextKey: null,
|
|
1030
|
+
});
|
|
1031
|
+
expect(result.status).toBe("waiting");
|
|
1032
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["s1", "s2"]);
|
|
1033
|
+
|
|
1034
|
+
const lock = [...runs.waitLocks.values()][0]!;
|
|
1035
|
+
const { resumeRun } = await import("./engine");
|
|
1036
|
+
const resumed = await resumeRun(deps, {
|
|
1037
|
+
runId: result.runId,
|
|
1038
|
+
automation: auto,
|
|
1039
|
+
waitedAtPath: lock.actionPath,
|
|
1040
|
+
});
|
|
1041
|
+
expect(resumed.status).toBe("success");
|
|
1042
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["s1", "s2", "s3"]);
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// ─── Durable state: snapshot + sweeper recovery ──────────────────────────
|
|
1047
|
+
|
|
1048
|
+
describe("dispatch engine — durable state + sweeper recovery", () => {
|
|
1049
|
+
it("writes a scope snapshot after each successful step", async () => {
|
|
1050
|
+
const actionsReg = createActionRegistry();
|
|
1051
|
+
const rec = makeRecordingAction();
|
|
1052
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
1053
|
+
const { deps, state } = makeDispatchDeps({ actions: actionsReg });
|
|
1054
|
+
|
|
1055
|
+
const result = await dispatchTrigger(deps, {
|
|
1056
|
+
automation: automation([
|
|
1057
|
+
{ variables: { x: "v1" } },
|
|
1058
|
+
{ action: "test.record", config: { value: "{{ variables.x }}" } },
|
|
1059
|
+
]),
|
|
1060
|
+
triggerId: "test_event",
|
|
1061
|
+
triggerEventId: "test.event",
|
|
1062
|
+
payload: {},
|
|
1063
|
+
contextKey: null,
|
|
1064
|
+
});
|
|
1065
|
+
expect(result.status).toBe("success");
|
|
1066
|
+
// Snapshot is cleared on terminal status.
|
|
1067
|
+
expect(state.states.size).toBe(0);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("recoverStalledRun resumes from the persisted snapshot", async () => {
|
|
1071
|
+
const actionsReg = createActionRegistry();
|
|
1072
|
+
const rec = makeRecordingAction();
|
|
1073
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
1074
|
+
const { deps, runs, state } = makeDispatchDeps({ actions: actionsReg });
|
|
1075
|
+
|
|
1076
|
+
const auto = automation([
|
|
1077
|
+
{ action: "test.record", config: { value: "step-1" } },
|
|
1078
|
+
{ action: "test.record", config: { value: "step-2" } },
|
|
1079
|
+
{ action: "test.record", config: { value: "step-3" } },
|
|
1080
|
+
]);
|
|
1081
|
+
|
|
1082
|
+
// Simulate a process that completed step-1 then "crashed".
|
|
1083
|
+
const runId = await deps.runStore.createRun({
|
|
1084
|
+
automationId: auto.id,
|
|
1085
|
+
triggerId: "test_event",
|
|
1086
|
+
triggerEventId: "test.event",
|
|
1087
|
+
triggerPayload: {},
|
|
1088
|
+
contextKey: null,
|
|
1089
|
+
});
|
|
1090
|
+
await deps.runStateStore.upsert({
|
|
1091
|
+
runId,
|
|
1092
|
+
scopeSnapshot: {
|
|
1093
|
+
trigger: { id: "test_event", eventId: "test.event", payload: {} },
|
|
1094
|
+
variables: {},
|
|
1095
|
+
artifacts: {},
|
|
1096
|
+
now: new Date().toISOString(),
|
|
1097
|
+
},
|
|
1098
|
+
lastActionPath: "actions[0]",
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
const { recoverStalledRun } = await import("./engine");
|
|
1102
|
+
const recovered = await recoverStalledRun(deps, {
|
|
1103
|
+
runId,
|
|
1104
|
+
automation: auto,
|
|
1105
|
+
});
|
|
1106
|
+
expect(recovered.status).toBe("success");
|
|
1107
|
+
// step-1 is treated as "already done", so only step-2 + step-3 fire.
|
|
1108
|
+
expect(rec.calls.map((c) => c.value)).toEqual(["step-2", "step-3"]);
|
|
1109
|
+
expect(runs.runs.get(runId)?.status).toBe("success");
|
|
1110
|
+
expect(state.states.size).toBe(0);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("recoverStalledRun fails the run if the snapshot is missing", async () => {
|
|
1114
|
+
const { deps } = makeDispatchDeps();
|
|
1115
|
+
const auto = automation([{ delay: { seconds: 0 } }]);
|
|
1116
|
+
|
|
1117
|
+
const runId = await deps.runStore.createRun({
|
|
1118
|
+
automationId: auto.id,
|
|
1119
|
+
triggerId: "test_event",
|
|
1120
|
+
triggerEventId: "test.event",
|
|
1121
|
+
triggerPayload: {},
|
|
1122
|
+
contextKey: null,
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
const { recoverStalledRun } = await import("./engine");
|
|
1126
|
+
const result = await recoverStalledRun(deps, { runId, automation: auto });
|
|
1127
|
+
expect(result.status).toBe("failed");
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// ─── Advisory lock prevents double-resume ────────────────────────────────
|
|
1132
|
+
|
|
1133
|
+
describe("dispatch engine — advisory lock", () => {
|
|
1134
|
+
it("blocks a second resumer while one is in-flight", async () => {
|
|
1135
|
+
const actionsReg = createActionRegistry();
|
|
1136
|
+
actionsReg.register(makeRecordingAction().definition, testPlugin);
|
|
1137
|
+
const { deps } = makeDispatchDeps({ actions: actionsReg });
|
|
1138
|
+
|
|
1139
|
+
const auto = automation([
|
|
1140
|
+
{
|
|
1141
|
+
wait_for_trigger: { event: "x.y" },
|
|
1142
|
+
},
|
|
1143
|
+
{ action: "test.record", config: { value: "after" } },
|
|
1144
|
+
]);
|
|
1145
|
+
|
|
1146
|
+
const result = await dispatchTrigger(deps, {
|
|
1147
|
+
automation: auto,
|
|
1148
|
+
triggerId: "test_event",
|
|
1149
|
+
triggerEventId: "test.event",
|
|
1150
|
+
payload: {},
|
|
1151
|
+
contextKey: null,
|
|
1152
|
+
});
|
|
1153
|
+
expect(result.status).toBe("waiting");
|
|
1154
|
+
|
|
1155
|
+
// Acquire the advisory lock externally, simulating another instance.
|
|
1156
|
+
await deps.runStateStore.tryAdvisoryLock(result.runId);
|
|
1157
|
+
|
|
1158
|
+
const { resumeRun } = await import("./engine");
|
|
1159
|
+
const blocked = await resumeRun(deps, {
|
|
1160
|
+
runId: result.runId,
|
|
1161
|
+
automation: auto,
|
|
1162
|
+
waitedAtPath: "actions[0]",
|
|
1163
|
+
});
|
|
1164
|
+
// Resume returned the current run status without progressing.
|
|
1165
|
+
expect(blocked.status).toBe("waiting");
|
|
1166
|
+
|
|
1167
|
+
// Release and retry — now it succeeds.
|
|
1168
|
+
await deps.runStateStore.releaseAdvisoryLock(result.runId);
|
|
1169
|
+
const unblocked = await resumeRun(deps, {
|
|
1170
|
+
runId: result.runId,
|
|
1171
|
+
automation: auto,
|
|
1172
|
+
waitedAtPath: "actions[0]",
|
|
1173
|
+
});
|
|
1174
|
+
expect(unblocked.status).toBe("success");
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// ─── Concurrency mode integration ───────────────────────────────────────
|
|
1179
|
+
|
|
1180
|
+
describe("dispatch engine — run lifecycle", () => {
|
|
1181
|
+
it("starts in running and finishes in success", async () => {
|
|
1182
|
+
const { deps, runs } = makeDispatchDeps();
|
|
1183
|
+
const result = await dispatchTrigger(deps, {
|
|
1184
|
+
automation: automation([]),
|
|
1185
|
+
triggerId: "test_event",
|
|
1186
|
+
triggerEventId: "test.event",
|
|
1187
|
+
payload: {},
|
|
1188
|
+
contextKey: null,
|
|
1189
|
+
});
|
|
1190
|
+
expect(result.status).toBe("success");
|
|
1191
|
+
const run = runs.runs.get(result.runId);
|
|
1192
|
+
expect(run?.status).toBe("success");
|
|
1193
|
+
expect(run?.finishedAt).not.toBeNull();
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
// Make use of the artifact-type registry helper to keep its import live.
|
|
1198
|
+
void createArtifactTypeRegistry;
|