@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/src/routes/projects.ts
DELETED
|
@@ -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
|
-
}
|
package/src/routes/runs.ts
DELETED
|
@@ -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
|
-
}
|