33 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 89c4e26d06 Release v0.2.2
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:58:21 +01:00
tbenandClaude Opus 5.5 d67cd6a27a Close the proposal modal once a proposal is decided
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:57:28 +01:00
tbenandClaude Opus 5.5 2cbf6a6e6c Compress AGENTS.md to its rules, invariants and pointers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:56:18 +01:00
tbenandClaude Opus 5.5 373210c2a5 Make the skill runbook limits a general setting
The longest runbook a skill may save and how much of it load_skill hands
back are now set on Settings > General (skill_body_max_chars,
skill_body_shown_chars), defaulting to AppData.SkillBodyMaxChars and
SkillBodyShownChars. Saving, confirming and proposing a skill check the
install's limit; a skill saved before it was lowered can still be toggled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:43:00 +01:00
tbenandClaude Opus 5.5 2408185a65 Release v0.2.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:25:43 +01:00
tbenandClaude Opus 5.5 577523de3c Only ignore the root logs folder
The bare "logs" pattern ignored every folder named logs, so
api/admin/logs/clear-logs.ts never reached the repository or the
production build. Anchor it to the root and add the route.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:24:55 +01:00
tbenandClaude Opus 5.5 b9a49ee09c Fix data archives and restores failing on their own lock
startDataBackupRun already holds the backup run lock, but the archive
(createDataBackup) and restoreDataBackup took it again, so every run
failed a few seconds in with "Another backup or restore is already
running". The writer and the restore now take no lock of their own.

The run's notice (archiving, restoring, or why it failed) moves to its
own line under the actions row so the buttons don't shift, and archiving
reads in gray rather than the warning colour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:24:55 +01:00
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
437 changed files with 16463 additions and 1588 deletions
+19 -76
View File
@@ -1,5 +1,12 @@
name: Release image 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: on:
release: release:
types: [published] types: [published]
@@ -30,13 +37,6 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} 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 # The version the app reports on Settings > General when the build
# isn't a published release. # isn't a published release.
- name: Read the source version - name: Read the source version
@@ -45,6 +45,11 @@ jobs:
set -euo pipefail set -euo pipefail
echo "version=$(sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' package.json | head -n1)" >> "$GITHUB_OUTPUT" 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 - name: Work out image tags
id: tags id: tags
env: env:
@@ -57,32 +62,24 @@ jobs:
if [ "$EVENT" = "release" ] && [ -n "$RELEASE_TAG" ]; then if [ "$EVENT" = "release" ] && [ -n "$RELEASE_TAG" ]; then
version="$RELEASE_TAG" version="$RELEASE_TAG"
minor="$(printf '%s' "$version" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/\1.\2/')" 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:latest" \
"ghcr.io/moduletrace/cobble:${version}" \ "ghcr.io/moduletrace/cobble:${version}" \
"ghcr.io/moduletrace/cobble:${minor}" \ "ghcr.io/moduletrace/cobble:${minor}" \
"ghcr.io/moduletrace/cobble:${sha}")" "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 else
version="$SOURCE_VERSION" version="$SOURCE_VERSION"
ghcr="ghcr.io/moduletrace/cobble:${sha}" tags="ghcr.io/moduletrace/cobble:${sha}"
gitea="git.tben.me/moduletrace/cobble:${sha}"
fi fi
{ {
echo "version=$version" echo "version=$version"
echo "ghcr<<EOF" echo "tags<<EOF"
echo "$ghcr" echo "$tags"
echo "EOF"
echo "gitea<<EOF"
echo "$gitea"
echo "EOF" echo "EOF"
} >> "$GITHUB_OUTPUT" } >> "$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 - name: Build and push to GHCR
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@@ -92,62 +89,8 @@ jobs:
push: true push: true
build-args: | build-args: |
COBBLE_VERSION=${{ steps.tags.outputs.version }} COBBLE_VERSION=${{ steps.tags.outputs.version }}
tags: ${{ steps.tags.outputs.ghcr }} tags: ${{ steps.tags.outputs.tags }}
provenance: false provenance: false
sbom: false sbom: false
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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. It must still be
# loud: `continue-on-error` alone leaves the check green when the
# mirror does nothing at all, which is how the 413 below went unseen
# for four releases.
- name: Mirror to Gitea
id: mirror
continue-on-error: true
env:
GITEA_TAGS: ${{ steps.tags.outputs.gitea }}
GHCR_IMAGES: ${{ steps.tags.outputs.ghcr }}
run: |
set -uo 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"
output="$(mktemp)"
status=0
docker buildx imagetools create "${args[@]}" "${first_ghcr}" 2>&1 | tee "$output" || status=$?
if [ "$status" -eq 0 ]; then
{
echo "### Gitea mirror"
echo
echo "Mirrored \`${first_ghcr}\` to:"
echo
printf -- '- \`%s\`\n' $GITEA_TAGS
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# The usual cause: git.tben.me sits behind Cloudflare, whose
# request-body cap is 100 MB, and this image has a 243 MiB
# `bun install` layer that no route through it can carry.
reason="$(grep -oE '[0-9]{3} [A-Za-z ]{0,40}' "$output" | tail -n1)"
{
echo "### Gitea mirror failed"
echo
echo "\`${first_ghcr}\` did **not** reach the Gitea registry. The release itself is fine: the installer defaults to GHCR."
echo
echo '```'
tail -n 12 "$output"
echo '```'
echo
echo "See the [registry push limits](https://developers.cloudflare.com/workers/platform/limits/): git.tben.me is behind Cloudflare, which refuses any request body over 100 MB, and this image's \`bun install\` layer alone is about 243 MiB."
} >> "$GITHUB_STEP_SUMMARY"
echo "::warning title=Gitea mirror failed::${reason:-docker buildx imagetools create failed}; the image did not reach git.tben.me. See the job summary."
exit "$status"
+1 -1
View File
@@ -11,7 +11,7 @@ coverage
*.lcov *.lcov
# logs # logs
logs /logs
!src/pages/admin/logs/ !src/pages/admin/logs/
_.log _.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+172 -222
View File
@@ -1,293 +1,243 @@
# Agent Instructions # Agent Instructions
This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI worker bots. Unit noun is **bot**, never "agent" or "expert". **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI worker bots. The unit noun is **bot**, never "agent" or "expert".
**The handoff is outdated.** `assets/handoff/` (gitignored locally: `PRODUCT.md`, `IMPLEMENTATION.md`, `tokens/`, `assets/`) is the *foundation*, not the design: it is what the project started from, and the app has moved well past it. Its layouts, screens (`assets/screens/*.png`), right-hand "working on" panel, and PRODUCT/IMPLEMENTATION briefs describe an early single-chat prototype and must not be used as reference for the current UI or copied into docs, screenshots or marketing. What is still authoritative in it: This file is a map: it names the rules, the invariants and where things live. For detail, read the code it points to. When this file, the handoff, the code and the user disagree, the code and the user win.
- **Brand assets:** the stone icons (`public/brand/icons/*.svg`, mirrored into `src/data/stone-geometry.ts`), the coordinator (`public/brand/coordinator/`), the app icon (`public/brand/app-icon/`), and the wordmark (`public/brand/wordmark/`, mirrored into `src/data/wordmark-paths.ts`). These are the identity and do not change; the mark is a stone with exactly one flat cut, a bot's colour is its identity (never status), the coordinator is hue-less. ## The handoff is outdated
- **Tokens:** `tokens/tokens.css` was the origin of the `:root` / `.dark` block in `src/styles/main.css`. From here on `src/styles/main.css` is the source of truth; treat the handoff CSS as historical.
For the current UI, read the code: `src/layouts/bots/` (rail, workspace rail, user popover), `src/pages/admin/bots/[bot_id]/` (conversation header, thread, composer, modals) and `src/pages/admin/workspaces/` (dashboard). When the handoff and the current code or the user disagree, the code and the user win, always. `assets/handoff/` (gitignored) is the project's foundation, not its design. Never use its layouts, `assets/screens/*.png`, right-hand "working on" panel or PRODUCT/IMPLEMENTATION briefs as reference for the UI, docs, screenshots or marketing. Still authoritative:
- **Brand assets** (never change): stone icons (`public/brand/icons/*.svg`, mirrored in `src/data/stone-geometry.ts`), coordinator (`public/brand/coordinator/`), app icon (`public/brand/app-icon/`), wordmark (`public/brand/wordmark/`, mirrored in `src/data/wordmark-paths.ts`). The mark is a stone with exactly one flat cut; a bot's colour is its identity (never status); the coordinator is hue-less.
- **Tokens:** `src/styles/main.css` is now the source of truth (the handoff `tokens.css` is history).
For the current UI read `src/layouts/bots/` (rail, workspace rail, user popover), `src/pages/admin/bots/[bot_id]/` (header, thread, composer, modals) and `src/pages/admin/workspaces/` (dashboard).
## Stack ## Stack
- **Runtime:** Bun - not Node. Use `bun` commands, never `npm`/`npx`. - **Runtime:** Bun, not Node. Use `bun`, never `npm`/`npx`.
- **Framework:** `@moduletrace/bunext` - not Next.js. Routing is filesystem-based under `src/pages/`, served by `Bun.serve` in `src/server.ts`. - **Framework:** `@moduletrace/bunext`, not Next.js. Filesystem routing under `src/pages/`, served by `Bun.serve` in `src/server.ts`.
- **Database:** `@moduletrace/bun-sqlite` - SQLite database. Schema in `db/schema.ts`, generated types in `db/types/db.ts`. Config in `bun-sqlite.config.ts`. - **Database:** `@moduletrace/bun-sqlite` (SQLite). Schema `db/schema.ts`, generated types `db/types/db.ts`, config `bun-sqlite.config.ts`.
- **UI:** Tailwind CSS v4 + the `@moduletrace/twui` component library (installed package) + shared components in `src/components/general/`. There is **no** shadcn/ui, no `tailwind.config`, and no `components.json` - twui is the only component system. CSS chain: `src/pages/__root.tsx` imports `src/styles/main.css`, which imports `@moduletrace/twui/base.css` (`@import "tailwindcss"`). Theme tokens (colors/radius/fonts) are overridden via the `@theme inline` block in `src/styles/main.css`. - **UI:** Tailwind v4 + `@moduletrace/twui` (the only component system: no shadcn, no `tailwind.config`, no `components.json`) + `src/components/general/`. CSS chain: `src/pages/__root.tsx` -> `src/styles/main.css` -> `@moduletrace/twui/base.css`. Theme tokens in the `@theme inline` block of `main.css`.
- **Icons:** `lucide-react`. - **Icons:** `lucide-react`. Never render a lucide icon by name (twui `LucideIcon`, a string `beforeIcon`): it ships every icon. Import the icon.
- **Validation:** `zod` v4 (`[email protected]`). - **Validation:** `zod` v4 (`[email protected]`).
- **Branding:** Site name/slug live only in `src/data/site-data.ts`. Logo source of truth is `public/logos/logo.svg` (the ember stone; rendered inline via `BotStone` in `src/components/general/logo-icon.tsx`); browser favicon/assets are generated from it. Handoff brand assets (stones, coordinator, wordmarks, app icon) live in `public/brand/`. - **Branding:** site name/slug only in `src/data/site-data.ts`. Logo source `public/logos/logo.svg` (the ember stone, rendered via `BotStone` in `logo-icon.tsx`).
## Entrypoint
`src/server.ts` - turns on SQLite foreign keys (`enableForeignKeys`, so the schema's cascades fire; off by default per connection), runs `bunextInit()`, starts `Bun.serve`, then starts cron:
- `/media/*` short-circuits to the media server handler
- bunext's responses go through `compressResponse` (`src/functions/server/compress-response.ts`): page scripts and styles bunext names after their content (`isHashedAssetPath`) are cached `immutable` and compressed once (brotli or gzip, kept in memory within `CompressCacheMaxFiles` and `CompressCacheMaxBytes`, with parallel requests sharing one pass); pages and API JSON are gzipped as they stream; media, streams and small bodies go out as they are. Every variant carries `Vary: Accept-Encoding`. `public/sw.js` serves every `.bunext/public` file from the build's own cache (see PWA). Keep page props small: every page is a full server render, so anything only a modal needs loads when the modal opens. The chat header's models modal fetches the install-wide catalog once (`api/admin/harness/catalog`) and the conversation's small setting separately (`api/admin/bots/harness-overview`), both kept in `localStorage` (the catalog for `AppData.HarnessCatalogCacheMs`, the setting for `AppData.HarnessOverviewCacheMs`) and cleared on logout or after a model save (`src/utils/harness-cache.ts`). `bun run check:bundle` (run in CI after a production build) fails if the conversation page's initial JS passes its budget or the full Lucide set returns. Never render a lucide icon by name (twui's `LucideIcon`, a string `beforeIcon`): that ships every icon; import the icon
- `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/`
- everything else goes to `bunext.bunextRequestHandler`
- `ensureDockerNetwork()` makes sure the computers' docker network (`AppData.DockerNetworkName`) exists before the server starts; without docker the process exits (computers are containers, so nothing works without it). Creating a computer's container checks it again first
- `cron()` and the bots queue handler (`botsQueue()`, `src/bots-queue/`) start after the server is up, then `liveRelay()` (`src/live-relay/`, every process, ungated); in production cron and the queue are gated to the `MAIN_SERVER` container
## Commands ## Commands
| Purpose | Command | | Purpose | Command |
| -------------------------------- | --------------------------------------------------------- | | --- | --- |
| Dev server | `bun run dev` (runs `bun src/server.ts`) | | Dev server | `bun run dev` |
| Production | `bun run start` (`MAIN_SERVER=1 NODE_ENV=production bun src/server.ts`, no build: it serves whatever `.bunext/` holds, a dev build included, and builds only when there is none; build for production first with `NODE_ENV=production bunx bunext build`) | | Production | `bun run start` (serves whatever `.bunext/` holds; build first with `NODE_ENV=production bunx bunext build`) |
| Type-check only | `bun run typecheck` | | Type-check | `bun run typecheck` |
| Bundle budget (after a build) | `bun run check:bundle` | | Tests | `bun run test` (one file: `bun test <file>`; tests are colocated `.test.ts(x)`) |
| Run tests | `bun run test` | | Bundle budget (after a production build, CI) | `bun run check:bundle` (fails if the conversation page's initial JS passes budget or the full Lucide set returns) |
| Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) | | Favicon/brand/PWA assets | `bun run gen:favicon` |
| DB schema (tables) | `bun run db:schema` | | DB schema sync / admin UI / backup | `bun run db:schema` / `db:admin` / `db:backup` |
| DB admin UI | `bun run db:admin` | | Seed super admin / sample crew | `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) / `bun run seed:bots [email]` (idempotent) |
| DB backup | `bun run db:backup` | | Query DB (read-only, JSON envelope) | `bun llm-code-assistant/query-db.ts "<sql>"` |
| Seed super admin user | `bun run seed` (runs `src/scripts/seed-super-admin.tsx`) | | Publish a release | `bun run publish:github` / `publish:gitea`; `bun run mirror:gitea` afterwards |
| Seed sample bot crew | `bun run seed:bots [email]` (runs `src/scripts/seed-bots.ts`, idempotent: adds sample messages to the existing coordinator and the sample workers, skips users who already created workers; assigns the sample workers to them) |
| Query DB (CLI) | `bun llm-code-assistant/query-db.ts "<sql>"` |
| Install | `bun install` |
| Publish a release to GitHub | `bun run publish:github` (`scripts/publish-github.sh`) |
| Publish a release to Gitea | `bun run publish:gitea` (`scripts/publish-gitea.sh`) |
No linter or formatter configured. `bun run typecheck` and `bun run test` are the verification steps. Tests are colocated `.test.ts`/`.test.tsx` files - run one with `bun test <file>`. No linter or formatter. `bun run typecheck` and `bun run test` are the verification steps. Use `query-db.ts` to see what is actually in the database instead of reading ORM code.
## Querying the Database Path alias `@/*` resolves from the repo root (`@moduletrace/twui/layout/Button`, `@/src/data/site-data`, `@/db/types/db`).
Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-only SQL CLI that loads the schema from `db/schema.ts` automatically and prints a normalized JSON envelope (`success`, `payload`, `count`, `insert_return`, `error`, `msg`). Exit code `1` on failure. Use it instead of fishing through ORM code when you need to know what's actually in the database. ## Entrypoint (`src/server.ts`)
## Path Alias Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDockerNetwork()` (exits without docker: computers are containers), `Bun.serve`, then `cron()`, `botsQueue()`, the file-ingest worker and `liveRelay()` (cron and the queue are gated to `MAIN_SERVER` in production; the relay runs everywhere).
`@/*` resolves from repo root. Import like `@moduletrace/twui/layout/Button`, `@/src/components/general/logo`, `@/src/data/site-data`, `@/db/types/db`. - `/media/*` -> media server; `/computers/<id>/` -> computer proxy; `/ws` upgrades only after `userAuth`; `/sw.js` and PWA files via `handlePwaFile`; precache gate before bunext; everything else -> `bunextRequestHandler`.
- Responses go through `compressResponse` (`src/functions/server/compress-response.ts`): hashed assets cached `immutable` and compressed once (in-memory, bounded); pages/JSON gzipped as they stream; media and streams untouched.
- **Keep page props small:** every page is a full server render. Anything only a modal needs loads when the modal opens (e.g. the models modal fetches `api/admin/harness/catalog` and `api/admin/bots/harness-overview`, cached in `localStorage` by `src/utils/harness-cache.ts`).
## Architecture ## Architecture
### Auth ### Auth
- **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request. - **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`.
- No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there - **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request.
- No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away - **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`.
- Redirects unauthenticated `/admin/*` → `/auth/login` - **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`).
- Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general` - **User types** (`src/dict/user-types-dict.ts`, the only definition): `super_admin` (user #1), `admin` (only the super admin grants it), `member` (default), `read_only` (non-GET CRUD blocked), `revoked`. A user needs at least one type to sign in.
- Already-authenticated users hitting `/auth/*` (except paths containing `/logout`) are redirected to `/admin/bots`; login and signup land there too - **RBAC:** `src/utils/check-user-access.ts` plus the matrix engine in `src/utils/rbac/` (`userTypeAssignment` over `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix`).
- **Session:** encrypted auth cookie (AES-256-GCM, PBKDF2-derived key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus a CSRF cookie, both `Secure` only when the request came over HTTPS (`isSecureRequest`, `src/utils/is-secure-request.ts`: the URL's scheme or `X-Forwarded-Proto`), so plain HTTP over a VPN or LAN address can still sign in. `userAuth` (`src/functions/backend/auth/user-auth.ts`) decrypts, checks expiry (`AuthExpiryDays`, 60) against the embedded `date`, verifies the CSRF cookie against the token embedded in the session (constant-time, `src/utils/safe-compare.ts`), and re-reads `user_types` from the DB every request (revoked users lose access immediately). - **Audit:** `src/functions/backend/logger.ts` (`logEvent`/`logError` -> `logs`). Auth events, cron runs, every unexpected API error (`safeErrorMessage` -> `logUnexpectedError`, not for `AppError`), every failed or lost bot job. WebSocket connects are not logged.
- **Login:** local install, so email or username plus password only (`api/auth/password-login.ts`, rate limited), no emailed code step and no emailed password reset. Google login (`api/auth/google-login.ts`) is for existing accounts only and shows when `GOOGLE_CLIENT_ID` is set; it never creates accounts. Sessions are issued by `src/functions/backend/auth/login-user.ts` (supports `reauth` for silent refresh without audit logging).
- **Signup:** first-run only. `api/auth/signup.tsx` refuses once any user exists, creates the super admin (`create-super-admin-user.ts`, so user #1) with a required password, and logs them in. No Google signup. `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) is the headless alternative.
- **Passwords:** bcrypt hashes live in the separate `user_passwords` table (never in `users`; blocked from the CRUD API via `src/dict/private-tables.ts`). Set/change at `/admin/settings/change-password`.
- **Auth layout:** `/auth/*` (except `/auth/logout`) renders in `src/layouts/auth/` with no site header/footer.
- **User types** (`UserTypes` in `src/dict/user-types-dict.ts`, the only place they're defined): `super_admin` (the first account, runs the install), `admin` (manages members; only the super admin grants it), `member` (a regular user: new users start with it), `read_only`, `revoked` (can't sign in). A user needs at least one to sign in. Admins grant and remove `member`, `read_only` and `revoked` on anyone but admins and the super admin.
- **RBAC:** `src/utils/check-user-access.ts` (includes/excludes primitive, `super_admin` bypass) plus the declarative engine in `src/utils/rbac/` - `userTypeAssignment` interprets `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix` in `src/dict/user-types-dict.ts`. `read_only` users are blocked from non-GET CRUD in the router.
- **Audit trail:** `src/functions/backend/logger.ts` - `logEvent`/`logError` write to the `logs` table. Signup, login, Google login, logout, cron runs, and WebSocket connects are all logged.
### WebSocket ### WebSocket
- Upgrades on `/ws` with `userAuth` validation (401 if not logged in); `WebSocketSession` (`src/websocket/types.ts`) carries `{ user, user_types }`. `/ws`, session frozen at upgrade (`src/websocket/types.ts`). Rooms in `src/dict/websocket-rooms.ts`, store in `src/websocket/(data)/rooms.ts`. Dispatch in `handle-message.ts`; client handlers are folder-per-event in `src/websocket/client-requests/<event>/`. Non-JSON messages (twui's `"twui::ping"`) are ignored. Server pushes: `sendToUser`; clients listen with `useServerEvent`.
- Rooms declared in `src/dict/websocket-rooms.ts` -> typed `WebsocketRoomType`. Store in `src/websocket/(data)/rooms.ts`: `WeakMap` for per-client metadata, `Map<room, Set<ws>>` for lookups, empty rooms deleted. Join/leave/broadcast in `src/websocket/(functions)/`.
- Message dispatch in `handle-message.ts`: `client:ping` → `server:pong`, unknown events → `error`. The twui `useWebSocket` hook sends a raw `"twui::ping"` keepalive string - non-JSON messages are ignored.
- Client handlers: `src/websocket/client-requests/<event>/index.ts` (folder-per-event), replying through `wsSendData`.
### Cron ### Cron
`src/cron/index.ts` is the single registration point called from `server.ts`. Tasks live in one folder each (`src/cron/<name>/index.ts`) as default-exported async functions, scheduled with in-process `Bun.cron` via `registerCronJob({ title, schedule, task, run_on_start, quiet })` (`src/cron/register-cron-job.ts`), which logs before each run (unless `quiet`) and catches/logs errors. Schedules are cron expressions or nicknames (`@hourly`) interpreted in UTC; runs never overlap. Gated in production by `MAIN_SERVER`. The bundled `heartbeat` task doubles as a DB-liveness probe; `sync-harness-models` refreshes the model catalog hourly; `run-cron-jobs` (every minute, quiet) queues the bots' due schedules (see Schedules). `src/cron/index.ts` registers everything via `registerCronJob` (`src/cron/register-cron-job.ts`); one folder per task. In-process `Bun.cron`, UTC, never overlapping. Notable: `heartbeat`, `sync-harness-models` (hourly), `run-cron-jobs` (every minute, fires schedules), `sync-computers`, `sync-mcp-servers`, `prune-bots-queue`.
### Admin CRUD API ### Admin CRUD API
`api/admin/crud/[[...paths]].ts` dispatches `GET/POST/PUT/DELETE` by table name: `userAuth` → `read_only` GET-only gate → per-table switch → generic fallback (super admin only). Tables without custom handlers fall through to generic CRUD with zero code. Customize a table by adding `src/pages/api/admin/crud/(tables)/<table>/` with `index.ts` + per-method files, then add a `case` in the router. `api/admin/crud/[[...paths]].ts`: `userAuth` -> read-only gate -> per-table handler in `src/pages/api/admin/crud/(tables)/<table>/` -> generic fallback (super admin only). Add a table handler folder plus a `case` in the router.
### Bots (the chat shell) ### Crew access (bots, workspaces, computers)
- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. - Bots, workspaces and computers belong to the install; their `user_id` is only the creator. Users reach them only through grants: `bot_users` (a worker's own chat) and `workspace_users` (every bot in that workspace).
- **Tables:** `bots` (`kind` worker|coordinator, `color` (a free `#rrggbb` the user picks) and `stone` (which flat-cut shape it's drawn on), `role`, `system_prompt` (the bot's core instructions, markdown); `hue` is the palette column they replaced, read only as a fallback), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the **only** one who sees or chats with the coordinator; `admin` reaches every worker and workspace; `member` only their grants. `canManageCrew` (super admin + admin) creates/edits/deletes crew and grants access; DB helpers check it themselves (`assertCrewManager`).
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - Access SQL lives in `src/utils/crew-access/` (tested against SQLite) and backs `grabChatBot`, `grabUserBot`, `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots`, `grabUserComputer(s)`.
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - Conversations, faces and status lines are **per user** (`bot_sessions.user_id`, `bot_user_faces`). A job runs for its session's user, with that user's memory and reach. Page reads overlay the viewer's face (`botFaceSql`). Status lines never name a task.
- **Routes:** `/admin/bots` opens the bot this browser was last in (`readLastVisitedBot` from the single `cobble_last_visited_bot_id` cookie, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears; a conversation inside a workspace is never remembered, so the landing page always reopens the bot's own chat), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, only a file whose extension the extractor unpacks gets `text_content` - `canExtractText`, cut to `AppData.BotAttachmentTextStoreMaxChars` - limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; The page renders a session's latest `AppData.ConversationFirstMessages` messages (`bot_messages_more` says there are older), and the thread loads older ones `AppData.ConversationOlderMessages` at a time as it scrolls to its top (`older-messages.ts`, `use-older-messages`, `older-messages-loader.tsx`). Bot and workspace lists in page props go through `railBots` / `railWorkspaces` (`src/utils/`): no core instructions, memory or workspace context, and the last message cut to a preview (`AppData.RailPreviewMaxChars`); the header avatar's edit form loads the instructions when it opens (`bot-instructions.ts`, `use-bot-instructions`). `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Sending shows the bubble at once and empties the input (with its quote and files), so a slow connection never waits on the request: the optimistic message rides in `pending_messages` in `conversation.tsx` until the response or a push replaces it with the saved one (`(utils)/drop-sent-message.ts`), and a failed send takes it back and restores the text and files. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`).
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed).
- **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one).
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; a message still on its way to the server reads "<name> is getting your message…" (`BotGettingMessageStatusLine`), while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. A stop button rides on that row (`stop-bot-button.tsx`, and the header menu's Stop row, both through `StopBotContext`) exactly while `botBusyState` (`src/pages/admin/bots/[bot_id]/(utils)/bot-busy-state.ts`, which also decides the row's line) says the bot has work open for that user anywhere; a message still on its way to the server isn't stoppable, since its reply is queued after it lands. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles.
- **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`) or, from chat, by the bots themselves through the `set_picture` tool (`src/harness/tools/bot/set-picture-tool.ts`, every run: a bot sets its own face, the coordinator any worker) and `update_workspace`'s `picture_file_id` (`setBotPicture`, `copyMediaAsPicture`; super admins and admins only, `assertCrewManager`). Both take the image by its `[file:ID]` ref (`grabPictureSource`, so a bot only sets what it could already read; chat uploads land in `uploads/...` and are labelled with their media id in `load-attachment-parts.ts`/`describeAttachment.ts`) or by an upload's media id. The bytes are **copied** into a new picture row, never moved: a chat message that attached the source keeps loading, and only a row whose paradigm is `bot-image`/`workspace-image` is deleted when the picture is replaced (`removeReplacedPicture`, `is-picture-media.ts`). A picture keeps its own format (`imageOutputFormat`, `src/utils/image-output-format.ts`: PNG stays a PNG with its transparency, GIF becomes PNG, everything else JPEG), for the full image and the thumbnail alike. Its change is pushed live (`pushBotUpdated`'s `image_media_id`, `pushWorkspaceUpdated` and `server:workspace-updated`, `relayChangedWorkspaces`), so `BotAvatar` (rounded square, else its stone, its colour still applies) and `WorkspaceAvatar` (else the workspace icon) swap it without a reload. A bot is also edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less).
- **Colour:** a bot's colour is free. It lives in `bots.color` (`#rrggbb`, set from the palette tiles, the native swatch or a typed hex in `(partials)/bot-colour-picker.tsx`, `api/admin/bots/`), and the stone it's drawn on is `bots.stone` (`BotStoneShapes`), because shape no longer follows colour. `src/utils/bot-color.ts` is the only place that resolves them (`resolveBotColor`/`resolveBotStone` fall back to the old `hue` for a bot saved before this, `botColorNeedsPaperFace` flips the eyes to paper on a very dark body), and `src/utils/bot-stone-props.ts` gives every row its `color`/`stone` props. A new bot starts on the palette's next free colour (`src/utils/pick-free-color.ts`). `src/scripts/move-bot-colour-to-owners.ts` backfills an install from `hue` (safe to rerun).
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, the bot's colour as the body's SVG `fill` attribute, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
- **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only).
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then the install-wide `# Install-wide instructions` (`bot_system_prompt_append` in `app_settings`, set by a super admin on Settings > General, `grabSystemPromptAppend`; empty when unset), then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's.
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `stop-bot-jobs` (a user pressing Stop: every pending or processing job of that bot in their conversations, `open-bot-jobs-sql`, failed with `BotJobStoppedError`, its task closed, a `Stopped` step recorded and one closing message per session, `stopped-job-report`; the running handler's controller is aborted from this process through `job-abort-registry`, so a job waiting on a model stops at once instead of on its next heartbeat, and a late `setFace` after the abort is dropped so a stopped bot is never pushed back to `working`; `api/admin/bots/stop.ts` then pushes the messages and the idle face, `quiet_push` so the user who pressed it gets no push notification), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. One attempt has a deadline (`AppData.BotJobAttemptTimeoutMs`): the heartbeat keeps the lock alive for as long as the run takes, so a run past the deadline is aborted, reported as a failed try and retried, and a model or tool that never answers can't hold its slot, conversation or computer forever. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)").
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`; polling starts even if that release fails, and a production process without `MAIN_SERVER` says so in the log instead of going quiet), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs, and its `workspace_ids` after a membership change, so a workspace's crew drops a bot that left it. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A deleted workspace is pushed the same way as `server:workspace-removed` (`WorkspaceRemovedData`), read before the delete too. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`).
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): the workspace's computers in a workspace, its default first; in its own chat, its direct grants and those of the workspaces it's attached to (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
- **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`).
- **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. A run acts on its own (`ScheduledRunNote`: the brief is already approved, so it sends rather than drafts); a run that can't do its brief calls `report_run_failed` (scheduled runs only), which throws `ScheduledRunFailedError` so the queue retries it, `AppData.ScheduledRunRetryMs` apart and doubling (`scheduledRunRetryAt`); once out of attempts the user gets one "failed after N tries" message and the schedule's `last_error` keeps why (`recordCronJobFailure`). `/admin/routines` lists every one of the user's (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat, and each conversation's own `(partials)/bot-routines-modal.tsx` ("Schedules" in the header menu, beside Tasks) lists the ones that run there (a bot's own chat or one workspace, `routineScopeSql` in `src/utils/routine-scope/`) through `api/admin/bots/list-routines`, which owns them through `grabUserConversation` and `grabCronJobs`'s `workspace_id` filter and loads only when the modal opens. It shares `RoutineForm`, `RoutineRow` and `RoutineEditModal` with the routines page (both in `src/components/general/`).
- **Tasks (the crew's to-do list):** `tasks` is the tracker, one collection per bot (`bot_id`) and one per workspace (`workspace_id`; exactly one is set, enforced in `taskScopeValues`, since SQLite treats NULL in a unique constraint as distinct - the `envs` precedent). It is not `bot_tasks` or `bots_queue`: those record a queued job, this records the work. Fields: `title`, `description` (the brief, markdown), `status` (`TaskStatuses` in `src/dict/task-dict.ts`: `todo|doing|blocked|done|cancelled`), `blocked_reason` (only while blocked), `origin` (`user|bot`), `created_by_bot_id`/`created_by_user_id` (not foreign keys), `completed_at`. `task_notes` is the progress log (author `user|bot`, `bot_id`, `text`); `task_media` links a file to a task as a `deliverable` or a `reference` (`task_id`, `media_id` FK, `bot_file_id` not a FK, `kind`, `name`, `added_by_bot_id`), the bytes being a private `media` row (paradigm `task-media`) served at `/media/<id>` through `grabTaskMediaFile` (registered in `handle-media-server.ts` beside `grabBotFileMedia`). Visibility is SQL in `src/utils/task-access/` (`taskScopesSql` for a user: `chatBotsSql` bots plus `workspacesSql` workspaces; `botTaskScopesSql` for a run: its own plus every workspace it works in, everything for the coordinator; tested against SQLite). A workspace's tasks are visible to every bot working there and to the coordinator. Helpers in `src/functions/backend/db/tasks/`: `create-task` (`assertCrewManager` for a manual add, `TaskMaxPerScope` cap), `grab-tasks` (filters, joins the bot's face, the workspace and the latest note), `grab-task`, `update-task` (stamps `completed_at`, clears `blocked_reason` when it leaves blocked), `delete-task`, `add-task-note`, `attach-task-file`/`detach-task-file` (a bot file resolved through `grabBotFile` so a run can't link outside its folders; a chat upload must be the user's own media; `TaskMediaMaxPerTask`), `grab-task-media`, `grab-task-media-file`, `find-duplicate-task`, `purge-task-media` (`grabTaskMediaIds`, read before `deleteBot`/`deleteWorkspace` so a task's own chat uploads aren't stranded; a bot file keeps its folder). Every run gets `list_tasks`, `create_task`, `update_task` (`src/harness/tools/bot/tasks/`, from `grabBotTools`): a worker may only add to its own list or the workspace it works in, the coordinator to any worker's, any workspace's, or its own, and `update_task` takes `status`, `blocked_reason`, a `note` and `deliverable_file_ids`/`reference_file_ids` (`[file:ID]` refs) in one call. `TasksNote` (every reply and task) says how to keep the list honest; `TaskReminderNote` (chat only) tells the bot to check `list_tasks` on a greeting or when proposing next steps and mention at most `AppData.TaskReminderDigestCount` open tasks in one line, blocked first, never repeating a reminder and staying quiet mid-task. The user adds and changes tasks by hand in a workspace's dashboard (`(partials)/workspace-tasks-group.tsx`) and from the conversation header's menu (`(partials)/bot-tasks-modal.tsx`, loading its list when it opens so the page props stay small); both compose the shared `TaskForm`, `TaskListItem`, `TaskDetailPanel`, `TaskStatusChip` and `TaskFileRow` in `src/components/general/`. API in `api/admin/tasks/` (`list-tasks`, `get-task`, `save-task`, `delete-task`, `add-note`, `attach-file`, `detach-file`), each `userAuth` → `isReadOnlyUser` → `assertUserReachesTask` → `safeErrorMessage`. Live pushes for task changes are not built yet.
- **Not built yet:** task steps (nothing writes `bot_task_steps` yet), live `server:task-changed` pushes, and a `/admin/tasks` page.
### Installer and release ### Bot types
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`, `bots`, `workspaces`, `mcp`). `bot_types` is to bots what `user_types` is to users (`src/dict/bot-types-dict.ts`): `coordinator` (install-wide, written only by `ensureCoordinatorBot`), `admin` (scoped to one workspace), `worker` (no row). `BotTypePermissionMatrix`, `BotTypeTargetMatrix`, `BotTypeAssignmentMatrix`/`DeletionMatrix` are read by the generic interpreter `src/utils/rbac/type-assignment.ts`. Every run reads `grabBotRunPowers` once and gates tools on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, **never on `bots.kind`** (`kind` is only the legacy marker of the coordinator for visibility SQL). A run's powers are its type's AND its user's. A workspace admin gets `grabWorkspaceAdminTools` (delegate to members, set their workspace instructions, edit that workspace). Set via `api/admin/bots/set-bot-type`. Admins are marked with `AdminShield` (green, beside the name, never on the stone).
The bind mounts are built in the marked `selinux-detection` and `mount-specs` blocks, and `install.test.ts` runs those blocks in bash and parses the specs the way docker does. A mount spec is `source:destination[:options]`: docker takes the SELinux `z` and the read-only `ro` as comma separated options in that third field, so a destination is always a plain absolute path. Appending an option to the destination (`$DATA_DIR:/data,z`) makes docker mount a directory literally named `/data,z`: the app then writes its database to its own container layer, `container_matches` never matches, every update recreates the container without its data, `backup_database` silently skips, and `resolveHostPath` refuses to provision computers (`/data/computers/1 isn't on a volume`). `rescue_container_data` runs before a container is replaced and copies a container's unmounted `/data` onto the host, so repairing an install from the broken form does not lose its database. ### Bots, sessions, chat
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`. - **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`.
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at `AppData.BotSessionMaxMessages`, titled and summarised by `name-bot-session.ts`, summary carried into the next session). Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes.
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`. Parsers in `src/utils/*-ref.ts`.
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
- **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet.
- **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates.
- **Voice:** mic -> `api/admin/bots/transcribe.ts`; play -> `GET speak-message` streamed into `<audio>`. Nothing recorded is stored. Both disabled with a note when the harness has no model.
- **Pictures:** `image_media_id` (private media, `bot-image`/`workspace-image`), via `set-profile-image.ts`, the `set_picture` tool or `update_workspace`. Bytes are **copied**, never moved; only picture-paradigm rows are deleted on replace. Format kept by `imageOutputFormat`.
- **Colour:** only `src/utils/bot-color.ts` resolves/validates colours and stones (`resolveBotColor`, `resolveBotStone`, `botColorNeedsPaperFace`); new bots get `pick-free-color.ts`.
- **Avatars:** `BotStone` draws the stone inline with the colour as SVG `fill` and the face as eyes. Coordinator: ink body, paper eyes. **Never put a stone in a circle/badge or add outline/shadow.** The wordmark is vector (`Wordmark`), never live text.
- **Copy helpers:** `src/utils/crew-status.ts`, `src/utils/relative-time.ts` (relative timestamps only).
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, `--cpus 1` and `--memory 2048m` (a light orchestration process: the heavy work happens in each computer's own container; overridable with `--cpus`/`--memory`), a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume. ### Prompt and memory
Publishing is `scripts/publish-github.sh` and `scripts/publish-gitea.sh`: the shared flow (argument parsing, the release gate, tag preparation, the push, the release notes) lives in `scripts/lib/publish-common.sh` and `scripts/lib/publish-release.sh`, and only the release object differs (`gh release create` for GitHub, which is what fires the image build, and `curl` against the Gitea API with a `GITEA_TOKEN` or the token `tea login` stored). Both are marked-block scripts tested by `scripts/publish-tools.test.ts`, which lifts the blocks out with the `install.test.ts` approach. Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. The version they are built with is passed as the `COBBLE_VERSION` build arg (the published tag, else `package.json`'s version read from `<version>`), which the image turns into `ENV COBBLE_VERSION` and `runningVersion` (`src/utils/running-version.ts`) reports on Settings > General; bump `package.json`'s `version` with a release, since a local build and a workflow-dispatch build fall back to it. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative. `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`, via `grab-bot-prompt`): core `system_prompt`, install-wide append (`bot_system_prompt_append`), then in a workspace its context and the bot's instructions there, then core `memory`, per-user memory (`bot_user_memories`), workspace memory (`workspace_bots.memory`). Core memory is never about one user. `update_memory` defaults to `user` (`workspace` in a workspace); core only for what holds for everyone. Whole-text replace, capped at `AppData.PromptMaxLength`.
### Queue (`bots_queue`)
- Kinds (`BotJobKinds`): `reply` (chat answer, no task row) and `task` (long work, usually on a computer). bun-sqlite `isQueue` table. Helpers in `src/functions/backend/db/bots-queue/`: `enqueueBotJob`, `claimBotJobs` (kind-filtered atomic claim), `updateBotJob` (false once lost/cancelled), `completeBotJob`, `failBotJob` (backoff or fatal), `deferBotJob` (all models resting: attempt refunded, face `asleep`), `cancelBotJob`, `stop-bot-jobs`.
- Handler `src/bots-queue/index.ts`: separate reply/task slots, heartbeat, per-attempt deadline (`AppData.BotJobAttemptTimeoutMs`), one task per computer and one reply per session (`createKeyLock`). Work in `run-bot-job.ts` -> `run-bot-reply.ts` / `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix.
- Every model turn is a step in `bots_queue_updates` (tool names, never arguments; `describe-job-step.ts`, labels in `JobStepToolLabels`), pushed as `server:bot-job-update`.
- **Replies take in messages sent mid-run** (`incoming`, `IncomingMessagesNote`, redraft); separate requests are acknowledged with `send_message` (interim). A queued reply finishes silently only when a finished reply already read every user message (`hasUnansweredMessage`), never by who wrote last.
### Live updates
Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`), `pushBotUpdated` (to the bot's whole audience), `server:bot-created|removed`, `server:workspace-created|updated|removed`, `server:computer-updated`. Deletions push to the audience read **before** the delete. `liveRelay` (`src/live-relay/`) polls the DB every `AppData.LiveRelayPollMs` so work from other processes shows; clients dedupe by id. `useLiveBots` keeps rail and header current.
### Workspaces
`workspaces` (`description`, shared `system_prompt`, default `computer_id` (not an FK), `archived`), `workspace_bots` (per-bot instructions and memory there; coordinator is an implicit member), `workspace_computers`. First computer linked becomes the default. Workspace models win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` are access-checked; structural ones are crew-manager only.
### Computers
- An AIO sandbox per docker container on `AppData.DockerNetworkName`, managed over the docker socket (`src/functions/backend/docker/`). `containers` records every container except the app's own. `createComputer` + background `provisionComputer` (status `creating` until the health check passes). Resource changes recreate the container.
- Only `/home/gem/workspace` is mounted, from `DATA_DIR/computers/<id>` (with `bots/`, `workspaces/`, marker `AppData.ComputerWorkspaceMarker`); the rest of home lives in the container. Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`).
- One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers.
- A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`).
- **Folders (enforced in tools, not a Linux user):** a run works only in `bots/<bot>/users/<user>` (cwd, `HOME`), `bots/<bot>` and `workspaces/<workspace>` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused).
- **Viewing:** `/computers/<id>/` proxy (`handleComputerProxy`, strips app cookies, bridges websockets). `ComputerView` modes: browser, terminal, code, desktop (noVNC; the only one with Chrome's toolbar). **Take-over:** `ask_user_to_take_over` writes `[take-over:ID:mode]`; "Hand back" sends a chat message; tasks stop and report rather than wait.
### Schedules (routines in the UI)
`cron_jobs` per user and bot (`schedule` is a `Bun.cron` expression in UTC, `max_runs`, `next_run_at`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (ownership-checked). `nextCronRun` validates and enforces `AppData.CronJobMinIntervalMs`; max `AppData.CronJobMaxPerBot` per bot. `fireCronJob` claims a run atomically, skips while the last job is open, queues a task. A missed run runs once. Runs act on their own (`ScheduledRunNote`); `report_run_failed` retries with doubling backoff. UI: `/admin/routines` and each conversation's Schedules modal (shared `RoutineForm`/`RoutineRow`/`RoutineEditModal`).
### Tasks (the to-do tracker)
`tasks` (one collection per bot or per workspace; exactly one of `bot_id`/`workspace_id`, enforced in `taskScopeValues` since SQLite unique treats NULLs as distinct), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
### Envs ### Envs
Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer, bot) with `workspace_id`/`computer_id`/`bot_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it). A bot's or a workspace's own envs are also edited on its settings page (`settings/(partials)/envs/scoped-envs-group.tsx`, fed by `grabScopedEnvs`, `bot_envs` / `workspace_envs` page props), super admins only, which is the same table and the same `save-env`/`delete-env` routes (`scoped: true` makes them answer with just that scope, `EnvsMutationResult`, so the page updates in place). Set in Settings > Environment (super admin), not `.env`. `envs` (private; encrypted `value`; `paradigm` general|workspace|computer|bot; one per key and scope, enforced in `saveEnv`). Cached per process. App settings (`EnvDict` in `src/dict/env-dict.ts`) via `grabEnv` (general value, else `process.env`); `ProcessOnlyEnvKeys` can't be saved. Precedence for runs: bot > computer > workspace > general (`resolveScopedEnvs`). Written to `~/.cobble/env` and `env.json` on the computer each run; bots see keys and descriptions, never values; every computer tool result goes through `redactSecrets`.
- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `DB_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are the container's own start-up settings.
- **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): the bot's own beat the computer's, which beat the workspace's, which beat general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. Every computer call site passes the run's `bot_id` (`openComputerTools`, `searchComputersTool`, `runScriptTool`, through `grabBotChatTools` / `run-bot-task`), and `grabRunEnvs` (`{{env.KEY}}` in an MCP server's or custom tool's templates) layers the bot's own over the workspace's over general. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`).
- **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back), or `grabScopedEnvs` when the request came from a bot's or workspace's settings page.
### PWA and notifications ### PWA and notifications
- **Generated assets:** `public/manifest.webmanifest`, `public/icons/*.png`, `public/offline.html` and `public/loading.html` are generated from `public/logos/logo.svg` and the theme tokens by `bun run gen:favicon` (`src/scripts/generate-brand-assets.ts` reads `src/styles/main.css`, so the manifest colors and the offline page never drift from the theme). The outputs are committed like the favicons. `src/data/pwa-data.ts` holds the shared paths, icon list, start path (`/admin/bots`) and shortcuts. The apple touch icon is opaque on paper (a transparent one renders black on iOS). - Generated assets (manifest, icons, `offline.html`, `loading.html`) come from `bun run gen:favicon`, which reads `main.css`; outputs are committed. Shared paths in `src/data/pwa-data.ts`.
- **Head and install:** `src/pages/__root.tsx` `Head` links the manifest, sets the apple/mobile meta tags and renders `PwaInitScript`, which captures `beforeinstallprompt` before hydration (twui's hook reads it later), points `theme-color` at `--c-paper` (following the theme without repeating the color), and rewrites bunext's fixed viewport meta to add `viewport-fit=cover` and `interactive-widget=resizes-content`. `use-install-prompt` exposes installable/standalone/iOS state; the rail user menu shows Install app while installable, hides once installed, and opens `install-app-modal` with the Safari Share steps on iOS. - `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. It never reloads on its own. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`).
- **Service worker:** `public/sw.js`, served by `src/server.ts` through `handlePwaFile` with `no-store` and `Service-Worker-Allowed: /` (bunext's one-hour public cache would delay every deploy, including through Cloudflare). Navigations are network-only with `public/offline.html` as the fallback; authenticated HTML is never cached. `/public/`, icons and logos are stale-while-revalidate; `/api/`, `/ws`, `/media/`, `/computers/` and `/build-info.json` are never intercepted. `ServiceWorkerRegistrar` in `__root.tsx` registers it in production only (development HMR is never cached) and offers a reload when a new version waits; it never reloads on its own, so a running reply isn't cut off. In development it does the opposite (`clear-development-service-worker.ts`): it unregisters every worker and deletes every cache a production deploy left on the device, then reloads once into the uncontrolled tab, because the installed worker keeps serving its build's `/.bunext/public` files cache first while every cache-busting mechanism (the precache gate, `/build-info.json`, the loading page) is production only. Development also answers `/sw.js` with a stub that unregisters itself and drops the caches, so any other registration path (Web Push, the loading page) ends the same way. - **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-<build_id>`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this.
- **Precache (production):** the whole of `.bunext/public` is downloaded once per build before the app is used. On start the server reads the build id from `.bunext/build-info.json` and lists every file in `.bunext/public` (`grabPrecacheManifest`, logged, read once). `handlePrecacheGate` (in `src/server.ts`, before bunext) answers a page navigation whose `AppData.BuildCookieName` cookie isn't that build with the generated `public/loading.html` at the same address (`no-store`, `{{build}}` filled in; `needsPrecachePage`: `Sec-Fetch-Mode: navigate` or `Sec-Fetch-Dest: document`, since a navigation the worker passes on arrives as `empty`, never `/api/`, `/.bunext/`, `/media`, `/computers/`, `/ws` or a path with an extension). The page reads the build's files from `GET /build-info.json` (`handle-build-info.ts`, `no-store`) and downloads them into `cobble-assets-<build_id>` itself (`AppData.PrecacheConcurrency` at a time), so it works whichever worker version is in charge. It then checks every file is in the cache, downloading any missing ones again up to `AppData.PrecacheVerifyPasses` more times, before it writes the marker (`PwaPrecacheMarkerPath`). It deletes the old build's cache, tells a new worker waiting behind an older one to take over (`SKIP_WAITING`, after `reg.update()`, for up to `AppData.PrecacheWorkerWaitMs`), sets the cookie and reloads into the app. Without Cache Storage, when files are still missing after the last pass, or once nothing has landed for `AppData.PrecacheStallMs`, it sets the cookie anyway, so the app runs from the network. The worker only serves `/.bunext/public/*` cache-first from the one `cobble-assets-*` cache. `sw.js` never changes per build, and React knows nothing of any of this. The loading page is generated with the offline page (`bun run gen:favicon`). - Phone shell: rail is a slide-over below `md`; rail internals use the `@rail:` container variant, not viewport `lg:`. `LiveSocket` is remounted on online/pageshow/visibility since twui stops retrying after three attempts. Nothing is queued offline.
- **Phone shell:** below `md` the rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (`RailDrawerContext`); it is a 64px icon rail at `md` and 260px at `lg`. Rail internals use the `@container` `@rail:` variant (`--container-rail` in `src/styles/main.css`), not viewport `lg:`, so the drawer shows the full rail. `RailDrawerButton` sits in both pane headers. The shell is `h-dvh` with safe-area insets, and the headers/thread/composer use mobile padding. - **Web Push:** `push_subscriptions`, `user_presence`; VAPID keys generated into `envs` by `ensureVapidKeys`. `pushBotNotice` is the one trigger, skipped while the user is present (`AppData.PushPresenceWindowMs`, per user not per device). Best effort, no retry. Needs HTTPS.
- **Connection recovery:** twui's `useWebSocket` stops retrying after three attempts, so `LiveSocket` is remounted on `online`, `pageshow` and a hidden tab becoming visible (`use-connection-epoch`). `useOnlineStatus` drives the offline strip and disables sending: nothing is queued offline.
- **Web Push:** `push_subscriptions` (one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) and `user_presence` (one row per user, refreshed by the socket keepalive in `src/websocket/index.ts`). VAPID keys are app settings in the `envs` table (`VAPID_PUBLIC_KEY` not secret, `VAPID_PRIVATE_KEY` secret), generated by `ensureVapidKeys` on the main server's first start; the subject is `SiteData.SiteURL`. `pushBotNotice` is the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped while `user_presence.last_seen_at` is within `AppData.PushPresenceWindowMs`, so an app open anywhere wins. `src/dict/push-dict.ts` owns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API in `api/admin/push/` (subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (`notifications/`) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app.
- **Risks and notes:** push needs HTTPS (or localhost), so it depends on the proxy or tunnel in front. Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap.
### Bot files and folders ### Bot files and folders
Bots keep files on the app's data volume, never on a computer: `DATA_DIR/bots/<bot_id>` for a bot's own, `DATA_DIR/workspaces/<workspace_id>` shared by the bots working there. The catalog is `bot_files` (scope `bot`|`workspace`, `owner_id`, `path`, `size`, `purpose`, `source`, `ingested_at`, `ingest_error`), its bytes are a private `media` row (paradigm `bot-file`) written by `recordBotFile` (`src/functions/backend/db/bot-files/record-bot-file.ts`), so they're served at `/media/<media id>` through `grabBotFileMedia` and deleted with the row. - Files live on the data volume, not on computers: `DATA_DIR/bots/<id>` and `DATA_DIR/workspaces/<id>`. Catalog `bot_files`, bytes in private `media` (`bot-file`) written by `recordBotFile`. Reach via `grabStorageScopes`; paths via `resolveBotPath` (no absolute, `..`, NUL, symlink escape); quota checked by `assertStorageRoom`.
- Tools in `src/harness/tools/storage/` (every run). `attach` puts a file on the reply. `grabConversationFiles` must list the **whole** folder tree, or nested `[file:ID]` refs render dead.
Who reaches what: `grabStorageScopes` (`src/utils/storage-access.ts`) gives a worker its own folder plus the workspace's while it works there, and the coordinator every bot's and every workspace's. Enforced in the tools, never left to the prompt. `resolveBotPath` (`src/utils/resolve-bot-path.ts`) keeps every path inside its root (no absolute paths, `..`, NUL, symlink escapes) and the quota (`app_settings` key `bot_files_quota_mb`, set on Settings > General, `grabBotFileQuotaBytes`) is checked by `assertStorageRoom` before a write. - `import_file` copies from a computer through the sandbox's streaming `GET /v1/file/download`, **never through its shell** (`/v1/shell/exec` caps output at ~30K chars and silently truncated imports). Size read first, `change_policy=abort`, length checked.
- Ingestion: chunks + embeddings into `bot_file_chunks` / `bot_file_vectors` (vec0), worked by `src/file-ingest/`; without an embedding model search falls back to words and says so. sqlite-vec limits: metadata columns are NOT NULL; side-car columns can't be filtered with `MATCH` (filter in code). **Never pass `--vector` to `bunx bun-sqlite schema` in `entrypoint.sh`** (wipes vectors on boot). Changing `AppData.EmbeddingDimensions` needs a manual `--vector` run and a re-ingest.
Tools in `src/harness/tools/storage/` (`grabBotFileTools`, every run: chat, tasks, routines): `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file` (only when the run reaches more than one folder), `generate_image` and `make_pdf`. A file written with `attach` rides on the reply: the outcome's `attachments` become `bot_message_media` rows on the result (`completeBotJob`). Bots name files as `[file:<id>]` (`src/utils/file-ref.ts`, linked by `linkComputerRefs`, rendered by `file-ref-chip.tsx`). A chip looks the file up in the conversation's page props (`grabConversationFiles`, which must list the **whole** folder tree, not just the top level, or every file saved under `reports/` or `seo/...` renders as dead plain text) and fetches it by id (`api/admin/bots/files?file_id=`) when it is older than the page's first page. Chat uploads are copied into the conversation's folder (`storeChatAttachments`) so a later run can search them. - **Foreign keys are off per connection** unless `enableForeignKeys` ran (only `src/server.ts` does). Scripts never fire `ON DELETE CASCADE`, so **any helper that deletes a parent row must delete its children explicitly** (as `deleteBotFile`, `purgeStorage`, `deleteSkill` do). A leftover child fails the next `bun run db:schema` with a foreign key violation.
A task on a computer also gets `import_file` (`src/harness/tools/computer/import-file-tool.ts`): it copies a file or a folder off the computer into the run's files (a folder as a gzipped tar, unpacked into one `bot_files` row per file, source `tool`), ingests it at once, and answers with the `[file:ID]` refs plus the original's path. The bytes come back through `downloadComputerFile` (`download-computer-file.ts`), the sandbox's streaming `GET /v1/file/download` - **never through its shell**: `/v1/shell/exec` caps output at 30,047 characters, so `base64 -w0` silently cut every import at 22,528 bytes and a 1.12 MiB archive was saved as a 22 KB file that would not open, with the tool reporting success. The size is read first (nothing over `AppData.BotFileImportMaxBytes` is transferred), the download asks for `change_policy=abort` (409 if the file changes while it is sent) and the length is checked against the size read, so a cut-short transfer is reported instead of saved. `BotFilesNote` and `ComputerFilesNote` (`src/data/harness-prompts.ts`) tell the bot to save the deliverable before the long tail of a run, so a step limit can't cost the user the report.
Anything a bot leaves on a computer is not in `bot_files`, but the user can still open it: bots name a folder as `[folder:<volume>:<owner>:<path>]` (`src/utils/folder-ref.ts`, e.g. `[folder:computer:3:seo/coderank/raw]`), linked by the same `linkComputerRefs` and rendered by `folder-ref-chip.tsx`, which opens `/admin/files` on that folder and downloads nothing. A `[file:<id>]` ref (`src/utils/file-ref.ts`) is always a saved file, whose chip opens `/media/<id>`; the two are never guessed from a name's extension.
Ingestion (`bot_file_chunks`, `bot_file_vectors` a vec0 table, `file_ingest_queue`): `ingestBotFile` extracts text (`extractBotFileText`, reusing `fileToText`; images and audio only on request), chunks it (`chunkText`), embeds it through the `embedding` harness and fits every vector to the fixed `AppData.EmbeddingDimensions` (`fitVectorDimensions`, pad or cut then normalize). The `file-ingest` worker (`src/file-ingest/`, started from `server.ts` next to the bots queue) works the queue; `ingest-pending-files` sweeps up anything stranded. With no embedding model the chunks are still stored and `searchBotFiles` falls back to word matching, always saying which it did.
Two sqlite-vec limits the code works around: a vec0 table's metadata columns are NOT NULL (a raw insert fills `created_at`/`updated_at` itself), and a side-car column can't be filtered in the same query as `MATCH` (the search pulls the nearest `k` and filters scopes in code). Never pass `--vector` to `bunx bun-sqlite schema` in `entrypoint.sh`: it would wipe every vector on each boot. Changing `AppData.EmbeddingDimensions` needs a manual `--vector` run plus a re-ingest.
A third limit is SQLite's, and it bit this code: foreign keys are **off per connection** unless `enableForeignKeys` (`src/functions/backend/db/enable-foreign-keys.ts`) ran, and only `src/server.ts` runs it. A script or CLI process that deletes rows therefore never fires the schema's `ON DELETE CASCADE`, so `deleteBotFile` and `purgeStorage` delete `bot_file_chunks` themselves instead of leaving it to the cascade. A chunk left behind fails the next `bun run db:schema` with `Foreign key violations after schema sync: bot_file_chunks -> bot_files` (bun-sqlite runs `PRAGMA foreign_key_check` after a sync). Any new helper that deletes a parent row must delete its children explicitly for the same reason.
A bot's or workspace's folder is removed with it (`purgeStorage`, called from `deleteBot` and `deleteWorkspace`, which read the media ids first).
### Files browser (`/admin/files`) ### Files browser (`/admin/files`)
Every folder on the data volume, browsable read-only: a bot's own, a workspace's shared one, a computer's home (`DATA_DIR/computers/<id>`, where everything a bot left in `~/…` actually is), the media library, an MCP server's install folder and the database. A `RailPages` entry beside Routines, Activity and Help. Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspaces, computers, media, mcp, db). `resolveStorageSpec` is the only access gate (members see only their bots' and their own user folders inside a computer, `computerPathAllowed`); `resolveStoragePath` applies the path rules. Links built only by `storageSpecHref`. Deletion stays in the chat's files modal and bot tools.
- **Volumes:** `src/utils/storage-volumes.ts` (client-safe): `StorageVolumes` (`bots`, `workspaces`, `computers`, `media`, `mcp`, `db`) with each one's ref name (`bot`, `computer`, …), `parseStorageSpec({ volume, owner_id })` (null for anything unknown, so a bad id never becomes a path), and `storageSpecHref({ spec, path })` → `/admin/files?volume=<v>&owner=<id>&path=<p>`, the one place a link is built. ### Harness (the model layer, `src/harness/`)
- **Access:** `resolveStorageSpec` (`src/functions/backend/storage/resolve-storage-spec.ts`) is the only gate, called before any path exists: a bot through `grabUserBot`, a workspace through `grabUserWorkspace`, a computer through `grabUserComputer` (so a member sees only their grants), `media` only their own (super admins and admins anyone's), `mcp` for crew managers, `db` for the super admin alone. `grabStorageSpecs` lists the same folders for the picker, with `reachableBotsSql` (`src/utils/crew-access/reachable-bots-sql.ts`, tested beside the other crew SQL) so the picker never offers one the resolver refuses.
- **Paths:** `resolveStoragePath` (`src/functions/backend/storage/resolve-storage-path.ts`) applies the `resolveBotPath` rules (no absolute paths, `..`, NUL, too deep, symlink escape) to any volume root.
- **Reading:** `listStorageEntries` (folders first, `AppData.StorageBrowserPageSize`, a missing folder shows empty, symlinks are skipped, `bot_files` rows joined by `(scope, owner_id, path)` so those files keep opening through `/media/<id>`) and `grabStorageFile` (a regular file under `AppData.StorageBrowserReadMaxBytes`). API in `src/pages/api/admin/storage/` (`list`, `file`, the latter with an ETag like `handleMediaServer`), zod in `src/schemas/storage-schema.ts`.
- **Page:** `src/pages/admin/files/index.server.ts` + `index.tsx` (in `SettingsShell`, `wide`), route-local code: `(sections)/files-browser.tsx` and `(sections)/files-pane.tsx` (state, `history.pushState`, so a folder link is shareable and the back button walks back up), `(partials)/files-volume-row.tsx`, `files-breadcrumb-row.tsx`, `files-search-input.tsx`, `storage-entry-row.tsx`, `(hooks)/use-storage-folder.ts` (the page's own copy first, the API on navigation). Read-only: deletion stays in the chat's files modal and the bot's tools.
- **Chips:** `[folder:…]` refs open it, and the bot files modal links into it ("Browse this folder in Files").
### Harness (the model layer) - **Harnesses** (`src/dict/harness-dict.ts`): `text` (always on), `vision`, `transcription`, `speech`, `image`, `embedding`. One `harness_settings` row each (provider, pinned model or automatic, `fallback`, `enabled`, `options`). Transcription, speech and embedding have no automatic picks: unavailable until a model is picked.
- **Providers** (`src/dict/harness-provider-dict.ts`): OpenRouter default plus OpenAI-compatible ones; one adapter today (`src/harness/providers/openai-compatible/`). Keys encrypted in private `harness_providers`, falling back to the preset env.
- **Catalog:** `harness_models`, synced hourly. OpenRouter "free" means `:free` or `openrouter/free`, never zero prices. Auto picks: `rank-auto-models`.
- **Calling:** `resolveHarnessCandidates` -> `withCandidates` with process-wide cooldowns; errors are `HarnessError`. Entry points under `text/`, `media/`, `audio/`, `image/`, `embeddings/`. `run-text-loop.ts` sticks to one model, caps steps (`grabHarnessMaxToolSteps`, `grabReplyMaxToolSteps`), trims old tool output to fit context. Models that can't take an image/audio part get it read by vision/transcription first.
- **Per-bot models:** workspace row > bot row > default; the coordinator always uses the defaults. Every entry point takes `bot_id` and passes it on. Shared form `settings/models/(partials)/harness-settings-form.tsx`.
- **Tools:** `HarnessTool` built per run; own tools validate with zod. `run-tool-call.ts` reports errors back to the model and caps each call (`AppData.HarnessToolTimeoutMs`). Bot tools in `src/harness/tools/bot/` (memory, instructions, tasks, schedules, `start_task`, coordinator's crew management and `delegate_task` with results relayed back by `relayJobToRequester`).
- **Extensions:** MCP servers (`mcp_servers`, remote or stdio), custom tools (`custom_tools`: http, script, prompt) and skills (`skills`, runbooks loaded with `load_skill`). Given per bot / per workspace (`botExtensionsSql`); `all_bots` only decides what new bots get. Templates `{{args.x}}`/`{{env.KEY}}` via `fillTemplate`; secrets redacted from results. stdio servers run in a per-process pool. Skill body limits are app settings (`grabSkillBodyMaxChars`, `grabSkillBodyShownChars`).
- **Bots never add or repoint extensions or set envs.** The coordinator may only change what can't alter where data is sent (name, description, on/off, access). New skills, servers, tools, or changes to a URL, headers, kind, config or a skill's runbook go through `propose_*` -> `extension_proposals` -> `[proposal:ID]` card, which the app describes from the config itself (never from what the bot says) and the super admin confirms (`checkProposalData` re-checks). Stdio servers are never proposed by bots (they run in the app container, which holds the docker socket).
- **Check before and after acting** (`ActionCheckNote`): look for an existing record before creating one, verify every action took effect. Tools enforce it (read-back on memory saves, duplicate checks on bots, schedules, tasks, workspaces).
`src/harness/` is the brain: every model call goes through it. Install-wide, managed by super admins at `/admin/settings/models`. ### Token usage
- **Harnesses** (`Harnesses` in `src/dict/harness-dict.ts`): `text` (chats, plans, calls tools, drives computers; always on), `vision` ("Media reading": images/scans/screenshots for a text model that can't see), `transcription` (speech to text: speech-to-text models only, never chat models that hear audio), `speech` (text to speech: text-to-speech models only), `image` (image generation), `embedding` (turns bot file chunks into vectors for `search_files`: embedding models only, never auto-picked, so it stays unavailable until a super admin picks one, and search falls back to words without it). Each has one `harness_settings` row (no row = automatic, on, with fallback): a `provider_id` (a `harness_providers` row the harness is limited to, for its pick and its automatic picks; null for any provider; with no row, the default provider, OpenRouter, `grabDefaultHarnessProviderId`), a pinned `model_id` (a `harness_models` row from that provider, not a foreign key) or null for automatic, `fallback` (also try the automatic picks), `enabled`, `options` (`voice`). - Every model call writes a `token_usages` row via `record-harness-usage.ts` (never throws) in `withCandidates`'s `on_success`, with the provider/model that actually answered and the call's settings (`HarnessCallSettings`).
- **Providers** (`HarnessProviderKinds` in `src/dict/harness-provider-dict.ts`): OpenRouter (default, created by `ensureDefaultHarnessProvider`), OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama (local, its models count as free), any OpenAI-compatible server. Rows in `harness_providers` (a private table: the key is encrypted with `src/utils/crypt.ts`, never sent to the client; falls back to the preset's env, e.g. `OPENROUTER_API_KEY`, via `grabEnv`). One adapter today, `src/harness/providers/openai-compatible/` (`chat`, `transcribe`, `speak`, `generateImage`, `embed` (`POST /embeddings`, batched), `listModels`), chosen in `grab-harness-adapter.ts`; a provider needing a native API gets its own adapter there (`HarnessAdapter` in `src/harness/types.ts`). - Attribution through an `AsyncLocalStorage` scope (`src/harness/usage/harness-usage-scope.ts`) entered once in `run-bot-job.ts`; merged, not replaced; `null` clears a field. `withUsageScope` / `runWithFallbackScope` for calls outside a run. No owner, no row.
- **Catalog:** `harness_models` per provider (modalities, tools support, voices, Artificial Analysis scores, `free`, `available`). The `sync-harness-models` cron (hourly, on start) refreshes every enabled provider (`syncAllHarnessModels`); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (`guess-model-modalities`). Free on OpenRouter means `:free` or `openrouter/free`, never zero token prices. - `complete-bot-job.ts` back-fills `message_id` for the job's calls. Messages show `MessageUsageChip` and a details modal (`grabMessageDetails`).
- **Automatic picks** (`rank-auto-models`): only for the `automatic` harnesses in `Harnesses` (text, vision, image); transcription and speech have none and are not available until a model is picked (the model select reads "Not available", no fallback checkbox). Free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. - `/admin/usage` (crew managers). **Days are local, not UTC:** group with `date(created_at / 1000, 'unixepoch', 'localtime')`; never name a local day with `toISOString().slice(0, 10)` (`bucket-usage-days.test.ts` pins this). Bars use static `h-*` classes from `(data)/usage-bar-heights.ts`.
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`, `embeddings/embed-text.ts` (batches, then `fit-vector-dimensions.ts` pads or cuts each vector to `AppData.EmbeddingDimensions` and normalizes it, since a provider's width varies). A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`.
- **Skills** are the third extension kind (`skills`, Settings, under Skills; `bot_skills` / `workspace_skills`, same `all_bots` rules, `ExtensionTables.kind = "skill"`). A skill is a runbook: `name`, a unique `slug` a bot loads it by, a one-line `description` (when it applies) and the markdown `body`, capped by `AppData.SkillBodyMaxChars`. Unlike an MCP server or a tool it has no `status`/`last_error`: nothing connects or runs, and it never reports a failure (`ExtensionRow` and `ComposerToolRow` key the state line off `kind == "skill"`). A run sees only the slug and description; `openBotSkills` (`src/harness/tools/skills/`, fed by the `skills` that `grabBotExtensions` already reads) adds one `load_skill` tool that returns the body, cut at `AppData.SkillBodyShownChars` with a line saying so, and throws the valid slugs for an unknown one. Slugs are checked by `checkSkillSlug` (`src/utils/check-skill-slug.ts`, tested); `load_skill` is in `ReservedToolNames` so a custom tool can't shadow it. The settings page is `api/admin/skills/` (`save-skill`, `delete-skill` super admin only, `set-skill-access`), `grabSkillsOverview`; `deleteSkill` deletes the two link tables itself, since foreign keys are off outside the server process. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (the bot's own beat the workspace's, which beat general; app settings never, no need to be set on computers); secret values are redacted from every result.
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
- **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use.
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools, the file tools and the job's computer tools, closing the MCP connection after. Both add `BotFilesNote` (where a bot's files live, that they persist, and to search before saying it doesn't know) and collect `attachments` for files the bot wrote with `attach`, which `completeBotJob` links to the result message. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
## Admin Route Hierarchy ### Installer and release
Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspaces` (the index opens the first workspace), `/admin/routines`, `/admin/logs`, `/admin/help` and `/admin/settings`. `/admin` redirects to `/admin/bots`; a workspace's dashboard is `/admin/workspaces/<id>`. The routes in detail: - `install.sh` (`curl -fsSL https://url.moduletrace.com/cobble | sudo bash`) is the only install and update path; idempotent; `--uninstall [--purge]`. Never regenerates secrets; backs up the DB before replacing the container. Everything persistent lives in `$COBBLE_HOME/data` (default `/opt/cobble`).
- **Mount specs:** `source:destination[:options]`, options (`z`, `ro`) comma-separated in the **third** field. Never append an option to the destination (`$DATA_DIR:/data,z` mounts a directory literally named `/data,z` and loses all data on update). `install.test.ts` runs the marked `selinux-detection` and `mount-specs` blocks. `/data` must stay a bind mount (`resolveHostPath` maps it to host paths for computers).
- Plain HTTP only; TLS is the proxy's job. Container: `--network cobble`, `.env` read-only at `/app/.env`, docker socket, `MAIN_SERVER=1`, `--init`, `--cpus 1 --memory 2048m` by default.
- Publishing: `scripts/publish-github.sh` / `publish-gitea.sh` sharing `scripts/lib/publish-*.sh` (tested by `scripts/publish-tools.test.ts`). A GitHub release triggers `.github/workflows/release.yml` (amd64 + arm64 to `ghcr.io/moduletrace/cobble`); `mirror-gitea.sh` copies to `git.tben.me/moduletrace/cobble` (CI can't reach the VPN). **Bump `package.json`'s `version` with a release.** CI (`ci.yml`) runs typecheck and tests. Dockerfile pins Bun 1.4.3.
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/` ## Admin routes
- `/admin/routines` (live) - every schedule the user's bots have (see Schedules), opened from "Routines" in the rail's user popover
- `/admin/logs` (live) - Activity, the audit log: super admins only (others are redirected; the popover's "Activity" item shows to super admins only). Rendered in `SettingsShell` with `wide`
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
- Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
- `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). `AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout.
## Page Directory Rules - **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside.
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `profile`, `notifications`, `change-password`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills` (crew managers), `users` (`canListUsers`).
- Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`).
- **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process.
- **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`.
- Add flow (`(partials)/add-flow.tsx`) from the rail's Add icon: bot, workspace, computer (super admin). Page heroes use `AdminHero`.
For route-local code under `src/pages`, use non-routed meta folders: ## Page directory rules
- `(sections)` - page sections Route-local code under `src/pages` goes in non-routed meta folders: `(sections)`, `(partials)`, `(data)`, `(functions)`, `(utils)`, `(hooks)`. Keep route files (`index.tsx`) thin. Dynamic routes `[param]`, catch-all `[[...paths]]`. Server functions in `index.server.ts` beside `index.tsx`.
- `(partials)` - components used by sections
- `(data)` - page-local data
- `(functions)` - page-local functions
- `(utils)` - page-local utilities
- `(hooks)` - page-local hooks
Keep route files (e.g. `index.tsx`) thin - compose from route-local meta folders. ## Shared code
Dynamic routes use `[param]` syntax (e.g. `[id]`, `[user_type]`, `[key]`). Catch-all routes use `[[...paths]]`. - `src/hooks/` shared hooks; `src/functions/backend/` server-only (auth, db, email, logger); `src/functions/frontend/` client-safe handlers; `src/functions/server/` server utilities.
- `src/utils/` utilities (most with tests), `src/utils/rbac/` the RBAC engine.
- `src/dict/` dictionaries/constants; `src/data/` static data (`site-data.ts`, `app-data.ts` with every `AppData.*` constant); `src/types/`; `src/scripts/` CLI scripts; `src/schemas/` zod schemas.
- `src/components/general/`: shared components built on twui (`logo`, `logo-icon`, `wordmark`, `bot-stone`, `user-avatar`, `code-editor`, `computer-view`, `admin-hero`, `login-shell`, ...).
Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-document/[id]/index.server.ts`). ## UI and styling rules (MANDATORY)
## Shared Code Organization - **Build UI with twui and `src/components/general/`.** Don't hand-roll HTML for layout, typography or controls. Layout: `Section`, `Container`, `Stack`, `Row`, `Center`, `Span`, `P`, `H1`-`H5`, `Button`, `Divider`, `Img`, `Link`, `ArrowedLink`, `Main`, `Header`, `Footer`, `Spacer`. Elements: `Card`, `Paper`, `Tag`, `HeaderNav`, `LinkList`, `Tabs`, `Table`, `Breadcrumbs`, `Modal`, `Dropdown`, `CheckBulletPoints`, `EmptyContent`, `Loading`, `LoadingBlock`, `Toggle`, `Toast`. Forms: `Form`, `Input`, `Select`, `Textarea`, `Checkbox`, `Radios`, `SearchSelect`, `FileUpload`, `ImageUpload`.
- **UNCHANGEABLE: twui components always win over raw JSX elements** (`<h1>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, ...). Never introduce one where a twui equivalent exists; override via `className`.
- **`src/hooks/`** - Shared React hooks: `use-admin-crud-get`, `use-fetch-get`, `use-form-init`, `use-local-user`, `use-users-get`, `use-server-event`, `use-live-bots` - **No new CSS classes, CSS files or inline `style={{...}}`.** Style only with component props, existing `twui-*` classes, semantic token utilities (`bg-paper`, `text-ink`, `border-hairline`, ...) and utilities already used in the codebase. Extend component code only when the API can't express the need.
- **`src/functions/`** - Shared functions split by target: - **Chunk classes with `twMerge`**, one chunk per claim: base first, one string per conditional, caller override last. Never concatenate conditional classes with template literals or `+`, never one long mixed string: `className={twMerge("w-full flex-col gap-0! rounded-default py-1", selected && "bg-ink/5")}`.
- `src/functions/backend/` - Server-only: auth (`backend/auth/`), DB queries (`backend/db/` incl. `bots/`), email client + templates (`email/`), media upload, `logger.ts` (audit logging) - **Theming:** colours, radii and fonts change only in `src/styles/main.css` (`:root`/`.dark` vars exposed as `paper`, `surface`, `sunken`, `ink`, `ink-secondary`, `ink-disabled`, `hairline`, `hairline-strong`, `on-ink`, `hue-<name>`, `shadow-popover`, `--m-fast/base/slow`). Never hardcode hex colours, radii or font stacks. Hues are bot identity only, never state.
- `src/functions/frontend/` - Client-safe: CRUD handlers, fetch handlers, media upload/delete handlers - **The one exception:** a bot's user-chosen colour reaches `BotStone` and `UnreadDot` as an SVG `fill` attribute. Nothing else may take a colour that way, and `src/utils/bot-color.ts` is the only place a colour string is validated.
- `src/functions/server/` - Server utilities (media server handler etc.) - **Screenshots:** `docs/readme/{hero,workspace}-{light,dark}.png` are real screenshots (1440px at 2x, downscaled to 1600px). Retake them from a seeded dev instance when the UI changes, same names. Never use handoff screens.
- **`src/utils/`** - Utility modules: cookies, crypt, ejson, money, safe-compare, text-to-html, and more. Most have corresponding `.test.ts` files. - **Logo:** `public/logos/logo.svg` is the single source; after changing it run `bun run gen:favicon`.
- **`src/utils/rbac/`** - RBAC engine: `user-type-assignment` (matrix interpreter), `is-pass-through-user`, `can-*` permission helpers, `filter-assignable-user-types` - with `bun:test` suites driven by fixture matrices - **Site name:** interpolate `SiteData["SiteName"]` in titles, emails and copy; never hardcode the brand.
- **`src/dict/`** - Dictionaries/constants: main links (`main-links-dict.ts`), user types + assignment matrices, media types, document statuses (`document-dict.ts`), websocket rooms, log categories, company data
- **`src/data/`** - Static data: `site-data.ts` (site name/slug - the only place brand literals live), `app-data.ts` (app constants - cookie names, limits, defaults, Paystack endpoint)
- **`src/types/`** - Type definitions: `index.ts` (incl. WebSocket event/room types), `sql-joins.ts`, `paystack.ts`, `search-api.ts`
- **`src/scripts/`** - CLI scripts: `seed-super-admin.tsx` (idempotent super admin seed) + `generate-brand-assets.ts` (favicon/brand asset generator)
- **`llm-code-assistant/`** - `query-db.ts` (read-only SQL CLI for coding assistants)
## Component Systems
| Directory | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@moduletrace/twui` | **The** design system - external package. Contains layout primitives, elements, editors (TinyMCE), form components, hooks, SVGs, and utilities. Import from `@moduletrace/twui` or a deep path such as `@moduletrace/twui/layout/Button` |
| `src/components/general/` | Shared page components built on twui: `logo`, `logo-icon`, `wordmark`, `bot-stone` (+ `bot-stone-face`, `stone-eye-*`), `user-avatar` (round photo or initials, `sm|md|lg`), `code-editor` (twui `AceEditor` for commands and code, loading the ACE scripts on demand via `loadAceScripts`), `icon-tile`, `computer-view` (+ `computer-view-frame`, `computer-view-mode-button`), `inner-page-hero-section`, `main-hero-section`, `admin-hero`, `login-shell`, `tagline-section`, media/image helpers |
## UI & Styling Rules (MANDATORY)
- **Always build UI with the twui component library and the shared `src/components/general/` components.** Do not hand-roll HTML for layout, typography, or controls.
- Layout: `Section`, `Container`, `Stack`, `Row`, `Center`, `Span`, `P`, `H1`-`H5`, `Button`, `Divider`, `Img`, `Link`, `ArrowedLink`, `Main`, `Header`, `Footer`, `Spacer`
- Elements: `Card`, `Paper`, `Tag`, `HeaderNav` (+ `MainLinks` dict), `LinkList`, `Tabs`, `Table`, `Breadcrumbs`, `Modal`, `Dropdown`, `CheckBulletPoints`, `EmptyContent`, `Loading`, `LoadingBlock`, `Toggle`, `Toast`
- Forms: `Form`, `Input`, `Select`, `Textarea`, `Checkbox`, `Radios`, `SearchSelect`, `FileUpload`, `ImageUpload`
- Shared: `Logo`, `LogoIcon`, `InnerPageHeroSection`, `AdminHero`, `LoginShell` (auth pages/forms), `TaglineSection`
- **UNCHANGEABLE RULE - twui components always win over raw JSX elements.** Use twui components (`H1`-`H5`, `P`, `Span`, `List`, `Img`, `Row`, `Stack`, `Container`, `Section`, `Card`, `Button`, `Tag`, `Link`, etc.) before basic JSX elements (`<h1>`-`<h6>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults via `className` rather than reaching for the raw element.
- **Never create new CSS classes, CSS files, or inline `style={{...}}` hacks.** Style exclusively by composing existing component props/classNames, existing twui classNames (`twui-*`), semantic theme-token utilities (`bg-dark`, `bg-light`, `text-dark`, `text-primary`, `bg-primary`, `text-accent`, `bg-accent`, `text-secondary`, `border-slate-200`, etc.), and utility classes already used elsewhere in this codebase. Build up from what exists; extend component code only when the existing API cannot express the need.
- **Theming:** colors, radii, and fonts change only in `src/styles/main.css`: Cobble tokens as CSS vars on `:root` (light) and `.dark`, exposed in the `@theme inline` block as `paper`, `surface`, `sunken`, `ink`, `ink-secondary`, `ink-disabled`, `hairline`, `hairline-strong`, `on-ink`, `hue-<name>`, plus `shadow-popover` and `--m-fast/base/slow` motion vars (zeroed under reduced motion). Font is Lato (stand-in). Never hardcode hex colors, radii, or font stacks inside components. Hues are bot identity only, never state, and the `--color-hue-*` tokens remain for the fixed palettes that use them (the files browser's volume icons, `BotHues`' own hexes).
- **The one exception:** a bot's colour is user data, not a theme value, so it reaches `BotStone` (`src/components/general/bot-stone.tsx`) and `UnreadDot` as an SVG `fill` attribute - an arbitrary hex has no token to point at. Nothing else in the app may take a colour that way, and `resolveBotColor`/`normalizeBotColor` (`src/utils/bot-color.ts`) are the only place a colour string is validated.
- **Branding:** `docs/readme/hero-light.png`, `hero-dark.png`, `workspace-light.png` and `workspace-dark.png` are screenshots of the current app (chat and workspace dashboard, 1440px at 2x, downscaled to 1600px wide), committed and picked per theme by GitHub's `#gh-light-mode-only` / `#gh-dark-mode-only` fragment. They are not generated: when the UI changes, retake them from a seeded dev instance (`bun run seed`, `bun run seed:bots`) and keep the same names, so the README never shows an older dashboard. Never use `assets/handoff/assets/screens/*.png` for anything: that design is outdated. `public/logos/logo.svg` is the single source of truth for the logo mark; `logo-icon.tsx` renders it inline (via `BotStone`) and `logo.tsx` renders the stone beside the vector `Wordmark`. Whenever the logo changes, run `bun run gen:favicon` to regenerate `public/favicon.ico`, `public/favicon.png`, `public/apple-touch-icon.png`, and `public/logos/logo.png` (the last is used in emails). Font + favicon links live in `__root.tsx` `Head()`.
- **Site name:** brand literals live only in `src/data/site-data.ts`. Page meta titles, emails, and UI copy must interpolate `SiteData["SiteName"]` - never hardcode a brand name in page files.
## Conventions ## Conventions
- Prefer one function/component per file over monolithic files with multiple exports. - One function/component per file.
- **Modularization is non-negotiable: prioritize modularization over monolithic functions/components.** Repeated UI blocks must be extracted into their own reusable components driven by props (e.g. an info row component taking `{ icon, text }`), never repeated inline markup. - **Modularization is non-negotiable.** Repeated UI blocks become reusable prop-driven components.
- **Every rendered array must have a separate component for each array item.** Always extract the item JSX into its own component and render it via `items.map((item) => <ItemComponent {...item} />)` - never inline the item's JSX inside the map callback. - **Every rendered array item is its own component:** `items.map((item) => <Item {...item} />)`, never inline JSX in the map.
- **No em dashes (U+2014) anywhere** in the repo: code, comments, docs, markdown, or user-facing strings. Use a regular hyphen (`-`) or restructure the sentence instead. - **No em dashes (U+2014) anywhere** (code, comments, docs, copy). Use `-` or restructure.
- Always use object parameters (`{ foo, bar }`) instead of comma-separated positional parameters. - Always object parameters (`{ foo, bar }`), never positional.
- Prefer existing patterns over new abstractions. - Prefer existing patterns over new abstractions.
- **Do not patch `@moduletrace/twui`** - it is a general, publishable library shared across projects. Do not edit the installed package in `node_modules`; compose or override with `src/components/general/` components and `src/styles/main.css` instead. - **Do not patch `@moduletrace/twui`** (shared, publishable). Compose in `src/components/general/` or `main.css`.
- `.env` is gitignored but exists locally with secrets. Do not commit it. It is also excluded by `.dockerignore`; production passes it with `docker run --env-file`. - `.env` (secrets, gitignored, dockerignored) and `secrets/` are never committed. `external/` binary dirs are gitignored.
- **Rate limiting:** `src/utils/rate-limit.ts` (in-memory fixed window, per process). Unauthenticated endpoints that send email or check secrets (`password-login`, `signup`, `contact`) must be rate limited. - **Rate limit** unauthenticated endpoints that check secrets or send email (`src/utils/rate-limit.ts`).
- **Validation:** validate untrusted request bodies with the zod schemas in `src/schemas/` via `parseWithSchema`. Throw `AppError` for messages that are safe to show; catch blocks return `safeErrorMessage` so internal errors never reach the client. - **Validate** untrusted bodies with `src/schemas/` via `parseWithSchema`. Throw `AppError` for safe messages; catch blocks return `safeErrorMessage`.
- **HTML columns:** any `html: true` column that accepts user input must be sanitized with `sanitizeRichTextHtml` (or `sanitizeUserBio` for `users.bio`) before it is stored or rendered with `dangerouslySetInnerHTML`. - **HTML columns** accepting user input: sanitize with `sanitizeRichTextHtml` (or `sanitizeUserBio`) before storing or `dangerouslySetInnerHTML`.
- **Outgoing email outside production** goes through `resolveEmailRecipient` (`src/functions/backend/email/resolve-email-recipient.ts`) and is redirected to `DEV_EMAIL_RECIPIENT`. Never hardcode recipient addresses. - **Email outside production** goes through `resolveEmailRecipient` (to `DEV_EMAIL_RECIPIENT`). Never hardcode recipients.
- **Never trust ids from the request body for ownership or sessions.** Use the session `user.id`, and gate acting on other users with the `src/utils/rbac/` helpers (`canUpdateUser` etc.). - **Never trust ids from the request body for ownership or sessions.** Use the session `user.id` and the `src/utils/rbac/` helpers.
- `secrets/` directory is also gitignored. - Any helper deleting a parent row deletes its children explicitly (foreign keys are off outside the server process).
- `external/` binary directories (`**/models`, `**/bin`, `**/x-86`, `**/arm-64`) are gitignored. - **Keep this file current, and short:** when you change something it describes, update the line; add new rules and gotchas, not inventories of helper names.
+16 -9
View File
@@ -126,6 +126,10 @@ point that at `http://<this machine>:7227`:
sign-in cookies `Secure`; Caddy does this by default, and nginx needs sign-in cookies `Secure`; Caddy does this by default, and nginx needs
`proxy_set_header X-Forwarded-Proto $scheme;`. `proxy_set_header X-Forwarded-Proto $scheme;`.
- Pass `/ws` through as well as ordinary requests, including the upgrade. - 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 - Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
the installable app, offline support, Web Push notifications and voice the installable app, offline support, Web Push notifications and voice
recording stay off until the address is HTTPS. recording stay off until the address is HTTPS.
@@ -245,15 +249,16 @@ queue and Web Push.
## Images ## 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` The same image is copied into `git.tben.me/moduletrace/cobble` by
- `git.tben.me/moduletrace/cobble` `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).
Tags are `latest`, the release version, `major.minor` and the commit sha. Install from Gitea with `--image git.tben.me/moduletrace/cobble`, or build
Install from Gitea with from source with `docker build -t cobble:local .` and
`--image git.tben.me/moduletrace/cobble`, or build from source with `--image cobble --version local --no-pull`.
`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 A release is published from a clean `main` with the two scripts, which tag the
`package.json` version, push and create the release objects: `package.json` version, push and create the release objects:
@@ -264,7 +269,9 @@ scripts/publish-gitea.sh # or: bun run publish:gitea
``` ```
Both take `--version VERSION`, `--dry-run` and `--yes`. Only the GitHub release 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 ## Development
+14 -6
View File
@@ -62,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 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 7. [ ] The release workflow builds `linux/amd64` and `linux/arm64` and
pushes to both registries: pushes to `ghcr.io/moduletrace/cobble`, the registry the installer
- `ghcr.io/moduletrace/cobble` defaults to. It does not touch Gitea: that registry is on the VPN,
- `git.tben.me/moduletrace/cobble` and a GitHub runner cannot reach it.
8. [ ] Check the manifests: 8. [ ] Check the manifests:
docker buildx imagetools inspect ghcr.io/moduletrace/cobble:latest 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. 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 scripts/publish-gitea.sh # or: bun run publish:gitea
@@ -79,7 +87,7 @@ them in that order, since only the GitHub release builds images.
git push gitea main git push gitea main
git push gitea refs/tags/v0.1.0 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 curl -fsSL https://url.moduletrace.com/cobble | sudo bash
+2 -2
View File
@@ -9,7 +9,7 @@
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3", "@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.6", "@moduletrace/twui": "^0.1.7",
"@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0", "@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "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/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=="], "@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=="],
+293 -1
View File
@@ -1,4 +1,5 @@
import { LogCategories } from "@/src/dict/log-categories"; import { LogCategories } from "@/src/dict/log-categories";
import { BotTypes } from "@/src/dict/bot-types-dict";
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict"; import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
import { import {
BotFaces, BotFaces,
@@ -230,7 +231,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
{ {
fieldName: "kind", fieldName: "kind",
fieldDescription: `One coordinator per owner; everything else is a worker`, fieldDescription: `Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from \`bot_types\``,
dataType: "TEXT", dataType: "TEXT",
options: BotKinds.map((bk) => bk.value), options: BotKinds.map((bk) => bk.value),
defaultValue: "worker", defaultValue: "worker",
@@ -439,6 +440,13 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
options: [0, 1], options: [0, 1],
defaultValue: 0, defaultValue: 0,
}, },
{
fieldName: "interim",
fieldDescription: `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`,
dataType: "INTEGER",
options: [0, 1],
defaultValue: 0,
},
{ {
fieldName: "reply_to_message_id", fieldName: "reply_to_message_id",
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`, fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
@@ -1224,6 +1232,60 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "bot_types",
tableDescription: `A bot's types, like \`user_types\` for users: what it can do (\`BotTypePermissionMatrix\`). A row with no workspace holds everywhere (the coordinator); one with a workspace only there (a workspace admin). A bot with no row is a worker. One per bot, type and workspace, enforced in \`setBotType\` since SQLite treats NULL ids as distinct`,
fields: [
{
fieldName: "bot_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_types_bot_fk",
},
},
{
fieldName: "bot_type",
dataType: "TEXT",
options: BotTypes.map((bt) => bt.value),
},
{
fieldName: "workspace_id",
fieldDescription: `The workspace the type holds in; null for install-wide`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "workspaces",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_types_workspace_fk",
},
},
{
fieldName: "granted_by_user_id",
fieldDescription: `The user who granted it (or for whom the granting bot ran). Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "granted_by_bot_id",
fieldDescription: `The bot that granted it from chat (the coordinator), if any. Not a foreign key`,
dataType: "INTEGER",
},
],
indexes: [
{
indexName: "idx_bot_types_bot_id_workspace_id",
indexTableFields: ["bot_id", "workspace_id"],
},
{
indexName: "idx_bot_types_workspace_id",
indexTableFields: ["workspace_id"],
},
],
},
{ {
tableName: "workspace_users", tableName: "workspace_users",
tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`, tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`,
@@ -2209,6 +2271,88 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "extension_proposals",
tableDescription: `An MCP server, custom tool or skill the coordinator proposed adding or changing, waiting for the super admin to confirm or reject it from the chat. Nothing is saved, connected or run until they confirm: the card shows where it sends, which envs it reads (saved or missing), for a skill what its runbook tells bots to do and what changed, and the bot's background check`,
fields: [
{
fieldName: "kind",
fieldDescription: `mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)`,
dataType: "TEXT",
options: ["mcp", "tool", "skill"],
},
{
fieldName: "extension_id",
fieldDescription: `The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm`,
dataType: "INTEGER",
},
{
fieldName: "name",
fieldDescription: `The server's or tool's name, for the chip`,
dataType: "TEXT",
},
{
fieldName: "data",
fieldDescription: `JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)`,
dataType: "TEXT",
},
{
fieldName: "research",
fieldDescription: `The bot's background check, markdown: who publishes it, whether it's official, what it needs`,
dataType: "TEXT",
},
{
fieldName: "sources",
fieldDescription: `JSON array of the URLs the background check read`,
dataType: "TEXT",
},
{
fieldName: "status",
dataType: "TEXT",
options: ["pending", "confirmed", "rejected", "replaced", "failed"],
defaultValue: "pending",
},
{
fieldName: "outcome",
fieldDescription: `What happened on confirm (saved, connected, keys missing) or why it failed`,
dataType: "TEXT",
},
{
fieldName: "result_id",
fieldDescription: `The server or tool it saved, once confirmed`,
dataType: "INTEGER",
},
{
fieldName: "expires_at",
fieldDescription: `A pending proposal can't be confirmed after this (Unix ms)`,
dataType: "INTEGER",
},
{
fieldName: "decided_at",
dataType: "INTEGER",
},
{
fieldName: "user_id",
fieldDescription: `The super admin whose conversation it was proposed in`,
dataType: "INTEGER",
},
{
fieldName: "bot_id",
fieldDescription: `The bot that proposed it (the coordinator). Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "session_id",
fieldDescription: `The conversation it was proposed in, where the outcome is reported. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "workspace_id",
fieldDescription: `The session's workspace, null for the bot's own chat. Not a foreign key`,
dataType: "INTEGER",
},
],
},
{ {
tableName: "bot_mcp_servers", tableName: "bot_mcp_servers",
tableDescription: `A bot that gets an MCP server's tools everywhere it works`, tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
@@ -2866,6 +3010,154 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "token_usages",
tableDescription: `One model call that reported its usage: the tokens it spent, on which model and provider, for whom and for what. A bot message's total is the sum of the calls it took; the usage page sums them by model, provider and period`,
fields: [
{
fieldName: "user_id",
fieldDescription: `Whose work it was: the user whose conversation the call ran for`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "token_usage_user_fk",
},
},
{
fieldName: "bot_id",
fieldDescription: `The bot it was for, when it was for one`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "token_usage_bot_fk",
},
},
{
fieldName: "workspace_id",
fieldDescription: `The workspace the call was in; null for a bot's own chat`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "workspaces",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "token_usage_workspace_fk",
},
},
{
fieldName: "session_id",
fieldDescription: `The conversation it was in`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bot_sessions",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "token_usage_session_fk",
},
},
{
fieldName: "message_id",
fieldDescription: `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)`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bot_messages",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "token_usage_message_fk",
},
},
{
fieldName: "job_id",
fieldDescription: `The queued job it ran for, when it ran for one`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots_queue",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "token_usage_job_fk",
},
},
{
fieldName: "harness",
fieldDescription: `What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding`,
dataType: "TEXT",
options: Harnesses.map((h) => h.value),
},
{
fieldName: "provider_id",
fieldDescription: `The provider that served it. Not a foreign key: it stays readable after the provider is deleted`,
dataType: "INTEGER",
},
{
fieldName: "provider_name",
fieldDescription: `The provider's name as it was, so a deleted provider still reads`,
dataType: "TEXT",
},
{
fieldName: "model_id",
fieldDescription: `The catalog row of the model. Not a foreign key: a model the provider stops listing still counts`,
dataType: "INTEGER",
},
{
fieldName: "model_slug",
fieldDescription: `The provider's model id, as it was called`,
dataType: "TEXT",
},
{
fieldName: "prompt_tokens",
fieldDescription: `Tokens sent: the prompt, the system instructions and the tool results`,
dataType: "INTEGER",
defaultValue: 0,
},
{
fieldName: "completion_tokens",
fieldDescription: `Tokens written back: the answer, its tool calls and its reasoning`,
dataType: "INTEGER",
defaultValue: 0,
},
{
fieldName: "cost",
fieldDescription: `The provider's price in USD, when it reports one`,
dataType: "REAL",
},
{
fieldName: "settings",
fieldDescription: `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`,
dataType: "TEXT",
},
],
indexes: [
{
indexName: "idx_token_usages_created_at",
indexTableFields: ["created_at"],
},
{
indexName: "idx_token_usages_message_id",
indexTableFields: ["message_id"],
},
{
indexName: "idx_token_usages_provider_model",
indexTableFields: ["provider_id", "model_slug"],
},
{
indexName: "idx_token_usages_user_created_at",
indexTableFields: ["user_id", "created_at"],
},
{
indexName: "idx_token_usages_bot_created_at",
indexTableFields: ["bot_id", "created_at"],
},
],
},
{ {
tableName: "bot_file_vectors", tableName: "bot_file_vectors",
tableDescription: `sqlite-vec vectors of a file's chunks, at \`AppData.EmbeddingDimensions\` wide whatever the model returns. A vec0 table is only recreated with \`bunx bun-sqlite schema --vector\`, so the entrypoint never passes it`, tableDescription: `sqlite-vec vectors of a file's chunks, at \`AppData.EmbeddingDimensions\` wide whatever the model returns. A vec0 table is only recreated with \`bunx bun-sqlite schema --vector\`, so the entrypoint never passes it`,
+182 -2
View File
@@ -19,6 +19,7 @@ export const BunSQLiteTables = [
"cron_jobs", "cron_jobs",
"workspaces", "workspaces",
"workspace_bots", "workspace_bots",
"bot_types",
"workspace_users", "workspace_users",
"bot_users", "bot_users",
"bot_user_memories", "bot_user_memories",
@@ -36,6 +37,7 @@ export const BunSQLiteTables = [
"mcp_servers", "mcp_servers",
"custom_tools", "custom_tools",
"skills", "skills",
"extension_proposals",
"bot_mcp_servers", "bot_mcp_servers",
"workspace_mcp_servers", "workspace_mcp_servers",
"bot_custom_tools", "bot_custom_tools",
@@ -48,6 +50,7 @@ export const BunSQLiteTables = [
"envs", "envs",
"bot_files", "bot_files",
"bot_file_chunks", "bot_file_chunks",
"token_usages",
"bot_file_vectors", "bot_file_vectors",
"file_ingest_queue", "file_ingest_queue",
"app_settings", "app_settings",
@@ -191,7 +194,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
user_id?: number | ""; user_id?: number | "";
name?: string; 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" | ""; kind?: "worker" | "coordinator" | "";
/** /**
@@ -308,6 +311,10 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
* Renders the Needs you chip under the message * Renders the Needs you chip under the message
*/ */
needs_you?: 0 | 1 | ""; 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 * The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
*/ */
@@ -761,6 +768,35 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
memory?: string; 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 = { export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1358,6 +1394,75 @@ export type BUN_SQLITE_COBBLE_SKILLS = {
user_id?: number | ""; 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 = { export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1717,6 +1822,81 @@ export type BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS = {
token_estimate?: number | ""; 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 = { export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1800,4 +1980,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string; 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
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.1.5", "version": "0.2.2",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -19,7 +19,8 @@
"test": "bun test", "test": "bun test",
"check:bundle": "bun src/scripts/check-bundle-size.ts", "check:bundle": "bun src/scripts/check-bundle-size.ts",
"publish:github": "bash scripts/publish-github.sh", "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": { "devDependencies": {
"@next/mdx": "^16.3.0", "@next/mdx": "^16.3.0",
@@ -44,7 +45,7 @@
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3", "@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.6", "@moduletrace/twui": "^0.1.7",
"@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0", "@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0", "@tiptap/extension-placeholder": "3.32.0",
+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
+6 -6
View File
@@ -8,9 +8,10 @@
# creates the Gitea release over the API. # creates the Gitea release over the API.
# #
# Gitea has no release workflow, so nothing is built here: the image is built # Gitea has no release workflow, so nothing is built here: the image is built
# once by .github/workflows/release.yml and mirrored to # once by .github/workflows/release.yml and copied into the Gitea registry by
# git.tben.me/moduletrace/cobble. This script only publishes the source and # scripts/mirror-gitea.sh, run from the VPN side, since a GitHub runner can
# the release object. # 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 # 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 # if the tea CLI is installed. It needs the `write:repository` scope on the
@@ -221,6 +222,5 @@ else
fi fi
print_release_summary "$REMOTE" "$BRANCH" "https://${REMOTE_HOST}/${REPO_SLUG}/releases/tag/$TAG" 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 '\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 ' docker buildx imagetools create --tag git.tben.me/moduletrace/cobble:latest \\\n' printf ' bun run mirror:gitea\n'
printf ' ghcr.io/moduletrace/cobble:latest\n'
+4 -3
View File
@@ -7,11 +7,12 @@
# installer syntax), tags HEAD with the package.json version, pushes main and # installer syntax), tags HEAD with the package.json version, pushes main and
# the tag to the `origin` remote, then creates the GitHub release. Publishing # the tag to the `origin` remote, then creates the GitHub release. Publishing
# that release is what fires .github/workflows/release.yml, which builds the # 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 # 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 # object, and scripts/mirror-gitea.sh once the image build has finished.
# twin are it.
set -Eeuo pipefail set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+160 -1
View File
@@ -4,6 +4,7 @@ import path from "path";
const ROOT = path.join(import.meta.dir, ".."); const ROOT = path.join(import.meta.dir, "..");
const COMMON = path.join(ROOT, "scripts/lib/publish-common.sh"); const COMMON = path.join(ROOT, "scripts/lib/publish-common.sh");
const RELEASE = path.join(ROOT, "scripts/lib/publish-release.sh"); const RELEASE = path.join(ROOT, "scripts/lib/publish-release.sh");
const MIRROR = path.join(ROOT, "scripts/mirror-gitea.sh");
const SCRIPTS = [ const SCRIPTS = [
path.join(ROOT, "scripts/publish-github.sh"), path.join(ROOT, "scripts/publish-github.sh"),
path.join(ROOT, "scripts/publish-gitea.sh"), path.join(ROOT, "scripts/publish-gitea.sh"),
@@ -99,13 +100,21 @@ async function pathWithoutJq(): Promise<string> {
} }
describe("publish scripts syntax", () => { 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 () => { test(`${path.relative(ROOT, script)} parses`, async () => {
const proc = Bun.spawn({ cmd: ["bash", "-n", script] }); const proc = Bun.spawn({ cmd: ["bash", "-n", script] });
expect(await proc.exited).toBe(0); 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 () => { test("both publish scripts source the shared library", async () => {
for (const script of SCRIPTS) { for (const script of SCRIPTS) {
const source = await Bun.file(script).text(); 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", () => { describe("publish lib version handling", () => {
test("normalizes a version with and without the v", async () => { test("normalizes a version with and without the v", async () => {
const block = await blockCaller({ name: "version" }); const block = await blockCaller({ name: "version" });
@@ -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";
}
+4 -4
View File
@@ -1,11 +1,10 @@
import path from "node:path"; import mediaFilePath from "@/src/utils/media-file-path";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import grabMessagesMedia, { import grabMessagesMedia, {
type MessageMediaRow, type MessageMediaRow,
} from "@/src/functions/backend/db/bots/grab-messages-media"; } from "@/src/functions/backend/db/bots/grab-messages-media";
import type { HarnessContentPart } from "@/src/harness/types"; import type { HarnessContentPart } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import describeAttachment from "./describe-attachment"; import describeAttachment from "./describe-attachment";
type Params = { type Params = {
@@ -74,9 +73,10 @@ async function fileToParts({ file }: { file: MessageMediaRow }): Promise<Harness
return [{ type: "text", text: `[Attached video ${label}: 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())) { if (!disk || !(await disk.exists())) {
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }]; return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
} }
+19 -2
View File
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
import { SessionNamePrompt } from "@/src/data/harness-prompts"; import { SessionNamePrompt } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import generateText from "@/src/harness/text/generate-text"; import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import parseSessionName from "./parse-session-name"; import parseSessionName from "./parse-session-name";
type Params = { type Params = {
@@ -30,7 +31,7 @@ export default async function nameBotSession({
}: Params): Promise<boolean> { }: Params): Promise<boolean> {
const session = ( const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({ 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], values: [session_id],
}) })
).payload?.[0]; ).payload?.[0];
@@ -57,7 +58,22 @@ export default async function nameBotSession({
) )
.join("\n\n"); .join("\n\n");
const result = await generateText({ /**
* 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: [ messages: [
{ role: "system", content: SessionNamePrompt }, { role: "system", content: SessionNamePrompt },
{ role: "user", content: transcript }, { role: "user", content: transcript },
@@ -65,6 +81,7 @@ export default async function nameBotSession({
bot_id, bot_id,
workspace_id, workspace_id,
signal, signal,
}),
}); });
const name = parseSessionName({ text: result.text }); const name = parseSessionName({ text: result.text });
+32
View File
@@ -290,6 +290,38 @@ export default async function processBotJob({
return null; 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) { if (failed?.notice) {
await pushBotNotice({ bot_id, ...failed.notice }); await pushBotNotice({ bot_id, ...failed.notice });
} }
+13
View File
@@ -74,6 +74,18 @@ async function push({
}) })
).payload?.[0]; ).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) { for (const viewer_id of audience) {
if (users && !users.has(String(viewer_id))) continue; if (users && !users.has(String(viewer_id))) continue;
@@ -87,6 +99,7 @@ async function push({
color: identity?.color || null, color: identity?.color || null,
stone: identity?.stone || null, stone: identity?.stone || null,
image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : 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 } : {}), ...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
...(workspace_ids !== undefined ? { workspace_ids } : {}), ...(workspace_ids !== undefined ? { workspace_ids } : {}),
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }), task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
+13 -1
View File
@@ -1,10 +1,11 @@
import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs"; 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 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"; import pushBotNotice from "./push-bot-notice";
/** /**
* Tells the user about jobs that ran out of attempts by going silent * 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() { export default async function reportLostBotJobs() {
const jobs = await grabUnreportedFailedBotJobs(); const jobs = await grabUnreportedFailedBotJobs();
@@ -18,5 +19,16 @@ export default async function reportLostBotJobs() {
}); });
await pushBotNotice({ bot_id: job.bot_id, ...notice }); 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 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 grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
import HarnessError from "@/src/harness/harness-error"; import HarnessError from "@/src/harness/harness-error";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import BotJobFatalError from "./bot-job-fatal-error"; import BotJobFatalError from "./bot-job-fatal-error";
import runBotReply from "./run-bot-reply"; import runBotReply from "./run-bot-reply";
import runBotTask from "./run-bot-task"; import runBotTask from "./run-bot-task";
@@ -33,9 +34,25 @@ const runBotJob: BotJobRunner = async (context) => {
const owned = { ...bot, id: bot.id }; const owned = { ...bot, id: bot.id };
try { try {
return job.kind == "reply" /**
? await runBotReply({ ...context, bot: owned, user_id }) * Everything the job's models do is recorded for this user, this
: await runBotTask({ ...context, bot: owned, user_id }); * 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) { } catch (error: unknown) {
if ( if (
error instanceof HarnessError && error instanceof HarnessError &&
+123 -28
View File
@@ -1,16 +1,22 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings/grab-skill-limits";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { import {
ActionCheckNote, ActionCheckNote,
BotFilesNote, botFilesNote,
ChatReplyNote, chatReplyNote,
ComputerFilesNote, ComputerFilesNote,
CoordinatorReplyNote, coordinatorReplyNote,
CoordinatorSoloNote, CoordinatorSoloNote,
TasksNote, TasksNote,
TaskReminderNote, TaskReminderNote,
workspaceAdminNote,
WorkspaceLearningNote, WorkspaceLearningNote,
} from "@/src/data/harness-prompts"; } 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 grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt"; import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers"; import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
@@ -27,7 +33,14 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import loadAttachmentParts from "./load-attachment-parts"; import loadAttachmentParts from "./load-attachment-parts";
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names"; import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
import withTakeOverRefs from "./with-take-over-refs"; 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 type { BotJobContext, BotJobOutcome } from "./types";
import computerRunDirs from "@/src/utils/computer-work-dirs";
type Params = BotJobContext & { type Params = BotJobContext & {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number }; bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
@@ -46,8 +59,12 @@ type Params = BotJobContext & {
* instructions, start_task, and for the coordinator delegate_task) plus * instructions, start_task, and for the coordinator delegate_task) plus
* its MCP servers' and custom tools (`openBotExtensions`, script tools on * its MCP servers' and custom tools (`openBotExtensions`, script tools on
* its current computer). * its current computer).
* Finishes silently when the last message is already the bot's, i.e. an * Messages the user sends while it works join the run (`incoming`): one
* earlier reply answered everything. * 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({ export default async function runBotReply({
bot, bot,
@@ -64,10 +81,48 @@ export default async function runBotReply({
limit: AppData.BotReplyHistoryMessages, 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 }; 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 prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id }); const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
@@ -83,6 +138,11 @@ export default async function runBotReply({
}).catch(() => ""); }).catch(() => "");
const take_overs: string[] = []; 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. * Files the bot wrote and asked to send with its reply.
*/ */
@@ -103,8 +163,16 @@ export default async function runBotReply({
onAttach: ({ media_id }) => attachments.push(media_id), 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({ const tools = await grabBotChatTools({
bot, bot,
powers,
user_id, user_id,
computers, computers,
session_id, session_id,
@@ -112,12 +180,22 @@ export default async function runBotReply({
scopes, scopes,
message_id: job.message_id || undefined, message_id: job.message_id || undefined,
onTakeOver: ({ ref }) => take_overs.push(ref), 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({ const extensions = await openBotExtensions({
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
computer_id: computers[0]?.id || null, computer_id: computers[0]?.id || null,
dirs,
taken: tools.map((tool) => tool.name), taken: tools.map((tool) => tool.name),
signal, signal,
}); });
@@ -127,34 +205,40 @@ export default async function runBotReply({
*/ */
const skills = openBotSkills({ const skills = openBotSkills({
skills: extensions.skills, skills: extensions.skills,
shown_chars: await grabSkillBodyShownChars(),
taken: [...tools, ...extensions.tools].map((tool) => tool.name), taken: [...tools, ...extensions.tools].map((tool) => tool.name),
}); });
/** /**
* 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 = const can_delegate = tools.some((tool) => tool.name == "delegate_task");
bot.kind != "coordinator" const lead = led as WorkspaceAdminTools | null;
? "" const crew_note = powers.is_coordinator
: tools.some((tool) => tool.name == "delegate_task") ? can_delegate
? CoordinatorReplyNote ? coordinatorReplyNote({ bot_id: bot.id })
: CoordinatorSoloNote; : 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 { try {
const result = await runTextLoop({ const result = await runTextLoop({
system: [ system: [
prompt, prompt,
carry_over, carry_over,
ChatReplyNote, chatReplyNote({ bot_id: bot.id }),
BotFilesNote, botFilesNote({ bot_id: bot.id }),
ComputerFilesNote({ computer_id: computers[0]?.id || 0, can_import: false }), ComputerFilesNote({ computer_id: computers[0]?.id || 0, dirs, can_import: false }),
TasksNote, TasksNote,
TaskReminderNote, TaskReminderNote,
ActionCheckNote, ActionCheckNote,
workspace_id ? WorkspaceLearningNote : "", workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({ composeComputersNote({
computers, computers,
is_coordinator: bot.kind == "coordinator", is_coordinator: powers.is_coordinator,
in_workspace: Boolean(workspace_id), in_workspace: Boolean(workspace_id),
}), }),
crew_note, crew_note,
@@ -164,27 +248,38 @@ export default async function runBotReply({
] ]
.filter(Boolean) .filter(Boolean)
.join("\n\n"), .join("\n\n"),
messages: botMessagesToHarness({ messages: await toTurns({ messages: history }),
messages: history, tools: [
parts: await loadAttachmentParts({ messages: history }), ...tools,
refs: await grabMessageRefNames({ sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
texts: history.filter((m) => m.author == "user").map((m) => m.text || ""), ...file_tools,
user_id, ...extensions.tools,
}), ...skills.tools,
}), ],
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools], max_steps: await grabReplyMaxToolSteps(),
max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
signal, signal,
onStep: report, onStep: report,
incoming,
}); });
if (job.id && answering_id) {
await markReplyAnswered({ job_id: job.id, message_id: answering_id });
}
return { return {
...withTakeOverRefs({ text: result.text, refs: take_overs }), ...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }),
attachments: attachments.length ? attachments : undefined, attachments: attachments.length ? attachments : undefined,
}; };
} finally { } finally {
await extensions.close(); 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)),
);
}
+45 -11
View File
@@ -1,13 +1,18 @@
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings/grab-skill-limits";
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 type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { import {
ActionCheckNote, ActionCheckNote,
BotFilesNote, botFilesNote,
ComputerFilesNote, ComputerFilesNote,
ScheduledRunNote, ScheduledRunNote,
TasksNote, TasksNote,
TaskRunNote, TaskRunNote,
WorkspaceLearningNote, WorkspaceLearningNote,
} from "@/src/data/harness-prompts"; } 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 grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt"; import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
@@ -31,6 +36,7 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import withTakeOverRefs from "./with-take-over-refs"; import withTakeOverRefs from "./with-take-over-refs";
import ScheduledRunFailedError from "./scheduled-run-failed-error"; import ScheduledRunFailedError from "./scheduled-run-failed-error";
import type { BotJobContext, BotJobOutcome } from "./types"; import type { BotJobContext, BotJobOutcome } from "./types";
import computerRunDirs from "@/src/utils/computer-work-dirs";
type Params = BotJobContext & { type Params = BotJobContext & {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number }; bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
@@ -61,12 +67,17 @@ export default async function runBotTask({
report, report,
}: Params): Promise<BotJobOutcome> { }: Params): Promise<BotJobOutcome> {
const workspace_id = job.workspace_id || null; 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 const computer = job.computer_id
? await openComputerTools({ ? await openComputerTools({
computer_id: job.computer_id, computer_id: job.computer_id,
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
dirs,
signal, signal,
}) })
: null; : null;
@@ -80,11 +91,13 @@ export default async function runBotTask({
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id }); const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
/** /**
* A coordinator's task can also change workspaces: the findings * What the bot's types let it do here. A coordinator's task can
* it was asked to record go there, not only in the report. * 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 = const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
bot.kind == "coordinator" const coordinator_workspaces = powers.is_coordinator
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived) ? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
: []; : [];
const carry_over = await grabSessionCarryOver({ const carry_over = await grabSessionCarryOver({
@@ -114,6 +127,16 @@ export default async function runBotTask({
*/ */
const scopes = await grabRunScopes({ bot, user_id, workspace_id }); 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 = [ const tools = [
...(await grabBotTools({ ...(await grabBotTools({
user_id, user_id,
@@ -121,10 +144,12 @@ export default async function runBotTask({
workspace_id, workspace_id,
computers, computers,
session_id, session_id,
is_coordinator: bot.kind == "coordinator", is_coordinator: powers.is_coordinator,
admin_workspace_ids: powers.admin_workspace_ids,
scopes, scopes,
})), })),
...(bot.kind == "coordinator" ...admin_tools,
...(powers.is_coordinator
? [ ? [
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }), createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
...(coordinator_workspaces.length ...(coordinator_workspaces.length
@@ -141,7 +166,7 @@ export default async function runBotTask({
: []), : []),
...(computers.length ...(computers.length
? [ ? [
searchComputersTool({ bot_id: bot.id, computers, workspace_id }), searchComputersTool({ bot_id: bot.id, computers, workspace_id, dirs }),
askToTakeOverTool({ askToTakeOverTool({
computers: take_over_computers, computers: take_over_computers,
in_task: true, in_task: true,
@@ -158,6 +183,7 @@ export default async function runBotTask({
scopes, scopes,
bot_id: bot.id, bot_id: bot.id,
user_id, user_id,
dirs,
}), }),
] ]
: []), : []),
@@ -175,12 +201,14 @@ export default async function runBotTask({
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
computer_id: job.computer_id || null, computer_id: job.computer_id || null,
dirs,
taken: tools.map((tool) => tool.name), taken: tools.map((tool) => tool.name),
signal, signal,
}); });
skills = openBotSkills({ skills = openBotSkills({
skills: extensions.skills, skills: extensions.skills,
shown_chars: await grabSkillBodyShownChars(),
taken: [...tools, ...extensions.tools].map((tool) => tool.name), taken: [...tools, ...extensions.tools].map((tool) => tool.name),
}); });
@@ -190,14 +218,19 @@ export default async function runBotTask({
carry_over, carry_over,
TaskRunNote, TaskRunNote,
scheduled ? ScheduledRunNote : "", scheduled ? ScheduledRunNote : "",
BotFilesNote, botFilesNote({ bot_id: bot.id }),
ComputerFilesNote({ computer_id: job.computer_id || 0 }), ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
TasksNote, TasksNote,
ActionCheckNote, ActionCheckNote,
workspace_id ? WorkspaceLearningNote : "", 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.` ? `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 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.` ? `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 : workspace_id
@@ -227,6 +260,7 @@ export default async function runBotTask({
}, },
], ],
tools: [...tools, ...extensions.tools, ...skills.tools], tools: [...tools, ...extensions.tools, ...skills.tools],
max_steps: await grabHarnessMaxToolSteps(),
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
signal, signal,
+6 -4
View File
@@ -1,15 +1,17 @@
type Params = { type Params = {
text: string; 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[]; refs: string[];
}; };
/** /**
* A job's message with every take-over the bot asked for in it: refs the * A job's message with every take-over the bot asked for in it and every
* bot didn't write itself are added at the end, so the user always gets * proposal it made: refs the bot didn't write itself are added at the
* the button. A message asking for a take-over needs the user. * end, so the user always gets the button. A message asking for either
* needs the user.
*/ */
export default function withTakeOverRefs({ text, refs }: Params) { export default function withTakeOverRefs({ text, refs }: Params) {
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref)); 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>
);
}
-5
View File
@@ -1,4 +1,3 @@
import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
@@ -15,9 +14,5 @@ export default async function pruneBotsQueue() {
values: [cutoff], values: [cutoff],
}); });
bunext.bunextLog.info(
`Cron: pruned ${res.payload?.length || 0} finished bot job(s)`,
);
return res; return res;
} }
+9 -4
View File
@@ -1,23 +1,26 @@
import bunext from "@moduletrace/bunext"; import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db"; 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"; import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
type Row = Pick< type Row = Pick<
BUN_SQLITE_COBBLE_CONTAINERS, BUN_SQLITE_COBBLE_CONTAINERS,
"id" | "docker_id" | "status" | "ip_address" "id" | "docker_id" | "status" | "ip_address" | "metadata"
> & { computer_id: number }; > & { computer_id: number };
/** /**
* Reads every computer container's state from docker and records it * Reads every computer container's state from docker and records it
* (`refreshComputerContainer`), telling owners about changes. Containers * (`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() { export default async function syncComputers() {
const rows = const rows =
( (
await BunSQLite.sql<Row>({ await BunSQLite.sql<Row>({
sql: `SELECT co.id, co.docker_id, co.status, 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 FROM containers co JOIN computers c ON c.container_id = co.id
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`, WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
}) })
@@ -25,7 +28,9 @@ export default async function syncComputers() {
let changed = 0; let changed = 0;
for (const { computer_id, ...container } of rows) { for (const { computer_id, metadata, ...container } of rows) {
if (await migrateComputerLayout({ computer_id, metadata })) continue;
const refreshed = await refreshComputerContainer({ const refreshed = await refreshComputerContainer({
computer_id, computer_id,
container, container,
+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 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 * 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() { export default async function cronSyncHarnessModels() {
const results = await syncAllHarnessModels(); const results = await syncAllHarnessModels();
for (const result of results) { for (const result of results) {
if (result.error) { if (!result.error) continue;
bunext.bunextLog.error(
`Cron: model sync for provider #${result.provider_id} failed: ${result.error}`, await logError({
); title: `Cron: model sync for provider #${result.provider_id} failed`,
} else { category: "cron",
bunext.bunextLog.info( error: result.error,
`Cron: provider #${result.provider_id} lists ${result.count} model(s)`, metadata: { provider_id: result.provider_id },
); }).catch(() => {});
}
} }
return results; return results;
+94 -6
View File
@@ -96,12 +96,34 @@ export const AppData = {
ComputerImage: "ghcr.io/agent-infra/sandbox:latest", ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`, ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
/** /**
* A computer's home directory (`ComputerHomeDir` in its container) is * Only a computer's workspace (`ComputerWorkspaceDir` in its
* kept at `DATA_DIR/<ComputerDataDir>/<id>`, owned by the sandbox's * container) is kept, at `DATA_DIR/<ComputerDataDir>/<id>`, owned by
* user. It stays when the computer is deleted. * 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", ComputerDataDir: "computers",
ComputerHomeDir: "/home/gem", 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, ComputerUserUid: 1000,
ComputerUserGid: 1000, ComputerUserGid: 1000,
/** /**
@@ -246,7 +268,10 @@ export const AppData = {
HarnessCooldownMs: 60 * 1000, HarnessCooldownMs: 60 * 1000,
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000, HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
/** /**
* Model turns in one text run before it must stop and report. * Model turns in one text run before it must stop and report. The
* install may save its own limit (the `harness_max_tool_steps` setting,
* Settings > General), which wins over this one; a task run reads it
* through `grabHarnessMaxToolSteps`.
*/ */
HarnessMaxToolSteps: 200, HarnessMaxToolSteps: 200,
/** /**
@@ -284,11 +309,29 @@ export const AppData = {
/** /**
* Skills (Settings, under Skills): how long one's body may be, and how * Skills (Settings, under Skills): how long one's body may be, and how
* much of it `load_skill` hands back in one call. A runbook past that * much of it `load_skill` hands back in one call. A runbook past that
* is worth splitting into two skills. * is worth splitting into two skills. These are the defaults: the
* install sets its own on Settings > General (`skill_body_max_chars`,
* `skill_body_shown_chars`, `grab-skill-limits.ts`), never past
* `SkillBodyLimitMax`.
*/ */
SkillBodyMaxChars: 60000, SkillBodyMaxChars: 60000,
SkillBodyShownChars: 30000, SkillBodyShownChars: 30000,
SkillBodyLimitMax: 1000000,
SkillDescriptionMaxChars: 300, 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, * `search_computers`: how long the command may run on each computer,
* and how much of each computer's output the bot reads. * and how much of each computer's output the bot reads.
@@ -328,6 +371,21 @@ export const AppData = {
* How many older messages each scroll to the top of the thread loads. * How many older messages each scroll to the top of the thread loads.
*/ */
ConversationOlderMessages: 30, 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 * How much of a bot's last message the rail's lists carry, as plain
* text (`messagePreview`): a row shows one line of it. * text (`messagePreview`): a row shows one line of it.
@@ -379,8 +437,23 @@ export const AppData = {
WorkspaceActivityItems: 6, WorkspaceActivityItems: 6,
/** /**
* Tool turns a chat reply may take. Long work goes to a task instead. * 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 * Chat attachments: per file, per message, and how much of a
* document's text a reply reads. * document's text a reply reads.
@@ -441,6 +514,12 @@ export const AppData = {
* Most folders deep a path may go, so nothing buries a file. * Most folders deep a path may go, so nothing buries a file.
*/ */
BotFilePathMaxDepth: 8, 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. * The files modal and the bot's file list: one page of them.
*/ */
@@ -532,6 +611,13 @@ export const AppData = {
*/ */
DataBackupNamePrefix: "cobble-data-", DataBackupNamePrefix: "cobble-data-",
DataBackupsKept: 5, 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 * Responses smaller than this go out uncompressed: the encoding
* costs more than it saves. * costs more than it saves.
@@ -550,4 +636,6 @@ export const AppData = {
*/ */
CompressCacheMaxFiles: 600, CompressCacheMaxFiles: 600,
CompressCacheMaxBytes: 32 * 1024 * 1024, CompressCacheMaxBytes: 32 * 1024 * 1024,
DefaultIconSize: 16,
} as const; } 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 ## 2. Delegate well
- Pick by role, instructions and tools, not by name. - 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. - 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. - 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. - 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\`. - 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. - 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. - 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: 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:");
});
});
+58 -9
View File
@@ -1,6 +1,7 @@
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import botRef from "@/src/utils/bot-ref"; import botRef from "@/src/utils/bot-ref";
import folderRef from "@/src/utils/folder-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 * 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.`; 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. 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. - 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. - 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. - 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 ## Keep the work moving
@@ -41,9 +52,10 @@ 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, * 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. You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
@@ -53,7 +65,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
- 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 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 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 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. - 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. - 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. - 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.
@@ -71,15 +83,28 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
*/ */
export const ComputerFilesNote = ({ export const ComputerFilesNote = ({
computer_id, computer_id,
dirs,
can_import = true, can_import = true,
}: { }: {
computer_id: number | string; computer_id: number | string;
/**
* The run's folders on the computer (`computerRunDirs`).
*/
dirs: ComputerRunDirs;
can_import?: boolean; can_import?: boolean;
}) => `# Files on the computer }) => `# 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 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` ? `- 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`
@@ -114,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. * 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, * Added to the coordinator's chat prompt instead while it has no workers,
@@ -123,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.`; 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. * 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/workspaces",
"/admin/routines", "/admin/routines",
"/admin/files", "/admin/files",
"/admin/usage",
"/admin/logs", "/admin/logs",
"/admin/help", "/admin/help",
"/admin/settings", "/admin/settings",
+6
View File
@@ -110,6 +110,12 @@ export const CoordinatorBotName = "Coordinator";
*/ */
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append"; 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. * A bot's face is its status. There are no badges or spinners.
*/ */
+1
View File
@@ -111,6 +111,7 @@ export const JobStepToolLabels: Record<string, string> = {
update_memory: "Updating memory", update_memory: "Updating memory",
update_instructions: "Updating instructions", update_instructions: "Updating instructions",
load_skill: "Reading a skill", 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 * A computer is an AIO sandbox running in its own docker container on the
* cobble network, created by the app (`createComputer`), its home * cobble network, created by the app (`createComputer`), only its
* directory kept under `DATA_DIR/computers/<id>`. * workspace (`AppData.ComputerWorkspaceDir`) kept under
* `DATA_DIR/computers/<id>`.
*/ */
export const ComputerKinds = [ export const ComputerKinds = [
{ {
+10
View File
@@ -64,18 +64,28 @@ export const ReservedToolNames = [
"create_schedule", "create_schedule",
"create_workspace", "create_workspace",
"delegate_task", "delegate_task",
"delete_extension",
"delete_schedule", "delete_schedule",
"list_extensions",
"list_schedules", "list_schedules",
"load_skill", "load_skill",
"propose_custom_tool",
"propose_mcp_server",
"propose_skill",
"report_run_failed", "report_run_failed",
"search_computers", "search_computers",
"send_message",
"set_bot_workspace_instructions", "set_bot_workspace_instructions",
"set_extension_access",
"set_picture", "set_picture",
"set_workspace_computer", "set_workspace_computer",
"start_task", "start_task",
"update_custom_tool",
"update_instructions", "update_instructions",
"update_mcp_server",
"update_memory", "update_memory",
"update_schedule", "update_schedule",
"update_skill",
"update_workspace", "update_workspace",
] as const; ] as const;
+1 -1
View File
@@ -68,7 +68,7 @@ export const EnvDict: readonly KnownEnv[] = [
}, },
{ {
key: "MAIN_COMPUTER_DATA_DIR", 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, required: false,
secret: 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"]; 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 * Vendors preferred for automatic picks, best first. Free models from any
* other vendor come after these, and only if they look competent. * 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"; import { HelpPath } from "./help-dict";
export type RailPage = { export type RailPage = {
@@ -9,11 +11,16 @@ export type RailPage = {
* Shown and served to super admins only. * Shown and served to super admins only.
*/ */
super_admin_only?: boolean; super_admin_only?: boolean;
/**
* Who sees it, when that's an RBAC rule rather than super admins only.
*/
can_view?: (params: { user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null }) => boolean;
}; };
export const RoutinesPath = "/admin/routines"; export const RoutinesPath = "/admin/routines";
export const FilesPath = "/admin/files"; export const FilesPath = "/admin/files";
export const ActivityPath = "/admin/logs"; export const ActivityPath = "/admin/logs";
export const UsagePath = "/admin/usage";
/** /**
* Pages opened from the rail's user popover that have their own rail: a * 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[] = [ export const RailPages: RailPage[] = [
{ title: "Routines", url: RoutinesPath, Icon: Clock }, { title: "Routines", url: RoutinesPath, Icon: Clock },
{ title: "Files", url: FilesPath, Icon: FolderOpen }, { 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: "Activity", url: ActivityPath, Icon: Activity, super_admin_only: true },
{ title: "Help", url: HelpPath, Icon: CircleHelp }, { title: "Help", url: HelpPath, Icon: CircleHelp },
]; ];
+8
View File
@@ -14,3 +14,11 @@ export const LoadSkillToolName = "load_skill";
* The slug format, for the settings form and its hint. * The slug format, for the settings form and its hint.
*/ */
export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
/**
* The `app_settings` keys for the longest runbook a skill may save, and
* how much of it `load_skill` hands back at once (Settings > General).
* Unset, `AppData.SkillBodyMaxChars` and `AppData.SkillBodyShownChars`.
*/
export const SkillBodyMaxCharsSettingKey = "skill_body_max_chars";
export const SkillBodyShownCharsSettingKey = "skill_body_shown_chars";
@@ -1,21 +0,0 @@
import withBackupRunLock from "./with-backup-run-lock";
import writeDataBackupArchive from "./write-data-backup-archive";
/**
* Archives the whole `DATA_DIR` (database, media, computer homes, bot and
* workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as
* `cobble-data-{timestamp}.tar.gz`, then keeps only the newest
* `AppData.DataBackupsKept`. Every entry sits under one root folder named
* after the archive (`cobble-data-{timestamp}/`), so extracting it lands
* one folder, never a heap of files in the current directory. The
* database's own snapshots in `db/.backups` are inside `DATA_DIR`, so they
* ride along and are left alone on disk.
*
* The archive is a filesystem copy of a live volume, so the SQLite file
* inside it is only as consistent as the moment tar read it; a consistent
* database snapshot is what the `db/.backups` list is for. Restoring a
* whole-data archive is `restoreDataBackup` (`restore-data-backup.ts`).
*/
export default async function createDataBackup() {
return await withBackupRunLock({ task: writeDataBackupArchive });
}
@@ -0,0 +1,99 @@
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";
import restoreDataBackup from "./restore-data-backup";
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");
});
test("a restore runs under the run's lock instead of taking it again", async () => {
startDataBackupRun({
kind: "restore",
task: async () =>
(await restoreDataBackup({ backup_name: "missing.tar.gz" }))
.restored_name,
});
await settle();
const failed = grabDataBackupRun();
expect(failed.status).toBe("failed");
expect(failed.error).toBe("Archive not found");
expect(isBackupRunning()).toBe(false);
});
});
@@ -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 };
}
@@ -5,7 +5,6 @@ import { AppData } from "@/src/data/app-data";
import grabDirNames from "@/src/utils/grab-dir-names"; import grabDirNames from "@/src/utils/grab-dir-names";
import writeDataBackupArchive from "./write-data-backup-archive"; import writeDataBackupArchive from "./write-data-backup-archive";
import resolveDataBackupPath from "./resolve-data-backup-path"; import resolveDataBackupPath from "./resolve-data-backup-path";
import withBackupRunLock from "./with-backup-run-lock";
export type DataBackupRestoreResult = { export type DataBackupRestoreResult = {
/** /**
@@ -61,14 +60,15 @@ function findArchiveRoot({ staging_dir }: RootParams) {
* is taken as a safety copy, then the data directory is emptied (the * is taken as a safety copy, then the data directory is emptied (the
* database snapshots included) and filled from the archive. * database snapshots included) and filled from the archive.
* *
* Runs only inside `startDataBackupRun`, which holds the backup run lock
* for it (taking it again here would refuse every restore).
*
* The caller restarts the process afterwards: the open SQLite handle still * The caller restarts the process afterwards: the open SQLite handle still
* points at the replaced file, so nothing may keep reading it. * points at the replaced file, so nothing may keep reading it.
*/ */
export default async function restoreDataBackup({ export default async function restoreDataBackup({
backup_name, backup_name,
}: Params): Promise<DataBackupRestoreResult> { }: Params): Promise<DataBackupRestoreResult> {
return await withBackupRunLock({
task: async () => {
const { DATA_DIR } = grabDirNames(); const { DATA_DIR } = grabDirNames();
const archive_path = resolveDataBackupPath({ backup_name }); const archive_path = resolveDataBackupPath({ backup_name });
const archive_name = path.basename(archive_path); const archive_name = path.basename(archive_path);
@@ -116,8 +116,6 @@ export default async function restoreDataBackup({
// The archive's snapshots would otherwise vanish with the // The archive's snapshots would otherwise vanish with the
// current ones, so the safety copy is taken before them. // current ones, so the safety copy is taken before them.
// The run lock is already held here, hence the unlocked
// writer rather than `createDataBackup`.
const safety = await writeDataBackupArchive(); const safety = await writeDataBackupArchive();
// Everything goes, the snapshots in `db/.backups` included: // Everything goes, the snapshots in `db/.backups` included:
@@ -144,6 +142,4 @@ export default async function restoreDataBackup({
} finally { } finally {
fs.rmSync(staging_dir, { recursive: true, force: true }); fs.rmSync(staging_dir, { recursive: true, force: true });
} }
},
});
} }
@@ -2,6 +2,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir"; import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
import AppError from "@/src/utils/app-error";
type Params = { type Params = {
file: File; file: File;
@@ -18,13 +19,13 @@ const GZIP_MAGIC = [0x1f, 0x8b];
*/ */
export default async function saveUploadedDataBackup({ file }: Params) { export default async function saveUploadedDataBackup({ file }: Params) {
if (!file.size) { 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()); const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) { 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(); const archive_dir = grabBackupArchiveDir();
@@ -4,6 +4,14 @@ type Params<T> = {
task: () => Promise<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 * 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 * database snapshot, a database restore and a whole-data archive never
@@ -11,9 +11,17 @@ export type CreatedDataBackup = {
}; };
/** /**
* Writes one archive of the whole `DATA_DIR` and prunes the old ones. * Writes one archive of the whole `DATA_DIR` (database, media, computer
* Callers already hold the backup run lock (`withBackupRunLock`), so a * workspaces, bot and workspace files, MCP installs) into
* restore can take its safety copy without deadlocking on it. * `BACKUP_ARCHIVE_DIR` and keeps only the newest `AppData.DataBackupsKept`.
* Every entry sits under one root folder named after the archive, so
* extracting it lands one folder. The SQLite file inside is only as
* consistent as the moment tar read it; `db/.backups` is for consistent
* snapshots.
*
* It takes no lock: callers run inside `startDataBackupRun`, which holds
* the backup run lock (`withBackupRunLock`) for the whole run, so the
* archive route and a restore's safety copy never take it twice.
*/ */
export default async function writeDataBackupArchive(): Promise<CreatedDataBackup> { export default async function writeDataBackupArchive(): Promise<CreatedDataBackup> {
const { DATA_DIR } = grabDirNames(); const { DATA_DIR } = grabDirNames();
@@ -37,6 +45,13 @@ export default async function writeDataBackupArchive(): Promise<CreatedDataBacku
// after it and an extract drops a single folder. // after it and an extract drops a single folder.
"--transform", "--transform",
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`, `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", "-C",
DATA_DIR, DATA_DIR,
".", ".",
@@ -18,7 +18,9 @@ beforeAll(() => {
(5, 7, NULL, 0), (5, 7, NULL, 0),
(6, 7, NULL, 0), (6, 7, NULL, 0),
(7, 7, NULL, 0), (7, 7, NULL, 0),
(8, 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) // 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 db.run(`INSERT INTO media_paradigms VALUES
@@ -29,12 +31,14 @@ beforeAll(() => {
(5, 'bot-image'), (5, 'bot-image'),
(6, 'workspace-image'), (6, 'workspace-image'),
(7, 'workspace-image'), (7, 'workspace-image'),
(8, 'bot-file')`); (8, 'bot-file'),
(10, 'bot-attachment')`);
// 4: still bot 2's picture, 5: a bot picture no bot points at any more // 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)`); db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`);
// 6: still workspace 10's picture, 7: a workspace picture nothing points at // 6: still workspace 10's picture, 7: a workspace picture nothing points at
db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL)`); // 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 } = {}) { function visible({ archived }: { archived?: number } = {}) {
@@ -52,12 +56,12 @@ function visible({ archived }: { archived?: number } = {}) {
describe("sharedPictureMediaSql", () => { describe("sharedPictureMediaSql", () => {
test("every user's current photo, and every bot's and workspace's picture", () => { test("every user's current photo, and every bot's and workspace's picture", () => {
expect(visible()).toEqual([1, 2, 4, 6]); expect(visible()).toEqual([1, 2, 4, 6, 10]);
}); });
test("archived media is not a picture anyone gets", () => { test("archived media is not a picture anyone gets", () => {
db.run(`UPDATE media SET archived = 1 WHERE id = 2`); db.run(`UPDATE media SET archived = 1 WHERE id = 2`);
expect(visible({ archived: 0 })).toEqual([1, 4, 6]); expect(visible({ archived: 0 })).toEqual([1, 4, 6, 10]);
db.run(`UPDATE media SET archived = 0 WHERE id = 2`); db.run(`UPDATE media SET archived = 0 WHERE id = 2`);
}); });
@@ -72,6 +76,11 @@ describe("sharedPictureMediaSql", () => {
test("anything else stays private, a bot's file among them", () => { test("anything else stays private, a bot's file among them", () => {
expect(visible()).not.toContain(8); 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", () => { test("removing a picture row closes it at once", () => {
@@ -13,28 +13,29 @@ type Params = {
* *
* - a member's current profile photo (their primary `user-profile-image`, * - a member's current profile photo (their primary `user-profile-image`,
* the one `grabUsers` reports as `profile_media_id`), * the one `grabUsers` reports as `profile_media_id`),
* - a bot's picture (`bots.image_media_id`), * - a bot's picture (whatever `bots.image_media_id` points at),
* - a workspace's picture (`workspaces.image_media_id`). * - a workspace's picture (whatever `workspaces.image_media_id` points at).
* *
* Bots and workspaces belong to the install, and people know each other by * 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 * their photo, so a picture is never limited to whoever happens to reach
* its owner. It does have to be one still in use: the paradigm alone is not * its owner. It does have to be one still in use: a profile photo's
* enough, since a removed picture leaves its paradigm row behind. * 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 { export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition {
return { return {
sql: `EXISTS ( sql: `(
EXISTS (
SELECT 1 FROM media_paradigms mp SELECT 1 FROM media_paradigms mp
WHERE mp.media_id = ${alias}.id WHERE mp.media_id = ${alias}.id
AND ( AND mp.media_paradigm = 'user-profile-image'
(mp.media_paradigm = 'user-profile-image' AND ${alias}.is_primary = 1) AND ${alias}.is_primary = 1
OR (mp.media_paradigm = 'bot-image' AND EXISTS (
SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id
))
OR (mp.media_paradigm = 'workspace-image' AND EXISTS (
SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id
))
) )
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: [], 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;
};
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
import { SkillBodyMaxCharsSettingKey, SkillBodyShownCharsSettingKey } from "@/src/dict/skill-dict";
import readStepLimit from "@/src/utils/read-step-limit";
import type { SkillLimitsOverview } from "@/src/types";
import grabAppSetting from "./grab-app-setting";
/**
* The longest runbook a skill may save: the install's own limit
* (`skill_body_max_chars`, Settings > General), else
* `AppData.SkillBodyMaxChars`.
*/
export async function grabSkillBodyMaxChars() {
return readStepLimit({
saved: await grabAppSetting({ key: SkillBodyMaxCharsSettingKey }),
fallback: AppData.SkillBodyMaxChars,
max: AppData.SkillBodyLimitMax,
});
}
/**
* How much of a runbook `load_skill` hands back in one call: the install's
* own limit (`skill_body_shown_chars`, Settings > General), else
* `AppData.SkillBodyShownChars`.
*/
export async function grabSkillBodyShownChars() {
return readStepLimit({
saved: await grabAppSetting({ key: SkillBodyShownCharsSettingKey }),
fallback: AppData.SkillBodyShownChars,
max: AppData.SkillBodyLimitMax,
});
}
/**
* Both limits with their defaults, for the settings pages.
*/
export default async function grabSkillLimitsOverview() {
const [body_max_chars, body_shown_chars] = await Promise.all([
grabSkillBodyMaxChars(),
grabSkillBodyShownChars(),
]);
const overview: SkillLimitsOverview = {
body_max_chars,
body_shown_chars,
body_max_default: AppData.SkillBodyMaxChars,
body_shown_default: AppData.SkillBodyShownChars,
max: AppData.SkillBodyLimitMax,
};
return overview;
}
export type { SkillLimitsOverview };
@@ -1,5 +1,5 @@
import mediaFilePath from "@/src/utils/media-file-path";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { StorageScope } from "@/src/utils/storage-access"; import type { StorageScope } from "@/src/utils/storage-access";
import { canReachScope } from "@/src/utils/storage-access"; import { canReachScope } from "@/src/utils/storage-access";
import grabBotFile from "./grab-bot-file"; import grabBotFile from "./grab-bot-file";
@@ -42,12 +42,13 @@ export default async function copyBotFile({
const file = await grabBotFile({ scopes, file_id }); 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.`); throw new AppError(`That file isn't in your files.`);
} }
const { DATA_DIR } = grabDirNames(); const source = await Bun.file(absolute).arrayBuffer();
const source = await Bun.file(`${DATA_DIR}/${file.media.media_write_path}`).arrayBuffer();
return await recordBotFile({ return await recordBotFile({
bot_id, bot_id,
@@ -1,3 +1,4 @@
import mediaFilePath from "@/src/utils/media-file-path";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
import { rmSync } from "node:fs"; 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.`); 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 root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
const absolute = path.join(root, file.path || ""); const absolute = path.join(root, file.path || "");
@@ -55,12 +55,10 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
values: [file.media_id], values: [file.media_id],
}).then((res) => res.payload?.[0]); }).then((res) => res.payload?.[0]);
if (media?.media_write_path) { for (const write_path of [media?.media_write_path, media?.media_thumbnail_write_path]) {
rmSync(path.join(DATA_DIR, media.media_write_path), { force: true }); const absolute = mediaFilePath({ write_path });
}
if (media?.media_thumbnail_write_path) { if (absolute) rmSync(absolute, { force: true });
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), { force: true });
} }
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] }); await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
@@ -7,6 +7,10 @@ import type {
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import embedText from "@/src/harness/embeddings/embed-text"; import embedText from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error"; 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 chunkText from "./chunk-text";
import extractBotFileText from "./extract-bot-file-text"; import extractBotFileText from "./extract-bot-file-text";
@@ -72,13 +76,27 @@ export default async function ingestBotFile({
return { status: "skipped", chunks: 0 }; 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 { try {
const text = await extractBotFileText({ const text = await runWithFallbackScope({
scope: owner_scope,
task: () =>
extractBotFileText({
write_path: media?.media_write_path, write_path: media?.media_write_path,
text_content: media?.text_content, text_content: media?.text_content,
name: file.name, name: file.name,
mime_type: file.mime_type, mime_type: file.mime_type,
read_media, read_media,
}),
}); });
await clearChunks({ file_id: file.id }); await clearChunks({ file_id: file.id });
@@ -107,11 +125,11 @@ export default async function ingestBotFile({
*/ */
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) { for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize); const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
const { vectors } = await embedText({ const { vectors } = await runEmbed({
input: batch, scope: owner_scope,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
signal, signal,
file,
batch,
}); });
for (let i = 0; i < vectors.length; i++) { for (let i = 0; i < vectors.length; i++) {
@@ -252,3 +270,54 @@ async function markIngested({
values: [chunks > 0 ? 1 : 0, now, file_id], values: [chunks > 0 ? 1 : 0, now, file_id],
}).catch(() => {}); }).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,8 +1,8 @@
import mediaFilePath from "@/src/utils/media-file-path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { BotUploadFolder } from "@/src/dict/bot-file-dict"; import { BotUploadFolder } from "@/src/dict/bot-file-dict";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest"; import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import { logError } from "@/src/functions/backend/logger"; import { logError } from "@/src/functions/backend/logger";
import grabDirNames from "@/src/utils/grab-dir-names";
import recordBotFile from "./record-bot-file"; import recordBotFile from "./record-bot-file";
type Params = { type Params = {
@@ -26,14 +26,15 @@ export default async function storeChatAttachments({
}: Params) { }: Params) {
if (!media.length) return; if (!media.length) return;
const { DATA_DIR } = grabDirNames();
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`; const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
for (const file of media) { for (const file of media) {
try { 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; if (!(await source.exists())) continue;
@@ -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 type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { BotTextModel, PagePropsType } from "@/src/types"; import type { BotTextModel, PagePropsType } from "@/src/types";
import railBots from "@/src/utils/rail-bots"; import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces"; import railWorkspaces from "@/src/utils/rail-workspaces";
import grabBots from "../bots/grab-bots"; 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 grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
import grabConversationFiles from "../bot-files/grab-conversation-files"; import grabConversationFiles from "../bot-files/grab-conversation-files";
import grabBotComputers from "../computers/grab-bot-computers"; import grabBotComputers from "../computers/grab-bot-computers";
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates"; import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
import grabUserWorkspaces from "../workspaces/grab-user-workspaces"; import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
import ensureBotSession from "./ensure-bot-session";
import grabUserConversation from "./grab-user-conversation"; import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
type Params = { type Params = {
user_id: number; user_id: number;
@@ -27,11 +22,12 @@ type Params = {
/** /**
* The page props of a conversation (`/admin/bots/<id>` or * The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's * `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked * in a workspace) and workspaces, the bot, the text model it runs on, its
* read) and its latest `AppData.ConversationFirstMessages` messages (the * computers, tools, MCP servers and files there, and whether its mic and
* thread loads older ones as it scrolls), the text model it runs on, its computers, * read-aloud have a model to run on. The history (its session, messages
* tools and MCP servers and active task there, and whether its mic and * and active task) is left to the client (`grabConversationHistory`,
* read-aloud have a model to run on. The full model settings are left to * `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`) * the models modal: the install-wide catalog (`api/admin/harness/catalog`)
* and the conversation's small setting * and the conversation's small setting
* (`api/admin/bots/harness-overview`). They are most of a page's weight * (`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; if (!conversation) return null;
const scope = conversation.workspace_id; 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([ const [bots, workspaces] = await Promise.all([
grabBots({ user_id, workspace_id: scope }), grabBots({ user_id, workspace_id: scope }),
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
if (!bot?.id) return null; if (!bot?.id) return null;
/** Loading its history marks the open session read. */
bot.unread = 0; bot.unread = 0;
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([ const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
grabBotMessages({
session_id: session.id!,
user_id,
limit: AppData.ConversationFirstMessages + 1,
}),
grabTextModel({ bot_id: bot.id, workspace_id: scope }), grabTextModel({ bot_id: bot.id, workspace_id: scope }),
grabBotComputers({ 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 }), grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
grabConversationFiles({ 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: "transcription", bot_id: bot.id, workspace_id: scope }),
hasHarnessModel({ harness: "speech", 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 { return {
bots: railBots({ bots }), bots: railBots({ bots }),
bot: railBots({ bots: [bot] })[0], bot: railBots({ bots: [bot] })[0],
workspaces: railWorkspaces({ workspaces }), workspaces: railWorkspaces({ workspaces }),
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null, 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_text_model,
bot_computers, bot_computers,
bot_task,
bot_extensions, bot_extensions,
bot_files, bot_files,
bot_voice: { transcription, speech }, 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)],
});
}
@@ -0,0 +1,52 @@
import type { BotPermission } from "@/src/types";
import botCan from "@/src/utils/rbac/bot-can";
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
import grabBotTypes from "./grab-bot-types";
type Params = {
bot_id: number | string;
/**
* Where the run works: its own chat (null) or a workspace.
*/
workspace_id?: number | string | null;
};
export type BotRunPowers = {
bot_types: BotTypeRow[];
/**
* Holds the install-wide coordinator type.
*/
is_coordinator: boolean;
/**
* The workspaces it's admin of.
*/
admin_workspace_ids: number[];
/**
* Admin of the workspace the run is in.
*/
is_workspace_admin: boolean;
/**
* Whether its types allow `permission` where the run works. The bot's
* side only: the run's user must still be allowed.
*/
can: (permission: BotPermission) => boolean;
};
/**
* What a bot's types let it do on one run, read once (`grabBotTypes`).
*/
export default async function grabBotRunPowers({ bot_id, workspace_id }: Params): Promise<BotRunPowers> {
const bot_types = await grabBotTypes({ bot_id });
const admin_workspace_ids = bot_types
.filter((bt) => bt.bot_type == "admin" && bt.workspace_id)
.map((bt) => Number(bt.workspace_id));
return {
bot_types,
is_coordinator: bot_types.some((bt) => bt.bot_type == "coordinator" && !bt.workspace_id),
admin_workspace_ids,
is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))),
can: (permission) => botCan({ bot_types, permission, workspace_id }),
};
}
@@ -0,0 +1,21 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
type Params = {
bot_id: number | string;
};
/**
* A bot's types (`bot_types`), plus the install-wide coordinator type for
* the legacy `kind = 'coordinator'` bot, so the coordinator keeps its
* powers before `ensureCoordinatorBot` has written its row.
*/
export default async function grabBotTypes({ bot_id }: Params): Promise<BotTypeRow[]> {
const res = await BunSQLite.sql<BotTypeRow>({
sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ?
UNION SELECT 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id = ? AND kind = 'coordinator'`,
values: [Number(bot_id), Number(bot_id)],
});
return res.payload || [];
}
@@ -0,0 +1,34 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
type Params = {
bot_ids: (number | string | "" | undefined)[];
};
type Row = BotTypeRow & { bot_id: number };
/**
* `grabBotTypes` for a list of bots at once, keyed by bot id. A bot with no
* row is missing from the map (a worker).
*/
export default async function grabBotsTypes({ bot_ids }: Params): Promise<Map<number, BotTypeRow[]>> {
const ids = [...new Set(bot_ids.map(Number).filter(Boolean))];
const map = new Map<number, BotTypeRow[]>();
if (!ids.length) return map;
const marks = ids.map(() => "?").join(", ");
const res = await BunSQLite.sql<Row>({
sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks})
UNION SELECT id AS bot_id, 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id IN (${marks}) AND kind = 'coordinator'`,
values: [...ids, ...ids],
});
for (const row of res.payload || []) {
const list = map.get(row.bot_id) || [];
list.push({ bot_type: row.bot_type, workspace_id: row.workspace_id });
map.set(row.bot_id, list);
}
return map;
}
@@ -0,0 +1,35 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import botCanTarget from "@/src/utils/rbac/bot-can-target";
import grabCrewWorkers from "../bots/grab-crew-workers";
import grabBotTypes from "./grab-bot-types";
import grabBotsTypes from "./grab-bots-types";
type Params = {
/**
* The bot handing out the work.
*/
bot_id: number | string;
workspace_id: number | string;
};
/**
* The bots in a workspace that a bot may direct there (hand a task to, set
* instructions for): read fresh, so a member added mid-run is there, and
* filtered through `BotTypeTargetMatrix` (`botCanTarget`), never itself.
*/
export default async function grabDirectableBots({ bot_id, workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
const members = (await grabCrewWorkers({ workspace_id })).filter((bot) => bot.id != Number(bot_id));
if (!members.length) return [];
const actor_bot_types = await grabBotTypes({ bot_id });
const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) });
return members.filter((bot) =>
botCanTarget({
actor_bot_types,
target_bot_types: types.get(Number(bot.id)) || [],
workspace_id,
}),
);
}
@@ -0,0 +1,21 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
type Params = {
workspace_id: number | string;
};
/**
* The bots that are admins of a workspace and still work in it.
*/
export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b
JOIN bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?
JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = bt.workspace_id
GROUP BY b.id ORDER BY b.id ASC`,
values: [Number(workspace_id)],
});
return res.payload || [];
}
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import logEvent from "@/src/functions/backend/logger";
import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change";
/**
* Takes a workspace type away from a bot. Taking one it doesn't have is a
* no-op. Checked by `assertBotTypeChange`.
*/
export default async function removeBotType(params: BotTypeChangeParams) {
const { bot, workspace } = await assertBotTypeChange({ ...params, action: "delete" });
const res = await BunSQLite.sql({
sql: `DELETE FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ?`,
values: [Number(bot.id), params.bot_type, Number(workspace.id)],
});
if (!res.success) {
throw new Error(`Couldn't remove the bot's type: ${res.error}`);
}
await logEvent({
title: `Bot type removed`,
category: "system",
user_id: Number(params.user_id),
plain_text: `${bot.name} (#${bot.id}) is no longer ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`,
}).catch(() => {});
return { bot, workspace };
}
@@ -0,0 +1,45 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import logEvent from "@/src/functions/backend/logger";
import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change";
/**
* Gives a bot a workspace type (makes it admin of a workspace it works
* in). Giving it again is a no-op. Checked by `assertBotTypeChange`.
*/
export default async function setBotType(params: BotTypeChangeParams) {
const { bot, workspace } = await assertBotTypeChange({ ...params, action: "assign" });
const existing = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ? LIMIT 1`,
values: [Number(bot.id), params.bot_type, Number(workspace.id)],
});
if (existing.payload?.[0]?.id) return { bot, workspace, changed: false };
const res = await BunSQLite.sql({
sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, granted_by_user_id, granted_by_bot_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
Number(bot.id),
params.bot_type,
Number(workspace.id),
Number(params.user_id),
params.actor_bot_id ? Number(params.actor_bot_id) : null,
Date.now(),
Date.now(),
],
});
if (!res.success) {
throw new Error(`Couldn't save the bot's type: ${res.error}`);
}
await logEvent({
title: `Bot type given`,
category: "system",
user_id: Number(params.user_id),
plain_text: `${bot.name} (#${bot.id}) is now ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`,
}).catch(() => {});
return { bot, workspace, changed: true };
}
@@ -0,0 +1,46 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import answeredMessageIdSql from "./answered-message-id-sql";
/**
* What decides whether a queued reply still has something to answer, run
* against real SQLite: only finished replies in the same session count, so
* a failed or still running one never hides a message.
*/
const db = new Database(":memory:");
db.run(
`CREATE TABLE bots_queue (id INTEGER PRIMARY KEY, session_id INTEGER, kind TEXT, status TEXT, message_id INTEGER)`,
);
db.run(`INSERT INTO bots_queue VALUES
(1, 100, 'reply', 'completed', 10),
(2, 100, 'reply', 'completed', 14),
(3, 100, 'reply', 'processing', 20),
(4, 100, 'reply', 'failed', 22),
(5, 100, 'task', 'completed', 30),
(6, 101, 'reply', 'completed', 40),
(7, 102, 'reply', 'pending', 50)`);
function answered(session_id: number) {
const { sql, values } = answeredMessageIdSql({ session_id });
return (db.query(sql).get(...values) as { id: number | null }).id;
}
describe("answeredMessageIdSql", () => {
test("is the newest message a finished reply in the session read", () => {
expect(answered(100)).toBe(14);
expect(answered(101)).toBe(40);
});
test("ignores running and failed replies, and tasks", () => {
expect(answered(100)).not.toBe(20);
expect(answered(100)).not.toBe(22);
expect(answered(100)).not.toBe(30);
});
test("is null where no reply has finished", () => {
expect(answered(102)).toBe(null);
expect(answered(999)).toBe(null);
});
});
@@ -0,0 +1,20 @@
import type { SqlCondition } from "@/src/utils/crew-access/types";
type Params = {
session_id: number | string;
};
/**
* The SQL for the newest user message a finished reply in the session
* answered: a reply job's `message_id` is the newest one it read
* (`markReplyAnswered`), so a message with a higher id came in after the
* bot last looked and still needs an answer, wherever its reply landed in
* the thread. Selects one `id` column, null when no reply has finished.
*/
export default function answeredMessageIdSql({ session_id }: Params): SqlCondition {
return {
sql: `SELECT MAX(message_id) AS id FROM bots_queue
WHERE session_id = ? AND kind = 'reply' AND status = 'completed'`,
values: [Number(session_id)],
};
}
@@ -9,6 +9,7 @@ import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id"; import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id"; import grabJobUserId from "./grab-job-user-id";
import grabRunningBotJobFace from "./grab-running-bot-job-face"; import grabRunningBotJobFace from "./grab-running-bot-job-face";
import withMessageUsage from "../usage/with-message-usage";
type Params = { type Params = {
job_id: number | string; job_id: number | string;
@@ -78,7 +79,7 @@ export default async function completeBotJob({
}); });
} }
const message = silent const inserted = silent
? null ? null
: await insertBotMessage({ : await insertBotMessage({
bot_id: job.bot_id, bot_id: job.bot_id,
@@ -89,6 +90,26 @@ export default async function completeBotJob({
media_ids: attachments, media_ids: attachments,
}); });
if (job.id && inserted?.id) {
/**
* The job's model calls were written down as they happened, before
* its message existed. Now that it does, they belong to it, so the
* thread can show what the answer cost.
*/
await BunSQLite.sql({
sql: `UPDATE token_usages SET message_id = ? WHERE job_id = ? AND message_id IS NULL`,
values: [inserted.id, job.id],
}).catch(() => {});
}
/**
* Pushed live as it is returned, so it carries its usage now: the
* thread shows the tokens without a reload.
*/
const message = inserted
? ((await withMessageUsage({ messages: [inserted] }).catch(() => [inserted]))[0] ?? inserted)
: null;
const user_id = await grabJobUserId({ job }); const user_id = await grabJobUserId({ job });
const bot = !user_id const bot = !user_id
@@ -0,0 +1,19 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import answeredMessageIdSql from "./answered-message-id-sql";
type Params = {
session_id: number | string;
};
/**
* The newest user message a finished reply in the session answered
* (`answeredMessageIdSql`), or null when none is on record.
*/
export default async function grabAnsweredMessageId({ session_id }: Params) {
const { sql, values } = answeredMessageIdSql({ session_id });
const res = await BunSQLite.sql<{ id: number | null }>({ sql, values });
const id = res.payload?.[0]?.id;
return id ? Number(id) : null;
}
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
job_id: number | string;
/**
* The newest user message the reply read.
*/
message_id: number;
};
/**
* Records the newest user message a running reply read, so a reply queued
* for a message it already took in finishes silently and one it missed is
* still answered (`grabAnsweredMessageId`). Never lowers it.
*/
export default async function markReplyAnswered({ job_id, message_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bots_queue SET message_id = ?
WHERE id = ? AND status = 'processing' AND (message_id IS NULL OR message_id < ?)`,
values: [message_id, job_id, message_id],
});
}
@@ -38,6 +38,11 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
); );
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []); const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
/**
* Explicitly, since foreign keys are off outside the server process.
*/
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] }); const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
if (!res.success) { if (!res.success) {
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import { CoordinatorBotName } from "@/src/dict/bot-dict"; import { CoordinatorBotName } from "@/src/dict/bot-dict";
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt"; import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
import attachDefaultExtensions from "../extensions/attach-default-extensions"; import attachDefaultExtensions from "../extensions/attach-default-extensions";
import ensureCoordinatorType from "../bot-types/ensure-coordinator-type";
type Params = { type Params = {
/** /**
@@ -16,7 +17,9 @@ type Params = {
* alone), creating it first (with the default `CoordinatorSystemPrompt`) * alone), creating it first (with the default `CoordinatorSystemPrompt`)
* if there is none. A * if there is none. A
* coordinator without a prompt gets the default; an edited prompt is never * coordinator without a prompt gets the default; an edited prompt is never
* overwritten. Safe to call on every visit. * overwritten. Its install-wide `coordinator` bot type is written too
* (`ensureCoordinatorType`), which backfills older installs. Safe to call
* on every visit.
*/ */
export default async function ensureCoordinatorBot({ user_id }: Params) { export default async function ensureCoordinatorBot({ user_id }: Params) {
const existing = ( const existing = (
@@ -43,6 +46,8 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
}); });
} }
await ensureCoordinatorType({ bot_id: existing.id });
return existing.id; return existing.id;
} }
@@ -68,6 +73,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
} }
await attachDefaultExtensions({ bot_id }); await attachDefaultExtensions({ bot_id });
await ensureCoordinatorType({ bot_id });
return bot_id; return bot_id;
} }
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import grabMessageUsage from "../usage/grab-message-usage";
import grabMessagesMedia from "./grab-messages-media"; import grabMessagesMedia from "./grab-messages-media";
import grabMessagesReactions from "./grab-messages-reactions"; import grabMessagesReactions from "./grab-messages-reactions";
import grabMessagesReplies from "./grab-messages-replies"; import grabMessagesReplies from "./grab-messages-replies";
@@ -13,6 +14,15 @@ type Params = {
* Only messages older than this one, for loading further back. * Only messages older than this one, for loading further back.
*/ */
before_id?: number | null; before_id?: number | null;
/**
* Only messages saved after this one (by id, which is insertion
* order), for what arrived while a reply ran.
*/
after_id?: number | null;
/**
* Only one side's messages.
*/
author?: "user" | "bot";
/** /**
* Whose conversation it is, so a reaction can show as the user's own. * Whose conversation it is, so a reaction can show as the user's own.
* Left out for the bot's own history reads, which don't need it. * Left out for the bot's own history reads, which don't need it.
@@ -29,9 +39,12 @@ export default async function grabBotMessages({
session_id, session_id,
limit = 100, limit = 100,
before_id, before_id,
after_id,
author,
user_id, user_id,
}: Params): Promise<BotMessageWithAttachments[]> { }: Params): Promise<BotMessageWithAttachments[]> {
let sql = ``; let sql = ``;
const values: (string | number)[] = [session_id];
sql += `SELECT * FROM (\n`; sql += `SELECT * FROM (\n`;
sql += ` SELECT * FROM bot_messages\n`; sql += ` SELECT * FROM bot_messages\n`;
@@ -39,6 +52,17 @@ export default async function grabBotMessages({
if (before_id) { if (before_id) {
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`; sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
values.push(before_id);
}
if (after_id) {
sql += ` AND id > ?\n`;
values.push(after_id);
}
if (author) {
sql += ` AND author = ?\n`;
values.push(author);
} }
sql += ` ORDER BY created_at DESC, id DESC\n`; sql += ` ORDER BY created_at DESC, id DESC\n`;
@@ -47,18 +71,19 @@ export default async function grabBotMessages({
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({ const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql, sql,
values: before_id ? [session_id, before_id, limit] : [session_id, limit], values: [...values, limit],
}); });
const messages = res.payload || []; const messages = res.payload || [];
const message_ids = messages.map((message) => Number(message.id)); const message_ids = messages.map((message) => Number(message.id));
const [media, replies, reactions] = await Promise.all([ const [media, replies, reactions, usage] = await Promise.all([
grabMessagesMedia({ message_ids }), grabMessagesMedia({ message_ids }),
grabMessagesReplies({ message_ids }), grabMessagesReplies({ message_ids }),
user_id user_id
? grabMessagesReactions({ message_ids, user_id }) ? grabMessagesReactions({ message_ids, user_id })
: Promise.resolve(new Map()), : Promise.resolve(new Map()),
grabMessageUsage({ message_ids }),
]); ]);
/** /**
@@ -74,6 +99,8 @@ export default async function grabBotMessages({
const files = media.get(id); const files = media.get(id);
const reply_to = replies.get(id); const reply_to = replies.get(id);
const spent = usage.get(id);
return { return {
...message, ...message,
...(files ...(files
@@ -88,6 +115,7 @@ export default async function grabBotMessages({
: {}), : {}),
...(reply_to ? { reply_to } : {}), ...(reply_to ? { reply_to } : {}),
...(user_id ? { reactions: reactions.get(id) || [] } : {}), ...(user_id ? { reactions: reactions.get(id) || [] } : {}),
...(spent ? { usage: spent } : {}),
}; };
}); });
} }
@@ -19,10 +19,10 @@ type Params = {
}; };
/** /**
* The full prompt for one of the user's bots (`composeBotPrompt`): its * The full prompt for one of the user's bots (`composeBotPrompt`): who it
* core instructions, the install-wide appendix, its memory, what it * is (its name and id), its core instructions, the install-wide appendix,
* remembers about this user, and in a workspace that workspace's context * its memory, what it remembers about this user, and in a workspace that
* and what the bot has for it. * workspace's context and what the bot has for it.
*/ */
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) { export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id }); const bot = await grabUserBot({ bot_id, user_id });
@@ -40,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
} }
return composeBotPrompt({ return composeBotPrompt({
bot: { id: bot.id, name: bot.name, kind: bot.kind, role: bot.role },
system_prompt: bot.system_prompt, system_prompt: bot.system_prompt,
appendix: await grabSystemPromptAppend(), appendix: await grabSystemPromptAppend(),
memory: bot.memory, memory: bot.memory,
+6 -4
View File
@@ -20,7 +20,8 @@ type Params = {
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The * (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
* coordinator is pinned first, then workers by latest activity. Each row * coordinator is pinned first, then workers by latest activity. Each row
* carries the user's own face (`botFaceSql`) and conversation there: its * carries the user's own face (`botFaceSql`) and conversation there: its
* unread flag, latest activity and latest message. * unread flag, latest activity and latest message, and in a workspace
* whether it's that workspace's admin (`is_admin`).
*/ */
export default async function grabBots({ export default async function grabBots({
user_id, user_id,
@@ -40,7 +41,8 @@ export default async function grabBots({
sql += ` COALESCE(t.unread, 0) AS unread,\n`; sql += ` COALESCE(t.unread, 0) AS unread,\n`;
sql += ` t.last_activity_at AS last_activity_at,\n`; sql += ` t.last_activity_at AS last_activity_at,\n`;
sql += ` lm.text AS last_message_text,\n`; sql += ` lm.text AS last_message_text,\n`;
sql += ` lm.created_at AS last_message_at\n`; sql += ` lm.created_at AS last_message_at,\n`;
sql += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?) AS is_admin\n`;
sql += `FROM bots b\n`; sql += `FROM bots b\n`;
sql += `LEFT JOIN (\n`; sql += `LEFT JOIN (\n`;
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`; sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
@@ -59,8 +61,8 @@ export default async function grabBots({
const res = await BunSQLite.sql<BotWithLastMessage>({ const res = await BunSQLite.sql<BotWithLastMessage>({
sql, sql,
values: [...face.values, scope, user_id, scope, user_id, ...access.values], values: [...face.values, scope, scope, user_id, scope, user_id, ...access.values],
}); });
return res.payload || []; return (res.payload || []).map((bot) => ({ ...bot, is_admin: Boolean(bot.is_admin) }));
} }
@@ -13,6 +13,10 @@ type Params = {
workspace_id?: number | null; workspace_id?: number | null;
text: string; text: string;
needs_you?: boolean; needs_you?: boolean;
/**
* Sent mid-reply (`send_message`): the reply is still being worked on.
*/
interim?: boolean;
/** /**
* Files the bot saved and sent with it (`media` ids): a PDF it made, a * Files the bot saved and sent with it (`media` ids): a PDF it made, a
* generated image, a document it wrote. * generated image, a document it wrote.
@@ -30,10 +34,12 @@ export default async function insertBotMessage({
workspace_id, workspace_id,
text, text,
needs_you, needs_you,
interim,
media_ids = [], media_ids = [],
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> { }: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const now = Date.now(); const now = Date.now();
const needs_you_flag = needs_you ? 1 : 0; const needs_you_flag = needs_you ? 1 : 0;
const interim_flag = interim ? 1 : 0;
const res = await BunSQLite.insert< const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOT_MESSAGES,
@@ -48,6 +54,7 @@ export default async function insertBotMessage({
author: "bot", author: "bot",
text, text,
needs_you: needs_you_flag, needs_you: needs_you_flag,
interim: interim_flag,
}, },
], ],
}); });
@@ -78,6 +85,7 @@ export default async function insertBotMessage({
author: "bot", author: "bot",
text, text,
needs_you: needs_you_flag, needs_you: needs_you_flag,
interim: interim_flag,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -0,0 +1,36 @@
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import logEvent from "@/src/functions/backend/logger";
import provisionComputer from "./provision-computer";
type Params = {
computer_id: number;
/**
* Its container's `metadata`.
*/
metadata?: string | null;
};
/**
* Recreates a computer whose container still mounts its whole home (set up
* before only `AppData.ComputerWorkspaceDir` was mounted), so its bots work
* in the workspace alone. `provisionComputer` moves the old home aside
* (`prepareComputerDataDir`) and records the layout, so this runs once per
* computer. Returns whether it did.
*/
export default async function migrateComputerLayout({ computer_id, metadata }: Params) {
const layout = parseJobJson({ text: metadata || "" })?.layout;
if (layout == AppData.ComputerWorkspaceLayout) return false;
await provisionComputer({ computer_id });
await logEvent({
title: `Computer #${computer_id} now mounts only its workspace`,
category: "system",
plain_text: `Recreated computer #${computer_id} so only ${AppData.ComputerWorkspaceDir} is kept; its old home was moved aside and kept on disk.`,
metadata: { computer_id },
});
return true;
}
@@ -1,4 +1,5 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import grabComputerDataDir from "./grab-computer-data-dir"; import grabComputerDataDir from "./grab-computer-data-dir";
@@ -13,28 +14,45 @@ type Params = {
}; };
/** /**
* Creates a computer's home directory, owned by the sandbox's user so it * Creates a computer's workspace directory (mounted at
* can write there, and returns its path. Docker would otherwise create a * `AppData.ComputerWorkspaceDir`), with its `bots` and `workspaces`
* missing mount source owned by root. * folders, owned by the sandbox's user so it can write there, and returns
* its path. Docker would otherwise create a missing mount source owned by
* root.
*
* A directory without the layout marker (`AppData.ComputerWorkspaceMarker`)
* is a computer's whole home from before only the workspace was mounted:
* it is moved aside to `<id>-home-<timestamp>` and kept on disk (left out
* of data backups), so the computer starts with a clean workspace.
*/ */
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) { export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
const dir = grabComputerDataDir({ computer_id }); const dir = grabComputerDataDir({ computer_id });
const marker = path.join(dir, AppData.ComputerWorkspaceMarker);
if (fresh) {
const existing = await fs.readdir(dir).catch(() => null); const existing = await fs.readdir(dir).catch(() => null);
if (existing?.length) { if (existing?.length) {
if (fresh) {
await fs.rename(dir, `${dir}-${Date.now()}`); await fs.rename(dir, `${dir}-${Date.now()}`);
} else if (!(await fs.stat(marker).catch(() => null))) {
await fs.rename(dir, `${dir}${AppData.ComputerOldHomeSuffix}${Date.now()}`);
} }
} }
await fs.mkdir(dir, { recursive: true }); for (const folder of [dir, path.join(dir, "bots"), path.join(dir, "workspaces")]) {
await fs.mkdir(folder, { recursive: true });
await chownToSandbox({ target: folder });
}
/** await fs.writeFile(marker, "", { flag: "a" });
* Only root can hand a directory to another user; a dev server await chownToSandbox({ target: marker });
* running as that user already owns it.
*/
await fs.chown(dir, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
return dir; return dir;
} }
/**
* Only root can hand a file to another user; a dev server running as that
* user already owns it.
*/
async function chownToSandbox({ target }: { target: string }) {
await fs.chown(target, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
}
@@ -10,18 +10,20 @@ import pushComputerUpdate from "./push-computer-update";
type Params = { type Params = {
computer_id: number; computer_id: number;
/** /**
* A brand new computer: start from an empty home directory. * A brand new computer: start from an empty workspace.
*/ */
fresh?: boolean; fresh?: boolean;
}; };
/** /**
* Sets up a computer's container: prepares its home directory, pulls the * Sets up a computer's container: prepares its workspace directory, pulls the
* sandbox image and starts the container with the computer's resources * sandbox image and starts the container with the computer's resources
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`: * (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
* docker id, state, and its IP address on the network). A failure is * docker id, state, and its IP address on the network). A failure is
* recorded as `failed` with its reason, which the computer lists show. The * recorded as `failed` with its reason, which the computer lists show. The
* owner's tabs hear about it either way. Never throws. * owner's tabs hear about it either way. The metadata's `layout` says the
* container mounts only the workspace (`syncComputers` recreates one that
* doesn't). Never throws.
*/ */
export default async function provisionComputer({ computer_id, fresh }: Params) { export default async function provisionComputer({ computer_id, fresh }: Params) {
const row = ( const row = (
@@ -65,7 +67,12 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
created.ip_address, created.ip_address,
AppData.ComputerPort, AppData.ComputerPort,
created.status, created.status,
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }), JSON.stringify({
...kept,
data_dir,
host_dir: created.host_dir,
layout: AppData.ComputerWorkspaceLayout,
}),
Date.now(), Date.now(),
Date.now(), Date.now(),
row.id, row.id,
@@ -1,16 +1,16 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import resolveComputerHomeDir from "./resolve-computer-home-dir"; import resolveComputerWorkspaceDir from "./resolve-computer-workspace-dir";
const data_dir = "/data"; const data_dir = "/data";
describe("resolveComputerHomeDir", () => { describe("resolveComputerWorkspaceDir", () => {
test("uses the directory the app bind-mounted", () => { test("uses the directory the app bind-mounted", () => {
/** /**
* The truth for a container the app provisioned: on a bind mount * The truth for a container the app provisioned: on a bind mount
* that can be anywhere on the docker host, not under DATA_DIR. * that can be anywhere on the docker host, not under DATA_DIR.
*/ */
expect( expect(
resolveComputerHomeDir({ resolveComputerWorkspaceDir({
computer_id: 2, computer_id: 2,
kind: "sandbox", kind: "sandbox",
metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }), metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }),
@@ -22,7 +22,7 @@ describe("resolveComputerHomeDir", () => {
test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => { test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => {
expect( expect(
resolveComputerHomeDir({ resolveComputerWorkspaceDir({
computer_id: 1, computer_id: 1,
kind: "main", kind: "main",
metadata: null, metadata: null,
@@ -34,7 +34,7 @@ describe("resolveComputerHomeDir", () => {
test("leaves a sandbox alone whatever the main setting says", () => { test("leaves a sandbox alone whatever the main setting says", () => {
expect( expect(
resolveComputerHomeDir({ resolveComputerWorkspaceDir({
computer_id: 3, computer_id: 3,
kind: "sandbox", kind: "sandbox",
metadata: null, metadata: null,
@@ -45,26 +45,26 @@ describe("resolveComputerHomeDir", () => {
}); });
test("uses the conventional folder when nothing is recorded", () => { test("uses the conventional folder when nothing is recorded", () => {
expect(resolveComputerHomeDir({ computer_id: 4, kind: "main", data_dir })).toBe( expect(resolveComputerWorkspaceDir({ computer_id: 4, kind: "main", data_dir })).toBe(
"/data/computers/4", "/data/computers/4",
); );
}); });
test("ignores an empty or unreadable record", () => { test("ignores an empty or unreadable record", () => {
expect( expect(
resolveComputerHomeDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }), resolveComputerWorkspaceDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
).toBe("/data/computers/5"); ).toBe("/data/computers/5");
expect(resolveComputerHomeDir({ computer_id: 5, metadata: "not json", data_dir })).toBe( expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
"/data/computers/5", "/data/computers/5",
); );
expect(resolveComputerHomeDir({ computer_id: 5, metadata: null, data_dir })).toBe( expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: null, data_dir })).toBe(
"/data/computers/5", "/data/computers/5",
); );
}); });
test("normalizes a recorded relative path", () => { test("normalizes a recorded relative path", () => {
expect( expect(
resolveComputerHomeDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }), resolveComputerWorkspaceDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
).toBe("/srv/b"); ).toBe("/srv/b");
}); });
}); });
@@ -27,8 +27,9 @@ type Params = {
}; };
/** /**
* Where a computer's home (`AppData.ComputerHomeDir` in its container) * Where a computer's workspace (`AppData.ComputerWorkspaceDir` in its
* actually is on this app's disk, for the files browser and for anything * container, the only directory mounted from the data volume) actually is
* on this app's disk, for the files browser and for anything
* else that has to read a computer's files. * else that has to read a computer's files.
* *
* A container the app provisioned recorded the directory it bind-mounted * A container the app provisioned recorded the directory it bind-mounted
@@ -36,15 +37,16 @@ type Params = {
* that path can be anywhere the docker host allows. A computer the app * that path can be anywhere the docker host allows. A computer the app
* didn't provision has no record. That is the install's main computer on a * didn't provision has no record. That is the install's main computer on a
* server reached at `MAIN_COMPUTER_URL` (a dev server outside docker), * server reached at `MAIN_COMPUTER_URL` (a dev server outside docker),
* whose sandbox keeps its home wherever its compose file says, so * whose sandbox keeps its workspace wherever its compose file says, so
* `MAIN_COMPUTER_DATA_DIR` names that directory for this app. Without * `MAIN_COMPUTER_DATA_DIR` names that directory (the host side of
* `AppData.ComputerWorkspaceDir`) for this app. Without
* either, the conventional place every app-provisioned computer uses: * either, the conventional place every app-provisioned computer uses:
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`. * `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
* *
* Kept apart from the docker and database calls so the precedence is * Kept apart from the docker and database calls so the precedence is
* testable on its own. * testable on its own.
*/ */
export default function resolveComputerHomeDir({ export default function resolveComputerWorkspaceDir({
computer_id, computer_id,
kind, kind,
metadata, metadata,
@@ -87,7 +87,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id }, metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id },
}); });
} catch (error) { } catch (error) {
await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.` }) }); await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.`, log: false }) });
await logError({ await logError({
title: `Schedule #${id} couldn't run`, title: `Schedule #${id} couldn't run`,
@@ -0,0 +1,39 @@
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
import AppError from "@/src/utils/app-error";
import hasEnvTemplate from "@/src/utils/has-env-template";
import literalUrlHost from "@/src/utils/literal-url-host";
type Params =
| { kind: "mcp"; data: SaveMcpServerRequest }
| { kind: "tool"; data: SaveCustomToolRequest };
/**
* Throws for what a bot may never propose, checked when it proposes and
* again on confirm: a stdio server (a command in the app's container), a
* URL whose host a template decides (where a key goes must be readable,
* and a bot must not pick it per call), and envs in a prompt tool (the
* model's answer would carry the secret back past redaction).
*/
export default function checkProposalData(params: Params) {
if (params.kind == "mcp") {
if (params.data.transport != "remote") {
throw new AppError(`Only remote MCP servers can be proposed. A stdio one is added by the user in Settings, under MCPs.`);
}
if (!literalUrlHost({ url: params.data.url })) {
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
}
return;
}
const tool = params.data;
if (tool.kind == "http" && !literalUrlHost({ url: tool.config.url })) {
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
}
if (tool.kind == "prompt" && hasEnvTemplate({ values: [tool.config.prompt] })) {
throw new AppError(`A prompt tool can't read envs: the model's answer would carry the value back.`);
}
}
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import type { ExtensionProposalRecord } from "./parse-proposal-row";
type Params = {
proposal: ExtensionProposalRecord;
status: "confirmed" | "rejected";
};
/**
* Decides a proposal exactly once: only a pending one moves, and only a
* proposal that hasn't expired can be confirmed, so two tabs or a double
* click never save it twice. Throws why when it can't.
*/
export default async function claimExtensionProposal({ proposal, status }: Params) {
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE extension_proposals SET status = ?, decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' ${status == "confirmed" ? "AND expires_at > ?" : ""} RETURNING id`,
values: [status, now, now, proposal.id, ...(status == "confirmed" ? [now] : [])],
});
if (claimed.payload?.[0]) return;
if (proposal.status != "pending") throw new AppError(`This proposal was already ${proposal.status}.`);
throw new AppError(`This proposal expired. Ask the bot to propose it again.`);
}
@@ -0,0 +1,122 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import redactSecrets from "@/src/utils/redact-secrets";
import grabAllEnvs from "../envs/grab-all-envs";
import grabCustomTool from "../extensions/grab-custom-tool";
import grabMcpServer from "../extensions/grab-mcp-server";
import saveCustomTool from "../extensions/save-custom-tool";
import saveMcpServer from "../extensions/save-mcp-server";
import grabSkill from "../extensions/grab-skill";
import saveSkill from "../extensions/save-skill";
import syncMcpServer from "../extensions/sync-mcp-server";
import extensionEnvUses from "@/src/utils/extension-env-uses";
import checkProposalData from "./check-proposal-data";
import claimExtensionProposal from "./claim-extension-proposal";
import grabExtensionProposal from "./grab-extension-proposal";
import grabProposalEnvs from "./grab-proposal-envs";
type Params = {
user_id: number | string;
proposal_id: number | string;
};
/**
* The user confirmed a proposal from its card: claims it (once, and only
* while it's pending and not expired), checks it again, then saves it
* with the settings pages' own helpers. A skill is saved as proposed. A server or tool that sends a key
* that isn't saved yet is saved switched off, and the outcome names the
* keys to add. A remote server that's on is connected and its tools
* listed. Whatever happens is recorded on the proposal as its `outcome`.
*/
export default async function confirmExtensionProposal({ user_id, proposal_id }: Params) {
const proposal = await grabExtensionProposal({ proposal_id, user_id });
await claimExtensionProposal({ proposal, status: "confirmed" });
try {
if (proposal.kind == "skill") {
if (proposal.extension_id) await grabSkill({ skill_id: proposal.extension_id });
const result_id = await saveSkill({
user_id,
data: { ...proposal.data, skill_id: proposal.extension_id || undefined },
});
const outcome = `Saved as skill #${result_id} (slug ${proposal.data.slug}).${
!proposal.extension_id && !proposal.data.all_bots ? " No bot gets it yet." : ""
}`;
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
values: [outcome, result_id, Date.now(), proposal.id],
});
return proposal;
}
checkProposalData(proposal);
const { sends } = extensionEnvUses(proposal);
const missing = [
...new Set(
(await grabProposalEnvs({ sends })).filter((env) => !env.saved_in.length).map((env) => env.key),
),
];
const keys_note = missing.length
? ` It's switched off until ${missing.length == 1 ? "this key is" : "these keys are"} added in Settings, under Environment: ${missing.join(", ")}. Then switch it on in Settings, under ${proposal.kind == "mcp" ? "MCPs" : "Tools"}.`
: "";
let outcome = "";
let result_id = 0;
if (proposal.kind == "mcp") {
if (proposal.extension_id) {
const current = await grabMcpServer({ mcp_server_id: proposal.extension_id });
if (current.transport != "remote") throw new AppError(`${current.name} runs a command here, so it can't be changed from a proposal.`);
}
const data = { ...proposal.data, mcp_server_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
result_id = await saveMcpServer({ user_id, data });
const sync = data.enabled ? await syncMcpServer({ mcp_server_id: result_id }) : null;
outcome = `Saved as MCP server #${result_id}.${keys_note}${
!sync
? missing.length
? ""
: " It's off, so it wasn't connected."
: sync.ok
? ` It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
: ` It didn't connect: ${sync.error}`
}`;
} else {
if (proposal.extension_id) await grabCustomTool({ custom_tool_id: proposal.extension_id });
const data = { ...proposal.data, custom_tool_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
result_id = await saveCustomTool({ user_id, data });
outcome = `Saved as tool #${result_id}.${keys_note}`;
}
if (!proposal.extension_id && !proposal.data.all_bots) outcome += ` No bot gets it yet.`;
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
values: [outcome, result_id, Date.now(), proposal.id],
});
} catch (error: unknown) {
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
const message = redactSecrets({
text: error instanceof Error ? error.message : String(error),
secrets,
}).slice(0, 2000);
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
});
}
return proposal;
}
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { SaveCustomToolRequest, SaveMcpServerRequest, SaveSkillRequest } from "@/src/schemas/extension-schema";
import checkProposalData from "./check-proposal-data";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id: number | string;
workspace_id?: number | string | null;
research: string;
sources: string[];
} & (
| { kind: "mcp"; data: SaveMcpServerRequest }
| { kind: "tool"; data: SaveCustomToolRequest }
| { kind: "skill"; data: SaveSkillRequest }
);
/**
* Stores what a bot proposes adding or changing (an MCP server, a tool or
* a skill), pending until the user
* confirms it from the chat. Nothing is saved, connected or run here. A
* proposal it makes again for the same server or tool in the same
* conversation replaces the one before, so only the latest card can be
* confirmed. Returns its id.
*/
export default async function createExtensionProposal(params: Params) {
if (params.kind != "skill") checkProposalData(params);
const extension_id =
(params.kind == "mcp"
? params.data.mcp_server_id
: params.kind == "skill"
? params.data.skill_id
: params.data.custom_tool_id) || undefined;
const now = Date.now();
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, (typeof BunSQLiteTables)[number]>({
table: "extension_proposals",
data: [
{
kind: params.kind,
extension_id,
name: params.data.name,
data: JSON.stringify(params.data),
research: params.research,
sources: JSON.stringify(params.sources),
status: "pending",
outcome: "",
expires_at: now + AppData.ExtensionProposalTtlMs,
user_id: Number(params.user_id),
bot_id: Number(params.bot_id),
session_id: Number(params.session_id),
workspace_id: params.workspace_id ? Number(params.workspace_id) : undefined,
},
],
});
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
if (!res.success || !id) throw new Error(`Couldn't store the proposal: ${res.msg}`);
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET status = 'replaced', outcome = ?, decided_at = ?, updated_at = ?
WHERE session_id = ? AND kind = ? AND status = 'pending' AND id != ?
AND (${extension_id ? "extension_id = ?" : "extension_id IS NULL AND name = ? COLLATE NOCASE"})`,
values: [
`Replaced by proposal #${id}.`,
now,
now,
Number(params.session_id),
params.kind,
id,
extension_id || params.data.name,
],
});
return id;
}
@@ -0,0 +1,68 @@
import type { ExtensionProposalView } from "@/src/types";
import describeProposalDetails from "@/src/utils/describe-proposal-details";
import describeSkillProposal from "@/src/utils/describe-skill-proposal";
import grabSkill from "../extensions/grab-skill";
import extensionEnvUses from "@/src/utils/extension-env-uses";
import grabExtensionProposal from "./grab-extension-proposal";
import grabProposalEnvs from "./grab-proposal-envs";
import type { ExtensionProposalRecord } from "./parse-proposal-row";
type Params = {
proposal_id: number | string;
user_id: number | string;
};
/**
* A proposal as its chip and confirm card show it: the facts (host, envs
* and whether each is saved, every detail of the config; for a skill what
* its runbook points at and what changed) worked out from the config now,
* so a key added since shows as saved; the bot's research beside them,
* labelled as its own.
*/
export default async function grabExtensionProposalView({ proposal_id, user_id }: Params): Promise<ExtensionProposalView> {
const proposal = await grabExtensionProposal({ proposal_id, user_id });
const facts = await grabProposalFacts({ proposal });
return {
id: proposal.id,
kind: proposal.kind,
extension_id: proposal.extension_id,
name: proposal.name,
status: proposal.status,
expired: proposal.status == "pending" && proposal.expires_at < Date.now(),
expires_at: proposal.expires_at,
outcome: proposal.outcome,
result_id: proposal.result_id,
...facts,
research: proposal.research,
sources: proposal.sources,
};
}
/**
* The facts a proposal's card shows, worked out now: for a server or tool
* its host, its envs and whether each is saved, and its config; for a
* skill what its runbook points bots at and what changed against the
* saved one (a deleted skill shows as new).
*/
async function grabProposalFacts({ proposal }: { proposal: ExtensionProposalRecord }) {
if (proposal.kind == "skill") {
const current = proposal.extension_id
? await grabSkill({ skill_id: proposal.extension_id }).catch(() => null)
: null;
return { host: null, envs: [], details: describeSkillProposal({ data: proposal.data, current }) };
}
const typed =
proposal.kind == "mcp"
? ({ kind: "mcp", data: proposal.data } as const)
: ({ kind: "tool", data: proposal.data } as const);
const { host, sends } = extensionEnvUses(typed);
return {
host,
envs: await grabProposalEnvs({ sends }),
details: describeProposalDetails({ ...typed, is_new: !proposal.extension_id }),
};
}
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import parseProposalRow from "./parse-proposal-row";
type Params = {
proposal_id: number | string;
/**
* The super admin it was proposed to: nobody else reads it.
*/
user_id: number | string;
};
/**
* One proposal made to this user, or an `AppError`.
*/
export default async function grabExtensionProposal({ proposal_id, user_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS>({
sql: `SELECT * FROM extension_proposals WHERE id = ? AND user_id = ?`,
values: [proposal_id, user_id],
})
).payload?.[0];
if (!row) throw new AppError(`That proposal doesn't exist.`);
return parseProposalRow({ row });
}
@@ -0,0 +1,53 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { EnvDict } from "@/src/dict/env-dict";
import type { ExtensionProposalEnv } from "@/src/types";
import type { ExtensionEnvUse } from "@/src/utils/extension-env-uses";
import grabAllEnvs from "../envs/grab-all-envs";
type Params = {
sends: ExtensionEnvUse[];
};
/**
* Each env a proposed server or tool sends, with where it's saved: the
* scopes templates read (`grabRunEnvs`: general, a bot's own, a
* workspace's), named. App settings never fill a template, so one of
* those keys reads as missing.
*/
export default async function grabProposalEnvs({ sends }: Params): Promise<ExtensionProposalEnv[]> {
const app_keys = new Set(EnvDict.map((env) => env.key));
const envs = (await grabAllEnvs()).filter(
(env) => !app_keys.has(env.key) && ["general", "bot", "workspace"].includes(env.paradigm),
);
const bot_ids = [...new Set(envs.map((env) => env.bot_id).filter(Boolean))];
const workspace_ids = [...new Set(envs.map((env) => env.workspace_id).filter(Boolean))];
const [bots, workspaces] = await Promise.all([
names({ table: "bots", ids: bot_ids as number[] }),
names({ table: "workspaces", ids: workspace_ids as number[] }),
]);
return sends.map((use) => ({
...use,
saved_in: envs
.filter((env) => env.key == use.key)
.map((env) =>
env.paradigm == "general"
? "general"
: env.paradigm == "bot"
? `bot ${bots.get(Number(env.bot_id)) || env.bot_id} only`
: `workspace ${workspaces.get(Number(env.workspace_id)) || env.workspace_id} only`,
),
}));
}
async function names({ table, ids }: { table: "bots" | "workspaces"; ids: number[] }) {
if (!ids.length) return new Map<number, string>();
const rows = await BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM ${table} WHERE id IN (${ids.map(() => "?").join(", ")})`,
values: ids,
});
return new Map((rows.payload || []).map((row) => [Number(row.id), row.name || ""]));
}
@@ -0,0 +1,64 @@
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
import type { ExtensionProposalKind, ExtensionProposalStatus } from "@/src/dict/extension-proposal-dict";
import {
SaveCustomToolRequestSchema,
SaveMcpServerRequestSchema,
SaveSkillRequestSchema,
type SaveCustomToolRequest,
type SaveMcpServerRequest,
type SaveSkillRequest,
} from "@/src/schemas/extension-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import parseJsonColumn from "../extensions/parse-json-column";
type Params = {
row: BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS;
};
export type ExtensionProposalRecord = {
id: number;
extension_id: number | null;
name: string;
research: string;
sources: string[];
status: ExtensionProposalStatus;
outcome: string;
result_id: number | null;
expires_at: number;
user_id: number;
bot_id: number;
session_id: number;
} & (
| { kind: "mcp"; data: SaveMcpServerRequest }
| { kind: "tool"; data: SaveCustomToolRequest }
| { kind: "skill"; data: SaveSkillRequest }
);
/**
* An `extension_proposals` row with its config read and checked again by
* the settings forms' schemas, so what confirm saves is what they'd allow.
*/
export default function parseProposalRow({ row }: Params): ExtensionProposalRecord {
const kind = (row.kind || "mcp") as ExtensionProposalKind;
const raw = parseJsonColumn<Record<string, unknown>>({ text: row.data, fallback: {} });
const base = {
id: Number(row.id),
extension_id: row.extension_id ? Number(row.extension_id) : null,
name: row.name || "",
research: row.research || "",
sources: parseJsonColumn<string[]>({ text: row.sources, fallback: [] }),
status: (row.status || "pending") as ExtensionProposalStatus,
outcome: row.outcome || "",
result_id: row.result_id ? Number(row.result_id) : null,
expires_at: Number(row.expires_at) || 0,
user_id: Number(row.user_id),
bot_id: Number(row.bot_id),
session_id: Number(row.session_id),
};
if (kind == "mcp") return { ...base, kind, data: parseWithSchema({ schema: SaveMcpServerRequestSchema, data: raw }) };
if (kind == "skill") return { ...base, kind, data: parseWithSchema({ schema: SaveSkillRequestSchema, data: raw }) };
return { ...base, kind: "tool", data: parseWithSchema({ schema: SaveCustomToolRequestSchema, data: raw }) };
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import claimExtensionProposal from "./claim-extension-proposal";
import grabExtensionProposal from "./grab-extension-proposal";
type Params = {
user_id: number | string;
proposal_id: number | string;
};
/**
* The user rejected a proposal from its card: nothing is saved.
*/
export default async function rejectExtensionProposal({ user_id, proposal_id }: Params) {
const proposal = await grabExtensionProposal({ proposal_id, user_id });
await claimExtensionProposal({ proposal, status: "rejected" });
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET outcome = 'Rejected. Nothing was saved.', updated_at = ? WHERE id = ?`,
values: [Date.now(), proposal.id],
});
return proposal;
}
@@ -0,0 +1,20 @@
import { grabSkillBodyMaxChars } from "../app-settings/grab-skill-limits";
import AppError from "@/src/utils/app-error";
type Params = {
body: string;
};
/**
* Refuses a runbook longer than the install's limit (Settings > General).
* The request schemas only hold the ceiling, since the limit is a setting.
*/
export default async function assertSkillBodyFits({ body }: Params) {
const max = await grabSkillBodyMaxChars();
if (body.length > max) {
throw new AppError(
`Keep the runbook under ${max} characters (it has ${body.length}). A super admin can raise the limit on Settings > General.`,
);
}
}
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillsOverviewPayload } from "@/src/types"; import type { SkillsOverviewPayload } from "@/src/types";
import grabSkillLimitsOverview from "../app-settings/grab-skill-limits";
import grabCrewRole from "../access/grab-crew-role"; import grabCrewRole from "../access/grab-crew-role";
import grabExtensionAccess from "./grab-extension-access"; import grabExtensionAccess from "./grab-extension-access";
import grabExtensionTargets from "./grab-extension-targets"; import grabExtensionTargets from "./grab-extension-targets";
@@ -17,14 +18,16 @@ type Params = {
*/ */
export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> { export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> {
const role = await grabCrewRole({ user_id }); const role = await grabCrewRole({ user_id });
const [rows, access, targets] = await Promise.all([ const [rows, access, targets, limits] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }), BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }),
grabExtensionAccess({ kind: "skill" }), grabExtensionAccess({ kind: "skill" }),
grabExtensionTargets({ role }), grabExtensionTargets({ role }),
grabSkillLimitsOverview(),
]); ]);
return { return {
...targets, ...targets,
limits,
skills: (rows.payload || []).map((row) => { skills: (rows.payload || []).map((row) => {
const { all_bots, ...skill } = parseSkillRow({ row }); const { all_bots, ...skill } = parseSkillRow({ row });

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