@studio-foundation/api 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/ARCHITECTURE.md +52 -0
  2. package/LICENSE +663 -0
  3. package/README.md +161 -0
  4. package/dist/api.d.ts +8 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +6 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/bootstrap.d.ts +66 -0
  9. package/dist/bootstrap.d.ts.map +1 -0
  10. package/dist/bootstrap.js +193 -0
  11. package/dist/bootstrap.js.map +1 -0
  12. package/dist/event-bus.d.ts +16 -0
  13. package/dist/event-bus.d.ts.map +1 -0
  14. package/dist/event-bus.js +37 -0
  15. package/dist/event-bus.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +52 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/integration-runtime.d.ts +22 -0
  21. package/dist/integration-runtime.d.ts.map +1 -0
  22. package/dist/integration-runtime.js +150 -0
  23. package/dist/integration-runtime.js.map +1 -0
  24. package/dist/integration-store.d.ts +26 -0
  25. package/dist/integration-store.d.ts.map +1 -0
  26. package/dist/integration-store.js +80 -0
  27. package/dist/integration-store.js.map +1 -0
  28. package/dist/integrations/linear/failure-handler.d.ts +5 -0
  29. package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
  30. package/dist/integrations/linear/failure-handler.js +73 -0
  31. package/dist/integrations/linear/failure-handler.js.map +1 -0
  32. package/dist/integrations/linear/webhook-handler.d.ts +6 -0
  33. package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
  34. package/dist/integrations/linear/webhook-handler.js +131 -0
  35. package/dist/integrations/linear/webhook-handler.js.map +1 -0
  36. package/dist/integrations/registry.d.ts +4 -0
  37. package/dist/integrations/registry.d.ts.map +1 -0
  38. package/dist/integrations/registry.js +9 -0
  39. package/dist/integrations/registry.js.map +1 -0
  40. package/dist/integrations/types.d.ts +33 -0
  41. package/dist/integrations/types.d.ts.map +1 -0
  42. package/dist/integrations/types.js +2 -0
  43. package/dist/integrations/types.js.map +1 -0
  44. package/dist/launcher.d.ts +45 -0
  45. package/dist/launcher.d.ts.map +1 -0
  46. package/dist/launcher.js +149 -0
  47. package/dist/launcher.js.map +1 -0
  48. package/dist/logger.d.ts +7 -0
  49. package/dist/logger.d.ts.map +1 -0
  50. package/dist/logger.js +37 -0
  51. package/dist/logger.js.map +1 -0
  52. package/dist/plans.d.ts +13 -0
  53. package/dist/plans.d.ts.map +1 -0
  54. package/dist/plans.js +33 -0
  55. package/dist/plans.js.map +1 -0
  56. package/dist/routes/agents.d.ts +6 -0
  57. package/dist/routes/agents.d.ts.map +1 -0
  58. package/dist/routes/agents.js +118 -0
  59. package/dist/routes/agents.js.map +1 -0
  60. package/dist/routes/config.d.ts +6 -0
  61. package/dist/routes/config.d.ts.map +1 -0
  62. package/dist/routes/config.js +129 -0
  63. package/dist/routes/config.js.map +1 -0
  64. package/dist/routes/contracts.d.ts +6 -0
  65. package/dist/routes/contracts.d.ts.map +1 -0
  66. package/dist/routes/contracts.js +188 -0
  67. package/dist/routes/contracts.js.map +1 -0
  68. package/dist/routes/pipelines.d.ts +6 -0
  69. package/dist/routes/pipelines.d.ts.map +1 -0
  70. package/dist/routes/pipelines.js +133 -0
  71. package/dist/routes/pipelines.js.map +1 -0
  72. package/dist/routes/projects.d.ts +6 -0
  73. package/dist/routes/projects.d.ts.map +1 -0
  74. package/dist/routes/projects.js +220 -0
  75. package/dist/routes/projects.js.map +1 -0
  76. package/dist/routes/runs.d.ts +6 -0
  77. package/dist/routes/runs.d.ts.map +1 -0
  78. package/dist/routes/runs.js +427 -0
  79. package/dist/routes/runs.js.map +1 -0
  80. package/dist/routes/skills.d.ts +6 -0
  81. package/dist/routes/skills.d.ts.map +1 -0
  82. package/dist/routes/skills.js +123 -0
  83. package/dist/routes/skills.js.map +1 -0
  84. package/dist/routes/tools.d.ts +6 -0
  85. package/dist/routes/tools.d.ts.map +1 -0
  86. package/dist/routes/tools.js +193 -0
  87. package/dist/routes/tools.js.map +1 -0
  88. package/dist/routes/users.d.ts +6 -0
  89. package/dist/routes/users.d.ts.map +1 -0
  90. package/dist/routes/users.js +152 -0
  91. package/dist/routes/users.js.map +1 -0
  92. package/dist/routes/validate.d.ts +6 -0
  93. package/dist/routes/validate.d.ts.map +1 -0
  94. package/dist/routes/validate.js +171 -0
  95. package/dist/routes/validate.js.map +1 -0
  96. package/dist/routes/webhooks.d.ts +8 -0
  97. package/dist/routes/webhooks.d.ts.map +1 -0
  98. package/dist/routes/webhooks.js +102 -0
  99. package/dist/routes/webhooks.js.map +1 -0
  100. package/dist/server.d.ts +46 -0
  101. package/dist/server.d.ts.map +1 -0
  102. package/dist/server.js +101 -0
  103. package/dist/server.js.map +1 -0
  104. package/dist/spawners/http-api-spawner.d.ts +10 -0
  105. package/dist/spawners/http-api-spawner.d.ts.map +1 -0
  106. package/dist/spawners/http-api-spawner.js +88 -0
  107. package/dist/spawners/http-api-spawner.js.map +1 -0
  108. package/dist/user-store-pg.d.ts +21 -0
  109. package/dist/user-store-pg.d.ts.map +1 -0
  110. package/dist/user-store-pg.js +97 -0
  111. package/dist/user-store-pg.js.map +1 -0
  112. package/dist/user-store.d.ts +29 -0
  113. package/dist/user-store.d.ts.map +1 -0
  114. package/dist/user-store.js +97 -0
  115. package/dist/user-store.js.map +1 -0
  116. package/dist/utils/repo-resolver.d.ts +3 -0
  117. package/dist/utils/repo-resolver.d.ts.map +1 -0
  118. package/dist/utils/repo-resolver.js +3 -0
  119. package/dist/utils/repo-resolver.js.map +1 -0
  120. package/dist/webhook-dispatcher.d.ts +13 -0
  121. package/dist/webhook-dispatcher.d.ts.map +1 -0
  122. package/dist/webhook-dispatcher.js +108 -0
  123. package/dist/webhook-dispatcher.js.map +1 -0
  124. package/dist/webhook-store.d.ts +31 -0
  125. package/dist/webhook-store.d.ts.map +1 -0
  126. package/dist/webhook-store.js +91 -0
  127. package/dist/webhook-store.js.map +1 -0
  128. package/package.json +59 -0
  129. package/src/.gitkeep +0 -0
  130. package/src/api.ts +8 -0
  131. package/src/bootstrap.ts +259 -0
  132. package/src/event-bus.ts +64 -0
  133. package/src/index.ts +58 -0
  134. package/src/integration-runtime.ts +180 -0
  135. package/src/integration-store.ts +125 -0
  136. package/src/integrations/linear/failure-handler.ts +93 -0
  137. package/src/integrations/linear/webhook-handler.ts +156 -0
  138. package/src/integrations/registry.ts +12 -0
  139. package/src/integrations/types.ts +37 -0
  140. package/src/launcher.ts +214 -0
  141. package/src/logger.ts +50 -0
  142. package/src/plans.ts +43 -0
  143. package/src/routes/agents.ts +131 -0
  144. package/src/routes/config.ts +154 -0
  145. package/src/routes/contracts.ts +205 -0
  146. package/src/routes/pipelines.ts +146 -0
  147. package/src/routes/projects.ts +237 -0
  148. package/src/routes/runs.ts +468 -0
  149. package/src/routes/skills.ts +136 -0
  150. package/src/routes/tools.ts +222 -0
  151. package/src/routes/users.ts +169 -0
  152. package/src/routes/validate.ts +196 -0
  153. package/src/routes/webhooks.ts +120 -0
  154. package/src/server.ts +155 -0
  155. package/src/spawners/http-api-spawner.ts +96 -0
  156. package/src/user-store-pg.ts +138 -0
  157. package/src/user-store.ts +125 -0
  158. package/src/utils/repo-resolver.ts +3 -0
  159. package/src/webhook-dispatcher.ts +142 -0
  160. package/src/webhook-store.ts +141 -0
  161. package/tests/agents.test.ts +164 -0
  162. package/tests/cancel.test.ts +120 -0
  163. package/tests/config.test.ts +196 -0
  164. package/tests/contracts.test.ts +302 -0
  165. package/tests/event-bus.test.ts +53 -0
  166. package/tests/http-api-spawner.test.ts +158 -0
  167. package/tests/integration-runtime.test.ts +199 -0
  168. package/tests/integration-store.test.ts +66 -0
  169. package/tests/integrations/linear/failure-handler.test.ts +113 -0
  170. package/tests/integrations/linear/webhook-handler.test.ts +191 -0
  171. package/tests/launcher.test.ts +380 -0
  172. package/tests/linear-webhook.test.ts +390 -0
  173. package/tests/pipelines.test.ts +166 -0
  174. package/tests/projects.test.ts +283 -0
  175. package/tests/runs.test.ts +379 -0
  176. package/tests/server.test.ts +208 -0
  177. package/tests/skills.test.ts +149 -0
  178. package/tests/sse.test.ts +129 -0
  179. package/tests/tools.test.ts +233 -0
  180. package/tests/user-store.test.ts +93 -0
  181. package/tests/users.test.ts +189 -0
  182. package/tests/utils/repo-resolver.test.ts +105 -0
  183. package/tests/validate.test.ts +233 -0
  184. package/tests/webhook-dispatcher.test.ts +214 -0
  185. package/tests/webhook-store.test.ts +98 -0
  186. package/tests/webhooks.test.ts +176 -0
  187. package/tsconfig.json +24 -0
  188. package/vitest.config.ts +7 -0
@@ -0,0 +1,64 @@
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 ADDED
@@ -0,0 +1,58 @@
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();
@@ -0,0 +1,180 @@
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
+ }
@@ -0,0 +1,125 @@
1
+ // IntegrationStore — generic SQLite persistence for integration configs and trigger logs
2
+ // Partitioned by integration_name — supports any integration (linear, slack, github, etc.)
3
+ // Uses the same DB file as the run store (.studio/runs/runs.db)
4
+
5
+ import { createRequire } from 'node:module';
6
+
7
+ export interface IntegrationConfig {
8
+ pipeline?: string;
9
+ active: boolean;
10
+ }
11
+
12
+ export interface IntegrationTriggerRecord {
13
+ id: string;
14
+ integration_name: string;
15
+ received_at: string;
16
+ external_id?: string;
17
+ external_label?: string;
18
+ external_url?: string;
19
+ pipeline: string;
20
+ run_id?: string;
21
+ status: 'success' | 'failed';
22
+ }
23
+
24
+ export class IntegrationStore {
25
+ private db: import('better-sqlite3').Database;
26
+
27
+ constructor(dbPath: string) {
28
+ const _require = createRequire(import.meta.url);
29
+ const Database = _require('better-sqlite3') as new (path: string) => import('better-sqlite3').Database;
30
+ this.db = new Database(dbPath);
31
+ this.db.pragma('journal_mode = WAL');
32
+ this.initSchema();
33
+ }
34
+
35
+ private initSchema(): void {
36
+ this.db.exec(`
37
+ CREATE TABLE IF NOT EXISTS integration_config (
38
+ integration_name TEXT NOT NULL,
39
+ pipeline TEXT,
40
+ active INTEGER NOT NULL DEFAULT 0,
41
+ PRIMARY KEY (integration_name)
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS integration_triggers (
45
+ id TEXT PRIMARY KEY,
46
+ integration_name TEXT NOT NULL,
47
+ received_at TEXT NOT NULL,
48
+ external_id TEXT,
49
+ external_label TEXT,
50
+ external_url TEXT,
51
+ pipeline TEXT NOT NULL,
52
+ run_id TEXT,
53
+ status TEXT NOT NULL
54
+ );
55
+ `);
56
+ }
57
+
58
+ getConfig(integrationName: string): IntegrationConfig {
59
+ const row = this.db.prepare(
60
+ 'SELECT pipeline, active FROM integration_config WHERE integration_name = ?'
61
+ ).get(integrationName) as { pipeline: string | null; active: number } | undefined;
62
+
63
+ if (!row) return { active: false };
64
+ return {
65
+ ...(row.pipeline != null ? { pipeline: row.pipeline } : {}),
66
+ active: row.active === 1,
67
+ };
68
+ }
69
+
70
+ patchConfig(integrationName: string, data: Partial<IntegrationConfig>): void {
71
+ const current = this.getConfig(integrationName);
72
+ const pipeline = 'pipeline' in data ? (data.pipeline ?? null) : (current.pipeline ?? null);
73
+ const active = 'active' in data ? (data.active ? 1 : 0) : (current.active ? 1 : 0);
74
+ this.db.prepare(`
75
+ INSERT INTO integration_config (integration_name, pipeline, active) VALUES (?, ?, ?)
76
+ ON CONFLICT(integration_name) DO UPDATE SET pipeline = excluded.pipeline, active = excluded.active
77
+ `).run(integrationName, pipeline, active);
78
+ }
79
+
80
+ insertTrigger(trigger: IntegrationTriggerRecord): void {
81
+ this.db.prepare(`
82
+ INSERT INTO integration_triggers
83
+ (id, integration_name, received_at, external_id, external_label, external_url, pipeline, run_id, status)
84
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
85
+ `).run(
86
+ trigger.id,
87
+ trigger.integration_name,
88
+ trigger.received_at,
89
+ trigger.external_id ?? null,
90
+ trigger.external_label ?? null,
91
+ trigger.external_url ?? null,
92
+ trigger.pipeline,
93
+ trigger.run_id ?? null,
94
+ trigger.status,
95
+ );
96
+ }
97
+
98
+ listTriggers(integrationName: string, limit = 50): IntegrationTriggerRecord[] {
99
+ type Row = {
100
+ id: string; integration_name: string; received_at: string;
101
+ external_id: string | null; external_label: string | null; external_url: string | null;
102
+ pipeline: string; run_id: string | null; status: string;
103
+ };
104
+ const rows = this.db.prepare(
105
+ `SELECT id, integration_name, received_at, external_id, external_label, external_url, pipeline, run_id, status
106
+ FROM integration_triggers WHERE integration_name = ? ORDER BY received_at DESC LIMIT ?`
107
+ ).all(integrationName, limit) as Row[];
108
+
109
+ return rows.map(row => ({
110
+ id: row.id,
111
+ integration_name: row.integration_name,
112
+ received_at: row.received_at,
113
+ ...(row.external_id != null ? { external_id: row.external_id } : {}),
114
+ ...(row.external_label != null ? { external_label: row.external_label } : {}),
115
+ ...(row.external_url != null ? { external_url: row.external_url } : {}),
116
+ pipeline: row.pipeline,
117
+ ...(row.run_id != null ? { run_id: row.run_id } : {}),
118
+ status: row.status as 'success' | 'failed',
119
+ }));
120
+ }
121
+
122
+ close(): void {
123
+ this.db.close();
124
+ }
125
+ }
@@ -0,0 +1,93 @@
1
+ // api/src/integrations/linear/failure-handler.ts
2
+ import type { FailureHandler, FailureHandlerContext } from '../types.js';
3
+
4
+ const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
5
+
6
+ async function gql(query: string, variables: Record<string, unknown>, apiKey: string): Promise<Record<string, unknown>> {
7
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json', Authorization: apiKey },
10
+ body: JSON.stringify({ query, variables }),
11
+ });
12
+ if (!res.ok) throw new Error(`Linear GraphQL HTTP error: ${res.status} ${res.statusText}`);
13
+ return res.json() as Promise<Record<string, unknown>>;
14
+ }
15
+
16
+ function buildFailureComment(ctx: FailureHandlerContext): string {
17
+ const iterations = ctx.lastGroupFeedback?.iteration;
18
+ const iterLabel = iterations != null ? ` après ${iterations} itérations QA` : '';
19
+ const rejectionReason = ctx.lastGroupFeedback?.rejection_reason;
20
+ const rejectionDetails = ctx.lastGroupFeedback?.rejection_details;
21
+ const lines: string[] = [];
22
+ lines.push(`❌ **Code Builder échoué** —${iterLabel ? ` QA a rejeté${iterLabel}` : ' pipeline échoué'}`);
23
+ lines.push('');
24
+ if (rejectionReason) {
25
+ lines.push('**Dernière raison de rejet :**');
26
+ if (rejectionDetails && rejectionDetails.length > 0) {
27
+ for (const detail of rejectionDetails) lines.push(`- ${detail}`);
28
+ } else {
29
+ lines.push(`- ${rejectionReason}`);
30
+ }
31
+ lines.push('');
32
+ }
33
+ lines.push('**Action requise :** réviser le brief ou augmenter max_iterations');
34
+ lines.push('');
35
+ lines.push(`**Run ID :** ${ctx.runId}`);
36
+ return lines.join('\n');
37
+ }
38
+
39
+ export class LinearFailureHandler implements FailureHandler {
40
+ async handleFailure(ctx: FailureHandlerContext): Promise<void> {
41
+ const issueId = typeof ctx.meta['linear_issue_id'] === 'string' ? ctx.meta['linear_issue_id'] : undefined;
42
+ if (!issueId) return;
43
+
44
+ const apiKey = (ctx.integrationConfig['LINEAR_API_KEY'] as string | undefined)
45
+ ?? process.env['LINEAR_API_KEY'];
46
+ if (!apiKey) {
47
+ console.warn('[linear-failure-handler] LINEAR_API_KEY not set — skipping failure notification');
48
+ return;
49
+ }
50
+
51
+ const comment = buildFailureComment(ctx);
52
+ try {
53
+ await gql(
54
+ `mutation CreateComment($issueId: String!, $body: String!) {
55
+ commentCreate(input: { issueId: $issueId, body: $body }) { success }
56
+ }`,
57
+ { issueId, body: comment },
58
+ apiKey,
59
+ );
60
+
61
+ const statesResult = await gql(
62
+ `query GetWorkflowStates($issueId: String!) {
63
+ issue(id: $issueId) { team { states { nodes { id name } } } }
64
+ }`,
65
+ { issueId },
66
+ apiKey,
67
+ );
68
+
69
+ type StateNode = { id: string; name: string };
70
+ const nodes = (
71
+ (statesResult as { data?: { issue?: { team?: { states?: { nodes?: StateNode[] } } } } })
72
+ .data?.issue?.team?.states?.nodes
73
+ ) ?? [];
74
+ const backlogState = nodes.find((s: StateNode) => s.name === 'Backlog');
75
+
76
+ if (backlogState) {
77
+ await gql(
78
+ `mutation UpdateIssue($id: String!, $stateId: String!) {
79
+ issueUpdate(id: $id, input: { stateId: $stateId }) { success }
80
+ }`,
81
+ { id: issueId, stateId: backlogState.id },
82
+ apiKey,
83
+ );
84
+ } else {
85
+ console.warn(`[linear-failure-handler] "Backlog" state not found for issue ${issueId} — status not updated`);
86
+ }
87
+
88
+ console.log(`[linear-failure-handler] Failure notification posted for issue ${issueId}`);
89
+ } catch (err) {
90
+ console.error('[linear-failure-handler] Failed to notify Linear:', err);
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,156 @@
1
+ // LinearWebhookHandler — plugin class implementing WebhookHandler
2
+ // Extracted from routes/linear-webhook.ts POST /integrations/linear/webhook
3
+ // Receives all deps via WebhookHandlerContext instead of Fastify plugin closure.
4
+
5
+ import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
6
+ import { join } from 'node:path';
7
+ import type { FastifyReply } from 'fastify';
8
+ import type { WebhookHandler, WebhookHandlerContext } from '../types.js';
9
+ import { loadPipelineByName } from '@studio-foundation/engine';
10
+ import { resolveRepoPath } from '../../utils/repo-resolver.js';
11
+
12
+ interface LinearIssuePayload {
13
+ type?: string;
14
+ action?: string;
15
+ data?: {
16
+ id?: string;
17
+ identifier?: string;
18
+ title?: string;
19
+ description?: string;
20
+ state?: { name?: string };
21
+ };
22
+ }
23
+
24
+ function verifyHmac(rawBody: Buffer, signature: string, secret: string): boolean {
25
+ const computed = createHmac('sha256', secret).update(rawBody).digest('hex');
26
+ try {
27
+ const a = Buffer.from(computed, 'hex');
28
+ const b = Buffer.from(signature, 'hex');
29
+ if (a.length !== b.length) return false;
30
+ return timingSafeEqual(a, b);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export class LinearWebhookHandler implements WebhookHandler {
37
+ async handle(ctx: WebhookHandlerContext, reply: FastifyReply): Promise<unknown> {
38
+ const { rawBody, headers, integration, store, launcher, configsDir, projectsDir, integrationConfig } = ctx;
39
+
40
+ // HMAC verification — only when hmac config is present in the integration def
41
+ const hmacConfig = integration.webhook?.hmac;
42
+ if (hmacConfig) {
43
+ const secret = integrationConfig[hmacConfig.secret_env] as string | undefined;
44
+ if (secret) {
45
+ const sig = headers[hmacConfig.header];
46
+ if (typeof sig !== 'string') {
47
+ return reply.status(401).send({ error: `Missing ${hmacConfig.header} header` });
48
+ }
49
+ if (!verifyHmac(rawBody, sig, secret)) {
50
+ return reply.status(401).send({ error: 'Invalid signature' });
51
+ }
52
+ }
53
+ }
54
+
55
+ let payload: LinearIssuePayload;
56
+ try {
57
+ payload = JSON.parse(rawBody.toString('utf-8')) as LinearIssuePayload;
58
+ } catch {
59
+ return reply.status(400).send({ error: 'Invalid JSON' });
60
+ }
61
+
62
+ // Only handle Issue update events
63
+ if (payload.type !== 'Issue' || payload.action !== 'update') {
64
+ return reply.status(200).send({ ignored: true, reason: 'not an issue update' });
65
+ }
66
+
67
+ const issue = payload.data ?? {};
68
+
69
+ // Only trigger on transitions to "In Progress"
70
+ if (issue.state?.name !== 'In Progress') {
71
+ return reply.status(200).send({ ignored: true, reason: `state is "${issue.state?.name ?? 'unknown'}"` });
72
+ }
73
+
74
+ // Check integration is active
75
+ const config = store.getConfig(integration.name);
76
+ if (!config.active) {
77
+ return reply.status(200).send({ ignored: true, reason: 'integration is inactive' });
78
+ }
79
+
80
+ const pipeline = config.pipeline ?? 'feature-builder';
81
+ const issueUrl = `https://linear.app/studioag/issue/${issue.identifier}`;
82
+
83
+ // Resolve repo path from pipeline YAML — mirrors CLI behaviour.
84
+ // If the pipeline file can't be loaded, fall back gracefully —
85
+ // launcher.launch() will surface the proper "pipeline not found" error afterwards.
86
+ let pipelineRepoUrl: string | undefined;
87
+ let pipelineRepoBranch: string | undefined;
88
+ try {
89
+ const pipelineDef = await loadPipelineByName(pipeline, join(configsDir, 'pipelines'));
90
+ pipelineRepoUrl = pipelineDef.repo?.url;
91
+ pipelineRepoBranch = pipelineDef.repo?.branch;
92
+ } catch {
93
+ // Pipeline not found or unparseable — launcher.launch() will handle the error
94
+ }
95
+
96
+ let repoPath: string;
97
+ try {
98
+ repoPath = await resolveRepoPath({
99
+ repoUrl: pipelineRepoUrl,
100
+ rawProjectsDir: projectsDir,
101
+ pipelineName: pipeline,
102
+ branch: pipelineRepoBranch,
103
+ });
104
+ } catch (err) {
105
+ return reply.status(400).send({ error: err instanceof Error ? err.message : String(err) });
106
+ }
107
+
108
+ // Construct structured input for the configured pipeline
109
+ const input: Record<string, unknown> = {
110
+ brief_summary: [issue.identifier, issue.title].filter(Boolean).join(' — '),
111
+ description: issue.description ?? '',
112
+ acceptance_criteria: [],
113
+ };
114
+
115
+ const meta: Record<string, unknown> = {
116
+ linear_issue_id: issue.id,
117
+ linear_issue_identifier: issue.identifier,
118
+ linear_issue_url: issueUrl,
119
+ };
120
+
121
+ const runId = randomUUID();
122
+ const triggerId = randomUUID();
123
+ const receivedAt = new Date().toISOString();
124
+
125
+ try {
126
+ await launcher.launch({ runId, pipeline, input, configsDir, repoPath, meta });
127
+
128
+ store.insertTrigger({
129
+ id: triggerId,
130
+ integration_name: integration.name,
131
+ received_at: receivedAt,
132
+ external_id: issue.id,
133
+ external_label: [issue.identifier, issue.title].filter(Boolean).join(' — '),
134
+ external_url: issueUrl,
135
+ pipeline,
136
+ run_id: runId,
137
+ status: 'success',
138
+ });
139
+ } catch (err) {
140
+ store.insertTrigger({
141
+ id: triggerId,
142
+ integration_name: integration.name,
143
+ received_at: receivedAt,
144
+ external_id: issue.id,
145
+ external_label: [issue.identifier, issue.title].filter(Boolean).join(' — '),
146
+ external_url: issueUrl,
147
+ pipeline,
148
+ run_id: runId,
149
+ status: 'failed',
150
+ });
151
+ throw err;
152
+ }
153
+
154
+ return reply.status(202).send({ run_id: runId, stream_url: `/api/runs/${runId}/stream` });
155
+ }
156
+ }
@@ -0,0 +1,12 @@
1
+ // api/src/integrations/registry.ts
2
+ import type { WebhookHandler, FailureHandler } from './types.js';
3
+ import { LinearWebhookHandler } from './linear/webhook-handler.js';
4
+ import { LinearFailureHandler } from './linear/failure-handler.js';
5
+
6
+ export const WEBHOOK_HANDLERS: Record<string, WebhookHandler> = {
7
+ 'linear-webhook': new LinearWebhookHandler(),
8
+ };
9
+
10
+ export const FAILURE_HANDLERS: Record<string, FailureHandler> = {
11
+ 'linear-failure': new LinearFailureHandler(),
12
+ };