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