@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.
- package/ARCHITECTURE.md +52 -0
- package/LICENSE +663 -0
- package/README.md +161 -0
- package/dist/api.d.ts +8 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +6 -0
- package/dist/api.js.map +1 -0
- package/dist/bootstrap.d.ts +66 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +193 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/event-bus.d.ts +16 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +37 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-runtime.d.ts +22 -0
- package/dist/integration-runtime.d.ts.map +1 -0
- package/dist/integration-runtime.js +150 -0
- package/dist/integration-runtime.js.map +1 -0
- package/dist/integration-store.d.ts +26 -0
- package/dist/integration-store.d.ts.map +1 -0
- package/dist/integration-store.js +80 -0
- package/dist/integration-store.js.map +1 -0
- package/dist/integrations/linear/failure-handler.d.ts +5 -0
- package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
- package/dist/integrations/linear/failure-handler.js +73 -0
- package/dist/integrations/linear/failure-handler.js.map +1 -0
- package/dist/integrations/linear/webhook-handler.d.ts +6 -0
- package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
- package/dist/integrations/linear/webhook-handler.js +131 -0
- package/dist/integrations/linear/webhook-handler.js.map +1 -0
- package/dist/integrations/registry.d.ts +4 -0
- package/dist/integrations/registry.d.ts.map +1 -0
- package/dist/integrations/registry.js +9 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/types.d.ts +33 -0
- package/dist/integrations/types.d.ts.map +1 -0
- package/dist/integrations/types.js +2 -0
- package/dist/integrations/types.js.map +1 -0
- package/dist/launcher.d.ts +45 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +149 -0
- package/dist/launcher.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/plans.d.ts +13 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +33 -0
- package/dist/plans.js.map +1 -0
- package/dist/routes/agents.d.ts +6 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +118 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/config.d.ts +6 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +129 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/contracts.d.ts +6 -0
- package/dist/routes/contracts.d.ts.map +1 -0
- package/dist/routes/contracts.js +188 -0
- package/dist/routes/contracts.js.map +1 -0
- package/dist/routes/pipelines.d.ts +6 -0
- package/dist/routes/pipelines.d.ts.map +1 -0
- package/dist/routes/pipelines.js +133 -0
- package/dist/routes/pipelines.js.map +1 -0
- package/dist/routes/projects.d.ts +6 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +220 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/runs.d.ts +6 -0
- package/dist/routes/runs.d.ts.map +1 -0
- package/dist/routes/runs.js +427 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +123 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/tools.d.ts +6 -0
- package/dist/routes/tools.d.ts.map +1 -0
- package/dist/routes/tools.js +193 -0
- package/dist/routes/tools.js.map +1 -0
- package/dist/routes/users.d.ts +6 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +152 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/routes/validate.d.ts +6 -0
- package/dist/routes/validate.d.ts.map +1 -0
- package/dist/routes/validate.js +171 -0
- package/dist/routes/validate.js.map +1 -0
- package/dist/routes/webhooks.d.ts +8 -0
- package/dist/routes/webhooks.d.ts.map +1 -0
- package/dist/routes/webhooks.js +102 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +101 -0
- package/dist/server.js.map +1 -0
- package/dist/spawners/http-api-spawner.d.ts +10 -0
- package/dist/spawners/http-api-spawner.d.ts.map +1 -0
- package/dist/spawners/http-api-spawner.js +88 -0
- package/dist/spawners/http-api-spawner.js.map +1 -0
- package/dist/user-store-pg.d.ts +21 -0
- package/dist/user-store-pg.d.ts.map +1 -0
- package/dist/user-store-pg.js +97 -0
- package/dist/user-store-pg.js.map +1 -0
- package/dist/user-store.d.ts +29 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +97 -0
- package/dist/user-store.js.map +1 -0
- package/dist/utils/repo-resolver.d.ts +3 -0
- package/dist/utils/repo-resolver.d.ts.map +1 -0
- package/dist/utils/repo-resolver.js +3 -0
- package/dist/utils/repo-resolver.js.map +1 -0
- package/dist/webhook-dispatcher.d.ts +13 -0
- package/dist/webhook-dispatcher.d.ts.map +1 -0
- package/dist/webhook-dispatcher.js +108 -0
- package/dist/webhook-dispatcher.js.map +1 -0
- package/dist/webhook-store.d.ts +31 -0
- package/dist/webhook-store.d.ts.map +1 -0
- package/dist/webhook-store.js +91 -0
- package/dist/webhook-store.js.map +1 -0
- package/package.json +59 -0
- package/src/.gitkeep +0 -0
- package/src/api.ts +8 -0
- package/src/bootstrap.ts +259 -0
- package/src/event-bus.ts +64 -0
- package/src/index.ts +58 -0
- package/src/integration-runtime.ts +180 -0
- package/src/integration-store.ts +125 -0
- package/src/integrations/linear/failure-handler.ts +93 -0
- package/src/integrations/linear/webhook-handler.ts +156 -0
- package/src/integrations/registry.ts +12 -0
- package/src/integrations/types.ts +37 -0
- package/src/launcher.ts +214 -0
- package/src/logger.ts +50 -0
- package/src/plans.ts +43 -0
- package/src/routes/agents.ts +131 -0
- package/src/routes/config.ts +154 -0
- package/src/routes/contracts.ts +205 -0
- package/src/routes/pipelines.ts +146 -0
- package/src/routes/projects.ts +237 -0
- package/src/routes/runs.ts +468 -0
- package/src/routes/skills.ts +136 -0
- package/src/routes/tools.ts +222 -0
- package/src/routes/users.ts +169 -0
- package/src/routes/validate.ts +196 -0
- package/src/routes/webhooks.ts +120 -0
- package/src/server.ts +155 -0
- package/src/spawners/http-api-spawner.ts +96 -0
- package/src/user-store-pg.ts +138 -0
- package/src/user-store.ts +125 -0
- package/src/utils/repo-resolver.ts +3 -0
- package/src/webhook-dispatcher.ts +142 -0
- package/src/webhook-store.ts +141 -0
- package/tests/agents.test.ts +164 -0
- package/tests/cancel.test.ts +120 -0
- package/tests/config.test.ts +196 -0
- package/tests/contracts.test.ts +302 -0
- package/tests/event-bus.test.ts +53 -0
- package/tests/http-api-spawner.test.ts +158 -0
- package/tests/integration-runtime.test.ts +199 -0
- package/tests/integration-store.test.ts +66 -0
- package/tests/integrations/linear/failure-handler.test.ts +113 -0
- package/tests/integrations/linear/webhook-handler.test.ts +191 -0
- package/tests/launcher.test.ts +380 -0
- package/tests/linear-webhook.test.ts +390 -0
- package/tests/pipelines.test.ts +166 -0
- package/tests/projects.test.ts +283 -0
- package/tests/runs.test.ts +379 -0
- package/tests/server.test.ts +208 -0
- package/tests/skills.test.ts +149 -0
- package/tests/sse.test.ts +129 -0
- package/tests/tools.test.ts +233 -0
- package/tests/user-store.test.ts +93 -0
- package/tests/users.test.ts +189 -0
- package/tests/utils/repo-resolver.test.ts +105 -0
- package/tests/validate.test.ts +233 -0
- package/tests/webhook-dispatcher.test.ts +214 -0
- package/tests/webhook-store.test.ts +98 -0
- package/tests/webhooks.test.ts +176 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,380 @@
|
|
|
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
|
+
});
|