diff --git a/AGENTS.md b/AGENTS.md index 26f157a..bcb0c6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - 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 ""` - 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//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//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 ""` - 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 `-` 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/` (`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//` (`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//` (`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 ", 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 ` 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 diff --git a/db/schema.ts b/db/schema.ts index 43846e0..10cd94f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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: [ diff --git a/db/types/db.ts b/db/types/db.ts index 96d8a2a..e2c1c01 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/src/cron/index.ts b/src/cron/index.ts index 6e7e1a8..e14e459 100644 --- a/src/cron/index.ts +++ b/src/cron/index.ts @@ -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, + }); } diff --git a/src/cron/register-cron-job.ts b/src/cron/register-cron-job.ts index 8855dfa..a64b1a5 100644 --- a/src/cron/register-cron-job.ts +++ b/src/cron/register-cron-job.ts @@ -5,6 +5,11 @@ type Params = { schedule: Bun.CronWithAutocomplete; task: () => Promise; 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 { - await logEvent({ - title: `Cron: ${title}`, - category: "cron", - plain_text: `CRON: ${title} ...`, - }); + if (!quiet) { + await logEvent({ + title: `Cron: ${title}`, + category: "cron", + plain_text: `CRON: ${title} ...`, + }); + } await task(); } catch (error) { await logError({ diff --git a/src/cron/run-cron-jobs/index.ts b/src/cron/run-cron-jobs/index.ts new file mode 100644 index 0000000..3d8106a --- /dev/null +++ b/src/cron/run-cron-jobs/index.ts @@ -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({ + 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 }); + } +} diff --git a/src/data/app-data.ts b/src/data/app-data.ts index be4bf86..ddc769d 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -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 diff --git a/src/dict/admin-sections-dict.ts b/src/dict/admin-sections-dict.ts index 283d72c..abc521f 100644 --- a/src/dict/admin-sections-dict.ts +++ b/src/dict/admin-sections-dict.ts @@ -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"; diff --git a/src/dict/computer-dict.ts b/src/dict/computer-dict.ts index 46ab59c..d05ef88 100644 --- a/src/dict/computer-dict.ts +++ b/src/dict/computer-dict.ts @@ -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"]; diff --git a/src/dict/help-dict.ts b/src/dict/help-dict.ts new file mode 100644 index 0000000..65c0fdb --- /dev/null +++ b/src/dict/help-dict.ts @@ -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"; diff --git a/src/dict/rail-pages-dict.ts b/src/dict/rail-pages-dict.ts new file mode 100644 index 0000000..5377389 --- /dev/null +++ b/src/dict/rail-pages-dict.ts @@ -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 }, +]; diff --git a/src/functions/backend/db/cron-jobs/create-cron-job.ts b/src/functions/backend/db/cron-jobs/create-cron-job.ts new file mode 100644 index 0000000..0d4eb92 --- /dev/null +++ b/src/functions/backend/db/cron-jobs/create-cron-job.ts @@ -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({ + 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 }); +} diff --git a/src/functions/backend/db/cron-jobs/delete-cron-job.ts b/src/functions/backend/db/cron-jobs/delete-cron-job.ts new file mode 100644 index 0000000..f550222 --- /dev/null +++ b/src/functions/backend/db/cron-jobs/delete-cron-job.ts @@ -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; +} diff --git a/src/functions/backend/db/cron-jobs/fire-cron-job.ts b/src/functions/backend/db/cron-jobs/fire-cron-job.ts new file mode 100644 index 0000000..34d24c5 --- /dev/null +++ b/src/functions/backend/db/cron-jobs/fire-cron-job.ts @@ -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, + ], + }); +} diff --git a/src/functions/backend/db/cron-jobs/grab-cron-jobs.ts b/src/functions/backend/db/cron-jobs/grab-cron-jobs.ts new file mode 100644 index 0000000..0e6f78d --- /dev/null +++ b/src/functions/backend/db/cron-jobs/grab-cron-jobs.ts @@ -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 { + 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({ + 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 || []; +} diff --git a/src/functions/backend/db/cron-jobs/grab-user-cron-job.ts b/src/functions/backend/db/cron-jobs/grab-user-cron-job.ts new file mode 100644 index 0000000..ad46596 --- /dev/null +++ b/src/functions/backend/db/cron-jobs/grab-user-cron-job.ts @@ -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; +} diff --git a/src/functions/backend/db/cron-jobs/update-cron-job.ts b/src/functions/backend/db/cron-jobs/update-cron-job.ts new file mode 100644 index 0000000..3317070 --- /dev/null +++ b/src/functions/backend/db/cron-jobs/update-cron-job.ts @@ -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! }); +} diff --git a/src/harness/tools/bot/create-schedule-tool.ts b/src/harness/tools/bot/create-schedule-tool.ts new file mode 100644 index 0000000..0cfb333 --- /dev/null +++ b/src/harness/tools/bot/create-schedule-tool.ts @@ -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 { + 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.`; + }, + }; +} diff --git a/src/harness/tools/bot/delete-schedule-tool.ts b/src/harness/tools/bot/delete-schedule-tool.ts new file mode 100644 index 0000000..0457923 --- /dev/null +++ b/src/harness/tools/bot/delete-schedule-tool.ts @@ -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 { + 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.`; + }, + }; +} diff --git a/src/harness/tools/bot/grab-bot-tools.ts b/src/harness/tools/bot/grab-bot-tools.ts index f5d5cee..5bde822 100644 --- a/src/harness/tools/bot/grab-bot-tools.ts +++ b/src/harness/tools/bot/grab-bot-tools.ts @@ -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 })] : []), diff --git a/src/harness/tools/bot/list-schedules-tool.ts b/src/harness/tools/bot/list-schedules-tool.ts new file mode 100644 index 0000000..691035a --- /dev/null +++ b/src/harness/tools/bot/list-schedules-tool.ts @@ -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> { + 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"); + }, + }; +} diff --git a/src/harness/tools/bot/update-schedule-tool.ts b/src/harness/tools/bot/update-schedule-tool.ts new file mode 100644 index 0000000..f54aca6 --- /dev/null +++ b/src/harness/tools/bot/update-schedule-tool.ts @@ -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 { + 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 })}.`; + }, + }; +} diff --git a/src/harness/tools/computer/ask-to-take-over-tool.ts b/src/harness/tools/computer/ask-to-take-over-tool.ts index a61d047..36692e7 100644 --- a/src/harness/tools/computer/ask-to-take-over-tool.ts +++ b/src/harness/tools/computer/ask-to-take-over-tool.ts @@ -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), diff --git a/src/layouts/bots/(dict)/rail-user-menu-dict.ts b/src/layouts/bots/(dict)/rail-user-menu-dict.ts index 6bc6a55..dbb33cf 100644 --- a/src/layouts/bots/(dict)/rail-user-menu-dict.ts +++ b/src/layouts/bots/(dict)/rail-user-menu-dict.ts @@ -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 }, ]; diff --git a/src/layouts/bots/(partials)/rail-pages-list.tsx b/src/layouts/bots/(partials)/rail-pages-list.tsx new file mode 100644 index 0000000..1c3c993 --- /dev/null +++ b/src/layouts/bots/(partials)/rail-pages-list.tsx @@ -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 ( + <> + + + {RailPages.filter((page) => is_super_admin || !page.super_admin_only).map((page) => ( + + ))} + + ); +} diff --git a/src/layouts/bots/(partials)/rail-user-menu.tsx b/src/layouts/bots/(partials)/rail-user-menu.tsx index c4eaa22..47099a2 100644 --- a/src/layouts/bots/(partials)/rail-user-menu.tsx +++ b/src/layouts/bots/(partials)/rail-user-menu.tsx @@ -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 ( 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"} > - + {is_settings_page ? ( + ) : is_rail_page ? ( + ) : in_workspace && live_workspace ? ( + {pending.map((item) => ( ) { + function handleKeyDown( + e: KeyboardEvent, + ) { 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] }); }} > - + - - + +