@studio-foundation/api 0.3.0-beta.1 → 0.3.0-beta.5
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 +7 -4
- package/ARCHITECTURE.md +0 -52
- package/src/.gitkeep +0 -0
- package/src/api.ts +0 -8
- package/src/bootstrap.ts +0 -259
- package/src/event-bus.ts +0 -64
- package/src/index.ts +0 -58
- package/src/integration-runtime.ts +0 -180
- package/src/integration-store.ts +0 -125
- package/src/integrations/linear/failure-handler.ts +0 -93
- package/src/integrations/linear/webhook-handler.ts +0 -156
- package/src/integrations/registry.ts +0 -12
- package/src/integrations/types.ts +0 -37
- package/src/launcher.ts +0 -214
- package/src/logger.ts +0 -50
- package/src/plans.ts +0 -43
- package/src/routes/agents.ts +0 -131
- package/src/routes/config.ts +0 -154
- package/src/routes/contracts.ts +0 -205
- package/src/routes/pipelines.ts +0 -146
- package/src/routes/projects.ts +0 -237
- package/src/routes/runs.ts +0 -468
- package/src/routes/skills.ts +0 -136
- package/src/routes/tools.ts +0 -222
- package/src/routes/users.ts +0 -169
- package/src/routes/validate.ts +0 -196
- package/src/routes/webhooks.ts +0 -120
- package/src/server.ts +0 -155
- package/src/spawners/http-api-spawner.ts +0 -96
- package/src/user-store-pg.ts +0 -138
- package/src/user-store.ts +0 -125
- package/src/utils/repo-resolver.ts +0 -3
- package/src/webhook-dispatcher.ts +0 -142
- package/src/webhook-store.ts +0 -141
- package/tests/agents.test.ts +0 -164
- package/tests/cancel.test.ts +0 -120
- package/tests/config.test.ts +0 -196
- package/tests/contracts.test.ts +0 -302
- package/tests/event-bus.test.ts +0 -53
- package/tests/http-api-spawner.test.ts +0 -158
- package/tests/integration-runtime.test.ts +0 -199
- package/tests/integration-store.test.ts +0 -66
- package/tests/integrations/linear/failure-handler.test.ts +0 -113
- package/tests/integrations/linear/webhook-handler.test.ts +0 -191
- package/tests/launcher.test.ts +0 -380
- package/tests/linear-webhook.test.ts +0 -390
- package/tests/pipelines.test.ts +0 -166
- package/tests/projects.test.ts +0 -283
- package/tests/runs.test.ts +0 -379
- package/tests/server.test.ts +0 -208
- package/tests/skills.test.ts +0 -149
- package/tests/sse.test.ts +0 -129
- package/tests/tools.test.ts +0 -233
- package/tests/user-store.test.ts +0 -93
- package/tests/users.test.ts +0 -189
- package/tests/utils/repo-resolver.test.ts +0 -105
- package/tests/validate.test.ts +0 -233
- package/tests/webhook-dispatcher.test.ts +0 -214
- package/tests/webhook-store.test.ts +0 -98
- package/tests/webhooks.test.ts +0 -176
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -7
package/tests/projects.test.ts
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
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_DIR = resolve('/tmp', `.studio-api-test-${Date.now()}`);
|
|
10
|
-
const PIPELINES_DIR = resolve(TMP_DIR, 'pipelines');
|
|
11
|
-
|
|
12
|
-
const PROJECT_TMP = resolve('/tmp', `.studio-project-introspection-test-${Date.now()}`);
|
|
13
|
-
|
|
14
|
-
beforeAll(() => {
|
|
15
|
-
mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
16
|
-
writeFileSync(resolve(PIPELINES_DIR, 'feature-builder.pipeline.yaml'), 'name: feature-builder\n');
|
|
17
|
-
writeFileSync(resolve(PIPELINES_DIR, 'code-review.pipeline.yaml'), 'name: code-review\n');
|
|
18
|
-
writeFileSync(resolve(PIPELINES_DIR, 'not-a-pipeline.yaml'), ''); // doit être ignoré
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterAll(() => {
|
|
22
|
-
rmSync(TMP_DIR, { recursive: true, force: true });
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
beforeAll(() => {
|
|
26
|
-
// pipelines
|
|
27
|
-
mkdirSync(resolve(PROJECT_TMP, 'pipelines'), { recursive: true });
|
|
28
|
-
writeFileSync(resolve(PROJECT_TMP, 'pipelines', 'feature-builder.pipeline.yaml'), '');
|
|
29
|
-
writeFileSync(resolve(PROJECT_TMP, 'pipelines', 'bug-fixer.pipeline.yaml'), '');
|
|
30
|
-
writeFileSync(resolve(PROJECT_TMP, 'pipelines', 'ignored.yaml'), ''); // must be ignored
|
|
31
|
-
|
|
32
|
-
// contracts
|
|
33
|
-
mkdirSync(resolve(PROJECT_TMP, 'contracts'), { recursive: true });
|
|
34
|
-
writeFileSync(resolve(PROJECT_TMP, 'contracts', 'brief-analysis.contract.yaml'), '');
|
|
35
|
-
writeFileSync(resolve(PROJECT_TMP, 'contracts', 'code-generation.contract.yaml'), '');
|
|
36
|
-
|
|
37
|
-
// agents
|
|
38
|
-
mkdirSync(resolve(PROJECT_TMP, 'agents'), { recursive: true });
|
|
39
|
-
writeFileSync(resolve(PROJECT_TMP, 'agents', 'analyst.agent.yaml'), '');
|
|
40
|
-
writeFileSync(resolve(PROJECT_TMP, 'agents', 'coder.agent.yaml'), '');
|
|
41
|
-
|
|
42
|
-
// tools
|
|
43
|
-
mkdirSync(resolve(PROJECT_TMP, 'tools'), { recursive: true });
|
|
44
|
-
writeFileSync(resolve(PROJECT_TMP, 'tools', 'repo_manager-read_file.tool.yaml'), '');
|
|
45
|
-
|
|
46
|
-
// inputs
|
|
47
|
-
mkdirSync(resolve(PROJECT_TMP, 'inputs'), { recursive: true });
|
|
48
|
-
writeFileSync(resolve(PROJECT_TMP, 'inputs', 'faq-about.input.yaml'), 'brief_summary: "Add a FAQ section"\ntarget_page: src/pages/about.tsx\n');
|
|
49
|
-
|
|
50
|
-
// skills — positive test to verify suffix stripping
|
|
51
|
-
mkdirSync(resolve(PROJECT_TMP, 'skills'), { recursive: true });
|
|
52
|
-
writeFileSync(resolve(PROJECT_TMP, 'skills', 'commit-conventions.skill.md'), '');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
afterAll(() => {
|
|
56
|
-
rmSync(PROJECT_TMP, { recursive: true, force: true });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
60
|
-
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
61
|
-
|
|
62
|
-
function makeServer() {
|
|
63
|
-
return buildServer({
|
|
64
|
-
store: new InMemoryRunStore(),
|
|
65
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
66
|
-
configsDir: TMP_DIR,
|
|
67
|
-
projectName: 'my-project',
|
|
68
|
-
apiConfig: {},
|
|
69
|
-
studioVersion: '0.0.0-test',
|
|
70
|
-
maskedConfig: { providers: [] },
|
|
71
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
72
|
-
integrationStore: nullIntegrationStore,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function makeProjectServer(opts: { withConfig?: boolean } = {}) {
|
|
77
|
-
return buildServer({
|
|
78
|
-
store: new InMemoryRunStore(),
|
|
79
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
80
|
-
configsDir: PROJECT_TMP,
|
|
81
|
-
projectName: 'my-project',
|
|
82
|
-
apiConfig: {},
|
|
83
|
-
studioVersion: '1.2.3',
|
|
84
|
-
maskedConfig: opts.withConfig
|
|
85
|
-
? { defaults: { provider: 'anthropic', model: 'claude-haiku' }, providers: ['anthropic'] }
|
|
86
|
-
: { providers: [] },
|
|
87
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
88
|
-
integrationStore: nullIntegrationStore,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
describe('GET /api/projects', () => {
|
|
93
|
-
it('returns the single project', async () => {
|
|
94
|
-
const server = makeServer();
|
|
95
|
-
const res = await server.inject({ method: 'GET', url: '/api/projects' });
|
|
96
|
-
expect(res.statusCode).toBe(200);
|
|
97
|
-
|
|
98
|
-
const { projects } = res.json() as { projects: Array<{ id: string; name: string }> };
|
|
99
|
-
expect(projects).toHaveLength(1);
|
|
100
|
-
expect(projects[0].name).toBe('my-project');
|
|
101
|
-
expect(projects[0].id).toBeTruthy();
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('GET /api/projects/:id/pipelines', () => {
|
|
106
|
-
it('returns only .pipeline.yaml files as pipeline names', async () => {
|
|
107
|
-
const server = makeServer();
|
|
108
|
-
const { projects } = (await server.inject({ method: 'GET', url: '/api/projects' })).json() as {
|
|
109
|
-
projects: Array<{ id: string }>;
|
|
110
|
-
};
|
|
111
|
-
const projectId = projects[0].id;
|
|
112
|
-
|
|
113
|
-
const res = await server.inject({
|
|
114
|
-
method: 'GET',
|
|
115
|
-
url: `/api/projects/${projectId}/pipelines`,
|
|
116
|
-
});
|
|
117
|
-
expect(res.statusCode).toBe(200);
|
|
118
|
-
|
|
119
|
-
const { pipelines } = res.json() as { pipelines: string[] };
|
|
120
|
-
expect(pipelines).toContain('feature-builder');
|
|
121
|
-
expect(pipelines).toContain('code-review');
|
|
122
|
-
expect(pipelines).not.toContain('not-a-pipeline');
|
|
123
|
-
expect(pipelines).not.toContain('feature-builder.pipeline.yaml');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('returns 404 for unknown project id', async () => {
|
|
127
|
-
const server = makeServer();
|
|
128
|
-
const res = await server.inject({
|
|
129
|
-
method: 'GET',
|
|
130
|
-
url: '/api/projects/unknown-project/pipelines',
|
|
131
|
-
});
|
|
132
|
-
expect(res.statusCode).toBe(404);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
describe('GET /api/project', () => {
|
|
137
|
-
it('returns all resource lists and metadata', async () => {
|
|
138
|
-
const server = makeProjectServer({ withConfig: true });
|
|
139
|
-
const res = await server.inject({ method: 'GET', url: '/api/project' });
|
|
140
|
-
expect(res.statusCode).toBe(200);
|
|
141
|
-
|
|
142
|
-
const body = res.json() as Record<string, unknown>;
|
|
143
|
-
expect(body.studio_version).toBe('1.2.3');
|
|
144
|
-
expect(body.studio_dir).toBe(PROJECT_TMP);
|
|
145
|
-
expect(body.pipelines).toEqual(expect.arrayContaining(['feature-builder', 'bug-fixer']));
|
|
146
|
-
expect((body.pipelines as string[])).not.toContain('ignored');
|
|
147
|
-
expect(body.contracts).toEqual(expect.arrayContaining(['brief-analysis', 'code-generation']));
|
|
148
|
-
expect(body.agents).toEqual(expect.arrayContaining(['analyst', 'coder']));
|
|
149
|
-
expect(body.tools).toEqual(expect.arrayContaining(['repo_manager-read_file']));
|
|
150
|
-
expect(body.inputs).toEqual(expect.arrayContaining(['faq-about']));
|
|
151
|
-
expect(body.skills).toEqual(expect.arrayContaining(['commit-conventions']));
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('returns empty array for missing skills/ directory', async () => {
|
|
155
|
-
// Use TMP_DIR which has no skills/ directory
|
|
156
|
-
const server = buildServer({
|
|
157
|
-
store: new InMemoryRunStore(),
|
|
158
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
159
|
-
configsDir: TMP_DIR,
|
|
160
|
-
projectName: 'my-project',
|
|
161
|
-
apiConfig: {},
|
|
162
|
-
studioVersion: '0.0.0-test',
|
|
163
|
-
maskedConfig: { providers: [] },
|
|
164
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
165
|
-
integrationStore: nullIntegrationStore,
|
|
166
|
-
});
|
|
167
|
-
const res = await server.inject({ method: 'GET', url: '/api/project' });
|
|
168
|
-
expect(res.statusCode).toBe(200);
|
|
169
|
-
expect((res.json() as { skills: string[] }).skills).toEqual([]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('includes masked config with provider names only', async () => {
|
|
173
|
-
const server = makeProjectServer({ withConfig: true });
|
|
174
|
-
const res = await server.inject({ method: 'GET', url: '/api/project' });
|
|
175
|
-
const body = res.json() as { config: { providers: string[]; defaults: Record<string, string> } };
|
|
176
|
-
expect(body.config.providers).toEqual(['anthropic']);
|
|
177
|
-
expect(body.config.defaults?.provider).toBe('anthropic');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('returns empty providers when no config', async () => {
|
|
181
|
-
const server = makeProjectServer({ withConfig: false });
|
|
182
|
-
const res = await server.inject({ method: 'GET', url: '/api/project' });
|
|
183
|
-
const body = res.json() as { config: { providers: string[] } };
|
|
184
|
-
expect(body.config.providers).toEqual([]);
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe('GET /api/projects/:id/inputs', () => {
|
|
189
|
-
it('returns only *.input.yaml files as input names', async () => {
|
|
190
|
-
const server = makeProjectServer();
|
|
191
|
-
const { projects } = (await server.inject({ method: 'GET', url: '/api/projects' })).json() as {
|
|
192
|
-
projects: Array<{ id: string }>;
|
|
193
|
-
};
|
|
194
|
-
const projectId = projects[0].id;
|
|
195
|
-
|
|
196
|
-
const res = await server.inject({
|
|
197
|
-
method: 'GET',
|
|
198
|
-
url: `/api/projects/${projectId}/inputs`,
|
|
199
|
-
});
|
|
200
|
-
expect(res.statusCode).toBe(200);
|
|
201
|
-
|
|
202
|
-
const { inputs } = res.json() as { inputs: string[] };
|
|
203
|
-
expect(inputs).toContain('faq-about');
|
|
204
|
-
expect(inputs).not.toContain('faq-about.input.yaml');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('returns 404 for unknown project id', async () => {
|
|
208
|
-
const server = makeProjectServer();
|
|
209
|
-
const res = await server.inject({
|
|
210
|
-
method: 'GET',
|
|
211
|
-
url: '/api/projects/unknown-project/inputs',
|
|
212
|
-
});
|
|
213
|
-
expect(res.statusCode).toBe(404);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('returns empty array when inputs dir is missing', async () => {
|
|
217
|
-
const server = buildServer({
|
|
218
|
-
store: new InMemoryRunStore(),
|
|
219
|
-
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
220
|
-
configsDir: resolve('/tmp', `.studio-no-inputs-${Date.now()}`),
|
|
221
|
-
projectName: 'test-project',
|
|
222
|
-
apiConfig: {},
|
|
223
|
-
studioVersion: '0.0.0-test',
|
|
224
|
-
maskedConfig: { providers: [] },
|
|
225
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
226
|
-
integrationStore: nullIntegrationStore,
|
|
227
|
-
});
|
|
228
|
-
const { projects } = (await server.inject({ method: 'GET', url: '/api/projects' })).json() as {
|
|
229
|
-
projects: Array<{ id: string }>;
|
|
230
|
-
};
|
|
231
|
-
const projectId = projects[0].id;
|
|
232
|
-
const res = await server.inject({
|
|
233
|
-
method: 'GET',
|
|
234
|
-
url: `/api/projects/${projectId}/inputs`,
|
|
235
|
-
});
|
|
236
|
-
expect(res.statusCode).toBe(200);
|
|
237
|
-
expect((res.json() as { inputs: string[] }).inputs).toEqual([]);
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
describe('GET /api/projects/:id/inputs/:name', () => {
|
|
242
|
-
it('returns parsed input content as JSON', async () => {
|
|
243
|
-
const server = makeProjectServer();
|
|
244
|
-
const { projects } = (await server.inject({ method: 'GET', url: '/api/projects' })).json() as {
|
|
245
|
-
projects: Array<{ id: string }>;
|
|
246
|
-
};
|
|
247
|
-
const projectId = projects[0].id;
|
|
248
|
-
|
|
249
|
-
const res = await server.inject({
|
|
250
|
-
method: 'GET',
|
|
251
|
-
url: `/api/projects/${projectId}/inputs/faq-about`,
|
|
252
|
-
});
|
|
253
|
-
expect(res.statusCode).toBe(200);
|
|
254
|
-
const body = res.json() as { brief_summary: string; target_page: string };
|
|
255
|
-
expect(body.brief_summary).toBe('Add a FAQ section');
|
|
256
|
-
expect(body.target_page).toBe('src/pages/about.tsx');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('returns 404 for unknown project id', async () => {
|
|
260
|
-
const server = makeProjectServer();
|
|
261
|
-
const res = await server.inject({
|
|
262
|
-
method: 'GET',
|
|
263
|
-
url: '/api/projects/unknown-project/inputs/faq-about',
|
|
264
|
-
});
|
|
265
|
-
expect(res.statusCode).toBe(404);
|
|
266
|
-
expect((res.json() as { error: string }).error).toBe('Project not found');
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('returns 404 for unknown input name', async () => {
|
|
270
|
-
const server = makeProjectServer();
|
|
271
|
-
const { projects } = (await server.inject({ method: 'GET', url: '/api/projects' })).json() as {
|
|
272
|
-
projects: Array<{ id: string }>;
|
|
273
|
-
};
|
|
274
|
-
const projectId = projects[0].id;
|
|
275
|
-
|
|
276
|
-
const res = await server.inject({
|
|
277
|
-
method: 'GET',
|
|
278
|
-
url: `/api/projects/${projectId}/inputs/nonexistent`,
|
|
279
|
-
});
|
|
280
|
-
expect(res.statusCode).toBe(404);
|
|
281
|
-
expect((res.json() as { error: string }).error).toBe('Input not found');
|
|
282
|
-
});
|
|
283
|
-
});
|
package/tests/runs.test.ts
DELETED
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { buildServer } from '../src/server.js';
|
|
3
|
-
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
4
|
-
import type { RunStore } from '@studio-foundation/engine';
|
|
5
|
-
import type { RunLauncher } from '../src/launcher.js';
|
|
6
|
-
import type { PipelineRun } from '@studio-foundation/contracts';
|
|
7
|
-
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
8
|
-
import { resolve } from 'node:path';
|
|
9
|
-
import { tmpdir } from 'node:os';
|
|
10
|
-
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
12
|
-
import type { IntegrationStore } from '../src/integration-store.js';
|
|
13
|
-
|
|
14
|
-
function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
|
|
15
|
-
return {
|
|
16
|
-
id: 'run-abc123',
|
|
17
|
-
pipeline_name: 'feature-builder',
|
|
18
|
-
status: 'success',
|
|
19
|
-
started_at: '2026-01-01T10:00:00Z',
|
|
20
|
-
completed_at: '2026-01-01T10:01:00Z',
|
|
21
|
-
stages: [],
|
|
22
|
-
...overrides,
|
|
23
|
-
} as PipelineRun;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
27
|
-
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
28
|
-
|
|
29
|
-
function makeServer(store: RunStore, launcher?: RunLauncher) {
|
|
30
|
-
return buildServer({
|
|
31
|
-
store,
|
|
32
|
-
launcher: launcher ?? { launch: async () => ({ run_id: 'new-run' }), cancel: async () => {} },
|
|
33
|
-
configsDir: '/tmp/.studio',
|
|
34
|
-
projectName: 'test',
|
|
35
|
-
apiConfig: {},
|
|
36
|
-
integrationRuntime: nullIntegrationRuntime,
|
|
37
|
-
integrationStore: nullIntegrationStore,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function makeTempLog(lines: string[]): { logPath: string; cleanup: () => void } {
|
|
42
|
-
const dir = resolve(tmpdir(), `studio-test-logs-${randomUUID()}`);
|
|
43
|
-
mkdirSync(dir, { recursive: true });
|
|
44
|
-
const logPath = resolve(dir, 'test.jsonl');
|
|
45
|
-
writeFileSync(logPath, lines.join('\n') + '\n');
|
|
46
|
-
return { logPath, cleanup: () => rmSync(dir, { recursive: true }) };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
describe('POST /api/runs', () => {
|
|
50
|
-
it('returns 201 with run_id and status running', async () => {
|
|
51
|
-
const store = new InMemoryRunStore();
|
|
52
|
-
const launcher = { launch: vi.fn().mockResolvedValue({ run_id: 'generated-id' }), cancel: vi.fn() };
|
|
53
|
-
const server = makeServer(store, launcher);
|
|
54
|
-
|
|
55
|
-
const res = await server.inject({
|
|
56
|
-
method: 'POST',
|
|
57
|
-
url: '/api/runs',
|
|
58
|
-
payload: { pipeline: 'feature-builder', input: { brief: 'Add FAQ' } },
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
expect(res.statusCode).toBe(201);
|
|
62
|
-
const body = res.json();
|
|
63
|
-
expect(body.run_id).toBe('generated-id');
|
|
64
|
-
expect(body.status).toBe('running');
|
|
65
|
-
expect(body.stream_url).toBe('/api/runs/generated-id/stream');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('returns 400 if pipeline is missing', async () => {
|
|
69
|
-
const server = makeServer(new InMemoryRunStore());
|
|
70
|
-
const res = await server.inject({
|
|
71
|
-
method: 'POST',
|
|
72
|
-
url: '/api/runs',
|
|
73
|
-
payload: { input: {} },
|
|
74
|
-
});
|
|
75
|
-
expect(res.statusCode).toBe(400);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('returns 400 if input is missing', async () => {
|
|
79
|
-
const server = makeServer(new InMemoryRunStore());
|
|
80
|
-
const res = await server.inject({
|
|
81
|
-
method: 'POST',
|
|
82
|
-
url: '/api/runs',
|
|
83
|
-
payload: { pipeline: 'test' },
|
|
84
|
-
});
|
|
85
|
-
expect(res.statusCode).toBe(400);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('passes X-Studio-Depth and X-Studio-Parent-Run-Id headers to launcher', async () => {
|
|
89
|
-
const store = new InMemoryRunStore();
|
|
90
|
-
const launcher = { launch: vi.fn().mockResolvedValue({ run_id: 'r1' }), cancel: vi.fn() };
|
|
91
|
-
const server = makeServer(store, launcher);
|
|
92
|
-
const res = await server.inject({
|
|
93
|
-
method: 'POST',
|
|
94
|
-
url: '/api/runs',
|
|
95
|
-
headers: {
|
|
96
|
-
'content-type': 'application/json',
|
|
97
|
-
'x-studio-depth': '2',
|
|
98
|
-
'x-studio-parent-run-id': 'parent-abc',
|
|
99
|
-
},
|
|
100
|
-
payload: { pipeline: 'test', input: {} },
|
|
101
|
-
});
|
|
102
|
-
expect(res.statusCode).toBe(201);
|
|
103
|
-
expect(launcher.launch).toHaveBeenCalledWith(
|
|
104
|
-
expect.objectContaining({ depth: 2, parentRunId: 'parent-abc' })
|
|
105
|
-
);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('GET /api/runs', () => {
|
|
110
|
-
it('returns empty list when no runs', async () => {
|
|
111
|
-
const server = makeServer(new InMemoryRunStore());
|
|
112
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs' });
|
|
113
|
-
expect(res.statusCode).toBe(200);
|
|
114
|
-
expect(res.json()).toEqual({ runs: [] });
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('returns saved runs', async () => {
|
|
118
|
-
const store = new InMemoryRunStore();
|
|
119
|
-
store.savePipelineRun(makeRun({ id: 'run-1' }));
|
|
120
|
-
store.savePipelineRun(makeRun({ id: 'run-2', status: 'running' }));
|
|
121
|
-
const server = makeServer(store);
|
|
122
|
-
|
|
123
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs' });
|
|
124
|
-
expect(res.statusCode).toBe(200);
|
|
125
|
-
const { runs } = res.json() as { runs: PipelineRun[] };
|
|
126
|
-
expect(runs).toHaveLength(2);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('filters by status query param', async () => {
|
|
130
|
-
const store = new InMemoryRunStore();
|
|
131
|
-
store.savePipelineRun(makeRun({ id: 'r1', status: 'success' }));
|
|
132
|
-
store.savePipelineRun(makeRun({ id: 'r2', status: 'running' }));
|
|
133
|
-
const server = makeServer(store);
|
|
134
|
-
|
|
135
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs?status=success' });
|
|
136
|
-
const { runs } = res.json() as { runs: PipelineRun[] };
|
|
137
|
-
expect(runs).toHaveLength(1);
|
|
138
|
-
expect(runs[0].id).toBe('r1');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('respects limit query param', async () => {
|
|
142
|
-
const store = new InMemoryRunStore();
|
|
143
|
-
for (let i = 0; i < 5; i++) {
|
|
144
|
-
store.savePipelineRun(makeRun({ id: `run-${i}` }));
|
|
145
|
-
}
|
|
146
|
-
const server = makeServer(store);
|
|
147
|
-
|
|
148
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs?limit=2' });
|
|
149
|
-
const { runs } = res.json() as { runs: PipelineRun[] };
|
|
150
|
-
expect(runs).toHaveLength(2);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('GET /api/runs/:id', () => {
|
|
155
|
-
it('returns the run by id', async () => {
|
|
156
|
-
const store = new InMemoryRunStore();
|
|
157
|
-
store.savePipelineRun(makeRun({ id: 'target-run' }));
|
|
158
|
-
const server = makeServer(store);
|
|
159
|
-
|
|
160
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/target-run' });
|
|
161
|
-
expect(res.statusCode).toBe(200);
|
|
162
|
-
expect(res.json().id).toBe('target-run');
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('returns 404 for unknown run', async () => {
|
|
166
|
-
const server = makeServer(new InMemoryRunStore());
|
|
167
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/nonexistent' });
|
|
168
|
-
expect(res.statusCode).toBe(404);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('GET /api/runs/:id/logs', () => {
|
|
173
|
-
let store: InMemoryRunStore;
|
|
174
|
-
|
|
175
|
-
beforeEach(() => {
|
|
176
|
-
store = new InMemoryRunStore();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('returns 404 if run not found', async () => {
|
|
180
|
-
const server = makeServer(store);
|
|
181
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/unknown/logs' });
|
|
182
|
-
expect(res.statusCode).toBe(404);
|
|
183
|
-
expect(res.json().error).toMatch(/not found/i);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('returns 404 if run exists but log not yet available', async () => {
|
|
187
|
-
store.savePipelineRun(makeRun({ id: 'run-nolog' }));
|
|
188
|
-
// No saveLogPath call
|
|
189
|
-
const server = makeServer(store);
|
|
190
|
-
|
|
191
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-nolog/logs' });
|
|
192
|
-
expect(res.statusCode).toBe(404);
|
|
193
|
-
expect(res.json().error).toMatch(/not yet available/i);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('returns 404 if log path saved but file does not exist', async () => {
|
|
197
|
-
store.savePipelineRun(makeRun({ id: 'run-missing-file' }));
|
|
198
|
-
store.saveLogPath('run-missing-file', '/tmp/nonexistent-log.jsonl');
|
|
199
|
-
const server = makeServer(store);
|
|
200
|
-
|
|
201
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-missing-file/logs' });
|
|
202
|
-
expect(res.statusCode).toBe(404);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('returns structured JSON with parsed entries by default', async () => {
|
|
206
|
-
const { logPath, cleanup } = makeTempLog([
|
|
207
|
-
JSON.stringify({ ts: '2026-01-01T10:00:00Z', event: 'onPipelineStart', pipeline_name: 'feature-builder' }),
|
|
208
|
-
JSON.stringify({ ts: '2026-01-01T10:01:00Z', event: 'onStageComplete', stage_name: 'code-gen', status: 'success' }),
|
|
209
|
-
]);
|
|
210
|
-
store.savePipelineRun(makeRun({ id: 'run-structured' }));
|
|
211
|
-
store.saveLogPath('run-structured', logPath);
|
|
212
|
-
const server = makeServer(store);
|
|
213
|
-
|
|
214
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-structured/logs' });
|
|
215
|
-
cleanup();
|
|
216
|
-
|
|
217
|
-
expect(res.statusCode).toBe(200);
|
|
218
|
-
const body = res.json() as { run_id: string; entries: Array<{ event: string; timestamp: string; data: Record<string, unknown> }> };
|
|
219
|
-
expect(body.run_id).toBe('run-structured');
|
|
220
|
-
expect(body.entries).toHaveLength(2);
|
|
221
|
-
expect(body.entries[0]).toEqual({
|
|
222
|
-
event: 'onPipelineStart',
|
|
223
|
-
timestamp: '2026-01-01T10:00:00Z',
|
|
224
|
-
data: { pipeline_name: 'feature-builder' },
|
|
225
|
-
});
|
|
226
|
-
expect(body.entries[1]).toEqual({
|
|
227
|
-
event: 'onStageComplete',
|
|
228
|
-
timestamp: '2026-01-01T10:01:00Z',
|
|
229
|
-
data: { stage_name: 'code-gen', status: 'success' },
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('returns raw text/plain with ?raw=true', async () => {
|
|
234
|
-
const rawContent = JSON.stringify({ ts: '2026-01-01T10:00:00Z', event: 'onPipelineStart' }) + '\n';
|
|
235
|
-
const { logPath, cleanup } = makeTempLog([rawContent.trimEnd()]);
|
|
236
|
-
store.savePipelineRun(makeRun({ id: 'run-raw' }));
|
|
237
|
-
store.saveLogPath('run-raw', logPath);
|
|
238
|
-
const server = makeServer(store);
|
|
239
|
-
|
|
240
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-raw/logs?raw=true' });
|
|
241
|
-
cleanup();
|
|
242
|
-
|
|
243
|
-
expect(res.statusCode).toBe(200);
|
|
244
|
-
expect(res.headers['content-type']).toMatch(/text\/plain/);
|
|
245
|
-
expect(res.body).toBe(rawContent);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('skips malformed JSON lines in structured mode', async () => {
|
|
249
|
-
const { logPath, cleanup } = makeTempLog([
|
|
250
|
-
'not valid json',
|
|
251
|
-
JSON.stringify({ ts: '2026-01-01T10:00:00Z', event: 'onPipelineStart' }),
|
|
252
|
-
'{ broken',
|
|
253
|
-
]);
|
|
254
|
-
store.savePipelineRun(makeRun({ id: 'run-malformed' }));
|
|
255
|
-
store.saveLogPath('run-malformed', logPath);
|
|
256
|
-
const server = makeServer(store);
|
|
257
|
-
|
|
258
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-malformed/logs' });
|
|
259
|
-
cleanup();
|
|
260
|
-
|
|
261
|
-
expect(res.statusCode).toBe(200);
|
|
262
|
-
const body = res.json() as { entries: unknown[] };
|
|
263
|
-
expect(body.entries).toHaveLength(1);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('skips lines without event field in structured mode', async () => {
|
|
267
|
-
const { logPath, cleanup } = makeTempLog([
|
|
268
|
-
JSON.stringify({ ts: '2026-01-01T10:00:00Z', event: 'onPipelineStart' }),
|
|
269
|
-
JSON.stringify({ ts: '2026-01-01T10:00:01Z', some_field: 'no_event_here' }),
|
|
270
|
-
]);
|
|
271
|
-
store.savePipelineRun(makeRun({ id: 'run-no-event' }));
|
|
272
|
-
store.saveLogPath('run-no-event', logPath);
|
|
273
|
-
const server = makeServer(store);
|
|
274
|
-
|
|
275
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-no-event/logs' });
|
|
276
|
-
cleanup();
|
|
277
|
-
|
|
278
|
-
expect(res.statusCode).toBe(200);
|
|
279
|
-
const body = res.json() as { entries: unknown[] };
|
|
280
|
-
expect(body.entries).toHaveLength(1);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('returns empty entries array for empty log file in structured mode', async () => {
|
|
284
|
-
const { logPath, cleanup } = makeTempLog([]);
|
|
285
|
-
store.savePipelineRun(makeRun({ id: 'run-empty-log' }));
|
|
286
|
-
store.saveLogPath('run-empty-log', logPath);
|
|
287
|
-
const server = makeServer(store);
|
|
288
|
-
|
|
289
|
-
const res = await server.inject({ method: 'GET', url: '/api/runs/run-empty-log/logs' });
|
|
290
|
-
cleanup();
|
|
291
|
-
|
|
292
|
-
expect(res.statusCode).toBe(200);
|
|
293
|
-
const body = res.json() as { run_id: string; entries: unknown[] };
|
|
294
|
-
expect(body.run_id).toBe('run-empty-log');
|
|
295
|
-
expect(body.entries).toHaveLength(0);
|
|
296
|
-
});
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
describe('POST /api/runs/:id/retry', () => {
|
|
300
|
-
it('returns 404 if original run does not exist', async () => {
|
|
301
|
-
const server = makeServer(new InMemoryRunStore());
|
|
302
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/nonexistent/retry' });
|
|
303
|
-
expect(res.statusCode).toBe(404);
|
|
304
|
-
expect(res.json().error).toMatch(/not found/i);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('returns 422 if original run has no stored input', async () => {
|
|
308
|
-
const store = new InMemoryRunStore();
|
|
309
|
-
store.savePipelineRun(makeRun({ id: 'run-no-input' }));
|
|
310
|
-
const server = makeServer(store);
|
|
311
|
-
|
|
312
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-no-input/retry' });
|
|
313
|
-
expect(res.statusCode).toBe(422);
|
|
314
|
-
expect(res.json().error).toMatch(/no stored input/i);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('creates a new run and returns 201 with parent_run_id', async () => {
|
|
318
|
-
const store = new InMemoryRunStore();
|
|
319
|
-
store.savePipelineRun(makeRun({
|
|
320
|
-
id: 'run-original',
|
|
321
|
-
pipeline_name: 'feature-builder',
|
|
322
|
-
input: { brief: 'Add FAQ' },
|
|
323
|
-
}));
|
|
324
|
-
|
|
325
|
-
const launcher = { launch: vi.fn().mockResolvedValue({ run_id: 'retry-run-id' }), cancel: vi.fn(), subscribe: vi.fn() };
|
|
326
|
-
const server = makeServer(store, launcher);
|
|
327
|
-
|
|
328
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-original/retry' });
|
|
329
|
-
|
|
330
|
-
expect(res.statusCode).toBe(201);
|
|
331
|
-
const body = res.json();
|
|
332
|
-
expect(body.run_id).toBe('retry-run-id');
|
|
333
|
-
expect(body.status).toBe('running');
|
|
334
|
-
expect(body.parent_run_id).toBe('run-original');
|
|
335
|
-
expect(body.stream_url).toBe('/api/runs/retry-run-id/stream');
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it('launches with same pipeline and input as original', async () => {
|
|
339
|
-
const store = new InMemoryRunStore();
|
|
340
|
-
store.savePipelineRun(makeRun({
|
|
341
|
-
id: 'run-original',
|
|
342
|
-
pipeline_name: 'feature-builder',
|
|
343
|
-
input: { brief: 'Add FAQ', target: 'src/about.tsx' },
|
|
344
|
-
}));
|
|
345
|
-
|
|
346
|
-
const launcher = { launch: vi.fn().mockResolvedValue({ run_id: 'retry-run-id' }), cancel: vi.fn(), subscribe: vi.fn() };
|
|
347
|
-
const server = makeServer(store, launcher);
|
|
348
|
-
|
|
349
|
-
await server.inject({ method: 'POST', url: '/api/runs/run-original/retry' });
|
|
350
|
-
|
|
351
|
-
expect(launcher.launch).toHaveBeenCalledOnce();
|
|
352
|
-
const launchArgs = launcher.launch.mock.calls[0][0];
|
|
353
|
-
expect(launchArgs.pipeline).toBe('feature-builder');
|
|
354
|
-
expect(launchArgs.input).toEqual({ brief: 'Add FAQ', target: 'src/about.tsx' });
|
|
355
|
-
expect(launchArgs.parentRunId).toBe('run-original');
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('can retry a run with status success', async () => {
|
|
359
|
-
const store = new InMemoryRunStore();
|
|
360
|
-
store.savePipelineRun(makeRun({ id: 'run-success', status: 'success', input: { brief: 'x' } }));
|
|
361
|
-
|
|
362
|
-
const launcher = { launch: vi.fn().mockResolvedValue({ run_id: 'r2' }), cancel: vi.fn(), subscribe: vi.fn() };
|
|
363
|
-
const server = makeServer(store, launcher);
|
|
364
|
-
|
|
365
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-success/retry' });
|
|
366
|
-
expect(res.statusCode).toBe(201);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('can retry a run with status failed', async () => {
|
|
370
|
-
const store = new InMemoryRunStore();
|
|
371
|
-
store.savePipelineRun(makeRun({ id: 'run-failed', status: 'failed', input: { brief: 'x' } }));
|
|
372
|
-
|
|
373
|
-
const launcher = { launch: vi.fn().mockResolvedValue({ run_id: 'r3' }), cancel: vi.fn(), subscribe: vi.fn() };
|
|
374
|
-
const server = makeServer(store, launcher);
|
|
375
|
-
|
|
376
|
-
const res = await server.inject({ method: 'POST', url: '/api/runs/run-failed/retry' });
|
|
377
|
-
expect(res.statusCode).toBe(201);
|
|
378
|
-
});
|
|
379
|
-
});
|