@decocms/runtime 1.2.14 → 1.3.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/package.json +4 -2
- package/src/decopilot.ts +1 -1
- package/src/tools.ts +0 -7
- package/src/trigger-storage.ts +195 -0
- package/src/triggers.test.ts +411 -0
- package/src/triggers.ts +307 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"check": "tsc --noEmit",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"./bindings": "./src/bindings/index.ts",
|
|
23
23
|
"./asset-server": "./src/asset-server/index.ts",
|
|
24
24
|
"./tools": "./src/tools.ts",
|
|
25
|
-
"./decopilot": "./src/decopilot.ts"
|
|
25
|
+
"./decopilot": "./src/decopilot.ts",
|
|
26
|
+
"./triggers": "./src/triggers.ts",
|
|
27
|
+
"./trigger-storage": "./src/trigger-storage.ts"
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
|
28
30
|
"ai": ">=6.0.0"
|
package/src/decopilot.ts
CHANGED
|
@@ -105,7 +105,7 @@ export async function streamAgent(
|
|
|
105
105
|
|
|
106
106
|
const credentialId = params.credentialId ?? config.credentialId;
|
|
107
107
|
const thinking = params.thinking ?? config.thinking;
|
|
108
|
-
const hasModels =
|
|
108
|
+
const hasModels = thinking?.id;
|
|
109
109
|
|
|
110
110
|
const request = {
|
|
111
111
|
messages: params.messages,
|
package/src/tools.ts
CHANGED
|
@@ -1050,9 +1050,6 @@ export const createMCPServer = <
|
|
|
1050
1050
|
|
|
1051
1051
|
// Only close transport for non-streaming responses
|
|
1052
1052
|
if (!isStreaming) {
|
|
1053
|
-
console.debug(
|
|
1054
|
-
"[MCP Transport] Closing transport for non-streaming response",
|
|
1055
|
-
);
|
|
1056
1053
|
try {
|
|
1057
1054
|
await transport.close?.();
|
|
1058
1055
|
} catch {
|
|
@@ -1063,10 +1060,6 @@ export const createMCPServer = <
|
|
|
1063
1060
|
return response;
|
|
1064
1061
|
} catch (error) {
|
|
1065
1062
|
// On error, always try to close transport to prevent leaks
|
|
1066
|
-
console.debug(
|
|
1067
|
-
"[MCP Transport] Closing transport due to error:",
|
|
1068
|
-
error instanceof Error ? error.message : error,
|
|
1069
|
-
);
|
|
1070
1063
|
try {
|
|
1071
1064
|
await transport.close?.();
|
|
1072
1065
|
} catch {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in TriggerStorage implementations.
|
|
3
|
+
*
|
|
4
|
+
* - StudioKV: Persists to Mesh/Studio's KV API (recommended for production)
|
|
5
|
+
* - JsonFileStorage: Persists to a local JSON file (for dev/simple deployments)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TriggerStorage } from "./triggers.ts";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// StudioKV — backed by Mesh's /api/kv endpoint
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
interface StudioKVOptions {
|
|
15
|
+
/** Mesh/Studio base URL (e.g., "https://studio.example.com") */
|
|
16
|
+
url: string;
|
|
17
|
+
/** API key created in the Studio org */
|
|
18
|
+
apiKey: string;
|
|
19
|
+
/** Key prefix to namespace trigger data (default: "triggers") */
|
|
20
|
+
prefix?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* TriggerStorage backed by Mesh/Studio's org-scoped KV API.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { createTriggers } from "@decocms/runtime/triggers";
|
|
29
|
+
* import { StudioKV } from "@decocms/runtime/trigger-storage";
|
|
30
|
+
*
|
|
31
|
+
* const triggers = createTriggers({
|
|
32
|
+
* definitions: [...],
|
|
33
|
+
* storage: new StudioKV({
|
|
34
|
+
* url: process.env.MESH_URL!,
|
|
35
|
+
* apiKey: process.env.MESH_API_KEY!,
|
|
36
|
+
* }),
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class StudioKV implements TriggerStorage {
|
|
41
|
+
private baseUrl: string;
|
|
42
|
+
private apiKey: string;
|
|
43
|
+
private prefix: string;
|
|
44
|
+
|
|
45
|
+
constructor(options: StudioKVOptions) {
|
|
46
|
+
this.baseUrl = options.url.replace(/\/$/, "");
|
|
47
|
+
this.apiKey = options.apiKey;
|
|
48
|
+
this.prefix = options.prefix ?? "triggers";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private key(connectionId: string): string {
|
|
52
|
+
return `${this.prefix}:${connectionId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async get(connectionId: string) {
|
|
56
|
+
const res = await fetch(
|
|
57
|
+
`${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
|
|
58
|
+
{
|
|
59
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (res.status === 404) return null;
|
|
64
|
+
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
console.error(`[StudioKV] GET failed: ${res.status} ${res.statusText}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const body = (await res.json()) as {
|
|
71
|
+
value?: {
|
|
72
|
+
credentials: { callbackUrl: string; callbackToken: string };
|
|
73
|
+
activeTriggerTypes: string[];
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
return body.value ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async set(
|
|
80
|
+
connectionId: string,
|
|
81
|
+
state: {
|
|
82
|
+
credentials: { callbackUrl: string; callbackToken: string };
|
|
83
|
+
activeTriggerTypes: string[];
|
|
84
|
+
},
|
|
85
|
+
) {
|
|
86
|
+
const res = await fetch(
|
|
87
|
+
`${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
|
|
88
|
+
{
|
|
89
|
+
method: "PUT",
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify(state),
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
console.error(`[StudioKV] PUT failed: ${res.status} ${res.statusText}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async delete(connectionId: string) {
|
|
104
|
+
const res = await fetch(
|
|
105
|
+
`${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
|
|
106
|
+
{
|
|
107
|
+
method: "DELETE",
|
|
108
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!res.ok && res.status !== 404) {
|
|
113
|
+
console.error(
|
|
114
|
+
`[StudioKV] DELETE failed: ${res.status} ${res.statusText}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// JsonFileStorage — backed by a local JSON file
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
interface JsonFileStorageOptions {
|
|
125
|
+
/** Path to the JSON file (will be created if it doesn't exist) */
|
|
126
|
+
path: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* TriggerStorage backed by a local JSON file.
|
|
131
|
+
* Suitable for development and single-instance deployments.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* import { createTriggers } from "@decocms/runtime/triggers";
|
|
136
|
+
* import { JsonFileStorage } from "@decocms/runtime/trigger-storage";
|
|
137
|
+
*
|
|
138
|
+
* const triggers = createTriggers({
|
|
139
|
+
* definitions: [...],
|
|
140
|
+
* storage: new JsonFileStorage({ path: "./trigger-state.json" }),
|
|
141
|
+
* });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export class JsonFileStorage implements TriggerStorage {
|
|
145
|
+
private path: string;
|
|
146
|
+
private cache: Map<string, unknown> | null = null;
|
|
147
|
+
|
|
148
|
+
constructor(options: JsonFileStorageOptions) {
|
|
149
|
+
this.path = options.path;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async load(): Promise<Map<string, unknown>> {
|
|
153
|
+
if (this.cache) return this.cache;
|
|
154
|
+
try {
|
|
155
|
+
const fs = await import("node:fs/promises");
|
|
156
|
+
const raw = await fs.readFile(this.path, "utf-8");
|
|
157
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
158
|
+
this.cache = new Map(Object.entries(data));
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
if (
|
|
161
|
+
err instanceof Error &&
|
|
162
|
+
"code" in err &&
|
|
163
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
164
|
+
) {
|
|
165
|
+
this.cache = new Map();
|
|
166
|
+
} else {
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return this.cache;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async save(): Promise<void> {
|
|
174
|
+
const data = Object.fromEntries(this.cache ?? new Map());
|
|
175
|
+
const fs = await import("node:fs/promises");
|
|
176
|
+
await fs.writeFile(this.path, JSON.stringify(data, null, 2));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async get(connectionId: string) {
|
|
180
|
+
const map = await this.load();
|
|
181
|
+
return (map.get(connectionId) as any) ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async set(connectionId: string, state: unknown) {
|
|
185
|
+
const map = await this.load();
|
|
186
|
+
map.set(connectionId, state);
|
|
187
|
+
await this.save();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async delete(connectionId: string) {
|
|
191
|
+
const map = await this.load();
|
|
192
|
+
map.delete(connectionId);
|
|
193
|
+
await this.save();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -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
|
+
}
|