@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
package/src/builtin-triggers.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in triggers shipped by automation-backend itself.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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"
|
|
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
|
-
// ───
|
|
223
|
+
// ─── numeric_state ───────────────────────────────────────────────────────
|
|
226
224
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
256
|
-
export type TemplateFiredPayload = z.infer<typeof templateFiredPayloadSchema>;
|
|
274
|
+
export type NumericStatePayload = z.infer<typeof numericStatePayloadSchema>;
|
|
257
275
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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: "
|
|
263
|
-
displayName: "
|
|
292
|
+
id: "numeric_state",
|
|
293
|
+
displayName: "Numeric Threshold",
|
|
264
294
|
description:
|
|
265
|
-
"
|
|
266
|
-
category: "
|
|
267
|
-
icon: "
|
|
268
|
-
payloadSchema:
|
|
269
|
-
configSchema:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
-
|
|
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
|
+
});
|