@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
@@ -22,6 +22,62 @@
22
22
  "when": 1780046001689,
23
23
  "tag": "0002_silky_omega_red",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1780147180582,
30
+ "tag": "0003_sparkling_xorn",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1780149946844,
37
+ "tag": "0004_cultured_spyke",
38
+ "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1780189837825,
44
+ "tag": "0005_classy_the_hand",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1780192006216,
51
+ "tag": "0006_burly_wallop",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1780216684007,
58
+ "tag": "0007_nappy_jackal",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1780246726759,
65
+ "tag": "0008_remove_seeded_auto_incident_automations",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "7",
71
+ "when": 1780248673895,
72
+ "tag": "0009_steady_liz_osborn",
73
+ "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1780250068979,
79
+ "tag": "0010_chunky_changeling",
80
+ "breakpoints": true
25
81
  }
26
82
  ]
27
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/automation-backend",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,27 +15,38 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/automation-common": "0.1.0",
19
- "@checkstack/backend-api": "0.17.1",
20
- "@checkstack/command-backend": "0.1.30",
21
- "@checkstack/integration-common": "0.5.0",
22
- "@checkstack/notification-common": "1.2.0",
23
- "@checkstack/common": "0.11.0",
24
- "@checkstack/queue-api": "0.3.5",
25
- "@checkstack/signal-common": "0.2.4",
26
- "@checkstack/template-engine": "0.1.0",
18
+ "@checkstack/automation-common": "0.2.0",
19
+ "@checkstack/backend-api": "0.18.0",
20
+ "@checkstack/command-backend": "0.1.31",
21
+ "@checkstack/secrets-common": "0.0.1",
22
+ "@checkstack/gitops-backend": "0.3.7",
23
+ "@checkstack/gitops-common": "0.4.2",
24
+ "@checkstack/healthcheck-common": "1.3.0",
25
+ "@checkstack/integration-common": "0.6.0",
26
+ "@checkstack/notification-common": "1.2.1",
27
+ "@checkstack/script-packages-backend": "0.1.0",
28
+ "@checkstack/common": "0.12.0",
29
+ "@checkstack/queue-api": "0.3.6",
30
+ "@checkstack/signal-common": "0.2.5",
31
+ "@checkstack/template-engine": "0.2.0",
27
32
  "@orpc/server": "^1.13.2",
28
33
  "drizzle-orm": "^0.45.0",
29
34
  "yaml": "^2.6.1",
30
35
  "zod": "^4.2.1"
31
36
  },
32
37
  "devDependencies": {
38
+ "@checkstack/backend": "0.11.0",
33
39
  "@checkstack/drizzle-helper": "0.0.5",
34
- "@checkstack/scripts": "0.3.3",
35
- "@checkstack/test-utils-backend": "0.1.30",
40
+ "@checkstack/integration-backend": "0.2.0",
41
+ "@checkstack/scripts": "0.3.4",
42
+ "@checkstack/secrets-backend": "0.0.1",
43
+ "@checkstack/test-utils-backend": "0.1.31",
36
44
  "@checkstack/tsconfig": "0.0.7",
37
45
  "@types/node": "^20.0.0",
46
+ "@types/pg": "^8.20.0",
47
+ "bullmq": "^5.66.4",
38
48
  "drizzle-kit": "^0.31.10",
49
+ "pg": "^8.21.0",
39
50
  "typescript": "^5.0.0"
40
51
  }
41
52
  }
@@ -133,10 +133,33 @@ export interface TriggerDefinition<
133
133
  */
134
134
  contextKey?: (payload: TPayload) => string | undefined;
135
135
 
136
+ /**
137
+ * Human label for the dimension `contextKey` extracts (e.g. `"system"` for
138
+ * a `systemId` key). Purely a UI hint — surfaced to the editor (via
139
+ * `TriggerInfo.contextKeyLabel`) so the window gate's "Partition by" field
140
+ * can show the default partition ("Leave blank to count per system"). No
141
+ * runtime behaviour. Omit when the trigger has no `contextKey` (the UI then
142
+ * shows "per automation").
143
+ */
144
+ contextKeyLabel?: string;
145
+
136
146
  /** Hook-backed flavour. */
137
147
  hook?: Hook<TPayload>;
138
148
  /** Setup-backed flavour. */
139
149
  setup?: TriggerSetupFn<TPayload, TConfig>;
150
+
151
+ /**
152
+ * Optional structured config gate for hook-backed triggers. When set,
153
+ * the trigger fan-in calls this with the incoming payload + the
154
+ * per-automation trigger `config` BEFORE starting a run; a `false`
155
+ * result skips the firing for that automation (in addition to, and
156
+ * before, the operator's template `filter`).
157
+ *
158
+ * Used by structured triggers like `numeric_state` whose firing depends
159
+ * on typed config (`field` / `above` / `below`) rather than a
160
+ * hand-written filter expression. Pure + synchronous — no I/O.
161
+ */
162
+ evaluateConfig?: (payload: TPayload, config: TConfig) => boolean;
140
163
  }
141
164
 
142
165
  export interface RegisteredTrigger<TPayload = unknown, TConfig = unknown>
@@ -1,6 +1,7 @@
1
1
  import { and, desc, eq, isNull } from "drizzle-orm";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
3
  import { automationArtifacts } from "./schema";
4
+ import type { RunSecretRegistry } from "./dispatch/run-secret-registry";
4
5
 
5
6
  /**
6
7
  * Inputs for recording a new artifact.
@@ -62,6 +63,15 @@ export interface ArtifactStore {
62
63
 
63
64
  export function createArtifactStore(
64
65
  db: SafeDatabase<{ automationArtifacts: typeof automationArtifacts }>,
66
+ /**
67
+ * Run-scoped secret values accumulated during dispatch. When provided,
68
+ * an artifact's `data` is masked (Jenkins-style, by-value) BEFORE
69
+ * insert — so a resolved connection credential surfaced into a produced
70
+ * artifact can't reach a replay / run-detail reader unmasked. Same
71
+ * persist-time choke-point pattern as the run-state + run stores.
72
+ * Optional so tests / older boots degrade to no masking.
73
+ */
74
+ secretRegistry?: RunSecretRegistry,
65
75
  ): ArtifactStore {
66
76
  const mapRow = (
67
77
  row: typeof automationArtifacts.$inferSelect,
@@ -80,6 +90,11 @@ export function createArtifactStore(
80
90
 
81
91
  return {
82
92
  async record(input) {
93
+ // Mask resolved secret values out of the artifact data BEFORE insert
94
+ // — the persistence choke point, so a credential surfaced into a
95
+ // produced artifact never reaches a replay / run-detail reader.
96
+ const maskedData = (secretRegistry?.maskDeep(input.runId, input.data) ??
97
+ input.data) as Record<string, unknown>;
83
98
  const [row] = await db
84
99
  .insert(automationArtifacts)
85
100
  .values({
@@ -88,7 +103,7 @@ export function createArtifactStore(
88
103
  stepId: input.stepId,
89
104
  actionId: input.actionId,
90
105
  artifactType: input.artifactType,
91
- data: input.data,
106
+ data: maskedData,
92
107
  contextKey: input.contextKey,
93
108
  })
94
109
  .returning();
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createAutomationStore } from "./automation-store";
3
+
4
+ /**
5
+ * Minimal valid definition JSON for rows returned by mocked queries
6
+ * (`mapToAutomation` parses it).
7
+ */
8
+ const DEFINITION = {
9
+ name: "n",
10
+ triggers: [{ event: "incident.incident.created" }],
11
+ conditions: [],
12
+ actions: [],
13
+ mode: "single",
14
+ concurrency_scope: "automation",
15
+ max_runs: 10,
16
+ };
17
+
18
+ function row(overrides: Record<string, unknown>) {
19
+ return {
20
+ id: "a1",
21
+ name: "A1",
22
+ description: null,
23
+ group: null,
24
+ status: "enabled",
25
+ definition: DEFINITION,
26
+ managedBy: null,
27
+ createdAt: new Date("2026-01-01T00:00:00Z"),
28
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe("automation-store create", () => {
34
+ it("persists the group column from the input", async () => {
35
+ const values = mock((_v: Record<string, unknown>) => ({
36
+ returning: mock(() => Promise.resolve([row({ group: "Alerting" })])),
37
+ }));
38
+ const db = { insert: mock(() => ({ values })) };
39
+ const store = createAutomationStore(db as never);
40
+
41
+ const result = await store.create({
42
+ name: "A1",
43
+ group: "Alerting",
44
+ status: "enabled",
45
+ definition: DEFINITION as never,
46
+ });
47
+
48
+ const inserted = values.mock.calls[0]?.[0] as { group: string | null };
49
+ expect(inserted.group).toBe("Alerting");
50
+ expect(result.group).toBe("Alerting");
51
+ });
52
+
53
+ it("inserts null group when none is provided", async () => {
54
+ const values = mock((_v: Record<string, unknown>) => ({
55
+ returning: mock(() => Promise.resolve([row({ group: null })])),
56
+ }));
57
+ const db = { insert: mock(() => ({ values })) };
58
+ const store = createAutomationStore(db as never);
59
+
60
+ await store.create({
61
+ name: "A1",
62
+ status: "enabled",
63
+ definition: DEFINITION as never,
64
+ });
65
+
66
+ const inserted = values.mock.calls[0]?.[0] as { group: string | null };
67
+ expect(inserted.group).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("automation-store update set-builder", () => {
72
+ function updateDb(returned: Record<string, unknown>) {
73
+ // getById (select…limit) then update (set…where…returning).
74
+ const select = mock(() => ({
75
+ from: mock(() => ({
76
+ where: mock(() => ({
77
+ limit: mock(() => Promise.resolve([row({})])),
78
+ })),
79
+ })),
80
+ }));
81
+ const set = mock((_v: Record<string, unknown>) => ({
82
+ where: mock(() => ({
83
+ returning: mock(() => Promise.resolve([row(returned)])),
84
+ })),
85
+ }));
86
+ const db = { select, update: mock(() => ({ set })) };
87
+ return { db, set };
88
+ }
89
+
90
+ it("sets the group when a string is given", async () => {
91
+ const { db, set } = updateDb({ group: "Networking" });
92
+ const store = createAutomationStore(db as never);
93
+ await store.update({ id: "a1", group: "Networking" });
94
+ const patch = set.mock.calls[0]?.[0] as { group?: string | null };
95
+ expect(patch.group).toBe("Networking");
96
+ });
97
+
98
+ it("clears the group when null is given", async () => {
99
+ const { db, set } = updateDb({ group: null });
100
+ const store = createAutomationStore(db as never);
101
+ await store.update({ id: "a1", group: null });
102
+ const patch = set.mock.calls[0]?.[0] as { group?: string | null };
103
+ expect(patch.group).toBeNull();
104
+ });
105
+
106
+ it("leaves the group untouched when omitted", async () => {
107
+ const { db, set } = updateDb({ group: "Existing" });
108
+ const store = createAutomationStore(db as never);
109
+ await store.update({ id: "a1", name: "renamed" });
110
+ const patch = set.mock.calls[0]?.[0] as { group?: string | null };
111
+ expect("group" in patch).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("automation-store listGroups", () => {
116
+ it("returns the distinct non-null group values produced by the query", async () => {
117
+ const where = mock(() => ({
118
+ orderBy: mock(() =>
119
+ Promise.resolve([{ group: "Alerting" }, { group: "Networking" }]),
120
+ ),
121
+ }));
122
+ const db = {
123
+ selectDistinct: mock(() => ({ from: mock(() => ({ where })) })),
124
+ };
125
+ const store = createAutomationStore(db as never);
126
+ const groups = await store.listGroups();
127
+ expect(groups).toEqual(["Alerting", "Networking"]);
128
+ });
129
+
130
+ it("filters out any null group that slips through", async () => {
131
+ const where = mock(() => ({
132
+ orderBy: mock(() =>
133
+ Promise.resolve([{ group: "Alerting" }, { group: null }]),
134
+ ),
135
+ }));
136
+ const db = {
137
+ selectDistinct: mock(() => ({ from: mock(() => ({ where })) })),
138
+ };
139
+ const store = createAutomationStore(db as never);
140
+ const groups = await store.listGroups();
141
+ expect(groups).toEqual(["Alerting"]);
142
+ });
143
+ });
@@ -5,7 +5,7 @@
5
5
  * Returns are typed against the public `Automation` shape from
6
6
  * `@checkstack/automation-common`, with parsed `definition`.
7
7
  */
8
- import { and, eq, sql } from "drizzle-orm";
8
+ import { and, eq, isNotNull, sql } from "drizzle-orm";
9
9
  import type { SafeDatabase } from "@checkstack/backend-api";
10
10
  import {
11
11
  AutomationDefinitionSchema,
@@ -29,10 +29,17 @@ export interface AutomationStore {
29
29
  getById(id: string): Promise<Automation | undefined>;
30
30
  list(filter?: {
31
31
  status?: AutomationStatus;
32
+ group?: string;
32
33
  limit?: number;
33
34
  offset?: number;
34
35
  }): Promise<{ items: Automation[]; total: number }>;
35
36
 
37
+ /**
38
+ * Distinct, non-null group values across all automations, sorted
39
+ * alphabetically. Powers the edit-page group picker suggestions.
40
+ */
41
+ listGroups(): Promise<string[]>;
42
+
36
43
  /**
37
44
  * Enabled automations that reference the given trigger event id in one
38
45
  * of their trigger declarations. Used by the trigger fan-in to fan an
@@ -55,6 +62,7 @@ function mapToAutomation(
55
62
  id: row.id,
56
63
  name: row.name,
57
64
  description: row.description ?? undefined,
65
+ group: row.group ?? undefined,
58
66
  status: row.status === "disabled" ? "disabled" : "enabled",
59
67
  definition,
60
68
  managedBy: row.managedBy ?? undefined,
@@ -87,6 +95,7 @@ export function createAutomationStore(
87
95
  .values({
88
96
  name: input.name,
89
97
  description: input.description ?? null,
98
+ group: input.group ?? null,
90
99
  status: input.status,
91
100
  definition: parsedDefinition as unknown as Record<string, unknown>,
92
101
  })
@@ -103,6 +112,8 @@ export function createAutomationStore(
103
112
  if (input.name !== undefined) set.name = input.name;
104
113
  if (input.description !== undefined)
105
114
  set.description = input.description ?? null;
115
+ // `null` clears the group, a string sets it; `undefined` leaves it.
116
+ if (input.group !== undefined) set.group = input.group ?? null;
106
117
  if (input.status !== undefined) set.status = input.status;
107
118
  if (input.definition !== undefined) {
108
119
  const parsed = AutomationDefinitionSchema.parse(input.definition);
@@ -146,9 +157,12 @@ export function createAutomationStore(
146
157
  async list(filter = {}) {
147
158
  const limit = filter.limit ?? 50;
148
159
  const offset = filter.offset ?? 0;
149
- const whereExpr = filter.status
150
- ? eq(automations.status, filter.status)
151
- : undefined;
160
+
161
+ const conditions = [];
162
+ if (filter.status) conditions.push(eq(automations.status, filter.status));
163
+ if (filter.group) conditions.push(eq(automations.group, filter.group));
164
+ const whereExpr =
165
+ conditions.length > 0 ? and(...conditions) : undefined;
152
166
 
153
167
  const rows = whereExpr
154
168
  ? await db
@@ -178,6 +192,18 @@ export function createAutomationStore(
178
192
  };
179
193
  },
180
194
 
195
+ async listGroups() {
196
+ const rows = await db
197
+ .selectDistinct({ group: automations.group })
198
+ .from(automations)
199
+ .where(isNotNull(automations.group))
200
+ .orderBy(automations.group);
201
+ // `isNotNull` already filters NULLs; the guard narrows the type.
202
+ return rows
203
+ .map((r) => r.group)
204
+ .filter((g): g is string => g !== null);
205
+ },
206
+
181
207
  async findEnabledByTriggerEvent(eventId) {
182
208
  // The `definition.triggers` array is queried via JSONB containment.
183
209
  // We can't index the inner JSON cheaply without a generated column;
@@ -221,7 +247,3 @@ export function createAutomationStore(
221
247
  },
222
248
  };
223
249
  }
224
-
225
- // Silence unused-imports for the second `and` symbol (kept around for the
226
- // inevitable future "filter by enabled AND something" query).
227
- void and;
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Behaviour tests for the built-in `time.cron`, `time.interval`, and
3
- * `template` triggers.
2
+ * Behaviour tests for the built-in `time.cron` and `time.interval` triggers.
4
3
  *
5
- * Each test exercises one factory, fakes out the queue, runs setup()
6
- * to register a fire-callback in the module-scoped tick map, and then
7
- * either (a) inspects the queue arguments, or (b) plays a tick through
8
- * the recorded callback to verify the fire behaviour (including the
9
- * template trigger's false → true edge detection).
4
+ * Each test exercises one factory, fakes out the queue, runs setup() to
5
+ * register a fire-callback in the module-scoped tick map, and then either
6
+ * (a) inspects the queue arguments, or (b) plays a tick through the recorded
7
+ * callback to verify the fire behaviour.
10
8
  */
11
9
  import { beforeEach, describe, expect, it, mock } from "bun:test";
12
10
  import type { Logger } from "@checkstack/backend-api";
@@ -16,7 +14,7 @@ import { createMockLogger } from "@checkstack/test-utils-backend";
16
14
  import {
17
15
  _resetBuiltinTriggerTickHandlersForTests,
18
16
  BUILTIN_TRIGGER_QUEUE,
19
- createTemplateTrigger,
17
+ createNumericStateTrigger,
20
18
  createTimeCronTrigger,
21
19
  createTimeIntervalTrigger,
22
20
  registerBuiltinTriggerConsumer,
@@ -178,87 +176,92 @@ describe("automation.interval", () => {
178
176
  });
179
177
  });
180
178
 
181
- describe("automation.template", () => {
182
- it("fires only on the false true edge", async () => {
183
- const fx = makeQueueFixture();
184
- await registerBuiltinTriggerConsumer({
185
- queueManager: fx.queueManager,
186
- logger,
187
- });
188
- const fire = mock(async (_payload: unknown) => {});
189
-
190
- // Use a template that toggles based on a flag in the closure.
191
- // Since the trigger only has access to `{ now }`, we simulate the
192
- // edge by switching the template via two separate setup calls.
193
- const trigger = createTemplateTrigger({ queueManager: fx.queueManager });
194
- const teardown = await trigger.setup!({
195
- // Truthy from the start. We expect:
196
- // tick 1 → previousTruthy was false → fire.
197
- // tick 2 → previousTruthy is now true → no fire.
198
- config: { value_template: "true", intervalSeconds: 5 },
199
- identity: { automationId: "auto-1", triggerId: "t-1" },
200
- fire,
201
- logger,
202
- });
203
-
204
- await fx.consumer!(tick("builtin:template:auto-1:t-1"));
205
- await fx.consumer!(tick("builtin:template:auto-1:t-1"));
206
- expect(fire).toHaveBeenCalledTimes(1);
179
+ // The polling `template` trigger was removed in the reactive engine
180
+ // (reactive automation engine §7); its cases are covered reactively by the
181
+ // numeric_state / state triggers + conditions. Its behaviour tests were
182
+ // removed with it.
207
183
 
208
- await teardown();
209
- });
210
-
211
- it("does not fire when the template is always falsy", async () => {
184
+ describe("builtin trigger consumer", () => {
185
+ it("logs but does not throw when a tick arrives without a registered handler", async () => {
212
186
  const fx = makeQueueFixture();
213
187
  await registerBuiltinTriggerConsumer({
214
188
  queueManager: fx.queueManager,
215
189
  logger,
216
190
  });
217
- const fire = mock(async (_payload: unknown) => {});
191
+ await expect(
192
+ fx.consumer!(tick("builtin:cron:unregistered:nope")),
193
+ ).resolves.toBeUndefined();
194
+ });
218
195
 
219
- const trigger = createTemplateTrigger({ queueManager: fx.queueManager });
220
- const teardown = await trigger.setup!({
221
- config: { value_template: "false", intervalSeconds: 5 },
222
- identity: { automationId: "auto-1", triggerId: "t-1" },
223
- fire,
224
- logger,
225
- });
196
+ it("uses the shared queue name", () => {
197
+ expect(BUILTIN_TRIGGER_QUEUE).toBe("automation-builtin-triggers");
198
+ });
199
+ });
226
200
 
227
- await fx.consumer!(tick("builtin:template:auto-1:t-1"));
228
- await fx.consumer!(tick("builtin:template:auto-1:t-1"));
229
- expect(fire).not.toHaveBeenCalled();
201
+ describe("numeric_state trigger", () => {
202
+ const trigger = createNumericStateTrigger();
230
203
 
231
- await teardown();
204
+ it("is hook-backed on healthcheck.check.completed", () => {
205
+ expect(trigger.hook?.id).toBe("healthcheck.check.completed");
206
+ expect(trigger.setup).toBeUndefined();
232
207
  });
233
208
 
234
- it("rejects an invalid template at setup time", async () => {
235
- const fx = makeQueueFixture();
236
- const trigger = createTemplateTrigger({ queueManager: fx.queueManager });
237
- await expect(
238
- trigger.setup!({
239
- config: { value_template: "((((", intervalSeconds: 5 },
240
- identity: { automationId: "auto-1", triggerId: "t-1" },
241
- fire: mock(async () => {}),
242
- logger,
209
+ it("extracts systemId as the context key", () => {
210
+ expect(
211
+ trigger.contextKey?.({
212
+ systemId: "sys-1",
213
+ configurationId: "c",
214
+ status: "unhealthy",
215
+ field: "latencyMs",
216
+ value: 1,
243
217
  }),
244
- ).rejects.toThrow(/invalid value_template/);
245
- expect(fx.scheduleMock).not.toHaveBeenCalled();
218
+ ).toBe("sys-1");
246
219
  });
247
- });
248
220
 
249
- describe("builtin trigger consumer", () => {
250
- it("logs but does not throw when a tick arrives without a registered handler", async () => {
251
- const fx = makeQueueFixture();
252
- await registerBuiltinTriggerConsumer({
253
- queueManager: fx.queueManager,
254
- logger,
255
- });
256
- await expect(
257
- fx.consumer!(tick("builtin:cron:unregistered:nope")),
258
- ).resolves.toBeUndefined();
221
+ it("evaluateConfig fires when top-level latencyMs is above the bound", () => {
222
+ const payload = {
223
+ systemId: "sys-1",
224
+ configurationId: "c",
225
+ status: "degraded",
226
+ latencyMs: 600,
227
+ } as unknown as Parameters<NonNullable<typeof trigger.evaluateConfig>>[0];
228
+ expect(
229
+ trigger.evaluateConfig!(payload, { field: "latencyMs", above: 500 }),
230
+ ).toBe(true);
231
+ expect(
232
+ trigger.evaluateConfig!(payload, { field: "latencyMs", above: 700 }),
233
+ ).toBe(false);
259
234
  });
260
235
 
261
- it("uses the shared queue name", () => {
262
- expect(BUILTIN_TRIGGER_QUEUE).toBe("automation-builtin-triggers");
236
+ it("evaluateConfig reads a collector field under result (collectors map)", () => {
237
+ const payload = {
238
+ systemId: "sys-1",
239
+ configurationId: "c",
240
+ status: "healthy",
241
+ result: { http: { responseTimeMs: 120 } },
242
+ } as unknown as Parameters<NonNullable<typeof trigger.evaluateConfig>>[0];
243
+ expect(
244
+ trigger.evaluateConfig!(payload, {
245
+ field: "collectors.http.responseTimeMs",
246
+ below: 200,
247
+ }),
248
+ ).toBe(true);
249
+ expect(
250
+ trigger.evaluateConfig!(payload, {
251
+ field: "collectors.http.responseTimeMs",
252
+ below: 100,
253
+ }),
254
+ ).toBe(false);
255
+ });
256
+
257
+ it("evaluateConfig is false when the field is missing", () => {
258
+ const payload = {
259
+ systemId: "sys-1",
260
+ configurationId: "c",
261
+ status: "healthy",
262
+ } as unknown as Parameters<NonNullable<typeof trigger.evaluateConfig>>[0];
263
+ expect(
264
+ trigger.evaluateConfig!(payload, { field: "latencyMs", above: 1 }),
265
+ ).toBe(false);
263
266
  });
264
267
  });