json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
413 lines (373 loc) • 15.1 kB
JavaScript
// 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;