@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
JavaScript
/**
* 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));
}
}
}
})();
}
}