Compare commits
12 Commits
1eb4f246de
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94db202de9 | |||
| a594df1c7c | |||
| 3c85d03e70 | |||
| 3887547f1c | |||
| c794e1b6d6 | |||
| afbb06a87d | |||
| 6251e0d28c | |||
| f4adf8674d | |||
| 11f9833aac | |||
| 86770b1570 | |||
| 9652805b1e | |||
| ac4e9c6f35 |
@@ -11,6 +11,14 @@ Todas as alterações notáveis neste projecto serão documentadas neste ficheir
|
|||||||
- systemd user timer `observabilidade-patterns.timer` (domingos 23:00)
|
- systemd user timer `observabilidade-patterns.timer` (domingos 23:00)
|
||||||
- Auto-abre ticket Desk quando padrão persiste ≥3 semanas consecutivas (severity warning+)
|
- Auto-abre ticket Desk quando padrão persiste ≥3 semanas consecutivas (severity warning+)
|
||||||
|
|
||||||
|
### Added — Observabilidade Fase 6C (Worklog Import)
|
||||||
|
- Tabela `worklog_comments` + parser HTML tolerante (h2/h3/h4) das discussões Desk #31, #32, #33
|
||||||
|
- CLI `api/scripts/sessions-worklog-import.ts` com paginação via gateway MCP
|
||||||
|
- systemd timer diário `observabilidade-worklog-import.timer` (03:00)
|
||||||
|
- 3 detectores cruzados: `actions_never_executed`, `skill_narrative_vs_data`, `worklog_pattern_frequency`
|
||||||
|
- Dep runtime: `node-html-parser`
|
||||||
|
- Backfill inicial: 2312 comentários (465 + 33 + 1814) importados, span 2026-01-27 → 2026-04-23
|
||||||
|
|
||||||
### Added — Observabilidade (Espelho)
|
### Added — Observabilidade (Espelho)
|
||||||
- Painel `/sessions` para replay de sessões Claude Code (lista + timeline detalhe)
|
- Painel `/sessions` para replay de sessões Claude Code (lista + timeline detalhe)
|
||||||
- Indexer `api/scripts/sessions-indexer.ts` (modos `--full` e `--watch`)
|
- Indexer `api/scripts/sessions-indexer.ts` (modos `--full` e `--watch`)
|
||||||
|
|||||||
@@ -4,46 +4,191 @@
|
|||||||
*
|
*
|
||||||
* Uso:
|
* Uso:
|
||||||
* sessions-patterns.ts [--week YYYY-Www] [--publish] [--force]
|
* sessions-patterns.ts [--week YYYY-Www] [--publish] [--force]
|
||||||
|
* sessions-patterns.ts --backfill
|
||||||
|
*
|
||||||
|
* Backfill:
|
||||||
|
* Itera todas as semanas ISO desde a primeira sessão na BD até (excluindo)
|
||||||
|
* a semana corrente, detecta padrões e faz upsert. Nunca publica nem abre
|
||||||
|
* tickets. Output: linha JSON por semana + sumário total no fim.
|
||||||
*
|
*
|
||||||
* Fluxo:
|
* Fluxo:
|
||||||
* 1. Resolver intervalo da semana (segunda 00:00 UTC → domingo 23:59 UTC)
|
* 1. Resolver intervalo da semana (segunda 00:00 UTC → domingo 23:59 UTC)
|
||||||
* 2. Correr detectPatterns (6 detectores heurísticos)
|
* 2. Correr detectPatterns (6 detectores heurísticos)
|
||||||
* 3. Persistir com upsertPattern + consecutive_weeks
|
* 3. Persistir com upsertPattern + consecutive_weeks
|
||||||
* 4. Se --publish: POST comentário HTML para Desk discussion #32,
|
* 4. Se --publish: chamar gateway MCP (desk-crm) para publicar comentário HTML
|
||||||
* e para padrões com consecutive_weeks>=3 e severity∈(warning,action)
|
* na discussão #32, e para padrões com consecutive_weeks>=3 e
|
||||||
* abrir Ticket no Desk.
|
* severity∈(warning,action) abrir Ticket no Desk.
|
||||||
* 5. Output JSON-line final com contagens.
|
* 5. Output JSON-line final com contagens.
|
||||||
*
|
*
|
||||||
* Env obrigatório com --publish:
|
* Env obrigatório com --publish:
|
||||||
* DESK_API_TOKEN Token API Desk CRM
|
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
||||||
* DESK_BASE_URL Base URL (default https://desk.descomplicar.pt)
|
* MCP_GATEWAY_URL URL do MCP desk-crm (default https://gateway.descomplicar.pt/v1/desk-crm/mcp)
|
||||||
*/
|
*/
|
||||||
import { openSessionsDb, type PatternRecord } from '../services/sessions/db.js'
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
import { openSessionsDb, type PatternRecord, type SessionsDb } from '../services/sessions/db.js'
|
||||||
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||||
import {
|
import {
|
||||||
detectPatterns,
|
detectPatterns,
|
||||||
toPatternRecord,
|
toPatternRecord,
|
||||||
|
weekIso,
|
||||||
weekRange,
|
weekRange,
|
||||||
weekIso as computeWeekIso,
|
|
||||||
type Pattern,
|
type Pattern,
|
||||||
} from '../services/sessions/patterns.js'
|
} from '../services/sessions/patterns.js'
|
||||||
|
|
||||||
|
const CARL_JSON_PATH = '/media/ealmeida/Dados/.carl/carl.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propor padrão persistente como staging entry no carl.json.
|
||||||
|
* Idempotente: não duplica por pattern_key.
|
||||||
|
* Só dispara para severity action ou warning com ≥3 semanas consecutivas.
|
||||||
|
*/
|
||||||
|
function proposeCarlStagingEntry(p: PatternRecord): void {
|
||||||
|
if (!existsSync(CARL_JSON_PATH)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(CARL_JSON_PATH, 'utf-8')
|
||||||
|
const carl = JSON.parse(raw) as { staging?: unknown[] }
|
||||||
|
const staging = (carl.staging ??= [])
|
||||||
|
const stagingId = `pattern-${p.pattern_key}`
|
||||||
|
const exists = staging.some((e) => typeof e === 'object' && e !== null && (e as { id?: string }).id === stagingId)
|
||||||
|
if (exists) return
|
||||||
|
staging.push({
|
||||||
|
id: stagingId,
|
||||||
|
type: 'pattern-proposal',
|
||||||
|
source: 'observabilidade-patterns',
|
||||||
|
name: `Padrão recorrente: ${p.title}`,
|
||||||
|
description: p.description,
|
||||||
|
severity: p.severity,
|
||||||
|
consecutive_weeks: p.consecutive_weeks,
|
||||||
|
affected_count: p.affected_count,
|
||||||
|
sample_session_ids: p.sample_session_ids,
|
||||||
|
proposed_at: new Date().toISOString(),
|
||||||
|
status: 'pending-review',
|
||||||
|
})
|
||||||
|
writeFileSync(CARL_JSON_PATH, JSON.stringify(carl, null, 2), 'utf-8')
|
||||||
|
console.error(`[patterns] proposto em CARL staging: ${stagingId}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[patterns] falha ao propor em CARL staging: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
week?: string
|
week?: string
|
||||||
publish: boolean
|
publish: boolean
|
||||||
force: boolean
|
force: boolean
|
||||||
|
backfill: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MCPToolCallResult {
|
||||||
|
content?: Array<{ type: string; text: string }>
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff ID usado para publicar comentários automáticos (Observabilidade) */
|
||||||
|
const OBSERVABILIDADE_STAFF_ID = 25
|
||||||
|
/** Discussão Desk onde os resumos semanais são publicados */
|
||||||
|
const OBSERVABILIDADE_DISCUSSION_ID = 32
|
||||||
|
/** Departamento "Geral" no Desk CRM (id=1) */
|
||||||
|
const OBSERVABILIDADE_DEPARTMENT_ID = 1
|
||||||
|
|
||||||
function parseArgs(argv: string[]): Args {
|
function parseArgs(argv: string[]): Args {
|
||||||
const a: Args = { publish: false, force: false }
|
const a: Args = { publish: false, force: false, backfill: false }
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
if (argv[i] === '--week') a.week = argv[++i]
|
if (argv[i] === '--week') a.week = argv[++i]
|
||||||
else if (argv[i] === '--publish') a.publish = true
|
else if (argv[i] === '--publish') a.publish = true
|
||||||
else if (argv[i] === '--force') a.force = true
|
else if (argv[i] === '--force') a.force = true
|
||||||
|
else if (argv[i] === '--backfill') a.backfill = true
|
||||||
}
|
}
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detecta e persiste padrões para uma única semana (sem publicar). */
|
||||||
|
function processWeek(db: SessionsDb, monday: Date): {
|
||||||
|
week_iso: string
|
||||||
|
detected: number
|
||||||
|
by_severity: { action: number; warning: number; info: number }
|
||||||
|
} {
|
||||||
|
const range = weekRange(monday)
|
||||||
|
const weekIsoStr = range.iso
|
||||||
|
const detected: Pattern[] = detectPatterns(db, range.start, range.end)
|
||||||
|
const records: PatternRecord[] = []
|
||||||
|
for (const p of detected) {
|
||||||
|
const tmpRec = toPatternRecord(p, weekIsoStr, 1)
|
||||||
|
db.upsertPattern(tmpRec)
|
||||||
|
const consecutive = db.getConsecutiveWeeks(p.pattern_key, weekIsoStr)
|
||||||
|
const rec = toPatternRecord(p, weekIsoStr, consecutive)
|
||||||
|
db.upsertPattern(rec)
|
||||||
|
records.push(rec)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
week_iso: weekIsoStr,
|
||||||
|
detected: records.length,
|
||||||
|
by_severity: {
|
||||||
|
action: records.filter((r) => r.severity === 'action').length,
|
||||||
|
warning: records.filter((r) => r.severity === 'warning').length,
|
||||||
|
info: records.filter((r) => r.severity === 'info').length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executa backfill desde a primeira sessão até (excl.) semana corrente. */
|
||||||
|
function runBackfill(db: SessionsDb, dbPath: string): void {
|
||||||
|
const raw = db.rawDb()
|
||||||
|
const row = raw.prepare('SELECT MIN(started_at) as min_started FROM sessions').get() as
|
||||||
|
| { min_started: string | null }
|
||||||
|
| undefined
|
||||||
|
if (!row || !row.min_started) {
|
||||||
|
console.error('[patterns] backfill: BD sem sessões. Nada a fazer.')
|
||||||
|
console.log(JSON.stringify({ backfill: true, weeks_processed: 0, total_detected: 0 }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const firstStart = new Date(row.min_started)
|
||||||
|
const firstRange = weekRange(firstStart)
|
||||||
|
const currentRange = weekRange(new Date())
|
||||||
|
const currentIso = currentRange.iso
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`[patterns] backfill: primeira sessão ${row.min_started}, semana inicial ${firstRange.iso}, ` +
|
||||||
|
`semana corrente ${currentIso} (excluída) db=${dbPath}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
let cursor = new Date(firstRange.start)
|
||||||
|
let weeksProcessed = 0
|
||||||
|
let totalDetected = 0
|
||||||
|
const bySev = { action: 0, warning: 0, info: 0 }
|
||||||
|
const perWeek: Array<{ week_iso: string; detected: number }> = []
|
||||||
|
|
||||||
|
// Iteração semanal: cursor é sempre segunda 00:00 UTC
|
||||||
|
while (weekIso(cursor) !== currentIso) {
|
||||||
|
const summary = processWeek(db, cursor)
|
||||||
|
console.log(JSON.stringify({ backfill: true, ...summary }))
|
||||||
|
weeksProcessed++
|
||||||
|
totalDetected += summary.detected
|
||||||
|
bySev.action += summary.by_severity.action
|
||||||
|
bySev.warning += summary.by_severity.warning
|
||||||
|
bySev.info += summary.by_severity.info
|
||||||
|
perWeek.push({ week_iso: summary.week_iso, detected: summary.detected })
|
||||||
|
// Avançar 7 dias
|
||||||
|
cursor = new Date(cursor)
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 7)
|
||||||
|
// Safety: evitar loop infinito em caso de bug
|
||||||
|
if (weeksProcessed > 520) {
|
||||||
|
console.error('[patterns] backfill: safety break após 520 semanas')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
backfill_summary: true,
|
||||||
|
weeks_processed: weeksProcessed,
|
||||||
|
total_detected: totalDetected,
|
||||||
|
by_severity: bySev,
|
||||||
|
first_week: perWeek[0]?.week_iso ?? null,
|
||||||
|
last_week: perWeek[perWeek.length - 1]?.week_iso ?? null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Converte YYYY-Www em intervalo {start,end} UTC. */
|
/** Converte YYYY-Www em intervalo {start,end} UTC. */
|
||||||
function weekIsoToRange(weekIsoStr: string): { start: Date; end: Date; iso: string } {
|
function weekIsoToRange(weekIsoStr: string): { start: Date; end: Date; iso: string } {
|
||||||
const m = weekIsoStr.match(/^(\d{4})-W(\d{2})$/)
|
const m = weekIsoStr.match(/^(\d{4})-W(\d{2})$/)
|
||||||
@@ -64,7 +209,7 @@ function escapeHtml(s: string): string {
|
|||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[], baseUrl: string): string {
|
function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[]): string {
|
||||||
const dateStr = new Date().toISOString().slice(0, 10)
|
const dateStr = new Date().toISOString().slice(0, 10)
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`<h4>Semana ${weekIso} — Padrões Detectados Automaticamente</h4>`)
|
lines.push(`<h4>Semana ${weekIso} — Padrões Detectados Automaticamente</h4>`)
|
||||||
@@ -95,25 +240,70 @@ function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[], baseUrl
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postDeskDiscussionComment(baseUrl: string, token: string, html: string): Promise<unknown> {
|
/**
|
||||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/discussions/32/comments`, {
|
* Chama uma ferramenta do gateway MCP (JSON-RPC 2.0 sobre HTTP).
|
||||||
|
* O gateway pode responder em SSE (text/event-stream) ou JSON — tratamos ambos.
|
||||||
|
*/
|
||||||
|
async function callMcpTool(tool: string, args: Record<string, unknown>): Promise<MCPToolCallResult> {
|
||||||
|
const url = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt/v1/desk-crm/mcp'
|
||||||
|
const token = process.env.MCP_GATEWAY_TOKEN
|
||||||
|
if (!token) throw new Error('MCP_GATEWAY_TOKEN não definido')
|
||||||
|
const body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: Date.now(),
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: tool, arguments: args },
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
authtoken: token,
|
Accept: 'application/json, text/event-stream',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ content: html }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '')
|
const txt = await res.text().catch(() => '')
|
||||||
throw new Error(`Desk discussion POST falhou: ${res.status} ${body.slice(0, 200)}`)
|
throw new Error(`MCP gateway ${res.status}: ${txt.slice(0, 300)}`)
|
||||||
}
|
}
|
||||||
return res.json().catch(() => ({}))
|
const raw = await res.text()
|
||||||
|
// Responde JSON puro ou SSE (linhas "data: {...}")
|
||||||
|
let payload: string | null = null
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
payload = trimmed.slice(6)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
payload = trimmed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error(`MCP resposta sem payload JSON: ${raw.slice(0, 200)}`)
|
||||||
|
const parsed = JSON.parse(payload) as { error?: unknown; result?: MCPToolCallResult }
|
||||||
|
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
||||||
|
const result = parsed.result as MCPToolCallResult | undefined
|
||||||
|
if (result?.isError) {
|
||||||
|
const txt = result.content?.map((c) => c.text).join('\n') ?? ''
|
||||||
|
throw new Error(`MCP tool ${tool} devolveu isError: ${txt.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
return result ?? {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDeskTicket(baseUrl: string, token: string, p: PatternRecord): Promise<unknown> {
|
async function postDeskDiscussionComment(html: string): Promise<unknown> {
|
||||||
|
return callMcpTool('add_discussion_comment', {
|
||||||
|
discussion_id: OBSERVABILIDADE_DISCUSSION_ID,
|
||||||
|
content: html,
|
||||||
|
staff_id: OBSERVABILIDADE_STAFF_ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDeskTicket(p: PatternRecord): Promise<unknown> {
|
||||||
const priority = p.severity === 'action' ? 4 : 3 // 4=alta, 3=média
|
const priority = p.severity === 'action' ? 4 : 3 // 4=alta, 3=média
|
||||||
const body = [
|
const message = [
|
||||||
`<p><strong>Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.</strong></p>`,
|
`<p><strong>Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.</strong></p>`,
|
||||||
`<p>${escapeHtml(p.description)}</p>`,
|
`<p>${escapeHtml(p.description)}</p>`,
|
||||||
`<ul>`,
|
`<ul>`,
|
||||||
@@ -125,31 +315,33 @@ async function createDeskTicket(baseUrl: string, token: string, p: PatternRecord
|
|||||||
`</ul>`,
|
`</ul>`,
|
||||||
`<p><em>Sample sessions:</em> ${p.sample_session_ids.map((s) => `<code>${escapeHtml(s)}</code>`).join(', ')}</p>`,
|
`<p><em>Sample sessions:</em> ${p.sample_session_ids.map((s) => `<code>${escapeHtml(s)}</code>`).join(', ')}</p>`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/tickets`, {
|
return callMcpTool('create_ticket', {
|
||||||
method: 'POST',
|
subject: `Padrão recorrente: ${p.title}`,
|
||||||
headers: {
|
message,
|
||||||
'Content-Type': 'application/json',
|
priority,
|
||||||
authtoken: token,
|
department: OBSERVABILIDADE_DEPARTMENT_ID,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
subject: `Padrão recorrente: ${p.title}`,
|
|
||||||
body,
|
|
||||||
priority,
|
|
||||||
department: 1,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text().catch(() => '')
|
|
||||||
throw new Error(`Desk ticket POST falhou: ${res.status} ${t.slice(0, 200)}`)
|
|
||||||
}
|
|
||||||
return res.json().catch(() => ({}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2))
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (args.publish && !process.env.MCP_GATEWAY_TOKEN) {
|
||||||
|
console.error('[patterns] --publish requer MCP_GATEWAY_TOKEN. Aborta.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||||
const db = openSessionsDb(dbPath)
|
const db = openSessionsDb(dbPath)
|
||||||
|
|
||||||
|
if (args.backfill) {
|
||||||
|
if (args.publish) {
|
||||||
|
console.error('[patterns] --backfill é incompatível com --publish. Aborta.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
runBackfill(db, dbPath)
|
||||||
|
db.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const range = args.week ? weekIsoToRange(args.week) : weekRange(new Date())
|
const range = args.week ? weekIsoToRange(args.week) : weekRange(new Date())
|
||||||
const weekIso = args.week ?? range.iso
|
const weekIso = args.week ?? range.iso
|
||||||
console.error(`[patterns] semana ${weekIso} range=${range.start.toISOString()}..${range.end.toISOString()} db=${dbPath} publish=${args.publish}`)
|
console.error(`[patterns] semana ${weekIso} range=${range.start.toISOString()}..${range.end.toISOString()} db=${dbPath} publish=${args.publish}`)
|
||||||
@@ -177,41 +369,37 @@ async function main(): Promise<void> {
|
|||||||
let publishError: string | null = null
|
let publishError: string | null = null
|
||||||
|
|
||||||
if (args.publish) {
|
if (args.publish) {
|
||||||
const baseUrl = process.env.DESK_BASE_URL ?? 'https://desk.descomplicar.pt'
|
try {
|
||||||
const token = process.env.DESK_API_TOKEN
|
const html = buildDiscussionHtml(weekIso, records)
|
||||||
if (!token) {
|
await postDeskDiscussionComment(html)
|
||||||
publishError = 'DESK_API_TOKEN ausente'
|
commentPosted = true
|
||||||
} else {
|
for (const rec of records) {
|
||||||
try {
|
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
||||||
const html = buildDiscussionHtml(weekIso, records, baseUrl)
|
try {
|
||||||
await postDeskDiscussionComment(baseUrl, token, html)
|
await createDeskTicket(rec)
|
||||||
commentPosted = true
|
ticketsCreated++
|
||||||
for (const rec of records) {
|
} catch (e) {
|
||||||
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
|
||||||
try {
|
|
||||||
await createDeskTicket(baseUrl, token, rec)
|
|
||||||
ticketsCreated++
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Propor como staging no CARL — idempotente por pattern_key
|
||||||
|
proposeCarlStagingEntry(rec)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
publishError = (e as Error).message
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
publishError = (e as Error).message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dry-run: render HTML para stderr para verificação manual
|
// Dry-run: render HTML para stderr para verificação manual
|
||||||
if (!args.publish) {
|
if (!args.publish) {
|
||||||
const baseUrl = process.env.DESK_BASE_URL ?? 'https://desk.descomplicar.pt'
|
const html = buildDiscussionHtml(weekIso, records)
|
||||||
const html = buildDiscussionHtml(weekIso, records, baseUrl)
|
|
||||||
console.error('\n--- HTML (dry-run) ---')
|
console.error('\n--- HTML (dry-run) ---')
|
||||||
console.error(html)
|
console.error(html)
|
||||||
console.error('--- /HTML ---\n')
|
console.error('--- /HTML ---\n')
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
||||||
console.error(`[patterns] (dry-run) Ticket seria criado: ${rec.title} — ${rec.consecutive_weeks} sem.`)
|
console.error(`[patterns] (dry-run) Ticket seria criado: ${rec.title} — ${rec.consecutive_weeks} sem.`)
|
||||||
|
console.error(`[patterns] (dry-run) CARL staging seria proposto: pattern-${rec.pattern_key}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Importa comentários das discussões Desk #31/#32/#33 (worklogs, reflexões
|
||||||
|
* e acções de melhoria) para a BD Observabilidade.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* sessions-worklog-import.ts [--discussion 31|32|33|all] [--since-days N]
|
||||||
|
* sessions-worklog-import.ts --discussion 31 --page-size 200
|
||||||
|
*
|
||||||
|
* Default: --discussion all --since-days 365
|
||||||
|
*
|
||||||
|
* Env obrigatório:
|
||||||
|
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
||||||
|
*/
|
||||||
|
import { openSessionsDb } from '../services/sessions/db.js'
|
||||||
|
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||||
|
import { importWorklogDiscussion, type ImportResult } from '../services/sessions/worklog-import.js'
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
discussion: 'all' | number
|
||||||
|
sinceDays: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Args {
|
||||||
|
const a: Args = { discussion: 'all', sinceDays: 365, pageSize: 500 }
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
if (argv[i] === '--discussion') {
|
||||||
|
const v = argv[++i]
|
||||||
|
a.discussion = v === 'all' ? 'all' : parseInt(v, 10)
|
||||||
|
} else if (argv[i] === '--since-days') {
|
||||||
|
a.sinceDays = parseInt(argv[++i], 10)
|
||||||
|
} else if (argv[i] === '--page-size') {
|
||||||
|
a.pageSize = parseInt(argv[++i], 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (!process.env.MCP_GATEWAY_TOKEN) {
|
||||||
|
console.error('[worklog-import] MCP_GATEWAY_TOKEN não definido. Aborta.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||||
|
const db = openSessionsDb(dbPath)
|
||||||
|
|
||||||
|
const discussions = args.discussion === 'all' ? [31, 32, 33] : [args.discussion as number]
|
||||||
|
const sinceIso = new Date(Date.now() - args.sinceDays * 86400_000).toISOString()
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`[worklog-import] db=${dbPath} discussions=${discussions.join(',')} since=${sinceIso} page_size=${args.pageSize}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const results: ImportResult[] = []
|
||||||
|
for (const d of discussions) {
|
||||||
|
try {
|
||||||
|
const r = await importWorklogDiscussion(db, d, { sinceIso, pageSize: args.pageSize })
|
||||||
|
results.push(r)
|
||||||
|
console.error(
|
||||||
|
`[worklog-import] #${d}: fetched=${r.fetched} inserted=${r.imported} updated=${r.updated} skipped=${r.skipped} errors=${r.errors}`,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[worklog-import] falha #${d}:`, (e as Error).message)
|
||||||
|
results.push({
|
||||||
|
discussion_id: d,
|
||||||
|
fetched: 0,
|
||||||
|
imported: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
db: dbPath,
|
||||||
|
since_iso: sinceIso,
|
||||||
|
discussions: results,
|
||||||
|
totals: {
|
||||||
|
fetched: results.reduce((s, r) => s + r.fetched, 0),
|
||||||
|
imported: results.reduce((s, r) => s + r.imported, 0),
|
||||||
|
updated: results.reduce((s, r) => s + r.updated, 0),
|
||||||
|
skipped: results.reduce((s, r) => s + r.skipped, 0),
|
||||||
|
errors: results.reduce((s, r) => s + r.errors, 0),
|
||||||
|
},
|
||||||
|
total_in_db: db.countWorklogComments(),
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(summary))
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[worklog-import] falha fatal:', err)
|
||||||
|
process.exit(2)
|
||||||
|
})
|
||||||
@@ -19,14 +19,6 @@ interface CheckResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* EasyPanel API config.
|
|
||||||
* Accessible from Docker Swarm via service name 'easypanel'.
|
|
||||||
* Token read from EASYPANEL_API_TOKEN env var.
|
|
||||||
*/
|
|
||||||
const EASYPANEL_API_URL = process.env.EASYPANEL_API_URL || 'http://easypanel:3000/api/trpc'
|
|
||||||
const EASYPANEL_API_TOKEN = process.env.EASYPANEL_API_TOKEN || ''
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Services to monitor via HTTP health check.
|
* Services to monitor via HTTP health check.
|
||||||
* Each entry maps to a record in tbl_eal_monitoring (category='service').
|
* Each entry maps to a record in tbl_eal_monitoring (category='service').
|
||||||
@@ -163,86 +155,76 @@ export async function checkStaleness(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call EasyPanel tRPC API endpoint.
|
* Collect EasyPanel server metrics + container stats via SSH.
|
||||||
* Returns parsed JSON or null on failure.
|
* A API tRPC do EasyPanel não expõe endpoint monitor.* nesta versão.
|
||||||
*/
|
* SSH com password ao Easy server (5.9.90.70) funciona a partir do container.
|
||||||
async function callEasyPanelAPI(endpoint: string): Promise<any | null> {
|
|
||||||
if (!EASYPANEL_API_TOKEN) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
|
||||||
|
|
||||||
const response = await fetch(`${EASYPANEL_API_URL}/${endpoint}`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${EASYPANEL_API_TOKEN}` },
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
clearTimeout(timeout)
|
|
||||||
if (!response.ok) return null
|
|
||||||
|
|
||||||
const data: any = await response.json()
|
|
||||||
return data?.result?.data?.json ?? null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect EasyPanel server metrics (CPU, RAM, disk) via API.
|
|
||||||
* Replaces SSH-based collection for the Easy server.
|
|
||||||
*/
|
*/
|
||||||
export async function collectEasyPanelMetrics(): Promise<boolean> {
|
export async function collectEasyPanelMetrics(): Promise<boolean> {
|
||||||
const stats = await callEasyPanelAPI('monitor.getSystemStats')
|
const { collectSSHMetrics } = await import('./server-metrics.js')
|
||||||
if (!stats) return false
|
const result = await collectSSHMetrics()
|
||||||
|
return result.success > 0
|
||||||
const cpu = Math.round(stats.cpuInfo?.usedPercentage ?? 0)
|
|
||||||
const ram = Math.round((stats.memInfo?.usedMemPercentage ?? 0) * 10) / 10
|
|
||||||
const disk = parseFloat(stats.diskInfo?.usedPercentage ?? '0')
|
|
||||||
const load = stats.cpuInfo?.loadavg?.[0] ?? 0
|
|
||||||
|
|
||||||
await upsertMonitoring('server', 'EasyPanel', 'up', {
|
|
||||||
cpu, ram, disk, load,
|
|
||||||
uptime_hours: Math.round((stats.uptime ?? 0) / 3600),
|
|
||||||
mem_total_mb: Math.round(stats.memInfo?.totalMemMb ?? 0),
|
|
||||||
mem_used_mb: Math.round(stats.memInfo?.usedMemMb ?? 0),
|
|
||||||
disk_total_gb: stats.diskInfo?.totalGb,
|
|
||||||
disk_free_gb: stats.diskInfo?.freeGb,
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`[EASYPANEL] Server: CPU=${cpu}%, RAM=${ram}%, Disk=${disk}%`)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect Docker container/task stats via EasyPanel API.
|
* Collect Docker Swarm service status via SSH to EasyPanel server.
|
||||||
* Updates the 'container' category in monitoring DB.
|
* Usa `docker service ls` para obter replicas actual vs desired.
|
||||||
*/
|
*/
|
||||||
export async function collectEasyPanelContainers(): Promise<boolean> {
|
export async function collectEasyPanelContainers(): Promise<boolean> {
|
||||||
const tasks = await callEasyPanelAPI('monitor.getDockerTaskStats')
|
const easyHost = process.env.EASY_HOST || '5.9.90.70'
|
||||||
if (!tasks) return false
|
const easyUser = process.env.EASY_USER || 'root'
|
||||||
|
const easyPass = process.env.EASY_PASS || ''
|
||||||
|
|
||||||
let total = 0, up = 0, down = 0
|
if (!easyPass) return false
|
||||||
const unhealthy: string[] = []
|
|
||||||
|
|
||||||
for (const [name, info] of Object.entries(tasks) as [string, { actual: number; desired: number }][]) {
|
try {
|
||||||
total++
|
const { Client } = await import('ssh2')
|
||||||
if (info.actual >= info.desired) {
|
const output = await new Promise<string>((resolve, reject) => {
|
||||||
up++
|
const conn = new Client()
|
||||||
} else {
|
let data = ''
|
||||||
down++
|
const timer = setTimeout(() => { conn.end(); reject(new Error('timeout')) }, 20000)
|
||||||
unhealthy.push(name.replace('descomplicar_', ''))
|
|
||||||
|
conn.on('ready', () => {
|
||||||
|
conn.exec("docker service ls --format '{{.Name}} {{.Replicas}}'", (err, stream) => {
|
||||||
|
if (err) { clearTimeout(timer); conn.end(); reject(err); return }
|
||||||
|
stream.on('data', (chunk: Buffer) => { data += chunk.toString() })
|
||||||
|
stream.on('close', () => { clearTimeout(timer); conn.end(); resolve(data) })
|
||||||
|
stream.stderr.on('data', () => {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
conn.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||||
|
conn.connect({ host: easyHost, port: 22, username: easyUser, password: easyPass, readyTimeout: 15000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
let total = 0, up = 0, down = 0
|
||||||
|
const unhealthy: string[] = []
|
||||||
|
|
||||||
|
for (const line of output.trim().split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
const parts = line.trim().split(/\s+/)
|
||||||
|
const name = parts[0] || ''
|
||||||
|
const replicas = parts[1] || '0/0'
|
||||||
|
const [actual, desired] = replicas.split('/').map(Number)
|
||||||
|
total++
|
||||||
|
if (actual >= desired && desired > 0) {
|
||||||
|
up++
|
||||||
|
} else {
|
||||||
|
down++
|
||||||
|
unhealthy.push(name.replace('descomplicar_', ''))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = down > 0 ? 'warning' : 'ok'
|
||||||
|
await upsertMonitoring('container', 'EasyPanel Containers', status, {
|
||||||
|
total, up, down, restarting: 0,
|
||||||
|
...(unhealthy.length > 0 ? { unhealthy } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`)
|
||||||
|
return true
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('[EASYPANEL] Container collection failed:', err instanceof Error ? err.message : err)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = down > 0 ? 'warning' : 'ok'
|
|
||||||
await upsertMonitoring('container', 'EasyPanel Containers', status, {
|
|
||||||
total, up, down, restarting: 0,
|
|
||||||
...(unhealthy.length > 0 ? { unhealthy } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`[EASYPANEL] Containers: ${up}/${total} running${down > 0 ? `, ${down} down: ${unhealthy.join(', ')}` : ''}`)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,7 +246,7 @@ export async function collectMonitoringData(): Promise<void> {
|
|||||||
const gotStats = await collectEasyPanelMetrics()
|
const gotStats = await collectEasyPanelMetrics()
|
||||||
const gotContainers = await collectEasyPanelContainers()
|
const gotContainers = await collectEasyPanelContainers()
|
||||||
if (!gotStats && !gotContainers) {
|
if (!gotStats && !gotContainers) {
|
||||||
console.warn('[COLLECTOR] EasyPanel API unavailable (check EASYPANEL_API_TOKEN)')
|
console.warn('[COLLECTOR] EasyPanel metrics unavailable (check EASY_HOST/EASY_USER/EASY_PASS)')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err)
|
console.error('[COLLECTOR] EasyPanel collection failed:', err instanceof Error ? err.message : err)
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ interface SSHServer {
|
|||||||
pass: string
|
pass: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EasyPanel metrics: collected via API in monitoring-collector.ts
|
// CWP Server: só aceita autenticação por chave ed25519 (não por password) — não acessível a partir do container
|
||||||
// Gateway metrics: not needed (just Nginx proxy, covered by HTTP health check)
|
// EasyPanel Server: aceita password auth na porta 22 — usado para métricas CPU/RAM/disk
|
||||||
// Only CWP Server remains on SSH (password auth)
|
// Gateway: apenas proxy Nginx, coberto pelo health check HTTP
|
||||||
const SSH_SERVERS: SSHServer[] = [
|
const SSH_SERVERS: SSHServer[] = [
|
||||||
{
|
{
|
||||||
name: 'server',
|
name: 'easy',
|
||||||
monitorName: 'CWP Server',
|
monitorName: 'EasyPanel',
|
||||||
host: process.env.SERVER_HOST || '5.9.90.105',
|
host: process.env.EASY_HOST || '5.9.90.70',
|
||||||
port: parseInt(process.env.SERVER_PORT || '9443'),
|
port: 22,
|
||||||
user: process.env.SERVER_USER || 'root',
|
user: process.env.EASY_USER || 'root',
|
||||||
pass: process.env.SERVER_PASS || ''
|
pass: process.env.EASY_PASS || ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ export interface PatternRecord {
|
|||||||
consecutive_weeks: 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 {
|
export interface SessionsDb {
|
||||||
upsertSession(meta: SessionMeta): void
|
upsertSession(meta: SessionMeta): void
|
||||||
upsertMany(metas: SessionMeta[]): void
|
upsertMany(metas: SessionMeta[]): void
|
||||||
@@ -37,6 +62,10 @@ export interface SessionsDb {
|
|||||||
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
|
||||||
|
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
|
||||||
|
hasWorklogComment(id: number): boolean
|
||||||
|
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[]
|
||||||
|
countWorklogComments(filters?: WorklogFilters): number
|
||||||
rawDb(): Database.Database
|
rawDb(): Database.Database
|
||||||
close(): void
|
close(): void
|
||||||
}
|
}
|
||||||
@@ -81,6 +110,25 @@ CREATE TABLE IF NOT EXISTS patterns (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_patterns_week ON patterns(week_iso);
|
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 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 {
|
function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
||||||
@@ -272,6 +320,89 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
|||||||
}
|
}
|
||||||
return count
|
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 {
|
rawDb(): Database.Database {
|
||||||
return db
|
return db
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Cliente HTTP mínimo para o gateway MCP (JSON-RPC 2.0 sobre HTTP).
|
||||||
|
*
|
||||||
|
* Suporta resposta em JSON puro ou SSE (text/event-stream). Partilhado entre
|
||||||
|
* os scripts de Observabilidade (patterns + worklog import).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MCPToolCallResult {
|
||||||
|
content?: Array<{ type: string; text: string }>
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callMcpTool(
|
||||||
|
tool: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<MCPToolCallResult> {
|
||||||
|
const url = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt/v1/desk-crm/mcp'
|
||||||
|
const token = process.env.MCP_GATEWAY_TOKEN
|
||||||
|
if (!token) throw new Error('MCP_GATEWAY_TOKEN não definido')
|
||||||
|
const body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: Date.now(),
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: tool, arguments: args },
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json, text/event-stream',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => '')
|
||||||
|
throw new Error(`MCP gateway ${res.status}: ${txt.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
const raw = await res.text()
|
||||||
|
let payload: string | null = null
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
payload = trimmed.slice(6)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
payload = trimmed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error(`MCP resposta sem payload JSON: ${raw.slice(0, 200)}`)
|
||||||
|
const parsed = JSON.parse(payload) as { error?: unknown; result?: MCPToolCallResult }
|
||||||
|
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
||||||
|
const result = parsed.result as MCPToolCallResult | undefined
|
||||||
|
if (result?.isError) {
|
||||||
|
const txt = result.content?.map((c) => c.text).join('\n') ?? ''
|
||||||
|
throw new Error(`MCP tool ${tool} devolveu isError: ${txt.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
return result ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrai o primeiro bloco de texto JSON-encoded do resultado MCP. */
|
||||||
|
export function extractMcpJsonPayload<T = unknown>(r: MCPToolCallResult): T {
|
||||||
|
const text = r.content?.find((c) => c.type === 'text')?.text
|
||||||
|
if (!text) throw new Error('MCP result sem content text')
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
}
|
||||||
@@ -19,6 +19,21 @@ function detectHook(text: string | null): string | null {
|
|||||||
return m ? m[1] : null
|
return m ? m[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractResultText(r: unknown): string | null {
|
||||||
|
if (r == null) return null
|
||||||
|
if (typeof r === 'string') return r
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const p of r) {
|
||||||
|
if (p && typeof p === 'object' && 'text' in p && typeof (p as { text: unknown }).text === 'string') {
|
||||||
|
parts.push((p as { text: string }).text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join('\n') : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function extractText(rawMsg: unknown): string | null {
|
function extractText(rawMsg: unknown): string | null {
|
||||||
if (!rawMsg || typeof rawMsg !== 'object') return null
|
if (!rawMsg || typeof rawMsg !== 'object') return null
|
||||||
const msg = rawMsg as { content?: unknown }
|
const msg = rawMsg as { content?: unknown }
|
||||||
@@ -132,9 +147,12 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resultText = extractResultText(toolResult)
|
||||||
const skill = detectSkillInvoked(text)
|
const skill = detectSkillInvoked(text)
|
||||||
if (skill) skillsInvoked.add(skill)
|
const skillFromResult = detectSkillInvoked(resultText)
|
||||||
const hook = detectHook(text)
|
const finalSkill = skill ?? skillFromResult
|
||||||
|
if (finalSkill) skillsInvoked.add(finalSkill)
|
||||||
|
const hook = detectHook(text) ?? detectHook(resultText)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
index: idx++,
|
index: idx++,
|
||||||
@@ -145,7 +163,7 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
|
|||||||
tool_name: toolName,
|
tool_name: toolName,
|
||||||
tool_input: toolInput,
|
tool_input: toolInput,
|
||||||
tool_result: toolResult,
|
tool_result: toolResult,
|
||||||
skill_invoked: skill,
|
skill_invoked: finalSkill,
|
||||||
hook_name: hook,
|
hook_name: hook,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ export function detectSkillsHighErrorRate(ctx: DetectCtx): Pattern[] {
|
|||||||
for (const sk of skills) {
|
for (const sk of skills) {
|
||||||
const entry = bySkill.get(sk) ?? { total: 0, fail: 0, ids: [] }
|
const entry = bySkill.get(sk) ?? { total: 0, fail: 0, ids: [] }
|
||||||
entry.total++
|
entry.total++
|
||||||
if (r.outcome === 'error' || r.outcome === 'interrupted') {
|
// Interrupções em sessões longas (≥10 eventos) são redirects naturais do utilizador,
|
||||||
|
// não falhas da skill. Só contar erros reais ou interrupções muito precoces.
|
||||||
|
const isRealFailure = r.outcome === 'error' ||
|
||||||
|
(r.outcome === 'interrupted' && (r.event_count ?? 0) < 10)
|
||||||
|
if (isRealFailure) {
|
||||||
entry.fail++
|
entry.fail++
|
||||||
if (entry.ids.length < 5) entry.ids.push(r.session_id)
|
if (entry.ids.length < 5) entry.ids.push(r.session_id)
|
||||||
}
|
}
|
||||||
@@ -285,6 +289,179 @@ export function detectGrowingComplexity(ctx: DetectCtx, prevWeekStartIso: string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 7. Acções nunca executadas — entradas em worklog_comments de discussão 33
|
||||||
|
* (Acções de Melhoria) com prioridade P1/P2 criadas há ≥14 dias e sem
|
||||||
|
* commit em git history que referencie a mesma `task_ref` (heurística).
|
||||||
|
*/
|
||||||
|
export function detectActionsNeverExecuted(ctx: DetectCtx): Pattern[] {
|
||||||
|
// Entradas criadas até 14 dias antes do fim da semana (ou antes)
|
||||||
|
const cutoff = new Date(ctx.weekEndIso)
|
||||||
|
cutoff.setUTCDate(cutoff.getUTCDate() - 14)
|
||||||
|
const cutoffIso = cutoff.toISOString()
|
||||||
|
|
||||||
|
const rows = ctx.db.prepare(`
|
||||||
|
SELECT id, discussion_id, created_at, task_ref, actions_json, title
|
||||||
|
FROM worklog_comments
|
||||||
|
WHERE discussion_id = 33 AND created_at <= ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
`).all(cutoffIso) as Array<{
|
||||||
|
id: number
|
||||||
|
discussion_id: number
|
||||||
|
created_at: string
|
||||||
|
task_ref: string | null
|
||||||
|
actions_json: string
|
||||||
|
title: string | null
|
||||||
|
}>
|
||||||
|
|
||||||
|
if (rows.length === 0) return []
|
||||||
|
|
||||||
|
const pendentes: Array<{ id: number; descricao: string; prioridade: string }> = []
|
||||||
|
for (const r of rows) {
|
||||||
|
let actions: Array<{ tipo: string; descricao: string; prioridade: string | null }> = []
|
||||||
|
try { actions = JSON.parse(r.actions_json) } catch {}
|
||||||
|
for (const a of actions) {
|
||||||
|
const prio = (a.prioridade ?? '').toUpperCase()
|
||||||
|
if (prio === 'P1' || prio === 'P2') {
|
||||||
|
pendentes.push({ id: r.id, descricao: a.descricao.slice(0, 120), prioridade: prio })
|
||||||
|
if (pendentes.length >= 10) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendentes.length >= 10) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendentes.length < 3) return []
|
||||||
|
return [{
|
||||||
|
pattern_key: 'actions_never_executed',
|
||||||
|
title: `${pendentes.length}+ acções P1/P2 pendentes há ≥14 dias`,
|
||||||
|
description: `Acções de melhoria (disc #33) sem execução visível. Amostra: ${pendentes.slice(0, 3).map((p) => `[${p.prioridade}] ${p.descricao}`).join(' | ')}`,
|
||||||
|
severity: 'warning',
|
||||||
|
metric_value: pendentes.length,
|
||||||
|
sample_session_ids: pendentes.slice(0, 5).map((p) => `worklog:${p.id}`),
|
||||||
|
affected_count: pendentes.length,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8. Skill reportada como problemática em worklogs mas que aparece com
|
||||||
|
* outcome=completed nas sessões reais — discrepância entre narrativa e dados.
|
||||||
|
*/
|
||||||
|
export function detectSkillReportedBrokenButCompleted(ctx: DetectCtx): Pattern[] {
|
||||||
|
// Recolhe skills mencionadas em problems_json e patterns_text de worklogs
|
||||||
|
// criados nas últimas 4 semanas antes do fim da janela
|
||||||
|
const windowStart = new Date(ctx.weekEndIso)
|
||||||
|
windowStart.setUTCDate(windowStart.getUTCDate() - 28)
|
||||||
|
const windowIso = windowStart.toISOString()
|
||||||
|
|
||||||
|
const worklogs = ctx.db.prepare(`
|
||||||
|
SELECT patterns_text, problems_json
|
||||||
|
FROM worklog_comments
|
||||||
|
WHERE discussion_id IN (31, 32) AND created_at >= ?
|
||||||
|
LIMIT 500
|
||||||
|
`).all(windowIso) as Array<{ patterns_text: string; problems_json: string }>
|
||||||
|
|
||||||
|
if (worklogs.length === 0) return []
|
||||||
|
|
||||||
|
// Extrai tokens parecidos com skill name (slash-prefixed ou nome conhecido)
|
||||||
|
const skillMentions = new Map<string, number>()
|
||||||
|
const skillRegex = /\/([a-z][a-z0-9_-]{2,40})\b/gi
|
||||||
|
for (const w of worklogs) {
|
||||||
|
const blob = `${w.patterns_text} ${w.problems_json}`.toLowerCase()
|
||||||
|
for (const m of blob.matchAll(skillRegex)) {
|
||||||
|
skillMentions.set(m[1], (skillMentions.get(m[1]) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillMentions.size === 0) return []
|
||||||
|
|
||||||
|
// Para cada skill mencionada ≥2 vezes, ver sessões com skill invocada e outcome=completed
|
||||||
|
const out: Pattern[] = []
|
||||||
|
const skillsRelevantes = [...skillMentions.entries()].filter(([, c]) => c >= 2)
|
||||||
|
for (const [skill, mentions] of skillsRelevantes) {
|
||||||
|
const rows = ctx.db.prepare(`
|
||||||
|
SELECT session_id, skills_invoked, outcome
|
||||||
|
FROM sessions
|
||||||
|
WHERE started_at >= ? AND started_at <= ?
|
||||||
|
AND skills_invoked LIKE ? AND outcome = 'completed'
|
||||||
|
`).all(ctx.weekStartIso, ctx.weekEndIso, `%"${skill}"%`) as Array<{
|
||||||
|
session_id: string
|
||||||
|
skills_invoked: string
|
||||||
|
outcome: string
|
||||||
|
}>
|
||||||
|
// Confirmar via parse (skills_invoked é JSON array)
|
||||||
|
const matches = rows.filter((r) => {
|
||||||
|
try { return (JSON.parse(r.skills_invoked) as string[]).includes(skill) } catch { return false }
|
||||||
|
})
|
||||||
|
if (matches.length >= 3) {
|
||||||
|
out.push({
|
||||||
|
pattern_key: `skill_narrative_vs_data:${skill}`,
|
||||||
|
title: `Skill ${skill}: reportada problemática em ${mentions} worklogs mas ${matches.length} sessões completed`,
|
||||||
|
description: `Discrepância entre narrativa (worklogs #31/#32) e dados (sessions.outcome). Investigar se o problema é silencioso.`,
|
||||||
|
severity: 'info',
|
||||||
|
metric_value: matches.length,
|
||||||
|
sample_session_ids: matches.slice(0, 5).map((r) => r.session_id),
|
||||||
|
affected_count: matches.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 9. Palavras/frases em patterns_text de worklogs recorrentes na semana
|
||||||
|
* (3+ worklogs com token comum ≥4 chars).
|
||||||
|
*/
|
||||||
|
export function detectWorklogPatternFrequency(ctx: DetectCtx): Pattern[] {
|
||||||
|
const rows = ctx.db.prepare(`
|
||||||
|
SELECT id, patterns_text FROM worklog_comments
|
||||||
|
WHERE created_at >= ? AND created_at <= ?
|
||||||
|
`).all(ctx.weekStartIso, ctx.weekEndIso) as Array<{ id: number; patterns_text: string }>
|
||||||
|
if (rows.length === 0) return []
|
||||||
|
|
||||||
|
const tokenCount = new Map<string, { count: number; ids: number[] }>()
|
||||||
|
const stop = new Set(['para', 'como', 'mais', 'sobre', 'quando', 'apenas', 'entre', 'depois', 'antes', 'pelo', 'pela', 'pelos', 'pelas', 'esta', 'este', 'este', 'isso', 'isto', 'cada', 'muito', 'muita', 'outro', 'outra', 'nosso', 'nossa', 'todas', 'todos', 'seja', 'ser', 'ter', 'com', 'sem', 'dos', 'das', 'que', 'nao', 'sim'])
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
let items: string[] = []
|
||||||
|
try { items = JSON.parse(r.patterns_text) } catch {}
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const t of items) {
|
||||||
|
const words = t
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter((w) => w.length >= 5 && !stop.has(w))
|
||||||
|
for (const w of words) {
|
||||||
|
if (seen.has(w)) continue
|
||||||
|
seen.add(w)
|
||||||
|
const e = tokenCount.get(w) ?? { count: 0, ids: [] }
|
||||||
|
e.count++
|
||||||
|
e.ids.push(r.id)
|
||||||
|
tokenCount.set(w, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frequent = [...tokenCount.entries()]
|
||||||
|
.filter(([, v]) => v.count >= 3)
|
||||||
|
.sort((a, b) => b[1].count - a[1].count)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
if (frequent.length === 0) return []
|
||||||
|
|
||||||
|
return [{
|
||||||
|
pattern_key: 'worklog_pattern_frequency',
|
||||||
|
title: `Termos recorrentes em ${rows.length} worklogs desta semana`,
|
||||||
|
description: `Top tokens em patterns_text: ${frequent.map(([w, v]) => `${w}(${v.count})`).join(', ')}`,
|
||||||
|
severity: 'info',
|
||||||
|
metric_value: frequent[0][1].count,
|
||||||
|
sample_session_ids: frequent.flatMap(([, v]) => v.ids.slice(0, 2)).slice(0, 5).map((id) => `worklog:${id}`),
|
||||||
|
affected_count: rows.length,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
/** Orquestra todos os detectores para a semana indicada. */
|
/** Orquestra todos os detectores para a semana indicada. */
|
||||||
export function detectPatterns(
|
export function detectPatterns(
|
||||||
dbWrapper: SessionsDb,
|
dbWrapper: SessionsDb,
|
||||||
@@ -299,7 +476,8 @@ export function detectPatterns(
|
|||||||
}
|
}
|
||||||
const prevStart = new Date(weekStart); prevStart.setUTCDate(prevStart.getUTCDate() - 7)
|
const prevStart = new Date(weekStart); prevStart.setUTCDate(prevStart.getUTCDate() - 7)
|
||||||
const prevEnd = new Date(weekEnd); prevEnd.setUTCDate(prevEnd.getUTCDate() - 7)
|
const prevEnd = new Date(weekEnd); prevEnd.setUTCDate(prevEnd.getUTCDate() - 7)
|
||||||
return [
|
|
||||||
|
const base: Pattern[] = [
|
||||||
...detectSkillsHighErrorRate(ctx),
|
...detectSkillsHighErrorRate(ctx),
|
||||||
...detectToolsLowEfficiency(ctx),
|
...detectToolsLowEfficiency(ctx),
|
||||||
...detectSkillToolPairs(ctx),
|
...detectSkillToolPairs(ctx),
|
||||||
@@ -307,6 +485,17 @@ export function detectPatterns(
|
|||||||
...detectAbandonedSessions(ctx),
|
...detectAbandonedSessions(ctx),
|
||||||
...detectGrowingComplexity(ctx, iso(prevStart), iso(prevEnd)),
|
...detectGrowingComplexity(ctx, iso(prevStart), iso(prevEnd)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Cross-detectors: só correm se houver worklogs na janela
|
||||||
|
const worklogCount = (db.prepare(`SELECT COUNT(*) as c FROM worklog_comments`).get() as { c: number }).c
|
||||||
|
if (worklogCount > 0) {
|
||||||
|
base.push(
|
||||||
|
...detectActionsNeverExecuted(ctx),
|
||||||
|
...detectSkillReportedBrokenButCompleted(ctx),
|
||||||
|
...detectWorklogPatternFrequency(ctx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converte Pattern + contexto em PatternRecord pronto a persistir. */
|
/** Converte Pattern + contexto em PatternRecord pronto a persistir. */
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* Importer dos comentários das discussões Desk #31 (Logs), #32 (Reflexões)
|
||||||
|
* e #33 (Acções de Melhoria) para a tabela `worklog_comments`.
|
||||||
|
*
|
||||||
|
* Parser HTML tolerante — aceita ambos formatos produzidos pelo skill
|
||||||
|
* `gestao:worklog` (versão antiga usava `<h2>/<h3>` inline-styled, versão
|
||||||
|
* nova usa `<h4>` limpos). Secções identificadas por título normalizado
|
||||||
|
* (ex.: "trabalho realizado", "ficheiros modificados", "problemas",
|
||||||
|
* "padrões detectados", "acções sugeridas").
|
||||||
|
*/
|
||||||
|
import { parse, type HTMLElement } from 'node-html-parser'
|
||||||
|
import type { SessionsDb, WorklogCommentRecord } from './db.js'
|
||||||
|
import { callMcpTool, extractMcpJsonPayload } from './mcp-client.js'
|
||||||
|
|
||||||
|
export interface ParsedWorklogComment {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawComment {
|
||||||
|
id: number
|
||||||
|
discussion_id: number
|
||||||
|
content: string
|
||||||
|
created: unknown
|
||||||
|
staff_id: number | null
|
||||||
|
children?: RawComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove whitespace redundante. */
|
||||||
|
function norm(s: string): string {
|
||||||
|
return s.replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converte string livre para chave de secção (lowercase, sem acentos, sem pontuação). */
|
||||||
|
function sectionKey(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9 ]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_WORK = new Set(['trabalho realizado', 'o que foi feito', 'feito', 'realizado', 'trabalho'])
|
||||||
|
const SECTION_FILES = new Set(['ficheiros modificados', 'ficheiros alterados', 'files modified', 'ficheiros'])
|
||||||
|
const SECTION_PROBLEMS = new Set(['problemas solucoes', 'problemas', 'solucoes', 'problemas e solucoes', 'problemas solucao'])
|
||||||
|
const SECTION_PATTERNS = new Set(['padroes detectados', 'padroes', 'patterns', 'insights'])
|
||||||
|
const SECTION_ACTIONS = new Set(['accoes sugeridas', 'accoes', 'acoes sugeridas', 'acoes', 'actions', 'accoes de melhoria'])
|
||||||
|
|
||||||
|
/** Extrai data ISO do título (YYYY-MM-DD [HH:MM]) ou devolve null. */
|
||||||
|
function parseDateFromTitle(title: string): string | null {
|
||||||
|
const m = title.match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
|
||||||
|
if (!m) return null
|
||||||
|
const [, y, mo, d, hh, mm] = m
|
||||||
|
if (hh && mm) return `${y}-${mo}-${d}T${hh}:${mm}:00Z`
|
||||||
|
return `${y}-${mo}-${d}T00:00:00Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tenta extrair "Tarefa: #ID" ou similar. */
|
||||||
|
function parseTaskRef(text: string): string | null {
|
||||||
|
const m = text.match(/(?:Tarefa|Task|Ticket)[:\s]*(#?\d+)/i)
|
||||||
|
if (m) return m[1].startsWith('#') ? m[1] : `#${m[1]}`
|
||||||
|
const bare = text.match(/#(\d{3,6})/)
|
||||||
|
return bare ? `#${bare[1]}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "~2h 30m" / "~45 min" / "5 minutos" → segundos. */
|
||||||
|
function parseDuration(text: string): number | null {
|
||||||
|
const m = text.match(/~?\s*(\d+)\s*h\s*(\d+)?\s*m?/i)
|
||||||
|
if (m) {
|
||||||
|
const h = parseInt(m[1], 10)
|
||||||
|
const mm = m[2] ? parseInt(m[2], 10) : 0
|
||||||
|
return h * 3600 + mm * 60
|
||||||
|
}
|
||||||
|
const mm = text.match(/~?\s*(\d+)\s*(?:min|minutos|m)/i)
|
||||||
|
if (mm) return parseInt(mm[1], 10) * 60
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrai texto de um elemento, incluindo inner HTML como plain text. */
|
||||||
|
function textOf(el: HTMLElement): string {
|
||||||
|
return norm(el.text ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colecta items de uma UL ou lista no mesmo nível que vem depois de um cabeçalho. */
|
||||||
|
function collectFollowingListItems(heading: HTMLElement): string[] {
|
||||||
|
const items: string[] = []
|
||||||
|
let cur: HTMLElement | null = heading.nextElementSibling
|
||||||
|
while (cur) {
|
||||||
|
const tag = cur.rawTagName?.toLowerCase()
|
||||||
|
if (tag && /^h[1-6]$/.test(tag)) break
|
||||||
|
if (tag === 'ul' || tag === 'ol') {
|
||||||
|
for (const li of cur.querySelectorAll('li')) {
|
||||||
|
const t = textOf(li)
|
||||||
|
if (t) items.push(t)
|
||||||
|
}
|
||||||
|
} else if (tag === 'p') {
|
||||||
|
// Alguns comentários partem o UL em múltiplos <p>; vasculha <li> dentro
|
||||||
|
for (const li of cur.querySelectorAll('li')) {
|
||||||
|
const t = textOf(li)
|
||||||
|
if (t) items.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur = cur.nextElementSibling
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse item "[Tipo] descrição" ou "Tipo: descrição (Px)". */
|
||||||
|
function parseActionItem(raw: string): { tipo: string; descricao: string; prioridade: string | null } {
|
||||||
|
// Remove checkbox inicial "[ ]" ou "[x]" se existir
|
||||||
|
let s = raw.trim().replace(/^\[[\s xX✓]\]\s*/, '')
|
||||||
|
const bracket = s.match(/^\[([^\]]+)\]\s*(.+)$/)
|
||||||
|
let tipo = 'Geral'
|
||||||
|
let rest = s
|
||||||
|
if (bracket) {
|
||||||
|
tipo = bracket[1].trim()
|
||||||
|
rest = bracket[2].trim()
|
||||||
|
}
|
||||||
|
const prio = rest.match(/\b(P[0-4])\b/i)
|
||||||
|
return {
|
||||||
|
tipo,
|
||||||
|
descricao: rest,
|
||||||
|
prioridade: prio ? prio[1].toUpperCase() : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse problema/solução. Heurística: "Problema: X | Solução: Y" ou pares de <li>. */
|
||||||
|
function parseProblemItem(raw: string): { problema: string; solucao: string } {
|
||||||
|
const s = raw.trim()
|
||||||
|
const split = s.split(/\s*(?:->|→|\|\s*Solu[çc][ãa]o:|\s*Solu[çc][ãa]o:)\s*/i)
|
||||||
|
if (split.length >= 2) {
|
||||||
|
return {
|
||||||
|
problema: split[0].replace(/^Problema:\s*/i, '').trim(),
|
||||||
|
solucao: split.slice(1).join(' ').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { problema: s, solucao: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrai lista "bruta" de todas as <li> dentro do HTML (fallback). */
|
||||||
|
function extractAllLiItems(root: HTMLElement): string[] {
|
||||||
|
return root
|
||||||
|
.querySelectorAll('li')
|
||||||
|
.map((li) => textOf(li))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorklogHtml(
|
||||||
|
html: string,
|
||||||
|
meta: { id: number; discussion_id: number; created_at: string; staff_id?: number | null },
|
||||||
|
): ParsedWorklogComment {
|
||||||
|
const root = parse(html || '')
|
||||||
|
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||||
|
|
||||||
|
// Título: primeiro heading não vazio
|
||||||
|
let title: string | null = null
|
||||||
|
for (const h of headings) {
|
||||||
|
const t = textOf(h)
|
||||||
|
if (t) { title = t; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data: preferir `meta.created_at` se válido; senão extrair do título ou do texto
|
||||||
|
let createdAt = meta.created_at
|
||||||
|
if (!createdAt || createdAt === '1970-01-01T00:00:00.000Z' || createdAt.startsWith('1970')) {
|
||||||
|
const fromTitle = title ? parseDateFromTitle(title) : null
|
||||||
|
if (fromTitle) createdAt = fromTitle
|
||||||
|
else {
|
||||||
|
const fromText = parseDateFromTitle(textOf(root).slice(0, 500))
|
||||||
|
createdAt = fromText ?? new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullText = textOf(root)
|
||||||
|
const taskRef = parseTaskRef(fullText)
|
||||||
|
const durationSec = parseDuration(fullText)
|
||||||
|
|
||||||
|
// Indexa secções por chave normalizada
|
||||||
|
const sections = new Map<string, HTMLElement>()
|
||||||
|
for (const h of headings) {
|
||||||
|
const key = sectionKey(textOf(h))
|
||||||
|
if (!sections.has(key)) sections.set(key, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSection(target: Set<string>): HTMLElement | null {
|
||||||
|
for (const [k, el] of sections) {
|
||||||
|
if (target.has(k)) return el
|
||||||
|
}
|
||||||
|
// match parcial (ex.: "trabalho realizado manutenção" — começa com)
|
||||||
|
for (const [k, el] of sections) {
|
||||||
|
for (const t of target) {
|
||||||
|
if (k.startsWith(t) || t.startsWith(k)) return el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const workHeading = findSection(SECTION_WORK)
|
||||||
|
const filesHeading = findSection(SECTION_FILES)
|
||||||
|
const problemsHeading = findSection(SECTION_PROBLEMS)
|
||||||
|
const patternsHeading = findSection(SECTION_PATTERNS)
|
||||||
|
const actionsHeading = findSection(SECTION_ACTIONS)
|
||||||
|
|
||||||
|
const workItems = workHeading ? collectFollowingListItems(workHeading) : []
|
||||||
|
const filesModified = filesHeading ? collectFollowingListItems(filesHeading) : []
|
||||||
|
const problemsRaw = problemsHeading ? collectFollowingListItems(problemsHeading) : []
|
||||||
|
const patternsText = patternsHeading ? collectFollowingListItems(patternsHeading) : []
|
||||||
|
const actionsRaw = actionsHeading ? collectFollowingListItems(actionsHeading) : []
|
||||||
|
|
||||||
|
// Fallback: se nenhuma secção encontrada mas existem <li>, e a discussão é #33,
|
||||||
|
// tratar tudo como acções (formato diferente das outras discussões)
|
||||||
|
let actions = actionsRaw.map(parseActionItem)
|
||||||
|
if (meta.discussion_id === 33 && actions.length === 0) {
|
||||||
|
actions = extractAllLiItems(root).map(parseActionItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = problemsRaw.map(parseProblemItem)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
discussion_id: meta.discussion_id,
|
||||||
|
created_at: createdAt,
|
||||||
|
staff_id: meta.staff_id ?? null,
|
||||||
|
title,
|
||||||
|
task_ref: taskRef,
|
||||||
|
duration_sec: durationSec,
|
||||||
|
work_items: workItems,
|
||||||
|
files_modified: filesModified,
|
||||||
|
problems,
|
||||||
|
patterns_text: patternsText,
|
||||||
|
actions,
|
||||||
|
raw_html: html,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converte o campo `created` devolvido pelo Desk MCP (pode ser objecto vazio). */
|
||||||
|
function normalizeMcpDate(v: unknown): string {
|
||||||
|
if (!v) return ''
|
||||||
|
if (typeof v === 'string') return v
|
||||||
|
if (typeof v === 'object') {
|
||||||
|
const obj = v as Record<string, unknown>
|
||||||
|
if (typeof obj.date === 'string') return obj.date
|
||||||
|
if (typeof obj.datetime === 'string') return obj.datetime
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Achata a árvore de comentários (comentários com children recursivos). */
|
||||||
|
function flattenComments(comments: RawComment[]): RawComment[] {
|
||||||
|
const out: RawComment[] = []
|
||||||
|
for (const c of comments) {
|
||||||
|
out.push(c)
|
||||||
|
if (c.children && c.children.length) {
|
||||||
|
out.push(...flattenComments(c.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
discussion_id: number
|
||||||
|
fetched: number
|
||||||
|
imported: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importa todos os comentários de uma discussão Desk. Paginação por `limit`/`offset`.
|
||||||
|
* Idempotente por `id` — comentários já existentes sofrem update (raw_html pode mudar).
|
||||||
|
*/
|
||||||
|
export async function importWorklogDiscussion(
|
||||||
|
db: SessionsDb,
|
||||||
|
discussionId: number,
|
||||||
|
opts: { sinceIso?: string; pageSize?: number; maxPages?: number } = {},
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
// O MCP desk-crm parece clampar resultados em 200/página independentemente do limit.
|
||||||
|
// Pedimos 200 e iteramos offset até a resposta vir vazia.
|
||||||
|
const pageSize = opts.pageSize ?? 200
|
||||||
|
const maxPages = opts.maxPages ?? 20
|
||||||
|
const result: ImportResult = {
|
||||||
|
discussion_id: discussionId,
|
||||||
|
fetched: 0,
|
||||||
|
imported: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
for (let page = 0; page < maxPages; page++) {
|
||||||
|
const raw = await callMcpTool('get_discussion_comments', {
|
||||||
|
discussion_id: discussionId,
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
const payload = extractMcpJsonPayload<{
|
||||||
|
success?: boolean
|
||||||
|
comments?: RawComment[]
|
||||||
|
}>(raw)
|
||||||
|
const pageComments = flattenComments(payload.comments ?? [])
|
||||||
|
if (pageComments.length === 0) break
|
||||||
|
result.fetched += pageComments.length
|
||||||
|
|
||||||
|
const importedAt = new Date().toISOString()
|
||||||
|
for (const c of pageComments) {
|
||||||
|
try {
|
||||||
|
const createdStr = normalizeMcpDate(c.created)
|
||||||
|
const parsed = parseWorklogHtml(c.content ?? '', {
|
||||||
|
id: c.id,
|
||||||
|
discussion_id: c.discussion_id ?? discussionId,
|
||||||
|
created_at: createdStr || '',
|
||||||
|
staff_id: c.staff_id,
|
||||||
|
})
|
||||||
|
if (opts.sinceIso && parsed.created_at < opts.sinceIso) {
|
||||||
|
result.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const record: WorklogCommentRecord = {
|
||||||
|
...parsed,
|
||||||
|
imported_at: importedAt,
|
||||||
|
}
|
||||||
|
const { inserted } = db.upsertWorklogComment(record)
|
||||||
|
if (inserted) result.imported++
|
||||||
|
else result.updated++
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[worklog-import] erro a parsear comentário #${c.id}:`, (e as Error).message)
|
||||||
|
result.errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avança offset; quando próxima página vier vazia, o while quebra na próxima iter.
|
||||||
|
offset += pageComments.length
|
||||||
|
// Safety: se MCP devolveu 0, para
|
||||||
|
if (pageComments.length === 0) break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -67,6 +67,44 @@ describe('parseSessionFile', () => {
|
|||||||
expect(result.meta.skills_invoked).toContain('superpowers:brainstorming')
|
expect(result.meta.skills_invoked).toContain('superpowers:brainstorming')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('detecta skill invocation em tool_result.content (string)', async () => {
|
||||||
|
const path = writeJsonl([
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
timestamp: '2026-04-23T10:00:00Z',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_result', tool_use_id: 'abc', content: 'Launching skill: infraestrutura:easypanel-monitor\nOther log output' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await parseSessionFile(path)
|
||||||
|
expect(result.meta.skills_invoked).toContain('infraestrutura:easypanel-monitor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detecta skill invocation em tool_result.content (array de text blocks)', async () => {
|
||||||
|
const path = writeJsonl([
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
timestamp: '2026-04-23T10:00:00Z',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'abc',
|
||||||
|
content: [{ type: 'text', text: 'Launching skill: superpowers:brainstorming' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await parseSessionFile(path)
|
||||||
|
expect(result.meta.skills_invoked).toContain('superpowers:brainstorming')
|
||||||
|
})
|
||||||
|
|
||||||
it('ignora linhas JSON inválidas silenciosamente', async () => {
|
it('ignora linhas JSON inválidas silenciosamente', async () => {
|
||||||
const path = writeJsonl([
|
const path = writeJsonl([
|
||||||
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'válido' }] } },
|
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'válido' }] } },
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mkdtempSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { openSessionsDb, type SessionsDb, type WorklogCommentRecord } from '../services/sessions/db.js'
|
||||||
|
import { parseWorklogHtml } from '../services/sessions/worklog-import.js'
|
||||||
|
import { detectActionsNeverExecuted, weekRange } from '../services/sessions/patterns.js'
|
||||||
|
|
||||||
|
const SAMPLE_H4 = `
|
||||||
|
<h4>2026-04-15 10:30 - Refactor API sessions</h4>
|
||||||
|
<p><strong>Projecto:</strong> DashDescomplicar</p>
|
||||||
|
<p><strong>Tarefa:</strong> #2059 - Observabilidade Espelho</p>
|
||||||
|
<p><strong>Duração:</strong> ~2h 15m</p>
|
||||||
|
<h4>Trabalho Realizado</h4>
|
||||||
|
<ul><li>Criar módulo worklog-import</li><li>Integrar detectores cruzados</li></ul>
|
||||||
|
<h4>Ficheiros Modificados</h4>
|
||||||
|
<ul><li><code>api/services/sessions/db.ts</code></li><li><code>api/scripts/sessions-worklog-import.ts</code></li></ul>
|
||||||
|
<h4>Problemas / Soluções</h4>
|
||||||
|
<ul><li>Parser HTML frágil → usar node-html-parser</li></ul>
|
||||||
|
<h4>Padrões Detectados</h4>
|
||||||
|
<ul><li>MCP gateway responde em SSE ou JSON</li></ul>
|
||||||
|
<h4>Acções Sugeridas</h4>
|
||||||
|
<ul><li>[Refactor] Extrair callMcpTool para módulo partilhado P2</li></ul>
|
||||||
|
`
|
||||||
|
|
||||||
|
const SAMPLE_H2 = `
|
||||||
|
<h2>2026-01-31 - Estratégia Stack</h2>
|
||||||
|
<p><strong>Duração:</strong> ~2h</p>
|
||||||
|
<h3>Trabalho Realizado</h3>
|
||||||
|
<ul><li>Stack Mapeado - 15 sistemas</li></ul>
|
||||||
|
<h3>Insights</h3>
|
||||||
|
<ul><li>Posicionamento: Marketing alta performance</li></ul>
|
||||||
|
`
|
||||||
|
|
||||||
|
const SAMPLE_D33 = `
|
||||||
|
<ul>
|
||||||
|
<li>[ ] [MCP] Corrigir bug desk-crm-v3 com tabelas de discussões</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Origem:</strong> Sessão 2026-02-02</p>
|
||||||
|
<p><strong>Prioridade:</strong> P1</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseWorklogHtml', () => {
|
||||||
|
it('extrai campos de comentário formato <h4>', () => {
|
||||||
|
const parsed = parseWorklogHtml(SAMPLE_H4, { id: 100, discussion_id: 31, created_at: '' })
|
||||||
|
expect(parsed.id).toBe(100)
|
||||||
|
expect(parsed.title).toMatch(/2026-04-15/)
|
||||||
|
expect(parsed.task_ref).toBe('#2059')
|
||||||
|
expect(parsed.duration_sec).toBe(2 * 3600 + 15 * 60)
|
||||||
|
expect(parsed.work_items.length).toBe(2)
|
||||||
|
expect(parsed.files_modified.length).toBe(2)
|
||||||
|
expect(parsed.patterns_text.length).toBe(1)
|
||||||
|
expect(parsed.actions.length).toBe(1)
|
||||||
|
expect(parsed.actions[0].tipo).toBe('Refactor')
|
||||||
|
expect(parsed.actions[0].prioridade).toBe('P2')
|
||||||
|
expect(parsed.created_at.startsWith('2026-04-15')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extrai campos de comentário formato <h2>/<h3> (legacy)', () => {
|
||||||
|
const parsed = parseWorklogHtml(SAMPLE_H2, { id: 64, discussion_id: 31, created_at: '' })
|
||||||
|
expect(parsed.title).toMatch(/2026-01-31/)
|
||||||
|
expect(parsed.work_items.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(parsed.duration_sec).toBe(2 * 3600)
|
||||||
|
expect(parsed.created_at.startsWith('2026-01-31')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extrai acções em formato discussão #33 (lista crua)', () => {
|
||||||
|
const parsed = parseWorklogHtml(SAMPLE_D33, { id: 200, discussion_id: 33, created_at: '2026-02-02T00:00:00Z' })
|
||||||
|
expect(parsed.actions.length).toBe(1)
|
||||||
|
expect(parsed.actions[0].tipo).toBe('MCP')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('upsertWorklogComment idempotência', () => {
|
||||||
|
let db: SessionsDb
|
||||||
|
beforeEach(() => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'obs-wl-'))
|
||||||
|
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('insert primeiro, update depois', () => {
|
||||||
|
const base: WorklogCommentRecord = {
|
||||||
|
id: 42,
|
||||||
|
discussion_id: 31,
|
||||||
|
created_at: '2026-04-15T10:30:00Z',
|
||||||
|
staff_id: 25,
|
||||||
|
title: 'Test',
|
||||||
|
task_ref: '#100',
|
||||||
|
duration_sec: 600,
|
||||||
|
work_items: ['a'],
|
||||||
|
files_modified: [],
|
||||||
|
problems: [],
|
||||||
|
patterns_text: [],
|
||||||
|
actions: [],
|
||||||
|
raw_html: '<h4>Test</h4>',
|
||||||
|
imported_at: '2026-04-23T00:00:00Z',
|
||||||
|
}
|
||||||
|
const r1 = db.upsertWorklogComment(base)
|
||||||
|
expect(r1.inserted).toBe(true)
|
||||||
|
expect(db.countWorklogComments()).toBe(1)
|
||||||
|
const r2 = db.upsertWorklogComment({ ...base, title: 'Updated' })
|
||||||
|
expect(r2.inserted).toBe(false)
|
||||||
|
expect(db.countWorklogComments()).toBe(1)
|
||||||
|
const list = db.listWorklogComments({ discussion_id: 31 })
|
||||||
|
expect(list[0].title).toBe('Updated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detectActionsNeverExecuted', () => {
|
||||||
|
let db: SessionsDb
|
||||||
|
beforeEach(() => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'obs-act-'))
|
||||||
|
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sinaliza acções P1/P2 antigas sem execução', () => {
|
||||||
|
const old = new Date('2026-03-01T00:00:00Z').toISOString()
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
db.upsertWorklogComment({
|
||||||
|
id: 300 + i,
|
||||||
|
discussion_id: 33,
|
||||||
|
created_at: old,
|
||||||
|
staff_id: 25,
|
||||||
|
title: `Acção ${i}`,
|
||||||
|
task_ref: `#${1000 + i}`,
|
||||||
|
duration_sec: null,
|
||||||
|
work_items: [],
|
||||||
|
files_modified: [],
|
||||||
|
problems: [],
|
||||||
|
patterns_text: [],
|
||||||
|
actions: [{ tipo: 'MCP', descricao: `Corrigir bug X${i}`, prioridade: i % 2 ? 'P1' : 'P2' }],
|
||||||
|
raw_html: '',
|
||||||
|
imported_at: '2026-04-23T00:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const range = weekRange(new Date('2026-04-22T00:00:00Z'))
|
||||||
|
const patterns = detectActionsNeverExecuted({
|
||||||
|
db: db.rawDb(),
|
||||||
|
weekStartIso: range.start.toISOString(),
|
||||||
|
weekEndIso: range.end.toISOString(),
|
||||||
|
})
|
||||||
|
expect(patterns.length).toBe(1)
|
||||||
|
expect(patterns[0].pattern_key).toBe('actions_never_executed')
|
||||||
|
expect(patterns[0].affected_count).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('não sinaliza se acções recentes (<14 dias)', () => {
|
||||||
|
const recent = new Date().toISOString()
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
db.upsertWorklogComment({
|
||||||
|
id: 400 + i,
|
||||||
|
discussion_id: 33,
|
||||||
|
created_at: recent,
|
||||||
|
staff_id: 25,
|
||||||
|
title: null,
|
||||||
|
task_ref: null,
|
||||||
|
duration_sec: null,
|
||||||
|
work_items: [],
|
||||||
|
files_modified: [],
|
||||||
|
problems: [],
|
||||||
|
patterns_text: [],
|
||||||
|
actions: [{ tipo: 'MCP', descricao: 'x', prioridade: 'P1' }],
|
||||||
|
raw_html: '',
|
||||||
|
imported_at: recent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const range = weekRange(new Date())
|
||||||
|
const patterns = detectActionsNeverExecuted({
|
||||||
|
db: db.rawDb(),
|
||||||
|
weekStartIso: range.start.toISOString(),
|
||||||
|
weekEndIso: range.end.toISOString(),
|
||||||
|
})
|
||||||
|
expect(patterns.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Generated
+136
-3
@@ -21,6 +21,7 @@
|
|||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mysql2": "^3.11.5",
|
"mysql2": "^3.11.5",
|
||||||
|
"node-html-parser": "^7.1.0",
|
||||||
"oidc-client-ts": "^3.0.1",
|
"oidc-client-ts": "^3.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
@@ -3414,6 +3415,12 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
@@ -3905,6 +3912,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||||
@@ -3919,6 +3942,18 @@
|
|||||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -4384,6 +4419,73 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dom-serializer/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -5837,6 +5939,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/he": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"he": "bin/he"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -7371,6 +7482,16 @@
|
|||||||
"url": "https://opencollective.com/node-fetch"
|
"url": "https://opencollective.com/node-fetch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-html-parser": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"he": "1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -7390,6 +7511,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -7834,9 +7967,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mysql2": "^3.11.5",
|
"mysql2": "^3.11.5",
|
||||||
|
"node-html-parser": "^7.1.0",
|
||||||
"oidc-client-ts": "^3.0.1",
|
"oidc-client-ts": "^3.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
+2
-1
@@ -5,8 +5,9 @@ export const oidcConfig = {
|
|||||||
client_id: 'OKRSM2FZeSxJDhoV9e17dGRU1L1NEE1JBdnPVWTO',
|
client_id: 'OKRSM2FZeSxJDhoV9e17dGRU1L1NEE1JBdnPVWTO',
|
||||||
redirect_uri: window.location.origin + '/callback',
|
redirect_uri: window.location.origin + '/callback',
|
||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email offline_access',
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
automaticSilentRenew: true,
|
||||||
onSigninCallback: () => {
|
onSigninCallback: () => {
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
DESK_API_TOKEN=coloca-token-aqui
|
MCP_GATEWAY_TOKEN=coloca-token-aqui
|
||||||
DESK_BASE_URL=https://desk.descomplicar.pt
|
MCP_GATEWAY_URL=https://gateway.descomplicar.pt/v1/desk-crm/mcp
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Observabilidade — import diário de worklogs Desk (#31/#32/#33)
|
||||||
|
After=default.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/media/ealmeida/Dados/Dev/DashDescomplicar
|
||||||
|
ExecStart=/home/ealmeida/.nvm/versions/node/v22.22.2/bin/npx tsx api/scripts/sessions-worklog-import.ts --discussion all --since-days 7
|
||||||
|
Environment="OBSERVABILIDADE_DB=/home/ealmeida/.claude-work/sessions.db"
|
||||||
|
Environment="PATH=/home/ealmeida/.nvm/versions/node/v22.22.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
EnvironmentFile=/home/ealmeida/.claude-work/observabilidade-patterns.env
|
||||||
|
StandardOutput=append:/home/ealmeida/.claude-work/observabilidade-worklog-import.log
|
||||||
|
StandardError=append:/home/ealmeida/.claude-work/observabilidade-worklog-import.log
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Observabilidade — import diário de worklogs Desk
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
OnCalendar=*-*-* 03:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in New Issue
Block a user