merlyn/server/jobs/run-scheduled-job.js
Marcello Fitton 41495cdabe
feat: Scheduled Jobs (#5322)
* initialize

* expand tool result text limit | add syntax highlighting and json formatting to tool result rendering

* fix onError jsdoc

* lint

* fix unread icon

* route protection

* improve form handling for NewJobModal

* safeJsonParse

* remove unneeded comments

* remove trycatch

* add truncateText helper

* add explicit fallback value tos safeJsonParse

* add shared cron constant and helpers

* reduce frontend indirection

* use isLight to compute syntax highlighting theme

* remove dead code

* remove forJob and make job limit to 50

* create recomputeNextRunAt helper method

* add comment about nextRunAt recomputation

* add job queue and concurrency control to scheduled jobs

* use p-queue

* change default max concurrent value to 1

* add comment explaining internal scheduling system

* add recomputeNextRunAt on boot

* add generated documents to run details

* Modify toolsOverride functionality where no tools selected means no tools are given to the agent

add a select all/deselect all toggle button for easily selecting all
tools in the cerate job form

* create usePolling hook

* add polling to scheduled jobs and scheduled job runs pages

* add cron generation feature in job form

* remove cron generation feature | add cron builder feature | add max active scheduled jobs limit

* set MAX_ACTIVE to null

* replace hour and minute input fields with input with type time

* simplify

* organize components

* move components to bottom of page component

* change Generated Documents to Generated Files

* add i18n to cronstrue

* add i18n

* add type="button" to button elements

* refactor fileSource retrieval logic

* one scheduled job run can have status "running"

* add protection of file retrieveal from scheduled job in multiuser mode

* fix comments

* make job status default to queued

* add queued status

* fix bug with result trace rendering

* store timeout ref and clearTimeout once race settles

* remove unneeded handlerPromise tracking

* move imports to top level

* refactor hardcoded paths to path resolve functions

* implement new job form design

* simplify

* fix button styles

* fix runJob bug

* implement styles for scheduled jobs page

* apply dark mode figma styles

* delete unused translation key

* implement light mode for new new job modal, run history, and run details

* lint

* fix light mode scroll bar in tool call card

* adjust table header contrast

* fix type in subtitle

* kill workers when job is in-flight before deleting job

* add border-none to buttons

* change locale time to iso string

* import BackgroundService module level | instatiate backgroundService singltone once and reuse across handlers

* add p-queue, @breejs/later and cron-validate as core deps

* parse cron expression to a builder state once

* add theme to day buttons in cron builder

* fix stale tools selection caption

* flip popover when popover clips screen height

* make ScheduleJob.trigger() await the run insertion | disable run now button if job is in flight

* regen table

* refactor generated file card

* refactor frontend

* remove logs

* major refactor for tool picking, fix bree/later bug

* combine action endpoints, move contine to method

* fix unoptimized query with include + take + order

* fix dangerous use, refactor job to utils

* add copy content to text response

* improve notification system subscription for browser

* remove unused translations

* prevent gen-file cleanup job from deleting active job file generated references

* rich text copy

* Scheduled Jobs: Translations (#5482)

* add locales for scheduled jobs

* i18n

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* add config flag with UI notice

* update README

* telemetry datapoints

* Always use UTC on backend, convert to local in frontend

* fix tz render

* Add job killing

* cleanup thinking text in job notifications and break out reasoning in response text.
Also hide zero metrics since that is useless

* Port generatedFile schema to the normalized workspace chat `outputs` file format so porting to thread is simple and implem between chats <> jobs is 1:1

* what the fuck

* compiled bug

* fixed thinking oddity in complied frontend

* supress multi-toast

* fix duration call

* Revert "fix duration call"

This reverts commit 0491bc71f4223e65ea4046561b15b268fefb8da2.

* revert and reapply fix

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2026-04-29 12:05:46 -07:00

158 lines
5.0 KiB
JavaScript

const { log, conclude } = require("./helpers/index.js");
const { v4: uuidv4 } = require("uuid");
const { safeJsonParse } = require("../utils/http");
const {
agentActionCb,
SCHEDULED_JOB_TIMEOUT_MS,
sendWebPushNotification,
} = require("./helpers/scheduled-job-helper.js");
const { ScheduledJob } = require("../models/scheduledJob.js");
const { ScheduledJobRun } = require("../models/scheduledJobRun.js");
/** Status of the scheduled job run @type {'success' | 'failed' | 'timed_out' | 'not_found' | 'killed' | undefined} */
let status;
let runId = null;
process.on("SIGTERM", async () => {
status = "killed";
log("Received SIGTERM, marking job as killed by user");
if (runId) await ScheduledJobRun.kill(runId);
conclude();
});
process.on("message", async (payload) => {
const { jobId, runId: payloadRunId } = payload;
runId = payloadRunId;
let timeoutId = null;
let errorMessage = null;
// The run row was created by the parent process (BackgroundService) in
// status `queued` (it may have been waiting in p-queue). The worker
// transitions it to `running` here so `startedAt` reflects actual execution
// start, then runs to a terminal state. If the job has been deleted between
// enqueue and now, fail the row.
try {
if (!jobId || !runId) return;
const job = await ScheduledJob.get({ id: Number(jobId) });
if (!job) {
log(`Scheduled job ${jobId} not found`);
status = "not_found";
return;
}
// Transition queued -> running. If this returns false, the row was
// already moved to a terminal state (e.g. parent failed it because it
// thought the worker had died). Bail out without touching it further.
const transitioned = await ScheduledJobRun.markRunning(runId);
if (!transitioned) {
log(
`Scheduled job "${job.name}" (id=${job.id}) is no longer queued, skipping`
);
return;
}
log(
`Starting scheduled job: "${job.name}" (id=${job.id}) with timeout ${SCHEDULED_JOB_TIMEOUT_MS}ms`
);
await ScheduledJob.updateRunTimestamps(job.id);
const { handler, thoughts, toolCalls, state } = agentActionCb();
const { EphemeralAgentHandler } = require("../utils/agents/ephemeral.js");
const agentHandler = await new EphemeralAgentHandler({
uuid: uuidv4(),
prompt: job.prompt,
}).init();
// Tool overrides control which tools the agent can use:
// - Array with items: only those specific tools are loaded
// - Empty array: no tools are loaded
const toolOverrides = safeJsonParse(job.tools, []);
await agentHandler.createAIbitat({
handler,
toolOverrides,
});
// Auto-approve all tool invocations when running a scheduled job
agentHandler.aibitat.requestToolApproval = async () => {
log("Tool approval requested for scheduled job, auto-approving");
return {
approved: true,
message: "Auto-approved by scheduled job runner.",
};
};
// Capture tool results for the execution trace
agentHandler.aibitat.onToolCallResult(
({ toolName, arguments: args, result }) => {
toolCalls.push({
toolName,
arguments: args,
result,
timestamp: Date.now(),
});
}
);
const startTime = Date.now();
await Promise.race([
agentHandler.startAgentCluster(),
new Promise((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error("SCHEDULED_JOB_TIMEOUT")),
SCHEDULED_JOB_TIMEOUT_MS
);
}),
]).finally(() => {
if (!timeoutId) return;
clearTimeout(timeoutId);
timeoutId = null;
});
const duration = Date.now() - startTime;
// Get outputs from aibitat which include proper type info (e.g., PptxFileDownload, ExcelFileDownload)
// for correct re-rendering when porting to workspace chat
const outputs = agentHandler.getPendingOutputs();
status = "success";
await ScheduledJobRun.complete(runId, {
result: {
text: state.textResponse,
thoughts,
toolCalls,
outputs,
metrics: state.metrics,
duration,
},
});
log(`Scheduled job "${job.name}" completed in ${duration}ms)`);
await sendWebPushNotification(job, runId, state.textResponse, log);
} catch (error) {
if (error.message === "SCHEDULED_JOB_TIMEOUT") {
status = "timed_out";
log("Scheduled job timed out");
} else {
status = "failed";
log(`Scheduled job error: ${error.message}`);
errorMessage = error.message;
}
} finally {
switch (status) {
case "not_found":
await ScheduledJobRun.failIfNotTerminal(runId, "Job no longer exists");
break;
case "timed_out":
await ScheduledJobRun.timeout(runId);
break;
case "failed":
await ScheduledJobRun.fail(runId, { error: errorMessage });
break;
default: // Do nothing by default (success, killed, other)
break;
}
if (timeoutId) clearTimeout(timeoutId);
conclude();
}
});