converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
910 lines (799 loc) • 24.5 kB
JavaScript
/**
* Async Job Store - Job State Management
*
* Manages async execution status, progress, and results using lru-cache for TTL management.
* Core foundation for async chat and consensus tools, providing fast access to job states
* with automatic cleanup. Integrates with existing continuation store patterns.
*/
import { LRUCache } from 'lru-cache';
import { nanoid } from 'nanoid';
import { debugLog, debugError } from '../utils/console.js';
import { getEventBus, EVENT_TYPES } from './eventBus.js';
/**
* Job statuses
*/
export const JOB_STATUS = {
QUEUED: 'queued',
RUNNING: 'running',
COMPLETED: 'completed',
FAILED: 'failed',
CANCELLED: 'cancelled',
};
/**
* Storage backend interface that all async job stores must implement
* This ensures pluggable backend replacement without changing the API
*/
export class AsyncJobStoreInterface {
/**
* Create new job
* @param {string} _tool - Tool name ('chat' | 'consensus')
* @param {object} _options - Job options
* @returns {Promise<string>} Job ID
*/
async create(_tool, _options = {}) {
throw new Error('create() method must be implemented by storage backend');
}
/**
* Get job state
* @param {string} _jobId - Job identifier
* @returns {Promise<object|null>} Job state or null if not found
*/
async get(_jobId) {
throw new Error('get() method must be implemented by storage backend');
}
/**
* Update job state
* @param {string} _jobId - Job identifier
* @param {object} _updates - State updates
* @returns {Promise<boolean>} True if updated
*/
async update(_jobId, _updates) {
throw new Error('update() method must be implemented by storage backend');
}
/**
* Complete job with result
* @param {string} _jobId - Job identifier
* @param {object} _result - Job result
* @returns {Promise<boolean>} True if completed
*/
async complete(_jobId, _result) {
throw new Error('complete() method must be implemented by storage backend');
}
/**
* Fail job with error
* @param {string} _jobId - Job identifier
* @param {Error|object} _error - Error information
* @returns {Promise<boolean>} True if failed
*/
async fail(_jobId, _error) {
throw new Error('fail() method must be implemented by storage backend');
}
/**
* Check if job exists
* @param {string} jobId - Job identifier
* @returns {Promise<boolean>} True if exists
*/
async exists(jobId) {
const job = await this.get(jobId);
return job !== null;
}
/**
* Get all jobs
* @param {object} _options - Query options (limit, status, etc.)
* @returns {Promise<Array>} Array of all jobs
*/
async getAllJobs(_options = {}) {
throw new Error(
'getAllJobs() method must be implemented by storage backend',
);
}
/**
* Get storage statistics
* @returns {Promise<object>} Backend-specific statistics
*/
async getStats() {
throw new Error('getStats() method must be implemented by storage backend');
}
/**
* Clean up old data
* @param {number} _maxAgeMs - Maximum age in milliseconds
* @returns {Promise<number>} Number of items cleaned up
*/
async cleanup(_maxAgeMs) {
throw new Error('cleanup() method must be implemented by storage backend');
}
}
/**
* Custom error class for async job store operations
*/
export class AsyncJobStoreError extends Error {
constructor(message, code = 'JOB_STORE_ERROR') {
super(message);
this.name = 'AsyncJobStoreError';
this.code = code;
}
}
/**
* In-memory async job store implementation using LRU cache
* Implements the AsyncJobStoreInterface for pluggable backend replacement
*/
class LRUAsyncJobStore extends AsyncJobStoreInterface {
constructor() {
super();
// Check environment variable for memory TTL
const envMemoryTTL = process.env.ASYNC_MEMORY_TTL_MS
? parseInt(process.env.ASYNC_MEMORY_TTL_MS, 10)
: null;
const ttl = envMemoryTTL || 24 * 60 * 60 * 1000; // Default 24 hours
// Configure LRU cache with configurable TTL and 10k job capacity
this.jobs = new LRUCache({
max: 10000, // Maximum 10k jobs to prevent memory leaks
ttl, // Configurable TTL from environment
updateAgeOnGet: true, // Update TTL on access
updateAgeOnHas: false, // Don't update TTL just for existence checks
});
this.maxEventsPerJob = 100; // Ring buffer size for events
this.eventBus = getEventBus(); // Get global EventBus instance
debugLog('AsyncJobStore', `Initialized with TTL: ${ttl}ms`);
// Set up EventBus listeners to capture events for storage
this._setupEventBusListeners();
}
/**
* Create new job
* @param {string} tool - Tool name ('chat' | 'consensus')
* @param {object} options - Job options
* @returns {Promise<string>} Job ID
* @throws {AsyncJobStoreError} If creation fails
*/
async create(tool, options = {}) {
try {
// Validate parameters
if (!tool || !['chat'].includes(tool)) {
throw new AsyncJobStoreError(
'Invalid tool: must be "chat"',
'INVALID_TOOL',
);
}
// Use provided jobId from options (required)
const jobId = options.jobId;
if (!jobId) {
throw new AsyncJobStoreError(
'jobId is required in options',
'MISSING_JOB_ID',
);
}
const now = Date.now();
// Create initial job state
const jobState = {
jobId,
sessionId: options.sessionId || 'local-user',
status: JOB_STATUS.QUEUED,
tool,
createdAt: now,
updatedAt: now,
overall: {
progress: 0.0, // 0.0 to 1.0
startedAt: null,
endedAt: null,
result: null,
error: null,
},
providers: new Map(), // Per-provider state tracking
events: [], // Ring buffer for events
seq: 0, // Sequence counter for events
// Summarization fields
accumulated_content: null, // Full streaming content
title: null, // Generated request title
final_summary: null, // Completed job summary
reasoning_summary: null, // OpenAI reasoning summary
...options, // Allow additional options
};
// Store the job
this.jobs.set(jobId, jobState);
// Log event
this._addEvent(jobState, 'job_created', {
tool,
});
debugLog(`AsyncJobStore: Created job ${jobId} for ${tool}`);
return jobId;
} catch (error) {
if (error instanceof AsyncJobStoreError) {
throw error;
}
throw new AsyncJobStoreError(
`Failed to create job: ${error.message}`,
'CREATION_ERROR',
);
}
}
/**
* Get job state
* @param {string} jobId - Job identifier
* @returns {Promise<object|null>} Job state or null if not found
* @throws {AsyncJobStoreError} If retrieval fails
*/
async get(jobId) {
try {
// Validate job ID
if (!jobId || typeof jobId !== 'string') {
throw new AsyncJobStoreError(
'Invalid job ID: must be a non-empty string',
'INVALID_JOB_ID',
);
}
const job = this.jobs.get(jobId);
if (!job) {
return null;
}
// Update last accessed time
job.lastAccessed = Date.now();
// Return deep copy to prevent external mutations
return this._deepClone(job);
} catch (error) {
if (error instanceof AsyncJobStoreError) {
throw error;
}
throw new AsyncJobStoreError(
`Failed to retrieve job: ${error.message}`,
'RETRIEVAL_ERROR',
);
}
}
/**
* Update job state
* @param {string} jobId - Job identifier
* @param {object} updates - State updates
* @returns {Promise<boolean>} True if updated
* @throws {AsyncJobStoreError} If update fails
*/
async update(jobId, updates) {
try {
// Validate parameters
if (!jobId || typeof jobId !== 'string') {
throw new AsyncJobStoreError(
'Invalid job ID: must be a non-empty string',
'INVALID_JOB_ID',
);
}
if (!updates || typeof updates !== 'object') {
throw new AsyncJobStoreError(
'Invalid updates: must be an object',
'INVALID_UPDATES',
);
}
const job = this.jobs.get(jobId);
if (!job) {
return false;
}
const now = Date.now();
// Apply updates
if (
updates.status &&
Object.values(JOB_STATUS).includes(updates.status)
) {
job.status = updates.status;
// Set startedAt when status changes to running
if (updates.status === JOB_STATUS.RUNNING && !job.overall.startedAt) {
job.overall.startedAt = now;
}
}
if (updates.progress !== undefined) {
job.overall.progress = Math.max(0.0, Math.min(1.0, updates.progress));
}
if (updates.providers) {
// Update provider states
Object.entries(updates.providers).forEach(([provider, state]) => {
job.providers.set(provider, {
...job.providers.get(provider),
...state,
});
});
}
// Handle summarization fields explicitly
if (updates.accumulated_content !== undefined) {
job.accumulated_content = updates.accumulated_content;
}
if (updates.title !== undefined) {
job.title = updates.title;
}
if (updates.final_summary !== undefined) {
job.final_summary = updates.final_summary;
}
if (updates.reasoning_summary !== undefined) {
job.reasoning_summary = updates.reasoning_summary;
}
// Apply any other updates as direct properties on the job
const reservedFields = [
'status',
'progress',
'providers',
'overall',
'jobId',
'sessionId',
'createdAt',
'updatedAt',
'events',
'seq',
'accumulated_content',
'title',
'final_summary',
'reasoning_summary',
];
Object.entries(updates).forEach(([key, value]) => {
if (!reservedFields.includes(key)) {
job[key] = value;
}
});
// Update timestamp
job.updatedAt = now;
// Log event
this._addEvent(job, 'job_updated', updates);
return true;
} catch (error) {
if (error instanceof AsyncJobStoreError) {
throw error;
}
throw new AsyncJobStoreError(
`Failed to update job: ${error.message}`,
'UPDATE_ERROR',
);
}
}
/**
* Complete job with result
* @param {string} jobId - Job identifier
* @param {object} result - Job result
* @returns {Promise<boolean>} True if completed
* @throws {AsyncJobStoreError} If completion fails
*/
async complete(jobId, result) {
try {
const job = this.jobs.get(jobId);
if (!job) {
return false;
}
const now = Date.now();
// Update job state
job.status = JOB_STATUS.COMPLETED;
job.overall.progress = 1.0;
job.overall.endedAt = now;
job.overall.result = result;
job.updatedAt = now;
// Log event
this._addEvent(job, 'job_completed', {
result: result ? 'present' : 'null',
});
debugLog(`AsyncJobStore: Completed job ${jobId}`);
return true;
} catch (error) {
throw new AsyncJobStoreError(
`Failed to complete job: ${error.message}`,
'COMPLETION_ERROR',
);
}
}
/**
* Fail job with error
* @param {string} jobId - Job identifier
* @param {Error|object} error - Error information
* @returns {Promise<boolean>} True if failed
* @throws {AsyncJobStoreError} If failing fails
*/
async fail(jobId, error) {
try {
const job = this.jobs.get(jobId);
if (!job) {
return false;
}
const now = Date.now();
// Serialize error information
const errorInfo =
error instanceof Error
? { message: error.message, name: error.name, stack: error.stack }
: error;
// Update job state
job.status = JOB_STATUS.FAILED;
job.overall.endedAt = now;
job.overall.error = errorInfo;
job.updatedAt = now;
// Log event
this._addEvent(job, 'job_failed', {
error: errorInfo?.message || 'Unknown error',
});
debugError(`AsyncJobStore: Failed job ${jobId}:`, errorInfo);
return true;
} catch (err) {
throw new AsyncJobStoreError(
`Failed to fail job: ${err.message}`,
'FAILURE_ERROR',
);
}
}
/**
* Get storage statistics
* @returns {Promise<object>} Store statistics
*/
async getStats() {
const statusCounts = {};
let totalEvents = 0;
// Count jobs by status
Object.values(JOB_STATUS).forEach((status) => {
statusCounts[status] = 0;
});
for (const job of this.jobs.values()) {
statusCounts[job.status]++;
totalEvents += job.events.length;
}
return {
backend: 'lru-cache',
totalJobs: this.jobs.size,
maxJobs: this.jobs.max,
ttl: this.jobs.ttl,
statusCounts,
totalEvents,
maxEventsPerJob: this.maxEventsPerJob,
memoryUsage: process.memoryUsage(),
};
}
/**
* Clean up old jobs
* @param {number} maxAgeMs - Maximum age in milliseconds (default: 24 hours)
* @returns {Promise<number>} Number of jobs cleaned up
*/
async cleanup(maxAgeMs = 24 * 60 * 60 * 1000) {
const now = Date.now();
let cleanedCount = 0;
// Special case: if maxAgeMs is 0, clean up all jobs
if (maxAgeMs === 0) {
cleanedCount = this.jobs.size;
this.jobs.clear();
return cleanedCount;
}
// Clean up old jobs based on last update time
for (const [jobId, job] of this.jobs.entries()) {
if (now - job.updatedAt > maxAgeMs) {
this.jobs.delete(jobId);
cleanedCount++;
}
}
return cleanedCount;
}
/**
* Get all jobs
* @param {object} options - Query options
* @param {number} options.limit - Maximum number of jobs to return (default: 50)
* @param {string} options.status - Filter by job status (optional)
* @param {string} options.sortBy - Sort by field ('createdAt', 'updatedAt') (default: 'updatedAt')
* @param {string} options.sortOrder - Sort order ('asc', 'desc') (default: 'desc')
* @returns {Promise<Array>} Array of all jobs
*/
async getAllJobs(options = {}) {
try {
const {
limit = 50,
status,
sortBy = 'updatedAt',
sortOrder = 'desc',
} = options;
// Validate options
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
throw new AsyncJobStoreError(
'Limit must be an integer between 1 and 1000',
'INVALID_LIMIT',
);
}
if (status && !Object.values(JOB_STATUS).includes(status)) {
throw new AsyncJobStoreError(
`Invalid status: must be one of ${Object.values(JOB_STATUS).join(', ')}`,
'INVALID_STATUS',
);
}
// Collect all jobs
const allJobs = [];
for (const job of this.jobs.values()) {
// Apply status filter if specified
if (!status || job.status === status) {
// Return deep copy to prevent external mutations
allJobs.push(this._deepClone(job));
}
}
// Sort jobs
const sortField = sortBy === 'createdAt' ? 'createdAt' : 'updatedAt';
const sortMultiplier = sortOrder === 'asc' ? 1 : -1;
allJobs.sort((a, b) => {
return (a[sortField] - b[sortField]) * sortMultiplier;
});
// Apply limit
const limitedJobs = allJobs.slice(0, limit);
debugLog(`AsyncJobStore: Found ${limitedJobs.length} jobs`);
return limitedJobs;
} catch (error) {
if (error instanceof AsyncJobStoreError) {
throw error;
}
throw new AsyncJobStoreError(
`Failed to get all jobs: ${error.message}`,
'QUERY_ERROR',
);
}
}
/**
* Set up EventBus listeners to capture job lifecycle events
* @private
*/
_setupEventBusListeners() {
// Listen for all job lifecycle events and store them in job ring buffers
const eventTypes = [
EVENT_TYPES.JOB_CREATED,
EVENT_TYPES.JOB_STARTED,
EVENT_TYPES.JOB_UPDATED,
EVENT_TYPES.JOB_COMPLETED,
EVENT_TYPES.JOB_FAILED,
EVENT_TYPES.JOB_CANCELLED,
];
eventTypes.forEach((eventType) => {
this.eventBus.on(eventType, (eventData) => {
this._storeEventInJob(eventData);
});
});
debugLog(
'AsyncJobStore: Set up EventBus listeners for job lifecycle events',
);
}
/**
* Store EventBus event in job's ring buffer
* @param {object} eventData - Event data from EventBus
* @private
*/
_storeEventInJob(eventData) {
try {
const job = this.jobs.get(eventData.jobId);
if (!job) {
// Job might have been cleaned up, skip event storage
return;
}
// Convert EventBus event format to job event format
const jobEvent = {
seq: ++job.seq,
timestamp: eventData.timestamp,
type: eventData.eventType,
data: eventData.data || {},
source: 'eventbus',
};
// Add to job's event ring buffer
job.events.push(jobEvent);
// Maintain ring buffer size
if (job.events.length > this.maxEventsPerJob) {
job.events.shift();
}
// Update job's last activity
job.updatedAt = Date.now();
} catch (error) {
debugError(
'AsyncJobStore: Failed to store EventBus event in job:',
error,
);
}
}
/**
* Get events from job with optional filtering
* @param {string} jobId - Job identifier
* @param {object} options - Filtering options
* @param {string} options.eventType - Filter by event type
* @param {number} options.limit - Maximum events to return
* @param {number} options.afterSeq - Return events after this sequence number
* @returns {Promise<Array>} Array of events
*/
async getJobEvents(jobId, options = {}) {
try {
const job = this.jobs.get(jobId);
if (!job) {
return [];
}
let events = job.events;
// Apply filters
if (options.eventType) {
events = events.filter((event) => event.type === options.eventType);
}
if (options.afterSeq !== undefined) {
events = events.filter((event) => event.seq > options.afterSeq);
}
// Apply limit
if (options.limit) {
events = events.slice(-options.limit);
}
// Return deep copy to prevent mutations
return this._deepClone(events);
} catch (error) {
debugError(
`AsyncJobStore: Failed to get events for job ${jobId}:`,
error,
);
return [];
}
}
/**
* Get latest event for a job
* @param {string} jobId - Job identifier
* @returns {Promise<object|null>} Latest event or null
*/
async getLatestJobEvent(jobId) {
try {
const job = this.jobs.get(jobId);
if (!job || job.events.length === 0) {
return null;
}
const latestEvent = job.events[job.events.length - 1];
return this._deepClone(latestEvent);
} catch (error) {
debugError(
`AsyncJobStore: Failed to get latest event for job ${jobId}:`,
error,
);
return null;
}
}
/**
* Generate a new job ID using same pattern as continuation store
* @returns {string} Unique job ID (format: job_XXXXXXXXXX)
* @private
*/
_generateJobId() {
// Generate a 10-character nanoid for short but unique IDs
return `job_${nanoid(10)}`;
}
/**
* Add event to job history with ring buffer behavior
* @param {object} job - Job state object
* @param {string} eventType - Type of event
* @param {object} data - Event data
* @private
*/
_addEvent(job, eventType, data = {}) {
const event = {
seq: ++job.seq,
timestamp: Date.now(),
type: eventType,
data,
};
job.events.push(event);
// Maintain ring buffer size
if (job.events.length > this.maxEventsPerJob) {
job.events.shift();
}
}
/**
* Deep clone object to prevent external mutations
* @param {object} obj - Object to clone
* @returns {object} Deep clone
* @private
*/
_deepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Map) {
const clonedMap = new Map();
for (const [key, value] of obj.entries()) {
clonedMap.set(key, this._deepClone(value));
}
return clonedMap;
}
if (obj instanceof Date) {
return new Date(obj.getTime());
}
if (Array.isArray(obj)) {
return obj.map((item) => this._deepClone(item));
}
const cloned = {};
for (const [key, value] of Object.entries(obj)) {
cloned[key] = this._deepClone(value);
}
return cloned;
}
}
// Singleton instance - can be replaced for different backends
let asyncJobStore = null;
let cleanupIntervalId = null;
/**
* Get the async job store instance
* @returns {AsyncJobStoreInterface} Async job store instance
*/
export function getAsyncJobStore() {
if (!asyncJobStore) {
asyncJobStore = new LRUAsyncJobStore();
// Set up periodic cleanup (runs every 10 minutes, same as continuation store)
cleanupIntervalId = setInterval(
async () => {
try {
const cleaned = await asyncJobStore.cleanup();
if (cleaned > 0) {
debugLog(`AsyncJobStore: Cleaned up ${cleaned} old jobs`);
}
} catch (error) {
debugError('AsyncJobStore cleanup failed:', error);
}
},
10 * 60 * 1000,
);
// Unref the interval so it doesn't keep the process alive
if (cleanupIntervalId && typeof cleanupIntervalId.unref === 'function') {
cleanupIntervalId.unref();
}
}
return asyncJobStore;
}
/**
* Set a custom async job store backend (for testing or different implementations)
* @param {AsyncJobStoreInterface|null} store - Custom store implementation or null to reset
*/
export function setAsyncJobStore(store) {
if (store !== null && !(store instanceof AsyncJobStoreInterface)) {
throw new AsyncJobStoreError(
'Store must extend AsyncJobStoreInterface',
'INVALID_STORE',
);
}
asyncJobStore = store;
}
/**
* Stop the cleanup interval timer
*/
export function stopAsyncJobStoreCleanup() {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
cleanupIntervalId = null;
}
}
/**
* Generate a new job ID
* @returns {string} Unique job ID (format: job_XXXXXXXXXX)
*/
export function generateJobId() {
// Generate a 10-character nanoid for short but unique IDs
return `job_${nanoid(10)}`;
}
/**
* Validate job ID format
* @param {string} jobId - ID to validate
* @returns {boolean} True if valid format
*/
export function isValidJobId(jobId) {
if (!jobId || typeof jobId !== 'string') {
return false;
}
// Accept both old job_ format and new continuation ID formats
const jobPattern = /^job_[A-Za-z0-9_-]{10}$/; // Old format
const continuationPattern = /^(conv|cons)_[A-Za-z0-9_-]{10,}$/; // New format
return jobPattern.test(jobId) || continuationPattern.test(jobId);
}
/**
* Helper function to add provider state to job
* @param {object} jobState - Current job state
* @param {string} providerId - Provider identifier
* @param {object} providerState - Provider state to set
* @returns {object} Updated state
*/
export function setProviderState(jobState, providerId, providerState) {
if (!jobState.providers) {
jobState.providers = new Map();
}
jobState.providers.set(providerId, {
...jobState.providers.get(providerId),
...providerState,
updatedAt: Date.now(),
});
return jobState;
}
/**
* Helper function to get provider state from job
* @param {object} jobState - Current job state
* @param {string} providerId - Provider identifier
* @returns {object|null} Provider state or null if not found
*/
export function getProviderState(jobState, providerId) {
if (!jobState.providers) {
return null;
}
return jobState.providers.get(providerId) || null;
}