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
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import computerViewUrl from "./computer-view-url";
describe("computerViewUrl", () => {
test("serves a mode under the computer's address", () => {
expect(computerViewUrl({ computer_id: 3, mode: "terminal" })).toBe("/computers/3/terminal");
});
test("points the desktop's VNC socket back through the same address", () => {
expect(computerViewUrl({ computer_id: 3, mode: "desktop" })).toBe(
"/computers/3/vnc/vnc.html?autoconnect=true&resize=scale&reconnect=true&path=computers/3/websockify",
);
});
});
+3 -2
View File
@@ -11,10 +11,11 @@ type Params = {
/**
* The app address of one of a computer's modes, e.g.
* `/computers/3/terminal`.
* `/computers/3/terminal`, with `{prefix}` in its path filled in.
*/
export default function computerViewUrl({ computer_id, mode }: Params) {
const base = `${ComputerProxyPrefix}/${computer_id}/`;
const path = ComputerViewModes.find((m) => m.value == mode)?.path || "";
return `${ComputerProxyPrefix}/${computer_id}/${path}`;
return `${base}${path.replace("{prefix}", base.replace(/^\//, ""))}`;
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import describeCronJob from "./describe-cron-job";
describe("describeCronJob", () => {
test("describes a running schedule", () => {
expect(
describeCronJob({
cron_job: {
id: 3,
title: "Morning digest",
schedule: "0 9 * * MON-FRI",
enabled: 1,
next_run_at: Date.UTC(2026, 9, 20, 9),
run_count: 1,
workspace_name: "Launch",
computer_id: 2,
},
}),
).toBe(
`#3 "Morning digest", \`0 9 * * MON-FRI\` (UTC), in the workspace Launch, on computer 2, next run 2026-10-20T09:00:00.000Z, 1 run so far`,
);
});
test("describes a paused one-off with an error", () => {
expect(
describeCronJob({
cron_job: {
id: 4,
title: "Remind me",
schedule: "30 14 21 10 *",
enabled: 0,
run_count: 0,
max_runs: 1,
last_error: "This bot has no access to that computer.",
},
}),
).toBe(
`#4 "Remind me", \`30 14 21 10 *\` (UTC), in your own chat, paused, 0 of 1 runs so far, last error: This bot has no access to that computer.`,
);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { CronJobWithNames } from "@/src/types";
type Params = {
cron_job: CronJobWithNames;
};
/**
* One schedule as a bot reads it: id, title, expression, where it runs,
* its state and next run (ISO, UTC), and the last error if any.
*/
export default function describeCronJob({ cron_job }: Params) {
const runs = Number(cron_job.run_count || 0);
const parts = [
`#${cron_job.id} "${cron_job.title}"`,
`\`${cron_job.schedule}\` (UTC)`,
cron_job.workspace_name ? `in the workspace ${cron_job.workspace_name}` : `in your own chat`,
cron_job.computer_id ? `on computer ${cron_job.computer_id}` : "",
cron_job.enabled
? cron_job.next_run_at
? `next run ${new Date(cron_job.next_run_at).toISOString()}`
: "no next run"
: "paused",
`${runs}${cron_job.max_runs ? ` of ${cron_job.max_runs}` : ""} run${runs == 1 ? "" : "s"} so far`,
cron_job.last_error ? `last error: ${cron_job.last_error}` : "",
];
return parts.filter(Boolean).join(", ");
}
+4 -1
View File
@@ -6,6 +6,9 @@ describe("isAdminSectionPath", () => {
expect(isAdminSectionPath({ pathname: "/admin/bots" })).toBe(true);
expect(isAdminSectionPath({ pathname: "/admin/bots/3" })).toBe(true);
expect(isAdminSectionPath({ pathname: "/admin/workspaces/2/bots/1" })).toBe(true);
expect(isAdminSectionPath({ pathname: "/admin/routines" })).toBe(true);
expect(isAdminSectionPath({ pathname: "/admin/logs" })).toBe(true);
expect(isAdminSectionPath({ pathname: "/admin/help" })).toBe(true);
expect(isAdminSectionPath({ pathname: "/admin/settings/change-password" })).toBe(
true,
);
@@ -14,7 +17,7 @@ describe("isAdminSectionPath", () => {
test("rejects the admin root, closed sections and lookalikes", () => {
expect(isAdminSectionPath({ pathname: "/admin" })).toBe(false);
expect(isAdminSectionPath({ pathname: "/admin/people" })).toBe(false);
expect(isAdminSectionPath({ pathname: "/admin/logs" })).toBe(false);
expect(isAdminSectionPath({ pathname: "/admin/backups" })).toBe(false);
expect(isAdminSectionPath({ pathname: "/admin/botsx" })).toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import nextCronRun from "./next-cron-run";
const from = Date.UTC(2026, 9, 19, 8, 30);
describe("nextCronRun", () => {
test("finds the next run in UTC", () => {
expect(nextCronRun({ schedule: "0 9 * * *", from })).toBe(Date.UTC(2026, 9, 19, 9, 0));
expect(nextCronRun({ schedule: "@daily", from })).toBe(Date.UTC(2026, 9, 20, 0, 0));
});
test("is null when the schedule never runs", () => {
expect(nextCronRun({ schedule: "0 0 30 2 *", from })).toBeNull();
});
test("rejects invalid expressions", () => {
expect(() => nextCronRun({ schedule: "every morning", from })).toThrow("isn't a valid schedule");
});
test("rejects schedules that run too often when asked", () => {
expect(() => nextCronRun({ schedule: "* * * * *", from, check_interval: true })).toThrow("runs too often");
expect(nextCronRun({ schedule: "* * * * *", from })).toBe(Date.UTC(2026, 9, 19, 8, 31));
expect(nextCronRun({ schedule: "*/15 * * * *", from, check_interval: true })).toBe(Date.UTC(2026, 9, 19, 8, 45));
});
});
+50
View File
@@ -0,0 +1,50 @@
import { AppData } from "@/src/data/app-data";
import AppError from "./app-error";
type Params = {
/**
* A Bun.cron expression: 5 fields or a nickname like `@daily`, in UTC.
*/
schedule: string;
/**
* Unix ms to look from (the next run is after it). Defaults to now.
*/
from?: number;
/**
* Refuse schedules that run more often than
* `AppData.CronJobMinIntervalMs`. Set when a schedule is saved.
*/
check_interval?: boolean;
};
/**
* The next run of a schedule after `from`, in unix ms, or null when it
* never runs again. Throws an `AppError` for an invalid expression (or,
* with `check_interval`, one that runs too often).
*/
export default function nextCronRun({ schedule, from = Date.now(), check_interval }: Params): number | null {
const expression = schedule.trim() as Bun.CronWithAutocomplete;
let next: Date | null;
try {
next = Bun.cron.parse(expression, from);
} catch {
throw new AppError(
`"${schedule}" isn't a valid schedule. Use 5 cron fields (minute hour day month weekday, e.g. "0 9 * * MON-FRI") or a nickname like @daily.`,
);
}
if (!next) return null;
if (check_interval) {
const after = Bun.cron.parse(expression, next);
if (after && after.getTime() - next.getTime() < AppData.CronJobMinIntervalMs) {
throw new AppError(
`"${schedule}" runs too often. Leave at least ${Math.round(AppData.CronJobMinIntervalMs / 60000)} minutes between runs.`,
);
}
}
return next.getTime();
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import timeUntil from "./time-until";
const now = Date.UTC(2026, 9, 19, 8, 30);
describe("timeUntil", () => {
test("counts down in the largest whole unit", () => {
expect(timeUntil({ at: now + 30 * 1000, now })).toBe("now");
expect(timeUntil({ at: now - 5000, now })).toBe("now");
expect(timeUntil({ at: now + 5 * 60 * 1000, now })).toBe("in 5m");
expect(timeUntil({ at: now + 3 * 60 * 60 * 1000, now })).toBe("in 3h");
expect(timeUntil({ at: now + 2 * 24 * 60 * 60 * 1000, now })).toBe("in 2d");
expect(timeUntil({ at: now + 21 * 24 * 60 * 60 * 1000, now })).toBe("in 3w");
});
});
+27
View File
@@ -0,0 +1,27 @@
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
type Params = {
/**
* Unix timestamp in milliseconds.
*/
at: number;
now?: number;
};
/**
* The future side of `relativeTime`: `now`, `in 5m`, `in 3h`, `in 2d`,
* `in 3w`.
*/
export default function timeUntil({ at, now = Date.now() }: Params): string {
const diff = at - now;
if (diff < MINUTE) return "now";
if (diff < HOUR) return `in ${Math.floor(diff / MINUTE)}m`;
if (diff < DAY) return `in ${Math.floor(diff / HOUR)}h`;
if (diff < WEEK) return `in ${Math.floor(diff / DAY)}d`;
return `in ${Math.floor(diff / WEEK)}w`;
}