@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
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Trigger `for:` dwell — "fire only if the matched state still holds
3
+ * after Y" (Home Assistant's `for:`, but restart-safe).
4
+ *
5
+ * A dwell arms BEFORE any run exists, so it lives in its own pre-run
6
+ * `automation_dwell_timers` table (not the run-scoped wait locks). The
7
+ * row is the source of truth; an `automation-dwell` queue job (carrying
8
+ * the matching `startDelay`) is the wake signal, and the stalled sweeper
9
+ * catches expired rows whose job was lost. Cancellation is DB-side: a
10
+ * deleted row makes the queue job no-op when it pops (constraint 2).
11
+ *
12
+ * On expiry the dwell RE-CONFIRMS the system is still in the status it
13
+ * was in when armed (via the Phase 13 health-state provider) before
14
+ * starting the run. A recovery within the dwell window therefore
15
+ * cancels the pending fire even if no explicit inverse event arrived —
16
+ * this is the HA semantic and why Phase 13 is a hard prerequisite.
17
+ */
18
+ import type { Actor } from "@checkstack/common";
19
+ import {
20
+ durationToMs,
21
+ type Duration,
22
+ type Trigger,
23
+ } from "@checkstack/automation-common";
24
+
25
+ import type { AutomationStore } from "../automation-store";
26
+ import type { DispatchDeps, LoadedAutomation, LoadedDwell } from "./types";
27
+ import { parseActorSnapshot } from "./snapshots";
28
+
29
+ /** Queue carrying dwell wake-up jobs. */
30
+ export const DWELL_QUEUE_NAME = "automation-dwell";
31
+
32
+ /** Payload of a dwell wake-up job. The row is loaded fresh on firing. */
33
+ export interface DwellFireJob {
34
+ dwellId: string;
35
+ }
36
+
37
+ export interface ArmDwellArgs {
38
+ deps: DispatchDeps;
39
+ automation: LoadedAutomation;
40
+ trigger: Trigger;
41
+ triggerId: string;
42
+ eventId: string;
43
+ contextKey: string | null;
44
+ triggerPayload: Record<string, unknown>;
45
+ actor: Actor;
46
+ }
47
+
48
+ /**
49
+ * Arm (or re-arm) a dwell for a `for:`-configured trigger. Snapshots the
50
+ * system's current status so expiry can re-confirm it, upserts the dwell
51
+ * row (re-fire pushes `fireAt`), and enqueues the wake job. Does NOT
52
+ * start a run.
53
+ */
54
+ export async function armDwell(args: ArmDwellArgs): Promise<void> {
55
+ const { deps, automation, trigger, triggerId, eventId, contextKey } = args;
56
+ if (!trigger.for) return;
57
+
58
+ const forMs = durationToMs(trigger.for as Duration);
59
+ if (forMs === null || forMs <= 0) {
60
+ deps.logger.warn(
61
+ `Dwell for ${automation.id}/${triggerId} has an invalid duration; firing immediately is unsafe, skipping arm`,
62
+ );
63
+ return;
64
+ }
65
+
66
+ // Snapshot the current status so the expiry re-confirm has something to
67
+ // gate on. Best-effort: a missing client / unresolvable system leaves
68
+ // armedStatus null and re-confirm proceeds without a status gate.
69
+ const armedStatus = await resolveArmedStatus({
70
+ deps,
71
+ contextKey,
72
+ });
73
+
74
+ const fireAt = new Date(Date.now() + forMs);
75
+ const { id: dwellId, created } = await deps.dwellStore.arm({
76
+ automationId: automation.id,
77
+ triggerId,
78
+ eventId,
79
+ contextKey,
80
+ armedStatus,
81
+ payloadSnapshot: args.triggerPayload,
82
+ actorSnapshot: args.actor as unknown as Record<string, unknown>,
83
+ fireAt,
84
+ });
85
+
86
+ // Continuation: a dwell is already armed for this key. Its original
87
+ // deadline + queue job stand, so the `for:` window measures "since
88
+ // first arm" (HA semantics). Re-arming MUST NOT push `fireAt`, or a
89
+ // continuously re-firing trigger (e.g. level-triggered numeric_state)
90
+ // would never elapse.
91
+ if (!created) {
92
+ deps.logger.debug(
93
+ `Dwell for ${automation.id}/${triggerId} already armed; preserving original deadline`,
94
+ );
95
+ return;
96
+ }
97
+
98
+ const queue = deps.queueManager.getQueue<DwellFireJob>(DWELL_QUEUE_NAME);
99
+ await queue.enqueue(
100
+ { dwellId },
101
+ {
102
+ startDelay: Math.ceil(forMs / 1000),
103
+ // Stable per (automation, trigger, contextKey) — the sweeper is the
104
+ // backstop if the job is ever lost.
105
+ jobId: `${automation.id}:${triggerId}:${contextKey ?? "_"}`,
106
+ },
107
+ );
108
+
109
+ deps.logger.debug(
110
+ `Armed dwell ${dwellId} for ${automation.id}/${triggerId} (fires in ${Math.round(forMs / 1000)}s, armedStatus=${armedStatus ?? "none"})`,
111
+ );
112
+ }
113
+
114
+ async function resolveArmedStatus(args: {
115
+ deps: DispatchDeps;
116
+ contextKey: string | null;
117
+ }): Promise<string | null> {
118
+ const { deps, contextKey } = args;
119
+ if (!deps.healthCheckClient || !contextKey) return null;
120
+ try {
121
+ const state = await deps.healthCheckClient.getHealthState({
122
+ systemId: contextKey,
123
+ });
124
+ return state.status;
125
+ } catch (error) {
126
+ deps.logger.warn(
127
+ `Dwell arm: failed to resolve status for ${contextKey}; arming without a status gate: ${(error as Error).message}`,
128
+ );
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Callback that starts a run for a fired dwell, honouring the
135
+ * automation's concurrency mode + pre-run conditions. Supplied by the
136
+ * trigger subscriber so the dwell module stays free of the concurrency /
137
+ * condition machinery.
138
+ */
139
+ export type StartRunFromDwell = (args: {
140
+ deps: DispatchDeps;
141
+ automation: LoadedAutomation;
142
+ trigger: Trigger;
143
+ triggerId: string;
144
+ eventId: string;
145
+ contextKey: string | null;
146
+ triggerPayload: Record<string, unknown>;
147
+ actor: Actor;
148
+ }) => Promise<void>;
149
+
150
+ export interface FireDwellArgs {
151
+ deps: DispatchDeps;
152
+ automationStore: AutomationStore;
153
+ dwell: LoadedDwell;
154
+ startRun: StartRunFromDwell;
155
+ }
156
+
157
+ /**
158
+ * Fire an expired dwell: re-confirm the system is still in the armed
159
+ * status, then (if it is) start the run. Always deletes the dwell row
160
+ * first so a concurrent sweeper / consumer can't double-fire (the row is
161
+ * the lock).
162
+ */
163
+ export async function fireDwell(args: FireDwellArgs): Promise<void> {
164
+ const { deps, automationStore, dwell, startRun } = args;
165
+
166
+ // Delete-first makes firing idempotent AND mutually exclusive: the
167
+ // delete is an atomic claim (DELETE ... RETURNING), so only the caller
168
+ // whose delete actually removed the row proceeds. A racing
169
+ // consumer/sweeper (even on another pod) gets `false` and no-ops,
170
+ // preventing a double-fire.
171
+ const claimed = await deps.dwellStore.delete(dwell.id);
172
+ if (!claimed) {
173
+ deps.logger.debug(
174
+ `Dwell ${dwell.id} already claimed by a concurrent fire; skipping`,
175
+ );
176
+ return;
177
+ }
178
+
179
+ const automation = await automationStore.getById(dwell.automationId);
180
+ if (!automation || automation.status !== "enabled") {
181
+ deps.logger.debug(
182
+ `Dwell ${dwell.id} fired but automation ${dwell.automationId} is gone/disabled; dropping`,
183
+ );
184
+ return;
185
+ }
186
+
187
+ const trigger = automation.definition.triggers.find((t) => {
188
+ const tid = t.id ?? deriveTriggerId(t.event);
189
+ return tid === dwell.triggerId && t.event === dwell.eventId;
190
+ });
191
+ if (!trigger) {
192
+ deps.logger.debug(
193
+ `Dwell ${dwell.id} fired but trigger ${dwell.triggerId} no longer exists on the automation; dropping`,
194
+ );
195
+ return;
196
+ }
197
+
198
+ // Re-confirm: only proceed if the system is STILL in the armed status.
199
+ if (!(await reconfirmStatus({ deps, dwell }))) {
200
+ deps.logger.debug(
201
+ `Dwell ${dwell.id} re-confirm failed (system left status "${dwell.armedStatus}"); not firing`,
202
+ );
203
+ return;
204
+ }
205
+
206
+ await startRun({
207
+ deps,
208
+ automation: {
209
+ id: automation.id,
210
+ name: automation.name,
211
+ status: automation.status,
212
+ definition: automation.definition,
213
+ },
214
+ trigger,
215
+ triggerId: dwell.triggerId,
216
+ eventId: dwell.eventId,
217
+ contextKey: dwell.contextKey,
218
+ triggerPayload: dwell.payloadSnapshot,
219
+ // Parse the stored actor on load — a drifted/hand-edited snapshot
220
+ // degrades to the system actor (logged) instead of flowing through as
221
+ // an untyped value.
222
+ actor: parseActorSnapshot({
223
+ value: dwell.actorSnapshot,
224
+ logger: deps.logger,
225
+ context: `Dwell ${dwell.id}`,
226
+ }),
227
+ });
228
+ }
229
+
230
+ async function reconfirmStatus(args: {
231
+ deps: DispatchDeps;
232
+ dwell: LoadedDwell;
233
+ }): Promise<boolean> {
234
+ const { deps, dwell } = args;
235
+ // No status was captured at arm time (no client / unresolvable system):
236
+ // proceed without a gate — the pre-run conditions still apply downstream.
237
+ if (dwell.armedStatus === null) return true;
238
+ if (!deps.healthCheckClient || !dwell.contextKey) return true;
239
+ try {
240
+ const state = await deps.healthCheckClient.getHealthState({
241
+ systemId: dwell.contextKey,
242
+ });
243
+ return state.status === dwell.armedStatus;
244
+ } catch (error) {
245
+ // Fail-open: a provider outage at expiry should not silently drop a
246
+ // legitimate alert. Proceed and let downstream conditions decide.
247
+ deps.logger.warn(
248
+ `Dwell ${dwell.id} re-confirm errored; proceeding (fail-open): ${(error as Error).message}`,
249
+ );
250
+ return true;
251
+ }
252
+ }
253
+
254
+ /** Mirror of the dispatcher's id derivation (kept local to avoid a cycle). */
255
+ function deriveTriggerId(event: string): string {
256
+ return event.replaceAll(/[^a-z0-9]+/gi, "_").toLowerCase();
257
+ }
@@ -1153,7 +1153,8 @@ describe("dispatch engine — advisory lock", () => {
1153
1153
  expect(result.status).toBe("waiting");
1154
1154
 
1155
1155
  // Acquire the advisory lock externally, simulating another instance.
1156
- await deps.runStateStore.tryAdvisoryLock(result.runId);
1156
+ const heldLock = await deps.runStateStore.tryAdvisoryLock(result.runId);
1157
+ expect(heldLock).not.toBeNull();
1157
1158
 
1158
1159
  const { resumeRun } = await import("./engine");
1159
1160
  const blocked = await resumeRun(deps, {
@@ -1165,7 +1166,7 @@ describe("dispatch engine — advisory lock", () => {
1165
1166
  expect(blocked.status).toBe("waiting");
1166
1167
 
1167
1168
  // Release and retry — now it succeeds.
1168
- await deps.runStateStore.releaseAdvisoryLock(result.runId);
1169
+ await heldLock!.release();
1169
1170
  const unblocked = await resumeRun(deps, {
1170
1171
  runId: result.runId,
1171
1172
  automation: auto,
@@ -1194,5 +1195,191 @@ describe("dispatch engine — run lifecycle", () => {
1194
1195
  });
1195
1196
  });
1196
1197
 
1198
+ // ─── sensing layer — state in scope ──────────────────────────────────────
1199
+
1200
+ describe("dispatch engine — health.* scope enrichment", () => {
1201
+ function healthClient(
1202
+ status: string,
1203
+ inStatusSince: Date,
1204
+ transitionsInWindow = 0,
1205
+ ) {
1206
+ return {
1207
+ getBulkHealthState: async ({
1208
+ systemIds,
1209
+ transitionWindowMinutes,
1210
+ }: {
1211
+ systemIds: string[];
1212
+ transitionWindowMinutes?: number;
1213
+ }) => {
1214
+ const states: Record<string, unknown> = {};
1215
+ for (const id of systemIds) {
1216
+ states[id] = {
1217
+ status,
1218
+ inStatusSince,
1219
+ inStatusForMs: Date.now() - inStatusSince.getTime(),
1220
+ inMaintenance: false,
1221
+ transitionsInWindow,
1222
+ transitionWindowMinutes: transitionWindowMinutes ?? 60,
1223
+ evaluatedAt: new Date(),
1224
+ };
1225
+ }
1226
+ return { states };
1227
+ },
1228
+ } as unknown as ReturnType<typeof makeDispatchDeps>["deps"]["healthCheckClient"];
1229
+ }
1230
+
1231
+ it("makes health.system.* readable in an action config template", async () => {
1232
+ const actionsReg = createActionRegistry();
1233
+ const rec = makeRecordingAction();
1234
+ actionsReg.register(rec.definition, testPlugin);
1235
+
1236
+ const since = new Date("2026-05-30T11:00:00.000Z");
1237
+ const { deps } = makeDispatchDeps({
1238
+ actions: actionsReg,
1239
+ healthCheckClient: healthClient("unhealthy", since),
1240
+ });
1241
+
1242
+ const result = await dispatchTrigger(deps, {
1243
+ automation: automation([
1244
+ {
1245
+ action: "test.record",
1246
+ config: { value: "{{ health.system.status }}" },
1247
+ },
1248
+ ]),
1249
+ triggerId: "test_event",
1250
+ triggerEventId: "test.event",
1251
+ payload: { id: "sys-9" },
1252
+ contextKey: "sys-9",
1253
+ });
1254
+
1255
+ expect(result.status).toBe("success");
1256
+ expect(rec.calls[0]?.value).toBe("unhealthy");
1257
+ });
1258
+
1259
+ it("supports a dwell condition over health.system using duration filters", async () => {
1260
+ const actionsReg = createActionRegistry();
1261
+ const rec = makeRecordingAction();
1262
+ actionsReg.register(rec.definition, testPlugin);
1263
+
1264
+ // Unhealthy since 45 min ago -> older_than(30 | minutes) is true.
1265
+ const since = new Date(Date.now() - 45 * 60_000);
1266
+ const { deps } = makeDispatchDeps({
1267
+ actions: actionsReg,
1268
+ healthCheckClient: healthClient("unhealthy", since),
1269
+ });
1270
+
1271
+ const result = await dispatchTrigger(deps, {
1272
+ automation: automation([
1273
+ {
1274
+ // A condition-guard halts the run when false; success here means
1275
+ // the dwell condition evaluated true against enriched scope.
1276
+ condition:
1277
+ "health.system.status == 'unhealthy' && (health.system.in_status_since | older_than(30 | minutes))",
1278
+ },
1279
+ { action: "test.record", config: { value: "fired" } },
1280
+ ]),
1281
+ triggerId: "test_event",
1282
+ triggerEventId: "test.event",
1283
+ payload: { id: "sys-9" },
1284
+ contextKey: "sys-9",
1285
+ });
1286
+
1287
+ expect(result.status).toBe("success");
1288
+ expect(rec.calls[0]?.value).toBe("fired");
1289
+ });
1290
+
1291
+ it("fails open: no client wired leaves health namespace empty, run still proceeds", async () => {
1292
+ const actionsReg = createActionRegistry();
1293
+ const rec = makeRecordingAction();
1294
+ actionsReg.register(rec.definition, testPlugin);
1295
+ const { deps } = makeDispatchDeps({ actions: actionsReg });
1296
+
1297
+ const result = await dispatchTrigger(deps, {
1298
+ automation: automation([
1299
+ {
1300
+ action: "test.record",
1301
+ config: { value: "{{ health.system.status | default('unknown') }}" },
1302
+ },
1303
+ ]),
1304
+ triggerId: "test_event",
1305
+ triggerEventId: "test.event",
1306
+ payload: { id: "sys-9" },
1307
+ contextKey: "sys-9",
1308
+ });
1309
+
1310
+ expect(result.status).toBe("success");
1311
+ expect(rec.calls[0]?.value).toBe("unknown");
1312
+ });
1313
+
1314
+ it("exposes health.system.transitions_in_window for custom flapping rules", async () => {
1315
+ const actionsReg = createActionRegistry();
1316
+ const rec = makeRecordingAction();
1317
+ actionsReg.register(rec.definition, testPlugin);
1318
+ const since = new Date("2026-05-30T11:00:00.000Z");
1319
+ const { deps } = makeDispatchDeps({
1320
+ actions: actionsReg,
1321
+ healthCheckClient: healthClient("unhealthy", since, 4),
1322
+ });
1323
+
1324
+ // Flapping rule: a numeric_state condition over the windowed count.
1325
+ // 4 transitions >= 3 → the guard passes and the action fires.
1326
+ const result = await dispatchTrigger(deps, {
1327
+ automation: automation([
1328
+ {
1329
+ condition: {
1330
+ numeric_state: {
1331
+ value: "health.system.transitions_in_window",
1332
+ above: 2,
1333
+ },
1334
+ },
1335
+ },
1336
+ { action: "test.record", config: { value: "flapping" } },
1337
+ ]),
1338
+ triggerId: "test_event",
1339
+ triggerEventId: "test.event",
1340
+ payload: { id: "sys-9" },
1341
+ contextKey: "sys-9",
1342
+ });
1343
+
1344
+ expect(result.status).toBe("success");
1345
+ expect(rec.calls[0]?.value).toBe("flapping");
1346
+ });
1347
+
1348
+ it("does not fire the flapping rule below the transition threshold", async () => {
1349
+ const actionsReg = createActionRegistry();
1350
+ const rec = makeRecordingAction();
1351
+ actionsReg.register(rec.definition, testPlugin);
1352
+ const since = new Date("2026-05-30T11:00:00.000Z");
1353
+ const { deps } = makeDispatchDeps({
1354
+ actions: actionsReg,
1355
+ healthCheckClient: healthClient("unhealthy", since, 1),
1356
+ });
1357
+
1358
+ // 1 transition is NOT above 2 → the condition guard halts the run.
1359
+ const result = await dispatchTrigger(deps, {
1360
+ automation: automation([
1361
+ {
1362
+ condition: {
1363
+ numeric_state: {
1364
+ value: "health.system.transitions_in_window",
1365
+ above: 2,
1366
+ },
1367
+ },
1368
+ },
1369
+ { action: "test.record", config: { value: "flapping" } },
1370
+ ]),
1371
+ triggerId: "test_event",
1372
+ triggerEventId: "test.event",
1373
+ payload: { id: "sys-9" },
1374
+ contextKey: "sys-9",
1375
+ });
1376
+
1377
+ // A falsy condition guard halts the run cleanly (status "success",
1378
+ // no error) BEFORE the downstream action — so the action never fires.
1379
+ expect(rec.calls).toHaveLength(0);
1380
+ expect(result.status).toBe("success");
1381
+ });
1382
+ });
1383
+
1197
1384
  // Make use of the artifact-type registry helper to keep its import live.
1198
1385
  void createArtifactTypeRegistry;