UNPKG

json-object-editor

Version:

JOE the Json Object Editor | Platform Edition

413 lines (373 loc) 15.1 kB
// modules/AiJobs.js /** * AI Jobs tracking module for JOE. * Tracks active AI operations (prompts, autofill, thoughts) by object and identifier. * Jobs are keyed by lookup key: {objectId}_{identifier} */ const AiJobs = { // Active jobs storage: lookupKey -> array of job objects active: {}, /** * Emit socket event for job update * TODO: Phase 2 - Remove socket, use polling only * @param {string} lookupKey - Lookup key ({objectId}_{fieldName}) * @param {object} job - Job object */ emitJobUpdate: function(lookupKey, job) { // Socket removed for Phase 1 - will use polling only // TODO: Remove this function in Phase 2 or re-enable if socket needed }, /** * Extract lookup key from token: {objectId}|{fieldName}|{timestamp}|{userid} * Returns: {objectId}_{fieldName} * * Examples: * "objId|fieldName|123|userid" -> "objId_fieldName" * "objId|description|456|userid" -> "objId_description" * "objId|select_prompt|789|userid" -> "objId_select_prompt" */ extractKey: function(token) { if (!token || typeof token !== 'string') return null; // Token format: objectId|fieldName|timestamp|userid (4 parts separated by |) var parts = token.split('|'); if (parts.length !== 4) { return null; } // New format: parts[0] = objectId, parts[1] = fieldName // Return lookup key using underscore separator (consistent with API routes) return parts[0] + '_' + parts[1]; }, /** * Create/register a job * @param {string} token - Full job token * @param {object} jobData - { promptId?, promptName?, fieldId?, startTime?, status?, progress?, total?, message? } * @returns {boolean} Success */ createJob: function(token, jobData) { if (!token) return false; var lookupKey = this.extractKey(token); if (!lookupKey) { console.warn('[AiJobs] createJob: Invalid token format:', token); return false; } if (!this.active[lookupKey]) { this.active[lookupKey] = []; } var job = { token: token, startTime: jobData.startTime || new Date().toISOString(), status: jobData.status || 'running', promptId: jobData.promptId || null, promptName: jobData.promptName || null, fieldId: jobData.fieldId || null, progress: jobData.progress != null ? jobData.progress : 0, total: jobData.total != null ? jobData.total : null, message: jobData.message || '' }; // Check if job already exists (avoid duplicates) var existing = this.active[lookupKey].find(function(j) { return j.token === token; }); if (existing) { // Update existing job Object.assign(existing, job); // Emit socket update this.emitJobUpdate(lookupKey, existing); } else { this.active[lookupKey].push(job); // Emit socket update for new job this.emitJobUpdate(lookupKey, job); } return true; }, /** * Remove a job by token * @param {string} token - Full job token * @returns {boolean} Success */ removeJob: function(token) { if (!token) return false; var lookupKey = this.extractKey(token); if (!lookupKey || !this.active[lookupKey]) return false; this.active[lookupKey] = this.active[lookupKey].filter(function(j) { return j.token !== token; }); // Clean up empty arrays if (this.active[lookupKey].length === 0) { delete this.active[lookupKey]; } return true; }, /** * Remove a job with delay (updates status first, then removes after delay) * @param {string} token - Full job token * @param {string} finalStatus - Optional: 'error' or 'complete' (default: 'complete') * @param {string} message - Optional: Final message * @param {number} delaySeconds - Optional: Delay before removal in seconds (default: 10) * @returns {number|null} Timeout ID (can be used to cancel), or null if job not found */ removeJobWithDelay: function(token, finalStatus, message, delaySeconds) { if (!token) return null; finalStatus = finalStatus || 'complete'; delaySeconds = delaySeconds != null ? delaySeconds : 10; // Update job status first var lookupKey = this.extractKey(token); if (!lookupKey) return null; var updated = this.updateJob(token, { status: finalStatus, message: message || (finalStatus === 'error' ? 'Error occurred' : 'Complete'), progress: finalStatus === 'error' ? null : 100, total: finalStatus === 'error' ? null : 100 }); if (!updated) { return null; // Job not found } // Emit final status update var job = this.active[lookupKey] && this.active[lookupKey].find(function(j) { return j.token === token; }); if (job) { this.emitJobUpdate(lookupKey, job); } // Schedule removal after delay var delayMs = delaySeconds * 1000; var self = this; var timeoutId = setTimeout(function() { self.removeJob(token); }, delayMs); return timeoutId; }, /** * Get all active jobs for an object * @param {string} objectId - Object ID * @returns {array} Array of { lookupKey, jobs: [...] } */ getActiveJobsForObject: function(objectId) { if (!objectId) return []; var results = []; for (var lookupKey in this.active) { // Check if lookupKey starts with objectId_ if (lookupKey.indexOf(objectId + '_') === 0) { var jobs = this.active[lookupKey].filter(function(j) { return j.status === 'running' || j.status === 'starting'; }); if (jobs.length > 0) { results.push({ lookupKey: lookupKey, jobs: jobs }); } } } return results; }, /** * Update job progress * @param {string} token - Full job token * @param {object} updates - { progress?, total?, message?, status? } * @returns {boolean} Success */ updateJob: function(token, updates) { if (!token) return false; var lookupKey = this.extractKey(token); if (!lookupKey || !this.active[lookupKey]) return false; var job = this.active[lookupKey].find(function(j) { return j.token === token; }); if (job) { Object.assign(job, updates); // Emit socket update this.emitJobUpdate(lookupKey, job); return true; } return false; }, /** * Cleanup old jobs (safety net - call periodically) * @param {number} maxAgeMinutes - Remove jobs older than this (default: 60) */ cleanup: function(maxAgeMinutes) { maxAgeMinutes = maxAgeMinutes || 60; // Default 1 hour var cutoff = new Date(Date.now() - (maxAgeMinutes * 60 * 1000)).toISOString(); var removed = 0; for (var lookupKey in this.active) { var before = this.active[lookupKey].length; this.active[lookupKey] = this.active[lookupKey].filter(function(j) { return j.startTime > cutoff; }); removed += (before - this.active[lookupKey].length); if (this.active[lookupKey].length === 0) { delete this.active[lookupKey]; } } if (removed > 0) { console.log('[AiJobs] cleanup: Removed ' + removed + ' stale job(s)'); } return removed; }, /** * Calculate elapsed seconds from startTime * @param {string} startTime - ISO timestamp string * @returns {number} Elapsed seconds (integer) */ calculateElapsed: function(startTime) { if (!startTime) return 0; try { var start = new Date(startTime); var now = new Date(); var elapsedMs = now - start; return Math.floor(elapsedMs / 1000); } catch (e) { return 0; } }, /** * Get all active jobs (for debugging/admin) * @returns {object} All active jobs by lookupKey */ getAllActive: function() { return this.active; }, /** * HTTP Route Handler: Get all active jobs */ getAllActiveRoute: function(req, res) { try { var allJobs = []; var totalCount = 0; for (var lookupKey in AiJobs.active) { var jobs = AiJobs.active[lookupKey].filter(function(j) { return j.status === 'running' || j.status === 'starting'; }).map(function(j) { // Add elapsed seconds to each job var elapsedSeconds = AiJobs.calculateElapsed(j.startTime); return Object.assign({}, j, { elapsedSeconds: elapsedSeconds }); }); if (jobs.length > 0) { allJobs.push({ lookupKey: lookupKey, jobs: jobs }); totalCount += jobs.length; } } return res.json({ jobs: allJobs, count: totalCount, lookupKeys: allJobs.length }); } catch (e) { console.error('[AiJobs] getAllActiveRoute error:', e); return res.status(500).json({ error: e.message || 'Error getting active jobs' }); } }, /** * HTTP Route Handler: Get active jobs for specific object */ getActiveForObjectRoute: function(req, res) { try { var objectId = req.params.objectId; if (!objectId) { return res.status(400).json({ error: 'Object ID required' }); } var jobs = AiJobs.getActiveJobsForObject(objectId); // Add elapsed seconds to each job jobs = jobs.map(function(group) { return { lookupKey: group.lookupKey, jobs: (group.jobs || []).map(function(j) { var elapsedSeconds = AiJobs.calculateElapsed(j.startTime); return Object.assign({}, j, { elapsedSeconds: elapsedSeconds }); }) }; }); return res.json({ jobs: jobs, objectId: objectId, count: jobs.reduce(function(sum, group) { return sum + (group.jobs ? group.jobs.length : 0); }, 0) }); } catch (e) { console.error('[AiJobs] getActiveForObjectRoute error:', e); return res.status(500).json({ error: e.message || 'Error getting jobs for object' }); } }, /** * HTTP Route Handler: Get active jobs for specific object and field */ getActiveForFieldRoute: function(req, res) { try { var objectId = req.params.objectId; var fieldName = req.params.fieldName; if (!objectId || !fieldName) { return res.status(400).json({ error: 'Object ID and field name required' }); } // Lookup key format: {objectId}_{fieldName} var lookupKey = objectId + '_' + fieldName; var jobs = []; if (AiJobs.active[lookupKey]) { jobs = AiJobs.active[lookupKey].filter(function(j) { // Return all jobs (including completed) - client will filter for display return true; }).map(function(j) { var elapsedSeconds = AiJobs.calculateElapsed(j.startTime); return Object.assign({}, j, { elapsedSeconds: elapsedSeconds }); }); } return res.json({ jobs: jobs, objectId: objectId, fieldName: fieldName, count: jobs.length }); } catch (e) { console.error('[AiJobs] getActiveForFieldRoute error:', e); return res.status(500).json({ error: e.message || 'Error getting jobs for field' }); } }, /** * Initialize routes (called from init.js after Server is ready) */ init: function initAiJobsRoutes() { try { if (!global.JOE || !JOE.Server) return; if (JOE._aiJobsInitialized) return; const server = JOE.Server; const auth = JOE.auth; // may be undefined if no auth configured // Get all active jobs if (auth) { server.get('/API/aijobs', auth, function(req, res) { return AiJobs.getAllActiveRoute(req, res); }); server.get('/API/aijobs/:objectId', auth, function(req, res) { return AiJobs.getActiveForObjectRoute(req, res); }); server.get('/API/aijobs/:objectId/:fieldName', auth, function(req, res) { return AiJobs.getActiveForFieldRoute(req, res); }); } else { server.get('/API/aijobs', function(req, res) { return AiJobs.getAllActiveRoute(req, res); }); server.get('/API/aijobs/:objectId', function(req, res) { return AiJobs.getActiveForObjectRoute(req, res); }); server.get('/API/aijobs/:objectId/:fieldName', function(req, res) { return AiJobs.getActiveForFieldRoute(req, res); }); } JOE._aiJobsInitialized = true; console.log('[AiJobs] routes attached'); } catch (e) { console.log('[AiJobs] init error:', e); } } }; module.exports = AiJobs;