@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,208 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { buildServer } from '../src/server.js';
|
|
5
|
+
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
6
|
+
import type { RunLauncher } from '../src/launcher.js';
|
|
7
|
+
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
8
|
+
import type { IntegrationStore } from '../src/integration-store.js';
|
|
9
|
+
import { UserStore, type User } from '../src/user-store.js';
|
|
10
|
+
import { DEFAULT_PLANS } from '../src/plans.js';
|
|
11
|
+
import type { WebhookStore } from '../src/webhook-store.js';
|
|
12
|
+
|
|
13
|
+
function makeMockLauncher(): RunLauncher {
|
|
14
|
+
return {
|
|
15
|
+
launch: async () => ({ run_id: 'mock-run-id' }),
|
|
16
|
+
cancel: async () => {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
21
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
22
|
+
|
|
23
|
+
function makeTempUserStore(): UserStore {
|
|
24
|
+
const dir = resolve('/tmp', `.studio-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
return new UserStore(resolve(dir, 'runs.db'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const nullWebhookStore = {} as unknown as WebhookStore;
|
|
30
|
+
|
|
31
|
+
const proUser: User = {
|
|
32
|
+
id: 'user-pro-1',
|
|
33
|
+
email: 'pro@example.com',
|
|
34
|
+
plan: 'pro',
|
|
35
|
+
api_key: 'sk-pro-key',
|
|
36
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('buildServer — auth', () => {
|
|
40
|
+
it('no api key configured → requests pass without Authorization', async () => {
|
|
41
|
+
const server = buildServer({
|
|
42
|
+
store: new InMemoryRunStore(),
|
|
43
|
+
launcher: makeMockLauncher(),
|
|
44
|
+
configsDir: '/tmp/.studio',
|
|
45
|
+
projectName: 'test-project',
|
|
46
|
+
apiConfig: {}, // no key
|
|
47
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
48
|
+
integrationStore: nullIntegrationStore,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const res = await server.inject({ method: 'GET', url: '/api/projects' });
|
|
52
|
+
expect(res.statusCode).not.toBe(401);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('api key configured → missing Authorization → 401', async () => {
|
|
56
|
+
const server = buildServer({
|
|
57
|
+
store: new InMemoryRunStore(),
|
|
58
|
+
launcher: makeMockLauncher(),
|
|
59
|
+
configsDir: '/tmp/.studio',
|
|
60
|
+
projectName: 'test-project',
|
|
61
|
+
apiConfig: { key: 'sk-studio-secret' },
|
|
62
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
63
|
+
integrationStore: nullIntegrationStore,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const res = await server.inject({ method: 'GET', url: '/api/projects' });
|
|
67
|
+
expect(res.statusCode).toBe(401);
|
|
68
|
+
expect(res.json()).toEqual({ error: 'Unauthorized' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('api key configured → wrong key → 401', async () => {
|
|
72
|
+
const server = buildServer({
|
|
73
|
+
store: new InMemoryRunStore(),
|
|
74
|
+
launcher: makeMockLauncher(),
|
|
75
|
+
configsDir: '/tmp/.studio',
|
|
76
|
+
projectName: 'test-project',
|
|
77
|
+
apiConfig: { key: 'sk-studio-secret' },
|
|
78
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
79
|
+
integrationStore: nullIntegrationStore,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const res = await server.inject({
|
|
83
|
+
method: 'GET',
|
|
84
|
+
url: '/api/projects',
|
|
85
|
+
headers: { Authorization: 'Bearer sk-studio-wrong' },
|
|
86
|
+
});
|
|
87
|
+
expect(res.statusCode).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('api key configured → correct key → passes', async () => {
|
|
91
|
+
const server = buildServer({
|
|
92
|
+
store: new InMemoryRunStore(),
|
|
93
|
+
launcher: makeMockLauncher(),
|
|
94
|
+
configsDir: '/tmp/.studio',
|
|
95
|
+
projectName: 'test-project',
|
|
96
|
+
apiConfig: { key: 'sk-studio-secret' },
|
|
97
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
98
|
+
integrationStore: nullIntegrationStore,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const res = await server.inject({
|
|
102
|
+
method: 'GET',
|
|
103
|
+
url: '/api/projects',
|
|
104
|
+
headers: { Authorization: 'Bearer sk-studio-secret' },
|
|
105
|
+
});
|
|
106
|
+
expect(res.statusCode).not.toBe(401);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('buildServer — multi-user auth', () => {
|
|
111
|
+
it('user api_key → 200', async () => {
|
|
112
|
+
const userStore = makeTempUserStore();
|
|
113
|
+
userStore.saveUser(proUser);
|
|
114
|
+
|
|
115
|
+
const server = buildServer({
|
|
116
|
+
store: new InMemoryRunStore(),
|
|
117
|
+
launcher: makeMockLauncher(),
|
|
118
|
+
configsDir: '/tmp/.studio',
|
|
119
|
+
projectName: 'test',
|
|
120
|
+
apiConfig: {},
|
|
121
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
122
|
+
integrationStore: nullIntegrationStore,
|
|
123
|
+
userStore,
|
|
124
|
+
plans: DEFAULT_PLANS,
|
|
125
|
+
hasUsers: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const res = await server.inject({
|
|
129
|
+
method: 'GET',
|
|
130
|
+
url: '/api/projects',
|
|
131
|
+
headers: { Authorization: 'Bearer sk-pro-key' },
|
|
132
|
+
});
|
|
133
|
+
userStore.close();
|
|
134
|
+
expect(res.statusCode).not.toBe(401);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('unknown api_key when users exist → 401', async () => {
|
|
138
|
+
const userStore = makeTempUserStore();
|
|
139
|
+
userStore.saveUser(proUser);
|
|
140
|
+
|
|
141
|
+
const server = buildServer({
|
|
142
|
+
store: new InMemoryRunStore(),
|
|
143
|
+
launcher: makeMockLauncher(),
|
|
144
|
+
configsDir: '/tmp/.studio',
|
|
145
|
+
projectName: 'test',
|
|
146
|
+
apiConfig: {},
|
|
147
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
148
|
+
integrationStore: nullIntegrationStore,
|
|
149
|
+
userStore,
|
|
150
|
+
plans: DEFAULT_PLANS,
|
|
151
|
+
hasUsers: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const res = await server.inject({
|
|
155
|
+
method: 'GET',
|
|
156
|
+
url: '/api/projects',
|
|
157
|
+
headers: { Authorization: 'Bearer sk-unknown' },
|
|
158
|
+
});
|
|
159
|
+
userStore.close();
|
|
160
|
+
expect(res.statusCode).toBe(401);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('legacy api.key works when no users in DB (hasUsers=false)', async () => {
|
|
164
|
+
const userStore = makeTempUserStore();
|
|
165
|
+
|
|
166
|
+
const server = buildServer({
|
|
167
|
+
store: new InMemoryRunStore(),
|
|
168
|
+
launcher: makeMockLauncher(),
|
|
169
|
+
configsDir: '/tmp/.studio',
|
|
170
|
+
projectName: 'test',
|
|
171
|
+
apiConfig: { key: 'sk-legacy-key' },
|
|
172
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
173
|
+
integrationStore: nullIntegrationStore,
|
|
174
|
+
userStore,
|
|
175
|
+
plans: DEFAULT_PLANS,
|
|
176
|
+
hasUsers: false,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const res = await server.inject({
|
|
180
|
+
method: 'GET',
|
|
181
|
+
url: '/api/projects',
|
|
182
|
+
headers: { Authorization: 'Bearer sk-legacy-key' },
|
|
183
|
+
});
|
|
184
|
+
userStore.close();
|
|
185
|
+
expect(res.statusCode).not.toBe(401);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('no users, no api.key → open dev mode', async () => {
|
|
189
|
+
const userStore = makeTempUserStore();
|
|
190
|
+
|
|
191
|
+
const server = buildServer({
|
|
192
|
+
store: new InMemoryRunStore(),
|
|
193
|
+
launcher: makeMockLauncher(),
|
|
194
|
+
configsDir: '/tmp/.studio',
|
|
195
|
+
projectName: 'test',
|
|
196
|
+
apiConfig: {},
|
|
197
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
198
|
+
integrationStore: nullIntegrationStore,
|
|
199
|
+
userStore,
|
|
200
|
+
plans: DEFAULT_PLANS,
|
|
201
|
+
hasUsers: false,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const res = await server.inject({ method: 'GET', url: '/api/projects' });
|
|
205
|
+
userStore.close();
|
|
206
|
+
expect(res.statusCode).not.toBe(401);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { buildServer } from '../src/server.js';
|
|
5
|
+
import { InMemoryRunStore } from '@studio-foundation/engine';
|
|
6
|
+
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
7
|
+
import type { IntegrationStore } from '../src/integration-store.js';
|
|
8
|
+
|
|
9
|
+
const TMP = resolve('/tmp', `.studio-skills-test-${Date.now()}`);
|
|
10
|
+
const SKILLS_DIR = resolve(TMP, 'skills');
|
|
11
|
+
|
|
12
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
13
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
14
|
+
|
|
15
|
+
function makeServer() {
|
|
16
|
+
return buildServer({
|
|
17
|
+
store: new InMemoryRunStore(),
|
|
18
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
19
|
+
configsDir: TMP,
|
|
20
|
+
projectName: 'test-project',
|
|
21
|
+
apiConfig: {},
|
|
22
|
+
studioVersion: '0.0.0-test',
|
|
23
|
+
maskedConfig: { providers: [] },
|
|
24
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
25
|
+
integrationStore: nullIntegrationStore,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
mkdirSync(SKILLS_DIR, { recursive: true });
|
|
31
|
+
writeFileSync(resolve(SKILLS_DIR, 'commit-conventions.skill.md'), '# Commit Conventions\nUse conventional commits.');
|
|
32
|
+
writeFileSync(resolve(SKILLS_DIR, 'react-patterns.skill.md'), '# React Patterns\nPrefer hooks over HOCs.');
|
|
33
|
+
writeFileSync(resolve(SKILLS_DIR, 'ignored.md'), ''); // must be ignored
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('GET /api/skills', () => {
|
|
41
|
+
it('returns only *.skill.md files as skill names', async () => {
|
|
42
|
+
const server = makeServer();
|
|
43
|
+
const res = await server.inject({ method: 'GET', url: '/api/skills' });
|
|
44
|
+
expect(res.statusCode).toBe(200);
|
|
45
|
+
const { skills } = res.json() as { skills: string[] };
|
|
46
|
+
expect(skills).toContain('commit-conventions');
|
|
47
|
+
expect(skills).toContain('react-patterns');
|
|
48
|
+
expect(skills).not.toContain('ignored');
|
|
49
|
+
expect(skills).not.toContain('commit-conventions.skill.md');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns empty array when skills dir is missing', async () => {
|
|
53
|
+
const server = buildServer({
|
|
54
|
+
store: new InMemoryRunStore(),
|
|
55
|
+
launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
|
|
56
|
+
configsDir: resolve('/tmp', `.studio-no-skills-${Date.now()}`),
|
|
57
|
+
projectName: 'test-project',
|
|
58
|
+
apiConfig: {},
|
|
59
|
+
studioVersion: '0.0.0-test',
|
|
60
|
+
maskedConfig: { providers: [] },
|
|
61
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
62
|
+
integrationStore: nullIntegrationStore,
|
|
63
|
+
});
|
|
64
|
+
const res = await server.inject({ method: 'GET', url: '/api/skills' });
|
|
65
|
+
expect(res.statusCode).toBe(200);
|
|
66
|
+
expect((res.json() as { skills: string[] }).skills).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('GET /api/skills/:name', () => {
|
|
71
|
+
it('returns skill content as JSON', async () => {
|
|
72
|
+
const server = makeServer();
|
|
73
|
+
const res = await server.inject({ method: 'GET', url: '/api/skills/commit-conventions' });
|
|
74
|
+
expect(res.statusCode).toBe(200);
|
|
75
|
+
const body = res.json() as { name: string; content: string };
|
|
76
|
+
expect(body.name).toBe('commit-conventions');
|
|
77
|
+
expect(body.content).toContain('conventional commits');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns 404 for unknown skill', async () => {
|
|
81
|
+
const server = makeServer();
|
|
82
|
+
const res = await server.inject({ method: 'GET', url: '/api/skills/nonexistent' });
|
|
83
|
+
expect(res.statusCode).toBe(404);
|
|
84
|
+
expect((res.json() as { error: string }).error).toBe('Skill not found');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('PUT /api/skills/:name', () => {
|
|
89
|
+
it('creates a new skill file', async () => {
|
|
90
|
+
const server = makeServer();
|
|
91
|
+
const res = await server.inject({
|
|
92
|
+
method: 'PUT',
|
|
93
|
+
url: '/api/skills/new-skill',
|
|
94
|
+
payload: { content: '# New Skill\nSome instructions.' },
|
|
95
|
+
});
|
|
96
|
+
expect(res.statusCode).toBe(200);
|
|
97
|
+
const body = res.json() as { name: string; content: string };
|
|
98
|
+
expect(body.name).toBe('new-skill');
|
|
99
|
+
expect(body.content).toContain('New Skill');
|
|
100
|
+
// Verify it can be read back
|
|
101
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/skills/new-skill' });
|
|
102
|
+
expect(getRes.statusCode).toBe(200);
|
|
103
|
+
expect((getRes.json() as { content: string }).content).toContain('New Skill');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('updates an existing skill', async () => {
|
|
107
|
+
const server = makeServer();
|
|
108
|
+
const res = await server.inject({
|
|
109
|
+
method: 'PUT',
|
|
110
|
+
url: '/api/skills/react-patterns',
|
|
111
|
+
payload: { content: '# React Patterns\nUpdated content.' },
|
|
112
|
+
});
|
|
113
|
+
expect(res.statusCode).toBe(200);
|
|
114
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/skills/react-patterns' });
|
|
115
|
+
expect((getRes.json() as { content: string }).content).toContain('Updated content');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns 400 when content field is missing', async () => {
|
|
119
|
+
const server = makeServer();
|
|
120
|
+
const res = await server.inject({
|
|
121
|
+
method: 'PUT',
|
|
122
|
+
url: '/api/skills/foo',
|
|
123
|
+
payload: { name: 'foo' },
|
|
124
|
+
});
|
|
125
|
+
expect(res.statusCode).toBe(400);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('DELETE /api/skills/:name', () => {
|
|
130
|
+
it('deletes a skill and returns 204', async () => {
|
|
131
|
+
const server = makeServer();
|
|
132
|
+
await server.inject({
|
|
133
|
+
method: 'PUT',
|
|
134
|
+
url: '/api/skills/to-delete',
|
|
135
|
+
payload: { content: '# To Delete' },
|
|
136
|
+
});
|
|
137
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/skills/to-delete' });
|
|
138
|
+
expect(res.statusCode).toBe(204);
|
|
139
|
+
const getRes = await server.inject({ method: 'GET', url: '/api/skills/to-delete' });
|
|
140
|
+
expect(getRes.statusCode).toBe(404);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns 404 for nonexistent skill', async () => {
|
|
144
|
+
const server = makeServer();
|
|
145
|
+
const res = await server.inject({ method: 'DELETE', url: '/api/skills/nonexistent' });
|
|
146
|
+
expect(res.statusCode).toBe(404);
|
|
147
|
+
expect((res.json() as { error: string }).error).toBe('Skill not found');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { buildServer } from '../src/server.js';
|
|
5
|
+
import type { RunLauncher } from '../src/launcher.js';
|
|
6
|
+
import type { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
7
|
+
import type { IntegrationStore } from '../src/integration-store.js';
|
|
8
|
+
|
|
9
|
+
const TMP = resolve('/tmp', `studio-sse-test-${Date.now()}`);
|
|
10
|
+
mkdirSync(TMP, { recursive: true });
|
|
11
|
+
|
|
12
|
+
const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
|
|
13
|
+
const nullIntegrationStore = {} as unknown as IntegrationStore;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
17
|
+
mkdirSync(TMP, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function makeDeps(overrides?: Partial<{
|
|
21
|
+
runExists: boolean;
|
|
22
|
+
runStatus: string;
|
|
23
|
+
logPath: string | null;
|
|
24
|
+
}>) {
|
|
25
|
+
const { runExists = false, runStatus = 'running', logPath = null } = overrides ?? {};
|
|
26
|
+
|
|
27
|
+
const mockStore = {
|
|
28
|
+
getPipelineRun: vi.fn().mockReturnValue(
|
|
29
|
+
runExists
|
|
30
|
+
? { id: 'run-1', pipeline_name: 'p', status: runStatus, started_at: '', stages: [] }
|
|
31
|
+
: null
|
|
32
|
+
),
|
|
33
|
+
getLogPath: vi.fn().mockReturnValue(logPath),
|
|
34
|
+
saveLogPath: vi.fn(),
|
|
35
|
+
listPipelineRuns: vi.fn().mockReturnValue([]),
|
|
36
|
+
savePipelineRun: vi.fn(),
|
|
37
|
+
updatePipelineRun: vi.fn(),
|
|
38
|
+
saveStageRun: vi.fn(),
|
|
39
|
+
saveTaskRun: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const mockLauncher: RunLauncher = {
|
|
43
|
+
launch: vi.fn().mockResolvedValue({ run_id: 'run-1' }),
|
|
44
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
subscribe: vi.fn().mockReturnValue(() => {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { mockStore, mockLauncher };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('GET /api/runs/:id/stream', () => {
|
|
52
|
+
it('returns 404 for unknown run', async () => {
|
|
53
|
+
const { mockStore, mockLauncher } = makeDeps({ runExists: false });
|
|
54
|
+
const fastify = buildServer({
|
|
55
|
+
store: mockStore as never,
|
|
56
|
+
launcher: mockLauncher,
|
|
57
|
+
configsDir: TMP,
|
|
58
|
+
projectName: 'test',
|
|
59
|
+
apiConfig: {},
|
|
60
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
61
|
+
integrationStore: nullIntegrationStore,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const res = await fastify.inject({ method: 'GET', url: '/api/runs/unknown-id/stream' });
|
|
65
|
+
expect(res.statusCode).toBe(404);
|
|
66
|
+
expect(JSON.parse(res.body)).toMatchObject({ error: 'Run not found' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replays JSONL history and closes for a terminated run', async () => {
|
|
70
|
+
const logFile = resolve(TMP, 'run.jsonl');
|
|
71
|
+
writeFileSync(logFile, [
|
|
72
|
+
JSON.stringify({ ts: '2026-01-01', run_id: 'r1', event: 'stage_complete', stage_name: 'brief-analysis', status: 'success' }),
|
|
73
|
+
JSON.stringify({ ts: '2026-01-01', run_id: 'r1', event: 'pipeline_complete', status: 'success' }),
|
|
74
|
+
].join('\n') + '\n');
|
|
75
|
+
|
|
76
|
+
const { mockStore, mockLauncher } = makeDeps({
|
|
77
|
+
runExists: true,
|
|
78
|
+
runStatus: 'success',
|
|
79
|
+
logPath: logFile,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const fastify = buildServer({
|
|
83
|
+
store: mockStore as never,
|
|
84
|
+
launcher: mockLauncher,
|
|
85
|
+
configsDir: TMP,
|
|
86
|
+
projectName: 'test',
|
|
87
|
+
apiConfig: {},
|
|
88
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
89
|
+
integrationStore: nullIntegrationStore,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const res = await fastify.inject({ method: 'GET', url: '/api/runs/run-1/stream' });
|
|
93
|
+
expect(res.headers['content-type']).toContain('text/event-stream');
|
|
94
|
+
expect(res.body).toContain('event: stage_complete');
|
|
95
|
+
expect(res.body).toContain('event: pipeline_complete');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('filters events by ?events= query param', async () => {
|
|
99
|
+
const logFile = resolve(TMP, 'run-filter.jsonl');
|
|
100
|
+
writeFileSync(logFile, [
|
|
101
|
+
JSON.stringify({ ts: '2026-01-01', run_id: 'r1', event: 'stage_complete', stage_name: 's1' }),
|
|
102
|
+
JSON.stringify({ ts: '2026-01-01', run_id: 'r1', event: 'pipeline_complete', status: 'success' }),
|
|
103
|
+
].join('\n') + '\n');
|
|
104
|
+
|
|
105
|
+
const { mockStore, mockLauncher } = makeDeps({
|
|
106
|
+
runExists: true,
|
|
107
|
+
runStatus: 'success',
|
|
108
|
+
logPath: logFile,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const fastify = buildServer({
|
|
112
|
+
store: mockStore as never,
|
|
113
|
+
launcher: mockLauncher,
|
|
114
|
+
configsDir: TMP,
|
|
115
|
+
projectName: 'test',
|
|
116
|
+
apiConfig: {},
|
|
117
|
+
integrationRuntime: nullIntegrationRuntime,
|
|
118
|
+
integrationStore: nullIntegrationStore,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const res = await fastify.inject({
|
|
122
|
+
method: 'GET',
|
|
123
|
+
url: '/api/runs/run-1/stream?events=pipeline_complete',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(res.body).not.toContain('event: stage_complete');
|
|
127
|
+
expect(res.body).toContain('event: pipeline_complete');
|
|
128
|
+
});
|
|
129
|
+
});
|