@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +453 -0
  2. package/drizzle/0000_acoustic_diamondback.sql +80 -0
  3. package/drizzle/0001_mute_vindicator.sql +12 -0
  4. package/drizzle/0002_silky_omega_red.sql +12 -0
  5. package/drizzle/meta/0000_snapshot.json +688 -0
  6. package/drizzle/meta/0001_snapshot.json +785 -0
  7. package/drizzle/meta/0002_snapshot.json +861 -0
  8. package/drizzle/meta/_journal.json +27 -0
  9. package/drizzle.config.ts +12 -0
  10. package/package.json +41 -0
  11. package/src/action-registry.ts +83 -0
  12. package/src/action-types.ts +324 -0
  13. package/src/artifact-store.ts +140 -0
  14. package/src/artifact-type-registry.ts +64 -0
  15. package/src/automation-store.ts +227 -0
  16. package/src/builtin-actions.test.ts +185 -0
  17. package/src/builtin-actions.ts +132 -0
  18. package/src/builtin-triggers.test.ts +264 -0
  19. package/src/builtin-triggers.ts +365 -0
  20. package/src/dispatch/action-kind.ts +44 -0
  21. package/src/dispatch/condition.ts +61 -0
  22. package/src/dispatch/delay-queue.ts +91 -0
  23. package/src/dispatch/engine.test.ts +1198 -0
  24. package/src/dispatch/engine.ts +1672 -0
  25. package/src/dispatch/path-nav.ts +65 -0
  26. package/src/dispatch/render.test.ts +75 -0
  27. package/src/dispatch/render.ts +136 -0
  28. package/src/dispatch/run-state-store.ts +143 -0
  29. package/src/dispatch/run-state.ts +298 -0
  30. package/src/dispatch/scope.test.ts +40 -0
  31. package/src/dispatch/scope.ts +125 -0
  32. package/src/dispatch/stalled-sweeper.ts +164 -0
  33. package/src/dispatch/test-fixtures.ts +558 -0
  34. package/src/dispatch/trigger-subscriber.ts +397 -0
  35. package/src/dispatch/types.ts +259 -0
  36. package/src/extension-points.ts +88 -0
  37. package/src/index.ts +379 -0
  38. package/src/migration/from-webhook-subscriptions.test.ts +237 -0
  39. package/src/migration/from-webhook-subscriptions.ts +398 -0
  40. package/src/registries.test.ts +357 -0
  41. package/src/router.test.ts +724 -0
  42. package/src/router.ts +556 -0
  43. package/src/schema.ts +310 -0
  44. package/src/trigger-registry.ts +99 -0
  45. package/src/validate-definition.test.ts +306 -0
  46. package/src/validate-definition.ts +304 -0
  47. package/tsconfig.json +41 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Drizzle-backed CRUD for the `automations` table plus the lookups the
3
+ * dispatch engine needs (find by referenced trigger event, etc.).
4
+ *
5
+ * Returns are typed against the public `Automation` shape from
6
+ * `@checkstack/automation-common`, with parsed `definition`.
7
+ */
8
+ import { and, eq, sql } from "drizzle-orm";
9
+ import type { SafeDatabase } from "@checkstack/backend-api";
10
+ import {
11
+ AutomationDefinitionSchema,
12
+ type Automation,
13
+ type AutomationDefinition,
14
+ type AutomationStatus,
15
+ type CreateAutomationInput,
16
+ type UpdateAutomationInput,
17
+ } from "@checkstack/automation-common";
18
+
19
+ import { automations } from "./schema";
20
+ import type { LoadedAutomation } from "./dispatch/types";
21
+
22
+ type Schema = { automations: typeof automations };
23
+
24
+ export interface AutomationStore {
25
+ create(input: CreateAutomationInput): Promise<Automation>;
26
+ update(input: UpdateAutomationInput): Promise<Automation>;
27
+ delete(id: string): Promise<void>;
28
+ toggle(id: string, enabled: boolean): Promise<Automation>;
29
+ getById(id: string): Promise<Automation | undefined>;
30
+ list(filter?: {
31
+ status?: AutomationStatus;
32
+ limit?: number;
33
+ offset?: number;
34
+ }): Promise<{ items: Automation[]; total: number }>;
35
+
36
+ /**
37
+ * Enabled automations that reference the given trigger event id in one
38
+ * of their trigger declarations. Used by the trigger fan-in to fan an
39
+ * incoming event out to the right automations.
40
+ */
41
+ findEnabledByTriggerEvent(eventId: string): Promise<LoadedAutomation[]>;
42
+
43
+ /**
44
+ * All enabled automations — used at boot to set up setup-backed
45
+ * triggers (cron, interval, …).
46
+ */
47
+ listEnabled(): Promise<LoadedAutomation[]>;
48
+ }
49
+
50
+ function mapToAutomation(
51
+ row: typeof automations.$inferSelect,
52
+ ): Automation {
53
+ const definition = AutomationDefinitionSchema.parse(row.definition);
54
+ return {
55
+ id: row.id,
56
+ name: row.name,
57
+ description: row.description ?? undefined,
58
+ status: row.status === "disabled" ? "disabled" : "enabled",
59
+ definition,
60
+ managedBy: row.managedBy ?? undefined,
61
+ createdAt: row.createdAt,
62
+ updatedAt: row.updatedAt,
63
+ };
64
+ }
65
+
66
+ function mapToLoaded(
67
+ row: typeof automations.$inferSelect,
68
+ ): LoadedAutomation {
69
+ return {
70
+ id: row.id,
71
+ name: row.name,
72
+ status: row.status === "disabled" ? "disabled" : "enabled",
73
+ definition: AutomationDefinitionSchema.parse(row.definition),
74
+ };
75
+ }
76
+
77
+ export function createAutomationStore(
78
+ db: SafeDatabase<Schema>,
79
+ ): AutomationStore {
80
+ return {
81
+ async create(input) {
82
+ const parsedDefinition = AutomationDefinitionSchema.parse(
83
+ input.definition,
84
+ );
85
+ const [row] = await db
86
+ .insert(automations)
87
+ .values({
88
+ name: input.name,
89
+ description: input.description ?? null,
90
+ status: input.status,
91
+ definition: parsedDefinition as unknown as Record<string, unknown>,
92
+ })
93
+ .returning();
94
+ if (!row) throw new Error("create: insert returned no rows");
95
+ return mapToAutomation(row);
96
+ },
97
+
98
+ async update(input) {
99
+ const existing = await this.getById(input.id);
100
+ if (!existing) throw new Error(`Automation ${input.id} not found`);
101
+
102
+ const set: Record<string, unknown> = { updatedAt: new Date() };
103
+ if (input.name !== undefined) set.name = input.name;
104
+ if (input.description !== undefined)
105
+ set.description = input.description ?? null;
106
+ if (input.status !== undefined) set.status = input.status;
107
+ if (input.definition !== undefined) {
108
+ const parsed = AutomationDefinitionSchema.parse(input.definition);
109
+ set.definition = parsed as unknown as Record<string, unknown>;
110
+ }
111
+
112
+ const [row] = await db
113
+ .update(automations)
114
+ .set(set)
115
+ .where(eq(automations.id, input.id))
116
+ .returning();
117
+ if (!row) throw new Error(`update: row ${input.id} not found after update`);
118
+ return mapToAutomation(row);
119
+ },
120
+
121
+ async delete(id) {
122
+ await db.delete(automations).where(eq(automations.id, id));
123
+ },
124
+
125
+ async toggle(id, enabled) {
126
+ const status: AutomationStatus = enabled ? "enabled" : "disabled";
127
+ const [row] = await db
128
+ .update(automations)
129
+ .set({ status, updatedAt: new Date() })
130
+ .where(eq(automations.id, id))
131
+ .returning();
132
+ if (!row) throw new Error(`Automation ${id} not found`);
133
+ return mapToAutomation(row);
134
+ },
135
+
136
+ async getById(id) {
137
+ const rows = await db
138
+ .select()
139
+ .from(automations)
140
+ .where(eq(automations.id, id))
141
+ .limit(1);
142
+ const row = rows[0];
143
+ return row ? mapToAutomation(row) : undefined;
144
+ },
145
+
146
+ async list(filter = {}) {
147
+ const limit = filter.limit ?? 50;
148
+ const offset = filter.offset ?? 0;
149
+ const whereExpr = filter.status
150
+ ? eq(automations.status, filter.status)
151
+ : undefined;
152
+
153
+ const rows = whereExpr
154
+ ? await db
155
+ .select()
156
+ .from(automations)
157
+ .where(whereExpr)
158
+ .limit(limit)
159
+ .offset(offset)
160
+ : await db
161
+ .select()
162
+ .from(automations)
163
+ .limit(limit)
164
+ .offset(offset);
165
+
166
+ const countRows = whereExpr
167
+ ? await db
168
+ .select({ c: sql<number>`count(*)::int` })
169
+ .from(automations)
170
+ .where(whereExpr)
171
+ : await db
172
+ .select({ c: sql<number>`count(*)::int` })
173
+ .from(automations);
174
+
175
+ return {
176
+ items: rows.map((r) => mapToAutomation(r)),
177
+ total: countRows[0]?.c ?? 0,
178
+ };
179
+ },
180
+
181
+ async findEnabledByTriggerEvent(eventId) {
182
+ // The `definition.triggers` array is queried via JSONB containment.
183
+ // We can't index the inner JSON cheaply without a generated column;
184
+ // for v1 we filter in-memory across all enabled rows. Volume is
185
+ // expected to be in the dozens or low hundreds.
186
+ const rows = await db
187
+ .select()
188
+ .from(automations)
189
+ .where(eq(automations.status, "enabled"));
190
+
191
+ const matching: LoadedAutomation[] = [];
192
+ for (const row of rows) {
193
+ const parsed = AutomationDefinitionSchema.safeParse(row.definition);
194
+ if (!parsed.success) continue;
195
+ const defn: AutomationDefinition = parsed.data;
196
+ if (defn.triggers.some((t) => t.event === eventId)) {
197
+ matching.push({
198
+ id: row.id,
199
+ name: row.name,
200
+ status: "enabled",
201
+ definition: defn,
202
+ });
203
+ }
204
+ }
205
+ return matching;
206
+ },
207
+
208
+ async listEnabled() {
209
+ const rows = await db
210
+ .select()
211
+ .from(automations)
212
+ .where(eq(automations.status, "enabled"));
213
+ const result: LoadedAutomation[] = [];
214
+ for (const row of rows) {
215
+ const parsed = AutomationDefinitionSchema.safeParse(row.definition);
216
+ if (parsed.success) {
217
+ result.push(mapToLoaded({ ...row, definition: parsed.data as unknown as Record<string, unknown> }));
218
+ }
219
+ }
220
+ return result;
221
+ },
222
+ };
223
+ }
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;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Behaviour tests for the built-in `log` and `notify_user` actions.
3
+ */
4
+ import { describe, expect, it, mock } from "bun:test";
5
+ import type { Logger, RpcClient } from "@checkstack/backend-api";
6
+ import { createMockLogger } from "@checkstack/test-utils-backend";
7
+
8
+ import {
9
+ createNotifyUserAction,
10
+ logAction,
11
+ notifyUserArtifactType,
12
+ type NotifyUserArtifact,
13
+ } from "./builtin-actions";
14
+
15
+ const baseLogger = createMockLogger();
16
+ const logger = baseLogger as Logger;
17
+
18
+ const ctxBase = {
19
+ runId: "run-1",
20
+ automationId: "auto-1",
21
+ contextKey: null,
22
+ logger,
23
+ getService: async <T,>(): Promise<T> => {
24
+ throw new Error("not used");
25
+ },
26
+ };
27
+
28
+ describe("automation.log", () => {
29
+ it("forwards the message to the run logger at the requested level", async () => {
30
+ const localLogger = createMockLogger();
31
+ const result = await logAction.execute({
32
+ ...ctxBase,
33
+ logger: localLogger as Logger,
34
+ consumedArtifacts: {},
35
+ config: { message: "hello", level: "warn" } as never,
36
+ });
37
+ expect(result.success).toBe(true);
38
+ expect(localLogger.warn).toHaveBeenCalledTimes(1);
39
+ const call = (localLogger.warn as unknown as {
40
+ mock: { calls: unknown[][] };
41
+ }).mock.calls[0];
42
+ expect(call?.[0]).toContain("hello");
43
+ });
44
+
45
+ it("defaults to level=info when not provided (after zod parsing)", async () => {
46
+ const localLogger = createMockLogger();
47
+ // The dispatch engine validates config before calling execute,
48
+ // so the schema's default has already kicked in by the time the
49
+ // action runs. Simulate that by passing `level: "info"`.
50
+ const result = await logAction.execute({
51
+ ...ctxBase,
52
+ logger: localLogger as Logger,
53
+ consumedArtifacts: {},
54
+ config: { message: "hi", level: "info" } as never,
55
+ });
56
+ expect(result.success).toBe(true);
57
+ expect(localLogger.info).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it("returns success without an artifact", async () => {
61
+ const result = await logAction.execute({
62
+ ...ctxBase,
63
+ consumedArtifacts: {},
64
+ config: { message: "x", level: "info" } as never,
65
+ });
66
+ expect(result.success).toBe(true);
67
+ expect(result.artifact).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ interface SendResult {
72
+ deliveredCount: number;
73
+ results: Array<{ strategyId: string; success: boolean; error?: string }>;
74
+ }
75
+
76
+ function makeRpcClient(
77
+ behaviour:
78
+ | { ok: true; result: SendResult }
79
+ | { ok: false; error: Error },
80
+ ): RpcClient & { sendMock: ReturnType<typeof mock> } {
81
+ const sendMock = mock(async (_input: unknown) => {
82
+ if (!behaviour.ok) throw behaviour.error;
83
+ return behaviour.result;
84
+ });
85
+ return {
86
+ forPlugin: () => ({ sendTransactional: sendMock }),
87
+ sendMock,
88
+ } as unknown as RpcClient & { sendMock: ReturnType<typeof mock> };
89
+ }
90
+
91
+ describe("automation.notify_user", () => {
92
+ it("delegates to sendTransactional and emits a per-strategy artifact", async () => {
93
+ const rpcClient = makeRpcClient({
94
+ ok: true,
95
+ result: {
96
+ deliveredCount: 1,
97
+ results: [{ strategyId: "email.smtp", success: true }],
98
+ },
99
+ });
100
+ const action = createNotifyUserAction({ rpcClient });
101
+ const result = await action.execute({
102
+ ...ctxBase,
103
+ consumedArtifacts: {},
104
+ config: {
105
+ userId: "user-1",
106
+ title: "Hello",
107
+ body: "World",
108
+ } as never,
109
+ });
110
+ expect(result.success).toBe(true);
111
+ if (!result.success) return;
112
+ expect(result.externalId).toBe("user-1");
113
+ const artifact = result.artifact as NotifyUserArtifact;
114
+ expect(artifact.userId).toBe("user-1");
115
+ expect(artifact.deliveredCount).toBe(1);
116
+ expect(artifact.results).toHaveLength(1);
117
+
118
+ const call = rpcClient.sendMock.mock.calls[0]![0] as {
119
+ userId: string;
120
+ notification: { title: string; body: string };
121
+ };
122
+ expect(call.userId).toBe("user-1");
123
+ expect(call.notification.title).toBe("Hello");
124
+ expect(call.notification.body).toBe("World");
125
+ });
126
+
127
+ it("returns success=false when no strategy delivers but still emits the artifact for audit", async () => {
128
+ const rpcClient = makeRpcClient({
129
+ ok: true,
130
+ result: { deliveredCount: 0, results: [] },
131
+ });
132
+ const action = createNotifyUserAction({ rpcClient });
133
+ const result = await action.execute({
134
+ ...ctxBase,
135
+ consumedArtifacts: {},
136
+ config: {
137
+ userId: "user-1",
138
+ title: "Hi",
139
+ body: "Hello",
140
+ } as never,
141
+ });
142
+ expect(result.success).toBe(false);
143
+ if (result.success) return;
144
+ expect(result.artifact).toBeDefined();
145
+ });
146
+
147
+ it("returns failure when sendTransactional throws", async () => {
148
+ const rpcClient = makeRpcClient({
149
+ ok: false,
150
+ error: new Error("rpc down"),
151
+ });
152
+ const action = createNotifyUserAction({ rpcClient });
153
+ const result = await action.execute({
154
+ ...ctxBase,
155
+ consumedArtifacts: {},
156
+ config: {
157
+ userId: "user-1",
158
+ title: "Hi",
159
+ body: "Hello",
160
+ } as never,
161
+ });
162
+ expect(result.success).toBe(false);
163
+ if (result.success) return;
164
+ expect(result.error).toMatch(/rpc down/);
165
+ });
166
+ });
167
+
168
+ describe("notifyUserArtifactType", () => {
169
+ it("validates the canonical artifact shape", () => {
170
+ const ok = notifyUserArtifactType.schema.safeParse({
171
+ userId: "user-1",
172
+ deliveredCount: 2,
173
+ results: [{ strategyId: "email.smtp", success: true }],
174
+ });
175
+ expect(ok.success).toBe(true);
176
+ });
177
+
178
+ it("rejects when deliveredCount is missing", () => {
179
+ const bad = notifyUserArtifactType.schema.safeParse({
180
+ userId: "user-1",
181
+ results: [],
182
+ });
183
+ expect(bad.success).toBe(false);
184
+ });
185
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Built-in actions shipped by automation-backend itself.
3
+ *
4
+ * Two actions, intentionally minimal:
5
+ *
6
+ * - `log` — write a single line to the run logger. No artifact, no
7
+ * external delivery. The cheapest "I want to see something happened
8
+ * here" primitive, useful inside `choose`/`parallel` branches as a
9
+ * no-op placeholder until the operator wires the real action.
10
+ *
11
+ * - `notify_user` — send a transactional notification to a specific
12
+ * user via the existing service-mode `NotificationApi.sendTransactional`
13
+ * RPC. Same delivery path as the integration plugin's
14
+ * `notification.send`; this one ships in the core automation
15
+ * catalog so an operator gets a built-in "notify a user" action
16
+ * even on a minimal install where the notification integration
17
+ * plugin hasn't been added.
18
+ */
19
+ import { z } from "zod";
20
+ import { Versioned, type RpcClient } from "@checkstack/backend-api";
21
+ import { extractErrorMessage } from "@checkstack/common";
22
+ import { NotificationApi } from "@checkstack/notification-common";
23
+
24
+ import type { ActionDefinition } from "./action-types";
25
+
26
+ // ─── log ───────────────────────────────────────────────────────────────
27
+
28
+ const logConfigSchema = z.object({
29
+ message: z.string().min(1).describe("Message to write to the run log"),
30
+ level: z
31
+ .enum(["debug", "info", "warn", "error"])
32
+ .default("info")
33
+ .describe("Log level — surfaces with the same severity in the run-detail UI"),
34
+ });
35
+
36
+ export type LogConfig = z.infer<typeof logConfigSchema>;
37
+
38
+ export const logAction: ActionDefinition<LogConfig, undefined> = {
39
+ id: "log",
40
+ displayName: "Log Message",
41
+ description: "Write a single line to the automation run's log",
42
+ category: "Built-in",
43
+ icon: "FileText",
44
+ config: new Versioned({ version: 1, schema: logConfigSchema }),
45
+ execute: async ({ config, logger }) => {
46
+ logger[config.level](`[automation.log] ${config.message}`);
47
+ return { success: true };
48
+ },
49
+ };
50
+
51
+ // ─── notify_user ───────────────────────────────────────────────────────
52
+
53
+ const notifyUserConfigSchema = z.object({
54
+ userId: z.string().min(1).describe("Target user to notify"),
55
+ title: z.string().min(1).describe("Notification title"),
56
+ body: z.string().min(1).describe("Notification body (supports markdown)"),
57
+ importance: z
58
+ .enum(["info", "warning", "critical"])
59
+ .optional()
60
+ .describe("Severity; defaults to 'info'"),
61
+ });
62
+
63
+ export type NotifyUserConfig = z.infer<typeof notifyUserConfigSchema>;
64
+
65
+ const notifyUserArtifactSchema = z.object({
66
+ userId: z.string(),
67
+ deliveredCount: z.number(),
68
+ results: z.array(
69
+ z.object({
70
+ strategyId: z.string(),
71
+ success: z.boolean(),
72
+ error: z.string().optional(),
73
+ }),
74
+ ),
75
+ });
76
+
77
+ export type NotifyUserArtifact = z.infer<typeof notifyUserArtifactSchema>;
78
+
79
+ export const notifyUserArtifactType = {
80
+ id: "notify_user_result",
81
+ displayName: "Notify User Result",
82
+ description:
83
+ "Per-strategy outcome from a built-in `notify_user` automation action",
84
+ schema: notifyUserArtifactSchema,
85
+ } as const;
86
+
87
+ export interface BuiltinActionDeps {
88
+ rpcClient: RpcClient;
89
+ }
90
+
91
+ export function createNotifyUserAction(
92
+ deps: BuiltinActionDeps,
93
+ ): ActionDefinition<NotifyUserConfig, NotifyUserArtifact> {
94
+ return {
95
+ id: "notify_user",
96
+ displayName: "Notify User",
97
+ description: "Send a transactional notification to a specific user",
98
+ category: "Built-in",
99
+ icon: "BellRing",
100
+ config: new Versioned({ version: 1, schema: notifyUserConfigSchema }),
101
+ produces: "automation.notify_user_result",
102
+ execute: async ({ config, logger }) => {
103
+ const notificationClient = deps.rpcClient.forPlugin(NotificationApi);
104
+ try {
105
+ const result = await notificationClient.sendTransactional({
106
+ userId: config.userId,
107
+ notification: {
108
+ title: config.title,
109
+ body: config.body,
110
+ importance: config.importance,
111
+ },
112
+ });
113
+ logger.info(
114
+ `notify_user → ${config.userId} (${result.deliveredCount} delivered)`,
115
+ );
116
+ return {
117
+ success: result.deliveredCount > 0,
118
+ externalId: config.userId,
119
+ artifact: {
120
+ userId: config.userId,
121
+ deliveredCount: result.deliveredCount,
122
+ results: result.results,
123
+ },
124
+ };
125
+ } catch (error) {
126
+ const message = extractErrorMessage(error);
127
+ logger.error(`notify_user failed: ${message}`);
128
+ return { success: false, error: message };
129
+ }
130
+ },
131
+ };
132
+ }