@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
package/src/dispatch/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
}
|