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:
@@ -54,7 +54,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
### Auth
|
||||
|
||||
- **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request.
|
||||
- No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People, logs and backups exist in code but are closed until added back to `AdminSections` and the aside dict
|
||||
- No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People and backups exist in code but are closed until added back to `AdminSections`
|
||||
- No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away
|
||||
- Redirects unauthenticated `/admin/*` → `/auth/login`
|
||||
- Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general`
|
||||
@@ -77,7 +77,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
|
||||
### Cron
|
||||
|
||||
`src/cron/index.ts` is the single registration point called from `server.ts`. Tasks live in one folder each (`src/cron/<name>/index.ts`) as default-exported async functions, scheduled with in-process `Bun.cron` via `registerCronJob({ title, schedule, task, run_on_start })` (`src/cron/register-cron-job.ts`), which logs before each run and catches/logs errors. Schedules are cron expressions or nicknames (`@hourly`) interpreted in UTC; runs never overlap. Gated in production by `MAIN_SERVER`. The bundled `heartbeat` task doubles as a DB-liveness probe; `sync-harness-models` refreshes the model catalog hourly.
|
||||
`src/cron/index.ts` is the single registration point called from `server.ts`. Tasks live in one folder each (`src/cron/<name>/index.ts`) as default-exported async functions, scheduled with in-process `Bun.cron` via `registerCronJob({ title, schedule, task, run_on_start, quiet })` (`src/cron/register-cron-job.ts`), which logs before each run (unless `quiet`) and catches/logs errors. Schedules are cron expressions or nicknames (`@hourly`) interpreted in UTC; runs never overlap. Gated in production by `MAIN_SERVER`. The bundled `heartbeat` task doubles as a DB-liveness probe; `sync-harness-models` refreshes the model catalog hourly; `run-cron-jobs` (every minute, quiet) queues the bots' due schedules (see Schedules).
|
||||
|
||||
### Admin CRUD API
|
||||
|
||||
@@ -101,10 +101,11 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
|
||||
- **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace page, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`), all ownership-checked against the session user; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies).
|
||||
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. Each user has one main computer, created with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
|
||||
- **Opening a computer:** the app serves each computer to its owner under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, ownership via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
|
||||
- **Opening a computer:** the app serves each computer to its owner under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, ownership via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
|
||||
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
|
||||
- **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`).
|
||||
- **Not built yet:** routines, help, task steps (nothing writes `bot_task_steps` yet).
|
||||
- **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. `/admin/routines` lists them (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat.
|
||||
- **Not built yet:** task steps (nothing writes `bot_task_steps` yet).
|
||||
|
||||
### Production container
|
||||
|
||||
@@ -135,11 +136,14 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
|
||||
|
||||
## Admin Route Hierarchy
|
||||
|
||||
Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspaces` (the index opens the first workspace) and `/admin/settings`. `/admin` redirects to `/admin/bots`; there is no dashboard. The rest below are built but closed for now (redirect to `/admin/bots`):
|
||||
Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspaces` (the index opens the first workspace), `/admin/routines`, `/admin/logs`, `/admin/help` and `/admin/settings`. `/admin` redirects to `/admin/bots`; there is no dashboard. The rest below are built but closed for now (redirect to `/admin/bots`):
|
||||
|
||||
- `/admin/people` - admin users list, plus `create/`, `[user_id]/` (profile), `[user_id]/edit/`
|
||||
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/`
|
||||
- `/admin/logs` - audit log (super admin; the "Activity" item in the bots popover is disabled while this is closed)
|
||||
- `/admin/routines` (live) - every schedule the user's bots have (see Schedules), opened from "Routines" in the rail's user popover
|
||||
- `/admin/logs` (live) - Activity, the audit log: super admins only (others are redirected; the popover's "Activity" item shows to super admins only). Rendered in `SettingsShell` with `wide`
|
||||
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
|
||||
- Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
|
||||
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
|
||||
|
||||
+111
@@ -709,6 +709,117 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "cron_jobs",
|
||||
tableDescription: `A bot's schedules (routines in the UI): a brief queued as a task on a cron schedule, in UTC. The \`run-cron-jobs\` cron queues the due ones every minute`,
|
||||
fields: [
|
||||
{
|
||||
fieldName: "user_id",
|
||||
fieldDescription: `The owner`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "users",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "cron_jobs_user_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "bot_id",
|
||||
fieldDescription: `The bot that runs it`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "bots",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "cron_jobs_bot_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "workspace_id",
|
||||
fieldDescription: `The workspace it runs in (context, computers, models, and the conversation its results go to); null for the bot's own chat`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "workspaces",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "cron_jobs_workspace_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "title",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "instructions",
|
||||
fieldDescription: `The brief each run gets (markdown), like a task's`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "schedule",
|
||||
fieldDescription: `A Bun.cron expression (5 fields or a nickname like @daily), in UTC`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "computer_id",
|
||||
fieldDescription: `The computer its runs use; null for the bot's current one when it runs. Not a foreign key`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "enabled",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "max_runs",
|
||||
fieldDescription: `Turns itself off after this many runs (1 for a one-off); null runs until stopped`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "run_count",
|
||||
dataType: "INTEGER",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
fieldName: "next_run_at",
|
||||
fieldDescription: `Unix ms of the next run; null once it can't run again`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "last_run_at",
|
||||
fieldDescription: `Unix ms of the last run queued`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "last_job_id",
|
||||
fieldDescription: `The \`bots_queue\` job of the last run. Not a foreign key: finished jobs are pruned`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "last_error",
|
||||
fieldDescription: `Why the last run couldn't be queued or was skipped; cleared by the next run`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
indexName: "idx_cron_jobs_enabled_next_run_at",
|
||||
indexTableFields: ["enabled", "next_run_at"],
|
||||
},
|
||||
{
|
||||
indexName: "idx_cron_jobs_bot_id",
|
||||
indexTableFields: ["bot_id"],
|
||||
},
|
||||
{
|
||||
indexName: "idx_cron_jobs_user_id",
|
||||
indexTableFields: ["user_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "workspaces",
|
||||
fields: [
|
||||
|
||||
+65
-2
@@ -12,6 +12,7 @@ export const BunSQLiteTables = [
|
||||
"bot_task_steps",
|
||||
"bots_queue",
|
||||
"bots_queue_updates",
|
||||
"cron_jobs",
|
||||
"workspaces",
|
||||
"workspace_bots",
|
||||
"bot_message_media",
|
||||
@@ -466,6 +467,68 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES = {
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_CRON_JOBS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
*/
|
||||
id?: number | "";
|
||||
/**
|
||||
* The time when the record was created. (Unix Timestamp)
|
||||
*/
|
||||
created_at?: number | "";
|
||||
/**
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
/**
|
||||
* The owner
|
||||
*/
|
||||
user_id?: number | "";
|
||||
/**
|
||||
* The bot that runs it
|
||||
*/
|
||||
bot_id?: number | "";
|
||||
/**
|
||||
* The workspace it runs in (context, computers, models, and the conversation its results go to); null for the bot's own chat
|
||||
*/
|
||||
workspace_id?: number | "";
|
||||
title?: string;
|
||||
/**
|
||||
* The brief each run gets (markdown), like a task's
|
||||
*/
|
||||
instructions?: string;
|
||||
/**
|
||||
* A Bun.cron expression (5 fields or a nickname like @daily), in UTC
|
||||
*/
|
||||
schedule?: string;
|
||||
/**
|
||||
* The computer its runs use; null for the bot's current one when it runs. Not a foreign key
|
||||
*/
|
||||
computer_id?: number | "";
|
||||
enabled?: 0 | 1 | "";
|
||||
/**
|
||||
* Turns itself off after this many runs (1 for a one-off); null runs until stopped
|
||||
*/
|
||||
max_runs?: number | "";
|
||||
run_count?: number | "";
|
||||
/**
|
||||
* Unix ms of the next run; null once it can't run again
|
||||
*/
|
||||
next_run_at?: number | "";
|
||||
/**
|
||||
* Unix ms of the last run queued
|
||||
*/
|
||||
last_run_at?: number | "";
|
||||
/**
|
||||
* The `bots_queue` job of the last run. Not a foreign key: finished jobs are pruned
|
||||
*/
|
||||
last_job_id?: number | "";
|
||||
/**
|
||||
* Why the last run couldn't be queued or was skipped; cleared by the next run
|
||||
*/
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_WORKSPACES = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
@@ -587,7 +650,7 @@ export type BUN_SQLITE_COBBLE_CONTAINERS = {
|
||||
/**
|
||||
* Last known docker state
|
||||
*/
|
||||
status?: "creating" | "failed" | "created" | "running" | "paused" | "restarting" | "exited" | "removing" | "dead" | "unknown" | "";
|
||||
status?: "creating" | "failed" | "created" | "starting" | "running" | "unhealthy" | "paused" | "restarting" | "exited" | "removing" | "dead" | "unknown" | "";
|
||||
/**
|
||||
* Extra docker details (ports, volumes, env names, labels)
|
||||
*/
|
||||
@@ -977,4 +1040,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
||||
expose_to_computers?: 0 | 1 | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
|
||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
|
||||
@@ -3,6 +3,7 @@ import cronHeartbeat from "./heartbeat";
|
||||
import pruneBotsQueue from "./prune-bots-queue";
|
||||
import cronSyncHarnessModels from "./sync-harness-models";
|
||||
import syncComputers from "./sync-computers";
|
||||
import runCronJobs from "./run-cron-jobs";
|
||||
import registerCronJob from "./register-cron-job";
|
||||
|
||||
export default function cron() {
|
||||
@@ -38,4 +39,12 @@ export default function cron() {
|
||||
task: syncComputers,
|
||||
run_on_start: true,
|
||||
});
|
||||
|
||||
registerCronJob({
|
||||
title: "run-cron-jobs",
|
||||
schedule: "* * * * *",
|
||||
task: runCronJobs,
|
||||
run_on_start: true,
|
||||
quiet: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ type Params = {
|
||||
schedule: Bun.CronWithAutocomplete;
|
||||
task: () => Promise<unknown>;
|
||||
run_on_start?: boolean;
|
||||
/**
|
||||
* Don't log every run (for jobs that run every minute). Errors are
|
||||
* still logged.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,14 +23,17 @@ export default function registerCronJob({
|
||||
schedule,
|
||||
task,
|
||||
run_on_start,
|
||||
quiet,
|
||||
}: Params) {
|
||||
const run = async () => {
|
||||
try {
|
||||
if (!quiet) {
|
||||
await logEvent({
|
||||
title: `Cron: ${title}`,
|
||||
category: "cron",
|
||||
plain_text: `CRON: ${title} ...`,
|
||||
});
|
||||
}
|
||||
await task();
|
||||
} catch (error) {
|
||||
await logError({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CRON_JOBS } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import fireCronJob from "@/src/functions/backend/db/cron-jobs/fire-cron-job";
|
||||
|
||||
/**
|
||||
* Queues the bots' due schedules (`cron_jobs`), oldest due first, at most
|
||||
* `AppData.CronJobBatchSize` a tick. A schedule missed while the server
|
||||
* was down runs once on the next tick, then carries on from now.
|
||||
*/
|
||||
export default async function runCronJobs() {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_CRON_JOBS>({
|
||||
sql: `SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?
|
||||
ORDER BY next_run_at ASC LIMIT ?`,
|
||||
values: [now, AppData.CronJobBatchSize],
|
||||
});
|
||||
|
||||
for (const cron_job of res.payload || []) {
|
||||
await fireCronJob({ cron_job, now });
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,16 @@ export const AppData = {
|
||||
* Finished jobs are deleted after this many days.
|
||||
*/
|
||||
BotQueueRetentionDays: 30,
|
||||
/**
|
||||
* A bot's schedules (`cron_jobs`): how many it can have, and the
|
||||
* shortest gap between two runs, so a schedule can't flood the queue.
|
||||
*/
|
||||
CronJobMaxPerBot: 20,
|
||||
CronJobMinIntervalMs: 5 * 60 * 1000,
|
||||
/**
|
||||
* Due schedules queued per tick of the `run-cron-jobs` cron.
|
||||
*/
|
||||
CronJobBatchSize: 50,
|
||||
/**
|
||||
* Automatic model picks (`rankAutoModels`). A free model from outside
|
||||
* `AutoModelVendorPreference` must have at least this much context, and
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* The admin sections that are live. Any other `/admin/*` path redirects to
|
||||
* `AdminHomePath`. People, logs and backups exist in code but stay closed
|
||||
* until they are added back here.
|
||||
* `AdminHomePath`. People and backups exist in code but stay closed until
|
||||
* they are added back here. Logs (Activity) is super admins only.
|
||||
*/
|
||||
export const AdminSections = ["/admin/bots", "/admin/workspaces", "/admin/settings"] as const;
|
||||
export const AdminSections = [
|
||||
"/admin/bots",
|
||||
"/admin/workspaces",
|
||||
"/admin/routines",
|
||||
"/admin/logs",
|
||||
"/admin/help",
|
||||
"/admin/settings",
|
||||
] as const;
|
||||
|
||||
export const AdminHomePath = "/admin/bots";
|
||||
|
||||
@@ -110,8 +110,11 @@ export const ComputerMcpExcludedTools = [
|
||||
|
||||
/**
|
||||
* How the user can open a computer, each one of the AIO sandbox's own
|
||||
* apps at `path` (relative to the computer's address). More of them
|
||||
* (VNC, opencode, codex) can join later.
|
||||
* apps at `path` (relative to the computer's address; `{prefix}` is the
|
||||
* computer's app address without its leading slash, e.g. `computers/3/`).
|
||||
* Desktop is the whole screen over VNC: the only view with the browser's
|
||||
* toolbar (extensions) and a clipboard panel. More of them (opencode,
|
||||
* codex) can join later.
|
||||
*/
|
||||
export const ComputerViewModes = [
|
||||
{
|
||||
@@ -129,6 +132,11 @@ export const ComputerViewModes = [
|
||||
value: "code",
|
||||
path: "code-server/",
|
||||
},
|
||||
{
|
||||
title: "Desktop",
|
||||
value: "desktop",
|
||||
path: "vnc/vnc.html?autoconnect=true&resize=scale&reconnect=true&path={prefix}websockify",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ComputerViewMode = (typeof ComputerViewModes)[number]["value"];
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The help page, and the topics other code points to.
|
||||
*/
|
||||
export const HelpPath = "/admin/help";
|
||||
|
||||
/**
|
||||
* Signing in to Google and other strict sites with Cookie-Editor.
|
||||
*/
|
||||
export const HelpSignInTopicId = "sign-in-with-cookies";
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Activity, CircleHelp, Clock, type LucideIcon } from "lucide-react";
|
||||
import { HelpPath } from "./help-dict";
|
||||
|
||||
export type RailPage = {
|
||||
title: string;
|
||||
url: string;
|
||||
Icon: LucideIcon;
|
||||
/**
|
||||
* Shown and served to super admins only.
|
||||
*/
|
||||
super_admin_only?: boolean;
|
||||
};
|
||||
|
||||
export const RoutinesPath = "/admin/routines";
|
||||
export const ActivityPath = "/admin/logs";
|
||||
|
||||
/**
|
||||
* Pages opened from the rail's user popover that have their own rail: a
|
||||
* back link, then this list (like settings).
|
||||
*/
|
||||
export const RailPages: RailPage[] = [
|
||||
{ title: "Routines", url: RoutinesPath, Icon: Clock },
|
||||
{ title: "Activity", url: ActivityPath, Icon: Activity, super_admin_only: true },
|
||||
{ title: "Help", url: HelpPath, Icon: CircleHelp },
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CRON_JOBS, BunSQLiteTables } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import nextCronRun from "@/src/utils/next-cron-run";
|
||||
import grabUserConversation from "../bot-sessions/grab-user-conversation";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import grabUserCronJob from "./grab-user-cron-job";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace it runs in; none for the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
title: string;
|
||||
instructions: string;
|
||||
/**
|
||||
* A Bun.cron expression, in UTC.
|
||||
*/
|
||||
schedule: string;
|
||||
/**
|
||||
* Must be one of the bot's computers there; none runs on its current
|
||||
* one.
|
||||
*/
|
||||
computer_id?: number | string | null;
|
||||
max_runs?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives one of the user's bots a schedule: its brief is queued as a task
|
||||
* at every run (`fireCronJob`), in the bot's conversation there. Returns
|
||||
* the new schedule.
|
||||
*/
|
||||
export default async function createCronJob({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
title,
|
||||
instructions,
|
||||
schedule,
|
||||
computer_id,
|
||||
max_runs,
|
||||
}: Params) {
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id });
|
||||
const bot_id_num = conversation.bot.id;
|
||||
|
||||
const count = Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM cron_jobs WHERE bot_id = ?`,
|
||||
values: [bot_id_num],
|
||||
})
|
||||
).payload?.[0]?.n || 0,
|
||||
);
|
||||
|
||||
if (count >= AppData.CronJobMaxPerBot) {
|
||||
throw new AppError(
|
||||
`${conversation.bot.name || "This bot"} already has ${AppData.CronJobMaxPerBot} schedules. Delete one first.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (computer_id) {
|
||||
const computers = await grabBotComputers({
|
||||
bot_id: bot_id_num,
|
||||
user_id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
});
|
||||
|
||||
if (!computers.some((c) => String(c.id) == String(computer_id))) {
|
||||
throw new AppError(`This bot has no access to that computer.`);
|
||||
}
|
||||
}
|
||||
|
||||
const next_run_at = nextCronRun({ schedule, check_interval: true });
|
||||
|
||||
if (!next_run_at) {
|
||||
throw new AppError(`"${schedule}" never runs.`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_CRON_JOBS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "cron_jobs",
|
||||
data: [
|
||||
{
|
||||
user_id: Number(user_id),
|
||||
bot_id: bot_id_num,
|
||||
workspace_id: conversation.workspace_id || undefined,
|
||||
title,
|
||||
instructions,
|
||||
schedule: schedule.trim(),
|
||||
computer_id: computer_id ? Number(computer_id) : undefined,
|
||||
enabled: 1,
|
||||
max_runs: max_runs || undefined,
|
||||
run_count: 0,
|
||||
next_run_at,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cron_job_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !cron_job_id) {
|
||||
throw new Error(`Couldn't save the schedule: ${res.msg}`);
|
||||
}
|
||||
|
||||
return await grabUserCronJob({ user_id, cron_job_id });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabUserCronJob from "./grab-user-cron-job";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
cron_job_id: number | string;
|
||||
/**
|
||||
* Only if it's this bot's (a bot managing its own schedules).
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes one of the user's schedules. A run already queued carries on.
|
||||
*/
|
||||
export default async function deleteCronJob({ user_id, cron_job_id, bot_id }: Params) {
|
||||
const cron_job = await grabUserCronJob({ user_id, cron_job_id, bot_id });
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `DELETE FROM cron_jobs WHERE id = ?`,
|
||||
values: [cron_job.id!],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't delete the schedule: ${res.error}`);
|
||||
}
|
||||
|
||||
return cron_job;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
/**
|
||||
* Only this bot's.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
/**
|
||||
* Only this one.
|
||||
*/
|
||||
cron_job_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's schedules with their bot and workspace names, the next to
|
||||
* run first and paused ones last.
|
||||
*/
|
||||
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
|
||||
const where = [`c.user_id = ?`];
|
||||
const values: (number | string)[] = [user_id];
|
||||
|
||||
if (bot_id) {
|
||||
where.push(`c.bot_id = ?`);
|
||||
values.push(bot_id);
|
||||
}
|
||||
|
||||
if (cron_job_id) {
|
||||
where.push(`c.id = ?`);
|
||||
values.push(cron_job_id);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql<CronJobWithNames>({
|
||||
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.face AS bot_face,
|
||||
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
|
||||
FROM cron_jobs c
|
||||
JOIN bots b ON b.id = c.bot_id
|
||||
LEFT JOIN workspaces w ON w.id = c.workspace_id
|
||||
WHERE ${where.join(" AND ")}
|
||||
ORDER BY c.enabled DESC, c.next_run_at IS NULL, c.next_run_at ASC, c.id ASC`,
|
||||
values,
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabCronJobs from "./grab-cron-jobs";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
cron_job_id: number | string;
|
||||
/**
|
||||
* Only if it's this bot's (a bot managing its own schedules).
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One of the user's schedules. Throws when it isn't theirs (or the bot's).
|
||||
*/
|
||||
export default async function grabUserCronJob({ user_id, cron_job_id, bot_id }: Params) {
|
||||
const [cron_job] = await grabCronJobs({ user_id, bot_id, cron_job_id });
|
||||
|
||||
if (!cron_job?.id) {
|
||||
throw new AppError(`Schedule #${cron_job_id} not found.`);
|
||||
}
|
||||
|
||||
return cron_job;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import nextCronRun from "@/src/utils/next-cron-run";
|
||||
import grabUserCronJob from "./grab-user-cron-job";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
cron_job_id: number | string;
|
||||
/**
|
||||
* Only if it's this bot's (a bot managing its own schedules).
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
/**
|
||||
* Only the fields given are changed. `max_runs: null` runs it until
|
||||
* it's stopped.
|
||||
*/
|
||||
data: {
|
||||
title?: string;
|
||||
instructions?: string;
|
||||
schedule?: string;
|
||||
enabled?: boolean;
|
||||
max_runs?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes one of the user's schedules. A new schedule, or turning it back
|
||||
* on, counts the next run from now; turning a finished one back on (all
|
||||
* its `max_runs` done) starts its count over. Returns the schedule.
|
||||
*/
|
||||
export default async function updateCronJob({ user_id, cron_job_id, bot_id, data }: Params) {
|
||||
const cron_job = await grabUserCronJob({ user_id, cron_job_id, bot_id });
|
||||
|
||||
const schedule = data.schedule?.trim() || cron_job.schedule || "";
|
||||
const schedule_changed = Boolean(data.schedule?.trim()) && schedule != cron_job.schedule;
|
||||
const enabled = data.enabled ?? Boolean(cron_job.enabled);
|
||||
const turned_on = enabled && !cron_job.enabled;
|
||||
const max_runs = data.max_runs === undefined ? cron_job.max_runs || null : data.max_runs;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
const set = (field: string, value: string | number | null) => {
|
||||
sets.push(`${field} = ?`);
|
||||
values.push(value);
|
||||
};
|
||||
|
||||
if (data.title !== undefined) set("title", data.title);
|
||||
if (data.instructions !== undefined) set("instructions", data.instructions);
|
||||
if (data.max_runs !== undefined) set("max_runs", data.max_runs);
|
||||
if (data.enabled !== undefined) set("enabled", enabled ? 1 : 0);
|
||||
|
||||
if (schedule_changed || turned_on) {
|
||||
const next_run_at = nextCronRun({ schedule, check_interval: schedule_changed });
|
||||
|
||||
if (!next_run_at) {
|
||||
throw new AppError(`"${schedule}" never runs.`);
|
||||
}
|
||||
|
||||
set("schedule", schedule);
|
||||
set("next_run_at", next_run_at);
|
||||
set("last_error", null);
|
||||
}
|
||||
|
||||
if (turned_on && max_runs && Number(cron_job.run_count || 0) >= max_runs) {
|
||||
set("run_count", 0);
|
||||
}
|
||||
|
||||
if (!sets.length) return cron_job;
|
||||
|
||||
set("updated_at", Date.now());
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE cron_jobs SET ${sets.join(", ")} WHERE id = ?`,
|
||||
values: [...values, cron_job.id!],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't update the schedule: ${res.error}`);
|
||||
}
|
||||
|
||||
return await grabUserCronJob({ user_id, cron_job_id: cron_job.id! });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import createCronJob from "@/src/functions/backend/db/cron-jobs/create-cron-job";
|
||||
import describeCronJob from "@/src/utils/describe-cron-job";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Where the run is: the schedule runs there too.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The bot's computers where it runs, the current one first.
|
||||
*/
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
title: string;
|
||||
instructions: string;
|
||||
schedule: string;
|
||||
computer_id?: number;
|
||||
max_runs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot schedule work for itself (a routine, to the user): its brief
|
||||
* is queued as a task at every run, here, and the result posted in this
|
||||
* conversation.
|
||||
*/
|
||||
export default function createScheduleTool({ user_id, bot_id, workspace_id, computers }: Params): HarnessTool<Args> {
|
||||
const listed = computers.map((computer) => `${computer.id} (${computer.name || computer.kind})`).join(", ");
|
||||
|
||||
return {
|
||||
name: "create_schedule",
|
||||
description: `Schedule work to run on its own, again and again or once (the user sees schedules as routines). At every run your instructions are queued as a task for you, ${
|
||||
workspace_id ? "in this workspace" : "in your own chat"
|
||||
}, and the result is posted here. The schedule is a cron expression in UTC: 5 fields (minute hour day-of-month month day-of-week, e.g. "0 9 * * MON-FRI" for 09:00 UTC on weekdays, "*/30 * * * *" every 30 minutes) or a nickname (@hourly, @daily, @weekly, @monthly). It's ${new Date().toISOString()} now. Convert the user's local time to UTC; if you don't know their time zone, ask, and remember it. At least ${Math.round(
|
||||
AppData.CronJobMinIntervalMs / 60000,
|
||||
)} minutes between runs. For a one-off, pin the date (e.g. "30 14 21 10 *") and set max_runs to 1. ${
|
||||
computers.length
|
||||
? `Runs use the computer you're on when they start unless you pick one: ${listed}.`
|
||||
: `You have no computer here, so runs can't use one.`
|
||||
} Write the instructions as a full brief for a future you with no memory of this chat.`,
|
||||
schema: z.object({
|
||||
title: z.string().min(1).max(80).describe(`A short label, e.g. "Morning inbox digest"`),
|
||||
instructions: z.string().min(1).max(8000),
|
||||
schedule: z.string().min(1).max(100),
|
||||
computer_id: z.number().int().optional(),
|
||||
max_runs: z.number().int().positive().optional().describe("Stop after this many runs; leave out to run until stopped"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const cron_job = await createCronJob({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
title: args.title,
|
||||
instructions: args.instructions,
|
||||
schedule: args.schedule,
|
||||
computer_id: args.computer_id,
|
||||
max_runs: args.max_runs,
|
||||
});
|
||||
|
||||
return `Schedule saved: ${describeCronJob({ cron_job })}. Tell the user when it runs, in their time, and that they can pause or change it under Routines.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
import deleteCronJob from "@/src/functions/backend/db/cron-jobs/delete-cron-job";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
schedule_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot delete one of its own schedules.
|
||||
*/
|
||||
export default function deleteScheduleTool({ user_id, bot_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "delete_schedule",
|
||||
description: `Delete one of your schedules for good (ids from list_schedules). To stop it for a while, pause it with update_schedule instead.`,
|
||||
schema: z.object({
|
||||
schedule_id: z.number().int(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const cron_job = await deleteCronJob({ user_id, bot_id, cron_job_id: args.schedule_id });
|
||||
|
||||
return `Schedule #${cron_job.id} "${cron_job.title}" deleted.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import createScheduleTool from "./create-schedule-tool";
|
||||
import deleteScheduleTool from "./delete-schedule-tool";
|
||||
import listSchedulesTool from "./list-schedules-tool";
|
||||
import setWorkspaceComputerTool from "./set-workspace-computer-tool";
|
||||
import updateScheduleTool from "./update-schedule-tool";
|
||||
import updateInstructionsTool from "./update-instructions-tool";
|
||||
import updateMemoryTool from "./update-memory-tool";
|
||||
|
||||
@@ -20,7 +24,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The tools a bot works with on every run, bound to that bot and where it
|
||||
* works.
|
||||
* works: its memory and instructions, its schedules, and in a workspace
|
||||
* with several computers switching the default one.
|
||||
*/
|
||||
export default function grabBotTools({
|
||||
user_id,
|
||||
@@ -31,6 +36,10 @@ export default function grabBotTools({
|
||||
return [
|
||||
updateMemoryTool({ user_id, bot_id, workspace_id }),
|
||||
updateInstructionsTool({ user_id, bot_id, workspace_id }),
|
||||
createScheduleTool({ user_id, bot_id, workspace_id, computers }),
|
||||
listSchedulesTool({ user_id, bot_id }),
|
||||
updateScheduleTool({ user_id, bot_id }),
|
||||
deleteScheduleTool({ user_id, bot_id }),
|
||||
...(workspace_id && computers.length > 1
|
||||
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
|
||||
import describeCronJob from "@/src/utils/describe-cron-job";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot see its own schedules, everywhere it works.
|
||||
*/
|
||||
export default function listSchedulesTool({ user_id, bot_id }: Params): HarnessTool<Record<string, never>> {
|
||||
return {
|
||||
name: "list_schedules",
|
||||
description: `List your schedules (routines): id, cron expression (UTC), where each runs, whether it's paused, its next run and its last error.`,
|
||||
schema: z.object({}),
|
||||
run: async () => {
|
||||
const cron_jobs = await grabCronJobs({ user_id, bot_id });
|
||||
|
||||
if (!cron_jobs.length) return `You have no schedules.`;
|
||||
|
||||
return cron_jobs.map((cron_job) => `- ${describeCronJob({ cron_job })}`).join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import updateCronJob from "@/src/functions/backend/db/cron-jobs/update-cron-job";
|
||||
import describeCronJob from "@/src/utils/describe-cron-job";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
schedule_id: number;
|
||||
title?: string;
|
||||
instructions?: string;
|
||||
schedule?: string;
|
||||
enabled?: boolean;
|
||||
max_runs?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot change, pause or resume one of its own schedules.
|
||||
*/
|
||||
export default function updateScheduleTool({ user_id, bot_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_schedule",
|
||||
description: `Change one of your schedules (ids from list_schedules): its title, instructions, cron expression (UTC), or pause it (enabled false) and resume it (enabled true). Only the fields you give change; max_runs null runs it until stopped.`,
|
||||
schema: z.object({
|
||||
schedule_id: z.number().int(),
|
||||
title: z.string().min(1).max(80).optional(),
|
||||
instructions: z.string().min(1).max(8000).optional(),
|
||||
schedule: z.string().min(1).max(100).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
max_runs: z.number().int().positive().nullable().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const { schedule_id, ...data } = args;
|
||||
const cron_job = await updateCronJob({ user_id, bot_id, cron_job_id: schedule_id, data });
|
||||
|
||||
return `Schedule updated: ${describeCronJob({ cron_job })}.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
|
||||
import { HelpPath, HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
import takeOverRef from "@/src/utils/take-over-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
@@ -40,7 +41,7 @@ export default function askToTakeOverTool({ computers, in_task, onAsk }: Params)
|
||||
|
||||
return {
|
||||
name: "ask_user_to_take_over",
|
||||
description: `Ask the user to take over one of your computers when only they can do something on it: log in, solve a captcha, enter a code, approve a prompt, or show you something. They get a button that opens it in the mode you pick: browser (the web browser), terminal (a shell) or code (the code editor). Computers: ${listed}. Defaults to ${computers[0]?.id}.`,
|
||||
description: `Ask the user to take over one of your computers when only they can do something on it: log in, solve a captcha, enter a code, approve a prompt, or show you something. They get a button that opens it in the mode you pick: browser (the web browser), terminal (a shell), code (the code editor) or desktop (the whole screen, with the browser's toolbar and extensions). When a site refuses to sign in on the computer (Google often does: "This browser or app may not be secure"), pick desktop and point the user to the guide at ${HelpPath}#${HelpSignInTopicId}: they bring their session over with the Cookie-Editor extension. Computers: ${listed}. Defaults to ${computers[0]?.id}.`,
|
||||
schema: z.object({
|
||||
computer_id: z.number().int().optional(),
|
||||
mode: z.enum(Modes),
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
CircleHelp,
|
||||
Clock,
|
||||
LogOut,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { LogOut, SlidersHorizontal, type LucideIcon } from "lucide-react";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import { RailPages } from "@/src/dict/rail-pages-dict";
|
||||
|
||||
export type RailUserMenuItem = {
|
||||
title: string;
|
||||
@@ -20,16 +14,19 @@ export type RailUserMenuItem = {
|
||||
* Rendered last, after a rule.
|
||||
*/
|
||||
destructive?: boolean;
|
||||
/**
|
||||
* Shown to super admins only.
|
||||
*/
|
||||
super_admin_only?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exactly the five popover items. Nothing else lives in the rail: these
|
||||
* destinations are only reachable from here.
|
||||
* The popover items: the rail pages (routines, activity, help), settings,
|
||||
* sign out. Nothing else lives in the rail: these destinations are only
|
||||
* reachable from here.
|
||||
*/
|
||||
export const RailUserMenuItems: RailUserMenuItem[] = [
|
||||
{ title: "Activity", Icon: Activity },
|
||||
{ title: "Routines", Icon: Clock },
|
||||
...RailPages,
|
||||
{ title: "Settings", url: SettingsDefaultPath, Icon: SlidersHorizontal },
|
||||
{ title: "Help", Icon: CircleHelp },
|
||||
{ title: "Sign out", url: "/auth/logout", Icon: LogOut, destructive: true },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useContext } from "react";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { RailPages } from "@/src/dict/rail-pages-dict";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import RailBackLink from "./rail-back-link";
|
||||
import RailSettingsLink from "./rail-settings-link";
|
||||
|
||||
type Props = {
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rail on routines, activity and help: back to the bots, then those
|
||||
* pages (super admin ones for super admins only).
|
||||
*/
|
||||
export default function RailPagesList({ pathname }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const current = pathname?.replace(/\/$/, "");
|
||||
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
|
||||
return (
|
||||
<>
|
||||
<RailBackLink />
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
{RailPages.filter((page) => is_super_admin || !page.super_admin_only).map((page) => (
|
||||
<RailSettingsLink
|
||||
key={page.url}
|
||||
{...page}
|
||||
selected={current == page.url || Boolean(current?.startsWith(`${page.url}/`))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useContext } from "react";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
|
||||
import RailUserMenuEntry from "./rail-user-menu-entry";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
|
||||
/**
|
||||
* 220px popover: the only element in the shell with elevation.
|
||||
* 220px popover: the only element in the shell with elevation. Super
|
||||
* admin items show to super admins only.
|
||||
*/
|
||||
export default function RailUserMenu() {
|
||||
const items = RailUserMenuItems.filter((item) => !item.destructive);
|
||||
const destructive = RailUserMenuItems.filter((item) => item.destructive);
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
const shown = RailUserMenuItems.filter((item) => is_super_admin || !item.super_admin_only);
|
||||
const items = shown.filter((item) => !item.destructive);
|
||||
const destructive = shown.filter((item) => item.destructive);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
|
||||
@@ -3,7 +3,9 @@ import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useLiveWorkspaces from "../(hooks)/use-live-workspaces";
|
||||
import { RailPages } from "@/src/dict/rail-pages-dict";
|
||||
import RailHeader from "../(partials)/rail-header";
|
||||
import RailPagesList from "../(partials)/rail-pages-list";
|
||||
import RailCrewList from "../(partials)/rail-crew-list";
|
||||
import RailSettingsList from "../(partials)/rail-settings-list";
|
||||
import RailUserRow from "../(partials)/rail-user-row";
|
||||
@@ -13,13 +15,17 @@ import RailWorkspaceList from "../(partials)/rail-workspace-list";
|
||||
* Left rail: wordmark and Add, then the crew (pinned coordinator first,
|
||||
* the latest bots, the workspaces). Inside a workspace it lists that
|
||||
* workspace's crew instead; on `/admin/settings/*` a back link and the
|
||||
* settings sub pages. Collapses to a 64px icon rail below 1024px.
|
||||
* settings sub pages, and on routines, activity and help a back link and
|
||||
* those pages (`RailPages`). Collapses to a 64px icon rail below 1024px.
|
||||
*/
|
||||
export default function BotsRail() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
|
||||
const is_rail_page = RailPages.some(
|
||||
(page) => pathname == page.url || Boolean(pathname?.startsWith(`${page.url}/`)),
|
||||
);
|
||||
const workspace = pageProps?.workspace;
|
||||
const in_workspace = Boolean(workspace?.id && pathname?.startsWith("/admin/workspaces/"));
|
||||
|
||||
@@ -39,10 +45,12 @@ export default function BotsRail() {
|
||||
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
|
||||
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
|
||||
>
|
||||
<RailHeader show_add={!is_settings_page} />
|
||||
<RailHeader show_add={!is_settings_page && !is_rail_page} />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
|
||||
{is_settings_page ? (
|
||||
<RailSettingsList pathname={pathname} />
|
||||
) : is_rail_page ? (
|
||||
<RailPagesList pathname={pathname} />
|
||||
) : in_workspace && live_workspace ? (
|
||||
<RailWorkspaceList
|
||||
workspace={live_workspace}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
|
||||
if (!pending.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="w-full max-w-[760px] mx-auto gap-2! px-6 pb-3">
|
||||
<Row className="w-full max-w-container mx-auto gap-2! px-6 pb-3">
|
||||
{pending.map((item) => (
|
||||
<ComposerAttachmentChip
|
||||
key={item.key}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useState, type KeyboardEvent } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
|
||||
import type {
|
||||
BotMessageWithAttachments,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
@@ -30,7 +33,9 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
const files = useComposerAttachments();
|
||||
|
||||
const can_send =
|
||||
Boolean(text.trim() || files.media_ids.length) && !sending && !files.uploading;
|
||||
Boolean(text.trim() || files.media_ids.length) &&
|
||||
!sending &&
|
||||
!files.uploading;
|
||||
|
||||
async function send() {
|
||||
if (!can_send || !bot.id) return;
|
||||
@@ -57,7 +62,9 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
function handleKeyDown(
|
||||
e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
@@ -77,10 +84,16 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
files.add({ files: [...e.dataTransfer.files] });
|
||||
}}
|
||||
>
|
||||
<ComposerAttachments pending={files.pending} onRemove={files.remove} />
|
||||
<ComposerAttachments
|
||||
pending={files.pending}
|
||||
onRemove={files.remove}
|
||||
/>
|
||||
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
|
||||
<Row className="w-full max-w-[760px] mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
|
||||
<ComposerAttachButton disabled={files.full} onFiles={files.add} />
|
||||
<Row className="w-full max-w-container mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
|
||||
<ComposerAttachButton
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<Textarea
|
||||
name="message"
|
||||
aria-label={`Message ${bot.name}`}
|
||||
@@ -106,7 +119,11 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
|
||||
}}
|
||||
/>
|
||||
<ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} />
|
||||
<ComposerSendButton
|
||||
disabled={!can_send}
|
||||
loading={sending || files.uploading}
|
||||
onClick={send}
|
||||
/>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
|
||||
import type {
|
||||
BotMessageWithAttachments,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import groupMessages from "../(utils)/group-messages";
|
||||
import BotThinkingRow from "./bot-thinking-row";
|
||||
import MessageGroupBlock from "./message-group-block";
|
||||
@@ -28,10 +31,14 @@ export default function MessageThread({ bot, messages, progress }: Props) {
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label={`Conversation with ${bot.name}`}
|
||||
className="w-full max-w-[760px] mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
|
||||
className="w-full max-w-container mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock key={group.key} bot={bot} group={group} />
|
||||
<MessageGroupBlock
|
||||
key={group.key}
|
||||
bot={bot}
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
<BotThinkingRow bot={bot} progress={progress} />
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { HelpSignInTopicId } from "@/src/dict/help-dict";
|
||||
|
||||
export type HelpTopic = {
|
||||
/**
|
||||
* The anchor: `/admin/help#<id>` opens the page at it.
|
||||
*/
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* One line under the title: when this is the topic you need.
|
||||
*/
|
||||
summary: string;
|
||||
/**
|
||||
* The answer, in markdown.
|
||||
*/
|
||||
body: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The help page: familiar problems and how to get past them, the most
|
||||
* common first.
|
||||
*/
|
||||
export const HelpTopics: HelpTopic[] = [
|
||||
{
|
||||
id: HelpSignInTopicId,
|
||||
title: "Google won't let me sign in on a computer",
|
||||
summary: `"This browser or app may not be secure", "Couldn't sign you in", or a sign-in that loops. Google and some other sites do this.`,
|
||||
body: `Your bots drive the browser on their computers, and Google (like some banks and social sites) refuses sign-ins from browsers it can tell are automated. Typing the password yourself doesn't help: the browser itself is turned away.
|
||||
|
||||
The way around it is to sign in on your own browser, where Google trusts you, and carry that session over to the computer with the **Cookie-Editor** extension.
|
||||
|
||||
**Before you start:** the cookies you copy *are* your signed-in session. Anyone who has them is signed in as you, without a password. Never paste them into a chat with a bot, don't keep them in a file, and clear them when you're done (step 9).
|
||||
|
||||
### On your own browser
|
||||
|
||||
1. In Chrome (or Edge, Brave, any Chromium browser), install [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) from the Chrome Web Store.
|
||||
2. Sign in to the site with the account your bot should use. For Google, go to [myaccount.google.com](https://myaccount.google.com) and sign in.
|
||||
3. Still on that site, click the extensions icon (the puzzle piece) in the toolbar, then **Cookie-Editor**. Click **Export**, then **JSON**. The cookies are now on your clipboard.
|
||||
|
||||
### On the bot's computer
|
||||
|
||||
4. Open the computer: in the bot's chat, click the computer icon in the header and open the computer, or click **Take over** if the bot asked you to. Switch to **Desktop**. This is the only view that shows the browser's toolbar, where extensions live.
|
||||
5. The first time only: in the computer's Chrome, open the [Cookie-Editor page](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), click **Add to Chrome**, then **Add extension**. Pin it from the puzzle piece so it's always one click away. It stays installed: the computer keeps its browser between restarts.
|
||||
6. In the computer's Chrome, go to the same site (for Google, [myaccount.google.com](https://myaccount.google.com)). It's fine that it shows the sign-in page.
|
||||
7. Put the cookies on the computer's clipboard. Desktop has a small handle on the left edge: open it, click the **Clipboard** icon, and paste into the box (Ctrl+V, or Cmd+V on a Mac). What's in that box is the computer's clipboard.
|
||||
8. Click **Cookie-Editor** in the computer's toolbar, then **Import**. Click into the box, paste (Ctrl+V), and click **Import**. Reload the page (F5): you're signed in.
|
||||
9. Clean up: open the **Clipboard** panel again and clear it.
|
||||
|
||||
Then tell the bot you're signed in, or click **Hand back** if it asked you to take over, and it carries on.
|
||||
|
||||
### If it doesn't work
|
||||
|
||||
- **The paste in step 7 or 8 is empty.** Use the computer's code editor instead: switch to **Code**, create a file such as \`cookies.json\`, and paste into it (that paste always works). In the computer's Chrome, open \`file:///home/gem/cookies.json\`, press Ctrl+A then Ctrl+C, and import as in step 8. **Delete the file afterwards.**
|
||||
- **You're signed in, then signed out again soon after.** Google sometimes ends a session it sees used from a new place. Sign out and back in on your own browser, export again, and import straight away. Using the same account and keeping the bot's work regular helps the session last.
|
||||
- **Cookie-Editor's icon doesn't show.** You're in the **Browser** view, which shows pages only. Switch to **Desktop**.
|
||||
- **Another site still says no.** Make sure you exported while on that site (Cookie-Editor exports the cookies of the site you're on), and that you opened the same site on the computer before importing.`,
|
||||
},
|
||||
{
|
||||
id: "take-over",
|
||||
title: "A bot asks me to take over",
|
||||
summary: "A message with a Take over button, and the bot shows it needs you.",
|
||||
body: `Some things only you can do on a computer: sign in, solve a captcha, type a code sent to your phone, approve a prompt. The bot stops there and asks you to take over.
|
||||
|
||||
1. Click **Take over** in its message. The computer opens in the view the bot picked (browser, terminal, code or desktop).
|
||||
2. Do what it asked. You're using the same computer the bot works on.
|
||||
3. Click **Hand back to** the bot. It gets a message that you're done and carries on.
|
||||
|
||||
A task that hit the wall stops and reports instead of waiting, so after you hand back the bot picks the work up again from the chat.`,
|
||||
},
|
||||
{
|
||||
id: "computer-not-ready",
|
||||
title: "A computer is stuck on Creating, or isn't ready",
|
||||
summary: "Tasks fail with \"isn't ready\", or the computer never leaves Creating.",
|
||||
body: `A new computer downloads its image and starts up, which can take a few minutes the first time. It stays **Creating** until its health check passes (its sandbox and browser both answer).
|
||||
|
||||
- **Still Creating after several minutes, or Failed:** open **Settings, Computers** and look at its state. A super admin can **Retry** it there; the error shows on the row.
|
||||
- **Unhealthy or stopped:** the computer's container is down or struggling. Give it more memory or CPUs from **Edit** (it restarts, its files are kept), or retry it.
|
||||
- **Everything fails at once:** the app needs Docker on the machine it runs on. Check that Docker is running.
|
||||
|
||||
A computer's home folder lives in the app's data folder on the machine, so restarting or recreating its container never loses its files.`,
|
||||
},
|
||||
{
|
||||
id: "bot-asleep",
|
||||
title: "A bot is asleep, or says no model is available",
|
||||
summary: "Its face is asleep and replies wait, or tasks fail naming the models.",
|
||||
body: `Bots think with AI models. By default they use free models, which are shared and rate limited: when every model a bot can use is resting, it falls asleep and its work waits until the first one comes back. Nothing is lost.
|
||||
|
||||
To make this rarer, a super admin can open **Settings, Models**:
|
||||
|
||||
- Add a provider with your own API key (OpenRouter, OpenAI, Anthropic, Gemini and others), so paid models are there when free ones rest.
|
||||
- Pick a model for the text harness instead of automatic, and keep **fallback** on so the automatic picks still step in.
|
||||
|
||||
A bot's own models (the model icon in its chat header) win over the defaults, and a workspace's win over a bot's.`,
|
||||
},
|
||||
{
|
||||
id: "routine-time",
|
||||
title: "A routine runs at the wrong time",
|
||||
summary: "It runs hours early or late.",
|
||||
body: `Routines run on UTC, not your local time. A routine set for \`0 9 * * *\` runs at 09:00 UTC.
|
||||
|
||||
Tell the bot your time zone once ("I'm in Lagos", "I'm on New York time") and it remembers it and converts for you. To fix one yourself, open **Routines**, click **Edit**, and change the schedule to the UTC hour you want. Routines don't follow daylight saving changes, so ones near a clock change may need a nudge twice a year.`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import type { HelpTopic } from "../(data)/help-topics";
|
||||
|
||||
/**
|
||||
* One help topic: the problem and when you'd see it on the left, the
|
||||
* answer (markdown, rendered like a bot message) on the right. Its id is
|
||||
* the anchor bots and other pages link to.
|
||||
*/
|
||||
export default function HelpTopicGroup({ id, title, summary, body }: HelpTopic) {
|
||||
return (
|
||||
<SettingsGroup id={id} title={title} description={summary}>
|
||||
<Stack className="gap-3! items-stretch min-w-0">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={MessageMarkdownComponents}>
|
||||
{body}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HelpTopics } from "../(data)/help-topics";
|
||||
import HelpTopicGroup from "../(partials)/help-topic-group";
|
||||
|
||||
/**
|
||||
* Every help topic, one block each.
|
||||
*/
|
||||
export default function HelpTopicsSection() {
|
||||
return (
|
||||
<>
|
||||
{HelpTopics.map((topic) => (
|
||||
<HelpTopicGroup key={topic.id} {...topic} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import HelpTopicsSection from "./(sections)/help-topics-section";
|
||||
|
||||
export default function AdminHelpPage() {
|
||||
return (
|
||||
<SettingsShell title="Help" description="Familiar problems, and how to get past them.">
|
||||
<HelpTopicsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async () => {
|
||||
return {
|
||||
title: `Help | ${SiteData["SiteName"]}`,
|
||||
description: `Familiar problems on ${SiteData["SiteName"]}, and how to get past them.`,
|
||||
};
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import AdminHero from "@/src/components/general/admin-hero";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<AdminHero
|
||||
title="Logs"
|
||||
description="System activity and audit trail - login, WebSocket, cron, and error events"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import LogsList from "../(partials)/logs-list";
|
||||
|
||||
export default function LogsListSection() {
|
||||
return (
|
||||
<Section>
|
||||
<Stack className="w-full items-stretch">
|
||||
<Stack className="w-full items-stretch py-8">
|
||||
<LogsList />
|
||||
</Stack>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Activity (the audit log): super admins only, everyone else lands on the
|
||||
* bots.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: AdminHomePath } };
|
||||
}
|
||||
|
||||
return { props: {} };
|
||||
};
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Hero from "./(sections)/hero";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import LogsListSection from "./(sections)/logs-list-section";
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<SettingsShell
|
||||
title="Activity"
|
||||
description="Sign-ins, scheduled runs, cron and errors, newest first."
|
||||
wide
|
||||
>
|
||||
<LogsListSection />
|
||||
</>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
export const meta: BunextPageModuleMetaFn = async () => {
|
||||
return {
|
||||
title: `Logs | ${SiteData["SiteName"]}`,
|
||||
title: `Activity | ${SiteData["SiteName"]}`,
|
||||
description: "System activity and audit logs",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import RoutineForm from "./routine-form";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Open while set.
|
||||
*/
|
||||
cron_job?: CronJobWithNames;
|
||||
onClose: () => void;
|
||||
onSaved: (params: { cron_job: CronJobWithNames }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits a routine in a modal: its name, schedule and brief.
|
||||
*/
|
||||
export default function RoutineEditModal({ cron_job, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<AddModalShell
|
||||
open={Boolean(cron_job)}
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`Edit ${cron_job?.title || "the routine"}`}
|
||||
description={`${cron_job?.bot_name || "The bot"} gets the brief as a task at every run.`}
|
||||
>
|
||||
{cron_job ? <RoutineForm key={cron_job.id} cron_job={cron_job} onCancel={onClose} onSaved={onSaved} /> : null}
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
onCancel: () => void;
|
||||
onSaved: (params: { cron_job: CronJobWithNames }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief.
|
||||
*/
|
||||
export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
||||
const [title, setTitle] = useState(cron_job.title || "");
|
||||
const [schedule, setSchedule] = useState(cron_job.schedule || "");
|
||||
const [instructions, setInstructions] = useState(cron_job.instructions || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, title, schedule, instructions },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the routine.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved({ cron_job: res.singleRes });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id="routine-title"
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="routine-schedule"
|
||||
title="Schedule"
|
||||
label="Schedule"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
placeholder="0 9 * * MON-FRI"
|
||||
defaultValue={schedule}
|
||||
info="Cron, in UTC: minute hour day month weekday, or @hourly, @daily, @weekly."
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="routine-instructions"
|
||||
title="Brief"
|
||||
label="Brief"
|
||||
showLabel
|
||||
rows={6}
|
||||
defaultValue={instructions}
|
||||
info="What the bot does at every run, and how it knows it's done."
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
/>
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import routineSubtitle from "../(utils)/routine-subtitle";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
busy?: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One routine: its bot's avatar, its name (Paused or Done when it is),
|
||||
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete.
|
||||
*/
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) {
|
||||
const title = cron_job.title || `Routine ${cron_job.id}`;
|
||||
const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
|
||||
const chat_url = cron_job.workspace_id
|
||||
? `/admin/workspaces/${cron_job.workspace_id}/bots/${cron_job.bot_id}`
|
||||
: `/admin/bots/${cron_job.bot_id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={cron_job.bot_image_media_id}
|
||||
hue={(cron_job.bot_kind == "coordinator" ? null : cron_job.bot_hue || null) as BotHue | null}
|
||||
face={(cron_job.bot_face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
title={title}
|
||||
badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"}
|
||||
subtitle={routineSubtitle({ cron_job })}
|
||||
>
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
<SettingsRowAction
|
||||
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
|
||||
label={cron_job.enabled ? "Pause" : "Resume"}
|
||||
disabled={busy}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import useSettingsList from "@/src/pages/admin/settings/(hooks)/use-settings-list";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import RoutineEditModal from "../(partials)/routine-edit-modal";
|
||||
import RoutineRow from "../(partials)/routine-row";
|
||||
|
||||
/**
|
||||
* Every routine the user's bots have: chat with the bot, pause or resume
|
||||
* one, edit it, delete it. Bots create them when asked in chat.
|
||||
*/
|
||||
export default function RoutinesSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({
|
||||
items: pageProps?.cron_jobs || [],
|
||||
});
|
||||
const [editing, setEditing] = useState<CronJobWithNames>();
|
||||
|
||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||
setItems((prev) => prev.map((item) => (item.id == cron_job.id ? cron_job : item)));
|
||||
|
||||
const toggle = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, enabled: !cron_job.enabled },
|
||||
});
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ cron_job: res.singleRes });
|
||||
setStatus({ success: true, msg: res.msg });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Your routines"
|
||||
description={`Ask any bot to set one up, e.g. "Every weekday at 9, sum up my inbox." Each run is a task, and its result lands in that bot's chat.`}
|
||||
>
|
||||
<SettingsList is_empty={!items.length} empty="No routines yet.">
|
||||
{items.map((cron_job) => (
|
||||
<RoutineRow
|
||||
key={cron_job.id}
|
||||
cron_job={cron_job}
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job })}
|
||||
onEdit={() => setEditing(cron_job)}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: cron_job.id,
|
||||
path: "routines/delete-routine",
|
||||
body: { cron_job_id: cron_job.id },
|
||||
confirm: `Delete the routine "${cron_job.title}"? A run already started carries on.`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
<SettingsStatus status={status} />
|
||||
<RoutineEditModal
|
||||
cron_job={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={({ cron_job }) => {
|
||||
replace({ cron_job });
|
||||
setEditing(undefined);
|
||||
setStatus({ success: true, msg: "Saved." });
|
||||
}}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import routineSubtitle from "./routine-subtitle";
|
||||
|
||||
const now = Date.UTC(2026, 9, 19, 8, 30);
|
||||
|
||||
describe("routineSubtitle", () => {
|
||||
test("names the bot, workspace, schedule and next run", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: {
|
||||
bot_name: "Scout",
|
||||
workspace_name: "Launch",
|
||||
schedule: "0 9 * * *",
|
||||
enabled: 1,
|
||||
next_run_at: now + 30 * 60 * 1000,
|
||||
},
|
||||
now,
|
||||
}),
|
||||
).toBe("Scout · Launch · 0 9 * * * UTC · next in 30m");
|
||||
});
|
||||
|
||||
test("shows the last error instead, and no next run when paused", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now, last_error: "Skipped a run." },
|
||||
now,
|
||||
}),
|
||||
).toBe("Scout · @daily UTC · Skipped a run.");
|
||||
expect(routineSubtitle({ cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now }, now })).toBe(
|
||||
"Scout · @daily UTC",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import timeUntil from "@/src/utils/time-until";
|
||||
|
||||
type Params = {
|
||||
cron_job: CronJobWithNames;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The line under a routine's name: whose it is, where it runs, its
|
||||
* schedule, then when it runs next or why it last couldn't.
|
||||
*/
|
||||
export default function routineSubtitle({ cron_job, now = Date.now() }: Params) {
|
||||
const next =
|
||||
cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : "";
|
||||
|
||||
return [
|
||||
cron_job.bot_name || "A bot",
|
||||
cron_job.workspace_name || "",
|
||||
`${cron_job.schedule} UTC`,
|
||||
cron_job.last_error || next,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* The routines page: every schedule the user's bots have.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
return { props: { cron_jobs: await grabCronJobs({ user_id: user.id }) } };
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import RoutinesSection from "./(sections)/routines-section";
|
||||
|
||||
export default function AdminRoutinesPage() {
|
||||
return (
|
||||
<SettingsShell title="Routines" description="Work your bots do on a schedule, on their own.">
|
||||
<RoutinesSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async () => {
|
||||
return {
|
||||
title: `Routines | ${SiteData["SiteName"]}`,
|
||||
description: "Work your bots do on a schedule.",
|
||||
};
|
||||
};
|
||||
@@ -7,15 +7,19 @@ type Props = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* An anchor, so a link can open the page at this block.
|
||||
*/
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One block of settings: what it is on the left, the controls on the right.
|
||||
* Stacks below 768px. Blocks are separated by hairlines, never cards.
|
||||
*/
|
||||
export default function SettingsGroup({ title, description, children }: Props) {
|
||||
export default function SettingsGroup({ title, description, children, id }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-4! md:grid md:grid-cols-[200px_1fr] md:gap-10! py-8 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack id={id} className="w-full scroll-mt-4 gap-4! md:grid md:grid-cols-[200px_1fr] md:gap-10! py-8 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack className="gap-1! min-w-0">
|
||||
<H2 className="text-sm! leading-5! tracking-normal! font-bold text-ink mb-0!">
|
||||
{title}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import SettingsPaneHeader from "./settings-pane-header";
|
||||
|
||||
@@ -6,18 +7,32 @@ type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* A wider column, for tables (activity).
|
||||
*/
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings pane beside the rail: a 64px header like the conversation
|
||||
* header, then one readable column that scrolls on its own.
|
||||
*/
|
||||
export default function SettingsShell({ title, description, children }: Props) {
|
||||
export default function SettingsShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
wide,
|
||||
}: Props) {
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<SettingsPaneHeader title={title} description={description} />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
|
||||
<Stack className="w-full max-w-[760px] mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16">
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"w-full max-w-container mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16",
|
||||
wide && "max-w-[1200px]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deleteCronJob from "@/src/functions/backend/db/cron-jobs/delete-cron-job";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { CronJobIdRequestSchema } from "@/src/schemas/cron-job-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes one of the session user's routines (a bot's schedule).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { cron_job_id } = parseWithSchema({ schema: CronJobIdRequestSchema, data: body });
|
||||
|
||||
const cron_job = await deleteCronJob({ user_id: user.id, cron_job_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Routine deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the routine "${cron_job.title}" of ${cron_job.bot_name}.`,
|
||||
metadata: { cron_job_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${cron_job.title || "The routine"} is deleted.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import updateCronJob from "@/src/functions/backend/db/cron-jobs/update-cron-job";
|
||||
import { UpdateCronJobRequestSchema } from "@/src/schemas/cron-job-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Changes one of the session user's routines (a bot's schedule): its
|
||||
* name, brief or schedule, or pauses and resumes it. Returns the routine.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<CronJobWithNames>> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { cron_job_id, ...data } = parseWithSchema({ schema: UpdateCronJobRequestSchema, data: body });
|
||||
|
||||
const cron_job = await updateCronJob({ user_id: user.id, cron_job_id, data });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: cron_job,
|
||||
msg:
|
||||
data.enabled === undefined
|
||||
? `Saved.`
|
||||
: data.enabled
|
||||
? `${cron_job.title} is back on.`
|
||||
: `${cron_job.title} is paused.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const CronJobIdField = z.coerce.number().int().positive("Pick a routine");
|
||||
|
||||
export const UpdateCronJobRequestSchema = z.object({
|
||||
cron_job_id: CronJobIdField,
|
||||
title: z.string().trim().min(1, "Give the routine a name").max(80, "Keep the name under 80 characters").optional(),
|
||||
instructions: z.string().trim().min(1, "Tell the bot what to do").max(8000).optional(),
|
||||
schedule: z.string().trim().min(1, "Set when it runs").max(100).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateCronJobRequest = z.infer<typeof UpdateCronJobRequestSchema>;
|
||||
|
||||
export const CronJobIdRequestSchema = z.object({
|
||||
cron_job_id: CronJobIdField,
|
||||
});
|
||||
|
||||
export type CronJobIdRequest = z.infer<typeof CronJobIdRequestSchema>;
|
||||
@@ -151,6 +151,8 @@
|
||||
--radius-default-xs: 4px;
|
||||
--radius-default-lg: 10px;
|
||||
--radius-default-xl: 14px;
|
||||
|
||||
--container-container: 960px;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
BUN_SQLITE_COBBLE_BOTS,
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES,
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_CRON_JOBS,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
@@ -52,6 +53,19 @@ export type BotWithLastMessage = BUN_SQLITE_COBBLE_BOTS & {
|
||||
last_message_at?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot's schedule with what the routines page shows beside it: the bot
|
||||
* and the workspace it runs in.
|
||||
*/
|
||||
export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
|
||||
bot_name?: string | null;
|
||||
bot_kind?: string | null;
|
||||
bot_hue?: string | null;
|
||||
bot_face?: string | null;
|
||||
bot_image_media_id?: number | null;
|
||||
workspace_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A session in a conversation's sessions modal.
|
||||
*/
|
||||
@@ -350,6 +364,10 @@ export type PagePropsType = {
|
||||
* The open bot's active task and what its job has done so far.
|
||||
*/
|
||||
bot_task?: BotTaskWithSteps | null;
|
||||
/**
|
||||
* The routines page: every schedule the user's bots have.
|
||||
*/
|
||||
cron_jobs?: CronJobWithNames[] | null;
|
||||
environment?: string;
|
||||
has_password?: boolean;
|
||||
backups?: DbBackup[] | null;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(/^\//, ""))}`;
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user