@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
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Built-in triggers shipped by automation-backend itself.
3
3
  *
4
- * Three triggers, all setup-backed (no plugin hook to subscribe to):
4
+ * Two setup-backed triggers (no plugin hook to subscribe to):
5
5
  *
6
6
  * - `time.cron` — recurring queue job on a cron pattern.
7
7
  * - `time.interval` — recurring queue job on a fixed interval.
8
- * - `template` — interval-based polling; evaluates a template on each
9
- * tick and fires on the false → true edge (so an automation
10
- * subscribing on "value template is truthy" doesn't re-fire while
11
- * the value stays truthy).
12
8
  *
13
- * All three share the same two-stage runtime structure:
9
+ * (The polling `template` trigger was removed in the reactive engine
10
+ * §7. Its real cases are covered reactively by the `numeric_state` /
11
+ * `state` triggers + conditions, which wake on `ENTITY_CHANGED` instead of
12
+ * re-evaluating on a timer.)
13
+ *
14
+ * Both share the same two-stage runtime structure:
14
15
  *
15
16
  * 1. A single shared queue (`automation-builtin-triggers`) accepts
16
17
  * every recurring tick. A consumer registered at plugin init reads
@@ -33,15 +34,12 @@
33
34
  * back in place by the time the consumer would dispatch.
34
35
  */
35
36
  import { z } from "zod";
36
- import type { Logger } from "@checkstack/backend-api";
37
+ import { createHook, type Logger } from "@checkstack/backend-api";
37
38
  import type { QueueManager } from "@checkstack/queue-api";
38
39
  import type { PluginMetadata } from "@checkstack/common";
39
- import {
40
- evaluateBoolean,
41
- parseCondition,
42
- } from "@checkstack/template-engine";
43
40
 
44
41
  import type { TriggerDefinition } from "./action-types";
42
+ import { extractNumericField, matchesThreshold } from "./dispatch/numeric";
45
43
 
46
44
  // ─── Shared queue plumbing ─────────────────────────────────────────────
47
45
 
@@ -99,7 +97,7 @@ export async function registerBuiltinTriggerConsumer(args: {
99
97
  }
100
98
 
101
99
  function buildJobId(args: {
102
- kind: "cron" | "interval" | "template";
100
+ kind: "cron" | "interval";
103
101
  automationId: string;
104
102
  triggerId: string;
105
103
  }): string {
@@ -222,106 +220,102 @@ export function createTimeIntervalTrigger(
222
220
  };
223
221
  }
224
222
 
225
- // ─── template ──────────────────────────────────────────────────────────
223
+ // ─── numeric_state ───────────────────────────────────────────────────────
226
224
 
227
- const templateConfigSchema = z.object({
228
- /**
229
- * Boolean expression evaluated every tick uses the template
230
- * engine's condition grammar.
231
- *
232
- * The trigger context exposes `{ now }` (ISO string). Anything more
233
- * elaborate should be reached via the standard automation `variables`
234
- * block / action pipeline, not from the trigger itself — the
235
- * tick frequency makes anything I/O-heavy in here costly.
236
- */
237
- value_template: z
238
- .string()
239
- .min(1)
240
- .describe(
241
- "Boolean template — fires on the false → true edge. Has access to `{ now }`.",
242
- ),
243
- intervalSeconds: z
244
- .number()
245
- .int()
246
- .min(1)
247
- .max(86_400)
248
- .describe("How often to re-evaluate the template (1s – 24h)."),
249
- });
225
+ /**
226
+ * The `healthcheck.check.completed` hook this trigger rides. Referenced
227
+ * by id only (a `Hook` is just `{ id }`), so automation-backend needs no
228
+ * compile-time dependency on healthcheck-backend — the id must match what
229
+ * healthcheck emits.
230
+ */
231
+ const HEALTHCHECK_CHECK_COMPLETED = "healthcheck.check.completed";
250
232
 
251
- const templateFiredPayloadSchema = z.object({
252
- firedAt: z.string(),
233
+ interface CheckCompletedPayload {
234
+ systemId: string;
235
+ configurationId: string;
236
+ status: string;
237
+ latencyMs?: number;
238
+ result?: Record<string, unknown>;
239
+ timestamp?: string;
240
+ }
241
+
242
+ const numericStateConfigSchema = z
243
+ .object({
244
+ field: z
245
+ .string()
246
+ .min(1)
247
+ .describe(
248
+ "Numeric field to watch: `latencyMs`, `p95LatencyMs`, or a collector path like `collectors.http.responseTimeMs`.",
249
+ ),
250
+ above: z
251
+ .number()
252
+ .optional()
253
+ .describe("Fire when the field is strictly greater than this."),
254
+ below: z
255
+ .number()
256
+ .optional()
257
+ .describe("Fire when the field is strictly less than this."),
258
+ })
259
+ .refine((c) => c.above !== undefined || c.below !== undefined, {
260
+ message: "numeric_state trigger requires at least one of `above` / `below`",
261
+ });
262
+
263
+ export type NumericStateConfig = z.infer<typeof numericStateConfigSchema>;
264
+
265
+ const numericStatePayloadSchema = z.object({
266
+ systemId: z.string(),
267
+ configurationId: z.string(),
268
+ status: z.string(),
269
+ field: z.string().describe("The watched field path."),
270
+ value: z.number().describe("The numeric value that crossed the threshold."),
271
+ timestamp: z.string().optional(),
253
272
  });
254
273
 
255
- export type TemplateConfig = z.infer<typeof templateConfigSchema>;
256
- export type TemplateFiredPayload = z.infer<typeof templateFiredPayloadSchema>;
274
+ export type NumericStatePayload = z.infer<typeof numericStatePayloadSchema>;
257
275
 
258
- export function createTemplateTrigger(
259
- deps: BuiltinTriggerDeps,
260
- ): TriggerDefinition<TemplateFiredPayload, TemplateConfig> {
276
+ /**
277
+ * `numeric_state` — fires off `healthcheck.check.completed` when a numeric
278
+ * field crosses an `above` / `below` threshold. Hook-backed; the per-
279
+ * automation threshold is enforced via `evaluateConfig` (a structured gate
280
+ * the trigger fan-in calls before starting a run). Combine with a
281
+ * trigger-level `for:` (Phase 15) for "above X for Y minutes".
282
+ *
283
+ * v1 is LEVEL-triggered: it fires on every completed check whose field is
284
+ * past the threshold. Edge (false → true) de-duplication is deferred;
285
+ * `mode: single` + `for:` already prevent alert storms in practice.
286
+ */
287
+ export function createNumericStateTrigger(): TriggerDefinition<
288
+ NumericStatePayload,
289
+ NumericStateConfig
290
+ > {
261
291
  return {
262
- id: "template",
263
- displayName: "Template Condition",
292
+ id: "numeric_state",
293
+ displayName: "Numeric Threshold",
264
294
  description:
265
- "Polls a boolean template on a fixed interval. Fires on the false true edge so the automation runs once per truthy window, not on every tick.",
266
- category: "Time",
267
- icon: "FileCode",
268
- payloadSchema: templateFiredPayloadSchema,
269
- configSchema: templateConfigSchema,
270
- setup: async ({ config, identity, fire, logger }) => {
271
- const jobId = buildJobId({
272
- kind: "template",
273
- automationId: identity.automationId,
274
- triggerId: identity.triggerId,
275
- });
276
- // Each tick lives in this closure — `previousTruthy` survives
277
- // across ticks for the lifetime of the setup() return. Tearing
278
- // down resets it via the map-removal.
279
- let previousTruthy = false;
280
- let parsed;
281
- try {
282
- parsed = parseCondition(config.value_template);
283
- } catch (error) {
284
- // A malformed expression should fail fast at setup so the
285
- // operator sees the error in the editor rather than as
286
- // silently-never-firing.
287
- throw new Error(
288
- `template trigger: invalid value_template — ${(error as Error).message}`,
289
- );
290
- }
291
- tickHandlers.set(jobId, async (tickLogger) => {
292
- const truthy = (() => {
293
- try {
294
- return evaluateBoolean(parsed!, { now: new Date().toISOString() });
295
- } catch (error) {
296
- tickLogger.warn(
297
- `template trigger ${jobId} threw on evaluation — treating as false: ${(error as Error).message}`,
298
- );
299
- return false;
300
- }
301
- })();
302
- if (truthy && !previousTruthy) {
303
- await fire({ firedAt: new Date().toISOString() });
304
- }
305
- previousTruthy = truthy;
306
- });
307
- const queue = deps.queueManager.getQueue<BuiltinTriggerTickPayload>(
308
- BUILTIN_TRIGGER_QUEUE,
295
+ "Fires when a health-check numeric field (latency, p95, a collector metric) crosses an above/below threshold. Pair with `for:` for sustained thresholds.",
296
+ category: "Health",
297
+ icon: "Gauge",
298
+ payloadSchema: numericStatePayloadSchema,
299
+ configSchema: numericStateConfigSchema,
300
+ // Rides the healthcheck check-completed hook; the threshold gate below
301
+ // decides whether a given completion fires this automation.
302
+ hook: createHook<NumericStatePayload>(HEALTHCHECK_CHECK_COMPLETED),
303
+ contextKey: (payload) => payload.systemId,
304
+ contextKeyLabel: "system",
305
+ evaluateConfig: (payload, config) => {
306
+ // The hook delivers the raw `healthcheck.check.completed` payload
307
+ // (latencyMs top-level, `result` = collectors map). extractNumericField
308
+ // knows that shape.
309
+ const raw = payload as unknown as CheckCompletedPayload;
310
+ const value = extractNumericField(
311
+ raw as unknown as Record<string, unknown>,
312
+ config.field,
309
313
  );
310
- await queue.scheduleRecurring(
311
- { jobId },
312
- {
313
- jobId,
314
- intervalSeconds: config.intervalSeconds,
315
- startDelay: config.intervalSeconds,
316
- },
317
- );
318
- logger.debug(
319
- `Scheduled template trigger for automation ${identity.automationId}: every ${config.intervalSeconds}s`,
320
- );
321
- return async () => {
322
- tickHandlers.delete(jobId);
323
- await queue.cancelRecurring(jobId);
324
- };
314
+ return matchesThreshold({
315
+ value,
316
+ above: config.above,
317
+ below: config.below,
318
+ });
325
319
  },
326
320
  };
327
321
  }
@@ -329,9 +323,9 @@ export function createTemplateTrigger(
329
323
  // ─── Public registry helper ────────────────────────────────────────────
330
324
 
331
325
  /**
332
- * Construct all three built-in triggers and register them through the
333
- * provided callback (which the automation-backend init phase passes
334
- * straight into the trigger registry).
326
+ * Construct the built-in triggers and register them through the provided
327
+ * callback (which the automation-backend init phase passes straight into
328
+ * the trigger registry).
335
329
  */
336
330
  export function registerBuiltinTriggers(args: {
337
331
  queueManager: QueueManager;
@@ -351,7 +345,10 @@ export function registerBuiltinTriggers(args: {
351
345
  args.pluginMetadata,
352
346
  );
353
347
  args.registerTrigger(
354
- createTemplateTrigger(deps) as unknown as TriggerDefinition<unknown, unknown>,
348
+ createNumericStateTrigger() as unknown as TriggerDefinition<
349
+ unknown,
350
+ unknown
351
+ >,
355
352
  args.pluginMetadata,
356
353
  );
357
354
  }
@@ -17,6 +17,7 @@ export type ActionKind =
17
17
  | "condition"
18
18
  | "stop"
19
19
  | "wait_for_trigger"
20
+ | "wait_until"
20
21
  | "sequence";
21
22
 
22
23
  /**
@@ -35,6 +36,7 @@ export function detectActionKind(action: Action): ActionKind {
35
36
  if ("condition" in a) return "condition";
36
37
  if ("stop" in a) return "stop";
37
38
  if ("wait_for_trigger" in a) return "wait_for_trigger";
39
+ if ("wait_until" in a) return "wait_until";
38
40
  if ("sequence" in a) return "sequence";
39
41
  throw new Error(
40
42
  `Unknown action shape — none of the discriminator keys are present: ${JSON.stringify(
@@ -0,0 +1,31 @@
1
+ import type { ServiceRef } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Assemble the dispatch engine's `getService` from the plugin `env`.
5
+ *
6
+ * The automation dispatch path is the only context that resolves ARBITRARY
7
+ * cross-plugin service refs (the integration connection store, the secret
8
+ * resolver) OUTSIDE an RPC handler — at action execute time. It does so
9
+ * through the plugin `env.getService`, which resolves via the real
10
+ * `ServiceRegistry` using this plugin's identity.
11
+ *
12
+ * This used to be a throwing stub in `index.ts`, which meant every provider
13
+ * action (Jira / Teams / Webex) and every `secretEnv` script action threw in
14
+ * production. The whole dispatch test suite stubs `getService`, so the break
15
+ * was invisible. Extracting the assembly here lets a unit test assert the
16
+ * production wiring resolves a registered ref (and is NOT a throwing stub),
17
+ * guarding that exact regression.
18
+ *
19
+ * Safe to call from `init` / `afterPluginsReady` onward — dispatch never
20
+ * runs before then, by which point every service is registered. A missing
21
+ * ref propagates a CLEAR error from `env.getService` (fail loud), never a
22
+ * silent `undefined`.
23
+ */
24
+ export function assembleDispatchGetService({
25
+ envGetService,
26
+ }: {
27
+ /** The plugin `env.getService` — registry-backed resolution. */
28
+ envGetService: <T>(ref: ServiceRef<T>) => Promise<T>;
29
+ }): <T>(ref: ServiceRef<T>) => Promise<T> {
30
+ return envGetService;
31
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SYSTEM_ACTOR } from "@checkstack/common";
3
+ import { AutomationDefinitionSchema } from "@checkstack/automation-common";
4
+ import type { AutomationStore } from "../automation-store";
5
+ import { createActionRegistry } from "../action-registry";
6
+ import { dispatchTrigger, resumeRun } from "./engine";
7
+ import { handleTriggerFiring } from "./trigger-subscriber";
8
+ import {
9
+ makeDispatchDeps,
10
+ makeRecordingAction,
11
+ testPlugin,
12
+ } from "./test-fixtures";
13
+ import type { LoadedAutomation } from "./types";
14
+
15
+ const EVENT = "test.event";
16
+
17
+ function automation(actions: unknown[], mode = "single"): LoadedAutomation {
18
+ const definition = AutomationDefinitionSchema.parse({
19
+ name: "Cancel test",
20
+ triggers: [{ event: EVENT }],
21
+ conditions: [],
22
+ actions,
23
+ mode,
24
+ max_runs: 10,
25
+ });
26
+ return { id: "auto-1", name: "Cancel test", status: "enabled", definition };
27
+ }
28
+
29
+ function storeFor(auto: LoadedAutomation): AutomationStore {
30
+ return {
31
+ create: async () => {
32
+ throw new Error("nope");
33
+ },
34
+ update: async () => {
35
+ throw new Error("nope");
36
+ },
37
+ delete: async () => {},
38
+ toggle: async () => {
39
+ throw new Error("nope");
40
+ },
41
+ getById: async (id) =>
42
+ id === auto.id
43
+ ? {
44
+ id: auto.id,
45
+ name: auto.name,
46
+ description: undefined,
47
+ status: auto.status,
48
+ definition: auto.definition,
49
+ managedBy: undefined,
50
+ createdAt: new Date(),
51
+ updatedAt: new Date(),
52
+ }
53
+ : undefined,
54
+ list: async () => ({ items: [], total: 0 }),
55
+ listGroups: async () => [],
56
+ findEnabledByTriggerEvent: async () => [auto],
57
+ listEnabled: async () => [auto],
58
+ };
59
+ }
60
+
61
+ describe("H1 — cancelled runs must not resurrect", () => {
62
+ it("a cancelled (restart) run does not resume when its awaited trigger fires", async () => {
63
+ const actionsReg = createActionRegistry();
64
+ const rec = makeRecordingAction();
65
+ actionsReg.register(rec.definition, testPlugin);
66
+ const { deps, runs, state } = makeDispatchDeps({ actions: actionsReg });
67
+
68
+ // A run that opens, waits for a resolve event, then closes.
69
+ const auto = automation([
70
+ { action: "test.record", config: { value: "open" } },
71
+ { wait_for_trigger: { event: "incident.resolved" } },
72
+ { action: "test.record", config: { value: "close" } },
73
+ ]);
74
+
75
+ const result = await dispatchTrigger(deps, {
76
+ automation: auto,
77
+ triggerId: "test_event",
78
+ triggerEventId: EVENT,
79
+ payload: {},
80
+ contextKey: "incident-1",
81
+ });
82
+ expect(result.status).toBe("waiting");
83
+ expect(rec.calls.map((c) => c.value)).toEqual(["open"]);
84
+ expect(runs.waitLocks.size).toBe(1);
85
+
86
+ // Cancel it (restart mode / operator cancel both flip status to
87
+ // "cancelled" and should tear down the wait lock + run-state).
88
+ const cancelled = await deps.runStore.cancelActiveRuns(
89
+ auto.id,
90
+ "cancelled by test",
91
+ );
92
+ expect(cancelled).toContain(result.runId);
93
+ // The cancel must have removed the wait lock + durable state.
94
+ expect(runs.waitLocks.size).toBe(0);
95
+ expect(state.states.has(result.runId)).toBe(false);
96
+
97
+ // Now the awaited trigger fires. wakeWaitingRuns must NOT resume the
98
+ // cancelled run, and the post-wait "close" action must NOT fire.
99
+ await handleTriggerFiring({
100
+ deps,
101
+ automationStore: storeFor(auto),
102
+ qualifiedEventId: "incident.resolved",
103
+ triggerPayload: { resolved: true },
104
+ actor: SYSTEM_ACTOR,
105
+ contextKey: "incident-1",
106
+ });
107
+
108
+ expect(rec.calls.map((c) => c.value)).toEqual(["open"]);
109
+ expect(runs.runs.get(result.runId)?.status).toBe("cancelled");
110
+ });
111
+
112
+ it("resumeRun directly refuses a cancelled run and drops any stale lock", async () => {
113
+ const actionsReg = createActionRegistry();
114
+ const rec = makeRecordingAction();
115
+ actionsReg.register(rec.definition, testPlugin);
116
+ const { deps, runs } = makeDispatchDeps({ actions: actionsReg });
117
+
118
+ const auto = automation([
119
+ { action: "test.record", config: { value: "open" } },
120
+ { wait_for_trigger: { event: "go" } },
121
+ { action: "test.record", config: { value: "close" } },
122
+ ]);
123
+
124
+ const result = await dispatchTrigger(deps, {
125
+ automation: auto,
126
+ triggerId: "test_event",
127
+ triggerEventId: EVENT,
128
+ payload: {},
129
+ contextKey: null,
130
+ });
131
+ expect(result.status).toBe("waiting");
132
+
133
+ // Cancel the run but leave a stale wait lock behind (simulating a path
134
+ // that flipped status without cleanup) — resumeRun must refuse AND drop
135
+ // the orphaned lock.
136
+ runs.runs.get(result.runId)!.status = "cancelled";
137
+
138
+ const resumed = await resumeRun(deps, {
139
+ runId: result.runId,
140
+ automation: auto,
141
+ waitedAtPath: "actions[1]",
142
+ });
143
+ expect(resumed.status).toBe("cancelled");
144
+ expect(rec.calls.map((c) => c.value)).toEqual(["open"]);
145
+ expect(runs.waitLocks.size).toBe(0);
146
+ });
147
+ });