@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.
Files changed (62) hide show
  1. package/package.json +7 -4
  2. package/ARCHITECTURE.md +0 -52
  3. package/src/.gitkeep +0 -0
  4. package/src/api.ts +0 -8
  5. package/src/bootstrap.ts +0 -259
  6. package/src/event-bus.ts +0 -64
  7. package/src/index.ts +0 -58
  8. package/src/integration-runtime.ts +0 -180
  9. package/src/integration-store.ts +0 -125
  10. package/src/integrations/linear/failure-handler.ts +0 -93
  11. package/src/integrations/linear/webhook-handler.ts +0 -156
  12. package/src/integrations/registry.ts +0 -12
  13. package/src/integrations/types.ts +0 -37
  14. package/src/launcher.ts +0 -214
  15. package/src/logger.ts +0 -50
  16. package/src/plans.ts +0 -43
  17. package/src/routes/agents.ts +0 -131
  18. package/src/routes/config.ts +0 -154
  19. package/src/routes/contracts.ts +0 -205
  20. package/src/routes/pipelines.ts +0 -146
  21. package/src/routes/projects.ts +0 -237
  22. package/src/routes/runs.ts +0 -468
  23. package/src/routes/skills.ts +0 -136
  24. package/src/routes/tools.ts +0 -222
  25. package/src/routes/users.ts +0 -169
  26. package/src/routes/validate.ts +0 -196
  27. package/src/routes/webhooks.ts +0 -120
  28. package/src/server.ts +0 -155
  29. package/src/spawners/http-api-spawner.ts +0 -96
  30. package/src/user-store-pg.ts +0 -138
  31. package/src/user-store.ts +0 -125
  32. package/src/utils/repo-resolver.ts +0 -3
  33. package/src/webhook-dispatcher.ts +0 -142
  34. package/src/webhook-store.ts +0 -141
  35. package/tests/agents.test.ts +0 -164
  36. package/tests/cancel.test.ts +0 -120
  37. package/tests/config.test.ts +0 -196
  38. package/tests/contracts.test.ts +0 -302
  39. package/tests/event-bus.test.ts +0 -53
  40. package/tests/http-api-spawner.test.ts +0 -158
  41. package/tests/integration-runtime.test.ts +0 -199
  42. package/tests/integration-store.test.ts +0 -66
  43. package/tests/integrations/linear/failure-handler.test.ts +0 -113
  44. package/tests/integrations/linear/webhook-handler.test.ts +0 -191
  45. package/tests/launcher.test.ts +0 -380
  46. package/tests/linear-webhook.test.ts +0 -390
  47. package/tests/pipelines.test.ts +0 -166
  48. package/tests/projects.test.ts +0 -283
  49. package/tests/runs.test.ts +0 -379
  50. package/tests/server.test.ts +0 -208
  51. package/tests/skills.test.ts +0 -149
  52. package/tests/sse.test.ts +0 -129
  53. package/tests/tools.test.ts +0 -233
  54. package/tests/user-store.test.ts +0 -93
  55. package/tests/users.test.ts +0 -189
  56. package/tests/utils/repo-resolver.test.ts +0 -105
  57. package/tests/validate.test.ts +0 -233
  58. package/tests/webhook-dispatcher.test.ts +0 -214
  59. package/tests/webhook-store.test.ts +0 -98
  60. package/tests/webhooks.test.ts +0 -176
  61. package/tsconfig.json +0 -24
  62. package/vitest.config.ts +0 -7
@@ -1,237 +0,0 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import * as yaml from 'js-yaml';
4
- import { createHash } from 'node:crypto';
5
- import type { FastifyInstance } from 'fastify';
6
- import type { ServerDeps } from '../server.js';
7
-
8
- function projectId(configsDir: string): string {
9
- return createHash('sha256').update(configsDir).digest('hex').slice(0, 12);
10
- }
11
-
12
- async function listResources(dir: string, suffix: string): Promise<string[]> {
13
- try {
14
- const entries = await readdir(dir);
15
- return entries
16
- .filter(f => f.endsWith(suffix))
17
- .map(f => f.slice(0, -suffix.length));
18
- } catch {
19
- return [];
20
- }
21
- }
22
-
23
- export async function projectsRoutes(
24
- fastify: FastifyInstance,
25
- options: { deps: ServerDeps }
26
- ): Promise<void> {
27
- const { configsDir, projectName, studioVersion, maskedConfig } = options.deps;
28
- const id = projectId(configsDir);
29
-
30
- const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
31
-
32
- // GET /api/projects — returns the single project this API serves
33
- fastify.get('/projects', {
34
- schema: {
35
- tags: ['projects'],
36
- summary: 'List projects served by this API instance',
37
- response: {
38
- 200: {
39
- type: 'object',
40
- properties: {
41
- projects: {
42
- type: 'array',
43
- items: {
44
- type: 'object',
45
- properties: {
46
- id: { type: 'string' },
47
- name: { type: 'string' },
48
- pipelines_dir: { type: 'string' },
49
- },
50
- },
51
- },
52
- },
53
- },
54
- },
55
- },
56
- }, async (_request, reply) => {
57
- return reply.send({
58
- projects: [
59
- {
60
- id,
61
- name: projectName,
62
- pipelines_dir: join(configsDir, 'pipelines'),
63
- },
64
- ],
65
- });
66
- });
67
-
68
- // GET /api/projects/:id/pipelines
69
- fastify.get<{ Params: { id: string } }>('/projects/:id/pipelines', {
70
- schema: {
71
- tags: ['projects'],
72
- summary: 'List pipelines for a project',
73
- params: {
74
- type: 'object',
75
- properties: { id: { type: 'string' } },
76
- required: ['id'],
77
- },
78
- response: {
79
- 200: {
80
- type: 'object',
81
- properties: {
82
- pipelines: { type: 'array', items: { type: 'string' } },
83
- },
84
- },
85
- 404: errorSchema,
86
- },
87
- },
88
- }, async (request, reply) => {
89
- if (request.params.id !== id) {
90
- return reply.status(404).send({ error: 'Project not found' });
91
- }
92
-
93
- const pipelinesDir = join(configsDir, 'pipelines');
94
- let entries: string[];
95
- try {
96
- entries = await readdir(pipelinesDir);
97
- } catch {
98
- return reply.send({ pipelines: [] });
99
- }
100
-
101
- const pipelines = entries
102
- .filter(f => f.endsWith('.pipeline.yaml'))
103
- .map(f => f.replace('.pipeline.yaml', ''));
104
-
105
- return reply.send({ pipelines });
106
- });
107
- // GET /api/projects/:id/inputs
108
- fastify.get<{ Params: { id: string } }>('/projects/:id/inputs', {
109
- schema: {
110
- tags: ['projects'],
111
- summary: 'List inputs for a project',
112
- params: {
113
- type: 'object',
114
- properties: { id: { type: 'string' } },
115
- required: ['id'],
116
- },
117
- response: {
118
- 200: {
119
- type: 'object',
120
- properties: {
121
- inputs: { type: 'array', items: { type: 'string' } },
122
- },
123
- },
124
- 404: errorSchema,
125
- },
126
- },
127
- }, async (request, reply) => {
128
- if (request.params.id !== id) {
129
- return reply.status(404).send({ error: 'Project not found' });
130
- }
131
-
132
- const inputsDir = join(configsDir, 'inputs');
133
- let entries: string[];
134
- try {
135
- entries = await readdir(inputsDir);
136
- } catch {
137
- return reply.send({ inputs: [] });
138
- }
139
-
140
- const inputs = entries
141
- .filter(f => f.endsWith('.input.yaml'))
142
- .map(f => f.replace('.input.yaml', ''));
143
-
144
- return reply.send({ inputs });
145
- });
146
-
147
-
148
- // GET /api/projects/:id/inputs/:name — read an input file (YAML parsed to JSON)
149
- fastify.get<{ Params: { id: string; name: string } }>('/projects/:id/inputs/:name', {
150
- schema: {
151
- tags: ['projects'],
152
- summary: 'Get an input file by name',
153
- params: {
154
- type: 'object',
155
- properties: {
156
- id: { type: 'string' },
157
- name: { type: 'string' },
158
- },
159
- required: ['id', 'name'],
160
- },
161
- response: {
162
- 200: { type: 'object', additionalProperties: true },
163
- 404: errorSchema,
164
- },
165
- },
166
- }, async (request, reply) => {
167
- if (request.params.id !== id) {
168
- return reply.status(404).send({ error: 'Project not found' });
169
- }
170
-
171
- const filePath = join(configsDir, 'inputs', `${request.params.name}.input.yaml`);
172
- let content: string;
173
- try {
174
- content = await readFile(filePath, 'utf-8');
175
- } catch {
176
- return reply.status(404).send({ error: 'Input not found' });
177
- }
178
- return reply.send(yaml.load(content));
179
- });
180
-
181
- // GET /api/project — full introspection of the current Studio project
182
- fastify.get('/project', {
183
- schema: {
184
- tags: ['projects'],
185
- summary: 'Introspect the current Studio project',
186
- response: {
187
- 200: {
188
- type: 'object',
189
- properties: {
190
- studio_version: { type: 'string' },
191
- studio_dir: { type: 'string' },
192
- config: {
193
- type: 'object',
194
- properties: {
195
- defaults: {
196
- type: 'object',
197
- properties: {
198
- provider: { type: 'string' },
199
- model: { type: 'string' },
200
- },
201
- },
202
- providers: { type: 'array', items: { type: 'string' } },
203
- },
204
- },
205
- pipelines: { type: 'array', items: { type: 'string' } },
206
- contracts: { type: 'array', items: { type: 'string' } },
207
- agents: { type: 'array', items: { type: 'string' } },
208
- tools: { type: 'array', items: { type: 'string' } },
209
- skills: { type: 'array', items: { type: 'string' } },
210
- inputs: { type: 'array', items: { type: 'string' } },
211
- },
212
- },
213
- },
214
- },
215
- }, async (_request, reply) => {
216
- const [pipelines, contracts, agents, tools, skills, inputs] = await Promise.all([
217
- listResources(join(configsDir, 'pipelines'), '.pipeline.yaml'),
218
- listResources(join(configsDir, 'contracts'), '.contract.yaml'),
219
- listResources(join(configsDir, 'agents'), '.agent.yaml'),
220
- listResources(join(configsDir, 'tools'), '.tool.yaml'),
221
- listResources(join(configsDir, 'skills'), '.skill.md'),
222
- listResources(join(configsDir, 'inputs'), '.input.yaml'),
223
- ]);
224
-
225
- return reply.send({
226
- studio_version: studioVersion,
227
- studio_dir: configsDir,
228
- config: maskedConfig,
229
- pipelines,
230
- contracts,
231
- agents,
232
- tools,
233
- skills,
234
- inputs,
235
- });
236
- });
237
- }
@@ -1,468 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { randomUUID } from 'node:crypto';
4
- import type { FastifyInstance, FastifyReply } from 'fastify';
5
- import type { ServerDeps } from '../server.js';
6
- import { resolveRepoPath } from '../utils/repo-resolver.js';
7
- import { loadPipelineByName } from '@studio-foundation/engine';
8
-
9
- async function replayJsonl(
10
- logPath: string,
11
- send: (type: string, data: unknown) => void,
12
- ): Promise<void> {
13
- let content: string;
14
- try {
15
- content = await readFile(logPath, 'utf-8');
16
- } catch {
17
- return; // log not yet written or missing
18
- }
19
- for (const line of content.split('\n')) {
20
- const trimmed = line.trim();
21
- if (!trimmed) continue;
22
- try {
23
- const parsed = JSON.parse(trimmed) as { event?: string } & Record<string, unknown>;
24
- if (parsed.event) send(parsed.event, parsed);
25
- } catch {
26
- // skip malformed lines
27
- }
28
- }
29
- }
30
-
31
- const stageRunSchema = {
32
- type: 'object',
33
- properties: {
34
- id: { type: 'string' },
35
- stage_name: { type: 'string' },
36
- status: { type: 'string' },
37
- started_at: { type: 'string' },
38
- completed_at: { type: 'string' },
39
- tasks: { type: 'array', items: { type: 'object', additionalProperties: true } },
40
- },
41
- };
42
-
43
- const pipelineRunSchema = {
44
- type: 'object',
45
- properties: {
46
- id: { type: 'string' },
47
- pipeline_name: { type: 'string' },
48
- status: { type: 'string', enum: ['pending', 'running', 'success', 'failed', 'rejected', 'skipped', 'cancelled'] },
49
- started_at: { type: 'string' },
50
- completed_at: { type: 'string' },
51
- stages: { type: 'array', items: stageRunSchema },
52
- parent_run_id: { type: 'string' },
53
- },
54
- };
55
-
56
- const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
57
-
58
- export async function runsRoutes(
59
- fastify: FastifyInstance,
60
- options: { deps: ServerDeps }
61
- ): Promise<void> {
62
- const { store, launcher } = options.deps;
63
-
64
- // Shared cancel handler — used by both POST /runs/:id/cancel and DELETE /runs/:id
65
- const handleCancel = async (id: string, reply: FastifyReply): Promise<unknown> => {
66
- const run = await store.getPipelineRun(id);
67
- if (!run) {
68
- return reply.status(404).send({ error: 'Run not found' });
69
- }
70
- if (run.status !== 'running') {
71
- return reply.status(409).send({ error: `Run is not cancellable (status: ${run.status})` });
72
- }
73
- await launcher.cancel(id);
74
- return reply.send({ run_id: id });
75
- };
76
-
77
- // POST /api/runs — fire-and-forget
78
- fastify.post<{
79
- Body: { pipeline: string; input: Record<string, unknown>; provider?: string };
80
- }>('/runs', {
81
- schema: {
82
- tags: ['runs'],
83
- summary: 'Start a pipeline run',
84
- body: {
85
- type: 'object',
86
- required: ['pipeline', 'input'],
87
- properties: {
88
- pipeline: { type: 'string' },
89
- input: {
90
- type: 'object',
91
- description: 'Pipeline input. If input contains "repo_url", the repo is cloned before the run starts.',
92
- },
93
- provider: { type: 'string' },
94
- },
95
- },
96
- response: {
97
- 201: {
98
- type: 'object',
99
- properties: {
100
- run_id: { type: 'string' },
101
- status: { type: 'string' },
102
- stream_url: { type: 'string' },
103
- },
104
- },
105
- 400: errorSchema,
106
- 429: errorSchema,
107
- },
108
- },
109
- }, async (request, reply) => {
110
- const { pipeline, provider } = request.body;
111
- const runId = randomUUID();
112
- const depth = parseInt((request.headers['x-studio-depth'] as string) ?? '0', 10) || 0;
113
- const parentRunId = request.headers['x-studio-parent-run-id'] as string | undefined;
114
-
115
- // Extract repo_url from input (like CLI does with --input-file), then strip it before passing to engine
116
- const input = { ...request.body.input };
117
- const inputRepoUrl = typeof input['repo_url'] === 'string' ? input['repo_url'] : undefined;
118
- if (inputRepoUrl !== undefined) delete input['repo_url'];
119
-
120
- // Fall back to pipeline YAML repo.url when not provided in input (mirrors CLI behaviour)
121
- let pipelineRepoUrl: string | undefined;
122
- let pipelineRepoBranch: string | undefined;
123
- try {
124
- const pipelineDef = await loadPipelineByName(pipeline, join(options.deps.configsDir, 'pipelines'));
125
- pipelineRepoUrl = pipelineDef.repo?.url;
126
- pipelineRepoBranch = pipelineDef.repo?.branch;
127
- } catch {
128
- // Pipeline not found — launcher.launch() will throw a proper error later
129
- }
130
-
131
- const repoUrl = inputRepoUrl ?? pipelineRepoUrl;
132
- let repoPath: string;
133
- try {
134
- repoPath = await resolveRepoPath({
135
- repoUrl,
136
- rawProjectsDir: options.deps.projectsDir,
137
- pipelineName: pipeline,
138
- branch: pipelineRepoBranch,
139
- });
140
- } catch (err) {
141
- return reply.status(400).send({ error: err instanceof Error ? err.message : String(err) });
142
- }
143
-
144
- let run_id: string;
145
- try {
146
- const result = await launcher.launch({
147
- runId,
148
- pipeline,
149
- input,
150
- configsDir: options.deps.configsDir,
151
- repoPath,
152
- providerOverride: provider,
153
- depth,
154
- parentRunId,
155
- userId: request.user?.id,
156
- });
157
- run_id = result.run_id;
158
- } catch (err) {
159
- const code = (err as { code?: string }).code;
160
- if (code === 'QUOTA_EXCEEDED') {
161
- return reply.status(429).send({ error: err instanceof Error ? err.message : String(err) });
162
- }
163
- return reply.status(500).send({ error: err instanceof Error ? err.message : String(err) });
164
- }
165
-
166
- return reply.status(201).send({
167
- run_id,
168
- status: 'running',
169
- stream_url: `/api/runs/${run_id}/stream`,
170
- });
171
- });
172
-
173
- // GET /api/runs
174
- fastify.get<{
175
- Querystring: { status?: string; limit?: string };
176
- }>('/runs', {
177
- schema: {
178
- tags: ['runs'],
179
- summary: 'List pipeline runs',
180
- querystring: {
181
- type: 'object',
182
- properties: {
183
- status: { type: 'string' },
184
- limit: { type: 'string' },
185
- },
186
- },
187
- response: {
188
- 200: {
189
- type: 'object',
190
- properties: {
191
- runs: { type: 'array', items: pipelineRunSchema },
192
- },
193
- },
194
- },
195
- },
196
- }, async (request, reply) => {
197
- const { status, limit } = request.query;
198
- const runs = await store.listPipelineRuns({
199
- status,
200
- limit: limit ? parseInt(limit, 10) : undefined,
201
- });
202
- return reply.send({ runs });
203
- });
204
-
205
- // GET /api/runs/:id
206
- fastify.get<{ Params: { id: string } }>('/runs/:id', {
207
- schema: {
208
- tags: ['runs'],
209
- summary: 'Get a run by ID',
210
- params: {
211
- type: 'object',
212
- properties: { id: { type: 'string' } },
213
- required: ['id'],
214
- },
215
- response: {
216
- 200: pipelineRunSchema,
217
- 404: errorSchema,
218
- },
219
- },
220
- }, async (request, reply) => {
221
- const run = await store.getPipelineRun(request.params.id);
222
- if (!run) {
223
- return reply.status(404).send({ error: 'Run not found' });
224
- }
225
- return reply.send(run);
226
- });
227
-
228
- // GET /api/runs/:id/logs
229
- fastify.get<{
230
- Params: { id: string };
231
- Querystring: { raw?: string };
232
- }>('/runs/:id/logs', {
233
- schema: {
234
- tags: ['runs'],
235
- summary: 'Get run logs (parsed JSONL or raw text)',
236
- params: {
237
- type: 'object',
238
- properties: { id: { type: 'string' } },
239
- required: ['id'],
240
- },
241
- querystring: {
242
- type: 'object',
243
- properties: { raw: { type: 'string', description: 'Set to "true" for raw JSONL text' } },
244
- },
245
- response: {
246
- 200: {
247
- type: 'object',
248
- properties: {
249
- run_id: { type: 'string' },
250
- entries: {
251
- type: 'array',
252
- items: {
253
- type: 'object',
254
- properties: {
255
- event: { type: 'string' },
256
- timestamp: { type: 'string' },
257
- data: { type: 'object', additionalProperties: true },
258
- },
259
- },
260
- },
261
- },
262
- },
263
- 404: errorSchema,
264
- },
265
- },
266
- }, async (request, reply) => {
267
- const { id } = request.params;
268
- const isRaw = request.query.raw === 'true';
269
-
270
- const run = await store.getPipelineRun(id);
271
- if (!run) {
272
- return reply.status(404).send({ error: 'Run not found' });
273
- }
274
-
275
- const logPath = await store.getLogPath(id);
276
- if (!logPath) {
277
- return reply.status(404).send({ error: 'Log not yet available' });
278
- }
279
-
280
- let content: string;
281
- try {
282
- content = await readFile(logPath, 'utf-8');
283
- } catch {
284
- return reply.status(404).send({ error: 'Log file not found' });
285
- }
286
-
287
- if (isRaw) {
288
- return reply.type('text/plain').send(content);
289
- }
290
-
291
- const entries: Array<{ event: string; timestamp: string; data: Record<string, unknown> }> = [];
292
- for (const line of content.split('\n')) {
293
- const trimmed = line.trim();
294
- if (!trimmed) continue;
295
- try {
296
- const parsed = JSON.parse(trimmed) as Record<string, unknown>;
297
- const { event, ts, ...data } = parsed;
298
- if (typeof event !== 'string') continue;
299
- entries.push({ event, timestamp: typeof ts === 'string' ? ts : '', data });
300
- } catch {
301
- // skip malformed lines
302
- }
303
- }
304
-
305
- return reply.send({ run_id: id, entries });
306
- });
307
-
308
-
309
- // POST /api/runs/:id/cancel
310
- fastify.post<{ Params: { id: string } }>('/runs/:id/cancel', {
311
- schema: {
312
- tags: ['runs'],
313
- summary: 'Cancel a running pipeline',
314
- params: {
315
- type: 'object',
316
- properties: { id: { type: 'string' } },
317
- required: ['id'],
318
- },
319
- response: {
320
- 200: {
321
- type: 'object',
322
- properties: { run_id: { type: 'string' } },
323
- },
324
- 404: errorSchema,
325
- 409: errorSchema,
326
- },
327
- },
328
- }, async (request, reply) => {
329
- return handleCancel(request.params.id, reply);
330
- });
331
-
332
- // DELETE /api/runs/:id — cancel a running pipeline (spec-aligned alias for POST /runs/:id/cancel)
333
- fastify.delete<{ Params: { id: string } }>('/runs/:id', {
334
- schema: {
335
- tags: ['runs'],
336
- summary: 'Cancel a running pipeline (DELETE alias for POST /runs/:id/cancel)',
337
- params: {
338
- type: 'object',
339
- properties: { id: { type: 'string' } },
340
- required: ['id'],
341
- },
342
- response: {
343
- 200: {
344
- type: 'object',
345
- properties: { run_id: { type: 'string' } },
346
- },
347
- 404: errorSchema,
348
- 409: errorSchema,
349
- },
350
- },
351
- }, async (request, reply) => {
352
- return handleCancel(request.params.id, reply);
353
- });
354
-
355
- // GET /api/runs/:id/stream — SSE
356
- fastify.get<{
357
- Params: { id: string };
358
- Querystring: { events?: string };
359
- }>('/runs/:id/stream', {
360
- schema: {
361
- tags: ['runs'],
362
- summary: 'Stream run events via SSE',
363
- params: {
364
- type: 'object',
365
- properties: { id: { type: 'string' } },
366
- required: ['id'],
367
- },
368
- querystring: {
369
- type: 'object',
370
- properties: { events: { type: 'string', description: 'Comma-separated event types to filter' } },
371
- },
372
- response: {
373
- 404: errorSchema,
374
- },
375
- },
376
- }, async (request, reply) => {
377
- const { id } = request.params;
378
- const filterParam = request.query.events;
379
- const filter = filterParam ? filterParam.split(',') : null;
380
-
381
- const run = await store.getPipelineRun(id);
382
- if (!run) {
383
- return reply.status(404).send({ error: 'Run not found' });
384
- }
385
-
386
- reply.raw.writeHead(200, {
387
- 'Content-Type': 'text/event-stream',
388
- 'Cache-Control': 'no-cache',
389
- 'Connection': 'keep-alive',
390
- });
391
-
392
- const send = (type: string, data: unknown) => {
393
- if (filter && !filter.includes(type)) return;
394
- reply.raw.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
395
- };
396
-
397
- // Replay historical events from JSONL
398
- const logPath = await store.getLogPath(id);
399
- if (logPath) await replayJsonl(logPath, send);
400
-
401
- const TERMINAL = ['success', 'failed', 'rejected', 'cancelled'];
402
- if (TERMINAL.includes(run.status)) {
403
- reply.raw.end();
404
- return reply;
405
- }
406
-
407
- // Subscribe to live events
408
- const unsub = options.deps.launcher.subscribe(id, ({ type, data }) => send(type, data));
409
-
410
- // Cleanup on client disconnect
411
- request.raw.on('close', unsub);
412
-
413
- // Keep connection open — Fastify won't auto-close since we used reply.raw
414
- return reply;
415
- });
416
-
417
- // POST /api/runs/:id/retry — re-run with the same pipeline + input
418
- fastify.post<{ Params: { id: string } }>('/runs/:id/retry', {
419
- schema: {
420
- tags: ['runs'],
421
- summary: 'Retry a run with the same parameters',
422
- params: {
423
- type: 'object',
424
- properties: { id: { type: 'string' } },
425
- required: ['id'],
426
- },
427
- response: {
428
- 201: {
429
- type: 'object',
430
- properties: {
431
- run_id: { type: 'string' },
432
- status: { type: 'string' },
433
- stream_url: { type: 'string' },
434
- parent_run_id: { type: 'string' },
435
- },
436
- },
437
- 404: errorSchema,
438
- 422: errorSchema,
439
- },
440
- },
441
- }, async (request, reply) => {
442
- const { id } = request.params;
443
-
444
- const originalRun = await store.getPipelineRun(id);
445
- if (!originalRun) {
446
- return reply.status(404).send({ error: 'Run not found' });
447
- }
448
-
449
- if (!originalRun.input) {
450
- return reply.status(422).send({ error: 'Original run has no stored input — cannot retry' });
451
- }
452
-
453
- const { run_id: runId } = await launcher.launch({
454
- runId: randomUUID(),
455
- pipeline: originalRun.pipeline_name,
456
- input: originalRun.input,
457
- configsDir: options.deps.configsDir,
458
- parentRunId: id,
459
- });
460
-
461
- return reply.status(201).send({
462
- run_id: runId,
463
- status: 'running',
464
- stream_url: `/api/runs/${runId}/stream`,
465
- parent_run_id: id,
466
- });
467
- });
468
- }