@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
|
@@ -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.
|
|
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.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/command-backend": "0.1.
|
|
21
|
-
"@checkstack/
|
|
22
|
-
"@checkstack/
|
|
23
|
-
"@checkstack/common": "0.
|
|
24
|
-
"@checkstack/
|
|
25
|
-
"@checkstack/
|
|
26
|
-
"@checkstack/
|
|
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/
|
|
35
|
-
"@checkstack/
|
|
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
|
}
|
package/src/action-types.ts
CHANGED
|
@@ -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>
|
package/src/artifact-store.ts
CHANGED
|
@@ -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:
|
|
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
|
+
});
|
package/src/automation-store.ts
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
+
await expect(
|
|
192
|
+
fx.consumer!(tick("builtin:cron:unregistered:nope")),
|
|
193
|
+
).resolves.toBeUndefined();
|
|
194
|
+
});
|
|
218
195
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
expect(fire).not.toHaveBeenCalled();
|
|
201
|
+
describe("numeric_state trigger", () => {
|
|
202
|
+
const trigger = createNumericStateTrigger();
|
|
230
203
|
|
|
231
|
-
|
|
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("
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
).
|
|
245
|
-
expect(fx.scheduleMock).not.toHaveBeenCalled();
|
|
218
|
+
).toBe("sys-1");
|
|
246
219
|
});
|
|
247
|
-
});
|
|
248
220
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
).
|
|
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("
|
|
262
|
-
|
|
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
|
});
|