Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0166f31cfc | ||
|
|
caa9ad87e4 | ||
|
|
30a4df183c | ||
|
|
ee687e1d31 | ||
|
|
024d847b9a |
@@ -194,7 +194,7 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's
|
||||
- **Providers** (`HarnessProviderKinds` in `src/dict/harness-provider-dict.ts`): OpenRouter (default, created by `ensureDefaultHarnessProvider`), OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama (local, its models count as free), any OpenAI-compatible server. Rows in `harness_providers` (a private table: the key is encrypted with `src/utils/crypt.ts`, never sent to the client; falls back to the preset's env, e.g. `OPENROUTER_API_KEY`, via `grabEnv`). One adapter today, `src/harness/providers/openai-compatible/` (`chat`, `transcribe`, `speak`, `generateImage`, `embed` (`POST /embeddings`, batched), `listModels`), chosen in `grab-harness-adapter.ts`; a provider needing a native API gets its own adapter there (`HarnessAdapter` in `src/harness/types.ts`).
|
||||
- **Catalog:** `harness_models` per provider (modalities, tools support, voices, Artificial Analysis scores, `free`, `available`). The `sync-harness-models` cron (hourly, on start) refreshes every enabled provider (`syncAllHarnessModels`); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (`guess-model-modalities`). Free on OpenRouter means `:free` or `openrouter/free`, never zero token prices.
|
||||
- **Automatic picks** (`rank-auto-models`): only for the `automatic` harnesses in `Harnesses` (text, vision, image); transcription and speech have none and are not available until a model is picked (the model select reads "Not available", no fallback checkbox). Free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first.
|
||||
- **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` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`, `embeddings/embed-text.ts` (batches, then `fit-vector-dimensions.ts` pads or cuts each vector to `AppData.EmbeddingDimensions` and normalizes it, since a provider's width varies). 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 `max_steps`, `AppData.HarnessMaxToolSteps` by default and the install's own saved limit for a task run, `grabHarnessMaxToolSteps` in `app-settings/grab-run-limits.ts` reading the `harness_max_tool_steps` app setting, and `grabReplyMaxToolSteps`/`bot_reply_max_tool_steps` for a chat reply in place of `AppData.BotReplyMaxToolSteps`; `src/utils/read-step-limit.ts` falls back to the code default for anything missing, below 1 or past `AppData.BotStepLimitMax`; 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` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`, `embeddings/embed-text.ts` (batches, then `fit-vector-dimensions.ts` pads or cuts each vector to `AppData.EmbeddingDimensions` and normalizes it, since a provider's width varies). 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`).
|
||||
- **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: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). 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; a task handed over in the coordinator's own chat runs in the worker's own chat unless it names a `workspace_id`, which puts it in that workspace: the worker's workspace instructions and memory, the workspace's context, files and computers apply, and it needs membership, added first via `addBotToWorkspace` when missing; handed over from inside a workspace it always runs there and a different `workspace_id` is refused; 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 `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `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), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `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` (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).
|
||||
@@ -203,7 +203,7 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's
|
||||
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
|
||||
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
|
||||
- **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use.
|
||||
- **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, the file tools and the job's computer tools, closing the MCP connection after. Both add `BotFilesNote` (where a bot's files live, that they persist, and to search before saying it doesn't know) and collect `attachments` for files the bot wrote with `attach`, which `completeBotJob` links to the result message. 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 `grabReplyMaxToolSteps()` steps (the `bot_reply_max_tool_steps` app setting, else `AppData.BotReplyMaxToolSteps`), 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, the file tools and the job's computer tools, closing the MCP connection after. Both add `BotFilesNote` (where a bot's files live, that they persist, and to search before saying it doesn't know) and collect `attachments` for files the bot wrote with `attach`, which `completeBotJob` links to the result message. 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.
|
||||
|
||||
### Token usage
|
||||
@@ -230,7 +230,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
||||
- Routines, Files, Usage, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings. A page carries `super_admin_only` or a `can_view` rule (Usage is `canManageCrew`), and `canSeeRailItem` (`src/utils/rbac/can-see-rail-item.ts`, tested) decides both the popover and the aside, so a page is never offered in one and hidden in the other
|
||||
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
|
||||
- `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/`
|
||||
- `/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; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `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), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) 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`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), 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
|
||||
- `/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; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `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 that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). 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), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) 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`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), 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 (super admins and admins only, like the rail's "+" buttons) 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; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); 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, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), 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`).
|
||||
|
||||
@@ -282,6 +282,7 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
||||
- Shared: `Logo`, `LogoIcon`, `InnerPageHeroSection`, `AdminHero`, `LoginShell` (auth pages/forms), `TaglineSection`
|
||||
- **UNCHANGEABLE RULE - twui components always win over raw JSX elements.** Use twui components (`H1`-`H5`, `P`, `Span`, `List`, `Img`, `Row`, `Stack`, `Container`, `Section`, `Card`, `Button`, `Tag`, `Link`, etc.) before basic JSX elements (`<h1>`-`<h6>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults via `className` rather than reaching for the raw element.
|
||||
- **Never create new CSS classes, CSS files, or inline `style={{...}}` hacks.** Style exclusively by composing existing component props/classNames, existing twui classNames (`twui-*`), semantic theme-token utilities (`bg-dark`, `bg-light`, `text-dark`, `text-primary`, `bg-primary`, `text-accent`, `bg-accent`, `text-secondary`, `border-slate-200`, etc.), and utility classes already used elsewhere in this codebase. Build up from what exists; extend component code only when the existing API cannot express the need.
|
||||
- **Chunk Tailwind classes with `tailwind-merge`, never one long line.** A `className` is `twMerge(...)` (`import { twMerge } from "tailwind-merge"`) with one chunk per claim: base layout and spacing first, then one string per conditional, and a caller's override last, so a later chunk always beats an earlier one instead of depending on source order in the CSS. Never concatenate conditional classes with a template literal or a bare `+`, and never leave a single 20-class string where the parts mean different things: `className={twMerge("w-full shrink-0 flex-col gap-0! items-stretch rounded-default py-1", selected && "bg-ink/5")}`.
|
||||
- **Theming:** colors, radii, and fonts change only in `src/styles/main.css`: Cobble tokens as CSS vars on `:root` (light) and `.dark`, exposed in the `@theme inline` block as `paper`, `surface`, `sunken`, `ink`, `ink-secondary`, `ink-disabled`, `hairline`, `hairline-strong`, `on-ink`, `hue-<name>`, plus `shadow-popover` and `--m-fast/base/slow` motion vars (zeroed under reduced motion). Font is Lato (stand-in). Never hardcode hex colors, radii, or font stacks inside components. Hues are bot identity only, never state, and the `--color-hue-*` tokens remain for the fixed palettes that use them (the files browser's volume icons, `BotHues`' own hexes).
|
||||
- **The one exception:** a bot's colour is user data, not a theme value, so it reaches `BotStone` (`src/components/general/bot-stone.tsx`) and `UnreadDot` as an SVG `fill` attribute - an arbitrary hex has no token to point at. Nothing else in the app may take a colour that way, and `resolveBotColor`/`normalizeBotColor` (`src/utils/bot-color.ts`) are the only place a colour string is validated.
|
||||
- **Branding:** `docs/readme/hero-light.png`, `hero-dark.png`, `workspace-light.png` and `workspace-dark.png` are screenshots of the current app (chat and workspace dashboard, 1440px at 2x, downscaled to 1600px wide), committed and picked per theme by GitHub's `#gh-light-mode-only` / `#gh-dark-mode-only` fragment. They are not generated: when the UI changes, retake them from a seeded dev instance (`bun run seed`, `bun run seed:bots`) and keep the same names, so the README never shows an older dashboard. Never use `assets/handoff/assets/screens/*.png` for anything: that design is outdated. `public/logos/logo.svg` is the single source of truth for the logo mark; `logo-icon.tsx` renders it inline (via `BotStone`) and `logo.tsx` renders the stone beside the vector `Wordmark`. Whenever the logo changes, run `bun run gen:favicon` to regenerate `public/favicon.ico`, `public/favicon.png`, `public/apple-touch-icon.png`, and `public/logos/logo.png` (the last is used in emails). Font + favicon links live in `__root.tsx` `Head()`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TaskReminderNote,
|
||||
WorkspaceLearningNote,
|
||||
} from "@/src/data/harness-prompts";
|
||||
import { grabReplyMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||
@@ -173,7 +174,7 @@ export default async function runBotReply({
|
||||
}),
|
||||
}),
|
||||
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
|
||||
max_steps: AppData.BotReplyMaxToolSteps,
|
||||
max_steps: await grabReplyMaxToolSteps(),
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
signal,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TaskRunNote,
|
||||
WorkspaceLearningNote,
|
||||
} from "@/src/data/harness-prompts";
|
||||
import { grabHarnessMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
@@ -227,6 +228,7 @@ export default async function runBotTask({
|
||||
},
|
||||
],
|
||||
tools: [...tools, ...extensions.tools, ...skills.tools],
|
||||
max_steps: await grabHarnessMaxToolSteps(),
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The address the page is at, e.g. `/admin/settings/bots/3`.
|
||||
*/
|
||||
pathname?: string | null;
|
||||
/**
|
||||
* The page's own name, for the last crumb: what the address can't say,
|
||||
* since its last segment is an id (`Scout`, not `3`) or a slug that
|
||||
* differs from the name the page shows (`MCPs`, not `Mcps`).
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The trail above a page's content: `Home`, then every section the address
|
||||
* passes through, then the page itself, named by `title`.
|
||||
*
|
||||
* It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the
|
||||
* sections come from the address and only the page's own name is given
|
||||
* here; `hasBreadcrumbTrail` says which pages get one at all.
|
||||
*/
|
||||
export default function AdminBreadcrumbs({ pathname, title }: Props) {
|
||||
if (!hasBreadcrumbTrail({ pathname })) return null;
|
||||
|
||||
return (
|
||||
<Stack className={twMerge("w-full shrink-0 pt-5 pb-1")}>
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname || AdminHomePath}
|
||||
currentTitle={title}
|
||||
excludeRegexMatch={/^admin$/}
|
||||
linkProps={{
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
title,
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||
}}
|
||||
divider={
|
||||
<ChevronRight
|
||||
size={12}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ink-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -246,7 +246,10 @@ export const AppData = {
|
||||
HarnessCooldownMs: 60 * 1000,
|
||||
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
||||
/**
|
||||
* Model turns in one text run before it must stop and report.
|
||||
* Model turns in one text run before it must stop and report. The
|
||||
* install may save its own limit (the `harness_max_tool_steps` setting,
|
||||
* Settings > General), which wins over this one; a task run reads it
|
||||
* through `grabHarnessMaxToolSteps`.
|
||||
*/
|
||||
HarnessMaxToolSteps: 200,
|
||||
/**
|
||||
@@ -389,8 +392,17 @@ export const AppData = {
|
||||
WorkspaceActivityItems: 6,
|
||||
/**
|
||||
* Tool turns a chat reply may take. Long work goes to a task instead.
|
||||
* The install may save its own limit (the `bot_reply_max_tool_steps`
|
||||
* setting, Settings > General), which wins over this one; a reply
|
||||
* reads it through `grabReplyMaxToolSteps`.
|
||||
*/
|
||||
BotReplyMaxToolSteps: 8,
|
||||
/**
|
||||
* The most steps a saved run limit may ask for, for a task run or a
|
||||
* chat reply. A typo in the settings page can't turn one run into an
|
||||
* hour of tool calls, and a huge limit still ends with a report.
|
||||
*/
|
||||
BotStepLimitMax: 1000,
|
||||
/**
|
||||
* Chat attachments: per file, per message, and how much of a
|
||||
* document's text a reply reads.
|
||||
|
||||
@@ -110,6 +110,12 @@ export const CoordinatorBotName = "Coordinator";
|
||||
*/
|
||||
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
||||
|
||||
/**
|
||||
* The `app_settings` key holding the install's own limit on the tool turns
|
||||
* one chat reply may take. Unset means `AppData.BotReplyMaxToolSteps`.
|
||||
*/
|
||||
export const BotReplyMaxToolStepsSettingKey = "bot_reply_max_tool_steps";
|
||||
|
||||
/**
|
||||
* A bot's face is its status. There are no badges or spinners.
|
||||
*/
|
||||
|
||||
@@ -53,6 +53,12 @@ export const Harnesses = [
|
||||
|
||||
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
||||
|
||||
/**
|
||||
* The `app_settings` key holding the install's own limit on model turns in
|
||||
* one run (a task). Unset means `AppData.HarnessMaxToolSteps`.
|
||||
*/
|
||||
export const HarnessMaxToolStepsSettingKey = "harness_max_tool_steps";
|
||||
|
||||
/**
|
||||
* Vendors preferred for automatic picks, best first. Free models from any
|
||||
* other vendor come after these, and only if they look competent.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { BotReplyMaxToolStepsSettingKey } from "@/src/dict/bot-dict";
|
||||
import { HarnessMaxToolStepsSettingKey } from "@/src/dict/harness-dict";
|
||||
import readStepLimit from "@/src/utils/read-step-limit";
|
||||
import grabAppSetting from "./grab-app-setting";
|
||||
|
||||
/**
|
||||
* Model turns a task run may take: the install's own limit
|
||||
* (`harness_max_tool_steps`, Settings > General), else
|
||||
* `AppData.HarnessMaxToolSteps`. `runTextLoop`'s default is the code's one,
|
||||
* so a task run passes this in.
|
||||
*/
|
||||
export async function grabHarnessMaxToolSteps() {
|
||||
return readStepLimit({
|
||||
saved: await grabAppSetting({ key: HarnessMaxToolStepsSettingKey }),
|
||||
fallback: AppData.HarnessMaxToolSteps,
|
||||
max: AppData.BotStepLimitMax,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Model turns a chat reply may take: the install's own limit
|
||||
* (`bot_reply_max_tool_steps`, Settings > General), else
|
||||
* `AppData.BotReplyMaxToolSteps`.
|
||||
*/
|
||||
export async function grabReplyMaxToolSteps() {
|
||||
return readStepLimit({
|
||||
saved: await grabAppSetting({ key: BotReplyMaxToolStepsSettingKey }),
|
||||
fallback: AppData.BotReplyMaxToolSteps,
|
||||
max: AppData.BotStepLimitMax,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Both limits with their defaults, for the settings page.
|
||||
*/
|
||||
export default async function grabRunLimitsOverview() {
|
||||
const [task_steps, reply_steps] = await Promise.all([
|
||||
grabHarnessMaxToolSteps(),
|
||||
grabReplyMaxToolSteps(),
|
||||
]);
|
||||
|
||||
const overview: RunLimitsOverview = {
|
||||
task_steps,
|
||||
reply_steps,
|
||||
task_default: AppData.HarnessMaxToolSteps,
|
||||
reply_default: AppData.BotReplyMaxToolSteps,
|
||||
max: AppData.BotStepLimitMax,
|
||||
};
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
export type RunLimitsOverview = {
|
||||
/**
|
||||
* The limit a task run uses right now.
|
||||
*/
|
||||
task_steps: number;
|
||||
/**
|
||||
* The limit a chat reply uses right now.
|
||||
*/
|
||||
reply_steps: number;
|
||||
/**
|
||||
* What a task run uses when the install saved no limit.
|
||||
*/
|
||||
task_default: number;
|
||||
/**
|
||||
* What a chat reply uses when the install saved no limit.
|
||||
*/
|
||||
reply_default: number;
|
||||
/**
|
||||
* The most the settings page accepts.
|
||||
*/
|
||||
max: number;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio"
|
||||
import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-password-hash";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabRunLimitsOverview from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
|
||||
import runningVersion from "@/src/utils/running-version";
|
||||
@@ -45,6 +46,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
||||
has_password,
|
||||
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
||||
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
|
||||
run_limits: await grabRunLimitsOverview().catch(() => null),
|
||||
running_version: runningVersion({ env: process.env }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { AppContextObject, PagePropsType } from "@/src/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsShell from "./settings-shell";
|
||||
|
||||
type RenderProps = {
|
||||
pathname: string;
|
||||
/**
|
||||
* The page's own title, the name the header shows.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
function render({ pathname, title }: RenderProps) {
|
||||
const pageProps: PagePropsType = {
|
||||
url: { pathname } as PagePropsType["url"],
|
||||
};
|
||||
const context = { pageProps } as AppContextObject;
|
||||
|
||||
return renderToString(
|
||||
<AppContext.Provider value={context}>
|
||||
<SettingsShell
|
||||
title={title}
|
||||
description="Work your bots do on a schedule."
|
||||
>
|
||||
<span>PAGE_CONTENT</span>
|
||||
</SettingsShell>
|
||||
</AppContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsShell breadcrumbs", () => {
|
||||
test("a top-level page shows Home and itself", () => {
|
||||
const html = render({ pathname: "/admin/routines", title: "Routines" });
|
||||
|
||||
expect(html).toContain('aria-label="Breadcrumb"');
|
||||
expect(html).toContain(">Home<");
|
||||
expect(html).toContain(">Routines<");
|
||||
expect(html).toContain('href="/admin/routines"');
|
||||
});
|
||||
|
||||
test("a settings page walks through Settings up to the page", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/general",
|
||||
title: "General",
|
||||
});
|
||||
|
||||
expect(html).toContain('href="/admin"');
|
||||
expect(html).toContain('href="/admin/settings"');
|
||||
expect(html).toContain('href="/admin/settings/general"');
|
||||
});
|
||||
|
||||
test("a record page reads its name, not the id in its address", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/bots/3",
|
||||
title: "Scout",
|
||||
});
|
||||
|
||||
expect(html).toContain(">Scout<");
|
||||
expect(html).not.toContain(">3<");
|
||||
});
|
||||
|
||||
test("a page whose slug differs from its name shows the name", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/change-password",
|
||||
title: "Change password",
|
||||
});
|
||||
|
||||
expect(html).toContain(">Change password<");
|
||||
expect(html).not.toContain(">Change Password<");
|
||||
});
|
||||
|
||||
test("Activity keeps its name though its address says logs", () => {
|
||||
const html = render({ pathname: "/admin/logs", title: "Activity" });
|
||||
|
||||
expect(html).toContain(">Activity<");
|
||||
expect(html).not.toContain(">Logs<");
|
||||
});
|
||||
|
||||
test("the trail sits above the page's content, right after the header", () => {
|
||||
const html = render({ pathname: "/admin/routines", title: "Routines" });
|
||||
|
||||
expect(html.indexOf('aria-label="Breadcrumb"')).toBeLessThan(
|
||||
html.indexOf("PAGE_CONTENT"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots, which is home, has no trail above it", () => {
|
||||
const html = render({ pathname: "/admin/bots", title: "Bots" });
|
||||
|
||||
expect(html).not.toContain('aria-label="Breadcrumb"');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The record's name, shown where the URL has its id.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The hierarchy above a nested settings page: Settings / Bots / Scout.
|
||||
* Built from the path, with the record's name replacing its id.
|
||||
*/
|
||||
export default function SettingsBreadcrumbs({ title }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
|
||||
if (!pathname) return null;
|
||||
|
||||
return (
|
||||
<Stack className="w-full shrink-0 pt-5 pb-1">
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname}
|
||||
currentTitle={title}
|
||||
skipHome
|
||||
excludeRegexMatch={/^admin$/}
|
||||
linkProps={{
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
title,
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||
}}
|
||||
divider={
|
||||
<ChevronRight
|
||||
size={12}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ink-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import SettingsBreadcrumbs from "./settings-breadcrumbs";
|
||||
import AdminBreadcrumbs from "@/src/components/general/admin-breadcrumbs";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsPaneHeader from "./settings-pane-header";
|
||||
|
||||
type Props = {
|
||||
@@ -12,10 +13,6 @@ type Props = {
|
||||
* A wider column, for tables (activity).
|
||||
*/
|
||||
wide?: boolean;
|
||||
/**
|
||||
* A nested record page's name, for its breadcrumb trail.
|
||||
*/
|
||||
breadcrumb_title?: string;
|
||||
/**
|
||||
* Controls at the end of the pane header, e.g. a settings link.
|
||||
*/
|
||||
@@ -23,17 +20,19 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings pane beside the rail: a 64px header like the conversation
|
||||
* header, then one readable column that scrolls on its own.
|
||||
* The pane beside the rail: a 64px header like the conversation header,
|
||||
* then one readable column that scrolls on its own, opening with the
|
||||
* breadcrumb trail above the page.
|
||||
*/
|
||||
export default function SettingsShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
wide,
|
||||
breadcrumb_title,
|
||||
action,
|
||||
}: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<SettingsPaneHeader
|
||||
@@ -48,9 +47,15 @@ export default function SettingsShell({
|
||||
wide && "max-w-[1200px]",
|
||||
)}
|
||||
>
|
||||
{breadcrumb_title ? (
|
||||
<SettingsBreadcrumbs title={breadcrumb_title} />
|
||||
) : null}
|
||||
{/**
|
||||
* The page's own `title` names the last crumb: the
|
||||
* address would say the id of a record, or a slug that
|
||||
* differs from the name the page shows.
|
||||
*/}
|
||||
<AdminBreadcrumbs
|
||||
pathname={pageProps?.url?.pathname}
|
||||
title={title}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function BotSettingsSection() {
|
||||
? "Coordinates your crew and hands work to it"
|
||||
: "A worker bot.")
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<BotOverviewGroup bot={bot} />
|
||||
<BotDetailsGroup
|
||||
|
||||
-1
@@ -45,7 +45,6 @@ export default function ComputerSettingsSection() {
|
||||
computer.description ||
|
||||
"Its container, the bots that reach it, and its workspaces."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<ComputerOverviewGroup computer={computer} />
|
||||
{is_super_admin ? (
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from "react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { RunLimitsOverview } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||
|
||||
type Props = {
|
||||
limits?: RunLimitsOverview | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many model turns a task run and a chat reply may take, saved on its
|
||||
* own without a reload. Zero puts the code's default back for that one.
|
||||
*/
|
||||
export default function RunLimitsForm({ limits }: Props) {
|
||||
const [overview, setOverview] = useState(limits || undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [task_steps, setTaskSteps] = useState(String(limits?.task_steps ?? ""));
|
||||
const [reply_steps, setReplySteps] = useState(String(limits?.reply_steps ?? ""));
|
||||
/**
|
||||
* The inputs only read `defaultValue` when they mount, so discarding or
|
||||
* saving remounts them with the saved numbers.
|
||||
*/
|
||||
const [form_key, setFormKey] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
msg?: string;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
}>();
|
||||
|
||||
const save = async () => {
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<RunLimitsOverview>>(
|
||||
"/api/admin/settings/run-limits",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
task_steps: Number(task_steps) || 0,
|
||||
reply_steps: Number(reply_steps) || 0,
|
||||
},
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({
|
||||
error: true,
|
||||
msg: res?.msg || `Couldn't save the limits.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setOverview(res.singleRes);
|
||||
setTaskSteps(String(res.singleRes.task_steps));
|
||||
setReplySteps(String(res.singleRes.reply_steps));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus({
|
||||
success: true,
|
||||
msg: `Saved: ${res.singleRes.task_steps} steps per task, ${res.singleRes.reply_steps} per reply.`,
|
||||
});
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setTaskSteps(String(overview?.task_steps ?? ""));
|
||||
setReplySteps(String(overview?.reply_steps ?? ""));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save().catch(() => {});
|
||||
}}
|
||||
className="w-full gap-3! items-stretch"
|
||||
>
|
||||
<Stack className="w-full gap-3! items-stretch">
|
||||
<Input
|
||||
key={`task-${form_key}`}
|
||||
label="Tool steps per task"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={task_steps}
|
||||
onChange={(event) => {
|
||||
setTaskSteps(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix={`steps`}
|
||||
info={`How many turns the model may take before it must stop and report, when it works on a task. 0 uses the default (${overview?.task_default ?? ""}).`}
|
||||
/>
|
||||
<Input
|
||||
key={`reply-${form_key}`}
|
||||
label="Tool steps per chat reply"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={reply_steps}
|
||||
onChange={(event) => {
|
||||
setReplySteps(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix={`steps`}
|
||||
info={`How many turns a chat reply may take before it answers. Long work belongs in a task. 0 uses the default (${overview?.reply_default ?? ""}).`}
|
||||
/>
|
||||
</Stack>
|
||||
<SettingsSaveBar
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
status={status}
|
||||
save_label="Save limits"
|
||||
onDiscard={discard}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import RunLimitsForm from "../(partials)/run-limits-form";
|
||||
|
||||
/**
|
||||
* How long a bot may work on one task or reply before it reports. Only the
|
||||
* super admin sees it, since it changes every bot on the install.
|
||||
*/
|
||||
export default function RunLimitsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Run limits"
|
||||
description="How many turns a bot may take with its tools before it must stop and report. A task may take far more than a chat reply, since it does the long work."
|
||||
id="run-limits"
|
||||
>
|
||||
<RunLimitsForm limits={pageProps?.run_limits} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppearanceSection from "./(sections)/appearance-section";
|
||||
import NotificationsSection from "./(sections)/notifications-section";
|
||||
import PromptSection from "./(sections)/prompt-section";
|
||||
import RunLimitsSection from "./(sections)/run-limits-section";
|
||||
import StorageSection from "./(sections)/storage-section";
|
||||
import VersionSection from "./(sections)/version-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
@@ -15,6 +16,7 @@ export default function AdminSettingsGeneralPage() {
|
||||
<AppearanceSection />
|
||||
<NotificationsSection />
|
||||
<PromptSection />
|
||||
<RunLimitsSection />
|
||||
<StorageSection />
|
||||
<VersionSection />
|
||||
</SettingsShell>
|
||||
|
||||
@@ -4,7 +4,10 @@ import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsMcpsPage() {
|
||||
return (
|
||||
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
|
||||
<SettingsShell
|
||||
title="MCPs"
|
||||
description="MCP servers whose tools your bots can use."
|
||||
>
|
||||
<McpsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function UserSettingsSection() {
|
||||
description={
|
||||
types_title || person.email || "A user on this install."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<UserOverviewGroup
|
||||
person={person}
|
||||
|
||||
@@ -50,7 +50,6 @@ export default function WorkspaceOverview() {
|
||||
workspace.description ||
|
||||
"Context, bots, computers and models for this workspace."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<WorkspaceImageGroup workspace={workspace} />
|
||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabRunLimitsOverview, {
|
||||
type RunLimitsOverview,
|
||||
} from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { BotReplyMaxToolStepsSettingKey } from "@/src/dict/bot-dict";
|
||||
import { HarnessMaxToolStepsSettingKey } from "@/src/dict/harness-dict";
|
||||
import { SaveRunLimitsRequestSchema } from "@/src/schemas/harness-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: how many model turns a task run and a chat reply may
|
||||
* take. Zero puts the code's default back for that one.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<RunLimitsOverview>> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) throw new AppError(`Only a super admin can change the run limits.`);
|
||||
|
||||
const { task_steps, reply_steps } = parseWithSchema({
|
||||
schema: SaveRunLimitsRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await saveAppSetting({
|
||||
key: HarnessMaxToolStepsSettingKey,
|
||||
value: task_steps > 0 ? String(task_steps) : null,
|
||||
});
|
||||
await saveAppSetting({
|
||||
key: BotReplyMaxToolStepsSettingKey,
|
||||
value: reply_steps > 0 ? String(reply_steps) : null,
|
||||
});
|
||||
|
||||
await logEvent({
|
||||
title: "Bot run limits changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} set the tool step limits to ${task_steps || "default"} per task and ${reply_steps || "default"} per chat reply.`,
|
||||
metadata: { task_steps, reply_steps },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: await grabRunLimitsOverview() };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { Harnesses, type HarnessKind } from "@/src/dict/harness-dict";
|
||||
import {
|
||||
HarnessProviderKinds,
|
||||
@@ -96,6 +97,17 @@ export type SaveBotHarnessSettingsRequest = z.infer<
|
||||
typeof SaveBotHarnessSettingsRequestSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* The General settings' run limits: model turns a task run and a chat reply
|
||||
* may take. Zero puts the code's default back.
|
||||
*/
|
||||
export const SaveRunLimitsRequestSchema = z.object({
|
||||
task_steps: z.coerce.number().int().min(0).max(AppData.BotStepLimitMax),
|
||||
reply_steps: z.coerce.number().int().min(0).max(AppData.BotStepLimitMax),
|
||||
});
|
||||
|
||||
export type SaveRunLimitsRequest = z.infer<typeof SaveRunLimitsRequestSchema>;
|
||||
|
||||
export const PreviewVoiceRequestSchema = z.object({
|
||||
model_id: z.coerce.number().int().positive("Pick a speech model"),
|
||||
voice: z.string().trim().max(200).optional(),
|
||||
|
||||
@@ -1192,6 +1192,17 @@ export type PagePropsType = {
|
||||
setting_key: string;
|
||||
text: string;
|
||||
} | null;
|
||||
/**
|
||||
* Model turns a task run and a chat reply may take, with the code's
|
||||
* defaults (the General settings page).
|
||||
*/
|
||||
run_limits?: {
|
||||
task_steps: number;
|
||||
reply_steps: number;
|
||||
task_default: number;
|
||||
reply_default: number;
|
||||
max: number;
|
||||
} | null;
|
||||
/**
|
||||
* The release the server is running, for the General settings page.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hasBreadcrumbTrail from "./has-breadcrumb-trail";
|
||||
|
||||
describe("hasBreadcrumbTrail", () => {
|
||||
test("a top-level page has one, so a lone page never goes without", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/routines" })).toBe(true);
|
||||
});
|
||||
|
||||
test("a nested page has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/general" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/bots/3" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots is home, so it has none", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("the address that only redirects to home has none either", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("a folder in the files browser keeps the trail of its page", () => {
|
||||
expect(
|
||||
hasBreadcrumbTrail({
|
||||
pathname: "/admin/files?volume=bot&owner=1&path=reports",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("nothing outside the admin pages has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/auth/login" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({})).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("a path that merely starts like admin does not count", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/administrator" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
|
||||
type Params = {
|
||||
pathname?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an address has a trail above it: `Home`, then every section it
|
||||
* passes through, then the page. `twui`'s `Breadcrumbs` draws the trail
|
||||
* itself from the path, so this only answers which pages get one.
|
||||
*
|
||||
* The crew of bots is where the app lands, and its own address would
|
||||
* read `Home / Bots`, both the same page, so it has none. Neither does
|
||||
* anything outside the admin pages, since everything else redirects to
|
||||
* the bots.
|
||||
*
|
||||
* The trailing slash and the query string are ignored, so a folder opened
|
||||
* in the files browser keeps the trail of the page it is on.
|
||||
*/
|
||||
export default function hasBreadcrumbTrail({ pathname }: Params): boolean {
|
||||
if (!pathname) return false;
|
||||
|
||||
const path = pathname.split("?")[0]!.replace(/\/$/, "");
|
||||
|
||||
if (path != "/admin" && !path.startsWith("/admin/")) return false;
|
||||
|
||||
/**
|
||||
* `/admin` only redirects to the crew of bots, which is home.
|
||||
*/
|
||||
return path != AdminHomePath && path != "/admin";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import readStepLimit from "./read-step-limit";
|
||||
|
||||
const base = { fallback: 200, max: 1000 };
|
||||
|
||||
describe("readStepLimit", () => {
|
||||
test("uses a saved limit inside the allowed range", () => {
|
||||
expect(readStepLimit({ ...base, saved: "40" })).toBe(40);
|
||||
expect(readStepLimit({ ...base, saved: " 12 " })).toBe(12);
|
||||
expect(readStepLimit({ ...base, saved: "1" })).toBe(1);
|
||||
expect(readStepLimit({ ...base, saved: "1000" })).toBe(1000);
|
||||
});
|
||||
|
||||
test("falls back when nothing was saved", () => {
|
||||
expect(readStepLimit({ ...base, saved: undefined })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: null })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: " " })).toBe(200);
|
||||
});
|
||||
|
||||
test("falls back when the saved limit can't drive a run", () => {
|
||||
expect(readStepLimit({ ...base, saved: "0" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "-3" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "abc" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "12.5" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "NaN" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "12steps" })).toBe(200);
|
||||
});
|
||||
|
||||
test("caps at the ceiling, never past it", () => {
|
||||
expect(readStepLimit({ ...base, saved: "1001" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "999999" })).toBe(200);
|
||||
expect(readStepLimit({ fallback: 8, max: 50, saved: "51" })).toBe(8);
|
||||
expect(readStepLimit({ fallback: 8, max: 50, saved: "50" })).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
type Params = {
|
||||
/**
|
||||
* What the install saved, as it came out of `app_settings`: undefined or
|
||||
* empty when it never did.
|
||||
*/
|
||||
saved?: string | null;
|
||||
/**
|
||||
* The code's own limit, used whenever the saved one can't be trusted.
|
||||
*/
|
||||
fallback: number;
|
||||
/**
|
||||
* The most a saved limit may ask for.
|
||||
*/
|
||||
max: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a tool-step limit saved in the settings, falling back to the code's
|
||||
* default when it is missing, not a whole number, below one, or above the
|
||||
* ceiling. The settings page and the saved row are both untrusted input:
|
||||
* without this a typo (or a zero) would stop every run before its first
|
||||
* turn, and a huge number would let one run alone run for hours.
|
||||
*/
|
||||
export default function readStepLimit({ saved, fallback, max }: Params) {
|
||||
const text = String(saved ?? "").trim();
|
||||
|
||||
if (!text) return fallback;
|
||||
|
||||
const value = Number(text);
|
||||
|
||||
if (!Number.isInteger(value) || value < 1 || value > max) return fallback;
|
||||
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user