Files
openchamber/packages/web/server/lib/scheduled-tasks/routes.js
T
Bohdan Triapitsyn 8152bd7808 perf: reduce desktop quit risk polling
Refresh quit risk only when quitting
Use in-process status for Electron local server
Avoid repeated scheduled task status scans
2026-04-30 13:54:58 +03:00

236 lines
7.5 KiB
JavaScript

const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const parseProjectID = (req) => asNonEmptyString(req?.params?.projectId);
const parseTaskID = (req) => asNonEmptyString(req?.params?.taskId);
export const registerScheduledTaskRoutes = (app, dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
getOpenChamberEventClients,
writeSseEvent,
} = dependencies;
const findProjectByID = async (projectID) => {
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
return projects.find((project) => project.id === projectID) || null;
};
app.get('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
const projectID = parseProjectID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
return res.json({ tasks });
} catch (error) {
console.error('[ScheduledTasks] failed to load tasks:', error);
return res.status(500).json({ error: 'Failed to load scheduled tasks' });
}
});
app.put('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
const projectID = parseProjectID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
const taskInput = req.body && typeof req.body === 'object' ? req.body.task : null;
if (!taskInput || typeof taskInput !== 'object') {
return res.status(400).json({ error: 'task payload is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const upserted = await projectConfigRuntime.upsertScheduledTask(projectID, taskInput);
await scheduledTasksRuntime.syncProject(projectID);
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
const freshTask = freshTasks.find((task) => task.id === upserted.task.id) || upserted.task;
return res.json({
tasks: freshTasks,
task: freshTask,
created: upserted.created,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save scheduled task';
const statusCode = message.toLowerCase().includes('required') || message.toLowerCase().includes('invalid')
? 400
: 500;
if (statusCode === 500) {
console.error('[ScheduledTasks] failed to save task:', error);
}
return res.status(statusCode).json({ error: message });
}
});
app.delete('/api/projects/:projectId/scheduled-tasks/:taskId', async (req, res) => {
const projectID = parseProjectID(req);
const taskID = parseTaskID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
if (!taskID) {
return res.status(400).json({ error: 'taskId is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const result = await projectConfigRuntime.deleteScheduledTask(projectID, taskID);
if (!result.deleted) {
return res.status(404).json({ error: 'Task not found' });
}
await scheduledTasksRuntime.syncProject(projectID);
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
return res.json({ tasks: freshTasks });
} catch (error) {
console.error('[ScheduledTasks] failed to delete task:', error);
return res.status(500).json({ error: 'Failed to delete scheduled task' });
}
});
app.post('/api/projects/:projectId/scheduled-tasks/:taskId/run', async (req, res) => {
const projectID = parseProjectID(req);
const taskID = parseTaskID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
if (!taskID) {
return res.status(400).json({ error: 'taskId is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const result = await scheduledTasksRuntime.runNow(projectID, taskID);
if (result.running || result.queued) {
return res.status(409).json({ error: result.error || 'Task already running' });
}
if (result.skipped) {
return res.status(404).json({ error: 'Task not found or disabled' });
}
if (!result.ok) {
return res.status(500).json({
error: result.error || 'Task run failed',
task: result.task,
});
}
return res.json({
ok: true,
task: result.task,
sessionId: result.sessionID,
});
} catch (error) {
console.error('[ScheduledTasks] failed to run task:', error);
return res.status(500).json({ error: 'Failed to run scheduled task' });
}
});
app.get('/api/openchamber/scheduled-tasks/status', async (_req, res) => {
try {
if (typeof scheduledTasksRuntime.getStatus === 'function') {
return res.json(scheduledTasksRuntime.getStatus());
}
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
let enabledCount = 0;
let runningCount = 0;
for (const project of projects) {
try {
const tasks = await projectConfigRuntime.listScheduledTasks(project.id);
for (const task of tasks) {
if (task?.enabled) {
enabledCount += 1;
}
if (task?.state?.lastStatus === 'running') {
runningCount += 1;
}
}
} catch {
}
}
return res.json({
hasEnabledScheduledTasks: enabledCount > 0,
hasRunningScheduledTasks: runningCount > 0,
enabledScheduledTasksCount: enabledCount,
runningScheduledTasksCount: runningCount,
});
} catch (error) {
console.error('[ScheduledTasks] failed to resolve scheduled task status:', error);
return res.status(500).json({ error: 'Failed to resolve scheduled task status' });
}
});
app.get('/api/openchamber/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const clients = getOpenChamberEventClients();
clients.add(res);
try {
writeSseEvent(res, {
type: 'openchamber:event-stream-ready',
properties: {
connectedAt: Date.now(),
},
});
} catch {
}
const heartbeat = setInterval(() => {
try {
writeSseEvent(res, {
type: 'openchamber:heartbeat',
properties: {
timestamp: Date.now(),
},
});
} catch {
clearInterval(heartbeat);
clients.delete(res);
}
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
};