UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

222 lines (221 loc) 9.13 kB
/** * RedisTaskStore — Redis-backed persistence for TaskManager. * Used automatically when backend is "bullmq". * * Key patterns: * neurolink:tasks (Hash) — all task definitions * neurolink:task:{id}:runs (List) — run log entries (newest first) * neurolink:task:{id}:history (List) — continuation mode conversation history */ import { createClient } from "redis"; import { logger } from "../../utils/logger.js"; import { TaskError } from "../errors.js"; import { TASK_DEFAULTS, } from "../../types/index.js"; const KEY_PREFIX = "neurolink:"; const TASKS_HASH = `${KEY_PREFIX}tasks`; function taskRunsKey(taskId) { return `${KEY_PREFIX}task:${taskId}:runs`; } function taskHistoryKey(taskId) { return `${KEY_PREFIX}task:${taskId}:history`; } export class RedisTaskStore { config; type = "redis"; client = null; maxRunLogs; maxHistoryEntries; retentionConfig; constructor(config) { this.config = config; this.maxRunLogs = config.maxRunLogs ?? TASK_DEFAULTS.maxRunLogs; this.maxHistoryEntries = config.maxHistoryEntries ?? TASK_DEFAULTS.maxHistoryEntries; this.retentionConfig = { ...TASK_DEFAULTS.retention, ...config.taskRetention, }; } async initialize() { const redis = this.config.redis ?? {}; const url = redis.url ?? `redis://${redis.host ?? TASK_DEFAULTS.redis.host}:${redis.port ?? TASK_DEFAULTS.redis.port}/${redis.db ?? 0}`; this.client = createClient({ url, ...(redis.password ? { password: redis.password } : {}), }); this.client.on("error", (err) => { logger.error("[TaskStore:Redis] Connection error", { error: String(err), }); }); await this.client.connect(); logger.info("[TaskStore:Redis] Connected"); } async shutdown() { if (this.client?.isOpen) { await this.client.quit(); logger.info("[TaskStore:Redis] Disconnected"); } this.client = null; } // ── Task CRUD ─────────────────────────────────────────── async save(task) { const client = this.getClient(); await client.hSet(TASKS_HASH, task.id, JSON.stringify(task)); this.applyRetentionTTL(task, client); } async get(taskId) { const client = this.getClient(); const data = await client.hGet(TASKS_HASH, taskId); if (!data) { return null; } return JSON.parse(String(data)); } async list(filter) { const client = this.getClient(); const all = await client.hGetAll(TASKS_HASH); let tasks = Object.values(all).map((v) => JSON.parse(String(v))); if (filter?.status) { tasks = tasks.filter((t) => t.status === filter.status); } return tasks.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } async update(taskId, updates) { const client = this.getClient(); const existing = await this.get(taskId); if (!existing) { throw TaskError.create("TASK_NOT_FOUND", `Task not found: ${taskId}`); } const updated = { ...existing, ...updates, id: existing.id, // ID is immutable updatedAt: new Date().toISOString(), }; await client.hSet(TASKS_HASH, taskId, JSON.stringify(updated)); this.applyRetentionTTL(updated, client); return updated; } async delete(taskId) { const client = this.getClient(); await Promise.all([ client.hDel(TASKS_HASH, taskId), client.del(taskRunsKey(taskId)), client.del(taskHistoryKey(taskId)), ]); } // ── Run Logs ────────────────────────────────────────── async appendRun(taskId, run) { const client = this.getClient(); const key = taskRunsKey(taskId); await client.lPush(key, JSON.stringify(run)); // Trim to keep only the latest maxRunLogs entries await client.lTrim(key, 0, this.maxRunLogs - 1); } async getRuns(taskId, options) { const client = this.getClient(); const limit = options?.limit ?? 20; const key = taskRunsKey(taskId); // When a status filter is applied, we need to fetch more items than `limit` // because post-filter may discard many entries. Fetch all (-1) when filtering, // otherwise fetch exactly `limit` items. const fetchEnd = options?.status ? -1 : limit - 1; const items = await client.lRange(key, 0, fetchEnd); let runs = items.map((v) => JSON.parse(String(v))); if (options?.status) { runs = runs.filter((r) => r.status === options.status); } return runs.slice(0, limit); } // ── Continuation History ────────────────────────────── async appendHistory(taskId, messages) { const client = this.getClient(); const key = taskHistoryKey(taskId); const serialized = messages.map((m) => JSON.stringify(m)); if (serialized.length > 0) { await client.rPush(key, serialized); // Trim to keep only the most recent entries, preventing unbounded growth await client.lTrim(key, -this.maxHistoryEntries, -1); } } async getHistory(taskId) { const client = this.getClient(); const key = taskHistoryKey(taskId); const items = await client.lRange(key, 0, -1); return items.map((v) => JSON.parse(String(v))); } async clearHistory(taskId) { const client = this.getClient(); await client.del(taskHistoryKey(taskId)); } // ── Internal ────────────────────────────────────────── ensureConnected() { if (!this.client?.isOpen) { throw TaskError.create("BACKEND_NOT_INITIALIZED", "[TaskStore:Redis] Not connected. Call initialize() first."); } } getClient() { this.ensureConnected(); if (!this.client) { throw TaskError.create("BACKEND_NOT_INITIALIZED", "[TaskStore:Redis] Client is unavailable after initialization."); } return this.client; } /** * Set Redis TTL on terminal-state tasks so they auto-expire. * Active and paused tasks never expire. */ applyRetentionTTL(task, client) { // We don't set EXPIRE on the hash field directly (Redis doesn't support per-field TTL). // Instead, run logs and history keys get TTL. The task hash field itself must be // cleaned up via manual deletion or BullMQ's built-in job cleanup. const ttlMap = { completed: this.retentionConfig.completedTTL, failed: this.retentionConfig.failedTTL, cancelled: this.retentionConfig.cancelledTTL, }; const ttlMs = ttlMap[task.status]; if (!ttlMs || !client.isOpen) { return; } const ttlSeconds = Math.ceil(ttlMs / 1000); // Set TTL on associated keys best-effort. A successful task write should not // be surfaced as a failure just because the retention metadata could not be updated. void (async () => { const runsKey = taskRunsKey(task.id); for (let attempt = 1; attempt <= 3; attempt++) { try { await client.expire(runsKey, ttlSeconds); break; } catch (err) { if (attempt === 3) { logger.warn("[TaskStore:Redis] expire failed after 3 attempts on task runs key — task data may outlive retention window", { taskId: task.id, key: runsKey, ttlSeconds, err: String(err) }); } else { await new Promise((r) => setTimeout(r, 100 * attempt)); } } } })(); void (async () => { const histKey = taskHistoryKey(task.id); for (let attempt = 1; attempt <= 3; attempt++) { try { await client.expire(histKey, ttlSeconds); break; } catch (err) { if (attempt === 3) { logger.warn("[TaskStore:Redis] expire failed after 3 attempts on task history key — task data may outlive retention window", { taskId: task.id, key: histKey, ttlSeconds, err: String(err) }); } else { await new Promise((r) => setTimeout(r, 100 * attempt)); } } } })(); } }