@decocms/runtime 1.2.15 → 1.3.1
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/package.json +4 -2
- package/src/tools.ts +178 -118
- package/src/trigger-storage.ts +195 -0
- package/src/triggers.test.ts +411 -0
- package/src/triggers.ts +307 -0
- package/src/workflows.ts +50 -11
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createTriggers, type TriggerStorage } from "./triggers.ts";
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint: test mocks don't need full type compliance
|
|
6
|
+
const mockCtx = (connectionId?: string) =>
|
|
7
|
+
({
|
|
8
|
+
env: connectionId
|
|
9
|
+
? { MESH_REQUEST_CONTEXT: { connectionId } }
|
|
10
|
+
: { MESH_REQUEST_CONTEXT: {} },
|
|
11
|
+
ctx: { waitUntil: () => {} },
|
|
12
|
+
}) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
13
|
+
|
|
14
|
+
const triggers = createTriggers([
|
|
15
|
+
{
|
|
16
|
+
type: "github.push",
|
|
17
|
+
description: "Triggered when code is pushed",
|
|
18
|
+
params: z.object({
|
|
19
|
+
repo: z.string().describe("Repository full name (owner/repo)"),
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: "github.pull_request.opened",
|
|
24
|
+
description: "Triggered when a PR is opened",
|
|
25
|
+
params: z.object({
|
|
26
|
+
repo: z.string().describe("Repository full name"),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
describe("createTriggers", () => {
|
|
32
|
+
it("tools() returns TRIGGER_LIST and TRIGGER_CONFIGURE", () => {
|
|
33
|
+
const tools = triggers.tools();
|
|
34
|
+
expect(tools).toHaveLength(2);
|
|
35
|
+
expect(tools[0].id).toBe("TRIGGER_LIST");
|
|
36
|
+
expect(tools[1].id).toBe("TRIGGER_CONFIGURE");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("TRIGGER_LIST returns trigger definitions with paramsSchema", async () => {
|
|
40
|
+
const listTool = triggers.tools()[0];
|
|
41
|
+
const result = (await listTool.execute({
|
|
42
|
+
context: {},
|
|
43
|
+
runtimeContext: mockCtx(),
|
|
44
|
+
})) as {
|
|
45
|
+
triggers: Array<{ type: string; paramsSchema: Record<string, unknown> }>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
expect(result.triggers).toHaveLength(2);
|
|
49
|
+
expect(result.triggers[0].type).toBe("github.push");
|
|
50
|
+
expect(result.triggers[0].paramsSchema).toEqual({
|
|
51
|
+
repo: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Repository full name (owner/repo)",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
expect(result.triggers[1].type).toBe("github.pull_request.opened");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("TRIGGER_LIST includes enum values from z.enum params", async () => {
|
|
60
|
+
const enumTriggers = createTriggers([
|
|
61
|
+
{
|
|
62
|
+
type: "test.event",
|
|
63
|
+
description: "Test",
|
|
64
|
+
params: z.object({
|
|
65
|
+
action: z.enum(["opened", "closed", "merged"]).describe("PR action"),
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
const listTool = enumTriggers.tools()[0];
|
|
70
|
+
const result = (await listTool.execute({
|
|
71
|
+
context: {},
|
|
72
|
+
runtimeContext: mockCtx(),
|
|
73
|
+
})) as {
|
|
74
|
+
triggers: Array<{ paramsSchema: Record<string, { enum?: string[] }> }>;
|
|
75
|
+
};
|
|
76
|
+
expect(result.triggers[0].paramsSchema.action.enum).toEqual([
|
|
77
|
+
"opened",
|
|
78
|
+
"closed",
|
|
79
|
+
"merged",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("TRIGGER_CONFIGURE stores callback credentials and notify delivers", async () => {
|
|
84
|
+
const configureTool = triggers.tools()[1];
|
|
85
|
+
|
|
86
|
+
const mockResponse = new Response("ok", { status: 202 });
|
|
87
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse);
|
|
88
|
+
|
|
89
|
+
// Configure a trigger with callback
|
|
90
|
+
await configureTool.execute({
|
|
91
|
+
context: {
|
|
92
|
+
type: "github.push",
|
|
93
|
+
params: { repo: "owner/repo" },
|
|
94
|
+
enabled: true,
|
|
95
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
96
|
+
callbackToken: "test-token-123",
|
|
97
|
+
},
|
|
98
|
+
runtimeContext: mockCtx("conn-1"),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Notify should POST to the callback URL
|
|
102
|
+
triggers.notify("conn-1", "github.push", {
|
|
103
|
+
repository: { full_name: "owner/repo" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Wait for the fire-and-forget fetch
|
|
107
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
108
|
+
|
|
109
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
110
|
+
"https://mesh.example.com/api/trigger-callback",
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
Authorization: "Bearer test-token-123",
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const callBody = JSON.parse(
|
|
121
|
+
(fetchSpy.mock.calls[0][1] as RequestInit).body as string,
|
|
122
|
+
);
|
|
123
|
+
expect(callBody.type).toBe("github.push");
|
|
124
|
+
expect(callBody.data.repository.full_name).toBe("owner/repo");
|
|
125
|
+
|
|
126
|
+
fetchSpy.mockRestore();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("disabling one trigger keeps credentials when another is still active", async () => {
|
|
130
|
+
const configureTool = triggers.tools()[1];
|
|
131
|
+
|
|
132
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
133
|
+
new Response("ok", { status: 202 }),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Enable two trigger types
|
|
137
|
+
await configureTool.execute({
|
|
138
|
+
context: {
|
|
139
|
+
type: "github.push",
|
|
140
|
+
params: {},
|
|
141
|
+
enabled: true,
|
|
142
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
143
|
+
callbackToken: "token-multi",
|
|
144
|
+
},
|
|
145
|
+
runtimeContext: mockCtx("conn-multi"),
|
|
146
|
+
});
|
|
147
|
+
await configureTool.execute({
|
|
148
|
+
context: {
|
|
149
|
+
type: "github.pull_request.opened",
|
|
150
|
+
params: {},
|
|
151
|
+
enabled: true,
|
|
152
|
+
},
|
|
153
|
+
runtimeContext: mockCtx("conn-multi"),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Disable one — credentials should stay for the other
|
|
157
|
+
await configureTool.execute({
|
|
158
|
+
context: {
|
|
159
|
+
type: "github.push",
|
|
160
|
+
params: {},
|
|
161
|
+
enabled: false,
|
|
162
|
+
},
|
|
163
|
+
runtimeContext: mockCtx("conn-multi"),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
triggers.notify("conn-multi", "github.pull_request.opened", {});
|
|
167
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
168
|
+
expect(fetchSpy).toHaveBeenCalled();
|
|
169
|
+
|
|
170
|
+
fetchSpy.mockRestore();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("disabling the last trigger clears credentials", async () => {
|
|
174
|
+
const configureTool = triggers.tools()[1];
|
|
175
|
+
|
|
176
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
177
|
+
new Response("ok", { status: 202 }),
|
|
178
|
+
);
|
|
179
|
+
const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
180
|
+
|
|
181
|
+
// Enable a trigger
|
|
182
|
+
await configureTool.execute({
|
|
183
|
+
context: {
|
|
184
|
+
type: "github.push",
|
|
185
|
+
params: {},
|
|
186
|
+
enabled: true,
|
|
187
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
188
|
+
callbackToken: "token-cleanup",
|
|
189
|
+
},
|
|
190
|
+
runtimeContext: mockCtx("conn-cleanup"),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Disable it — last trigger, credentials should be cleared
|
|
194
|
+
await configureTool.execute({
|
|
195
|
+
context: {
|
|
196
|
+
type: "github.push",
|
|
197
|
+
params: {},
|
|
198
|
+
enabled: false,
|
|
199
|
+
},
|
|
200
|
+
runtimeContext: mockCtx("conn-cleanup"),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
triggers.notify("conn-cleanup", "github.push", {});
|
|
204
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
205
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
206
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
207
|
+
expect.stringContaining("No callback credentials"),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
fetchSpy.mockRestore();
|
|
211
|
+
consoleSpy.mockRestore();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("notify is a no-op when no credentials exist", async () => {
|
|
215
|
+
const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
216
|
+
triggers.notify("unknown-conn", "github.push", {});
|
|
217
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
218
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
219
|
+
expect.stringContaining("No callback credentials"),
|
|
220
|
+
);
|
|
221
|
+
consoleSpy.mockRestore();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("notify logs error on non-2xx response", async () => {
|
|
225
|
+
const configureTool = triggers.tools()[1];
|
|
226
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
227
|
+
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
|
228
|
+
);
|
|
229
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
230
|
+
|
|
231
|
+
// Ensure credentials exist (reuse from prior test state or set up fresh)
|
|
232
|
+
await configureTool.execute({
|
|
233
|
+
context: {
|
|
234
|
+
type: "github.push",
|
|
235
|
+
params: {},
|
|
236
|
+
enabled: true,
|
|
237
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
238
|
+
callbackToken: "token-err",
|
|
239
|
+
},
|
|
240
|
+
runtimeContext: mockCtx("conn-err"),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
fetchSpy.mockResolvedValue(
|
|
244
|
+
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
triggers.notify("conn-err", "github.push", {});
|
|
248
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
249
|
+
|
|
250
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
251
|
+
expect.stringContaining("Callback delivery failed"),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
fetchSpy.mockRestore();
|
|
255
|
+
errorSpy.mockRestore();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("TRIGGER_CONFIGURE throws without connectionId", async () => {
|
|
259
|
+
const configureTool = triggers.tools()[1];
|
|
260
|
+
expect(
|
|
261
|
+
configureTool.execute({
|
|
262
|
+
context: { type: "github.push", params: {}, enabled: true },
|
|
263
|
+
runtimeContext: mockCtx(),
|
|
264
|
+
}),
|
|
265
|
+
).rejects.toThrow("Connection ID not available");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("createTriggers with storage", () => {
|
|
270
|
+
function createMockStorage(): TriggerStorage & {
|
|
271
|
+
data: Map<string, unknown>;
|
|
272
|
+
} {
|
|
273
|
+
const data = new Map<string, unknown>();
|
|
274
|
+
return {
|
|
275
|
+
data,
|
|
276
|
+
get: async (id) => (data.get(id) as any) ?? null,
|
|
277
|
+
set: async (id, state) => {
|
|
278
|
+
data.set(id, state);
|
|
279
|
+
},
|
|
280
|
+
delete: async (id) => {
|
|
281
|
+
data.delete(id);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const defs = [
|
|
287
|
+
{
|
|
288
|
+
type: "github.push" as const,
|
|
289
|
+
description: "Push",
|
|
290
|
+
params: z.object({
|
|
291
|
+
repo: z.string().describe("Repo"),
|
|
292
|
+
}),
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
it("persists trigger state to storage on configure", async () => {
|
|
297
|
+
const storage = createMockStorage();
|
|
298
|
+
const t = createTriggers({ definitions: defs, storage });
|
|
299
|
+
const configureTool = t.tools()[1];
|
|
300
|
+
|
|
301
|
+
await configureTool.execute({
|
|
302
|
+
context: {
|
|
303
|
+
type: "github.push",
|
|
304
|
+
params: {},
|
|
305
|
+
enabled: true,
|
|
306
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
307
|
+
callbackToken: "persisted-token",
|
|
308
|
+
},
|
|
309
|
+
runtimeContext: mockCtx("conn-persist"),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(storage.data.has("conn-persist")).toBe(true);
|
|
313
|
+
const stored = storage.data.get("conn-persist") as any;
|
|
314
|
+
expect(stored.credentials.callbackToken).toBe("persisted-token");
|
|
315
|
+
expect(stored.activeTriggerTypes).toEqual(["github.push"]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("deletes from storage when last trigger is disabled", async () => {
|
|
319
|
+
const storage = createMockStorage();
|
|
320
|
+
const t = createTriggers({ definitions: defs, storage });
|
|
321
|
+
const configureTool = t.tools()[1];
|
|
322
|
+
|
|
323
|
+
await configureTool.execute({
|
|
324
|
+
context: {
|
|
325
|
+
type: "github.push",
|
|
326
|
+
params: {},
|
|
327
|
+
enabled: true,
|
|
328
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
329
|
+
callbackToken: "to-delete",
|
|
330
|
+
},
|
|
331
|
+
runtimeContext: mockCtx("conn-del"),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(storage.data.has("conn-del")).toBe(true);
|
|
335
|
+
|
|
336
|
+
await configureTool.execute({
|
|
337
|
+
context: { type: "github.push", params: {}, enabled: false },
|
|
338
|
+
runtimeContext: mockCtx("conn-del"),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(storage.data.has("conn-del")).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("restores credentials from storage on notify after restart", async () => {
|
|
345
|
+
const storage = createMockStorage();
|
|
346
|
+
|
|
347
|
+
// Simulate prior session: write state directly to storage
|
|
348
|
+
storage.data.set("conn-restart", {
|
|
349
|
+
credentials: {
|
|
350
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
351
|
+
callbackToken: "restored-token",
|
|
352
|
+
},
|
|
353
|
+
activeTriggerTypes: ["github.push"],
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// New instance (simulates restart) — in-memory cache is empty
|
|
357
|
+
const t = createTriggers({ definitions: defs, storage });
|
|
358
|
+
|
|
359
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
360
|
+
new Response("ok", { status: 202 }),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
t.notify("conn-restart", "github.push", { test: true });
|
|
364
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
365
|
+
|
|
366
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
367
|
+
"https://mesh.example.com/api/trigger-callback",
|
|
368
|
+
expect.objectContaining({
|
|
369
|
+
headers: expect.objectContaining({
|
|
370
|
+
Authorization: "Bearer restored-token",
|
|
371
|
+
}),
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
fetchSpy.mockRestore();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("disable after restart clears persisted credentials from storage", async () => {
|
|
379
|
+
const storage = createMockStorage();
|
|
380
|
+
|
|
381
|
+
// Simulate prior session
|
|
382
|
+
storage.data.set("conn-disable-restart", {
|
|
383
|
+
credentials: {
|
|
384
|
+
callbackUrl: "https://mesh.example.com/api/trigger-callback",
|
|
385
|
+
callbackToken: "stale-token",
|
|
386
|
+
},
|
|
387
|
+
activeTriggerTypes: ["github.push"],
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// New instance (simulates restart)
|
|
391
|
+
const t = createTriggers({ definitions: defs, storage });
|
|
392
|
+
const configureTool = t.tools()[1];
|
|
393
|
+
|
|
394
|
+
// Disable the trigger — should load from storage, then clean up
|
|
395
|
+
await configureTool.execute({
|
|
396
|
+
context: { type: "github.push", params: {}, enabled: false },
|
|
397
|
+
runtimeContext: mockCtx("conn-disable-restart"),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(storage.data.has("conn-disable-restart")).toBe(false);
|
|
401
|
+
|
|
402
|
+
// notify should be a no-op
|
|
403
|
+
const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
404
|
+
t.notify("conn-disable-restart", "github.push", {});
|
|
405
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
406
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
407
|
+
expect.stringContaining("No callback credentials"),
|
|
408
|
+
);
|
|
409
|
+
consoleSpy.mockRestore();
|
|
410
|
+
});
|
|
411
|
+
});
|
package/src/triggers.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TriggerConfigureInputSchema,
|
|
3
|
+
TriggerListOutputSchema,
|
|
4
|
+
type TriggerDefinition,
|
|
5
|
+
} from "@decocms/bindings/trigger";
|
|
6
|
+
import { z, type ZodObject, type ZodRawShape } from "zod";
|
|
7
|
+
import type { DefaultEnv } from "./index.ts";
|
|
8
|
+
import { createTool, type CreatedTool } from "./tools.ts";
|
|
9
|
+
|
|
10
|
+
interface CallbackCredentials {
|
|
11
|
+
callbackUrl: string;
|
|
12
|
+
callbackToken: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TriggerState {
|
|
16
|
+
credentials: CallbackCredentials;
|
|
17
|
+
activeTriggerTypes: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Storage interface for persisting trigger state across MCP restarts.
|
|
22
|
+
*
|
|
23
|
+
* Implement this with your storage backend (KV, DB, file system, etc.)
|
|
24
|
+
* and pass it to `createTriggers({ storage })`.
|
|
25
|
+
*
|
|
26
|
+
* Keys are connection IDs, values are serializable trigger state objects.
|
|
27
|
+
*/
|
|
28
|
+
export interface TriggerStorage {
|
|
29
|
+
get(connectionId: string): Promise<TriggerState | null>;
|
|
30
|
+
set(connectionId: string, state: TriggerState): Promise<void>;
|
|
31
|
+
delete(connectionId: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TriggerDef<
|
|
35
|
+
TType extends string = string,
|
|
36
|
+
TParams extends ZodObject<ZodRawShape> = ZodObject<ZodRawShape>,
|
|
37
|
+
> {
|
|
38
|
+
type: TType;
|
|
39
|
+
description: string;
|
|
40
|
+
params: TParams;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TriggersOptions<TDefs extends TriggerDef[]> {
|
|
44
|
+
definitions: TDefs;
|
|
45
|
+
storage?: TriggerStorage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Triggers<TDefs extends TriggerDef[]> {
|
|
49
|
+
/**
|
|
50
|
+
* Returns TRIGGER_LIST and TRIGGER_CONFIGURE tools
|
|
51
|
+
* ready to be spread into your `withRuntime({ tools })` array.
|
|
52
|
+
*/
|
|
53
|
+
tools(): CreatedTool[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Notify Mesh that an event occurred.
|
|
57
|
+
* The SDK matches it to stored callback credentials and POSTs to Mesh.
|
|
58
|
+
* Fire-and-forget — errors are logged, not thrown.
|
|
59
|
+
*/
|
|
60
|
+
notify<T extends TDefs[number]["type"]>(
|
|
61
|
+
connectionId: string,
|
|
62
|
+
type: T,
|
|
63
|
+
data: Record<string, unknown>,
|
|
64
|
+
): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// In-memory cache backed by optional persistent storage
|
|
68
|
+
class TriggerStateManager {
|
|
69
|
+
private credentials = new Map<string, CallbackCredentials>();
|
|
70
|
+
private activeTriggers = new Map<string, Set<string>>();
|
|
71
|
+
private storage: TriggerStorage | null;
|
|
72
|
+
|
|
73
|
+
constructor(storage?: TriggerStorage) {
|
|
74
|
+
this.storage = storage ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getCredentials(connectionId: string): CallbackCredentials | undefined {
|
|
78
|
+
return this.credentials.get(connectionId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async loadFromStorage(connectionId: string): Promise<void> {
|
|
82
|
+
if (!this.storage || this.credentials.has(connectionId)) return;
|
|
83
|
+
const state = await this.storage.get(connectionId);
|
|
84
|
+
if (state) {
|
|
85
|
+
this.credentials.set(connectionId, state.credentials);
|
|
86
|
+
this.activeTriggers.set(connectionId, new Set(state.activeTriggerTypes));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async enable(
|
|
91
|
+
connectionId: string,
|
|
92
|
+
triggerType: string,
|
|
93
|
+
newCredentials?: CallbackCredentials,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (newCredentials) {
|
|
96
|
+
this.credentials.set(connectionId, newCredentials);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const types = this.activeTriggers.get(connectionId) ?? new Set();
|
|
100
|
+
types.add(triggerType);
|
|
101
|
+
this.activeTriggers.set(connectionId, types);
|
|
102
|
+
|
|
103
|
+
await this.persist(connectionId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async disable(connectionId: string, triggerType: string): Promise<void> {
|
|
107
|
+
// Ensure state is loaded (may be empty after restart)
|
|
108
|
+
await this.loadFromStorage(connectionId);
|
|
109
|
+
const types = this.activeTriggers.get(connectionId);
|
|
110
|
+
if (types) {
|
|
111
|
+
types.delete(triggerType);
|
|
112
|
+
if (types.size === 0) {
|
|
113
|
+
this.activeTriggers.delete(connectionId);
|
|
114
|
+
this.credentials.delete(connectionId);
|
|
115
|
+
await this.storage?.delete(connectionId);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await this.persist(connectionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async persist(connectionId: string): Promise<void> {
|
|
124
|
+
if (!this.storage) return;
|
|
125
|
+
const creds = this.credentials.get(connectionId);
|
|
126
|
+
const types = this.activeTriggers.get(connectionId);
|
|
127
|
+
if (!creds || !types || types.size === 0) return;
|
|
128
|
+
await this.storage.set(connectionId, {
|
|
129
|
+
credentials: creds,
|
|
130
|
+
activeTriggerTypes: [...types],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a trigger SDK for your MCP.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```typescript
|
|
140
|
+
* import { createTriggers } from "@decocms/runtime/triggers";
|
|
141
|
+
* import { z } from "zod";
|
|
142
|
+
*
|
|
143
|
+
* const triggers = createTriggers({
|
|
144
|
+
* definitions: [
|
|
145
|
+
* {
|
|
146
|
+
* type: "github.push",
|
|
147
|
+
* description: "Triggered when code is pushed to a repository",
|
|
148
|
+
* params: z.object({
|
|
149
|
+
* repo: z.string().describe("Repository full name (owner/repo)"),
|
|
150
|
+
* }),
|
|
151
|
+
* },
|
|
152
|
+
* ],
|
|
153
|
+
* // Optional: persist trigger state across restarts
|
|
154
|
+
* storage: myKVStorage,
|
|
155
|
+
* });
|
|
156
|
+
*
|
|
157
|
+
* // In withRuntime:
|
|
158
|
+
* export default withRuntime({
|
|
159
|
+
* tools: [() => triggers.tools()],
|
|
160
|
+
* });
|
|
161
|
+
*
|
|
162
|
+
* // In webhook handler:
|
|
163
|
+
* triggers.notify(connectionId, "github.push", payload);
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export function createTriggers<const TDefs extends TriggerDef[]>(
|
|
167
|
+
input: TDefs | TriggersOptions<TDefs>,
|
|
168
|
+
): Triggers<TDefs> {
|
|
169
|
+
const { definitions, storage } = Array.isArray(input)
|
|
170
|
+
? { definitions: input as TDefs, storage: undefined }
|
|
171
|
+
: input;
|
|
172
|
+
|
|
173
|
+
const state = new TriggerStateManager(storage);
|
|
174
|
+
|
|
175
|
+
const triggerDefinitions: TriggerDefinition[] = definitions.map((def) => {
|
|
176
|
+
const shape = def.params.shape;
|
|
177
|
+
const paramsSchema: Record<
|
|
178
|
+
string,
|
|
179
|
+
{ type: "string"; description?: string; enum?: string[] }
|
|
180
|
+
> = {};
|
|
181
|
+
|
|
182
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
183
|
+
const zodField = value as z.ZodTypeAny;
|
|
184
|
+
const entry: {
|
|
185
|
+
type: "string";
|
|
186
|
+
description?: string;
|
|
187
|
+
enum?: string[];
|
|
188
|
+
} = {
|
|
189
|
+
type: "string" as const,
|
|
190
|
+
description: zodField.description,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Extract enum values from z.enum() schemas
|
|
194
|
+
if ("options" in zodField && Array.isArray(zodField.options)) {
|
|
195
|
+
entry.enum = zodField.options as string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
paramsSchema[key] = entry;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
type: def.type,
|
|
203
|
+
description: def.description,
|
|
204
|
+
paramsSchema,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const TRIGGER_LIST = createTool({
|
|
209
|
+
id: "TRIGGER_LIST" as const,
|
|
210
|
+
description: "List available trigger definitions",
|
|
211
|
+
inputSchema: z.object({}),
|
|
212
|
+
outputSchema: TriggerListOutputSchema,
|
|
213
|
+
execute: async () => {
|
|
214
|
+
return { triggers: triggerDefinitions };
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const TRIGGER_CONFIGURE = createTool({
|
|
219
|
+
id: "TRIGGER_CONFIGURE" as const,
|
|
220
|
+
description: "Configure a trigger with parameters",
|
|
221
|
+
inputSchema: TriggerConfigureInputSchema,
|
|
222
|
+
outputSchema: z.object({ success: z.boolean() }),
|
|
223
|
+
execute: async ({ context, runtimeContext }) => {
|
|
224
|
+
const connectionId = (runtimeContext?.env as unknown as DefaultEnv)
|
|
225
|
+
?.MESH_REQUEST_CONTEXT?.connectionId;
|
|
226
|
+
|
|
227
|
+
if (!connectionId) {
|
|
228
|
+
throw new Error("Connection ID not available");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (context.enabled) {
|
|
232
|
+
const creds =
|
|
233
|
+
context.callbackUrl && context.callbackToken
|
|
234
|
+
? {
|
|
235
|
+
callbackUrl: context.callbackUrl,
|
|
236
|
+
callbackToken: context.callbackToken,
|
|
237
|
+
}
|
|
238
|
+
: undefined;
|
|
239
|
+
await state.enable(connectionId, context.type, creds);
|
|
240
|
+
} else {
|
|
241
|
+
await state.disable(connectionId, context.type);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { success: true };
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
tools() {
|
|
250
|
+
return [TRIGGER_LIST, TRIGGER_CONFIGURE] as CreatedTool[];
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
notify(connectionId, type, data) {
|
|
254
|
+
// Try in-memory first, fall back to storage load
|
|
255
|
+
const credentials = state.getCredentials(connectionId);
|
|
256
|
+
if (credentials) {
|
|
257
|
+
deliverCallback(credentials, type, data);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Attempt async load from storage (fire-and-forget)
|
|
262
|
+
state
|
|
263
|
+
.loadFromStorage(connectionId)
|
|
264
|
+
.then(() => {
|
|
265
|
+
const loaded = state.getCredentials(connectionId);
|
|
266
|
+
if (loaded) {
|
|
267
|
+
deliverCallback(loaded, type, data);
|
|
268
|
+
} else {
|
|
269
|
+
console.log(
|
|
270
|
+
`[Triggers] No callback credentials for connection=${connectionId}, skipping notify`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
.catch((err) => {
|
|
275
|
+
console.error(
|
|
276
|
+
`[Triggers] Failed to load credentials for ${connectionId}:`,
|
|
277
|
+
err,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function deliverCallback(
|
|
285
|
+
credentials: CallbackCredentials,
|
|
286
|
+
type: string,
|
|
287
|
+
data: Record<string, unknown>,
|
|
288
|
+
): void {
|
|
289
|
+
fetch(credentials.callbackUrl, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: {
|
|
292
|
+
"Content-Type": "application/json",
|
|
293
|
+
Authorization: `Bearer ${credentials.callbackToken}`,
|
|
294
|
+
},
|
|
295
|
+
body: JSON.stringify({ type, data }),
|
|
296
|
+
})
|
|
297
|
+
.then((res) => {
|
|
298
|
+
if (!res.ok) {
|
|
299
|
+
console.error(
|
|
300
|
+
`[Triggers] Callback delivery failed for ${type}: ${res.status} ${res.statusText}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
.catch((err) => {
|
|
305
|
+
console.error(`[Triggers] Failed to deliver callback for ${type}:`, err);
|
|
306
|
+
});
|
|
307
|
+
}
|