@studio-foundation/api 0.3.0-beta.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/ARCHITECTURE.md +52 -0
- package/LICENSE +663 -0
- package/README.md +161 -0
- package/dist/api.d.ts +8 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +6 -0
- package/dist/api.js.map +1 -0
- package/dist/bootstrap.d.ts +66 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +193 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/event-bus.d.ts +16 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +37 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-runtime.d.ts +22 -0
- package/dist/integration-runtime.d.ts.map +1 -0
- package/dist/integration-runtime.js +150 -0
- package/dist/integration-runtime.js.map +1 -0
- package/dist/integration-store.d.ts +26 -0
- package/dist/integration-store.d.ts.map +1 -0
- package/dist/integration-store.js +80 -0
- package/dist/integration-store.js.map +1 -0
- package/dist/integrations/linear/failure-handler.d.ts +5 -0
- package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
- package/dist/integrations/linear/failure-handler.js +73 -0
- package/dist/integrations/linear/failure-handler.js.map +1 -0
- package/dist/integrations/linear/webhook-handler.d.ts +6 -0
- package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
- package/dist/integrations/linear/webhook-handler.js +131 -0
- package/dist/integrations/linear/webhook-handler.js.map +1 -0
- package/dist/integrations/registry.d.ts +4 -0
- package/dist/integrations/registry.d.ts.map +1 -0
- package/dist/integrations/registry.js +9 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/types.d.ts +33 -0
- package/dist/integrations/types.d.ts.map +1 -0
- package/dist/integrations/types.js +2 -0
- package/dist/integrations/types.js.map +1 -0
- package/dist/launcher.d.ts +45 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +149 -0
- package/dist/launcher.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/plans.d.ts +13 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +33 -0
- package/dist/plans.js.map +1 -0
- package/dist/routes/agents.d.ts +6 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +118 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/config.d.ts +6 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +129 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/contracts.d.ts +6 -0
- package/dist/routes/contracts.d.ts.map +1 -0
- package/dist/routes/contracts.js +188 -0
- package/dist/routes/contracts.js.map +1 -0
- package/dist/routes/pipelines.d.ts +6 -0
- package/dist/routes/pipelines.d.ts.map +1 -0
- package/dist/routes/pipelines.js +133 -0
- package/dist/routes/pipelines.js.map +1 -0
- package/dist/routes/projects.d.ts +6 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +220 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/runs.d.ts +6 -0
- package/dist/routes/runs.d.ts.map +1 -0
- package/dist/routes/runs.js +427 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +123 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/tools.d.ts +6 -0
- package/dist/routes/tools.d.ts.map +1 -0
- package/dist/routes/tools.js +193 -0
- package/dist/routes/tools.js.map +1 -0
- package/dist/routes/users.d.ts +6 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +152 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/routes/validate.d.ts +6 -0
- package/dist/routes/validate.d.ts.map +1 -0
- package/dist/routes/validate.js +171 -0
- package/dist/routes/validate.js.map +1 -0
- package/dist/routes/webhooks.d.ts +8 -0
- package/dist/routes/webhooks.d.ts.map +1 -0
- package/dist/routes/webhooks.js +102 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +101 -0
- package/dist/server.js.map +1 -0
- package/dist/spawners/http-api-spawner.d.ts +10 -0
- package/dist/spawners/http-api-spawner.d.ts.map +1 -0
- package/dist/spawners/http-api-spawner.js +88 -0
- package/dist/spawners/http-api-spawner.js.map +1 -0
- package/dist/user-store-pg.d.ts +21 -0
- package/dist/user-store-pg.d.ts.map +1 -0
- package/dist/user-store-pg.js +97 -0
- package/dist/user-store-pg.js.map +1 -0
- package/dist/user-store.d.ts +29 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +97 -0
- package/dist/user-store.js.map +1 -0
- package/dist/utils/repo-resolver.d.ts +3 -0
- package/dist/utils/repo-resolver.d.ts.map +1 -0
- package/dist/utils/repo-resolver.js +3 -0
- package/dist/utils/repo-resolver.js.map +1 -0
- package/dist/webhook-dispatcher.d.ts +13 -0
- package/dist/webhook-dispatcher.d.ts.map +1 -0
- package/dist/webhook-dispatcher.js +108 -0
- package/dist/webhook-dispatcher.js.map +1 -0
- package/dist/webhook-store.d.ts +31 -0
- package/dist/webhook-store.d.ts.map +1 -0
- package/dist/webhook-store.js +91 -0
- package/dist/webhook-store.js.map +1 -0
- package/package.json +59 -0
- package/src/.gitkeep +0 -0
- package/src/api.ts +8 -0
- package/src/bootstrap.ts +259 -0
- package/src/event-bus.ts +64 -0
- package/src/index.ts +58 -0
- package/src/integration-runtime.ts +180 -0
- package/src/integration-store.ts +125 -0
- package/src/integrations/linear/failure-handler.ts +93 -0
- package/src/integrations/linear/webhook-handler.ts +156 -0
- package/src/integrations/registry.ts +12 -0
- package/src/integrations/types.ts +37 -0
- package/src/launcher.ts +214 -0
- package/src/logger.ts +50 -0
- package/src/plans.ts +43 -0
- package/src/routes/agents.ts +131 -0
- package/src/routes/config.ts +154 -0
- package/src/routes/contracts.ts +205 -0
- package/src/routes/pipelines.ts +146 -0
- package/src/routes/projects.ts +237 -0
- package/src/routes/runs.ts +468 -0
- package/src/routes/skills.ts +136 -0
- package/src/routes/tools.ts +222 -0
- package/src/routes/users.ts +169 -0
- package/src/routes/validate.ts +196 -0
- package/src/routes/webhooks.ts +120 -0
- package/src/server.ts +155 -0
- package/src/spawners/http-api-spawner.ts +96 -0
- package/src/user-store-pg.ts +138 -0
- package/src/user-store.ts +125 -0
- package/src/utils/repo-resolver.ts +3 -0
- package/src/webhook-dispatcher.ts +142 -0
- package/src/webhook-store.ts +141 -0
- package/tests/agents.test.ts +164 -0
- package/tests/cancel.test.ts +120 -0
- package/tests/config.test.ts +196 -0
- package/tests/contracts.test.ts +302 -0
- package/tests/event-bus.test.ts +53 -0
- package/tests/http-api-spawner.test.ts +158 -0
- package/tests/integration-runtime.test.ts +199 -0
- package/tests/integration-store.test.ts +66 -0
- package/tests/integrations/linear/failure-handler.test.ts +113 -0
- package/tests/integrations/linear/webhook-handler.test.ts +191 -0
- package/tests/launcher.test.ts +380 -0
- package/tests/linear-webhook.test.ts +390 -0
- package/tests/pipelines.test.ts +166 -0
- package/tests/projects.test.ts +283 -0
- package/tests/runs.test.ts +379 -0
- package/tests/server.test.ts +208 -0
- package/tests/skills.test.ts +149 -0
- package/tests/sse.test.ts +129 -0
- package/tests/tools.test.ts +233 -0
- package/tests/user-store.test.ts +93 -0
- package/tests/users.test.ts +189 -0
- package/tests/utils/repo-resolver.test.ts +105 -0
- package/tests/validate.test.ts +233 -0
- package/tests/webhook-dispatcher.test.ts +214 -0
- package/tests/webhook-store.test.ts +98 -0
- package/tests/webhooks.test.ts +176 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { buildServer } from '../src/server.js';
|
|
5
|
+
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
6
|
+
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
7
|
+
import type { IntegrationStore } from '../src/integration-store.js';
|
|
8
|
+
|
|
9
|
+
const TMP = resolve('/tmp', `.studio-validate-test-${Date.now()}`);
|
|
10
|
+
|
|
11
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
12
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
13
|
+
|
|
14
|
+
function makeServer() {
|
|
15
|
+
return buildServer({
|
|
16
|
+
store: new InMemoryRunStore(),
|
|
17
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
18
|
+
configsDir: TMP,
|
|
19
|
+
projectName: 'test-project',
|
|
20
|
+
apiConfig: {},
|
|
21
|
+
studioVersion: '0.0.0-test',
|
|
22
|
+
maskedConfig: { providers: [] },
|
|
23
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
24
|
+
integrationStore: nullIntegrationStore,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
mkdirSync(resolve(TMP, 'agents'), { recursive: true });
|
|
30
|
+
mkdirSync(resolve(TMP, 'contracts'), { recursive: true });
|
|
31
|
+
mkdirSync(resolve(TMP, 'tools'), { recursive: true });
|
|
32
|
+
|
|
33
|
+
writeFileSync(resolve(TMP, 'agents', 'analyst.agent.yaml'), 'name: analyst\nmodel: claude-sonnet-4-20250514\n');
|
|
34
|
+
writeFileSync(resolve(TMP, 'contracts', 'brief-analysis.contract.yaml'), 'name: brief-analysis\nversion: 1\n');
|
|
35
|
+
writeFileSync(resolve(TMP, 'tools', 'my-tool.tool.yaml'), 'name: my-tool\n');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('POST /api/validate', () => {
|
|
43
|
+
it('returns valid=true for a pipeline with existing agent and contract', async () => {
|
|
44
|
+
const server = makeServer();
|
|
45
|
+
const res = await server.inject({
|
|
46
|
+
method: 'POST',
|
|
47
|
+
url: '/api/validate',
|
|
48
|
+
payload: {
|
|
49
|
+
type: 'pipeline',
|
|
50
|
+
name: 'feature-builder',
|
|
51
|
+
content: 'stages:\n - name: analysis\n agent: analyst\n contract: brief-analysis\n',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(res.statusCode).toBe(200);
|
|
55
|
+
const body = res.json() as { valid: boolean; errors: string[] };
|
|
56
|
+
expect(body.valid).toBe(true);
|
|
57
|
+
expect(body.errors).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns errors for a pipeline referencing a missing agent', async () => {
|
|
61
|
+
const server = makeServer();
|
|
62
|
+
const res = await server.inject({
|
|
63
|
+
method: 'POST',
|
|
64
|
+
url: '/api/validate',
|
|
65
|
+
payload: {
|
|
66
|
+
type: 'pipeline',
|
|
67
|
+
name: 'bad-pipeline',
|
|
68
|
+
content: 'stages:\n - name: s1\n agent: missing-agent\n contract: brief-analysis\n',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
expect(res.statusCode).toBe(200);
|
|
72
|
+
const body = res.json() as { valid: boolean; errors: string[] };
|
|
73
|
+
expect(body.valid).toBe(false);
|
|
74
|
+
expect(body.errors).toContain("Agent 'missing-agent' not found");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns errors for a pipeline referencing a missing contract', async () => {
|
|
78
|
+
const server = makeServer();
|
|
79
|
+
const res = await server.inject({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
url: '/api/validate',
|
|
82
|
+
payload: {
|
|
83
|
+
type: 'pipeline',
|
|
84
|
+
name: 'bad-pipeline',
|
|
85
|
+
content: 'stages:\n - name: s1\n agent: analyst\n contract: missing-contract\n',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(res.statusCode).toBe(200);
|
|
89
|
+
const body = res.json() as { valid: boolean; errors: string[] };
|
|
90
|
+
expect(body.valid).toBe(false);
|
|
91
|
+
expect(body.errors).toContain("Contract 'missing-contract' not found");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('validates group stages recursively', async () => {
|
|
95
|
+
const server = makeServer();
|
|
96
|
+
const res = await server.inject({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
url: '/api/validate',
|
|
99
|
+
payload: {
|
|
100
|
+
type: 'pipeline',
|
|
101
|
+
name: 'grouped',
|
|
102
|
+
content: `stages:\n - group: impl\n max_iterations: 3\n stages:\n - name: code\n agent: analyst\n contract: missing-contract\n`,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
expect(res.statusCode).toBe(200);
|
|
106
|
+
const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
|
|
107
|
+
expect(valid).toBe(false);
|
|
108
|
+
expect(errors).toContain("Contract 'missing-contract' not found");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns errors for agent with unknown tool', async () => {
|
|
112
|
+
const server = makeServer();
|
|
113
|
+
const res = await server.inject({
|
|
114
|
+
method: 'POST',
|
|
115
|
+
url: '/api/validate',
|
|
116
|
+
payload: {
|
|
117
|
+
type: 'agent',
|
|
118
|
+
name: 'coder',
|
|
119
|
+
content: 'name: coder\ntools:\n - repo_manager-write_file\n - ghost-tool\n',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
expect(res.statusCode).toBe(200);
|
|
123
|
+
const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
|
|
124
|
+
expect(valid).toBe(false);
|
|
125
|
+
expect(errors).toContain("Tool 'ghost-tool' not found");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('accepts agent with builtin tools', async () => {
|
|
129
|
+
const server = makeServer();
|
|
130
|
+
const res = await server.inject({
|
|
131
|
+
method: 'POST',
|
|
132
|
+
url: '/api/validate',
|
|
133
|
+
payload: {
|
|
134
|
+
type: 'agent',
|
|
135
|
+
name: 'coder',
|
|
136
|
+
content: 'name: coder\ntools:\n - repo_manager-write_file\n - shell-run_command\n',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const { valid } = res.json() as { valid: boolean; errors: string[] };
|
|
140
|
+
expect(valid).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('accepts agent with custom tools (exact plugin name)', async () => {
|
|
144
|
+
const server = makeServer();
|
|
145
|
+
const res = await server.inject({
|
|
146
|
+
method: 'POST',
|
|
147
|
+
url: '/api/validate',
|
|
148
|
+
payload: {
|
|
149
|
+
type: 'agent',
|
|
150
|
+
name: 'coder',
|
|
151
|
+
content: 'name: coder\ntools:\n - my-tool\n',
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const { valid } = res.json() as { valid: boolean; errors: string[] };
|
|
155
|
+
expect(valid).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('accepts agent with custom tool actions (plugin-action naming)', async () => {
|
|
159
|
+
const server = makeServer();
|
|
160
|
+
const res = await server.inject({
|
|
161
|
+
method: 'POST',
|
|
162
|
+
url: '/api/validate',
|
|
163
|
+
payload: {
|
|
164
|
+
type: 'agent',
|
|
165
|
+
name: 'coder',
|
|
166
|
+
content: 'name: coder\ntools:\n - my-tool-run\n',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const { valid } = res.json() as { valid: boolean; errors: string[] };
|
|
170
|
+
expect(valid).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns parse error for invalid YAML', async () => {
|
|
174
|
+
const server = makeServer();
|
|
175
|
+
const res = await server.inject({
|
|
176
|
+
method: 'POST',
|
|
177
|
+
url: '/api/validate',
|
|
178
|
+
payload: { type: 'contract', name: 'x', content: 'key: [unclosed' },
|
|
179
|
+
});
|
|
180
|
+
const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
|
|
181
|
+
expect(valid).toBe(false);
|
|
182
|
+
expect(errors[0]).toMatch(/YAML parse error/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('validates skill as valid when non-empty', async () => {
|
|
186
|
+
const server = makeServer();
|
|
187
|
+
const res = await server.inject({
|
|
188
|
+
method: 'POST',
|
|
189
|
+
url: '/api/validate',
|
|
190
|
+
payload: { type: 'skill', name: 'my-skill', content: '# My skill\nDo this thing.' },
|
|
191
|
+
});
|
|
192
|
+
const { valid } = res.json() as { valid: boolean; errors: string[] };
|
|
193
|
+
expect(valid).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('validates empty skill as invalid', async () => {
|
|
197
|
+
const server = makeServer();
|
|
198
|
+
const res = await server.inject({
|
|
199
|
+
method: 'POST',
|
|
200
|
+
url: '/api/validate',
|
|
201
|
+
payload: { type: 'skill', name: 'my-skill', content: ' ' },
|
|
202
|
+
});
|
|
203
|
+
const { valid } = res.json() as { valid: boolean; errors: string[] };
|
|
204
|
+
expect(valid).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns error when pipeline has no stages array', async () => {
|
|
208
|
+
const server = makeServer();
|
|
209
|
+
const res = await server.inject({
|
|
210
|
+
method: 'POST',
|
|
211
|
+
url: '/api/validate',
|
|
212
|
+
payload: { type: 'pipeline', name: 'p', content: 'name: my-pipeline\n' },
|
|
213
|
+
});
|
|
214
|
+
const { valid, errors } = res.json() as { valid: boolean; errors: string[] };
|
|
215
|
+
expect(valid).toBe(false);
|
|
216
|
+
expect(errors).toContain('Pipeline must have a "stages" array');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns valid=true for a well-formed contract', async () => {
|
|
220
|
+
const server = makeServer();
|
|
221
|
+
const res = await server.inject({
|
|
222
|
+
method: 'POST',
|
|
223
|
+
url: '/api/validate',
|
|
224
|
+
payload: {
|
|
225
|
+
type: 'contract',
|
|
226
|
+
name: 'c',
|
|
227
|
+
content: 'name: my-contract\nversion: 1\nschema:\n required_fields:\n - summary\n',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const { valid } = res.json() as { valid: boolean; errors: string[] };
|
|
231
|
+
expect(valid).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { WebhookStore } from '../src/webhook-store.js';
|
|
5
|
+
import { WebhookDispatcher } from '../src/webhook-dispatcher.js';
|
|
6
|
+
|
|
7
|
+
type FetchCall = { url: string; options: RequestInit };
|
|
8
|
+
|
|
9
|
+
function makeStore(): { store: WebhookStore; cleanup: () => void } {
|
|
10
|
+
const dir = resolve('/tmp', `.studio-dispatcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
const store = new WebhookStore(resolve(dir, 'runs.db'));
|
|
13
|
+
return { store, cleanup: () => store.close() };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeFetch(status = 200): { fetch: typeof fetch; calls: FetchCall[] } {
|
|
17
|
+
const calls: FetchCall[] = [];
|
|
18
|
+
const mockFetch = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
|
|
19
|
+
calls.push({ url: url.toString(), options: options ?? {} });
|
|
20
|
+
return new Response('OK', { status });
|
|
21
|
+
}) as unknown as typeof fetch;
|
|
22
|
+
return { fetch: mockFetch, calls };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('WebhookDispatcher', () => {
|
|
26
|
+
let store: WebhookStore;
|
|
27
|
+
let cleanup: () => void;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
({ store, cleanup } = makeStore());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('dispatches pipeline_complete to matching webhook', async () => {
|
|
38
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
39
|
+
const { fetch, calls } = makeFetch();
|
|
40
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
41
|
+
|
|
42
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', {
|
|
43
|
+
pipeline_name: 'feature-builder',
|
|
44
|
+
run_id: 'run-1',
|
|
45
|
+
status: 'success',
|
|
46
|
+
duration_ms: 1000,
|
|
47
|
+
total_tokens: 100,
|
|
48
|
+
total_tool_calls: 2,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(calls).toHaveLength(1);
|
|
52
|
+
expect(calls[0].url).toBe('https://hook.example.com/cb');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('does not dispatch when event type does not match webhook subscription', async () => {
|
|
56
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_failed'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
57
|
+
const { fetch, calls } = makeFetch();
|
|
58
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
59
|
+
|
|
60
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
61
|
+
|
|
62
|
+
expect(calls).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('includes X-Studio-Event header', async () => {
|
|
66
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
67
|
+
const { fetch, calls } = makeFetch();
|
|
68
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
69
|
+
|
|
70
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
71
|
+
|
|
72
|
+
const headers = calls[0].options.headers as Record<string, string>;
|
|
73
|
+
expect(headers['X-Studio-Event']).toBe('pipeline_complete');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('includes X-Studio-Signature header when webhook has a secret', async () => {
|
|
77
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], secret: 'mysecret', status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
78
|
+
const { fetch, calls } = makeFetch();
|
|
79
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
80
|
+
|
|
81
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
82
|
+
|
|
83
|
+
const headers = calls[0].options.headers as Record<string, string>;
|
|
84
|
+
expect(headers['X-Studio-Signature']).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('does not include X-Studio-Signature when no secret configured', async () => {
|
|
88
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
89
|
+
const { fetch, calls } = makeFetch();
|
|
90
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
91
|
+
|
|
92
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
93
|
+
|
|
94
|
+
const headers = calls[0].options.headers as Record<string, string>;
|
|
95
|
+
expect(headers['X-Studio-Signature']).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('payload contains run_id, event, ts fields', async () => {
|
|
99
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
100
|
+
const { fetch, calls } = makeFetch();
|
|
101
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
102
|
+
|
|
103
|
+
await dispatcher.handleBusEvent('run-42', 'pipeline_complete', { status: 'success' });
|
|
104
|
+
|
|
105
|
+
const body = JSON.parse(calls[0].options.body as string);
|
|
106
|
+
expect(body.run_id).toBe('run-42');
|
|
107
|
+
expect(body.event).toBe('pipeline_complete');
|
|
108
|
+
expect(body.ts).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('maps stage_complete with rejected status to stage_rejected webhook event', async () => {
|
|
112
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_rejected'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
113
|
+
const { fetch, calls } = makeFetch();
|
|
114
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
115
|
+
|
|
116
|
+
await dispatcher.handleBusEvent('run-1', 'stage_complete', { stage_name: 'qa', status: 'rejected' });
|
|
117
|
+
|
|
118
|
+
expect(calls).toHaveLength(1);
|
|
119
|
+
const body = JSON.parse(calls[0].options.body as string);
|
|
120
|
+
expect(body.event).toBe('stage_rejected');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('maps stage_complete with failed status to stage_failed webhook event', async () => {
|
|
124
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_failed'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
125
|
+
const { fetch, calls } = makeFetch();
|
|
126
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
127
|
+
|
|
128
|
+
await dispatcher.handleBusEvent('run-1', 'stage_complete', { stage_name: 'code', status: 'failed' });
|
|
129
|
+
|
|
130
|
+
expect(calls).toHaveLength(1);
|
|
131
|
+
const body = JSON.parse(calls[0].options.body as string);
|
|
132
|
+
expect(body.event).toBe('stage_failed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('maps stage_complete with success status to stage_complete webhook event', async () => {
|
|
136
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['stage_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
137
|
+
const { fetch, calls } = makeFetch();
|
|
138
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
139
|
+
|
|
140
|
+
await dispatcher.handleBusEvent('run-1', 'stage_complete', { stage_name: 'code', status: 'success' });
|
|
141
|
+
|
|
142
|
+
expect(calls).toHaveLength(1);
|
|
143
|
+
const body = JSON.parse(calls[0].options.body as string);
|
|
144
|
+
expect(body.event).toBe('stage_complete');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('does not dispatch to failed webhooks', async () => {
|
|
148
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://hook.example.com/cb', events: ['pipeline_complete'], status: 'failed', created_at: '2026-01-01T00:00:00.000Z' });
|
|
149
|
+
const { fetch, calls } = makeFetch();
|
|
150
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
151
|
+
|
|
152
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
153
|
+
|
|
154
|
+
expect(calls).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('dispatches to multiple matching webhooks', async () => {
|
|
158
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.example.com/hook', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
159
|
+
store.saveWebhook({ id: 'wh-2', url: 'https://b.example.com/hook', events: ['pipeline_complete', 'stage_failed'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
160
|
+
const { fetch, calls } = makeFetch();
|
|
161
|
+
const dispatcher = new WebhookDispatcher(store, 'my-project', fetch);
|
|
162
|
+
|
|
163
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
164
|
+
|
|
165
|
+
expect(calls).toHaveLength(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('retries on HTTP 5xx and marks webhook failed after 3 failed retries', async () => {
|
|
169
|
+
vi.useFakeTimers();
|
|
170
|
+
let callCount = 0;
|
|
171
|
+
const failingFetch = vi.fn(async () => {
|
|
172
|
+
callCount++;
|
|
173
|
+
return new Response('Error', { status: 500 });
|
|
174
|
+
}) as unknown as typeof fetch;
|
|
175
|
+
|
|
176
|
+
const { store: s, cleanup: c } = makeStore();
|
|
177
|
+
s.saveWebhook({ id: 'wh-fail', url: 'https://fail.example.com/hook', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
178
|
+
const dispatcher = new WebhookDispatcher(s, 'test', failingFetch);
|
|
179
|
+
|
|
180
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
181
|
+
|
|
182
|
+
// Advance through all retry delays: 30s + 5min + 15min
|
|
183
|
+
await vi.advanceTimersByTimeAsync(30_001);
|
|
184
|
+
await vi.advanceTimersByTimeAsync(5 * 60_001);
|
|
185
|
+
await vi.advanceTimersByTimeAsync(15 * 60_001);
|
|
186
|
+
|
|
187
|
+
expect(callCount).toBe(4); // 1 original + 3 retries
|
|
188
|
+
expect(s.getWebhook('wh-fail')?.status).toBe('failed');
|
|
189
|
+
|
|
190
|
+
c();
|
|
191
|
+
vi.useRealTimers();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('does not retry on successful delivery', async () => {
|
|
195
|
+
vi.useFakeTimers();
|
|
196
|
+
let callCount = 0;
|
|
197
|
+
const successFetch = vi.fn(async () => {
|
|
198
|
+
callCount++;
|
|
199
|
+
return new Response('OK', { status: 200 });
|
|
200
|
+
}) as unknown as typeof fetch;
|
|
201
|
+
|
|
202
|
+
const { store: s, cleanup: c } = makeStore();
|
|
203
|
+
s.saveWebhook({ id: 'wh-ok', url: 'https://ok.example.com/hook', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
204
|
+
const dispatcher = new WebhookDispatcher(s, 'test', successFetch);
|
|
205
|
+
|
|
206
|
+
await dispatcher.handleBusEvent('run-1', 'pipeline_complete', { status: 'success' });
|
|
207
|
+
await vi.runAllTimersAsync();
|
|
208
|
+
|
|
209
|
+
expect(callCount).toBe(1);
|
|
210
|
+
|
|
211
|
+
c();
|
|
212
|
+
vi.useRealTimers();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { WebhookStore, type WebhookRegistration, type WebhookDelivery } from '../src/webhook-store.js';
|
|
5
|
+
|
|
6
|
+
describe('WebhookStore', () => {
|
|
7
|
+
let store: WebhookStore;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
const dbDir = resolve('/tmp', `.studio-webhook-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
mkdirSync(dbDir, { recursive: true });
|
|
12
|
+
store = new WebhookStore(resolve(dbDir, 'runs.db'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
store.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('saves and retrieves a webhook', () => {
|
|
20
|
+
const webhook: WebhookRegistration = {
|
|
21
|
+
id: 'wh-1',
|
|
22
|
+
url: 'https://example.com/webhook',
|
|
23
|
+
events: ['pipeline_complete', 'stage_failed'],
|
|
24
|
+
secret: 'secret123',
|
|
25
|
+
status: 'active',
|
|
26
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
27
|
+
};
|
|
28
|
+
store.saveWebhook(webhook);
|
|
29
|
+
expect(store.getWebhook('wh-1')).toEqual(webhook);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns null for unknown webhook', () => {
|
|
33
|
+
expect(store.getWebhook('nonexistent')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('lists all webhooks', () => {
|
|
37
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: ['pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
38
|
+
store.saveWebhook({ id: 'wh-2', url: 'https://b.com', events: ['stage_failed'], status: 'active', created_at: '2026-01-02T00:00:00.000Z' });
|
|
39
|
+
const webhooks = store.listWebhooks();
|
|
40
|
+
expect(webhooks).toHaveLength(2);
|
|
41
|
+
expect(webhooks.map(w => w.id)).toContain('wh-1');
|
|
42
|
+
expect(webhooks.map(w => w.id)).toContain('wh-2');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('returns empty list when no webhooks', () => {
|
|
46
|
+
expect(store.listWebhooks()).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('deletes a webhook and returns true', () => {
|
|
50
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
51
|
+
expect(store.deleteWebhook('wh-1')).toBe(true);
|
|
52
|
+
expect(store.getWebhook('wh-1')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('deleteWebhook returns false for unknown id', () => {
|
|
56
|
+
expect(store.deleteWebhook('does-not-exist')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('marks webhook as failed', () => {
|
|
60
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
61
|
+
store.markWebhookFailed('wh-1');
|
|
62
|
+
expect(store.getWebhook('wh-1')?.status).toBe('failed');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('saves a delivery without error', () => {
|
|
66
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
67
|
+
const delivery: WebhookDelivery = {
|
|
68
|
+
id: 'del-1',
|
|
69
|
+
webhook_id: 'wh-1',
|
|
70
|
+
event: 'pipeline_complete',
|
|
71
|
+
run_id: 'run-1',
|
|
72
|
+
status: 'pending',
|
|
73
|
+
attempt: 1,
|
|
74
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
75
|
+
};
|
|
76
|
+
expect(() => store.saveDelivery(delivery)).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('updates a delivery status without error', () => {
|
|
80
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: [], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
81
|
+
store.saveDelivery({
|
|
82
|
+
id: 'del-1',
|
|
83
|
+
webhook_id: 'wh-1',
|
|
84
|
+
event: 'pipeline_complete',
|
|
85
|
+
run_id: 'run-1',
|
|
86
|
+
status: 'pending',
|
|
87
|
+
attempt: 1,
|
|
88
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
89
|
+
});
|
|
90
|
+
expect(() => store.updateDelivery('del-1', 'success')).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('persists events array as JSON', () => {
|
|
94
|
+
store.saveWebhook({ id: 'wh-1', url: 'https://a.com', events: ['pipeline_start', 'pipeline_complete'], status: 'active', created_at: '2026-01-01T00:00:00.000Z' });
|
|
95
|
+
const result = store.getWebhook('wh-1');
|
|
96
|
+
expect(result?.events).toEqual(['pipeline_start', 'pipeline_complete']);
|
|
97
|
+
});
|
|
98
|
+
});
|