@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,544 @@
1
+ /**
2
+ * Reactive `wait_until` wake re-evaluation for NON-health entity kinds
3
+ * (reactive automation engine §3.6, §8). A wait whose condition reads
4
+ * `state.<kind>.<id>` must, on wake, re-enrich scope with that entity kind
5
+ * resolved KIND-AGNOSTICALLY through the entity store — not only health.
6
+ * Otherwise a Phase-4 entity (incident, slo, …) wait can never resolve true
7
+ * and the run waits until timeout (the latent bug this guards against).
8
+ */
9
+ import { describe, it, expect } from "bun:test";
10
+ import { AutomationDefinitionSchema } from "@checkstack/automation-common";
11
+ import { SYSTEM_ACTOR } from "@checkstack/common";
12
+ import type { EntityChanged } from "@checkstack/automation-common";
13
+
14
+ import { createActionRegistry } from "../action-registry";
15
+ import { dispatchTrigger } from "./engine";
16
+ import { handleDispatchJob } from "./stage2-dispatch";
17
+ import { routeEntityChange } from "./stage1-router";
18
+ import { createChangeDeriverRegistry } from "../entity/change-derivers";
19
+ import {
20
+ makeDispatchDeps,
21
+ makeRecordingAction,
22
+ testPlugin,
23
+ } from "./test-fixtures";
24
+ import type { DispatchDeps } from "./types";
25
+ import type { LoadedAutomation } from "./types";
26
+ import type { AutomationStore } from "../automation-store";
27
+
28
+ /**
29
+ * An in-memory entity store for ONE non-health kind, exposing a
30
+ * kind-agnostic resolver (`getMany`-style) + a setter so a test can mutate
31
+ * the entity between suspend and wake.
32
+ */
33
+ function fakeIncidentEntities(kind: string) {
34
+ const rows = new Map<string, Record<string, unknown>>();
35
+ const resolverFor: DispatchDeps["entityResolverFor"] = (k) => {
36
+ if (k !== kind) return undefined;
37
+ return async (ids) => {
38
+ const out: Record<string, Record<string, unknown>> = {};
39
+ for (const id of ids) {
40
+ const row = rows.get(id);
41
+ if (row) out[id] = row;
42
+ }
43
+ return out;
44
+ };
45
+ };
46
+ return {
47
+ set: (id: string, state: Record<string, unknown>) => rows.set(id, state),
48
+ resolverFor,
49
+ };
50
+ }
51
+
52
+ function automation(actions: unknown[]): LoadedAutomation {
53
+ const definition = AutomationDefinitionSchema.parse({
54
+ name: "WU",
55
+ triggers: [{ event: "test.event" }],
56
+ conditions: [],
57
+ actions,
58
+ mode: "single",
59
+ max_runs: 10,
60
+ });
61
+ return { id: "auto-1", name: "WU", status: "enabled", definition };
62
+ }
63
+
64
+ function storeFor(auto: LoadedAutomation): AutomationStore {
65
+ return {
66
+ create: async () => {
67
+ throw new Error("nope");
68
+ },
69
+ update: async () => {
70
+ throw new Error("nope");
71
+ },
72
+ delete: async () => {},
73
+ toggle: async () => {
74
+ throw new Error("nope");
75
+ },
76
+ getById: async (id) =>
77
+ id === auto.id
78
+ ? {
79
+ id: auto.id,
80
+ name: auto.name,
81
+ description: undefined,
82
+ status: auto.status,
83
+ definition: auto.definition,
84
+ managedBy: undefined,
85
+ createdAt: new Date(),
86
+ updatedAt: new Date(),
87
+ }
88
+ : undefined,
89
+ list: async () => ({ items: [], total: 0 }),
90
+ listGroups: async () => [],
91
+ findEnabledByTriggerEvent: async () => [auto],
92
+ listEnabled: async () => [auto],
93
+ };
94
+ }
95
+
96
+ function incidentChange(id: string, status: string): EntityChanged {
97
+ return {
98
+ kind: "incident",
99
+ id,
100
+ prev: { status: "open" },
101
+ next: { status },
102
+ delta: { status },
103
+ changedFields: ["status"],
104
+ actor: SYSTEM_ACTOR,
105
+ occurredAt: new Date().toISOString(),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * A per-system mutable health client. Tracks which system ids were requested
111
+ * via `getBulkHealthState` so a test can assert the changed system was fed in.
112
+ */
113
+ function perSystemHealthClient(initial: Record<string, string>) {
114
+ const statuses = new Map<string, string>(Object.entries(initial));
115
+ const requested: string[] = [];
116
+ const stateObj = (status: string) => ({
117
+ status,
118
+ inStatusSince: new Date(),
119
+ inStatusForMs: 0,
120
+ inMaintenance: false,
121
+ evaluatedAt: new Date(),
122
+ });
123
+ return {
124
+ set: (id: string, status: string) => statuses.set(id, status),
125
+ requestedIds: () => requested,
126
+ client: {
127
+ getHealthState: async () => stateObj("healthy"),
128
+ getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
129
+ const states: Record<string, unknown> = {};
130
+ for (const id of systemIds) {
131
+ requested.push(id);
132
+ states[id] = stateObj(statuses.get(id) ?? "unknown");
133
+ }
134
+ return { states };
135
+ },
136
+ } as never,
137
+ };
138
+ }
139
+
140
+ function healthChange(id: string, status: string): EntityChanged {
141
+ return {
142
+ kind: "health",
143
+ id,
144
+ prev: { status: "unhealthy" },
145
+ next: { status },
146
+ delta: { status },
147
+ changedFields: ["status"],
148
+ actor: SYSTEM_ACTOR,
149
+ occurredAt: new Date().toISOString(),
150
+ };
151
+ }
152
+
153
+ describe("wait_until — wildcard health wake drops nothing (§Fix 2)", () => {
154
+ it("a health:* wait woken by a non-context system resumes (changed system is resolved)", async () => {
155
+ // The condition reads a DYNAMIC health system id (from the payload), so
156
+ // ref extraction yields the health WILDCARD `health:*`. The wait is woken
157
+ // by `health:sys-other`, which is NEITHER the contextKey NOR listed in
158
+ // `uses_state`. The fix injects the changed system into the health
159
+ // resolution so `scope.health.systems[sys-other]` is populated and the
160
+ // condition can become true.
161
+ const health = perSystemHealthClient({
162
+ "sys-ctx": "healthy",
163
+ "sys-other": "unhealthy",
164
+ });
165
+
166
+ const actions = createActionRegistry();
167
+ const rec = makeRecordingAction();
168
+ actions.register(rec.definition, testPlugin);
169
+ const { deps, runs } = makeDispatchDeps({
170
+ actions,
171
+ healthCheckClient: health.client,
172
+ });
173
+
174
+ const auto = automation([
175
+ {
176
+ wait_until: {
177
+ // Dynamic system id → extraction can only record `health:*`.
178
+ condition:
179
+ "health.systems[trigger.payload.target].status == 'healthy'",
180
+ },
181
+ },
182
+ { action: "test.record", config: { value: "recovered" } },
183
+ ]);
184
+
185
+ const result = await dispatchTrigger(deps, {
186
+ automation: auto,
187
+ triggerId: "test_event",
188
+ triggerEventId: "test.event",
189
+ // contextKey is sys-ctx; the watched system is sys-other.
190
+ payload: { id: "sys-ctx", target: "sys-other" },
191
+ contextKey: "sys-ctx",
192
+ });
193
+ expect(result.status).toBe("waiting");
194
+
195
+ const lock = [...runs.waitLocks.values()][0]!;
196
+ expect([...(runs.wakeRefs.get(lock.id) ?? [])]).toEqual(["health:*"]);
197
+
198
+ // sys-other recovers; the wildcard wake matches.
199
+ health.set("sys-other", "healthy");
200
+ const jobs = await routeEntityChange({
201
+ deps,
202
+ automationStore: storeFor(auto),
203
+ changeDerivers: createChangeDeriverRegistry(),
204
+ changed: healthChange("sys-other", "healthy"),
205
+ });
206
+ expect(jobs).toHaveLength(1);
207
+ expect(jobs[0]?.reason).toBe("wake");
208
+ expect(jobs[0]?.ref).toBe("health:sys-other");
209
+
210
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: createChangeDeriverRegistry(), job: jobs[0]! });
211
+
212
+ // Resumed: without the fix, sys-other would never be resolved into
213
+ // scope.health.systems and the run would stay waiting.
214
+ expect(runs.runs.get(result.runId)?.status).toBe("success");
215
+ expect(rec.calls.map((c) => c.value)).toEqual(["recovered"]);
216
+ expect(runs.waitLocks.size).toBe(0);
217
+ // The changed (non-context) system was actually fed into health resolution.
218
+ expect(health.requestedIds()).toContain("sys-other");
219
+ });
220
+ });
221
+
222
+ describe("wait_until — non-health entity-kind wake re-eval", () => {
223
+ it("wakes on an incident change, re-enriches state.incident.*, and resumes", async () => {
224
+ const incidents = fakeIncidentEntities("incident");
225
+ // The incident starts open (condition false at suspend time).
226
+ incidents.set("inc-1", { status: "open" });
227
+
228
+ const actions = createActionRegistry();
229
+ const rec = makeRecordingAction();
230
+ actions.register(rec.definition, testPlugin);
231
+ const { deps, runs } = makeDispatchDeps({
232
+ actions,
233
+ entityResolverFor: incidents.resolverFor,
234
+ });
235
+
236
+ const auto = automation([
237
+ { wait_until: { condition: "state.incident['inc-1'].status == 'resolved'" } },
238
+ { action: "test.record", config: { value: "closed" } },
239
+ ]);
240
+
241
+ const result = await dispatchTrigger(deps, {
242
+ automation: auto,
243
+ triggerId: "test_event",
244
+ triggerEventId: "test.event",
245
+ payload: { id: "inc-1" },
246
+ contextKey: "inc-1",
247
+ });
248
+ // Suspends: state.incident isn't in the initial (health-only) scope.
249
+ expect(result.status).toBe("waiting");
250
+ expect(runs.waitLocks.size).toBe(1);
251
+ expect(rec.calls).toHaveLength(0);
252
+
253
+ // The wake-index recorded the concrete incident ref.
254
+ const lock = [...runs.waitLocks.values()][0]!;
255
+ expect([...(runs.wakeRefs.get(lock.id) ?? [])]).toEqual(["incident:inc-1"]);
256
+
257
+ // The incident resolves; an ENTITY_CHANGED routes a Stage-2 wake.
258
+ incidents.set("inc-1", { status: "resolved" });
259
+ const jobs = await routeEntityChange({
260
+ deps,
261
+ automationStore: storeFor(auto),
262
+ changeDerivers: createChangeDeriverRegistry(),
263
+ changed: incidentChange("inc-1", "resolved"),
264
+ });
265
+ expect(jobs).toHaveLength(1);
266
+ expect(jobs[0]?.reason).toBe("wake");
267
+
268
+ // Run the wake job: re-enrich state.incident.inc-1 (now resolved) → resume.
269
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: createChangeDeriverRegistry(), job: jobs[0]! });
270
+
271
+ expect(runs.runs.get(result.runId)?.status).toBe("success");
272
+ expect(rec.calls.map((c) => c.value)).toEqual(["closed"]);
273
+ expect(runs.waitLocks.size).toBe(0);
274
+ });
275
+
276
+ it("does NOT resume while the entity still fails the condition", async () => {
277
+ const incidents = fakeIncidentEntities("incident");
278
+ incidents.set("inc-1", { status: "open" });
279
+
280
+ const actions = createActionRegistry();
281
+ const rec = makeRecordingAction();
282
+ actions.register(rec.definition, testPlugin);
283
+ const { deps, runs } = makeDispatchDeps({
284
+ actions,
285
+ entityResolverFor: incidents.resolverFor,
286
+ });
287
+
288
+ const auto = automation([
289
+ { wait_until: { condition: "state.incident['inc-1'].status == 'resolved'" } },
290
+ { action: "test.record", config: { value: "closed" } },
291
+ ]);
292
+ const result = await dispatchTrigger(deps, {
293
+ automation: auto,
294
+ triggerId: "test_event",
295
+ triggerEventId: "test.event",
296
+ payload: { id: "inc-1" },
297
+ contextKey: "inc-1",
298
+ });
299
+ expect(result.status).toBe("waiting");
300
+
301
+ // The incident merely updates (still open) → wake re-evals false.
302
+ const jobs = await routeEntityChange({
303
+ deps,
304
+ automationStore: storeFor(auto),
305
+ changeDerivers: createChangeDeriverRegistry(),
306
+ changed: incidentChange("inc-1", "open"),
307
+ });
308
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: createChangeDeriverRegistry(), job: jobs[0]! });
309
+
310
+ expect(runs.runs.get(result.runId)?.status).toBe("waiting");
311
+ expect(rec.calls).toHaveLength(0);
312
+ expect(runs.waitLocks.size).toBe(1);
313
+ });
314
+
315
+ it("resolves a wildcard wait via the changedRef (dynamic id)", async () => {
316
+ const incidents = fakeIncidentEntities("incident");
317
+
318
+ const actions = createActionRegistry();
319
+ const rec = makeRecordingAction();
320
+ actions.register(rec.definition, testPlugin);
321
+ const { deps, runs } = makeDispatchDeps({
322
+ actions,
323
+ entityResolverFor: incidents.resolverFor,
324
+ });
325
+
326
+ // Dynamic id: the condition reads the incident id from the trigger
327
+ // payload, so extraction yields the kind WILDCARD `incident:*`.
328
+ const auto = automation([
329
+ {
330
+ wait_until: {
331
+ condition:
332
+ "state.incident[trigger.payload.incidentId].status == 'resolved'",
333
+ },
334
+ },
335
+ { action: "test.record", config: { value: "closed" } },
336
+ ]);
337
+ const result = await dispatchTrigger(deps, {
338
+ automation: auto,
339
+ triggerId: "test_event",
340
+ triggerEventId: "test.event",
341
+ payload: { incidentId: "inc-9" },
342
+ contextKey: "inc-9",
343
+ });
344
+ expect(result.status).toBe("waiting");
345
+
346
+ const lock = [...runs.waitLocks.values()][0]!;
347
+ expect([...(runs.wakeRefs.get(lock.id) ?? [])]).toEqual(["incident:*"]);
348
+
349
+ // The specific incident resolves; the wildcard wake matches and the
350
+ // changedRef drives resolution of the (otherwise un-extractable) id.
351
+ incidents.set("inc-9", { status: "resolved" });
352
+ const jobs = await routeEntityChange({
353
+ deps,
354
+ automationStore: storeFor(auto),
355
+ changeDerivers: createChangeDeriverRegistry(),
356
+ changed: incidentChange("inc-9", "resolved"),
357
+ });
358
+ expect(jobs).toHaveLength(1);
359
+ expect(jobs[0]?.reason).toBe("wake");
360
+ expect(jobs[0]?.ref).toBe("incident:inc-9");
361
+
362
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: createChangeDeriverRegistry(), job: jobs[0]! });
363
+
364
+ expect(runs.runs.get(result.runId)?.status).toBe("success");
365
+ expect(rec.calls.map((c) => c.value)).toEqual(["closed"]);
366
+ });
367
+
368
+ it("a wait combining health AND an incident ref keeps both working", async () => {
369
+ const incidents = fakeIncidentEntities("incident");
370
+ incidents.set("inc-1", { status: "open" });
371
+
372
+ const actions = createActionRegistry();
373
+ const rec = makeRecordingAction();
374
+ actions.register(rec.definition, testPlugin);
375
+
376
+ // Health client reporting healthy.
377
+ const healthClient = {
378
+ getHealthState: async () => ({
379
+ status: "healthy",
380
+ inStatusSince: new Date(),
381
+ inStatusForMs: 0,
382
+ inMaintenance: false,
383
+ evaluatedAt: new Date(),
384
+ }),
385
+ getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
386
+ const states: Record<string, unknown> = {};
387
+ for (const id of systemIds) {
388
+ states[id] = {
389
+ status: "healthy",
390
+ inStatusSince: new Date(),
391
+ inStatusForMs: 0,
392
+ inMaintenance: false,
393
+ evaluatedAt: new Date(),
394
+ };
395
+ }
396
+ return { states };
397
+ },
398
+ } as never;
399
+
400
+ const { deps, runs } = makeDispatchDeps({
401
+ actions,
402
+ healthCheckClient: healthClient,
403
+ entityResolverFor: incidents.resolverFor,
404
+ });
405
+
406
+ // Both refs must hold. Health is already healthy; the incident gates it.
407
+ const auto = automation([
408
+ {
409
+ wait_until: {
410
+ condition:
411
+ "health.system.status == 'healthy' && state.incident['inc-1'].status == 'resolved'",
412
+ },
413
+ },
414
+ { action: "test.record", config: { value: "done" } },
415
+ ]);
416
+ const result = await dispatchTrigger(deps, {
417
+ automation: auto,
418
+ triggerId: "test_event",
419
+ triggerEventId: "test.event",
420
+ payload: { id: "sys-1" },
421
+ contextKey: "sys-1",
422
+ });
423
+ // Health true but incident still open → suspends.
424
+ expect(result.status).toBe("waiting");
425
+
426
+ const lock = [...runs.waitLocks.values()][0]!;
427
+ // Both refs recorded (health wildcard from `health.system` + the incident).
428
+ expect([...(runs.wakeRefs.get(lock.id) ?? [])].toSorted()).toEqual([
429
+ "health:*",
430
+ "incident:inc-1",
431
+ ]);
432
+
433
+ // Resolve the incident → wake re-evals BOTH (health stays healthy).
434
+ incidents.set("inc-1", { status: "resolved" });
435
+ const jobs = await routeEntityChange({
436
+ deps,
437
+ automationStore: storeFor(auto),
438
+ changeDerivers: createChangeDeriverRegistry(),
439
+ changed: incidentChange("inc-1", "resolved"),
440
+ });
441
+ const wake = jobs.find((j) => j.reason === "wake")!;
442
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: createChangeDeriverRegistry(), job: wake });
443
+
444
+ expect(runs.runs.get(result.runId)?.status).toBe("success");
445
+ expect(rec.calls.map((c) => c.value)).toEqual(["done"]);
446
+ });
447
+
448
+ it("resolves health once per wake — the rich RPC path, never the entity resolver", async () => {
449
+ // Health has a registered entity resolver (it is a real `defineEntity`
450
+ // kind). The wait re-enrichment must STILL resolve health only through
451
+ // the rich `getBulkHealthState` RPC and EXCLUDE the health kind from the
452
+ // entity-store pass, so health is never round-tripped twice per wake.
453
+ const incidents = fakeIncidentEntities("incident");
454
+ incidents.set("inc-1", { status: "open" });
455
+
456
+ const actions = createActionRegistry();
457
+ const rec = makeRecordingAction();
458
+ actions.register(rec.definition, testPlugin);
459
+
460
+ let bulkHealthCalls = 0;
461
+ let healthEntityResolverCalls = 0;
462
+ const healthClient = {
463
+ getHealthState: async () => ({
464
+ status: "healthy",
465
+ inStatusSince: new Date(),
466
+ inStatusForMs: 0,
467
+ inMaintenance: false,
468
+ evaluatedAt: new Date(),
469
+ }),
470
+ getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
471
+ bulkHealthCalls += 1;
472
+ const states: Record<string, unknown> = {};
473
+ for (const id of systemIds) {
474
+ states[id] = {
475
+ status: "healthy",
476
+ inStatusSince: new Date(),
477
+ inStatusForMs: 0,
478
+ inMaintenance: false,
479
+ evaluatedAt: new Date(),
480
+ };
481
+ }
482
+ return { states };
483
+ },
484
+ } as never;
485
+
486
+ // A resolver that ALSO knows the health kind — if the entity pass ever
487
+ // asked for health, this counter would tick (it must not).
488
+ const resolverFor: DispatchDeps["entityResolverFor"] = (k) => {
489
+ if (k === "health") {
490
+ return async (ids) => {
491
+ healthEntityResolverCalls += 1;
492
+ const out: Record<string, Record<string, unknown>> = {};
493
+ for (const id of ids) {
494
+ out[id] = { status: "healthy", healthyChecks: 1, totalChecks: 1 };
495
+ }
496
+ return out;
497
+ };
498
+ }
499
+ return incidents.resolverFor?.(k);
500
+ };
501
+
502
+ const { deps, runs } = makeDispatchDeps({
503
+ actions,
504
+ healthCheckClient: healthClient,
505
+ entityResolverFor: resolverFor,
506
+ });
507
+
508
+ const auto = automation([
509
+ {
510
+ wait_until: {
511
+ condition:
512
+ "health.system.status == 'healthy' && state.incident['inc-1'].status == 'resolved'",
513
+ },
514
+ },
515
+ { action: "test.record", config: { value: "ok" } },
516
+ ]);
517
+ const result = await dispatchTrigger(deps, {
518
+ automation: auto,
519
+ triggerId: "test_event",
520
+ triggerEventId: "test.event",
521
+ payload: { id: "sys-1" },
522
+ contextKey: "sys-1",
523
+ });
524
+ expect(result.status).toBe("waiting");
525
+
526
+ const bulkAfterDispatch = bulkHealthCalls;
527
+
528
+ incidents.set("inc-1", { status: "resolved" });
529
+ const jobs = await routeEntityChange({
530
+ deps,
531
+ automationStore: storeFor(auto),
532
+ changeDerivers: createChangeDeriverRegistry(),
533
+ changed: incidentChange("inc-1", "resolved"),
534
+ });
535
+ const wake = jobs.find((j) => j.reason === "wake")!;
536
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: createChangeDeriverRegistry(), job: wake });
537
+
538
+ expect(runs.runs.get(result.runId)?.status).toBe("success");
539
+ // Health went through the rich RPC path on the wake re-eval...
540
+ expect(bulkHealthCalls).toBeGreaterThan(bulkAfterDispatch);
541
+ // ...and NEVER through the entity-store resolver — resolved once per build.
542
+ expect(healthEntityResolverCalls).toBe(0);
543
+ });
544
+ });