@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,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
|
+
}
|