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