@checkstack/automation-backend 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time migration: rewrite legacy `healthcheck.flapping_detected` triggers
|
|
3
|
+
* onto the generic windowed-count gate.
|
|
4
|
+
*
|
|
5
|
+
* The pre-derived `flapping_detected` trigger (+ its `{ transitions,
|
|
6
|
+
* windowMinutes }` config) was removed. Flapping is now expressed as a window
|
|
7
|
+
* over the raw `healthcheck.system_health_changed` change event:
|
|
8
|
+
*
|
|
9
|
+
* event: healthcheck.flapping_detected
|
|
10
|
+
* config: { transitions, windowMinutes }
|
|
11
|
+
* ↓
|
|
12
|
+
* event: healthcheck.system_health_changed
|
|
13
|
+
* filter: trigger.payload.newStatus != "healthy"
|
|
14
|
+
* window: { count: transitions ?? 3, minutes: windowMinutes ?? 60, refire: "once" }
|
|
15
|
+
*
|
|
16
|
+
* Targets USER-created automations only — the seeded `auto-incident:*` rows
|
|
17
|
+
* were already deleted (migration 0008). Idempotent: an automation whose
|
|
18
|
+
* triggers no longer reference the legacy event is left untouched.
|
|
19
|
+
*
|
|
20
|
+
* Safe-option note on a PRE-EXISTING filter: a legacy flapping trigger could
|
|
21
|
+
* (rarely) already carry an operator `filter`. The unhealthy-transition filter
|
|
22
|
+
* is the load-bearing semantic of the new flapping shape, so we REPLACE any
|
|
23
|
+
* pre-existing filter with the canonical one rather than silently AND-combining
|
|
24
|
+
* (which could change behaviour in surprising ways). This is logged per row so
|
|
25
|
+
* an operator can re-add a bespoke clause if they had one.
|
|
26
|
+
*/
|
|
27
|
+
import { eq } from "drizzle-orm";
|
|
28
|
+
import type { Logger, SafeDatabase } from "@checkstack/backend-api";
|
|
29
|
+
|
|
30
|
+
import * as schema from "../schema";
|
|
31
|
+
|
|
32
|
+
type Db = SafeDatabase<typeof schema>;
|
|
33
|
+
|
|
34
|
+
/** Legacy + new event ids (string-compared against stored `trigger.event`). */
|
|
35
|
+
export const LEGACY_FLAPPING_EVENT = "healthcheck.flapping_detected";
|
|
36
|
+
export const HEALTH_CHANGED_EVENT = "healthcheck.system_health_changed";
|
|
37
|
+
|
|
38
|
+
/** Canonical filter for the windowed flapping shape (unhealthy transitions). */
|
|
39
|
+
export const FLAPPING_FILTER = 'trigger.payload.newStatus != "healthy"';
|
|
40
|
+
|
|
41
|
+
/** Defaults applied when a legacy trigger carried no / partial config. */
|
|
42
|
+
export const DEFAULT_FLAPPING_COUNT = 3;
|
|
43
|
+
export const DEFAULT_FLAPPING_MINUTES = 60;
|
|
44
|
+
|
|
45
|
+
interface RewriteOutcome {
|
|
46
|
+
/** The (possibly) rewritten definition. */
|
|
47
|
+
definition: Record<string, unknown>;
|
|
48
|
+
/** Number of flapping triggers rewritten in this definition. */
|
|
49
|
+
rewritten: number;
|
|
50
|
+
/** True if any rewritten trigger had a pre-existing filter we replaced. */
|
|
51
|
+
replacedFilter: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
56
|
+
? (value as Record<string, unknown>)
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readPositiveInt(value: unknown, fallback: number): number {
|
|
61
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1
|
|
62
|
+
? value
|
|
63
|
+
: fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rewrite every legacy `flapping_detected` trigger in a stored automation
|
|
68
|
+
* definition. Pure — no I/O — so the mapping (config → window, defaults,
|
|
69
|
+
* filter replacement) is unit-testable in isolation. Returns the new
|
|
70
|
+
* definition plus counters; `rewritten === 0` means "leave the row untouched".
|
|
71
|
+
*/
|
|
72
|
+
export function rewriteFlappingTriggers(
|
|
73
|
+
definition: Record<string, unknown>,
|
|
74
|
+
): RewriteOutcome {
|
|
75
|
+
const triggers = definition["triggers"];
|
|
76
|
+
if (!Array.isArray(triggers)) {
|
|
77
|
+
return { definition, rewritten: 0, replacedFilter: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let rewritten = 0;
|
|
81
|
+
let replacedFilter = false;
|
|
82
|
+
|
|
83
|
+
const nextTriggers = triggers.map((raw) => {
|
|
84
|
+
const trigger = asRecord(raw);
|
|
85
|
+
if (!trigger || trigger["event"] !== LEGACY_FLAPPING_EVENT) return raw;
|
|
86
|
+
|
|
87
|
+
const config = asRecord(trigger["config"]);
|
|
88
|
+
const count = readPositiveInt(config?.["transitions"], DEFAULT_FLAPPING_COUNT);
|
|
89
|
+
const minutes = readPositiveInt(
|
|
90
|
+
config?.["windowMinutes"],
|
|
91
|
+
DEFAULT_FLAPPING_MINUTES,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (typeof trigger["filter"] === "string") replacedFilter = true;
|
|
95
|
+
rewritten += 1;
|
|
96
|
+
|
|
97
|
+
// Drop `config`; set the new event + canonical filter + window. Preserve
|
|
98
|
+
// any other fields the operator set (id, for, etc.).
|
|
99
|
+
const { config: _dropped, ...rest } = trigger;
|
|
100
|
+
void _dropped;
|
|
101
|
+
return {
|
|
102
|
+
...rest,
|
|
103
|
+
event: HEALTH_CHANGED_EVENT,
|
|
104
|
+
filter: FLAPPING_FILTER,
|
|
105
|
+
window: { count, minutes, refire: "once" },
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (rewritten === 0) {
|
|
110
|
+
return { definition, rewritten: 0, replacedFilter: false };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
definition: { ...definition, triggers: nextTriggers },
|
|
114
|
+
rewritten,
|
|
115
|
+
replacedFilter,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface FlappingMigrationResult {
|
|
120
|
+
/** Automations scanned. */
|
|
121
|
+
scanned: number;
|
|
122
|
+
/** Automations whose definition was rewritten + persisted. */
|
|
123
|
+
migrated: number;
|
|
124
|
+
/** Total flapping triggers rewritten across all rows. */
|
|
125
|
+
triggersRewritten: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scan every automation and rewrite legacy flapping triggers. Idempotent:
|
|
130
|
+
* rows with no flapping trigger are skipped; rows already migrated have no
|
|
131
|
+
* `flapping_detected` event and are skipped too. Safe to run on every boot.
|
|
132
|
+
*
|
|
133
|
+
* After the rewrite, logs a WARNING if any enabled automation still references
|
|
134
|
+
* the legacy event (should never happen post-rewrite — a leftover indicates a
|
|
135
|
+
* definition the migration couldn't parse).
|
|
136
|
+
*/
|
|
137
|
+
export async function runFlappingAutomationMigration(args: {
|
|
138
|
+
db: Db;
|
|
139
|
+
logger: Logger;
|
|
140
|
+
}): Promise<FlappingMigrationResult> {
|
|
141
|
+
const { db, logger } = args;
|
|
142
|
+
|
|
143
|
+
const rows = await db
|
|
144
|
+
.select({
|
|
145
|
+
id: schema.automations.id,
|
|
146
|
+
name: schema.automations.name,
|
|
147
|
+
status: schema.automations.status,
|
|
148
|
+
definition: schema.automations.definition,
|
|
149
|
+
})
|
|
150
|
+
.from(schema.automations);
|
|
151
|
+
|
|
152
|
+
let migrated = 0;
|
|
153
|
+
let triggersRewritten = 0;
|
|
154
|
+
// Enabled rows that STILL reference the dead event after the rewrite pass —
|
|
155
|
+
// i.e. the rewrite couldn't convert them (unparseable definition). These
|
|
156
|
+
// would never fire again, so they get a per-boot warning.
|
|
157
|
+
const leftover: string[] = [];
|
|
158
|
+
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const outcome = rewriteFlappingTriggers(row.definition);
|
|
161
|
+
if (outcome.rewritten === 0) {
|
|
162
|
+
// Untouched: flag it only if it (still) points at the dead event while
|
|
163
|
+
// enabled — the rewrite found nothing to convert there.
|
|
164
|
+
const triggers = row.definition["triggers"];
|
|
165
|
+
if (
|
|
166
|
+
row.status === "enabled" &&
|
|
167
|
+
Array.isArray(triggers) &&
|
|
168
|
+
triggers.some((t) => asRecord(t)?.["event"] === LEGACY_FLAPPING_EVENT)
|
|
169
|
+
) {
|
|
170
|
+
leftover.push(row.id);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await db
|
|
176
|
+
.update(schema.automations)
|
|
177
|
+
.set({ definition: outcome.definition, updatedAt: new Date() })
|
|
178
|
+
.where(eq(schema.automations.id, row.id));
|
|
179
|
+
|
|
180
|
+
migrated += 1;
|
|
181
|
+
triggersRewritten += outcome.rewritten;
|
|
182
|
+
logger.info(
|
|
183
|
+
`flapping migration: rewrote ${outcome.rewritten} flapping trigger(s) on automation ${row.id} (${row.name}) → windowed system_health_changed${
|
|
184
|
+
outcome.replacedFilter
|
|
185
|
+
? "; a pre-existing trigger filter was replaced with the canonical unhealthy-transition filter"
|
|
186
|
+
: ""
|
|
187
|
+
}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (leftover.length > 0) {
|
|
192
|
+
logger.warn(
|
|
193
|
+
`flapping migration: ${leftover.length} enabled automation(s) still reference the removed "${LEGACY_FLAPPING_EVENT}" event after migration and will not fire: ${leftover.join(
|
|
194
|
+
", ",
|
|
195
|
+
)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (migrated > 0) {
|
|
200
|
+
logger.debug(
|
|
201
|
+
`flapping migration: migrated ${migrated} automation(s) (${triggersRewritten} trigger(s))`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return { scanned: rows.length, migrated, triggersRewritten };
|
|
205
|
+
}
|
package/src/router.test.ts
CHANGED
|
@@ -69,6 +69,7 @@ const sampleTrigger: TriggerDefinition<{ incidentId: string }> = {
|
|
|
69
69
|
payloadSchema: samplePayloadSchema,
|
|
70
70
|
hook: sampleHook,
|
|
71
71
|
contextKey: (p) => p.incidentId,
|
|
72
|
+
contextKeyLabel: "incident",
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
const sampleAction: ActionDefinition<{ message: string }, { id: string }> = {
|
|
@@ -97,6 +98,7 @@ const sampleDefinition: AutomationDefinition = {
|
|
|
97
98
|
conditions: [],
|
|
98
99
|
actions: [],
|
|
99
100
|
mode: "single",
|
|
101
|
+
concurrency_scope: "automation",
|
|
100
102
|
max_runs: 10,
|
|
101
103
|
};
|
|
102
104
|
|
|
@@ -117,6 +119,7 @@ function createInMemoryAutomationStore(): {
|
|
|
117
119
|
id,
|
|
118
120
|
name: input.name,
|
|
119
121
|
description: input.description,
|
|
122
|
+
group: input.group,
|
|
120
123
|
status: input.status,
|
|
121
124
|
definition: input.definition,
|
|
122
125
|
createdAt: now(),
|
|
@@ -132,6 +135,8 @@ function createInMemoryAutomationStore(): {
|
|
|
132
135
|
...existing,
|
|
133
136
|
name: input.name ?? existing.name,
|
|
134
137
|
description: input.description ?? existing.description,
|
|
138
|
+
// null clears, undefined leaves unchanged, string sets.
|
|
139
|
+
group: input.group === undefined ? existing.group : (input.group ?? undefined),
|
|
135
140
|
status: input.status ?? existing.status,
|
|
136
141
|
definition: input.definition ?? existing.definition,
|
|
137
142
|
updatedAt: now(),
|
|
@@ -160,13 +165,20 @@ function createInMemoryAutomationStore(): {
|
|
|
160
165
|
const limit = filter?.limit ?? 50;
|
|
161
166
|
const offset = filter?.offset ?? 0;
|
|
162
167
|
const all = [...rows.values()].filter(
|
|
163
|
-
(a) =>
|
|
168
|
+
(a) =>
|
|
169
|
+
(!filter?.status || a.status === filter.status) &&
|
|
170
|
+
(!filter?.group || a.group === filter.group),
|
|
164
171
|
);
|
|
165
172
|
return {
|
|
166
173
|
items: all.slice(offset, offset + limit),
|
|
167
174
|
total: all.length,
|
|
168
175
|
};
|
|
169
176
|
},
|
|
177
|
+
async listGroups() {
|
|
178
|
+
const groups = new Set<string>();
|
|
179
|
+
for (const a of rows.values()) if (a.group) groups.add(a.group);
|
|
180
|
+
return [...groups].sort();
|
|
181
|
+
},
|
|
170
182
|
async findEnabledByTriggerEvent() {
|
|
171
183
|
return [];
|
|
172
184
|
},
|
|
@@ -239,6 +251,7 @@ interface RouterHarness {
|
|
|
239
251
|
signalService: MockSignalService;
|
|
240
252
|
automationRows: Map<string, Automation>;
|
|
241
253
|
db: ReturnType<typeof createMockDbForRouter>;
|
|
254
|
+
dispatchDeps: ReturnType<typeof makeDispatchDeps>["deps"];
|
|
242
255
|
}
|
|
243
256
|
|
|
244
257
|
function createMockDbForRouter() {
|
|
@@ -295,6 +308,7 @@ function makeRouter(): RouterHarness {
|
|
|
295
308
|
signalService,
|
|
296
309
|
automationRows,
|
|
297
310
|
db,
|
|
311
|
+
dispatchDeps,
|
|
298
312
|
};
|
|
299
313
|
}
|
|
300
314
|
|
|
@@ -348,6 +362,56 @@ describe("Automation Router", () => {
|
|
|
348
362
|
expect(res.total).toBe(1);
|
|
349
363
|
expect(res.items[0]?.name).toBe("A");
|
|
350
364
|
});
|
|
365
|
+
|
|
366
|
+
it("threads the group filter through to the store", async () => {
|
|
367
|
+
await h.automationStore.create({
|
|
368
|
+
name: "A",
|
|
369
|
+
group: "Alerting",
|
|
370
|
+
status: "enabled",
|
|
371
|
+
definition: sampleDefinition,
|
|
372
|
+
});
|
|
373
|
+
await h.automationStore.create({
|
|
374
|
+
name: "B",
|
|
375
|
+
group: "Networking",
|
|
376
|
+
status: "enabled",
|
|
377
|
+
definition: sampleDefinition,
|
|
378
|
+
});
|
|
379
|
+
const res = await call(
|
|
380
|
+
h.router.listAutomations,
|
|
381
|
+
{ limit: 50, offset: 0, group: "Alerting" },
|
|
382
|
+
{ context: h.context },
|
|
383
|
+
);
|
|
384
|
+
expect(res.total).toBe(1);
|
|
385
|
+
expect(res.items[0]?.name).toBe("A");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("listAutomationGroups", () => {
|
|
390
|
+
it("returns the distinct non-null group values", async () => {
|
|
391
|
+
await h.automationStore.create({
|
|
392
|
+
name: "A",
|
|
393
|
+
group: "Networking",
|
|
394
|
+
status: "enabled",
|
|
395
|
+
definition: sampleDefinition,
|
|
396
|
+
});
|
|
397
|
+
await h.automationStore.create({
|
|
398
|
+
name: "B",
|
|
399
|
+
group: "Alerting",
|
|
400
|
+
status: "enabled",
|
|
401
|
+
definition: sampleDefinition,
|
|
402
|
+
});
|
|
403
|
+
await h.automationStore.create({
|
|
404
|
+
name: "C",
|
|
405
|
+
status: "enabled",
|
|
406
|
+
definition: sampleDefinition,
|
|
407
|
+
});
|
|
408
|
+
const res = await call(
|
|
409
|
+
h.router.listAutomationGroups,
|
|
410
|
+
{},
|
|
411
|
+
{ context: h.context },
|
|
412
|
+
);
|
|
413
|
+
expect(res.groups).toEqual(["Alerting", "Networking"]);
|
|
414
|
+
});
|
|
351
415
|
});
|
|
352
416
|
|
|
353
417
|
describe("getAutomation", () => {
|
|
@@ -521,6 +585,9 @@ describe("Automation Router", () => {
|
|
|
521
585
|
expect(res.items).toHaveLength(1);
|
|
522
586
|
expect(res.items[0]?.qualifiedId).toBe("test.incident.created");
|
|
523
587
|
expect(res.items[0]?.payloadSchema).toBeDefined();
|
|
588
|
+
// contextKeyLabel flows through to the wire format for the editor's
|
|
589
|
+
// window "Partition by" default hint.
|
|
590
|
+
expect(res.items[0]?.contextKeyLabel).toBe("incident");
|
|
524
591
|
});
|
|
525
592
|
});
|
|
526
593
|
|
|
@@ -721,4 +788,118 @@ describe("Automation Router", () => {
|
|
|
721
788
|
expect(typeof res.error?.column).toBe("number");
|
|
722
789
|
});
|
|
723
790
|
});
|
|
791
|
+
|
|
792
|
+
describe("testScript", () => {
|
|
793
|
+
it("runs a shell script against the flattened sample context", async () => {
|
|
794
|
+
const res = await call(
|
|
795
|
+
h.router.testScript,
|
|
796
|
+
{
|
|
797
|
+
kind: "shell",
|
|
798
|
+
script: 'echo "$CHECKSTACK_TRIGGER_PAYLOAD_ID"',
|
|
799
|
+
context: { trigger: { event: "incident.created", payload: { id: "INC-7" } } },
|
|
800
|
+
timeoutMs: 10_000,
|
|
801
|
+
},
|
|
802
|
+
{ context: h.context },
|
|
803
|
+
);
|
|
804
|
+
expect(res.exitCode).toBe(0);
|
|
805
|
+
expect(res.stdout).toBe("INC-7");
|
|
806
|
+
expect(res.error).toBeUndefined();
|
|
807
|
+
expect(res.timedOut).toBe(false);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("surfaces a non-zero shell exit code as an error", async () => {
|
|
811
|
+
const res = await call(
|
|
812
|
+
h.router.testScript,
|
|
813
|
+
{ kind: "shell", script: "exit 3", timeoutMs: 10_000 },
|
|
814
|
+
{ context: h.context },
|
|
815
|
+
);
|
|
816
|
+
expect(res.exitCode).toBe(3);
|
|
817
|
+
expect(res.error).toContain("exited with code 3");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("runs a typescript script and returns its default export", async () => {
|
|
821
|
+
const res = await call(
|
|
822
|
+
h.router.testScript,
|
|
823
|
+
{
|
|
824
|
+
kind: "typescript",
|
|
825
|
+
script: "export default { ok: context.trigger.payload.id };",
|
|
826
|
+
context: { trigger: { event: "e", payload: { id: "INC-9" } } },
|
|
827
|
+
timeoutMs: 10_000,
|
|
828
|
+
},
|
|
829
|
+
{ context: h.context },
|
|
830
|
+
);
|
|
831
|
+
expect(res.result).toEqual({ ok: "INC-9" });
|
|
832
|
+
expect(res.error).toBeUndefined();
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
describe("getRunScopeForReplay", () => {
|
|
837
|
+
it("reconstructs trigger + artifacts and reports snapshot availability", async () => {
|
|
838
|
+
// First select() → the run row; second select() → artifact rows.
|
|
839
|
+
const runRow = {
|
|
840
|
+
id: "run-1",
|
|
841
|
+
triggerEventId: "incident.incident.created",
|
|
842
|
+
triggerPayload: { id: "INC-7" },
|
|
843
|
+
};
|
|
844
|
+
const artifactRows = [
|
|
845
|
+
{ artifactType: "jira.issue", actionId: "j", data: { key: "P-1" } },
|
|
846
|
+
];
|
|
847
|
+
h.db.select = mock(() => fluentSelect([runRow]))
|
|
848
|
+
.mockImplementationOnce(() => fluentSelect([runRow]))
|
|
849
|
+
.mockImplementationOnce(() => fluentSelect(artifactRows));
|
|
850
|
+
h.dispatchDeps.runStateStore.load = mock(async () => ({
|
|
851
|
+
scopeSnapshot: { vars: { count: 2 } },
|
|
852
|
+
lastActionPath: null,
|
|
853
|
+
lastHeartbeatAt: new Date(),
|
|
854
|
+
}));
|
|
855
|
+
|
|
856
|
+
const res = await call(
|
|
857
|
+
h.router.getRunScopeForReplay,
|
|
858
|
+
{ runId: "run-1" },
|
|
859
|
+
{ context: h.context },
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
expect(res.context.trigger).toEqual({
|
|
863
|
+
event: "incident.incident.created",
|
|
864
|
+
payload: { id: "INC-7" },
|
|
865
|
+
});
|
|
866
|
+
expect(res.context.artifacts).toEqual({
|
|
867
|
+
"jira.issue": { key: "P-1" },
|
|
868
|
+
j: { key: "P-1" },
|
|
869
|
+
});
|
|
870
|
+
expect(res.context.var).toEqual({ count: 2 });
|
|
871
|
+
expect(res.scopeSnapshotAvailable).toBe(true);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it("reports scopeSnapshotAvailable=false when the run state is cleared", async () => {
|
|
875
|
+
const runRow = {
|
|
876
|
+
id: "run-2",
|
|
877
|
+
triggerEventId: "e",
|
|
878
|
+
triggerPayload: {},
|
|
879
|
+
};
|
|
880
|
+
h.db.select = mock(() => fluentSelect([runRow]))
|
|
881
|
+
.mockImplementationOnce(() => fluentSelect([runRow]))
|
|
882
|
+
.mockImplementationOnce(() => fluentSelect([]));
|
|
883
|
+
h.dispatchDeps.runStateStore.load = mock(async () => undefined);
|
|
884
|
+
|
|
885
|
+
const res = await call(
|
|
886
|
+
h.router.getRunScopeForReplay,
|
|
887
|
+
{ runId: "run-2" },
|
|
888
|
+
{ context: h.context },
|
|
889
|
+
);
|
|
890
|
+
expect(res.scopeSnapshotAvailable).toBe(false);
|
|
891
|
+
expect(res.context.var).toEqual({});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("404s on an unknown run id", async () => {
|
|
895
|
+
h.db.select = mock(() => fluentSelect([]));
|
|
896
|
+
await expect(
|
|
897
|
+
call(
|
|
898
|
+
h.router.getRunScopeForReplay,
|
|
899
|
+
{ runId: "missing" },
|
|
900
|
+
{ context: h.context },
|
|
901
|
+
),
|
|
902
|
+
).rejects.toThrow(/not found/i);
|
|
903
|
+
});
|
|
904
|
+
});
|
|
724
905
|
});
|
package/src/router.ts
CHANGED
|
@@ -43,6 +43,12 @@ import type { AutomationStore } from "./automation-store";
|
|
|
43
43
|
import { dispatchTrigger } from "./dispatch/engine";
|
|
44
44
|
import type { DispatchDeps } from "./dispatch/types";
|
|
45
45
|
import { collectDefinitionIssues } from "./validate-definition";
|
|
46
|
+
import {
|
|
47
|
+
resolveResolutionRootFromStore,
|
|
48
|
+
resolveScriptPackagesDir,
|
|
49
|
+
} from "@checkstack/script-packages-backend";
|
|
50
|
+
import { runScriptTest } from "./script-test";
|
|
51
|
+
import { buildReplayContext } from "./script-test-replay";
|
|
46
52
|
import * as schema from "./schema";
|
|
47
53
|
|
|
48
54
|
interface RouterDeps {
|
|
@@ -140,8 +146,13 @@ export function createAutomationRouter(deps: RouterDeps) {
|
|
|
140
146
|
// ─── Automations CRUD ────────────────────────────────────────────────
|
|
141
147
|
|
|
142
148
|
listAutomations: os.listAutomations.handler(async ({ input }) => {
|
|
143
|
-
const { limit, offset, status } = input;
|
|
144
|
-
const result = await automationStore.list({
|
|
149
|
+
const { limit, offset, status, group } = input;
|
|
150
|
+
const result = await automationStore.list({
|
|
151
|
+
limit,
|
|
152
|
+
offset,
|
|
153
|
+
status,
|
|
154
|
+
group,
|
|
155
|
+
});
|
|
145
156
|
return {
|
|
146
157
|
items: result.items,
|
|
147
158
|
total: result.total,
|
|
@@ -150,6 +161,11 @@ export function createAutomationRouter(deps: RouterDeps) {
|
|
|
150
161
|
};
|
|
151
162
|
}),
|
|
152
163
|
|
|
164
|
+
listAutomationGroups: os.listAutomationGroups.handler(async () => {
|
|
165
|
+
const groups = await automationStore.listGroups();
|
|
166
|
+
return { groups };
|
|
167
|
+
}),
|
|
168
|
+
|
|
153
169
|
getAutomation: os.getAutomation.handler(async ({ input }) => {
|
|
154
170
|
const automation = await automationStore.getById(input.id);
|
|
155
171
|
if (!automation) {
|
|
@@ -446,6 +462,7 @@ export function createAutomationRouter(deps: RouterDeps) {
|
|
|
446
462
|
ownerPluginId: t.ownerPluginId,
|
|
447
463
|
payloadSchema: t.payloadJsonSchema,
|
|
448
464
|
configSchema: t.configJsonSchema,
|
|
465
|
+
contextKeyLabel: t.contextKeyLabel,
|
|
449
466
|
}));
|
|
450
467
|
return { items };
|
|
451
468
|
}),
|
|
@@ -514,6 +531,60 @@ export function createAutomationRouter(deps: RouterDeps) {
|
|
|
514
531
|
},
|
|
515
532
|
),
|
|
516
533
|
|
|
534
|
+
// ─── Inline script testing ───────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
testScript: os.testScript.handler(async ({ input }) => {
|
|
537
|
+
// Resolve the managed npm-package root from the local store so a test
|
|
538
|
+
// resolves the same allowlisted packages the real `run_script` action
|
|
539
|
+
// would (plan §4.1). Filesystem-only: ready when a tree is
|
|
540
|
+
// materialized, else unset. Execution safety is the runner's
|
|
541
|
+
// (auto-install disabled).
|
|
542
|
+
const status = await resolveResolutionRootFromStore(
|
|
543
|
+
resolveScriptPackagesDir(),
|
|
544
|
+
);
|
|
545
|
+
const resolutionRoot =
|
|
546
|
+
status.mode === "ready" ? status.root : undefined;
|
|
547
|
+
return runScriptTest({ input, deps: { resolutionRoot } });
|
|
548
|
+
}),
|
|
549
|
+
|
|
550
|
+
getRunScopeForReplay: os.getRunScopeForReplay.handler(async ({ input }) => {
|
|
551
|
+
const runRow = await db
|
|
552
|
+
.select()
|
|
553
|
+
.from(schema.automationRuns)
|
|
554
|
+
.where(eq(schema.automationRuns.id, input.runId))
|
|
555
|
+
.limit(1);
|
|
556
|
+
const run = runRow[0];
|
|
557
|
+
if (!run) {
|
|
558
|
+
throw new ORPCError("NOT_FOUND", {
|
|
559
|
+
message: `Run ${input.runId} not found`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const [artifactRows, runState] = await Promise.all([
|
|
564
|
+
db
|
|
565
|
+
.select()
|
|
566
|
+
.from(schema.automationArtifacts)
|
|
567
|
+
.where(eq(schema.automationArtifacts.runId, input.runId))
|
|
568
|
+
.orderBy(asc(schema.automationArtifacts.createdAt)),
|
|
569
|
+
dispatchDeps.runStateStore.load(input.runId),
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
const context = buildReplayContext({
|
|
573
|
+
run: {
|
|
574
|
+
triggerEventId: run.triggerEventId,
|
|
575
|
+
triggerPayload: run.triggerPayload,
|
|
576
|
+
},
|
|
577
|
+
artifacts: artifactRows.map((row) => ({
|
|
578
|
+
artifactType: row.artifactType,
|
|
579
|
+
actionId: row.actionId,
|
|
580
|
+
data: row.data,
|
|
581
|
+
})),
|
|
582
|
+
scopeSnapshot: runState?.scopeSnapshot,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return { context, scopeSnapshotAvailable: runState !== undefined };
|
|
586
|
+
}),
|
|
587
|
+
|
|
517
588
|
// ─── Template playground ─────────────────────────────────────────────
|
|
518
589
|
|
|
519
590
|
renderTemplate: os.renderTemplate.handler(async ({ input }) => {
|