@opensassi/opencode 0.1.2 → 0.1.4
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/dashboard/dashboard.e2e.test.ts +247 -0
- package/dashboard/dist/index.d.ts +9 -0
- package/dashboard/dist/index.js +36 -0
- package/dashboard/dist/routes/api.d.ts +2 -0
- package/dashboard/dist/routes/api.js +215 -0
- package/dashboard/dist/services/cache.d.ts +13 -0
- package/dashboard/dist/services/cache.js +29 -0
- package/dashboard/dist/services/experiments.d.ts +11 -0
- package/dashboard/dist/services/experiments.js +108 -0
- package/dashboard/dist/services/git.d.ts +12 -0
- package/dashboard/dist/services/git.js +149 -0
- package/dashboard/dist/services/sessions.d.ts +25 -0
- package/dashboard/dist/services/sessions.js +208 -0
- package/dashboard/dist/services/specs.d.ts +9 -0
- package/dashboard/dist/services/specs.js +102 -0
- package/dashboard/dist/types.d.ts +173 -0
- package/dashboard/dist/types.js +1 -0
- package/dashboard/opencode.e2e.test.ts +100 -0
- package/dashboard/playwright.config.ts +11 -0
- package/dashboard/public/app.js +961 -0
- package/dashboard/public/index.html +29 -0
- package/dashboard/public/style.css +231 -0
- package/dashboard/src/index.ts +53 -0
- package/dashboard/src/routes/api.ts +235 -0
- package/dashboard/src/services/cache.ts +38 -0
- package/dashboard/src/services/experiments.ts +117 -0
- package/dashboard/src/services/git.ts +139 -0
- package/dashboard/src/services/sessions.ts +216 -0
- package/dashboard/src/services/specs.ts +95 -0
- package/dashboard/src/types.ts +168 -0
- package/dashboard/technical-specification.md +414 -0
- package/dashboard/test-api.sh +127 -0
- package/dashboard/tsconfig.json +16 -0
- package/lib/util/paths.js +9 -1
- package/package.json +9 -1
- package/scripts/dashboard.js +17 -0
- package/scripts/generate-daily-summaries.js +190 -0
- package/skills/opensassi/SKILL.md +150 -56
- package/skills/todo/SKILL.md +45 -63
- package/skills-index.json +10 -7
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { execSync, spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const HARNESS_DIR = resolve(import.meta.dirname ?? '.', '..');
|
|
6
|
+
let server: ChildProcess;
|
|
7
|
+
const PORT = 3099;
|
|
8
|
+
|
|
9
|
+
test.beforeAll(() => {
|
|
10
|
+
server = spawn('node', ['scripts/dashboard.js', '--port', String(PORT)], {
|
|
11
|
+
cwd: HARNESS_DIR,
|
|
12
|
+
stdio: 'pipe',
|
|
13
|
+
});
|
|
14
|
+
// Wait for server to start
|
|
15
|
+
const maxWait = 10000;
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
while (Date.now() - start < maxWait) {
|
|
18
|
+
try {
|
|
19
|
+
execSync(`curl -sf http://127.0.0.1:${PORT}/api/health`, { stdio: 'ignore' });
|
|
20
|
+
break;
|
|
21
|
+
} catch {
|
|
22
|
+
execSync('sleep 0.3');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test.afterAll(() => {
|
|
28
|
+
if (server) {
|
|
29
|
+
server.kill();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test.describe('API endpoints', () => {
|
|
34
|
+
|
|
35
|
+
test('GET /api/health returns ok', async () => {
|
|
36
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/health`);
|
|
37
|
+
expect(res.ok).toBe(true);
|
|
38
|
+
const body = await res.json();
|
|
39
|
+
expect(body.status).toBe('ok');
|
|
40
|
+
expect(body.days_count).toBeGreaterThan(0);
|
|
41
|
+
expect(body.sessions_count).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('GET /api/days returns day list', async () => {
|
|
45
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/days`);
|
|
46
|
+
expect(res.ok).toBe(true);
|
|
47
|
+
const body = await res.json();
|
|
48
|
+
expect(body.days).toBeInstanceOf(Array);
|
|
49
|
+
expect(body.days.length).toBeGreaterThanOrEqual(4);
|
|
50
|
+
expect(body.days).toContain('2026-05-16');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('GET /api/days/latest returns latest day', async () => {
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/days/latest`);
|
|
55
|
+
expect(res.ok).toBe(true);
|
|
56
|
+
const body = await res.json();
|
|
57
|
+
expect(body.date).toBe('2026-05-16');
|
|
58
|
+
expect(body.total_sessions).toBe(2);
|
|
59
|
+
expect(body.session_breakdown).toBeInstanceOf(Array);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('GET /api/days/:date returns specific day', async () => {
|
|
63
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/days/2026-05-16`);
|
|
64
|
+
expect(res.ok).toBe(true);
|
|
65
|
+
const body = await res.json();
|
|
66
|
+
expect(body.date).toBe('2026-05-16');
|
|
67
|
+
expect(body.total_sessions).toBe(2);
|
|
68
|
+
expect(body.total_prompter_time_hours).toBe(1.5);
|
|
69
|
+
expect(body.total_sme_time_hours).toBe(14);
|
|
70
|
+
expect(body.ai_multiplier).toBe(9.3);
|
|
71
|
+
expect(body.top_subject_areas.length).toBeGreaterThan(0);
|
|
72
|
+
expect(body.session_breakdown.length).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('GET /api/days/:date 404 on missing date', async () => {
|
|
76
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/days/2099-01-01`);
|
|
77
|
+
expect(res.status).toBe(404);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('GET /api/sessions returns paginated sessions', async () => {
|
|
81
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/sessions`);
|
|
82
|
+
expect(res.ok).toBe(true);
|
|
83
|
+
const body = await res.json();
|
|
84
|
+
expect(body.total).toBeGreaterThan(0);
|
|
85
|
+
expect(body.sessions).toBeInstanceOf(Array);
|
|
86
|
+
expect(body.sessions.length).toBeGreaterThan(0);
|
|
87
|
+
expect(body.sessions[0]).toHaveProperty('date');
|
|
88
|
+
expect(body.sessions[0]).toHaveProperty('entry');
|
|
89
|
+
expect(body.sessions[0].entry).toHaveProperty('session_id');
|
|
90
|
+
expect(body.sessions[0].entry).toHaveProperty('duration_minutes');
|
|
91
|
+
expect(body.sessions[0].entry).toHaveProperty('tags');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('GET /api/sessions/:id returns session with detail', async () => {
|
|
95
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/sessions/2026-05-16-npm-optimizer-skill-revision`);
|
|
96
|
+
expect(res.ok).toBe(true);
|
|
97
|
+
const body = await res.json();
|
|
98
|
+
expect(body.summary).toBeTruthy();
|
|
99
|
+
expect(body.summary.entry.session_id).toContain('2026-05-16-npm-optimizer');
|
|
100
|
+
expect(body.detail).toBeTruthy();
|
|
101
|
+
expect(body.detail.info).toHaveProperty('title');
|
|
102
|
+
expect(body.detail.messages).toBeInstanceOf(Array);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('GET /api/sessions/:id 404 on missing session', async () => {
|
|
106
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/sessions/nonexistent-session`);
|
|
107
|
+
expect(res.status).toBe(404);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('GET /api/stats returns cross-day aggregates', async () => {
|
|
111
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/stats`);
|
|
112
|
+
expect(res.ok).toBe(true);
|
|
113
|
+
const body = await res.json();
|
|
114
|
+
expect(body.total_days).toBe(4);
|
|
115
|
+
expect(body.total_sessions).toBe(18);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('GET /api/search returns results', async () => {
|
|
119
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/search?q=opensassi`);
|
|
120
|
+
expect(res.ok).toBe(true);
|
|
121
|
+
const body = await res.json();
|
|
122
|
+
expect(body.results).toBeInstanceOf(Array);
|
|
123
|
+
expect(body.results.length).toBeGreaterThan(0);
|
|
124
|
+
expect(body.results[0]).toHaveProperty('session_id');
|
|
125
|
+
expect(body.results[0]).toHaveProperty('match_type');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('GET /api/search returns empty for no match', async () => {
|
|
129
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/search?q=zzz_nonexistent_zzz`);
|
|
130
|
+
expect(res.ok).toBe(true);
|
|
131
|
+
const body = await res.json();
|
|
132
|
+
expect(body.results).toBeInstanceOf(Array);
|
|
133
|
+
expect(body.results.length).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('GET /api/git/log returns commits', async () => {
|
|
137
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/git/log`);
|
|
138
|
+
expect(res.ok).toBe(true);
|
|
139
|
+
const body = await res.json();
|
|
140
|
+
expect(body.commits).toBeInstanceOf(Array);
|
|
141
|
+
expect(body.commits.length).toBeGreaterThan(0);
|
|
142
|
+
expect(body.commits[0]).toHaveProperty('commit');
|
|
143
|
+
expect(body.commits[0]).toHaveProperty('message');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('GET /api/git/stats returns stats', async () => {
|
|
147
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/git/stats`);
|
|
148
|
+
expect(res.ok).toBe(true);
|
|
149
|
+
const body = await res.json();
|
|
150
|
+
expect(body.total_commits).toBeGreaterThan(0);
|
|
151
|
+
expect(body).toHaveProperty('per_date');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('GET /api/git/commit/:hash returns diff', async () => {
|
|
155
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/git/commit/HEAD`);
|
|
156
|
+
expect(res.ok).toBe(true);
|
|
157
|
+
const body = await res.json();
|
|
158
|
+
expect(body.diff).toBeTruthy();
|
|
159
|
+
expect(body.diff.length).toBeGreaterThan(100);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('GET /api/git/commit/:hash 404 on missing', async () => {
|
|
163
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/git/commit/0000000000000000000000000000000000000000`);
|
|
164
|
+
expect(res.status).toBe(404);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test.describe('Frontend pages', () => {
|
|
169
|
+
|
|
170
|
+
test('serves index.html at /', async ({ page }) => {
|
|
171
|
+
await page.goto(`http://127.0.0.1:${PORT}/`);
|
|
172
|
+
await expect(page.locator('nav')).toBeVisible();
|
|
173
|
+
await expect(page.locator('.nav-brand')).toHaveText('opencode');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('hash routing renders overview page', async ({ page }) => {
|
|
177
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/`);
|
|
178
|
+
await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('navigates to Daily tab', async ({ page }) => {
|
|
182
|
+
await page.goto(`http://127.0.0.1:${PORT}/`);
|
|
183
|
+
await page.click('a[href="#/daily"]');
|
|
184
|
+
await page.waitForSelector('.page-title');
|
|
185
|
+
await expect(page.locator('.page-title')).toHaveText('Daily Reports');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('opens a daily detail page', async ({ page }) => {
|
|
189
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/daily/2026-05-16`);
|
|
190
|
+
await page.waitForSelector('.page-title');
|
|
191
|
+
await expect(page.locator('.page-title')).toHaveText('2026-05-16');
|
|
192
|
+
await expect(page.locator('.stats-grid .card-value').first()).toBeVisible();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('opens a session page from daily', async ({ page }) => {
|
|
196
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/daily/2026-05-16`);
|
|
197
|
+
await page.waitForSelector('.session-card');
|
|
198
|
+
await page.click('.session-card');
|
|
199
|
+
await page.waitForSelector('.page-title');
|
|
200
|
+
const title = await page.locator('.page-title').textContent();
|
|
201
|
+
expect(title).toContain('2026-05-16');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('search page works', async ({ page }) => {
|
|
205
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/search?q=opensassi`);
|
|
206
|
+
await page.waitForSelector('.page-title');
|
|
207
|
+
await expect(page.locator('.page-title')).toHaveText('Search');
|
|
208
|
+
await page.waitForSelector('.session-card');
|
|
209
|
+
const cards = await page.locator('.session-card').count();
|
|
210
|
+
expect(cards).toBeGreaterThan(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('git page renders commits', async ({ page }) => {
|
|
214
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/git`);
|
|
215
|
+
await page.waitForSelector('.page-title');
|
|
216
|
+
await expect(page.locator('.page-title')).toHaveText('Git Activity');
|
|
217
|
+
await page.waitForSelector('.session-card');
|
|
218
|
+
const commits = await page.locator('.session-card').count();
|
|
219
|
+
expect(commits).toBeGreaterThan(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('experiments list page renders', async ({ page }) => {
|
|
223
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/experiments`);
|
|
224
|
+
await page.waitForSelector('.page-title');
|
|
225
|
+
await expect(page.locator('.page-title')).toHaveText('Experiments');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('specs tree renders technical-specification.md first', async ({ page }) => {
|
|
229
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/specs`);
|
|
230
|
+
await page.waitForSelector('.page-title');
|
|
231
|
+
await expect(page.locator('.page-title')).toHaveText('Technical Specifications');
|
|
232
|
+
// The first link should be technical-specification.md
|
|
233
|
+
const firstLink = page.locator('a[href*="spec/"]').first();
|
|
234
|
+
await expect(firstLink).toBeVisible();
|
|
235
|
+
const text = await firstLink.textContent();
|
|
236
|
+
expect(text).toBe('technical-specification.md');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('specs tree loads a spec file', async ({ page }) => {
|
|
240
|
+
await page.goto(`http://127.0.0.1:${PORT}/#/specs`);
|
|
241
|
+
await page.waitForSelector('.page-title');
|
|
242
|
+
const firstLink = page.locator('a[href*="spec/"]').first();
|
|
243
|
+
await firstLink.click();
|
|
244
|
+
await page.waitForTimeout(500);
|
|
245
|
+
expect(page.url()).toContain('/spec/');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { resolve, dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { createApiRouter } from './routes/api.js';
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
export function startDashboard(opts = {}) {
|
|
9
|
+
const port = opts.port ?? (parseInt(process.env.DEEPENC_DASHBOARD_PORT ?? '') || 3000);
|
|
10
|
+
const host = opts.host ?? '127.0.0.1';
|
|
11
|
+
const repoDir = opts.repoDir ?? process.cwd();
|
|
12
|
+
const sessionsDir = opts.sessionsDir ?? resolve(repoDir, 'sessions');
|
|
13
|
+
const experimentsDir = opts.experimentsDir ?? resolve(repoDir, 'perf', 'experiments');
|
|
14
|
+
const specsDir = resolve(repoDir);
|
|
15
|
+
const gitSince = opts.gitSince;
|
|
16
|
+
const app = express();
|
|
17
|
+
const publicDir = join(__dirname, '..', 'public');
|
|
18
|
+
app.use(express.static(publicDir));
|
|
19
|
+
app.use('/api', createApiRouter(sessionsDir, repoDir, experimentsDir, gitSince, specsDir));
|
|
20
|
+
app.get('/{*path}', (_req, res) => {
|
|
21
|
+
res.sendFile(join(publicDir, 'index.html'));
|
|
22
|
+
});
|
|
23
|
+
const server = app.listen(port, host, () => {
|
|
24
|
+
console.log(`opencode dashboard running at http://${host}:${port}`);
|
|
25
|
+
const hasData = existsSync(join(sessionsDir, 'daily'));
|
|
26
|
+
if (!hasData) {
|
|
27
|
+
console.log(` (sessions directory not found at ${sessionsDir})`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const shutdown = () => {
|
|
31
|
+
console.log('\nShutting down dashboard...');
|
|
32
|
+
server.close(() => process.exit(0));
|
|
33
|
+
};
|
|
34
|
+
process.on('SIGINT', shutdown);
|
|
35
|
+
process.on('SIGTERM', shutdown);
|
|
36
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { SessionsService } from '../services/sessions.js';
|
|
4
|
+
import { GitService } from '../services/git.js';
|
|
5
|
+
import { ExperimentsService } from '../services/experiments.js';
|
|
6
|
+
import { TechSpecService } from '../services/specs.js';
|
|
7
|
+
function qs(v) {
|
|
8
|
+
if (typeof v === 'string')
|
|
9
|
+
return v;
|
|
10
|
+
if (Array.isArray(v) && v.length > 0)
|
|
11
|
+
return String(v[0]);
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
export function createApiRouter(sessionsDir, repoDir, experimentsDir, gitSince, specsRoot) {
|
|
15
|
+
const router = Router();
|
|
16
|
+
const sessions = new SessionsService(sessionsDir);
|
|
17
|
+
const git = new GitService(repoDir, gitSince);
|
|
18
|
+
const experiments = experimentsDir ? new ExperimentsService(experimentsDir) : null;
|
|
19
|
+
const specs = specsRoot ? new TechSpecService(specsRoot) : null;
|
|
20
|
+
router.get('/health', (_req, res) => {
|
|
21
|
+
const days = sessions.listDays();
|
|
22
|
+
const flat = sessions.getAllSessionsFlat();
|
|
23
|
+
const health = {
|
|
24
|
+
status: days.length > 0 ? 'ok' : 'error',
|
|
25
|
+
days_count: days.length,
|
|
26
|
+
sessions_count: flat.length,
|
|
27
|
+
sessions_path: sessionsDir,
|
|
28
|
+
};
|
|
29
|
+
res.json(health);
|
|
30
|
+
});
|
|
31
|
+
router.get('/days', (_req, res) => {
|
|
32
|
+
const days = sessions.listDays();
|
|
33
|
+
res.json({ days });
|
|
34
|
+
});
|
|
35
|
+
router.get('/days/latest', (_req, res) => {
|
|
36
|
+
const days = sessions.listDays();
|
|
37
|
+
if (days.length === 0) {
|
|
38
|
+
res.status(404).json({ error: 'No daily data found' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const day = sessions.getDay(days[days.length - 1]);
|
|
42
|
+
res.json(day);
|
|
43
|
+
});
|
|
44
|
+
router.get('/days/:date', (req, res) => {
|
|
45
|
+
const date = String(req.params.date);
|
|
46
|
+
const day = sessions.getDay(date);
|
|
47
|
+
if (day.total_sessions === 0 && day.top_subject_areas.length === 0) {
|
|
48
|
+
res.status(404).json({ error: `No data for date: ${date}` });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
res.json(day);
|
|
52
|
+
});
|
|
53
|
+
router.get('/sessions', (req, res) => {
|
|
54
|
+
const flat = sessions.getAllSessionsFlat();
|
|
55
|
+
const page = parseInt(qs(req.query.page) ?? '') || 1;
|
|
56
|
+
const limit = parseInt(qs(req.query.limit) ?? '') || 50;
|
|
57
|
+
const start = (page - 1) * limit;
|
|
58
|
+
const paginated = flat.slice(start, start + limit);
|
|
59
|
+
res.json({ sessions: paginated, total: flat.length, page, limit });
|
|
60
|
+
});
|
|
61
|
+
router.get('/sessions/:sessionId/summary', (req, res) => {
|
|
62
|
+
const sessionId = String(req.params.sessionId);
|
|
63
|
+
const flat = sessions.getAllSessionsFlat();
|
|
64
|
+
const summary = flat.find(s => s.entry.session_id === sessionId);
|
|
65
|
+
if (!summary) {
|
|
66
|
+
res.status(404).json({ error: `Session not found: ${sessionId}` });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const md = sessions.getSessionSummary(sessionId);
|
|
70
|
+
if (!md) {
|
|
71
|
+
res.status(404).json({ error: 'No summary file found for this session' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.json({ summary, markdown: md });
|
|
75
|
+
});
|
|
76
|
+
router.get('/sessions/:sessionId', (req, res) => {
|
|
77
|
+
const sessionId = String(req.params.sessionId);
|
|
78
|
+
const flat = sessions.getAllSessionsFlat();
|
|
79
|
+
const summary = flat.find(s => s.entry.session_id === sessionId);
|
|
80
|
+
if (!summary) {
|
|
81
|
+
res.status(404).json({ error: `Session not found: ${sessionId}` });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const detail = sessions.getSessionDetail(sessionId);
|
|
85
|
+
res.json({ summary, detail });
|
|
86
|
+
});
|
|
87
|
+
router.get('/stats', (_req, res) => {
|
|
88
|
+
const days = sessions.listDays();
|
|
89
|
+
const normalizedDays = days.map(d => sessions.getDay(d));
|
|
90
|
+
let totalSessions = 0;
|
|
91
|
+
let totalPrompter = 0;
|
|
92
|
+
let totalSme = 0;
|
|
93
|
+
let multiplierSum = 0;
|
|
94
|
+
let multiplierCount = 0;
|
|
95
|
+
for (const day of normalizedDays) {
|
|
96
|
+
totalSessions += day.total_sessions;
|
|
97
|
+
totalPrompter += day.total_prompter_time_hours;
|
|
98
|
+
totalSme += day.total_sme_time_hours;
|
|
99
|
+
if (day.ai_multiplier > 0) {
|
|
100
|
+
multiplierSum += day.ai_multiplier;
|
|
101
|
+
multiplierCount++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const stats = {
|
|
105
|
+
total_days: days.length,
|
|
106
|
+
total_sessions: totalSessions,
|
|
107
|
+
total_prompter_time_hours: Math.round(totalPrompter * 10) / 10,
|
|
108
|
+
total_sme_time_hours: Math.round(totalSme * 10) / 10,
|
|
109
|
+
avg_multiplier: multiplierCount > 0 ? Math.round((multiplierSum / multiplierCount) * 10) / 10 : 0,
|
|
110
|
+
per_day: normalizedDays,
|
|
111
|
+
};
|
|
112
|
+
res.json(stats);
|
|
113
|
+
});
|
|
114
|
+
router.get('/search', (req, res) => {
|
|
115
|
+
const query = qs(req.query.q) ?? '';
|
|
116
|
+
if (!query.trim()) {
|
|
117
|
+
res.json({ results: [] });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const results = sessions.search(query.trim());
|
|
121
|
+
res.json({ results });
|
|
122
|
+
});
|
|
123
|
+
router.get('/refresh', (_req, res) => {
|
|
124
|
+
sessions.refresh();
|
|
125
|
+
res.json({ status: 'refreshed' });
|
|
126
|
+
});
|
|
127
|
+
router.get('/git/log', (req, res) => {
|
|
128
|
+
const since = qs(req.query.since);
|
|
129
|
+
const until = qs(req.query.until);
|
|
130
|
+
const forkRange = git.detectForkPoint();
|
|
131
|
+
const range = since ? undefined : git.getRange(forkRange);
|
|
132
|
+
const commits = git.getLog(since, until, range);
|
|
133
|
+
res.json({ commits, forkRange });
|
|
134
|
+
});
|
|
135
|
+
router.get('/git/stats', (_req, res) => {
|
|
136
|
+
const forkRange = git.detectForkPoint();
|
|
137
|
+
const range = git.getRange(forkRange);
|
|
138
|
+
const stats = git.getStats(range);
|
|
139
|
+
res.json(stats);
|
|
140
|
+
});
|
|
141
|
+
router.get('/git/commit/:hash', (req, res) => {
|
|
142
|
+
const diff = git.getCommitDiff(String(req.params.hash));
|
|
143
|
+
if (!diff) {
|
|
144
|
+
res.status(404).json({ error: 'Commit not found' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
res.json({ diff });
|
|
148
|
+
});
|
|
149
|
+
if (specs) {
|
|
150
|
+
router.get('/specs', (_req, res) => {
|
|
151
|
+
const tree = specs.getTree();
|
|
152
|
+
res.json(tree);
|
|
153
|
+
});
|
|
154
|
+
router.get('/specs/read', (req, res) => {
|
|
155
|
+
const specPath = qs(req.query.path);
|
|
156
|
+
if (!specPath) {
|
|
157
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const content = specs.readSpec(specPath);
|
|
161
|
+
if (content === null) {
|
|
162
|
+
res.status(404).json({ error: 'Spec not found' });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
res.json({ path: specPath, content });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (experiments) {
|
|
169
|
+
router.get('/experiments', (_req, res) => {
|
|
170
|
+
const entries = experiments.listExperiments();
|
|
171
|
+
res.json({ experiments: entries });
|
|
172
|
+
});
|
|
173
|
+
router.get('/experiments/:name', (req, res) => {
|
|
174
|
+
const detail = experiments.getExperiment(String(req.params.name));
|
|
175
|
+
if (!detail) {
|
|
176
|
+
res.status(404).json({ error: 'Experiment not found' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.json(detail);
|
|
180
|
+
});
|
|
181
|
+
router.get('/experiments/:name/read', (req, res) => {
|
|
182
|
+
const filePath = qs(req.query.path);
|
|
183
|
+
const raw = qs(req.query.raw);
|
|
184
|
+
if (!filePath) {
|
|
185
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (raw === 'true') {
|
|
189
|
+
const buf = experiments.readFileRaw(filePath);
|
|
190
|
+
if (!buf) {
|
|
191
|
+
res.status(404).json({ error: 'File not found' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const ext = extname(filePath).toLowerCase();
|
|
195
|
+
const mime = {
|
|
196
|
+
'.png': 'image/png',
|
|
197
|
+
'.jpg': 'image/jpeg',
|
|
198
|
+
'.jpeg': 'image/jpeg',
|
|
199
|
+
'.gif': 'image/gif',
|
|
200
|
+
'.svg': 'image/svg+xml',
|
|
201
|
+
'.html': 'text/html; charset=utf-8',
|
|
202
|
+
};
|
|
203
|
+
res.type(mime[ext] ?? 'application/octet-stream').send(buf);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const content = experiments.readFile(filePath);
|
|
207
|
+
if (content === null) {
|
|
208
|
+
res.status(404).json({ error: 'File not found' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
res.json({ path: filePath, content });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return router;
|
|
215
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
expiresAt: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class TTLCache<T> {
|
|
6
|
+
private store;
|
|
7
|
+
private defaultTTL;
|
|
8
|
+
constructor(defaultTTLMs?: number);
|
|
9
|
+
get(key: string): T | undefined;
|
|
10
|
+
set(key: string, value: T, ttlMs?: number): void;
|
|
11
|
+
invalidate(key: string): void;
|
|
12
|
+
invalidateAll(): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class TTLCache {
|
|
2
|
+
store = new Map();
|
|
3
|
+
defaultTTL;
|
|
4
|
+
constructor(defaultTTLMs = 60_000) {
|
|
5
|
+
this.defaultTTL = defaultTTLMs;
|
|
6
|
+
}
|
|
7
|
+
get(key) {
|
|
8
|
+
const entry = this.store.get(key);
|
|
9
|
+
if (!entry)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (Date.now() > entry.expiresAt) {
|
|
12
|
+
this.store.delete(key);
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return entry.value;
|
|
16
|
+
}
|
|
17
|
+
set(key, value, ttlMs) {
|
|
18
|
+
this.store.set(key, {
|
|
19
|
+
value,
|
|
20
|
+
expiresAt: Date.now() + (ttlMs ?? this.defaultTTL),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
invalidate(key) {
|
|
24
|
+
this.store.delete(key);
|
|
25
|
+
}
|
|
26
|
+
invalidateAll() {
|
|
27
|
+
this.store.clear();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExperimentEntry, ExperimentDetail } from '../types.js';
|
|
2
|
+
export declare class ExperimentsService {
|
|
3
|
+
private experimentsDir;
|
|
4
|
+
constructor(experimentsDir: string);
|
|
5
|
+
listExperiments(): ExperimentEntry[];
|
|
6
|
+
getExperiment(directory: string): ExperimentDetail | null;
|
|
7
|
+
readFile(filePath: string): string | null;
|
|
8
|
+
readFileRaw(filePath: string): Buffer | null;
|
|
9
|
+
private listFilesRecursive;
|
|
10
|
+
private parseTable;
|
|
11
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, relative } from 'node:path';
|
|
3
|
+
export class ExperimentsService {
|
|
4
|
+
experimentsDir;
|
|
5
|
+
constructor(experimentsDir) {
|
|
6
|
+
this.experimentsDir = resolve(experimentsDir);
|
|
7
|
+
}
|
|
8
|
+
listExperiments() {
|
|
9
|
+
const indexPath = join(this.experimentsDir, 'README.md');
|
|
10
|
+
if (!existsSync(indexPath))
|
|
11
|
+
return [];
|
|
12
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
13
|
+
return this.parseTable(content);
|
|
14
|
+
}
|
|
15
|
+
getExperiment(directory) {
|
|
16
|
+
const entries = this.listExperiments();
|
|
17
|
+
const entry = entries.find(e => e.directory === directory || e.directory.replace(/\/$/, '') === directory.replace(/\/$/, ''));
|
|
18
|
+
if (!entry)
|
|
19
|
+
return null;
|
|
20
|
+
const dirPath = join(this.experimentsDir, entry.directory.replace(/\/$/, ''));
|
|
21
|
+
if (!existsSync(dirPath))
|
|
22
|
+
return null;
|
|
23
|
+
let readme = null;
|
|
24
|
+
const readmePath = join(dirPath, 'README.md');
|
|
25
|
+
if (existsSync(readmePath)) {
|
|
26
|
+
readme = readFileSync(readmePath, 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
const subdirs = [];
|
|
29
|
+
const allFiles = [];
|
|
30
|
+
const expPrefix = entry.directory.replace(/\/$/, '');
|
|
31
|
+
const items = readdirSync(dirPath);
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const itemPath = join(dirPath, item);
|
|
34
|
+
const stat = statSync(itemPath);
|
|
35
|
+
if (stat.isDirectory()) {
|
|
36
|
+
const files = this.listFilesRecursive(itemPath, this.experimentsDir);
|
|
37
|
+
subdirs.push({ name: item, files });
|
|
38
|
+
allFiles.push(...files);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const file = {
|
|
42
|
+
path: relative(this.experimentsDir, itemPath),
|
|
43
|
+
name: item,
|
|
44
|
+
size: stat.size,
|
|
45
|
+
};
|
|
46
|
+
if (item !== 'README.md') {
|
|
47
|
+
allFiles.push(file);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { entry, readme, subdirs, allFiles };
|
|
52
|
+
}
|
|
53
|
+
readFile(filePath) {
|
|
54
|
+
const fullPath = join(this.experimentsDir, filePath);
|
|
55
|
+
if (!existsSync(fullPath))
|
|
56
|
+
return null;
|
|
57
|
+
return readFileSync(fullPath, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
readFileRaw(filePath) {
|
|
60
|
+
const fullPath = join(this.experimentsDir, filePath);
|
|
61
|
+
if (!existsSync(fullPath))
|
|
62
|
+
return null;
|
|
63
|
+
return readFileSync(fullPath);
|
|
64
|
+
}
|
|
65
|
+
listFilesRecursive(dirPath, rootDir) {
|
|
66
|
+
const files = [];
|
|
67
|
+
const items = readdirSync(dirPath);
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const itemPath = join(dirPath, item);
|
|
70
|
+
const stat = statSync(itemPath);
|
|
71
|
+
if (stat.isDirectory()) {
|
|
72
|
+
files.push(...this.listFilesRecursive(itemPath, rootDir));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
files.push({
|
|
76
|
+
path: relative(rootDir, itemPath),
|
|
77
|
+
name: item,
|
|
78
|
+
size: stat.size,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
parseTable(content) {
|
|
85
|
+
const entries = [];
|
|
86
|
+
const lines = content.split('\n');
|
|
87
|
+
let inTable = false;
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
if (trimmed.startsWith('|') && trimmed.includes('---')) {
|
|
91
|
+
inTable = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!trimmed.startsWith('|') || !inTable)
|
|
95
|
+
continue;
|
|
96
|
+
const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
|
|
97
|
+
if (cells.length < 5)
|
|
98
|
+
continue;
|
|
99
|
+
const date = cells[0].trim();
|
|
100
|
+
const dirCell = cells[1].replace(/^`|`$/g, '').replace(/\/$/, '').trim();
|
|
101
|
+
const desc = cells[2].replace(/^`|`$/g, '').trim();
|
|
102
|
+
const outcome = cells[3].replace(/\*\*/g, '').trim();
|
|
103
|
+
const agent = cells[4].replace(/^`|`$/g, '').trim();
|
|
104
|
+
entries.push({ date, directory: dirCell, description: desc, outcome, agent });
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
}
|