40 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 410d05e0b1 Release v0.2.0
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:06:34 +01:00
tbenandClaude Opus 5.5 19fac97d18 Default lucide icons to 16px
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:04:14 +01:00
tbenandClaude Opus 5.5 ea62dd11f0 Add bot types with a permission matrix, and workspace admin bots
A bot's powers came from bots.kind alone, so only the coordinator could
hand work to other bots. bot_types now works like user_types: a row with
no workspace holds everywhere (the coordinator), one with a workspace
only there (a workspace admin), and no row is a worker. bots.kind stays
the legacy marker of the one coordinator for who sees it.

The matrices in src/dict/bot-types-dict.ts say what each type may do,
whom it may direct and who grants it, read by one generic interpreter
(rbac/type-assignment.ts) that userTypeAssignment now wraps too. Every
run reads its powers once (grabBotRunPowers) and the chat tools, task
runs and task tools gate on them instead of kind; a run's powers are its
type's and its user's together.

In the workspace it leads, an admin hands tasks to the workers there
(results relayed back to it), sets their instructions, adds tasks to
their lists and keeps the workspace's context when its user may. Super
admins and admins grant it from the workspace's settings or the chat
header's menu, and the coordinator with set_bot_type. Admins are marked
with a filled green shield beside their name across the workspace, kept
live by pushBotUpdated's admin_workspace_ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:04:14 +01:00
tbenandClaude Opus 5.5 4161863587 Run whole-data archives and restores in the background
A data archive or restore ran inside its request, which sent nothing
until tar finished: Bun closes a silent socket after 10s (its default
idleTimeout in production) and a proxy cuts it at about 100s, so the
page showed a network error while the archive kept going, and a retry
said another backup was already running.

The routes now start the run (startDataBackupRun, refused while the
backup lock is held) and answer at once. The page follows it by polling
api/admin/backups/data-status: an archive reloads once written, a
restore once the status comes from a new process after its restart,
and a failure shows its error. A run in progress survives a reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:34:29 +01:00
tbenandClaude Opus 5.5 86becd50bc Keep every bot's computer work in its own workspace folder
Computers now mount only /home/gem/workspace from DATA_DIR/computers/<id>,
so only the workspace persists and goes into the data archive. An older
computer's whole home is moved aside to <id>-home-<ts> (kept, never
archived) and its container recreated once by sync-computers.

Each run works in bots/<bot>/users/<user> (also its cwd and HOME), with
bots/<bot> for work every user shares and workspaces/<id> shared by a
workspace's bots; the coordinator reaches the whole workspace. Computer
tools are gated by withComputerDirs: bash and code start there, file tool
paths and file: URLs outside the run's folders are refused, and so is sudo.
search_computers, import_file and script tools start there too.

The files browser shows members only the bots' folders they reach, their
own user folder in each and their workspaces', with names on the id folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:24:49 +01:00
tbenandClaude Opus 5.5 fefc295d15 Let the coordinator manage skills, MCP servers and tools, behind a confirm card
- Coordinator tools in its own chat (super admin only): list_extensions,
  set_extension_access, delete_extension, and update_skill /
  update_mcp_server / update_custom_tool for what can't change where
  anything is sent (on/off, names, arguments, left-out tools)
- Adding a skill, MCP server or tool, or changing a skill's words, a
  server's URL or headers or a tool's kind or config, goes through
  propose_skill / propose_mcp_server / propose_custom_tool: stored in
  extension_proposals with the bot's background check, shown as a
  [proposal:ID] chip that opens a card with Confirm and Reject
- The card's facts are worked out by the app from what Confirm saves: the
  host, every {{env.KEY}} and whether it's saved, the config; for a skill
  its links, hosts, tool names, mentions of secrets and a diff against
  the saved runbook
- Confirming decides once and checks again; a server or tool missing a key
  is saved switched off, and the outcome is posted to the bot in chat
- Never from a bot: stdio servers, a URL whose host a template decides,
  envs in a prompt tool, a key's value

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:08:09 +01:00
tbenandClaude Opus 5.5 e6c278198b Quiet the logs and capture the errors that were missing
- Activity page: "Delete all" empties the open tab (api/admin/logs/clear-logs,
  super admin only, logged under system)
- Stop logging every WebSocket connect
- Log unexpected API errors (safeErrorMessage -> logUnexpectedError), every
  failed bot job attempt and every job the queue lost
- Drop the cron jobs' console output; a failed model sync goes to the logs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:11:31 +01:00
tbenandClaude Opus 5.5 3a9e57f682 Fix the usage page's tables, range tabs and day bars
- Both tables lay their heading and rows on one shared grid, so every
  figure sits under its own heading (twui's Row wrapped the cells).
- The selected range reads as pressed: ink-filled in a hairline track.
- Each day's bar opens a small summary card on hover or tap.
- twui 0.1.7 places popovers before paint, so opening one no longer
  makes the layout flicker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:01:24 +01:00
tbenandClaude Opus 5.5 b0fe619ae8 Let bots take in messages sent while they reply
A reply read the session once at the start, so a message sent while it
ran was never seen, and the follow-up reply queued for it then finished
silently: the message was saved before the reply, so the thread ended
with the bot. Those messages went unanswered.

The text loop now reads new user messages after every round of tools and
before an answer goes out, and the bot decides from what each one means
whether it changes the work (folded in, or the unsent answer redrafted)
or is a separate request, which it acknowledges at once with the new
send_message tool and does after the current work. Messages sent that
way are marked interim, so the thinking row stays up while the bot
works. A reply job records the newest user message it read, and a queued
reply only stays silent when a finished reply already read everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:49:10 +01:00
tbenandClaude Opus 5.5 c13b9ea826 Show how each bot message was generated
Every bot message gets an info button that opens its details: the
provider and model behind it, tokens and cost, the job that ran it, each
model used and every call in order. Each model call now records how it
was made in token_usages.settings (generation settings sent, whose
settings they were, pinned or automatic model, fallbacks), shown per
model in the modal.

Live replies now carry their token usage, from the job's push and the
live relay alike, so it shows without a reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:33:47 +01:00
tbenandClaude Opus 5.5 feec69adca Share every bot and workspace picture with everyone signed in
A bot's or workspace's picture was only shared when its media row carried
the bot-image or workspace-image paradigm. A picture set before pictures
were copied into their own row points at the chat upload itself, so only
its uploader could load it and everyone else saw the default icon. A
picture is now shared because a bot or workspace points at it, whatever
its paradigm; profile photos still have to be the user's primary one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:08:16 +01:00
tbenandClaude Opus 5.5 d542b4e9ad Load a conversation's history on the client
The conversation pages rendered their messages on the server, so a page
the back button restored from the back-forward cache showed an old
thread. The server now renders everything else (rail, header, composer)
and the history (active session, latest messages, active task) loads
from api/admin/bots/conversation-history once the page is on screen,
with a loading line under the header, and again on a pageshow restore.
Messages sent or pushed while it loads are merged with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:55:38 +01:00
tbenandClaude Opus 5.5 cdb93ac1bc Fix file access, XSS and access-control bugs from a security scan
- Media rows can no longer be created through the CRUD API, updates only
  take description fields on the caller's own row, and every media read
  or delete goes through mediaFilePath, which refuses paths outside
  DATA_DIR or inside the database folder.
- Uploads strip the file name and extension to safe characters and keep
  only title, description and summary from the request's media object.
- Folder imports no longer follow symlinks (walkRegularFiles), and the
  source path is quoted in the computer's shell.
- User and bot files are served with nosniff, and HTML, SVG, XML and
  scripts are sandboxed by CSP.
- Sandbox cookies are kept to the computer's prefix, and websocket
  upgrades on /ws and the computer proxy must come from the app's own
  address (README and Help note nginx's Host header).
- user_types CRUD writes go through the assignment matrix; members only
  read their own user record; read-only users can read CRUD again;
  grabUsers builds valid SQL for array search terms and bad pages; an
  admin media DELETE needs an id; read-only users can't delete media.
- Rate limits and the audit log use the connection's address, trusting
  forwarded headers only from a proxy, and login limits are split per
  address, per account from that address and per account overall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:38:07 +01:00
tben 2e8e8de976 Release v0.1.8
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
2026-10-23 18:00:06 +01:00
tben e75ab28041 Open a computer chip in the viewer instead of switching to it
A computer a bot names in a reply is now a button that opens it in the
shared computer viewer modal, the bot's own computer included (sunken and
checked, as before). Switching stays in the header's computers modal, so
the chip has one job. A computer the bot can no longer use stays a plain
name.
2026-10-23 17:58:08 +01:00
tben 4261535741 Tell every bot its own id, in every run
The bot's id never reached its prompt: `composeBotPrompt` only took its
instructions, the appendix, its memory and its workspace, and
`grabBotPrompt` never passed `bot.id` or `bot.name` on. A bot could only
learn its id by accident, from a tool description that named it, so
"which bot am I" was unanswerable in most contexts.

- Open the prompt with who the bot is (`composeIdentity`): its name,
  whether it's the coordinator or a worker, its role, and its id, said
  to be how tools and refs point back at it. `grabBotPrompt` now passes
  the bot, which covers chat replies, tasks, routines and delegated
  tasks, since both runners build their prompt there.
- Turn the notes' example refs into functions bound to the running bot:
  `chatReplyNote`, `botFilesNote` and `coordinatorReplyNote` take its id,
  so the folder-ref example no longer points at a hardcoded `owner_id: 5`
  (another bot's folder) nor `[bot:3]`. ComputerFilesNote keeps the
  computer it's given.
- Stop `create_task` from hiding the worker's own id in its own chat.

Tests: four new `composeBotPrompt` cases pin the identity block (worker,
coordinator, unnamed, no bot at all), and a new `harness-prompts.test.ts`
checks each note's own-folder and self refs point at exactly the running
bot, for three ids.
2026-10-23 17:40:28 +01:00
tben 0166f31cfc Release v0.1.7
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
2026-10-23 16:50:51 +01:00
tben caa9ad87e4 Remove sticky breadcrumbs 2026-10-23 16:49:52 +01:00
tben 30a4df183c Give every admin page a breadcrumb trail
The trail is twui's Breadcrumbs reading Home, the sections and the page
off the address, rendered as the first thing in SettingsShell's content
column, so every page that is not a conversation gets one without a page
touching it.

The last crumb is named by the page's own title rather than the address:
a record reads Scout instead of its id, /admin/logs reads Activity, and
/admin/settings/mcps reads MCPs. That replaces the breadcrumb_title prop
the four settings record pages had to pass, so settings-breadcrumbs.tsx
goes.

hasBreadcrumbTrail drops the trail on the crew of bots, home, and on
anything outside /admin; the trailing slash and query string are ignored,
so a folder in the files browser keeps the trail of the page it is on.
2026-10-23 16:49:01 +01:00
tben ee687e1d31 Chunk Tailwind classes with tailwind-merge
Say it once in AGENTS.md: a className is twMerge(...) with one chunk per claim, so a later chunk beats an earlier one instead of depending on CSS source order.
2026-10-23 16:42:01 +01:00
tben 024d847b9a Let the install set how many steps a run may take
The two limits were compiled in: a task run stopped at
AppData.HarnessMaxToolSteps (200) and a chat reply at
AppData.BotReplyMaxToolSteps (8), so an install that needed a longer task
had to rebuild.

Both are now `app_settings` rows (`harness_max_tool_steps` and
`bot_reply_max_tool_steps`), read at the two loop call sites through
grabHarnessMaxToolSteps and grabReplyMaxToolSteps, and edited by the super
admin on Settings > General beside the prompt appendix. Zero clears a
limit back to the code default, like the bot file quota.

readStepLimit keeps the saved number only when it is a whole number of at
least 1 and no more than AppData.BotStepLimitMax (1000), so a blank, a
zero or a typo falls back instead of stopping every run before its first
turn, and a huge number still ends in the wrap-up report.

runTextLoop keeps AppData.HarnessMaxToolSteps as its default, so it stays
free of database reads and its tests run as they did.
2026-10-23 16:20:38 +01:00
tben 06586a6dc8 Release v0.1.6
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
2026-10-23 16:05:01 +01:00
tben 55fc5ee97b Follow the thread's bottom, and offer a jump back to it
Sending a message while scrolled up left it out of sight: the thread only
ever anchored its bottom, so nothing brought the view down. The column now
follows new messages while it sits at the bottom, stays put while it's
scrolled up, and always brings a message the user just sent into view.
While the thread sits above its bottom, "Jump to latest" floats over it.

Whether the thread is at its bottom is read from where the content ends
rather than from `scrollTop`, whose sign a reversed column reports
differently across engines. Content that grows after it lands (an image,
a wider window) is followed too, so the newest message never slips under
the composer.
2026-10-23 16:03:37 +01:00
tben d9aea8e9c8 Record what every model call spends, and show it
Nothing anywhere recorded what a model call cost: the harness used a
provider's usage numbers only to decide whether a turn had finished, then
dropped them. So a reply could not say what it took to make, one model
could not be compared with another on price, and there was no way to ask
what a day, a week or a month of the crew's work came to.

- `token_usages` records one row per call: user, bot, workspace, session,
  job and message, the harness, the provider and model that actually
  answered, the prompt and completion counts, and the cost when the
  provider reports one.
- `harness-usage-scope.ts` carries those ids in an AsyncLocalStorage
  scope that `run-bot-job.ts` enters once, so a run's turns, its session
  summary, its transcriptions, images and embeddings are all attributed
  without threading ids through every harness function. Nested runs merge
  rather than replace, and a null genuinely clears a field (a bot's own
  chat has no workspace).
- Work with no run behind it is charged to its owner instead:
  `run-with-fallback-scope.ts` reaches the workspace's creator else the
  bot's, and deliberately does not override a real run, so an ingest a
  bot asked for is not misattributed to the bot's owner.
- All six harnesses record through `withCandidates`'s `on_success`.
  Recording never throws: a model that answered must not lose its answer
  to a failed insert.
- A job's calls land before its message exists, so `complete-bot-job.ts`
  back-fills `message_id` once the message is inserted.
- The message's actions row shows what it cost, beside the timestamp and
  the play button, with the breakdown in its title.
- `/admin/usage` totals the install's spending for 7, 30 or 90 days: the
  total against the previous period, a day-by-day chart, a split per kind
  of call, and a table per model with the price per thousand tokens
  worked out from cost and tokens, which is what makes two models
  comparable. Crew managers only, since it is the whole install's work
  rather than one user's conversation. It sits with Routines and Activity
  in the user popover rather than under Settings, because it is a record
  of what happened, not something to configure.

Days are grouped in local time (`date(created_at / 1000, 'unixepoch',
'localtime')`), matching the local midnights the window builds and the
`YYYY-MM-DD` buckets it looks up. Grouping by the bare UTC day put a late
evening's calls under a name no bucket carried, so they vanished and the
chart's total silently disagreed with the totals card; a test pins it.

`canSeeRailItem` now decides who sees a popover or rail entry. The two
each filtered with their own inline rule and only one understood
`can_view`, so a gated page could be offered in the popover to everyone.
2026-10-23 15:41:41 +01:00
tben 0048324d8a Let a delegated task choose the workspace it runs in
delegate_task had no workspace argument: a task handed over from the
coordinator's own chat always ran in the worker's own chat, on that
worker's memory, files and directly assigned computers alone, even when
the work belonged to a workspace. The coordinator could not say where the
work goes, and the prompt never said the two differ.

- delegate_task takes an optional workspace_id: the worker runs the task
  in that workspace's context, with its own instructions and memory
  there, the project's files and the workspace's computers, and the
  result lands in its conversation in that workspace.
- A worker missing from that workspace is added first (pushBotCreated)
  and the reply says so.
- Handed over from inside a workspace the task always runs there; a
  different workspace_id is refused instead of ignored.
- The coordinator prompt, CoordinatorReplyNote and the tool description
  now spell the distinction out.
2026-10-23 14:25:21 +01:00
tben f7ef218507 Take the Gitea registry out of CI and mirror it separately
CI / check (push) Canceled after 0s
The Gitea registry is on a VPN, which a GitHub runner can never reach, so
the mirror step in the release workflow could only ever fail: its last four
releases reported a step that did nothing. The workflow now builds and
pushes to GHCR alone, which is the registry the installer defaults to, and
nothing in CI mentions Gitea.

The image reaches the Gitea registry through `scripts/mirror-gitea.sh`
(`bun run mirror:gitea`) instead, run in parallel with a release and from the
VPN side: it copies from the Gitea host over ssh, where skopeo can see both
registries, so nothing has to be installed there. `skopeo copy --all` keeps
the manifest list and its signatures, and every destination tag is read back
and compared with GHCR's digest, so a partial copy fails loudly instead of
being assumed to have worked. `latest` and `major.minor` are only moved while
that release still owns them in GHCR, so mirroring an older release never
rolls them back.

Neither direction can fail the other now, which is the point: a registry that
is down, slow or full no longer takes a release with it.
2026-10-23 12:03:23 +01:00
tben aac5d41474 Release v0.1.5
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
2026-10-23 10:21:06 +01:00
tben fcad4b49d6 Let a user stop a bot, and say the app is still alpha
The thinking row and the header menu both carry Stop while a bot has work
open for the user anywhere: every pending or processing job of that bot in
their conversations is failed as stopped, its task closed, a Stopped step
recorded and one closing message posted per session, so the thread stops
waiting and the bot goes idle. The handler running the job is aborted from
this process through a registry, so a job stuck waiting on a model stops at
once instead of on its next heartbeat, and a late face change after the
abort is dropped.

The release stage is now said in the app too: Settings > General reports
"Cobble v0.1.5 (alpha)" next to development, and the README and RELEASING.md
say what alpha means for installs.
2026-10-23 10:19:41 +01:00
tben 5beb5f3de4 Let a conversation's menu show the routines that run in it
The header menu had Tasks but nothing for the schedules whose results
land in that conversation. It now has Schedules, right under Tasks,
opened in a modal that lists only what runs there: a bot's own chat holds
the routines that run nowhere, a workspace only that bot's routines in
it (`routineScopeSql`, tested against SQLite).

The list loads when the modal opens, through
`api/admin/bots/list-routines`, which checks the user chats in that
conversation first, so the page props stay small. `grabCronJobs` gained
an optional `workspace_id`: left out it still means everywhere.

The routines page's row, form and edit modal moved to
`src/components/general/`, and its subtitle to `src/utils/`, since both
the page and the modal use them.
2026-10-23 07:23:15 +01:00
tben 796c53716f Show a mirror failure, and link the release's own range
CI / check (push) Canceled after 0s
2026-10-23 06:54:06 +01:00
tben 5af2f0362a Fetch the tags in CI so the publish tests can read them 2026-10-23 06:13:14 +01:00
tben b3afda9ed8 Stop the tamper test from tampering with its own prefix
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
2026-10-23 06:10:41 +01:00
tben eb29e2bca9 Bump the version to 0.1.4 2026-10-23 06:10:05 +01:00
tben 41bb4aaac2 Let a super admin read the database from the app
A new super admin settings page, /admin/settings/database, lists every
table with its record count and size, and one page of the open table's
records newest key first. Values are cut inside SQL (400 characters, with
a flag saying so) and a record opens whole in a modal, so a table of long
texts reads without shipping it all. A search matches every column's
text, word by word or as a phrase, which is the one thing that also works
on a virtual table. Nothing in it writes: the catalog, the records and
the single record are all reads, GET only.
2026-10-23 06:03:42 +01:00
tben 9b26c169d6 Let a text run take 200 steps
A task that works through a folder or a long computer session was
running out of steps before it could report.
2026-10-23 06:01:38 +01:00
tben b5c3b7d126 Share every picture with everyone signed in
A profile photo, a bot's picture and a workspace's picture all come from
one condition now (sharedPictureMediaSql), so a face is never blank just
because its owner isn't in the viewer's rail. Anything that isn't a
picture stays behind its own check.
2026-10-23 06:01:34 +01:00
tben 93e3a4e3b0 Say what media.text_content is for in the schema
The column holds extracted text only; raw binary never goes in it.
2026-10-23 06:01:24 +01:00
tben f2802c1bf3 Let bots set a picture from a file they can see
A coordinator asked to set an uploaded logo as a workspace picture could
not: the picture tools take a media id, and nothing ever gave the bot one.
Attachments reached the model as "[Attached image \"logo.png\"]", every
file tool answers with a [file:ID] ref, and an SVG is deliberately stored
as a document rather than an image.

- copyMediaAsPicture copies an approved image into a new picture row
  (bot-image/workspace-image). The source keeps its row and bytes, so a
  message that attached it still loads and changing a picture can never
  take a file away from a conversation.
- grabPictureSource resolves a picture by [file:ID] (the same scope gate
  every file tool uses) or by the user's own upload; parseFileRef reads
  the [file:12] form.
- The new set_picture tool ships on every run: a bot sets its own face,
  the coordinator can also dress a worker. update_workspace gained
  picture_file_id, and setBotPicture/setProfileImage share the copy and
  the delete.
- removeReplacedPicture deletes a replaced picture only when it really is
  one, never a chat upload.
- uploadAndRecordMedia writes images in their own format: PNG stays PNG
  with its transparency, WebP stays WebP, GIF becomes PNG, everything
  else JPEG, and the row records the real mime type (it used to say jpeg
  for every image).
- Pictures change live: BotUpdatedData carries image_media_id and a new
  server:workspace-updated pushes workspace pictures, relayed between
  processes.
- Attachments are labelled with their media id, and BotFilesNote tells a
  bot that a [file:ID] can become a picture.
- grabRunScopes is now the single source of a run's reachable folders,
  shared by the file tools, imports and the picture tools.
2026-10-23 05:24:55 +01:00
tben d3357cb7df Stop storing raw file bytes in media.text_content
`text_content` is an extracted-text cache, but nothing stopped it from
holding a dump of bytes:

- `fileToText` ended its switch with `default: return buffer.toString("utf-8")`
  and defaulted a missing extension to "txt", so any binary was decoded.
- Both extraction callers gated on `media_type == "document"`, and
  `attachmentFileKind` classes everything non-image/audio/video as a
  document: an SVG, a zip, a spreadsheet, a .bin.
- Nothing capped the length, so a 20 MB attachment could leave 20 MB of
  text in the database.
- `storeChatAttachments` copied the text onto a second `media` row, and
  `SELECT m.*` pulled the column into page props and API responses.

On a real install an SVG held 6852 characters of markup and a
`.designcraft` file held 142 KB of raw bytes.

Changes:

- `file-to-text.ts` lists the text extensions explicitly and returns
  undefined for anything else; no extension is no text.
- `can-extract-text.ts` is the one allowlist (reusing
  `BotTextFileExtensions`), used by both upload routes and by
  `uploadAndRecordMedia`; `extension-of.ts` is shared with the storage
  catalog instead of duplicated.
- `BotAttachmentTextStoreMaxChars` (200k) caps the column via
  `cap-text-content.ts`.
- `store-chat-attachments.ts` no longer copies the text; ingestion
  re-reads the bytes once.
- `media-columns.ts` selects every media column but `text_content`, which
  now has to be asked for (`grab-messages-media`'s reply path and
  ingestion do); the media CRUD routes sanitize rows they write.
- `ingest-bot-file.ts` decides on size alone, since a cached slice made
  an oversized file look already read.
- `bun run media:prune-text` cleans rows that were already stored
  (rerunnable, with a VACUUM hint).

Verified on the dev database: both bad rows cleared (146 KB), rerun
reports nothing to do.
2026-10-23 05:22:04 +01:00
tben 5475241371 Move the notification settings onto the general settings page 2026-10-23 04:11:21 +01:00
571 changed files with 22826 additions and 1510 deletions
+4
View File
@@ -16,7 +16,11 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
# The publish tests read real release tags out of the history, so a
# shallow, tagless clone makes them fail.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
+19 -40
View File
@@ -1,5 +1,12 @@
name: Release image
# Builds and pushes the multi-arch image to GHCR, the registry the installer
# defaults to.
#
# The Gitea registry is on a VPN, which a GitHub runner can never reach, so CI
# does not touch it at all: `scripts/mirror-gitea.sh` copies a release into it
# from the VPN side, in parallel and after the fact. Keeping the two apart is
# what makes a registry outage unable to fail a release.
on:
release:
types: [published]
@@ -30,13 +37,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Gitea
uses: docker/login-action@v3
with:
registry: git.tben.me
username: ${{ secrets.GITEA_USERNAME }}
password: ${{ secrets.GITEA_TOKEN }}
# The version the app reports on Settings > General when the build
# isn't a published release.
- name: Read the source version
@@ -45,6 +45,11 @@ jobs:
set -euo pipefail
echo "version=$(sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' package.json | head -n1)" >> "$GITHUB_OUTPUT"
# The same four tags for every kind of build: a release gets its own
# version plus the moving `latest` and `major.minor`, so that
# scripts/mirror-gitea.sh can copy whichever of them still belong to
# it, and a dispatch build gets the commit tag only, since it is not a
# release and must not move `latest`.
- name: Work out image tags
id: tags
env:
@@ -57,32 +62,24 @@ jobs:
if [ "$EVENT" = "release" ] && [ -n "$RELEASE_TAG" ]; then
version="$RELEASE_TAG"
minor="$(printf '%s' "$version" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/\1.\2/')"
ghcr="$(printf '%s\n' \
tags="$(printf '%s\n' \
"ghcr.io/moduletrace/cobble:latest" \
"ghcr.io/moduletrace/cobble:${version}" \
"ghcr.io/moduletrace/cobble:${minor}" \
"ghcr.io/moduletrace/cobble:${sha}")"
gitea="$(printf '%s\n' \
"git.tben.me/moduletrace/cobble:latest" \
"git.tben.me/moduletrace/cobble:${version}" \
"git.tben.me/moduletrace/cobble:${minor}" \
"git.tben.me/moduletrace/cobble:${sha}")"
else
version="$SOURCE_VERSION"
ghcr="ghcr.io/moduletrace/cobble:${sha}"
gitea="git.tben.me/moduletrace/cobble:${sha}"
tags="ghcr.io/moduletrace/cobble:${sha}"
fi
{
echo "version=$version"
echo "ghcr<<EOF"
echo "$ghcr"
echo "EOF"
echo "gitea<<EOF"
echo "$gitea"
echo "tags<<EOF"
echo "$tags"
echo "EOF"
} >> "$GITHUB_OUTPUT"
# The registry the installer defaults to, so this one must succeed.
# The only push CI makes. The installer defaults to this registry, so
# this step failing has to fail the release.
- name: Build and push to GHCR
uses: docker/build-push-action@v6
with:
@@ -92,26 +89,8 @@ jobs:
push: true
build-args: |
COBBLE_VERSION=${{ steps.tags.outputs.version }}
tags: ${{ steps.tags.outputs.ghcr }}
tags: ${{ steps.tags.outputs.tags }}
provenance: false
sbom: false
cache-from: type=gha
cache-to: type=gha,mode=max
# The mirror's registry, from the image just built: no second build,
# and Cloudflare rate limiting git.tben.me (429/1015 on blob uploads)
# can never fail a release the installer depends on.
- name: Mirror to Gitea
continue-on-error: true
env:
GITEA_TAGS: ${{ steps.tags.outputs.gitea }}
GHCR_IMAGES: ${{ steps.tags.outputs.ghcr }}
run: |
set -euo pipefail
first_ghcr="$(printf '%s\n' "$GHCR_IMAGES" | head -n1)"
args=()
while IFS= read -r tag; do
[ -n "$tag" ] || continue
args+=(--tag "${tag}")
done <<< "$GITEA_TAGS"
docker buildx imagetools create "${args[@]}" "${first_ghcr}"
+36 -19
View File
File diff suppressed because one or more lines are too long
+25 -10
View File
@@ -5,7 +5,7 @@
<img src="docs/readme/lockup-dark.svg#gh-dark-mode-only" alt="Cobble" height="56">
</h1>
A crew of small AI worker bots you host yourself.
A crew of small AI worker bots you host yourself (alpha).
![Cobble: a crew of small AI worker bots you host yourself](docs/readme/hero-light.png#gh-light-mode-only)
![Cobble: a crew of small AI worker bots you host yourself](docs/readme/hero-dark.png#gh-dark-mode-only)
@@ -19,6 +19,13 @@ A crew of small AI worker bots you host yourself.
</div>
> **Alpha.** Cobble is still in alpha: interfaces, database schema and
> settings change between releases, and things break. Install it to try it
> out and to tell us what is wrong, but keep your own backups (Settings >
> Backups) and do not put it in front of work you cannot lose. Every release
> is a real release: the installer updates an existing install in place and
> keeps its data.
Cobble is a self-hosted crew of small AI worker bots. You get a coordinator
and a set of workers, each with its own job, models, memory and workspace.
They talk to you in one chat, share workspaces, run routines on a schedule,
@@ -49,6 +56,7 @@ your data on your own machine.
| Crew | One coordinator and as many workers as you need, each with a name, a role, a hue and its own core instructions |
| Rail | The coordinator, your recent workers and their latest sessions, then your workspaces, each row carrying its status line |
| Chat | Markdown with @-mentions for bots and #-mentions for workspaces, replies and quotes, reactions, attachments, older messages on scroll, drafts kept locally |
| Stop | Stop a bot from the thinking row or the header menu: its queued and running work stops at once, everywhere you chat with it, and it goes idle |
| Header | The bot's model, provider and computer at a glance, one click from either, and its picture, instructions and settings on the avatar |
| Workspaces | A shared home for a group of bots: shared context, per-workspace instructions and memory, a default computer, and a dashboard |
| Memory | Each bot keeps core memory, memory about you, and workspace memory, and rewrites it as it works |
@@ -118,6 +126,10 @@ point that at `http://<this machine>:7227`:
sign-in cookies `Secure`; Caddy does this by default, and nginx needs
`proxy_set_header X-Forwarded-Proto $scheme;`.
- Pass `/ws` through as well as ordinary requests, including the upgrade.
- Keep the address the browser used in `Host` (or send it as
`X-Forwarded-Host`). Live updates and the computers' terminals only accept
a websocket from the app's own address; Caddy and Cloudflare Tunnel keep
it, and nginx needs `proxy_set_header Host $host;`.
- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
the installable app, offline support, Web Push notifications and voice
recording stay off until the address is HTTPS.
@@ -237,15 +249,16 @@ queue and Web Push.
## Images
Every release publishes a multi-arch image to both registries:
Every release publishes a multi-arch image to
`ghcr.io/moduletrace/cobble`, which is what the installer pulls. Tags are
`latest`, the release version, `major.minor` and the commit sha.
- `ghcr.io/moduletrace/cobble`
- `git.tben.me/moduletrace/cobble`
Tags are `latest`, the release version, `major.minor` and the commit sha.
Install from Gitea with
`--image git.tben.me/moduletrace/cobble`, or build from source with
`docker build -t cobble:local .` and `--image cobble --version local --no-pull`.
The same image is copied into `git.tben.me/moduletrace/cobble` by
`bun run mirror:gitea`, which runs after the build over the VPN (the Gitea
registry is not on the public internet, so GitHub Actions cannot reach it).
Install from Gitea with `--image git.tben.me/moduletrace/cobble`, or build
from source with `docker build -t cobble:local .` and
`--image cobble --version local --no-pull`.
A release is published from a clean `main` with the two scripts, which tag the
`package.json` version, push and create the release objects:
@@ -256,7 +269,9 @@ scripts/publish-gitea.sh # or: bun run publish:gitea
```
Both take `--version VERSION`, `--dry-run` and `--yes`. Only the GitHub release
builds the image; see [RELEASING.md](RELEASING.md) for the full checklist.
builds the image, and the Gitea registry is filled in afterwards, once that
build has finished and from the VPN, by `bun run mirror:gitea`; see
[RELEASING.md](RELEASING.md) for the full checklist.
## Development
+20 -6
View File
@@ -3,6 +3,12 @@
Cobble ships as a multi-arch container image and a single `install.sh`.
The repository starts private and is made public at the launch gate below.
Cobble is in **alpha** (see the note in `README.md`). Every release published
before v1.0.0 is an alpha release: interfaces, the database schema and the
settings may change between them, so a release that needs a migration step
has to say so in its notes, and Settings > General keeps saying the stage
until v1.0.0.
## One-time setup
- [ ] `GITEA_USERNAME` and `GITEA_TOKEN` are set as GitHub Actions secrets.
@@ -56,16 +62,24 @@ them in that order, since only the GitHub release builds images.
gh release create v0.1.0 --title v0.1.0 --notes-file notes.md --latest
7. [ ] The release workflow builds `linux/amd64` and `linux/arm64` and
pushes to both registries:
- `ghcr.io/moduletrace/cobble`
- `git.tben.me/moduletrace/cobble`
pushes to `ghcr.io/moduletrace/cobble`, the registry the installer
defaults to. It does not touch Gitea: that registry is on the VPN,
and a GitHub runner cannot reach it.
8. [ ] Check the manifests:
docker buildx imagetools inspect ghcr.io/moduletrace/cobble:latest
docker buildx imagetools inspect git.tben.me/moduletrace/cobble:latest
9. [ ] Confirm the GHCR package is public and pull it anonymously.
10. [ ] Publish the release to Gitea as well:
10. [ ] Once the build has finished, copy the image into the Gitea registry
from the VPN:
GITEA_TOKEN=... bun run mirror:gitea # or: scripts/mirror-gitea.sh
It copies the version and commit tags, plus `latest` and
`major.minor` while this release still owns them, and reads every
destination digest back. A failure here does not affect the release:
the installer keeps working from GHCR.
11. [ ] Publish the release to Gitea as well:
scripts/publish-gitea.sh # or: bun run publish:gitea
@@ -73,7 +87,7 @@ them in that order, since only the GitHub release builds images.
git push gitea main
git push gitea refs/tags/v0.1.0
11. [ ] Update every install with the one-liner:
12. [ ] Update every install with the one-liner:
curl -fsSL https://url.moduletrace.com/cobble | sudo bash
+2 -2
View File
@@ -9,7 +9,7 @@
"@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.6",
"@moduletrace/twui": "^0.1.7",
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0",
@@ -255,7 +255,7 @@
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
"@moduletrace/twui": ["@moduletrace/[email protected].6", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.6/twui-0.1.6.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-BFNIV1itkIYXmhq1rBJA8E8MjwN9AP7MESbOnzM0yUb6xbsOFIBbmlKfUu6kTr7QCl1TWNxWTGFPVmQ2S54cDA=="],
"@moduletrace/twui": ["@moduletrace/[email protected].7", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.7/twui-0.1.7.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-/Y9QyhtQsoEAJO+ahlGYQaYMx3gUWS+DlSkaBVjAIY5V9OQ6i7qhhqMNcmnfOi9m3eyAze1uW00jox+RDUqLpA=="],
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
+356 -17
View File
File diff suppressed because it is too large Load Diff
+185 -2
View File
@@ -19,6 +19,7 @@ export const BunSQLiteTables = [
"cron_jobs",
"workspaces",
"workspace_bots",
"bot_types",
"workspace_users",
"bot_users",
"bot_user_memories",
@@ -36,6 +37,7 @@ export const BunSQLiteTables = [
"mcp_servers",
"custom_tools",
"skills",
"extension_proposals",
"bot_mcp_servers",
"workspace_mcp_servers",
"bot_custom_tools",
@@ -48,6 +50,7 @@ export const BunSQLiteTables = [
"envs",
"bot_files",
"bot_file_chunks",
"token_usages",
"bot_file_vectors",
"file_ingest_queue",
"app_settings",
@@ -146,6 +149,9 @@ export type BUN_SQLITE_COBBLE_MEDIA = {
media_write_path?: string;
media_thumbnail_write_path?: string;
mime_type?: string;
/**
* For text content only. Raw binary text is not allowed.
*/
text_content?: string;
is_primary?: 0 | 1 | "";
is_rag_ingested?: 0 | 1 | "";
@@ -188,7 +194,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
user_id?: number | "";
name?: string;
/**
* One coordinator per owner; everything else is a worker
* Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from `bot_types`
*/
kind?: "worker" | "coordinator" | "";
/**
@@ -305,6 +311,10 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
* Renders the Needs you chip under the message
*/
needs_you?: 0 | 1 | "";
/**
* Sent mid-reply with send_message (an acknowledgement, a heads-up): the bot is still working on the reply, so the thinking row stays up while its face is working
*/
interim?: 0 | 1 | "";
/**
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
*/
@@ -758,6 +768,35 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
memory?: string;
}
export type BUN_SQLITE_COBBLE_BOT_TYPES = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
bot_id?: number | "";
bot_type?: "coordinator" | "admin" | "worker" | "";
/**
* The workspace the type holds in; null for install-wide
*/
workspace_id?: number | "";
/**
* The user who granted it (or for whom the granting bot ran). Not a foreign key
*/
granted_by_user_id?: number | "";
/**
* The bot that granted it from chat (the coordinator), if any. Not a foreign key
*/
granted_by_bot_id?: number | "";
}
export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
/**
* The unique identifier of the record.
@@ -1355,6 +1394,75 @@ export type BUN_SQLITE_COBBLE_SKILLS = {
user_id?: number | "";
}
export type BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)
*/
kind?: "mcp" | "tool" | "skill" | "";
/**
* The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm
*/
extension_id?: number | "";
/**
* The server's or tool's name, for the chip
*/
name?: string;
/**
* JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)
*/
data?: string;
/**
* The bot's background check, markdown: who publishes it, whether it's official, what it needs
*/
research?: string;
/**
* JSON array of the URLs the background check read
*/
sources?: string;
status?: "pending" | "confirmed" | "rejected" | "replaced" | "failed" | "";
/**
* What happened on confirm (saved, connected, keys missing) or why it failed
*/
outcome?: string;
/**
* The server or tool it saved, once confirmed
*/
result_id?: number | "";
/**
* A pending proposal can't be confirmed after this (Unix ms)
*/
expires_at?: number | "";
decided_at?: number | "";
/**
* The super admin whose conversation it was proposed in
*/
user_id?: number | "";
/**
* The bot that proposed it (the coordinator). Not a foreign key
*/
bot_id?: number | "";
/**
* The conversation it was proposed in, where the outcome is reported. Not a foreign key
*/
session_id?: number | "";
/**
* The session's workspace, null for the bot's own chat. Not a foreign key
*/
workspace_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
/**
* The unique identifier of the record.
@@ -1714,6 +1822,81 @@ export type BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS = {
token_estimate?: number | "";
}
export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* Whose work it was: the user whose conversation the call ran for
*/
user_id?: number | "";
/**
* The bot it was for, when it was for one
*/
bot_id?: number | "";
/**
* The workspace the call was in; null for a bot's own chat
*/
workspace_id?: number | "";
/**
* The conversation it was in
*/
session_id?: number | "";
/**
* The message it produced, so the thread can show what an answer cost. Null for calls that produce no message (naming a session, embedding a file)
*/
message_id?: number | "";
/**
* The queued job it ran for, when it ran for one
*/
job_id?: number | "";
/**
* What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding
*/
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
/**
* The provider that served it. Not a foreign key: it stays readable after the provider is deleted
*/
provider_id?: number | "";
/**
* The provider's name as it was, so a deleted provider still reads
*/
provider_name?: string;
/**
* The catalog row of the model. Not a foreign key: a model the provider stops listing still counts
*/
model_id?: number | "";
/**
* The provider's model id, as it was called
*/
model_slug?: string;
/**
* Tokens sent: the prompt, the system instructions and the tool results
*/
prompt_tokens?: number | "";
/**
* Tokens written back: the answer, its tool calls and its reasoning
*/
completion_tokens?: number | "";
/**
* The provider's price in USD, when it reports one
*/
cost?: number | "";
/**
* JSON (`HarnessCallSettings`): how the call was made. The generation settings sent (temperature, reasoning effort, max tokens, voice), where they came from, whether the model was picked or automatic, and the models that failed before it
*/
settings?: string;
}
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
/**
* The unique identifier of the record.
@@ -1797,4 +1980,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string;
}
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cobble",
"version": "0.1.3",
"version": "0.2.0",
"module": "index.ts",
"type": "module",
"private": true,
@@ -13,12 +13,14 @@
"db:backup": "bunx bun-sqlite backup",
"seed": "bun src/scripts/seed-super-admin.tsx",
"seed:bots": "bun src/scripts/seed-bots.ts",
"media:prune-text": "bun src/scripts/prune-media-text-content.ts",
"start": "MAIN_SERVER=1 NODE_ENV=production bun src/server.ts",
"typecheck": "tsc --noEmit",
"test": "bun test",
"check:bundle": "bun src/scripts/check-bundle-size.ts",
"publish:github": "bash scripts/publish-github.sh",
"publish:gitea": "bash scripts/publish-gitea.sh"
"publish:gitea": "bash scripts/publish-gitea.sh",
"mirror:gitea": "bash scripts/mirror-gitea.sh"
},
"devDependencies": {
"@next/mdx": "^16.3.0",
@@ -43,7 +45,7 @@
"@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.6",
"@moduletrace/twui": "^0.1.7",
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0",
+19 -2
View File
@@ -134,9 +134,24 @@ prepare_tag() {
# END:tag
# MARK:notes-file
# Writes release_notes into NOTES_FILE, which the caller cleans up.
# Writes release_notes into NOTES_FILE, which the caller cleans up. The
# compare base is the host's /compare/ path; the tag range is added here,
# since only this side knows which tag came before (a bare /compare/ opens
# the host's default comparison rather than this release's).
build_compare_url() {
local compare_base="$1"
local from="$2"
if [ -z "$from" ]; then
printf '%s' "$compare_base"
return 0
fi
printf '%s%s...%s' "$compare_base" "$from" "$TAG"
}
write_notes_file() {
local compare_url="$1"
local compare_base="$1"
local compare_url=""
local from=""
# A dry run makes no tag, so the notes are read up to HEAD instead.
local to="$TAG"
@@ -153,6 +168,8 @@ write_notes_file() {
detail "No earlier tag, so every commit is listed."
fi
compare_url="$(build_compare_url "$compare_base" "$from")"
NOTES_FILE="$(mktemp -t cobble-release-notes.XXXXXX)"
release_notes --from "$from" --to "$to" --compare-url "$compare_url" >"$NOTES_FILE"
ok "Wrote the release notes to $NOTES_FILE."
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env bash
# Copies a published Cobble image from GHCR into the Gitea registry.
#
# scripts/mirror-gitea.sh
#
# The Gitea registry is on the VPN and GitHub Actions runners are on the
# public internet, so CI can never reach it. The release workflow therefore
# builds and pushes to GHCR only, and this script mirrors the result
# separately: a registry that is unreachable, slow or full can never fail a
# release the installer depends on.
#
# It copies from the Gitea host itself, because that host reaches GHCR over
# the public internet and its own registry over the docker network it shares
# with it. Nothing has to be installed on that host: skopeo runs there in a
# container.
#
# `skopeo copy --all` keeps the manifest list and its signatures, so every
# architecture comes across and the digest is unchanged. Each destination tag
# is then read back and compared, so a partial copy is reported as a failure
# instead of being assumed to have worked.
#
# Run it after scripts/publish-github.sh: that release is what builds the
# image, so there is nothing to copy until it has finished.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/publish-common.sh
. "$SCRIPT_DIR/lib/publish-common.sh"
# shellcheck source=lib/publish-release.sh
. "$SCRIPT_DIR/lib/publish-release.sh"
SOURCE_REPO="${MIRROR_SOURCE_REPO:-moduletrace/cobble}"
SOURCE_REGISTRY_HOST="${MIRROR_SOURCE_REGISTRY_HOST:-ghcr.io}"
HOST="${MIRROR_HOST:-10.7.0.4}"
HOST_USER="${MIRROR_HOST_USER:-root}"
REGISTRY_HOST="${MIRROR_REGISTRY_HOST:-gitea:3000}"
REGISTRY_REPO="${MIRROR_REGISTRY_REPO:-moduletrace/cobble}"
# The name a person types, which is not the one the copy uses: the host
# reaches its registry by docker network name, while an install reaches it
# through the proxy in front of it.
REGISTRY_PUBLIC_HOST="${MIRROR_REGISTRY_PUBLIC_HOST:-git.tben.me}"
NETWORK="${MIRROR_NETWORK:-cloudflare}"
SKOPEO_IMAGE="${MIRROR_SKOPEO_IMAGE:-quay.io/skopeo/stable:latest}"
MIRROR_USER="${MIRROR_USER:-cobble}"
CONNECT_TIMEOUT="${MIRROR_CONNECT_TIMEOUT:-10}"
usage() {
cat <<EOF
Copies a published Cobble image from GHCR into the Gitea registry.
Usage: scripts/mirror-gitea.sh [options]
Options:
--version VERSION Version to mirror, with or without the leading v.
Defaults to package.json's version.
--yes, -y Do not prompt.
--dry-run Print what would run, copy nothing.
-h, --help Show this help.
The version is copied as vX.Y.Z and sha-<commit>. It is copied as latest and
major.minor too, but only while it is still the newest release in GHCR, so
mirroring an older release never rolls those two tags backwards. Every
destination is read back and its digest compared with GHCR's.
Run it after scripts/publish-github.sh, and after the image build for that
release has finished.
Environment:
GITEA_TOKEN Token for the registry (required).
MIRROR_HOST The Gitea host to copy from over the VPN.
Default: 10.7.0.4
MIRROR_HOST_USER Its ssh user. Default: root
MIRROR_REGISTRY_HOST The registry as that host sees it, by its docker
network name. Default: gitea:3000
MIRROR_REGISTRY_PUBLIC_HOST
The registry's public name, for the pull command
it prints. Default: git.tben.me
MIRROR_SOURCE_REGISTRY_HOST
Where the release workflow pushed.
Default: ghcr.io
MIRROR_NETWORK The docker network the registry is on, so the
copy container can resolve it.
Default: cloudflare
MIRROR_USER The registry user for basic auth. Default: cobble
MIRROR_SKOPEO_IMAGE The skopeo image the host runs.
Default: quay.io/skopeo/stable:latest
DRY_RUN=1, ASSUME_YES=1 Same as --dry-run and --yes.
EOF
}
parse_publish_args "$@"
if [ "$HELP" = "1" ]; then
usage
exit 0
fi
reject_unknown_args "$0"
# MARK:mirror-tags
# The moving tag a release owns: major.minor, read from the version the same
# way the release workflow reads it, so the two never disagree.
mirror_minor_tag() {
printf '%s' "${1#v}" | sed -E 's/^([0-9]+)\.([0-9]+).*/\1.\2/'
}
# The commit tag for a release: the first 12 characters of the commit the
# tag points at, which is `sha-${GITHUB_SHA::12}` in the workflow.
mirror_sha_tag() {
local sha
sha="$(git rev-parse -q --verify "refs/tags/$1^{commit}" 2>/dev/null || true)"
if [ -z "$sha" ]; then
return 1
fi
printf 'sha-%s' "${sha:0:12}"
}
# The tags this release owns outright, which no newer release claims.
mirror_fixed_tags() {
local sha_tag
printf '%s\n' "$1"
if sha_tag="$(mirror_sha_tag "$1")"; then
printf '%s\n' "$sha_tag"
fi
}
# The tags a newer release takes over.
mirror_moving_tags() {
printf 'latest\n%s\n' "$(mirror_minor_tag "$1")"
}
# END:mirror-tags
# MARK:mirror-guard
# Whether this release may still write the moving tags. It may while GHCR's
# `latest` is its own image: once a newer release has moved `latest`, copying
# an older version into the Gitea registry must not drag it back.
mirror_owns_moving_tags() {
if [ -z "$2" ]; then
# Nothing to compare against, so this release is taken as the newest.
return 0
fi
[ "$1" = "$2" ]
}
# END:mirror-guard
# MARK:mirror-digest
MANIFEST_ACCEPT="application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"
# Reads a tag's digest from GHCR over the public internet. The token is
# anonymous: the image is public, and a registry answers a bare manifest
# request with 401 until it is given one.
github_registry_digest() {
local tag="$1"
local token
token="$(
curl -sSf "https://$SOURCE_REGISTRY_HOST/token?scope=repository:$SOURCE_REPO:pull" 2>/dev/null |
sed -n 's/.*"token":"\([^"]*\)".*/\1/p'
)" || return 1
if [ -z "$token" ]; then
return 1
fi
curl -sSf -o /dev/null -D- \
-H "Authorization: Bearer $token" \
-H "Accept: $MANIFEST_ACCEPT" \
"https://$SOURCE_REGISTRY_HOST/v2/$SOURCE_REPO/manifests/$tag" 2>/dev/null |
tr -d '\r' | sed -n 's/^docker-content-digest: //Ip' | head -n1
}
# END:mirror-digest
# MARK:mirror-remote
# Runs a bash script on the Gitea host, which is the one place that reaches
# both registries. The script arrives on stdin, so nothing is installed there
# and no quoting has to survive a round trip through ssh.
mirror_on_host() {
ssh -o BatchMode=yes -o ConnectTimeout="$CONNECT_TIMEOUT" "$HOST_USER@$HOST" \
"MIRROR_NETWORK='$NETWORK' MIRROR_SKOPEO_IMAGE='$SKOPEO_IMAGE' bash -s"
}
# Reads a destination tag's digest back from the registry. Empty means the
# tag is not there, which a caller reports differently from a mismatch.
mirror_digest_on_host() {
mirror_on_host <<REMOTE
set -uo pipefail
docker run --rm --network "\$MIRROR_NETWORK" "\$MIRROR_SKOPEO_IMAGE" \\
inspect --tls-verify=false --creds '$MIRROR_USER:$GITEA_TOKEN' \\
--format '{{.Digest}}' 'docker://$REGISTRY_HOST/$REGISTRY_REPO:$1' 2>/dev/null | tail -n1
REMOTE
}
# Copies one tag, leaving skopeo's output in MIRROR_COPY_LOG so a failure can
# be shown without streaming its progress into the report.
mirror_copy_on_host() {
MIRROR_COPY_LOG="$(mktemp -t cobble-mirror.XXXXXX)"
mirror_on_host <<REMOTE >"$MIRROR_COPY_LOG" 2>&1
set -uo pipefail
docker run --rm --network "\$MIRROR_NETWORK" "\$MIRROR_SKOPEO_IMAGE" \\
copy --all --retry-times 5 \\
--src-tls-verify=true --dest-tls-verify=false \\
--dest-creds '$MIRROR_USER:$GITEA_TOKEN' \\
'docker://$SOURCE_REGISTRY_HOST/$SOURCE_REPO:$1' \\
'docker://$REGISTRY_HOST/$REGISTRY_REPO:$2'
REMOTE
}
# END:mirror-remote
# MARK:mirror-main
require_repo_root
step "Resolving the version"
if [ -z "$VERSION_RAW" ]; then
VERSION_RAW="$(source_version)"
if [ -z "$VERSION_RAW" ]; then
fail "No version in package.json and none passed with --version."
fi
detail "Read from package.json: $VERSION_RAW"
fi
validate_version "$VERSION_RAW"
TAG="$(normalize_version "$VERSION_RAW")"
ok "Mirroring $TAG."
require_cmd git
require_cmd ssh "Install an ssh client."
require_cmd curl "Install curl."
if [ -z "${GITEA_TOKEN:-}" ]; then
fail "No Gitea token. Set GITEA_TOKEN to a token whose user can push packages to the $REGISTRY_REPO owner."
fi
step "Checking the released image in $SOURCE_REGISTRY_HOST"
SOURCE_DIGEST="$(github_registry_digest "$TAG")" ||
fail "No $TAG in $SOURCE_REGISTRY_HOST/$SOURCE_REPO. Publish the GitHub release first: scripts/publish-github.sh builds the image."
if [ -z "$SOURCE_DIGEST" ]; then
fail "No $TAG in $SOURCE_REGISTRY_HOST/$SOURCE_REPO. Publish the GitHub release first: scripts/publish-github.sh builds the image."
fi
ok "$TAG is $SOURCE_DIGEST."
LATEST_DIGEST="$(github_registry_digest latest || true)"
TAGS_TO_COPY="$(mirror_fixed_tags "$TAG")"
MOVING_SKIPPED=""
if mirror_owns_moving_tags "$SOURCE_DIGEST" "$LATEST_DIGEST"; then
TAGS_TO_COPY="$(
printf '%s\n%s\n' "$TAGS_TO_COPY" "$(mirror_moving_tags "$TAG")" |
awk 'NF && !seen[$0]++'
)"
else
MOVING_SKIPPED="$(mirror_moving_tags "$TAG" | tr '\n' ' ')"
fi
step "Mirroring $TAG into the Gitea registry"
printf ' %shost%s %s\n' "$C_DIM" "$C_RESET" "$HOST_USER@$HOST"
printf ' %sregistry%s %s/%s\n' "$C_DIM" "$C_RESET" "$REGISTRY_HOST" "$REGISTRY_REPO"
printf ' %ssource%s %s/%s\n' "$C_DIM" "$C_RESET" "$SOURCE_REGISTRY_HOST" "$SOURCE_REPO"
printf ' %stags%s %s\n' "$C_DIM" "$C_RESET" "$(printf '%s' "$TAGS_TO_COPY" | tr '\n' ' ')"
if [ -n "$MOVING_SKIPPED" ]; then
warn "$SOURCE_REGISTRY_HOST/$SOURCE_REPO:latest belongs to a newer release, so ${MOVING_SKIPPED% } is left where it is."
fi
step "Checking the Gitea host"
if is_dry_run; then
detail "[dry run] ssh -o BatchMode=yes $HOST_USER@$HOST 'bash -s'"
remote_hostname="$HOST"
else
remote_hostname="$(
mirror_on_host <<'REMOTE'
hostname
REMOTE
)" || fail "Could not reach $HOST_USER@$HOST over ssh. The Gitea host is on the VPN: connect to it first."
ok "Reached $remote_hostname."
fi
confirm "Mirror $TAG into $REGISTRY_HOST/$REGISTRY_REPO?"
failed=0
for dest_tag in $TAGS_TO_COPY; do
step "Copying $TAG to $dest_tag"
if is_dry_run; then
detail "[dry run] skopeo copy --all $SOURCE_REGISTRY_HOST/$SOURCE_REPO:$TAG -> $REGISTRY_HOST/$REGISTRY_REPO:$dest_tag"
continue
fi
if ! mirror_copy_on_host "$TAG" "$dest_tag"; then
sed -n '$!{/^$/d};p' "${MIRROR_COPY_LOG:-/dev/null}" 2>/dev/null | tail -n 8 >&2
warn "The copy to $dest_tag failed."
failed=1
continue
fi
# The digest is what makes the two registries the same image: a copy that
# stored a different manifest list would read back differently here.
copied_digest="$(mirror_digest_on_host "$dest_tag")"
if [ "$copied_digest" != "$SOURCE_DIGEST" ]; then
warn "$dest_tag reads back as ${copied_digest:-nothing}, not $SOURCE_DIGEST."
failed=1
continue
fi
ok "$dest_tag is $SOURCE_DIGEST."
done
if [ -n "${MIRROR_COPY_LOG:-}" ] && [ -f "$MIRROR_COPY_LOG" ]; then
rm -f "$MIRROR_COPY_LOG"
fi
step "Mirrored $TAG"
printf ' %sregistry%s %s/%s\n' "$C_DIM" "$C_RESET" "$REGISTRY_HOST" "$REGISTRY_REPO"
printf ' %stags%s %s\n' "$C_DIM" "$C_RESET" "$(printf '%s' "$TAGS_TO_COPY" | tr '\n' ' ')"
printf ' %sdigest%s %s\n' "$C_DIM" "$C_RESET" "$SOURCE_DIGEST"
if [ "$DRY_RUN" = "1" ]; then
printf '\n%sDry run: copied nothing.%s\n' "$C_YELLOW" "$C_RESET"
fi
if [ "$failed" = "1" ]; then
fail "The Gitea registry does not match $SOURCE_REGISTRY_HOST/$SOURCE_REPO. The release itself is unaffected: the installer defaults to GHCR."
fi
printf '\nAn install that can reach the Gitea registry can pull it with:\n'
printf ' curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --image %s/%s:%s\n' \
"$REGISTRY_PUBLIC_HOST" "$REGISTRY_REPO" "$TAG"
# END:mirror-main
+9 -6
View File
@@ -8,9 +8,10 @@
# creates the Gitea release over the API.
#
# Gitea has no release workflow, so nothing is built here: the image is built
# once by .github/workflows/release.yml and mirrored to
# git.tben.me/moduletrace/cobble. This script only publishes the source and
# the release object.
# once by .github/workflows/release.yml and copied into the Gitea registry by
# scripts/mirror-gitea.sh, run from the VPN side, since a GitHub runner can
# never reach it. This script only publishes the source and the release
# object.
#
# The token comes from GITEA_TOKEN, or from the Gitea section of ~/.config/tea
# if the tea CLI is installed. It needs the `write:repository` scope on the
@@ -208,6 +209,9 @@ else
if [ -n "${response_file:-}" ]; then
rm -f "$response_file"
fi
if [ "$api_status" = "403" ]; then
fail "Gitea answered HTTP 403: the token's user cannot write to $REPO_SLUG. The tags and $BRANCH still push over SSH, but creating a release needs the token's user to be a writer (write or admin) on the repository. Check it with: curl -s -H \"Authorization: token \$GITEA_TOKEN\" $API_URL/repos/$REPO_SLUG | grep -o '\"permissions\":{[^}]*}'"
fi
fail "Gitea answered HTTP $api_status for the release."
;;
esac
@@ -218,6 +222,5 @@ else
fi
print_release_summary "$REMOTE" "$BRANCH" "https://${REMOTE_HOST}/${REPO_SLUG}/releases/tag/$TAG"
printf '\nThe image is built by the GitHub release; mirror it into the Gitea registry with:\n'
printf ' docker buildx imagetools create --tag git.tben.me/moduletrace/cobble:latest \\\n'
printf ' ghcr.io/moduletrace/cobble:latest\n'
printf '\nThe image is built by the GitHub release, which cannot reach this registry.\nOnce that build has finished, copy it in over the VPN with:\n'
printf ' bun run mirror:gitea\n'
+4 -3
View File
@@ -7,11 +7,12 @@
# installer syntax), tags HEAD with the package.json version, pushes main and
# the tag to the `origin` remote, then creates the GitHub release. Publishing
# that release is what fires .github/workflows/release.yml, which builds the
# multi-arch image and pushes it to GHCR (and mirrors it to Gitea).
# multi-arch image and pushes it to GHCR, the registry the installer defaults
# to. It never pushes to Gitea: that registry is on the VPN, out of a
# runner's reach, so scripts/mirror-gitea.sh copies the image separately.
#
# Run scripts/publish-gitea.sh afterwards for the source mirror's own release
# object. There is no existing publish script in the repo: this and its Gitea
# twin are it.
# object, and scripts/mirror-gitea.sh once the image build has finished.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+192 -1
View File
@@ -4,6 +4,7 @@ import path from "path";
const ROOT = path.join(import.meta.dir, "..");
const COMMON = path.join(ROOT, "scripts/lib/publish-common.sh");
const RELEASE = path.join(ROOT, "scripts/lib/publish-release.sh");
const MIRROR = path.join(ROOT, "scripts/mirror-gitea.sh");
const SCRIPTS = [
path.join(ROOT, "scripts/publish-github.sh"),
path.join(ROOT, "scripts/publish-gitea.sh"),
@@ -99,13 +100,21 @@ async function pathWithoutJq(): Promise<string> {
}
describe("publish scripts syntax", () => {
for (const script of [...SCRIPTS, COMMON, RELEASE]) {
for (const script of [...SCRIPTS, MIRROR, COMMON, RELEASE]) {
test(`${path.relative(ROOT, script)} parses`, async () => {
const proc = Bun.spawn({ cmd: ["bash", "-n", script] });
expect(await proc.exited).toBe(0);
});
}
test("the mirror script sources the shared libraries", async () => {
const source = await Bun.file(MIRROR).text();
expect(source).toContain('SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"');
expect(source).toContain('. "$SCRIPT_DIR/lib/publish-common.sh"');
expect(source).toContain('. "$SCRIPT_DIR/lib/publish-release.sh"');
});
test("both publish scripts source the shared library", async () => {
for (const script of SCRIPTS) {
const source = await Bun.file(script).text();
@@ -141,6 +150,156 @@ describe("publish scripts remote wiring", () => {
});
});
describe("mirror script wiring", () => {
test("CI never pushes to the Gitea registry", async () => {
const workflow = await Bun.file(
path.join(ROOT, ".github/workflows/release.yml"),
).text();
expect(workflow).not.toContain("git.tben.me");
expect(workflow).not.toContain("GITEA_TOKEN");
expect(workflow).not.toContain("imagetools create");
expect(workflow).toContain("Build and push to GHCR");
});
test("the mirror script copies every tag the workflow publishes", async () => {
const [workflow, mirror] = await Promise.all([
Bun.file(path.join(ROOT, ".github/workflows/release.yml")).text(),
Bun.file(MIRROR).text(),
]);
// The workflow's own four tags, so a mirror tag can never be invented
// here and go missing there.
for (const tag of ["latest", "${version}", "${minor}", "${sha}"]) {
expect(workflow).toContain(tag);
}
expect(mirror).toContain("mirror_minor_tag");
expect(mirror).toContain("mirror_sha_tag");
expect(mirror).toContain("mirror_fixed_tags");
expect(mirror).toContain("mirror_moving_tags");
});
test("the mirror script asks before copying and verifies each digest", async () => {
const source = await Bun.file(MIRROR).text();
expect(source).toContain("confirm \"Mirror $TAG into");
expect(source).toContain("mirror_digest_on_host");
expect(source).toContain("skopeo");
expect(source).toContain("--all");
});
test("the pull command it prints names the public host, not the docker one", async () => {
const source = await Bun.file(MIRROR).text();
// The copy reaches the registry by docker network name, which is not
// resolvable for an install; printing that would hand out a broken
// command.
expect(source).toContain("MIRROR_REGISTRY_PUBLIC_HOST");
expect(source).toContain('"$REGISTRY_PUBLIC_HOST" "$REGISTRY_REPO" "$TAG"');
expect(source).not.toContain('"${REGISTRY_HOST%%:*}" "$REGISTRY_REPO" "$TAG"');
});
test("without a token the mirror script refuses instead of guessing", async () => {
const proc = Bun.spawn({
cmd: ["bash", MIRROR, "--yes"],
cwd: ROOT,
env: { ...process.env, GITEA_TOKEN: "" },
stdout: "pipe",
stderr: "pipe",
});
const [stderr, code] = await Promise.all([
new Response(proc.stderr).text(),
proc.exited,
]);
expect(code).toBe(1);
expect(stderr).toContain("No Gitea token");
});
});
describe("mirror lib tag handling", () => {
test("major.minor is read exactly as the release workflow reads it", async () => {
const block = await blockCaller({ file: MIRROR, name: "mirror-tags" });
for (const [version, expected] of [
["v0.1.5", "0.1"],
["v1.2.3", "1.2"],
["v10.20.30", "10.20"],
["v0.1.5-rc.1", "0.1"],
] as const) {
const out = await runBash({
script: [block, `mirror_minor_tag '${version}'`].join("\n"),
});
expect(out).toBe(expected);
}
});
test("fixed tags are the version and its commit, moving ones the rest", async () => {
const block = await blockCaller({ file: MIRROR, name: "mirror-tags" });
const fixed = await runBash({
script: [block, "mirror_fixed_tags v0.1.5"].join("\n"),
});
expect(fixed.split("\n")[0]).toBe("v0.1.5");
const moving = await runBash({
script: [block, "mirror_moving_tags v0.1.5"].join("\n"),
});
expect(moving.split("\n")).toEqual(["latest", "0.1"]);
});
test("a tag with no commit on disk still mirrors its version", async () => {
const block = await blockCaller({ file: MIRROR, name: "mirror-tags" });
const out = await runBash({
script: [block, "mirror_fixed_tags v99.99.99"].join("\n"),
});
expect(out).toBe("v99.99.99");
});
});
describe("mirror lib moving tag guard", () => {
test("a release may move latest while it owns it", async () => {
const block = await blockCaller({ file: MIRROR, name: "mirror-guard" });
const out = await runBash({
script: [
block,
'mirror_owns_moving_tags sha256:aa sha256:aa && printf owns',
].join("\n"),
});
expect(out).toBe("owns");
});
test("a newer release's latest is never rolled back", async () => {
const block = await blockCaller({ file: MIRROR, name: "mirror-guard" });
const out = await runBash({
script: [
block,
'if mirror_owns_moving_tags sha256:aa sha256:bb; then printf owns; else printf refuses; fi',
].join("\n"),
});
expect(out).toBe("refuses");
});
test("with no latest to compare, the release is taken as newest", async () => {
const block = await blockCaller({ file: MIRROR, name: "mirror-guard" });
const out = await runBash({
script: [
block,
'mirror_owns_moving_tags sha256:aa "" && printf owns',
].join("\n"),
});
expect(out).toBe("owns");
});
});
describe("publish lib version handling", () => {
test("normalizes a version with and without the v", async () => {
const block = await blockCaller({ name: "version" });
@@ -292,6 +451,38 @@ describe("publish lib release notes", () => {
});
});
describe("publish lib compare link", () => {
// The range is what makes the link open this release's diff; a bare
// /compare/ opened the host's default comparison instead.
test("adds the tag range to the compare path", async () => {
const block = await blockCaller({ file: RELEASE, name: "notes-file" });
const out = await runBash({
script: [
block,
`TAG=v0.1.4`,
`build_compare_url https://example.test/compare/ v0.1.3`,
].join("\n"),
});
expect(out).toBe("https://example.test/compare/v0.1.3...v0.1.4");
});
test("leaves the path alone when there is no earlier tag", async () => {
const block = await blockCaller({ file: RELEASE, name: "notes-file" });
const out = await runBash({
script: [
block,
`TAG=v0.1.0`,
`build_compare_url https://example.test/compare/ ""`,
].join("\n"),
});
expect(out).toBe("https://example.test/compare/");
});
});
describe("publish lib json escaping", () => {
const sample = 'a\nb\t"c" \\ é';
@@ -97,7 +97,7 @@ describe("botMessagesToHarness with a file-only reply", () => {
).toEqual([
{
role: "user",
content: `[Replying to your earlier message: "Send me the logo."]\n\n[Attached earlier: "logo.png"]`,
content: `[Replying to your earlier message: "Send me the logo."]\n\n[Attached earlier: "logo.png", media id 9]`,
},
]);
});
@@ -130,7 +130,13 @@ describe("botMessagesToHarness with files", () => {
botMessagesToHarness({
messages: [{ id: 2, author: "user", text: "", attachments }],
}),
).toEqual([{ role: "user", content: `[Attached earlier: "shot.png"]` }]);
).toEqual([
{
role: "user",
/** Its media id rides along, so a picture can be set from it. */
content: `[Attached earlier: "shot.png", media id 9]`,
},
]);
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types";
import describeMessageRefs, { type MessageRefNames } from "@/src/utils/describe-message-refs";
import describeAttachment from "./describe-attachment";
type Params = {
messages: Pick<
@@ -87,7 +88,7 @@ function messageParts({
...content,
{
type: "text",
text: `[Attached earlier: ${message.attachments.map((file) => `"${file.name}"`).join(", ")}]`,
text: `[Attached earlier: ${message.attachments.map((file) => describeAttachment({ name: file.name, id: file.id })).join(", ")}]`,
},
];
}
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import describeAttachment from "./describe-attachment";
describe("describeAttachment", () => {
test("names the upload and its media id, so a tool can be given it", () => {
expect(describeAttachment({ name: "summit-logo.png", id: 42 })).toBe(
`"summit-logo.png", media id 42`,
);
});
test("takes the id as text as well as a number", () => {
expect(describeAttachment({ name: "logo.svg", id: "7" })).toBe(`"logo.svg", media id 7`);
});
});
+22
View File
@@ -0,0 +1,22 @@
type Params = {
/**
* The name the file was uploaded with.
*/
name: string;
/**
* Its `media` id.
*/
id: number | string;
};
/**
* How an upload is described to the model: its name and its media id, e.g.
* `"summit-logo.png", media id 42`.
*
* The id matters: the picture tools (`set_picture`, `update_workspace`)
* take an upload by media id, so a bot that can read the id can act on the
* image the user sent instead of asking them to upload it again.
*/
export default function describeAttachment({ name, id }: Params) {
return `"${name}", media id ${id}`;
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import hasUnansweredMessage from "./has-unanswered-message";
const user = (id: number) => ({ id, author: "user" as const });
const bot = (id: number) => ({ id, author: "bot" as const });
describe("hasUnansweredMessage", () => {
test("a message sent while the last reply ran is answered, though the thread ends with the bot", () => {
/**
* A (10) was being answered when B (11) came in; the reply (12)
* was saved after B but its run never read it.
*/
expect(hasUnansweredMessage({ history: [user(10), user(11), bot(12)], answered_id: 10 })).toBe(true);
});
test("a message the last reply took in is not answered twice", () => {
expect(hasUnansweredMessage({ history: [user(10), user(11), bot(12)], answered_id: 11 })).toBe(false);
});
test("a burst after the answer is answered", () => {
expect(
hasUnansweredMessage({ history: [user(10), bot(12), user(13), user(14)], answered_id: 10 }),
).toBe(true);
});
test("without a record, the user having the last word decides", () => {
expect(hasUnansweredMessage({ history: [user(1)], answered_id: null })).toBe(true);
expect(hasUnansweredMessage({ history: [user(1), bot(2)], answered_id: null })).toBe(false);
});
test("nothing from the user, nothing to answer", () => {
expect(hasUnansweredMessage({ history: [], answered_id: null })).toBe(false);
expect(hasUnansweredMessage({ history: [bot(4)], answered_id: 2 })).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
type Params = {
/**
* The session's latest messages, oldest first.
*/
history: Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "id" | "author">[];
/**
* The newest user message a finished reply answered
* (`grabAnsweredMessageId`), null when none is on record.
*/
answered_id: number | null;
};
/**
* Whether a reply has anything left to answer. A user message that came in
* while the last reply ran is saved before that reply, so the thread ends
* with the bot's message even though the bot never read it: what the last
* reply read decides, not who wrote last. Without a record (no reply has
* finished in the session), the user having the last word does.
*/
export default function hasUnansweredMessage({ history, answered_id }: Params) {
const user_ids = history
.filter((message) => message.author == "user")
.map((message) => Number(message.id));
if (!user_ids.length) return false;
if (answered_id) {
return Math.max(...user_ids) > answered_id;
}
return history[history.length - 1]?.author == "user";
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test";
import { abortRunningJob, registerJobAbort, unregisterJobAbort } from "./job-abort-registry";
describe("job-abort-registry", () => {
test("aborts the job this process is running", () => {
let aborted = false;
registerJobAbort({ job_id: 101, abort: () => (aborted = true) });
expect(abortRunningJob({ job_id: 101 })).toBe(true);
expect(aborted).toBe(true);
unregisterJobAbort({ job_id: 101 });
});
test("a job running in another process is not here, so it isn't reported as aborted", () => {
expect(abortRunningJob({ job_id: 202 })).toBe(false);
});
test("a finished job is cleaned up, so its id can't abort a later one", () => {
let aborted = false;
registerJobAbort({ job_id: 303, abort: () => (aborted = true) });
unregisterJobAbort({ job_id: 303 });
expect(abortRunningJob({ job_id: 303 })).toBe(false);
expect(aborted).toBe(false);
});
test("a numeric string id reaches the same entry, since a route may send one", () => {
let aborted = false;
registerJobAbort({ job_id: 404, abort: () => (aborted = true) });
expect(abortRunningJob({ job_id: "404" })).toBe(true);
expect(aborted).toBe(true);
unregisterJobAbort({ job_id: 404 });
});
test("unregistering a job nobody registered is harmless", () => {
expect(() => unregisterJobAbort({ job_id: 505 })).not.toThrow();
});
test("only one job is aborted at a time", () => {
const aborted: number[] = [];
registerJobAbort({ job_id: 606, abort: () => aborted.push(606) });
registerJobAbort({ job_id: 707, abort: () => aborted.push(707) });
abortRunningJob({ job_id: 606 });
expect(aborted).toEqual([606]);
unregisterJobAbort({ job_id: 606 });
unregisterJobAbort({ job_id: 707 });
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* The abort controllers of the jobs this process is running right now,
* keyed by job id. Stopping a bot from the chat aborts the request its job
* is waiting on (a model that never answers, a tool that hangs) instead of
* leaving it to find out on its next heartbeat, which can take minutes.
*
* Only the process running a job can abort it, so this is a bonus: the job
* is already failed in `bots_queue`, and a handler in another process
* learns that on its next `updateBotJob`. Kept free of imports so the API
* routes can use it without pulling the queue handler into a page bundle.
*/
const running = new Map<number, () => void>();
/**
* Called by a handler once it holds a job, with the way to abort it.
*/
export function registerJobAbort({ job_id, abort }: { job_id: number | string; abort: () => void }) {
running.set(Number(job_id), abort);
}
/**
* Called by a handler in its `finally`, whatever the outcome.
*/
export function unregisterJobAbort({ job_id }: { job_id: number | string }) {
running.delete(Number(job_id));
}
/**
* Aborts a job running here. False means another process (or nobody) has
* it, so the row's new status is how it finds out.
*/
export function abortRunningJob({ job_id }: { job_id: number | string }) {
const abort = running.get(Number(job_id));
if (!abort) return false;
abort();
return true;
}
+21 -11
View File
@@ -1,11 +1,11 @@
import path from "node:path";
import mediaFilePath from "@/src/utils/media-file-path";
import { AppData } from "@/src/data/app-data";
import grabMessagesMedia, {
type MessageMediaRow,
} from "@/src/functions/backend/db/bots/grab-messages-media";
import type { HarnessContentPart } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import describeAttachment from "./describe-attachment";
type Params = {
/**
@@ -27,7 +27,10 @@ export default async function loadAttachmentParts({ messages }: Params) {
.slice(-AppData.BotReplyAttachmentMessages)
.map((message) => Number(message.id));
const media = await grabMessagesMedia({ message_ids: recent });
const media = await grabMessagesMedia({
message_ids: recent,
with_text_content: true,
});
const parts = new Map<number, HarnessContentPart[]>();
for (const [message_id, files] of media) {
@@ -42,12 +45,18 @@ export default async function loadAttachmentParts({ messages }: Params) {
async function fileToParts({ file }: { file: MessageMediaRow }): Promise<HarnessContentPart[]> {
const name = file.title || `file ${file.id}`;
/**
* The upload's own id, so a bot can name it back to a tool
* (`set_picture` and `update_workspace` take one) instead of guessing
* which file the user means.
*/
const label = describeAttachment({ name, id: file.id! });
if (file.media_type == "document") {
const text = file.text_content?.trim();
if (!text) {
return [{ type: "text", text: `[Attached file "${name}": its text couldn't be read.]` }];
return [{ type: "text", text: `[Attached file ${label}: its text couldn't be read.]` }];
}
const cut = text.length > AppData.BotAttachmentMaxTextChars;
@@ -55,32 +64,33 @@ async function fileToParts({ file }: { file: MessageMediaRow }): Promise<Harness
return [
{
type: "text",
text: `Attached file "${name}":\n\n${text.slice(0, AppData.BotAttachmentMaxTextChars)}${cut ? `\n\n[The rest of "${name}" was cut: it's ${text.length} characters long.]` : ""}`,
text: `Attached file ${label}:\n\n${text.slice(0, AppData.BotAttachmentMaxTextChars)}${cut ? `\n\n[The rest of "${name}" was cut: it's ${text.length} characters long.]` : ""}`,
},
];
}
if (file.media_type == "video") {
return [{ type: "text", text: `[Attached video "${name}": videos can't be read yet.]` }];
return [{ type: "text", text: `[Attached video ${label}: videos can't be read yet.]` }];
}
const disk = Bun.file(path.join(grabDirNames().DATA_DIR, file.media_write_path || ""));
const absolute = mediaFilePath({ write_path: file.media_write_path });
const disk = absolute ? Bun.file(absolute) : null;
if (!file.media_write_path || !(await disk.exists())) {
return [{ type: "text", text: `[Attached file "${name}" is missing on the server.]` }];
if (!disk || !(await disk.exists())) {
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
}
const base64 = Buffer.from(await disk.arrayBuffer()).toString("base64");
if (file.media_type == "image") {
return [
{ type: "text", text: `[Attached image "${name}"]` },
{ type: "text", text: `[Attached image ${label}]` },
{ type: "image", url: `data:image/${file.mime_type || "jpeg"};base64,${base64}`, name },
];
}
return [
{ type: "text", text: `[Attached audio "${name}"]` },
{ type: "text", text: `[Attached audio ${label}]` },
{ type: "audio", data: base64, format: file.mime_type || "mp3", name },
];
}
+26 -9
View File
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
import { SessionNamePrompt } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import parseSessionName from "./parse-session-name";
type Params = {
@@ -30,7 +31,7 @@ export default async function nameBotSession({
}: Params): Promise<boolean> {
const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id, title, summary FROM bot_sessions WHERE id = ? LIMIT 1`,
sql: `SELECT id, user_id, title, summary FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [session_id],
})
).payload?.[0];
@@ -57,14 +58,30 @@ export default async function nameBotSession({
)
.join("\n\n");
const result = await generateText({
messages: [
{ role: "system", content: SessionNamePrompt },
{ role: "user", content: transcript },
],
bot_id,
workspace_id,
signal,
/**
* The naming turn is a model call too, so it is recorded for this
* conversation. It runs after the job is complete, on purpose, so it
* has no job and no message of its own to charge it to.
*/
const result = await runWithHarnessUsageScope({
scope: {
user_id: session.user_id || undefined,
bot_id,
workspace_id: workspace_id || null,
session_id: session.id,
job_id: null,
message_id: null,
},
task: () =>
generateText({
messages: [
{ role: "system", content: SessionNamePrompt },
{ role: "user", content: transcript },
],
bot_id,
workspace_id,
signal,
}),
});
const name = parseSessionName({ text: result.text });
+53 -5
View File
@@ -27,6 +27,7 @@ import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json";
import nameBotSession from "./name-bot-session";
import pushBotSession from "./push-bot-session";
import { registerJobAbort, unregisterJobAbort } from "./job-abort-registry";
import type { BotJobUpdateData } from "@/src/types";
import type { BotJobContext, BotJobRunner } from "./types";
@@ -80,11 +81,6 @@ export default async function processBotJob({
*/
const user_id = await grabJobUserId({ job }).catch(() => null);
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
if (!user_id) return;
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
};
const controller = new AbortController();
/**
* The deadline is the one abort that is a failure, not a cancellation:
@@ -94,6 +90,25 @@ export default async function processBotJob({
const timeout_message = `"${job.title || "The job"}" took too long and was stopped.`;
let timed_out = false;
/**
* A stopped job never touches the face again: `stopBotJobs` already set
* it to idle, and a late `working` or `asleep` would push over that and
* leave a bot that shows work with nothing behind it.
*/
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
if (!user_id || controller.signal.aborted) return;
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
};
/**
* Stopping the bot from the chat aborts the request this job is
* waiting on, here and now, rather than at the next heartbeat. The job
* is already failed in the queue, so an abort is a cancellation and its
* result is dropped (`controller.signal.aborted` below).
*/
registerJobAbort({ job_id, abort: () => controller.abort() });
const update: BotJobContext["update"] = async (params) => {
if (controller.signal.aborted) return false;
@@ -275,6 +290,38 @@ export default async function processBotJob({
return null;
});
/**
* Every failed try is on the Activity page, so a model or provider
* that keeps failing shows there and not only in one chat.
*/
if (failed) {
const final = failed.status == "failed";
await logError({
title: final
? `Bot job #${job_id} failed`
: `Bot job #${job_id} failed, retrying (try ${tries})`,
error: timed_out ? new Error(timeout_message) : error,
user_id: user_id || undefined,
details: [
{ label: "Bot", value: bot_id },
{ label: "Kind", value: job.kind },
{ label: "Title", value: job.title || "" },
{ label: "Try", value: tries },
],
metadata: {
job_id,
bot_id,
kind: job.kind,
session_id: job.session_id,
workspace_id: job.workspace_id,
schedule_id,
fatal: error instanceof BotJobFatalError,
harness_code: error instanceof HarnessError ? error.code : undefined,
},
}).catch(() => {});
}
if (failed?.notice) {
await pushBotNotice({ bot_id, ...failed.notice });
}
@@ -305,5 +352,6 @@ export default async function processBotJob({
}
} finally {
clearInterval(heartbeat);
unregisterJobAbort({ job_id });
}
}
+11 -5
View File
@@ -18,6 +18,12 @@ type Params = {
* (`relayJobToRequester`): a message in that bot's conversation.
*/
relay?: { bot_id: number; message: BUN_SQLITE_COBBLE_BOT_MESSAGES } | null;
/**
* Skip the push notification, keeping only the live send. For a message
* the user just caused themselves (stopping the bot): they are looking
* at the app, and one notification per conversation would be noise.
*/
quiet_push?: boolean;
};
/**
@@ -27,9 +33,9 @@ type Params = {
* goes to the conversation it was posted in. Best effort: a push that
* fails is logged and never fails the job that made the change.
*/
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
export default async function pushBotNotice({ bot_id, message, bot, relay, quiet_push }: Params) {
try {
await push({ bot_id, message, bot, relay });
await push({ bot_id, message, bot, relay, quiet_push });
} catch (error) {
await logError({
title: "Bot notice wasn't pushed",
@@ -39,7 +45,7 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
}
}
async function push({ bot_id, message, bot, relay }: Params) {
async function push({ bot_id, message, bot, relay, quiet_push }: Params) {
if (message) {
await pushToSessionUser({
session_id: message.session_id,
@@ -49,7 +55,7 @@ async function push({ bot_id, message, bot, relay }: Params) {
/**
* Not waited on: a push service's latency must not slow the job.
*/
void notifyBotMessage({ message });
if (!quiet_push) void notifyBotMessage({ message });
}
if (relay) {
@@ -58,7 +64,7 @@ async function push({ bot_id, message, bot, relay }: Params) {
data: { event: "server:bot-message", data: relay.message },
});
void notifyBotMessage({ message: relay.message });
if (!quiet_push) void notifyBotMessage({ message: relay.message });
}
if (bot) {
+19 -2
View File
@@ -63,14 +63,29 @@ async function push({
workspace_ids,
}: Params) {
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
/** Its colour and stone, so a recolour reaches every open tab. */
/**
* Its colour, stone and picture, so a recolour or a new face reaches
* every open tab.
*/
const identity = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT color, stone FROM bots WHERE id = ? LIMIT 1`,
sql: `SELECT color, stone, image_media_id FROM bots WHERE id = ? LIMIT 1`,
values: [bot_id],
})
).payload?.[0];
/**
* The workspaces it leads, so each crew shows its shield.
*/
const admin_workspace_ids = (
(
await BunSQLite.sql<{ workspace_id: number }>({
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin' AND workspace_id IS NOT NULL`,
values: [bot_id],
})
).payload || []
).map((row) => Number(row.workspace_id));
for (const viewer_id of audience) {
if (users && !users.has(String(viewer_id))) continue;
@@ -83,6 +98,8 @@ async function push({
...(await grabBotFace({ bot_id, user_id: viewer_id })),
color: identity?.color || null,
stone: identity?.stone || null,
image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : null,
admin_workspace_ids,
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
...(workspace_ids !== undefined ? { workspace_ids } : {}),
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
+55
View File
@@ -0,0 +1,55 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import grabWorkspaceAudience from "@/src/functions/backend/db/access/grab-workspace-audience";
import { logError } from "@/src/functions/backend/logger";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
workspace_id: number | string;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the workspace.
*/
users?: Set<string>;
};
/**
* Pushes `server:workspace-updated` to everyone who sees a workspace whose
* picture changed, so the rail and its header swap it without a reload.
* Best effort: a push that fails is logged and never fails the change that
* caused it.
*/
export default async function pushWorkspaceUpdated({ workspace_id, users }: Params) {
if (!workspace_id) return;
try {
const workspace = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, image_media_id FROM workspaces WHERE id = ? LIMIT 1`,
values: [Number(workspace_id)],
})
).payload?.[0];
if (!workspace?.id) return;
const data = {
workspace_id: Number(workspace.id),
image_media_id: workspace.image_media_id ? Number(workspace.image_media_id) : null,
};
for (const user_id of await grabWorkspaceAudience({ workspace_id: workspace.id })) {
if (users && !users.has(String(user_id))) continue;
sendToUser({
user_id,
data: { event: "server:workspace-updated", data },
});
}
} catch (error) {
await logError({
title: "Workspace update wasn't pushed",
error,
metadata: { workspace_id },
}).catch(() => {});
}
}
+13 -1
View File
@@ -1,10 +1,11 @@
import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs";
import notifyBotJobFailed from "@/src/functions/backend/db/bots-queue/notify-bot-job-failed";
import { logError } from "@/src/functions/backend/logger";
import pushBotNotice from "./push-bot-notice";
/**
* Tells the user about jobs that ran out of attempts by going silent
* (bun-sqlite fails those itself, without a message).
* (bun-sqlite fails those itself, without a message), and logs each.
*/
export default async function reportLostBotJobs() {
const jobs = await grabUnreportedFailedBotJobs();
@@ -18,5 +19,16 @@ export default async function reportLostBotJobs() {
});
await pushBotNotice({ bot_id: job.bot_id, ...notice });
await logError({
title: `Bot job #${job.id} lost`,
error: job.last_error || "The job's lock timed out.",
details: [
{ label: "Bot", value: job.bot_id },
{ label: "Kind", value: job.kind },
{ label: "Title", value: job.title || "" },
],
metadata: { job_id: job.id, bot_id: job.bot_id, kind: job.kind },
}).catch(() => {});
}
}
+20 -3
View File
@@ -1,6 +1,7 @@
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
import grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
import HarnessError from "@/src/harness/harness-error";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import BotJobFatalError from "./bot-job-fatal-error";
import runBotReply from "./run-bot-reply";
import runBotTask from "./run-bot-task";
@@ -33,9 +34,25 @@ const runBotJob: BotJobRunner = async (context) => {
const owned = { ...bot, id: bot.id };
try {
return job.kind == "reply"
? await runBotReply({ ...context, bot: owned, user_id })
: await runBotTask({ ...context, bot: owned, user_id });
/**
* Everything the job's models do is recorded for this user, this
* bot, this conversation and this job. A reply's text loop adds
* the message it is producing to the one-turn scope it opens.
*/
return await runWithHarnessUsageScope({
scope: {
user_id,
bot_id: bot.id,
workspace_id: job.workspace_id || null,
session_id: job.session_id || null,
job_id: job.id || null,
message_id: null,
},
task: () =>
job.kind == "reply"
? runBotReply({ ...context, bot: owned, user_id })
: runBotTask({ ...context, bot: owned, user_id }),
});
} catch (error: unknown) {
if (
error instanceof HarnessError &&
+130 -28
View File
@@ -2,21 +2,27 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import {
ActionCheckNote,
BotFilesNote,
ChatReplyNote,
botFilesNote,
chatReplyNote,
ComputerFilesNote,
CoordinatorReplyNote,
coordinatorReplyNote,
CoordinatorSoloNote,
TasksNote,
TaskReminderNote,
workspaceAdminNote,
WorkspaceLearningNote,
} from "@/src/data/harness-prompts";
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
import workspaceAdminsNote from "./workspace-admins-note";
import type { WorkspaceAdminTools } from "@/src/harness/tools/bot/grab-workspace-admin-tools";
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";
import runTextLoop from "@/src/harness/text/run-text-loop";
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools";
import grabRunScopes from "@/src/functions/backend/db/bot-files/grab-run-scopes";
import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions";
import openBotSkills from "@/src/harness/tools/skills/open-bot-skills";
import botMessagesToHarness from "./bot-messages-to-harness";
@@ -26,7 +32,14 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import loadAttachmentParts from "./load-attachment-parts";
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
import withTakeOverRefs from "./with-take-over-refs";
import hasUnansweredMessage from "./has-unanswered-message";
import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id";
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
import type { BotMessageWithAttachments } from "@/src/types";
import type { HarnessMessage } from "@/src/harness/types";
import type { BotJobContext, BotJobOutcome } from "./types";
import computerRunDirs from "@/src/utils/computer-work-dirs";
type Params = BotJobContext & {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
@@ -45,8 +58,12 @@ type Params = BotJobContext & {
* instructions, start_task, and for the coordinator delegate_task) plus
* its MCP servers' and custom tools (`openBotExtensions`, script tools on
* its current computer).
* Finishes silently when the last message is already the bot's, i.e. an
* earlier reply answered everything.
* Messages the user sends while it works join the run (`incoming`): one
* that changes the work changes the answer before it goes out, and one
* that's separate can be acknowledged at once (`send_message`) and done
* after the current work. The job records the newest
* one it read (`markReplyAnswered`). Finishes silently when an earlier
* reply already read everything (`hasUnansweredMessage`).
*/
export default async function runBotReply({
bot,
@@ -63,10 +80,48 @@ export default async function runBotReply({
limit: AppData.BotReplyHistoryMessages,
});
if (history[history.length - 1]?.author != "user") {
const answered_id = await grabAnsweredMessageId({ session_id });
if (!hasUnansweredMessage({ history, answered_id })) {
return { result: "", silent: true };
}
/**
* The newest message the run has read, and of those the newest user
* message, which is what the reply answers.
*/
let seen_id = Math.max(0, ...history.map((message) => Number(message.id)));
let answering_id = newestUserMessageId({ messages: history });
const toTurns = async ({ messages }: { messages: BotMessageWithAttachments[] }) =>
botMessagesToHarness({
messages,
parts: await loadAttachmentParts({ messages }),
refs: await grabMessageRefNames({
texts: messages.filter((m) => m.author == "user").map((m) => m.text || ""),
user_id,
}),
});
/**
* What the user wrote since the run last looked.
*/
const incoming = async (): Promise<HarnessMessage[]> => {
const fresh = await grabBotMessages({
session_id,
after_id: seen_id,
author: "user",
limit: AppData.BotReplyHistoryMessages,
});
if (!fresh.length) return [];
seen_id = Math.max(seen_id, ...fresh.map((message) => Number(message.id)));
answering_id = Math.max(answering_id, newestUserMessageId({ messages: fresh }));
return await toTurns({ messages: fresh });
};
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
@@ -82,33 +137,64 @@ export default async function runBotReply({
}).catch(() => "");
const take_overs: string[] = [];
/**
* The MCP servers and tools the coordinator proposed: their cards need
* the user just like a take-over.
*/
const proposals: string[] = [];
/**
* Files the bot wrote and asked to send with its reply.
*/
const attachments: number[] = [];
/**
* The files this run reaches: its file tools and the pictures it may
* set work from the same answer (`grabRunScopes`).
*/
const scopes = await grabRunScopes({ bot, user_id, workspace_id });
const file_tools = await grabBotFileTools({
bot,
user_id,
workspace_id,
session_id,
scopes,
onAttach: ({ media_id }) => attachments.push(media_id),
});
/**
* What the bot's types let it do here, and what it leads when it's this
* workspace's admin.
*/
const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
let led: WorkspaceAdminTools | null = null;
const tools = await grabBotChatTools({
bot,
powers,
user_id,
computers,
session_id,
workspace_id,
scopes,
message_id: job.message_id || undefined,
onTakeOver: ({ ref }) => take_overs.push(ref),
onPropose: ({ ref }) => proposals.push(ref),
onWorkspaceAdmin: (admin) => {
led = admin;
},
});
/**
* Where the run works on a computer: the user's folder in the bot's.
*/
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
const extensions = await openBotExtensions({
bot_id: bot.id,
workspace_id,
computer_id: computers[0]?.id || null,
dirs,
taken: tools.map((tool) => tool.name),
signal,
});
@@ -122,30 +208,35 @@ export default async function runBotReply({
});
/**
* delegate_task is only there while the coordinator has workers.
* delegate_task is only there while the coordinator, or a workspace's
* admin, has bots to hand work to. Anyone else in a workspace with an
* admin is told who leads it.
*/
const crew_note =
bot.kind != "coordinator"
? ""
: tools.some((tool) => tool.name == "delegate_task")
? CoordinatorReplyNote
: CoordinatorSoloNote;
const can_delegate = tools.some((tool) => tool.name == "delegate_task");
const lead = led as WorkspaceAdminTools | null;
const crew_note = powers.is_coordinator
? can_delegate
? coordinatorReplyNote({ bot_id: bot.id })
: CoordinatorSoloNote
: lead?.workspace && can_delegate
? workspaceAdminNote({ workspace_name: lead.workspace.name || "this workspace", members: lead.members })
: await workspaceAdminsNote({ bot_id: bot.id, workspace_id });
try {
const result = await runTextLoop({
system: [
prompt,
carry_over,
ChatReplyNote,
BotFilesNote,
ComputerFilesNote({ computer_id: computers[0]?.id || 0, can_import: false }),
chatReplyNote({ bot_id: bot.id }),
botFilesNote({ bot_id: bot.id }),
ComputerFilesNote({ computer_id: computers[0]?.id || 0, dirs, can_import: false }),
TasksNote,
TaskReminderNote,
ActionCheckNote,
workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({
computers,
is_coordinator: bot.kind == "coordinator",
is_coordinator: powers.is_coordinator,
in_workspace: Boolean(workspace_id),
}),
crew_note,
@@ -155,27 +246,38 @@ export default async function runBotReply({
]
.filter(Boolean)
.join("\n\n"),
messages: botMessagesToHarness({
messages: history,
parts: await loadAttachmentParts({ messages: history }),
refs: await grabMessageRefNames({
texts: history.filter((m) => m.author == "user").map((m) => m.text || ""),
user_id,
}),
}),
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
max_steps: AppData.BotReplyMaxToolSteps,
messages: await toTurns({ messages: history }),
tools: [
...tools,
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
...file_tools,
...extensions.tools,
...skills.tools,
],
max_steps: await grabReplyMaxToolSteps(),
bot_id: bot.id,
workspace_id,
signal,
onStep: report,
incoming,
});
if (job.id && answering_id) {
await markReplyAnswered({ job_id: job.id, message_id: answering_id });
}
return {
...withTakeOverRefs({ text: result.text, refs: take_overs }),
...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }),
attachments: attachments.length ? attachments : undefined,
};
} finally {
await extensions.close();
}
}
function newestUserMessageId({ messages }: { messages: Pick<BotMessageWithAttachments, "id" | "author">[] }) {
return Math.max(
0,
...messages.filter((message) => message.author == "user").map((message) => Number(message.id)),
);
}
+56 -20
View File
@@ -1,13 +1,17 @@
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools";
import workspaceAdminsNote from "./workspace-admins-note";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import {
ActionCheckNote,
BotFilesNote,
botFilesNote,
ComputerFilesNote,
ScheduledRunNote,
TasksNote,
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";
@@ -24,13 +28,14 @@ import searchComputersTool from "@/src/harness/tools/computer/search-computers-t
import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools";
import reportRunFailedTool from "@/src/harness/tools/bot/report-run-failed-tool";
import computerRef from "@/src/utils/computer-ref";
import grabStorageScopes from "@/src/utils/storage-access";
import grabRunScopes from "@/src/functions/backend/db/bot-files/grab-run-scopes";
import composeJobBrief from "./compose-job-brief";
import grabSessionCarryOver from "./grab-session-carry-over";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import withTakeOverRefs from "./with-take-over-refs";
import ScheduledRunFailedError from "./scheduled-run-failed-error";
import type { BotJobContext, BotJobOutcome } from "./types";
import computerRunDirs from "@/src/utils/computer-work-dirs";
type Params = BotJobContext & {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
@@ -61,12 +66,17 @@ export default async function runBotTask({
report,
}: Params): Promise<BotJobOutcome> {
const workspace_id = job.workspace_id || null;
/**
* Where the run works on a computer: the user's folder in the bot's.
*/
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
const computer = job.computer_id
? await openComputerTools({
computer_id: job.computer_id,
bot_id: bot.id,
workspace_id,
dirs,
signal,
})
: null;
@@ -80,13 +90,15 @@ export default async function runBotTask({
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
/**
* A coordinator's task can also change workspaces: the findings
* it was asked to record go there, not only in the report.
* What the bot's types let it do here. A coordinator's task can
* also change workspaces, and a workspace admin's its workspace:
* the findings it was asked to record go there, not only in the
* report.
*/
const coordinator_workspaces =
bot.kind == "coordinator"
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
: [];
const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
const coordinator_workspaces = powers.is_coordinator
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
: [];
const carry_over = await grabSessionCarryOver({
session_id,
bot_id: bot.id,
@@ -108,6 +120,21 @@ export default async function runBotTask({
...computers.filter((c) => c.id == job.computer_id),
...computers.filter((c) => c.id != job.computer_id),
];
/**
* The files this run reaches, for its file tools, an import and the
* pictures it may set: one answer, where `grabRunScopes` decides.
*/
const scopes = await grabRunScopes({ bot, user_id, workspace_id });
/**
* A workspace admin's task keeps the workspace's context and its
* members' instructions; it hands nothing over (no session to report
* back to mid-task).
*/
const admin_tools =
!powers.is_coordinator && workspace_id && powers.is_workspace_admin
? (await grabWorkspaceAdminTools({ bot, user_id, workspace_id, powers, scopes })).tools
: [];
const tools = [
...(await grabBotTools({
@@ -116,9 +143,12 @@ export default async function runBotTask({
workspace_id,
computers,
session_id,
is_coordinator: bot.kind == "coordinator",
is_coordinator: powers.is_coordinator,
admin_workspace_ids: powers.admin_workspace_ids,
scopes,
})),
...(bot.kind == "coordinator"
...admin_tools,
...(powers.is_coordinator
? [
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
...(coordinator_workspaces.length
@@ -127,6 +157,7 @@ export default async function runBotTask({
user_id,
workspaces: coordinator_workspaces,
workspace_id,
scopes,
}),
]
: []),
@@ -134,7 +165,7 @@ export default async function runBotTask({
: []),
...(computers.length
? [
searchComputersTool({ bot_id: bot.id, computers, workspace_id }),
searchComputersTool({ bot_id: bot.id, computers, workspace_id, dirs }),
askToTakeOverTool({
computers: take_over_computers,
in_task: true,
@@ -148,13 +179,10 @@ export default async function runBotTask({
? [
importFileTool({
computer_id: job.computer_id,
scopes: grabStorageScopes({
bot_id: bot.id,
bot_kind: bot.kind,
workspace_id,
}),
scopes,
bot_id: bot.id,
user_id,
dirs,
}),
]
: []),
@@ -163,6 +191,7 @@ export default async function runBotTask({
user_id,
workspace_id,
session_id,
scopes,
onAttach: ({ media_id }) => attachments.push(media_id),
})),
];
@@ -171,6 +200,7 @@ export default async function runBotTask({
bot_id: bot.id,
workspace_id,
computer_id: job.computer_id || null,
dirs,
taken: tools.map((tool) => tool.name),
signal,
});
@@ -186,14 +216,19 @@ export default async function runBotTask({
carry_over,
TaskRunNote,
scheduled ? ScheduledRunNote : "",
BotFilesNote,
ComputerFilesNote({ computer_id: job.computer_id || 0 }),
botFilesNote({ bot_id: bot.id }),
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
TasksNote,
ActionCheckNote,
workspace_id ? WorkspaceLearningNote : "",
bot.kind == "coordinator"
powers.is_coordinator
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
: "",
: admin_tools.some((tool) => tool.name == "update_workspace")
? `If this brief asked you to record something in this workspace's context, do it yourself with update_workspace before you report: the report alone changes nothing.`
: "",
powers.is_coordinator || powers.is_workspace_admin
? ""
: await workspaceAdminsNote({ bot_id: bot.id, workspace_id }),
computer
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
: workspace_id
@@ -223,6 +258,7 @@ export default async function runBotTask({
},
],
tools: [...tools, ...extensions.tools, ...skills.tools],
max_steps: await grabHarnessMaxToolSteps(),
bot_id: bot.id,
workspace_id,
signal,
+6 -4
View File
@@ -1,15 +1,17 @@
type Params = {
text: string;
/**
* The take-over refs the bot asked for in this run (`askToTakeOverTool`).
* The take-over refs the bot asked for in this run (`askToTakeOverTool`),
* and the proposals it made for the user to confirm (`proposalRef`).
*/
refs: string[];
};
/**
* A job's message with every take-over the bot asked for in it: refs the
* bot didn't write itself are added at the end, so the user always gets
* the button. A message asking for a take-over needs the user.
* A job's message with every take-over the bot asked for in it and every
* proposal it made: refs the bot didn't write itself are added at the
* end, so the user always gets the button. A message asking for either
* needs the user.
*/
export default function withTakeOverRefs({ text, refs }: Params) {
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
+20
View File
@@ -0,0 +1,20 @@
import { workspaceAdminsLine } from "@/src/data/harness-prompts";
import grabWorkspaceAdmins from "@/src/functions/backend/db/bot-types/grab-workspace-admins";
/**
* For a bot in a workspace that has admins other than itself: the line
* naming them (`workspaceAdminsLine`).
*/
export default async function workspaceAdminsNote({
bot_id,
workspace_id,
}: {
bot_id: number | string | "" | undefined;
workspace_id?: number | string | null;
}) {
if (!workspace_id) return "";
const admins = (await grabWorkspaceAdmins({ workspace_id })).filter((admin) => admin.id != Number(bot_id));
return admins.length ? workspaceAdminsLine({ admins }) : "";
}
@@ -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>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { ShieldCheck } from "lucide-react";
import { twMerge } from "tailwind-merge";
type Props = {
/**
* Its size in pixels; 14 sits beside a 14px name.
*/
size?: number;
/**
* The tooltip and accessible name.
*/
label?: string;
className?: string;
};
/**
* The mark of a workspace's admin bot: a filled success-green shield with
* its check cut out in the background's colour, set beside the bot's name
* (never on its stone, which takes no badges). A role, not a status, and
* never the bot's own colour.
*/
export default function AdminShield({
size = 17,
label = "Workspace admin",
className,
}: Props) {
return (
<ShieldCheck
size={size}
role="img"
aria-label={label}
className={twMerge(
"shrink-0 inline-block align-middle",
"fill-success text-on-ink dark:fill-success-dark",
className,
)}
>
<title>{label}</title>
</ShieldCheck>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
type Props = {
label?: string;
className?: string;
};
/**
* A thin line sweeping across, set just under a pane header while its
* page loads. Takes no height (it overlays the top of what follows), so
* nothing moves when it goes. Under reduced motion it holds still.
*/
export default function LoadingLine({ label = "Loading", className }: Props) {
return (
<Stack
role="progressbar"
aria-label={label}
className={twMerge("relative h-0 w-full shrink-0 gap-0! z-10", className)}
>
<Stack className="absolute inset-x-0 top-0 h-0.5 gap-0! overflow-hidden bg-ink/5">
<Span
className={twMerge(
"block h-full w-1/3 bg-linear-to-r from-transparent via-ink to-transparent",
"animate-loading-line",
"motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-30",
)}
/>
</Stack>
</Stack>
);
}
@@ -13,7 +13,8 @@ type Props = {
};
/**
* A routine's name, schedule (a cron expression in UTC) and brief.
* A routine's name, schedule (a cron expression in UTC) and brief. The
* routines page and the conversation's routines modal both edit with it.
*/
export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
const [title, setTitle] = useState(cron_job.title || "");
@@ -1,10 +1,10 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { CronJobWithNames } from "@/src/types";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
import routineSubtitle from "../(utils)/routine-subtitle";
import routineSubtitle from "@/src/utils/routine-subtitle";
import BotAvatar from "./bot-avatar";
type Props = {
cron_job: CronJobWithNames;
@@ -12,13 +12,18 @@ type Props = {
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
/**
* In a conversation's own modal the Chat link and the bot's and
* workspace's names are already the ones on screen.
*/
compact?: boolean;
};
/**
* One routine: its bot's avatar, its name (Paused or Done when it is),
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete.
*/
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) {
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, compact }: Props) {
const title = cron_job.title || `Routine ${cron_job.id}`;
const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
const chat_url = cron_job.workspace_id
@@ -36,9 +41,15 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete
}
title={title}
badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"}
subtitle={routineSubtitle({ cron_job })}
subtitle={routineSubtitle({
cron_job,
show_bot: !compact,
show_workspace: !compact,
})}
>
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
{compact ? null : (
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
)}
<SettingsRowAction
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
label={cron_job.enabled ? "Pause" : "Resume"}
-4
View File
@@ -6,9 +6,5 @@ export default async function heartbeat() {
sql: `SELECT 1 AS alive`,
});
bunext.bunextLog.info(
`Cron: database heartbeat ${db_alive.success ? "ok" : "failed"}`,
);
return db_alive;
}
-5
View File
@@ -1,4 +1,3 @@
import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
@@ -15,9 +14,5 @@ export default async function pruneBotsQueue() {
values: [cutoff],
});
bunext.bunextLog.info(
`Cron: pruned ${res.payload?.length || 0} finished bot job(s)`,
);
return res;
}
+16 -8
View File
@@ -1,21 +1,26 @@
import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout";
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address"> &
{ computer_id: number };
type Row = Pick<
BUN_SQLITE_COBBLE_CONTAINERS,
"id" | "docker_id" | "status" | "ip_address" | "metadata"
> & { computer_id: number };
/**
* Reads every computer container's state from docker and records it
* (`refreshComputerContainer`), telling owners about changes. Containers
* still being set up, or that failed, are left alone.
* still being set up, or that failed, are left alone. A container that
* still mounts its computer's whole home is recreated with only the
* workspace (`migrateComputerLayout`, once per computer).
*/
export default async function syncComputers() {
const rows =
(
await BunSQLite.sql<Row>({
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, c.id AS computer_id
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, co.metadata, c.id AS computer_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')`,
})
@@ -23,11 +28,14 @@ export default async function syncComputers() {
let changed = 0;
for (const { computer_id, ...container } of rows) {
const refreshed = await refreshComputerContainer({ computer_id, container });
for (const { computer_id, metadata, ...container } of rows) {
if (await migrateComputerLayout({ computer_id, metadata })) continue;
const refreshed = await refreshComputerContainer({
computer_id,
container,
});
if (refreshed?.changed) changed++;
}
bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`);
}
+11 -11
View File
@@ -1,23 +1,23 @@
import bunext from "@moduletrace/bunext";
import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models";
import { logError } from "@/src/functions/backend/logger";
/**
* Refreshes every enabled provider's model list. Free models on OpenRouter
* come and go, and automatic picks follow them from this list.
* come and go, and automatic picks follow them from this list. A provider
* that fails doesn't fail the run, so its error goes to the logs here.
*/
export default async function cronSyncHarnessModels() {
const results = await syncAllHarnessModels();
for (const result of results) {
if (result.error) {
bunext.bunextLog.error(
`Cron: model sync for provider #${result.provider_id} failed: ${result.error}`,
);
} else {
bunext.bunextLog.info(
`Cron: provider #${result.provider_id} lists ${result.count} model(s)`,
);
}
if (!result.error) continue;
await logError({
title: `Cron: model sync for provider #${result.provider_id} failed`,
category: "cron",
error: result.error,
metadata: { provider_id: result.provider_id },
}).catch(() => {});
}
return results;
+111 -6
View File
@@ -96,12 +96,34 @@ export const AppData = {
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
/**
* A computer's home directory (`ComputerHomeDir` in its container) is
* kept at `DATA_DIR/<ComputerDataDir>/<id>`, owned by the sandbox's
* user. It stays when the computer is deleted.
* Only a computer's workspace (`ComputerWorkspaceDir` in its
* container) is kept, at `DATA_DIR/<ComputerDataDir>/<id>`, owned by
* the sandbox's user. It stays when the computer is deleted. The rest
* of its home (`ComputerHomeDir`: dotfiles, browser profile, the env
* files) lives in the container and goes with it. Every bot works in
* `bots/<bot id>` there, a user's work in `bots/<bot id>/users/<user
* id>`, and a workspace's bots share `workspaces/<workspace id>`
* (`computerRunDirs`).
*/
ComputerDataDir: "computers",
ComputerHomeDir: "/home/gem",
ComputerWorkspaceDir: "/home/gem/workspace",
/**
* Written at the root of a computer's workspace once it uses that
* layout, so an older computer's whole home is told apart and moved
* aside (`prepareComputerDataDir`).
*/
ComputerWorkspaceMarker: ".cobble-workspace",
/**
* An older computer's whole home is moved aside to
* `<id><ComputerOldHomeSuffix><timestamp>` and kept, never archived.
*/
ComputerOldHomeSuffix: "-home-",
/**
* Recorded in a container's `metadata.layout` once it mounts only the
* workspace.
*/
ComputerWorkspaceLayout: "workspace",
ComputerUserUid: 1000,
ComputerUserGid: 1000,
/**
@@ -246,9 +268,12 @@ 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: 50,
HarnessMaxToolSteps: 200,
/**
* Longest tool result handed back to the model.
*/
@@ -289,6 +314,20 @@ export const AppData = {
SkillBodyMaxChars: 60000,
SkillBodyShownChars: 30000,
SkillDescriptionMaxChars: 300,
/**
* How long the super admin has to confirm an MCP server or tool the
* coordinator proposed (`extension_proposals`). After that its card
* reads expired and the bot has to propose it again, so a card left in
* an old thread can't be confirmed weeks later.
*/
ExtensionProposalTtlMs: 24 * 60 * 60 * 1000,
/**
* A proposal's background check: the bot's summary, and the sources it
* read (at least one).
*/
ExtensionResearchMinChars: 40,
ExtensionResearchMaxChars: 6000,
ExtensionResearchSourcesMax: 10,
/**
* `search_computers`: how long the command may run on each computer,
* and how much of each computer's output the bot reads.
@@ -328,6 +367,21 @@ export const AppData = {
* How many older messages each scroll to the top of the thread loads.
*/
ConversationOlderMessages: 30,
/**
* The most model calls a message's details modal lists one by one. A
* long task can take hundreds; the totals still count every one.
*/
MessageDetailsMaxCalls: 200,
/**
* Days of usage the usage page totals by default, and the most it will
* total at once.
*/
UsageDefaultDays: 30,
UsageMaxDays: 365,
/**
* Models listed per page on the usage page, the busiest first.
*/
UsageModelPageSize: 20,
/**
* How much of a bot's last message the rail's lists carry, as plain
* text (`messagePreview`): a row shows one line of it.
@@ -379,8 +433,23 @@ 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,
BotReplyMaxToolSteps: 20,
/**
* Messages a chat reply may send while it works (`send_message`):
* acknowledging something the user wrote meanwhile, a heads-up. Its
* answer still comes at the end.
*/
BotReplyInterimMessagesMax: 3,
/**
* 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.
@@ -388,6 +457,13 @@ export const AppData = {
BotAttachmentMaxBytes: 20 * 1024 * 1024,
BotAttachmentMaxFiles: 6,
BotAttachmentMaxTextChars: 20000,
/**
* How much of a document's text is kept on its `media` row. A ceiling
* on the column itself: without it a 20 MB PDF left 20 MB of text in
* the database, even though a reply reads at most
* `BotAttachmentMaxTextChars` of it.
*/
BotAttachmentTextStoreMaxChars: 200000,
/**
* Only this many of the latest messages with files hand the files
* themselves to a reply; older ones are named.
@@ -434,6 +510,12 @@ export const AppData = {
* Most folders deep a path may go, so nothing buries a file.
*/
BotFilePathMaxDepth: 8,
/**
* Most folders deep the files browser opens: a computer's workspace
* starts four down (`bots/<bot>/users/<user>`) before a bot's own
* folders begin.
*/
StorageBrowserMaxDepth: 16,
/**
* The files modal and the bot's file list: one page of them.
*/
@@ -444,6 +526,20 @@ export const AppData = {
*/
StorageBrowserPageSize: 200,
StorageBrowserReadMaxBytes: 50 * 1024 * 1024,
/**
* The database browser at `/admin/settings/database`: how many records
* of a table one page shows, how much of a value a cell carries before
* the rest waits behind its record's modal, and the most of one value
* that modal shows (a whole media row or job checkpoint is not meant
* to be rendered).
*/
DatabaseBrowserPageSize: 50,
DatabaseBrowserCellMaxChars: 400,
DatabaseBrowserValueMaxChars: 20000,
/**
* The most words one search matches in the database browser.
*/
DatabaseBrowserMaxSearchWords: 8,
/**
* The most a bot copies off a computer into its own files in one
* call, so a folder of raw evidence can't fill the data volume.
@@ -511,6 +607,13 @@ export const AppData = {
*/
DataBackupNamePrefix: "cobble-data-",
DataBackupsKept: 5,
/**
* How often the backups page asks how a whole-data archive or restore
* is going, and how long it waits for the app to come back after a
* restore restarted it before it reloads anyway.
*/
DataBackupPollMs: 2000,
DataRestoreWaitMs: 120000,
/**
* Responses smaller than this go out uncompressed: the encoding
* costs more than it saves.
@@ -529,4 +632,6 @@ export const AppData = {
*/
CompressCacheMaxFiles: 600,
CompressCacheMaxBytes: 32 * 1024 * 1024,
DefaultIconSize: 16,
} as const;
+4
View File
@@ -27,6 +27,9 @@ Permission to draft is not permission to send, permission to analyze is not perm
## 2. Delegate well
- Pick by role, instructions and tools, not by name.
- Say where the work runs, and mean it. \`delegate_task\` takes an optional \`workspace_id\`, and that one argument decides the whole context the worker has: its own chat, or a workspace. A task in the worker's own chat has only that worker's own memory, files and directly assigned computers. A task in a workspace has the workspace's shared context, the worker's instructions and memory for that workspace, the project's files, and the workspace's computers as well as the worker's. The result also lands in a different conversation: the worker's own chat, or its conversation in that workspace beside the rest of that project. Choose by where the work belongs, not by which is easier to type.
- Handed over from a conversation inside a workspace, the task always runs in that workspace and \`workspace_id\` is not accepted: say so, don't pretend you moved it.
- Delegating into a workspace the worker isn't in adds it to that workspace as a side effect. Say that in your reply when it happens; don't add members silently for a one-off.
- Give a full brief: objective, inputs, deliverable, acceptance criteria, constraints.
- Check \`list_tasks\` first. Never give two bots the same job, and don't restart work an open task already covers.
- Track what comes back, judge it against the request, and integrate it. Don't pass a worker's unchecked claim on as fact.
@@ -40,6 +43,7 @@ Permission to draft is not permission to send, permission to analyze is not perm
- A workspace is where bots share one project's context. Reuse one that fits; create one with \`create_workspace\` for new work, and keep its name, description and context current with \`update_workspace\` and \`set_bot_workspace_instructions\`.
- Check membership with the tools. Never claim a bot is in a workspace, or that a workspace was deleted, without a confirming result. Explain what a deletion removes before asking for it.
- Workspace context is information, not authorization.
- The install's skills (runbooks), MCP servers and custom tools are yours to keep: read them with \`list_extensions\`, decide who gets one with \`set_extension_access\`, and remove one with \`delete_extension\`. Adding one, or changing what it does (a skill's words, where a server or tool sends), is the user's call: run a background check first (for a server or tool the publisher's own docs, the official URL, what it needs; for a skill a review of every step and where its content came from), then \`propose_skill\`, \`propose_mcp_server\` or \`propose_custom_tool\`, which puts a card in your reply for the user to confirm. Tell them what it does, where it sends, which keys it uses (saved and missing), and what your check found. Never ask for a key's value in chat: missing keys are added in Settings, under Environment. It's added only once the user's message says they confirmed it. \`update_skill\`, \`update_mcp_server\` and \`update_custom_tool\` change the rest (on or off, and for a server or tool its name, description and arguments) directly.
Instruction layers are separate, and changing one does not change the others:
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import {
botFilesNote,
chatReplyNote,
ComputerFilesNote,
coordinatorReplyNote,
TasksNote,
} from "./harness-prompts";
import computerRunDirs from "@/src/utils/computer-work-dirs";
/**
* Every note a bot reads names it by its own id, so a bot copying an
* example never points at another bot's folder, conversation or task.
*/
const bot_ids = [1, 5, 42] as const;
/**
* The bot ids a note's own-folder examples point at, in order.
*/
function folderOwners(note: string) {
return [...note.matchAll(/\[folder:bot:(\d+):/g)].map((match) => match[1]);
}
describe("harness prompts", () => {
for (const bot_id of bot_ids) {
test(`chat reply note names bot ${bot_id}'s own folder`, () => {
const owners = folderOwners(chatReplyNote({ bot_id }));
expect(owners).toEqual([String(bot_id)]);
});
test(`files note names bot ${bot_id}'s own folder`, () => {
const owners = folderOwners(botFilesNote({ bot_id }));
expect(owners).toEqual([String(bot_id)]);
});
test(`coordinator note names itself as bot ${bot_id}`, () => {
const note = coordinatorReplyNote({ bot_id });
expect(note).toContain(`[bot:${bot_id}]`);
expect(note).not.toContain("[bot:3]");
});
}
test("a note that already names the running bot is unchanged by its id", () => {
expect(botFilesNote({ bot_id: 5 })).toContain("[folder:bot:5:reports]");
});
test("the computer files note names the computer and the run's folders", () => {
const note = ComputerFilesNote({
computer_id: 8,
dirs: computerRunDirs({ bot_id: 3, user_id: 7, workspace_id: 2 }),
});
expect(note).toContain("[folder:computer:8:bots/3/users/7/seo/coderank/raw]");
expect(note).toContain("/home/gem/workspace/bots/3/users/7");
expect(note).toContain("/home/gem/workspace/workspaces/2");
});
test("notes with nothing bot-specific carry no ref example", () => {
expect(TasksNote).not.toContain("[folder:bot:");
expect(TasksNote).not.toContain("[bot:");
});
});
+60 -10
View File
@@ -1,6 +1,7 @@
import { AppData } from "@/src/data/app-data";
import botRef from "@/src/utils/bot-ref";
import folderRef from "@/src/utils/folder-ref";
import { computerWorkspaceRelative, type ComputerRunDirs } from "@/src/utils/computer-work-dirs";
/**
* Prompts the helper harnesses send. The text model gets their output in
@@ -19,9 +20,19 @@ export const ReadImagePrompt = `Describe this image for someone who can't see it
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
/**
* Added to a bot's prompt when it answers the user in chat.
* The folder-ref example a note shows, always over the running bot's own
* folder: a literal owner id in the prompt teaches the bot to name another
* bot's folder as if it were its own.
*/
export const ChatReplyNote = `# Replying in chat
function ownFolderRef({ bot_id }: { bot_id: number | string }) {
return folderRef({ volume: "bot", owner_id: bot_id, path: "reports" });
}
/**
* Added to a bot's prompt when it answers the user in chat. `bot_id` is the
* bot itself: the examples it reads are its own refs.
*/
export const chatReplyNote = ({ bot_id }: { bot_id: number | string }) => `# Replying in chat
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
@@ -33,7 +44,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
- Never claim you did something you didn't do.
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: that opens the folder in Files and downloads nothing.
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${ownFolderRef({ bot_id })}: that opens the folder in Files and downloads nothing.
## Keep the work moving
@@ -41,18 +52,20 @@ Be proactive. Follow where the conversation is heading and, once you've answered
/**
* Added to every run, chat replies and tasks alike: where its files live,
* that they persist, and how to search them.
* that they persist, and how to search them. `bot_id` is the bot itself,
* so the folder-ref example it reads is its own folder.
*/
export const BotFilesNote = `# Your files
export const botFilesNote = ({ bot_id }: { bot_id: number | string }) => `# Your files
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
- list_files shows what's in it; read_file opens one (by its [file:ID] ref or path); write_file saves a text file, make_pdf a PDF, generate_image an image. Move and delete with move_file and delete_file when something is out of date, so the folder stays useful.
- Text files are chunked and searchable as soon as they're saved. search_files finds the passages that answer a question, by meaning when an embedding model is set and by words otherwise. Search your files before saying you don't know something they may hold, and when the user asks about a document they gave you.
- Uploaded files land in the same folder, so what the user sent you earlier is searchable later too.
- Uploaded files land in the same folder, under uploads/ (uploads/2026-10/logo.png), so what the user sent you earlier is searchable later too.
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
- A file and a folder are two different refs, and you never guess which one from the name:
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
- A folder is a folder ref, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
- A file you only described is not saved. Save it first, then say it's saved.
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
@@ -70,15 +83,28 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
*/
export const ComputerFilesNote = ({
computer_id,
dirs,
can_import = true,
}: {
computer_id: number | string;
/**
* The run's folders on the computer (`computerRunDirs`).
*/
dirs: ComputerRunDirs;
can_import?: boolean;
}) => `# Files on the computer
What you make on the computer (downloads, screenshots, a crawl, raw evidence, logs) lives in its home, not in your files. It survives restarts of the computer, but it is not searchable and it goes if the computer is rebuilt.
You work only in your own folders on the computer, never elsewhere in it: every shell, code and file tool starts in ${dirs.cwd} (also ~), and a path outside your folders is refused.
- The user can open any of it: name a folder on the computer as a folder ref, e.g. ${folderRef({ volume: "computer", owner_id: computer_id, path: "seo/coderank/raw" })}. It opens that folder in Files, so it downloads nothing. Point at the folder holding the evidence they may want to see.
- ${dirs.cwd}: this user's work. Everything for them goes here.
- ${dirs.bot_dir}: only what is the same for every user you work for (other users' folders in it are not yours).${
dirs.shared_dir ? `\n- ${dirs.shared_dir}: shared with every bot in this workspace.` : ""
}
- The browser downloads to its own folder: move what you download into your folder straight away.
What you make there (downloads, screenshots, a crawl, raw evidence, logs) is kept with the computer, not in your files, and it is not searchable.
- The user can open any of it: name a folder on the computer as a folder ref, its path from the computer's workspace (${AppData.ComputerWorkspaceDir}), e.g. ${folderRef({ volume: "computer", owner_id: computer_id, path: `${computerWorkspaceRelative({ absolute: dirs.cwd })}/seo/coderank/raw` })}. It opens that folder in Files, so it downloads nothing. Point at the folder holding the evidence they may want to see.
${
can_import
? `- import_file copies a file or a folder off the computer into your own files, in one go, and the copy is searchable straight away. Import the finished work (the report, the spreadsheet, the final document), attach it when it is what they asked for, and name it with its [file:ID] ref.\n- The raw evidence can stay on the computer: import it only when the user needs it kept, or when the computer may be rebuilt.\n`
@@ -113,8 +139,10 @@ Every action counts here: saving memory or instructions, creating or changing a
/**
* Added to the coordinator's chat prompt: it hands work to the crew.
* `bot_id` is the coordinator itself, so the ref example it copies is its
* own rather than a worker's.
*/
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
/**
* Added to the coordinator's chat prompt instead while it has no workers,
@@ -122,6 +150,28 @@ export const CoordinatorReplyNote = `As the coordinator, hand work that fits a c
*/
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`;
/**
* Added to a workspace admin's chat prompt in the workspace it leads, while
* it has members to hand work to (`delegate_task` is there). `members` are
* the bots it directs, written as refs.
*/
export const workspaceAdminNote = ({
workspace_name,
members,
}: {
workspace_name: string;
members: { id?: number | "" }[];
}) => `# Leading ${workspace_name}
You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself; they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`;
/**
* Added to a workspace member's prompt while the workspace has an admin,
* so it knows who hands it work there.
*/
export const workspaceAdminsLine = ({ admins }: { admins: { id?: number | "" }[] }) =>
`In this workspace, ${admins.map((a) => botRef({ id: a.id || 0 })).join(" and ")} ${admins.length == 1 ? "is the admin and hands" : "are the admins and hand"} out work: a task from them is part of your job here.`;
/**
* Added to a bot's prompt when it works through a queued task.
*/
+1
View File
@@ -7,6 +7,7 @@ export const AdminSections = [
"/admin/workspaces",
"/admin/routines",
"/admin/files",
"/admin/usage",
"/admin/logs",
"/admin/help",
"/admin/settings",
+6
View File
@@ -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.
*/
+25
View File
@@ -4,6 +4,30 @@
*/
export const BotJobCancelledError = "Cancelled";
/**
* `last_error` of a job the user stopped from the chat. A stopped job is
* never retried and never told about: the user pulled the plug.
*/
export const BotJobStoppedError = "Stopped by the user";
/**
* The last step recorded on a stopped job. It's a step, not a message, so a
* bot that was stopped mid-thought doesn't answer on its own.
*/
export const BotJobStoppedStep = "Stopped";
/**
* What the thinking row's stop button says, so the row and its tooltip
* never drift.
*/
export const BotStopButtonLabel = "Stop";
/**
* What the bot says in the conversation once the user has stopped it. It's
* the bot's turn again, so the thread stops waiting on a reply.
*/
export const BotStoppedMessage = "Stopped. I'll carry on when you ask me to.";
/**
* `last_error` bun-sqlite sets when it releases a job whose handler went
* silent past the lock timeout on its last attempt.
@@ -87,6 +111,7 @@ export const JobStepToolLabels: Record<string, string> = {
update_memory: "Updating memory",
update_instructions: "Updating instructions",
load_skill: "Reading a skill",
send_message: "Sending a message",
};
/**
+127
View File
@@ -0,0 +1,127 @@
import type { BotPermission, BotType, TypeAssignmentMatrixType } from "../types";
/**
* Every bot type. A bot with no `bot_types` row is a worker. A type holds
* install-wide (`scope: "install"`, its row has no workspace) or in one
* workspace (`scope: "workspace"`, its row names it). `bots.kind` stays the
* legacy marker of the one coordinator for who sees it; what a bot can do
* comes from these.
*/
export const BotTypes = [
{
value: "coordinator",
title: "Coordinator",
description: "Coordinates the whole crew: hands work to every bot, grows the crew, runs workspaces and extensions. One per install, the super admin's alone.",
scope: "install",
},
{
value: "admin",
title: "Workspace admin",
description: "Leads one workspace: hands tasks to the other bots there, sets their instructions there, runs the workspace's task list and keeps its context.",
scope: "workspace",
},
{
value: "worker",
title: "Worker",
description: "Does its own job. Every bot without another type.",
scope: "install",
},
] as const;
/**
* What a bot type lets a bot do.
*/
export const BotPermissions = [
{
value: "delegate_task",
title: "Hand tasks to other bots",
},
{
value: "set_member_instructions",
title: "Set other bots' workspace instructions",
},
{
value: "manage_workspace_tasks",
title: "Run the workspace's task list",
},
{
value: "edit_workspace",
title: "Edit the workspace's name, description and context",
},
{
value: "manage_crew",
title: "Create, edit and delete bots, their computers and workspaces",
},
{
value: "manage_workspaces",
title: "Create and delete workspaces",
},
{
value: "manage_extensions",
title: "Manage skills, MCP servers and tools",
},
{
value: "set_bot_types",
title: "Make bots workspace admins",
},
{
value: "set_any_picture",
title: "Set any bot's picture",
},
{
value: "reach_all_tasks",
title: "Reach every task on the install",
},
] as const;
/**
* What each bot type may do, where it holds (everywhere for an
* install-wide type, in its workspace for a workspace one).
*/
export const BotTypePermissionMatrix: Record<BotType, BotPermission[]> = {
coordinator: BotPermissions.map((bp) => bp.value),
admin: [
"delegate_task",
"set_member_instructions",
"manage_workspace_tasks",
"edit_workspace",
],
worker: [],
};
/**
* Which bots a bot may act on (hand a task to, set instructions for), keyed
* by the target's type. Same shape as `UserTypeAssignmentMatrix`: the
* coordinator passes through. A workspace admin directs workers only, so
* two admins never hand work back and forth.
*/
export const BotTypeTargetMatrix: TypeAssignmentMatrixType<BotType> = {
coordinator: {},
admin: {
allowed_actors: [],
},
worker: {
allowed_actors: ["admin"],
},
};
/**
* Who may grant a bot type: user types (`admin`; the super admin passes
* through) and bot types (`coordinator`). The coordinator type is never
* granted: `ensureCoordinatorBot` writes it.
*/
export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType<BotType> = {
coordinator: {},
admin: {
allowed_actors: ["admin", "coordinator"],
},
worker: {},
};
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
coordinator: {},
admin: {
allowed_actors: ["admin", "coordinator"],
},
worker: {},
};
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* A computer is an AIO sandbox running in its own docker container on the
* cobble network, created by the app (`createComputer`), its home
* directory kept under `DATA_DIR/computers/<id>`.
* cobble network, created by the app (`createComputer`), only its
* workspace (`AppData.ComputerWorkspaceDir`) kept under
* `DATA_DIR/computers/<id>`.
*/
export const ComputerKinds = [
{
+11
View File
@@ -64,17 +64,28 @@ export const ReservedToolNames = [
"create_schedule",
"create_workspace",
"delegate_task",
"delete_extension",
"delete_schedule",
"list_extensions",
"list_schedules",
"load_skill",
"propose_custom_tool",
"propose_mcp_server",
"propose_skill",
"report_run_failed",
"search_computers",
"send_message",
"set_bot_workspace_instructions",
"set_extension_access",
"set_picture",
"set_workspace_computer",
"start_task",
"update_custom_tool",
"update_instructions",
"update_mcp_server",
"update_memory",
"update_schedule",
"update_skill",
"update_workspace",
] as const;
+1 -1
View File
@@ -68,7 +68,7 @@ export const EnvDict: readonly KnownEnv[] = [
},
{
key: "MAIN_COMPUTER_DATA_DIR",
description: `Where the main computer's home (/home/gem) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/<id>.`,
description: `Where the main computer's workspace (/home/gem/workspace, the only folder bots work in) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/<id>.`,
required: false,
secret: false,
},
+21
View File
@@ -0,0 +1,21 @@
/**
* Where an MCP server, tool or skill the coordinator proposed stands
* (`extension_proposals.status`): `pending` until the super admin
* confirms (`confirmed`, saved) or rejects it, `replaced` when the bot
* proposed it again, `failed` when confirming couldn't save it. A pending
* one past its `expires_at` reads as expired.
*/
export const ExtensionProposalStatuses = ["pending", "confirmed", "rejected", "replaced", "failed"] as const;
export type ExtensionProposalStatus = (typeof ExtensionProposalStatuses)[number];
/**
* What a proposal adds or changes.
*/
export const ExtensionProposalKinds = [
{ value: "mcp", title: "MCP server" },
{ value: "tool", title: "tool" },
{ value: "skill", title: "skill" },
] as const;
export type ExtensionProposalKind = (typeof ExtensionProposalKinds)[number]["value"];
+6
View File
@@ -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.
+9 -1
View File
@@ -1,4 +1,6 @@
import { Activity, CircleHelp, Clock, FolderOpen, type LucideIcon } from "lucide-react";
import { Activity, BarChart3, CircleHelp, Clock, FolderOpen, type LucideIcon } from "lucide-react";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import { HelpPath } from "./help-dict";
export type RailPage = {
@@ -9,11 +11,16 @@ export type RailPage = {
* Shown and served to super admins only.
*/
super_admin_only?: boolean;
/**
* Who sees it, when that's an RBAC rule rather than super admins only.
*/
can_view?: (params: { user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null }) => boolean;
};
export const RoutinesPath = "/admin/routines";
export const FilesPath = "/admin/files";
export const ActivityPath = "/admin/logs";
export const UsagePath = "/admin/usage";
/**
* Pages opened from the rail's user popover that have their own rail: a
@@ -22,6 +29,7 @@ export const ActivityPath = "/admin/logs";
export const RailPages: RailPage[] = [
{ title: "Routines", url: RoutinesPath, Icon: Clock },
{ title: "Files", url: FilesPath, Icon: FolderOpen },
{ title: "Usage", url: UsagePath, Icon: BarChart3, can_view: canManageCrew },
{ title: "Activity", url: ActivityPath, Icon: Activity, super_admin_only: true },
{ title: "Help", url: HelpPath, Icon: CircleHelp },
];
+8 -6
View File
@@ -1,8 +1,8 @@
import {
Bell,
BookOpen,
Bot,
CpuAi,
Database,
DatabaseBackup,
KeyRound,
Layers,
@@ -35,6 +35,7 @@ export type SettingsPage = {
export const SettingsComputersPath = "/admin/settings/computers";
export const SettingsBackupsPath = "/admin/settings/backups";
export const SettingsDatabasePath = "/admin/settings/database";
/**
* The `/admin/settings/*` sub pages, listed in the rail on every settings
@@ -47,11 +48,6 @@ export const SettingsPages: SettingsPage[] = [
Icon: SlidersHorizontal,
},
{ title: "Profile", url: "/admin/settings/profile", Icon: UserRound },
{
title: "Notifications",
url: "/admin/settings/notifications",
Icon: Bell,
},
{
title: "Change password",
url: "/admin/settings/change-password",
@@ -87,6 +83,12 @@ export const SettingsPages: SettingsPage[] = [
Icon: DatabaseBackup,
super_admin_only: true,
},
{
title: "Database",
url: SettingsDatabasePath,
Icon: Database,
super_admin_only: true,
},
];
export const SettingsDefaultPath = "/admin/settings/general";
@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import AppError from "@/src/utils/app-error";
import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run";
import { isBackupRunning } from "./with-backup-run-lock";
function deferred() {
let resolve!: (name: string) => void;
let reject!: (error: Error) => void;
const promise = new Promise<string>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("startDataBackupRun", () => {
test("answers at once, then records the finished archive", async () => {
const task = deferred();
const run = startDataBackupRun({
kind: "archive",
task: () => task.promise,
});
expect(run.status).toBe("running");
expect(run.kind).toBe("archive");
expect(run.boot_id).toBe(BootId);
expect(isBackupRunning()).toBe(true);
task.resolve("cobble-data-1.tar.gz");
await settle();
const done = grabDataBackupRun();
expect(done.status).toBe("done");
expect(done.name).toBe("cobble-data-1.tar.gz");
expect(isBackupRunning()).toBe(false);
});
test("refuses a second run while one is going, before it starts", async () => {
const task = deferred();
let second_started = false;
startDataBackupRun({ kind: "archive", task: () => task.promise });
expect(() =>
startDataBackupRun({
kind: "restore",
task: async () => {
second_started = true;
return "x";
},
}),
).toThrow(AppError);
expect(second_started).toBe(false);
task.resolve("cobble-data-2.tar.gz");
await settle();
});
test("records a failure and frees the lock for the next run", async () => {
const task = deferred();
startDataBackupRun({ kind: "restore", task: () => task.promise });
task.reject(new Error("tar: broken"));
await settle();
const failed = grabDataBackupRun();
expect(failed.status).toBe("failed");
expect(failed.kind).toBe("restore");
expect(failed.error).toBe("tar: broken");
expect(isBackupRunning()).toBe(false);
const next = startDataBackupRun({
kind: "archive",
task: async () => "cobble-data-3.tar.gz",
});
expect(next.status).toBe("running");
await settle();
expect(grabDataBackupRun().status).toBe("done");
});
});
@@ -0,0 +1,66 @@
import AppError from "@/src/utils/app-error";
import type { DataBackupRun, DataBackupRunKind } from "@/src/types";
import withBackupRunLock, { isBackupRunning } from "./with-backup-run-lock";
/**
* Changes every time the process starts, so the page can tell a restore
* restarted the app (the run it started belonged to another boot).
*/
export const BootId = crypto.randomUUID();
let run: Omit<DataBackupRun, "boot_id"> | null = null;
type Params = {
kind: DataBackupRunKind;
/**
* The archive or restore itself. It resolves to the archive's name.
*/
task: () => Promise<string>;
};
/**
* Starts a whole-data archive or restore in the background and hands back
* its state at once. Both take far longer than a request may stay silent
* (Bun closes an idle socket after 10s, a proxy after about 100s), so the
* route answers straight away and the page polls `data-status` until the
* run is done or failed. Throws while any backup or restore is running,
* before anything starts.
*/
export function startDataBackupRun({ kind, task }: Params): DataBackupRun {
if (isBackupRunning() || run?.status == "running") {
throw new AppError(`Another backup or restore is already running`);
}
const started = {
kind,
status: "running" as const,
started_at: Date.now(),
};
run = started;
withBackupRunLock({ task })
.then((name) => {
if (run !== started) return;
run = { ...started, status: "done", name, finished_at: Date.now() };
})
.catch((error: unknown) => {
if (run !== started) return;
run = {
...started,
status: "failed",
error: error instanceof Error ? error.message : String(error),
finished_at: Date.now(),
};
});
return grabDataBackupRun();
}
/**
* The last whole-data run this process started, if any, with this boot's
* id.
*/
export function grabDataBackupRun(): DataBackupRun {
return { ...(run ?? { status: "idle" }), boot_id: BootId };
}
@@ -2,6 +2,7 @@ import fs from "fs";
import path from "path";
import { AppData } from "@/src/data/app-data";
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
import AppError from "@/src/utils/app-error";
type Params = {
file: File;
@@ -18,13 +19,13 @@ const GZIP_MAGIC = [0x1f, 0x8b];
*/
export default async function saveUploadedDataBackup({ file }: Params) {
if (!file.size) {
throw new Error(`The uploaded archive is empty`);
throw new AppError(`The uploaded archive is empty`);
}
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) {
throw new Error(`Only a gzipped tar archive can be restored`);
throw new AppError(`Only a gzipped tar archive can be restored`);
}
const archive_dir = grabBackupArchiveDir();
@@ -4,6 +4,14 @@ type Params<T> = {
task: () => Promise<T>;
};
/**
* Whether a backup or restore of any kind holds the lock right now, so a
* background run can be refused before it starts.
*/
export function isBackupRunning() {
return is_running;
}
/**
* Runs one backup or restore at a time, whichever kind it is, so a
* database snapshot, a database restore and a whole-data archive never
@@ -37,6 +37,13 @@ export default async function writeDataBackupArchive(): Promise<CreatedDataBacku
// after it and an extract drops a single folder.
"--transform",
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
// A computer's old whole home, moved aside when only its
// workspace became the mount (`prepareComputerDataDir`),
// is kept on disk but never archived: only workspaces are.
// `*` stops at `/`, so a file inside a workspace whose name
// happens to hold the suffix is still archived.
"--no-wildcards-match-slash",
`--exclude=./${AppData.ComputerDataDir}/*${AppData.ComputerOldHomeSuffix}*`,
"-C",
DATA_DIR,
".",
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import quoteSqlIdentifier from "./quote-sql-identifier";
import resolveTableKey from "./resolve-table-key";
import type { DatabaseTableSummary } from "@/src/types";
import type { TableCatalogEntry } from "./table-catalog-types";
type Params = {
tables: TableCatalogEntry[];
/**
* How many bytes each table takes on disk, by name; empty when SQLite
* can't measure it, which leaves the size column blank.
*/
sizes: Map<string, number>;
};
type CountRow = { rows: number };
/**
* The catalog as the browser lists it: every table with its record count,
* its size on disk, its columns and what identifies its records.
*
* Each count is its own tiny statement rather than one aggregate, because
* a virtual table has no rowid to build one from, and the whole read stays
* in the low milliseconds for the install's fifty-odd tables.
*/
export default async function buildDatabaseTables({ tables, sizes }: Params): Promise<DatabaseTableSummary[]> {
const summaries: DatabaseTableSummary[] = [];
for (const table of tables) {
const size = sizes.get(table.name);
summaries.push({
name: table.name,
type: table.type,
rows: await countRows({ table: table.name }),
size_bytes: size === undefined ? null : size,
columns: table.columns,
key: resolveTableKey({ columns: table.columns, has_rowid: table.has_rowid }),
});
}
return summaries;
}
async function countRows({ table }: { table: string }): Promise<number | null> {
const res = await BunSQLite.sql<CountRow>({
sql: `SELECT COUNT(*) AS rows FROM ${quoteSqlIdentifier({ name: table })}`,
});
if (!res.success || !res.payload?.[0]) {
/**
* A table the module won't count (an index over a table whose
* module is missing) is still a table; it just has no count.
*/
return null;
}
return Number(res.payload[0].rows) || 0;
}
@@ -0,0 +1,43 @@
import { metaColumnAlias, RecordKeyAlias } from "./build-records-sql";
import toDatabaseCell from "./to-database-cell";
import type { DatabaseRecord, DatabaseTableColumn } from "@/src/types";
type Params = {
/**
* The row as SQLite returned it, its meta columns included.
*/
row: Record<string, unknown>;
columns: DatabaseTableColumn[];
max_chars: number;
};
/**
* One row of a records query as a record the browser can draw: its key,
* and every column's value as a cell. The meta columns the query added
* (`typeof` and `length` per column) are read and dropped here, so they
* never reach the client.
*/
export default function buildRecordRow({ row, columns, max_chars }: Params): DatabaseRecord {
const cells: Record<string, DatabaseRecord["cells"][string]> = {};
for (const column of columns) {
cells[column.name] = toDatabaseCell({
value: row[column.name],
type: String(row[metaColumnAlias({ name: column.name, kind: "type" })] ?? "null"),
length: Number(row[metaColumnAlias({ name: column.name, kind: "length" })] ?? 0),
max_chars,
});
}
const key = row[RecordKeyAlias];
return {
key:
typeof key == "string" || typeof key == "number"
? key
: key === null || key === undefined
? null
: String(key),
cells,
};
}
@@ -0,0 +1,337 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import buildRecordsSql, {
buildCountSql,
buildRecordSql,
buildRecordSelect,
metaColumnAlias,
parseRecordKey,
RecordKeyAlias,
} from "./build-records-sql";
import buildSearchClause, { searchWords } from "./build-search-clause";
import buildRecordRow from "./build-record-row";
import buildTableCatalogSql from "./build-table-catalog-sql";
import groupTableCatalog from "./group-table-catalog";
import resolveTableKey from "./resolve-table-key";
import quoteSqlIdentifier from "./quote-sql-identifier";
import type { TableCatalogRow } from "./table-catalog-types";
import type { DatabaseTableColumn } from "@/src/types";
const db = new Database(":memory:");
const messages_columns: DatabaseTableColumn[] = [
{ name: "id", type: "INTEGER", pk: true },
{ name: "role", type: "TEXT", pk: false },
{ name: "text", type: "TEXT", pk: false },
];
beforeAll(() => {
db.run(`CREATE TABLE "messages" ("id" INTEGER PRIMARY KEY, "role" TEXT, "text" TEXT)`);
db.run(`INSERT INTO "messages" VALUES
(1, 'user', 'hello there'),
(2, 'bot', 'Hello there, how can I help?'),
(3, 'user', 'a "quoted" table name and a long body'),
(4, 'bot', NULL)`);
db.run(`CREATE TABLE "no_key" ("a" TEXT, "b" TEXT)`);
db.run(`INSERT INTO "no_key" VALUES ('one', 'two')`);
});
function rows({ sql, values, max_chars = 400 }: { sql: string; values: string[]; max_chars?: number }) {
return db
.query(sql)
.all(...values)
.map((row) =>
buildRecordRow({ row: row as Record<string, unknown>, columns: messages_columns, max_chars }),
);
}
describe("quoteSqlIdentifier", () => {
test("wraps a plain name", () => {
expect(quoteSqlIdentifier({ name: "bots" })).toBe(`"bots"`);
});
test("doubles a quote instead of letting it out", () => {
expect(quoteSqlIdentifier({ name: `a"b` })).toBe(`"a""b"`);
});
});
describe("resolveTableKey", () => {
test("a single primary key column names the records", () => {
expect(resolveTableKey({ columns: messages_columns, has_rowid: true })).toEqual({ kind: "column", name: "id" });
});
test("no primary key falls back to the rowid", () => {
expect(resolveTableKey({ columns: [{ name: "a", type: "TEXT", pk: false }], has_rowid: true })).toEqual({
kind: "rowid",
});
});
test("a virtual table with neither has no key", () => {
expect(resolveTableKey({ columns: [{ name: "a", type: "TEXT", pk: false }], has_rowid: false })).toEqual({
kind: "none",
});
});
test("a composite primary key falls back to the rowid", () => {
expect(
resolveTableKey({
columns: [
{ name: "a", type: "TEXT", pk: true },
{ name: "b", type: "TEXT", pk: true },
],
has_rowid: true,
}),
).toEqual({ kind: "rowid" });
});
});
describe("buildRecordsSql", () => {
test("returns a page of records newest key first", () => {
const query = buildRecordsSql({
table: "messages",
columns: messages_columns.map((column) => column.name),
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 2,
offset: 0,
search: null,
});
const records = rows(query);
expect(records.map((record) => record.key)).toEqual([4, 3]);
expect(records[0]?.cells["text"]?.kind).toBe("null");
expect(records[1]?.cells["role"]?.text).toBe("user");
});
test("pages with the offset it is given", () => {
const query = buildRecordsSql({
table: "messages",
columns: messages_columns.map((column) => column.name),
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 2,
offset: 2,
search: null,
});
expect(rows(query).map((record) => record.key)).toEqual([2, 1]);
});
test("cuts a long value and says so", () => {
const query = buildRecordsSql({
table: "messages",
columns: messages_columns.map((column) => column.name),
key: { kind: "column", name: "id" },
max_chars: 4,
limit: 1,
offset: 1,
search: null,
});
const [record] = rows({ ...query, max_chars: 4 });
expect(record?.cells["text"]?.text).toBe(`a "q`); // four characters, cut
expect(record?.cells["text"]?.truncated).toBe(true);
});
test("orders by the rowid when the table has no primary key", () => {
const query = buildRecordsSql({
table: "no_key",
columns: ["a", "b"],
key: { kind: "rowid" },
max_chars: 400,
limit: 10,
offset: 0,
search: null,
});
const record = db.query(query.sql).get(...query.values) as Record<string, unknown>;
expect(Number(record[RecordKeyAlias])).toBe(1);
});
test("a table with no key comes back without one", () => {
const query = buildRecordsSql({
table: "no_key",
columns: ["a", "b"],
key: { kind: "none" },
max_chars: 400,
limit: 10,
offset: 0,
search: null,
});
const record = db.query(query.sql).get(...query.values) as Record<string, unknown>;
expect(query.sql).not.toContain(`ORDER BY`);
expect(record[RecordKeyAlias]).toBeUndefined();
});
test("selects a meta column per column, so a cut value can be known", () => {
const select = buildRecordSelect({
table: "messages",
columns: messages_columns.map((column) => column.name),
key: { kind: "column", name: "id" },
max_chars: 400,
});
expect(select.sql).toContain(quoteSqlIdentifier({ name: metaColumnAlias({ name: "text", kind: "length" }) }));
expect(select.sql).toContain(quoteSqlIdentifier({ name: metaColumnAlias({ name: "text", kind: "type" }) }));
});
});
describe("search", () => {
const columns = messages_columns.map((column) => column.name);
const every_word = { text: "hello there", mode: "every_word" as const, max_words: 8 };
test("every word matches anywhere in the record, in any order", () => {
const query = buildRecordsSql({
table: "messages",
columns,
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 10,
offset: 0,
search: every_word,
});
expect(rows(query).map((record) => record.key).sort()).toEqual([1, 2]);
});
test("a phrase only matches where the words sit together", () => {
const query = buildRecordsSql({
table: "messages",
columns,
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 10,
offset: 0,
search: { text: "hello there", mode: "phrase", max_words: 8 },
});
expect(rows(query).map((record) => record.key).sort()).toEqual([1, 2]);
});
test("a phrase of one word that sits apart doesn't match", () => {
const query = buildRecordsSql({
table: "messages",
columns,
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 10,
offset: 0,
search: { text: "help hello", mode: "phrase", max_words: 8 },
});
expect(rows(query)).toEqual([]);
});
test("prefixes find a value, wherever it is in a word", () => {
const query = buildRecordsSql({
table: "messages",
columns,
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 10,
offset: 0,
search: { text: "quoted", mode: "every_word", max_words: 8 },
});
expect(rows(query).map((record) => record.key)).toEqual([3]);
});
test("matches an id as text", () => {
const query = buildRecordsSql({
table: "messages",
columns,
key: { kind: "column", name: "id" },
max_chars: 400,
limit: 10,
offset: 0,
search: { text: "3", mode: "every_word", max_words: 8 },
});
expect(rows(query).map((record) => record.key)).toEqual([3]);
});
test("counts only what matched", () => {
const count = buildCountSql({ table: "messages", columns, search: every_word });
const result = db.query(count.sql).get(...count.values) as { total: number };
expect(result.total).toBe(2);
});
test("counts the whole table with no search", () => {
const count = buildCountSql({ table: "messages", columns, search: null });
const result = db.query(count.sql).get(...count.values) as { total: number };
expect(result.total).toBe(4);
});
test("an empty search matches everything rather than failing", () => {
const clause = buildSearchClause({ columns, search: " ", mode: "every_word", max_words: 8 });
expect(clause).toEqual({ sql: "1", values: [] });
});
test("keeps a search to its words", () => {
expect(searchWords({ search: " a b c ", max_words: 2 })).toEqual(["a", "b"]);
});
test("a table with no columns matches nothing", () => {
const clause = buildSearchClause({ columns: [], search: "x", mode: "every_word", max_words: 8 });
expect(clause.values).toEqual(["x"]);
expect(clause.sql).toContain(`''`);
});
});
describe("buildRecordSql", () => {
test("reads one record by its key", () => {
const query = buildRecordSql({
table: "messages",
columns: messages_columns.map((column) => column.name),
key: { kind: "column", name: "id" },
max_chars: 400,
value: 2,
});
const [record] = rows({ sql: query.sql, values: query.values as string[] });
expect(record?.key).toBe(2);
expect(record?.cells["text"]?.text).toBe("Hello there, how can I help?");
});
test("finds nothing for a key that isn't there", () => {
const query = buildRecordSql({
table: "messages",
columns: messages_columns.map((column) => column.name),
key: { kind: "column", name: "id" },
max_chars: 400,
value: 99,
});
expect(db.query(query.sql).all(...(query.values as string[]))).toEqual([]);
});
});
describe("parseRecordKey", () => {
test("an integer key comes back as a number", () => {
expect(parseRecordKey({ value: "42" })).toBe(42);
});
test("a text key stays text", () => {
expect(parseRecordKey({ value: "abc" })).toBe("abc");
});
test("a number too big to be safe stays text", () => {
expect(parseRecordKey({ value: "99999999999999999999" })).toBe("99999999999999999999");
});
});
describe("buildTableCatalogSql", () => {
test("reads every table and its columns, without SQLite's own tables", () => {
const res = db.query(buildTableCatalogSql()).all() as TableCatalogRow[];
const tables = groupTableCatalog({ rows: res });
expect(tables.map((table) => table.name)).toEqual(["messages", "no_key"]);
expect(tables[0]?.columns.map((column) => column.name)).toEqual(["id", "role", "text"]);
expect(tables[0]?.has_rowid).toBe(true);
});
});
@@ -0,0 +1,189 @@
import buildSearchClause from "./build-search-clause";
import quoteSqlIdentifier from "./quote-sql-identifier";
import type { DatabaseSearchMode, DatabaseTableKey } from "@/src/types";
/**
* The column every record's key comes back in.
*/
export const RecordKeyAlias = "__cobble_key";
type MetaKind = "type" | "length";
/**
* The column SQLite's own type of a value comes back in, beside the value.
*/
export function metaColumnAlias({ name, kind }: { name: string; kind: MetaKind }): string {
return `__cobble_${kind}__${name}`;
}
type ColumnListParams = {
/**
* The table's columns, in their declared order.
*/
columns: string[];
/**
* The most characters of a value the query takes out of SQLite.
*/
max_chars: number;
};
/**
* What a query selects: every column, cut to `max_chars` so no huge value
* (a record's long text, a job's checkpoint) crosses out of SQLite, and
* beside each one its stored type and length, the two things that say how
* to read it and whether it was cut.
*/
export function buildRecordColumns({ columns, max_chars }: ColumnListParams): string[] {
const selected: string[] = [];
for (const name of columns) {
const column = quoteSqlIdentifier({ name });
selected.push(`substr(${column}, 1, ${max_chars}) AS ${column}`);
selected.push(
`typeof(${column}) AS ${quoteSqlIdentifier({ name: metaColumnAlias({ name, kind: "type" }) })}`,
);
selected.push(
`length(${column}) AS ${quoteSqlIdentifier({ name: metaColumnAlias({ name, kind: "length" }) })}`,
);
}
return selected;
}
/**
* The key column, which is what identifies a record: a primary key, the
* rowid, or nothing at all for a virtual table that has neither.
*/
export function buildKeyColumn({ key }: { key: DatabaseTableKey }): string[] {
if (key.kind == "column" && key.name) {
return [`${quoteSqlIdentifier({ name: key.name })} AS "${RecordKeyAlias}"`];
}
if (key.kind == "rowid") {
/**
* Quoted: `rowid` is read as the ref's rowid that way, and as a
* column of the table's own named `rowid` otherwise.
*/
return [`"rowid" AS "${RecordKeyAlias}"`];
}
return [];
}
type SelectParams = ColumnListParams & {
table: string;
key: DatabaseTableKey;
/**
* What to look for, when the browser is narrowing the table down.
*/
search?: DatabaseSearch | null;
};
/**
* A search a records query narrows its table by.
*/
export type DatabaseSearch = {
text: string;
mode: DatabaseSearchMode;
/**
* How many words at most are matched, so a long line can't build a
* query of a hundred conditions.
*/
max_words: number;
};
type Query = { sql: string; values: string[] };
/**
* The `SELECT` a page of records and one record both start from: the key,
* every column with its meta columns, and, when something is searched, a
* `WHERE` over all of the table's text.
*/
export function buildRecordSelect({ table, columns, key, max_chars, search }: SelectParams): Query {
const selected = [...buildKeyColumn({ key }), ...buildRecordColumns({ columns, max_chars })];
const from = `SELECT ${selected.join(", ")} FROM ${quoteSqlIdentifier({ name: table })}`;
if (!search) return { sql: from, values: [] };
const where = buildSearchClause({
columns,
search: search.text,
mode: search.mode,
max_words: search.max_words,
});
return { sql: `${from} WHERE ${where.sql}`, values: where.values };
}
type PageParams = SelectParams & {
limit: number;
offset: number;
};
/**
* One page of a table's records, newest key first, so a freshly written
* record shows at the top. A table with no key to order by comes back in
* whatever order SQLite reads it.
*/
export default function buildRecordsSql({ limit, offset, ...params }: PageParams): Query {
const select = buildRecordSelect(params);
const order = params.key.kind == "none" ? "" : ` ORDER BY "${RecordKeyAlias}" DESC`;
return {
sql: `${select.sql}${order} LIMIT ${limit} OFFSET ${offset}`,
values: select.values,
};
}
type RecordParams = SelectParams & {
/**
* The record's key, as it came in the query string.
*/
value: string | number;
};
/**
* One record, by its key. Only asked for a table that has one, so a row
* always comes back.
*/
export function buildRecordSql({ value, ...params }: RecordParams): { sql: string; values: (string | number)[] } {
const select = buildRecordSelect(params);
return {
sql: `${select.sql} WHERE "${RecordKeyAlias}" = ? LIMIT 1`,
values: [...select.values, value],
};
}
type CountParams = Omit<SelectParams, "key" | "max_chars">;
/**
* How many records the table holds, or how many the search matched, for
* the pager under it.
*/
export function buildCountSql({ table, columns, search }: CountParams): Query {
const from = `SELECT COUNT(*) AS total FROM ${quoteSqlIdentifier({ name: table })}`;
if (!search) return { sql: from, values: [] };
const where = buildSearchClause({
columns,
search: search.text,
mode: search.mode,
max_words: search.max_words,
});
return { sql: `${from} WHERE ${where.sql}`, values: where.values };
}
/**
* The record SQL's parameter (a primary key or a rowid), read from a
* query string: digits are a number, so an integer key compares as one,
* and anything else stays a string.
*/
export function parseRecordKey({ value }: { value: string }): string | number {
const text = value.trim();
return /^-?\d+$/.test(text) && Number.isSafeInteger(Number(text)) ? Number(text) : text;
}
@@ -0,0 +1,85 @@
import quoteSqlIdentifier from "./quote-sql-identifier";
import type { DatabaseSearchMode } from "@/src/types";
type Params = {
/**
* The table's columns, in their declared order.
*/
columns: string[];
search: string;
mode: DatabaseSearchMode;
/**
* How many words at most are matched, so a long line can't build a
* query of a hundred conditions.
*/
max_words: number;
};
/**
* Splits a search into its words, in the case they were typed (SQLite's
* `lower` folds ASCII case).
*/
export function searchWords({ search, max_words }: { search: string; max_words: number }): string[] {
return search
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, max_words);
}
type WordParams = {
/**
* The SQL that reads every column of a row as one text blob.
*/
text: string;
search: string;
mode: DatabaseSearchMode;
max_words: number;
};
/**
* A search as a `WHERE` condition, with the parameters its placeholders
* take, in order.
*/
export function searchCondition({ text, search, mode, max_words }: WordParams): { sql: string; values: string[] } {
if (mode == "phrase") {
return {
sql: `instr(lower(${text}), lower(?)) > 0`,
values: [search.trim()],
};
}
const words = searchWords({ search, max_words });
if (!words.length) {
return { sql: `1`, values: [] };
}
return {
sql: words.map(() => `instr(lower(${text}), lower(?)) > 0`).join(` AND `),
values: words,
};
}
/**
* A search over every column of a table, as a condition the records query
* puts in its `WHERE` (and the count query puts in its own), with the
* parameters its placeholders take, in order.
*
* The columns are joined into one text blob per row, so a value is found
* wherever it lives: an id, a name, a message's words. Reading every row
* is the only honest way to find a value in a table with no index for it;
* the page sizes keep what comes back small, not the cost of the search.
*/
export default function buildSearchClause({ columns, search, mode, max_words }: Params): {
sql: string;
values: string[];
} {
const text = columns.length
? columns
.map((name) => `COALESCE(CAST(${quoteSqlIdentifier({ name })} AS TEXT), '')`)
.join(` || ' ' || `)
: `''`;
return searchCondition({ text, search, mode, max_words });
}
@@ -0,0 +1,33 @@
/**
* The tables the database browser lists, each with its columns in their
* declared order, in one statement: `pragma_table_list` finds the tables
* and `pragma_table_info` reads each one's columns, the pragma taking the
* table name as an argument the way a table-valued function does.
*
* Left out: SQLite's own `sqlite_*` tables, and the shadow tables a
* virtual table keeps its internals in (the `bot_file_vectors_*` tables
* behind the sqlite-vec index), which are storage, not rows anyone
* browses.
*/
export default function buildTableCatalogSql(): string {
return `SELECT
l.name AS table_name,
l.type AS table_type,
l.wr AS without_rowid,
p.cid AS column_index,
p.name AS column_name,
p.type AS column_type,
p.pk AS column_pk
FROM pragma_table_list AS l
JOIN pragma_table_info(l.name) AS p
WHERE l.schema = 'main'
AND l.type IN ('table', 'virtual')
AND l.name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
AND NOT EXISTS (
SELECT 1
FROM pragma_table_list AS v
WHERE v.type = 'virtual'
AND l.name LIKE v.name || '\\_%' ESCAPE '\\'
)
ORDER BY l.name ASC, p.cid ASC`;
}
@@ -0,0 +1,19 @@
/**
* How many bytes each table takes on disk. `dbstat` reports pages per
* b-tree; an index's pages are named after the index (`autoindex` or the
* schema's own), so the join puts them back under the table they belong
* to. A table with no pages yet has no row here.
*/
export default function buildTableSizesSql(): string {
return `SELECT
COALESCE(i.tbl_name, d.name) AS table_name,
SUM(d.pgsize) AS bytes
FROM dbstat AS d
LEFT JOIN sqlite_master AS i ON i.type = 'index' AND i.name = d.name
GROUP BY COALESCE(i.tbl_name, d.name)`;
}
export type TableSizeRow = {
table_name: string;
bytes: number;
};
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import clampPage from "./clamp-page";
describe("clampPage", () => {
test("keeps a page that is inside the table", () => {
expect(clampPage({ page: 2, total: 120, page_size: 50 })).toBe(2);
});
test("a page past the end lands on the last one", () => {
expect(clampPage({ page: 99, total: 120, page_size: 50 })).toBe(3);
});
test("an empty table still has a first page", () => {
expect(clampPage({ page: 4, total: 0, page_size: 50 })).toBe(1);
});
test("a page below one is the first", () => {
expect(clampPage({ page: 0, total: 120, page_size: 50 })).toBe(1);
expect(clampPage({ page: -3, total: 120, page_size: 50 })).toBe(1);
});
test("a table that fits exactly ends on the page asked for", () => {
expect(clampPage({ page: 2, total: 100, page_size: 50 })).toBe(2);
expect(clampPage({ page: 3, total: 100, page_size: 50 })).toBe(2);
});
test("a page size of nothing doesn't divide by zero", () => {
expect(clampPage({ page: 1, total: 10, page_size: 0 })).toBe(1);
});
});
@@ -0,0 +1,23 @@
type Params = {
/**
* The page asked for, from 1.
*/
page: number;
/**
* How many records the table holds (or how many matched a search).
*/
total: number;
page_size: number;
};
/**
* The page that actually gets read: a page past the end of the table lands
* on its last one (so a stale link or a deleted record never shows an empty
* page), and a table with nothing in it still has a first page.
*/
export default function clampPage({ page, total, page_size }: Params): number {
const size = Math.max(1, Math.trunc(page_size) || 1);
const last = Math.max(1, Math.ceil(total / size));
return Math.min(Math.max(1, Math.trunc(page) || 1), last);
}
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import buildDatabaseTables from "./build-database-tables";
import buildTableCatalogSql from "./build-table-catalog-sql";
import grabTableSizes from "./grab-table-sizes";
import groupTableCatalog from "./group-table-catalog";
import type { TableCatalogRow } from "./table-catalog-types";
import type { DatabaseTableSummary } from "@/src/types";
/**
* Every table the database browser lists: the catalog read in one
* statement (`buildTableCatalogSql`), the sizes on disk, then a count each.
* Empty when the catalog can't be read, which leaves the page saying so
* rather than failing.
*/
export default async function grabDatabaseTables(): Promise<DatabaseTableSummary[]> {
const res = await BunSQLite.sql<TableCatalogRow>({ sql: buildTableCatalogSql() });
if (!res.success || !res.payload) return [];
const tables = groupTableCatalog({ rows: res.payload });
if (!tables.length) return [];
return await buildDatabaseTables({ tables, sizes: await grabTableSizes() });
}
@@ -0,0 +1,36 @@
import AppError from "@/src/utils/app-error";
import grabDatabaseTables from "./grab-database-tables";
import type { DatabaseTableSummary } from "@/src/types";
type Params = {
/**
* The table asked for, as it came in the query string. The first table
* is used when nothing is asked for.
*/
table?: string | null;
};
/**
* One table, or the first one when nothing is asked for. Anything else is
* refused with a message safe to show: the browser only ever reads tables
* that are in the database, never a name it built itself.
*/
export default async function grabTableCatalogEntry({ table }: Params): Promise<DatabaseTableSummary> {
const tables = await grabDatabaseTables();
if (!tables.length) {
throw new AppError(`Couldn't read the database's tables.`);
}
const first = tables[0];
if (!table && first) return first;
const entry = tables.find((candidate) => candidate.name == table);
if (!entry) {
throw new AppError(`There's no table called "${table}".`);
}
return entry;
}
@@ -0,0 +1,58 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import buildRecordRow from "./build-record-row";
import { buildRecordSql, parseRecordKey } from "./build-records-sql";
import grabTableCatalogEntry from "./grab-table-catalog-entry";
import type { DatabaseRecordPayload } from "@/src/types";
import { AppData } from "@/src/data/app-data";
type Params = {
table?: string | null;
/**
* The record's key, as it came in the query string.
*/
key: string;
};
/**
* One record of one table, in full for its modal: every value read whole
* (up to `AppData.DatabaseBrowserValueMaxChars`, since a megabyte of blob
* or checkpoint is not something to render), so a cell cut short in the
* table can still be read to its end.
*/
export default async function grabTableRecord({ table, key }: Params): Promise<DatabaseRecordPayload> {
const entry = await grabTableCatalogEntry({ table });
if (entry.key.kind == "none") {
throw new AppError(`"${entry.name}" has no key to open a single record by.`);
}
const columns = entry.columns.map((column) => column.name);
const query = buildRecordSql({
table: entry.name,
columns,
key: entry.key,
max_chars: AppData.DatabaseBrowserValueMaxChars,
value: parseRecordKey({ value: key }),
});
const res = await BunSQLite.sql<Record<string, unknown>>(query);
if (!res.success) {
throw new AppError(`Couldn't read that record of "${entry.name}".`);
}
const row = res.payload?.[0];
if (!row) {
throw new AppError(`That record isn't in "${entry.name}" any more.`);
}
return {
table: entry,
record: buildRecordRow({
row,
columns: entry.columns,
max_chars: AppData.DatabaseBrowserValueMaxChars,
}),
};
}
@@ -0,0 +1,89 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import buildRecordRow from "./build-record-row";
import buildRecordsSql, { buildCountSql } from "./build-records-sql";
import clampPage from "./clamp-page";
import grabTableCatalogEntry from "./grab-table-catalog-entry";
import type { DatabaseRecord, DatabaseRecordsPayload, DatabaseSearchMode } from "@/src/types";
import { AppData } from "@/src/data/app-data";
type Params = {
table?: string | null;
/**
* Which page of records, from 1.
*/
page?: number;
/**
* What to look for in the table's records, across every column's text.
*/
search?: string;
mode?: DatabaseSearchMode;
};
type TotalRow = { total: number };
/**
* One page of one table's records, read-only: the table asked for with all
* of its columns, its records newest key first, and how many matched so the
* pager knows where the end is.
*
* Every value crosses out of SQLite already cut to
* `AppData.DatabaseBrowserCellMaxChars`, so a table of long texts can't
* turn one page into a megabyte of JSON.
*/
export default async function grabTableRecords({
table,
page = 1,
search = "",
mode = "every_word",
}: Params): Promise<DatabaseRecordsPayload> {
const entry = await grabTableCatalogEntry({ table });
const page_size = AppData.DatabaseBrowserPageSize;
const current_page = Math.max(1, Math.trunc(page || 1));
const columns = entry.columns.map((column) => column.name);
const trimmed = search.trim();
const search_params = trimmed
? { text: trimmed, mode, max_words: AppData.DatabaseBrowserMaxSearchWords }
: null;
const count_res = await BunSQLite.sql<TotalRow>(
buildCountSql({
table: entry.name,
columns,
search: search_params,
}),
);
const total = Number(count_res.payload?.[0]?.total) || 0;
const final_page = clampPage({ page: current_page, total, page_size });
const page_query = buildRecordsSql({
table: entry.name,
columns,
key: entry.key,
max_chars: AppData.DatabaseBrowserCellMaxChars,
limit: page_size,
offset: (final_page - 1) * page_size,
search: search_params,
});
const res = await BunSQLite.sql<Record<string, unknown>>(page_query);
if (!res.success || !res.payload) {
throw new AppError(`Couldn't read the records of "${entry.name}".`);
}
const records: DatabaseRecord[] = res.payload.map((row) =>
buildRecordRow({
row,
columns: entry.columns,
max_chars: AppData.DatabaseBrowserCellMaxChars,
}),
);
return {
table: entry,
records,
total,
page: final_page,
page_size,
search: trimmed,
mode,
};
}
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import buildTableSizesSql, { type TableSizeRow } from "./build-table-sizes-sql";
/**
* How many bytes each table takes on disk, by table name. Empty when
* SQLite was built without the `dbstat` vtab, which only costs the browser
* its size column: the records still read.
*/
export default async function grabTableSizes(): Promise<Map<string, number>> {
const sizes = new Map<string, number>();
const res = await BunSQLite.sql<TableSizeRow>({ sql: buildTableSizesSql() });
if (!res.success || !res.payload) return sizes;
for (const row of res.payload) {
sizes.set(row.table_name, Number(row.bytes) || 0);
}
return sizes;
}
@@ -0,0 +1,35 @@
import type { TableCatalogEntry, TableCatalogRow } from "./table-catalog-types";
type Params = {
rows: TableCatalogRow[];
};
/**
* Groups the catalog's rows per table, keeping the statement's order, so
* the list of tables and every table's columns come back in one read.
*/
export default function groupTableCatalog({ rows }: Params): TableCatalogEntry[] {
const entries: TableCatalogEntry[] = [];
for (const row of rows) {
let entry = entries.at(-1);
if (!entry || entry.name != row.table_name) {
entry = {
name: row.table_name,
type: row.table_type,
has_rowid: row.table_type == "table" && !row.without_rowid,
columns: [],
};
entries.push(entry);
}
entry.columns.push({
name: row.column_name,
type: row.column_type || "",
pk: Boolean(row.column_pk),
});
}
return entries;
}
@@ -0,0 +1,9 @@
/**
* Quotes a table or column name for SQL, wrapping it in double quotes and
* doubling any it holds. The database browser builds its statements from
* the database's own names, and every one of them still goes through here
* so a name with a quote or a space in it can't break out of the query.
*/
export default function quoteSqlIdentifier({ name }: { name: string }): string {
return `"${name.replace(/"/g, `""`)}"`;
}
@@ -0,0 +1,38 @@
import type { DatabaseTableColumn, DatabaseTableKey } from "@/src/types";
type Params = {
/**
* The table's columns in their declared order.
*/
columns: DatabaseTableColumn[];
/**
* Whether the table is one SQLite gives a rowid to: every ordinary
* table has one, a virtual table and a `WITHOUT ROWID` table do not.
*/
has_rowid: boolean;
};
/**
* What a table's records are identified by: its primary key when that is
* one column (a sqlite-vec index keeps its own `id`), else the rowid every
* ordinary SQLite table has, else nothing, which leaves the browser able
* to read a page but not to open one record.
*
* A composite primary key falls back to the rowid, which is the record's
* own and shows in the key column either way.
*/
export default function resolveTableKey({ columns, has_rowid }: Params): DatabaseTableKey {
const pk_columns = columns.filter((column) => column.pk);
const [pk_column] = pk_columns;
if (pk_column && pk_columns.length == 1) {
return { kind: "column", name: pk_column.name };
}
if (has_rowid) {
return { kind: "rowid" };
}
return { kind: "none" };
}
@@ -0,0 +1,33 @@
/**
* One row of the table catalog: one column of one table.
*/
export type TableCatalogRow = {
table_name: string;
table_type: "table" | "virtual";
/**
* 1 when the table was created `WITHOUT ROWID`.
*/
without_rowid: number;
column_index: number;
column_name: string;
column_type: string | null;
/**
* Where the column sits in the table's primary key; 0 when it isn't
* one.
*/
column_pk: number;
};
/**
* One table of the catalog.
*/
export type TableCatalogEntry = {
name: string;
type: "table" | "virtual";
/**
* Whether SQLite gives the table a rowid: an ordinary table does, a
* `WITHOUT ROWID` table and a virtual table do not.
*/
has_rowid: boolean;
columns: { name: string; type: string; pk: boolean }[];
};
@@ -0,0 +1,48 @@
import formatBytes from "@/src/utils/format-bytes";
import type { DatabaseCell } from "@/src/types";
type Params = {
/**
* The value read out of SQLite, already cut to the length asked for.
*/
value: unknown;
/**
* SQLite's own type for the stored value: `integer`, `real`, `text`,
* `blob` or `null`.
*/
type: string;
/**
* How long the stored value is (characters for text, bytes for a
* blob), which says whether `value` is the whole of it.
*/
length: number;
max_chars: number;
};
/**
* One stored value as a cell shows it. Text is cut at `max_chars` and
* marked truncated, a blob is only its size (its bytes would fill the page
* with noise), a number is printed as it is, and NULL is said, never left
* blank.
*/
export default function toDatabaseCell({ value, type, length, max_chars }: Params): DatabaseCell {
if (type == "null" || value === null || value === undefined) {
return { text: "", kind: "null", truncated: false };
}
if (type == "blob") {
return {
text: `<${formatBytes({ bytes: length })} of binary data>`,
kind: "blob",
truncated: false,
};
}
const text = String(value);
if (type == "integer" || type == "real") {
return { text, kind: "number", truncated: false };
}
return { text, kind: "text", truncated: length > max_chars };
}
@@ -1,40 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import grabUserBot from "../bots/grab-user-bot";
import grabUserWorkspace from "../workspaces/grab-user-workspace";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import sharedPictureMediaSql from "./shared-picture-media-sql";
type Params = {
media_id: number | string;
user_id: number | string;
};
/**
* A bot's or workspace's picture (private media, uploaded by whoever set
* it) for a user who reaches that bot or workspace, or null. Bots and
* workspaces belong to the install, so their pictures show to everyone
* who sees them.
* A picture every signed-in user may see - a member's current profile photo,
* a bot's picture, a workspace's picture (`sharedPictureMediaSql`) - or
* null when this media is something else.
*
* Which crew the viewer reaches plays no part: bots and workspaces belong to
* the install, and people know each other by their photo, so a picture is
* shared with everyone signed in. Everything a picture must not expose (a
* bot's files, a task's deliverable, someone's chat upload) stays behind
* its own check, and a removed picture stops being shared as soon as no
* user, bot or workspace points at it.
*/
export default async function grabSharedPictureMedia({ media_id, user_id }: Params) {
const [bot, workspace] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id FROM bots WHERE image_media_id = ? LIMIT 1`,
values: [media_id],
}).then((res) => res.payload?.[0]),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id FROM workspaces WHERE image_media_id = ? LIMIT 1`,
values: [media_id],
}).then((res) => res.payload?.[0]),
]);
export default async function grabSharedPictureMedia({ media_id }: Params) {
if (!media_id) return null;
const allowed =
(bot?.id && (await grabUserBot({ bot_id: bot.id, user_id }))?.id) ||
(workspace?.id && (await grabUserWorkspace({ workspace_id: workspace.id, user_id }))?.id);
if (!allowed) return null;
const condition = sharedPictureMediaSql({ alias: "m" });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [media_id],
sql: `SELECT m.* FROM media m WHERE m.id = ? AND COALESCE(m.archived, 0) <> 1 AND ${condition.sql} LIMIT 1`,
values: [media_id, ...condition.values],
});
return res.payload?.[0] || null;
@@ -0,0 +1,97 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import sharedPictureMediaSql from "./shared-picture-media-sql";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE media (id INTEGER PRIMARY KEY, user_id INTEGER, is_primary INTEGER, archived INTEGER)`);
db.run(`CREATE TABLE media_paradigms (media_id INTEGER, media_paradigm TEXT)`);
db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY, image_media_id INTEGER)`);
db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY, image_media_id INTEGER)`);
db.run(`INSERT INTO media VALUES
(1, 7, 1, 0),
(2, 8, 1, 0),
(3, 8, NULL, 0),
(4, 9, NULL, 0),
(5, 7, NULL, 0),
(6, 7, NULL, 0),
(7, 7, NULL, 0),
(8, 7, NULL, 0),
(10, 7, NULL, 0),
(11, 7, NULL, 0)`);
// 1: user 7's current photo, 2: user 8's, 3: an old photo of user 8's (no longer primary)
db.run(`INSERT INTO media_paradigms VALUES
(1, 'user-profile-image'),
(2, 'user-profile-image'),
(3, 'user-profile-image'),
(4, 'bot-image'),
(5, 'bot-image'),
(6, 'workspace-image'),
(7, 'workspace-image'),
(8, 'bot-file'),
(10, 'bot-attachment')`);
// 4: still bot 2's picture, 5: a bot picture no bot points at any more
db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`);
// 6: still workspace 10's picture, 7: a workspace picture nothing points at
// 10: an older picture set straight from a chat upload, still workspace 12's
db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL), (12, 10)`);
});
function visible({ archived }: { archived?: number } = {}) {
const condition = sharedPictureMediaSql();
return db
.query(
`SELECT m.id FROM media m WHERE ${condition.sql} ${
archived === undefined ? `` : `AND COALESCE(m.archived, 0) <> 1`
} ORDER BY m.id`,
)
.all(...condition.values)
.map((row) => (row as { id: number }).id);
}
describe("sharedPictureMediaSql", () => {
test("every user's current photo, and every bot's and workspace's picture", () => {
expect(visible()).toEqual([1, 2, 4, 6, 10]);
});
test("archived media is not a picture anyone gets", () => {
db.run(`UPDATE media SET archived = 1 WHERE id = 2`);
expect(visible({ archived: 0 })).toEqual([1, 4, 6, 10]);
db.run(`UPDATE media SET archived = 0 WHERE id = 2`);
});
test("a photo that is no longer the user's primary is not shared", () => {
expect(visible()).not.toContain(3);
});
test("a picture no bot or workspace points at any more is not shared", () => {
expect(visible()).not.toContain(5);
expect(visible()).not.toContain(7);
});
test("anything else stays private, a bot's file among them", () => {
expect(visible()).not.toContain(8);
expect(visible()).not.toContain(11);
});
test("a picture a workspace points at is shared whatever its paradigm", () => {
expect(visible()).toContain(10);
});
test("removing a picture row closes it at once", () => {
db.run(`INSERT INTO media VALUES (9, 7, NULL, 0)`);
db.run(`INSERT INTO media_paradigms VALUES (9, 'bot-image')`);
db.run(`UPDATE bots SET image_media_id = 9 WHERE id = 3`);
expect(visible()).toContain(9);
db.run(`UPDATE bots SET image_media_id = NULL WHERE id = 3`);
expect(visible()).not.toContain(9);
});
});
@@ -0,0 +1,42 @@
import type { SqlCondition } from "@/src/utils/crew-access/types";
type Params = {
/**
* The `media` table alias the condition reads.
*/
alias?: string;
};
/**
* The pictures every signed-in user may load, whoever they are and whatever
* crew they reach:
*
* - a member's current profile photo (their primary `user-profile-image`,
* the one `grabUsers` reports as `profile_media_id`),
* - a bot's picture (whatever `bots.image_media_id` points at),
* - a workspace's picture (whatever `workspaces.image_media_id` points at).
*
* Bots and workspaces belong to the install, and people know each other by
* their photo, so a picture is never limited to whoever happens to reach
* its owner. It does have to be one still in use: a profile photo's
* paradigm alone is not enough, since a removed photo leaves its paradigm
* row behind. A bot's or workspace's picture is shared because the bot or
* workspace points at it, not by its paradigm: a picture set before
* pictures were copied into their own `bot-image`/`workspace-image` row is
* the chat upload itself, and it is still the face everyone sees.
*/
export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition {
return {
sql: `(
EXISTS (
SELECT 1 FROM media_paradigms mp
WHERE mp.media_id = ${alias}.id
AND mp.media_paradigm = 'user-profile-image'
AND ${alias}.is_primary = 1
)
OR EXISTS (SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id)
OR EXISTS (SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id)
)`,
values: [],
};
}
@@ -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;
};
@@ -1,5 +1,5 @@
import mediaFilePath from "@/src/utils/media-file-path";
import AppError from "@/src/utils/app-error";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { StorageScope } from "@/src/utils/storage-access";
import { canReachScope } from "@/src/utils/storage-access";
import grabBotFile from "./grab-bot-file";
@@ -42,12 +42,13 @@ export default async function copyBotFile({
const file = await grabBotFile({ scopes, file_id });
if (!file?.id || !file.media?.media_write_path) {
const absolute = mediaFilePath({ write_path: file?.media?.media_write_path });
if (!file?.id || !absolute) {
throw new AppError(`That file isn't in your files.`);
}
const { DATA_DIR } = grabDirNames();
const source = await Bun.file(`${DATA_DIR}/${file.media.media_write_path}`).arrayBuffer();
const source = await Bun.file(absolute).arrayBuffer();
return await recordBotFile({
bot_id,
@@ -1,3 +1,4 @@
import mediaFilePath from "@/src/utils/media-file-path";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
import { rmSync } from "node:fs";
@@ -24,7 +25,6 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
throw new AppError(`That file isn't in your files.`);
}
const { DATA_DIR } = grabDirNames();
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
const absolute = path.join(root, file.path || "");
@@ -55,12 +55,10 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
values: [file.media_id],
}).then((res) => res.payload?.[0]);
if (media?.media_write_path) {
rmSync(path.join(DATA_DIR, media.media_write_path), { force: true });
}
for (const write_path of [media?.media_write_path, media?.media_thumbnail_write_path]) {
const absolute = mediaFilePath({ write_path });
if (media?.media_thumbnail_write_path) {
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), { force: true });
if (absolute) rmSync(absolute, { force: true });
}
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
@@ -35,7 +35,12 @@ export default async function grabBotFileMedia({
const role = await grabCrewRole({ user_id });
const reaches =
file.scope == "workspace"
? Boolean(await grabUserWorkspace({ workspace_id: file.owner_id!, user_id }))
? Boolean(
await grabUserWorkspace({
workspace_id: file.owner_id!,
user_id,
}),
)
: role == "super_admin"
? true
: Boolean(await grabUserBot({ bot_id: file.owner_id!, user_id }));
@@ -3,6 +3,7 @@ import type {
BUN_SQLITE_COBBLE_BOT_FILES,
BUN_SQLITE_COBBLE_MEDIA,
} from "@/db/types/db";
import mediaColumns from "@/src/functions/backend/db/media/media-columns";
import { canReachScope, type StorageScope } from "@/src/utils/storage-access";
type Params = {
@@ -59,7 +60,7 @@ export default async function grabBotFile({
const media = file.media_id
? await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
sql: `SELECT ${mediaColumns({ alias: "m" })} FROM media m WHERE m.id = ? LIMIT 1`,
values: [file.media_id],
}).then((r) => r.payload?.[0] || null)
: null;
@@ -0,0 +1,37 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabStorageScopes, { type StorageScope } from "@/src/utils/storage-access";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
/**
* The user the run is for: the coordinator reaches their workspaces.
*/
user_id: number | string;
workspace_id?: number | string | null;
};
/**
* The directories one run may touch, and the only place the coordinator's
* workspaces are read for it: its own folder first, then every workspace's
* (`grabStorageScopes`).
*
* Every tool that takes files uses this, so a file a bot can read with
* `list_files` is exactly a file it can set as a picture, attach to a task
* or import into.
*/
export default async function grabRunScopes({ bot, user_id, workspace_id }: Params): Promise<StorageScope[]> {
const all_workspace_ids =
bot.kind == "coordinator"
? (await grabUserWorkspaces({ user_id }))
.filter((workspace) => !workspace.archived)
.map((workspace) => Number(workspace.id))
: [];
return grabStorageScopes({
bot_id: bot.id || 0,
bot_kind: bot.kind,
workspace_id,
all_workspace_ids,
});
}
@@ -7,6 +7,10 @@ import type {
import { AppData } from "@/src/data/app-data";
import embedText from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error";
import { grabHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import type { HarnessUsageScope } from "@/src/harness/usage/types";
import runWithFallbackScope from "@/src/harness/usage/run-with-fallback-scope";
import grabUsageOwnerUserId from "@/src/functions/backend/db/usage/grab-usage-owner";
import chunkText from "./chunk-text";
import extractBotFileText from "./extract-bot-file-text";
@@ -57,7 +61,13 @@ export default async function ingestBotFile({
}).then((res) => res.payload?.[0])
: undefined;
if (Number(file.size) > AppData.IngestMaxBytes && !media?.text_content) {
/**
* Decided on the file's size alone. A cached `text_content` is not a
* reason to read an oversized file: it was only ever a slice of it,
* and taking it as the whole document made a file too big to unpack
* look already read.
*/
if (Number(file.size) > AppData.IngestMaxBytes) {
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [`Too large to read (over ${AppData.IngestMaxBytes / (1024 * 1024)} MB).`, Date.now(), file.id],
@@ -66,13 +76,27 @@ export default async function ingestBotFile({
return { status: "skipped", chunks: 0 };
}
/**
* A bot's own run already carries its user. The ingest queue has no run
* behind it, so everything this file costs - reading an image or an
* audio file, embedding its chunks - is entered under the owner's scope
* instead: it spends real tokens and must show on the usage page.
*/
const owner_scope: HarnessUsageScope | null = grabHarnessUsageScope()?.user_id
? null
: await resolveOwnerScope({ file });
try {
const text = await extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: file.name,
mime_type: file.mime_type,
read_media,
const text = await runWithFallbackScope({
scope: owner_scope,
task: () =>
extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: file.name,
mime_type: file.mime_type,
read_media,
}),
});
await clearChunks({ file_id: file.id });
@@ -101,11 +125,11 @@ export default async function ingestBotFile({
*/
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
const { vectors } = await embedText({
input: batch,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
const { vectors } = await runEmbed({
scope: owner_scope,
signal,
file,
batch,
});
for (let i = 0; i < vectors.length; i++) {
@@ -246,3 +270,54 @@ async function markIngested({
values: [chunks > 0 ? 1 : 0, now, file_id],
}).catch(() => {});
}
/**
* One batch of a file's chunks, embedded under whatever attribution owns
* it: the run that asked for the ingest, else the file's owner.
*/
async function runEmbed({
scope,
signal,
file,
batch,
}: {
scope: HarnessUsageScope | null;
signal?: AbortSignal;
file: BUN_SQLITE_COBBLE_BOT_FILES;
batch: string[];
}) {
return await runWithFallbackScope({
scope,
task: () =>
embedText({
input: batch,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
signal,
}),
});
}
/**
* The attribution a file the queue ingests reports under: its workspace's
* creator when it shares one, else its bot's.
*/
async function resolveOwnerScope({
file,
}: {
file: BUN_SQLITE_COBBLE_BOT_FILES;
}): Promise<HarnessUsageScope | null> {
const workspace_id = file.scope == "workspace" ? file.owner_id : null;
const user_id = await grabUsageOwnerUserId({ bot_id: file.bot_id, workspace_id });
if (!user_id) return null;
return {
user_id,
bot_id: file.scope == "bot" ? file.owner_id : file.bot_id,
workspace_id,
session_id: null,
message_id: null,
job_id: null,
};
}
@@ -1,5 +1,6 @@
import type { MediaType } from "@/src/types";
import { BotImageFileExtensions } from "@/src/dict/bot-file-dict";
import extensionOf from "@/src/utils/extension-of";
type Params = {
/**
@@ -27,19 +28,10 @@ export default function mediaTypeForFile({ name, mime_type }: Params): MediaType
}
/**
* Its extension, lower case, without the dot.
* Its extension, lower case, without the dot. Shared, so the storage
* catalog and the text extractor read a name the same way.
*/
export function extensionOf({ name, mime_type }: Params) {
const from_name = name?.includes(".") ? name.split(".").pop() : undefined;
if (from_name) return from_name.toLowerCase().replace(/[^a-z0-9]/g, "");
const from_mime = mime_type && !mime_type.includes("octet-stream")
? mime_type.split("/").pop()
: undefined;
return from_mime ? from_mime.toLowerCase().replace(/[^a-z0-9]/g, "") : undefined;
}
export { extensionOf };
/**
* A MIME type for the row, from the extension when there isn't one.
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
/**
* The module's own source, since the regression it guards is what the code
* writes to the database, not what it returns.
*/
async function source() {
return await Bun.file(
`${import.meta.dir}/store-chat-attachments.ts`,
).text();
}
describe("storeChatAttachments", () => {
/**
* The same text used to be copied onto the new `bot-file` media row,
* so a chat upload kept a second copy of its extracted text (and left
* it behind when the upload was pruned). The text belongs only on the
* message's own `bot-attachment` row, where a reply reads it;
* ingestion re-reads the bytes through `extractBotFileText`.
*/
test("never writes text_content onto the file's media row", async () => {
const code = await source();
expect(code).not.toContain("SET text_content");
expect(code).not.toContain("text_content = ?");
});
test("still saves the bytes and queues the ingest", async () => {
const code = await source();
expect(code).toContain("recordBotFile(");
expect(code).toContain("enqueueFileIngest(");
});
});
@@ -1,9 +1,8 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import mediaFilePath from "@/src/utils/media-file-path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import { logError } from "@/src/functions/backend/logger";
import grabDirNames from "@/src/utils/grab-dir-names";
import recordBotFile from "./record-bot-file";
type Params = {
@@ -27,14 +26,15 @@ export default async function storeChatAttachments({
}: Params) {
if (!media.length) return;
const { DATA_DIR } = grabDirNames();
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
for (const file of media) {
try {
if (!file.media_write_path) continue;
const absolute = mediaFilePath({ write_path: file.media_write_path });
const source = Bun.file(`${DATA_DIR}/${file.media_write_path}`);
if (!absolute) continue;
const source = Bun.file(absolute);
if (!(await source.exists())) continue;
@@ -44,6 +44,13 @@ export default async function storeChatAttachments({
*/
const name = safeName({ name: file.title || `file-${file.id}` });
/**
* The text stays on the message's own `bot-attachment` row,
* where a reply reads it. Copying it onto the file's row kept
* the same text twice and left the copy behind when the
* upload was pruned; ingestion re-reads the bytes once
* through `extractBotFileText` instead.
*/
const saved = await recordBotFile({
bot_id,
scope: workspace_id ? "workspace" : "bot",
@@ -57,17 +64,6 @@ export default async function storeChatAttachments({
user_id,
});
/**
* The same text the upload already extracted, so a PDF isn't
* unpacked a second time.
*/
if (file.text_content && saved.media_id) {
await BunSQLite.sql({
sql: `UPDATE media SET text_content = ? WHERE id = ?`,
values: [file.text_content, saved.media_id],
}).catch(() => {});
}
await enqueueFileIngest({ file_id: saved.id! });
} catch (error) {
await logError({
@@ -0,0 +1,67 @@
import { AppData } from "@/src/data/app-data";
import type { ConversationHistory } from "@/src/types";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import ensureBotSession from "./ensure-bot-session";
import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
type Params = {
user_id: number;
bot_id: number | string;
/**
* The workspace the conversation is in; none for the bot's own chat.
*/
workspace_id?: number | string | null;
};
/**
* A conversation's history, which the conversation pages load on the
* client once they render (`api/admin/bots/conversation-history`): the
* bot's active session there (marked read), its latest
* `AppData.ConversationFirstMessages` messages (the thread loads older
* ones as it scrolls) and the bot's active task in it. Everything else on
* the page comes with it from the server (`grabConversationProps`).
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationHistory({
user_id,
bot_id,
workspace_id,
}: Params): Promise<ConversationHistory | null> {
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
() => null,
);
if (!conversation?.bot?.id) return null;
const open_bot_id = conversation.bot.id;
const session = await ensureBotSession({
user_id,
bot_id: open_bot_id,
workspace_id: conversation.workspace_id,
});
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
session.unread = 0;
}
const [messages, task] = await Promise.all([
grabBotMessages({
session_id: session.id!,
user_id,
limit: AppData.ConversationFirstMessages + 1,
}),
grabActiveBotTask({ bot_id: open_bot_id, session_id: session.id }),
]);
const has_more = messages.length > AppData.ConversationFirstMessages;
return {
session,
messages: has_more ? messages.slice(1) : messages,
has_more,
task,
};
}
@@ -1,19 +1,14 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { BotTextModel, PagePropsType } from "@/src/types";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
import grabConversationFiles from "../bot-files/grab-conversation-files";
import grabBotComputers from "../computers/grab-bot-computers";
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
import ensureBotSession from "./ensure-bot-session";
import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
type Params = {
user_id: number;
@@ -27,11 +22,12 @@ type Params = {
/**
* The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its latest `AppData.ConversationFirstMessages` messages (the
* thread loads older ones as it scrolls), the text model it runs on, its computers,
* tools and MCP servers and active task there, and whether its mic and
* read-aloud have a model to run on. The full model settings are left to
* in a workspace) and workspaces, the bot, the text model it runs on, its
* computers, tools, MCP servers and files there, and whether its mic and
* read-aloud have a model to run on. The history (its session, messages
* and active task) is left to the client (`grabConversationHistory`,
* `api/admin/bots/conversation-history`), so a page the back button
* restores never shows an old thread. The full model settings are left to
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
* and the conversation's small setting
* (`api/admin/bots/harness-overview`). They are most of a page's weight
@@ -50,12 +46,6 @@ export default async function grabConversationProps({
if (!conversation) return null;
const scope = conversation.workspace_id;
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
session.unread = 0;
}
const [bots, workspaces] = await Promise.all([
grabBots({ user_id, workspace_id: scope }),
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
if (!bot?.id) return null;
/** Loading its history marks the open session read. */
bot.unread = 0;
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
grabBotMessages({
session_id: session.id!,
user_id,
limit: AppData.ConversationFirstMessages + 1,
}),
const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
]);
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
return {
bots: railBots({ bots }),
bot: railBots({ bots: [bot] })[0],
workspaces: railWorkspaces({ workspaces }),
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
bot_session: session,
bot_messages: bot_messages_more ? bot_messages.slice(1) : bot_messages,
bot_messages_more,
bot_text_model,
bot_computers,
bot_task,
bot_extensions,
bot_files,
bot_voice: { transcription, speech },
@@ -0,0 +1,87 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { BotTypes } from "@/src/dict/bot-types-dict";
import type { BotType } from "@/src/types";
import AppError from "@/src/utils/app-error";
import botTypeAssignment from "@/src/utils/rbac/bot-type-assignment";
import grabUserBot from "../bots/grab-user-bot";
import grabUserTypes from "../users/grab-user-types";
import grabUserWorkspace from "../workspaces/grab-user-workspace";
import grabBotTypes from "./grab-bot-types";
export type BotTypeChangeParams = {
/**
* The user making the change, or the user a granting bot runs for.
*/
user_id: number | string;
/**
* The bot making the change from chat (the coordinator), if any: it
* must be allowed as well as the user.
*/
actor_bot_id?: number | string | null;
bot_id: number | string;
bot_type: BotType;
workspace_id?: number | string | null;
};
/**
* Checks a grant or removal of a bot type: the user (and the bot, when one
* acts) may do it (`botTypeAssignment`), only workspace types are ever
* changed (the coordinator's is written by `ensureCoordinatorBot`), and the
* bot works in the workspace. Returns the bot and the workspace id.
*/
export default async function assertBotTypeChange({
user_id,
actor_bot_id,
bot_id,
bot_type,
workspace_id,
action,
}: BotTypeChangeParams & { action: "assign" | "delete" }) {
const type = BotTypes.find((bt) => bt.value == bot_type);
if (!type || type.scope != "workspace") {
throw new AppError(`Only a workspace type can be given or taken away.`);
}
if (!workspace_id) {
throw new AppError(`Pick the workspace the bot is ${type.title.toLowerCase()} of.`);
}
const user_types = await grabUserTypes({ user_id });
if (!botTypeAssignment({ actor_user_types: user_types, target_bot_type: bot_type, action })) {
throw new AppError(`Only an admin can do that.`);
}
if (actor_bot_id) {
const actor_bot_types = await grabBotTypes({ bot_id: actor_bot_id });
if (!botTypeAssignment({ actor_bot_types, target_bot_type: bot_type, action })) {
throw new AppError(`Only the coordinator can do that.`);
}
}
const bot = await grabUserBot({ bot_id, user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!bot?.id || !workspace?.id) {
throw new AppError(`Bot or workspace not found.`);
}
if (bot.kind == "coordinator") {
throw new AppError(`The coordinator already leads every workspace.`);
}
if (action == "assign") {
const member = await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`,
values: [workspace.id, bot.id],
});
if (!member.payload?.[0]?.n) {
throw new AppError(`${bot.name} isn't in ${workspace.name}. Add it to the workspace first.`);
}
}
return { bot, workspace };
}
@@ -0,0 +1,19 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
};
/**
* Gives the coordinator its install-wide `coordinator` bot type, once.
* The only place that type is ever written.
*/
export default async function ensureCoordinatorType({ bot_id }: Params) {
await BunSQLite.sql({
sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, created_at, updated_at)
SELECT ?, 'coordinator', NULL, ?, ? WHERE NOT EXISTS (
SELECT 1 FROM bot_types WHERE bot_id = ? AND bot_type = 'coordinator' AND workspace_id IS NULL
)`,
values: [Number(bot_id), Date.now(), Date.now(), Number(bot_id)],
});
}

Some files were not shown because too many files have changed in this diff Show More