feat(observabilidade): rota /api/sessions com validação Zod
Task 5 do MVP Espelho: endpoint Express com factory createSessionsRouter(db) que expõe GET / (lista filtrável por days/project/tool/skill/q + limit/offset validados via Zod) e GET /:id (meta + eventos via parseSessionFile). Integrado em server.ts com DB aberta a partir de OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH. Validação empírica: total=559 sessões (últimos 7d), detalhe com 37 eventos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Rota /api/sessions — lista e detalhe de sessões Claude Code.
|
||||
* Validação Zod em query params; detalhe carrega eventos via parseSessionFile.
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { SessionsDb } from '../services/sessions/db.js'
|
||||
import { parseSessionFile } from '../services/sessions/parser.js'
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(3650).optional(),
|
||||
project: z.string().min(1).max(200).optional(),
|
||||
tool: z.string().min(1).max(100).optional(),
|
||||
skill: z.string().min(1).max(200).optional(),
|
||||
q: z.string().max(500).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
const IdParamSchema = z.object({ id: z.string().min(1).max(200) })
|
||||
|
||||
export function createSessionsRouter(db: SessionsDb): Router {
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const parsed = ListQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid query', details: parsed.error.format() })
|
||||
}
|
||||
const filters = parsed.data
|
||||
const items = db.listSessions(filters)
|
||||
const total = db.countSessions(filters)
|
||||
return res.json({ total, items })
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const parsed = IdParamSchema.safeParse(req.params)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' })
|
||||
}
|
||||
const session = db.getSession(parsed.data.id)
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
||||
|
||||
try {
|
||||
const { events } = await parseSessionFile(session.jsonl_path)
|
||||
return res.json({ meta: session, events })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'Failed to parse session', message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import n8nRouter from './routes/n8n.js'
|
||||
import paperclipRouter from './routes/paperclip.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import operationsRouter from './routes/operations.js'
|
||||
import { createSessionsRouter } from './routes/sessions.js'
|
||||
import { openSessionsDb } from './services/sessions/db.js'
|
||||
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||
import { collectMonitoringData } from './services/monitoring-collector.js'
|
||||
|
||||
@@ -133,6 +136,10 @@ app.use('/api/paperclip', paperclipRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/operations', operationsRouter)
|
||||
|
||||
// Observabilidade (Espelho) — sessões Claude Code
|
||||
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
||||
app.use('/api/sessions', createSessionsRouter(sessionsDb))
|
||||
|
||||
// Serve static files in production
|
||||
if (isProduction) {
|
||||
// __dirname is /app/api/dist, need to go up 2 levels to /app/dist
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Testes da rota /api/sessions (validação Zod + integração com SessionsDb).
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import express from 'express'
|
||||
import request from 'supertest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb } from '../services/sessions/db.js'
|
||||
import { createSessionsRouter } from '../routes/sessions.js'
|
||||
import type { SessionMeta } from '../types/session.js'
|
||||
|
||||
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's1',
|
||||
project_path: '/tmp/p',
|
||||
project_slug: 'p',
|
||||
jsonl_path: '/tmp/p/s1.jsonl',
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
duration_sec: 60,
|
||||
event_count: 10,
|
||||
user_messages: 2,
|
||||
assistant_msgs: 5,
|
||||
tool_calls: 3,
|
||||
first_prompt: 'teste',
|
||||
tools_used: ['Bash'],
|
||||
skills_invoked: [],
|
||||
outcome: 'completed',
|
||||
permission_mode: 'default',
|
||||
file_size: 1000,
|
||||
indexed_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
let app: express.Express
|
||||
beforeAll(() => {
|
||||
const dbPath = join(mkdtempSync(join(tmpdir(), 'obs-r-')), 'sessions.db')
|
||||
const db = openSessionsDb(dbPath)
|
||||
db.upsertSession(meta({ session_id: 's1', project_slug: 'alpha', jsonl_path: '/tmp/p/s1.jsonl' }))
|
||||
db.upsertSession(meta({ session_id: 's2', project_slug: 'beta', jsonl_path: '/tmp/p/s2.jsonl' }))
|
||||
app = express()
|
||||
app.use('/api/sessions', createSessionsRouter(db))
|
||||
})
|
||||
|
||||
it('lista todas as sessões por omissão', async () => {
|
||||
const res = await request(app).get('/api/sessions')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(2)
|
||||
expect(res.body.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filtra por projecto', async () => {
|
||||
const res = await request(app).get('/api/sessions').query({ project: 'alpha' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(1)
|
||||
expect(res.body.items[0].project_slug).toBe('alpha')
|
||||
})
|
||||
|
||||
it('rejeita limit inválido', async () => {
|
||||
const res = await request(app).get('/api/sessions').query({ limit: '9999' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user