@checkstack/automation-backend 0.2.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 +453 -0
- package/drizzle/0000_acoustic_diamondback.sql +80 -0
- package/drizzle/0001_mute_vindicator.sql +12 -0
- package/drizzle/0002_silky_omega_red.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +688 -0
- package/drizzle/meta/0001_snapshot.json +785 -0
- package/drizzle/meta/0002_snapshot.json +861 -0
- package/drizzle/meta/_journal.json +27 -0
- package/drizzle.config.ts +12 -0
- package/package.json +41 -0
- package/src/action-registry.ts +83 -0
- package/src/action-types.ts +324 -0
- package/src/artifact-store.ts +140 -0
- package/src/artifact-type-registry.ts +64 -0
- package/src/automation-store.ts +227 -0
- package/src/builtin-actions.test.ts +185 -0
- package/src/builtin-actions.ts +132 -0
- package/src/builtin-triggers.test.ts +264 -0
- package/src/builtin-triggers.ts +365 -0
- package/src/dispatch/action-kind.ts +44 -0
- package/src/dispatch/condition.ts +61 -0
- package/src/dispatch/delay-queue.ts +91 -0
- package/src/dispatch/engine.test.ts +1198 -0
- package/src/dispatch/engine.ts +1672 -0
- package/src/dispatch/path-nav.ts +65 -0
- package/src/dispatch/render.test.ts +75 -0
- package/src/dispatch/render.ts +136 -0
- package/src/dispatch/run-state-store.ts +143 -0
- package/src/dispatch/run-state.ts +298 -0
- package/src/dispatch/scope.test.ts +40 -0
- package/src/dispatch/scope.ts +125 -0
- package/src/dispatch/stalled-sweeper.ts +164 -0
- package/src/dispatch/test-fixtures.ts +558 -0
- package/src/dispatch/trigger-subscriber.ts +397 -0
- package/src/dispatch/types.ts +259 -0
- package/src/extension-points.ts +88 -0
- package/src/index.ts +379 -0
- package/src/migration/from-webhook-subscriptions.test.ts +237 -0
- package/src/migration/from-webhook-subscriptions.ts +398 -0
- package/src/registries.test.ts +357 -0
- package/src/router.test.ts +724 -0
- package/src/router.ts +556 -0
- package/src/schema.ts +310 -0
- package/src/trigger-registry.ts +99 -0
- package/src/validate-definition.test.ts +306 -0
- package/src/validate-definition.ts +304 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createHook, Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
createTriggerRegistry,
|
|
6
|
+
type TriggerRegistry,
|
|
7
|
+
} from "./trigger-registry";
|
|
8
|
+
import {
|
|
9
|
+
createActionRegistry,
|
|
10
|
+
type ActionRegistry,
|
|
11
|
+
} from "./action-registry";
|
|
12
|
+
import {
|
|
13
|
+
createArtifactTypeRegistry,
|
|
14
|
+
type ArtifactTypeRegistry,
|
|
15
|
+
} from "./artifact-type-registry";
|
|
16
|
+
import type {
|
|
17
|
+
ActionDefinition,
|
|
18
|
+
ActionExecutionContext,
|
|
19
|
+
ArtifactTypeDefinition,
|
|
20
|
+
TriggerDefinition,
|
|
21
|
+
} from "./action-types";
|
|
22
|
+
|
|
23
|
+
const testPlugin = { pluginId: "test-plugin" } as const;
|
|
24
|
+
const otherPlugin = { pluginId: "other-plugin" } as const;
|
|
25
|
+
|
|
26
|
+
// ─── Shared fixtures ────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const incidentPayload = z.object({
|
|
29
|
+
incidentId: z.string(),
|
|
30
|
+
severity: z.enum(["info", "degraded", "critical"]),
|
|
31
|
+
});
|
|
32
|
+
const incidentHook = createHook<z.infer<typeof incidentPayload>>(
|
|
33
|
+
"incident.created",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const jiraConfig = z.object({
|
|
37
|
+
projectKey: z.string(),
|
|
38
|
+
summary: z.string(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const jiraIssueData = z.object({
|
|
42
|
+
issueKey: z.string(),
|
|
43
|
+
url: z.string(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ─── Trigger registry ───────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe("TriggerRegistry", () => {
|
|
49
|
+
let registry: TriggerRegistry;
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
registry = createTriggerRegistry();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("registers a hook-backed trigger with fully qualified id", () => {
|
|
55
|
+
const def: TriggerDefinition<z.infer<typeof incidentPayload>> = {
|
|
56
|
+
id: "incident.created",
|
|
57
|
+
displayName: "Incident Created",
|
|
58
|
+
category: "Incidents",
|
|
59
|
+
payloadSchema: incidentPayload,
|
|
60
|
+
hook: incidentHook,
|
|
61
|
+
contextKey: (p) => p.incidentId,
|
|
62
|
+
};
|
|
63
|
+
registry.register(def, testPlugin);
|
|
64
|
+
|
|
65
|
+
expect(registry.hasTrigger("test-plugin.incident.created")).toBe(true);
|
|
66
|
+
const t = registry.getTrigger("test-plugin.incident.created");
|
|
67
|
+
expect(t?.qualifiedId).toBe("test-plugin.incident.created");
|
|
68
|
+
expect(t?.ownerPluginId).toBe("test-plugin");
|
|
69
|
+
expect(t?.category).toBe("Incidents");
|
|
70
|
+
expect(t?.hook).toBe(incidentHook);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("defaults category to Uncategorized", () => {
|
|
74
|
+
registry.register(
|
|
75
|
+
{
|
|
76
|
+
id: "x",
|
|
77
|
+
displayName: "X",
|
|
78
|
+
payloadSchema: incidentPayload,
|
|
79
|
+
hook: incidentHook,
|
|
80
|
+
},
|
|
81
|
+
testPlugin,
|
|
82
|
+
);
|
|
83
|
+
expect(registry.getTrigger("test-plugin.x")?.category).toBe("Uncategorized");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("generates JSON Schema from zod payload schema", () => {
|
|
87
|
+
registry.register(
|
|
88
|
+
{
|
|
89
|
+
id: "x",
|
|
90
|
+
displayName: "X",
|
|
91
|
+
payloadSchema: incidentPayload,
|
|
92
|
+
hook: incidentHook,
|
|
93
|
+
},
|
|
94
|
+
testPlugin,
|
|
95
|
+
);
|
|
96
|
+
const jsonSchema = registry.getTrigger("test-plugin.x")?.payloadJsonSchema;
|
|
97
|
+
expect(jsonSchema).toBeDefined();
|
|
98
|
+
expect(jsonSchema?.type).toBe("object");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("preserves the contextKey extractor", () => {
|
|
102
|
+
registry.register(
|
|
103
|
+
{
|
|
104
|
+
id: "x",
|
|
105
|
+
displayName: "X",
|
|
106
|
+
payloadSchema: incidentPayload,
|
|
107
|
+
hook: incidentHook,
|
|
108
|
+
contextKey: (p) => p.incidentId,
|
|
109
|
+
},
|
|
110
|
+
testPlugin,
|
|
111
|
+
);
|
|
112
|
+
const t = registry.getTrigger("test-plugin.x");
|
|
113
|
+
expect(
|
|
114
|
+
t?.contextKey?.({ incidentId: "inc-42", severity: "critical" }),
|
|
115
|
+
).toBe("inc-42");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects a trigger without hook or setup (unreachable)", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
registry.register(
|
|
121
|
+
{
|
|
122
|
+
id: "broken",
|
|
123
|
+
displayName: "Broken",
|
|
124
|
+
payloadSchema: incidentPayload,
|
|
125
|
+
},
|
|
126
|
+
testPlugin,
|
|
127
|
+
),
|
|
128
|
+
).toThrow(/neither a hook nor a setup callback/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects duplicate registrations", () => {
|
|
132
|
+
const def: TriggerDefinition<z.infer<typeof incidentPayload>> = {
|
|
133
|
+
id: "dup",
|
|
134
|
+
displayName: "D",
|
|
135
|
+
payloadSchema: incidentPayload,
|
|
136
|
+
hook: incidentHook,
|
|
137
|
+
};
|
|
138
|
+
registry.register(def, testPlugin);
|
|
139
|
+
expect(() => registry.register(def, testPlugin)).toThrow(/already registered/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("namespaces by plugin id (no collision across plugins)", () => {
|
|
143
|
+
const def: TriggerDefinition<z.infer<typeof incidentPayload>> = {
|
|
144
|
+
id: "x",
|
|
145
|
+
displayName: "X",
|
|
146
|
+
payloadSchema: incidentPayload,
|
|
147
|
+
hook: incidentHook,
|
|
148
|
+
};
|
|
149
|
+
registry.register(def, testPlugin);
|
|
150
|
+
registry.register(def, otherPlugin);
|
|
151
|
+
expect(registry.getTriggers().map((t) => t.qualifiedId).sort()).toEqual([
|
|
152
|
+
"other-plugin.x",
|
|
153
|
+
"test-plugin.x",
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("groups triggers by category", () => {
|
|
158
|
+
registry.register(
|
|
159
|
+
{
|
|
160
|
+
id: "a",
|
|
161
|
+
displayName: "A",
|
|
162
|
+
category: "Incidents",
|
|
163
|
+
payloadSchema: incidentPayload,
|
|
164
|
+
hook: incidentHook,
|
|
165
|
+
},
|
|
166
|
+
testPlugin,
|
|
167
|
+
);
|
|
168
|
+
registry.register(
|
|
169
|
+
{
|
|
170
|
+
id: "b",
|
|
171
|
+
displayName: "B",
|
|
172
|
+
category: "Time",
|
|
173
|
+
payloadSchema: incidentPayload,
|
|
174
|
+
hook: incidentHook,
|
|
175
|
+
},
|
|
176
|
+
testPlugin,
|
|
177
|
+
);
|
|
178
|
+
const byCat = registry.getTriggersByCategory();
|
|
179
|
+
expect(byCat.size).toBe(2);
|
|
180
|
+
expect(byCat.get("Incidents")?.length).toBe(1);
|
|
181
|
+
expect(byCat.get("Time")?.length).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("supports setup-backed (non-hook) triggers", () => {
|
|
185
|
+
let cleanupCalled = false;
|
|
186
|
+
registry.register(
|
|
187
|
+
{
|
|
188
|
+
id: "time.cron",
|
|
189
|
+
displayName: "Cron",
|
|
190
|
+
payloadSchema: z.object({ scheduledAt: z.string() }),
|
|
191
|
+
configSchema: z.object({ pattern: z.string() }),
|
|
192
|
+
setup: async () => async () => {
|
|
193
|
+
cleanupCalled = true;
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
testPlugin,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const t = registry.getTrigger("test-plugin.time.cron");
|
|
200
|
+
expect(t?.setup).toBeDefined();
|
|
201
|
+
expect(t?.configJsonSchema).toBeDefined();
|
|
202
|
+
expect(cleanupCalled).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── Action registry ────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe("ActionRegistry", () => {
|
|
209
|
+
let registry: ActionRegistry;
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
registry = createActionRegistry();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const jiraCreate: ActionDefinition<z.infer<typeof jiraConfig>, { issueKey: string; url: string }> = {
|
|
215
|
+
id: "jira.create_issue",
|
|
216
|
+
displayName: "Create Jira issue",
|
|
217
|
+
category: "Jira",
|
|
218
|
+
config: new Versioned({ version: 1, schema: jiraConfig }),
|
|
219
|
+
// Local artifact id — the registry qualifies it with the plugin id.
|
|
220
|
+
produces: "issue",
|
|
221
|
+
execute: async () => ({ success: true }),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
it("registers an action with namespaced id and qualifies its produces", () => {
|
|
225
|
+
registry.register(jiraCreate, testPlugin);
|
|
226
|
+
expect(registry.hasAction("test-plugin.jira.create_issue")).toBe(true);
|
|
227
|
+
const a = registry.getAction("test-plugin.jira.create_issue");
|
|
228
|
+
expect(a?.qualifiedId).toBe("test-plugin.jira.create_issue");
|
|
229
|
+
expect(a?.ownerPluginId).toBe("test-plugin");
|
|
230
|
+
// `produces` is qualified with the owning plugin, matching how the
|
|
231
|
+
// artifact-type registry qualifies the artifact type's own id.
|
|
232
|
+
expect(a?.produces).toBe("test-plugin.issue");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("generates JSON Schema from zod config", () => {
|
|
236
|
+
registry.register(jiraCreate, testPlugin);
|
|
237
|
+
const a = registry.getAction("test-plugin.jira.create_issue");
|
|
238
|
+
expect(a?.configJsonSchema).toBeDefined();
|
|
239
|
+
expect(a?.configJsonSchema?.type).toBe("object");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("rejects duplicate registrations", () => {
|
|
243
|
+
registry.register(jiraCreate, testPlugin);
|
|
244
|
+
expect(() => registry.register(jiraCreate, testPlugin)).toThrow(
|
|
245
|
+
/already registered/,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("groups actions by category", () => {
|
|
250
|
+
registry.register(jiraCreate, testPlugin);
|
|
251
|
+
registry.register(
|
|
252
|
+
{
|
|
253
|
+
id: "incident.create",
|
|
254
|
+
displayName: "Create Incident",
|
|
255
|
+
category: "Incident",
|
|
256
|
+
config: new Versioned({ version: 1, schema: z.object({}) }),
|
|
257
|
+
execute: async () => ({ success: true }),
|
|
258
|
+
},
|
|
259
|
+
testPlugin,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const byCat = registry.getActionsByCategory();
|
|
263
|
+
expect(byCat.size).toBe(2);
|
|
264
|
+
expect(byCat.get("Jira")?.length).toBe(1);
|
|
265
|
+
expect(byCat.get("Incident")?.length).toBe(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("calls execute with the typed context shape", async () => {
|
|
269
|
+
let captured: ActionExecutionContext<z.infer<typeof jiraConfig>> | undefined;
|
|
270
|
+
registry.register(
|
|
271
|
+
{
|
|
272
|
+
id: "captured",
|
|
273
|
+
displayName: "Captured",
|
|
274
|
+
config: new Versioned({ version: 1, schema: jiraConfig }),
|
|
275
|
+
execute: async (ctx) => {
|
|
276
|
+
captured = ctx;
|
|
277
|
+
return { success: true };
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
testPlugin,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const a = registry.getAction("test-plugin.captured");
|
|
284
|
+
const result = await a?.execute({
|
|
285
|
+
config: { projectKey: "PROJ", summary: "Test" },
|
|
286
|
+
consumedArtifacts: {},
|
|
287
|
+
runId: "r-1",
|
|
288
|
+
automationId: "a-1",
|
|
289
|
+
contextKey: null,
|
|
290
|
+
logger: {
|
|
291
|
+
debug: () => {},
|
|
292
|
+
info: () => {},
|
|
293
|
+
warn: () => {},
|
|
294
|
+
error: () => {},
|
|
295
|
+
} as unknown as ActionExecutionContext<unknown>["logger"],
|
|
296
|
+
getService: async () => {
|
|
297
|
+
throw new Error("no service in test");
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(result?.success).toBe(true);
|
|
302
|
+
expect(captured?.config.projectKey).toBe("PROJ");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ─── Artifact-type registry ─────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
describe("ArtifactTypeRegistry", () => {
|
|
309
|
+
let registry: ArtifactTypeRegistry;
|
|
310
|
+
beforeEach(() => {
|
|
311
|
+
registry = createArtifactTypeRegistry();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const jiraIssue: ArtifactTypeDefinition<z.infer<typeof jiraIssueData>> = {
|
|
315
|
+
id: "issue",
|
|
316
|
+
displayName: "Jira Issue",
|
|
317
|
+
schema: jiraIssueData,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
it("registers an artifact type with namespaced id", () => {
|
|
321
|
+
registry.register(jiraIssue, { pluginId: "jira" });
|
|
322
|
+
expect(registry.hasArtifactType("jira.issue")).toBe(true);
|
|
323
|
+
const t = registry.getArtifactType("jira.issue");
|
|
324
|
+
expect(t?.qualifiedId).toBe("jira.issue");
|
|
325
|
+
expect(t?.ownerPluginId).toBe("jira");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("generates JSON Schema from zod schema", () => {
|
|
329
|
+
registry.register(jiraIssue, { pluginId: "jira" });
|
|
330
|
+
const t = registry.getArtifactType("jira.issue");
|
|
331
|
+
expect(t?.jsonSchema).toBeDefined();
|
|
332
|
+
expect(t?.jsonSchema?.type).toBe("object");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("rejects duplicates", () => {
|
|
336
|
+
registry.register(jiraIssue, { pluginId: "jira" });
|
|
337
|
+
expect(() =>
|
|
338
|
+
registry.register(jiraIssue, { pluginId: "jira" }),
|
|
339
|
+
).toThrow(/already registered/);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("returns all registered types", () => {
|
|
343
|
+
registry.register(jiraIssue, { pluginId: "jira" });
|
|
344
|
+
registry.register(
|
|
345
|
+
{
|
|
346
|
+
id: "message",
|
|
347
|
+
displayName: "Teams Message",
|
|
348
|
+
schema: z.object({ messageId: z.string() }),
|
|
349
|
+
},
|
|
350
|
+
{ pluginId: "teams" },
|
|
351
|
+
);
|
|
352
|
+
expect(registry.getArtifactTypes().map((t) => t.qualifiedId).sort()).toEqual([
|
|
353
|
+
"jira.issue",
|
|
354
|
+
"teams.message",
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
357
|
+
});
|