@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,380 +0,0 @@
1
- import { describe, it, expect, vi, afterAll } from 'vitest';
2
- import { rmSync, mkdirSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
- import { InProcessLauncher } from '../src/launcher.js';
5
- import { RunEventBus } from '../src/event-bus.js';
6
- import { InMemoryRunStore } from '@studio-foundation/engine';
7
- import type { EngineConfig, EngineEvents } from '@studio-foundation/engine';
8
- import { UserStore } from '../src/user-store.js';
9
- import type { PlansConfig } from '../src/plans.js';
10
-
11
- const TMP_RUNS_DIR = resolve('/tmp', `studio-launcher-test-${Date.now()}`);
12
-
13
- afterAll(() => {
14
- rmSync(TMP_RUNS_DIR, { recursive: true, force: true });
15
- });
16
-
17
- // Minimal EngineConfig stub — launcher only passes it to engineFactory
18
- const stubConfig = {} as EngineConfig;
19
-
20
- function makeMockFactory(onEvents?: (events: EngineEvents) => void) {
21
- const runFn = vi.fn().mockResolvedValue({
22
- id: 'test-run-id',
23
- pipeline_name: 'test-pipeline',
24
- status: 'success',
25
- started_at: new Date().toISOString(),
26
- stages: [],
27
- });
28
- const factory = vi.fn().mockImplementation((_cfg: EngineConfig, events: EngineEvents) => {
29
- onEvents?.(events);
30
- return { run: runFn };
31
- });
32
- return { factory, runFn };
33
- }
34
-
35
- describe('InProcessLauncher', () => {
36
- it('launch returns run_id immediately (fire-and-forget)', async () => {
37
- let engineStarted = false;
38
- let engineCompleted = false;
39
- const store = new InMemoryRunStore();
40
- const bus = new RunEventBus();
41
- const { factory } = makeMockFactory();
42
- factory.mockImplementation(() => ({
43
- run: vi.fn().mockImplementation(async () => {
44
- engineStarted = true;
45
- await new Promise((r) => setTimeout(r, 50));
46
- engineCompleted = true;
47
- return { id: 'run-1', pipeline_name: 'p', status: 'success', started_at: '', stages: [] };
48
- }),
49
- }));
50
-
51
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
52
- const result = await launcher.launch({
53
- runId: 'run-1',
54
- pipeline: 'test-pipeline',
55
- input: {},
56
- configsDir: TMP_RUNS_DIR,
57
- });
58
-
59
- expect(result.run_id).toBe('run-1');
60
- expect(engineStarted).toBe(true);
61
- expect(engineCompleted).toBe(false);
62
- });
63
-
64
- it('saves log path to store immediately after launch', async () => {
65
- const store = new InMemoryRunStore();
66
- const bus = new RunEventBus();
67
- const { factory } = makeMockFactory();
68
- factory.mockImplementation(() => ({
69
- run: vi.fn().mockImplementation(async () => {
70
- await new Promise((r) => setTimeout(r, 100));
71
- return { id: 'run-log', pipeline_name: 'p', status: 'success', started_at: '', stages: [] };
72
- }),
73
- }));
74
-
75
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
76
- await launcher.launch({ runId: 'run-log', pipeline: 'test-pipeline', input: {}, configsDir: TMP_RUNS_DIR });
77
-
78
- const logPath = store.getLogPath('run-log');
79
- expect(logPath).not.toBeNull();
80
- expect(logPath).toContain('test-pipeline');
81
- expect(logPath).toContain('.jsonl');
82
- });
83
-
84
- it('subscribe delivers events emitted during a run', async () => {
85
- const store = new InMemoryRunStore();
86
- const bus = new RunEventBus();
87
- let capturedEvents: EngineEvents = {};
88
-
89
- const { factory } = makeMockFactory((evts) => { capturedEvents = evts; });
90
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
91
-
92
- const received: string[] = [];
93
- launcher.subscribe('run-evt', ({ type }) => received.push(type));
94
-
95
- await launcher.launch({ runId: 'run-evt', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
96
-
97
- // Simulate engine emitting events
98
- capturedEvents.onStageComplete?.({ stage_name: 's', stage_index: 0, total_stages: 1, status: 'success', attempts: 1, duration_ms: 100 });
99
- capturedEvents.onPipelineComplete?.({ pipeline_name: 'p', run_id: 'run-evt', status: 'success', duration_ms: 200, total_tokens: 100, total_tool_calls: 0 });
100
-
101
- expect(received).toContain('stage_complete');
102
- expect(received).toContain('pipeline_complete');
103
- expect(received).toContain('done'); // bus.close called after pipeline_complete
104
- });
105
-
106
- it('cancel aborts a running pipeline', async () => {
107
- let abortSeen = false;
108
- const store = new InMemoryRunStore();
109
- const bus = new RunEventBus();
110
- const { factory } = makeMockFactory();
111
- factory.mockImplementation(() => ({
112
- run: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
113
- await new Promise((r) => setTimeout(r, 200));
114
- abortSeen = signal?.aborted ?? false;
115
- return { id: 'run-cancel', pipeline_name: 'p', status: 'failed', started_at: '', stages: [] };
116
- }),
117
- }));
118
-
119
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
120
- await launcher.launch({ runId: 'run-cancel', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
121
- await launcher.cancel('run-cancel');
122
- await new Promise((r) => setTimeout(r, 250));
123
- expect(abortSeen).toBe(true);
124
- });
125
-
126
- it('cancel ignores unknown run_id', async () => {
127
- const launcher = new InProcessLauncher(stubConfig, new InMemoryRunStore(), TMP_RUNS_DIR, new RunEventBus());
128
- await expect(launcher.cancel('nonexistent')).resolves.toBeUndefined();
129
- });
130
-
131
- it('bus stays open after pipeline_cancelled so pipeline_complete can close it', async () => {
132
- const store = new InMemoryRunStore();
133
- const bus = new RunEventBus();
134
- const closeSpy = vi.spyOn(bus, 'close');
135
-
136
- let capturedEvents!: EngineEvents;
137
- const factory = vi.fn().mockImplementation((_cfg: EngineConfig, events: EngineEvents) => {
138
- capturedEvents = events;
139
- return { run: vi.fn().mockResolvedValue({ id: 'r1', pipeline_name: 'p', status: 'cancelled', started_at: '', stages: [] }) };
140
- });
141
-
142
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
143
- await launcher.launch({ runId: 'r1', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
144
- await new Promise((r) => setTimeout(r, 10)); // let factory.run capture events
145
-
146
- // Manually fire engine events in order (as the engine does for a cancelled run)
147
- capturedEvents.onPipelineCancelled?.({ run_id: 'r1', cancelled_at_stage: 's1', duration_ms: 5 });
148
- // Bus should NOT be closed yet — pipeline_complete hasn't fired
149
- expect(closeSpy).not.toHaveBeenCalledWith('r1');
150
-
151
- capturedEvents.onPipelineComplete?.({ pipeline_name: 'p', run_id: 'r1', status: 'cancelled', duration_ms: 5, total_tokens: 0, total_tool_calls: 0 });
152
- // Bus should be closed now (by pipeline_complete)
153
- expect(closeSpy).toHaveBeenCalledWith('r1');
154
- // And only once (not twice)
155
- expect(closeSpy).toHaveBeenCalledTimes(1);
156
- });
157
- });
158
-
159
- describe('InProcessLauncher — Linear failure notification (STU-98)', () => {
160
- it('pipeline_complete bus event includes meta and last_group_feedback when pipeline fails', async () => {
161
- const store = new InMemoryRunStore();
162
- const bus = new RunEventBus();
163
- let capturedEvents: EngineEvents = {};
164
-
165
- const { factory } = makeMockFactory((evts) => { capturedEvents = evts; });
166
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
167
-
168
- const received: Array<{ type: string; data: unknown }> = [];
169
- launcher.subscribe('run-fail-linear', ({ type, data }) => received.push({ type, data }));
170
-
171
- await launcher.launch({
172
- runId: 'run-fail-linear',
173
- pipeline: 'feature-builder',
174
- input: {},
175
- configsDir: TMP_RUNS_DIR,
176
- meta: { linear_issue_id: 'issue-abc-123' },
177
- });
178
-
179
- // Simulate group feedback then pipeline failure
180
- capturedEvents.onGroupFeedback?.({
181
- group_name: 'implementation-review',
182
- iteration: 3,
183
- rejection_reason: 'QA rejected the code',
184
- rejection_details: ['Hardcoded strings (blocking)', 'Missing error handling (blocking)'],
185
- });
186
- capturedEvents.onPipelineComplete?.({
187
- pipeline_name: 'feature-builder',
188
- run_id: 'run-fail-linear',
189
- status: 'rejected',
190
- duration_ms: 180000,
191
- total_tokens: 5000,
192
- total_tool_calls: 12,
193
- });
194
-
195
- await new Promise((r) => setTimeout(r, 10));
196
-
197
- const pipelineCompleteEvent = received.find(e => e.type === 'pipeline_complete');
198
- expect(pipelineCompleteEvent).toBeDefined();
199
- const data = pipelineCompleteEvent!.data as Record<string, unknown>;
200
- expect(data['meta']).toEqual({ linear_issue_id: 'issue-abc-123' });
201
- expect(data['last_group_feedback']).toMatchObject({
202
- group_name: 'implementation-review',
203
- iteration: 3,
204
- rejection_reason: 'QA rejected the code',
205
- rejection_details: ['Hardcoded strings (blocking)', 'Missing error handling (blocking)'],
206
- });
207
- });
208
-
209
- it('pipeline_complete bus event includes meta with no last_group_feedback when no group feedback occurred', async () => {
210
- const store = new InMemoryRunStore();
211
- const bus = new RunEventBus();
212
- let capturedEvents: EngineEvents = {};
213
-
214
- const { factory } = makeMockFactory((evts) => { capturedEvents = evts; });
215
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
216
-
217
- const received: Array<{ type: string; data: unknown }> = [];
218
- launcher.subscribe('run-fail-no-feedback', ({ type, data }) => received.push({ type, data }));
219
-
220
- await launcher.launch({
221
- runId: 'run-fail-no-feedback',
222
- pipeline: 'feature-builder',
223
- input: {},
224
- configsDir: TMP_RUNS_DIR,
225
- meta: { linear_issue_id: 'issue-xyz' },
226
- });
227
-
228
- // No onGroupFeedback fired — a stage failed directly
229
- capturedEvents.onPipelineComplete?.({
230
- pipeline_name: 'feature-builder',
231
- run_id: 'run-fail-no-feedback',
232
- status: 'failed',
233
- duration_ms: 30000,
234
- total_tokens: 500,
235
- total_tool_calls: 2,
236
- });
237
-
238
- await new Promise((r) => setTimeout(r, 10));
239
-
240
- const pipelineCompleteEvent = received.find(e => e.type === 'pipeline_complete');
241
- expect(pipelineCompleteEvent).toBeDefined();
242
- const data = pipelineCompleteEvent!.data as Record<string, unknown>;
243
- expect(data['meta']).toEqual({ linear_issue_id: 'issue-xyz' });
244
- expect(data['last_group_feedback']).toBeUndefined();
245
- });
246
- });
247
-
248
- function makeTempUserStore(): UserStore {
249
- const dir = resolve('/tmp', `.studio-launcher-quota-${Date.now()}-${Math.random().toString(36).slice(2)}`);
250
- mkdirSync(dir, { recursive: true });
251
- return new UserStore(resolve(dir, 'runs.db'));
252
- }
253
-
254
- const strictPlan: PlansConfig = {
255
- strict: { runs_per_day: 2, max_concurrent: 1, max_tokens_per_run: 1000, rate_limit_per_minute: 10 },
256
- };
257
-
258
- describe('InProcessLauncher — quota enforcement', () => {
259
- it('increments runs_count when a run is launched with userId', async () => {
260
- const store = new InMemoryRunStore();
261
- const bus = new RunEventBus();
262
- const userStore = makeTempUserStore();
263
- userStore.saveUser({ id: 'user-1', email: 'a@a.com', plan: 'strict', api_key: 'k1', created_at: '' });
264
- const { factory } = makeMockFactory();
265
- factory.mockImplementation(() => ({
266
- run: vi.fn().mockResolvedValue({ id: 'r1', pipeline_name: 'p', status: 'success', started_at: '', stages: [] }),
267
- }));
268
-
269
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory, userStore, strictPlan);
270
- await launcher.launch({ runId: 'r1', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR, userId: 'user-1' });
271
-
272
- const today = new Date().toISOString().slice(0, 10);
273
- expect(userStore.getDailyUsage('user-1', today).runs_count).toBe(1);
274
- userStore.close();
275
- });
276
-
277
- it('throws when daily limit is reached', async () => {
278
- const store = new InMemoryRunStore();
279
- const bus = new RunEventBus();
280
- const userStore = makeTempUserStore();
281
- userStore.saveUser({ id: 'user-1', email: 'a@a.com', plan: 'strict', api_key: 'k1', created_at: '' });
282
- const { factory } = makeMockFactory();
283
- factory.mockImplementation(() => ({
284
- run: vi.fn().mockResolvedValue({ id: 'r1', pipeline_name: 'p', status: 'success', started_at: '', stages: [] }),
285
- }));
286
-
287
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory, userStore, strictPlan);
288
- await launcher.launch({ runId: 'r1', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR, userId: 'user-1' });
289
- // Wait for the engine's void promise chain to settle so activePerUser is cleaned up
290
- await new Promise((r) => setTimeout(r, 50));
291
- await launcher.launch({ runId: 'r2', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR, userId: 'user-1' });
292
- await new Promise((r) => setTimeout(r, 50));
293
-
294
- await expect(
295
- launcher.launch({ runId: 'r3', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR, userId: 'user-1' })
296
- ).rejects.toThrow('Daily run limit exceeded');
297
- userStore.close();
298
- });
299
-
300
- it('does not enforce quota when no userId', async () => {
301
- const store = new InMemoryRunStore();
302
- const bus = new RunEventBus();
303
- const userStore = makeTempUserStore();
304
- const { factory } = makeMockFactory();
305
- factory.mockImplementation(() => ({
306
- run: vi.fn().mockResolvedValue({ id: 'r1', pipeline_name: 'p', status: 'success', started_at: '', stages: [] }),
307
- }));
308
-
309
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory, userStore, strictPlan);
310
-
311
- // Should not throw even beyond limit when no userId
312
- await launcher.launch({ runId: 'r1', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
313
- await launcher.launch({ runId: 'r2', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
314
- await expect(
315
- launcher.launch({ runId: 'r3', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR })
316
- ).resolves.toBeDefined();
317
- userStore.close();
318
- });
319
- });
320
-
321
- describe('InProcessLauncher — tool call SSE events (STU-174)', () => {
322
- it('emits tool_call_start event when engine fires onToolCallStart', async () => {
323
- const store = new InMemoryRunStore();
324
- const bus = new RunEventBus();
325
- let capturedEvents: EngineEvents = {};
326
-
327
- const { factory } = makeMockFactory((evts) => { capturedEvents = evts; });
328
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
329
-
330
- const received: Array<{ type: string; data: unknown }> = [];
331
- launcher.subscribe('stu174-tool-s', ({ type, data }) => received.push({ type, data }));
332
-
333
- await launcher.launch({ runId: 'stu174-tool-s', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
334
-
335
- capturedEvents.onToolCallStart?.({
336
- stage: 'code-generation',
337
- tool: 'repo_manager-write_file',
338
- params: { path: 'src/foo.ts' },
339
- timestamp: 1700000000000,
340
- } as never);
341
-
342
- // Close the logger cleanly before assertions
343
- capturedEvents.onPipelineComplete?.({ pipeline_name: 'p', run_id: 'stu174-tool-s', status: 'success', duration_ms: 10, total_tokens: 0, total_tool_calls: 1 });
344
- await new Promise((r) => setTimeout(r, 10));
345
-
346
- expect(received.map(e => e.type)).toContain('tool_call_start');
347
- const evt = received.find(e => e.type === 'tool_call_start')?.data;
348
- expect(evt).toMatchObject({ stage: 'code-generation', tool: 'repo_manager-write_file' });
349
- });
350
-
351
- it('emits tool_call_complete event when engine fires onToolCallComplete', async () => {
352
- const store = new InMemoryRunStore();
353
- const bus = new RunEventBus();
354
- let capturedEvents: EngineEvents = {};
355
-
356
- const { factory } = makeMockFactory((evts) => { capturedEvents = evts; });
357
- const launcher = new InProcessLauncher(stubConfig, store, TMP_RUNS_DIR, bus, factory);
358
-
359
- const received: Array<{ type: string; data: unknown }> = [];
360
- launcher.subscribe('stu174-tool-c', ({ type, data }) => received.push({ type, data }));
361
-
362
- await launcher.launch({ runId: 'stu174-tool-c', pipeline: 'p', input: {}, configsDir: TMP_RUNS_DIR });
363
-
364
- capturedEvents.onToolCallComplete?.({
365
- stage: 'code-generation',
366
- tool: 'repo_manager-write_file',
367
- result: 'written',
368
- duration_ms: 100,
369
- timestamp: 1700000000000,
370
- } as never);
371
-
372
- // Close the logger cleanly before assertions
373
- capturedEvents.onPipelineComplete?.({ pipeline_name: 'p', run_id: 'stu174-tool-c', status: 'success', duration_ms: 10, total_tokens: 0, total_tool_calls: 1 });
374
- await new Promise((r) => setTimeout(r, 10));
375
-
376
- expect(received.map(e => e.type)).toContain('tool_call_complete');
377
- const evt = received.find(e => e.type === 'tool_call_complete')?.data;
378
- expect(evt).toMatchObject({ stage: 'code-generation', tool: 'repo_manager-write_file', duration_ms: 100 });
379
- });
380
- });