@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.
Files changed (188) hide show
  1. package/ARCHITECTURE.md +52 -0
  2. package/LICENSE +663 -0
  3. package/README.md +161 -0
  4. package/dist/api.d.ts +8 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +6 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/bootstrap.d.ts +66 -0
  9. package/dist/bootstrap.d.ts.map +1 -0
  10. package/dist/bootstrap.js +193 -0
  11. package/dist/bootstrap.js.map +1 -0
  12. package/dist/event-bus.d.ts +16 -0
  13. package/dist/event-bus.d.ts.map +1 -0
  14. package/dist/event-bus.js +37 -0
  15. package/dist/event-bus.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +52 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/integration-runtime.d.ts +22 -0
  21. package/dist/integration-runtime.d.ts.map +1 -0
  22. package/dist/integration-runtime.js +150 -0
  23. package/dist/integration-runtime.js.map +1 -0
  24. package/dist/integration-store.d.ts +26 -0
  25. package/dist/integration-store.d.ts.map +1 -0
  26. package/dist/integration-store.js +80 -0
  27. package/dist/integration-store.js.map +1 -0
  28. package/dist/integrations/linear/failure-handler.d.ts +5 -0
  29. package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
  30. package/dist/integrations/linear/failure-handler.js +73 -0
  31. package/dist/integrations/linear/failure-handler.js.map +1 -0
  32. package/dist/integrations/linear/webhook-handler.d.ts +6 -0
  33. package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
  34. package/dist/integrations/linear/webhook-handler.js +131 -0
  35. package/dist/integrations/linear/webhook-handler.js.map +1 -0
  36. package/dist/integrations/registry.d.ts +4 -0
  37. package/dist/integrations/registry.d.ts.map +1 -0
  38. package/dist/integrations/registry.js +9 -0
  39. package/dist/integrations/registry.js.map +1 -0
  40. package/dist/integrations/types.d.ts +33 -0
  41. package/dist/integrations/types.d.ts.map +1 -0
  42. package/dist/integrations/types.js +2 -0
  43. package/dist/integrations/types.js.map +1 -0
  44. package/dist/launcher.d.ts +45 -0
  45. package/dist/launcher.d.ts.map +1 -0
  46. package/dist/launcher.js +149 -0
  47. package/dist/launcher.js.map +1 -0
  48. package/dist/logger.d.ts +7 -0
  49. package/dist/logger.d.ts.map +1 -0
  50. package/dist/logger.js +37 -0
  51. package/dist/logger.js.map +1 -0
  52. package/dist/plans.d.ts +13 -0
  53. package/dist/plans.d.ts.map +1 -0
  54. package/dist/plans.js +33 -0
  55. package/dist/plans.js.map +1 -0
  56. package/dist/routes/agents.d.ts +6 -0
  57. package/dist/routes/agents.d.ts.map +1 -0
  58. package/dist/routes/agents.js +118 -0
  59. package/dist/routes/agents.js.map +1 -0
  60. package/dist/routes/config.d.ts +6 -0
  61. package/dist/routes/config.d.ts.map +1 -0
  62. package/dist/routes/config.js +129 -0
  63. package/dist/routes/config.js.map +1 -0
  64. package/dist/routes/contracts.d.ts +6 -0
  65. package/dist/routes/contracts.d.ts.map +1 -0
  66. package/dist/routes/contracts.js +188 -0
  67. package/dist/routes/contracts.js.map +1 -0
  68. package/dist/routes/pipelines.d.ts +6 -0
  69. package/dist/routes/pipelines.d.ts.map +1 -0
  70. package/dist/routes/pipelines.js +133 -0
  71. package/dist/routes/pipelines.js.map +1 -0
  72. package/dist/routes/projects.d.ts +6 -0
  73. package/dist/routes/projects.d.ts.map +1 -0
  74. package/dist/routes/projects.js +220 -0
  75. package/dist/routes/projects.js.map +1 -0
  76. package/dist/routes/runs.d.ts +6 -0
  77. package/dist/routes/runs.d.ts.map +1 -0
  78. package/dist/routes/runs.js +427 -0
  79. package/dist/routes/runs.js.map +1 -0
  80. package/dist/routes/skills.d.ts +6 -0
  81. package/dist/routes/skills.d.ts.map +1 -0
  82. package/dist/routes/skills.js +123 -0
  83. package/dist/routes/skills.js.map +1 -0
  84. package/dist/routes/tools.d.ts +6 -0
  85. package/dist/routes/tools.d.ts.map +1 -0
  86. package/dist/routes/tools.js +193 -0
  87. package/dist/routes/tools.js.map +1 -0
  88. package/dist/routes/users.d.ts +6 -0
  89. package/dist/routes/users.d.ts.map +1 -0
  90. package/dist/routes/users.js +152 -0
  91. package/dist/routes/users.js.map +1 -0
  92. package/dist/routes/validate.d.ts +6 -0
  93. package/dist/routes/validate.d.ts.map +1 -0
  94. package/dist/routes/validate.js +171 -0
  95. package/dist/routes/validate.js.map +1 -0
  96. package/dist/routes/webhooks.d.ts +8 -0
  97. package/dist/routes/webhooks.d.ts.map +1 -0
  98. package/dist/routes/webhooks.js +102 -0
  99. package/dist/routes/webhooks.js.map +1 -0
  100. package/dist/server.d.ts +46 -0
  101. package/dist/server.d.ts.map +1 -0
  102. package/dist/server.js +101 -0
  103. package/dist/server.js.map +1 -0
  104. package/dist/spawners/http-api-spawner.d.ts +10 -0
  105. package/dist/spawners/http-api-spawner.d.ts.map +1 -0
  106. package/dist/spawners/http-api-spawner.js +88 -0
  107. package/dist/spawners/http-api-spawner.js.map +1 -0
  108. package/dist/user-store-pg.d.ts +21 -0
  109. package/dist/user-store-pg.d.ts.map +1 -0
  110. package/dist/user-store-pg.js +97 -0
  111. package/dist/user-store-pg.js.map +1 -0
  112. package/dist/user-store.d.ts +29 -0
  113. package/dist/user-store.d.ts.map +1 -0
  114. package/dist/user-store.js +97 -0
  115. package/dist/user-store.js.map +1 -0
  116. package/dist/utils/repo-resolver.d.ts +3 -0
  117. package/dist/utils/repo-resolver.d.ts.map +1 -0
  118. package/dist/utils/repo-resolver.js +3 -0
  119. package/dist/utils/repo-resolver.js.map +1 -0
  120. package/dist/webhook-dispatcher.d.ts +13 -0
  121. package/dist/webhook-dispatcher.d.ts.map +1 -0
  122. package/dist/webhook-dispatcher.js +108 -0
  123. package/dist/webhook-dispatcher.js.map +1 -0
  124. package/dist/webhook-store.d.ts +31 -0
  125. package/dist/webhook-store.d.ts.map +1 -0
  126. package/dist/webhook-store.js +91 -0
  127. package/dist/webhook-store.js.map +1 -0
  128. package/package.json +59 -0
  129. package/src/.gitkeep +0 -0
  130. package/src/api.ts +8 -0
  131. package/src/bootstrap.ts +259 -0
  132. package/src/event-bus.ts +64 -0
  133. package/src/index.ts +58 -0
  134. package/src/integration-runtime.ts +180 -0
  135. package/src/integration-store.ts +125 -0
  136. package/src/integrations/linear/failure-handler.ts +93 -0
  137. package/src/integrations/linear/webhook-handler.ts +156 -0
  138. package/src/integrations/registry.ts +12 -0
  139. package/src/integrations/types.ts +37 -0
  140. package/src/launcher.ts +214 -0
  141. package/src/logger.ts +50 -0
  142. package/src/plans.ts +43 -0
  143. package/src/routes/agents.ts +131 -0
  144. package/src/routes/config.ts +154 -0
  145. package/src/routes/contracts.ts +205 -0
  146. package/src/routes/pipelines.ts +146 -0
  147. package/src/routes/projects.ts +237 -0
  148. package/src/routes/runs.ts +468 -0
  149. package/src/routes/skills.ts +136 -0
  150. package/src/routes/tools.ts +222 -0
  151. package/src/routes/users.ts +169 -0
  152. package/src/routes/validate.ts +196 -0
  153. package/src/routes/webhooks.ts +120 -0
  154. package/src/server.ts +155 -0
  155. package/src/spawners/http-api-spawner.ts +96 -0
  156. package/src/user-store-pg.ts +138 -0
  157. package/src/user-store.ts +125 -0
  158. package/src/utils/repo-resolver.ts +3 -0
  159. package/src/webhook-dispatcher.ts +142 -0
  160. package/src/webhook-store.ts +141 -0
  161. package/tests/agents.test.ts +164 -0
  162. package/tests/cancel.test.ts +120 -0
  163. package/tests/config.test.ts +196 -0
  164. package/tests/contracts.test.ts +302 -0
  165. package/tests/event-bus.test.ts +53 -0
  166. package/tests/http-api-spawner.test.ts +158 -0
  167. package/tests/integration-runtime.test.ts +199 -0
  168. package/tests/integration-store.test.ts +66 -0
  169. package/tests/integrations/linear/failure-handler.test.ts +113 -0
  170. package/tests/integrations/linear/webhook-handler.test.ts +191 -0
  171. package/tests/launcher.test.ts +380 -0
  172. package/tests/linear-webhook.test.ts +390 -0
  173. package/tests/pipelines.test.ts +166 -0
  174. package/tests/projects.test.ts +283 -0
  175. package/tests/runs.test.ts +379 -0
  176. package/tests/server.test.ts +208 -0
  177. package/tests/skills.test.ts +149 -0
  178. package/tests/sse.test.ts +129 -0
  179. package/tests/tools.test.ts +233 -0
  180. package/tests/user-store.test.ts +93 -0
  181. package/tests/users.test.ts +189 -0
  182. package/tests/utils/repo-resolver.test.ts +105 -0
  183. package/tests/validate.test.ts +233 -0
  184. package/tests/webhook-dispatcher.test.ts +214 -0
  185. package/tests/webhook-store.test.ts +98 -0
  186. package/tests/webhooks.test.ts +176 -0
  187. package/tsconfig.json +24 -0
  188. package/vitest.config.ts +7 -0
@@ -0,0 +1,468 @@
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
+ }
@@ -0,0 +1,136 @@
1
+ import { readdir, readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { FastifyInstance } from 'fastify';
4
+ import type { ServerDeps } from '../server.js';
5
+
6
+ export async function skillsRoutes(
7
+ fastify: FastifyInstance,
8
+ options: { deps: ServerDeps }
9
+ ): Promise<void> {
10
+ const skillsDir = join(options.deps.configsDir, 'skills');
11
+
12
+ const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
13
+
14
+ // GET /api/skills
15
+ fastify.get('/skills', {
16
+ schema: {
17
+ tags: ['skills'],
18
+ summary: 'List all skill names',
19
+ response: {
20
+ 200: {
21
+ type: 'object',
22
+ properties: {
23
+ skills: { type: 'array', items: { type: 'string' } },
24
+ },
25
+ },
26
+ },
27
+ },
28
+ }, async (_request, reply) => {
29
+ let entries: string[];
30
+ try {
31
+ entries = await readdir(skillsDir);
32
+ } catch {
33
+ return reply.send({ skills: [] });
34
+ }
35
+ const skills = entries
36
+ .filter(f => f.endsWith('.skill.md'))
37
+ .map(f => f.slice(0, -'.skill.md'.length));
38
+ return reply.send({ skills });
39
+ });
40
+
41
+ // GET /api/skills/:name
42
+ fastify.get<{ Params: { name: string } }>('/skills/:name', {
43
+ schema: {
44
+ tags: ['skills'],
45
+ summary: 'Get a skill by name',
46
+ params: {
47
+ type: 'object',
48
+ properties: { name: { type: 'string' } },
49
+ required: ['name'],
50
+ },
51
+ response: {
52
+ 200: {
53
+ type: 'object',
54
+ properties: {
55
+ name: { type: 'string' },
56
+ content: { type: 'string' },
57
+ },
58
+ },
59
+ 404: errorSchema,
60
+ },
61
+ },
62
+ }, async (request, reply) => {
63
+ const filePath = join(skillsDir, `${request.params.name}.skill.md`);
64
+ let content: string;
65
+ try {
66
+ content = await readFile(filePath, 'utf-8');
67
+ } catch {
68
+ return reply.status(404).send({ error: 'Skill not found' });
69
+ }
70
+ return reply.send({ name: request.params.name, content });
71
+ });
72
+
73
+ // PUT /api/skills/:name
74
+ fastify.put<{
75
+ Params: { name: string };
76
+ Body: Record<string, unknown>;
77
+ }>('/skills/:name', {
78
+ schema: {
79
+ tags: ['skills'],
80
+ summary: 'Create or update a skill',
81
+ params: {
82
+ type: 'object',
83
+ properties: { name: { type: 'string' } },
84
+ required: ['name'],
85
+ },
86
+ body: { type: 'object' },
87
+ response: {
88
+ 200: {
89
+ type: 'object',
90
+ properties: {
91
+ name: { type: 'string' },
92
+ content: { type: 'string' },
93
+ },
94
+ },
95
+ 400: errorSchema,
96
+ },
97
+ },
98
+ }, async (request, reply) => {
99
+ const { content } = request.body;
100
+
101
+ if (typeof content !== 'string') {
102
+ return reply.status(400).send({ error: "Skill must have a 'content' field (string)" });
103
+ }
104
+
105
+ await mkdir(skillsDir, { recursive: true });
106
+ const filePath = join(skillsDir, `${request.params.name}.skill.md`);
107
+ await writeFile(filePath, content, 'utf-8');
108
+
109
+ return reply.send({ name: request.params.name, content });
110
+ });
111
+
112
+ // DELETE /api/skills/:name
113
+ fastify.delete<{ Params: { name: string } }>('/skills/:name', {
114
+ schema: {
115
+ tags: ['skills'],
116
+ summary: 'Delete a skill',
117
+ params: {
118
+ type: 'object',
119
+ properties: { name: { type: 'string' } },
120
+ required: ['name'],
121
+ },
122
+ response: {
123
+ 204: { type: 'null', description: 'No content' },
124
+ 404: errorSchema,
125
+ },
126
+ },
127
+ }, async (request, reply) => {
128
+ const filePath = join(skillsDir, `${request.params.name}.skill.md`);
129
+ try {
130
+ await unlink(filePath);
131
+ } catch {
132
+ return reply.status(404).send({ error: 'Skill not found' });
133
+ }
134
+ return reply.status(204).send();
135
+ });
136
+ }