@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
@@ -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
- }