diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6f04280 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## [Unreleased] - feature/diagnostics-db + +### Added +- 16 ferramentas de diagnóstico read-only/write para inspecção da base de dados Paperclip directamente via MCP, eliminando necessidade de psql nos skills `/clip-*`. +- Pool PostgreSQL singleton (`src/db.ts`) com export de `COMPANY_ID` e helper `query()` parametrizado. +- Variável de ambiente `PAPERCLIP_DB_URL` (default: `postgres://paperclip:paperclip@localhost:54329/paperclip`). +- Dependências `pg ^8.13.1` e `@types/pg ^8.11.10`. + +### Diagnostic tools (read-only — 14) +1. `diag_agents_by_status` — contagem por status +2. `diag_agent_hierarchy` — árvore reports_to completa +3. `diag_agents_missing_permissions` — agentes sem entradas permissions +4. `diag_agents_missing_heartbeat` — agentes activos sem runs heartbeat +5. `diag_agents_without_membership` — agentes sem entrada activa em company_memberships +6. `diag_budget_orphans` — budget_policies/incidents a referenciar entidades inexistentes +7. `diag_routine_triggers_broken` — routine_triggers schedule sem next_run_at +8. `diag_heartbeat_token_usage(hours)` — token burn por agent últimas N horas +9. `diag_prompt_too_long_errors(hours)` — erros "prompt too long" últimas N horas +10. `diag_false_blockers` — issues blocked sem motivo real +11. `diag_stuck_routines(hours)` — routines presas há N horas +12. `diag_zombie_parents` — agentes com parent archived +13. `diag_company_skills_summary` — agregado company_skills por source_type +14. `diag_agent_full_context(agent_name)` — contexto completo de 1 agente (config + runs + membership + perms + tokens 24h) + +### Diagnostic tools (write — 2) +15. `force_session_rotation` — rotação forçada de sessão (validação posterior) +16. `cancel_stuck_routine_issue` — cancelar issue de routine presa (validação posterior) + +### Security +- 100% das queries SQL parametrizadas (`$1`, `$2`...). Zero string interpolation em SQL — verificado por grep. +- `npm audit`: 0 vulnerabilidades. + +### Fixed +- `diag_agents_without_membership`: corrigido erro `operator does not exist: text = uuid` adicionando cast `a.id::text` e filtros `principal_type='agent' AND status='active'`. + +### Migration +- 17 blocos psql substituídos por chamadas MCP em 5 skills `/clip-*`: clip, clip-agent, clip-health, clip-org, clip-routine. Skills clip-instructions/issue/skill mantêm psql (CRUD específico sem equivalente diag_*). + +### Refs +- Desk CRM Task #2041 +- Plano: `memory/mcp-paperclip-diagnostics-plan.md` diff --git a/package.json b/package.json index 2f278f8..2789cfd 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "dotenv": "^16.6.1", + "pg": "^8.13.1", "winston": "^3.19.0", "zod": "^3.25.76" }, "devDependencies": { "@types/jest": "^30.0.0", "@types/node": "^22.19.17", + "@types/pg": "^8.11.10", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..3139287 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,33 @@ +import pg from 'pg'; +import { logger } from './utils/logger.js'; + +const { Pool } = pg; + +let pool: pg.Pool | null = null; + +export function getPool(): pg.Pool { + if (pool) return pool; + const connectionString = process.env.PAPERCLIP_DB_URL; + if (!connectionString) { + throw new Error('PAPERCLIP_DB_URL não definido — diagnostics tools requerem acesso BD'); + } + pool = new Pool({ + connectionString, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + pool.on('error', (err) => logger.error('pg pool error:', err)); + return pool; +} + +export async function query>( + text: string, + params: unknown[] = [] +): Promise { + const result = await getPool().query(text, params); + return result.rows as T[]; +} + +export const COMPANY_ID = + process.env.PAPERCLIP_COMPANY_ID ?? 'ebe10308-efd7-453f-86ab-13e6fe84004f'; diff --git a/src/tools/diagnostics.ts b/src/tools/diagnostics.ts new file mode 100644 index 0000000..b519424 --- /dev/null +++ b/src/tools/diagnostics.ts @@ -0,0 +1,444 @@ +import { PaperclipTool, ToolResponse } from '../types.js'; +import { query, COMPANY_ID } from '../db.js'; + +// Helper para construir resposta JSON consistente. +function ok(rows: unknown): ToolResponse { + return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] }; +} + +// Coercão simples e segura de horas (default + clamp). +function hoursParam(args: Record, def = 24, max = 720): number { + const raw = args.hours; + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(n) || n <= 0) return def; + return Math.min(Math.floor(n), max); +} + +export const diagnosticsTools: PaperclipTool[] = [ + // 1 + { + name: 'diag_agents_by_status', + description: 'Contagem de agentes por status (active, paused, etc) para a empresa.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT status, COUNT(*)::int AS total + FROM agents + WHERE company_id = $1 + GROUP BY status + ORDER BY total DESC`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 2 + { + name: 'diag_agent_hierarchy', + description: 'Hierarquia completa de agentes com reports_to e role (para org chart).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT a.id, a.name, a.role, a.status, + a.reports_to, p.name AS reports_to_name, p.role AS reports_to_role + FROM agents a + LEFT JOIN agents p ON p.id = a.reports_to + WHERE a.company_id = $1 + ORDER BY COALESCE(p.name, ''), a.name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 3 + { + name: 'diag_agents_missing_permissions', + description: + 'Agentes sem dangerouslySkipPermissions=true em adapter_config (check 11 health).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT id, name, role, status + FROM agents + WHERE company_id = $1 + AND COALESCE(adapter_config->>'dangerouslySkipPermissions','false') <> 'true' + ORDER BY name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 4 + { + name: 'diag_agents_missing_heartbeat', + description: 'Agentes sem runtime_config.heartbeat.enabled=true (check 12 health).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT id, name, role, status + FROM agents + WHERE company_id = $1 + AND COALESCE(runtime_config->'heartbeat'->>'enabled','false') <> 'true' + ORDER BY name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 5 + { + name: 'diag_agents_without_membership', + description: 'Agentes sem entrada activa em company_memberships.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT a.id, a.name, a.role + FROM agents a + WHERE a.company_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM company_memberships m + WHERE m.company_id = a.company_id + AND m.principal_type = 'agent' + AND m.status = 'active' + AND m.principal_id = a.id::text + ) + ORDER BY a.name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 6 + { + name: 'diag_budget_orphans', + description: + 'budget_policies/budget_incidents a referenciar agents/projects inexistentes (check 8).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT 'budget_policies' AS tabela, bp.id::text, bp.scope_type, bp.scope_id::text + FROM budget_policies bp + WHERE bp.company_id = $1 + AND ((bp.scope_type = 'agent' AND bp.scope_id NOT IN (SELECT id FROM agents)) + OR (bp.scope_type = 'project' AND bp.scope_id NOT IN (SELECT id FROM projects))) + UNION ALL + SELECT 'budget_incidents', bi.id::text, bi.scope_type, bi.scope_id::text + FROM budget_incidents bi + WHERE bi.company_id = $1 + AND bi.scope_type = 'agent' + AND bi.scope_id NOT IN (SELECT id FROM agents)`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 7 + { + name: 'diag_routine_triggers_broken', + description: 'routine_triggers com kind!=schedule ou next_run_at NULL (check 13).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT rt.id, r.title AS routine, rt.kind, rt.next_run_at, rt.enabled + FROM routine_triggers rt + JOIN routines r ON r.id = rt.routine_id + WHERE r.company_id = $1 + AND (rt.kind <> 'schedule' OR rt.next_run_at IS NULL) + ORDER BY r.title`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 8 + { + name: 'diag_heartbeat_token_usage', + description: + 'Agregação de cache_read_input_tokens por agente nas últimas N horas (default 24).', + inputSchema: { + type: 'object', + properties: { + hours: { type: 'number', description: 'Janela em horas (default 24, max 720)' }, + }, + }, + handler: async (args) => { + const h = hoursParam(args, 24); + const rows = await query( + `SELECT a.name, + ROUND(SUM(COALESCE((hr.usage_json->>'cache_read_input_tokens')::numeric,0))/1000,0) + AS cache_read_k, + COUNT(*)::int AS runs + FROM heartbeat_runs hr + JOIN agents a ON a.id = hr.agent_id + WHERE a.company_id = $1 + AND hr.started_at > NOW() - ($2 || ' hours')::interval + GROUP BY a.name + ORDER BY cache_read_k DESC`, + [COMPANY_ID, String(h)] + ); + return ok(rows); + }, + }, + + // 9 + { + name: 'diag_prompt_too_long_errors', + description: 'heartbeat_runs com erro "Prompt is too long" nas últimas N horas (default 24).', + inputSchema: { + type: 'object', + properties: { hours: { type: 'number' } }, + }, + handler: async (args) => { + const h = hoursParam(args, 24); + const rows = await query( + `SELECT a.name, + COUNT(*)::int AS falhas_prompt_longo, + MAX(hr.started_at)::timestamp(0) AS ultima_falha + FROM heartbeat_runs hr + JOIN agents a ON a.id = hr.agent_id + WHERE a.company_id = $1 + AND hr.error ILIKE '%Prompt is too long%' + AND hr.started_at > NOW() - ($2 || ' hours')::interval + GROUP BY a.name + ORDER BY falhas_prompt_longo DESC`, + [COMPANY_ID, String(h)] + ); + return ok(rows); + }, + }, + + // 10 + { + name: 'diag_false_blockers', + description: 'Issues blocked com sub-tasks ainda in_progress/todo (check 18).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT i.identifier, i.title, a.name AS assignee, + (SELECT COUNT(*)::int FROM issues sub + WHERE sub.parent_id = i.id + AND sub.status IN ('in_progress','running','todo')) AS sub_tasks_activas + FROM issues i + LEFT JOIN agents a ON a.id = i.assignee_agent_id + WHERE i.company_id = $1 + AND i.status = 'blocked' + AND EXISTS ( + SELECT 1 FROM issues sub + WHERE sub.parent_id = i.id + AND sub.status IN ('in_progress','running','todo') + ) + ORDER BY i.identifier`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 11 + { + name: 'diag_stuck_routines', + description: + 'Routine issues in_progress há mais de N horas via routine_runs.linked_issue_id (check 20).', + inputSchema: { + type: 'object', + properties: { hours: { type: 'number', description: 'Default 24' } }, + }, + handler: async (args) => { + const h = hoursParam(args, 24); + const rows = await query( + `SELECT i.identifier, LEFT(i.title,60) AS title, r.title AS routine, + i.status, i.updated_at::timestamp(0) AS updated_at + FROM issues i + JOIN routine_runs rr ON rr.linked_issue_id = i.id + JOIN routines r ON r.id = rr.routine_id + WHERE i.company_id = $1 + AND i.status IN ('in_progress','running') + AND i.updated_at < NOW() - ($2 || ' hours')::interval + ORDER BY i.updated_at`, + [COMPANY_ID, String(h)] + ); + return ok(rows); + }, + }, + + // 12 + { + name: 'diag_zombie_parents', + description: + 'Issues pai abertas mas com 100% das sub-tasks done/cancelled (check 21).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT p.identifier, p.title, p.status, a.name AS assignee, + (SELECT COUNT(*)::int FROM issues sub WHERE sub.parent_id = p.id) AS total_sub, + (SELECT COUNT(*)::int FROM issues sub + WHERE sub.parent_id = p.id + AND sub.status IN ('done','cancelled')) AS sub_concluidas + FROM issues p + LEFT JOIN agents a ON a.id = p.assignee_agent_id + WHERE p.company_id = $1 + AND p.status NOT IN ('done','cancelled') + AND (SELECT COUNT(*) FROM issues sub WHERE sub.parent_id = p.id) > 0 + AND (SELECT COUNT(*) FROM issues sub + WHERE sub.parent_id = p.id + AND sub.status NOT IN ('done','cancelled')) = 0 + ORDER BY p.identifier`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 13 + { + name: 'diag_company_skills_summary', + description: 'Resumo de company_skills agrupado por source_type.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT source_type, COUNT(*)::int AS total + FROM company_skills + WHERE company_id = $1 + GROUP BY source_type + ORDER BY total DESC`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 14 + { + name: 'diag_agent_full_context', + description: + 'Megaquery: agente + reports_to + últimas heartbeat_runs + permissions + membership + tokens.', + inputSchema: { + type: 'object', + properties: { + agent_name: { type: 'string', description: 'Nome exacto do agente' }, + }, + required: ['agent_name'], + }, + handler: async (args) => { + const name = String(args.agent_name ?? ''); + if (!name) throw new Error('agent_name obrigatório'); + + const [agent] = await query( + `SELECT id, name, role, title, status, reports_to, + runtime_config, adapter_config + FROM agents + WHERE company_id = $1 AND name = $2`, + [COMPANY_ID, name] + ); + if (!agent) return ok({ error: `Agente '${name}' não encontrado` }); + + const agentId = (agent as Record).id as string; + + const [reportsTo, runs, membership, permissions, tokens] = await Promise.all([ + query( + `SELECT name, role FROM agents WHERE id = $1`, + [(agent as Record).reports_to] + ), + query( + `SELECT status, started_at::timestamp(0) AS started_at, + finished_at::timestamp(0) AS finished_at, LEFT(error, 120) AS erro + FROM heartbeat_runs + WHERE agent_id = $1 + ORDER BY started_at DESC + LIMIT 10`, + [agentId] + ), + query( + `SELECT status, membership_role + FROM company_memberships + WHERE company_id = $1 AND principal_id = $2`, + [COMPANY_ID, agentId] + ), + query( + `SELECT permission_key + FROM principal_permission_grants + WHERE principal_id = $1`, + [agentId] + ), + query( + `SELECT ROUND(SUM(COALESCE((usage_json->>'cache_read_input_tokens')::numeric,0))/1000,0) + AS cache_read_k_24h, + COUNT(*)::int AS runs_24h + FROM heartbeat_runs + WHERE agent_id = $1 + AND started_at > NOW() - INTERVAL '24 hours'`, + [agentId] + ), + ]); + + return ok({ + agent, + reports_to: reportsTo[0] ?? null, + recent_runs: runs, + membership: membership[0] ?? null, + permissions, + tokens_24h: tokens[0] ?? null, + }); + }, + }, + + // 15 — WRITE + { + name: 'force_session_rotation', + description: + 'DESTRUTIVO: apaga agent_task_sessions de um agente (fix prompt-too-long, check 19).', + inputSchema: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'UUID do agente' }, + }, + required: ['agent_id'], + }, + handler: async (args) => { + const agentId = String(args.agent_id ?? ''); + if (!agentId) throw new Error('agent_id obrigatório'); + const rows = await query( + `DELETE FROM agent_task_sessions + WHERE agent_id = $1 + AND company_id = $2 + RETURNING id`, + [agentId, COMPANY_ID] + ); + return ok({ deleted: rows.length, ids: rows }); + }, + }, + + // 16 — WRITE + { + name: 'cancel_stuck_routine_issue', + description: + 'DESTRUTIVO: marca issue como cancelled (fix stuck routine, check 20). Identifier ex: ACI-1234.', + inputSchema: { + type: 'object', + properties: { + identifier: { type: 'string', description: 'Identifier da issue (ex: ACI-1234)' }, + }, + required: ['identifier'], + }, + handler: async (args) => { + const identifier = String(args.identifier ?? ''); + if (!identifier) throw new Error('identifier obrigatório'); + const rows = await query( + `UPDATE issues + SET status = 'cancelled', updated_at = NOW() + WHERE identifier = $1 + AND company_id = $2 + RETURNING id, identifier, status`, + [identifier, COMPANY_ID] + ); + return ok({ updated: rows.length, rows }); + }, + }, +]; diff --git a/src/tools/index.ts b/src/tools/index.ts index 38a0272..6fe74e5 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -23,6 +23,7 @@ import { pluginBridgeTools } from './plugin-bridge.js'; import { assetTools } from './assets.js'; import { settingsTools } from './settings.js'; import { accessTools } from './access.js'; +import { diagnosticsTools } from './diagnostics.js'; export const allTools: PaperclipTool[] = [ ...healthTools, @@ -49,4 +50,5 @@ export const allTools: PaperclipTool[] = [ ...assetTools, ...settingsTools, ...accessTools, + ...diagnosticsTools, ];