@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,88 @@
1
+ import {
2
+ createExtensionPoint,
3
+ createServiceRef,
4
+ } from "@checkstack/backend-api";
5
+ import type { PluginMetadata } from "@checkstack/common";
6
+ import type {
7
+ ActionDefinition,
8
+ ArtifactTypeDefinition,
9
+ TriggerDefinition,
10
+ } from "./action-types";
11
+ import type { ActionRegistry } from "./action-registry";
12
+ import type { ArtifactTypeRegistry } from "./artifact-type-registry";
13
+ import type { TriggerRegistry } from "./trigger-registry";
14
+ import type { ArtifactStore } from "./artifact-store";
15
+
16
+ /**
17
+ * Extension point for registering automation triggers — entry points that
18
+ * fire automations when their underlying event arrives.
19
+ */
20
+ export interface AutomationTriggerExtensionPoint {
21
+ registerTrigger<TPayload, TConfig = void>(
22
+ definition: TriggerDefinition<TPayload, TConfig>,
23
+ pluginMetadata: PluginMetadata,
24
+ ): void;
25
+ }
26
+
27
+ export const automationTriggerExtensionPoint =
28
+ createExtensionPoint<AutomationTriggerExtensionPoint>(
29
+ "automation.triggerExtensionPoint",
30
+ );
31
+
32
+ /**
33
+ * Extension point for registering automation actions — callable work the
34
+ * automation editor exposes to operators.
35
+ */
36
+ export interface AutomationActionExtensionPoint {
37
+ registerAction<TConfig, TArtifact = unknown>(
38
+ definition: ActionDefinition<TConfig, TArtifact>,
39
+ pluginMetadata: PluginMetadata,
40
+ ): void;
41
+ }
42
+
43
+ export const automationActionExtensionPoint =
44
+ createExtensionPoint<AutomationActionExtensionPoint>(
45
+ "automation.actionExtensionPoint",
46
+ );
47
+
48
+ /**
49
+ * Extension point for registering artifact types — typed payloads
50
+ * produced and consumed by actions.
51
+ */
52
+ export interface AutomationArtifactTypeExtensionPoint {
53
+ registerArtifactType<T>(
54
+ definition: ArtifactTypeDefinition<T>,
55
+ pluginMetadata: PluginMetadata,
56
+ ): void;
57
+ }
58
+
59
+ export const automationArtifactTypeExtensionPoint =
60
+ createExtensionPoint<AutomationArtifactTypeExtensionPoint>(
61
+ "automation.artifactTypeExtensionPoint",
62
+ );
63
+
64
+ // ─── Service refs ─────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Read-only view of the trigger / action / artifact-type registries.
68
+ * Other plugins (and the frontend RPC) can inject this to introspect what
69
+ * the automation platform offers.
70
+ */
71
+ export interface AutomationRegistries {
72
+ readonly triggers: TriggerRegistry;
73
+ readonly actions: ActionRegistry;
74
+ readonly artifactTypes: ArtifactTypeRegistry;
75
+ }
76
+
77
+ export const automationRegistriesRef = createServiceRef<AutomationRegistries>(
78
+ "automation.registries",
79
+ );
80
+
81
+ /**
82
+ * Service ref for the artifact store. Cross-plugin code (e.g. an action
83
+ * in `integration-jira-backend` that wants to look up a prior issue
84
+ * artifact) injects this to query / persist artifacts.
85
+ */
86
+ export const automationArtifactStoreRef = createServiceRef<ArtifactStore>(
87
+ "automation.artifactStore",
88
+ );
package/src/index.ts ADDED
@@ -0,0 +1,379 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import {
6
+ automationAccess,
7
+ automationAccessRules,
8
+ automationContract,
9
+ automationRoutes,
10
+ pluginMetadata,
11
+ } from "@checkstack/automation-common";
12
+ import { resolveRoute, extractErrorMessage } from "@checkstack/common";
13
+ import type { PluginMetadata } from "@checkstack/common";
14
+ import { registerSearchProvider } from "@checkstack/command-backend";
15
+ import { createDefaultFilterRegistry } from "@checkstack/template-engine";
16
+
17
+ import type {
18
+ ActionDefinition,
19
+ ArtifactTypeDefinition,
20
+ TriggerDefinition,
21
+ } from "./action-types";
22
+ import { createTriggerRegistry, type TriggerRegistry } from "./trigger-registry";
23
+ import { createActionRegistry, type ActionRegistry } from "./action-registry";
24
+ import {
25
+ createArtifactTypeRegistry,
26
+ type ArtifactTypeRegistry,
27
+ } from "./artifact-type-registry";
28
+ import { createArtifactStore } from "./artifact-store";
29
+ import { createAutomationStore } from "./automation-store";
30
+ import { createAutomationRouter } from "./router";
31
+ import { runWebhookSubscriptionMigration } from "./migration/from-webhook-subscriptions";
32
+ import {
33
+ startDelayQueueConsumer,
34
+ type DelayQueueConsumer,
35
+ } from "./dispatch/delay-queue";
36
+ import { createRunStore } from "./dispatch/run-state";
37
+ import { createRunStateStore } from "./dispatch/run-state-store";
38
+ import {
39
+ startStalledSweeper,
40
+ type StalledSweeper,
41
+ } from "./dispatch/stalled-sweeper";
42
+ import {
43
+ setupTriggerSubscriptions,
44
+ type TriggerSubscriptions,
45
+ } from "./dispatch/trigger-subscriber";
46
+ import type { DispatchDeps } from "./dispatch/types";
47
+ import {
48
+ automationActionExtensionPoint,
49
+ automationArtifactStoreRef,
50
+ automationArtifactTypeExtensionPoint,
51
+ automationRegistriesRef,
52
+ automationTriggerExtensionPoint,
53
+ } from "./extension-points";
54
+ import {
55
+ registerBuiltinTriggerConsumer,
56
+ registerBuiltinTriggers,
57
+ } from "./builtin-triggers";
58
+ import {
59
+ createNotifyUserAction,
60
+ logAction,
61
+ notifyUserArtifactType,
62
+ } from "./builtin-actions";
63
+ import * as schema from "./schema";
64
+
65
+ /**
66
+ * Internal env stash used to thread registries / stores from `register()`
67
+ * and `init()` into `afterPluginsReady()`. Mirrors the established
68
+ * pattern in `integration-backend/src/index.ts`.
69
+ */
70
+ interface EnvStash {
71
+ triggerRegistry: TriggerRegistry;
72
+ actionRegistry: ActionRegistry;
73
+ artifactTypeRegistry: ArtifactTypeRegistry;
74
+ dispatchDeps: DispatchDeps;
75
+ automationStore: ReturnType<typeof createAutomationStore>;
76
+ triggerSubscriptions?: TriggerSubscriptions;
77
+ stalledSweeper?: StalledSweeper;
78
+ delayConsumer?: DelayQueueConsumer;
79
+ }
80
+
81
+ export default createBackendPlugin({
82
+ metadata: pluginMetadata,
83
+
84
+ register(env) {
85
+ const triggerRegistry = createTriggerRegistry();
86
+ const actionRegistry = createActionRegistry();
87
+ const artifactTypeRegistry = createArtifactTypeRegistry();
88
+
89
+ env.registerAccessRules(automationAccessRules);
90
+
91
+ env.registerExtensionPoint(automationTriggerExtensionPoint, {
92
+ registerTrigger: <TPayload, TConfig = void>(
93
+ definition: TriggerDefinition<TPayload, TConfig>,
94
+ metadata: PluginMetadata,
95
+ ) => {
96
+ triggerRegistry.register(
97
+ definition as TriggerDefinition<unknown, unknown>,
98
+ metadata,
99
+ );
100
+ },
101
+ });
102
+
103
+ env.registerExtensionPoint(automationActionExtensionPoint, {
104
+ registerAction: <TConfig, TArtifact = unknown>(
105
+ definition: ActionDefinition<TConfig, TArtifact>,
106
+ metadata: PluginMetadata,
107
+ ) => {
108
+ actionRegistry.register(
109
+ definition as ActionDefinition<unknown, unknown>,
110
+ metadata,
111
+ );
112
+ },
113
+ });
114
+
115
+ env.registerExtensionPoint(automationArtifactTypeExtensionPoint, {
116
+ registerArtifactType: <T>(
117
+ definition: ArtifactTypeDefinition<T>,
118
+ metadata: PluginMetadata,
119
+ ) => {
120
+ artifactTypeRegistry.register(
121
+ definition as ArtifactTypeDefinition<unknown>,
122
+ metadata,
123
+ );
124
+ },
125
+ });
126
+
127
+ env.registerInit({
128
+ schema,
129
+ deps: {
130
+ logger: coreServices.logger,
131
+ rpc: coreServices.rpc,
132
+ rpcClient: coreServices.rpcClient,
133
+ queueManager: coreServices.queueManager,
134
+ signalService: coreServices.signalService,
135
+ },
136
+ init: async ({
137
+ logger,
138
+ database,
139
+ rpc,
140
+ rpcClient,
141
+ queueManager,
142
+ signalService,
143
+ }) => {
144
+ logger.debug("⚙️ Initializing Automation Backend...");
145
+
146
+ const artifactStore = createArtifactStore(database);
147
+ const runStore = createRunStore(database);
148
+ const runStateStore = createRunStateStore(database);
149
+ const automationStore = createAutomationStore(database);
150
+
151
+ env.registerService(automationArtifactStoreRef, artifactStore);
152
+ env.registerService(automationRegistriesRef, {
153
+ triggers: triggerRegistry,
154
+ actions: actionRegistry,
155
+ artifactTypes: artifactTypeRegistry,
156
+ });
157
+
158
+ // ─── Built-in triggers + actions ──────────────────────────────
159
+ // automation-backend ships its own triggers/actions through the
160
+ // same registries it exposes to other plugins; the registration
161
+ // happens here in init() because the trigger setup needs
162
+ // `queueManager` and the `notify_user` action needs
163
+ // `rpcClient`. The shared consumer for built-in trigger ticks
164
+ // is started here too — by the time `setupTriggerSubscriptions`
165
+ // calls each trigger's `setup()` in afterPluginsReady, the
166
+ // consumer is already draining the queue.
167
+ registerBuiltinTriggers({
168
+ queueManager,
169
+ pluginMetadata,
170
+ registerTrigger: (trigger, metadata) => {
171
+ triggerRegistry.register(trigger, metadata);
172
+ },
173
+ });
174
+ actionRegistry.register(
175
+ logAction as ActionDefinition<unknown, unknown>,
176
+ pluginMetadata,
177
+ );
178
+ actionRegistry.register(
179
+ createNotifyUserAction({ rpcClient }) as ActionDefinition<
180
+ unknown,
181
+ unknown
182
+ >,
183
+ pluginMetadata,
184
+ );
185
+ artifactTypeRegistry.register(
186
+ notifyUserArtifactType as ArtifactTypeDefinition<unknown>,
187
+ pluginMetadata,
188
+ );
189
+ await registerBuiltinTriggerConsumer({ queueManager, logger });
190
+
191
+ const dispatchDeps: DispatchDeps = {
192
+ logger,
193
+ filters: createDefaultFilterRegistry(),
194
+ registries: {
195
+ triggers: triggerRegistry,
196
+ actions: actionRegistry,
197
+ artifactTypes: artifactTypeRegistry,
198
+ },
199
+ artifactStore,
200
+ runStore,
201
+ runStateStore,
202
+ queueManager,
203
+ getService: async () => {
204
+ throw new Error(
205
+ "getService not yet wired — automation dispatch invoked too early",
206
+ );
207
+ },
208
+ };
209
+
210
+ const stash = env as unknown as EnvStash;
211
+ stash.triggerRegistry = triggerRegistry;
212
+ stash.actionRegistry = actionRegistry;
213
+ stash.artifactTypeRegistry = artifactTypeRegistry;
214
+ stash.dispatchDeps = dispatchDeps;
215
+ stash.automationStore = automationStore;
216
+
217
+ const router = createAutomationRouter({
218
+ db: database,
219
+ automationStore,
220
+ triggerRegistry,
221
+ actionRegistry,
222
+ artifactTypeRegistry,
223
+ dispatchDeps,
224
+ signalService,
225
+ logger,
226
+ });
227
+ rpc.registerRouter(router, automationContract);
228
+
229
+ registerSearchProvider({
230
+ pluginMetadata,
231
+ commands: [
232
+ {
233
+ id: "list",
234
+ title: "Manage Automations",
235
+ subtitle: "View, edit, enable, or disable automations",
236
+ iconName: "Workflow",
237
+ shortcuts: ["meta+shift+a", "ctrl+shift+a"],
238
+ route: resolveRoute(automationRoutes.routes.list),
239
+ requiredAccessRules: [automationAccess.read],
240
+ },
241
+ {
242
+ id: "create",
243
+ title: "Create Automation",
244
+ subtitle: "Build a new automation from triggers and actions",
245
+ iconName: "Plus",
246
+ route: resolveRoute(automationRoutes.routes.create),
247
+ requiredAccessRules: [automationAccess.manage],
248
+ },
249
+ {
250
+ id: "playground",
251
+ title: "Template Playground",
252
+ subtitle: "Test automation templates against a sample payload",
253
+ iconName: "Beaker",
254
+ route: resolveRoute(automationRoutes.routes.playground),
255
+ requiredAccessRules: [automationAccess.read],
256
+ },
257
+ ],
258
+ });
259
+
260
+ env.registerCleanup(async () => {
261
+ const s = env as unknown as EnvStash;
262
+ await s.triggerSubscriptions?.dispose();
263
+ s.stalledSweeper?.stop();
264
+ await s.delayConsumer?.stop();
265
+ });
266
+
267
+ logger.debug("✅ Automation Backend initialized.");
268
+ },
269
+
270
+ afterPluginsReady: async ({ database, logger, onHook, rpcClient }) => {
271
+ const stash = env as unknown as EnvStash;
272
+ const triggers = stash.triggerRegistry.getTriggers();
273
+ const actions = stash.actionRegistry.getActions();
274
+ const artifactTypes = stash.artifactTypeRegistry.getArtifactTypes();
275
+
276
+ logger.debug(
277
+ `⚙️ Registered ${triggers.length} automation triggers${
278
+ triggers.length > 0
279
+ ? ": " + triggers.map((t) => t.qualifiedId).join(", ")
280
+ : ""
281
+ }`,
282
+ );
283
+ logger.debug(
284
+ `⚙️ Registered ${actions.length} automation actions${
285
+ actions.length > 0
286
+ ? ": " + actions.map((a) => a.qualifiedId).join(", ")
287
+ : ""
288
+ }`,
289
+ );
290
+ logger.debug(
291
+ `⚙️ Registered ${artifactTypes.length} artifact types${
292
+ artifactTypes.length > 0
293
+ ? ": " + artifactTypes.map((t) => t.qualifiedId).join(", ")
294
+ : ""
295
+ }`,
296
+ );
297
+
298
+ // Trigger fan-in: subscribe to every registered hook-backed
299
+ // trigger in work-queue mode; instantiate setup-backed triggers
300
+ // per referencing automation.
301
+ stash.triggerSubscriptions = await setupTriggerSubscriptions({
302
+ deps: stash.dispatchDeps,
303
+ onHook,
304
+ automationStore: stash.automationStore,
305
+ logger,
306
+ });
307
+
308
+ // Crash-safe delay: register the consumer that fires when a
309
+ // scheduled queue job pops, resuming the suspended run.
310
+ stash.delayConsumer = await startDelayQueueConsumer({
311
+ deps: stash.dispatchDeps,
312
+ automationStore: stash.automationStore,
313
+ logger,
314
+ });
315
+
316
+ // Restart safety + horizontal scaling: periodically scan for
317
+ // runs whose heartbeat is older than the threshold and resume
318
+ // them under an advisory lock.
319
+ stash.stalledSweeper = startStalledSweeper({
320
+ deps: stash.dispatchDeps,
321
+ automationStore: stash.automationStore,
322
+ logger,
323
+ });
324
+
325
+ // One-time migration: pull legacy `webhook_subscriptions` rows
326
+ // via the integration-backend service RPC and translate each
327
+ // into an automation. Already-migrated rows (matched on
328
+ // `managed_by`) are skipped, so this is safe to run on every
329
+ // boot. Failures are recorded for admin review via the
330
+ // `listMigrationFailures` RPC.
331
+ try {
332
+ await runWebhookSubscriptionMigration({
333
+ db: database,
334
+ rpcClient,
335
+ logger,
336
+ });
337
+ } catch (error) {
338
+ logger.error(
339
+ `Subscription migration failed unexpectedly: ${extractErrorMessage(error, "unknown error")}`,
340
+ );
341
+ }
342
+
343
+ logger.debug("✅ Automation Backend afterPluginsReady complete.");
344
+ },
345
+ });
346
+ },
347
+ });
348
+
349
+ // ─── Re-exports for consumer plugins ─────────────────────────────────────
350
+
351
+ export {
352
+ automationTriggerExtensionPoint,
353
+ automationActionExtensionPoint,
354
+ automationArtifactTypeExtensionPoint,
355
+ automationRegistriesRef,
356
+ automationArtifactStoreRef,
357
+ } from "./extension-points";
358
+
359
+ export type {
360
+ TriggerDefinition,
361
+ ActionDefinition,
362
+ ArtifactTypeDefinition,
363
+ ActionExecutionContext,
364
+ ActionRunScope,
365
+ ActionResult,
366
+ ArtifactTypeRef,
367
+ RegisteredTrigger,
368
+ RegisteredAction,
369
+ RegisteredArtifactType,
370
+ TriggerSetupFn,
371
+ TriggerTeardown,
372
+ } from "./action-types";
373
+
374
+ export type { ArtifactStore, PersistedArtifact } from "./artifact-store";
375
+ export type { TriggerRegistry } from "./trigger-registry";
376
+ export type { ActionRegistry } from "./action-registry";
377
+ export type { ArtifactTypeRegistry } from "./artifact-type-registry";
378
+ export type { AutomationRegistries } from "./extension-points";
379
+ export type { AutomationStore } from "./automation-store";
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Behaviour tests for the per-provider translation rules.
3
+ *
4
+ * The full `runWebhookSubscriptionMigration` path needs a database and
5
+ * an RPC client, so it's covered by the existing dispatch-engine
6
+ * integration fixtures (and ultimately by the smoke test). Here we
7
+ * lock down `buildDefinitionFor`, which is where the interesting
8
+ * mapping decisions live.
9
+ */
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ buildDefinitionFor,
13
+ type LegacySubscription,
14
+ } from "./from-webhook-subscriptions";
15
+
16
+ function sub(overrides: Partial<LegacySubscription> = {}): LegacySubscription {
17
+ return {
18
+ id: "sub-1",
19
+ name: "Test",
20
+ providerId: "integration-webhook.webhook",
21
+ providerConfig: { url: "https://example.com/hook" },
22
+ eventId: "incident.created",
23
+ enabled: true,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("buildDefinitionFor", () => {
29
+ it("returns a failure for unknown providers", () => {
30
+ const result = buildDefinitionFor(
31
+ sub({ providerId: "made-up.provider" }),
32
+ );
33
+ expect(result.ok).toBe(false);
34
+ if (!result.ok) {
35
+ expect(result.reason).toMatch(/unsupported provider/);
36
+ }
37
+ });
38
+
39
+ it("translates a Jira subscription to the jira.create_issue action", () => {
40
+ const result = buildDefinitionFor(
41
+ sub({
42
+ providerId: "integration-jira.jira",
43
+ providerConfig: {
44
+ connectionId: "conn-1",
45
+ projectKey: "PROJ",
46
+ issueTypeId: "10001",
47
+ summaryTemplate: "{{ trigger.payload.title }}",
48
+ descriptionTemplate: "Severity: {{ trigger.payload.severity }}",
49
+ priorityId: "3",
50
+ fieldMappings: [
51
+ { fieldKey: "customfield_1", template: "{{ trigger.payload.foo }}" },
52
+ ],
53
+ },
54
+ }),
55
+ );
56
+ expect(result.ok).toBe(true);
57
+ if (!result.ok) return;
58
+ expect(result.actionId).toBe("integration-jira.create_issue");
59
+ expect(result.definition.actions).toHaveLength(1);
60
+ const action = result.definition.actions[0] as { action: string; config: Record<string, unknown> };
61
+ expect(action.action).toBe("integration-jira.create_issue");
62
+ expect(action.config.summary).toBe("{{ trigger.payload.title }}");
63
+ expect(action.config.description).toBe(
64
+ "Severity: {{ trigger.payload.severity }}",
65
+ );
66
+ expect(action.config.fieldMappings).toEqual([
67
+ { fieldKey: "customfield_1", value: "{{ trigger.payload.foo }}" },
68
+ ]);
69
+ });
70
+
71
+ it("generates a default body for Teams when the legacy schema had no template", () => {
72
+ const result = buildDefinitionFor(
73
+ sub({
74
+ providerId: "integration-teams.teams",
75
+ providerConfig: {
76
+ connectionId: "conn-1",
77
+ teamId: "team-1",
78
+ channelId: "ch-1",
79
+ },
80
+ }),
81
+ );
82
+ expect(result.ok).toBe(true);
83
+ if (!result.ok) return;
84
+ const action = result.definition.actions[0] as { config: Record<string, unknown> };
85
+ expect(typeof action.config.body).toBe("string");
86
+ expect((action.config.body as string).length).toBeGreaterThan(0);
87
+ });
88
+
89
+ it("preserves an explicit Webex messageTemplate", () => {
90
+ const result = buildDefinitionFor(
91
+ sub({
92
+ providerId: "integration-webex.webex",
93
+ providerConfig: {
94
+ connectionId: "conn-1",
95
+ roomId: "room-1",
96
+ messageTemplate: "Custom: {{ trigger.payload.title }}",
97
+ },
98
+ }),
99
+ );
100
+ expect(result.ok).toBe(true);
101
+ if (!result.ok) return;
102
+ const action = result.definition.actions[0] as { config: Record<string, unknown> };
103
+ expect(action.config.markdown).toBe("Custom: {{ trigger.payload.title }}");
104
+ });
105
+
106
+ it("falls back to a default Webex markdown when messageTemplate is empty", () => {
107
+ const result = buildDefinitionFor(
108
+ sub({
109
+ providerId: "integration-webex.webex",
110
+ providerConfig: {
111
+ connectionId: "conn-1",
112
+ roomId: "room-1",
113
+ },
114
+ }),
115
+ );
116
+ expect(result.ok).toBe(true);
117
+ if (!result.ok) return;
118
+ const action = result.definition.actions[0] as { config: Record<string, unknown> };
119
+ expect(typeof action.config.markdown).toBe("string");
120
+ expect((action.config.markdown as string).length).toBeGreaterThan(0);
121
+ });
122
+
123
+ it("renames webhook bodyTemplate to body", () => {
124
+ const result = buildDefinitionFor(
125
+ sub({
126
+ providerId: "integration-webhook.webhook",
127
+ providerConfig: {
128
+ url: "https://example.com/hook",
129
+ method: "POST",
130
+ contentType: "application/json",
131
+ authType: "none",
132
+ timeout: 10_000,
133
+ bodyTemplate: `{"x":"{{ trigger.payload.x }}"}`,
134
+ },
135
+ }),
136
+ );
137
+ expect(result.ok).toBe(true);
138
+ if (!result.ok) return;
139
+ const action = result.definition.actions[0] as { config: Record<string, unknown> };
140
+ expect(action.config.body).toBe(`{"x":"{{ trigger.payload.x }}"}`);
141
+ expect(action.config.bodyTemplate).toBeUndefined();
142
+ });
143
+
144
+ it("maps the legacy shell provider to integration-script.run_shell and preserves workingDirectory", () => {
145
+ const result = buildDefinitionFor(
146
+ sub({
147
+ providerId: "integration-script.shell",
148
+ providerConfig: {
149
+ script: "echo hi",
150
+ timeout: 10_000,
151
+ workingDirectory: "/tmp",
152
+ },
153
+ }),
154
+ );
155
+ expect(result.ok).toBe(true);
156
+ if (!result.ok) return;
157
+ expect(result.actionId).toBe("integration-script.run_shell");
158
+ const action = result.definition.actions[0] as {
159
+ config: Record<string, unknown>;
160
+ };
161
+ expect(action.config.script).toBe("echo hi");
162
+ expect(action.config.workingDirectory).toBe("/tmp");
163
+ });
164
+
165
+ it("maps the legacy TS-script provider to integration-script.run_script and drops shell-only fields", () => {
166
+ const result = buildDefinitionFor(
167
+ sub({
168
+ providerId: "integration-script.script",
169
+ providerConfig: {
170
+ script: "export default async () => ({ id: 'x' });",
171
+ timeout: 10_000,
172
+ // Legacy data may still have a stray workingDirectory — it
173
+ // doesn't apply to the ESM runner, so the migration drops it
174
+ // rather than silently feeding it to an action that ignores it.
175
+ workingDirectory: "/tmp",
176
+ },
177
+ }),
178
+ );
179
+ expect(result.ok).toBe(true);
180
+ if (!result.ok) return;
181
+ expect(result.actionId).toBe("integration-script.run_script");
182
+ const action = result.definition.actions[0] as {
183
+ config: Record<string, unknown>;
184
+ };
185
+ expect(action.config.script).toBe(
186
+ "export default async () => ({ id: 'x' });",
187
+ );
188
+ expect(action.config).not.toHaveProperty("workingDirectory");
189
+ });
190
+
191
+ it("emits an OR condition for systemFilter with multiple ids", () => {
192
+ const result = buildDefinitionFor(
193
+ sub({
194
+ systemFilter: ["sys-1", "sys-2"],
195
+ }),
196
+ );
197
+ expect(result.ok).toBe(true);
198
+ if (!result.ok) return;
199
+ expect(result.definition.conditions).toHaveLength(1);
200
+ const cond = result.definition.conditions[0] as { or: string[] };
201
+ expect(cond.or).toHaveLength(2);
202
+ expect(cond.or[0]).toContain("sys-1");
203
+ });
204
+
205
+ it("emits a single template condition for a single-id systemFilter", () => {
206
+ const result = buildDefinitionFor(
207
+ sub({ systemFilter: ["sys-only"] }),
208
+ );
209
+ expect(result.ok).toBe(true);
210
+ if (!result.ok) return;
211
+ expect(result.definition.conditions).toHaveLength(1);
212
+ expect(typeof result.definition.conditions[0]).toBe("string");
213
+ expect(result.definition.conditions[0]).toContain("sys-only");
214
+ });
215
+
216
+ it("emits no conditions when systemFilter is empty", () => {
217
+ const result = buildDefinitionFor(sub({ systemFilter: [] }));
218
+ expect(result.ok).toBe(true);
219
+ if (!result.ok) return;
220
+ expect(result.definition.conditions).toEqual([]);
221
+ });
222
+
223
+ it("preserves the description and trigger event id", () => {
224
+ const result = buildDefinitionFor(
225
+ sub({
226
+ description: "On-call paging",
227
+ eventId: "incident.incident.created",
228
+ }),
229
+ );
230
+ expect(result.ok).toBe(true);
231
+ if (!result.ok) return;
232
+ expect(result.definition.description).toBe("On-call paging");
233
+ expect(result.definition.triggers[0]?.event).toBe(
234
+ "incident.incident.created",
235
+ );
236
+ });
237
+ });