@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. 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, createHook } from "@checkstack/backend-api";
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
- export function createInMemoryRunStateStore(): {
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: input.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 < threshold) ids.push(runId);
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 false;
389
+ if (locks.has(runId)) return null;
305
390
  locks.add(runId);
306
- return true;
307
- },
308
- async releaseAdvisoryLock(runId) {
309
- locks.delete(runId);
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
- export function createFakeQueueManager(opts?: {
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
- const runs = createInMemoryRunStore();
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
- return { deps, runs, artifacts, state, queue };
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
+ }