@checkstack/automation-backend 0.2.0 → 0.3.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 +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -5,12 +5,9 @@
|
|
|
5
5
|
* registries so engine tests can run without a real database or queue.
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { Versioned
|
|
8
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
9
9
|
import { createDefaultFilterRegistry } from "@checkstack/template-engine";
|
|
10
|
-
import type {
|
|
11
|
-
ActionDefinition,
|
|
12
|
-
TriggerDefinition,
|
|
13
|
-
} from "../action-types";
|
|
10
|
+
import type { ActionDefinition } from "../action-types";
|
|
14
11
|
import { createActionRegistry, type ActionRegistry } from "../action-registry";
|
|
15
12
|
import {
|
|
16
13
|
createArtifactTypeRegistry,
|
|
@@ -23,14 +20,28 @@ import type {
|
|
|
23
20
|
CreateRunInput,
|
|
24
21
|
CreateStepInput,
|
|
25
22
|
CreateWaitLockInput,
|
|
23
|
+
CreateWaitLockWithRefsInput,
|
|
26
24
|
DispatchDeps,
|
|
25
|
+
DwellStore,
|
|
26
|
+
LoadedDwell,
|
|
27
27
|
LoadedRun,
|
|
28
28
|
LoadedWaitLock,
|
|
29
|
+
RecordWindowInput,
|
|
29
30
|
RunStore,
|
|
31
|
+
UpsertDwellInput,
|
|
32
|
+
WindowStore,
|
|
30
33
|
} from "./types";
|
|
31
34
|
import type { RunStateSnapshot, RunStateStore } from "./run-state-store";
|
|
32
35
|
|
|
33
|
-
export function createInMemoryRunStore(
|
|
36
|
+
export function createInMemoryRunStore(opts?: {
|
|
37
|
+
/**
|
|
38
|
+
* Called with the ids of runs cancelled by `cancelActiveRuns`, so a
|
|
39
|
+
* caller (makeDispatchDeps) can clear the corresponding run-state rows -
|
|
40
|
+
* faithfully modelling the real store, which deletes wait locks AND
|
|
41
|
+
* run-state in the same operation.
|
|
42
|
+
*/
|
|
43
|
+
onCancel?: (cancelledRunIds: string[]) => void;
|
|
44
|
+
}): {
|
|
34
45
|
store: RunStore;
|
|
35
46
|
runs: Map<string, LoadedRun>;
|
|
36
47
|
steps: Array<{
|
|
@@ -46,6 +57,8 @@ export function createInMemoryRunStore(): {
|
|
|
46
57
|
resultPayload?: Record<string, unknown>;
|
|
47
58
|
}>;
|
|
48
59
|
waitLocks: Map<string, LoadedWaitLock>;
|
|
60
|
+
/** Wake-index rows keyed by wait-lock id → set of refs (reactive §8). */
|
|
61
|
+
wakeRefs: Map<string, Set<string>>;
|
|
49
62
|
} {
|
|
50
63
|
const runs = new Map<string, LoadedRun>();
|
|
51
64
|
const steps: Array<{
|
|
@@ -61,6 +74,8 @@ export function createInMemoryRunStore(): {
|
|
|
61
74
|
resultPayload?: Record<string, unknown>;
|
|
62
75
|
}> = [];
|
|
63
76
|
const waitLocks = new Map<string, LoadedWaitLock>();
|
|
77
|
+
const wakeRefs = new Map<string, Set<string>>();
|
|
78
|
+
const onCancel = opts?.onCancel;
|
|
64
79
|
let runCounter = 0;
|
|
65
80
|
let stepCounter = 0;
|
|
66
81
|
let lockCounter = 0;
|
|
@@ -96,27 +111,29 @@ export function createInMemoryRunStore(): {
|
|
|
96
111
|
async loadRun(runId) {
|
|
97
112
|
return runs.get(runId);
|
|
98
113
|
},
|
|
99
|
-
async countActiveRuns(automationId) {
|
|
114
|
+
async countActiveRuns(automationId, contextKey?) {
|
|
100
115
|
let count = 0;
|
|
101
116
|
for (const r of runs.values()) {
|
|
102
117
|
if (
|
|
103
118
|
r.automationId === automationId &&
|
|
104
|
-
["pending", "running", "waiting"].includes(r.status)
|
|
119
|
+
["pending", "running", "waiting"].includes(r.status) &&
|
|
120
|
+
(contextKey === undefined || r.contextKey === contextKey)
|
|
105
121
|
) {
|
|
106
122
|
count += 1;
|
|
107
123
|
}
|
|
108
124
|
}
|
|
109
125
|
return count;
|
|
110
126
|
},
|
|
111
|
-
async hasActiveRun(automationId) {
|
|
112
|
-
return (await this.countActiveRuns(automationId)) > 0;
|
|
127
|
+
async hasActiveRun(automationId, contextKey?) {
|
|
128
|
+
return (await this.countActiveRuns(automationId, contextKey)) > 0;
|
|
113
129
|
},
|
|
114
|
-
async cancelActiveRuns(automationId, reason) {
|
|
130
|
+
async cancelActiveRuns(automationId, reason, contextKey?) {
|
|
115
131
|
const cancelled: string[] = [];
|
|
116
132
|
for (const r of runs.values()) {
|
|
117
133
|
if (
|
|
118
134
|
r.automationId === automationId &&
|
|
119
|
-
["pending", "running", "waiting"].includes(r.status)
|
|
135
|
+
["pending", "running", "waiting"].includes(r.status) &&
|
|
136
|
+
(contextKey === undefined || r.contextKey === contextKey)
|
|
120
137
|
) {
|
|
121
138
|
r.status = "cancelled";
|
|
122
139
|
r.errorMessage = reason;
|
|
@@ -124,6 +141,19 @@ export function createInMemoryRunStore(): {
|
|
|
124
141
|
cancelled.push(r.id);
|
|
125
142
|
}
|
|
126
143
|
}
|
|
144
|
+
// Faithful model of the real store: tear down the cancelled runs'
|
|
145
|
+
// wait locks in the same operation so a later wake can't resurrect
|
|
146
|
+
// them. (Run-state clearing happens via the optional hook wired in
|
|
147
|
+
// makeDispatchDeps.)
|
|
148
|
+
if (cancelled.length > 0) {
|
|
149
|
+
for (const [id, lock] of waitLocks) {
|
|
150
|
+
if (cancelled.includes(lock.runId)) {
|
|
151
|
+
waitLocks.delete(id);
|
|
152
|
+
wakeRefs.delete(id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
onCancel?.(cancelled);
|
|
156
|
+
}
|
|
127
157
|
return cancelled;
|
|
128
158
|
},
|
|
129
159
|
|
|
@@ -181,8 +211,26 @@ export function createInMemoryRunStore(): {
|
|
|
181
211
|
contextKey: input.contextKey,
|
|
182
212
|
filterTemplate: input.filterTemplate,
|
|
183
213
|
timeoutAt: input.timeoutAt,
|
|
214
|
+
waitConfig: input.waitConfig ?? null,
|
|
215
|
+
createdAt: new Date(),
|
|
216
|
+
});
|
|
217
|
+
return id;
|
|
218
|
+
},
|
|
219
|
+
async createWaitLockWithWakeRefs(input: CreateWaitLockWithRefsInput) {
|
|
220
|
+
const id = `lock-${++lockCounter}`;
|
|
221
|
+
waitLocks.set(id, {
|
|
222
|
+
id,
|
|
223
|
+
runId: input.runId,
|
|
224
|
+
actionPath: input.actionPath,
|
|
225
|
+
kind: "until",
|
|
226
|
+
eventId: input.eventId,
|
|
227
|
+
contextKey: input.contextKey,
|
|
228
|
+
filterTemplate: null,
|
|
229
|
+
timeoutAt: input.timeoutAt,
|
|
230
|
+
waitConfig: input.waitConfig,
|
|
184
231
|
createdAt: new Date(),
|
|
185
232
|
});
|
|
233
|
+
wakeRefs.set(id, new Set(input.wakeRefs));
|
|
186
234
|
return id;
|
|
187
235
|
},
|
|
188
236
|
async loadWaitLock(id) {
|
|
@@ -197,8 +245,27 @@ export function createInMemoryRunStore(): {
|
|
|
197
245
|
}
|
|
198
246
|
return matches;
|
|
199
247
|
},
|
|
248
|
+
async findWaitLocksByWakeRef(ref) {
|
|
249
|
+
const colon = ref.indexOf(":");
|
|
250
|
+
const kind = colon === -1 ? ref : ref.slice(0, colon);
|
|
251
|
+
const wildcard = `${kind}:*`;
|
|
252
|
+
const matches: LoadedWaitLock[] = [];
|
|
253
|
+
for (const [lockId, refs] of wakeRefs) {
|
|
254
|
+
const lock = waitLocks.get(lockId);
|
|
255
|
+
if (!lock || lock.kind !== "until") continue;
|
|
256
|
+
if (refs.has(ref) || refs.has(wildcard)) matches.push(lock);
|
|
257
|
+
}
|
|
258
|
+
return matches;
|
|
259
|
+
},
|
|
260
|
+
async findWaitLocksByKind(kind) {
|
|
261
|
+
return [...waitLocks.values()].filter((lock) => lock.kind === kind);
|
|
262
|
+
},
|
|
263
|
+
async findWaitLocksByRun(runId) {
|
|
264
|
+
return [...waitLocks.values()].filter((lock) => lock.runId === runId);
|
|
265
|
+
},
|
|
200
266
|
async deleteWaitLock(id) {
|
|
201
267
|
waitLocks.delete(id);
|
|
268
|
+
wakeRefs.delete(id);
|
|
202
269
|
},
|
|
203
270
|
async sweepExpiredWaitLocks(now) {
|
|
204
271
|
const expired: LoadedWaitLock[] = [];
|
|
@@ -211,7 +278,7 @@ export function createInMemoryRunStore(): {
|
|
|
211
278
|
},
|
|
212
279
|
};
|
|
213
280
|
|
|
214
|
-
return { store, runs, steps, waitLocks };
|
|
281
|
+
return { store, runs, steps, waitLocks, wakeRefs };
|
|
215
282
|
}
|
|
216
283
|
|
|
217
284
|
export function createInMemoryArtifactStore(): {
|
|
@@ -267,7 +334,15 @@ export function createInMemoryArtifactStore(): {
|
|
|
267
334
|
return { store, artifacts };
|
|
268
335
|
}
|
|
269
336
|
|
|
270
|
-
|
|
337
|
+
/**
|
|
338
|
+
* @param runs - the run store's `runs` map, so `findStalledRunIds`
|
|
339
|
+
* faithfully models the real DB join that filters to `status =
|
|
340
|
+
* 'running'`. Without it the fake would skip the status filter and a
|
|
341
|
+
* test couldn't observe the C1 fix (a `waiting` run must NOT be swept).
|
|
342
|
+
*/
|
|
343
|
+
export function createInMemoryRunStateStore(
|
|
344
|
+
runs?: Map<string, LoadedRun>,
|
|
345
|
+
): {
|
|
271
346
|
store: RunStateStore;
|
|
272
347
|
states: Map<string, RunStateSnapshot>;
|
|
273
348
|
locks: Set<string>;
|
|
@@ -277,9 +352,16 @@ export function createInMemoryRunStateStore(): {
|
|
|
277
352
|
|
|
278
353
|
const store: RunStateStore = {
|
|
279
354
|
async upsert(input) {
|
|
355
|
+
const existing = states.get(input.runId);
|
|
356
|
+
// Mirror the real store: omitting `lastActionPath` on an existing
|
|
357
|
+
// row preserves the prior checkpoint; passing it (incl. null) sets it.
|
|
358
|
+
const lastActionPath =
|
|
359
|
+
input.lastActionPath === undefined
|
|
360
|
+
? (existing?.lastActionPath ?? null)
|
|
361
|
+
: input.lastActionPath;
|
|
280
362
|
states.set(input.runId, {
|
|
281
363
|
scopeSnapshot: input.scopeSnapshot,
|
|
282
|
-
lastActionPath
|
|
364
|
+
lastActionPath,
|
|
283
365
|
lastHeartbeatAt: new Date(),
|
|
284
366
|
});
|
|
285
367
|
},
|
|
@@ -296,23 +378,169 @@ export function createInMemoryRunStateStore(): {
|
|
|
296
378
|
async findStalledRunIds(threshold) {
|
|
297
379
|
const ids: string[] = [];
|
|
298
380
|
for (const [runId, s] of states.entries()) {
|
|
299
|
-
if (s.lastHeartbeatAt
|
|
381
|
+
if (s.lastHeartbeatAt >= threshold) continue;
|
|
382
|
+
// Faithful model of the DB join: only `running` runs are stalled.
|
|
383
|
+
if (runs && runs.get(runId)?.status !== "running") continue;
|
|
384
|
+
ids.push(runId);
|
|
300
385
|
}
|
|
301
386
|
return ids;
|
|
302
387
|
},
|
|
303
388
|
async tryAdvisoryLock(runId) {
|
|
304
|
-
if (locks.has(runId)) return
|
|
389
|
+
if (locks.has(runId)) return null;
|
|
305
390
|
locks.add(runId);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
391
|
+
let released = false;
|
|
392
|
+
return {
|
|
393
|
+
async release() {
|
|
394
|
+
if (released) return;
|
|
395
|
+
released = true;
|
|
396
|
+
locks.delete(runId);
|
|
397
|
+
},
|
|
398
|
+
};
|
|
310
399
|
},
|
|
311
400
|
};
|
|
312
401
|
|
|
313
402
|
return { store, states, locks };
|
|
314
403
|
}
|
|
315
404
|
|
|
405
|
+
export function createInMemoryDwellStore(): {
|
|
406
|
+
store: DwellStore;
|
|
407
|
+
dwells: Map<string, LoadedDwell>;
|
|
408
|
+
} {
|
|
409
|
+
const dwells = new Map<string, LoadedDwell>();
|
|
410
|
+
let counter = 0;
|
|
411
|
+
|
|
412
|
+
const matchesKey = (
|
|
413
|
+
d: LoadedDwell,
|
|
414
|
+
automationId: string,
|
|
415
|
+
triggerId: string,
|
|
416
|
+
contextKey: string | null,
|
|
417
|
+
) =>
|
|
418
|
+
d.automationId === automationId &&
|
|
419
|
+
d.triggerId === triggerId &&
|
|
420
|
+
d.contextKey === contextKey;
|
|
421
|
+
|
|
422
|
+
const store: DwellStore = {
|
|
423
|
+
async arm(input: UpsertDwellInput) {
|
|
424
|
+
// Insert-if-absent: preserve an existing dwell's original fireAt.
|
|
425
|
+
const existing = [...dwells.values()].find((d) =>
|
|
426
|
+
matchesKey(d, input.automationId, input.triggerId, input.contextKey),
|
|
427
|
+
);
|
|
428
|
+
if (existing) {
|
|
429
|
+
return { id: existing.id, created: false, fireAt: existing.fireAt };
|
|
430
|
+
}
|
|
431
|
+
const id = `dwell-${++counter}`;
|
|
432
|
+
dwells.set(id, {
|
|
433
|
+
id,
|
|
434
|
+
automationId: input.automationId,
|
|
435
|
+
triggerId: input.triggerId,
|
|
436
|
+
eventId: input.eventId,
|
|
437
|
+
contextKey: input.contextKey,
|
|
438
|
+
armedStatus: input.armedStatus,
|
|
439
|
+
payloadSnapshot: input.payloadSnapshot,
|
|
440
|
+
actorSnapshot: input.actorSnapshot,
|
|
441
|
+
fireAt: input.fireAt,
|
|
442
|
+
createdAt: new Date(),
|
|
443
|
+
});
|
|
444
|
+
return { id, created: true, fireAt: input.fireAt };
|
|
445
|
+
},
|
|
446
|
+
async load(id) {
|
|
447
|
+
return dwells.get(id);
|
|
448
|
+
},
|
|
449
|
+
async findByKey(automationId, triggerId, contextKey) {
|
|
450
|
+
return [...dwells.values()].find((d) =>
|
|
451
|
+
matchesKey(d, automationId, triggerId, contextKey),
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
async delete(id) {
|
|
455
|
+
// Map.delete returns true only if the key existed — a faithful
|
|
456
|
+
// model of the DB's `DELETE ... RETURNING` atomic claim.
|
|
457
|
+
return dwells.delete(id);
|
|
458
|
+
},
|
|
459
|
+
async deleteByKey(automationId, triggerId, contextKey) {
|
|
460
|
+
for (const [id, d] of dwells.entries()) {
|
|
461
|
+
if (matchesKey(d, automationId, triggerId, contextKey)) dwells.delete(id);
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
async deleteForAutomation(automationId) {
|
|
465
|
+
for (const [id, d] of dwells.entries()) {
|
|
466
|
+
if (d.automationId === automationId) dwells.delete(id);
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
async sweepExpired(now) {
|
|
470
|
+
return [...dwells.values()].filter(
|
|
471
|
+
(d) => d.fireAt.getTime() <= now.getTime(),
|
|
472
|
+
);
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
return { store, dwells };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* In-memory `WindowStore` mirroring the SQL append-log semantics: each
|
|
481
|
+
* `recordAndCount` appends an occurrence, counts rows for the key within the
|
|
482
|
+
* trailing window (inclusive), and applies the re-fire policy. Faithful to
|
|
483
|
+
* `window-store.ts` so the gate's behaviour is testable without a DB.
|
|
484
|
+
*/
|
|
485
|
+
export function createInMemoryWindowStore(): {
|
|
486
|
+
store: WindowStore;
|
|
487
|
+
events: Array<{
|
|
488
|
+
automationId: string;
|
|
489
|
+
triggerId: string;
|
|
490
|
+
eventId: string;
|
|
491
|
+
contextKey: string | null;
|
|
492
|
+
occurredAt: Date;
|
|
493
|
+
}>;
|
|
494
|
+
} {
|
|
495
|
+
const events: Array<{
|
|
496
|
+
automationId: string;
|
|
497
|
+
triggerId: string;
|
|
498
|
+
eventId: string;
|
|
499
|
+
contextKey: string | null;
|
|
500
|
+
occurredAt: Date;
|
|
501
|
+
}> = [];
|
|
502
|
+
|
|
503
|
+
const store: WindowStore = {
|
|
504
|
+
async recordAndCount(input: RecordWindowInput) {
|
|
505
|
+
const {
|
|
506
|
+
automationId,
|
|
507
|
+
triggerId,
|
|
508
|
+
eventId,
|
|
509
|
+
contextKey,
|
|
510
|
+
occurredAt,
|
|
511
|
+
windowMinutes,
|
|
512
|
+
threshold,
|
|
513
|
+
refire,
|
|
514
|
+
} = input;
|
|
515
|
+
events.push({ automationId, triggerId, eventId, contextKey, occurredAt });
|
|
516
|
+
const windowStart = occurredAt.getTime() - windowMinutes * 60_000;
|
|
517
|
+
const newCount = events.filter(
|
|
518
|
+
(e) =>
|
|
519
|
+
e.automationId === automationId &&
|
|
520
|
+
e.triggerId === triggerId &&
|
|
521
|
+
e.contextKey === contextKey &&
|
|
522
|
+
e.occurredAt.getTime() >= windowStart,
|
|
523
|
+
).length;
|
|
524
|
+
if (refire === "once") return newCount === threshold;
|
|
525
|
+
return newCount >= threshold;
|
|
526
|
+
},
|
|
527
|
+
async sweepExpired(cutoff) {
|
|
528
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
529
|
+
if (events[i]!.occurredAt.getTime() < cutoff.getTime()) {
|
|
530
|
+
events.splice(i, 1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
async deleteForAutomation(automationId) {
|
|
535
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
536
|
+
if (events[i]!.automationId === automationId) events.splice(i, 1);
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
return { store, events };
|
|
542
|
+
}
|
|
543
|
+
|
|
316
544
|
/**
|
|
317
545
|
* Minimal in-memory queue manager stub for engine tests. Records enqueued
|
|
318
546
|
* jobs so a test can fire them synchronously to simulate the delay
|
|
@@ -329,7 +557,7 @@ export interface FakeQueueManager {
|
|
|
329
557
|
fireAll: () => Promise<void>;
|
|
330
558
|
}
|
|
331
559
|
|
|
332
|
-
|
|
560
|
+
function createFakeQueueManager(opts?: {
|
|
333
561
|
onJob?: (queue: string, data: unknown) => Promise<void> | void;
|
|
334
562
|
}): FakeQueueManager {
|
|
335
563
|
const jobs: FakeQueueManager["jobs"] = [];
|
|
@@ -442,16 +670,45 @@ export function makeDispatchDeps(opts?: {
|
|
|
442
670
|
actions?: ActionRegistry;
|
|
443
671
|
artifactTypes?: ArtifactTypeRegistry;
|
|
444
672
|
triggers?: TriggerRegistry;
|
|
673
|
+
/** Optional health-check client for sensing-layer enrichment tests. */
|
|
674
|
+
healthCheckClient?: DispatchDeps["healthCheckClient"];
|
|
675
|
+
/**
|
|
676
|
+
* Optional kind-agnostic entity resolver for reactive `wait_until` wake
|
|
677
|
+
* re-eval tests (resolves `state.<kind>.<id>` for non-health entity kinds).
|
|
678
|
+
*/
|
|
679
|
+
entityResolverFor?: DispatchDeps["entityResolverFor"];
|
|
680
|
+
/**
|
|
681
|
+
* Wire a faithful in-memory serializing lock for the concurrency-mode
|
|
682
|
+
* check-then-create (models the real transaction-scoped advisory lock:
|
|
683
|
+
* keyed, blocks until granted). Off by default so the natural
|
|
684
|
+
* check-then-create race is observable in tests that don't opt in.
|
|
685
|
+
*/
|
|
686
|
+
withConcurrencyLock?: boolean;
|
|
445
687
|
}): {
|
|
446
688
|
deps: DispatchDeps;
|
|
447
689
|
runs: ReturnType<typeof createInMemoryRunStore>;
|
|
448
690
|
artifacts: ReturnType<typeof createInMemoryArtifactStore>;
|
|
449
691
|
state: ReturnType<typeof createInMemoryRunStateStore>;
|
|
692
|
+
dwells: ReturnType<typeof createInMemoryDwellStore>;
|
|
693
|
+
windows: ReturnType<typeof createInMemoryWindowStore>;
|
|
450
694
|
queue: FakeQueueManager;
|
|
451
695
|
} {
|
|
452
|
-
|
|
696
|
+
// `cancelActiveRuns` clears the cancelled runs' run-state rows too (the
|
|
697
|
+
// real store deletes wait locks + run-state in one op). The run-state map
|
|
698
|
+
// doesn't exist yet, so let the cancel hook reach it lazily via a holder.
|
|
699
|
+
const stateHolder: {
|
|
700
|
+
store?: ReturnType<typeof createInMemoryRunStateStore>;
|
|
701
|
+
} = {};
|
|
702
|
+
const runs = createInMemoryRunStore({
|
|
703
|
+
onCancel: (ids) => {
|
|
704
|
+
for (const id of ids) stateHolder.store?.states.delete(id);
|
|
705
|
+
},
|
|
706
|
+
});
|
|
453
707
|
const artifacts = createInMemoryArtifactStore();
|
|
454
|
-
const state = createInMemoryRunStateStore();
|
|
708
|
+
const state = createInMemoryRunStateStore(runs.runs);
|
|
709
|
+
stateHolder.store = state;
|
|
710
|
+
const dwells = createInMemoryDwellStore();
|
|
711
|
+
const windows = createInMemoryWindowStore();
|
|
455
712
|
const queue = createFakeQueueManager();
|
|
456
713
|
const noopLogger = {
|
|
457
714
|
debug: () => {},
|
|
@@ -470,12 +727,36 @@ export function makeDispatchDeps(opts?: {
|
|
|
470
727
|
runStore: runs.store,
|
|
471
728
|
artifactStore: artifacts.store,
|
|
472
729
|
runStateStore: state.store,
|
|
730
|
+
dwellStore: dwells.store,
|
|
731
|
+
windowStore: windows.store,
|
|
473
732
|
queueManager: queue.manager,
|
|
733
|
+
healthCheckClient: opts?.healthCheckClient,
|
|
734
|
+
entityResolverFor: opts?.entityResolverFor,
|
|
474
735
|
getService: async () => {
|
|
475
736
|
throw new Error("getService not stubbed for this test");
|
|
476
737
|
},
|
|
477
738
|
};
|
|
478
|
-
|
|
739
|
+
if (opts?.withConcurrencyLock) {
|
|
740
|
+
// A faithful keyed async mutex: a second caller for the same key awaits
|
|
741
|
+
// the first's completion, exactly like pg_advisory_xact_lock blocking
|
|
742
|
+
// until COMMIT. Distinct keys never contend.
|
|
743
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
744
|
+
deps.withConcurrencyLock = <T>(key: string, fn: () => Promise<T>) => {
|
|
745
|
+
const prior = chains.get(key) ?? Promise.resolve();
|
|
746
|
+
const next = prior.then(() => fn());
|
|
747
|
+
// Keep the chain alive even if fn rejects, so the lock still releases
|
|
748
|
+
// (catch swallows both outcomes into a settled void promise).
|
|
749
|
+
chains.set(
|
|
750
|
+
key,
|
|
751
|
+
next.then(
|
|
752
|
+
() => {},
|
|
753
|
+
() => {},
|
|
754
|
+
),
|
|
755
|
+
);
|
|
756
|
+
return next;
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
return { deps, runs, artifacts, state, dwells, windows, queue };
|
|
479
760
|
}
|
|
480
761
|
|
|
481
762
|
// ─── Shared fixtures ────────────────────────────────────────────────────
|
|
@@ -543,16 +824,3 @@ export function makeFailingAction(): ActionDefinition<{ reason: string }> {
|
|
|
543
824
|
}),
|
|
544
825
|
};
|
|
545
826
|
}
|
|
546
|
-
|
|
547
|
-
/** Hook used by tests that need a registered hook reference. */
|
|
548
|
-
export const testHook = createHook<{ id: string }>("test.event");
|
|
549
|
-
|
|
550
|
-
export function makeTrigger(): TriggerDefinition<{ id: string }> {
|
|
551
|
-
return {
|
|
552
|
-
id: "event",
|
|
553
|
-
displayName: "Test event",
|
|
554
|
-
payloadSchema: z.object({ id: z.string() }),
|
|
555
|
-
hook: testHook,
|
|
556
|
-
contextKey: (p) => p.id,
|
|
557
|
-
};
|
|
558
|
-
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createHook, type HookSubscribeOptions } from "@checkstack/backend-api";
|
|
4
|
+
import type { TriggerDefinition } from "../action-types";
|
|
5
|
+
import { createTriggerRegistry } from "../trigger-registry";
|
|
6
|
+
import type { AutomationStore } from "../automation-store";
|
|
7
|
+
import {
|
|
8
|
+
setupTriggerSubscriptions,
|
|
9
|
+
type OnHookFn,
|
|
10
|
+
} from "./trigger-subscriber";
|
|
11
|
+
import { makeDispatchDeps } from "./test-fixtures";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tier-1 contract regression guard for the horizontal-scale fan-in.
|
|
15
|
+
*
|
|
16
|
+
* Every hook-backed trigger MUST be subscribed in `mode: "work-queue"` with
|
|
17
|
+
* a per-trigger `workerGroup`. That is what makes exactly ONE pod consume a
|
|
18
|
+
* given trigger emission across the fleet; dropping it would silently
|
|
19
|
+
* revert to broadcast delivery, double-firing every automation once per
|
|
20
|
+
* running pod.
|
|
21
|
+
*
|
|
22
|
+
* This guard can't prove exactly-once delivery (that needs a real BullMQ
|
|
23
|
+
* work queue — Tier 2), but it catches an accidental removal / mistyping of
|
|
24
|
+
* the work-queue options at the wiring boundary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function triggerFor(id: string): TriggerDefinition<{ id: string }> {
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
displayName: id,
|
|
31
|
+
payloadSchema: z.object({ id: z.string() }),
|
|
32
|
+
hook: createHook<{ id: string }>(`test.${id}`),
|
|
33
|
+
contextKey: (p) => p.id,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const emptyStore: AutomationStore = {
|
|
38
|
+
create: async () => {
|
|
39
|
+
throw new Error("nope");
|
|
40
|
+
},
|
|
41
|
+
update: async () => {
|
|
42
|
+
throw new Error("nope");
|
|
43
|
+
},
|
|
44
|
+
delete: async () => {},
|
|
45
|
+
toggle: async () => {
|
|
46
|
+
throw new Error("nope");
|
|
47
|
+
},
|
|
48
|
+
getById: async () => undefined,
|
|
49
|
+
list: async () => ({ items: [], total: 0 }),
|
|
50
|
+
listGroups: async () => [],
|
|
51
|
+
findEnabledByTriggerEvent: async () => [],
|
|
52
|
+
listEnabled: async () => [],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const noopLogger = {
|
|
56
|
+
debug: () => {},
|
|
57
|
+
info: () => {},
|
|
58
|
+
warn: () => {},
|
|
59
|
+
error: () => {},
|
|
60
|
+
} as unknown as Parameters<typeof setupTriggerSubscriptions>[0]["logger"];
|
|
61
|
+
|
|
62
|
+
describe("trigger fan-in — single-consumer (work-queue) contract", () => {
|
|
63
|
+
it("subscribes every hook-backed trigger in work-queue mode with a per-trigger workerGroup", async () => {
|
|
64
|
+
const triggers = createTriggerRegistry();
|
|
65
|
+
triggers.register(triggerFor("alpha"), { pluginId: "plug" });
|
|
66
|
+
triggers.register(triggerFor("beta"), { pluginId: "plug" });
|
|
67
|
+
|
|
68
|
+
const { deps } = makeDispatchDeps({ triggers });
|
|
69
|
+
|
|
70
|
+
// Spy onHook: capture (hookId, options) per subscription and hand back a
|
|
71
|
+
// no-op teardown so setup completes.
|
|
72
|
+
const captured: Array<{
|
|
73
|
+
hookId: string;
|
|
74
|
+
options: HookSubscribeOptions | undefined;
|
|
75
|
+
}> = [];
|
|
76
|
+
const onHook: OnHookFn = (hook, _listener, options) => {
|
|
77
|
+
captured.push({ hookId: hook.id, options });
|
|
78
|
+
return async () => {};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await setupTriggerSubscriptions({
|
|
82
|
+
deps,
|
|
83
|
+
onHook,
|
|
84
|
+
automationStore: emptyStore,
|
|
85
|
+
logger: noopLogger,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(captured).toHaveLength(2);
|
|
89
|
+
for (const { hookId, options } of captured) {
|
|
90
|
+
expect(options).toEqual({
|
|
91
|
+
mode: "work-queue",
|
|
92
|
+
workerGroup: `automation-trigger-${qualifiedIdForHook(hookId)}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Each per-trigger workerGroup is distinct so the two triggers fan in on
|
|
97
|
+
// independent consumer groups (a shared group would serialize unrelated
|
|
98
|
+
// triggers).
|
|
99
|
+
const groups = captured.map((c) => c.options?.workerGroup);
|
|
100
|
+
expect(new Set(groups).size).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The hook id is `test.<triggerId>` and the qualified id is
|
|
106
|
+
* `plug.<triggerId>`; map one to the other for the assertion.
|
|
107
|
+
*/
|
|
108
|
+
function qualifiedIdForHook(hookId: string): string {
|
|
109
|
+
const triggerId = hookId.replace(/^test\./, "");
|
|
110
|
+
return `plug.${triggerId}`;
|
|
111
|
+
}
|