Files
Agents-Orchestrator/src/agents/executor.js
Frederico Castro 356411d388 Botão Commit & Push nos projetos e correção do resume de sessão
- Adicionar botão de commit & push para cada projeto na página de arquivos
- Criar rota POST /api/files/commit-push com git add, commit e push
- Adicionar Modal.prompt reutilizável para inputs com valor padrão
- Corrigir detecção de erro no executor (is_error/errors do CLI)
- Fallback automático para nova execução quando sessão expira no resume
2026-02-28 08:55:39 -03:00

502 lines
15 KiB
JavaScript

import { spawn } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid';
import { settingsStore } from '../store/db.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AGENT_SETTINGS = path.resolve(__dirname, '..', '..', 'data', 'agent-settings.json');
const CLAUDE_BIN = resolveBin();
const activeExecutions = new Map();
const MAX_OUTPUT_SIZE = 512 * 1024;
const MAX_ERROR_SIZE = 100 * 1024;
const ALLOWED_DIRECTORIES = (process.env.ALLOWED_DIRECTORIES || '').split(',').map(d => d.trim()).filter(Boolean);
let maxConcurrent = settingsStore.get().maxConcurrent || 5;
export function updateMaxConcurrent(value) {
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
}
function isDirectoryAllowed(dir) {
if (ALLOWED_DIRECTORIES.length === 0) return true;
const resolved = path.resolve(dir);
return ALLOWED_DIRECTORIES.some(allowed => resolved.startsWith(path.resolve(allowed)));
}
function resolveBin() {
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
const home = process.env.HOME || '';
const candidates = [
`${home}/.local/bin/claude`,
'/usr/local/bin/claude',
'/usr/bin/claude',
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return 'claude';
}
function sanitizeText(str) {
if (typeof str !== 'string') return '';
return str
.replace(/\x00/g, '')
.replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.slice(0, 50000);
}
function cleanEnv(agentSecrets) {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
if (agentSecrets && typeof agentSecrets === 'object') {
Object.assign(env, agentSecrets);
}
return env;
}
function buildArgs(agentConfig) {
const model = agentConfig.model || 'claude-sonnet-4-6';
const args = ['--output-format', 'stream-json', '--verbose', '--model', model];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
if (agentConfig.systemPrompt) {
args.push('--system-prompt', agentConfig.systemPrompt);
}
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
args.push('--max-turns', String(agentConfig.maxTurns));
}
if (agentConfig.allowedTools && agentConfig.allowedTools.length > 0) {
const tools = Array.isArray(agentConfig.allowedTools)
? agentConfig.allowedTools.join(',')
: agentConfig.allowedTools;
args.push('--allowedTools', tools);
}
args.push('--permission-mode', agentConfig.permissionMode || 'bypassPermissions');
return args;
}
function buildPrompt(task, instructions) {
const parts = [];
if (task) parts.push(sanitizeText(task));
if (instructions) parts.push(`\nInstruções adicionais:\n${sanitizeText(instructions)}`);
return parts.join('\n');
}
function parseStreamLine(line) {
const trimmed = line.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return { type: 'text', content: trimmed };
}
}
function extractText(event) {
if (!event) return null;
if (event.type === 'assistant' && event.message?.content) {
return event.message.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('');
}
if (event.type === 'content_block_delta' && event.delta?.text) return event.delta.text;
if (event.type === 'content_block_start' && event.content_block?.text) return event.content_block.text;
if (event.type === 'result') {
if (typeof event.result === 'string') return event.result;
if (event.result?.content) {
return event.result.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('');
}
}
if (event.type === 'text') return event.content || null;
return null;
}
function extractToolInfo(event) {
if (!event) return null;
if (event.type === 'assistant' && event.message?.content) {
const toolBlocks = event.message.content.filter((b) => b.type === 'tool_use');
if (toolBlocks.length > 0) {
return toolBlocks.map((b) => {
const name = b.name || 'unknown';
const input = b.input || {};
let detail = '';
if (input.command) detail = input.command.slice(0, 120);
else if (input.file_path) detail = input.file_path;
else if (input.pattern) detail = input.pattern;
else if (input.query) detail = input.query;
else if (input.path) detail = input.path;
else if (input.prompt) detail = input.prompt.slice(0, 80);
else if (input.description) detail = input.description.slice(0, 80);
return { name, detail };
});
}
}
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
return [{ name: event.content_block.name || 'tool', detail: '' }];
}
return null;
}
function extractSystemInfo(event) {
if (!event) return null;
if (event.type === 'system' && event.message) return event.message;
if (event.type === 'error') return event.error?.message || event.message || 'Erro desconhecido';
if (event.type === 'result') {
const parts = [];
if (event.num_turns) parts.push(`${event.num_turns} turnos`);
if (event.cost_usd) parts.push(`custo: $${event.cost_usd.toFixed(4)}`);
if (event.duration_ms) {
const s = (event.duration_ms / 1000).toFixed(1);
parts.push(`duração: ${s}s`);
}
if (event.session_id) parts.push(`sessão: ${event.session_id.slice(0, 8)}...`);
return parts.length > 0 ? `Resultado: ${parts.join(' | ')}` : null;
}
return null;
}
function processChildOutput(child, executionId, callbacks, options = {}) {
const { onData, onError, onComplete } = callbacks;
const timeoutMs = options.timeout || 1800000;
const sessionIdOverride = options.sessionIdOverride || null;
let outputBuffer = '';
let errorBuffer = '';
let fullText = '';
let resultMeta = null;
let turnCount = 0;
let hadError = false;
const timeout = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000);
}, timeoutMs);
function processEvent(parsed) {
if (!parsed) return;
const tools = extractToolInfo(parsed);
if (tools) {
for (const t of tools) {
const msg = t.detail ? `${t.name}: ${t.detail}` : t.name;
if (onData) onData({ type: 'tool', content: msg, toolName: t.name }, executionId);
}
}
const text = extractText(parsed);
if (text) {
if (fullText.length < MAX_OUTPUT_SIZE) {
fullText += text;
}
if (onData) onData({ type: 'chunk', content: text }, executionId);
}
const sysInfo = extractSystemInfo(parsed);
if (sysInfo) {
if (onData) onData({ type: 'system', content: sysInfo }, executionId);
}
if (parsed.type === 'assistant') {
turnCount++;
if (onData) onData({ type: 'turn', content: `Turno ${turnCount}`, turn: turnCount }, executionId);
}
if (parsed.type === 'result') {
resultMeta = {
costUsd: parsed.cost_usd || 0,
totalCostUsd: parsed.total_cost_usd || 0,
durationMs: parsed.duration_ms || 0,
durationApiMs: parsed.duration_api_ms || 0,
numTurns: parsed.num_turns || 0,
sessionId: parsed.session_id || sessionIdOverride || '',
isError: parsed.is_error || false,
errors: parsed.errors || [],
};
}
}
child.stdout.on('data', (chunk) => {
const lines = (outputBuffer + chunk.toString()).split('\n');
outputBuffer = lines.pop();
for (const line of lines) processEvent(parseStreamLine(line));
});
child.stderr.on('data', (chunk) => {
const str = chunk.toString();
if (errorBuffer.length < MAX_ERROR_SIZE) {
errorBuffer += str;
}
const lines = str.split('\n').filter(l => l.trim());
for (const line of lines) {
if (onData) onData({ type: 'stderr', content: line.trim() }, executionId);
}
});
child.on('error', (err) => {
clearTimeout(timeout);
console.log(`[executor][error] ${err.message}`);
hadError = true;
activeExecutions.delete(executionId);
if (onError) onError(err, executionId);
});
child.on('close', (code) => {
clearTimeout(timeout);
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
activeExecutions.delete(executionId);
if (hadError) return;
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
if (resultMeta?.isError && resultMeta.errors?.length > 0) {
const errorMsg = resultMeta.errors.join('; ');
if (onError) onError(new Error(errorMsg), executionId);
return;
}
if (onComplete) {
onComplete({
executionId,
exitCode: code,
result: fullText,
stderr: errorBuffer,
canceled: wasCanceled,
...(resultMeta || {}),
}, executionId);
}
});
}
function validateWorkingDirectory(agentConfig, executionId, onError) {
if (!agentConfig.workingDirectory || !agentConfig.workingDirectory.trim()) return true;
if (!isDirectoryAllowed(agentConfig.workingDirectory)) {
const err = new Error(`Diretório de trabalho não permitido: ${agentConfig.workingDirectory}`);
if (onError) onError(err, executionId);
return false;
}
if (!existsSync(agentConfig.workingDirectory)) {
try {
mkdirSync(agentConfig.workingDirectory, { recursive: true });
} catch (e) {
const err = new Error(`Não foi possível criar o diretório: ${agentConfig.workingDirectory} (${e.message})`);
if (onError) onError(err, executionId);
return false;
}
}
return true;
}
export function execute(agentConfig, task, callbacks = {}, secrets = null) {
if (activeExecutions.size >= maxConcurrent) {
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const prompt = buildPrompt(task.description || task, task.instructions);
const args = buildArgs(agentConfig);
const spawnOptions = {
env: cleanEnv(secrets),
stdio: ['pipe', 'pipe', 'pipe'],
};
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
spawnOptions.cwd = agentConfig.workingDirectory;
}
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
child.stdin.write(prompt);
child.stdin.end();
activeExecutions.set(executionId, {
process: child,
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
processChildOutput(child, executionId, { onData, onError, onComplete }, {
timeout: agentConfig.timeout || 1800000,
});
return executionId;
}
export function resume(agentConfig, sessionId, message, callbacks = {}) {
if (activeExecutions.size >= maxConcurrent) {
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const model = agentConfig.model || 'claude-sonnet-4-6';
const args = [
'--resume', sessionId,
'-p', sanitizeText(message),
'--output-format', 'stream-json',
'--verbose',
'--model', model,
'--permission-mode', agentConfig.permissionMode || 'bypassPermissions',
];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
args.push('--max-turns', String(agentConfig.maxTurns));
}
const spawnOptions = {
env: cleanEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
};
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
spawnOptions.cwd = agentConfig.workingDirectory;
}
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
activeExecutions.set(executionId, {
process: child,
agentConfig,
task: { description: message },
startedAt: new Date().toISOString(),
executionId,
});
processChildOutput(child, executionId, { onData, onError, onComplete }, {
timeout: agentConfig.timeout || 1800000,
sessionIdOverride: sessionId,
});
return executionId;
}
export function cancel(executionId) {
const execution = activeExecutions.get(executionId);
if (!execution) return false;
execution.canceled = true;
execution.process.kill('SIGTERM');
return true;
}
export function cancelAllExecutions() {
for (const [, exec] of activeExecutions) exec.process.kill('SIGTERM');
activeExecutions.clear();
}
export function getActiveExecutions() {
return Array.from(activeExecutions.values()).map((exec) => ({
executionId: exec.executionId,
startedAt: exec.startedAt,
agentConfig: exec.agentConfig,
}));
}
export function summarize(text, threshold = 1500) {
return new Promise((resolve) => {
if (!text || text.length <= threshold) {
resolve(text);
return;
}
const prompt = `Resuma o conteúdo abaixo de forma estruturada e concisa. Preserve TODAS as informações críticas:
- Decisões técnicas e justificativas
- Trechos de código essenciais
- Dados, números e métricas
- Problemas encontrados e soluções
- Recomendações e próximos passos
Organize o resumo usando <tags> XML (ex: <decisoes>, <codigo>, <problemas>, <recomendacoes>).
NÃO omita informações que seriam necessárias para outro profissional continuar o trabalho.
<conteudo_para_resumir>
${text}
</conteudo_para_resumir>`;
const args = [
'--output-format', 'text',
'--model', 'claude-haiku-4-5-20251001',
'--max-turns', '1',
'--permission-mode', 'bypassPermissions',
];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
const child = spawn(CLAUDE_BIN, args, {
env: cleanEnv(),
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdin.write(prompt);
child.stdin.end();
let output = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
}, 120000);
child.stdout.on('data', (chunk) => { output += chunk.toString(); });
child.on('close', () => {
clearTimeout(timer);
const result = output.trim();
console.log(`[executor] Sumarização: ${text.length}${result.length} chars`);
resolve(result || text);
});
child.on('error', () => {
clearTimeout(timer);
resolve(text);
});
});
}
export function getBinPath() {
return CLAUDE_BIN;
}