@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,9 +5,14 @@
5
5
  * `@checkstack/automation-common` and the package's index re-exports.
6
6
  */
7
7
  import type { Logger, ServiceRef } from "@checkstack/backend-api";
8
- import type { AutomationDefinition } from "@checkstack/automation-common";
8
+ import type {
9
+ AutomationDefinition,
10
+ Condition,
11
+ } from "@checkstack/automation-common";
9
12
  import type { QueueManager } from "@checkstack/queue-api";
10
13
  import type { FilterRegistry } from "@checkstack/template-engine";
14
+ import type { InferClient } from "@checkstack/common";
15
+ import type { HealthCheckApi } from "@checkstack/healthcheck-common";
11
16
 
12
17
  import type { ActionRegistry } from "../action-registry";
13
18
  import type { ArtifactTypeRegistry } from "../artifact-type-registry";
@@ -15,6 +20,8 @@ import type { TriggerRegistry } from "../trigger-registry";
15
20
  import type { ArtifactStore } from "../artifact-store";
16
21
 
17
22
  import type { RunStateStore } from "./run-state-store";
23
+ import type { RunSecretRegistry } from "./run-secret-registry";
24
+ import type { EntityKindResolver } from "./state-scope";
18
25
 
19
26
  /**
20
27
  * Persistent dependency bundle threaded through the dispatch engine.
@@ -40,6 +47,52 @@ export interface DispatchDeps {
40
47
  * and any future queue-backed continuations.
41
48
  */
42
49
  queueManager: QueueManager;
50
+ /**
51
+ * Health-check client for the sensing layer's scope pre-resolution
52
+ * (`enrichScopeWithState`) and the `for:` dwell re-confirm. Optional so
53
+ * test harnesses and minimal installs that don't sense live state can
54
+ * omit it — enrichment then fails open to an empty `health` namespace,
55
+ * and a dwell with no client re-confirms without a status gate.
56
+ */
57
+ healthCheckClient?: InferClient<typeof HealthCheckApi>;
58
+ /**
59
+ * Kind-agnostic entity resolver for reactive `wait_until` wake re-eval
60
+ * (reactive automation engine §3.6, §8). On wake, the engine re-enriches
61
+ * scope with EVERY `state.<kind>.<id>` ref the wait depends on, resolved
62
+ * through the framework entity store. Returns a batched `getMany`-style
63
+ * resolver for a registered kind, or `undefined` for an unknown kind (the
64
+ * enrichment then leaves that kind unresolved, fail-open). Optional so test
65
+ * harnesses / minimal installs without the entity store omit it — non-health
66
+ * waits then can't re-resolve their state (health waits still work via
67
+ * `healthCheckClient`).
68
+ */
69
+ entityResolverFor?: (kind: string) => EntityKindResolver | undefined;
70
+ /** Persistence backend for pre-run `for:` dwell timers. */
71
+ dwellStore: DwellStore;
72
+ /** Persistence backend for windowed-count / rate trigger gates. */
73
+ windowStore: WindowStore;
74
+ /**
75
+ * Run-scoped secret registry. When set, the engine wraps each run's
76
+ * `getService` so resolving the secret resolver / connection store
77
+ * registers the resolved values, and the run store masks step / run
78
+ * output before persistence. Optional so tests / minimal installs skip
79
+ * masking. `secretResolverRefId` / `connectionStoreRefId` carry the
80
+ * service-ref ids to intercept (passed as strings to avoid a dependency
81
+ * cycle from the dispatch core onto the secrets / integration packages).
82
+ */
83
+ secretRegistry?: RunSecretRegistry;
84
+ secretResolverRefId?: string;
85
+ connectionStoreRefId?: string;
86
+ /**
87
+ * Serialize a short critical section under a transaction-scoped advisory
88
+ * lock keyed on `key` (blocks until granted, auto-releases at COMMIT).
89
+ * Used to make the concurrency-mode check-then-create atomic per
90
+ * `(automationId, scopeKey)` so two concurrent fires / a dwell-fire /
91
+ * cross-pod can't both pass a `single`-mode "no active run" check and
92
+ * both create a run. Optional so tests / minimal installs degrade to the
93
+ * unserialized check (correct under a single in-process caller).
94
+ */
95
+ withConcurrencyLock?: <T>(key: string, fn: () => Promise<T>) => Promise<T>;
43
96
  }
44
97
 
45
98
  /**
@@ -152,11 +205,27 @@ export interface RunStore {
152
205
  errorMessage?: string,
153
206
  ): Promise<void>;
154
207
  loadRun(runId: string): Promise<LoadedRun | undefined>;
155
- countActiveRuns(automationId: string): Promise<number>;
156
- /** Used by `mode: "single"` to detect a pre-existing run. */
157
- hasActiveRun(automationId: string): Promise<boolean>;
158
- /** Used by `mode: "restart"` to abort prior runs. */
159
- cancelActiveRuns(automationId: string, reason: string): Promise<string[]>;
208
+ /**
209
+ * Active-run count. When `contextKey` is omitted, counts across the
210
+ * whole automation (the default per-automation concurrency scope);
211
+ * when provided (including `null`), counts only runs with that context
212
+ * key (the per-context-key scope).
213
+ */
214
+ countActiveRuns(
215
+ automationId: string,
216
+ contextKey?: string | null,
217
+ ): Promise<number>;
218
+ /** Used by `mode: "single"` to detect a pre-existing run. See `countActiveRuns` for `contextKey`. */
219
+ hasActiveRun(
220
+ automationId: string,
221
+ contextKey?: string | null,
222
+ ): Promise<boolean>;
223
+ /** Used by `mode: "restart"` to abort prior runs. See `countActiveRuns` for `contextKey`. */
224
+ cancelActiveRuns(
225
+ automationId: string,
226
+ reason: string,
227
+ contextKey?: string | null,
228
+ ): Promise<string[]>;
160
229
 
161
230
  // Steps
162
231
  createStep(input: CreateStepInput): Promise<string>;
@@ -180,13 +249,35 @@ export interface RunStore {
180
249
  actionPath: string,
181
250
  ): Promise<LoadedStep | undefined>;
182
251
 
183
- // Wait locks (for wait_for_trigger + delay durability)
252
+ // Wait locks (for wait_for_trigger + delay + wait_until durability)
184
253
  createWaitLock(input: CreateWaitLockInput): Promise<string>;
254
+ /**
255
+ * Insert a `kind: "until"` wait lock PLUS its wake-index dependency rows
256
+ * (one per ref) atomically (reactive automation engine §8.2). The
257
+ * `(waitLockId, ref)` pair is unique, so duplicate refs collapse via
258
+ * `ON CONFLICT DO NOTHING`. Returns the new wait-lock id.
259
+ */
260
+ createWaitLockWithWakeRefs(input: CreateWaitLockWithRefsInput): Promise<string>;
185
261
  loadWaitLock(id: string): Promise<LoadedWaitLock | undefined>;
186
262
  findWaitLocksFor(
187
263
  eventId: string,
188
264
  contextKey: string | null,
189
265
  ): Promise<LoadedWaitLock[]>;
266
+ /**
267
+ * Wake-index intersection lookup (reactive automation engine §8.2): every
268
+ * `kind: "until"` wait lock that depends on `ref` (exact match) OR on the
269
+ * kind-level wildcard for `ref`'s kind. Generalizes `findWaitLocksFor`.
270
+ */
271
+ findWaitLocksByWakeRef(ref: string): Promise<LoadedWaitLock[]>;
272
+ /** All wait locks of a given kind — powers the sweeper's `until` re-tick. */
273
+ findWaitLocksByKind(kind: WaitLockKind): Promise<LoadedWaitLock[]>;
274
+ /**
275
+ * All wait locks belonging to a run. Used by stalled-recovery to detect
276
+ * a live wait (refuse to from-top recover a genuinely-suspended run) and
277
+ * to delete leftover locks before a from-top re-walk (so recovery
278
+ * doesn't leak a second lock + duplicate delay job).
279
+ */
280
+ findWaitLocksByRun(runId: string): Promise<LoadedWaitLock[]>;
190
281
  deleteWaitLock(id: string): Promise<void>;
191
282
  sweepExpiredWaitLocks(now: Date): Promise<LoadedWaitLock[]>;
192
283
  }
@@ -234,7 +325,17 @@ export interface LoadedStep {
234
325
  finishedAt: Date | null;
235
326
  }
236
327
 
237
- export type WaitLockKind = "trigger" | "delay";
328
+ export type WaitLockKind = "trigger" | "delay" | "until";
329
+
330
+ /**
331
+ * Persisted config for a `kind: "until"` wait lock. The condition can be a
332
+ * structured object, so it rides in the lock's `waitConfig` jsonb rather
333
+ * than `filterTemplate`.
334
+ */
335
+ export interface UntilWaitConfig {
336
+ condition: Condition;
337
+ continueOnTimeout: boolean;
338
+ }
238
339
 
239
340
  export interface CreateWaitLockInput {
240
341
  runId: string;
@@ -244,6 +345,25 @@ export interface CreateWaitLockInput {
244
345
  contextKey: string | null;
245
346
  filterTemplate: string | null;
246
347
  timeoutAt: Date | null;
348
+ /** Only set for `kind: "until"`. */
349
+ waitConfig?: UntilWaitConfig | null;
350
+ }
351
+
352
+ /**
353
+ * A reactive `wait_until` suspend: the wait lock plus the wake-index refs
354
+ * its condition depends on (reactive automation engine §8.2). `kind` is
355
+ * always `"until"`; the refs are the `${kind}:${id}` (or `${kind}:*`)
356
+ * strings the condition reads.
357
+ */
358
+ export interface CreateWaitLockWithRefsInput {
359
+ runId: string;
360
+ actionPath: string;
361
+ eventId: string;
362
+ contextKey: string | null;
363
+ timeoutAt: Date | null;
364
+ waitConfig: UntilWaitConfig;
365
+ /** Wake-index dependency refs (`${kind}:${id}` or `${kind}:*`). */
366
+ wakeRefs: ReadonlyArray<string>;
247
367
  }
248
368
 
249
369
  export interface LoadedWaitLock {
@@ -255,5 +375,140 @@ export interface LoadedWaitLock {
255
375
  contextKey: string | null;
256
376
  filterTemplate: string | null;
257
377
  timeoutAt: Date | null;
378
+ waitConfig: UntilWaitConfig | null;
379
+ createdAt: Date;
380
+ }
381
+
382
+ // ─── Dwell-timer store interface ─────────────────────────────────────────
383
+
384
+ /** Candidate dwell to arm. `fireAt` is used only on a fresh INSERT. */
385
+ export interface UpsertDwellInput {
386
+ automationId: string;
387
+ triggerId: string;
388
+ eventId: string;
389
+ contextKey: string | null;
390
+ armedStatus: string | null;
391
+ payloadSnapshot: Record<string, unknown>;
392
+ actorSnapshot: Record<string, unknown>;
393
+ fireAt: Date;
394
+ }
395
+
396
+ export interface LoadedDwell {
397
+ id: string;
398
+ automationId: string;
399
+ triggerId: string;
400
+ eventId: string;
401
+ contextKey: string | null;
402
+ armedStatus: string | null;
403
+ payloadSnapshot: Record<string, unknown>;
404
+ actorSnapshot: Record<string, unknown>;
405
+ fireAt: Date;
258
406
  createdAt: Date;
259
407
  }
408
+
409
+ /** Result of an idempotent {@link DwellStore.arm}. */
410
+ export interface ArmDwellResult {
411
+ id: string;
412
+ /**
413
+ * True when a NEW dwell row was inserted; false when a dwell already
414
+ * existed for the key and was preserved unchanged.
415
+ */
416
+ created: boolean;
417
+ /**
418
+ * The row's `fireAt` — for a continuation this is the ORIGINAL arm
419
+ * deadline, NOT the incoming candidate, so a `for:` window measures
420
+ * "continuously matched since first arm" (HA semantics).
421
+ */
422
+ fireAt: Date;
423
+ }
424
+
425
+ /**
426
+ * Persistence for pre-run `for:` dwell timers. The dwell row is the
427
+ * source of truth; the `automation-dwell` queue job is just the wake
428
+ * signal, and cancellation is a row delete (constraint 2).
429
+ */
430
+ export interface DwellStore {
431
+ /**
432
+ * Arm a dwell idempotently on the unique
433
+ * `(automationId, triggerId, contextKey)` key.
434
+ *
435
+ * - No row exists → INSERT with the supplied `fireAt` + snapshot.
436
+ * - A row already exists → preserve it UNCHANGED (same `fireAt`).
437
+ *
438
+ * This is the correct `for:` semantic: a re-fire while the dwell is
439
+ * still armed must NOT push the deadline forward, or a continuously
440
+ * re-firing trigger (e.g. a level-triggered numeric_state) would never
441
+ * elapse. A genuine recover-then-recur deletes the row first (via
442
+ * inverse-cancel / re-confirm), so a fresh window starts then.
443
+ */
444
+ arm(input: UpsertDwellInput): Promise<ArmDwellResult>;
445
+ load(id: string): Promise<LoadedDwell | undefined>;
446
+ /** Look up the single dwell for a key, if armed. */
447
+ findByKey(
448
+ automationId: string,
449
+ triggerId: string,
450
+ contextKey: string | null,
451
+ ): Promise<LoadedDwell | undefined>;
452
+ /**
453
+ * Delete one dwell (cancellation / fire). Idempotent. Returns `true`
454
+ * only if THIS call deleted the row, so a caller can use the delete as
455
+ * an atomic claim: whoever gets `true` owns the fire, racing callers
456
+ * (a second pod, or the sweeper vs the queue consumer) get `false`.
457
+ */
458
+ delete(id: string): Promise<boolean>;
459
+ /** Delete by key (early cancel on the inverse signal). Idempotent. */
460
+ deleteByKey(
461
+ automationId: string,
462
+ triggerId: string,
463
+ contextKey: string | null,
464
+ ): Promise<void>;
465
+ /** Drop every dwell for an automation (disabled / deleted). */
466
+ deleteForAutomation(automationId: string): Promise<void>;
467
+ /** Dwells whose `fireAt` has passed — the sweeper fallback. */
468
+ sweepExpired(now: Date): Promise<LoadedDwell[]>;
469
+ }
470
+
471
+ // ─── Windowed-count store interface ──────────────────────────────────────
472
+
473
+ /** Re-fire policy for a windowed-count gate. */
474
+ export type WindowRefire = "every" | "once";
475
+
476
+ /** Inputs to {@link WindowStore.recordAndCount}. */
477
+ export interface RecordWindowInput {
478
+ automationId: string;
479
+ triggerId: string;
480
+ eventId: string;
481
+ contextKey: string | null;
482
+ /** When the qualifying occurrence happened (the recorded row's timestamp). */
483
+ occurredAt: Date;
484
+ /** Trailing sliding window, in minutes. */
485
+ windowMinutes: number;
486
+ /** Occurrences within the window that arm the trigger. */
487
+ threshold: number;
488
+ /** `every` fires at/over the threshold; `once` fires only on the crossing edge. */
489
+ refire: WindowRefire;
490
+ }
491
+
492
+ /**
493
+ * Persistence for windowed-count / rate trigger gates. The append log is the
494
+ * source of truth; the in-window COUNT is a pure DB read, so every pod agrees.
495
+ */
496
+ export interface WindowStore {
497
+ /**
498
+ * Append one qualifying occurrence and decide whether the trigger fires:
499
+ *
500
+ * - records the row at `occurredAt`,
501
+ * - counts rows for `(automationId, triggerId, contextKey)` whose
502
+ * `occurredAt >= occurredAt - windowMinutes` (the new row is included),
503
+ * - returns `true` iff the re-fire policy fires for this occurrence:
504
+ * `every` → `newCount >= threshold`,
505
+ * `once` → `newCount === threshold` (the crossing edge).
506
+ *
507
+ * Single INSERT per claimed emission ⇒ no double-count across pods.
508
+ */
509
+ recordAndCount(input: RecordWindowInput): Promise<boolean>;
510
+ /** Delete occurrences older than `cutoff` — the sweeper's TTL prune. */
511
+ sweepExpired(cutoff: Date): Promise<void>;
512
+ /** Drop every occurrence for an automation (disabled / deleted). */
513
+ deleteForAutomation(automationId: string): Promise<void>;
514
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `wait_until` timeout-timer consumer (reactive automation engine §7).
3
+ *
4
+ * A reactive `wait_until` is event-driven: a relevant `ENTITY_CHANGED` wakes
5
+ * it via Stage 1. The ONLY queue job a suspended wait holds is a single
6
+ * timeout timer scheduled at `timeoutAt` (when a timeout is configured) —
7
+ * NOT a poll loop. When that timer fires this consumer runs `checkWaitUntil`,
8
+ * which re-evaluates the condition one last time and then applies the
9
+ * continue/fail-on-timeout policy.
10
+ *
11
+ * Idempotent: `checkWaitUntil` deletes the lock before resuming and
12
+ * `resumeRun` takes the per-run advisory lock, so a wake that already
13
+ * resumed the run makes this a no-op (`loadWaitLock` finds nothing).
14
+ * Mirrors the delay / dwell consumer wiring.
15
+ */
16
+ import type { Logger } from "@checkstack/backend-api";
17
+
18
+ import type { AutomationStore } from "../automation-store";
19
+ import {
20
+ checkWaitUntil,
21
+ WAIT_TIMEOUT_QUEUE_NAME,
22
+ type WaitTimeoutJob,
23
+ } from "./engine";
24
+ import type { DispatchDeps } from "./types";
25
+
26
+ export interface WaitTimeoutQueueConsumerArgs {
27
+ deps: DispatchDeps;
28
+ automationStore: AutomationStore;
29
+ logger: Logger;
30
+ }
31
+
32
+ export interface WaitTimeoutQueueConsumer {
33
+ stop: () => Promise<void>;
34
+ }
35
+
36
+ export async function startWaitTimeoutQueueConsumer(
37
+ args: WaitTimeoutQueueConsumerArgs,
38
+ ): Promise<WaitTimeoutQueueConsumer> {
39
+ const queue = args.deps.queueManager.getQueue<WaitTimeoutJob>(
40
+ WAIT_TIMEOUT_QUEUE_NAME,
41
+ );
42
+
43
+ await queue.consume(
44
+ async (job) => {
45
+ const { runId, waitLockId } = job.data;
46
+ const lock = await args.deps.runStore.loadWaitLock(waitLockId);
47
+ if (!lock || lock.kind !== "until") {
48
+ args.logger.debug(
49
+ `wait-timeout: lock ${waitLockId} gone (woken / cancelled)`,
50
+ );
51
+ return;
52
+ }
53
+ const run = await args.deps.runStore.loadRun(runId);
54
+ if (!run) {
55
+ await args.deps.runStore.deleteWaitLock(waitLockId);
56
+ return;
57
+ }
58
+ const automation = await args.automationStore.getById(run.automationId);
59
+ if (!automation) {
60
+ await args.deps.runStore.deleteWaitLock(waitLockId);
61
+ await args.deps.runStore.updateRunStatus(
62
+ runId,
63
+ "failed",
64
+ "automation deleted while run was suspended on wait_until",
65
+ );
66
+ await args.deps.runStateStore.clear(runId);
67
+ return;
68
+ }
69
+
70
+ await checkWaitUntil(args.deps, {
71
+ runId,
72
+ waitLockId,
73
+ automation: {
74
+ id: automation.id,
75
+ name: automation.name,
76
+ status: automation.status,
77
+ definition: automation.definition,
78
+ },
79
+ });
80
+ },
81
+ { consumerGroup: "automation-wait-timeout", maxRetries: 3 },
82
+ );
83
+
84
+ return {
85
+ stop: async () => {
86
+ await queue.stop();
87
+ },
88
+ };
89
+ }