diff --git a/api/routes/sessions.ts b/api/routes/sessions.ts index de2da72..2c24749 100644 --- a/api/routes/sessions.ts +++ b/api/routes/sessions.ts @@ -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 diff --git a/api/server.ts b/api/server.ts index c208986..be8d755 100644 --- a/api/server.ts +++ b/api/server.ts @@ -21,7 +21,7 @@ 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 { createSessionsRouter, createSessionsAnalysisRouter } 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' @@ -139,6 +139,8 @@ 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)) +// Alias curto do contrato C4 (F4.1): /api/patterns e /api/stats +app.use('/api', createSessionsAnalysisRouter(sessionsDb)) function closeSessionsDb(): void { try { diff --git a/api/services/sessions/db.ts b/api/services/sessions/db.ts index be84dae..1c234f7 100644 --- a/api/services/sessions/db.ts +++ b/api/services/sessions/db.ts @@ -53,6 +53,30 @@ export interface WorklogFilters { offset?: number } +export interface PatternFilters { + week?: string + severity?: 'info' | 'warning' | 'action' + pattern_key?: string + sinceIso?: string + limit?: number + offset?: number +} + +export interface SessionsStats { + days: number + sessions: number + by_day: { day: string; sessions: number }[] + by_source: Record + by_outcome: Record + tool_top: { tool: string; count: number }[] + skill_top: { skill: string; count: number }[] + patterns_by_type: { pattern_type: string; count: number }[] + gate_blocks: number | null + avg_duration_sec: number | null + last_indexed_at: string | null + latest_pattern_week: string | null +} + export interface SessionsDb { upsertSession(meta: SessionMeta): void upsertMany(metas: SessionMeta[]): void @@ -60,9 +84,14 @@ export interface SessionsDb { countSessions(filters: ListFilters): number getSession(id: string): SessionMeta | null deleteByJsonlPath(path: string): void + storeSessionEvents(sessionId: string, events: import('../../types/session.js').SessionEvent[]): void + getSessionEvents(sessionId: string): import('../../types/session.js').SessionEvent[] upsertPattern(p: PatternRecord): void getPatternsByWeek(week: string): PatternRecord[] getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number + listPatterns(filters: PatternFilters): PatternRecord[] + countPatterns(filters: PatternFilters): number + getStats(days: number): SessionsStats upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } hasWorklogComment(id: number): boolean listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[] @@ -101,6 +130,20 @@ CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC); CREATE INDEX IF NOT EXISTS idx_source ON sessions(source); +CREATE TABLE IF NOT EXISTS session_events ( + session_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + type TEXT NOT NULL, + timestamp TEXT, + text TEXT, + tool_name TEXT, + tool_input TEXT, + tool_result TEXT, + skill_invoked TEXT, + hook_name TEXT, + PRIMARY KEY (session_id, event_index) +) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS patterns ( id INTEGER PRIMARY KEY AUTOINCREMENT, detected_at TEXT NOT NULL, @@ -166,6 +209,47 @@ function rowToMeta(row: Record): SessionMeta { } } +function rowToPattern(r: Record): PatternRecord { + return { + id: r.id as number, + detected_at: r.detected_at as string, + week_iso: r.week_iso as string, + pattern_key: r.pattern_key as string, + title: r.title as string, + description: r.description as string, + severity: r.severity as PatternRecord['severity'], + metric_value: (r.metric_value as number | null) ?? null, + sample_session_ids: JSON.parse(r.sample_session_ids as string), + affected_count: r.affected_count as number, + consecutive_weeks: r.consecutive_weeks as number, + } +} + +function buildPatternsWhere(f: PatternFilters): { sql: string; params: Record } { + const parts: string[] = [] + const params: Record = {} + if (f.week) { + parts.push('week_iso = @week') + params.week = f.week + } + if (f.severity) { + parts.push('severity = @severity') + params.severity = f.severity + } + if (f.pattern_key) { + parts.push('pattern_key = @pattern_key') + params.pattern_key = f.pattern_key + } + if (f.sinceIso) { + parts.push('detected_at >= @since') + params.since = f.sinceIso + } + return { + sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '', + params, + } +} + function buildWhere(f: ListFilters): { sql: string; params: Record } { const parts: string[] = [] const params: Record = {} @@ -330,21 +414,50 @@ export function openSessionsDb(dbPath: string): SessionsDb { consecutive_weeks: p.consecutive_weeks, }) }, + storeSessionEvents(sessionId, events) { + const del = db.prepare('DELETE FROM session_events WHERE session_id = ?') + const ins = db.prepare(` + INSERT INTO session_events (session_id, event_index, type, timestamp, text, + tool_name, tool_input, tool_result, skill_invoked, hook_name) + VALUES (@session_id, @index, @type, @timestamp, @text, + @tool_name, @tool_input, @tool_result, @skill_invoked, @hook_name) + `) + db.transaction(() => { + del.run(sessionId) + for (const e of events) { + ins.run({ + session_id: sessionId, + index: e.index, + type: e.type, + timestamp: e.timestamp ?? null, + text: e.text, + tool_name: e.tool_name, + tool_input: e.tool_input ? JSON.stringify(e.tool_input) : null, + tool_result: typeof e.tool_result === 'string' ? e.tool_result.slice(0, 50000) : e.tool_result != null ? JSON.stringify(e.tool_result).slice(0, 50000) : null, + skill_invoked: e.skill_invoked, + hook_name: e.hook_name, + }) + } + })() + }, + getSessionEvents(sessionId) { + const rows = db.prepare('SELECT * FROM session_events WHERE session_id = ? ORDER BY event_index').all(sessionId) as Record[] + return rows.map((r) => ({ + index: r.event_index as number, + type: r.type as import('../../types/session.js').SessionEventType, + timestamp: (r.timestamp as string | null) ?? null, + raw: {}, // not stored + text: (r.text as string | null) ?? null, + tool_name: (r.tool_name as string | null) ?? null, + tool_input: r.tool_input ? JSON.parse(r.tool_input as string) as Record : null, + tool_result: r.tool_result ?? null, + skill_invoked: (r.skill_invoked as string | null) ?? null, + hook_name: (r.hook_name as string | null) ?? null, + })) + }, getPatternsByWeek(week: string): PatternRecord[] { const rows = db.prepare('SELECT * FROM patterns WHERE week_iso = ? ORDER BY severity DESC, affected_count DESC').all(week) as Record[] - return rows.map((r) => ({ - id: r.id as number, - detected_at: r.detected_at as string, - week_iso: r.week_iso as string, - pattern_key: r.pattern_key as string, - title: r.title as string, - description: r.description as string, - severity: r.severity as PatternRecord['severity'], - metric_value: (r.metric_value as number | null) ?? null, - sample_session_ids: JSON.parse(r.sample_session_ids as string), - affected_count: r.affected_count as number, - consecutive_weeks: r.consecutive_weeks as number, - })) + return rows.map(rowToPattern) }, getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number { // Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu @@ -362,6 +475,93 @@ export function openSessionsDb(dbPath: string): SessionsDb { } return count }, + listPatterns(filters: PatternFilters): PatternRecord[] { + const { sql, params } = buildPatternsWhere(filters) + const limit = filters.limit ?? 50 + const offset = filters.offset ?? 0 + const rows = db + .prepare(`SELECT * FROM patterns ${sql} ORDER BY week_iso DESC, severity DESC, affected_count DESC LIMIT @limit OFFSET @offset`) + .all({ ...params, limit, offset }) as Record[] + return rows.map(rowToPattern) + }, + countPatterns(filters: PatternFilters): number { + const { sql, params } = buildPatternsWhere(filters) + const row = db.prepare(`SELECT COUNT(*) as c FROM patterns ${sql}`).get(params) as { c: number } + return row.c + }, + getStats(days: number): SessionsStats { + const cutoff = new Date(Date.now() - days * 86400_000).toISOString() + const totals = db.prepare(` + SELECT COUNT(*) as sessions, AVG(duration_sec) as avg_duration + FROM sessions WHERE started_at >= ? + `).get(cutoff) as { sessions: number; avg_duration: number | null } + + const byDay = db.prepare(` + SELECT substr(started_at, 1, 10) as day, COUNT(*) as sessions + FROM sessions WHERE started_at >= ? + GROUP BY day ORDER BY day + `).all(cutoff) as { day: string; sessions: number }[] + + const bySource: Record = {} + for (const r of db.prepare('SELECT source, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY source').all(cutoff) as { source: string; c: number }[]) { + bySource[r.source] = r.c + } + + const byOutcome: Record = {} + for (const r of db.prepare('SELECT outcome, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY outcome').all(cutoff) as { outcome: string; c: number }[]) { + byOutcome[r.outcome] = r.c + } + + const toolTop = db.prepare(` + SELECT je.value as tool, COUNT(*) as count + FROM sessions s, json_each(s.tools_used) je + WHERE s.started_at >= ? + GROUP BY je.value ORDER BY count DESC LIMIT 10 + `).all(cutoff) as { tool: string; count: number }[] + + const skillTop = db.prepare(` + SELECT je.value as skill, COUNT(*) as count + FROM sessions s, json_each(s.skills_invoked) je + WHERE s.started_at >= ? + GROUP BY je.value ORDER BY count DESC LIMIT 10 + `).all(cutoff) as { skill: string; count: number }[] + + // Tipo do padrão = prefixo de pattern_key antes de ':' (ex.: 'skill_error_rate:X' → 'skill_error_rate') + const patternsByType = db.prepare(` + SELECT CASE WHEN instr(pattern_key, ':') > 0 + THEN substr(pattern_key, 1, instr(pattern_key, ':') - 1) + ELSE pattern_key END as pattern_type, + COUNT(*) as count + FROM patterns WHERE detected_at >= ? + GROUP BY pattern_type ORDER BY count DESC + `).all(cutoff) as { pattern_type: string; count: number }[] + + // gate_events só existe quando F1 (arco C3-gates) entregar — null até lá + let gateBlocks: number | null = null + const hasGateEvents = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='gate_events'").get() + if (hasGateEvents) { + const r = db.prepare("SELECT COUNT(*) as c FROM gate_events WHERE decision = 'block' AND ts >= ?").get(cutoff) as { c: number } + gateBlocks = r.c + } + + const lastIndexed = db.prepare('SELECT MAX(indexed_at) as m FROM sessions').get() as { m: string | null } + const latestWeek = db.prepare('SELECT MAX(week_iso) as m FROM patterns').get() as { m: string | null } + + return { + days, + sessions: totals.sessions, + by_day: byDay, + by_source: bySource, + by_outcome: byOutcome, + tool_top: toolTop, + skill_top: skillTop, + patterns_by_type: patternsByType, + gate_blocks: gateBlocks, + avg_duration_sec: totals.avg_duration != null ? Math.round(totals.avg_duration) : null, + last_indexed_at: lastIndexed.m, + latest_pattern_week: latestWeek.m, + } + }, upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } { const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id) const inserted = !existing diff --git a/api/services/sessions/indexer.ts b/api/services/sessions/indexer.ts index fa3c4ce..c7284ff 100644 --- a/api/services/sessions/indexer.ts +++ b/api/services/sessions/indexer.ts @@ -48,8 +48,9 @@ export function findAllJsonl(root: string = PROJECTS_ROOT): string[] { * Indexa um único ficheiro (parse + upsert). Uso individual — útil para o watcher (Task 8). */ export async function indexFile(db: SessionsDb, path: string): Promise { - const { meta } = await parseSessionFile(path) + const { meta, events } = await parseSessionFile(path) db.upsertSession(meta) + db.storeSessionEvents(meta.session_id, events) } export interface IndexAllOptions { @@ -78,7 +79,7 @@ export async function indexAll( try { for (let i = 0; i < files.length; i++) { try { - const { meta } = await parseSessionFile(files[i]) + const { meta, events } = await parseSessionFile(files[i]) meta.source = 'claude' meta.model = meta.model ?? null meta.ended_at = meta.ended_at ?? null @@ -91,6 +92,8 @@ export async function indexAll( meta.project_path = meta.project_path ?? null meta.project_slug = meta.project_slug ?? null meta.jsonl_path = meta.jsonl_path ?? null + // Store events per-session (delete+insert) + db.storeSessionEvents(meta.session_id, events) batch.push(meta) if (batch.length >= BATCH) { db.upsertMany(batch) diff --git a/api/tests/sessions-analysis-route.test.ts b/api/tests/sessions-analysis-route.test.ts new file mode 100644 index 0000000..f4978f2 --- /dev/null +++ b/api/tests/sessions-analysis-route.test.ts @@ -0,0 +1,228 @@ +/** + * Testes das rotas /patterns e /stats (contrato C4-análise, Pipeline QT F4.1). + * Cobre: filtros da lista de padrões, agregados de stats, validação Zod, + * gate_blocks condicional (tabela gate_events pode não existir) e a + * não-colisão de /patterns com o param route /:id do router de sessões. + * @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, type SessionsDb, type PatternRecord } from '../services/sessions/db.js' +import { createSessionsRouter, createSessionsAnalysisRouter } from '../routes/sessions.js' +import type { SessionMeta } from '../types/session.js' + +function meta(overrides: Partial = {}): SessionMeta { + return { + session_id: 's-' + Math.random().toString(36).slice(2, 10), + source: 'claude', + project_path: '/tmp/p', + project_slug: 'p', + jsonl_path: '/tmp/p/' + Math.random().toString(36).slice(2) + '.jsonl', + model: null, + started_at: new Date().toISOString(), + ended_at: null, + duration_sec: 60, + event_count: 10, + user_messages: 2, + assistant_msgs: 5, + tool_calls: 3, + input_tokens: null, + output_tokens: null, + estimated_cost: null, + first_prompt: 'teste', + tools_used: ['Bash'], + skills_invoked: [], + outcome: 'completed', + permission_mode: 'default', + file_size: 1000, + indexed_at: new Date().toISOString(), + ...overrides, + } +} + +function pattern(overrides: Partial = {}): PatternRecord { + return { + detected_at: new Date().toISOString(), + week_iso: '2026-W27', + pattern_key: 'abandoned_sessions', + title: 'Sessões abandonadas', + description: 'd', + severity: 'warning', + metric_value: 6, + sample_session_ids: ['a', 'b'], + affected_count: 6, + consecutive_weeks: 1, + ...overrides, + } +} + +describe('rotas de análise C4 (/patterns, /stats)', () => { + let app: express.Express + let db: SessionsDb + + beforeAll(() => { + const dbPath = join(mkdtempSync(join(tmpdir(), 'obs-c4-')), 'sessions.db') + db = openSessionsDb(dbPath) + + // Sessões: 2 claude completed hoje, 1 opencode error há 3 dias, 1 antiga (400 dias) + const now = Date.now() + db.upsertSession(meta({ session_id: 's1', tools_used: ['Bash', 'Read'], skills_invoked: ['wp-dev'] })) + db.upsertSession(meta({ session_id: 's2', tools_used: ['Bash'] })) + db.upsertSession(meta({ + session_id: 's3', + source: 'opencode', + outcome: 'error', + started_at: new Date(now - 3 * 86400_000).toISOString(), + })) + db.upsertSession(meta({ + session_id: 's-antiga', + started_at: new Date(now - 400 * 86400_000).toISOString(), + })) + + // Padrões: 2 semanas, 2 tipos, severities distintas + db.upsertPattern(pattern({ week_iso: '2026-W26', pattern_key: 'skill_error_rate:wp-dev', severity: 'action' })) + db.upsertPattern(pattern({ week_iso: '2026-W27', pattern_key: 'skill_error_rate:wp-dev', severity: 'action', consecutive_weeks: 2 })) + db.upsertPattern(pattern({ week_iso: '2026-W27', pattern_key: 'abandoned_sessions', severity: 'warning' })) + + app = express() + app.use('/api/sessions', createSessionsRouter(db)) + app.use('/api', createSessionsAnalysisRouter(db)) + }) + + describe('GET /api/patterns', () => { + it('lista todos os padrões por omissão, mais recentes primeiro', async () => { + const res = await request(app).get('/api/patterns') + expect(res.status).toBe(200) + expect(res.body.total).toBe(3) + expect(res.body.items).toHaveLength(3) + expect(res.body.items[0].week_iso).toBe('2026-W27') + }) + + it('filtra por semana ISO', async () => { + const res = await request(app).get('/api/patterns').query({ week: '2026-W27' }) + expect(res.status).toBe(200) + expect(res.body.total).toBe(2) + expect(res.body.week).toBe('2026-W27') + expect(res.body.items.every((p: PatternRecord) => p.week_iso === '2026-W27')).toBe(true) + }) + + it('filtra por severity e por key', async () => { + const bySev = await request(app).get('/api/patterns').query({ severity: 'warning' }) + expect(bySev.body.total).toBe(1) + expect(bySev.body.items[0].pattern_key).toBe('abandoned_sessions') + + const byKey = await request(app).get('/api/patterns').query({ key: 'skill_error_rate:wp-dev' }) + expect(byKey.body.total).toBe(2) + }) + + it('pagina com limit/offset', async () => { + const res = await request(app).get('/api/patterns').query({ limit: 2, offset: 2 }) + expect(res.status).toBe(200) + expect(res.body.total).toBe(3) + expect(res.body.items).toHaveLength(1) + }) + + it('devolve sample_session_ids e consecutive_weeks desserializados', async () => { + const res = await request(app).get('/api/patterns').query({ week: '2026-W27', key: 'skill_error_rate:wp-dev' }) + expect(res.body.items[0].sample_session_ids).toEqual(['a', 'b']) + expect(res.body.items[0].consecutive_weeks).toBe(2) + }) + + it('rejeita week com formato inválido', async () => { + const res = await request(app).get('/api/patterns').query({ week: '2026-27' }) + expect(res.status).toBe(400) + }) + + it('responde também em /api/sessions/patterns sem colidir com /:id', async () => { + const res = await request(app).get('/api/sessions/patterns') + expect(res.status).toBe(200) + expect(res.body.total).toBe(3) + }) + }) + + describe('GET /api/stats', () => { + it('agrega sessões dos últimos 30 dias por omissão', async () => { + const res = await request(app).get('/api/stats') + expect(res.status).toBe(200) + expect(res.body.days).toBe(30) + expect(res.body.sessions).toBe(3) // s-antiga (400 dias) fica fora + expect(res.body.by_source).toEqual({ claude: 2, opencode: 1 }) + expect(res.body.by_outcome).toEqual({ completed: 2, error: 1 }) + }) + + it('devolve by_day ordenado com contagens por dia', async () => { + const res = await request(app).get('/api/stats') + expect(res.body.by_day.length).toBeGreaterThanOrEqual(1) + const total = res.body.by_day.reduce((s: number, d: { sessions: number }) => s + d.sessions, 0) + expect(total).toBe(3) + for (const d of res.body.by_day) { + expect(d.day).toMatch(/^\d{4}-\d{2}-\d{2}$/) + } + }) + + it('devolve tool_top e skill_top a partir dos arrays JSON', async () => { + const res = await request(app).get('/api/stats') + const bash = res.body.tool_top.find((t: { tool: string }) => t.tool === 'Bash') + expect(bash).toBeDefined() + expect(bash.count).toBe(3) + expect(res.body.skill_top[0]).toEqual({ skill: 'wp-dev', count: 1 }) + }) + + it('agrupa padrões por tipo (prefixo antes de ":")', async () => { + const res = await request(app).get('/api/stats') + const types = Object.fromEntries( + res.body.patterns_by_type.map((p: { pattern_type: string; count: number }) => [p.pattern_type, p.count]), + ) + expect(types.skill_error_rate).toBe(2) + expect(types.abandoned_sessions).toBe(1) + }) + + it('devolve última indexação e última semana de padrões', async () => { + const res = await request(app).get('/api/stats') + expect(res.body.last_indexed_at).toBeTruthy() + expect(res.body.latest_pattern_week).toBe('2026-W27') + expect(res.body.avg_duration_sec).toBe(60) + }) + + it('gate_blocks é null sem tabela gate_events e conta blocks quando existe', async () => { + const before = await request(app).get('/api/stats') + expect(before.body.gate_blocks).toBeNull() + + db.rawDb().exec(` + CREATE TABLE gate_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, hook TEXT NOT NULL, gate TEXT, + session_id TEXT, project_slug TEXT, + decision TEXT NOT NULL, reason TEXT, target TEXT, raw TEXT + ); + `) + db.rawDb().prepare("INSERT INTO gate_events (ts, hook, decision) VALUES (?, 'stale-identifier-gate', 'block')") + .run(new Date().toISOString()) + db.rawDb().prepare("INSERT INTO gate_events (ts, hook, decision) VALUES (?, 'socrates-truth-gate', 'warn')") + .run(new Date().toISOString()) + + const after = await request(app).get('/api/stats') + expect(after.body.gate_blocks).toBe(1) + }) + + it('aceita days custom e rejeita days inválido', async () => { + const ok = await request(app).get('/api/stats').query({ days: 2 }) + expect(ok.status).toBe(200) + expect(ok.body.days).toBe(2) + expect(ok.body.sessions).toBe(2) // s3 (3 dias) fica fora + + const bad = await request(app).get('/api/stats').query({ days: 'abc' }) + expect(bad.status).toBe(400) + }) + + it('responde também em /api/sessions/stats', async () => { + const res = await request(app).get('/api/sessions/stats') + expect(res.status).toBe(200) + expect(res.body.sessions).toBe(3) + }) + }) +})