Histórico persistente de execuções com visualização detalhada

- Novo executionsStore em db.js com cache in-memory e escrita debounced
- Camada de cache (src/cache/index.js) com TTL e suporte opcional a Redis
- Persistência de execuções de agentes e pipelines com metadados completos
- Pipeline grava cada etapa com prompt, resultado, timestamps e status
- 4 endpoints REST: listagem paginada com filtros, detalhe, exclusão individual e limpeza total
- Componente frontend (history.js) com cards, filtros, paginação e modal de detalhe
- Timeline visual para pipelines com prompts colapsáveis por etapa
- Correção do executor: --max-turns em vez de --max-tokens, --permission-mode bypassPermissions
- Refatoração do scheduler com persistência melhorada e graceful shutdown
This commit is contained in:
Frederico Castro
2026-02-26 01:36:28 -03:00
parent 2f7a9d4c56
commit 4b6c876f36
13 changed files with 1536 additions and 398 deletions

153
src/cache/index.js vendored Normal file
View File

@@ -0,0 +1,153 @@
class MemoryCache {
#entries = new Map();
#timer;
constructor(cleanupIntervalMs = 5 * 60 * 1000) {
this.#timer = setInterval(() => this.#evict(), cleanupIntervalMs);
this.#timer.unref();
}
#evict() {
const now = Date.now();
for (const [key, entry] of this.#entries) {
if (entry.exp > 0 && now > entry.exp) {
this.#entries.delete(key);
}
}
}
get(key) {
const entry = this.#entries.get(key);
if (!entry) return undefined;
if (entry.exp > 0 && Date.now() > entry.exp) {
this.#entries.delete(key);
return undefined;
}
return entry.val;
}
set(key, value, ttlMs = 0) {
this.#entries.set(key, { val: value, exp: ttlMs > 0 ? Date.now() + ttlMs : 0 });
return this;
}
del(key) {
return this.#entries.delete(key);
}
has(key) {
return this.get(key) !== undefined;
}
invalidatePrefix(prefix) {
let n = 0;
for (const key of this.#entries.keys()) {
if (key.startsWith(prefix)) {
this.#entries.delete(key);
n++;
}
}
return n;
}
flush() {
this.#entries.clear();
}
get size() {
return this.#entries.size;
}
destroy() {
clearInterval(this.#timer);
this.#entries.clear();
}
}
async function tryRedis(url) {
try {
const { default: Redis } = await import('ioredis');
const client = new Redis(url, {
lazyConnect: true,
connectTimeout: 3000,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
await client.ping();
console.log('[cache] Redis conectado');
return client;
} catch (err) {
console.log('[cache] Redis indisponível, usando memória:', err.message);
return null;
}
}
export const mem = new MemoryCache();
let redisClient = null;
if (process.env.REDIS_URL) {
tryRedis(process.env.REDIS_URL).then((c) => {
redisClient = c;
});
}
function redisGet(key) {
if (!redisClient) return Promise.resolve(undefined);
return redisClient
.get(key)
.then((raw) => (raw != null ? JSON.parse(raw) : undefined))
.catch(() => undefined);
}
function redisSet(key, value, ttlMs) {
if (!redisClient) return;
const s = JSON.stringify(value);
const ttlSec = Math.ceil(ttlMs / 1000);
(ttlSec > 0 ? redisClient.setex(key, ttlSec, s) : redisClient.set(key, s)).catch(() => {});
}
function redisDel(key) {
if (!redisClient) return;
redisClient.del(key).catch(() => {});
}
export function cached(key, ttlMs, computeFn) {
const hit = mem.get(key);
if (hit !== undefined) return hit;
const value = computeFn();
mem.set(key, value, ttlMs);
redisSet(key, value, ttlMs);
return value;
}
export async function cachedAsync(key, ttlMs, computeFn) {
const hit = mem.get(key);
if (hit !== undefined) return hit;
const l2 = await redisGet(key);
if (l2 !== undefined) {
mem.set(key, l2, ttlMs);
return l2;
}
const value = await computeFn();
mem.set(key, value, ttlMs);
redisSet(key, value, ttlMs);
return value;
}
export function invalidate(key) {
mem.del(key);
redisDel(key);
}
export function invalidatePrefix(prefix) {
mem.invalidatePrefix(prefix);
if (redisClient) {
redisClient
.keys(`${prefix}*`)
.then((keys) => {
if (keys.length > 0) redisClient.del(...keys);
})
.catch(() => {});
}
}