@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,37 @@
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
+ }
@@ -0,0 +1,214 @@
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 ADDED
@@ -0,0 +1,50 @@
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
+ }
package/src/plans.ts ADDED
@@ -0,0 +1,43 @@
1
+ // api/src/plans.ts
2
+ // Plan configuration types and default plans
3
+
4
+ export interface PlanLimits {
5
+ runs_per_day: number; // -1 = unlimited
6
+ max_concurrent: number;
7
+ max_tokens_per_run: number; // -1 = unlimited
8
+ rate_limit_per_minute: number;
9
+ }
10
+
11
+ export type PlansConfig = Record<string, PlanLimits>;
12
+
13
+ export const DEFAULT_PLANS: PlansConfig = {
14
+ free: {
15
+ runs_per_day: 5,
16
+ max_concurrent: 1,
17
+ max_tokens_per_run: 50_000,
18
+ rate_limit_per_minute: 10,
19
+ },
20
+ pro: {
21
+ runs_per_day: 100,
22
+ max_concurrent: 5,
23
+ max_tokens_per_run: 500_000,
24
+ rate_limit_per_minute: 60,
25
+ },
26
+ unlimited: {
27
+ runs_per_day: -1,
28
+ max_concurrent: 20,
29
+ max_tokens_per_run: -1,
30
+ rate_limit_per_minute: 300,
31
+ },
32
+ };
33
+
34
+ /** Merge user-supplied partial plans with defaults. Unknown plans fall back to 'free'. */
35
+ export function resolvePlans(configPlans?: Record<string, PlanLimits>): PlansConfig {
36
+ if (!configPlans) return DEFAULT_PLANS;
37
+ return { ...DEFAULT_PLANS, ...configPlans };
38
+ }
39
+
40
+ /** Get plan limits for a user, defaulting to 'free' if plan is unknown. */
41
+ export function getPlanLimits(plans: PlansConfig, planName: string): PlanLimits {
42
+ return plans[planName] ?? plans.free ?? DEFAULT_PLANS.free;
43
+ }
@@ -0,0 +1,131 @@
1
+ import { readdir, readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import * as yaml from 'js-yaml';
4
+ import type { FastifyInstance } from 'fastify';
5
+ import type { ServerDeps } from '../server.js';
6
+
7
+ export async function agentsRoutes(
8
+ fastify: FastifyInstance,
9
+ options: { deps: ServerDeps }
10
+ ): Promise<void> {
11
+ const agentsDir = join(options.deps.configsDir, 'agents');
12
+
13
+ const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
14
+
15
+ // GET /api/agents
16
+ fastify.get('/agents', {
17
+ schema: {
18
+ tags: ['agents'],
19
+ summary: 'List all agent names',
20
+ response: {
21
+ 200: {
22
+ type: 'object',
23
+ properties: {
24
+ agents: { type: 'array', items: { type: 'string' } },
25
+ },
26
+ },
27
+ },
28
+ },
29
+ }, async (_request, reply) => {
30
+ let entries: string[];
31
+ try {
32
+ entries = await readdir(agentsDir);
33
+ } catch {
34
+ return reply.send({ agents: [] });
35
+ }
36
+ const agents = entries
37
+ .filter(f => f.endsWith('.agent.yaml'))
38
+ .map(f => f.slice(0, -'.agent.yaml'.length));
39
+ return reply.send({ agents });
40
+ });
41
+
42
+ // GET /api/agents/:name
43
+ fastify.get<{ Params: { name: string } }>('/agents/:name', {
44
+ schema: {
45
+ tags: ['agents'],
46
+ summary: 'Get an agent by name',
47
+ params: {
48
+ type: 'object',
49
+ properties: { name: { type: 'string' } },
50
+ required: ['name'],
51
+ },
52
+ response: {
53
+ 200: { type: 'object', additionalProperties: true },
54
+ 404: errorSchema,
55
+ },
56
+ },
57
+ }, async (request, reply) => {
58
+ const filePath = join(agentsDir, `${request.params.name}.agent.yaml`);
59
+ let content: string;
60
+ try {
61
+ content = await readFile(filePath, 'utf-8');
62
+ } catch {
63
+ return reply.status(404).send({ error: 'Agent not found' });
64
+ }
65
+ return reply.send(yaml.load(content));
66
+ });
67
+
68
+ // PUT /api/agents/:name
69
+ fastify.put<{
70
+ Params: { name: string };
71
+ Body: Record<string, unknown>;
72
+ }>('/agents/:name', {
73
+ schema: {
74
+ tags: ['agents'],
75
+ summary: 'Create or update an agent',
76
+ params: {
77
+ type: 'object',
78
+ properties: { name: { type: 'string' } },
79
+ required: ['name'],
80
+ },
81
+ body: { type: 'object' },
82
+ response: {
83
+ 200: {
84
+ type: 'object',
85
+ properties: {
86
+ name: { type: 'string' },
87
+ content: { type: 'object', additionalProperties: true },
88
+ },
89
+ },
90
+ 400: errorSchema,
91
+ },
92
+ },
93
+ }, async (request, reply) => {
94
+ const body = request.body;
95
+
96
+ if (!body['name'] || typeof body['name'] !== 'string') {
97
+ return reply.status(400).send({ error: "Agent must have a 'name' field (string)" });
98
+ }
99
+
100
+ await mkdir(agentsDir, { recursive: true });
101
+ const filePath = join(agentsDir, `${request.params.name}.agent.yaml`);
102
+ await writeFile(filePath, yaml.dump(body), 'utf-8');
103
+
104
+ return reply.send({ name: request.params.name, content: body });
105
+ });
106
+
107
+ // DELETE /api/agents/:name
108
+ fastify.delete<{ Params: { name: string } }>('/agents/:name', {
109
+ schema: {
110
+ tags: ['agents'],
111
+ summary: 'Delete an agent',
112
+ params: {
113
+ type: 'object',
114
+ properties: { name: { type: 'string' } },
115
+ required: ['name'],
116
+ },
117
+ response: {
118
+ 204: { type: 'null', description: 'No content' },
119
+ 404: errorSchema,
120
+ },
121
+ },
122
+ }, async (request, reply) => {
123
+ const filePath = join(agentsDir, `${request.params.name}.agent.yaml`);
124
+ try {
125
+ await unlink(filePath);
126
+ } catch {
127
+ return reply.status(404).send({ error: 'Agent not found' });
128
+ }
129
+ return reply.status(204).send();
130
+ });
131
+ }
@@ -0,0 +1,154 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import * as yaml from 'js-yaml';
4
+ import type { FastifyInstance } from 'fastify';
5
+ import type { ServerDeps } from '../server.js';
6
+
7
+ const KNOWN_PROVIDERS = new Set(['anthropic', 'openai', 'ollama']);
8
+
9
+ interface RawConfig {
10
+ providers?: Record<string, { apiKey?: string }>;
11
+ defaults?: Record<string, unknown>;
12
+ }
13
+
14
+ async function readConfig(configsDir: string): Promise<RawConfig> {
15
+ try {
16
+ const content = await readFile(join(configsDir, 'config.yaml'), 'utf-8');
17
+ return (yaml.load(content) as RawConfig) ?? {};
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ async function writeConfig(configsDir: string, config: RawConfig): Promise<void> {
24
+ await mkdir(configsDir, { recursive: true });
25
+ await writeFile(join(configsDir, 'config.yaml'), yaml.dump(config), 'utf-8');
26
+ }
27
+
28
+ function maskConfig(config: RawConfig): RawConfig {
29
+ if (!config.providers) return config;
30
+ const maskedProviders: Record<string, { apiKey: string }> = {};
31
+ for (const [name, provider] of Object.entries(config.providers)) {
32
+ maskedProviders[name] = { apiKey: provider.apiKey ? '***' : '' };
33
+ }
34
+ return { ...config, providers: maskedProviders };
35
+ }
36
+
37
+ export async function configRoutes(
38
+ fastify: FastifyInstance,
39
+ options: { deps: ServerDeps }
40
+ ): Promise<void> {
41
+ const { configsDir } = options.deps;
42
+
43
+ const configResponseSchema = {
44
+ type: 'object',
45
+ properties: {
46
+ providers: {
47
+ type: 'object',
48
+ additionalProperties: {
49
+ type: 'object',
50
+ properties: { apiKey: { type: 'string' } },
51
+ },
52
+ },
53
+ defaults: {
54
+ type: 'object',
55
+ properties: {
56
+ provider: { type: 'string' },
57
+ model: { type: 'string' },
58
+ },
59
+ },
60
+ },
61
+ };
62
+
63
+ // GET /api/config
64
+ fastify.get('/config', {
65
+ schema: {
66
+ tags: ['config'],
67
+ summary: 'Get current config',
68
+ response: {
69
+ 200: configResponseSchema,
70
+ },
71
+ },
72
+ }, async (_request, reply) => {
73
+ const config = await readConfig(configsDir);
74
+ return reply.send({
75
+ providers: {},
76
+ ...maskConfig(config),
77
+ });
78
+ });
79
+
80
+ // PATCH /api/config
81
+ fastify.patch<{ Body: Partial<RawConfig> }>('/config', {
82
+ schema: {
83
+ tags: ['config'],
84
+ summary: 'Patch config (merge defaults and providers)',
85
+ body: { type: 'object' },
86
+ response: {
87
+ 200: configResponseSchema,
88
+ },
89
+ },
90
+ }, async (request, reply) => {
91
+ const config = await readConfig(configsDir);
92
+ const patch = request.body;
93
+
94
+ if (patch.defaults) {
95
+ config.defaults = { ...config.defaults, ...patch.defaults };
96
+ }
97
+ if (patch.providers) {
98
+ config.providers = { ...config.providers, ...patch.providers };
99
+ }
100
+
101
+ await writeConfig(configsDir, config);
102
+ return reply.send({
103
+ providers: {},
104
+ ...maskConfig(config),
105
+ });
106
+ });
107
+
108
+ // POST /api/config/providers
109
+ fastify.post<{ Body: { provider: string; apiKeyEnvVar: string } }>(
110
+ '/config/providers',
111
+ {
112
+ schema: {
113
+ tags: ['config'],
114
+ summary: 'Add or update a provider',
115
+ body: {
116
+ type: 'object',
117
+ required: ['provider', 'apiKeyEnvVar'],
118
+ properties: {
119
+ provider: { type: 'string' },
120
+ apiKeyEnvVar: { type: 'string' },
121
+ },
122
+ },
123
+ response: {
124
+ 200: configResponseSchema,
125
+ 400: {
126
+ type: 'object',
127
+ properties: { error: { type: 'string' } },
128
+ },
129
+ },
130
+ },
131
+ },
132
+ async (request, reply) => {
133
+ const { provider, apiKeyEnvVar } = request.body;
134
+
135
+ if (!KNOWN_PROVIDERS.has(provider)) {
136
+ return reply.status(400).send({
137
+ error: `Unknown provider: '${provider}'. Supported: ${[...KNOWN_PROVIDERS].join(', ')}`,
138
+ });
139
+ }
140
+
141
+ const config = await readConfig(configsDir);
142
+ config.providers = {
143
+ ...config.providers,
144
+ [provider]: { apiKey: `\${${apiKeyEnvVar}}` },
145
+ };
146
+
147
+ await writeConfig(configsDir, config);
148
+ return reply.send({
149
+ providers: {},
150
+ ...maskConfig(config),
151
+ });
152
+ }
153
+ );
154
+ }