@studio-foundation/api 0.3.0-beta.1 → 0.3.0-beta.6
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/validate.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { readdir } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import yaml from 'js-yaml';
|
|
4
|
-
import type { FastifyInstance } from 'fastify';
|
|
5
|
-
import type { ServerDeps } from '../server.js';
|
|
6
|
-
import {
|
|
7
|
-
createRepoManagerTools,
|
|
8
|
-
createShellTools,
|
|
9
|
-
createSearchTools,
|
|
10
|
-
createPatchTools,
|
|
11
|
-
createGitTools,
|
|
12
|
-
} from '@studio-foundation/runner';
|
|
13
|
-
|
|
14
|
-
// Derived at module load from the actual factory functions — single source of truth.
|
|
15
|
-
// Using '.' as a dummy repoPath: only `execute` uses the path, `name` is hardcoded.
|
|
16
|
-
const BUILTIN_TOOL_ACTIONS: Set<string> = new Set([
|
|
17
|
-
...createRepoManagerTools('.').map(t => t.name),
|
|
18
|
-
...createShellTools('.').map(t => t.name),
|
|
19
|
-
...createSearchTools('.').map(t => t.name),
|
|
20
|
-
...createPatchTools('.').map(t => t.name),
|
|
21
|
-
...createGitTools('.').map(t => t.name),
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
async function listNames(dir: string, suffix: string): Promise<Set<string>> {
|
|
25
|
-
try {
|
|
26
|
-
const entries = await readdir(dir);
|
|
27
|
-
return new Set(
|
|
28
|
-
entries
|
|
29
|
-
.filter(f => f.endsWith(suffix))
|
|
30
|
-
.map(f => f.slice(0, -suffix.length))
|
|
31
|
-
);
|
|
32
|
-
} catch {
|
|
33
|
-
return new Set();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Read tool plugin YAMLs and return the `name` field from each.
|
|
39
|
-
* Falls back to the filename stem if the YAML cannot be parsed.
|
|
40
|
-
*/
|
|
41
|
-
async function listToolPluginNames(dir: string): Promise<Set<string>> {
|
|
42
|
-
try {
|
|
43
|
-
const { readFile } = await import('node:fs/promises');
|
|
44
|
-
const entries = await readdir(dir);
|
|
45
|
-
const names = new Set<string>();
|
|
46
|
-
await Promise.all(
|
|
47
|
-
entries
|
|
48
|
-
.filter(f => f.endsWith('.tool.yaml'))
|
|
49
|
-
.map(async (f) => {
|
|
50
|
-
try {
|
|
51
|
-
const raw = await readFile(join(dir, f), 'utf-8');
|
|
52
|
-
const parsed = yaml.load(raw) as Record<string, unknown> | null;
|
|
53
|
-
const name = parsed && typeof parsed.name === 'string'
|
|
54
|
-
? parsed.name
|
|
55
|
-
: f.slice(0, -'.tool.yaml'.length);
|
|
56
|
-
names.add(name);
|
|
57
|
-
} catch {
|
|
58
|
-
names.add(f.slice(0, -'.tool.yaml'.length));
|
|
59
|
-
}
|
|
60
|
-
})
|
|
61
|
-
);
|
|
62
|
-
return names;
|
|
63
|
-
} catch {
|
|
64
|
-
return new Set();
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
type StageRef = { agents: string[]; contracts: string[] };
|
|
69
|
-
|
|
70
|
-
function collectStageRefs(stage: Record<string, unknown>): StageRef {
|
|
71
|
-
const agents: string[] = [];
|
|
72
|
-
const contracts: string[] = [];
|
|
73
|
-
if (typeof stage.agent === 'string') agents.push(stage.agent);
|
|
74
|
-
if (typeof stage.contract === 'string') contracts.push(stage.contract);
|
|
75
|
-
// Recurse into group stages
|
|
76
|
-
if (Array.isArray(stage.stages)) {
|
|
77
|
-
for (const s of stage.stages) {
|
|
78
|
-
if (s && typeof s === 'object' && !Array.isArray(s)) {
|
|
79
|
-
const inner = collectStageRefs(s as Record<string, unknown>);
|
|
80
|
-
agents.push(...inner.agents);
|
|
81
|
-
contracts.push(...inner.contracts);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return { agents, contracts };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function validateRoutes(
|
|
89
|
-
fastify: FastifyInstance,
|
|
90
|
-
options: { deps: ServerDeps }
|
|
91
|
-
): Promise<void> {
|
|
92
|
-
const { configsDir } = options.deps;
|
|
93
|
-
|
|
94
|
-
const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
|
|
95
|
-
|
|
96
|
-
fastify.post<{
|
|
97
|
-
Body: { type: string; name?: string; content: string };
|
|
98
|
-
}>('/validate', {
|
|
99
|
-
schema: {
|
|
100
|
-
tags: ['validate'],
|
|
101
|
-
summary: 'Validate a Studio config for structural coherence',
|
|
102
|
-
body: {
|
|
103
|
-
type: 'object',
|
|
104
|
-
required: ['type', 'content'],
|
|
105
|
-
properties: {
|
|
106
|
-
type: {
|
|
107
|
-
type: 'string',
|
|
108
|
-
enum: ['pipeline', 'contract', 'agent', 'tool', 'skill'],
|
|
109
|
-
},
|
|
110
|
-
name: { type: 'string' },
|
|
111
|
-
content: { type: 'string' },
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
response: {
|
|
115
|
-
200: {
|
|
116
|
-
type: 'object',
|
|
117
|
-
properties: {
|
|
118
|
-
valid: { type: 'boolean' },
|
|
119
|
-
errors: { type: 'array', items: { type: 'string' } },
|
|
120
|
-
},
|
|
121
|
-
required: ['valid', 'errors'],
|
|
122
|
-
},
|
|
123
|
-
400: errorSchema,
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
}, async (request, reply) => {
|
|
127
|
-
const { type, content } = request.body;
|
|
128
|
-
const errors: string[] = [];
|
|
129
|
-
|
|
130
|
-
// Skills are markdown — just check non-empty
|
|
131
|
-
if (type === 'skill') {
|
|
132
|
-
if (!content.trim()) errors.push('Skill content is empty');
|
|
133
|
-
return reply.send({ valid: errors.length === 0, errors });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Parse YAML
|
|
137
|
-
let parsed: unknown;
|
|
138
|
-
try {
|
|
139
|
-
parsed = yaml.load(content);
|
|
140
|
-
} catch (e) {
|
|
141
|
-
errors.push(`YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
|
|
142
|
-
return reply.send({ valid: false, errors });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
146
|
-
errors.push('Content must be a YAML object');
|
|
147
|
-
return reply.send({ valid: false, errors });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const obj = parsed as Record<string, unknown>;
|
|
151
|
-
|
|
152
|
-
if (type === 'pipeline') {
|
|
153
|
-
const stages = obj.stages;
|
|
154
|
-
if (!Array.isArray(stages)) {
|
|
155
|
-
errors.push('Pipeline must have a "stages" array');
|
|
156
|
-
return reply.send({ valid: false, errors });
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const [agentNames, contractNames] = await Promise.all([
|
|
160
|
-
listNames(join(configsDir, 'agents'), '.agent.yaml'),
|
|
161
|
-
listNames(join(configsDir, 'contracts'), '.contract.yaml'),
|
|
162
|
-
]);
|
|
163
|
-
|
|
164
|
-
for (const stage of stages) {
|
|
165
|
-
if (!stage || typeof stage !== 'object' || Array.isArray(stage)) continue;
|
|
166
|
-
const { agents: refs_a, contracts: refs_c } = collectStageRefs(
|
|
167
|
-
stage as Record<string, unknown>
|
|
168
|
-
);
|
|
169
|
-
for (const agent of refs_a) {
|
|
170
|
-
if (!agentNames.has(agent)) errors.push(`Agent '${agent}' not found`);
|
|
171
|
-
}
|
|
172
|
-
for (const contract of refs_c) {
|
|
173
|
-
if (!contractNames.has(contract)) errors.push(`Contract '${contract}' not found`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (type === 'agent') {
|
|
179
|
-
const toolsUsed = obj.tools;
|
|
180
|
-
if (Array.isArray(toolsUsed)) {
|
|
181
|
-
const customPlugins = await listToolPluginNames(join(configsDir, 'tools'));
|
|
182
|
-
for (const tool of toolsUsed) {
|
|
183
|
-
if (typeof tool !== 'string') continue;
|
|
184
|
-
// Custom tool actions follow <plugin>-<action> naming (e.g. claude_code-run
|
|
185
|
-
// from claude_code.tool.yaml). Match if any plugin name is a prefix.
|
|
186
|
-
const isCustom = [...customPlugins].some(p => tool === p || tool.startsWith(p + '-'));
|
|
187
|
-
if (!BUILTIN_TOOL_ACTIONS.has(tool) && !isCustom) {
|
|
188
|
-
errors.push(`Tool '${tool}' not found`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return reply.send({ valid: errors.length === 0, errors });
|
|
195
|
-
});
|
|
196
|
-
}
|
package/src/routes/webhooks.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import type { FastifyInstance } from 'fastify';
|
|
3
|
-
import type { ServerDeps } from '../server.js';
|
|
4
|
-
|
|
5
|
-
const VALID_EVENTS = [
|
|
6
|
-
'pipeline_start',
|
|
7
|
-
'pipeline_complete',
|
|
8
|
-
'stage_complete',
|
|
9
|
-
'stage_rejected',
|
|
10
|
-
'stage_failed',
|
|
11
|
-
'group_feedback',
|
|
12
|
-
] as const;
|
|
13
|
-
|
|
14
|
-
const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
|
|
15
|
-
|
|
16
|
-
const webhookSchema = {
|
|
17
|
-
type: 'object',
|
|
18
|
-
properties: {
|
|
19
|
-
id: { type: 'string' },
|
|
20
|
-
url: { type: 'string' },
|
|
21
|
-
events: { type: 'array', items: { type: 'string' } },
|
|
22
|
-
status: { type: 'string', enum: ['active', 'failed'] },
|
|
23
|
-
created_at: { type: 'string' },
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export async function webhooksRoutes(
|
|
28
|
-
fastify: FastifyInstance,
|
|
29
|
-
options: { deps: ServerDeps },
|
|
30
|
-
): Promise<void> {
|
|
31
|
-
const { webhookStore } = options.deps;
|
|
32
|
-
|
|
33
|
-
// POST /api/webhooks
|
|
34
|
-
fastify.post<{
|
|
35
|
-
Body: { url: string; events: string[]; secret?: string };
|
|
36
|
-
}>('/webhooks', {
|
|
37
|
-
schema: {
|
|
38
|
-
tags: ['webhooks'],
|
|
39
|
-
summary: 'Register a new webhook',
|
|
40
|
-
body: {
|
|
41
|
-
type: 'object',
|
|
42
|
-
required: ['url', 'events'],
|
|
43
|
-
properties: {
|
|
44
|
-
url: { type: 'string' },
|
|
45
|
-
events: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
46
|
-
secret: { type: 'string' },
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
response: {
|
|
50
|
-
201: webhookSchema,
|
|
51
|
-
400: errorSchema,
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
}, async (request, reply) => {
|
|
55
|
-
const { url, events, secret } = request.body;
|
|
56
|
-
|
|
57
|
-
if (!events || events.length === 0) {
|
|
58
|
-
return reply.status(400).send({ error: 'events must not be empty' });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const id = randomUUID();
|
|
62
|
-
const created_at = new Date().toISOString();
|
|
63
|
-
|
|
64
|
-
webhookStore.saveWebhook({
|
|
65
|
-
id,
|
|
66
|
-
url,
|
|
67
|
-
events,
|
|
68
|
-
...(secret != null ? { secret } : {}),
|
|
69
|
-
status: 'active',
|
|
70
|
-
created_at,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
return reply.status(201).send({ id, url, events, status: 'active', created_at });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// GET /api/webhooks
|
|
77
|
-
fastify.get('/webhooks', {
|
|
78
|
-
schema: {
|
|
79
|
-
tags: ['webhooks'],
|
|
80
|
-
summary: 'List all registered webhooks',
|
|
81
|
-
response: {
|
|
82
|
-
200: {
|
|
83
|
-
type: 'object',
|
|
84
|
-
properties: {
|
|
85
|
-
webhooks: { type: 'array', items: webhookSchema },
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
}, async (_request, reply) => {
|
|
91
|
-
const webhooks = webhookStore.listWebhooks().map(({ secret: _secret, ...w }) => w);
|
|
92
|
-
return reply.send({ webhooks });
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// DELETE /api/webhooks/:id
|
|
96
|
-
fastify.delete<{ Params: { id: string } }>('/webhooks/:id', {
|
|
97
|
-
schema: {
|
|
98
|
-
tags: ['webhooks'],
|
|
99
|
-
summary: 'Delete a webhook',
|
|
100
|
-
params: {
|
|
101
|
-
type: 'object',
|
|
102
|
-
properties: { id: { type: 'string' } },
|
|
103
|
-
required: ['id'],
|
|
104
|
-
},
|
|
105
|
-
response: {
|
|
106
|
-
204: { type: 'null', description: 'No content' },
|
|
107
|
-
404: errorSchema,
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
}, async (request, reply) => {
|
|
111
|
-
const deleted = webhookStore.deleteWebhook(request.params.id);
|
|
112
|
-
if (!deleted) {
|
|
113
|
-
return reply.status(404).send({ error: 'Webhook not found' });
|
|
114
|
-
}
|
|
115
|
-
return reply.status(204).send();
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Export VALID_EVENTS for documentation/validation use
|
|
120
|
-
export { VALID_EVENTS };
|
package/src/server.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
// Fastify server builder — takes deps via injection for testability
|
|
2
|
-
// buildServer(deps) → FastifyInstance, ready to listen
|
|
3
|
-
|
|
4
|
-
import Fastify, { type FastifyRequest } from 'fastify';
|
|
5
|
-
import cors from '@fastify/cors';
|
|
6
|
-
import rateLimit from '@fastify/rate-limit';
|
|
7
|
-
import swagger from '@fastify/swagger';
|
|
8
|
-
import swaggerUi from '@fastify/swagger-ui';
|
|
9
|
-
import type { AnyRunStore } from '@studio-foundation/engine';
|
|
10
|
-
import type { RunLauncher } from './launcher.js';
|
|
11
|
-
import type { WebhookStore } from './webhook-store.js';
|
|
12
|
-
import type { IntegrationStore } from './integration-store.js';
|
|
13
|
-
import type { IntegrationRuntime } from './integration-runtime.js';
|
|
14
|
-
import type { UserStore } from './user-store.js';
|
|
15
|
-
import type { PgUserStore } from './user-store-pg.js';
|
|
16
|
-
import { getPlanLimits, DEFAULT_PLANS, type PlansConfig } from './plans.js';
|
|
17
|
-
import { runsRoutes } from './routes/runs.js';
|
|
18
|
-
import { projectsRoutes } from './routes/projects.js';
|
|
19
|
-
import { contractsRoutes } from './routes/contracts.js';
|
|
20
|
-
import { pipelinesRoutes } from './routes/pipelines.js';
|
|
21
|
-
import { toolsRoutes } from './routes/tools.js';
|
|
22
|
-
import { agentsRoutes } from './routes/agents.js';
|
|
23
|
-
import { configRoutes } from './routes/config.js';
|
|
24
|
-
import { skillsRoutes } from './routes/skills.js';
|
|
25
|
-
import { validateRoutes } from './routes/validate.js';
|
|
26
|
-
import { webhooksRoutes } from './routes/webhooks.js';
|
|
27
|
-
import { usersRoutes } from './routes/users.js';
|
|
28
|
-
|
|
29
|
-
export interface ApiConfig {
|
|
30
|
-
key?: string;
|
|
31
|
-
port?: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type MaskedConfig = {
|
|
35
|
-
defaults?: { provider?: string; model?: string };
|
|
36
|
-
providers: string[];
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export interface ServerDeps {
|
|
40
|
-
store: AnyRunStore;
|
|
41
|
-
launcher: RunLauncher;
|
|
42
|
-
configsDir: string;
|
|
43
|
-
/** Raw projects_dir from config (may contain ~). Used by route handlers for repo cloning. */
|
|
44
|
-
projectsDir?: string;
|
|
45
|
-
projectName: string;
|
|
46
|
-
apiConfig: ApiConfig;
|
|
47
|
-
studioVersion: string;
|
|
48
|
-
maskedConfig: MaskedConfig;
|
|
49
|
-
webhookStore: WebhookStore;
|
|
50
|
-
integrationStore: IntegrationStore;
|
|
51
|
-
integrationRuntime: IntegrationRuntime;
|
|
52
|
-
userStore?: UserStore | PgUserStore;
|
|
53
|
-
plans?: PlansConfig;
|
|
54
|
-
/** true when at least one user exists in the DB — computed at bootstrap time to avoid per-request DB calls */
|
|
55
|
-
hasUsers?: boolean;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// request.user type augmentation for Fastify
|
|
59
|
-
declare module 'fastify' {
|
|
60
|
-
interface FastifyRequest {
|
|
61
|
-
user?: import('./user-store.js').User;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function buildServer(deps: ServerDeps) {
|
|
66
|
-
const fastify = Fastify({ logger: false });
|
|
67
|
-
|
|
68
|
-
// Register text/plain parser so PUT routes that accept YAML bodies work correctly.
|
|
69
|
-
// Without this, Fastify v5 cannot parse text/plain and body validation fails with 400.
|
|
70
|
-
fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
|
|
71
|
-
done(null, body);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
void fastify.register(cors, { origin: true });
|
|
75
|
-
|
|
76
|
-
void fastify.register(rateLimit, {
|
|
77
|
-
global: true,
|
|
78
|
-
max: (req: FastifyRequest) => {
|
|
79
|
-
const planName = req.user?.plan ?? 'free';
|
|
80
|
-
const plans = deps.plans ?? DEFAULT_PLANS;
|
|
81
|
-
return getPlanLimits(plans, planName).rate_limit_per_minute;
|
|
82
|
-
},
|
|
83
|
-
timeWindow: '1 minute',
|
|
84
|
-
keyGenerator: (req: FastifyRequest) => req.user?.id ?? req.ip ?? 'anonymous',
|
|
85
|
-
addHeaders: {
|
|
86
|
-
'x-ratelimit-limit': true,
|
|
87
|
-
'x-ratelimit-remaining': true,
|
|
88
|
-
'x-ratelimit-reset': true,
|
|
89
|
-
},
|
|
90
|
-
allowList: (req: FastifyRequest) => req.url.startsWith('/api/integrations/'),
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
if (process.env['NODE_ENV'] !== 'production') {
|
|
94
|
-
void fastify.register(swagger, {
|
|
95
|
-
openapi: {
|
|
96
|
-
info: {
|
|
97
|
-
title: 'Studio API',
|
|
98
|
-
description: 'REST API for Studio pipeline orchestration',
|
|
99
|
-
version: '1.0.0',
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
void fastify.register(swaggerUi, {
|
|
104
|
-
routePrefix: '/api/docs',
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Auth hook — supports three modes:
|
|
109
|
-
// 1. Multi-user: userStore provided + hasUsers=true → lookup by api_key
|
|
110
|
-
// 2. Legacy single-key: hasUsers=false + api.key configured → Bearer check
|
|
111
|
-
// 3. Open/dev: no users, no api.key → allow all (local dev only)
|
|
112
|
-
fastify.addHook('onRequest', async (request, reply) => {
|
|
113
|
-
const path = request.url.split('?')[0];
|
|
114
|
-
if (path.startsWith('/api/integrations/')) return;
|
|
115
|
-
|
|
116
|
-
const auth = request.headers['authorization'];
|
|
117
|
-
const token = auth?.startsWith('Bearer ') ? auth.slice(7) : undefined;
|
|
118
|
-
|
|
119
|
-
if (deps.userStore && deps.hasUsers) {
|
|
120
|
-
// Multi-user mode: look up user by api_key
|
|
121
|
-
const user = token ? await deps.userStore.getUserByApiKey(token) : null;
|
|
122
|
-
if (!user) {
|
|
123
|
-
await reply.status(401).send({ error: 'Unauthorized' });
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
request.user = user;
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Legacy single-key mode
|
|
131
|
-
if (deps.apiConfig.key) {
|
|
132
|
-
if (!auth || auth !== `Bearer ${deps.apiConfig.key}`) {
|
|
133
|
-
await reply.status(401).send({ error: 'Unauthorized' });
|
|
134
|
-
}
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// No userStore with users, no api.key → open (local dev)
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
void fastify.register(runsRoutes, { prefix: '/api', deps });
|
|
142
|
-
void fastify.register(projectsRoutes, { prefix: '/api', deps });
|
|
143
|
-
void fastify.register(contractsRoutes, { prefix: '/api', deps });
|
|
144
|
-
void fastify.register(pipelinesRoutes, { prefix: '/api', deps });
|
|
145
|
-
void fastify.register(toolsRoutes, { prefix: '/api', deps });
|
|
146
|
-
void fastify.register(agentsRoutes, { prefix: '/api', deps });
|
|
147
|
-
void fastify.register(configRoutes, { prefix: '/api', deps });
|
|
148
|
-
void fastify.register(skillsRoutes, { prefix: '/api', deps });
|
|
149
|
-
void fastify.register(validateRoutes, { prefix: '/api', deps });
|
|
150
|
-
void fastify.register(webhooksRoutes, { prefix: '/api', deps });
|
|
151
|
-
void fastify.register(usersRoutes, { prefix: '/api', deps });
|
|
152
|
-
deps.integrationRuntime.registerRoutes(fastify, '/api');
|
|
153
|
-
|
|
154
|
-
return fastify;
|
|
155
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import type { RunSpawner, SpawnConfig, SpawnResult, PipelineRun } from '@studio-foundation/contracts';
|
|
2
|
-
|
|
3
|
-
export class HttpApiSpawner implements RunSpawner {
|
|
4
|
-
constructor(private apiUrl: string, private apiKey?: string) {}
|
|
5
|
-
|
|
6
|
-
private authHeaders(): Record<string, string> {
|
|
7
|
-
return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
async spawnAndWait(config: SpawnConfig): Promise<SpawnResult> {
|
|
11
|
-
// 1. Launch the run
|
|
12
|
-
const postRes = await fetch(`${this.apiUrl}/api/runs`, {
|
|
13
|
-
method: 'POST',
|
|
14
|
-
headers: {
|
|
15
|
-
'Content-Type': 'application/json',
|
|
16
|
-
'X-Studio-Depth': String(config.depth),
|
|
17
|
-
'X-Studio-Parent-Run-Id': config.parentRunId,
|
|
18
|
-
...this.authHeaders(),
|
|
19
|
-
},
|
|
20
|
-
body: JSON.stringify({ pipeline: config.pipeline, input: config.input }),
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
if (!postRes.ok) {
|
|
24
|
-
const text = await postRes.text();
|
|
25
|
-
throw new Error(`Failed to launch child run: ${postRes.status} ${text}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const { run_id } = (await postRes.json()) as { run_id: string };
|
|
29
|
-
|
|
30
|
-
// 2. Wait for pipeline_complete via SSE
|
|
31
|
-
await this.waitForCompletion(run_id);
|
|
32
|
-
|
|
33
|
-
// 3. Fetch full run result to get output
|
|
34
|
-
const getRes = await fetch(`${this.apiUrl}/api/runs/${run_id}`, {
|
|
35
|
-
headers: { ...this.authHeaders() },
|
|
36
|
-
});
|
|
37
|
-
if (!getRes.ok) {
|
|
38
|
-
throw new Error(`Failed to fetch child run result ${run_id}: ${getRes.status}`);
|
|
39
|
-
}
|
|
40
|
-
const run = (await getRes.json()) as PipelineRun;
|
|
41
|
-
|
|
42
|
-
if (run.status === 'failed' || run.status === 'rejected' || run.status === 'cancelled') {
|
|
43
|
-
throw new Error(`Child run ${run_id} ${run.status}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const lastStage = [...run.stages].reverse().find(s => s.status === 'success');
|
|
47
|
-
const output = (lastStage as { output?: unknown } | undefined)?.output ?? null;
|
|
48
|
-
|
|
49
|
-
return { run_id, status: run.status, output };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
private waitForCompletion(runId: string): Promise<void> {
|
|
53
|
-
return new Promise((resolve, reject) => {
|
|
54
|
-
fetch(`${this.apiUrl}/api/runs/${runId}/stream`, {
|
|
55
|
-
headers: { Accept: 'text/event-stream', ...this.authHeaders() },
|
|
56
|
-
})
|
|
57
|
-
.then(response => {
|
|
58
|
-
if (!response.ok || !response.body) {
|
|
59
|
-
reject(new Error(`SSE connection failed for run ${runId}: ${response.status}`));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const reader = response.body.getReader();
|
|
63
|
-
const decoder = new TextDecoder();
|
|
64
|
-
let buffer = '';
|
|
65
|
-
let currentEventType = '';
|
|
66
|
-
|
|
67
|
-
const pump = (): Promise<void> =>
|
|
68
|
-
reader.read().then(({ done, value }) => {
|
|
69
|
-
if (done) {
|
|
70
|
-
reject(new Error(`SSE stream ended without pipeline_complete for run ${runId}`));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
buffer += decoder.decode(value, { stream: true });
|
|
74
|
-
const lines = buffer.split('\n');
|
|
75
|
-
buffer = lines.pop() ?? '';
|
|
76
|
-
|
|
77
|
-
for (const line of lines) {
|
|
78
|
-
if (line.startsWith('event: ')) {
|
|
79
|
-
currentEventType = line.slice(7).trim();
|
|
80
|
-
} else if (line.startsWith('data: ') && currentEventType === 'pipeline_complete') {
|
|
81
|
-
reader.cancel();
|
|
82
|
-
resolve();
|
|
83
|
-
return;
|
|
84
|
-
} else if (line === '') {
|
|
85
|
-
currentEventType = '';
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return pump();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
pump().catch(reject);
|
|
92
|
-
})
|
|
93
|
-
.catch(reject);
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|