Correções de bugs, layout de cards e webhook test funcional
- Pipeline cancel/approve/reject corrigido com busca bidirecional - Secrets injetados no executor via cleanEnv - Versionamento automático ao atualizar agentes - writeJsonAsync com log de erro - Removido asyncHandler.js (código morto) - Restaurado permissionMode padrão bypassPermissions - Ícones dos cards alinhados à direita com wrapper - Botão Editar convertido para ícone nos cards - Webhook test agora dispara execução real do agente/pipeline - Corrigido App.navigateTo no teste de webhook
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { settingsStore } from '../store/db.js';
|
||||
|
||||
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;
|
||||
|
||||
@@ -12,6 +16,12 @@ 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 || '';
|
||||
@@ -34,13 +44,16 @@ function sanitizeText(str) {
|
||||
.slice(0, 50000);
|
||||
}
|
||||
|
||||
function cleanEnv() {
|
||||
function cleanEnv(agentSecrets) {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
|
||||
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
|
||||
}
|
||||
if (agentSecrets && typeof agentSecrets === 'object') {
|
||||
Object.assign(env, agentSecrets);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -161,52 +174,21 @@ function extractSystemInfo(event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function execute(agentConfig, task, 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();
|
||||
function processChildOutput(child, executionId, callbacks, options = {}) {
|
||||
const { onData, onError, onComplete } = callbacks;
|
||||
|
||||
const prompt = buildPrompt(task.description || task, task.instructions);
|
||||
const args = buildArgs(agentConfig, prompt);
|
||||
|
||||
const spawnOptions = {
|
||||
env: cleanEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return executionId;
|
||||
}
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
let hadError = false;
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
agentConfig,
|
||||
task,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionId,
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -221,7 +203,9 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
|
||||
const text = extractText(parsed);
|
||||
if (text) {
|
||||
fullText += text;
|
||||
if (fullText.length < MAX_OUTPUT_SIZE) {
|
||||
fullText += text;
|
||||
}
|
||||
if (onData) onData({ type: 'chunk', content: text }, executionId);
|
||||
}
|
||||
|
||||
@@ -242,7 +226,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
durationMs: parsed.duration_ms || 0,
|
||||
durationApiMs: parsed.duration_api_ms || 0,
|
||||
numTurns: parsed.num_turns || 0,
|
||||
sessionId: parsed.session_id || '',
|
||||
sessionId: parsed.session_id || sessionIdOverride || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -255,7 +239,9 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const str = chunk.toString();
|
||||
errorBuffer += str;
|
||||
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);
|
||||
@@ -263,6 +249,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(`[executor][error] ${err.message}`);
|
||||
hadError = true;
|
||||
activeExecutions.delete(executionId);
|
||||
@@ -270,21 +257,81 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
});
|
||||
|
||||
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 (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)) {
|
||||
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
|
||||
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, prompt);
|
||||
|
||||
const spawnOptions = {
|
||||
env: cleanEnv(secrets),
|
||||
stdio: ['ignore', '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);
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
agentConfig,
|
||||
task,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionId,
|
||||
});
|
||||
|
||||
processChildOutput(child, executionId, { onData, onError, onComplete }, {
|
||||
timeout: agentConfig.timeout || 1800000,
|
||||
});
|
||||
|
||||
return executionId;
|
||||
}
|
||||
@@ -299,6 +346,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
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,
|
||||
@@ -319,18 +368,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
};
|
||||
|
||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return executionId;
|
||||
}
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
let hadError = false;
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
@@ -340,86 +383,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
executionId,
|
||||
});
|
||||
|
||||
let outputBuffer = '';
|
||||
let errorBuffer = '';
|
||||
let fullText = '';
|
||||
let resultMeta = null;
|
||||
let turnCount = 0;
|
||||
|
||||
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) {
|
||||
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 || sessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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) => {
|
||||
console.log(`[executor][error] ${err.message}`);
|
||||
hadError = true;
|
||||
activeExecutions.delete(executionId);
|
||||
if (onError) onError(err, executionId);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
activeExecutions.delete(executionId);
|
||||
if (hadError) return;
|
||||
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
||||
if (onComplete) {
|
||||
onComplete({
|
||||
executionId,
|
||||
exitCode: code,
|
||||
result: fullText,
|
||||
stderr: errorBuffer,
|
||||
...(resultMeta || {}),
|
||||
}, executionId);
|
||||
}
|
||||
processChildOutput(child, executionId, { onData, onError, onComplete }, {
|
||||
timeout: agentConfig.timeout || 1800000,
|
||||
sessionIdOverride: sessionId,
|
||||
});
|
||||
|
||||
return executionId;
|
||||
@@ -428,8 +394,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
export function cancel(executionId) {
|
||||
const execution = activeExecutions.get(executionId);
|
||||
if (!execution) return false;
|
||||
execution.canceled = true;
|
||||
execution.process.kill('SIGTERM');
|
||||
activeExecutions.delete(executionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { agentsStore, schedulesStore, executionsStore, notificationsStore } from '../store/db.js';
|
||||
import cron from 'node-cron';
|
||||
import { agentsStore, schedulesStore, executionsStore, notificationsStore, secretsStore, agentVersionsStore, withLock } from '../store/db.js';
|
||||
import * as executor from './executor.js';
|
||||
import * as scheduler from './scheduler.js';
|
||||
import { generateAgentReport } from '../reports/generator.js';
|
||||
@@ -99,6 +100,13 @@ export function createAgent(data) {
|
||||
export function updateAgent(id, data) {
|
||||
const existing = agentsStore.getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
agentVersionsStore.create({
|
||||
agentId: id,
|
||||
version: existing,
|
||||
changedFields: Object.keys(data).filter(k => k !== 'id'),
|
||||
});
|
||||
|
||||
const updateData = {};
|
||||
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
@@ -114,25 +122,44 @@ export function deleteAgent(id) {
|
||||
return agentsStore.delete(id);
|
||||
}
|
||||
|
||||
function loadAgentSecrets(agentId) {
|
||||
const all = secretsStore.getAll();
|
||||
const agentSecrets = all.filter(s => s.agentId === agentId);
|
||||
if (agentSecrets.length === 0) return null;
|
||||
const env = {};
|
||||
for (const s of agentSecrets) env[s.name] = s.value;
|
||||
return env;
|
||||
}
|
||||
|
||||
export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||
if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`);
|
||||
|
||||
const retryEnabled = agent.config?.retryOnFailure === true;
|
||||
const maxRetries = Math.min(Math.max(parseInt(agent.config?.maxRetries) || 1, 1), 3);
|
||||
const attempt = metadata._retryAttempt || 1;
|
||||
|
||||
const cb = getWsCallback(wsCallback);
|
||||
const taskText = typeof task === 'string' ? task : task.description;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
type: 'agent',
|
||||
...metadata,
|
||||
agentId,
|
||||
agentName: agent.agent_name,
|
||||
task: taskText,
|
||||
instructions: instructions || '',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
});
|
||||
const historyRecord = metadata._historyRecordId
|
||||
? { id: metadata._historyRecordId }
|
||||
: executionsStore.create({
|
||||
type: 'agent',
|
||||
...metadata,
|
||||
agentId,
|
||||
agentName: agent.agent_name,
|
||||
task: taskText,
|
||||
instructions: instructions || '',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
});
|
||||
|
||||
if (metadata._retryAttempt) {
|
||||
executionsStore.update(historyRecord.id, { status: 'running', error: null });
|
||||
}
|
||||
|
||||
const execRecord = {
|
||||
executionId: null,
|
||||
@@ -143,6 +170,8 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
const agentSecrets = loadAgentSecrets(agentId);
|
||||
|
||||
const executionId = executor.execute(
|
||||
agent.config,
|
||||
{ description: task, instructions },
|
||||
@@ -153,6 +182,31 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
onError: (err, execId) => {
|
||||
const endedAt = new Date().toISOString();
|
||||
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
|
||||
|
||||
if (retryEnabled && attempt < maxRetries) {
|
||||
const delayMs = attempt * 5000;
|
||||
executionsStore.update(historyRecord.id, { status: 'retrying', error: err.message, attempt, endedAt });
|
||||
if (cb) cb({
|
||||
type: 'execution_retry',
|
||||
executionId: execId,
|
||||
agentId,
|
||||
data: { attempt, maxRetries, nextRetryIn: delayMs / 1000, reason: err.message },
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
executeTask(agentId, task, instructions, wsCallback, {
|
||||
...metadata,
|
||||
_retryAttempt: attempt + 1,
|
||||
_historyRecordId: historyRecord.id,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: retryErr.message, endedAt: new Date().toISOString() });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: retryErr.message } });
|
||||
}
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||
createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||
@@ -178,10 +232,11 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
const report = generateAgentReport(updated);
|
||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
}
|
||||
},
|
||||
agentSecrets
|
||||
);
|
||||
|
||||
if (!executionId) {
|
||||
@@ -203,18 +258,15 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
return executionId;
|
||||
}
|
||||
|
||||
function updateRecentBuffer(executionId, updates) {
|
||||
const entry = recentExecBuffer.find((e) => e.executionId === executionId);
|
||||
if (entry) Object.assign(entry, updates);
|
||||
}
|
||||
|
||||
function updateExecutionRecord(agentId, executionId, updates) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) return;
|
||||
const executions = (agent.executions || []).map((exec) =>
|
||||
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||
);
|
||||
agentsStore.update(agentId, { executions });
|
||||
async function updateExecutionRecord(agentId, executionId, updates) {
|
||||
await withLock(`agent:${agentId}`, () => {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) return;
|
||||
const executions = (agent.executions || []).map((exec) =>
|
||||
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||
);
|
||||
agentsStore.update(agentId, { executions });
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecentExecutions(limit = 20) {
|
||||
@@ -225,6 +277,10 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||
|
||||
if (!cron.validate(cronExpression)) {
|
||||
throw new Error(`Expressão cron inválida: ${cronExpression}`);
|
||||
}
|
||||
|
||||
const scheduleId = uuidv4();
|
||||
const items = schedulesStore.getAll();
|
||||
items.push({
|
||||
@@ -314,7 +370,7 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
||||
const report = generateAgentReport(updated);
|
||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
|
||||
});
|
||||
}
|
||||
|
||||
function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCallback) {
|
||||
function waitForApproval(executionId, pipelineId, stepIndex, previousOutput, agentName, wsCallback) {
|
||||
return new Promise((resolve) => {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
const state = activePipelines.get(executionId);
|
||||
if (!state) { resolve(false); return; }
|
||||
|
||||
state.pendingApproval = {
|
||||
@@ -116,6 +116,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
||||
wsCallback({
|
||||
type: 'pipeline_approval_required',
|
||||
pipelineId,
|
||||
executionId,
|
||||
stepIndex,
|
||||
agentName,
|
||||
previousOutput: previousOutput.slice(0, 3000),
|
||||
@@ -124,8 +125,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
||||
});
|
||||
}
|
||||
|
||||
export function approvePipelineStep(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
function findPipelineState(idOrExecId) {
|
||||
if (activePipelines.has(idOrExecId)) return activePipelines.get(idOrExecId);
|
||||
for (const [, state] of activePipelines) {
|
||||
if (state.pipelineId === idOrExecId) return state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function approvePipelineStep(id) {
|
||||
const state = findPipelineState(id);
|
||||
if (!state?.pendingApproval) return false;
|
||||
const { resolve } = state.pendingApproval;
|
||||
state.pendingApproval = null;
|
||||
@@ -133,8 +142,8 @@ export function approvePipelineStep(pipelineId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rejectPipelineStep(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
export function rejectPipelineStep(id) {
|
||||
const state = findPipelineState(id);
|
||||
if (!state?.pendingApproval) return false;
|
||||
const { resolve } = state.pendingApproval;
|
||||
state.pendingApproval = null;
|
||||
@@ -145,9 +154,11 @@ export function rejectPipelineStep(pipelineId) {
|
||||
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
|
||||
const pl = pipelinesStore.getById(pipelineId);
|
||||
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
|
||||
if (pl.status !== 'active') throw new Error(`Pipeline "${pl.name}" está desativado`);
|
||||
|
||||
const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(pipelineId, pipelineState);
|
||||
const executionId = uuidv4();
|
||||
const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(executionId, pipelineState);
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
type: 'pipeline',
|
||||
@@ -181,7 +192,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i });
|
||||
}
|
||||
|
||||
const approved = await waitForApproval(pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
const approved = await waitForApproval(executionId, pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
|
||||
if (!approved) {
|
||||
pipelineState.canceled = true;
|
||||
@@ -257,7 +268,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
}
|
||||
}
|
||||
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
|
||||
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
|
||||
executionsStore.update(historyRecord.id, {
|
||||
@@ -273,13 +284,13 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
const report = generatePipelineReport(updated);
|
||||
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
|
||||
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, executionId, results, totalCostUsd: totalCost });
|
||||
}
|
||||
|
||||
return results;
|
||||
return { executionId, results };
|
||||
} catch (err) {
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
executionsStore.update(historyRecord.id, {
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
@@ -298,8 +309,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelPipeline(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
export function cancelPipeline(id) {
|
||||
let executionId = id;
|
||||
let state = activePipelines.get(id);
|
||||
if (!state) {
|
||||
for (const [execId, s] of activePipelines) {
|
||||
if (s.pipelineId === id) { state = s; executionId = execId; break; }
|
||||
}
|
||||
}
|
||||
if (!state) return false;
|
||||
state.canceled = true;
|
||||
if (state.pendingApproval) {
|
||||
@@ -307,14 +324,12 @@ export function cancelPipeline(pipelineId) {
|
||||
state.pendingApproval = null;
|
||||
}
|
||||
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
|
||||
const allExecs = executionsStore.getAll();
|
||||
const idx = allExecs.findIndex(e => e.pipelineId === pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
|
||||
if (idx !== -1) {
|
||||
allExecs[idx].status = 'canceled';
|
||||
allExecs[idx].endedAt = new Date().toISOString();
|
||||
executionsStore.save(allExecs);
|
||||
const exec = allExecs.find(e => e.pipelineId === state.pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
|
||||
if (exec) {
|
||||
executionsStore.update(exec.id, { status: 'canceled', endedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -322,7 +337,8 @@ export function cancelPipeline(pipelineId) {
|
||||
|
||||
export function getActivePipelines() {
|
||||
return Array.from(activePipelines.entries()).map(([id, state]) => ({
|
||||
pipelineId: id,
|
||||
executionId: id,
|
||||
pipelineId: state.pipelineId,
|
||||
currentStep: state.currentStep,
|
||||
currentExecutionId: state.currentExecutionId,
|
||||
pendingApproval: !!state.pendingApproval,
|
||||
|
||||
@@ -17,7 +17,11 @@ function addToHistory(entry) {
|
||||
|
||||
function matchesCronPart(part, value) {
|
||||
if (part === '*') return true;
|
||||
if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0;
|
||||
if (part.startsWith('*/')) {
|
||||
const divisor = parseInt(part.slice(2));
|
||||
if (!divisor || divisor <= 0) return false;
|
||||
return value % divisor === 0;
|
||||
}
|
||||
if (part.includes(',')) return part.split(',').map(Number).includes(value);
|
||||
if (part.includes('-')) {
|
||||
const [start, end] = part.split('-').map(Number);
|
||||
@@ -66,6 +70,17 @@ export function schedule(taskId, cronExpr, callback, persist = true) {
|
||||
if (schedules.has(taskId)) unschedule(taskId, false);
|
||||
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
||||
|
||||
const MIN_INTERVAL_PARTS = cronExpr.split(' ');
|
||||
if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') {
|
||||
throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.');
|
||||
}
|
||||
if (MIN_INTERVAL_PARTS[0].startsWith('*/')) {
|
||||
const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2));
|
||||
if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') {
|
||||
throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`);
|
||||
}
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronExpr,
|
||||
() => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
import * as manager from '../agents/manager.js';
|
||||
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore } from '../store/db.js';
|
||||
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
|
||||
import * as scheduler from '../agents/scheduler.js';
|
||||
import * as pipeline from '../agents/pipeline.js';
|
||||
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
|
||||
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||
import { cached } from '../cache/index.js';
|
||||
import { readdirSync, readFileSync, unlinkSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { join, dirname, resolve as pathResolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -169,6 +169,92 @@ router.get('/agents/:id/export', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/agents/:id/secrets', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const all = secretsStore.getAll();
|
||||
const agentSecrets = all
|
||||
.filter((s) => s.agentId === req.params.id)
|
||||
.map((s) => ({ name: s.name, created_at: s.created_at }));
|
||||
res.json(agentSecrets);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/secrets', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const { name, value } = req.body;
|
||||
if (!name || !value) return res.status(400).json({ error: 'name e value são obrigatórios' });
|
||||
const all = secretsStore.getAll();
|
||||
const existing = all.find((s) => s.agentId === req.params.id && s.name === name);
|
||||
if (existing) {
|
||||
secretsStore.update(existing.id, { value });
|
||||
return res.json({ name, updated: true });
|
||||
}
|
||||
secretsStore.create({ agentId: req.params.id, name, value });
|
||||
res.status(201).json({ name, created: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/agents/:id/secrets/:name', (req, res) => {
|
||||
try {
|
||||
const secretName = decodeURIComponent(req.params.name);
|
||||
const all = secretsStore.getAll();
|
||||
const secret = all.find((s) => s.agentId === req.params.id && s.name === secretName);
|
||||
if (!secret) return res.status(404).json({ error: 'Secret não encontrado' });
|
||||
secretsStore.delete(secret.id);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/agents/:id/versions', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const all = agentVersionsStore.getAll();
|
||||
const versions = all
|
||||
.filter((v) => v.agentId === req.params.id)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
res.json(versions);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/versions/:version/restore', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const versionNum = parseInt(req.params.version);
|
||||
const all = agentVersionsStore.getAll();
|
||||
const target = all.find((v) => v.agentId === req.params.id && v.version === versionNum);
|
||||
if (!target) return res.status(404).json({ error: 'Versão não encontrada' });
|
||||
if (!target.snapshot) return res.status(400).json({ error: 'Snapshot da versão não disponível' });
|
||||
const { id, created_at, updated_at, ...snapshotData } = target.snapshot;
|
||||
const restored = manager.updateAgent(req.params.id, snapshotData);
|
||||
if (!restored) return res.status(500).json({ error: 'Falha ao restaurar versão' });
|
||||
invalidateAgentMapCache();
|
||||
agentVersionsStore.create({
|
||||
agentId: req.params.id,
|
||||
version: Math.max(...all.filter((v) => v.agentId === req.params.id).map((v) => v.version), 0) + 1,
|
||||
changes: ['restore'],
|
||||
changelog: `Restaurado para versão ${versionNum}`,
|
||||
snapshot: structuredClone(restored),
|
||||
});
|
||||
res.json(restored);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/duplicate', async (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
@@ -329,17 +415,18 @@ router.delete('/pipelines/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/pipelines/:id/execute', (req, res) => {
|
||||
router.post('/pipelines/:id/execute', async (req, res) => {
|
||||
try {
|
||||
const { input, workingDirectory } = req.body;
|
||||
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
const options = {};
|
||||
if (workingDirectory) options.workingDirectory = workingDirectory;
|
||||
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options).catch(() => {});
|
||||
const result = pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options);
|
||||
result.catch(() => {});
|
||||
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||
const status = err.message.includes('não encontrado') || err.message.includes('desativado') ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
@@ -409,18 +496,17 @@ router.post('/webhooks', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/webhooks/:id', async (req, res) => {
|
||||
router.put('/webhooks/:id', (req, res) => {
|
||||
try {
|
||||
const webhooks = webhooksStore.getAll();
|
||||
const idx = webhooks.findIndex(w => w.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Webhook não encontrado' });
|
||||
const existing = webhooksStore.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
|
||||
const allowed = ['name', 'targetType', 'targetId', 'active'];
|
||||
const updateData = {};
|
||||
for (const key of allowed) {
|
||||
if (req.body[key] !== undefined) webhooks[idx][key] = req.body[key];
|
||||
if (req.body[key] !== undefined) updateData[key] = req.body[key];
|
||||
}
|
||||
webhooks[idx].updated_at = new Date().toISOString();
|
||||
webhooksStore.save(webhooks);
|
||||
res.json(webhooks[idx]);
|
||||
const updated = webhooksStore.update(req.params.id, updateData);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
@@ -428,10 +514,22 @@ router.put('/webhooks/:id', async (req, res) => {
|
||||
|
||||
router.post('/webhooks/:id/test', async (req, res) => {
|
||||
try {
|
||||
const webhooks = webhooksStore.getAll();
|
||||
const wh = webhooks.find(w => w.id === req.params.id);
|
||||
const wh = webhooksStore.getById(req.params.id);
|
||||
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
|
||||
res.json({ success: true, message: 'Webhook testado com sucesso', webhook: { id: wh.id, name: wh.name, targetType: wh.targetType } });
|
||||
|
||||
if (wh.targetType === 'agent') {
|
||||
const executionId = manager.executeTask(wh.targetId, 'Teste de webhook', '', (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}, { source: 'webhook-test', webhookId: wh.id });
|
||||
res.status(202).json({ success: true, message: 'Webhook disparado com sucesso', executionId });
|
||||
} else if (wh.targetType === 'pipeline') {
|
||||
pipeline.executePipeline(wh.targetId, 'Teste de webhook', (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}).catch(() => {});
|
||||
res.status(202).json({ success: true, message: 'Pipeline disparada com sucesso', pipelineId: wh.targetId });
|
||||
} else {
|
||||
return res.status(400).json({ error: `targetType inválido: ${wh.targetType}` });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -471,12 +569,12 @@ hookRouter.post('/:token', (req, res) => {
|
||||
res.status(202).json({ executionId, status: 'started', webhook: webhook.name });
|
||||
} else if (webhook.targetType === 'pipeline') {
|
||||
const input = payload.input || payload.task || payload.message || 'Webhook trigger';
|
||||
const options = {};
|
||||
if (payload.workingDirectory) options.workingDirectory = payload.workingDirectory;
|
||||
pipeline.executePipeline(webhook.targetId, input, (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}, options).catch(() => {});
|
||||
}).catch(() => {});
|
||||
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
|
||||
} else {
|
||||
return res.status(400).json({ error: `targetType inválido: ${webhook.targetType}` });
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 500;
|
||||
@@ -590,11 +688,16 @@ router.get('/system/status', (req, res) => {
|
||||
|
||||
let claudeVersionCache = null;
|
||||
|
||||
router.get('/system/info', (req, res) => {
|
||||
router.get('/system/info', async (req, res) => {
|
||||
try {
|
||||
if (claudeVersionCache === null) {
|
||||
try {
|
||||
claudeVersionCache = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim();
|
||||
claudeVersionCache = await new Promise((resolve, reject) => {
|
||||
execFile(getBinPath(), ['--version'], { timeout: 5000 }, (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout.toString().trim());
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
claudeVersionCache = 'N/A';
|
||||
}
|
||||
@@ -783,24 +886,22 @@ router.get('/notifications', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notifications/:id/read', async (req, res) => {
|
||||
router.post('/notifications/:id/read', (req, res) => {
|
||||
try {
|
||||
const notifications = notificationsStore.getAll();
|
||||
const n = notifications.find(n => n.id === req.params.id);
|
||||
if (!n) return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||
n.read = true;
|
||||
notificationsStore.save(notifications);
|
||||
const updated = notificationsStore.update(req.params.id, { read: true });
|
||||
if (!updated) return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notifications/read-all', async (req, res) => {
|
||||
router.post('/notifications/read-all', (req, res) => {
|
||||
try {
|
||||
const notifications = notificationsStore.getAll();
|
||||
notifications.forEach(n => n.read = true);
|
||||
notificationsStore.save(notifications);
|
||||
for (const n of notifications) {
|
||||
if (!n.read) notificationsStore.update(n.id, { read: true });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -834,6 +935,10 @@ router.get('/reports/:filename', (req, res) => {
|
||||
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
||||
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
const resolved = pathResolve(filepath);
|
||||
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
|
||||
return res.status(400).json({ error: 'Caminho inválido' });
|
||||
}
|
||||
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
res.json({ filename, content });
|
||||
@@ -846,6 +951,10 @@ router.delete('/reports/:filename', (req, res) => {
|
||||
try {
|
||||
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
const resolved = pathResolve(filepath);
|
||||
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
|
||||
return res.status(400).json({ error: 'Caminho inválido' });
|
||||
}
|
||||
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
||||
unlinkSync(filepath);
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
||||
import { writeFile, rename } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -35,6 +36,13 @@ function writeJson(path, data) {
|
||||
renameSync(tmpPath, path);
|
||||
}
|
||||
|
||||
async function writeJsonAsync(path, data) {
|
||||
ensureDir();
|
||||
const tmpPath = path + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
await rename(tmpPath, path);
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
return structuredClone(v);
|
||||
}
|
||||
@@ -57,7 +65,7 @@ function createStore(filePath) {
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (dirty) {
|
||||
writeJson(filePath, mem);
|
||||
writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
|
||||
dirty = false;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
@@ -75,6 +83,20 @@ function createStore(filePath) {
|
||||
return item ? clone(item) : null;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return store.getById(id);
|
||||
},
|
||||
|
||||
count() {
|
||||
boot();
|
||||
return mem.length;
|
||||
},
|
||||
|
||||
filter(predicate) {
|
||||
boot();
|
||||
return mem.filter(predicate).map((item) => clone(item));
|
||||
},
|
||||
|
||||
create(data) {
|
||||
boot();
|
||||
const item = {
|
||||
@@ -110,7 +132,8 @@ function createStore(filePath) {
|
||||
},
|
||||
|
||||
save(items) {
|
||||
mem = Array.isArray(items) ? items : mem;
|
||||
if (!Array.isArray(items)) return;
|
||||
mem = items;
|
||||
touch();
|
||||
},
|
||||
|
||||
@@ -186,6 +209,21 @@ function createSettingsStore(filePath) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const locks = new Map();
|
||||
|
||||
export async function withLock(key, fn) {
|
||||
while (locks.has(key)) await locks.get(key);
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
locks.set(key, promise);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
locks.delete(key);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export function flushAllStores() {
|
||||
for (const s of allStores) s.flush();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user