@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/integration-store.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// api/src/integrations/types.ts
|
|
2
|
-
import type { FastifyReply } from 'fastify';
|
|
3
|
-
import type { IntegrationPluginDef } from '@studio-foundation/contracts';
|
|
4
|
-
import type { GroupFeedbackEvent } from '@studio-foundation/engine';
|
|
5
|
-
import type { IntegrationStore } from '../integration-store.js';
|
|
6
|
-
import type { RunLauncher } from '../launcher.js';
|
|
7
|
-
import type { ApiConfig } from '../server.js';
|
|
8
|
-
|
|
9
|
-
export interface WebhookHandlerContext {
|
|
10
|
-
rawBody: Buffer;
|
|
11
|
-
headers: Record<string, string | string[] | undefined>;
|
|
12
|
-
integration: IntegrationPluginDef;
|
|
13
|
-
store: IntegrationStore;
|
|
14
|
-
launcher: RunLauncher;
|
|
15
|
-
configsDir: string;
|
|
16
|
-
projectsDir?: string;
|
|
17
|
-
apiConfig: ApiConfig;
|
|
18
|
-
integrationConfig: Record<string, unknown>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface FailureHandlerContext {
|
|
22
|
-
runId: string;
|
|
23
|
-
durationMs: number;
|
|
24
|
-
status: string;
|
|
25
|
-
meta: Record<string, unknown>;
|
|
26
|
-
lastGroupFeedback?: GroupFeedbackEvent;
|
|
27
|
-
integration: IntegrationPluginDef;
|
|
28
|
-
integrationConfig: Record<string, unknown>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface WebhookHandler {
|
|
32
|
-
handle(ctx: WebhookHandlerContext, reply: FastifyReply): Promise<unknown>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface FailureHandler {
|
|
36
|
-
handleFailure(ctx: FailureHandlerContext): Promise<void>;
|
|
37
|
-
}
|
package/src/launcher.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
// Run launcher — interface + InProcessLauncher implementation
|
|
2
|
-
// InProcessLauncher creates a new PipelineEngine per run for event isolation.
|
|
3
|
-
// Future: BullMQLauncher, etc.
|
|
4
|
-
|
|
5
|
-
import type { PipelineRun } from '@studio-foundation/contracts';
|
|
6
|
-
import type {
|
|
7
|
-
EngineConfig,
|
|
8
|
-
EngineEvents,
|
|
9
|
-
AnyRunStore,
|
|
10
|
-
PipelineStartEvent,
|
|
11
|
-
StageStartEvent,
|
|
12
|
-
StageCompleteEvent,
|
|
13
|
-
StageRetryEvent,
|
|
14
|
-
StageContextEvent,
|
|
15
|
-
GroupStartEvent,
|
|
16
|
-
GroupIterationEvent,
|
|
17
|
-
GroupFeedbackEvent,
|
|
18
|
-
GroupCompleteEvent,
|
|
19
|
-
PipelineCompleteEvent,
|
|
20
|
-
PipelineCancelledEvent,
|
|
21
|
-
StagedToolCallStartEvent,
|
|
22
|
-
StagedToolCallCompleteEvent,
|
|
23
|
-
} from '@studio-foundation/engine';
|
|
24
|
-
import { PipelineEngine } from '@studio-foundation/engine';
|
|
25
|
-
import { loadProjectTools } from '@studio-foundation/runner';
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
import { createApiLogger } from './logger.js';
|
|
28
|
-
import type { RunEventBus, BusListener, SseEventType } from './event-bus.js';
|
|
29
|
-
import type { AnyUserStore } from './user-store-pg.js';
|
|
30
|
-
import { getPlanLimits, DEFAULT_PLANS, type PlansConfig } from './plans.js';
|
|
31
|
-
|
|
32
|
-
export interface LaunchConfig {
|
|
33
|
-
runId: string;
|
|
34
|
-
pipeline: string;
|
|
35
|
-
input: Record<string, unknown>;
|
|
36
|
-
configsDir: string;
|
|
37
|
-
/** Pre-resolved repo path (cloned or local). Overrides engineConfig.repoPath for this run. */
|
|
38
|
-
repoPath?: string;
|
|
39
|
-
providerOverride?: string;
|
|
40
|
-
depth?: number;
|
|
41
|
-
parentRunId?: string;
|
|
42
|
-
meta?: Record<string, unknown>;
|
|
43
|
-
userId?: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export type EngineFactory = (
|
|
47
|
-
config: EngineConfig,
|
|
48
|
-
events: EngineEvents,
|
|
49
|
-
) => Pick<PipelineEngine, 'run'>;
|
|
50
|
-
|
|
51
|
-
export interface RunLauncher {
|
|
52
|
-
launch(config: LaunchConfig): Promise<{ run_id: string }>;
|
|
53
|
-
cancel(run_id: string): Promise<void>;
|
|
54
|
-
subscribe(runId: string, listener: BusListener): () => void;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class InProcessLauncher implements RunLauncher {
|
|
58
|
-
private active = new Map<string, AbortController>();
|
|
59
|
-
private activePerUser = new Map<string, Set<string>>(); // userId → Set<runId>
|
|
60
|
-
|
|
61
|
-
constructor(
|
|
62
|
-
private engineConfig: EngineConfig,
|
|
63
|
-
private store: AnyRunStore,
|
|
64
|
-
private runsDir: string,
|
|
65
|
-
private bus: RunEventBus,
|
|
66
|
-
private engineFactory: EngineFactory = (cfg, evts) => new PipelineEngine(cfg, evts),
|
|
67
|
-
private userStore?: AnyUserStore,
|
|
68
|
-
private plans: PlansConfig = DEFAULT_PLANS,
|
|
69
|
-
) {}
|
|
70
|
-
|
|
71
|
-
subscribe(runId: string, listener: BusListener): () => void {
|
|
72
|
-
return this.bus.subscribe(runId, listener);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async launch(config: LaunchConfig): Promise<{ run_id: string }> {
|
|
76
|
-
const { runId, pipeline, input, meta, parentRunId, userId } = config;
|
|
77
|
-
|
|
78
|
-
// Quota enforcement (only if userId provided and userStore available)
|
|
79
|
-
if (userId && this.userStore) {
|
|
80
|
-
const user = await this.userStore.getUserById(userId);
|
|
81
|
-
if (user) {
|
|
82
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
83
|
-
const limits = getPlanLimits(this.plans, user.plan);
|
|
84
|
-
|
|
85
|
-
// Check runs_per_day
|
|
86
|
-
if (limits.runs_per_day !== -1) {
|
|
87
|
-
const usage = await this.userStore.getDailyUsage(userId, today);
|
|
88
|
-
if (usage.runs_count >= limits.runs_per_day) {
|
|
89
|
-
throw Object.assign(
|
|
90
|
-
new Error('Daily run limit exceeded'),
|
|
91
|
-
{ code: 'QUOTA_EXCEEDED', limit: limits.runs_per_day, used: usage.runs_count }
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Check max_concurrent
|
|
97
|
-
const activeForUser = this.activePerUser.get(userId)?.size ?? 0;
|
|
98
|
-
if (activeForUser >= limits.max_concurrent) {
|
|
99
|
-
throw Object.assign(
|
|
100
|
-
new Error('Concurrent run limit exceeded'),
|
|
101
|
-
{ code: 'QUOTA_EXCEEDED', limit: limits.max_concurrent, used: activeForUser }
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Increment runs_count
|
|
106
|
-
await this.userStore.incrementRuns(userId, today);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Track active run for user (before creating the controller)
|
|
111
|
-
if (userId) {
|
|
112
|
-
if (!this.activePerUser.has(userId)) this.activePerUser.set(userId, new Set());
|
|
113
|
-
this.activePerUser.get(userId)!.add(runId);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const controller = new AbortController();
|
|
117
|
-
this.active.set(runId, controller);
|
|
118
|
-
|
|
119
|
-
const logger = createApiLogger(this.runsDir, runId, pipeline);
|
|
120
|
-
|
|
121
|
-
// Pre-create the run in the store so GET /api/runs/:id/stream can find it immediately
|
|
122
|
-
// after launch() returns. The engine will upsert it again at onPipelineStart with the
|
|
123
|
-
// same id (savePipelineRun is ON CONFLICT DO UPDATE in all store implementations).
|
|
124
|
-
const stubRun: PipelineRun = {
|
|
125
|
-
id: runId,
|
|
126
|
-
pipeline_name: pipeline,
|
|
127
|
-
status: 'running',
|
|
128
|
-
started_at: new Date().toISOString(),
|
|
129
|
-
stages: [],
|
|
130
|
-
...(input && typeof input === 'object' ? { input: input as Record<string, unknown> } : {}),
|
|
131
|
-
...(parentRunId ? { parent_run_id: parentRunId } : {}),
|
|
132
|
-
};
|
|
133
|
-
await this.store.savePipelineRun(stubRun);
|
|
134
|
-
|
|
135
|
-
// Save log path immediately so callers can locate the log file right after launch().
|
|
136
|
-
// For InMemoryRunStore this is a simple Map.set() and works regardless of row existence.
|
|
137
|
-
// For SQLiteRunStore/PgRunStore the row now exists, so the UPDATE will take effect.
|
|
138
|
-
void this.store.saveLogPath(runId, logger.logPath);
|
|
139
|
-
|
|
140
|
-
const emit = (type: SseEventType, data: object) => {
|
|
141
|
-
this.bus.emit(runId, type, data);
|
|
142
|
-
logger.log({ event: type, ...(data as Record<string, unknown>) });
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
// Track last group feedback for inclusion in the pipeline_complete bus event
|
|
146
|
-
let lastGroupFeedback: GroupFeedbackEvent | undefined;
|
|
147
|
-
|
|
148
|
-
const perRunEvents: EngineEvents = {
|
|
149
|
-
onPipelineStart: (e: PipelineStartEvent) => {
|
|
150
|
-
// Row exists now — persist log_path for SQLiteRunStore (the call in launch() was a no-op there).
|
|
151
|
-
void this.store.saveLogPath(runId, logger.logPath);
|
|
152
|
-
emit('pipeline_start', e);
|
|
153
|
-
},
|
|
154
|
-
onStageStart: (e: StageStartEvent) => emit('stage_start', e),
|
|
155
|
-
onStageComplete: (e: StageCompleteEvent) => emit('stage_complete', e),
|
|
156
|
-
onTaskRetry: (e: StageRetryEvent) => emit('stage_retry', e),
|
|
157
|
-
onGroupStart: (e: GroupStartEvent) => emit('group_start', e),
|
|
158
|
-
onGroupIteration: (e: GroupIterationEvent) => emit('group_iteration', e),
|
|
159
|
-
onGroupFeedback: (e: GroupFeedbackEvent) => {
|
|
160
|
-
lastGroupFeedback = e;
|
|
161
|
-
emit('group_feedback', e);
|
|
162
|
-
},
|
|
163
|
-
onGroupComplete: (e: GroupCompleteEvent) => emit('group_complete', e),
|
|
164
|
-
onStageContext: (e: StageContextEvent) => emit('stage_context', e),
|
|
165
|
-
onToolCallStart: (e: StagedToolCallStartEvent) => emit('tool_call_start', e),
|
|
166
|
-
onToolCallComplete: (e: StagedToolCallCompleteEvent) => emit('tool_call_complete', e),
|
|
167
|
-
onPipelineComplete: (e: PipelineCompleteEvent) => {
|
|
168
|
-
emit('pipeline_complete', { ...e, meta: meta ?? {}, last_group_feedback: lastGroupFeedback });
|
|
169
|
-
this.bus.close(runId);
|
|
170
|
-
},
|
|
171
|
-
onPipelineCancelled: (e: PipelineCancelledEvent) => {
|
|
172
|
-
emit('pipeline_cancelled', e);
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
let runEngineConfig: EngineConfig = this.engineConfig;
|
|
177
|
-
if (config.repoPath !== undefined) {
|
|
178
|
-
const toolsDir = join(this.engineConfig.configsDir, 'tools');
|
|
179
|
-
const freshPlugins = await loadProjectTools(toolsDir, config.repoPath);
|
|
180
|
-
// toolRegistry is always provided when using the API launcher
|
|
181
|
-
const freshRegistry = this.engineConfig.toolRegistry!.clone();
|
|
182
|
-
for (const plugin of freshPlugins) {
|
|
183
|
-
freshRegistry.registerPlugin(plugin.name, plugin.tools, plugin.promptSnippet);
|
|
184
|
-
}
|
|
185
|
-
runEngineConfig = { ...this.engineConfig, repoPath: config.repoPath, toolRegistry: freshRegistry };
|
|
186
|
-
}
|
|
187
|
-
const engine = this.engineFactory(runEngineConfig, perRunEvents);
|
|
188
|
-
|
|
189
|
-
void engine
|
|
190
|
-
.run({ pipeline, input, meta, signal: controller.signal, id: runId, depth: config.depth, parentRunId })
|
|
191
|
-
.then(async () => {
|
|
192
|
-
await logger.close();
|
|
193
|
-
})
|
|
194
|
-
.catch(async (err: unknown) => {
|
|
195
|
-
logger.log({ event: 'pipeline_error', error: String(err) });
|
|
196
|
-
this.bus.close(runId);
|
|
197
|
-
await logger.close();
|
|
198
|
-
})
|
|
199
|
-
.finally(() => {
|
|
200
|
-
this.active.delete(runId);
|
|
201
|
-
if (userId) {
|
|
202
|
-
this.activePerUser.get(userId)?.delete(runId);
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
return { run_id: runId };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async cancel(run_id: string): Promise<void> {
|
|
210
|
-
this.active.get(run_id)?.abort();
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export { randomUUID as generateRunId } from 'node:crypto';
|
package/src/logger.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// JSONL logger for API runs
|
|
2
|
-
// Writes to <runsDir>/<date>-<pipeline>-<runId>.jsonl
|
|
3
|
-
|
|
4
|
-
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
5
|
-
import { resolve } from 'node:path';
|
|
6
|
-
|
|
7
|
-
function runIdShort(runId: string): string {
|
|
8
|
-
return runId.slice(0, 8);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function dateForFilename(): string {
|
|
12
|
-
const iso = new Date().toISOString();
|
|
13
|
-
return iso.slice(0, 16).replace(/(\d{2}):(\d{2})$/, '$1h$2m');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface ApiRunLogger {
|
|
17
|
-
logPath: string;
|
|
18
|
-
log(payload: Record<string, unknown>): void;
|
|
19
|
-
close(): Promise<void>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function createApiLogger(runsDir: string, runId: string, pipeline: string): ApiRunLogger {
|
|
23
|
-
const shortId = runIdShort(runId);
|
|
24
|
-
const date = dateForFilename();
|
|
25
|
-
const logPath = resolve(runsDir, `${date}-${pipeline}-${shortId}.jsonl`);
|
|
26
|
-
|
|
27
|
-
mkdirSync(runsDir, { recursive: true });
|
|
28
|
-
const stream = createWriteStream(logPath, { flags: 'a' });
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
logPath,
|
|
32
|
-
|
|
33
|
-
log(payload: Record<string, unknown>): void {
|
|
34
|
-
const line = {
|
|
35
|
-
ts: new Date().toISOString(),
|
|
36
|
-
run_id: shortId,
|
|
37
|
-
...payload,
|
|
38
|
-
};
|
|
39
|
-
if (stream.writable) {
|
|
40
|
-
stream.write(JSON.stringify(line) + '\n');
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
close(): Promise<void> {
|
|
45
|
-
return new Promise((resolve) => {
|
|
46
|
-
stream.end(() => resolve());
|
|
47
|
-
});
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|