@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,302 @@
|
|
|
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 { WebhookStore } from '../src/webhook-store.js';
|
|
7
|
+
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
8
|
+
import type { IntegrationStore } from '../src/integration-store.js';
|
|
9
|
+
|
|
10
|
+
const TMP = resolve('/tmp', `.studio-contracts-test-${Date.now()}`);
|
|
11
|
+
const CONTRACTS_DIR = resolve(TMP, 'contracts');
|
|
12
|
+
|
|
13
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
14
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
15
|
+
|
|
16
|
+
function makeServer() {
|
|
17
|
+
return buildServer({
|
|
18
|
+
store: new InMemoryRunStore(),
|
|
19
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
20
|
+
configsDir: TMP,
|
|
21
|
+
projectName: 'test-project',
|
|
22
|
+
apiConfig: {},
|
|
23
|
+
studioVersion: '0.0.0-test',
|
|
24
|
+
maskedConfig: { providers: [] },
|
|
25
|
+
webhookStore: new WebhookStore(resolve(TMP, 'runs.db')),
|
|
26
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
27
|
+
integrationStore: nullIntegrationStore,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
mkdirSync(CONTRACTS_DIR, { recursive: true });
|
|
33
|
+
writeFileSync(
|
|
34
|
+
resolve(CONTRACTS_DIR, 'brief-analysis.contract.yaml'),
|
|
35
|
+
'name: brief-analysis\nversion: 1\n'
|
|
36
|
+
);
|
|
37
|
+
writeFileSync(
|
|
38
|
+
resolve(CONTRACTS_DIR, 'code-generation.contract.yaml'),
|
|
39
|
+
'name: code-generation\nversion: 1\ntool_calls:\n minimum: 1\n'
|
|
40
|
+
);
|
|
41
|
+
writeFileSync(resolve(CONTRACTS_DIR, 'ignored.yaml'), ''); // must be ignored
|
|
42
|
+
|
|
43
|
+
writeFileSync(
|
|
44
|
+
resolve(CONTRACTS_DIR, 'with-tool-calls.contract.yaml'),
|
|
45
|
+
[
|
|
46
|
+
'name: with-tool-calls',
|
|
47
|
+
'version: 1',
|
|
48
|
+
'schema:',
|
|
49
|
+
' required_fields:',
|
|
50
|
+
' - summary',
|
|
51
|
+
'tool_calls:',
|
|
52
|
+
' minimum: 1',
|
|
53
|
+
].join('\n')
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
writeFileSync(
|
|
57
|
+
resolve(CONTRACTS_DIR, 'with-post-validation.contract.yaml'),
|
|
58
|
+
[
|
|
59
|
+
'name: with-post-validation',
|
|
60
|
+
'version: 1',
|
|
61
|
+
'schema:',
|
|
62
|
+
' required_fields:',
|
|
63
|
+
' - status',
|
|
64
|
+
'post_validation:',
|
|
65
|
+
' rejection_detection:',
|
|
66
|
+
' field: status',
|
|
67
|
+
' approved_values:',
|
|
68
|
+
' - approved',
|
|
69
|
+
' rejected_values:',
|
|
70
|
+
' - rejected',
|
|
71
|
+
].join('\n')
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(() => {
|
|
76
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('GET /api/contracts', () => {
|
|
80
|
+
it('returns only *.contract.yaml files as contract names', async () => {
|
|
81
|
+
const server = makeServer();
|
|
82
|
+
const res = await server.inject({ method: 'GET', url: '/api/contracts' });
|
|
83
|
+
expect(res.statusCode).toBe(200);
|
|
84
|
+
const { contracts } = res.json() as { contracts: string[] };
|
|
85
|
+
expect(contracts).toContain('brief-analysis');
|
|
86
|
+
expect(contracts).toContain('code-generation');
|
|
87
|
+
expect(contracts).not.toContain('ignored');
|
|
88
|
+
expect(contracts).not.toContain('brief-analysis.contract.yaml');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns empty array when contracts dir is missing', async () => {
|
|
92
|
+
const emptyDir = resolve('/tmp', `.studio-no-contracts-${Date.now()}`);
|
|
93
|
+
mkdirSync(emptyDir, { recursive: true });
|
|
94
|
+
const server = buildServer({
|
|
95
|
+
store: new InMemoryRunStore(),
|
|
96
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
97
|
+
configsDir: emptyDir,
|
|
98
|
+
projectName: 'test-project',
|
|
99
|
+
apiConfig: {},
|
|
100
|
+
studioVersion: '0.0.0-test',
|
|
101
|
+
maskedConfig: { providers: [] },
|
|
102
|
+
webhookStore: new WebhookStore(resolve(emptyDir, 'runs.db')),
|
|
103
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
104
|
+
integrationStore: nullIntegrationStore,
|
|
105
|
+
});
|
|
106
|
+
const res = await server.inject({ method: 'GET', url: '/api/contracts' });
|
|
107
|
+
expect(res.statusCode).toBe(200);
|
|
108
|
+
expect((res.json() as { contracts: string[] }).contracts).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('GET /api/contracts/:name', () => {
|
|
113
|
+
it('returns parsed contract content as JSON', async () => {
|
|
114
|
+
const server = makeServer();
|
|
115
|
+
const res = await server.inject({ method: 'GET', url: '/api/contracts/brief-analysis' });
|
|
116
|
+
expect(res.statusCode).toBe(200);
|
|
117
|
+
const body = res.json() as { name: string; version: number };
|
|
118
|
+
expect(body.name).toBe('brief-analysis');
|
|
119
|
+
expect(body.version).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns nested fields from YAML', async () => {
|
|
123
|
+
const server = makeServer();
|
|
124
|
+
const res = await server.inject({ method: 'GET', url: '/api/contracts/code-generation' });
|
|
125
|
+
expect(res.statusCode).toBe(200);
|
|
126
|
+
const body = res.json() as { tool_calls: { minimum: number } };
|
|
127
|
+
expect(body.tool_calls.minimum).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns 404 for unknown contract', async () => {
|
|
131
|
+
const server = makeServer();
|
|
132
|
+
const res = await server.inject({ method: 'GET', url: '/api/contracts/nonexistent' });
|
|
133
|
+
expect(res.statusCode).toBe(404);
|
|
134
|
+
expect((res.json() as { error: string }).error).toBe('Contract not found');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('PUT /api/contracts/:name', () => {
|
|
139
|
+
it('creates a new contract file', async () => {
|
|
140
|
+
const server = makeServer();
|
|
141
|
+
const res = await server.inject({
|
|
142
|
+
method: 'PUT',
|
|
143
|
+
url: '/api/contracts/new-contract',
|
|
144
|
+
payload: { name: 'new-contract', version: 1, schema: { required_fields: ['summary'] } },
|
|
145
|
+
});
|
|
146
|
+
expect(res.statusCode).toBe(200);
|
|
147
|
+
// Verify it can be read back
|
|
148
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/contracts/new-contract' });
|
|
149
|
+
expect(getRes.statusCode).toBe(200);
|
|
150
|
+
expect((getRes.json() as { version: number }).version).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('updates an existing contract', async () => {
|
|
154
|
+
const server = makeServer();
|
|
155
|
+
const res = await server.inject({
|
|
156
|
+
method: 'PUT',
|
|
157
|
+
url: '/api/contracts/brief-analysis',
|
|
158
|
+
payload: { name: 'brief-analysis', version: 2 },
|
|
159
|
+
});
|
|
160
|
+
expect(res.statusCode).toBe(200);
|
|
161
|
+
// Verify version updated
|
|
162
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/contracts/brief-analysis' });
|
|
163
|
+
expect((getRes.json() as { version: number }).version).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns 400 when name field is missing', async () => {
|
|
167
|
+
const server = makeServer();
|
|
168
|
+
const res = await server.inject({
|
|
169
|
+
method: 'PUT',
|
|
170
|
+
url: '/api/contracts/foo',
|
|
171
|
+
payload: { version: 1 },
|
|
172
|
+
});
|
|
173
|
+
expect(res.statusCode).toBe(400);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('returns 400 when version field is missing', async () => {
|
|
177
|
+
const server = makeServer();
|
|
178
|
+
const res = await server.inject({
|
|
179
|
+
method: 'PUT',
|
|
180
|
+
url: '/api/contracts/foo',
|
|
181
|
+
payload: { name: 'foo' },
|
|
182
|
+
});
|
|
183
|
+
expect(res.statusCode).toBe(400);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('DELETE /api/contracts/:name', () => {
|
|
188
|
+
it('deletes a contract and returns 204', async () => {
|
|
189
|
+
const server = makeServer();
|
|
190
|
+
// Create it first via PUT
|
|
191
|
+
await server.inject({
|
|
192
|
+
method: 'PUT',
|
|
193
|
+
url: '/api/contracts/to-delete',
|
|
194
|
+
payload: { name: 'to-delete', version: 1 },
|
|
195
|
+
});
|
|
196
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/contracts/to-delete' });
|
|
197
|
+
expect(res.statusCode).toBe(204);
|
|
198
|
+
// Verify it's gone
|
|
199
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/contracts/to-delete' });
|
|
200
|
+
expect(getRes.statusCode).toBe(404);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('returns 404 for nonexistent contract', async () => {
|
|
204
|
+
const server = makeServer();
|
|
205
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/contracts/nonexistent' });
|
|
206
|
+
expect(res.statusCode).toBe(404);
|
|
207
|
+
expect((res.json() as { error: string }).error).toBe('Contract not found');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('POST /api/contracts/:name/validate', () => {
|
|
212
|
+
it('returns valid: true for output that satisfies schema-only contract', async () => {
|
|
213
|
+
const server = makeServer();
|
|
214
|
+
const res = await server.inject({
|
|
215
|
+
method: 'POST',
|
|
216
|
+
url: '/api/contracts/brief-analysis/validate',
|
|
217
|
+
payload: { output: {} },
|
|
218
|
+
});
|
|
219
|
+
expect(res.statusCode).toBe(200);
|
|
220
|
+
const body = res.json() as { valid: boolean; errors: string[]; warnings: string[] };
|
|
221
|
+
expect(body.valid).toBe(true);
|
|
222
|
+
expect(body.errors).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns valid: false with error when tool_calls minimum not met', async () => {
|
|
226
|
+
const server = makeServer();
|
|
227
|
+
const res = await server.inject({
|
|
228
|
+
method: 'POST',
|
|
229
|
+
url: '/api/contracts/with-tool-calls/validate',
|
|
230
|
+
payload: { output: { summary: 'ok' }, tool_calls: [] },
|
|
231
|
+
});
|
|
232
|
+
expect(res.statusCode).toBe(200);
|
|
233
|
+
const body = res.json() as { valid: boolean; errors: string[] };
|
|
234
|
+
expect(body.valid).toBe(false);
|
|
235
|
+
expect(body.errors.some((e: string) => e.includes('at least 1 successful tool call'))).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('returns valid: true when tool_calls requirement met', async () => {
|
|
239
|
+
const server = makeServer();
|
|
240
|
+
const res = await server.inject({
|
|
241
|
+
method: 'POST',
|
|
242
|
+
url: '/api/contracts/with-tool-calls/validate',
|
|
243
|
+
payload: {
|
|
244
|
+
output: { summary: 'ok' },
|
|
245
|
+
tool_calls: [{ id: 'call-1', name: 'repo_manager-write_file', arguments: { path: 'a.ts' }, result: 'ok' }],
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
expect(res.statusCode).toBe(200);
|
|
249
|
+
const body = res.json() as { valid: boolean };
|
|
250
|
+
expect(body.valid).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns post_validation.accepted: false when output has rejected value', async () => {
|
|
254
|
+
const server = makeServer();
|
|
255
|
+
const res = await server.inject({
|
|
256
|
+
method: 'POST',
|
|
257
|
+
url: '/api/contracts/with-post-validation/validate',
|
|
258
|
+
payload: { output: { status: 'rejected' } },
|
|
259
|
+
});
|
|
260
|
+
expect(res.statusCode).toBe(200);
|
|
261
|
+
const body = res.json() as {
|
|
262
|
+
valid: boolean;
|
|
263
|
+
post_validation: { accepted: boolean; rejection_reason: string };
|
|
264
|
+
};
|
|
265
|
+
expect(body.valid).toBe(true);
|
|
266
|
+
expect(body.post_validation.accepted).toBe(false);
|
|
267
|
+
expect(body.post_validation.rejection_reason).toBeTruthy();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('returns post_validation.accepted: true when output has approved value', async () => {
|
|
271
|
+
const server = makeServer();
|
|
272
|
+
const res = await server.inject({
|
|
273
|
+
method: 'POST',
|
|
274
|
+
url: '/api/contracts/with-post-validation/validate',
|
|
275
|
+
payload: { output: { status: 'approved' } },
|
|
276
|
+
});
|
|
277
|
+
expect(res.statusCode).toBe(200);
|
|
278
|
+
const body = res.json() as { post_validation: { accepted: boolean } };
|
|
279
|
+
expect(body.post_validation.accepted).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('returns 404 for unknown contract', async () => {
|
|
283
|
+
const server = makeServer();
|
|
284
|
+
const res = await server.inject({
|
|
285
|
+
method: 'POST',
|
|
286
|
+
url: '/api/contracts/nonexistent/validate',
|
|
287
|
+
payload: { output: {} },
|
|
288
|
+
});
|
|
289
|
+
expect(res.statusCode).toBe(404);
|
|
290
|
+
expect((res.json() as { error: string }).error).toBe('Contract not found');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('returns 400 when output field is missing from body', async () => {
|
|
294
|
+
const server = makeServer();
|
|
295
|
+
const res = await server.inject({
|
|
296
|
+
method: 'POST',
|
|
297
|
+
url: '/api/contracts/brief-analysis/validate',
|
|
298
|
+
payload: {},
|
|
299
|
+
});
|
|
300
|
+
expect(res.statusCode).toBe(400);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RunEventBus } from '../src/event-bus.js';
|
|
3
|
+
|
|
4
|
+
describe('RunEventBus', () => {
|
|
5
|
+
it('delivers events to subscribers', () => {
|
|
6
|
+
const bus = new RunEventBus();
|
|
7
|
+
const received: unknown[] = [];
|
|
8
|
+
bus.subscribe('run-1', (e) => received.push(e));
|
|
9
|
+
bus.emit('run-1', 'stage_complete', { stage: 'brief-analysis' });
|
|
10
|
+
expect(received).toHaveLength(1);
|
|
11
|
+
expect(received[0]).toMatchObject({ type: 'stage_complete', data: { stage: 'brief-analysis' } });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('unsubscribe stops delivery', () => {
|
|
15
|
+
const bus = new RunEventBus();
|
|
16
|
+
const received: unknown[] = [];
|
|
17
|
+
const unsub = bus.subscribe('run-1', (e) => received.push(e));
|
|
18
|
+
unsub();
|
|
19
|
+
bus.emit('run-1', 'stage_complete', {});
|
|
20
|
+
expect(received).toHaveLength(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('close emits done then cleans up', () => {
|
|
24
|
+
const bus = new RunEventBus();
|
|
25
|
+
const types: string[] = [];
|
|
26
|
+
bus.subscribe('run-1', (e) => types.push(e.type));
|
|
27
|
+
bus.close('run-1');
|
|
28
|
+
expect(types).toEqual(['done']);
|
|
29
|
+
// After close, no more events
|
|
30
|
+
bus.emit('run-1', 'stage_complete', {});
|
|
31
|
+
expect(types).toHaveLength(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('isolates events between runs', () => {
|
|
35
|
+
const bus = new RunEventBus();
|
|
36
|
+
const run1: unknown[] = [];
|
|
37
|
+
const run2: unknown[] = [];
|
|
38
|
+
bus.subscribe('run-1', (e) => run1.push(e));
|
|
39
|
+
bus.subscribe('run-2', (e) => run2.push(e));
|
|
40
|
+
bus.emit('run-1', 'stage_complete', {});
|
|
41
|
+
expect(run1).toHaveLength(1);
|
|
42
|
+
expect(run2).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('supports multiple subscribers on same run', () => {
|
|
46
|
+
const bus = new RunEventBus();
|
|
47
|
+
let count = 0;
|
|
48
|
+
bus.subscribe('run-1', () => count++);
|
|
49
|
+
bus.subscribe('run-1', () => count++);
|
|
50
|
+
bus.emit('run-1', 'pipeline_complete', {});
|
|
51
|
+
expect(count).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { HttpApiSpawner } from '../src/spawners/http-api-spawner.js';
|
|
3
|
+
|
|
4
|
+
// Helper: create a fake SSE stream that emits events then closes
|
|
5
|
+
function makeFakeSseResponse(events: Array<{ type: string; data: unknown }>) {
|
|
6
|
+
const lines: string[] = [];
|
|
7
|
+
for (const e of events) {
|
|
8
|
+
lines.push(`event: ${e.type}`);
|
|
9
|
+
lines.push(`data: ${JSON.stringify(e.data)}`);
|
|
10
|
+
lines.push('');
|
|
11
|
+
}
|
|
12
|
+
const body = lines.join('\n');
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const encoded = encoder.encode(body);
|
|
15
|
+
|
|
16
|
+
const stream = new ReadableStream({
|
|
17
|
+
start(controller) {
|
|
18
|
+
controller.enqueue(encoded);
|
|
19
|
+
controller.close();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return new Response(stream, {
|
|
24
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('HttpApiSpawner', () => {
|
|
29
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
fetchMock = vi.fn();
|
|
33
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('POSTs to /api/runs with correct headers and body', async () => {
|
|
37
|
+
fetchMock
|
|
38
|
+
.mockResolvedValueOnce(
|
|
39
|
+
new Response(JSON.stringify({ run_id: 'child-1', status: 'running', stream_url: '/api/runs/child-1/stream' }), {
|
|
40
|
+
status: 201,
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
.mockResolvedValueOnce(
|
|
45
|
+
makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'success', run_id: 'child-1' } }])
|
|
46
|
+
)
|
|
47
|
+
.mockResolvedValueOnce(
|
|
48
|
+
new Response(
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
id: 'child-1',
|
|
51
|
+
pipeline_name: 'test',
|
|
52
|
+
status: 'success',
|
|
53
|
+
started_at: new Date().toISOString(),
|
|
54
|
+
stages: [{ id: 's1', stage_name: 'final', status: 'success', started_at: '', tasks: [], output: { ok: true } }],
|
|
55
|
+
}),
|
|
56
|
+
{ headers: { 'content-type': 'application/json' } }
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const spawner = new HttpApiSpawner('http://localhost:3000');
|
|
61
|
+
await spawner.spawnAndWait({ pipeline: 'test', input: { x: 1 }, parentRunId: 'p1', depth: 1 });
|
|
62
|
+
|
|
63
|
+
const postCall = fetchMock.mock.calls[0];
|
|
64
|
+
expect(postCall[0]).toBe('http://localhost:3000/api/runs');
|
|
65
|
+
expect(postCall[1].method).toBe('POST');
|
|
66
|
+
expect(postCall[1].headers['X-Studio-Depth']).toBe('1');
|
|
67
|
+
expect(postCall[1].headers['X-Studio-Parent-Run-Id']).toBe('p1');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns run_id, status, and output from last stage on success', async () => {
|
|
71
|
+
fetchMock
|
|
72
|
+
.mockResolvedValueOnce(
|
|
73
|
+
new Response(JSON.stringify({ run_id: 'child-2', status: 'running', stream_url: '' }), {
|
|
74
|
+
status: 201,
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
.mockResolvedValueOnce(
|
|
79
|
+
makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'success', run_id: 'child-2' } }])
|
|
80
|
+
)
|
|
81
|
+
.mockResolvedValueOnce(
|
|
82
|
+
new Response(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
id: 'child-2',
|
|
85
|
+
pipeline_name: 'p',
|
|
86
|
+
status: 'success',
|
|
87
|
+
started_at: '',
|
|
88
|
+
stages: [{ id: 's1', stage_name: 'final', status: 'success', started_at: '', tasks: [], output: { recipe: 'pasta' } }],
|
|
89
|
+
}),
|
|
90
|
+
{ headers: { 'content-type': 'application/json' } }
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const spawner = new HttpApiSpawner('http://localhost:3000');
|
|
95
|
+
const result = await spawner.spawnAndWait({ pipeline: 'p', input: {}, parentRunId: 'x', depth: 1 });
|
|
96
|
+
|
|
97
|
+
expect(result.run_id).toBe('child-2');
|
|
98
|
+
expect(result.status).toBe('success');
|
|
99
|
+
expect(result.output).toEqual({ recipe: 'pasta' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('sends Authorization header on all requests when apiKey is configured', async () => {
|
|
103
|
+
fetchMock
|
|
104
|
+
.mockResolvedValueOnce(
|
|
105
|
+
new Response(JSON.stringify({ run_id: 'child-auth', status: 'running', stream_url: '' }), {
|
|
106
|
+
status: 201,
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
.mockResolvedValueOnce(
|
|
111
|
+
makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'success', run_id: 'child-auth' } }])
|
|
112
|
+
)
|
|
113
|
+
.mockResolvedValueOnce(
|
|
114
|
+
new Response(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
id: 'child-auth',
|
|
117
|
+
pipeline_name: 'p',
|
|
118
|
+
status: 'success',
|
|
119
|
+
started_at: '',
|
|
120
|
+
stages: [{ id: 's1', stage_name: 'final', status: 'success', started_at: '', tasks: [], output: {} }],
|
|
121
|
+
}),
|
|
122
|
+
{ headers: { 'content-type': 'application/json' } }
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const spawner = new HttpApiSpawner('http://localhost:3000', 'my-secret-key');
|
|
127
|
+
await spawner.spawnAndWait({ pipeline: 'p', input: {}, parentRunId: 'x', depth: 1 });
|
|
128
|
+
|
|
129
|
+
// All 3 fetch calls (POST /runs, GET /runs/stream, GET /runs/:id) must include the auth header
|
|
130
|
+
for (const call of fetchMock.mock.calls) {
|
|
131
|
+
expect(call[1]?.headers?.['Authorization']).toBe('Bearer my-secret-key');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('throws when pipeline_complete has failed status', async () => {
|
|
136
|
+
fetchMock
|
|
137
|
+
.mockResolvedValueOnce(
|
|
138
|
+
new Response(JSON.stringify({ run_id: 'child-3', status: 'running', stream_url: '' }), {
|
|
139
|
+
status: 201,
|
|
140
|
+
headers: { 'content-type': 'application/json' },
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
.mockResolvedValueOnce(
|
|
144
|
+
makeFakeSseResponse([{ type: 'pipeline_complete', data: { status: 'failed', run_id: 'child-3' } }])
|
|
145
|
+
)
|
|
146
|
+
.mockResolvedValueOnce(
|
|
147
|
+
new Response(
|
|
148
|
+
JSON.stringify({ id: 'child-3', pipeline_name: 'p', status: 'failed', started_at: '', stages: [] }),
|
|
149
|
+
{ headers: { 'content-type': 'application/json' } }
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const spawner = new HttpApiSpawner('http://localhost:3000');
|
|
154
|
+
await expect(
|
|
155
|
+
spawner.spawnAndWait({ pipeline: 'bad', input: {}, parentRunId: 'x', depth: 1 })
|
|
156
|
+
).rejects.toThrow('Child run child-3 failed');
|
|
157
|
+
});
|
|
158
|
+
});
|