@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,152 @@
1
+ /**
2
+ * Stage-1 routing (reactive automation engine §7, §13).
3
+ *
4
+ * Subscribes to the internal `ENTITY_CHANGED` hook in work-queue mode
5
+ * (`workerGroup: "automation-entity-route"`) so exactly ONE instance claims
6
+ * each change. The claimer does only cheap, indexed routing:
7
+ *
8
+ * (a) Waiting runs — wake-index intersection lookup (§8.2): every
9
+ * `kind:"until"` wait lock whose dependency set includes the changed
10
+ * `${kind}:${id}` ref (or the kind-level wildcard). One Stage-2 `wake`
11
+ * job is enqueued per matching lock.
12
+ * (b) Fresh-run triggers — the change is run through the per-kind
13
+ * trigger-event deriver registry (§ change-derivers) to get the
14
+ * qualified trigger event id(s); each id is fanned out to the enabled
15
+ * automations referencing it (`findEnabledByTriggerEvent`). One
16
+ * Stage-2 `trigger` job is enqueued per (automation, event) match.
17
+ *
18
+ * Stage 1 never executes a run — it only enqueues Stage-2 jobs onto the
19
+ * `automation-dispatch` queue, keeping the claim fast.
20
+ */
21
+ import type { HookEventMeta, Logger } from "@checkstack/backend-api";
22
+ import {
23
+ EntityChangedSchema,
24
+ type DispatchJob,
25
+ type EntityChanged,
26
+ } from "@checkstack/automation-common";
27
+
28
+ import type { AutomationStore } from "../automation-store";
29
+ import type { ChangeDeriverRegistry } from "../entity/change-derivers";
30
+ import { ENTITY_CHANGED_HOOK } from "../entity/hook";
31
+ import { DISPATCH_QUEUE_NAME } from "./stage2-dispatch";
32
+ import type { DispatchDeps } from "./types";
33
+
34
+ /** Worker group for the Stage-1 claim (reactive automation engine §13.1). */
35
+ export const ENTITY_ROUTE_WORKER_GROUP = "automation-entity-route";
36
+
37
+ /** Type of `onHook` injected in afterPluginsReady (mirrors backend-api). */
38
+ export type OnHookFn = <T>(
39
+ hook: { id: string; _type?: T },
40
+ listener: (payload: T, meta?: HookEventMeta) => Promise<void>,
41
+ options?:
42
+ | { mode?: "broadcast"; maxRetries?: number }
43
+ | { mode: "work-queue"; workerGroup: string; maxRetries?: number }
44
+ | { mode: "instance-local" },
45
+ ) => () => Promise<void>;
46
+
47
+ export interface Stage1RouterArgs {
48
+ deps: DispatchDeps;
49
+ automationStore: AutomationStore;
50
+ changeDerivers: ChangeDeriverRegistry;
51
+ onHook: OnHookFn;
52
+ logger: Logger;
53
+ }
54
+
55
+ export interface Stage1Router {
56
+ dispose: () => Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * Route one validated entity change to Stage-2 jobs. Exported so tests can
61
+ * drive routing directly (without a real hook bus); returns the jobs it
62
+ * enqueued for assertion.
63
+ */
64
+ export async function routeEntityChange(args: {
65
+ deps: DispatchDeps;
66
+ automationStore: AutomationStore;
67
+ changeDerivers: ChangeDeriverRegistry;
68
+ changed: EntityChanged;
69
+ }): Promise<DispatchJob[]> {
70
+ const { deps, automationStore, changeDerivers, changed } = args;
71
+ const ref = `${changed.kind}:${changed.id}`;
72
+ const queue = deps.queueManager.getQueue<DispatchJob>(DISPATCH_QUEUE_NAME);
73
+ const enqueued: DispatchJob[] = [];
74
+
75
+ // (a) Waiting runs depending on this ref (or the kind wildcard).
76
+ const waits = await deps.runStore.findWaitLocksByWakeRef(ref);
77
+ for (const lock of waits) {
78
+ const job: DispatchJob = {
79
+ reason: "wake",
80
+ runId: lock.runId,
81
+ waitLockId: lock.id,
82
+ ref,
83
+ changed,
84
+ };
85
+ await queue.enqueue(job, {
86
+ // One in-flight wake per (lock, ref) — a duplicate change for the same
87
+ // ref collapses onto the same job id.
88
+ jobId: `wake:${lock.id}:${ref}`,
89
+ });
90
+ enqueued.push(job);
91
+ }
92
+
93
+ // (b) Fresh-run triggers — derive the qualified trigger event id(s).
94
+ const eventIds = changeDerivers.derive(changed);
95
+ for (const eventId of eventIds) {
96
+ const automations = await automationStore.findEnabledByTriggerEvent(eventId);
97
+ for (const automation of automations) {
98
+ const job: DispatchJob = {
99
+ reason: "trigger",
100
+ automationId: automation.id,
101
+ triggerId: eventId,
102
+ ref,
103
+ changed,
104
+ };
105
+ await queue.enqueue(job, {
106
+ // Dedup REDELIVERIES of one change (stable `changeId`) but distinguish
107
+ // two DISTINCT changes to the same entity — even within one
108
+ // millisecond, where `occurredAt` collides (§13.2). Fall back to
109
+ // `occurredAt` only for legacy payloads emitted before `changeId`.
110
+ jobId: `trigger:${automation.id}:${eventId}:${ref}:${changed.changeId ?? changed.occurredAt}`,
111
+ });
112
+ enqueued.push(job);
113
+ }
114
+ }
115
+
116
+ if (enqueued.length > 0) {
117
+ deps.logger.debug(
118
+ `stage1: routed ${ref} → ${enqueued.length} dispatch job(s) (${waits.length} wake, ${enqueued.length - waits.length} trigger)`,
119
+ );
120
+ }
121
+ return enqueued;
122
+ }
123
+
124
+ export async function startStage1Router(
125
+ args: Stage1RouterArgs,
126
+ ): Promise<Stage1Router> {
127
+ const unsub = args.onHook(
128
+ ENTITY_CHANGED_HOOK,
129
+ async (payload) => {
130
+ const parsed = EntityChangedSchema.safeParse(payload);
131
+ if (!parsed.success) {
132
+ args.logger.warn(
133
+ `stage1: dropping malformed ENTITY_CHANGED payload: ${parsed.error.message}`,
134
+ );
135
+ return;
136
+ }
137
+ await routeEntityChange({
138
+ deps: args.deps,
139
+ automationStore: args.automationStore,
140
+ changeDerivers: args.changeDerivers,
141
+ changed: parsed.data,
142
+ });
143
+ },
144
+ { mode: "work-queue", workerGroup: ENTITY_ROUTE_WORKER_GROUP },
145
+ );
146
+
147
+ return {
148
+ dispose: async () => {
149
+ await unsub();
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Integration test (real Redis / BullMQ) for Stage-1 routing exactly-once.
3
+ *
4
+ * Part of the surgical integration lane (plan §14.4 #4). The Stage-1 router
5
+ * subscribes to `ENTITY_CHANGED` in work-queue mode
6
+ * (`workerGroup: "automation-entity-route"`); the event-bus realises that as
7
+ * a BullMQ consumer group, so exactly one of N competing workers claims a
8
+ * given message. This pins that third-party contract directly against
9
+ * BullMQ: two workers sharing one consumer group on one queue → a single
10
+ * enqueued job is processed EXACTLY ONCE.
11
+ *
12
+ * Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it. The
13
+ * `integration` CI job sets the flag and provides a real Redis service.
14
+ * Connection comes from `CHECKSTACK_IT_REDIS_URL` (defaulting to the
15
+ * `docker-compose-dev.yml` Redis port). Each run uses a unique queue name +
16
+ * key prefix and tears the queue down afterwards.
17
+ */
18
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
19
+ import { Queue, Worker, type ConnectionOptions } from "bullmq";
20
+
21
+ function redisConnection(): ConnectionOptions {
22
+ const url = new URL(
23
+ process.env.CHECKSTACK_IT_REDIS_URL ?? "redis://localhost:6379",
24
+ );
25
+ return {
26
+ host: url.hostname,
27
+ port: Number(url.port || 6379),
28
+ password: url.password || undefined,
29
+ };
30
+ }
31
+
32
+ const QUEUE = `it_stage1_${crypto.randomUUID().replace(/-/g, "")}`;
33
+ const PREFIX = `it:${crypto.randomUUID().replace(/-/g, "")}`;
34
+
35
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
36
+ "Stage-1 routing exactly-once (real Redis)",
37
+ () => {
38
+ let queue: Queue;
39
+ const workers: Worker[] = [];
40
+
41
+ beforeAll(() => {
42
+ queue = new Queue(QUEUE, {
43
+ connection: redisConnection(),
44
+ prefix: PREFIX,
45
+ });
46
+ });
47
+
48
+ afterAll(async () => {
49
+ for (const w of workers) await w.close();
50
+ await queue.obliterate({ force: true }).catch(() => {});
51
+ await queue.close();
52
+ });
53
+
54
+ it("one ENTITY_CHANGED-style job runs the routing handler exactly once across two workers", async () => {
55
+ let processed = 0;
56
+ const seen: string[] = [];
57
+
58
+ // Two competing workers in the SAME consumer group (the event-bus uses
59
+ // the worker-group name as the BullMQ group; two workers on the same
60
+ // queue compete, so only one claims a given job).
61
+ const makeWorker = () =>
62
+ new Worker(
63
+ QUEUE,
64
+ async (job) => {
65
+ processed += 1;
66
+ seen.push(String(job.data.ref));
67
+ },
68
+ { connection: redisConnection(), prefix: PREFIX },
69
+ );
70
+ workers.push(makeWorker(), makeWorker());
71
+
72
+ // Wait for both workers to be ready before enqueuing.
73
+ await Promise.all(workers.map((w) => w.waitUntilReady()));
74
+
75
+ await queue.add("entity-changed", { ref: "health:sys-1" });
76
+
77
+ // Give the workers time to claim + process.
78
+ await new Promise((r) => setTimeout(r, 1500));
79
+
80
+ expect(processed).toBe(1);
81
+ expect(seen).toEqual(["health:sys-1"]);
82
+ });
83
+ },
84
+ );
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { AutomationDefinitionSchema } from "@checkstack/automation-common";
3
+ import type { EntityChanged, DispatchJob } from "@checkstack/automation-common";
4
+ import { SYSTEM_ACTOR } from "@checkstack/common";
5
+
6
+ import { createActionRegistry } from "../action-registry";
7
+ import { makeDispatchDeps, makeRecordingAction, testPlugin } from "./test-fixtures";
8
+ import { handleDispatchJob } from "./stage2-dispatch";
9
+ import { createChangeDeriverRegistry } from "../entity/change-derivers";
10
+ import type { AutomationStore } from "../automation-store";
11
+ import type { LoadedAutomation } from "./types";
12
+
13
+ /** An empty registry: every change falls back to the generic payload shape. */
14
+ function emptyDerivers() {
15
+ return createChangeDeriverRegistry();
16
+ }
17
+
18
+ function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
19
+ return {
20
+ kind: "fake",
21
+ id: "ent-1",
22
+ prev: null,
23
+ next: { status: "open", severity: "high" },
24
+ delta: { status: "open" },
25
+ changedFields: ["status"],
26
+ actor: SYSTEM_ACTOR,
27
+ occurredAt: new Date().toISOString(),
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function automationFor(event: string): LoadedAutomation {
33
+ const definition = AutomationDefinitionSchema.parse({
34
+ name: "A",
35
+ triggers: [{ event }],
36
+ conditions: [],
37
+ actions: [{ action: "test.record", config: { value: "{{ trigger.payload.status }}" } }],
38
+ mode: "single",
39
+ max_runs: 10,
40
+ });
41
+ return { id: "auto-1", name: "A", status: "enabled", definition };
42
+ }
43
+
44
+ function storeFor(auto: LoadedAutomation): AutomationStore {
45
+ return {
46
+ create: async () => {
47
+ throw new Error("nope");
48
+ },
49
+ update: async () => {
50
+ throw new Error("nope");
51
+ },
52
+ delete: async () => {},
53
+ toggle: async () => {
54
+ throw new Error("nope");
55
+ },
56
+ getById: async (id) =>
57
+ id === auto.id
58
+ ? {
59
+ id: auto.id,
60
+ name: auto.name,
61
+ description: undefined,
62
+ status: auto.status,
63
+ definition: auto.definition,
64
+ managedBy: undefined,
65
+ createdAt: new Date(),
66
+ updatedAt: new Date(),
67
+ }
68
+ : undefined,
69
+ list: async () => ({ items: [], total: 0 }),
70
+ listGroups: async () => [],
71
+ findEnabledByTriggerEvent: async () => [auto],
72
+ listEnabled: async () => [auto],
73
+ };
74
+ }
75
+
76
+ describe("Stage-2 handleDispatchJob — reason: trigger", () => {
77
+ it("starts a fresh run for the matched automation, with the change as payload", async () => {
78
+ const actions = createActionRegistry();
79
+ const rec = makeRecordingAction();
80
+ actions.register(rec.definition, testPlugin);
81
+ const { deps, runs } = makeDispatchDeps({ actions });
82
+
83
+ const auto = automationFor("fake.opened");
84
+ const job: DispatchJob = {
85
+ reason: "trigger",
86
+ automationId: "auto-1",
87
+ triggerId: "fake.opened",
88
+ ref: "fake:ent-1",
89
+ changed: change(),
90
+ };
91
+
92
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: emptyDerivers(), job });
93
+
94
+ // The action ran with the entity-change's `next.status` in payload.
95
+ expect(rec.calls.map((c) => c.value)).toEqual(["open"]);
96
+ // A run row exists and reached a terminal status.
97
+ expect([...runs.runs.values()].map((r) => r.status)).toEqual(["success"]);
98
+ });
99
+
100
+ it("drops the job when the automation is gone", async () => {
101
+ const { deps, runs } = makeDispatchDeps();
102
+ const job: DispatchJob = {
103
+ reason: "trigger",
104
+ automationId: "missing",
105
+ triggerId: "fake.opened",
106
+ ref: "fake:ent-1",
107
+ changed: change(),
108
+ };
109
+ await handleDispatchJob({
110
+ deps,
111
+ automationStore: storeFor(automationFor("fake.opened")),
112
+ changeDerivers: emptyDerivers(),
113
+ job,
114
+ });
115
+ expect(runs.runs.size).toBe(0);
116
+ });
117
+
118
+ it("does not run a disabled automation", async () => {
119
+ const actions = createActionRegistry();
120
+ const rec = makeRecordingAction();
121
+ actions.register(rec.definition, testPlugin);
122
+ const { deps, runs } = makeDispatchDeps({ actions });
123
+ const auto = automationFor("fake.opened");
124
+ auto.status = "disabled";
125
+ const job: DispatchJob = {
126
+ reason: "trigger",
127
+ automationId: "auto-1",
128
+ triggerId: "fake.opened",
129
+ ref: "fake:ent-1",
130
+ changed: change(),
131
+ };
132
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: emptyDerivers(), job });
133
+ expect(rec.calls).toHaveLength(0);
134
+ expect(runs.runs.size).toBe(0);
135
+ });
136
+ });
137
+
138
+ describe("Stage-2 handleDispatchJob — reason: wake", () => {
139
+ it("resumes a suspended wait_until whose condition now holds", async () => {
140
+ const actions = createActionRegistry();
141
+ const rec = makeRecordingAction();
142
+ actions.register(rec.definition, testPlugin);
143
+
144
+ // Health client that reports healthy, so the wait re-eval passes.
145
+ const healthClient = {
146
+ getHealthState: async () => ({
147
+ status: "healthy",
148
+ inStatusSince: new Date(),
149
+ inStatusForMs: 0,
150
+ inMaintenance: false,
151
+ evaluatedAt: new Date(),
152
+ }),
153
+ getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
154
+ const states: Record<string, unknown> = {};
155
+ for (const id of systemIds) {
156
+ states[id] = {
157
+ status: "healthy",
158
+ inStatusSince: new Date(),
159
+ inStatusForMs: 0,
160
+ inMaintenance: false,
161
+ evaluatedAt: new Date(),
162
+ };
163
+ }
164
+ return { states };
165
+ },
166
+ } as never;
167
+
168
+ const { deps, runs } = makeDispatchDeps({ actions, healthCheckClient: healthClient });
169
+
170
+ // Build an automation with a wait_until then a recording action, suspend
171
+ // it by dispatching while unhealthy is irrelevant — we seed the lock
172
+ // directly and drive the wake.
173
+ const definition = AutomationDefinitionSchema.parse({
174
+ name: "WU",
175
+ triggers: [{ event: "test.event" }],
176
+ conditions: [],
177
+ actions: [
178
+ { wait_until: { condition: "health.system.status == 'healthy'" } },
179
+ { action: "test.record", config: { value: "woke" } },
180
+ ],
181
+ mode: "single",
182
+ max_runs: 10,
183
+ });
184
+ const auto: LoadedAutomation = {
185
+ id: "auto-1",
186
+ name: "WU",
187
+ status: "enabled",
188
+ definition,
189
+ };
190
+
191
+ // Suspend the run via the engine so the lock + scope snapshot exist.
192
+ const unhealthyDeps = makeDispatchDeps({
193
+ actions,
194
+ healthCheckClient: {
195
+ getHealthState: async () => ({
196
+ status: "unhealthy",
197
+ inStatusSince: new Date(),
198
+ inStatusForMs: 0,
199
+ inMaintenance: false,
200
+ evaluatedAt: new Date(),
201
+ }),
202
+ getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
203
+ const states: Record<string, unknown> = {};
204
+ for (const id of systemIds) {
205
+ states[id] = {
206
+ status: "unhealthy",
207
+ inStatusSince: new Date(),
208
+ inStatusForMs: 0,
209
+ inMaintenance: false,
210
+ evaluatedAt: new Date(),
211
+ };
212
+ }
213
+ return { states };
214
+ },
215
+ } as never,
216
+ });
217
+ // Use one shared deps so the run/lock seeded by the engine is the one the
218
+ // wake reads. Re-dispatch on `deps` (healthy) would immediately satisfy,
219
+ // so instead seed with unhealthy deps and then wake with healthy deps
220
+ // pointing at the SAME stores.
221
+ void unhealthyDeps;
222
+
223
+ // Simplest faithful path: dispatch on `deps` but with health unhealthy
224
+ // first is not possible (deps is healthy). Instead seed the lock + run
225
+ // manually and drive the wake re-eval (healthy → resume).
226
+ await deps.runStore.createRun({
227
+ automationId: "auto-1",
228
+ triggerId: "t",
229
+ triggerEventId: "test.event",
230
+ triggerPayload: { id: "sys-1" },
231
+ contextKey: "sys-1",
232
+ });
233
+ await deps.runStore.updateRunStatus("run-1", "waiting");
234
+ await deps.runStateStore.upsert({
235
+ runId: "run-1",
236
+ scopeSnapshot: { trigger: { payload: { id: "sys-1" } } },
237
+ lastActionPath: null,
238
+ });
239
+ const lockId = await deps.runStore.createWaitLockWithWakeRefs({
240
+ runId: "run-1",
241
+ actionPath: "actions[0]",
242
+ eventId: "@@until",
243
+ contextKey: "sys-1",
244
+ timeoutAt: null,
245
+ waitConfig: {
246
+ condition: "health.system.status == 'healthy'",
247
+ continueOnTimeout: true,
248
+ },
249
+ wakeRefs: ["health:*"],
250
+ });
251
+
252
+ const job: DispatchJob = {
253
+ reason: "wake",
254
+ runId: "run-1",
255
+ waitLockId: lockId,
256
+ ref: "health:sys-1",
257
+ changed: change({ kind: "health", id: "sys-1" }),
258
+ };
259
+ await handleDispatchJob({ deps, automationStore: storeFor(auto), changeDerivers: emptyDerivers(), job });
260
+
261
+ // Re-eval passed (healthy) → resumed → recording action ran.
262
+ expect(rec.calls.map((c) => c.value)).toEqual(["woke"]);
263
+ expect(runs.runs.get("run-1")?.status).toBe("success");
264
+ expect(runs.waitLocks.size).toBe(0);
265
+ });
266
+
267
+ it("drops a wake job whose lock is gone", async () => {
268
+ const { deps } = makeDispatchDeps();
269
+ const job: DispatchJob = {
270
+ reason: "wake",
271
+ runId: "run-x",
272
+ waitLockId: "missing",
273
+ ref: "fake:ent-1",
274
+ changed: change(),
275
+ };
276
+ // No throw, no-op.
277
+ await handleDispatchJob({
278
+ deps,
279
+ automationStore: storeFor(automationFor("x")),
280
+ changeDerivers: emptyDerivers(),
281
+ job,
282
+ });
283
+ expect(true).toBe(true);
284
+ });
285
+ });