Merge PR #1: 18 tools de diagnóstico DB + write tools (sessão 5)

This commit is contained in:
2026-04-07 05:03:08 +01:00
6 changed files with 613 additions and 4 deletions
+43
View File
@@ -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`
+2
View File
@@ -21,12 +21,14 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"pg": "^8.13.1",
"winston": "^3.19.0", "winston": "^3.19.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.19.17", "@types/node": "^22.19.17",
"@types/pg": "^8.11.10",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
+33
View File
@@ -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<T = Record<string, unknown>>(
text: string,
params: unknown[] = []
): Promise<T[]> {
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';
+525
View File
@@ -0,0 +1,525 @@
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<string, unknown>, 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_without_skip_permissions',
description:
'Agentes sem dangerouslySkipPermissions=true em adapter_config (NOTA: NÃO é RBAC — é flag Claude Code/CLI para correr sem prompts. Aplicar conscientemente por agent, não em massa).',
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<string, unknown>).id as string;
const [reportsTo, runs, membership, permissions, tokens] = await Promise.all([
query(
`SELECT name, role FROM agents WHERE id = $1`,
[(agent as Record<string, unknown>).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 });
},
},
// 17 — WRITE
{
name: 'ensure_agent_membership',
description:
'WRITE: garante membership active em company_memberships para um agent (idempotente). Fix achado #2 sessão 5.',
inputSchema: {
type: 'object',
properties: {
agent_id: { type: 'string', description: 'UUID do agente' },
membership_role: { type: 'string', description: 'Default "member"' },
},
required: ['agent_id'],
},
handler: async (args) => {
const agentId = String(args.agent_id ?? '');
const role = String(args.membership_role ?? 'member');
if (!agentId) throw new Error('agent_id obrigatório');
const existing = await query(
`SELECT id, status, membership_role FROM company_memberships
WHERE company_id = $1 AND principal_type = 'agent' AND principal_id = $2`,
[COMPANY_ID, agentId]
);
if (existing.length > 0) {
const rows = await query(
`UPDATE company_memberships
SET status = 'active', membership_role = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, status, membership_role`,
[role, existing[0].id]
);
return ok({ action: 'updated', row: rows[0] });
}
const rows = await query(
`INSERT INTO company_memberships
(company_id, principal_type, principal_id, status, membership_role)
VALUES ($1, 'agent', $2, 'active', $3)
RETURNING id, status, membership_role`,
[COMPANY_ID, agentId, role]
);
return ok({ action: 'inserted', row: rows[0] });
},
},
// 18 — WRITE
{
name: 'grant_agent_permission',
description:
'WRITE: insere row em principal_permission_grants para um agent (idempotente por permission_key). Baseline RBAC. Fix achado #4.',
inputSchema: {
type: 'object',
properties: {
agent_id: { type: 'string', description: 'UUID do agente' },
permission_key: { type: 'string', description: 'Ex: tasks:assign, agents:create, runs:read' },
scope: { type: 'string', description: 'Scope opcional (default null)' },
},
required: ['agent_id', 'permission_key'],
},
handler: async (args) => {
const agentId = String(args.agent_id ?? '');
const permissionKey = String(args.permission_key ?? '');
const scope = args.scope ? String(args.scope) : null;
if (!agentId || !permissionKey) throw new Error('agent_id e permission_key obrigatórios');
const existing = await query(
`SELECT id FROM principal_permission_grants
WHERE company_id = $1 AND principal_type = 'agent' AND principal_id = $2 AND permission_key = $3`,
[COMPANY_ID, agentId, permissionKey]
);
if (existing.length > 0) {
return ok({ action: 'noop', existing: existing[0] });
}
const rows = await query(
`INSERT INTO principal_permission_grants
(company_id, principal_type, principal_id, permission_key, scope, granted_by_user_id)
VALUES ($1, 'agent', $2, $3, $4, 'mcp-bootstrap')
RETURNING id, permission_key, scope`,
[COMPANY_ID, agentId, permissionKey, scope]
);
return ok({ action: 'inserted', row: rows[0] });
},
},
];
+2
View File
@@ -23,6 +23,7 @@ import { pluginBridgeTools } from './plugin-bridge.js';
import { assetTools } from './assets.js'; import { assetTools } from './assets.js';
import { settingsTools } from './settings.js'; import { settingsTools } from './settings.js';
import { accessTools } from './access.js'; import { accessTools } from './access.js';
import { diagnosticsTools } from './diagnostics.js';
export const allTools: PaperclipTool[] = [ export const allTools: PaperclipTool[] = [
...healthTools, ...healthTools,
@@ -49,4 +50,5 @@ export const allTools: PaperclipTool[] = [
...assetTools, ...assetTools,
...settingsTools, ...settingsTools,
...accessTools, ...accessTools,
...diagnosticsTools,
]; ];
+8 -4
View File
@@ -101,15 +101,19 @@ export const routineTools: PaperclipTool[] = [
}, },
{ {
name: 'create_routine_trigger', name: 'create_routine_trigger',
description: 'Criar um gatilho para uma rotina', description: 'Criar um gatilho para uma rotina. Para schedule passar kind="schedule" + schedule={cron,tz} + enabled + label.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
routine_id: { type: 'string', description: 'ID da rotina' }, routine_id: { type: 'string', description: 'ID da rotina' },
type: { type: 'string', description: 'Tipo de gatilho' }, kind: { type: 'string', description: 'schedule|webhook|api', enum: ['schedule', 'webhook', 'api'] },
config: { type: 'object', description: 'Configuracao do gatilho' }, cronExpression: { type: 'string', description: 'Para kind=schedule: cron 5-field (ex: "0 9 * * 1")' },
timezone: { type: 'string', description: 'Para kind=schedule: ex Europe/Lisbon' },
webhookSecret: { type: 'string', description: 'Para kind=webhook' },
enabled: { type: 'boolean', description: 'Estado activo (default true)' },
label: { type: 'string', description: 'Label legivel' },
}, },
required: ['routine_id'], required: ['routine_id', 'kind'],
}, },
handler: async (args) => { handler: async (args) => {
const { routine_id, ...body } = args as { routine_id: string; [k: string]: unknown }; const { routine_id, ...body } = args as { routine_id: string; [k: string]: unknown };