@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,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
|
+
}
|