@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.
Files changed (62) hide show
  1. package/package.json +7 -4
  2. package/ARCHITECTURE.md +0 -52
  3. package/src/.gitkeep +0 -0
  4. package/src/api.ts +0 -8
  5. package/src/bootstrap.ts +0 -259
  6. package/src/event-bus.ts +0 -64
  7. package/src/index.ts +0 -58
  8. package/src/integration-runtime.ts +0 -180
  9. package/src/integration-store.ts +0 -125
  10. package/src/integrations/linear/failure-handler.ts +0 -93
  11. package/src/integrations/linear/webhook-handler.ts +0 -156
  12. package/src/integrations/registry.ts +0 -12
  13. package/src/integrations/types.ts +0 -37
  14. package/src/launcher.ts +0 -214
  15. package/src/logger.ts +0 -50
  16. package/src/plans.ts +0 -43
  17. package/src/routes/agents.ts +0 -131
  18. package/src/routes/config.ts +0 -154
  19. package/src/routes/contracts.ts +0 -205
  20. package/src/routes/pipelines.ts +0 -146
  21. package/src/routes/projects.ts +0 -237
  22. package/src/routes/runs.ts +0 -468
  23. package/src/routes/skills.ts +0 -136
  24. package/src/routes/tools.ts +0 -222
  25. package/src/routes/users.ts +0 -169
  26. package/src/routes/validate.ts +0 -196
  27. package/src/routes/webhooks.ts +0 -120
  28. package/src/server.ts +0 -155
  29. package/src/spawners/http-api-spawner.ts +0 -96
  30. package/src/user-store-pg.ts +0 -138
  31. package/src/user-store.ts +0 -125
  32. package/src/utils/repo-resolver.ts +0 -3
  33. package/src/webhook-dispatcher.ts +0 -142
  34. package/src/webhook-store.ts +0 -141
  35. package/tests/agents.test.ts +0 -164
  36. package/tests/cancel.test.ts +0 -120
  37. package/tests/config.test.ts +0 -196
  38. package/tests/contracts.test.ts +0 -302
  39. package/tests/event-bus.test.ts +0 -53
  40. package/tests/http-api-spawner.test.ts +0 -158
  41. package/tests/integration-runtime.test.ts +0 -199
  42. package/tests/integration-store.test.ts +0 -66
  43. package/tests/integrations/linear/failure-handler.test.ts +0 -113
  44. package/tests/integrations/linear/webhook-handler.test.ts +0 -191
  45. package/tests/launcher.test.ts +0 -380
  46. package/tests/linear-webhook.test.ts +0 -390
  47. package/tests/pipelines.test.ts +0 -166
  48. package/tests/projects.test.ts +0 -283
  49. package/tests/runs.test.ts +0 -379
  50. package/tests/server.test.ts +0 -208
  51. package/tests/skills.test.ts +0 -149
  52. package/tests/sse.test.ts +0 -129
  53. package/tests/tools.test.ts +0 -233
  54. package/tests/user-store.test.ts +0 -93
  55. package/tests/users.test.ts +0 -189
  56. package/tests/utils/repo-resolver.test.ts +0 -105
  57. package/tests/validate.test.ts +0 -233
  58. package/tests/webhook-dispatcher.test.ts +0 -214
  59. package/tests/webhook-store.test.ts +0 -98
  60. package/tests/webhooks.test.ts +0 -176
  61. package/tsconfig.json +0 -24
  62. package/vitest.config.ts +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studio-foundation/api",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.6",
4
4
  "description": "HTTP API for Studio v7",
5
5
  "type": "module",
6
6
  "main": "./dist/api.js",
@@ -21,6 +21,9 @@
21
21
  "bin": {
22
22
  "studio-api": "./dist/index.js"
23
23
  },
24
+ "files": [
25
+ "dist"
26
+ ],
24
27
  "keywords": [
25
28
  "studio",
26
29
  "api"
@@ -36,9 +39,9 @@
36
39
  "fastify": "^5.3.2",
37
40
  "js-yaml": "^4.1.1",
38
41
  "pg": "^8.19.0",
39
- "@studio-foundation/contracts": "0.3.0-beta.1",
40
- "@studio-foundation/engine": "0.3.0-beta.1",
41
- "@studio-foundation/runner": "0.3.0-beta.1"
42
+ "@studio-foundation/runner": "0.3.0-beta.6",
43
+ "@studio-foundation/engine": "0.3.0-beta.6",
44
+ "@studio-foundation/contracts": "0.3.0-beta.6"
42
45
  },
43
46
  "devDependencies": {
44
47
  "@types/better-sqlite3": "^7.6.8",
package/ARCHITECTURE.md DELETED
@@ -1,52 +0,0 @@
1
- # @studio-foundation/api
2
-
3
- Serveur HTTP REST pour Studio. Même engine que le CLI, interface machine-to-machine.
4
-
5
- ## Concept
6
-
7
- `bootstrap(cwd)` trouve `.studio/`, câble engine + store + launcher + integrations.
8
- `buildServer()` crée le serveur Fastify avec toutes les routes et le Swagger UI.
9
- Résultat : Linear webhook → pipeline run → SSE streaming → webhook dispatch.
10
-
11
- ## Règles
12
-
13
- - Le API est aussi un **composition root** — même pattern que CLI, dépend de `@studio-foundation/engine` et `@studio-foundation/runner`
14
- - Toute route Fastify **DOIT** avoir un schema Swagger complet (tags, summary, params, response). Sans ça, la route n'apparaît pas dans Swagger UI.
15
- - Le engine est appelé via `InProcessLauncher` — pas de HTTP round-trip interne sauf pour `HttpApiSpawner`
16
- - Swagger UI désactivé en production (`NODE_ENV=production`)
17
- - Auth Bearer optionnelle : si `api.key` dans `config.yaml`, toutes les routes l'exigent
18
- - DB configurable : `db.type: sqlite | postgres | inmemory` dans `config.yaml`
19
-
20
- ## Fichiers clés
21
-
22
- - `bootstrap.ts` — `bootstrap(cwd)` : trouve `.studio/`, crée store + engine + launcher + integrations
23
- - `server.ts` — `buildServer()` : Fastify factory, monte toutes les routes
24
- - `launcher.ts` — `InProcessLauncher` : lance les pipelines en arrière-plan, publie sur le bus d'événements
25
- - `event-bus.ts` — `RunEventBus` : bus interne SSE (run events → clients connectés)
26
- - `routes/runs.ts` — `GET/POST /api/runs`, SSE `/api/runs/:id/stream`, cancel
27
- - `routes/pipelines.ts`, `agents.ts`, `contracts.ts`, `skills.ts` — CRUD YAML
28
- - `routes/tools.ts` — `GET /api/tools`
29
- - `routes/projects.ts` — `GET /api/projects`
30
- - `routes/config.ts` — `GET/PUT /api/config`
31
- - `routes/validate.ts` — `POST /api/validate`
32
- - `routes/webhooks.ts` — enregistrement webhooks
33
- - `webhook-store.ts`, `webhook-dispatcher.ts` — persistence + dispatch webhooks
34
- - `integration-store.ts`, `integration-runtime.ts` — lifecycle intégrations
35
- - `integrations/linear/webhook-handler.ts` — handler Linear (drag → run → comment → move)
36
- - `spawners/http-api-spawner.ts` — `HttpApiSpawner` : spawner auto-référentiel pour `studio_run`
37
-
38
- ## Séquence bootstrap
39
-
40
- ```
41
- findStudioDir(cwd)
42
- → loadConfig (config.yaml + ${ENV_VAR} substitution)
43
- → createRunStore (SQLite | PostgreSQL | InMemory)
44
- → createProviderRegistry + ToolRegistry
45
- → loadProjectTools (.tool.yaml) + loadPlugins (MCP)
46
- → new HttpApiSpawner → EngineConfig → InProcessLauncher
47
- → WebhookStore + IntegrationStore + IntegrationRuntime
48
- ```
49
-
50
- ## Dépendances
51
-
52
- `@studio-foundation/engine`, `@studio-foundation/runner`, `@studio-foundation/contracts`
package/src/.gitkeep DELETED
File without changes
package/src/api.ts DELETED
@@ -1,8 +0,0 @@
1
- // Named exports for programmatic use (CLI, tests)
2
- export { bootstrap } from './bootstrap.js';
3
- export { buildServer } from './server.js';
4
- export type { ServerDeps, ApiConfig } from './server.js';
5
- export type { RunLauncher, LaunchConfig } from './launcher.js';
6
- export { InProcessLauncher, generateRunId } from './launcher.js';
7
- export { cloneRepo, resolveRepoPath } from './utils/repo-resolver.js';
8
- export type { RepoResolveOptions } from './utils/repo-resolver.js';
package/src/bootstrap.ts DELETED
@@ -1,259 +0,0 @@
1
- // Bootstrap — finds .studio/, loads config, creates engine + store + launcher
2
- // Same pattern as CLI but without terminal output
3
-
4
- import { resolve, join, dirname } from 'node:path';
5
- import { access, readFile } from 'node:fs/promises';
6
- import { fileURLToPath } from 'node:url';
7
- import yaml from 'js-yaml';
8
- import {
9
- type EngineConfig,
10
- SQLiteRunStore,
11
- InMemoryRunStore,
12
- PgRunStore,
13
- type AnyRunStore,
14
- } from '@studio-foundation/engine';
15
- import {
16
- createDefaultRegistry,
17
- ToolRegistry,
18
- loadProjectTools,
19
- loadPlugins,
20
- loadProjectIntegrations,
21
- MCPClient,
22
- type SkillContent,
23
- } from '@studio-foundation/runner';
24
- import { InProcessLauncher, type RunLauncher } from './launcher.js';
25
- import { RunEventBus } from './event-bus.js';
26
- import type { MaskedConfig } from './server.js';
27
- import { WebhookStore } from './webhook-store.js';
28
- import { WebhookDispatcher } from './webhook-dispatcher.js';
29
- import { IntegrationStore } from './integration-store.js';
30
- import { IntegrationRuntime } from './integration-runtime.js';
31
- import { HttpApiSpawner } from './spawners/http-api-spawner.js';
32
- import { resolvePlans, type PlansConfig } from './plans.js';
33
- import { UserStore } from './user-store.js';
34
- import { PgUserStore } from './user-store-pg.js';
35
-
36
- export interface StudioApiConfig {
37
- providers?: {
38
- openai?: { apiKey: string };
39
- anthropic?: { apiKey: string };
40
- ollama?: { baseUrl?: string }; // ADD THIS
41
- };
42
- paths?: { projects_dir?: string };
43
- defaults?: { provider?: string; model?: string };
44
- api?: { key?: string; port?: number };
45
- integrations?: Record<string, Record<string, unknown>>;
46
- db?: {
47
- type?: 'sqlite' | 'postgres' | 'inmemory';
48
- url?: string;
49
- };
50
- plans?: Record<string, {
51
- runs_per_day?: number;
52
- max_concurrent?: number;
53
- max_tokens_per_run?: number;
54
- rate_limit_per_minute?: number;
55
- }>;
56
- }
57
-
58
- export interface BootstrapResult {
59
- store: AnyRunStore;
60
- launcher: RunLauncher;
61
- configsDir: string;
62
- /** Raw projects_dir from config (may contain ~). Used by route handlers for repo cloning. */
63
- projectsDir?: string;
64
- projectName: string;
65
- apiConfig: { key?: string; port?: number };
66
- cleanup: () => Promise<void>;
67
- studioVersion: string;
68
- maskedConfig: MaskedConfig;
69
- webhookStore: WebhookStore;
70
- integrationStore: IntegrationStore;
71
- integrationRuntime: IntegrationRuntime;
72
- userStore: UserStore | PgUserStore;
73
- plans: PlansConfig;
74
- }
75
-
76
- async function findStudioDir(startDir: string): Promise<string | null> {
77
- let current = resolve(startDir);
78
- while (true) {
79
- const candidate = join(current, '.studio');
80
- try {
81
- await access(candidate);
82
- return candidate;
83
- } catch {
84
- // not here, go up
85
- }
86
- const parent = dirname(current);
87
- if (parent === current) return null;
88
- current = parent;
89
- }
90
- }
91
-
92
- export async function bootstrap(cwd: string = process.cwd()): Promise<BootstrapResult> {
93
- const studioDir = await findStudioDir(cwd);
94
- if (!studioDir) {
95
- throw new Error(`No .studio/ directory found from ${cwd}. Run 'studio init' first.`);
96
- }
97
-
98
- // Load config
99
- let config: StudioApiConfig = {};
100
- try {
101
- const raw = await readFile(join(studioDir, 'config.yaml'), 'utf-8');
102
- // Resolve env vars
103
- const resolved = raw.replace(/\$\{([^}]+)\}/g, (_m: string, v: string) => process.env[v.trim()] ?? '');
104
- config = (yaml.load(resolved) as StudioApiConfig) ?? {};
105
- } catch {
106
- // No config.yaml — use defaults
107
- }
108
-
109
- // Read studio version from api/package.json
110
- const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
111
- const pkgRaw = await readFile(pkgPath, 'utf-8');
112
- const studioVersion = (JSON.parse(pkgRaw) as { version: string }).version;
113
-
114
- // Build masked config — provider names only, no API keys
115
- const maskedConfig = {
116
- defaults: config.defaults,
117
- providers: Object.keys(config.providers ?? {}),
118
- };
119
-
120
- const runsDir = join(studioDir, 'runs');
121
-
122
- const dbType = config.db?.type ?? 'sqlite';
123
-
124
- // WebhookStore and IntegrationStore are always SQLite — they are separate from RunStore
125
- const dbPath = join(studioDir, 'runs', 'runs.db');
126
-
127
- let store: AnyRunStore;
128
- if (dbType === 'postgres') {
129
- const url = config.db?.url;
130
- if (!url) throw new Error('db.url is required when db.type is postgres');
131
- store = new PgRunStore(url);
132
- } else if (dbType === 'inmemory') {
133
- store = new InMemoryRunStore();
134
- } else {
135
- store = new SQLiteRunStore(dbPath);
136
- }
137
-
138
- // UserStore — same DB as run store
139
- let userStore: UserStore | PgUserStore;
140
- if (dbType === 'postgres') {
141
- const url = config.db?.url;
142
- if (!url) throw new Error('db.url is required when db.type is postgres');
143
- userStore = new PgUserStore(url);
144
- } else {
145
- userStore = new UserStore(dbPath);
146
- }
147
-
148
- const plans = resolvePlans(config.plans as Record<string, import('./plans.js').PlanLimits> | undefined);
149
-
150
- const providerRegistry = createDefaultRegistry({
151
- openai: config.providers?.openai ? { apiKey: config.providers.openai.apiKey } : undefined,
152
- anthropic: config.providers?.anthropic ? { apiKey: config.providers.anthropic.apiKey } : undefined,
153
- ollama: config.providers?.ollama ? { baseUrl: config.providers.ollama.baseUrl } : undefined,
154
- });
155
-
156
- const toolsDir = join(studioDir, 'tools');
157
- const loadedPlugins = await loadProjectTools(toolsDir, cwd);
158
- const toolRegistry = new ToolRegistry();
159
- for (const plugin of loadedPlugins) {
160
- toolRegistry.registerPlugin(plugin.name, plugin.tools, plugin.promptSnippet);
161
- }
162
-
163
- // Load MCP plugins
164
- const pluginsDir = join(studioDir, 'plugins');
165
- const pluginManifests = await loadPlugins(pluginsDir);
166
- const mcpClients: InstanceType<typeof MCPClient>[] = [];
167
- const pluginSkills: Record<string, string[]> = {};
168
-
169
- for (const manifest of pluginManifests) {
170
- for (const [serverName, serverDef] of Object.entries(manifest.mcpServers)) {
171
- const client = new MCPClient(manifest.name, serverName, serverDef);
172
- try {
173
- await client.start();
174
- const mcpTools = await client.getTools();
175
- toolRegistry.registerPlugin(`${manifest.name}-${serverName}`, mcpTools);
176
- mcpClients.push(client);
177
- } catch {
178
- // Plugin failed to start — skip silently for API
179
- }
180
- }
181
- if (manifest.skills.length > 0) {
182
- pluginSkills[manifest.name] = manifest.skills.map(
183
- (s: SkillContent) => `## Skill: ${s.name}\n\n${s.content}`
184
- );
185
- }
186
- }
187
-
188
- // Self-referential spawner: allows pipelines to spawn child runs via the API
189
- // Must match DEFAULT_PORT in index.ts (3700) — was 3000 which collides with common dev servers
190
- const apiPort = config.api?.port ?? 3700;
191
- const spawner = new HttpApiSpawner(`http://localhost:${apiPort}`, config.api?.key);
192
-
193
- const engineConfig: EngineConfig = {
194
- configsDir: studioDir,
195
- providerRegistry,
196
- toolRegistry,
197
- db: store,
198
- pluginSkills,
199
- spawner,
200
- maxDepth: 3,
201
- defaultProvider: config.defaults?.provider,
202
- defaultModel: config.defaults?.model,
203
- };
204
-
205
- const bus = new RunEventBus();
206
- const launcher = new InProcessLauncher(engineConfig, store, runsDir, bus);
207
-
208
- // Derive project name from the directory containing .studio/
209
- const projectName = studioDir.split('/').slice(-2, -1)[0] ?? 'studio-project';
210
-
211
- const webhookStore = new WebhookStore(dbPath);
212
- const integrationStore = new IntegrationStore(dbPath);
213
- const integrationsDir = join(studioDir, 'integrations');
214
- const loadedIntegrations = await loadProjectIntegrations(integrationsDir);
215
- const integrationConfigs = (config.integrations ?? {}) as Record<string, Record<string, unknown>>;
216
- const integrationRuntime = new IntegrationRuntime({
217
- integrations: loadedIntegrations,
218
- store: integrationStore,
219
- launcher,
220
- configsDir: studioDir,
221
- projectsDir: config.paths?.projects_dir,
222
- apiConfig: config.api ?? {},
223
- integrationConfigs,
224
- });
225
- integrationRuntime.setupEventBus(bus);
226
- const webhookDispatcher = new WebhookDispatcher(webhookStore, projectName);
227
- bus.subscribeAll((runId, event) => {
228
- void webhookDispatcher.handleBusEvent(runId, event.type, event.data);
229
- });
230
-
231
- return {
232
- store,
233
- launcher,
234
- configsDir: studioDir,
235
- projectsDir: config.paths?.projects_dir,
236
- projectName,
237
- apiConfig: config.api ?? {},
238
- studioVersion,
239
- maskedConfig,
240
- webhookStore,
241
- integrationStore,
242
- integrationRuntime,
243
- userStore,
244
- plans,
245
- cleanup: async () => {
246
- for (const client of mcpClients) {
247
- try { await client.close(); } catch { /* ignore */ }
248
- }
249
- if ('close' in store && typeof (store as AnyRunStore & { close?: () => unknown }).close === 'function') {
250
- await (store as AnyRunStore & { close(): Promise<void> | void }).close();
251
- }
252
- if (userStore && 'close' in userStore && typeof userStore.close === 'function') {
253
- await userStore.close();
254
- }
255
- webhookStore.close();
256
- integrationStore.close();
257
- },
258
- };
259
- }
package/src/event-bus.ts DELETED
@@ -1,64 +0,0 @@
1
- export type SseEventType =
2
- | 'pipeline_start'
3
- | 'stage_start'
4
- | 'stage_complete'
5
- | 'stage_retry'
6
- | 'group_start'
7
- | 'group_iteration'
8
- | 'group_feedback'
9
- | 'group_complete'
10
- | 'pipeline_complete'
11
- | 'pipeline_cancelled'
12
- | 'stage_context'
13
- | 'tool_call_start'
14
- | 'tool_call_complete'
15
- | 'done';
16
-
17
- export interface BusEvent {
18
- type: SseEventType;
19
- data: unknown;
20
- }
21
-
22
- export type BusListener = (event: BusEvent) => void;
23
- export type GlobalBusListener = (runId: string, event: BusEvent) => void;
24
-
25
- export class RunEventBus {
26
- private subs = new Map<string, Set<BusListener>>();
27
- private globalListeners = new Set<GlobalBusListener>();
28
-
29
- subscribe(runId: string, listener: BusListener): () => void {
30
- if (!this.subs.has(runId)) {
31
- this.subs.set(runId, new Set());
32
- }
33
- this.subs.get(runId)!.add(listener);
34
- return () => {
35
- this.subs.get(runId)?.delete(listener);
36
- };
37
- }
38
-
39
- // Global subscription — receives all events across all runs
40
- subscribeAll(listener: GlobalBusListener): () => void {
41
- this.globalListeners.add(listener);
42
- return () => {
43
- this.globalListeners.delete(listener);
44
- };
45
- }
46
-
47
- emit(runId: string, type: SseEventType, data: unknown): void {
48
- const event: BusEvent = { type, data };
49
- const listeners = this.subs.get(runId);
50
- if (listeners) {
51
- for (const listener of listeners) {
52
- listener(event);
53
- }
54
- }
55
- for (const listener of this.globalListeners) {
56
- listener(runId, event);
57
- }
58
- }
59
-
60
- close(runId: string): void {
61
- this.emit(runId, 'done', {});
62
- this.subs.delete(runId);
63
- }
64
- }
package/src/index.ts DELETED
@@ -1,58 +0,0 @@
1
- #!/usr/bin/env node
2
- // Standalone entrypoint for @studio/api
3
- // PM2: pm2 start node_modules/.bin/studio-api --env STUDIO_CWD=/path/to/project
4
- // systemd: ExecStart=/usr/bin/node /path/to/api/dist/index.js
5
-
6
- import { bootstrap } from './bootstrap.js';
7
- import { buildServer } from './server.js';
8
-
9
- const DEFAULT_PORT = 3700;
10
-
11
- async function main() {
12
- const cwd = process.env['STUDIO_CWD'] ?? process.cwd();
13
-
14
- let result: Awaited<ReturnType<typeof bootstrap>>;
15
- try {
16
- result = await bootstrap(cwd);
17
- } catch (err) {
18
- console.error('Bootstrap failed:', err instanceof Error ? err.message : String(err));
19
- process.exit(1);
20
- }
21
-
22
- const { store, launcher, configsDir, projectName, apiConfig, cleanup, studioVersion, maskedConfig, webhookStore, integrationStore, integrationRuntime, userStore, plans } = result;
23
- const port = apiConfig.port ?? DEFAULT_PORT;
24
-
25
- let hasUsers = false;
26
- try {
27
- // listUsers() is sync on UserStore and async on PgUserStore; await handles both
28
- const users = await userStore.listUsers();
29
- hasUsers = users.length > 0;
30
- } catch {
31
- // DB unavailable at startup — default to open/dev mode
32
- }
33
-
34
- const server = buildServer({ store, launcher, configsDir, projectName, apiConfig, studioVersion, maskedConfig, webhookStore, integrationStore, integrationRuntime, userStore, plans, hasUsers });
35
-
36
- // Graceful shutdown
37
- const shutdown = async () => {
38
- await server.close();
39
- await cleanup();
40
- process.exit(0);
41
- };
42
- process.on('SIGTERM', () => void shutdown());
43
- process.on('SIGINT', () => void shutdown());
44
-
45
- try {
46
- await server.listen({ port, host: '0.0.0.0' });
47
- console.log(`Studio API listening on port ${port}`);
48
- if (!apiConfig.key) {
49
- console.log('Warning: no api.key configured — running without auth');
50
- }
51
- } catch (err) {
52
- console.error('Failed to start server:', err);
53
- await cleanup();
54
- process.exit(1);
55
- }
56
- }
57
-
58
- void main();
@@ -1,180 +0,0 @@
1
- // api/src/integration-runtime.ts
2
- import type { FastifyInstance } from 'fastify';
3
- import type { IntegrationPluginDef } from '@studio-foundation/contracts';
4
- import type { IntegrationStore } from './integration-store.js';
5
- import type { RunLauncher } from './launcher.js';
6
- import type { RunEventBus } from './event-bus.js';
7
- import type { ApiConfig } from './server.js';
8
- import { WEBHOOK_HANDLERS, FAILURE_HANDLERS } from './integrations/registry.js';
9
- import type { FailureHandlerContext } from './integrations/types.js';
10
- import type { GroupFeedbackEvent } from '@studio-foundation/engine';
11
-
12
- export interface IntegrationRuntimeDeps {
13
- integrations: IntegrationPluginDef[];
14
- store: IntegrationStore;
15
- launcher: RunLauncher;
16
- configsDir: string;
17
- projectsDir?: string;
18
- apiConfig: ApiConfig;
19
- integrationConfigs: Record<string, Record<string, unknown>>;
20
- }
21
-
22
- export class IntegrationRuntime {
23
- constructor(private deps: IntegrationRuntimeDeps) {}
24
-
25
- setupEventBus(bus: RunEventBus): void {
26
- bus.subscribeAll((runId, event) => {
27
- if (event.type !== 'pipeline_complete') return;
28
-
29
- const data = event.data as {
30
- status: string;
31
- duration_ms: number;
32
- meta?: Record<string, unknown>;
33
- last_group_feedback?: GroupFeedbackEvent;
34
- };
35
-
36
- if (data.status === 'success') return;
37
-
38
- for (const integration of this.deps.integrations) {
39
- if (!integration.on_failure?.handler) continue;
40
- const handler = FAILURE_HANDLERS[integration.on_failure.handler];
41
- if (!handler) continue;
42
-
43
- const ctx: FailureHandlerContext = {
44
- runId,
45
- durationMs: data.duration_ms,
46
- status: data.status,
47
- meta: data.meta ?? {},
48
- lastGroupFeedback: data.last_group_feedback,
49
- integration,
50
- integrationConfig: this.deps.integrationConfigs[integration.name] ?? {},
51
- };
52
-
53
- void handler.handleFailure(ctx);
54
- }
55
- });
56
- }
57
-
58
- registerRoutes(fastify: FastifyInstance, prefix: string): void {
59
- for (const integration of this.deps.integrations) {
60
- if (!integration.webhook?.handler) continue;
61
- const webhookHandler = WEBHOOK_HANDLERS[integration.webhook.handler];
62
- if (!webhookHandler) continue;
63
-
64
- const name = integration.name;
65
- const { store, launcher, configsDir, projectsDir, apiConfig, integrationConfigs } = this.deps;
66
- const integrationConfig = integrationConfigs[name] ?? {};
67
- const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
68
-
69
- void fastify.register(async (scope) => {
70
- scope.addContentTypeParser(
71
- 'application/json',
72
- { parseAs: 'buffer' },
73
- (_req, body, done) => done(null, body),
74
- );
75
-
76
- // GET /api/integrations/{name}
77
- scope.get(`${prefix}/integrations/${name}`, {
78
- schema: {
79
- tags: ['integrations'],
80
- summary: `Get ${name} integration config and trigger log`,
81
- response: {
82
- 200: {
83
- type: 'object',
84
- properties: {
85
- webhook_url: { type: 'string' },
86
- pipeline: { type: ['string', 'null'] },
87
- active: { type: 'boolean' },
88
- triggers: { type: 'array' },
89
- },
90
- },
91
- },
92
- },
93
- }, async (request, reply) => {
94
- const config = store.getConfig(name);
95
- const triggers = store.listTriggers(name, 50);
96
- const baseUrl = process.env['STUDIO_BASE_URL'] ?? `${request.protocol}://${request.hostname}`;
97
- return reply.status(200).send({
98
- webhook_url: `${baseUrl}/api/integrations/${name}/webhook`,
99
- pipeline: config.pipeline ?? null,
100
- active: config.active,
101
- triggers,
102
- });
103
- });
104
-
105
- // PATCH /api/integrations/{name}
106
- scope.patch(`${prefix}/integrations/${name}`, {
107
- schema: {
108
- tags: ['integrations'],
109
- summary: `Update ${name} integration config`,
110
- body: {
111
- type: 'object',
112
- properties: {
113
- pipeline: { type: 'string' },
114
- active: { type: 'boolean' },
115
- },
116
- },
117
- response: {
118
- 200: {
119
- type: 'object',
120
- properties: {
121
- webhook_url: { type: 'string' },
122
- pipeline: { type: ['string', 'null'] },
123
- active: { type: 'boolean' },
124
- },
125
- },
126
- 400: errorSchema,
127
- },
128
- },
129
- }, async (request, reply) => {
130
- let data: { pipeline?: string; active?: boolean };
131
- try {
132
- data = JSON.parse((request.body as Buffer).toString('utf-8')) as typeof data;
133
- } catch {
134
- return reply.status(400).send({ error: 'Invalid JSON' });
135
- }
136
- store.patchConfig(name, data);
137
- const updated = store.getConfig(name);
138
- const baseUrl = process.env['STUDIO_BASE_URL'] ?? `${request.protocol}://${request.hostname}`;
139
- return reply.status(200).send({
140
- webhook_url: `${baseUrl}/api/integrations/${name}/webhook`,
141
- pipeline: updated.pipeline ?? null,
142
- active: updated.active,
143
- });
144
- });
145
-
146
- // POST /api/integrations/{name}/webhook
147
- scope.post(`${prefix}/integrations/${name}/webhook`, {
148
- schema: {
149
- tags: ['integrations'],
150
- summary: `Receive ${name} webhook event`,
151
- response: {
152
- 202: {
153
- type: 'object',
154
- properties: { run_id: { type: 'string' }, stream_url: { type: 'string' } },
155
- },
156
- 200: {
157
- type: 'object',
158
- properties: { ignored: { type: 'boolean' }, reason: { type: 'string' } },
159
- },
160
- 400: errorSchema,
161
- 401: errorSchema,
162
- },
163
- },
164
- }, async (request, reply) => {
165
- return webhookHandler.handle({
166
- rawBody: request.body as Buffer,
167
- headers: request.headers,
168
- integration,
169
- store,
170
- launcher,
171
- configsDir,
172
- projectsDir,
173
- apiConfig,
174
- integrationConfig,
175
- }, reply);
176
- });
177
- });
178
- }
179
- }
180
- }