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.
|
* 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.
|
* Validação Zod em query params; detalhe carrega eventos via parseSessionFile.
|
||||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||||
*/
|
*/
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { SessionsDb } from '../services/sessions/db.js'
|
import type { SessionsDb } from '../services/sessions/db.js'
|
||||||
import { parseSessionFile } from '../services/sessions/parser.js'
|
|
||||||
|
|
||||||
const ListQuerySchema = z.object({
|
const ListQuerySchema = z.object({
|
||||||
days: z.coerce.number().int().min(1).max(3650).optional(),
|
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 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 {
|
export function createSessionsRouter(db: SessionsDb): Router {
|
||||||
const router = 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) => {
|
router.get('/', (req, res) => {
|
||||||
const parsed = ListQuerySchema.safeParse(req.query)
|
const parsed = ListQuerySchema.safeParse(req.query)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -34,7 +88,7 @@ export function createSessionsRouter(db: SessionsDb): Router {
|
|||||||
return res.json({ total, items })
|
return res.json({ total, items })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
const parsed = IdParamSchema.safeParse(req.params)
|
const parsed = IdParamSchema.safeParse(req.params)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ error: 'Invalid id' })
|
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)
|
const session = db.getSession(parsed.data.id)
|
||||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
if (!session) return res.status(404).json({ error: 'Session not found' })
|
||||||
|
|
||||||
if (!session.jsonl_path) {
|
const events = db.getSessionEvents(parsed.data.id)
|
||||||
return res.status(422).json({ error: 'Session has no JSONL file (non-Claude source)' })
|
if (events.length === 0 && session.source === 'claude') {
|
||||||
}
|
return res.status(410).json({
|
||||||
try {
|
error: 'Session events not yet indexed',
|
||||||
const { events } = await parseSessionFile(session.jsonl_path)
|
session_id: parsed.data.id,
|
||||||
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 }),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return res.json({ meta: session, events })
|
||||||
})
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
+3
-1
@@ -21,7 +21,7 @@ import n8nRouter from './routes/n8n.js'
|
|||||||
import paperclipRouter from './routes/paperclip.js'
|
import paperclipRouter from './routes/paperclip.js'
|
||||||
import aiRouter from './routes/ai.js'
|
import aiRouter from './routes/ai.js'
|
||||||
import operationsRouter from './routes/operations.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 { openSessionsDb } from './services/sessions/db.js'
|
||||||
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
import { DEFAULT_DB_PATH } from './services/sessions/indexer.js'
|
||||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||||
@@ -139,6 +139,8 @@ app.use('/api/operations', operationsRouter)
|
|||||||
// Observabilidade (Espelho) — sessões Claude Code
|
// Observabilidade (Espelho) — sessões Claude Code
|
||||||
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH)
|
||||||
app.use('/api/sessions', createSessionsRouter(sessionsDb))
|
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 {
|
function closeSessionsDb(): void {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+213
-13
@@ -53,6 +53,30 @@ export interface WorklogFilters {
|
|||||||
offset?: number
|
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<string, number>
|
||||||
|
by_outcome: Record<string, number>
|
||||||
|
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 {
|
export interface SessionsDb {
|
||||||
upsertSession(meta: SessionMeta): void
|
upsertSession(meta: SessionMeta): void
|
||||||
upsertMany(metas: SessionMeta[]): void
|
upsertMany(metas: SessionMeta[]): void
|
||||||
@@ -60,9 +84,14 @@ export interface SessionsDb {
|
|||||||
countSessions(filters: ListFilters): number
|
countSessions(filters: ListFilters): number
|
||||||
getSession(id: string): SessionMeta | null
|
getSession(id: string): SessionMeta | null
|
||||||
deleteByJsonlPath(path: string): void
|
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
|
upsertPattern(p: PatternRecord): void
|
||||||
getPatternsByWeek(week: string): PatternRecord[]
|
getPatternsByWeek(week: string): PatternRecord[]
|
||||||
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number
|
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number
|
||||||
|
listPatterns(filters: PatternFilters): PatternRecord[]
|
||||||
|
countPatterns(filters: PatternFilters): number
|
||||||
|
getStats(days: number): SessionsStats
|
||||||
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
|
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
|
||||||
hasWorklogComment(id: number): boolean
|
hasWorklogComment(id: number): boolean
|
||||||
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[]
|
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_project ON sessions(project_slug, started_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
|
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 (
|
CREATE TABLE IF NOT EXISTS patterns (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
detected_at TEXT NOT NULL,
|
detected_at TEXT NOT NULL,
|
||||||
@@ -166,6 +209,47 @@ function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowToPattern(r: Record<string, unknown>): 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<string, unknown> } {
|
||||||
|
const parts: string[] = []
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
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<string, unknown> } {
|
function buildWhere(f: ListFilters): { sql: string; params: Record<string, unknown> } {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
const params: Record<string, unknown> = {}
|
const params: Record<string, unknown> = {}
|
||||||
@@ -330,21 +414,50 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
|||||||
consecutive_weeks: p.consecutive_weeks,
|
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<string, unknown>[]
|
||||||
|
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<string, unknown> : 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[] {
|
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<string, unknown>[]
|
const rows = db.prepare('SELECT * FROM patterns WHERE week_iso = ? ORDER BY severity DESC, affected_count DESC').all(week) as Record<string, unknown>[]
|
||||||
return rows.map((r) => ({
|
return rows.map(rowToPattern)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number {
|
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number {
|
||||||
// Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu
|
// Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu
|
||||||
@@ -362,6 +475,93 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
|||||||
}
|
}
|
||||||
return count
|
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<string, unknown>[]
|
||||||
|
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<string, number> = {}
|
||||||
|
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<string, number> = {}
|
||||||
|
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 } {
|
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } {
|
||||||
const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id)
|
const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id)
|
||||||
const inserted = !existing
|
const inserted = !existing
|
||||||
|
|||||||
@@ -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).
|
* Indexa um único ficheiro (parse + upsert). Uso individual — útil para o watcher (Task 8).
|
||||||
*/
|
*/
|
||||||
export async function indexFile(db: SessionsDb, path: string): Promise<void> {
|
export async function indexFile(db: SessionsDb, path: string): Promise<void> {
|
||||||
const { meta } = await parseSessionFile(path)
|
const { meta, events } = await parseSessionFile(path)
|
||||||
db.upsertSession(meta)
|
db.upsertSession(meta)
|
||||||
|
db.storeSessionEvents(meta.session_id, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndexAllOptions {
|
export interface IndexAllOptions {
|
||||||
@@ -78,7 +79,7 @@ export async function indexAll(
|
|||||||
try {
|
try {
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
try {
|
try {
|
||||||
const { meta } = await parseSessionFile(files[i])
|
const { meta, events } = await parseSessionFile(files[i])
|
||||||
meta.source = 'claude'
|
meta.source = 'claude'
|
||||||
meta.model = meta.model ?? null
|
meta.model = meta.model ?? null
|
||||||
meta.ended_at = meta.ended_at ?? 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_path = meta.project_path ?? null
|
||||||
meta.project_slug = meta.project_slug ?? null
|
meta.project_slug = meta.project_slug ?? null
|
||||||
meta.jsonl_path = meta.jsonl_path ?? null
|
meta.jsonl_path = meta.jsonl_path ?? null
|
||||||
|
// Store events per-session (delete+insert)
|
||||||
|
db.storeSessionEvents(meta.session_id, events)
|
||||||
batch.push(meta)
|
batch.push(meta)
|
||||||
if (batch.length >= BATCH) {
|
if (batch.length >= BATCH) {
|
||||||
db.upsertMany(batch)
|
db.upsertMany(batch)
|
||||||
|
|||||||
@@ -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> = {}): 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> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user