feat(pipeline): F4.1 — rotas GET /patterns e GET /stats (contrato C4-análise) #2415
- routes/sessions.ts: createSessionsAnalysisRouter com GET /patterns (filtros week/severity/key/days, paginado, Zod) e GET /stats (agregados: sessões totais e por dia, by_source, by_outcome, tool_top, skill_top, patterns_by_type, gate_blocks, avg_duration_sec, last_indexed_at, latest_pattern_week); montado em /api/sessions (C4) e /api (alias F4.1) - services/sessions/db.ts: listPatterns/countPatterns/getStats + rowToPattern/buildPatternsWhere; gate_blocks devolve null enquanto a tabela gate_events não existir (F1 arco C3-gates) - tests/sessions-analysis-route.test.ts: 15 testes (filtros, paginação, validação, gate_blocks condicional, não-colisão com /:id) - inclui camada session_events pendente de v2.7.3 (db.ts/indexer.ts — storeSessionEvents no indexAll; rota /:id lê da BD) já em execução, para a árvore commitada compilar coerente Validado contra BD real (11459 sessões, 160 padrões): curl 200 com JSON não-vazio em /api/patterns, /api/stats, /api/sessions/patterns?week=2026-W26, /api/sessions/stats?days=7.
This commit is contained in:
+62
-20
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Rota /api/sessions — lista e detalhe de sessões Claude Code.
|
||||
* Inclui /patterns e /stats (contrato C4-análise, Pipeline QT F4.1) — o 05
|
||||
* (stack-evaluator) consome só via API, nunca o SQLite directo.
|
||||
* 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(),
|
||||
@@ -20,9 +21,62 @@ const ListQuerySchema = z.object({
|
||||
|
||||
const IdParamSchema = z.object({ id: z.string().min(1).max(200) })
|
||||
|
||||
const PatternsQuerySchema = z.object({
|
||||
week: z.string().regex(/^\d{4}-W\d{2}$/, 'Formato esperado: YYYY-Www').optional(),
|
||||
severity: z.enum(['info', 'warning', 'action']).optional(),
|
||||
key: z.string().min(1).max(200).optional(),
|
||||
days: z.coerce.number().int().min(1).max(3650).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
const StatsQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||
})
|
||||
|
||||
/**
|
||||
* Router de análise (contrato C4): GET /patterns e GET /stats.
|
||||
* Montado em /api/sessions (contrato C4) e em /api (alias curto F4.1).
|
||||
*/
|
||||
export function createSessionsAnalysisRouter(db: SessionsDb): Router {
|
||||
const router = Router()
|
||||
|
||||
router.get('/patterns', (req, res) => {
|
||||
const parsed = PatternsQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid query', details: parsed.error.format() })
|
||||
}
|
||||
const { week, severity, key, days, limit, offset } = parsed.data
|
||||
const filters = {
|
||||
week,
|
||||
severity,
|
||||
pattern_key: key,
|
||||
sinceIso: days ? new Date(Date.now() - days * 86400_000).toISOString() : undefined,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
const items = db.listPatterns(filters)
|
||||
const total = db.countPatterns(filters)
|
||||
return res.json({ total, week: week ?? null, items })
|
||||
})
|
||||
|
||||
router.get('/stats', (req, res) => {
|
||||
const parsed = StatsQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid query', details: parsed.error.format() })
|
||||
}
|
||||
return res.json(db.getStats(parsed.data.days))
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
export function createSessionsRouter(db: SessionsDb): Router {
|
||||
const router = Router()
|
||||
|
||||
// C4-análise: registado ANTES de /:id para não ser apanhado pelo param route
|
||||
router.use(createSessionsAnalysisRouter(db))
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const parsed = ListQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
@@ -34,7 +88,7 @@ export function createSessionsRouter(db: SessionsDb): Router {
|
||||
return res.json({ total, items })
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
router.get('/:id', (req, res) => {
|
||||
const parsed = IdParamSchema.safeParse(req.params)
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' })
|
||||
@@ -42,26 +96,14 @@ export function createSessionsRouter(db: SessionsDb): Router {
|
||||
const session = db.getSession(parsed.data.id)
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
||||
|
||||
if (!session.jsonl_path) {
|
||||
return res.status(422).json({ error: 'Session has no JSONL file (non-Claude source)' })
|
||||
}
|
||||
try {
|
||||
const { events } = await parseSessionFile(session.jsonl_path)
|
||||
return res.json({ meta: session, events })
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException
|
||||
if (e.code === 'ENOENT') {
|
||||
return res.status(410).json({
|
||||
error: 'Session file missing (stale index)',
|
||||
session_id: parsed.data.id,
|
||||
})
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
return res.status(500).json({
|
||||
error: 'Failed to parse session',
|
||||
...(isProduction ? {} : { message: e.message }),
|
||||
const events = db.getSessionEvents(parsed.data.id)
|
||||
if (events.length === 0 && session.source === 'claude') {
|
||||
return res.status(410).json({
|
||||
error: 'Session events not yet indexed',
|
||||
session_id: parsed.data.id,
|
||||
})
|
||||
}
|
||||
return res.json({ meta: session, events })
|
||||
})
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user