11f9833aac
- Schema worklog_comments (id, discussion, parent, datas, staff, campos parseados em JSON) - Parser HTML tolerante (h2/h3/h4) extrai title, task_ref, duration, work_items, files_modified, problems, patterns_text, actions - Módulo worklog-import com paginação MCP get_discussion_comments - Helper mcp-client.ts partilhado (gateway MCP JSON-RPC + SSE) - Dep runtime: node-html-parser Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import Database from 'better-sqlite3'
|
|
import { mkdirSync } from 'fs'
|
|
import { dirname } from 'path'
|
|
import type { SessionMeta } from '../../types/session.js'
|
|
|
|
export interface ListFilters {
|
|
days?: number
|
|
project?: string
|
|
tool?: string
|
|
skill?: string
|
|
q?: string
|
|
limit?: number
|
|
offset?: number
|
|
}
|
|
|
|
export interface PatternRecord {
|
|
id?: number
|
|
detected_at: string
|
|
week_iso: string
|
|
pattern_key: string
|
|
title: string
|
|
description: string
|
|
severity: 'info' | 'warning' | 'action'
|
|
metric_value: number | null
|
|
sample_session_ids: string[]
|
|
affected_count: number
|
|
consecutive_weeks: number
|
|
}
|
|
|
|
export interface WorklogCommentRecord {
|
|
id: number
|
|
discussion_id: number
|
|
created_at: string
|
|
staff_id: number | null
|
|
title: string | null
|
|
task_ref: string | null
|
|
duration_sec: number | null
|
|
work_items: string[]
|
|
files_modified: string[]
|
|
problems: { problema: string; solucao: string }[]
|
|
patterns_text: string[]
|
|
actions: { tipo: string; descricao: string; prioridade: string | null }[]
|
|
raw_html: string
|
|
imported_at: string
|
|
}
|
|
|
|
export interface WorklogFilters {
|
|
discussion_id?: number
|
|
task_ref?: string
|
|
sinceIso?: string
|
|
limit?: number
|
|
offset?: number
|
|
}
|
|
|
|
export interface SessionsDb {
|
|
upsertSession(meta: SessionMeta): void
|
|
upsertMany(metas: SessionMeta[]): void
|
|
listSessions(filters: ListFilters): SessionMeta[]
|
|
countSessions(filters: ListFilters): number
|
|
getSession(id: string): SessionMeta | null
|
|
deleteByJsonlPath(path: string): void
|
|
upsertPattern(p: PatternRecord): void
|
|
getPatternsByWeek(week: string): PatternRecord[]
|
|
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number
|
|
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
|
|
hasWorklogComment(id: number): boolean
|
|
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[]
|
|
countWorklogComments(filters?: WorklogFilters): number
|
|
rawDb(): Database.Database
|
|
close(): void
|
|
}
|
|
|
|
const SCHEMA = `
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
project_path TEXT NOT NULL,
|
|
project_slug TEXT NOT NULL,
|
|
jsonl_path TEXT NOT NULL UNIQUE,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
duration_sec INTEGER,
|
|
event_count INTEGER NOT NULL,
|
|
user_messages INTEGER NOT NULL,
|
|
assistant_msgs INTEGER NOT NULL,
|
|
tool_calls INTEGER NOT NULL,
|
|
first_prompt TEXT,
|
|
tools_used TEXT NOT NULL,
|
|
skills_invoked TEXT NOT NULL,
|
|
outcome TEXT NOT NULL,
|
|
permission_mode TEXT,
|
|
file_size INTEGER NOT NULL,
|
|
indexed_at TEXT NOT NULL
|
|
);
|
|
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 TABLE IF NOT EXISTS patterns (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
detected_at TEXT NOT NULL,
|
|
week_iso TEXT NOT NULL,
|
|
pattern_key TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
severity TEXT NOT NULL,
|
|
metric_value REAL,
|
|
sample_session_ids TEXT NOT NULL,
|
|
affected_count INTEGER NOT NULL,
|
|
consecutive_weeks INTEGER NOT NULL DEFAULT 1,
|
|
UNIQUE(week_iso, pattern_key)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_patterns_week ON patterns(week_iso);
|
|
CREATE INDEX IF NOT EXISTS idx_patterns_key ON patterns(pattern_key);
|
|
|
|
CREATE TABLE IF NOT EXISTS worklog_comments (
|
|
id INTEGER PRIMARY KEY,
|
|
discussion_id INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
staff_id INTEGER,
|
|
title TEXT,
|
|
task_ref TEXT,
|
|
duration_sec INTEGER,
|
|
work_items TEXT NOT NULL,
|
|
files_modified TEXT NOT NULL,
|
|
problems_json TEXT NOT NULL,
|
|
patterns_text TEXT NOT NULL,
|
|
actions_json TEXT NOT NULL,
|
|
raw_html TEXT NOT NULL,
|
|
imported_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_wc_discussion ON worklog_comments(discussion_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref);
|
|
`
|
|
|
|
function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
|
return {
|
|
session_id: row.session_id as string,
|
|
project_path: row.project_path as string,
|
|
project_slug: row.project_slug as string,
|
|
jsonl_path: row.jsonl_path as string,
|
|
started_at: row.started_at as string,
|
|
ended_at: (row.ended_at as string | null) ?? null,
|
|
duration_sec: (row.duration_sec as number | null) ?? null,
|
|
event_count: row.event_count as number,
|
|
user_messages: row.user_messages as number,
|
|
assistant_msgs: row.assistant_msgs as number,
|
|
tool_calls: row.tool_calls as number,
|
|
first_prompt: (row.first_prompt as string | null) ?? null,
|
|
tools_used: JSON.parse(row.tools_used as string),
|
|
skills_invoked: JSON.parse(row.skills_invoked as string),
|
|
outcome: row.outcome as SessionMeta['outcome'],
|
|
permission_mode: (row.permission_mode as string | null) ?? null,
|
|
file_size: row.file_size as number,
|
|
indexed_at: row.indexed_at as string,
|
|
}
|
|
}
|
|
|
|
function buildWhere(f: ListFilters): { sql: string; params: Record<string, unknown> } {
|
|
const parts: string[] = []
|
|
const params: Record<string, unknown> = {}
|
|
if (f.days) {
|
|
const cutoff = new Date(Date.now() - f.days * 86400_000).toISOString()
|
|
parts.push('started_at >= @cutoff')
|
|
params.cutoff = cutoff
|
|
}
|
|
if (f.project) {
|
|
parts.push('project_slug = @project')
|
|
params.project = f.project
|
|
}
|
|
if (f.tool) {
|
|
parts.push("tools_used LIKE @toolLike")
|
|
params.toolLike = `%"${f.tool}"%`
|
|
}
|
|
if (f.skill) {
|
|
parts.push('skills_invoked LIKE @skillLike')
|
|
params.skillLike = `%"${f.skill}"%`
|
|
}
|
|
if (f.q) {
|
|
parts.push('first_prompt LIKE @q')
|
|
params.q = `%${f.q}%`
|
|
}
|
|
return {
|
|
sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '',
|
|
params,
|
|
}
|
|
}
|
|
|
|
export function openSessionsDb(dbPath: string): SessionsDb {
|
|
mkdirSync(dirname(dbPath), { recursive: true })
|
|
const db = new Database(dbPath)
|
|
db.pragma('journal_mode = WAL')
|
|
db.pragma('synchronous = NORMAL')
|
|
db.exec(SCHEMA)
|
|
|
|
const upsertStmt = db.prepare(`
|
|
INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at,
|
|
duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt,
|
|
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
|
|
VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at,
|
|
@duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt,
|
|
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
project_path = excluded.project_path,
|
|
project_slug = excluded.project_slug,
|
|
jsonl_path = excluded.jsonl_path,
|
|
started_at = excluded.started_at,
|
|
ended_at = excluded.ended_at,
|
|
duration_sec = excluded.duration_sec,
|
|
event_count = excluded.event_count,
|
|
user_messages = excluded.user_messages,
|
|
assistant_msgs = excluded.assistant_msgs,
|
|
tool_calls = excluded.tool_calls,
|
|
first_prompt = excluded.first_prompt,
|
|
tools_used = excluded.tools_used,
|
|
skills_invoked = excluded.skills_invoked,
|
|
outcome = excluded.outcome,
|
|
permission_mode = excluded.permission_mode,
|
|
file_size = excluded.file_size,
|
|
indexed_at = excluded.indexed_at
|
|
`)
|
|
|
|
const upsertManyTxn = db.transaction((metas: SessionMeta[]) => {
|
|
for (const meta of metas) {
|
|
upsertStmt.run({
|
|
...meta,
|
|
tools_used: JSON.stringify(meta.tools_used),
|
|
skills_invoked: JSON.stringify(meta.skills_invoked),
|
|
})
|
|
}
|
|
})
|
|
|
|
return {
|
|
upsertSession(meta) {
|
|
upsertStmt.run({
|
|
...meta,
|
|
tools_used: JSON.stringify(meta.tools_used),
|
|
skills_invoked: JSON.stringify(meta.skills_invoked),
|
|
})
|
|
},
|
|
upsertMany(metas) {
|
|
upsertManyTxn(metas)
|
|
},
|
|
listSessions(filters) {
|
|
const { sql, params } = buildWhere(filters)
|
|
const limit = filters.limit ?? 50
|
|
const offset = filters.offset ?? 0
|
|
const rows = db
|
|
.prepare(`SELECT * FROM sessions ${sql} ORDER BY started_at DESC LIMIT @limit OFFSET @offset`)
|
|
.all({ ...params, limit, offset }) as Record<string, unknown>[]
|
|
return rows.map(rowToMeta)
|
|
},
|
|
countSessions(filters) {
|
|
const { sql, params } = buildWhere(filters)
|
|
const row = db.prepare(`SELECT COUNT(*) as c FROM sessions ${sql}`).get(params) as { c: number }
|
|
return row.c
|
|
},
|
|
getSession(id) {
|
|
const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(id) as Record<string, unknown> | undefined
|
|
return row ? rowToMeta(row) : null
|
|
},
|
|
deleteByJsonlPath(path) {
|
|
db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path)
|
|
},
|
|
upsertPattern(p: PatternRecord) {
|
|
db.prepare(`
|
|
INSERT INTO patterns (detected_at, week_iso, pattern_key, title, description,
|
|
severity, metric_value, sample_session_ids, affected_count, consecutive_weeks)
|
|
VALUES (@detected_at, @week_iso, @pattern_key, @title, @description,
|
|
@severity, @metric_value, @sample_session_ids, @affected_count, @consecutive_weeks)
|
|
ON CONFLICT(week_iso, pattern_key) DO UPDATE SET
|
|
detected_at = excluded.detected_at,
|
|
title = excluded.title,
|
|
description = excluded.description,
|
|
severity = excluded.severity,
|
|
metric_value = excluded.metric_value,
|
|
sample_session_ids = excluded.sample_session_ids,
|
|
affected_count = excluded.affected_count,
|
|
consecutive_weeks = excluded.consecutive_weeks
|
|
`).run({
|
|
detected_at: p.detected_at,
|
|
week_iso: p.week_iso,
|
|
pattern_key: p.pattern_key,
|
|
title: p.title,
|
|
description: p.description,
|
|
severity: p.severity,
|
|
metric_value: p.metric_value,
|
|
sample_session_ids: JSON.stringify(p.sample_session_ids),
|
|
affected_count: p.affected_count,
|
|
consecutive_weeks: p.consecutive_weeks,
|
|
})
|
|
},
|
|
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>[]
|
|
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,
|
|
}))
|
|
},
|
|
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number {
|
|
// Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu
|
|
const rows = db.prepare('SELECT DISTINCT week_iso FROM patterns WHERE pattern_key = ? AND week_iso <= ? ORDER BY week_iso DESC').all(pattern_key, uptoWeek) as { week_iso: string }[]
|
|
if (rows.length === 0) return 0
|
|
let count = 0
|
|
let cursor = uptoWeek
|
|
for (const row of rows) {
|
|
if (row.week_iso === cursor) {
|
|
count++
|
|
cursor = prevWeekIso(cursor)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return count
|
|
},
|
|
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } {
|
|
const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id)
|
|
const inserted = !existing
|
|
db.prepare(`
|
|
INSERT INTO worklog_comments (id, discussion_id, created_at, staff_id, title, task_ref,
|
|
duration_sec, work_items, files_modified, problems_json, patterns_text, actions_json,
|
|
raw_html, imported_at)
|
|
VALUES (@id, @discussion_id, @created_at, @staff_id, @title, @task_ref,
|
|
@duration_sec, @work_items, @files_modified, @problems_json, @patterns_text, @actions_json,
|
|
@raw_html, @imported_at)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
discussion_id = excluded.discussion_id,
|
|
created_at = excluded.created_at,
|
|
staff_id = excluded.staff_id,
|
|
title = excluded.title,
|
|
task_ref = excluded.task_ref,
|
|
duration_sec = excluded.duration_sec,
|
|
work_items = excluded.work_items,
|
|
files_modified = excluded.files_modified,
|
|
problems_json = excluded.problems_json,
|
|
patterns_text = excluded.patterns_text,
|
|
actions_json = excluded.actions_json,
|
|
raw_html = excluded.raw_html,
|
|
imported_at = excluded.imported_at
|
|
`).run({
|
|
id: c.id,
|
|
discussion_id: c.discussion_id,
|
|
created_at: c.created_at,
|
|
staff_id: c.staff_id,
|
|
title: c.title,
|
|
task_ref: c.task_ref,
|
|
duration_sec: c.duration_sec,
|
|
work_items: JSON.stringify(c.work_items),
|
|
files_modified: JSON.stringify(c.files_modified),
|
|
problems_json: JSON.stringify(c.problems),
|
|
patterns_text: JSON.stringify(c.patterns_text),
|
|
actions_json: JSON.stringify(c.actions),
|
|
raw_html: c.raw_html,
|
|
imported_at: c.imported_at,
|
|
})
|
|
return { inserted }
|
|
},
|
|
hasWorklogComment(id: number): boolean {
|
|
return !!db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(id)
|
|
},
|
|
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[] {
|
|
const parts: string[] = []
|
|
const params: Record<string, unknown> = {}
|
|
if (filters.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id }
|
|
if (filters.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref }
|
|
if (filters.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso }
|
|
const where = parts.length ? 'WHERE ' + parts.join(' AND ') : ''
|
|
const limit = filters.limit ?? 1000
|
|
const offset = filters.offset ?? 0
|
|
const rows = db.prepare(`SELECT * FROM worklog_comments ${where} ORDER BY created_at DESC LIMIT @limit OFFSET @offset`)
|
|
.all({ ...params, limit, offset }) as Record<string, unknown>[]
|
|
return rows.map((r) => ({
|
|
id: r.id as number,
|
|
discussion_id: r.discussion_id as number,
|
|
created_at: r.created_at as string,
|
|
staff_id: (r.staff_id as number | null) ?? null,
|
|
title: (r.title as string | null) ?? null,
|
|
task_ref: (r.task_ref as string | null) ?? null,
|
|
duration_sec: (r.duration_sec as number | null) ?? null,
|
|
work_items: JSON.parse(r.work_items as string),
|
|
files_modified: JSON.parse(r.files_modified as string),
|
|
problems: JSON.parse(r.problems_json as string),
|
|
patterns_text: JSON.parse(r.patterns_text as string),
|
|
actions: JSON.parse(r.actions_json as string),
|
|
raw_html: r.raw_html as string,
|
|
imported_at: r.imported_at as string,
|
|
}))
|
|
},
|
|
countWorklogComments(filters?: WorklogFilters): number {
|
|
const parts: string[] = []
|
|
const params: Record<string, unknown> = {}
|
|
if (filters?.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id }
|
|
if (filters?.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref }
|
|
if (filters?.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso }
|
|
const where = parts.length ? 'WHERE ' + parts.join(' AND ') : ''
|
|
const row = db.prepare(`SELECT COUNT(*) as c FROM worklog_comments ${where}`).get(params) as { c: number }
|
|
return row.c
|
|
},
|
|
rawDb(): Database.Database {
|
|
return db
|
|
},
|
|
close() {
|
|
db.close()
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Calcula semana ISO anterior (YYYY-Www). */
|
|
export function prevWeekIso(week: string): string {
|
|
const m = week.match(/^(\d{4})-W(\d{2})$/)
|
|
if (!m) return week
|
|
const year = parseInt(m[1], 10)
|
|
const w = parseInt(m[2], 10)
|
|
if (w > 1) return `${year}-W${String(w - 1).padStart(2, '0')}`
|
|
// Semana 1 → última semana do ano anterior (52 ou 53)
|
|
const prevYear = year - 1
|
|
const last = weeksInYear(prevYear)
|
|
return `${prevYear}-W${String(last).padStart(2, '0')}`
|
|
}
|
|
|
|
function weeksInYear(year: number): number {
|
|
// ISO: ano tem 53 semanas se 1 Jan é quinta ou (ano bissexto e 1 Jan é quarta)
|
|
const jan1 = new Date(Date.UTC(year, 0, 1)).getUTCDay()
|
|
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
|
if (jan1 === 4 || (isLeap && jan1 === 3)) return 53
|
|
return 52
|
|
}
|