Run computers with resources and health checks, add settings for bots, workspaces, computers and users

- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and
  editable; new resources stop, remove and recreate the container, its home
  dir kept. Every computer container has a docker health check and stays
  "creating" until it passes; only healthy computers are reached, by
  hostname, else by their IP on the network (dev outside docker).
- Docker is required: the app creates the cobble network on startup and
  exits without docker.
- Production container: deploy/run-app-container.sh mounts .env, the data
  dir and the docker socket; the database (DB_DIR), media, computers' homes
  and bun-sqlite's live schema now live on the data volume.
- Settings pages for bots, workspaces, computers and users, sharing list
  components; bots can be deleted; users are created with a password and
  confirmation, edited and deleted through RBAC-gated APIs.
- User types trimmed to super_admin, admin, member, read_only and revoked;
  admins manage members. UserAvatar shows profile photos from the user row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 10:26:44 +01:00
co-authored by Claude Opus 5.5
parent 0ce61cdb49
commit f24c0d7e82
91 changed files with 2692 additions and 331 deletions
+10 -3
View File
@@ -19,6 +19,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w
- `/media/*` short-circuits to the media server handler - `/media/*` short-circuits to the media server handler
- `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/` - `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/`
- everything else goes to `bunext.bunextRequestHandler` - everything else goes to `bunext.bunextRequestHandler`
- `ensureDockerNetwork()` makes sure the computers' docker network (`AppData.DockerNetworkName`) exists before the server starts; without docker the process exits (computers are containers, so nothing works without it). Creating a computer's container checks it again first
- `cron()` and the bots queue handler (`botsQueue()`, `src/bots-queue/`) start after the server is up, then `liveRelay()` (`src/live-relay/`, every process, ungated); in production cron and the queue are gated to the `MAIN_SERVER` container - `cron()` and the bots queue handler (`botsQueue()`, `src/bots-queue/`) start after the server is up, then `liveRelay()` (`src/live-relay/`, every process, ungated); in production cron and the queue are gated to the `MAIN_SERVER` container
## Commands ## Commands
@@ -63,6 +64,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Signup:** first-run only. `api/auth/signup.tsx` refuses once any user exists, creates the super admin (`create-super-admin-user.ts`, so user #1) with a required password, and logs them in. No Google signup. `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) is the headless alternative. - **Signup:** first-run only. `api/auth/signup.tsx` refuses once any user exists, creates the super admin (`create-super-admin-user.ts`, so user #1) with a required password, and logs them in. No Google signup. `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) is the headless alternative.
- **Passwords:** bcrypt hashes live in the separate `user_passwords` table (never in `users`; blocked from the CRUD API via `src/dict/private-tables.ts`). Set/change at `/admin/settings/change-password`. - **Passwords:** bcrypt hashes live in the separate `user_passwords` table (never in `users`; blocked from the CRUD API via `src/dict/private-tables.ts`). Set/change at `/admin/settings/change-password`.
- **Auth layout:** `/auth/*` (except `/auth/logout`) renders in `src/layouts/auth/` with no site header/footer. - **Auth layout:** `/auth/*` (except `/auth/logout`) renders in `src/layouts/auth/` with no site header/footer.
- **User types** (`UserTypes` in `src/dict/user-types-dict.ts`, the only place they're defined): `super_admin` (the first account, runs the install), `admin` (manages members; only the super admin grants it), `member` (a regular user: new users start with it), `read_only`, `revoked` (can't sign in). A user needs at least one to sign in. Admins grant and remove `member`, `read_only` and `revoked` on anyone but admins and the super admin.
- **RBAC:** `src/utils/check-user-access.ts` (includes/excludes primitive, `super_admin` bypass) plus the declarative engine in `src/utils/rbac/` - `userTypeAssignment` interprets `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix` in `src/dict/user-types-dict.ts`. `read_only` users are blocked from non-GET CRUD in the router. - **RBAC:** `src/utils/check-user-access.ts` (includes/excludes primitive, `super_admin` bypass) plus the declarative engine in `src/utils/rbac/` - `userTypeAssignment` interprets `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix` in `src/dict/user-types-dict.ts`. `read_only` users are blocked from non-GET CRUD in the router.
- **Audit trail:** `src/functions/backend/logger.ts` - `logEvent`/`logError` write to the `logs` table. Signup, login, Google login, logout, cron runs, and WebSocket connects are all logged. - **Audit trail:** `src/functions/backend/logger.ts` - `logEvent`/`logError` write to the `logs` table. Signup, login, Google login, logout, cron runs, and WebSocket connects are all logged.
@@ -98,11 +100,16 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`. - **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
- **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`). - **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). - **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>`. 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 the container is published on a free localhost port. 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`. - **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/`; 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. - **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). - **Not built yet:** routines, help, task steps (nothing writes `bot_task_steps` yet).
### Production container
`deploy/run-app-container.sh` builds the image and (re)runs the app's container (`deploy/deploy-prod.sh` calls it on the server); rerun it to update. The app container and every computer container share the `cobble` network. Mounts: `.env` at `/app/.env` (read only; bun loads it), the host data dir (`COBBLE_DATA_DIR`, default `/data/cobble`) at `/data`, and the docker socket. Everything that must survive an update lives on `/data`: media and computers' home dirs (`DATA_DIR=/data`), the database with its backups and exports (`DB_DIR=/data/db`, read by `bun-sqlite.config.ts`; the schema always comes from `db/schema.ts`), and bun-sqlite's live schema record (`entrypoint.sh` links `/app/.bun-sqlite` there). `MAIN_SERVER=1` is set on that one container.
### Envs ### Envs
Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it).
@@ -122,7 +129,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`). - **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (container `metadata.mcp_url`, else the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`. - **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data. - **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
@@ -134,7 +141,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/` - `/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/logs` - audit log (super admin; the "Activity" item in the bots popover is disabled while this is closed)
- `/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/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. 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 - `/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
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
+4
View File
@@ -5,6 +5,10 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production ENV NODE_ENV=production
# Everything that must survive an update lives on the /data volume:
# the database, media, computers' home directories
ENV DATA_DIR=/data
ENV DB_DIR=/data/db
ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1 ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1
WORKDIR /app WORKDIR /app
+10 -2
View File
@@ -1,10 +1,18 @@
import path from "path";
import type { BunSQLiteConfig } from "@moduletrace/bun-sqlite/types"; import type { BunSQLiteConfig } from "@moduletrace/bun-sqlite/types";
/**
* The database lives in `DB_DIR` when it's set (the app's container puts
* it on the data volume, so it survives updates), else in `./db`. The
* schema always comes from the code, `db/schema.ts`.
*/
const db_dir = process.env.DB_DIR || "./db";
const config: BunSQLiteConfig = { const config: BunSQLiteConfig = {
db_name: "cobble", db_name: "cobble",
db_dir: "./db", db_dir,
typedef_file_path: "./db/types/db.ts", typedef_file_path: "./db/types/db.ts",
db_schema_file_name: "schema.ts", db_schema_file_name: path.relative(path.resolve(db_dir), path.resolve("db/schema.ts")),
}; };
export default config; export default config;
+15
View File
@@ -893,6 +893,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldDescription: `Address the main container reaches it on`, fieldDescription: `Address the main container reaches it on`,
dataType: "TEXT", dataType: "TEXT",
}, },
{
fieldName: "ip_address",
fieldDescription: `Its IP address on the network, tried when the hostname can't be reached (a dev server outside docker)`,
dataType: "TEXT",
},
{ {
fieldName: "port", fieldName: "port",
fieldDescription: `Port the service listens on inside the container`, fieldDescription: `Port the service listens on inside the container`,
@@ -960,6 +965,16 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldName: "description", fieldName: "description",
dataType: "TEXT", dataType: "TEXT",
}, },
{
fieldName: "cpus",
fieldDescription: `CPU cores the container may use (docker NanoCpus), set when the computer is created`,
dataType: "REAL",
},
{
fieldName: "memory_mb",
fieldDescription: `Memory limit of the container in MB, set when the computer is created`,
dataType: "INTEGER",
},
], ],
indexes: [ indexes: [
{ {
+12
View File
@@ -576,6 +576,10 @@ export type BUN_SQLITE_COBBLE_CONTAINERS = {
* Address the main container reaches it on * Address the main container reaches it on
*/ */
hostname?: string; hostname?: string;
/**
* Its IP address on the network, tried when the hostname can't be reached (a dev server outside docker)
*/
ip_address?: string;
/** /**
* Port the service listens on inside the container * Port the service listens on inside the container
*/ */
@@ -621,6 +625,14 @@ export type BUN_SQLITE_COBBLE_COMPUTERS = {
*/ */
kind?: "main" | "sandbox" | ""; kind?: "main" | "sandbox" | "";
description?: string; description?: string;
/**
* CPU cores the container may use (docker NanoCpus), set when the computer is created
*/
cpus?: number | "";
/**
* Memory limit of the container in MB, set when the computer is created
*/
memory_mb?: number | "";
} }
export type BUN_SQLITE_COBBLE_BOT_COMPUTERS = { export type BUN_SQLITE_COBBLE_BOT_COMPUTERS = {
+3 -14
View File
@@ -20,7 +20,7 @@ if [ ! -f "$ENV_FILE" ]; then
exit 1 exit 1
fi fi
DEPLOY_NAME="bunext-sqlite" DEPLOY_NAME="cobble"
SOURCE_PATH="${SOURCE_DIRECTORY:-$(read_env_var SOURCE_DIRECTORY)}" SOURCE_PATH="${SOURCE_DIRECTORY:-$(read_env_var SOURCE_DIRECTORY)}"
SERVER_IP="${DEPLOY_SERVER_IP:-$(read_env_var DEPLOY_SERVER_IP)}" SERVER_IP="${DEPLOY_SERVER_IP:-$(read_env_var DEPLOY_SERVER_IP)}"
SSH_USER="${SSH_USER:-$(read_env_var SSH_USER)}" SSH_USER="${SSH_USER:-$(read_env_var SSH_USER)}"
@@ -60,17 +60,6 @@ set -euo pipefail
DEPLOY_NAME="$1" DEPLOY_NAME="$1"
cd "/docker/$DEPLOY_NAME" cd "/docker/$DEPLOY_NAME"
docker build -t "$DEPLOY_NAME" . COBBLE_DATA_DIR="/data/$DEPLOY_NAME/.data" COBBLE_EXTRA_NETWORK=cloudflare \
./deploy/run-app-container.sh
docker rm -f "$DEPLOY_NAME" 2>/dev/null || true
docker run --name "$DEPLOY_NAME" -d \
--network cloudflare \
-v "/data/$DEPLOY_NAME/.data:/data" \
--cpus 1 --memory 1g \
--env-file "/docker/$DEPLOY_NAME/.env" \
--restart unless-stopped \
"$DEPLOY_NAME:latest"
docker image prune -f
EOF EOF
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Builds the app's image and (re)starts its container. Rerun it to update:
# the database, media and computers' home directories (the data dir) and
# .env stay on the host. The container needs the docker socket, since it
# creates every computer as a container on the cobble network.
#
# COBBLE_DATA_DIR host data dir, mounted at /data (default /data/cobble)
# COBBLE_BIND host address the app is published on (default 127.0.0.1)
# COBBLE_EXTRA_NETWORK another docker network to join, e.g. a tunnel's
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$PROJECT_ROOT/.env"
NAME="cobble"
NETWORK="cobble"
DATA_PATH="${COBBLE_DATA_DIR:-/data/cobble}"
BIND="${COBBLE_BIND:-127.0.0.1}"
DOCKER_SOCKET="/var/run/docker.sock"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found. Copy .env.example and set ENCRYPTION_KEY and ENCRYPTION_SALT." >&2
exit 1
fi
if [ ! -S "$DOCKER_SOCKET" ]; then
echo "Error: no docker socket at $DOCKER_SOCKET. Computers run in docker, so the app needs it." >&2
exit 1
fi
PORT="$(grep -E '^[[:space:]]*PORT=' "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d "\"' \r")"
PORT="${PORT:-7227}"
mkdir -p "$DATA_PATH"
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null
docker build -t "$NAME" "$PROJECT_ROOT"
docker stop -t 30 "$NAME" >/dev/null 2>&1 || true
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run --name "$NAME" -d \
--network "$NETWORK" \
-p "$BIND:$PORT:$PORT" \
-v "$ENV_FILE:/app/.env:ro" \
-v "$DATA_PATH:/data" \
-v "$DOCKER_SOCKET:$DOCKER_SOCKET" \
-e MAIN_SERVER=1 \
--cpus 1 --memory 1g \
--restart unless-stopped \
"$NAME:latest"
if [ -n "${COBBLE_EXTRA_NETWORK:-}" ]; then
docker network connect "$COBBLE_EXTRA_NETWORK" "$NAME"
fi
docker image prune -f >/dev/null
echo "Cobble is starting on http://$BIND:$PORT (data in $DATA_PATH)"
+7 -1
View File
@@ -1,10 +1,16 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "=== Bunext SQLite Template ===" echo "=== Cobble ==="
cd /app cd /app
# bun-sqlite's record of the live schema sits beside the code; keep it on
# the data volume with the database it describes
mkdir -p "${DATA_DIR:-/data}/.bun-sqlite"
rm -rf /app/.bun-sqlite
ln -sfn "${DATA_DIR:-/data}/.bun-sqlite" /app/.bun-sqlite
bunx bun-sqlite schema -y bunx bun-sqlite schema -y
bunx bunext build bunx bunext build
bunx bunext sitemap bunx bunext sitemap
@@ -0,0 +1,13 @@
import Span from "@moduletrace/twui/layout/Span";
import { Monitor } from "lucide-react";
/**
* A computer's avatar in lists: the monitor icon on a small tile.
*/
export default function ComputerIconTile() {
return (
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
<Monitor size={16} aria-hidden />
</Span>
);
}
+23 -5
View File
@@ -1,3 +1,6 @@
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import userInitials from "@/src/utils/user-initials";
import Img from "@moduletrace/twui/layout/Img"; import Img from "@moduletrace/twui/layout/Img";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -12,28 +15,43 @@ const UserAvatarSizeClasses = {
} as const; } as const;
type Props = { type Props = {
/**
* Their photo and initials come from this user (`grabUsers`' row, with
* `profile_media_id`), unless `src` or `initials` are given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
/**
* A photo to show instead, e.g. one just picked and not saved yet.
*/
src?: string; src?: string;
/** /**
* Tried when `src` fails to load, e.g. a full image behind a thumbnail. * Tried when `src` fails to load, e.g. a full image behind a thumbnail.
*/ */
img_err_src?: string; img_err_src?: string;
initials: string; initials?: string;
alt?: string; alt?: string;
size?: keyof typeof UserAvatarSizeClasses; size?: keyof typeof UserAvatarSizeClasses;
className?: string; className?: string;
}; };
/** /**
* A user's round photo, or their initials on sunken paper when there is none. * A user's round photo (its thumbnail, the full image if that fails), or
* their initials on sunken paper when there is none.
*/ */
export default function UserAvatar({ export default function UserAvatar({
src, person,
img_err_src, src: passed_src,
initials, img_err_src: passed_err_src,
initials: passed_initials,
alt = "", alt = "",
size = "md", size = "md",
className, className,
}: Props) { }: Props) {
const photo = person?.profile_media_id ? grabMediaURL({ user: person }) : {};
const src = passed_src || photo.media_thumbnail_url || photo.media_url;
const img_err_src = passed_src ? passed_err_src : photo.media_url;
const initials = passed_initials || userInitials(person || {});
const base_class = twMerge( const base_class = twMerge(
"shrink-0 rounded-full bg-sunken", "shrink-0 rounded-full bg-sunken",
UserAvatarSizeClasses[size], UserAvatarSizeClasses[size],
+10 -24
View File
@@ -1,23 +1,21 @@
import bunext from "@moduletrace/bunext"; import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import pushComputerUpdate from "@/src/functions/backend/db/computers/push-computer-update"; import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status"> & type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address"> &
Pick<BUN_SQLITE_COBBLE_COMPUTERS, "user_id"> & { computer_id: number }; { computer_id: number };
/** /**
* Reads every computer container's state from docker and records it * Reads every computer container's state from docker and records it
* (`containers.status`, `last_seen_at`), telling owners about changes. A * (`refreshComputerContainer`), telling owners about changes. Containers
* container docker no longer has shows as `dead`. Containers still being * still being set up, or that failed, are left alone.
* set up, or that failed, are left alone.
*/ */
export default async function syncComputers() { export default async function syncComputers() {
const rows = const rows =
( (
await BunSQLite.sql<Row>({ await BunSQLite.sql<Row>({
sql: `SELECT co.id, co.docker_id, co.status, c.user_id, c.id AS computer_id sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, c.id AS computer_id
FROM containers co JOIN computers c ON c.container_id = co.id FROM containers co JOIN computers c ON c.container_id = co.id
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`, WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
}) })
@@ -25,22 +23,10 @@ export default async function syncComputers() {
let changed = 0; let changed = 0;
for (const row of rows) { for (const { computer_id, ...container } of rows) {
const state = await inspectDockerContainer({ container: row.docker_id! }).catch(() => undefined); const refreshed = await refreshComputerContainer({ computer_id, container });
if (state === undefined) continue; if (refreshed?.changed) changed++;
const status = state?.status || "dead";
await BunSQLite.sql({
sql: `UPDATE containers SET status = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [status, Date.now(), Date.now(), row.id!],
});
if (status != row.status) {
changed++;
await pushComputerUpdate({ computer_id: row.computer_id }).catch(() => {});
}
} }
bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`); bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`);
+38 -5
View File
@@ -27,9 +27,9 @@ export const AppData = {
PasswordMaxLength: 128, PasswordMaxLength: 128,
PasswordAttemptLimit: 10, PasswordAttemptLimit: 10,
/** /**
* Docker network shared by the app and every computer. The app creates * Docker network shared by the app and every computer. The app makes
* it when it sets up its first computer and joins it when it runs in * sure it exists on startup and before it creates a container, and
* docker. * joins it when it runs in docker.
*/ */
DockerNetworkName: SiteData["SiteSlug"], DockerNetworkName: SiteData["SiteSlug"],
/** /**
@@ -51,11 +51,44 @@ export const AppData = {
ComputerHomeDir: "/home/gem", ComputerHomeDir: "/home/gem",
ComputerUserUid: 1000, ComputerUserUid: 1000,
ComputerUserGid: 1000, ComputerUserGid: 1000,
/**
* Resources a new computer's container gets, saved on the computer
* (`cpus`, `memory_mb`) when it is created.
*/
ComputerDefaultCpus: 1,
ComputerDefaultMemoryMb: 2048,
ComputerMinCpus: 0.25,
ComputerMaxCpus: 64,
ComputerMinMemoryMb: 512,
/**
* Docker's health check on every computer container: the sandbox's API
* on `ComputerPort` and its browser answer. A new computer stays
* `creating` until it passes, for at most `ComputerHealthyTimeoutMs`,
* checked every `ComputerHealthPollMs`.
*/
ComputerHealthcheck: {
IntervalSeconds: 5,
TimeoutSeconds: 5,
StartPeriodSeconds: 120,
Retries: 3,
},
ComputerHealthyTimeoutMs: 5 * 60 * 1000,
ComputerHealthPollMs: 2000,
/**
* How long docker gives a computer's container to stop before it is
* killed, when it is removed or recreated.
*/
ComputerStopTimeoutSeconds: 10,
/**
* A computer is reached at its hostname, else its IP address. Each
* address gets this long to answer, and the one that did is reused
* for `ComputerAddressCacheMs`.
*/
ComputerReachTimeoutMs: 2000,
ComputerAddressCacheMs: 60 * 1000,
/** /**
* Per computer container. * Per computer container.
*/ */
ComputerMemoryBytes: 4 * 1024 * 1024 * 1024,
ComputerCpus: 2,
ComputerShmBytes: 2 * 1024 * 1024 * 1024, ComputerShmBytes: 2 * 1024 * 1024 * 1024,
/** /**
* The docker engine API, reached over its unix socket. * The docker engine API, reached over its unix socket.
+13 -2
View File
@@ -20,8 +20,11 @@ export const MainComputerName = "Main computer";
/** /**
* Docker container state as reported by `docker inspect` (`State.Status`), * Docker container state as reported by `docker inspect` (`State.Status`),
* plus `creating` while the app sets it up, `failed` when that didn't * with `running` narrowed by its health check: `starting` until it first
* work, and `unknown` for a record that hasn't been inspected yet. * passes, `unhealthy` once it keeps failing. Plus `creating` while the app
* sets it up (until the health check passes), `failed` when that didn't
* work, and `unknown` for a record that hasn't been inspected yet. Only a
* `running` computer is reached.
*/ */
export const ContainerStatuses = [ export const ContainerStatuses = [
{ {
@@ -36,10 +39,18 @@ export const ContainerStatuses = [
title: "Created", title: "Created",
value: "created", value: "created",
}, },
{
title: "Starting",
value: "starting",
},
{ {
title: "Running", title: "Running",
value: "running", value: "running",
}, },
{
title: "Unhealthy",
value: "unhealthy",
},
{ {
title: "Paused", title: "Paused",
value: "paused", value: "paused",
+1
View File
@@ -70,6 +70,7 @@ export const ProcessOnlyEnvKeys = [
"ENCRYPTION_SALT", "ENCRYPTION_SALT",
"PORT", "PORT",
"DATA_DIR", "DATA_DIR",
"DB_DIR",
"MAIN_SERVER", "MAIN_SERVER",
] as const; ] as const;
+21
View File
@@ -1,11 +1,17 @@
import { import {
Bot,
CpuAi, CpuAi,
KeyRound, KeyRound,
Layers,
Monitor,
Users,
SlidersHorizontal, SlidersHorizontal,
UserRound, UserRound,
Variable, Variable,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import canListUsers from "@/src/utils/rbac/can-list-users";
export type SettingsPage = { export type SettingsPage = {
title: string; title: string;
@@ -15,8 +21,14 @@ export type SettingsPage = {
* Shown and served to super admins only. * Shown and served to super admins only.
*/ */
super_admin_only?: boolean; super_admin_only?: boolean;
/**
* Who sees it, when that's an RBAC rule rather than super admins only.
*/
can_view?: (params: { user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null }) => boolean;
}; };
export const SettingsComputersPath = "/admin/settings/computers";
/** /**
* The `/admin/settings/*` sub pages, listed in the rail on every settings * The `/admin/settings/*` sub pages, listed in the rail on every settings
* page. The first one is where `/admin/settings` lands. * page. The first one is where `/admin/settings` lands.
@@ -33,6 +45,15 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/change-password", url: "/admin/settings/change-password",
Icon: KeyRound, Icon: KeyRound,
}, },
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor },
{
title: "Users",
url: "/admin/settings/users",
Icon: Users,
can_view: canListUsers,
},
{ {
title: "Models", title: "Models",
url: "/admin/settings/models", url: "/admin/settings/models",
+11 -20
View File
@@ -19,6 +19,7 @@ describe("UserTypes dict", () => {
const required = [ const required = [
"super_admin", "super_admin",
"admin", "admin",
"member",
"revoked", "revoked",
"read_only", "read_only",
] as const; ] as const;
@@ -48,28 +49,18 @@ describe("Assignment matrices", () => {
).toBeUndefined(); ).toBeUndefined();
}); });
test("elevated types are pass-through only", () => { test("admin is granted by pass-through actors only", () => {
const elevated = [ const entry = UserTypeAssignmentMatrix["admin"];
"admin",
"president",
"vice_president",
"secretary",
] as const;
for (const type of elevated) {
const entry = UserTypeAssignmentMatrix[type];
expect(Array.isArray(entry.allowed_actors)).toBe(true); expect(Array.isArray(entry.allowed_actors)).toBe(true);
expect((entry.allowed_actors as string[]).length).toBe(0); expect((entry.allowed_actors as string[]).length).toBe(0);
});
test("admin manages member types, never on admins or the super admin", () => {
for (const type of ["member", "read_only", "revoked"] as const) {
for (const matrix of [UserTypeAssignmentMatrix, UserTypeDeletionMatrix]) {
const rules = (matrix[type].allowed_actors as Record<string, { excluded_targets?: string[] }>).admin;
expect(rules?.excluded_targets).toEqual(["super_admin", "admin"]);
}
} }
}); });
test("admin can manage auditor but not another admin", () => {
expect(
UserTypeAssignmentMatrix["auditor"].allowed_actors,
).toContain("admin");
expect(
(UserTypeAssignmentMatrix["admin"].allowed_actors as string[])
.length,
).toBe(0);
});
}); });
+29 -49
View File
@@ -1,29 +1,33 @@
import type { UserType, UserTypeAssignmentMatrixType } from "../types"; import type { UserType, UserTypeAssignmentMatrixType } from "../types";
/**
* Every user type on this install. A user needs at least one to sign in.
*/
export const UserTypes = [ export const UserTypes = [
{ {
value: "super_admin", value: "super_admin",
title: "Super admin",
description: "Runs the install: models, environment, computers and users. The first account.",
}, },
{ {
value: "admin", value: "admin",
title: "Admin",
description: "Adds and manages members.",
}, },
{ {
value: "auditor", value: "member",
}, title: "Member",
{ description: "Has their own crew, workspaces and main computer.",
value: "president",
},
{
value: "vice_president",
},
{
value: "secretary",
},
{
value: "revoked",
}, },
{ {
value: "read_only", value: "read_only",
title: "Read only",
description: "Can look around, but can't change anything.",
},
{
value: "revoked",
title: "Revoked",
description: "Can't sign in. Their crew and files are kept.",
}, },
] as const; ] as const;
@@ -58,36 +62,24 @@ export const UserTypeAssignmentMatrix: UserTypeAssignmentMatrixType = {
admin: { admin: {
allowed_actors: [], allowed_actors: [],
}, },
president: { member: {
allowed_actors: [], allowed_actors: {
admin: {
excluded_targets: ["super_admin", "admin"],
}, },
vice_president: {
allowed_actors: [],
}, },
secretary: {
allowed_actors: [],
},
auditor: {
allowed_actors: ["admin"],
}, },
read_only: { read_only: {
allowed_actors: { allowed_actors: {
admin: { admin: {
excluded_targets: ["super_admin"], excluded_targets: ["super_admin", "admin"],
}, },
}, },
}, },
revoked: { revoked: {
allowed_actors: { allowed_actors: {
admin: { admin: {
allowed_targets: ["read_only"], excluded_targets: ["super_admin", "admin"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
}, },
}, },
}, },
@@ -98,36 +90,24 @@ export const UserTypeDeletionMatrix: UserTypeAssignmentMatrixType = {
admin: { admin: {
allowed_actors: [], allowed_actors: [],
}, },
president: { member: {
allowed_actors: [], allowed_actors: {
admin: {
excluded_targets: ["super_admin", "admin"],
}, },
vice_president: {
allowed_actors: [],
}, },
secretary: {
allowed_actors: [],
},
auditor: {
allowed_actors: ["admin"],
}, },
read_only: { read_only: {
allowed_actors: { allowed_actors: {
admin: { admin: {
excluded_targets: ["super_admin"], excluded_targets: ["super_admin", "admin"],
}, },
}, },
}, },
revoked: { revoked: {
allowed_actors: { allowed_actors: {
admin: { admin: {
allowed_targets: ["read_only"], excluded_targets: ["super_admin", "admin"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
}, },
}, },
}, },
@@ -60,8 +60,8 @@ describe("checkUserAccess", () => {
test("includes match with OR semantics", () => { test("includes match with OR semantics", () => {
expect( expect(
checkUserAccess({ checkUserAccess({
user_types: makeUserTypes("president"), user_types: makeUserTypes("member"),
includes: ["admin", "president"], includes: ["admin", "member"],
}).success, }).success,
).toBe(true); ).toBe(true);
}); });
@@ -0,0 +1,38 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
};
/**
* Deletes one of the user's worker bots with its conversations, tasks,
* jobs, models and workspace places. A job it's running stops: it loses
* its row. The coordinator can't be deleted.
*/
export default async function deleteBot({ user_id, bot_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
if (bot.kind == "coordinator") {
throw new AppError(`The coordinator can't be deleted.`);
}
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
if (!res.success) {
throw new Error(`Couldn't delete the bot: ${res.error}`);
}
if (bot.image_media_id) {
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
return bot;
}
@@ -17,6 +17,15 @@ type Params = {
* `main` only for the owner's one main computer (`ensureMainComputer`). * `main` only for the owner's one main computer (`ensureMainComputer`).
*/ */
kind?: ComputerKind; kind?: ComputerKind;
/**
* CPU cores its container may use. Defaults to `AppData.ComputerDefaultCpus`.
*/
cpus?: number;
/**
* Its container's memory limit in MB. Defaults to
* `AppData.ComputerDefaultMemoryMb`.
*/
memory_mb?: number;
}; };
/** /**
@@ -24,7 +33,8 @@ type Params = {
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up * (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
* in the background (`provisionComputer`). Returns the computer at once, * in the background (`provisionComputer`). Returns the computer at once,
* still `creating`, and the setup to await when the caller must (a CLI * still `creating`, and the setup to await when the caller must (a CLI
* script). The main computer of a server that reaches it at * script). Its resources (`cpus`, `memory_mb`) are saved on the computer
* and applied to its container whenever it is set up. The main computer of a server that reaches it at
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own. * `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
*/ */
export default async function createComputer({ export default async function createComputer({
@@ -32,6 +42,8 @@ export default async function createComputer({
name, name,
description, description,
kind = "sandbox", kind = "sandbox",
cpus = AppData.ComputerDefaultCpus,
memory_mb = AppData.ComputerDefaultMemoryMb,
}: Params) { }: Params) {
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()); const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
@@ -70,6 +82,8 @@ export default async function createComputer({
name, name,
description: description || undefined, description: description || undefined,
kind, kind,
cpus,
memory_mb,
}, },
], ],
}); });
@@ -3,30 +3,39 @@ import type {
BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_CONTAINERS, BUN_SQLITE_COBBLE_CONTAINERS,
} from "@/db/types/db"; } from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { ComputerMcpPath } from "@/src/dict/computer-dict"; import { ComputerMcpPath, type ContainerStatus } from "@/src/dict/computer-dict";
import grabEnv from "../envs/grab-env"; import grabEnv from "../envs/grab-env";
import pickReachableAddress from "./pick-reachable-address";
import refreshComputerContainer from "./refresh-computer-container";
type Params = { type Params = {
computer_id: number | string; computer_id: number | string;
}; };
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> & type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "kind"> &
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">; Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "hostname" | "ip_address" | "port">;
export type ComputerAddress =
| { ready: true; url: string; base_url: string; name: string }
| { ready: false; status: ContainerStatus; name: string };
/** /**
* Where a computer's MCP server answers. In order: the container's * Where a computer's MCP server answers: `MAIN_COMPUTER_URL` for the
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app * main computer (an app setting, `grabEnv`: for a server outside docker,
* setting, `grabEnv`: for a server outside docker, e.g. * e.g. `http://localhost:8170`), else its docker hostname and port, or
* `http://localhost:8170`), else its docker hostname and port. `base_url` * its IP address on the network when the hostname can't be reached (a
* is the same address without `/mcp`, for the sandbox's REST API. Null * dev server outside docker, `pickReachableAddress`). `base_url` is the
* when the computer has no container. * same address without `/mcp`, for the sandbox's REST API. Only a
* computer whose health check passed (`running`) is reached; any other
* state is read again from docker first, since it may have come back
* since the last sync, and returned as `ready: false`. Null when the
* computer has no container.
*/ */
export default async function grabComputerMcpUrl({ computer_id }: Params) { export default async function grabComputerMcpUrl({ computer_id }: Params): Promise<ComputerAddress | null> {
const row = ( const row = (
await BunSQLite.sql<ComputerRow>({ await BunSQLite.sql<ComputerRow>({
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata sql: `SELECT co.name, co.kind, c.id, c.docker_id, c.status, c.hostname, c.ip_address, c.port
FROM computers co JOIN containers c ON c.id = co.container_id FROM computers co JOIN containers c ON c.id = co.container_id
WHERE co.id = ? LIMIT 1`, WHERE co.id = ? LIMIT 1`,
values: [computer_id], values: [computer_id],
@@ -35,23 +44,37 @@ export default async function grabComputerMcpUrl({ computer_id }: Params) {
if (!row?.hostname) return null; if (!row?.hostname) return null;
const metadata = parseJobJson({ text: row.metadata }) || {}; const name = row.name || "";
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
return {
url: metadata.mcp_url,
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
name: row.name || "",
};
}
const main_override = row.kind == "main" const main_override = row.kind == "main"
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim() ? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
: undefined; : undefined;
const base = main_override if (main_override) {
? main_override.replace(/\/+$/, "") const base_url = main_override.replace(/\/+$/, "");
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`; return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
}
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" }; let status = (row.status || "unknown") as ContainerStatus;
let ip_address: string | null | undefined = row.ip_address;
if (status != "running" && status != "creating" && status != "failed") {
const refreshed = await refreshComputerContainer({ computer_id, container: row });
if (refreshed) ({ status, ip_address } = refreshed);
}
if (status != "running") return { ready: false, status, name };
const port = row.port || AppData.ComputerPort;
const base_url = await pickReachableAddress({
key: `computer:${computer_id}`,
candidates: [
`http://${row.hostname}:${port}`,
...(ip_address ? [`http://${ip_address}:${port}`] : []),
],
});
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
} }
@@ -0,0 +1,26 @@
import { afterAll, describe, expect, test } from "bun:test";
import pickReachableAddress from "./pick-reachable-address";
const server = Bun.serve({ port: 0, fetch: () => new Response("", { status: 404 }) });
const reachable = `http://127.0.0.1:${server.port}`;
const unreachable = "http://cobble-computer-test.invalid:8080";
afterAll(() => server.stop(true));
describe("pickReachableAddress", () => {
test("falls back to the next address when the first can't be reached", async () => {
expect(await pickReachableAddress({ key: "a", candidates: [unreachable, reachable] })).toBe(reachable);
});
test("keeps the first address when it answers, whatever the status", async () => {
expect(await pickReachableAddress({ key: "b", candidates: [reachable, unreachable] })).toBe(reachable);
});
test("returns the first address when none answers", async () => {
expect(await pickReachableAddress({ key: "c", candidates: [unreachable, "http://127.0.0.1:1"] })).toBe(unreachable);
});
test("returns a lone address without trying it", async () => {
expect(await pickReachableAddress({ key: "d", candidates: [unreachable] })).toBe(unreachable);
});
});
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
type Params = {
/**
* Caches the pick, e.g. `computer:<id>`.
*/
key: string;
/**
* Base URLs in the order to try, e.g. the hostname, then the IP address.
*/
candidates: [string, ...string[]];
};
const picked = new Map<string, { base_url: string; at: number }>();
/**
* The first of `candidates` that answers over HTTP (any status counts),
* each given `AppData.ComputerReachTimeoutMs`. The pick is reused for
* `AppData.ComputerAddressCacheMs` while it is still a candidate. When
* none answers, the first, so the caller's own error names it.
*/
export default async function pickReachableAddress({ key, candidates }: Params) {
if (candidates.length < 2) return candidates[0];
const cached = picked.get(key);
if (
cached &&
Date.now() - cached.at < AppData.ComputerAddressCacheMs &&
candidates.includes(cached.base_url)
) {
return cached.base_url;
}
for (const base_url of candidates) {
const reached = await fetch(base_url, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(AppData.ComputerReachTimeoutMs),
})
.then(() => true)
.catch(() => false);
if (reached) {
picked.set(key, { base_url, at: Date.now() });
return base_url;
}
}
picked.delete(key);
return candidates[0];
}
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json"; import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container"; import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
@@ -17,15 +17,18 @@ type Params = {
/** /**
* Sets up a computer's container: prepares its home directory, pulls the * Sets up a computer's container: prepares its home directory, pulls the
* sandbox image and starts the container, then records it (`containers`: * sandbox image and starts the container with the computer's resources
* docker id, state, and where to reach it outside docker). A failure is * (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
* docker id, state, and its IP address on the network). A failure is
* recorded as `failed` with its reason, which the computer lists show. The * recorded as `failed` with its reason, which the computer lists show. The
* owner's tabs hear about it either way. Never throws. * owner's tabs hear about it either way. Never throws.
*/ */
export default async function provisionComputer({ computer_id, fresh }: Params) { export default async function provisionComputer({ computer_id, fresh }: Params) {
const row = ( const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({ await BunSQLite.sql<
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`, BUN_SQLITE_COBBLE_CONTAINERS & Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">
>({
sql: `SELECT co.*, c.cpus, c.memory_mb FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
values: [computer_id], values: [computer_id],
}) })
).payload?.[0]; ).payload?.[0];
@@ -41,26 +44,28 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
}); });
const data_dir = await prepareComputerDataDir({ computer_id, fresh }); const data_dir = await prepareComputerDataDir({ computer_id, fresh });
const created = await createComputerContainer({ computer_id, name: row.name, data_dir }); const created = await createComputerContainer({
computer_id,
name: row.name,
data_dir,
cpus: Number(row.cpus) || AppData.ComputerDefaultCpus,
memory_mb: Number(row.memory_mb) || AppData.ComputerDefaultMemoryMb,
});
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata; const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?, sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, ip_address = ?,
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`, port = ?, status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [ values: [
created.docker_id, created.docker_id,
AppData.ComputerImage, AppData.ComputerImage,
created.network, created.network,
row.name, row.name,
created.ip_address,
AppData.ComputerPort, AppData.ComputerPort,
created.status, created.status,
JSON.stringify({ JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
...kept,
data_dir,
host_dir: created.host_dir,
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
}),
Date.now(), Date.now(),
Date.now(), Date.now(),
row.id, row.id,
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import type { ContainerStatus } from "@/src/dict/computer-dict";
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
import pushComputerUpdate from "./push-computer-update";
type Params = {
computer_id: number | string;
container: Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address">;
};
/**
* Reads a computer container's state from docker and records it
* (`status`, `ip_address`, which can change when it restarts,
* `last_seen_at`), telling the owner when its state changed. A container
* docker no longer has is `dead`. Returns the state recorded, or null
* when docker couldn't be asked.
*/
export default async function refreshComputerContainer({ computer_id, container }: Params) {
if (!container.id || !container.docker_id) return null;
const state = await inspectDockerContainer({ container: container.docker_id }).catch(() => undefined);
if (state === undefined) return null;
const status: ContainerStatus = state?.status || "dead";
const ip_address = state?.ip_address || container.ip_address || null;
await BunSQLite.sql({
sql: `UPDATE containers SET status = ?, ip_address = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [status, ip_address, Date.now(), Date.now(), container.id],
});
const changed = status != container.status;
if (changed) await pushComputerUpdate({ computer_id }).catch(() => {});
return { status, ip_address, changed };
}
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BunSQLiteTables,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import _ from "lodash";
import grabEnv from "../envs/grab-env";
import grabUserComputers from "./grab-user-computers";
import grabUserComputer from "./grab-user-computer";
import provisionComputer from "./provision-computer";
type Params = {
user_id: number | string;
computer_id: number | string;
/**
* Only the fields given are changed.
*/
data: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "description" | "cpus" | "memory_mb">;
};
/**
* Updates one of the user's computers. When its container's settings
* change (`cpus`, `memory_mb`), the container is stopped, removed and
* run again with them in the background (`provisionComputer`); its home
* directory is bind-mounted, so its files stay. A name or description
* alone leaves the container running. The main computer of a server that
* reaches it at `MAIN_COMPUTER_URL` has no container to recreate.
*/
export default async function updateComputer({ user_id, computer_id, data }: Params) {
const computer = await grabUserComputer({ computer_id, user_id });
if (!computer?.id) {
throw new AppError(`Computer not found.`);
}
const recreate =
(data.cpus !== undefined && Number(data.cpus) != Number(computer.cpus)) ||
(data.memory_mb !== undefined && Number(data.memory_mb) != Number(computer.memory_mb));
if (recreate && (await grabUserComputers({ computer_id: computer.id }))[0]?.status == "creating") {
throw new AppError(`${computer.name || "The computer"} is still being set up. Try again once it's ready.`);
}
const res = await BunSQLite.update<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
targetId: computer.id,
/**
* `BunSQLite.update` writes every key it gets, so a field left out
* here would be set to null.
*/
data: _.omitBy(
{
name: data.name,
description: data.description,
cpus: data.cpus,
memory_mb: data.memory_mb,
},
_.isUndefined,
),
});
if (!res.success) {
throw new Error(`Couldn't update the computer: ${res.msg}`);
}
const external =
computer.kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
const recreating = recreate && !external && Boolean(computer.container_id);
const provisioning = recreating
? provisionComputer({ computer_id: Number(computer.id) })
: Promise.resolve();
return { recreating, provisioning };
}
@@ -0,0 +1,98 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
import AppError from "@/src/utils/app-error";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import grabUsers from "./grab-users";
type Params = {
/**
* Who is deleting: never themselves.
*/
actor_user_id: number | string;
/**
* Whoever may revoke a user may delete them (`canRevokeUser`).
*/
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
user_id: number | string;
};
/**
* Deletes a user with everything that's theirs (bots, workspaces,
* conversations, computers, user types, password, media and its files):
* their computers' containers are removed first, their home directories
* stay on disk. Their audit log entries stay too. The super admin can't be
* deleted, and nobody deletes themselves.
*/
export default async function deleteUser({ actor_user_id, actor_user_types, user_id }: Params) {
if (String(actor_user_id) == String(user_id)) {
throw new AppError(`You can't delete yourself.`);
}
const user = (await grabUsers({ user_id })).singleRes;
if (!user?.id) {
throw new AppError(`User not found.`);
}
if (parseUserTypes({ user_types: user.user_types }).includes("super_admin")) {
throw new AppError(`The super admin can't be deleted.`);
}
const target_user_types = parseUserTypes({ user_types: user.user_types }).map((user_type) => ({ user_type }));
if (!canRevokeUser({ user_types: actor_user_types, target_user_types })) {
throw new AppError(`You can't delete ${user.first_name || "this user"}.`);
}
const containers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.user_id = ?`,
values: [user.id],
})
).payload || [];
for (const container of containers) {
if (container.docker_id || container.name) {
await removeDockerContainer({ container: container.docker_id || container.name! });
}
}
const media_ids =
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM media WHERE user_id = ?`,
values: [user.id],
})
).payload?.map((media) => media.id) || [];
if (media_ids.length) {
await deleteMedia({ ids: media_ids, user_id: user.id });
}
const res = await BunSQLite.sql({ sql: `DELETE FROM users WHERE id = ?`, values: [user.id] });
if (!res.success) {
throw new Error(`Couldn't delete the user: ${res.error}`);
}
/**
* These point at the user without a foreign key, so nothing cascades.
*/
for (const table of ["user_types", "user_passwords", "sso_login_codes"]) {
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}
if (containers.length) {
await BunSQLite.sql({
sql: `DELETE FROM containers WHERE id IN (${containers.map(() => "?").join(", ")})`,
values: containers.map((container) => container.id!),
});
}
return user;
}
+10 -9
View File
@@ -29,21 +29,22 @@ export default async function grabUsers(
sql += `SELECT\n`; sql += `SELECT\n`;
sql += ` u.*,\n`; sql += ` u.*,\n`;
sql += ` ut_agg.user_types,\n`; sql += ` ut_agg.user_types,\n`;
sql += ` m.id AS profile_media_id\n`; /**
* Their profile photo only: other primary media (a bot's picture)
* must not stand in for it.
*/
sql += ` (\n`;
sql += ` SELECT m.id FROM media m\n`;
sql += ` JOIN media_paradigms mp ON mp.media_id = m.id AND mp.media_paradigm = 'user-profile-image'\n`;
sql += ` WHERE m.user_id = u.id AND m.is_primary = 1\n`;
sql += ` ORDER BY m.id DESC LIMIT 1\n`;
sql += ` ) AS profile_media_id\n`;
sql += `FROM users u\n`; sql += `FROM users u\n`;
sql += `LEFT JOIN (\n`; sql += `LEFT JOIN (\n`;
sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`; sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`;
sql += ` FROM user_types\n`; sql += ` FROM user_types\n`;
sql += ` GROUP BY user_id\n`; sql += ` GROUP BY user_id\n`;
sql += `) ut_agg ON u.id = ut_agg.user_id\n`; sql += `) ut_agg ON u.id = ut_agg.user_id\n`;
sql += `LEFT JOIN media m ON (\n`;
sql += ` u.id = m.user_id\n`;
sql += ` AND m.is_primary = 1\n`;
sql += `)\n`;
sql += `LEFT JOIN media_paradigms mp ON (\n`;
sql += ` m.id = mp.media_id\n`;
sql += ` AND mp.media_paradigm = 'user-profile-image'\n`;
sql += `)\n`;
sql += `WHERE COALESCE(u.archived, 0) <> 1\n`; sql += `WHERE COALESCE(u.archived, 0) <> 1\n`;
const target_user_id = params?.user_id || query?.user_id; const target_user_id = params?.user_id || query?.user_id;
+140
View File
@@ -0,0 +1,140 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_USERS,
BUN_SQLITE_COBBLE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
import type { SaveUserRequest } from "@/src/schemas/user-schema";
import AppError from "@/src/utils/app-error";
import parseUserTypes from "@/src/utils/parse-user-types";
import userTypeAssignment from "@/src/utils/rbac/user-type-assignment";
import validatePassword from "@/src/utils/validate-password";
import grabUsers from "./grab-users";
type Params = SaveUserRequest & {
/**
* The user types of whoever is saving: what they may grant or remove
* is the assignment matrix's call (`userTypeAssignment`).
*/
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
};
/**
* Creates a user (a password is required: login is password only) or
* updates one, then sets their user types to `user_types`: each one
* granted or removed must pass the assignment matrix, and uneditable ones
* (`super_admin`) stay as they are. The super admin's types are never
* changed here, and they can't be revoked. A user needs at least one type to sign
* in. Email and username stay unique. Returns the user's id and whether
* they're new.
*/
export default async function saveUser({
actor_user_types,
user_id,
first_name,
last_name,
email,
username,
password,
user_types,
}: Params) {
email = email.trim().toLowerCase();
username = username.trim().toLowerCase();
if (!AppData["UsernameRegex"].test(username)) {
throw new AppError(`Usernames must be 3-32 characters: lowercase letters, numbers, dots, dashes or underscores`);
}
if (password || !user_id) {
const password_error = validatePassword({ password });
if (password_error) throw new AppError(password_error);
}
const existing = user_id ? (await grabUsers({ user_id })).singleRes : null;
if (user_id && !existing?.id) {
throw new AppError(`User not found.`);
}
const current = parseUserTypes({ user_types: existing?.user_types });
const is_super_admin = current.includes("super_admin");
if (is_super_admin && user_types.includes("revoked")) {
throw new AppError(`The super admin can't be revoked.`);
}
const kept = is_super_admin ? current : current.filter((type) => UneditableUserTypes.includes(type));
const wanted = is_super_admin
? []
: [...new Set(user_types)].filter((type) => !UneditableUserTypes.includes(type));
const added = wanted.filter((type) => !current.includes(type));
const removed = current.filter((type) => !kept.includes(type) && !wanted.includes(type));
if (!kept.length && !wanted.length) {
throw new AppError(`Give them at least one user type, or they can't sign in.`);
}
for (const [types, action] of [[added, "assign"], [removed, "delete"]] as const) {
for (const target_user_type of types) {
if (!userTypeAssignment({ actor_user_types, target_user_type, target_user_types: current, action })) {
const title = UserTypes.find((type) => type.value == target_user_type)?.title || target_user_type;
throw new AppError(`You can't ${action == "assign" ? "grant" : "remove"} ${title}.`);
}
}
}
const taken = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_USERS>({
sql: `SELECT id, email, username FROM users WHERE (email = ? OR username = ?) AND id != ? LIMIT 1`,
values: [email, username, user_id || 0],
})
).payload?.[0];
if (taken) {
throw new AppError(taken.email == email ? `That email is already in use.` : `That username is taken.`);
}
let target_id = Number(user_id || 0);
if (existing?.id) {
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
table: "users",
targetId: existing.id,
data: { first_name, last_name, email, username },
});
if (!res.success) throw new Error(`Couldn't update the user: ${res.msg}`);
} else {
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
table: "users",
data: [{ first_name, last_name, email, username }],
});
target_id = Number(res.singleRes?.id || res.postInsertReturn?.insertId || 0);
if (!res.success || !target_id) throw new Error(`Couldn't create the user: ${res.msg}`);
}
if (password) {
await setUserPassword({ user_id: target_id, password });
}
if (removed.length) {
await BunSQLite.sql({
sql: `DELETE FROM user_types WHERE user_id = ? AND user_type IN (${removed.map(() => "?").join(", ")})`,
values: [target_id, ...removed],
});
}
if (added.length) {
await BunSQLite.insert<BUN_SQLITE_COBBLE_USER_TYPES, (typeof BunSQLiteTables)[number]>({
table: "user_types",
data: added.map((user_type) => ({ user_id: target_id, user_type })),
});
}
return { user_id: target_id, created: !existing?.id };
}
@@ -1,13 +1,16 @@
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { ComputerMcpPath } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request"; import dockerRequest from "./docker-request";
import ensureDockerNetwork from "./ensure-docker-network"; import ensureDockerNetwork from "./ensure-docker-network";
import grabAppContainer from "./grab-app-container";
import grabFreePort from "./grab-free-port";
import inspectDockerContainer from "./inspect-docker-container";
import pullDockerImage from "./pull-docker-image"; import pullDockerImage from "./pull-docker-image";
import removeDockerContainer from "./remove-docker-container"; import removeDockerContainer from "./remove-docker-container";
import resolveHostPath from "./resolve-host-path"; import resolveHostPath from "./resolve-host-path";
import waitForHealthyContainer from "./wait-for-healthy-container";
/**
* Docker takes health check durations in nanoseconds.
*/
const seconds = (value: number) => value * 1e9;
type Params = { type Params = {
computer_id: number; computer_id: number;
@@ -19,22 +22,36 @@ type Params = {
* The computer's home directory as this app sees it. * The computer's home directory as this app sees it.
*/ */
data_dir: string; data_dir: string;
/**
* CPU cores the container may use.
*/
cpus: number;
/**
* The container's memory limit in MB.
*/
memory_mb: number;
}; };
/** /**
* Creates and starts an AIO sandbox container for a computer * Creates and starts (the API's `docker run`) an AIO sandbox container
* (`AppData.ComputerImage`) on the computers' network, with its home * for a computer (`AppData.ComputerImage`), attached to the computers'
* directory bind-mounted from `data_dir` and restarted with docker. A * network (created first when missing), with its home directory
* container left by an earlier try is replaced. When the app runs outside * bind-mounted from `data_dir`, limited to `cpus` and `memory_mb`, with a
* docker, the sandbox is also published on a localhost port, returned as * health check (`AppData.ComputerHealthcheck`), and restarted with
* `mcp_url`'s host. * docker. A container left by an earlier try is stopped and replaced.
* Resolves once the health check passes, with its IP address on the network, which a dev
* server outside docker reaches it at, since it can't resolve the
* hostname.
*/ */
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) { export default async function createComputerContainer({
computer_id,
name,
data_dir,
cpus,
memory_mb,
}: Params) {
const network = await ensureDockerNetwork(); const network = await ensureDockerNetwork();
const in_docker = Boolean(await grabAppContainer());
const host_dir = await resolveHostPath({ local_path: data_dir }); const host_dir = await resolveHostPath({ local_path: data_dir });
const port_key = `${AppData.ComputerPort}/tcp`;
const host_port = in_docker ? undefined : grabFreePort();
await pullDockerImage({ image: AppData.ComputerImage }); await pullDockerImage({ image: AppData.ComputerImage });
await removeDockerContainer({ container: name }); await removeDockerContainer({ container: name });
@@ -49,18 +66,25 @@ export default async function createComputerContainer({ computer_id, name, data_
Labels: { Labels: {
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id), [`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
}, },
ExposedPorts: { [port_key]: {} }, ExposedPorts: { [`${AppData.ComputerPort}/tcp`]: {} },
Healthcheck: {
Test: [
"CMD-SHELL",
`curl -fs -o /dev/null http://localhost:${AppData.ComputerPort}/v1/sandbox && ([ "$DISABLE_BROWSER" = "true" ] || nc -z localhost "$BROWSER_REMOTE_DEBUGGING_PORT") || exit 1`,
],
Interval: seconds(AppData.ComputerHealthcheck.IntervalSeconds),
Timeout: seconds(AppData.ComputerHealthcheck.TimeoutSeconds),
StartPeriod: seconds(AppData.ComputerHealthcheck.StartPeriodSeconds),
Retries: AppData.ComputerHealthcheck.Retries,
},
HostConfig: { HostConfig: {
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`], Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
NetworkMode: network, NetworkMode: network,
RestartPolicy: { Name: "unless-stopped" }, RestartPolicy: { Name: "unless-stopped" },
SecurityOpt: ["seccomp=unconfined"], SecurityOpt: ["seccomp=unconfined"],
ShmSize: AppData.ComputerShmBytes, ShmSize: AppData.ComputerShmBytes,
Memory: AppData.ComputerMemoryBytes, Memory: memory_mb * 1024 * 1024,
NanoCpus: AppData.ComputerCpus * 1e9, NanoCpus: Math.round(cpus * 1e9),
PortBindings: host_port
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
: undefined,
}, },
NetworkingConfig: { NetworkingConfig: {
EndpointsConfig: { [network]: { Aliases: [name] } }, EndpointsConfig: { [network]: { Aliases: [name] } },
@@ -81,13 +105,13 @@ export default async function createComputerContainer({ computer_id, name, data_
throw new Error(`Couldn't start the container: ${started.message}`); throw new Error(`Couldn't start the container: ${started.message}`);
} }
const state = await inspectDockerContainer({ container: created.data.Id }); const state = await waitForHealthyContainer({ container: created.data.Id });
return { return {
docker_id: created.data.Id, docker_id: created.data.Id,
network, network,
status: state?.status || "unknown", status: state.status,
host_dir, host_dir,
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined, ip_address: state.ip_address,
}; };
} }
@@ -1,16 +0,0 @@
/**
* A port nothing on this host listens on right now, for publishing a
* computer when the app runs outside docker and can't reach it by name.
*/
export default function grabFreePort() {
const listener = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: { data() {} },
});
const port = listener.port;
listener.stop(true);
return port;
}
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import type { ContainerStatus } from "@/src/dict/computer-dict"; import type { ContainerStatus } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request"; import dockerRequest from "./docker-request";
@@ -10,11 +11,19 @@ type Params = {
export type DockerContainerState = { export type DockerContainerState = {
id: string; id: string;
/**
* `State.Status`, with `running` narrowed by the health check
* (`starting`, `unhealthy`). A container without one is `running`.
*/
status: ContainerStatus; status: ContainerStatus;
/** /**
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`. * Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
*/ */
ports: Record<string, string>; ports: Record<string, string>;
/**
* Its IP address on `AppData.DockerNetworkName`, when it's on it and running.
*/
ip_address: string | null;
}; };
/** /**
@@ -25,8 +34,14 @@ export default async function inspectDockerContainer({
}: Params): Promise<DockerContainerState | null> { }: Params): Promise<DockerContainerState | null> {
const res = await dockerRequest<{ const res = await dockerRequest<{
Id: string; Id: string;
State?: { Status?: ContainerStatus }; State?: {
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> }; Status?: ContainerStatus;
Health?: { Status?: "starting" | "healthy" | "unhealthy" | "none" };
};
NetworkSettings?: {
Ports?: Record<string, { HostPort?: string }[] | null>;
Networks?: Record<string, { IPAddress?: string }>;
};
}>({ path: `/containers/${encodeURIComponent(container)}/json` }); }>({ path: `/containers/${encodeURIComponent(container)}/json` });
if (res.status == 404) return null; if (res.status == 404) return null;
@@ -35,13 +50,20 @@ export default async function inspectDockerContainer({
throw new Error(`Couldn't inspect ${container}: ${res.message}`); throw new Error(`Couldn't inspect ${container}: ${res.message}`);
} }
const status = res.data.State?.Status || "unknown";
const health = res.data.State?.Health?.Status;
return { return {
id: res.data.Id, id: res.data.Id,
status: res.data.State?.Status || "unknown", status:
status == "running" && (health == "starting" || health == "unhealthy")
? health
: status,
ports: Object.fromEntries( ports: Object.fromEntries(
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) => Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [], bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
), ),
), ),
ip_address: res.data.NetworkSettings?.Networks?.[AppData.DockerNetworkName]?.IPAddress || null,
}; };
} }
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import dockerRequest from "./docker-request"; import dockerRequest from "./docker-request";
type Params = { type Params = {
@@ -8,10 +9,17 @@ type Params = {
}; };
/** /**
* Stops and removes a container. Its bind-mounted files stay on the host. * Stops a container (killed after `AppData.ComputerStopTimeoutSeconds`),
* A container docker doesn't have is already gone. * then removes it. Its bind-mounted files stay on the host. A container
* docker doesn't have is already gone.
*/ */
export default async function removeDockerContainer({ container }: Params) { export default async function removeDockerContainer({ container }: Params) {
await dockerRequest({
method: "POST",
path: `/containers/${encodeURIComponent(container)}/stop`,
query: { t: AppData.ComputerStopTimeoutSeconds },
});
const res = await dockerRequest({ const res = await dockerRequest({
method: "DELETE", method: "DELETE",
path: `/containers/${encodeURIComponent(container)}`, path: `/containers/${encodeURIComponent(container)}`,
@@ -0,0 +1,37 @@
import { AppData } from "@/src/data/app-data";
import inspectDockerContainer from "./inspect-docker-container";
type Params = {
/**
* The container's id or name.
*/
container: string;
};
/**
* Waits until a container's health check passes (`running`), checking
* every `AppData.ComputerHealthPollMs` for at most
* `AppData.ComputerHealthyTimeoutMs`, and returns its state. Throws when
* it turns unhealthy, stops, or takes too long.
*/
export default async function waitForHealthyContainer({ container }: Params) {
const deadline = Date.now() + AppData.ComputerHealthyTimeoutMs;
while (true) {
const state = await inspectDockerContainer({ container });
if (state?.status == "running") return state;
if (!state || !["starting", "created", "restarting"].includes(state.status)) {
throw new Error(`The container is ${state?.status || "gone"} instead of passing its health check.`);
}
if (Date.now() > deadline) {
throw new Error(
`The container didn't pass its health check within ${Math.round(AppData.ComputerHealthyTimeoutMs / 60000)} minutes.`,
);
}
await Bun.sleep(AppData.ComputerHealthPollMs);
}
}
@@ -1,6 +1,7 @@
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url"; import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer"; import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer";
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import parseComputerProxyPath from "@/src/utils/parse-computer-proxy-path"; import parseComputerProxyPath from "@/src/utils/parse-computer-proxy-path";
type Params = { type Params = {
@@ -10,8 +11,8 @@ type Params = {
/** /**
* Where a `/computers/<id>/...` request goes: the computer's own address * Where a `/computers/<id>/...` request goes: the computer's own address
* for that path, only for the computer's owner. An error response when * for that path, only for the computer's owner. An error response when
* the user isn't logged in, the computer isn't theirs, or it has no * the user isn't logged in, the computer isn't theirs, it has no
* container. * container, or it isn't ready.
*/ */
export default async function computerProxyTarget({ req }: Params) { export default async function computerProxyTarget({ req }: Params) {
const url = new URL(req.url); const url = new URL(req.url);
@@ -36,6 +37,10 @@ export default async function computerProxyTarget({ req }: Params) {
return { error: new Response("This computer has no container.", { status: 502 }) }; return { error: new Response("This computer has no container.", { status: 502 }) };
} }
if (!address.ready) {
return { error: new Response(computerNotReadyMessage(address), { status: 503 }) };
}
return { return {
session, session,
url, url,
@@ -1,6 +1,7 @@
import { ComputerMcpExcludedTools } from "@/src/dict/computer-dict"; import { ComputerMcpExcludedTools } from "@/src/dict/computer-dict";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url"; import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs"; import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import redactSecrets from "@/src/utils/redact-secrets"; import redactSecrets from "@/src/utils/redact-secrets";
import connectMcpServer from "../../mcp/connect-mcp-server"; import connectMcpServer from "../../mcp/connect-mcp-server";
import grabMcpTools from "../../mcp/grab-mcp-tools"; import grabMcpTools from "../../mcp/grab-mcp-tools";
@@ -58,6 +59,10 @@ export default async function openComputerTools({
throw new Error(`Computer #${computer_id} has no container to connect to.`); throw new Error(`Computer #${computer_id} has no container to connect to.`);
} }
if (!target.ready) {
throw new Error(computerNotReadyMessage(target));
}
const envs = await grabComputerEnvs({ computer_id, workspace_id }); const envs = await grabComputerEnvs({ computer_id, workspace_id });
const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal }); const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal });
const secrets = envs.filter((env) => env.secret); const secrets = envs.filter((env) => env.secret);
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url"; import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs"; import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerRef from "@/src/utils/computer-ref"; import computerRef from "@/src/utils/computer-ref";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import redactSecrets from "@/src/utils/redact-secrets"; import redactSecrets from "@/src/utils/redact-secrets";
import type { HarnessTool } from "../../types"; import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command"; import runComputerCommand from "./run-computer-command";
@@ -49,6 +50,8 @@ export default function searchComputersTool({
if (!target) return `${label}\nNot reachable: it has no container.`; if (!target) return `${label}\nNot reachable: it has no container.`;
if (!target.ready) return `${label}\nNot reachable: ${computerNotReadyMessage(target)}`;
const [result, envs] = await Promise.all([ const [result, envs] = await Promise.all([
runComputerCommand({ runComputerCommand({
base_url: target.base_url, base_url: target.base_url,
@@ -4,19 +4,30 @@ import type { ComputerWithStatus } from "@/src/types";
type Params = { type Params = {
computers: ComputerWithStatus[]; computers: ComputerWithStatus[];
/**
* Also add computers the list doesn't have yet (one just created), for
* a list of every computer the user has.
*/
include_new?: boolean;
}; };
/** /**
* A list of computers kept current from `server:computer-updated` (setup * A list of computers kept current from `server:computer-updated` (setup
* finishing or failing, a container stopping). * finishing or failing, a container stopping).
*/ */
export default function useLiveComputers({ computers: initial }: Params) { export default function useLiveComputers({ computers: initial, include_new }: Params) {
const [computers, setComputers] = useState(initial); const [computers, setComputers] = useState(initial);
useServerEvent({ useServerEvent({
event: "server:computer-updated", event: "server:computer-updated",
onData: (computer: ComputerWithStatus) => onData: (computer: ComputerWithStatus) =>
setComputers((prev) => prev.map((c) => (c.id == computer.id ? { ...c, ...computer } : c))), setComputers((prev) =>
prev.some((c) => c.id == computer.id)
? prev.map((c) => (c.id == computer.id ? { ...c, ...computer } : c))
: include_new
? [...prev, computer]
: prev,
),
}); });
return { computers, setComputers }; return { computers, setComputers };
@@ -2,30 +2,46 @@ import { useContext, useState } from "react";
import Form from "@moduletrace/twui/form/Form"; import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input"; import Input from "@moduletrace/twui/form/Input";
import Checkbox from "@moduletrace/twui/form/Checkbox"; import Checkbox from "@moduletrace/twui/form/Checkbox";
import Row from "@moduletrace/twui/layout/Row";
import { AppData } from "@/src/data/app-data";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import type { ComputerWithStatus } from "@/src/types"; import type { ComputerWithStatus } from "@/src/types";
import postAdminApi from "../(functions)/post-admin-api"; import postAdminApi from "../(functions)/post-admin-api";
import AddFormActions from "./add-form-actions"; import AddFormActions from "./add-form-actions";
type Props = { type Props = {
/**
* Edits this computer; creates one when not given.
*/
computer?: ComputerWithStatus;
onCancel: () => void; onCancel: () => void;
/** /**
* Gets the new computer (still being set up) and the server's note. * Gets the new computer (still being set up) and the server's note.
*/ */
onCreated: (params: { computer: ComputerWithStatus; msg: string; linked: boolean }) => void; onCreated?: (params: { computer: ComputerWithStatus; msg: string; linked: boolean }) => void;
/**
* Gets the saved computer and the server's note.
*/
onUpdated?: (params: { computer: ComputerWithStatus; msg: string }) => void;
}; };
/** /**
* Creates a computer: a new sandbox container on this host, its home * Creates a computer (a new sandbox container on this host, its home
* directory kept on disk. On a workspace's pages it's linked to that * directory kept on disk) or edits one: its name, what it's for, and the
* workspace too (unless unticked). * CPUs and memory its container gets. On a workspace's pages a new one is
* linked to that workspace too (unless unticked). New resources restart
* the computer; its files stay.
*/ */
export default function AddComputerForm({ onCancel, onCreated }: Props) { export default function AddComputerForm({ computer, onCancel, onCreated, onUpdated }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace; const workspace = computer ? null : pageProps?.workspace;
const [name, setName] = useState(""); const [name, setName] = useState(computer?.name || "");
const [description, setDescription] = useState(""); const [description, setDescription] = useState(computer?.description || "");
const [cpus, setCpus] = useState(String(Number(computer?.cpus) || AppData.ComputerDefaultCpus));
const [memory_gb, setMemoryGb] = useState(
String((Number(computer?.memory_mb) || AppData.ComputerDefaultMemoryMb) / 1024),
);
const [link, setLink] = useState(Boolean(workspace?.id)); const [link, setLink] = useState(Boolean(workspace?.id));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -36,20 +52,35 @@ export default function AddComputerForm({ onCancel, onCreated }: Props) {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
const resources = {
cpus: Number(cpus),
memory_mb: Math.round(Number(memory_gb) * 1024),
};
const workspace_id = link && workspace?.id ? workspace.id : null; const workspace_id = link && workspace?.id ? workspace.id : null;
const res = await postAdminApi<ComputerWithStatus>({ const res = computer?.id
? await postAdminApi<ComputerWithStatus>({
path: "computers/update-computer",
body: { computer_id: computer.id, name, description, ...resources },
})
: await postAdminApi<ComputerWithStatus>({
path: "computers/create-computer", path: "computers/create-computer",
body: { name, description, workspace_id }, body: { name, description, workspace_id, ...resources },
}); });
setLoading(false); setLoading(false);
if (!res.success || !res.singleRes?.id) { if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't create the computer."); setError(res.msg || "Couldn't save the computer.");
return; return;
} }
onCreated({ computer: res.singleRes, msg: res.msg || "Setting it up.", linked: Boolean(workspace_id) }); if (computer?.id) {
onUpdated?.({ computer: res.singleRes, msg: res.msg || "Saved." });
return;
}
onCreated?.({ computer: res.singleRes, msg: res.msg || "Setting it up.", linked: Boolean(workspace_id) });
}; };
return ( return (
@@ -75,6 +106,33 @@ export default function AddComputerForm({ onCancel, onCreated }: Props) {
info="Bots see this when they pick a computer." info="Bots see this when they pick a computer."
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
<Row className="w-full gap-4! flex-nowrap items-start">
<Input
id="add-computer-cpus"
title="CPUs"
label="CPUs"
showLabel
type="number"
min={AppData.ComputerMinCpus}
max={AppData.ComputerMaxCpus}
step={0.25}
defaultValue={cpus}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setCpus(e.target.value)}
/>
<Input
id="add-computer-memory"
title="Memory (GB)"
label="Memory (GB)"
showLabel
type="number"
min={AppData.ComputerMinMemoryMb / 1024}
step={0.5}
defaultValue={memory_gb}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setMemoryGb(e.target.value)}
/>
</Row>
{workspace?.id ? ( {workspace?.id ? (
<Checkbox <Checkbox
title={`Add it to ${workspace.name}`} title={`Add it to ${workspace.name}`}
@@ -83,7 +141,12 @@ export default function AddComputerForm({ onCancel, onCreated }: Props) {
changeHandler={setLink} changeHandler={setLink}
/> />
) : null} ) : null}
<AddFormActions submit_label="Create computer" loading={loading} error={error} onCancel={onCancel} /> <AddFormActions
submit_label={computer?.id ? "Save" : "Create computer"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form> </Form>
); );
} }
+7 -1
View File
@@ -1,6 +1,7 @@
import { useContext, useRef, useState, type ReactNode } from "react"; import { useContext, useRef, useState, type ReactNode } from "react";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import { SettingsComputersPath } from "@/src/dict/settings-pages-dict";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { AddFlowContext } from "../(data)/add-flow-context"; import { AddFlowContext } from "../(data)/add-flow-context";
import { AddChoices, type AddKind } from "../(dict)/add-choices-dict"; import { AddChoices, type AddKind } from "../(dict)/add-choices-dict";
@@ -118,7 +119,12 @@ export default function AddFlow({ children }: Props) {
<AddComputerForm <AddComputerForm
onCancel={close} onCancel={close}
onCreated={({ msg, linked }) => { onCreated={({ msg, linked }) => {
if (linked && pageProps?.url?.pathname == `/admin/workspaces/${workspace?.id}`) { const pathname = pageProps?.url?.pathname;
if (
(linked && pathname == `/admin/workspaces/${workspace?.id}`) ||
pathname == SettingsComputersPath
) {
window.location.reload(); window.location.reload();
return; return;
} }
@@ -2,6 +2,7 @@ import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict"; import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import { SiteData } from "@/src/data/site-data";
type Props = { type Props = {
title?: string; title?: string;
@@ -12,7 +13,10 @@ type Props = {
* Top of the rail on settings and workspace pages, returning to the * Top of the rail on settings and workspace pages, returning to the
* conversations. Same 48px row as a bot. * conversations. Same 48px row as a bot.
*/ */
export default function RailBackLink({ title = "Back to bots", href = AdminHomePath }: Props) { export default function RailBackLink({
title = `Back to ${SiteData["SiteSlug"]}`,
href = AdminHomePath,
}: Props) {
return ( return (
<Link <Link
href={href} href={href}
@@ -22,7 +26,9 @@ export default function RailBackLink({ title = "Back to bots", href = AdminHomeP
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0"> <Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<ArrowLeft size={18} aria-hidden /> <ArrowLeft size={18} aria-hidden />
</Span> </Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">{title}</Span> <Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
{title}
</Span>
</Link> </Link>
); );
} }
@@ -12,7 +12,8 @@ type Props = {
/** /**
* Settings mode of the rail: back to the bots, then the settings sub pages * Settings mode of the rail: back to the bots, then the settings sub pages
* (super admin pages only for super admins). * (super admin pages only for super admins, RBAC-gated pages by their
* own rule).
*/ */
export default function RailSettingsList({ pathname }: Props) { export default function RailSettingsList({ pathname }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
@@ -25,8 +26,10 @@ export default function RailSettingsList({ pathname }: Props) {
<> <>
<RailBackLink /> <RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{SettingsPages.filter( {SettingsPages.filter((page) =>
(page) => is_super_admin || !page.super_admin_only, page.can_view
? page.can_view({ user_types: pageProps?.user_types })
: is_super_admin || !page.super_admin_only,
).map((page) => ( ).map((page) => (
<RailSettingsLink <RailSettingsLink
key={page.url} key={page.url}
@@ -23,7 +23,8 @@ type Props = {
*/ */
export default function ComputerViewerModal({ bot }: Props) { export default function ComputerViewerModal({ bot }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const { computers, viewing, closeComputer } = useContext(BotComputersContext); const { computers, viewing, closeComputer } =
useContext(BotComputersContext);
const [handing_back, setHandingBack] = useState(false); const [handing_back, setHandingBack] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -57,7 +58,7 @@ export default function ComputerViewerModal({ bot }: Props) {
setOpen={(open) => { setOpen={(open) => {
if (!open) closeComputer(); if (!open) closeComputer();
}} }}
className="max-w-[1280px] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden" className="max-w-[95vw]! h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
> >
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline"> <Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 gap-0! items-start"> <Stack className="min-w-0 gap-0! items-start">
@@ -82,7 +83,10 @@ export default function ComputerViewerModal({ bot }: Props) {
{`Hand back to ${bot_name}`} {`Hand back to ${bot_name}`}
</Button> </Button>
{error ? ( {error ? (
<Span role="alert" className="text-[13px] leading-[18px] text-ink-secondary"> <Span
role="alert"
className="text-[13px] leading-[18px] text-ink-secondary"
>
{error} {error}
</Span> </Span>
) : null} ) : null}
@@ -13,6 +13,7 @@ import { AppContext } from "@/src/pages/__root";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user"; import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import { useContext } from "react"; import { useContext } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import UserAvatar from "@/src/components/general/user-avatar";
type Props = { type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN; person: BUN_SQLITE_COBBLE_USERS_JOIN;
@@ -54,6 +55,7 @@ export default function PeopleListCard({ person }: Props) {
> >
<Row className="w-full justify-between"> <Row className="w-full justify-between">
<Row> <Row>
<UserAvatar person={person} size="md" />
<Img <Img
alt={`${person_name}`} alt={`${person_name}`}
src={ src={
@@ -77,10 +79,7 @@ export default function PeopleListCard({ person }: Props) {
size={14} size={14}
className="opacity-50" className="opacity-50"
/> />
<Span <Span variant="faded" className="text-xs">
variant="faded"
className="text-xs"
>
{date_created.toString()} {date_created.toString()}
</Span> </Span>
</Row> </Row>
@@ -0,0 +1,39 @@
import { useState } from "react";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
type Status = { error?: boolean; success?: boolean; msg?: string };
type Params<T extends { id?: number | string | "" }> = {
items: T[];
};
/**
* A settings list's rows with its busy state and one status line, and
* `remove`: confirm, post to an admin API route, drop the row.
*/
export default function useSettingsList<T extends { id?: number | string | "" }>({ items: initial }: Params<T>) {
const [items, setItems] = useState(initial);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const remove = async ({ id, path, body, confirm }: { id: T["id"]; path: string; body: Record<string, unknown>; confirm: string }) => {
if (busy || !window.confirm(confirm)) return;
setBusy(true);
setStatus(undefined);
const res = await postAdminApi({ path, body });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
setItems((prev) => prev.filter((item) => item.id != id));
setStatus({ success: true, msg: res.msg || "Deleted." });
};
return { items, setItems, busy, status, setStatus, remove };
}
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
avatar: ReactNode;
title: string;
/**
* A word beside the title, e.g. "Default" or "You".
*/
badge?: string;
subtitle?: string;
/**
* Its actions, at the end of the row.
*/
children?: ReactNode;
};
/**
* One row of a settings list: an avatar, a title and one line about it,
* then what can be done with it.
*/
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
{avatar}
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
) : null}
</Stack>
{children ? <Row className="shrink-0 gap-1! flex-nowrap">{children}</Row> : null}
</Row>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
children: ReactNode;
/**
* Shown instead when the list has no rows.
*/
empty?: string;
is_empty?: boolean;
};
/**
* A framed list of rows inside a settings group.
*/
export default function SettingsList({ children, empty, is_empty }: Props) {
return (
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
{children}
{is_empty && empty ? (
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">{empty}</Span>
) : null}
</Stack>
);
}
@@ -3,14 +3,18 @@ import Button from "@moduletrace/twui/layout/Button";
type Props = { type Props = {
title: string; title: string;
label: string; label: string;
/**
* A link instead of a button.
*/
href?: string;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick?: () => void;
}; };
/** /**
* One small text action on a workspace computer row. * One small text action on a list row (Edit, Open, Delete).
*/ */
export default function WorkspaceComputerAction({ title, label, disabled, onClick }: Props) { export default function SettingsRowAction({ title, label, href, disabled, onClick }: Props) {
return ( return (
<Button <Button
title={title} title={title}
@@ -18,6 +22,7 @@ export default function WorkspaceComputerAction({ title, label, disabled, onClic
variant="ghost" variant="ghost"
color="gray" color="gray"
size="small" size="small"
href={href}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className="px-2! text-[13px]" className="px-2! text-[13px]"
@@ -0,0 +1,32 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
/**
* Open while set.
*/
bot?: BotWithLastMessage;
onClose: () => void;
};
/**
* Edits a bot in a modal, with the same form as adding one (and as the
* chat header's avatar).
*/
export default function BotEditModal({ bot, onClose }: Props) {
return (
<AddModalShell
open={Boolean(bot)}
setOpen={(open) => !open && onClose()}
title={`Edit ${bot?.name || "the bot"}`}
description={
bot?.kind == "coordinator"
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
: "Its core instructions apply everywhere. What it does in one workspace is set on that workspace's page."
}
>
{bot ? <BotForm key={bot.id} bot={bot} onCancel={onClose} /> : null}
</AddModalShell>
);
}
@@ -0,0 +1,44 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
bot: BotWithLastMessage;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One bot: its stone or picture, name and job, then Chat, Edit, and for a
* worker its Models and Delete.
*/
export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
const name = bot.name || `Bot ${bot.id}`;
const is_coordinator = bot.kind == "coordinator";
return (
<SettingsListRow
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
}
title={name}
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
>
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy} onClick={onEdit} />
{is_coordinator ? null : (
<>
<SettingsRowAction title={`${name}'s models`} label="Models" href={`/admin/bots/${bot.id}/models`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</>
)}
</SettingsListRow>
);
}
@@ -0,0 +1,63 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import type { BotWithLastMessage } from "@/src/types";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import BotEditModal from "../(partials)/bot-edit-modal";
import BotSettingsRow from "../(partials)/bot-settings-row";
/**
* The user's crew, coordinator first: chat with a bot, edit it, set its
* models, add one or delete a worker.
*/
export default function BotsSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
const [editing, setEditing] = useState<BotWithLastMessage>();
return (
<SettingsGroup
title="Your crew"
description="The coordinator stays. Deleting a bot removes its conversations, tasks and models."
>
<SettingsList is_empty={!items.length} empty="No bots yet.">
{items.map((bot) => (
<BotSettingsRow
key={bot.id}
bot={bot}
busy={busy}
onEdit={() => setEditing(bot)}
onDelete={() =>
remove({
id: bot.id,
path: "bots/delete-bot",
body: { bot_id: bot.id },
confirm: `Delete ${bot.name || "this bot"}? Its conversations, tasks and models go with it.`,
})
}
/>
))}
</SettingsList>
<Button
title="Add a bot"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "bot" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New bot
</Button>
<SettingsStatus status={status} />
<BotEditModal bot={editing} onClose={() => setEditing(undefined)} />
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
bots: await grabBots({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import BotsSettingsSection from "./(sections)/bots-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsBotsPage() {
return (
<SettingsShell title="Bots" description="Your crew: the coordinator and the bots it hands work to.">
<BotsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Bots" });
@@ -0,0 +1,30 @@
import AddComputerForm from "@/src/layouts/bots/(partials)/add-computer-form";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { ComputerWithStatus } from "@/src/types";
type Props = {
/**
* Open while set.
*/
computer?: ComputerWithStatus;
onClose: () => void;
onUpdated: (params: { computer: ComputerWithStatus; msg: string }) => void;
};
/**
* Edits a computer in a modal, with the same form as creating one.
*/
export default function ComputerEditModal({ computer, onClose, onUpdated }: Props) {
return (
<AddModalShell
open={Boolean(computer)}
setOpen={(open) => !open && onClose()}
title={`Edit ${computer?.name || "the computer"}`}
description="New CPUs or memory restart it with them. Its files stay."
>
{computer ? (
<AddComputerForm key={computer.id} computer={computer} onCancel={onClose} onUpdated={onUpdated} />
) : null}
</AddModalShell>
);
}
@@ -0,0 +1,45 @@
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import type { ComputerWithStatus } from "@/src/types";
import computerResourcesLabel from "@/src/utils/computer-resources-label";
import computerStatusLabel from "@/src/utils/computer-status-label";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
computer: ComputerWithStatus;
is_super_admin: boolean;
busy?: boolean;
onEdit: () => void;
onRetry: () => void;
onDelete: () => void;
};
/**
* One computer: its name, state and resources, then (super admins) Edit,
* Retry when its setup failed, and Delete (never the main one).
*/
export default function ComputerSettingsRow({ computer, is_super_admin, busy, onEdit, onRetry, onDelete }: Props) {
const name = computer.name || `Computer ${computer.id}`;
const busy_here = busy || computer.status == "creating";
return (
<SettingsListRow
avatar={<ComputerIconTile />}
title={name}
badge={computer.kind == "main" ? "Main" : undefined}
subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description]
.filter(Boolean)
.join(" · ")}
>
{is_super_admin ? (
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy_here} onClick={onEdit} />
) : null}
{is_super_admin && computer.status == "failed" ? (
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
) : null}
{is_super_admin && computer.kind != "main" ? (
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,103 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import useLiveComputers from "@/src/hooks/use-live-computers";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ComputerWithStatus } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import ComputerEditModal from "../(partials)/computer-edit-modal";
import ComputerSettingsRow from "../(partials)/computer-settings-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every computer the user has, main first, kept live: its state and
* resources. Super admins create, edit (new resources restart it, its
* files stay), retry and delete them.
*/
export default function ComputersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { computers, setComputers } = useLiveComputers({ computers: pageProps?.computers || [], include_new: true });
const [editing, setEditing] = useState<ComputerWithStatus>();
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
const merge = (fresh: ComputerWithStatus) =>
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
setBusy(true);
setStatus(undefined);
const res = await postAdminApi<ComputerWithStatus>({ path, body: { computer_id } });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
if (res.singleRes) merge(res.singleRes);
else setComputers((prev) => prev.filter((c) => c.id != computer_id));
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
};
return (
<SettingsGroup
title="Your computers"
description="Each one is a sandbox container on this machine. Bots reach them through workspaces or direct access."
>
<SettingsList is_empty={!computers.length} empty="No computers yet.">
{computers.map((computer) => (
<ComputerSettingsRow
key={computer.id}
computer={computer}
is_super_admin={is_super_admin}
busy={busy}
onEdit={() => setEditing(computer)}
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
onDelete={() => {
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
}}
/>
))}
</SettingsList>
{is_super_admin ? (
<Button
title="Create a new computer"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "computer" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New computer
</Button>
) : null}
<SettingsStatus status={status} />
<ComputerEditModal
computer={editing}
onClose={() => setEditing(undefined)}
onUpdated={({ computer, msg }) => {
merge(computer);
setEditing(undefined);
setStatus({ success: true, msg });
}}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
computers: await grabUserComputers({ user_id: settings.props.person.id }),
},
};
};
@@ -0,0 +1,13 @@
import ComputersSettingsSection from "./(sections)/computers-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsComputersPage() {
return (
<SettingsShell title="Computers" description="The machines your bots work on, and what each one gets.">
<ComputersSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Computers" });
@@ -32,12 +32,7 @@ export default function ProfilePhotoField({
return ( return (
<Row className="w-full gap-4! flex-nowrap items-center"> <Row className="w-full gap-4! flex-nowrap items-center">
<UserAvatar <UserAvatar src={preview_src} initials={initials} alt="Your photo" size="lg" />
src={preview_src}
initials={initials}
alt="Your photo"
size="lg"
/>
<Stack className="gap-2! min-w-0"> <Stack className="gap-2! min-w-0">
<Row className="gap-2!"> <Row className="gap-2!">
<Button <Button
@@ -0,0 +1,33 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "./user-form";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
open: boolean;
onClose: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Adds or edits a user in a modal.
*/
export default function UserEditModal({ person, open, onClose, onSaved }: Props) {
return (
<AddModalShell
open={open}
setOpen={(next) => !next && onClose()}
title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"}
description={
person
? "Leave the password empty to keep theirs."
: "They get their own coordinator and main computer, and sign in with this password."
}
>
{open ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -0,0 +1,141 @@
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Row from "@moduletrace/twui/layout/Row";
import { useContext, useState } from "react";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import { AppContext } from "@/src/pages/__root";
import type { UserType } from "@/src/types";
import parseUserTypes from "@/src/utils/parse-user-types";
import UserPasswordFields from "./user-password-fields";
import UserTypesSelect from "./user-types-select";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
onCancel: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* A user's name, email, username, password (twice, to confirm) and user
* types. Uneditable types (`super_admin`) aren't offered, and the super
* admin's types aren't shown at all: they never change here.
*/
export default function UserForm({ person, onCancel, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const current_types = parseUserTypes({ user_types: person?.user_types });
const is_super_admin = current_types.includes("super_admin");
const [first_name, setFirstName] = useState(person?.first_name || "");
const [last_name, setLastName] = useState(person?.last_name || "");
const [email, setEmail] = useState(person?.email || "");
const [username, setUsername] = useState(person?.username || "");
const [password, setPassword] = useState("");
const [confirm_password, setConfirmPassword] = useState("");
const [user_types, setUserTypes] = useState<UserType[]>(
person ? current_types.filter((type) => !UneditableUserTypes.includes(type)) : ["member"],
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
if (password != confirm_password) {
setError("The passwords don't match.");
return;
}
setLoading(true);
setError(undefined);
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
path: "users/save-user",
body: {
user_id: person?.id || null,
first_name,
last_name,
email,
username,
password,
confirm_password,
user_types: is_super_admin ? [] : user_types,
},
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't save the user.");
return;
}
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Row className="w-full gap-4! flex-nowrap items-start">
<Input
id="user-form-first-name"
title="First name"
label="First name"
showLabel
placeholder="Ada"
autoComplete="off"
defaultValue={first_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setFirstName(e.target.value)}
/>
<Input
id="user-form-last-name"
title="Last name"
label="Last name"
showLabel
placeholder="Lovelace"
autoComplete="off"
defaultValue={last_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setLastName(e.target.value)}
/>
</Row>
<Input
id="user-form-email"
title="Email"
label="Email"
showLabel
type="email"
placeholder="[email protected]"
autoComplete="off"
defaultValue={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="user-form-username"
title="Username"
label="Username"
showLabel
placeholder="ada"
autoComplete="off"
defaultValue={username}
info="Lowercase letters, numbers, dots, dashes or underscores."
onChange={(e) => setUsername(e.target.value)}
/>
{is_super_admin ? null : (
<UserTypesSelect actor_user_types={pageProps?.user_types} value={user_types} onChange={setUserTypes} />
)}
<UserPasswordFields optional={Boolean(person)} onPassword={setPassword} onConfirm={setConfirmPassword} />
<AddFormActions
submit_label={person ? "Save" : "Add user"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
);
}
@@ -0,0 +1,50 @@
import Input from "@moduletrace/twui/form/Input";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
type Props = {
/**
* Editing someone: both may stay empty to keep their password.
*/
optional?: boolean;
onPassword: (password: string) => void;
onConfirm: (password: string) => void;
};
/**
* The password section of the user form: the password, then the same
* again to confirm it.
*/
export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) {
return (
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
<Stack className="gap-0.5!">
<Span className="text-sm font-bold leading-5 text-ink">{optional ? "New password" : "Password"}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`At least ${AppData.PasswordMinLength} characters.${optional ? " Leave both empty to keep theirs." : " They sign in with it."}`}
</Span>
</Stack>
<Input
id="user-form-password"
title="Password"
label="Password"
showLabel
type="password"
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
autoComplete="new-password"
onChange={(e) => onPassword(e.target.value)}
/>
<Input
id="user-form-confirm-password"
title="Confirm password"
label="Confirm password"
showLabel
type="password"
placeholder="The same password again"
autoComplete="new-password"
onChange={(e) => onConfirm(e.target.value)}
/>
</Stack>
);
}
@@ -0,0 +1,71 @@
import UserAvatar from "@/src/components/general/user-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One user: their photo, name, user types and email, then Edit and
* Delete where the actor may (`canUpdateUser`, `canRevokeUser`; never
* deleting yourself).
*/
export default function UserSettingsRow({
person,
actor_user_id,
actor_user_types,
busy,
onEdit,
onDelete,
}: Props) {
const name =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
const user_types = parseUserTypes({ user_types: person.user_types });
const types_title = UserTypes.filter((type) => user_types.includes(type.value))
.map((type) => type.title)
.join(", ");
const is_self = person.id == actor_user_id;
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
return (
<SettingsListRow
avatar={<UserAvatar person={person} size="md" />}
title={name}
badge={is_self ? "You" : undefined}
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
>
{can_edit ? (
<SettingsRowAction
title={`Edit ${name}`}
label="Edit"
disabled={busy}
onClick={onEdit}
/>
) : null}
{can_delete ? (
<SettingsRowAction
title={`Delete ${name}`}
label="Delete"
disabled={busy}
onClick={onDelete}
/>
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,45 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import type { UserType } from "@/src/types";
import filterAssignableUserTypes from "@/src/utils/rbac/filter-assignable-user-types";
type Props = {
/**
* The session user's types: only what they may grant is offered.
*/
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
value: UserType[];
onChange: (user_types: UserType[]) => void;
};
/**
* Picks a user's types from `UserTypes`, leaving out the uneditable ones
* (`super_admin`) and any the assignment matrix keeps from the actor.
*/
export default function UserTypesSelect({ actor_user_types, value, onChange }: Props) {
const assignable = filterAssignableUserTypes({
actor_user_types,
user_types: UserTypes.map((type) => type.value).filter((type) => !UneditableUserTypes.includes(type)),
});
return (
<SearchSelect
id="user-form-user-types"
title="User types"
label="User types"
showLabel
multiple
placeholder="Pick their user types"
options={UserTypes.filter((type) => assignable.includes(type.value)).map((type) => ({
value: type.value,
title: type.title,
}))}
defaultValues={value}
info="At least one, or they can't sign in."
changeHandler={(next) =>
onChange((Array.isArray(next) ? next : next ? [next] : []) as UserType[])
}
/>
);
}
@@ -0,0 +1,84 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import canCreateUser from "@/src/utils/rbac/can-create-user";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import UserEditModal from "../(partials)/user-edit-modal";
import UserSettingsRow from "../(partials)/user-settings-row";
/**
* Everyone who can sign in (for super admins and admins): add a user
* with a password, edit one (name, email, username, a new password, user
* types), or delete one with everything that's theirs. What each person
* may do here is the RBAC helpers' call.
*/
export default function UsersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({ items: pageProps?.persons || [] });
/**
* The user being edited, or `true` while adding one.
*/
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
return (
<SettingsGroup
title="People"
description="Sign in is by email or username and password, so set one for each new user."
>
<SettingsList is_empty={!items.length} empty="No users yet.">
{items.map((person) => (
<UserSettingsRow
key={person.id}
person={person}
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
busy={busy}
onEdit={() => setEditing(person)}
onDelete={() =>
remove({
id: person.id,
path: "users/delete-user",
body: { user_id: person.id },
confirm: `Delete ${person.first_name || "this user"} with their bots, workspaces and computers? Their computers' files stay on disk.`,
})
}
/>
))}
</SettingsList>
{canCreateUser({ user_types: pageProps?.user_types }) ? (
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setEditing(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
) : null}
<SettingsStatus status={status} />
<UserEditModal
person={editing === true ? undefined : editing}
open={Boolean(editing)}
onClose={() => setEditing(undefined)}
onSaved={({ person, msg }) => {
setItems((prev) =>
prev.some((p) => p.id == person.id)
? prev.map((p) => (p.id == person.id ? person : p))
: [...prev, person],
);
setEditing(undefined);
setStatus({ success: true, msg });
}}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,30 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import canListUsers from "@/src/utils/rbac/can-list-users";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* For those who may list users (`canListUsers`: super admins, admins);
* everyone else lands on the general settings.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props) return settings;
const { user_types } = await userAuth({ req: params.req });
if (!canListUsers({ user_types })) {
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
}
return {
props: {
...settings.props,
persons: (await grabUsers()).payload || [],
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import UsersSettingsSection from "./(sections)/users-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsUsersPage() {
return (
<SettingsShell title="Users" description="Who can sign in to this install. Each has their own crew and computers.">
<UsersSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Users" });
@@ -0,0 +1,29 @@
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { WorkspaceWithActivity } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
workspace: WorkspaceWithActivity;
busy?: boolean;
onDelete: () => void;
};
/**
* One workspace: its picture, name and what it's about, then Open and
* Delete.
*/
export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Props) {
const name = workspace.name || `Workspace ${workspace.id}`;
return (
<SettingsListRow
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
title={name}
subtitle={workspace.description || undefined}
>
<SettingsRowAction title={`Open ${name}`} label="Open" href={`/admin/workspaces/${workspace.id}`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</SettingsListRow>
);
}
@@ -0,0 +1,58 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import WorkspaceSettingsRow from "../(partials)/workspace-settings-row";
/**
* Every workspace the user has: open one to set it up (its page has its
* context, bots, computers and models), create one, or delete one.
*/
export default function WorkspacesSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.workspaces || [] });
return (
<SettingsGroup
title="Your workspaces"
description="Bots in a workspace share its context and computers. Deleting one keeps its bots and computers."
>
<SettingsList is_empty={!items.length} empty="No workspaces yet.">
{items.map((workspace) => (
<WorkspaceSettingsRow
key={workspace.id}
workspace={workspace}
busy={busy}
onDelete={() =>
remove({
id: workspace.id,
path: "workspaces/delete-workspace",
body: { workspace_id: workspace.id },
confirm: `Delete ${workspace.name || "this workspace"} and its conversations? Its bots and computers stay.`,
})
}
/>
))}
</SettingsList>
<Button
title="Create a workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "workspace" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New workspace
</Button>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
},
};
};
@@ -0,0 +1,13 @@
import WorkspacesSettingsSection from "./(sections)/workspaces-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsWorkspacesPage() {
return (
<SettingsShell title="Workspaces" description="Projects with their own context, bots, computers and models.">
<WorkspacesSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Workspaces" });
@@ -1,10 +1,8 @@
import Row from "@moduletrace/twui/layout/Row"; import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import Span from "@moduletrace/twui/layout/Span"; import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import Stack from "@moduletrace/twui/layout/Stack"; import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
import { Monitor } from "lucide-react";
import type { ComputerWithStatus } from "@/src/types"; import type { ComputerWithStatus } from "@/src/types";
import computerStatusLabel from "../(utils)/computer-status-label"; import computerStatusLabel from "@/src/utils/computer-status-label";
import WorkspaceComputerAction from "./workspace-computer-action";
type Props = { type Props = {
computer: ComputerWithStatus; computer: ComputerWithStatus;
@@ -34,32 +32,22 @@ export default function WorkspaceComputerRow({
const name = computer.name || `Computer ${computer.id}`; const name = computer.name || `Computer ${computer.id}`;
return ( return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2"> <SettingsListRow
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper"> avatar={<ComputerIconTile />}
<Monitor size={16} aria-hidden /> title={name}
</Span> badge={is_default ? "Default" : undefined}
<Stack className="grow min-w-0 gap-0!"> subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`}
<Span className="text-sm font-bold leading-5 text-ink truncate w-full"> >
{name}
{is_default ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">Default</Span> : null}
</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{computerStatusLabel({ computer })}
{computer.description ? ` · ${computer.description}` : ""}
</Span>
</Stack>
<Row className="shrink-0 gap-1! flex-nowrap">
{is_default ? null : ( {is_default ? null : (
<WorkspaceComputerAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} /> <SettingsRowAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
)} )}
{computer.status == "failed" && is_super_admin ? ( {computer.status == "failed" && is_super_admin ? (
<WorkspaceComputerAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} /> <SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
) : null} ) : null}
<WorkspaceComputerAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} /> <SettingsRowAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
{is_super_admin && computer.kind != "main" ? ( {is_super_admin && computer.kind != "main" ? (
<WorkspaceComputerAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} /> <SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
) : null} ) : null}
</Row> </SettingsListRow>
</Row>
); );
} }
@@ -1,7 +1,5 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import type { ComputerWithStatus } from "@/src/types"; import type { ComputerWithStatus } from "@/src/types";
@@ -9,8 +7,9 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context"; import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"; 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 SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import useLiveComputers from "../(hooks)/use-live-computers"; import useLiveComputers from "@/src/hooks/use-live-computers";
import InlineAddSelect from "./inline-add-select"; import InlineAddSelect from "./inline-add-select";
import WorkspaceComputerRow from "./workspace-computer-row"; import WorkspaceComputerRow from "./workspace-computer-row";
@@ -77,7 +76,10 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
title="Computers" title="Computers"
description="Bots here work on these. Their tasks run on the default one unless they pick another." description="Bots here work on these. Their tasks run on the default one unless they pick another."
> >
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface"> <SettingsList
is_empty={!computers.length}
empty="No computers yet. Bots here can chat, but tasks that need a computer can't run."
>
{computers.map((computer) => ( {computers.map((computer) => (
<WorkspaceComputerRow <WorkspaceComputerRow
key={computer.id} key={computer.id}
@@ -95,12 +97,7 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
}} }}
/> />
))} ))}
{computers.length ? null : ( </SettingsList>
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
No computers yet. Bots here can chat, but tasks that need a computer can't run.
</Span>
)}
</Stack>
<InlineAddSelect <InlineAddSelect
id="workspace-add-computer" id="workspace-add-computer"
noun="computer" noun="computer"
+51
View File
@@ -0,0 +1,51 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteBot from "@/src/functions/backend/db/bots/delete-bot";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { BotIdRequestSchema } from "@/src/schemas/bot-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 worker bots and everything it had
* (conversations, tasks, models). The coordinator stays.
*/
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 { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
const bot = await deleteBot({ user_id: user.id, bot_id });
await logEvent({
title: "Bot deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted the bot ${bot.name}.`,
metadata: { bot_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: `${bot.name || "The bot"} is deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -42,13 +42,19 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus
throw new AppError(`Only a super admin can do that.`); throw new AppError(`Only a super admin can do that.`);
} }
const { name, description, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body }); const { name, description, cpus, memory_mb, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) { if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
throw new AppError(`Workspace not found.`); throw new AppError(`Workspace not found.`);
} }
const { computer_id } = await createComputer({ user_id: user.id, name, description }); const { computer_id } = await createComputer({
user_id: user.id,
name,
description,
cpus,
memory_mb,
});
if (workspace_id) { if (workspace_id) {
await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id }); await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id });
@@ -0,0 +1,67 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import updateComputer from "@/src/functions/backend/db/computers/update-computer";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { ComputerWithStatus } from "@/src/types";
import { UpdateComputerRequestSchema } from "@/src/schemas/computer-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 isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Updates a computer of the session user (super admins only: each one is
* a container on this host). New resources (`cpus`, `memory_mb`) recreate
* its container in the background, its files kept; its state arrives as
* `server:computer-updated`. Returns the computer.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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.`);
}
if (!isPassThroughUser({ user_types })) {
throw new AppError(`Only a super admin can do that.`);
}
const { computer_id, ...data } = parseWithSchema({ schema: UpdateComputerRequestSchema, data: body });
const { recreating } = await updateComputer({ user_id: user.id, computer_id, data });
const computer = (await grabUserComputers({ computer_id }))[0];
const name = computer?.name || "The computer";
await logEvent({
title: "Computer updated",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} updated the computer ${name}.`,
metadata: { computer_id, ...data, recreating },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: recreating ? `${name} is restarting with its new settings.` : `${name} saved.`,
singleRes: recreating ? { ...computer, status: "creating", error: null } : computer,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+50
View File
@@ -0,0 +1,50 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteUser from "@/src/functions/backend/db/users/delete-user";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { UserIdRequestSchema } from "@/src/schemas/user-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 a user and everything that's theirs, when the actor could
* revoke them (`canRevokeUser`), never themselves.
*/
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 { user_id } = parseWithSchema({ schema: UserIdRequestSchema, data: body });
const deleted = await deleteUser({ actor_user_id: user.id, actor_user_types: user_types || [], user_id });
await logEvent({
title: "User deleted",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} deleted user #${user_id} (${deleted.email}).`,
metadata: { user_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: `${deleted.first_name || "The user"} is deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+96
View File
@@ -0,0 +1,96 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import saveUser from "@/src/functions/backend/db/users/save-user";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
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 canCreateUser from "@/src/utils/rbac/can-create-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import parseUserTypes from "@/src/utils/parse-user-types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
* name, email, username, password and user types, each type checked
* against the assignment matrix. A new user gets their coordinator and main computer
* straight away, as at signup. Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = 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 data = parseWithSchema({ schema: SaveUserRequestSchema, data: body });
const target = data.user_id ? (await grabUsers({ user_id: data.user_id })).singleRes : null;
const allowed = data.user_id
? canUpdateUser({
actor_user_types: user_types,
actor_user_id: user.id,
target_user_id: data.user_id,
target_user_types: parseUserTypes({ user_types: target?.user_types }).map((user_type) => ({ user_type })),
update_fields: target && target.email != data.email.trim().toLowerCase() ? ["email"] : [],
})
: canCreateUser({ user_types });
if (!allowed) {
throw new AppError(`You can't do that.`);
}
const { user_id, created } = await saveUser({ ...data, actor_user_types: user_types || [] });
if (created) {
/**
* The account exists now; `/admin/bots` creates both on their
* first visit if this fails.
*/
await ensureCoordinatorBot({ user_id })
.then((coordinator_id) => ensureMainComputer({ user_id, coordinator_id }))
.catch((error) =>
logError({
title: "Coordinator or main computer not created with the user",
error,
user_id,
ip_address: grabRequestIP(req),
}).catch(() => {}),
);
}
await logEvent({
title: created ? "User created" : "User updated",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} ${created ? "created" : "updated"} user #${user_id}.`,
metadata: { user_id, user_types: data.user_types, password_changed: Boolean(data.password) },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: created ? `${data.first_name} can sign in now.` : `Saved.`,
singleRes: (await grabUsers({ user_id })).singleRes,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+30 -2
View File
@@ -1,8 +1,27 @@
import { z } from "zod"; import { z } from "zod";
import { AppData } from "@/src/data/app-data";
const ComputerCpusSchema = z.coerce
.number()
.min(AppData.ComputerMinCpus, `Give it at least ${AppData.ComputerMinCpus} CPU`)
.max(AppData.ComputerMaxCpus, `Keep it under ${AppData.ComputerMaxCpus} CPUs`);
const ComputerMemoryMbSchema = z.coerce
.number()
.int()
.min(AppData.ComputerMinMemoryMb, `Give it at least ${AppData.ComputerMinMemoryMb} MB of memory`);
const ComputerNameSchema = z.string().trim().min(1, "Give the computer a name").max(60, "Keep the name under 60 characters");
const ComputerDescriptionSchema = z.string().trim().max(240, "Keep the description short");
export const CreateComputerRequestSchema = z.object({ export const CreateComputerRequestSchema = z.object({
name: z.string().trim().min(1, "Give the computer a name").max(60, "Keep the name under 60 characters"), name: ComputerNameSchema,
description: z.string().trim().max(240, "Keep the description short").default(""), description: ComputerDescriptionSchema.default(""),
/**
* Defaults to `AppData.ComputerDefaultCpus` / `ComputerDefaultMemoryMb`.
*/
cpus: ComputerCpusSchema.optional(),
memory_mb: ComputerMemoryMbSchema.optional(),
/** /**
* Also link it to this workspace. * Also link it to this workspace.
*/ */
@@ -16,3 +35,12 @@ export const ComputerIdRequestSchema = z.object({
}); });
export type ComputerIdRequest = z.infer<typeof ComputerIdRequestSchema>; export type ComputerIdRequest = z.infer<typeof ComputerIdRequestSchema>;
export const UpdateComputerRequestSchema = ComputerIdRequestSchema.extend({
name: ComputerNameSchema.optional(),
description: ComputerDescriptionSchema.optional(),
cpus: ComputerCpusSchema.optional(),
memory_mb: ComputerMemoryMbSchema.optional(),
});
export type UpdateComputerRequest = z.infer<typeof UpdateComputerRequestSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { UserTypes } from "@/src/dict/user-types-dict";
export const SaveUserRequestSchema = z
.object({
/**
* Updates this user; creates one when not given.
*/
user_id: z.coerce.number().int().positive().nullish(),
first_name: z.string().trim().min(1, "Enter their first name").max(100),
last_name: z.string().trim().max(100).default(""),
email: z.email("Enter a valid email address"),
username: z.string().trim().min(3, "Usernames must be at least 3 characters").max(32, "Usernames must be at most 32 characters"),
/**
* Required for a new user; on an existing one, empty keeps theirs.
*/
password: z.string().max(128).default(""),
confirm_password: z.string().max(128).default(""),
/**
* Every user type they should hold (`UserTypes`). Types the actor may
* not grant or remove (`super_admin`, the assignment matrix) are left
* as they are.
*/
user_types: z.array(z.enum(UserTypes.map((type) => type.value))).default([]),
})
.refine((data) => data.password == data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
});
export type SaveUserRequest = z.infer<typeof SaveUserRequestSchema>;
export const UserIdRequestSchema = z.object({
user_id: z.coerce.number().int().positive("Pick a user"),
});
export type UserIdRequest = z.infer<typeof UserIdRequestSchema>;
+13
View File
@@ -10,6 +10,7 @@ import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy"; import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import { ComputerProxyPrefix } from "./dict/computer-dict"; import { ComputerProxyPrefix } from "./dict/computer-dict";
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
declare global {} declare global {}
@@ -21,6 +22,18 @@ const port = process.env.PORT || 7227;
await enableForeignKeys(); await enableForeignKeys();
await bunext.bunextInit(); await bunext.bunextInit();
/**
* Computers are docker containers on this network, so the app doesn't
* start without docker.
*/
try {
await ensureDockerNetwork();
} catch (error) {
await logError({ title: "Docker isn't available", error }).catch(() => {});
console.error(`Can't start without docker: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const server = Bun.serve({ const server = Bun.serve({
async fetch(req, server) { async fetch(req, server) {
try { try {
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import computerNotReadyMessage from "./computer-not-ready-message";
describe("computerNotReadyMessage", () => {
test("names the computer and its state", () => {
expect(computerNotReadyMessage({ name: "Main computer", status: "starting" })).toBe(
"Main computer isn't ready yet: it's starting.",
);
});
test("falls back to a generic name", () => {
expect(computerNotReadyMessage({ status: "creating" })).toBe("The computer isn't ready yet: it's creating.");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { ContainerStatuses, type ContainerStatus } from "@/src/dict/computer-dict";
type Params = {
name?: string | null;
status: ContainerStatus;
};
/**
* Why a computer can't be reached yet, e.g. "Main computer isn't ready
* yet: it's starting."
*/
export default function computerNotReadyMessage({ name, status }: Params) {
const title = ContainerStatuses.find((s) => s.value == status)?.title || "Unknown";
return `${name || "The computer"} isn't ready yet: it's ${title.toLowerCase()}.`;
}
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import computerResourcesLabel from "./computer-resources-label";
describe("computerResourcesLabel", () => {
test("shows what the computer has", () => {
expect(computerResourcesLabel({ computer: { cpus: 2, memory_mb: 4096 } })).toBe("2 CPUs · 4 GB");
expect(computerResourcesLabel({ computer: { cpus: 0.5, memory_mb: 1536 } })).toBe("0.5 CPUs · 1.5 GB");
});
test("falls back to the defaults", () => {
expect(computerResourcesLabel({ computer: { cpus: null as never, memory_mb: undefined } })).toBe("1 CPU · 2 GB");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { AppData } from "@/src/data/app-data";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
type Params = {
computer: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">;
};
/**
* A computer's resources in a few words, e.g. "1 CPU · 2 GB". One saved
* without them gets the defaults when it's next set up.
*/
export default function computerResourcesLabel({ computer }: Params) {
const cpus = Number(computer.cpus) || AppData.ComputerDefaultCpus;
const gb = (Number(computer.memory_mb) || AppData.ComputerDefaultMemoryMb) / 1024;
return `${cpus} CPU${cpus == 1 ? "" : "s"} · ${Number(gb.toFixed(2))} GB`;
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import parseUserTypes from "./parse-user-types";
describe("parseUserTypes", () => {
test("splits the joined types, in dict order", () => {
expect(parseUserTypes({ user_types: "read_only, admin" })).toEqual(["admin", "read_only"]);
});
test("drops unknown types and handles none", () => {
expect(parseUserTypes({ user_types: "auditor,admin" })).toEqual(["admin"]);
expect(parseUserTypes({ user_types: null })).toEqual([]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { UserTypes } from "@/src/dict/user-types-dict";
import type { UserType } from "@/src/types";
type Params = {
/**
* The user's types, comma separated (`grabUsers`' `user_types`).
*/
user_types?: string | null;
};
/**
* A user's types as a list, only the ones `UserTypes` defines.
*/
export default function parseUserTypes({ user_types }: Params): UserType[] {
const values = (user_types || "").split(",").map((type) => type.trim());
return UserTypes.map((type) => type.value).filter((type) => values.includes(type));
}
+12 -17
View File
@@ -8,10 +8,10 @@ import {
filterAssignableUserTypes, filterAssignableUserTypes,
isPassThroughUser, isPassThroughUser,
} from "./"; } from "./";
import { import { UserTypes } from "@/src/dict/user-types-dict";
ALL_USER_TYPES, import { makeUserTypes } from "./user-type-assignment.test-fixtures";
makeUserTypes,
} from "./user-type-assignment.test-fixtures"; const ALL_USER_TYPES = UserTypes.map((type) => type.value);
describe("isPassThroughUser", () => { describe("isPassThroughUser", () => {
test("super_admin is pass-through", () => { test("super_admin is pass-through", () => {
@@ -19,7 +19,7 @@ describe("isPassThroughUser", () => {
}); });
test("regular types are not pass-through", () => { test("regular types are not pass-through", () => {
for (const type of ["admin", "president", "secretary"]) { for (const type of ["admin", "member", "read_only"]) {
expect( expect(
isPassThroughUser({ user_types: makeUserTypes(type) }), isPassThroughUser({ user_types: makeUserTypes(type) }),
).toBe(false); ).toBe(false);
@@ -45,7 +45,7 @@ describe("canListUsers / canCreateUser / canManageUserTypes", () => {
}); });
test("non-admin types fail", () => { test("non-admin types fail", () => {
for (const type of ["auditor", "president", "secretary", "read_only"]) { for (const type of ["member", "read_only", "revoked"]) {
expect( expect(
canListUsers({ user_types: makeUserTypes(type) }), canListUsers({ user_types: makeUserTypes(type) }),
).toBe(false); ).toBe(false);
@@ -63,7 +63,7 @@ describe("canUpdateUser", () => {
test("actors may update their own record", () => { test("actors may update their own record", () => {
expect( expect(
canUpdateUser({ canUpdateUser({
actor_user_types: makeUserTypes("auditor"), actor_user_types: makeUserTypes("member"),
actor_user_id: 5, actor_user_id: 5,
target_user_id: 5, target_user_id: 5,
}), }),
@@ -108,7 +108,7 @@ describe("canUpdateUser", () => {
test("non-admin actors cannot update others", () => { test("non-admin actors cannot update others", () => {
expect( expect(
canUpdateUser({ canUpdateUser({
actor_user_types: makeUserTypes("auditor"), actor_user_types: makeUserTypes("member"),
actor_user_id: 1, actor_user_id: 1,
target_user_id: 2, target_user_id: 2,
update_fields: ["first_name"], update_fields: ["first_name"],
@@ -155,7 +155,7 @@ describe("canRevokeUser", () => {
expect( expect(
canRevokeUser({ canRevokeUser({
user_types: makeUserTypes("admin"), user_types: makeUserTypes("admin"),
target_user_types: makeUserTypes("president"), target_user_types: makeUserTypes("super_admin"),
}), }),
).toBe(false); ).toBe(false);
}); });
@@ -175,17 +175,12 @@ describe("filterAssignableUserTypes", () => {
actor_user_types: makeUserTypes("admin"), actor_user_types: makeUserTypes("admin"),
user_types: ALL_USER_TYPES, user_types: ALL_USER_TYPES,
}); });
expect(assignable).toContain("auditor"); expect(assignable).toEqual(["member", "read_only", "revoked"]);
expect(assignable).toContain("read_only");
expect(assignable).not.toContain("admin");
expect(assignable).not.toContain("super_admin");
expect(assignable).not.toContain("president");
expect(assignable).not.toContain("secretary");
}); });
test("secretary can assign nothing", () => { test("a member can assign nothing", () => {
const assignable = filterAssignableUserTypes({ const assignable = filterAssignableUserTypes({
actor_user_types: makeUserTypes("secretary"), actor_user_types: makeUserTypes("member"),
user_types: ALL_USER_TYPES, user_types: ALL_USER_TYPES,
}); });
expect(assignable).toHaveLength(0); expect(assignable).toHaveLength(0);
+25
View File
@@ -0,0 +1,25 @@
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import type { User } from "../types";
type Params = {
first_name?: string | null;
last_name?: string | null;
};
/**
* A user's initials for their avatar, e.g. "AB".
*/
export default function userInitials(
params?: User | BUN_SQLITE_COBBLE_USERS | null,
) {
if (!params?.first_name) {
return "NA";
}
const { first_name, last_name } = params;
return [first_name, last_name]
.filter(Boolean)
.map((part) => part!.charAt(0).toUpperCase())
.join("");
}