377 lines
11 KiB
JavaScript
377 lines
11 KiB
JavaScript
const { ScheduledJob } = require("../models/scheduledJob");
|
|
const { ScheduledJobRun } = require("../models/scheduledJobRun");
|
|
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
|
const { isSingleUserMode } = require("../utils/middleware/multiUserProtected");
|
|
const { reqBody, safeJsonParse } = require("../utils/http");
|
|
const { BackgroundService } = require("../utils/BackgroundWorkers");
|
|
const { Telemetry } = require("../models/telemetry");
|
|
|
|
// BackgroundService is a singleton, so `new BackgroundService()` anywhere in
|
|
// the codebase returns the same instance that `server/index.js` booted. We
|
|
// grab that reference once and reuse it across handlers.
|
|
const backgroundService = new BackgroundService();
|
|
|
|
function scheduledJobEndpoints(app) {
|
|
if (!app) return;
|
|
|
|
// List available tools for job configuration
|
|
app.get(
|
|
"/scheduled-jobs/available-tools",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (_request, response) => {
|
|
try {
|
|
const tools = await ScheduledJob.availableTools();
|
|
return response.status(200).json({ tools });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500).json({ tools: [] });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get a single run detail
|
|
app.get(
|
|
"/scheduled-jobs/runs/:runId",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const run = await ScheduledJobRun.get({
|
|
id: Number(request.params.runId),
|
|
});
|
|
if (!run) {
|
|
return response
|
|
.status(404)
|
|
.json({ run: null, error: "Run not found" });
|
|
}
|
|
|
|
const job = await ScheduledJob.get({ id: run.jobId });
|
|
return response.status(200).json({
|
|
run: {
|
|
...run,
|
|
result: safeJsonParse(run.result, null),
|
|
},
|
|
job,
|
|
});
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Mark a run as read or continue in thread, or kill a running or queued job run
|
|
app.post(
|
|
"/scheduled-jobs/runs/:runId/:action",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const { action } = request.params;
|
|
|
|
if (!["read", "continue", "kill"].includes(action))
|
|
throw new Error("Invalid action");
|
|
|
|
if (action === "read") {
|
|
await ScheduledJobRun.markRead(Number(request.params.runId));
|
|
return response.status(200).json({ success: true });
|
|
}
|
|
|
|
if (action === "continue") {
|
|
const { workspace, thread, error } =
|
|
await ScheduledJobRun.continueInThread(
|
|
Number(request.params.runId)
|
|
);
|
|
if (error) return response.status(500).json({ error });
|
|
|
|
return response.status(200).json({
|
|
workspaceSlug: workspace.slug,
|
|
threadSlug: thread.slug,
|
|
});
|
|
}
|
|
|
|
if (action === "kill") {
|
|
const run = await ScheduledJobRun.get({
|
|
id: Number(request.params.runId),
|
|
});
|
|
if (!run)
|
|
return response.status(404).json({ error: "Run not found" });
|
|
if (!["queued", "running"].includes(run.status)) {
|
|
return response.status(400).json({
|
|
error: "Only running or queued jobs can be killed",
|
|
});
|
|
}
|
|
|
|
const killed = backgroundService.killRun(run.jobId, run.id);
|
|
if (!killed) await ScheduledJobRun.kill(run.id);
|
|
return response.status(200).json({ success: true });
|
|
}
|
|
} catch {
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// List all scheduled jobs
|
|
app.get(
|
|
"/scheduled-jobs",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (_request, response) => {
|
|
try {
|
|
const jobs = await ScheduledJob.where({}, null, null, {
|
|
runs: {
|
|
take: 1,
|
|
orderBy: { startedAt: "desc" },
|
|
},
|
|
});
|
|
|
|
const jobsWithStatus = jobs.map(({ runs, ...job }) => ({
|
|
...job,
|
|
latestRun: runs[0] || null,
|
|
}));
|
|
|
|
return response.status(200).json({ jobs: jobsWithStatus });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Create a new scheduled job
|
|
app.post(
|
|
"/scheduled-jobs/new",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const { name, prompt, tools, schedule } = reqBody(request);
|
|
let errorMessage = null;
|
|
|
|
if (!name?.trim()) {
|
|
errorMessage = "Name is required";
|
|
} else if (!prompt?.trim()) {
|
|
errorMessage = "Prompt is required";
|
|
} else if (!schedule?.trim()) {
|
|
errorMessage = "Schedule is required";
|
|
} else if (!ScheduledJob.isValidCron(schedule)) {
|
|
errorMessage = "Invalid cron expression";
|
|
} else if (tools?.length > 0 && !Array.isArray(tools)) {
|
|
errorMessage = "Tools must be an array";
|
|
}
|
|
if (errorMessage)
|
|
return response.status(400).json({
|
|
job: null,
|
|
error: errorMessage,
|
|
});
|
|
|
|
// New jobs default to enabled, so creating one always counts as an
|
|
// activation. Reject if it would push us past the configured cap.
|
|
const activation = await ScheduledJob.canActivate();
|
|
if (!activation.allowed) {
|
|
return response.status(400).json({
|
|
job: null,
|
|
error: `Cannot create: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`,
|
|
});
|
|
}
|
|
|
|
const { job, error } = await ScheduledJob.create({
|
|
name: name.trim(),
|
|
prompt: prompt.trim(),
|
|
tools: tools || null,
|
|
schedule: schedule.trim(),
|
|
});
|
|
|
|
if (error) {
|
|
return response.status(400).json({ job: null, error });
|
|
}
|
|
|
|
backgroundService.addScheduledJob(job);
|
|
Telemetry.sendTelemetry("scheduled_job_created").catch(() => {});
|
|
return response.status(201).json({ job, error: null });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get a single scheduled job
|
|
app.get(
|
|
"/scheduled-jobs/:id",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const job = await ScheduledJob.get({
|
|
id: Number(request.params.id),
|
|
});
|
|
if (!job) {
|
|
return response
|
|
.status(404)
|
|
.json({ job: null, error: "Job not found" });
|
|
}
|
|
return response.status(200).json({ job });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Update a scheduled job
|
|
app.put(
|
|
"/scheduled-jobs/:id",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const { name, prompt, tools, schedule, enabled } = reqBody(request);
|
|
const updates = {};
|
|
|
|
if (name !== undefined) updates.name = String(name).trim();
|
|
if (prompt !== undefined) updates.prompt = String(prompt).trim();
|
|
if (tools !== undefined) updates.tools = tools;
|
|
if (enabled !== undefined) updates.enabled = Boolean(enabled);
|
|
if (schedule !== undefined) {
|
|
if (!ScheduledJob.isValidCron(schedule)) {
|
|
return response
|
|
.status(400)
|
|
.json({ job: null, error: "Invalid cron expression" });
|
|
}
|
|
updates.schedule = String(schedule).trim();
|
|
}
|
|
|
|
// If this update would activate the job, enforce the active-jobs cap.
|
|
// We pass excludeId so a re-save of an already-enabled job is not
|
|
// double-counted against the limit.
|
|
if (updates.enabled === true) {
|
|
const activation = await ScheduledJob.canActivate({
|
|
excludeId: Number(request.params.id),
|
|
});
|
|
if (!activation.allowed) {
|
|
return response.status(400).json({
|
|
job: null,
|
|
error: `Cannot enable: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const { job, error } = await ScheduledJob.update(
|
|
Number(request.params.id),
|
|
updates
|
|
);
|
|
|
|
if (error) {
|
|
return response.status(400).json({ job: null, error });
|
|
}
|
|
|
|
await backgroundService.syncScheduledJob(job.id);
|
|
|
|
return response.status(200).json({ job, error: null });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Delete a scheduled job
|
|
app.delete(
|
|
"/scheduled-jobs/:id",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
backgroundService.removeScheduledJob(Number(request.params.id));
|
|
|
|
const success = await ScheduledJob.delete(Number(request.params.id));
|
|
return response.status(200).json({ success });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Toggle enable/disable
|
|
app.post(
|
|
"/scheduled-jobs/:id/toggle",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const job = await ScheduledJob.get({
|
|
id: Number(request.params.id),
|
|
});
|
|
if (!job) {
|
|
return response.status(404).json({ error: "Job not found" });
|
|
}
|
|
|
|
// Toggling a disabled job to enabled is an activation — enforce the cap.
|
|
// Disabling never needs a check.
|
|
if (!job.enabled) {
|
|
const activation = await ScheduledJob.canActivate({
|
|
excludeId: job.id,
|
|
});
|
|
if (!activation.allowed) {
|
|
return response.status(400).json({
|
|
job: null,
|
|
error: `Cannot enable: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const { job: updated } = await ScheduledJob.update(job.id, {
|
|
enabled: !job.enabled,
|
|
});
|
|
|
|
await backgroundService.syncScheduledJob(job.id);
|
|
|
|
return response.status(200).json({ job: updated });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Manual trigger — runs the job immediately
|
|
app.post(
|
|
"/scheduled-jobs/:id/trigger",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const job = await ScheduledJob.get({
|
|
id: Number(request.params.id),
|
|
});
|
|
if (!job) {
|
|
return response.status(404).json({ error: "Job not found" });
|
|
}
|
|
|
|
const run = await backgroundService.enqueueScheduledJob(job.id);
|
|
return response
|
|
.status(200)
|
|
.json({ success: true, skipped: !run, error: null });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
|
|
// List runs for a job
|
|
app.get(
|
|
"/scheduled-jobs/:id/runs",
|
|
[validatedRequest, isSingleUserMode],
|
|
async (request, response) => {
|
|
try {
|
|
const runs = await ScheduledJobRun.where(
|
|
{ jobId: Number(request.params.id) },
|
|
50,
|
|
{ startedAt: "desc" }
|
|
);
|
|
return response.status(200).json({ runs });
|
|
} catch (e) {
|
|
console.error(e.message, e);
|
|
response.sendStatus(500);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
module.exports = { scheduledJobEndpoints };
|