@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
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
3
|
+
import {
|
|
4
|
+
AutomationDefinitionSchema,
|
|
5
|
+
type Automation,
|
|
6
|
+
type Trigger,
|
|
7
|
+
} from "@checkstack/automation-common";
|
|
8
|
+
import type { AutomationStore } from "../automation-store";
|
|
9
|
+
import { armDwell, fireDwell } from "./dwell";
|
|
10
|
+
import { handleTriggerFiring, startRunRespectingMode } from "./trigger-subscriber";
|
|
11
|
+
import { startStalledSweeper } from "./stalled-sweeper";
|
|
12
|
+
import { makeDispatchDeps, makeRecordingAction, testPlugin } from "./test-fixtures";
|
|
13
|
+
import { createActionRegistry } from "../action-registry";
|
|
14
|
+
import { createTriggerRegistry } from "../trigger-registry";
|
|
15
|
+
import { createNumericStateTrigger } from "../builtin-triggers";
|
|
16
|
+
import type { TriggerDefinition } from "../action-types";
|
|
17
|
+
import type { LoadedAutomation } from "./types";
|
|
18
|
+
|
|
19
|
+
const EVENT = "test.event";
|
|
20
|
+
|
|
21
|
+
/** Build an automation around a single trigger (optionally with `for:`). */
|
|
22
|
+
function buildAutomation(opts: {
|
|
23
|
+
id?: string;
|
|
24
|
+
status?: "enabled" | "disabled";
|
|
25
|
+
trigger: Partial<Trigger> & { event: string };
|
|
26
|
+
conditions?: unknown[];
|
|
27
|
+
actions?: unknown[];
|
|
28
|
+
}): Automation {
|
|
29
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
30
|
+
name: "Dwell test",
|
|
31
|
+
triggers: [opts.trigger],
|
|
32
|
+
conditions: opts.conditions ?? [],
|
|
33
|
+
actions: opts.actions ?? [
|
|
34
|
+
{ action: "test.record", config: { value: "fired" } },
|
|
35
|
+
],
|
|
36
|
+
mode: "single",
|
|
37
|
+
max_runs: 10,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
id: opts.id ?? "auto-1",
|
|
41
|
+
name: "Dwell test",
|
|
42
|
+
status: opts.status ?? "enabled",
|
|
43
|
+
definition,
|
|
44
|
+
createdAt: new Date(),
|
|
45
|
+
updatedAt: new Date(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Minimal in-memory automation store covering the dwell read paths. */
|
|
50
|
+
function makeAutomationStore(automations: Automation[]): AutomationStore {
|
|
51
|
+
const byId = new Map(automations.map((a) => [a.id, a]));
|
|
52
|
+
const loaded = (a: Automation): LoadedAutomation => ({
|
|
53
|
+
id: a.id,
|
|
54
|
+
name: a.name,
|
|
55
|
+
status: a.status,
|
|
56
|
+
definition: a.definition,
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
create: async () => {
|
|
60
|
+
throw new Error("not used");
|
|
61
|
+
},
|
|
62
|
+
update: async () => {
|
|
63
|
+
throw new Error("not used");
|
|
64
|
+
},
|
|
65
|
+
delete: async () => {},
|
|
66
|
+
toggle: async () => {
|
|
67
|
+
throw new Error("not used");
|
|
68
|
+
},
|
|
69
|
+
getById: async (id) => byId.get(id),
|
|
70
|
+
list: async () => ({ items: [...byId.values()], total: byId.size }),
|
|
71
|
+
listGroups: async () => [],
|
|
72
|
+
findEnabledByTriggerEvent: async (eventId) =>
|
|
73
|
+
[...byId.values()]
|
|
74
|
+
.filter(
|
|
75
|
+
(a) =>
|
|
76
|
+
a.status === "enabled" &&
|
|
77
|
+
a.definition.triggers.some((t) => t.event === eventId),
|
|
78
|
+
)
|
|
79
|
+
.map(loaded),
|
|
80
|
+
listEnabled: async () =>
|
|
81
|
+
[...byId.values()].filter((a) => a.status === "enabled").map(loaded),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fake health client returning a fixed status per system. */
|
|
86
|
+
function makeHealthClient(statuses: Record<string, string>) {
|
|
87
|
+
const calls: string[] = [];
|
|
88
|
+
return {
|
|
89
|
+
calls,
|
|
90
|
+
client: {
|
|
91
|
+
getHealthState: async ({ systemId }: { systemId: string }) => {
|
|
92
|
+
calls.push(systemId);
|
|
93
|
+
return {
|
|
94
|
+
status: statuses[systemId] ?? "healthy",
|
|
95
|
+
inStatusSince: new Date(),
|
|
96
|
+
inStatusForMs: 0,
|
|
97
|
+
inMaintenance: false,
|
|
98
|
+
evaluatedAt: new Date(),
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
getBulkHealthState: async ({ systemIds }: { systemIds: string[] }) => {
|
|
102
|
+
const states: Record<string, unknown> = {};
|
|
103
|
+
for (const id of systemIds) {
|
|
104
|
+
states[id] = {
|
|
105
|
+
status: statuses[id] ?? "healthy",
|
|
106
|
+
inStatusSince: new Date(),
|
|
107
|
+
inStatusForMs: 0,
|
|
108
|
+
inMaintenance: false,
|
|
109
|
+
evaluatedAt: new Date(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { states };
|
|
113
|
+
},
|
|
114
|
+
} as never,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function setup(opts?: { statuses?: Record<string, string> }) {
|
|
119
|
+
const actionsReg = createActionRegistry();
|
|
120
|
+
const rec = makeRecordingAction();
|
|
121
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
122
|
+
const health = makeHealthClient(opts?.statuses ?? { "sys-1": "unhealthy" });
|
|
123
|
+
const { deps, dwells, runs, queue } = makeDispatchDeps({
|
|
124
|
+
actions: actionsReg,
|
|
125
|
+
healthCheckClient: health.client,
|
|
126
|
+
});
|
|
127
|
+
return { deps, dwells, runs, queue, rec, health };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("armDwell", () => {
|
|
131
|
+
it("arms a dwell row + enqueues a wake job, snapshotting the armed status", async () => {
|
|
132
|
+
const { deps, dwells, queue } = setup();
|
|
133
|
+
const automation = buildAutomation({
|
|
134
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await armDwell({
|
|
138
|
+
deps,
|
|
139
|
+
automation,
|
|
140
|
+
trigger: automation.definition.triggers[0]!,
|
|
141
|
+
triggerId: "test_event",
|
|
142
|
+
eventId: EVENT,
|
|
143
|
+
contextKey: "sys-1",
|
|
144
|
+
triggerPayload: { id: "sys-1" },
|
|
145
|
+
actor: SYSTEM_ACTOR,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(dwells.dwells.size).toBe(1);
|
|
149
|
+
const dwell = [...dwells.dwells.values()][0]!;
|
|
150
|
+
expect(dwell.armedStatus).toBe("unhealthy");
|
|
151
|
+
expect(dwell.contextKey).toBe("sys-1");
|
|
152
|
+
// 30 min in the future (allow a small window)
|
|
153
|
+
const deltaMs = dwell.fireAt.getTime() - Date.now();
|
|
154
|
+
expect(deltaMs).toBeGreaterThan(29 * 60_000);
|
|
155
|
+
expect(deltaMs).toBeLessThanOrEqual(30 * 60_000 + 1000);
|
|
156
|
+
// a wake job was enqueued with the matching startDelay
|
|
157
|
+
expect(queue.jobs).toHaveLength(1);
|
|
158
|
+
expect(queue.jobs[0]?.queue).toBe("automation-dwell");
|
|
159
|
+
expect(queue.jobs[0]?.startDelay).toBe(30 * 60);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("re-arm PRESERVES the original fireAt (continuous-since-first-arm), not stacking", async () => {
|
|
163
|
+
// Critical for sustained `for:`: a continuously re-firing trigger
|
|
164
|
+
// (e.g. level-triggered numeric_state every 60s with for: 10m) must
|
|
165
|
+
// NOT keep pushing the deadline, or it would never elapse. The window
|
|
166
|
+
// measures "matched continuously since first arm" (HA semantics).
|
|
167
|
+
const { deps, dwells } = setup();
|
|
168
|
+
const automation = buildAutomation({
|
|
169
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
170
|
+
});
|
|
171
|
+
const arm = () =>
|
|
172
|
+
armDwell({
|
|
173
|
+
deps,
|
|
174
|
+
automation,
|
|
175
|
+
trigger: automation.definition.triggers[0]!,
|
|
176
|
+
triggerId: "test_event",
|
|
177
|
+
eventId: EVENT,
|
|
178
|
+
contextKey: "sys-1",
|
|
179
|
+
triggerPayload: { id: "sys-1" },
|
|
180
|
+
actor: SYSTEM_ACTOR,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await arm();
|
|
184
|
+
const first = [...dwells.dwells.values()][0]!;
|
|
185
|
+
const firstFireAt = first.fireAt.getTime();
|
|
186
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
187
|
+
await arm();
|
|
188
|
+
|
|
189
|
+
expect(dwells.dwells.size).toBe(1); // still one row
|
|
190
|
+
const after = [...dwells.dwells.values()][0]!;
|
|
191
|
+
expect(after.id).toBe(first.id);
|
|
192
|
+
// fireAt UNCHANGED — original deadline preserved.
|
|
193
|
+
expect(after.fireAt.getTime()).toBe(firstFireAt);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("fireDwell", () => {
|
|
198
|
+
it("re-confirms the armed status still holds, then starts the run", async () => {
|
|
199
|
+
const { deps, dwells, rec, runs } = setup({
|
|
200
|
+
statuses: { "sys-1": "unhealthy" },
|
|
201
|
+
});
|
|
202
|
+
const automation = buildAutomation({
|
|
203
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
204
|
+
});
|
|
205
|
+
const store = makeAutomationStore([automation]);
|
|
206
|
+
|
|
207
|
+
const { id: dwellId } = await deps.dwellStore.arm({
|
|
208
|
+
automationId: automation.id,
|
|
209
|
+
triggerId: "test_event",
|
|
210
|
+
eventId: EVENT,
|
|
211
|
+
contextKey: "sys-1",
|
|
212
|
+
armedStatus: "unhealthy",
|
|
213
|
+
payloadSnapshot: { id: "sys-1" },
|
|
214
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
215
|
+
fireAt: new Date(),
|
|
216
|
+
});
|
|
217
|
+
const dwell = (await deps.dwellStore.load(dwellId))!;
|
|
218
|
+
|
|
219
|
+
await fireDwell({
|
|
220
|
+
deps,
|
|
221
|
+
automationStore: store,
|
|
222
|
+
dwell,
|
|
223
|
+
startRun: startRunRespectingMode,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// dwell row consumed, and the run fired the action
|
|
227
|
+
expect(dwells.dwells.size).toBe(0);
|
|
228
|
+
expect(rec.calls).toHaveLength(1);
|
|
229
|
+
expect(rec.calls[0]?.value).toBe("fired");
|
|
230
|
+
expect(runs.runs.size).toBe(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does NOT start a run when the system has left the armed status", async () => {
|
|
234
|
+
// armed unhealthy, but the system recovered to healthy by expiry
|
|
235
|
+
const { deps, dwells, rec, runs } = setup({
|
|
236
|
+
statuses: { "sys-1": "healthy" },
|
|
237
|
+
});
|
|
238
|
+
const automation = buildAutomation({
|
|
239
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
240
|
+
});
|
|
241
|
+
const store = makeAutomationStore([automation]);
|
|
242
|
+
|
|
243
|
+
const { id: dwellId } = await deps.dwellStore.arm({
|
|
244
|
+
automationId: automation.id,
|
|
245
|
+
triggerId: "test_event",
|
|
246
|
+
eventId: EVENT,
|
|
247
|
+
contextKey: "sys-1",
|
|
248
|
+
armedStatus: "unhealthy",
|
|
249
|
+
payloadSnapshot: { id: "sys-1" },
|
|
250
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
251
|
+
fireAt: new Date(),
|
|
252
|
+
});
|
|
253
|
+
const dwell = (await deps.dwellStore.load(dwellId))!;
|
|
254
|
+
|
|
255
|
+
await fireDwell({
|
|
256
|
+
deps,
|
|
257
|
+
automationStore: store,
|
|
258
|
+
dwell,
|
|
259
|
+
startRun: startRunRespectingMode,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(dwells.dwells.size).toBe(0); // row still consumed (delete-first)
|
|
263
|
+
expect(rec.calls).toHaveLength(0); // but no run
|
|
264
|
+
expect(runs.runs.size).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("drops the dwell without firing when the automation is disabled", async () => {
|
|
268
|
+
const { deps, dwells, rec } = setup({ statuses: { "sys-1": "unhealthy" } });
|
|
269
|
+
const automation = buildAutomation({
|
|
270
|
+
status: "disabled",
|
|
271
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
272
|
+
});
|
|
273
|
+
const store = makeAutomationStore([automation]);
|
|
274
|
+
|
|
275
|
+
const { id: dwellId } = await deps.dwellStore.arm({
|
|
276
|
+
automationId: automation.id,
|
|
277
|
+
triggerId: "test_event",
|
|
278
|
+
eventId: EVENT,
|
|
279
|
+
contextKey: "sys-1",
|
|
280
|
+
armedStatus: "unhealthy",
|
|
281
|
+
payloadSnapshot: { id: "sys-1" },
|
|
282
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
283
|
+
fireAt: new Date(),
|
|
284
|
+
});
|
|
285
|
+
const dwell = (await deps.dwellStore.load(dwellId))!;
|
|
286
|
+
|
|
287
|
+
await fireDwell({
|
|
288
|
+
deps,
|
|
289
|
+
automationStore: store,
|
|
290
|
+
dwell,
|
|
291
|
+
startRun: startRunRespectingMode,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(dwells.dwells.size).toBe(0);
|
|
295
|
+
expect(rec.calls).toHaveLength(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("two concurrent fires of the same dwell start exactly ONE run (atomic claim)", async () => {
|
|
299
|
+
// H1 regression: fireDwell deletes the row FIRST as an atomic claim
|
|
300
|
+
// (DELETE ... RETURNING). Two racing callers (e.g. the queue consumer
|
|
301
|
+
// and the stalled sweeper, or two pods) must not both startRun.
|
|
302
|
+
const { deps, dwells, rec, runs } = setup({
|
|
303
|
+
statuses: { "sys-1": "unhealthy" },
|
|
304
|
+
});
|
|
305
|
+
const automation = buildAutomation({
|
|
306
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
307
|
+
});
|
|
308
|
+
const store = makeAutomationStore([automation]);
|
|
309
|
+
|
|
310
|
+
const { id: dwellId } = await deps.dwellStore.arm({
|
|
311
|
+
automationId: automation.id,
|
|
312
|
+
triggerId: "test_event",
|
|
313
|
+
eventId: EVENT,
|
|
314
|
+
contextKey: "sys-1",
|
|
315
|
+
armedStatus: "unhealthy",
|
|
316
|
+
payloadSnapshot: { id: "sys-1" },
|
|
317
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
318
|
+
fireAt: new Date(),
|
|
319
|
+
});
|
|
320
|
+
const dwell = (await deps.dwellStore.load(dwellId))!;
|
|
321
|
+
|
|
322
|
+
// Both callers see the same loaded dwell and race to fire it.
|
|
323
|
+
await Promise.all([
|
|
324
|
+
fireDwell({
|
|
325
|
+
deps,
|
|
326
|
+
automationStore: store,
|
|
327
|
+
dwell,
|
|
328
|
+
startRun: startRunRespectingMode,
|
|
329
|
+
}),
|
|
330
|
+
fireDwell({
|
|
331
|
+
deps,
|
|
332
|
+
automationStore: store,
|
|
333
|
+
dwell,
|
|
334
|
+
startRun: startRunRespectingMode,
|
|
335
|
+
}),
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
expect(dwells.dwells.size).toBe(0);
|
|
339
|
+
// Exactly one winner started a run / fired the action.
|
|
340
|
+
expect(rec.calls).toHaveLength(1);
|
|
341
|
+
expect(runs.runs.size).toBe(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("degrades a malformed stored actorSnapshot to the system actor instead of crashing (L1)", async () => {
|
|
345
|
+
// A drifted / hand-edited actorSnapshot must not flow through untyped:
|
|
346
|
+
// the load-time parse degrades it to the system actor so the run still
|
|
347
|
+
// fires (dropping a legitimate alert over a bad snapshot would be worse).
|
|
348
|
+
const { deps, rec, runs } = setup({ statuses: { "sys-1": "unhealthy" } });
|
|
349
|
+
const automation = buildAutomation({
|
|
350
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
351
|
+
});
|
|
352
|
+
const store = makeAutomationStore([automation]);
|
|
353
|
+
|
|
354
|
+
const { id: dwellId } = await deps.dwellStore.arm({
|
|
355
|
+
automationId: automation.id,
|
|
356
|
+
triggerId: "test_event",
|
|
357
|
+
eventId: EVENT,
|
|
358
|
+
contextKey: "sys-1",
|
|
359
|
+
armedStatus: "unhealthy",
|
|
360
|
+
payloadSnapshot: { id: "sys-1" },
|
|
361
|
+
// Garbage actor snapshot (e.g. a row hand-edited or written by an
|
|
362
|
+
// older/drifted schema).
|
|
363
|
+
actorSnapshot: { type: "bogus", nope: 1 } as unknown as Record<
|
|
364
|
+
string,
|
|
365
|
+
unknown
|
|
366
|
+
>,
|
|
367
|
+
fireAt: new Date(),
|
|
368
|
+
});
|
|
369
|
+
const dwell = (await deps.dwellStore.load(dwellId))!;
|
|
370
|
+
|
|
371
|
+
await fireDwell({
|
|
372
|
+
deps,
|
|
373
|
+
automationStore: store,
|
|
374
|
+
dwell,
|
|
375
|
+
startRun: startRunRespectingMode,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Still fired (degraded gracefully), did not throw.
|
|
379
|
+
expect(rec.calls).toHaveLength(1);
|
|
380
|
+
expect(runs.runs.size).toBe(1);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("re-checks pre-run conditions at fire time", async () => {
|
|
384
|
+
// condition references live health; system is healthy so it should NOT fire
|
|
385
|
+
const { deps, rec } = setup({ statuses: { "sys-1": "unhealthy" } });
|
|
386
|
+
const automation = buildAutomation({
|
|
387
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
388
|
+
conditions: ["health.system.status == 'degraded'"], // never true here
|
|
389
|
+
});
|
|
390
|
+
const store = makeAutomationStore([automation]);
|
|
391
|
+
|
|
392
|
+
const { id: dwellId } = await deps.dwellStore.arm({
|
|
393
|
+
automationId: automation.id,
|
|
394
|
+
triggerId: "test_event",
|
|
395
|
+
eventId: EVENT,
|
|
396
|
+
contextKey: "sys-1",
|
|
397
|
+
armedStatus: "unhealthy",
|
|
398
|
+
payloadSnapshot: { id: "sys-1" },
|
|
399
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
400
|
+
fireAt: new Date(),
|
|
401
|
+
});
|
|
402
|
+
const dwell = (await deps.dwellStore.load(dwellId))!;
|
|
403
|
+
|
|
404
|
+
await fireDwell({
|
|
405
|
+
deps,
|
|
406
|
+
automationStore: store,
|
|
407
|
+
dwell,
|
|
408
|
+
startRun: startRunRespectingMode,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(rec.calls).toHaveLength(0);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("handleTriggerFiring with for: triggers", () => {
|
|
416
|
+
it("arms a dwell instead of starting a run immediately", async () => {
|
|
417
|
+
const { deps, dwells, rec } = setup({ statuses: { "sys-1": "unhealthy" } });
|
|
418
|
+
const automation = buildAutomation({
|
|
419
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
420
|
+
});
|
|
421
|
+
const store = makeAutomationStore([automation]);
|
|
422
|
+
|
|
423
|
+
await handleTriggerFiring({
|
|
424
|
+
deps,
|
|
425
|
+
automationStore: store,
|
|
426
|
+
qualifiedEventId: EVENT,
|
|
427
|
+
triggerPayload: { id: "sys-1" },
|
|
428
|
+
actor: SYSTEM_ACTOR,
|
|
429
|
+
contextKey: "sys-1",
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect(dwells.dwells.size).toBe(1);
|
|
433
|
+
expect(rec.calls).toHaveLength(0); // armed, not fired
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("eagerly cancels a stale dwell when the system leaves the armed status", async () => {
|
|
437
|
+
// arm a dwell while unhealthy...
|
|
438
|
+
const { deps, dwells } = setup({ statuses: { "sys-1": "unhealthy" } });
|
|
439
|
+
const automation = buildAutomation({
|
|
440
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
441
|
+
});
|
|
442
|
+
const store = makeAutomationStore([automation]);
|
|
443
|
+
|
|
444
|
+
await deps.dwellStore.arm({
|
|
445
|
+
automationId: automation.id,
|
|
446
|
+
triggerId: "test_event",
|
|
447
|
+
eventId: EVENT,
|
|
448
|
+
contextKey: "sys-1",
|
|
449
|
+
armedStatus: "unhealthy",
|
|
450
|
+
payloadSnapshot: { id: "sys-1" },
|
|
451
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
452
|
+
fireAt: new Date(Date.now() + 30 * 60_000),
|
|
453
|
+
});
|
|
454
|
+
expect(dwells.dwells.size).toBe(1);
|
|
455
|
+
|
|
456
|
+
// ...now the system reports healthy and an event fires again
|
|
457
|
+
deps.healthCheckClient = makeHealthClient({ "sys-1": "healthy" }).client;
|
|
458
|
+
await handleTriggerFiring({
|
|
459
|
+
deps,
|
|
460
|
+
automationStore: store,
|
|
461
|
+
qualifiedEventId: EVENT,
|
|
462
|
+
triggerPayload: { id: "sys-1" },
|
|
463
|
+
actor: SYSTEM_ACTOR,
|
|
464
|
+
contextKey: "sys-1",
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// the stale dwell was cancelled; a fresh one was armed (still unhealthy
|
|
468
|
+
// armed-status? no — now healthy, so the fresh arm snapshots "healthy").
|
|
469
|
+
// Either way the original unhealthy-armed dwell is gone.
|
|
470
|
+
const remaining = [...dwells.dwells.values()];
|
|
471
|
+
expect(remaining.every((d) => d.armedStatus !== "unhealthy")).toBe(true);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("starts a run immediately for a trigger WITHOUT for:", async () => {
|
|
475
|
+
const { deps, dwells, rec } = setup({ statuses: { "sys-1": "unhealthy" } });
|
|
476
|
+
const automation = buildAutomation({ trigger: { event: EVENT } });
|
|
477
|
+
const store = makeAutomationStore([automation]);
|
|
478
|
+
|
|
479
|
+
await handleTriggerFiring({
|
|
480
|
+
deps,
|
|
481
|
+
automationStore: store,
|
|
482
|
+
qualifiedEventId: EVENT,
|
|
483
|
+
triggerPayload: { id: "sys-1" },
|
|
484
|
+
actor: SYSTEM_ACTOR,
|
|
485
|
+
contextKey: "sys-1",
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
expect(dwells.dwells.size).toBe(0);
|
|
489
|
+
expect(rec.calls).toHaveLength(1);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe("dwell store — keys + sweep", () => {
|
|
494
|
+
it("keeps at most one dwell per (automation, trigger, contextKey)", async () => {
|
|
495
|
+
const { deps, dwells } = setup();
|
|
496
|
+
const base = {
|
|
497
|
+
automationId: "auto-1",
|
|
498
|
+
triggerId: "test_event",
|
|
499
|
+
eventId: EVENT,
|
|
500
|
+
armedStatus: "unhealthy",
|
|
501
|
+
payloadSnapshot: {},
|
|
502
|
+
actorSnapshot: {},
|
|
503
|
+
};
|
|
504
|
+
await deps.dwellStore.arm({
|
|
505
|
+
...base,
|
|
506
|
+
contextKey: "sys-1",
|
|
507
|
+
fireAt: new Date(),
|
|
508
|
+
});
|
|
509
|
+
await deps.dwellStore.arm({
|
|
510
|
+
...base,
|
|
511
|
+
contextKey: "sys-1",
|
|
512
|
+
fireAt: new Date(),
|
|
513
|
+
});
|
|
514
|
+
await deps.dwellStore.arm({
|
|
515
|
+
...base,
|
|
516
|
+
contextKey: "sys-2",
|
|
517
|
+
fireAt: new Date(),
|
|
518
|
+
});
|
|
519
|
+
expect(dwells.dwells.size).toBe(2); // sys-1 deduped, sys-2 distinct
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("sweepExpired returns only dwells whose fireAt has passed", async () => {
|
|
523
|
+
const { deps } = setup();
|
|
524
|
+
await deps.dwellStore.arm({
|
|
525
|
+
automationId: "auto-1",
|
|
526
|
+
triggerId: "t",
|
|
527
|
+
eventId: EVENT,
|
|
528
|
+
contextKey: "past",
|
|
529
|
+
armedStatus: null,
|
|
530
|
+
payloadSnapshot: {},
|
|
531
|
+
actorSnapshot: {},
|
|
532
|
+
fireAt: new Date(Date.now() - 1000),
|
|
533
|
+
});
|
|
534
|
+
await deps.dwellStore.arm({
|
|
535
|
+
automationId: "auto-1",
|
|
536
|
+
triggerId: "t",
|
|
537
|
+
eventId: EVENT,
|
|
538
|
+
contextKey: "future",
|
|
539
|
+
armedStatus: null,
|
|
540
|
+
payloadSnapshot: {},
|
|
541
|
+
actorSnapshot: {},
|
|
542
|
+
fireAt: new Date(Date.now() + 60_000),
|
|
543
|
+
});
|
|
544
|
+
const expired = await deps.dwellStore.sweepExpired(new Date());
|
|
545
|
+
expect(expired).toHaveLength(1);
|
|
546
|
+
expect(expired[0]?.contextKey).toBe("past");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("restart recovery: the sweeper fires an expired dwell whose queue job was lost", async () => {
|
|
550
|
+
const { deps, dwells, rec, runs } = setup({
|
|
551
|
+
statuses: { "sys-1": "unhealthy" },
|
|
552
|
+
});
|
|
553
|
+
const automation = buildAutomation({
|
|
554
|
+
trigger: { event: EVENT, for: { minutes: 30 } },
|
|
555
|
+
});
|
|
556
|
+
const store = makeAutomationStore([automation]);
|
|
557
|
+
|
|
558
|
+
// Dwell armed and already past its fireAt — simulating a queue job that
|
|
559
|
+
// never arrived (process restart / Redis loss).
|
|
560
|
+
await deps.dwellStore.arm({
|
|
561
|
+
automationId: automation.id,
|
|
562
|
+
triggerId: "test_event",
|
|
563
|
+
eventId: EVENT,
|
|
564
|
+
contextKey: "sys-1",
|
|
565
|
+
armedStatus: "unhealthy",
|
|
566
|
+
payloadSnapshot: { id: "sys-1" },
|
|
567
|
+
actorSnapshot: SYSTEM_ACTOR as unknown as Record<string, unknown>,
|
|
568
|
+
fireAt: new Date(Date.now() - 1000),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const sweeper = startStalledSweeper({
|
|
572
|
+
deps,
|
|
573
|
+
automationStore: store,
|
|
574
|
+
logger: deps.logger,
|
|
575
|
+
});
|
|
576
|
+
await sweeper.sweep();
|
|
577
|
+
sweeper.stop();
|
|
578
|
+
|
|
579
|
+
expect(dwells.dwells.size).toBe(0);
|
|
580
|
+
expect(rec.calls).toHaveLength(1);
|
|
581
|
+
expect(runs.runs.size).toBe(1);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("deleteForAutomation drops every dwell for the automation", async () => {
|
|
585
|
+
const { deps, dwells } = setup();
|
|
586
|
+
await deps.dwellStore.arm({
|
|
587
|
+
automationId: "auto-1",
|
|
588
|
+
triggerId: "t",
|
|
589
|
+
eventId: EVENT,
|
|
590
|
+
contextKey: "a",
|
|
591
|
+
armedStatus: null,
|
|
592
|
+
payloadSnapshot: {},
|
|
593
|
+
actorSnapshot: {},
|
|
594
|
+
fireAt: new Date(),
|
|
595
|
+
});
|
|
596
|
+
await deps.dwellStore.arm({
|
|
597
|
+
automationId: "auto-2",
|
|
598
|
+
triggerId: "t",
|
|
599
|
+
eventId: EVENT,
|
|
600
|
+
contextKey: "b",
|
|
601
|
+
armedStatus: null,
|
|
602
|
+
payloadSnapshot: {},
|
|
603
|
+
actorSnapshot: {},
|
|
604
|
+
fireAt: new Date(),
|
|
605
|
+
});
|
|
606
|
+
await deps.dwellStore.deleteForAutomation("auto-1");
|
|
607
|
+
expect(dwells.dwells.size).toBe(1);
|
|
608
|
+
expect([...dwells.dwells.values()][0]?.automationId).toBe("auto-2");
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("numeric_state trigger + for: via handleTriggerFiring", () => {
|
|
613
|
+
const NUMERIC_EVENT = "test.numeric_state";
|
|
614
|
+
|
|
615
|
+
function setupNumeric() {
|
|
616
|
+
const triggers = createTriggerRegistry();
|
|
617
|
+
triggers.register(
|
|
618
|
+
// re-id the built-in numeric_state under the `test` plugin so the
|
|
619
|
+
// registered qualifiedId matches the automation's trigger event.
|
|
620
|
+
{
|
|
621
|
+
...createNumericStateTrigger(),
|
|
622
|
+
id: "numeric_state",
|
|
623
|
+
} as unknown as TriggerDefinition<unknown, unknown>,
|
|
624
|
+
testPlugin,
|
|
625
|
+
);
|
|
626
|
+
const actionsReg = createActionRegistry();
|
|
627
|
+
const rec = makeRecordingAction();
|
|
628
|
+
actionsReg.register(rec.definition, testPlugin);
|
|
629
|
+
const { deps, dwells, runs } = makeDispatchDeps({
|
|
630
|
+
actions: actionsReg,
|
|
631
|
+
triggers,
|
|
632
|
+
});
|
|
633
|
+
return { deps, dwells, runs, rec };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function numericAutomation(forDwell: boolean): Automation {
|
|
637
|
+
const trigger: Record<string, unknown> = {
|
|
638
|
+
event: NUMERIC_EVENT,
|
|
639
|
+
config: { field: "latencyMs", above: 500 },
|
|
640
|
+
};
|
|
641
|
+
if (forDwell) trigger.for = { minutes: 30 };
|
|
642
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
643
|
+
name: "Numeric",
|
|
644
|
+
triggers: [trigger],
|
|
645
|
+
conditions: [],
|
|
646
|
+
actions: [{ action: "test.record", config: { value: "fired" } }],
|
|
647
|
+
mode: "single",
|
|
648
|
+
max_runs: 10,
|
|
649
|
+
});
|
|
650
|
+
return {
|
|
651
|
+
id: "auto-num",
|
|
652
|
+
name: "Numeric",
|
|
653
|
+
status: "enabled",
|
|
654
|
+
definition,
|
|
655
|
+
createdAt: new Date(),
|
|
656
|
+
updatedAt: new Date(),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
it("arms a dwell only when the numeric threshold crosses (with for:)", async () => {
|
|
661
|
+
const { deps, dwells, rec } = setupNumeric();
|
|
662
|
+
const store = makeAutomationStore([numericAutomation(true)]);
|
|
663
|
+
|
|
664
|
+
// Below threshold → no arm.
|
|
665
|
+
await handleTriggerFiring({
|
|
666
|
+
deps,
|
|
667
|
+
automationStore: store,
|
|
668
|
+
qualifiedEventId: NUMERIC_EVENT,
|
|
669
|
+
triggerPayload: {
|
|
670
|
+
systemId: "sys-1",
|
|
671
|
+
configurationId: "c",
|
|
672
|
+
status: "healthy",
|
|
673
|
+
latencyMs: 100,
|
|
674
|
+
},
|
|
675
|
+
actor: SYSTEM_ACTOR,
|
|
676
|
+
contextKey: "sys-1",
|
|
677
|
+
});
|
|
678
|
+
expect(dwells.dwells.size).toBe(0);
|
|
679
|
+
|
|
680
|
+
// Above threshold → arm.
|
|
681
|
+
await handleTriggerFiring({
|
|
682
|
+
deps,
|
|
683
|
+
automationStore: store,
|
|
684
|
+
qualifiedEventId: NUMERIC_EVENT,
|
|
685
|
+
triggerPayload: {
|
|
686
|
+
systemId: "sys-1",
|
|
687
|
+
configurationId: "c",
|
|
688
|
+
status: "degraded",
|
|
689
|
+
latencyMs: 600,
|
|
690
|
+
},
|
|
691
|
+
actor: SYSTEM_ACTOR,
|
|
692
|
+
contextKey: "sys-1",
|
|
693
|
+
});
|
|
694
|
+
expect(dwells.dwells.size).toBe(1);
|
|
695
|
+
expect(rec.calls).toHaveLength(0); // armed, not fired
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("starts a run immediately when the threshold crosses without for:", async () => {
|
|
699
|
+
const { deps, dwells, rec } = setupNumeric();
|
|
700
|
+
const store = makeAutomationStore([numericAutomation(false)]);
|
|
701
|
+
|
|
702
|
+
await handleTriggerFiring({
|
|
703
|
+
deps,
|
|
704
|
+
automationStore: store,
|
|
705
|
+
qualifiedEventId: NUMERIC_EVENT,
|
|
706
|
+
triggerPayload: {
|
|
707
|
+
systemId: "sys-1",
|
|
708
|
+
configurationId: "c",
|
|
709
|
+
status: "degraded",
|
|
710
|
+
latencyMs: 600,
|
|
711
|
+
},
|
|
712
|
+
actor: SYSTEM_ACTOR,
|
|
713
|
+
contextKey: "sys-1",
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
expect(dwells.dwells.size).toBe(0);
|
|
717
|
+
expect(rec.calls).toHaveLength(1);
|
|
718
|
+
expect(rec.calls[0]?.value).toBe("fired");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("does not fire below the threshold (no dwell)", async () => {
|
|
722
|
+
const { deps, dwells, rec } = setupNumeric();
|
|
723
|
+
const store = makeAutomationStore([numericAutomation(false)]);
|
|
724
|
+
|
|
725
|
+
await handleTriggerFiring({
|
|
726
|
+
deps,
|
|
727
|
+
automationStore: store,
|
|
728
|
+
qualifiedEventId: NUMERIC_EVENT,
|
|
729
|
+
triggerPayload: {
|
|
730
|
+
systemId: "sys-1",
|
|
731
|
+
configurationId: "c",
|
|
732
|
+
status: "healthy",
|
|
733
|
+
latencyMs: 100,
|
|
734
|
+
},
|
|
735
|
+
actor: SYSTEM_ACTOR,
|
|
736
|
+
contextKey: "sys-1",
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
expect(dwells.dwells.size).toBe(0);
|
|
740
|
+
expect(rec.calls).toHaveLength(0);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("headline: repeated above-threshold firings fire the dwell exactly ONCE at first_arm + duration, not pushed indefinitely", async () => {
|
|
744
|
+
// The bug this guards: a level-triggered numeric_state fires on EVERY
|
|
745
|
+
// check completion above threshold (e.g. every 60s). With for: 10m,
|
|
746
|
+
// re-arming must NOT push fireAt forward, or the window never elapses.
|
|
747
|
+
const { deps, dwells, rec, runs } = setupNumeric();
|
|
748
|
+
const store = makeAutomationStore([numericAutomation(true)]);
|
|
749
|
+
|
|
750
|
+
const fireOnce = () =>
|
|
751
|
+
handleTriggerFiring({
|
|
752
|
+
deps,
|
|
753
|
+
automationStore: store,
|
|
754
|
+
qualifiedEventId: NUMERIC_EVENT,
|
|
755
|
+
triggerPayload: {
|
|
756
|
+
systemId: "sys-1",
|
|
757
|
+
configurationId: "c",
|
|
758
|
+
status: "degraded",
|
|
759
|
+
latencyMs: 600,
|
|
760
|
+
},
|
|
761
|
+
actor: SYSTEM_ACTOR,
|
|
762
|
+
contextKey: "sys-1",
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// First above-threshold completion arms the dwell.
|
|
766
|
+
await fireOnce();
|
|
767
|
+
expect(dwells.dwells.size).toBe(1);
|
|
768
|
+
const armed = [...dwells.dwells.values()][0]!;
|
|
769
|
+
const originalFireAt = armed.fireAt.getTime();
|
|
770
|
+
|
|
771
|
+
// Many more above-threshold completions arrive within the window. Each
|
|
772
|
+
// re-arm is a continuation and must preserve the original deadline.
|
|
773
|
+
for (let i = 0; i < 20; i++) {
|
|
774
|
+
await fireOnce();
|
|
775
|
+
}
|
|
776
|
+
expect(dwells.dwells.size).toBe(1);
|
|
777
|
+
expect([...dwells.dwells.values()][0]!.fireAt.getTime()).toBe(
|
|
778
|
+
originalFireAt,
|
|
779
|
+
);
|
|
780
|
+
expect(rec.calls).toHaveLength(0); // still dwelling, no run yet
|
|
781
|
+
|
|
782
|
+
// Simulate the window elapsing (fireAt now in the past) and the
|
|
783
|
+
// sweeper picking up the expired dwell.
|
|
784
|
+
[...dwells.dwells.values()][0]!.fireAt = new Date(Date.now() - 1000);
|
|
785
|
+
const sweeper = startStalledSweeper({
|
|
786
|
+
deps,
|
|
787
|
+
automationStore: store,
|
|
788
|
+
logger: deps.logger,
|
|
789
|
+
});
|
|
790
|
+
await sweeper.sweep();
|
|
791
|
+
sweeper.stop();
|
|
792
|
+
|
|
793
|
+
// Fired exactly once; dwell consumed.
|
|
794
|
+
expect(rec.calls).toHaveLength(1);
|
|
795
|
+
expect(rec.calls[0]?.value).toBe("fired");
|
|
796
|
+
expect(runs.runs.size).toBe(1);
|
|
797
|
+
expect(dwells.dwells.size).toBe(0);
|
|
798
|
+
});
|
|
799
|
+
});
|