Add bot schedules (routines), open Activity, add Help and a Desktop computer view

Bots create, list, update and delete schedules (cron_jobs, Bun.cron in UTC);
a per-minute cron queues due runs as tasks. Routines, Activity (super admins)
and Help get their own rail aside. Help covers familiar problems, starting
with signing in to Google through Cookie-Editor over the new Desktop (noVNC)
view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 10:52:32 +01:00
co-authored by Claude Opus 5.5
parent f24c0d7e82
commit c44048dbab
62 changed files with 1904 additions and 78 deletions
@@ -0,0 +1,145 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CRON_JOBS } from "@/db/types/db";
import wakeBotsQueue from "@/src/bots-queue/wake-bots-queue";
import logEvent, { logError } from "@/src/functions/backend/logger";
import nextCronRun from "@/src/utils/next-cron-run";
import safeErrorMessage from "@/src/utils/safe-error-message";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
import enqueueBotJob from "../bots-queue/enqueue-bot-job";
import grabBotComputers from "../computers/grab-bot-computers";
type Params = {
cron_job: BUN_SQLITE_COBBLE_CRON_JOBS;
now?: number;
};
/**
* Runs one due schedule: moves it to its next run (claimed atomically, so
* a run is never queued twice), then queues its brief as a task in the
* bot's active session there, on its computer or the bot's current one.
* A run is skipped while the last one is still queued or working. It
* turns itself off once it has no next run or has done its `max_runs`.
* Why a run couldn't be queued is kept in `last_error`.
*/
export default async function fireCronJob({ cron_job, now = Date.now() }: Params) {
const id = cron_job.id;
const schedule = cron_job.schedule || "";
if (!id || !cron_job.next_run_at) return;
let next_run_at: number | null = null;
try {
next_run_at = nextCronRun({ schedule, from: now });
} catch {}
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE cron_jobs SET next_run_at = ?, enabled = ?, updated_at = ?
WHERE id = ? AND enabled = 1 AND next_run_at = ? RETURNING id`,
values: [next_run_at, next_run_at ? 1 : 0, now, id, cron_job.next_run_at],
});
if (!claimed.payload?.[0]) return;
try {
if (cron_job.last_job_id && (await isJobOpen({ job_id: cron_job.last_job_id }))) {
await saveRun({ id, last_error: `Skipped a run: the last one was still going.` });
return;
}
const session = await ensureBotSession({
bot_id: cron_job.bot_id!,
workspace_id: cron_job.workspace_id || null,
});
const computer_id =
cron_job.computer_id ||
(
await grabBotComputers({
bot_id: cron_job.bot_id!,
user_id: cron_job.user_id!,
workspace_id: cron_job.workspace_id || null,
})
)[0]?.id;
const job_id = await enqueueBotJob({
user_id: cron_job.user_id!,
bot_id: cron_job.bot_id!,
session_id: session.id!,
title: cron_job.title || "Scheduled task",
instructions: `${cron_job.instructions || ""}\n\nThis is a scheduled run of your schedule #${id} (\`${schedule}\`, UTC). Do the work; don't create another schedule for it.`,
params: { schedule_id: id, schedule, run: Number(cron_job.run_count || 0) + 1 },
computer_id: computer_id || undefined,
});
const run_count = Number(cron_job.run_count || 0) + 1;
const done = Boolean(cron_job.max_runs && run_count >= cron_job.max_runs);
await saveRun({ id, last_run_at: now, last_job_id: Number(job_id), run_count, turn_off: done });
wakeBotsQueue();
await logEvent({
title: `Schedule: ${cron_job.title}`,
category: "cron",
user_id: cron_job.user_id || undefined,
plain_text: `Queued schedule #${id} "${cron_job.title}" (${schedule}) as job #${job_id}.`,
metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id },
});
} catch (error) {
await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.` }) });
await logError({
title: `Schedule #${id} couldn't run`,
category: "cron",
error,
user_id: cron_job.user_id || undefined,
metadata: { cron_job_id: id },
}).catch(() => {});
}
}
async function isJobOpen({ job_id }: { job_id: number }) {
const res = await BunSQLite.sql({
sql: `SELECT id FROM bots_queue WHERE id = ? AND status IN ('pending', 'processing') LIMIT 1`,
values: [job_id],
});
return Boolean(res.payload?.[0]);
}
async function saveRun({
id,
last_run_at,
last_job_id,
run_count,
turn_off,
last_error = null,
}: {
id: number;
last_run_at?: number;
last_job_id?: number;
run_count?: number;
turn_off?: boolean;
last_error?: string | null;
}) {
await BunSQLite.sql({
sql: `UPDATE cron_jobs SET
last_run_at = COALESCE(?, last_run_at),
last_job_id = COALESCE(?, last_job_id),
run_count = COALESCE(?, run_count),
enabled = CASE WHEN ? THEN 0 ELSE enabled END,
last_error = ?,
updated_at = ?
WHERE id = ?`,
values: [
last_run_at ?? null,
last_job_id ?? null,
run_count ?? null,
turn_off ? 1 : 0,
last_error,
Date.now(),
id,
],
});
}