@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,398 @@
1
+ /**
2
+ * One-time migration from the legacy `webhook_subscriptions` table to
3
+ * automations.
4
+ *
5
+ * Strategy:
6
+ * 1. Pull the legacy rows via `IntegrationApi.listLegacySubscriptions`
7
+ * (the only cross-plugin path — the table lives in another
8
+ * plugin's schema and `SafeDatabase` blocks direct queries).
9
+ * 2. For each row, build a single-trigger / single-action automation
10
+ * and INSERT it with `managed_by = "migrated-subscription:<id>"`.
11
+ * 3. Skip rows whose `managed_by` automation already exists — that
12
+ * makes the migration idempotent across restarts.
13
+ * 4. Any row that doesn't translate cleanly is recorded in
14
+ * `automation_migration_failures` so an admin can fix it via the
15
+ * RPC + UI.
16
+ *
17
+ * The migration runs on `afterPluginsReady` so the integration RPC is
18
+ * ready and all triggers/actions have been registered.
19
+ */
20
+ import { eq } from "drizzle-orm";
21
+ import type {
22
+ Logger,
23
+ SafeDatabase,
24
+ RpcClient,
25
+ } from "@checkstack/backend-api";
26
+ import { IntegrationApi } from "@checkstack/integration-common";
27
+ import {
28
+ AutomationDefinitionSchema,
29
+ type AutomationDefinition,
30
+ type ConditionInput,
31
+ } from "@checkstack/automation-common";
32
+ import { extractErrorMessage } from "@checkstack/common";
33
+
34
+ import * as schema from "../schema";
35
+
36
+ type Db = SafeDatabase<typeof schema>;
37
+
38
+ export interface LegacySubscription {
39
+ id: string;
40
+ name: string;
41
+ description?: string;
42
+ providerId: string;
43
+ providerConfig: Record<string, unknown>;
44
+ eventId: string;
45
+ systemFilter?: string[];
46
+ enabled: boolean;
47
+ }
48
+
49
+ interface MigrationResult {
50
+ total: number;
51
+ migrated: number;
52
+ skipped: number;
53
+ failed: number;
54
+ }
55
+
56
+ interface MigrationFailureRecord {
57
+ subscriptionId: string;
58
+ subscriptionName: string;
59
+ providerId: string;
60
+ eventId: string;
61
+ reason: string;
62
+ detail?: string;
63
+ providerConfig: Record<string, unknown>;
64
+ }
65
+
66
+ /**
67
+ * Map a legacy `providerId` to the qualified id of the action that
68
+ * replaces it on the Automation Platform. Returns `undefined` when no
69
+ * known mapping exists (the row goes into the failure log).
70
+ */
71
+ function resolveActionId(providerId: string): string | undefined {
72
+ switch (providerId) {
73
+ case "integration-jira.jira": {
74
+ return "integration-jira.create_issue";
75
+ }
76
+ case "integration-teams.teams": {
77
+ return "integration-teams.post_message";
78
+ }
79
+ case "integration-webex.webex": {
80
+ return "integration-webex.post_message";
81
+ }
82
+ case "integration-webhook.webhook": {
83
+ return "integration-webhook.send";
84
+ }
85
+ case "integration-script.script": {
86
+ return "integration-script.run_script";
87
+ }
88
+ case "integration-script.shell": {
89
+ return "integration-script.run_shell";
90
+ }
91
+ default: {
92
+ return;
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Translate a legacy `providerConfig` to the new action's config
99
+ * shape. Returns `{ ok: false }` for known providers whose config
100
+ * can't be translated (e.g. required fields missing).
101
+ */
102
+ function translateProviderConfig(
103
+ providerId: string,
104
+ providerConfig: Record<string, unknown>,
105
+ ): { ok: true; config: Record<string, unknown> } | { ok: false; reason: string } {
106
+ switch (providerId) {
107
+ case "integration-jira.jira": {
108
+ const fieldMappings = Array.isArray(providerConfig.fieldMappings)
109
+ ? (providerConfig.fieldMappings as Array<Record<string, unknown>>).map(
110
+ (mapping) => ({
111
+ fieldKey: String(mapping.fieldKey ?? ""),
112
+ value: String(mapping.template ?? ""),
113
+ }),
114
+ )
115
+ : undefined;
116
+ return {
117
+ ok: true,
118
+ config: {
119
+ connectionId: providerConfig.connectionId,
120
+ projectKey: providerConfig.projectKey,
121
+ issueTypeId: providerConfig.issueTypeId,
122
+ summary: providerConfig.summaryTemplate,
123
+ description: providerConfig.descriptionTemplate,
124
+ priorityId: providerConfig.priorityId,
125
+ fieldMappings,
126
+ },
127
+ };
128
+ }
129
+ case "integration-teams.teams": {
130
+ // The legacy provider auto-generated an adaptive card from the
131
+ // event payload. The new action requires a `body` — generate a
132
+ // sensible default that dumps the trigger payload so existing
133
+ // subscriptions keep working until the operator edits the
134
+ // template.
135
+ return {
136
+ ok: true,
137
+ config: {
138
+ connectionId: providerConfig.connectionId,
139
+ teamId: providerConfig.teamId,
140
+ channelId: providerConfig.channelId,
141
+ title: "Automation event",
142
+ body: "Event: {{ trigger.event }}\n\nPayload:\n```json\n{{ trigger.payload | json(2) }}\n```",
143
+ },
144
+ };
145
+ }
146
+ case "integration-webex.webex": {
147
+ const markdown =
148
+ typeof providerConfig.messageTemplate === "string" &&
149
+ providerConfig.messageTemplate.length > 0
150
+ ? providerConfig.messageTemplate
151
+ : "**Automation event**\n\nEvent: {{ trigger.event }}\n\n```json\n{{ trigger.payload | json(2) }}\n```";
152
+ return {
153
+ ok: true,
154
+ config: {
155
+ connectionId: providerConfig.connectionId,
156
+ roomId: providerConfig.roomId,
157
+ markdown,
158
+ },
159
+ };
160
+ }
161
+ case "integration-webhook.webhook": {
162
+ // The webhook config is 1:1 — only renames the body field.
163
+ const { bodyTemplate, ...rest } = providerConfig;
164
+ return {
165
+ ok: true,
166
+ config: {
167
+ ...rest,
168
+ body: bodyTemplate,
169
+ },
170
+ };
171
+ }
172
+ case "integration-script.script": {
173
+ // Legacy TS-script provider config was `{ script, timeout }`;
174
+ // workingDirectory / env never applied to the subprocess runner.
175
+ return {
176
+ ok: true,
177
+ config: {
178
+ script: providerConfig.script,
179
+ timeout: providerConfig.timeout,
180
+ },
181
+ };
182
+ }
183
+ case "integration-script.shell": {
184
+ // Legacy shell provider exposed script + timeout + workingDirectory.
185
+ // The auto-flattened `PAYLOAD_*` env vars are gone — operators now
186
+ // reference template values directly in the script body.
187
+ return {
188
+ ok: true,
189
+ config: {
190
+ script: providerConfig.script,
191
+ timeout: providerConfig.timeout,
192
+ workingDirectory: providerConfig.workingDirectory,
193
+ },
194
+ };
195
+ }
196
+ default: {
197
+ return { ok: false, reason: `unknown provider ${providerId}` };
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Build a top-level condition that gates the automation on the
204
+ * subscription's `systemFilter`. Empty filter → no condition.
205
+ */
206
+ function buildSystemFilterCondition(
207
+ systemFilter: string[] | undefined,
208
+ ): ConditionInput[] {
209
+ if (!systemFilter || systemFilter.length === 0) return [];
210
+ const branches = systemFilter.map(
211
+ (systemId) => `{{ trigger.payload.systemId == "${systemId}" }}`,
212
+ );
213
+ if (branches.length === 1) return [branches[0]!];
214
+ return [{ or: branches }];
215
+ }
216
+
217
+ /**
218
+ * Translate one legacy subscription to an `AutomationDefinition`.
219
+ *
220
+ * Exported so the per-provider mapping rules can be tested in
221
+ * isolation without standing up a database + rpcClient.
222
+ */
223
+ export function buildDefinitionFor(
224
+ sub: LegacySubscription,
225
+ ):
226
+ | { ok: true; definition: AutomationDefinition; actionId: string }
227
+ | { ok: false; reason: string; detail?: string } {
228
+ const actionId = resolveActionId(sub.providerId);
229
+ if (!actionId) {
230
+ return {
231
+ ok: false,
232
+ reason: `unsupported provider`,
233
+ detail: `No automation action is registered for provider ${sub.providerId}`,
234
+ };
235
+ }
236
+ const translated = translateProviderConfig(sub.providerId, sub.providerConfig);
237
+ if (!translated.ok) {
238
+ return { ok: false, reason: translated.reason };
239
+ }
240
+ // Round-trip through the schema's `parse` to fill in defaults (Zod
241
+ // strict-output forces `enabled` / `continue_on_error` etc.). Using
242
+ // the input shape here avoids re-listing every base-action default.
243
+ const draft = {
244
+ name: sub.name,
245
+ description: sub.description,
246
+ triggers: [{ event: sub.eventId }],
247
+ conditions: buildSystemFilterCondition(sub.systemFilter),
248
+ actions: [
249
+ {
250
+ action: actionId,
251
+ config: translated.config,
252
+ },
253
+ ],
254
+ mode: "single",
255
+ max_runs: 10,
256
+ };
257
+ const parsed = AutomationDefinitionSchema.safeParse(draft);
258
+ if (!parsed.success) {
259
+ return {
260
+ ok: false,
261
+ reason: "definition validation failed",
262
+ detail: parsed.error.message,
263
+ };
264
+ }
265
+ return { ok: true, definition: parsed.data, actionId };
266
+ }
267
+
268
+ export interface RunMigrationArgs {
269
+ db: Db;
270
+ rpcClient: RpcClient;
271
+ logger: Logger;
272
+ }
273
+
274
+ /**
275
+ * Run the migration once. Idempotent — already-migrated subscriptions
276
+ * are detected via the `managed_by = "migrated-subscription:<id>"`
277
+ * marker on the automation row.
278
+ */
279
+ export async function runWebhookSubscriptionMigration(
280
+ args: RunMigrationArgs,
281
+ ): Promise<MigrationResult> {
282
+ const { db, rpcClient, logger } = args;
283
+ const integrationClient = rpcClient.forPlugin(IntegrationApi);
284
+
285
+ let subscriptions: LegacySubscription[];
286
+ try {
287
+ subscriptions = await integrationClient.listLegacySubscriptions();
288
+ } catch (error) {
289
+ logger.warn(
290
+ `migration: legacy subscription endpoint not reachable — skipping (${extractErrorMessage(error, "unknown error")})`,
291
+ );
292
+ return { total: 0, migrated: 0, skipped: 0, failed: 0 };
293
+ }
294
+
295
+ if (subscriptions.length === 0) {
296
+ logger.debug("migration: no legacy subscriptions to migrate");
297
+ return { total: 0, migrated: 0, skipped: 0, failed: 0 };
298
+ }
299
+
300
+ let migrated = 0;
301
+ let skipped = 0;
302
+ const failures: MigrationFailureRecord[] = [];
303
+
304
+ for (const sub of subscriptions) {
305
+ const managedBy = `migrated-subscription:${sub.id}`;
306
+ const existing = await db
307
+ .select({ id: schema.automations.id })
308
+ .from(schema.automations)
309
+ .where(eq(schema.automations.managedBy, managedBy))
310
+ .limit(1);
311
+ if (existing.length > 0) {
312
+ skipped += 1;
313
+ continue;
314
+ }
315
+
316
+ const built = buildDefinitionFor(sub);
317
+ if (!built.ok) {
318
+ failures.push({
319
+ subscriptionId: sub.id,
320
+ subscriptionName: sub.name,
321
+ providerId: sub.providerId,
322
+ eventId: sub.eventId,
323
+ reason: built.reason,
324
+ detail: built.detail,
325
+ providerConfig: sub.providerConfig,
326
+ });
327
+ continue;
328
+ }
329
+
330
+ try {
331
+ await db.insert(schema.automations).values({
332
+ name: sub.name,
333
+ description: sub.description ?? null,
334
+ status: sub.enabled ? "enabled" : "disabled",
335
+ definition: built.definition as unknown as Record<string, unknown>,
336
+ managedBy,
337
+ });
338
+ migrated += 1;
339
+ logger.info(
340
+ `migration: migrated subscription ${sub.id} → automation (${sub.name})`,
341
+ );
342
+ } catch (error) {
343
+ const detail = extractErrorMessage(error, "insert failed");
344
+ failures.push({
345
+ subscriptionId: sub.id,
346
+ subscriptionName: sub.name,
347
+ providerId: sub.providerId,
348
+ eventId: sub.eventId,
349
+ reason: "insert failed",
350
+ detail,
351
+ providerConfig: sub.providerConfig,
352
+ });
353
+ }
354
+ }
355
+
356
+ // Record failures so the admin RPC + UI can surface them.
357
+ for (const failure of failures) {
358
+ try {
359
+ await db
360
+ .insert(schema.automationMigrationFailures)
361
+ .values({
362
+ subscriptionId: failure.subscriptionId,
363
+ subscriptionName: failure.subscriptionName,
364
+ providerId: failure.providerId,
365
+ eventId: failure.eventId,
366
+ reason: failure.reason,
367
+ detail: failure.detail ?? null,
368
+ providerConfig: failure.providerConfig,
369
+ })
370
+ .onConflictDoUpdate({
371
+ target: schema.automationMigrationFailures.subscriptionId,
372
+ set: {
373
+ subscriptionName: failure.subscriptionName,
374
+ providerId: failure.providerId,
375
+ eventId: failure.eventId,
376
+ reason: failure.reason,
377
+ detail: failure.detail ?? null,
378
+ providerConfig: failure.providerConfig,
379
+ },
380
+ });
381
+ } catch (error) {
382
+ logger.error(
383
+ `migration: could not record failure for subscription ${failure.subscriptionId}: ${extractErrorMessage(error, "unknown error")}`,
384
+ );
385
+ }
386
+ }
387
+
388
+ const result: MigrationResult = {
389
+ total: subscriptions.length,
390
+ migrated,
391
+ skipped,
392
+ failed: failures.length,
393
+ };
394
+ logger.info(
395
+ `migration: complete — total=${result.total} migrated=${result.migrated} skipped=${result.skipped} failed=${result.failed}`,
396
+ );
397
+ return result;
398
+ }