Merge PR #1: 18 tools de diagnóstico DB + write tools (sessão 5)
This commit is contained in:
@@ -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`
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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] });
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -101,15 +101,19 @@ export const routineTools: PaperclipTool[] = [
|
||||
},
|
||||
{
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
routine_id: { type: 'string', description: 'ID da rotina' },
|
||||
type: { type: 'string', description: 'Tipo de gatilho' },
|
||||
config: { type: 'object', description: 'Configuracao do gatilho' },
|
||||
kind: { type: 'string', description: 'schedule|webhook|api', enum: ['schedule', 'webhook', 'api'] },
|
||||
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) => {
|
||||
const { routine_id, ...body } = args as { routine_id: string; [k: string]: unknown };
|
||||
|
||||
Reference in New Issue
Block a user