Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac5d41474 | ||
|
|
fcad4b49d6 | ||
|
|
5beb5f3de4 | ||
|
|
796c53716f | ||
|
|
5af2f0362a |
@@ -16,7 +16,11 @@ jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The publish tests read real release tags out of the history, so a
|
||||
# shallow, tagless clone makes them fail.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
@@ -100,18 +100,54 @@ jobs:
|
||||
|
||||
# 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.
|
||||
# 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 -euo pipefail
|
||||
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"
|
||||
docker buildx imagetools create "${args[@]}" "${first_ghcr}"
|
||||
|
||||
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"
|
||||
|
||||
@@ -105,14 +105,14 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
- **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. Live notices come from the process that ran the job at once, and from every other process through the live relay.
|
||||
- **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`), `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:** 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`).
|
||||
@@ -120,7 +120,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
- **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 them (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img src="docs/readme/lockup-dark.svg#gh-dark-mode-only" alt="Cobble" height="56">
|
||||
</h1>
|
||||
|
||||
A crew of small AI worker bots you host yourself.
|
||||
A crew of small AI worker bots you host yourself (alpha).
|
||||
|
||||

|
||||

|
||||
@@ -19,6 +19,13 @@ A crew of small AI worker bots you host yourself.
|
||||
|
||||
</div>
|
||||
|
||||
> **Alpha.** Cobble is still in alpha: interfaces, database schema and
|
||||
> settings change between releases, and things break. Install it to try it
|
||||
> out and to tell us what is wrong, but keep your own backups (Settings >
|
||||
> Backups) and do not put it in front of work you cannot lose. Every release
|
||||
> is a real release: the installer updates an existing install in place and
|
||||
> keeps its data.
|
||||
|
||||
Cobble is a self-hosted crew of small AI worker bots. You get a coordinator
|
||||
and a set of workers, each with its own job, models, memory and workspace.
|
||||
They talk to you in one chat, share workspaces, run routines on a schedule,
|
||||
@@ -49,6 +56,7 @@ your data on your own machine.
|
||||
| Crew | One coordinator and as many workers as you need, each with a name, a role, a hue and its own core instructions |
|
||||
| Rail | The coordinator, your recent workers and their latest sessions, then your workspaces, each row carrying its status line |
|
||||
| Chat | Markdown with @-mentions for bots and #-mentions for workspaces, replies and quotes, reactions, attachments, older messages on scroll, drafts kept locally |
|
||||
| Stop | Stop a bot from the thinking row or the header menu: its queued and running work stops at once, everywhere you chat with it, and it goes idle |
|
||||
| Header | The bot's model, provider and computer at a glance, one click from either, and its picture, instructions and settings on the avatar |
|
||||
| Workspaces | A shared home for a group of bots: shared context, per-workspace instructions and memory, a default computer, and a dashboard |
|
||||
| Memory | Each bot keeps core memory, memory about you, and workspace memory, and rewrites it as it works |
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
Cobble ships as a multi-arch container image and a single `install.sh`.
|
||||
The repository starts private and is made public at the launch gate below.
|
||||
|
||||
Cobble is in **alpha** (see the note in `README.md`). Every release published
|
||||
before v1.0.0 is an alpha release: interfaces, the database schema and the
|
||||
settings may change between them, so a release that needs a migration step
|
||||
has to say so in its notes, and Settings > General keeps saying the stage
|
||||
until v1.0.0.
|
||||
|
||||
## One-time setup
|
||||
|
||||
- [ ] `GITEA_USERNAME` and `GITEA_TOKEN` are set as GitHub Actions secrets.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -134,9 +134,24 @@ prepare_tag() {
|
||||
# END:tag
|
||||
|
||||
# MARK:notes-file
|
||||
# Writes release_notes into NOTES_FILE, which the caller cleans up.
|
||||
# Writes release_notes into NOTES_FILE, which the caller cleans up. The
|
||||
# compare base is the host's /compare/ path; the tag range is added here,
|
||||
# since only this side knows which tag came before (a bare /compare/ opens
|
||||
# the host's default comparison rather than this release's).
|
||||
build_compare_url() {
|
||||
local compare_base="$1"
|
||||
local from="$2"
|
||||
|
||||
if [ -z "$from" ]; then
|
||||
printf '%s' "$compare_base"
|
||||
return 0
|
||||
fi
|
||||
printf '%s%s...%s' "$compare_base" "$from" "$TAG"
|
||||
}
|
||||
|
||||
write_notes_file() {
|
||||
local compare_url="$1"
|
||||
local compare_base="$1"
|
||||
local compare_url=""
|
||||
local from=""
|
||||
# A dry run makes no tag, so the notes are read up to HEAD instead.
|
||||
local to="$TAG"
|
||||
@@ -153,6 +168,8 @@ write_notes_file() {
|
||||
detail "No earlier tag, so every commit is listed."
|
||||
fi
|
||||
|
||||
compare_url="$(build_compare_url "$compare_base" "$from")"
|
||||
|
||||
NOTES_FILE="$(mktemp -t cobble-release-notes.XXXXXX)"
|
||||
release_notes --from "$from" --to "$to" --compare-url "$compare_url" >"$NOTES_FILE"
|
||||
ok "Wrote the release notes to $NOTES_FILE."
|
||||
|
||||
@@ -208,6 +208,9 @@ else
|
||||
if [ -n "${response_file:-}" ]; then
|
||||
rm -f "$response_file"
|
||||
fi
|
||||
if [ "$api_status" = "403" ]; then
|
||||
fail "Gitea answered HTTP 403: the token's user cannot write to $REPO_SLUG. The tags and $BRANCH still push over SSH, but creating a release needs the token's user to be a writer (write or admin) on the repository. Check it with: curl -s -H \"Authorization: token \$GITEA_TOKEN\" $API_URL/repos/$REPO_SLUG | grep -o '\"permissions\":{[^}]*}'"
|
||||
fi
|
||||
fail "Gitea answered HTTP $api_status for the release."
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -292,6 +292,38 @@ describe("publish lib release notes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish lib compare link", () => {
|
||||
// The range is what makes the link open this release's diff; a bare
|
||||
// /compare/ opened the host's default comparison instead.
|
||||
test("adds the tag range to the compare path", async () => {
|
||||
const block = await blockCaller({ file: RELEASE, name: "notes-file" });
|
||||
|
||||
const out = await runBash({
|
||||
script: [
|
||||
block,
|
||||
`TAG=v0.1.4`,
|
||||
`build_compare_url https://example.test/compare/ v0.1.3`,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
expect(out).toBe("https://example.test/compare/v0.1.3...v0.1.4");
|
||||
});
|
||||
|
||||
test("leaves the path alone when there is no earlier tag", async () => {
|
||||
const block = await blockCaller({ file: RELEASE, name: "notes-file" });
|
||||
|
||||
const out = await runBash({
|
||||
script: [
|
||||
block,
|
||||
`TAG=v0.1.0`,
|
||||
`build_compare_url https://example.test/compare/ ""`,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
expect(out).toBe("https://example.test/compare/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish lib json escaping", () => {
|
||||
const sample = 'a\nb\t"c" \\ é';
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { abortRunningJob, registerJobAbort, unregisterJobAbort } from "./job-abort-registry";
|
||||
|
||||
describe("job-abort-registry", () => {
|
||||
test("aborts the job this process is running", () => {
|
||||
let aborted = false;
|
||||
|
||||
registerJobAbort({ job_id: 101, abort: () => (aborted = true) });
|
||||
|
||||
expect(abortRunningJob({ job_id: 101 })).toBe(true);
|
||||
expect(aborted).toBe(true);
|
||||
|
||||
unregisterJobAbort({ job_id: 101 });
|
||||
});
|
||||
|
||||
test("a job running in another process is not here, so it isn't reported as aborted", () => {
|
||||
expect(abortRunningJob({ job_id: 202 })).toBe(false);
|
||||
});
|
||||
|
||||
test("a finished job is cleaned up, so its id can't abort a later one", () => {
|
||||
let aborted = false;
|
||||
|
||||
registerJobAbort({ job_id: 303, abort: () => (aborted = true) });
|
||||
unregisterJobAbort({ job_id: 303 });
|
||||
|
||||
expect(abortRunningJob({ job_id: 303 })).toBe(false);
|
||||
expect(aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("a numeric string id reaches the same entry, since a route may send one", () => {
|
||||
let aborted = false;
|
||||
|
||||
registerJobAbort({ job_id: 404, abort: () => (aborted = true) });
|
||||
|
||||
expect(abortRunningJob({ job_id: "404" })).toBe(true);
|
||||
expect(aborted).toBe(true);
|
||||
|
||||
unregisterJobAbort({ job_id: 404 });
|
||||
});
|
||||
|
||||
test("unregistering a job nobody registered is harmless", () => {
|
||||
expect(() => unregisterJobAbort({ job_id: 505 })).not.toThrow();
|
||||
});
|
||||
|
||||
test("only one job is aborted at a time", () => {
|
||||
const aborted: number[] = [];
|
||||
|
||||
registerJobAbort({ job_id: 606, abort: () => aborted.push(606) });
|
||||
registerJobAbort({ job_id: 707, abort: () => aborted.push(707) });
|
||||
|
||||
abortRunningJob({ job_id: 606 });
|
||||
|
||||
expect(aborted).toEqual([606]);
|
||||
|
||||
unregisterJobAbort({ job_id: 606 });
|
||||
unregisterJobAbort({ job_id: 707 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The abort controllers of the jobs this process is running right now,
|
||||
* keyed by job id. Stopping a bot from the chat aborts the request its job
|
||||
* is waiting on (a model that never answers, a tool that hangs) instead of
|
||||
* leaving it to find out on its next heartbeat, which can take minutes.
|
||||
*
|
||||
* Only the process running a job can abort it, so this is a bonus: the job
|
||||
* is already failed in `bots_queue`, and a handler in another process
|
||||
* learns that on its next `updateBotJob`. Kept free of imports so the API
|
||||
* routes can use it without pulling the queue handler into a page bundle.
|
||||
*/
|
||||
const running = new Map<number, () => void>();
|
||||
|
||||
/**
|
||||
* Called by a handler once it holds a job, with the way to abort it.
|
||||
*/
|
||||
export function registerJobAbort({ job_id, abort }: { job_id: number | string; abort: () => void }) {
|
||||
running.set(Number(job_id), abort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by a handler in its `finally`, whatever the outcome.
|
||||
*/
|
||||
export function unregisterJobAbort({ job_id }: { job_id: number | string }) {
|
||||
running.delete(Number(job_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts a job running here. False means another process (or nobody) has
|
||||
* it, so the row's new status is how it finds out.
|
||||
*/
|
||||
export function abortRunningJob({ job_id }: { job_id: number | string }) {
|
||||
const abort = running.get(Number(job_id));
|
||||
|
||||
if (!abort) return false;
|
||||
|
||||
abort();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import runBotJob from "./run-bot-job";
|
||||
import parseJobJson from "./parse-job-json";
|
||||
import nameBotSession from "./name-bot-session";
|
||||
import pushBotSession from "./push-bot-session";
|
||||
import { registerJobAbort, unregisterJobAbort } from "./job-abort-registry";
|
||||
import type { BotJobUpdateData } from "@/src/types";
|
||||
import type { BotJobContext, BotJobRunner } from "./types";
|
||||
|
||||
@@ -80,11 +81,6 @@ export default async function processBotJob({
|
||||
*/
|
||||
const user_id = await grabJobUserId({ job }).catch(() => null);
|
||||
|
||||
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
|
||||
if (!user_id) return;
|
||||
|
||||
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
|
||||
};
|
||||
const controller = new AbortController();
|
||||
/**
|
||||
* The deadline is the one abort that is a failure, not a cancellation:
|
||||
@@ -94,6 +90,25 @@ export default async function processBotJob({
|
||||
const timeout_message = `"${job.title || "The job"}" took too long and was stopped.`;
|
||||
let timed_out = false;
|
||||
|
||||
/**
|
||||
* A stopped job never touches the face again: `stopBotJobs` already set
|
||||
* it to idle, and a late `working` or `asleep` would push over that and
|
||||
* leave a bot that shows work with nothing behind it.
|
||||
*/
|
||||
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
|
||||
if (!user_id || controller.signal.aborted) return;
|
||||
|
||||
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Stopping the bot from the chat aborts the request this job is
|
||||
* waiting on, here and now, rather than at the next heartbeat. The job
|
||||
* is already failed in the queue, so an abort is a cancellation and its
|
||||
* result is dropped (`controller.signal.aborted` below).
|
||||
*/
|
||||
registerJobAbort({ job_id, abort: () => controller.abort() });
|
||||
|
||||
const update: BotJobContext["update"] = async (params) => {
|
||||
if (controller.signal.aborted) return false;
|
||||
|
||||
@@ -305,5 +320,6 @@ export default async function processBotJob({
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
unregisterJobAbort({ job_id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ type Params = {
|
||||
* (`relayJobToRequester`): a message in that bot's conversation.
|
||||
*/
|
||||
relay?: { bot_id: number; message: BUN_SQLITE_COBBLE_BOT_MESSAGES } | null;
|
||||
/**
|
||||
* Skip the push notification, keeping only the live send. For a message
|
||||
* the user just caused themselves (stopping the bot): they are looking
|
||||
* at the app, and one notification per conversation would be noise.
|
||||
*/
|
||||
quiet_push?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,9 +33,9 @@ type Params = {
|
||||
* goes to the conversation it was posted in. Best effort: a push that
|
||||
* fails is logged and never fails the job that made the change.
|
||||
*/
|
||||
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
|
||||
export default async function pushBotNotice({ bot_id, message, bot, relay, quiet_push }: Params) {
|
||||
try {
|
||||
await push({ bot_id, message, bot, relay });
|
||||
await push({ bot_id, message, bot, relay, quiet_push });
|
||||
} catch (error) {
|
||||
await logError({
|
||||
title: "Bot notice wasn't pushed",
|
||||
@@ -39,7 +45,7 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
|
||||
}
|
||||
}
|
||||
|
||||
async function push({ bot_id, message, bot, relay }: Params) {
|
||||
async function push({ bot_id, message, bot, relay, quiet_push }: Params) {
|
||||
if (message) {
|
||||
await pushToSessionUser({
|
||||
session_id: message.session_id,
|
||||
@@ -49,7 +55,7 @@ async function push({ bot_id, message, bot, relay }: Params) {
|
||||
/**
|
||||
* Not waited on: a push service's latency must not slow the job.
|
||||
*/
|
||||
void notifyBotMessage({ message });
|
||||
if (!quiet_push) void notifyBotMessage({ message });
|
||||
}
|
||||
|
||||
if (relay) {
|
||||
@@ -58,7 +64,7 @@ async function push({ bot_id, message, bot, relay }: Params) {
|
||||
data: { event: "server:bot-message", data: relay.message },
|
||||
});
|
||||
|
||||
void notifyBotMessage({ message: relay.message });
|
||||
if (!quiet_push) void notifyBotMessage({ message: relay.message });
|
||||
}
|
||||
|
||||
if (bot) {
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief.
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief. The
|
||||
* routines page and the conversation's routines modal both edit with it.
|
||||
*/
|
||||
export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
||||
const [title, setTitle] = useState(cron_job.title || "");
|
||||
+16
-5
@@ -1,10 +1,10 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import routineSubtitle from "../(utils)/routine-subtitle";
|
||||
import routineSubtitle from "@/src/utils/routine-subtitle";
|
||||
import BotAvatar from "./bot-avatar";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
@@ -12,13 +12,18 @@ type Props = {
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
/**
|
||||
* In a conversation's own modal the Chat link and the bot's and
|
||||
* workspace's names are already the ones on screen.
|
||||
*/
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One routine: its bot's avatar, its name (Paused or Done when it is),
|
||||
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete.
|
||||
*/
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) {
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, compact }: Props) {
|
||||
const title = cron_job.title || `Routine ${cron_job.id}`;
|
||||
const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
|
||||
const chat_url = cron_job.workspace_id
|
||||
@@ -36,9 +41,15 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete
|
||||
}
|
||||
title={title}
|
||||
badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"}
|
||||
subtitle={routineSubtitle({ cron_job })}
|
||||
subtitle={routineSubtitle({
|
||||
cron_job,
|
||||
show_bot: !compact,
|
||||
show_workspace: !compact,
|
||||
})}
|
||||
>
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
{compact ? null : (
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
)}
|
||||
<SettingsRowAction
|
||||
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
|
||||
label={cron_job.enabled ? "Pause" : "Resume"}
|
||||
@@ -6,9 +6,5 @@ export default async function heartbeat() {
|
||||
sql: `SELECT 1 AS alive`,
|
||||
});
|
||||
|
||||
bunext.bunextLog.info(
|
||||
`Cron: database heartbeat ${db_alive.success ? "ok" : "failed"}`,
|
||||
);
|
||||
|
||||
return db_alive;
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
||||
|
||||
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address"> &
|
||||
{ computer_id: number };
|
||||
type Row = Pick<
|
||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||
"id" | "docker_id" | "status" | "ip_address"
|
||||
> & { computer_id: number };
|
||||
|
||||
/**
|
||||
* Reads every computer container's state from docker and records it
|
||||
@@ -24,10 +26,11 @@ export default async function syncComputers() {
|
||||
let changed = 0;
|
||||
|
||||
for (const { computer_id, ...container } of rows) {
|
||||
const refreshed = await refreshComputerContainer({ computer_id, container });
|
||||
const refreshed = await refreshComputerContainer({
|
||||
computer_id,
|
||||
container,
|
||||
});
|
||||
|
||||
if (refreshed?.changed) changed++;
|
||||
}
|
||||
|
||||
bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,30 @@
|
||||
*/
|
||||
export const BotJobCancelledError = "Cancelled";
|
||||
|
||||
/**
|
||||
* `last_error` of a job the user stopped from the chat. A stopped job is
|
||||
* never retried and never told about: the user pulled the plug.
|
||||
*/
|
||||
export const BotJobStoppedError = "Stopped by the user";
|
||||
|
||||
/**
|
||||
* The last step recorded on a stopped job. It's a step, not a message, so a
|
||||
* bot that was stopped mid-thought doesn't answer on its own.
|
||||
*/
|
||||
export const BotJobStoppedStep = "Stopped";
|
||||
|
||||
/**
|
||||
* What the thinking row's stop button says, so the row and its tooltip
|
||||
* never drift.
|
||||
*/
|
||||
export const BotStopButtonLabel = "Stop";
|
||||
|
||||
/**
|
||||
* What the bot says in the conversation once the user has stopped it. It's
|
||||
* the bot's turn again, so the thread stops waiting on a reply.
|
||||
*/
|
||||
export const BotStoppedMessage = "Stopped. I'll carry on when you ask me to.";
|
||||
|
||||
/**
|
||||
* `last_error` bun-sqlite sets when it releases a job whose handler went
|
||||
* silent past the lock timeout on its last attempt.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import openBotJobsSql from "./open-bot-jobs-sql";
|
||||
import type { SqlCondition } from "@/src/utils/crew-access/types";
|
||||
|
||||
/**
|
||||
* What "stop the bot" pulls the plug on: a bot's open jobs for one user,
|
||||
* across every one of their conversations with it. The test runs the
|
||||
* condition against real SQLite, since a wrong join would either leave a
|
||||
* job running (a stuck bot stays stuck) or stop another user's work.
|
||||
*/
|
||||
const db = new Database(":memory:");
|
||||
|
||||
db.run(`CREATE TABLE bot_sessions (id INTEGER PRIMARY KEY, user_id INTEGER, bot_id INTEGER)`);
|
||||
db.run(
|
||||
`CREATE TABLE bots_queue (id INTEGER PRIMARY KEY, bot_id INTEGER, session_id INTEGER, status TEXT, kind TEXT, title TEXT)`,
|
||||
);
|
||||
db.run(`INSERT INTO bot_sessions VALUES (100, 7, 2), (101, 7, 2), (102, 8, 2), (103, 9, 3)`);
|
||||
db.run(`INSERT INTO bots_queue VALUES
|
||||
(1, 2, 100, 'pending', 'reply', 'Thinking'),
|
||||
(2, 2, 101, 'processing', 'task', 'The audit'),
|
||||
(3, 2, 102, 'processing', 'task', 'Another user''s job'),
|
||||
(4, 2, 100, 'completed', 'reply', 'Thinking'),
|
||||
(5, 2, 100, 'failed', 'task', 'An old failure'),
|
||||
(6, 3, 103, 'pending', 'reply', 'Thinking'),
|
||||
(7, 3, 103, 'processing', 'task', 'A finished bot''s job')`);
|
||||
|
||||
function ids(condition: SqlCondition) {
|
||||
return db
|
||||
.query(`SELECT q.id FROM bots_queue q WHERE ${condition.sql} ORDER BY q.id`)
|
||||
.all(...condition.values)
|
||||
.map((row) => (row as { id: number }).id);
|
||||
}
|
||||
|
||||
describe("openBotJobsSql", () => {
|
||||
test("takes every open job of the bot across the user's conversations, tasks and replies alike", () => {
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 7 }))).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("never takes another user's job, even on the same bot", () => {
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 7 }))).not.toContain(3);
|
||||
});
|
||||
|
||||
test("leaves finished jobs alone, so the work they did still counts", () => {
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 7 }))).not.toContain(4);
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 7 }))).not.toContain(5);
|
||||
});
|
||||
|
||||
test("another bot's jobs are never in it", () => {
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 7 }))).not.toContain(6);
|
||||
expect(ids(openBotJobsSql({ bot_id: 3, user_id: 9 }))).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
test("a user with nothing open stops nothing", () => {
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 8 }))).toEqual([3]);
|
||||
expect(ids(openBotJobsSql({ bot_id: 2, user_id: 99 }))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reads a join alias when one is given", () => {
|
||||
const condition = openBotJobsSql({ bot_id: 2, user_id: 7, alias: "j" });
|
||||
|
||||
expect(
|
||||
db
|
||||
.query(`SELECT j.id FROM bots_queue j WHERE ${condition.sql} ORDER BY j.id`)
|
||||
.all(...condition.values)
|
||||
.map((row) => (row as { id: number }).id),
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SqlCondition } from "@/src/utils/crew-access/types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Only jobs in this user's conversations, never another user's.
|
||||
*/
|
||||
user_id: number | string;
|
||||
/**
|
||||
* The `bots_queue` alias the condition reads.
|
||||
*/
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The SQL for a bot's jobs for one user that can still run or are running:
|
||||
* pending (including one held back with `available_at` until a model comes
|
||||
* back) and processing (including one waiting for its computer). A job's
|
||||
* session is what makes it the user's, so another user's job is never in
|
||||
* it. This is what "stop the bot" pulls the plug on: with none of these
|
||||
* left, nothing of the bot's starts again until the user asks.
|
||||
*/
|
||||
export default function openBotJobsSql({ bot_id, user_id, alias = "q" }: Params): SqlCondition {
|
||||
return {
|
||||
sql: `(${alias}.bot_id = ? AND ${alias}.status IN ('pending', 'processing')
|
||||
AND ${alias}.session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?))`,
|
||||
values: [Number(bot_id), Number(user_id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import stopBotJobs from "./stop-bot-jobs";
|
||||
|
||||
/**
|
||||
* Stopping the bot from the chat stops every open job of it in the user's
|
||||
* conversations and nothing else: another user's work, another bot's work
|
||||
* and finished jobs all survive, and the bot's own handler must find out
|
||||
* (its lock is gone) so it drops what it was producing.
|
||||
*
|
||||
* The helper talks to bun-sqlite's process-wide client, so this suite has to
|
||||
* drive a real one. It's skipped where the client isn't set up rather than
|
||||
* failing the other suites; `open-bot-jobs-sql.test.ts` covers the
|
||||
* selection itself against real SQLite unconditionally.
|
||||
*/
|
||||
const sqlite_ready = (() => {
|
||||
try {
|
||||
new Database(":memory:").run(`SELECT 1`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe.skipIf(!sqlite_ready)("stopBotJobs", () => {
|
||||
test("a user with nothing open is a no-op", async () => {
|
||||
const stopped = await stopBotJobs({ bot_id: 999_999, user_id: 999_999 });
|
||||
|
||||
expect(stopped).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import { BotJobStoppedError, BotJobStoppedStep } from "@/src/dict/bot-queue-dict";
|
||||
import insertBotMessage from "../bots/insert-bot-message";
|
||||
import setBotFace, { type BotFaceChange } from "../bots/set-bot-face";
|
||||
import { abortRunningJob } from "@/src/bots-queue/job-abort-registry";
|
||||
import insertBotJobUpdate from "./insert-bot-job-update";
|
||||
import openBotJobsSql from "./open-bot-jobs-sql";
|
||||
import stoppedJobReport, { pickReportedJobs } from "./stopped-job-report";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Whose conversations stop. Never another user's jobs.
|
||||
*/
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
export type StoppedBotJobs = {
|
||||
/**
|
||||
* The jobs that were open and are now failed as stopped.
|
||||
*/
|
||||
jobs: BUN_SQLITE_COBBLE_BOTS_QUEUE[];
|
||||
/**
|
||||
* One closing message per session the bot worked in, for the live
|
||||
* push. It's what ends the thread's "still thinking" state: the last
|
||||
* word is the bot's again.
|
||||
*/
|
||||
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
|
||||
/**
|
||||
* The user's face change (idle), for the live push.
|
||||
*/
|
||||
bot: BotFaceChange;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pulls the plug on a bot for one user: every job of it that could still
|
||||
* run or is running in their conversations (replies it still owes, tasks
|
||||
* it's on, across all of them) is failed as stopped, its task closed. A
|
||||
* running handler finds out on its next `updateBotJob` (which returns
|
||||
* false) and drops whatever it was producing. Each job gets a closing
|
||||
* step, so a thread's working row says why it went quiet even after a
|
||||
* reload, one message per session ends it out loud, and the bot goes
|
||||
* idle. It's not a bus: nothing restarts by itself, so a stopped bot
|
||||
* works again only when the user asks. A schedule the user set up still
|
||||
* comes round, since that is work they asked for.
|
||||
*/
|
||||
export default async function stopBotJobs({ bot_id, user_id }: Params): Promise<StoppedBotJobs | null> {
|
||||
const condition = openBotJobsSql({ bot_id, user_id });
|
||||
|
||||
const open = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `SELECT * FROM bots_queue q WHERE ${condition.sql} ORDER BY q.id ASC`,
|
||||
values: condition.values,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
if (!open.length) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const jobs: BUN_SQLITE_COBBLE_BOTS_QUEUE[] = [];
|
||||
|
||||
for (const job of open) {
|
||||
/**
|
||||
* One by one, and only while it's still open: a job that finished
|
||||
* on its own between the read and here keeps its result.
|
||||
*/
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `UPDATE bots_queue SET status = 'failed', last_error = ?, locked_at = NULL, locked_by = NULL, finished_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'processing')
|
||||
RETURNING *`,
|
||||
values: [BotJobStoppedError, now, now, Number(job.id)],
|
||||
});
|
||||
|
||||
const stopped = res.payload?.[0];
|
||||
|
||||
if (!stopped?.id) continue;
|
||||
|
||||
jobs.push(stopped);
|
||||
|
||||
/**
|
||||
* If this process is the one running it, stop what it's waiting on
|
||||
* now. Anywhere else, the handler finds out on its next heartbeat.
|
||||
*/
|
||||
abortRunningJob({ job_id: Number(stopped.id) });
|
||||
|
||||
if (stopped.task_id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_tasks SET status = 'done', updated_at = ? WHERE id = ?`,
|
||||
values: [now, Number(stopped.task_id)],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded as the job's last step, so the working row says why it
|
||||
* went quiet.
|
||||
*/
|
||||
try {
|
||||
await insertBotJobUpdate({
|
||||
job_id: Number(stopped.id),
|
||||
bot_id: Number(stopped.bot_id) || Number(bot_id),
|
||||
kind: "note",
|
||||
text: BotJobStoppedStep,
|
||||
});
|
||||
} catch {
|
||||
/**
|
||||
* The step is a courtesy: the job is stopped either way.
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
const messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[] = [];
|
||||
|
||||
for (const [session_id, job] of pickReportedJobs({ jobs })) {
|
||||
const message = await insertBotMessage({
|
||||
bot_id: Number(bot_id),
|
||||
session_id,
|
||||
workspace_id: job.workspace_id || null,
|
||||
text: stoppedJobReport({ job }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (message?.id) messages.push(message);
|
||||
}
|
||||
|
||||
return {
|
||||
jobs,
|
||||
messages,
|
||||
bot: await setBotFace({ bot_id, user_id, face: "idle" }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BotStoppedMessage } from "@/src/dict/bot-queue-dict";
|
||||
import stoppedJobReport, { pickReportedJobs } from "./stopped-job-report";
|
||||
|
||||
describe("stoppedJobReport", () => {
|
||||
test("a stopped task names the work that was cut off", () => {
|
||||
expect(stoppedJobReport({ job: { session_id: 1, kind: "task", title: "The audit" } })).toBe(
|
||||
`${BotStoppedMessage} I was on **The audit**.`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a stopped reply just says so, since it has no title worth quoting", () => {
|
||||
expect(stoppedJobReport({ job: { session_id: 1, kind: "reply", title: "Thinking" } })).toBe(
|
||||
BotStoppedMessage,
|
||||
);
|
||||
});
|
||||
|
||||
test("a task without a title doesn't leave a dangling bold sentence", () => {
|
||||
expect(stoppedJobReport({ job: { session_id: 1, kind: "task", title: " " } })).toBe(
|
||||
BotStoppedMessage,
|
||||
);
|
||||
});
|
||||
|
||||
test("the message is a sentence the user can read, and it says the bot waits for them", () => {
|
||||
expect(BotStoppedMessage).toContain("Stopped");
|
||||
expect(BotStoppedMessage).toContain("when you ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickReportedJobs", () => {
|
||||
test("one job speaks per session, so a reply and the task it started don't both report", () => {
|
||||
const picked = pickReportedJobs({
|
||||
jobs: [
|
||||
{ session_id: 7, kind: "reply", id: 1 },
|
||||
{ session_id: 7, kind: "task", id: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect([...picked.keys()]).toEqual([7]);
|
||||
});
|
||||
|
||||
test("the task wins, since it's the one that can name the work", () => {
|
||||
const picked = pickReportedJobs({
|
||||
jobs: [
|
||||
{ session_id: 7, kind: "reply", id: 1 },
|
||||
{ session_id: 7, kind: "task", id: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(picked.get(7)?.id).toBe(2);
|
||||
});
|
||||
|
||||
test("a task first isn't replaced by a later reply", () => {
|
||||
const picked = pickReportedJobs({
|
||||
jobs: [
|
||||
{ session_id: 7, kind: "task", id: 2 },
|
||||
{ session_id: 7, kind: "reply", id: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(picked.get(7)?.id).toBe(2);
|
||||
});
|
||||
|
||||
test("every session gets its own report", () => {
|
||||
const picked = pickReportedJobs({
|
||||
jobs: [
|
||||
{ session_id: 7, kind: "reply", id: 1 },
|
||||
{ session_id: 8, kind: "task", id: 2 },
|
||||
{ session_id: 9, kind: "task", id: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect([...picked.keys()].sort()).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
test("nothing open picks nothing", () => {
|
||||
expect(pickReportedJobs({ jobs: [] }).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BotStoppedMessage } from "@/src/dict/bot-queue-dict";
|
||||
|
||||
type Job = {
|
||||
session_id?: number | string | null;
|
||||
kind?: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the bot says in a conversation the user stopped it in. A task names
|
||||
* the work that was cut off, so a long job that quietly went nowhere reads
|
||||
* as stopped rather than as finished; a reply has no title worth quoting.
|
||||
*/
|
||||
export default function stoppedJobReport({ job }: { job: Job }): string {
|
||||
const title = (job.title || "").trim();
|
||||
|
||||
return job.kind == "task" && title
|
||||
? `${BotStoppedMessage} I was on **${title}**.`
|
||||
: BotStoppedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The job that speaks for each session: one per session, since a reply and
|
||||
* the task it started are the same conversation and two notes there read as
|
||||
* a stutter. A task wins, because it's the one that can name the work.
|
||||
*/
|
||||
export function pickReportedJobs<T extends Job>({ jobs }: { jobs: T[] }): Map<number, T> {
|
||||
const picked = new Map<number, T>();
|
||||
|
||||
for (const job of jobs) {
|
||||
/**
|
||||
* A job with no session is stopped all the same; it just has
|
||||
* nowhere to report it.
|
||||
*/
|
||||
if (job.session_id === null || job.session_id === undefined) continue;
|
||||
|
||||
const known = picked.get(Number(job.session_id));
|
||||
|
||||
if (!known || (known.kind != "task" && job.kind == "task")) {
|
||||
picked.set(Number(job.session_id), job);
|
||||
}
|
||||
}
|
||||
|
||||
return picked;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import botFaceSql from "@/src/utils/bot-face-sql";
|
||||
import routineScopeSql from "@/src/utils/routine-scope/routine-scope-sql";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
@@ -8,6 +9,12 @@ type Params = {
|
||||
* Only this bot's.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
/**
|
||||
* With `bot_id`: only the routines of that conversation, here
|
||||
* (`routineScopeSql`). `null` is the bot's own chat, so it has to be
|
||||
* given rather than left out to mean "everywhere".
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Only this one.
|
||||
*/
|
||||
@@ -16,17 +23,30 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The user's schedules with their bot (its face as the user sees it) and
|
||||
* workspace names, the next to
|
||||
* run first and paused ones last.
|
||||
* workspace names, the next to run first and paused ones last. Given a
|
||||
* `bot_id` and a `workspace_id` (null for its own chat) it is one
|
||||
* conversation's: the routines that run right there.
|
||||
*/
|
||||
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
|
||||
export default async function grabCronJobs({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
cron_job_id,
|
||||
}: Params): Promise<CronJobWithNames[]> {
|
||||
const face = botFaceSql({ user_id, prefix: "bot_" });
|
||||
const where = [`c.user_id = ?`];
|
||||
const values: (number | string)[] = [user_id];
|
||||
|
||||
if (bot_id) {
|
||||
where.push(`c.bot_id = ?`);
|
||||
values.push(bot_id);
|
||||
if (workspace_id === undefined) {
|
||||
where.push(`c.bot_id = ?`);
|
||||
values.push(bot_id);
|
||||
} else {
|
||||
const scope = routineScopeSql({ bot_id, workspace_id });
|
||||
|
||||
where.push(scope.sql);
|
||||
values.push(...scope.values);
|
||||
}
|
||||
}
|
||||
|
||||
if (cron_job_id) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import type { StopBot } from "../(hooks)/use-stop-bot";
|
||||
|
||||
export type StopBotState = StopBot & {
|
||||
/**
|
||||
* The bot has something open for the signed-in user right now (a reply
|
||||
* it owes, a task it's on, a wait for a model or a computer), so
|
||||
* stopping it has something to pull the plug on.
|
||||
*/
|
||||
can_stop: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation's stop control, shared by the thinking row under the
|
||||
* thread and the header's menu, so both pull the same plug.
|
||||
*/
|
||||
export const StopBotContext = React.createContext<StopBotState>({
|
||||
stop: async () => {},
|
||||
stopping: false,
|
||||
can_stop: false,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BotConversationRequest } from "@/src/schemas/bot-schema";
|
||||
import type { BotStoppedPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Pulls the plug on the bot for the signed-in user: every job of it that's
|
||||
* queued or running stops, wherever they chat with it. The messages that
|
||||
* end the wait arrive over the WebSocket, so nothing here reloads.
|
||||
*/
|
||||
export default async function stopBot({ bot_id, workspace_id }: BotConversationRequest) {
|
||||
return await fetchApi<
|
||||
BotConversationRequest,
|
||||
APIResponseObject<BotStoppedPayload>
|
||||
>(`/api/admin/bots/stop`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Load only once the modal opens, so the conversation's page props stay
|
||||
* small.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's routines: the schedules that run there, a bot's own in
|
||||
* its chat or the ones that run in the workspace. Loaded on open and
|
||||
* refreshed after every change, never part of the page props.
|
||||
*/
|
||||
export default function useBotRoutines({ bot_id, workspace_id, enabled = true }: Params) {
|
||||
const [routines, setRoutines] = useState<CronJobWithNames[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const query = new URLSearchParams({ bot_id: String(bot_id) });
|
||||
|
||||
if (workspace_id) query.set("workspace_id", String(workspace_id));
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<CronJobWithNames>>(
|
||||
`/api/admin/bots/list-routines?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success) {
|
||||
setError(res?.msg || `Couldn't load the routines.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setRoutines(res.payload || []);
|
||||
}, [bot_id, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load().catch(() => {});
|
||||
}, [enabled, load]);
|
||||
|
||||
return { routines, setRoutines, loading, error, reload: load };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useRef, useState } from "react";
|
||||
import stopBot from "../(functions)/stop-bot";
|
||||
|
||||
export type StopBot = {
|
||||
/**
|
||||
* Stops every job of the bot for the signed-in user.
|
||||
*/
|
||||
stop: () => Promise<void>;
|
||||
/**
|
||||
* A stop is on its way to the server.
|
||||
*/
|
||||
stopping: boolean;
|
||||
/**
|
||||
* Why the last stop didn't work, for the button's title.
|
||||
*/
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation's stop button: pulls the plug on the bot for the
|
||||
* signed-in user, wherever they chat with it. It's quiet on the way out:
|
||||
* the bot's closing message and idle face arrive as pushes, so nothing
|
||||
* here reloads and a failed stop only surfaces as the button's title.
|
||||
*/
|
||||
export default function useStopBot({ bot_id, workspace_id }: Params): StopBot {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
/**
|
||||
* A ref, not the state: two clicks in one tick both read the old
|
||||
* `stopping` and would send two stops.
|
||||
*/
|
||||
const in_flight = useRef(false);
|
||||
|
||||
const stop = async () => {
|
||||
if (in_flight.current) return;
|
||||
|
||||
in_flight.current = true;
|
||||
setStopping(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const res = await stopBot({
|
||||
bot_id,
|
||||
workspace_id: workspace_id || null,
|
||||
}).catch(() => null);
|
||||
|
||||
/**
|
||||
* A message without a failure is the count ("Stopped 2 jobs."),
|
||||
* so only an unsuccessful answer is an error.
|
||||
*/
|
||||
if (!res?.success) setError(res?.msg || "Couldn't stop the bot.");
|
||||
} finally {
|
||||
in_flight.current = false;
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { stop, stopping, error };
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Clock } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, CronJobWithNames } from "@/src/types";
|
||||
import RoutineForm from "@/src/components/general/routine-form";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import { dropRoutine, replaceRoutine } from "@/src/utils/routine-list-changes";
|
||||
import useBotRoutines from "../(hooks)/use-bot-routines";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
/**
|
||||
* A row in the conversation header's menu instead of an icon beside it.
|
||||
*/
|
||||
menu?: boolean;
|
||||
on_open?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The routines that run in this conversation: the schedules whose result
|
||||
* lands here, a bot's own on its chat or the ones it runs in this
|
||||
* workspace. Open it from the header menu; the list loads then, so the
|
||||
* page's props stay small. The user pauses, resumes, edits and deletes
|
||||
* their own; the bot makes them when asked in chat.
|
||||
*/
|
||||
export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) {
|
||||
const [open, set_open] = useState(false);
|
||||
const [editing, set_editing] = useState<CronJobWithNames>();
|
||||
const [busy, set_busy] = useState(false);
|
||||
const [status, set_status] = useState<Status>();
|
||||
const { routines, setRoutines, loading, error } = useBotRoutines({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const name = bot.name || "This bot";
|
||||
const place = workspace?.name || "this workspace";
|
||||
const title = workspace?.id ? `${place} routines` : `${name}'s routines`;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) on_open?.();
|
||||
}, [open]);
|
||||
|
||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||
setRoutines((prev) => replaceRoutine({ routines: prev, cron_job }));
|
||||
|
||||
const toggle = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
if (busy) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, enabled: !cron_job.enabled },
|
||||
});
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
set_status({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ cron_job: res.singleRes });
|
||||
set_status({ success: true, msg: cron_job.enabled ? "Paused." : "Resumed." });
|
||||
};
|
||||
|
||||
const remove = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete the routine "${cron_job.title}"? A run already started carries on.`)) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await postAdminApi({ path: "routines/delete-routine", body: { cron_job_id: cron_job.id } });
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success) {
|
||||
set_status({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
setRoutines((prev) => dropRoutine({ routines: prev, cron_job_id: cron_job.id }));
|
||||
set_status({ success: true, msg: "Deleted." });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
menu ? (
|
||||
<HeaderMenuItem icon={Clock} title="Schedules" description={title} loading={loading} />
|
||||
) : (
|
||||
<Button
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
className="shrink-0 p-2! text-ink-secondary hover:text-ink hover:bg-sunken rounded-default"
|
||||
>
|
||||
<Clock size={18} aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={(next) => {
|
||||
set_open(next);
|
||||
|
||||
if (!next) {
|
||||
set_editing(undefined);
|
||||
set_status(undefined);
|
||||
}
|
||||
}}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{editing ? editing.title || "Routine" : title}
|
||||
</H2>
|
||||
{editing ? (
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
set_editing(undefined);
|
||||
set_status(undefined);
|
||||
}}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{editing
|
||||
? `${name} gets this brief as a task at every run, and the result lands here.`
|
||||
: workspace?.id
|
||||
? `Work ${name} does on a schedule in ${place}. Each run is a task, and its result lands here.`
|
||||
: `Work ${name} does on its own, on a schedule. Each run is a task, and its result lands here.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-4 max-h-[55vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||
{editing ? (
|
||||
<RoutineForm
|
||||
key={editing.id}
|
||||
cron_job={editing}
|
||||
onCancel={() => set_editing(undefined)}
|
||||
onSaved={({ cron_job }) => {
|
||||
replace({ cron_job });
|
||||
set_editing(undefined);
|
||||
set_status({ success: true, msg: "Saved." });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SettingsList
|
||||
is_empty={!routines.length}
|
||||
empty={loading ? "Loading routines..." : error || "No routines here yet."}
|
||||
>
|
||||
{routines.map((cron_job) => (
|
||||
<RoutineRow
|
||||
key={cron_job.id}
|
||||
cron_job={cron_job}
|
||||
compact
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job }).catch(() => {})}
|
||||
onEdit={() => set_editing(cron_job)}
|
||||
onDelete={() => remove({ cron_job }).catch(() => {})}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-3 gap-1! items-stretch">
|
||||
<SettingsStatus status={status} />
|
||||
{editing ? null : (
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{`Ask ${name} in chat to set one up, or change one here.`}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,11 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import {
|
||||
BotComputerBusyStatusLine,
|
||||
BotGettingMessageStatusLine,
|
||||
BotReplyJobTitle,
|
||||
BotWaitingStatusLine,
|
||||
} from "@/src/dict/bot-queue-dict";
|
||||
import { BotGettingMessageStatusLine } from "@/src/dict/bot-queue-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import botBusyState from "../(utils)/bot-busy-state";
|
||||
import StopBotButton from "./stop-bot-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -45,7 +41,7 @@ type Props = {
|
||||
* reply shows from the thread alone: every user message queues one, so while
|
||||
* the user has the last word a reply is due here, even while the bot works
|
||||
* elsewhere. Only the waits (for a model, for its computer) come from the
|
||||
* face.
|
||||
* face. While it shows, a stop button pulls the plug on the bot's work.
|
||||
*/
|
||||
export default function BotThinkingRow({
|
||||
bot,
|
||||
@@ -56,17 +52,14 @@ export default function BotThinkingRow({
|
||||
has_task,
|
||||
}: Props) {
|
||||
const name = bot.name || "The bot";
|
||||
const asleep = bot.face == "asleep";
|
||||
const waiting_model = asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
|
||||
const waiting_computer =
|
||||
asleep && bot.status_line == BotComputerBusyStatusLine && Boolean(has_task) && !awaiting_reply;
|
||||
const waiting = waiting_model || waiting_computer;
|
||||
const getting = Boolean(receiving) && Boolean(awaiting_reply);
|
||||
const thinking = Boolean(awaiting_reply) && !waiting && !getting;
|
||||
const working =
|
||||
!awaiting_reply && bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task);
|
||||
const { waiting, getting, thinking, working, busy, stoppable } = botBusyState({
|
||||
bot,
|
||||
awaiting_reply,
|
||||
receiving,
|
||||
has_task,
|
||||
});
|
||||
|
||||
if (!getting && !thinking && !working && !waiting) return null;
|
||||
if (!busy) return null;
|
||||
|
||||
const label = getting
|
||||
? `${name} is ${BotGettingMessageStatusLine.toLowerCase()}…`
|
||||
@@ -95,6 +88,7 @@ export default function BotThinkingRow({
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
<StopBotButton bot_name={bot.name} disabled={!stoppable} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import BotComputersModal from "./bot-computers-modal";
|
||||
import BotFilesModal from "./bot-files-modal";
|
||||
import BotTasksModal from "./bot-tasks-modal";
|
||||
import BotRoutinesModal from "./bot-routines-modal";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
import StopBotButton from "./stop-bot-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -47,8 +49,9 @@ type Props = {
|
||||
|
||||
/**
|
||||
* The conversation header's menu: the sessions, a new session, the bot's
|
||||
* computer, its models and its settings. A row that opens a modal closes
|
||||
* the menu first, so the modal never sits behind an open menu.
|
||||
* computer, its models, its settings and, while it works, stopping it. A
|
||||
* row that opens a modal closes the menu first, so the modal never sits
|
||||
* behind an open menu.
|
||||
*/
|
||||
export default function ConversationHeaderMenu({
|
||||
bot,
|
||||
@@ -108,6 +111,12 @@ export default function ConversationHeaderMenu({
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotRoutinesModal
|
||||
menu
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotComputersModal
|
||||
menu
|
||||
bot={bot}
|
||||
@@ -129,6 +138,8 @@ export default function ConversationHeaderMenu({
|
||||
onSaved={on_model_saved}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
<StopBotButton menu bot_name={bot.name} />
|
||||
{can_open_settings ? (
|
||||
<>
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { CircleStop } from "lucide-react";
|
||||
import { BotStopButtonLabel } from "@/src/dict/bot-queue-dict";
|
||||
import { StopBotContext } from "../(data)/stop-bot-context";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Whose work stops, named in the tooltip so it's clear this pulls the
|
||||
* bot's plug everywhere the user chats with it.
|
||||
*/
|
||||
bot_name?: string | null;
|
||||
/**
|
||||
* Shown as a row in the conversation header's menu instead of a button
|
||||
* in the thinking row.
|
||||
*/
|
||||
menu?: boolean;
|
||||
/**
|
||||
* The caller already knows there's nothing to pull (a row that says the
|
||||
* bot is only collecting the message), so the button sits out.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pulls the plug on the bot for the signed-in user: its queued and running
|
||||
* work stops wherever they chat with it, so a bot stuck waiting for a model
|
||||
* (or anything else) comes back. The thinking row carries it while
|
||||
* something is running; the header's menu carries it too, disabled when
|
||||
* there's nothing to stop.
|
||||
*/
|
||||
export default function StopBotButton({ bot_name, menu, disabled }: Props) {
|
||||
const { stop, stopping, error, can_stop } = useContext(StopBotContext);
|
||||
const name = bot_name || "the bot";
|
||||
const off = Boolean(disabled) || !can_stop || stopping;
|
||||
const title = error
|
||||
? error
|
||||
: off && !stopping
|
||||
? `Nothing of ${name}'s is running for you.`
|
||||
: `Stop ${name}. Its queued and running work stops, everywhere you chat with it.`;
|
||||
|
||||
if (menu) {
|
||||
return (
|
||||
<HeaderMenuItem
|
||||
icon={CircleStop}
|
||||
title={BotStopButtonLabel}
|
||||
description={title}
|
||||
hint={stopping ? "Stopping" : undefined}
|
||||
loading={stopping}
|
||||
disabled={off}
|
||||
onClick={() => {
|
||||
void stop();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
aria-label={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
size="smaller"
|
||||
disabled={off}
|
||||
loading={stopping}
|
||||
loadingIconSize="small"
|
||||
beforeIcon={<CircleStop size={14} aria-hidden />}
|
||||
onClick={() => {
|
||||
void stop();
|
||||
}}
|
||||
className="shrink-0 text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
{BotStopButtonLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,15 @@ import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
||||
import {
|
||||
StopBotContext,
|
||||
type StopBotState,
|
||||
} from "../(data)/stop-bot-context";
|
||||
import reactToBotMessage from "../(functions)/react-to-bot-message";
|
||||
import botBusyState from "../(utils)/bot-busy-state";
|
||||
import dropSentMessage from "../(utils)/drop-sent-message";
|
||||
import useBotComputers from "../(hooks)/use-bot-computers";
|
||||
import useStopBot from "../(hooks)/use-stop-bot";
|
||||
import useTaskProgress from "../(hooks)/use-task-progress";
|
||||
import useReplyProgress from "../(hooks)/use-reply-progress";
|
||||
import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
|
||||
@@ -90,6 +96,10 @@ export default function Conversation() {
|
||||
workspace_id,
|
||||
computers: pageProps?.bot_computers || [],
|
||||
});
|
||||
const stop_bot = useStopBot({
|
||||
bot_id: bot?.id || 0,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
const speak = useSpeakMessage({
|
||||
enabled: Boolean(pageProps?.bot_voice?.speech),
|
||||
@@ -233,7 +243,26 @@ export default function Conversation() {
|
||||
|
||||
if (!bot?.id || !live_bot) return null;
|
||||
|
||||
return (
|
||||
/**
|
||||
* What the stop control pulls the plug on, read the way the thinking
|
||||
* row reads it: the same conditions, so the header's row is enabled
|
||||
* exactly while the thread shows the bot at work.
|
||||
*/
|
||||
const stop_state: StopBotState = {
|
||||
...stop_bot,
|
||||
can_stop: botBusyState({
|
||||
bot: live_bot,
|
||||
awaiting_reply,
|
||||
receiving: pending_messages.length > 0,
|
||||
has_task: Boolean(progress.task_id),
|
||||
}).stoppable,
|
||||
};
|
||||
|
||||
/**
|
||||
* The thread, kept intact so the stop control can wrap it without
|
||||
* reindenting every provider.
|
||||
*/
|
||||
const thread = (
|
||||
<CrewBotsContext.Provider value={bots}>
|
||||
<CrewWorkspacesContext.Provider value={workspaces}>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
@@ -316,4 +345,10 @@ export default function Conversation() {
|
||||
</CrewWorkspacesContext.Provider>
|
||||
</CrewBotsContext.Provider>
|
||||
);
|
||||
|
||||
return (
|
||||
<StopBotContext.Provider value={stop_state}>
|
||||
{thread}
|
||||
</StopBotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
BotComputerBusyStatusLine,
|
||||
BotGettingMessageStatusLine,
|
||||
BotReplyJobTitle,
|
||||
BotWaitingStatusLine,
|
||||
} from "@/src/dict/bot-queue-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import botBusyState from "./bot-busy-state";
|
||||
|
||||
function bot(overrides: Partial<BotWithLastMessage>): BotWithLastMessage {
|
||||
return { id: 1, name: "Ember", ...overrides } as BotWithLastMessage;
|
||||
}
|
||||
|
||||
describe("botBusyState", () => {
|
||||
test("a reply owed here is thinking", () => {
|
||||
const state = botBusyState({ bot: bot({ face: "idle" }), awaiting_reply: true });
|
||||
|
||||
expect(state.thinking).toBe(true);
|
||||
expect(state.busy).toBe(true);
|
||||
});
|
||||
|
||||
test("a message still on its way is getting, not thinking", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "working" }),
|
||||
awaiting_reply: true,
|
||||
receiving: true,
|
||||
});
|
||||
|
||||
expect(state.getting).toBe(true);
|
||||
expect(state.thinking).toBe(false);
|
||||
});
|
||||
|
||||
test("a task running while the bot has the last word is working", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "working", status_line: "Working" }),
|
||||
awaiting_reply: false,
|
||||
has_task: true,
|
||||
});
|
||||
|
||||
expect(state.working).toBe(true);
|
||||
expect(state.busy).toBe(true);
|
||||
});
|
||||
|
||||
test("a reply's own turn never shows as working on a task", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "working", status_line: BotReplyJobTitle }),
|
||||
has_task: true,
|
||||
});
|
||||
|
||||
expect(state.working).toBe(false);
|
||||
expect(state.busy).toBe(false);
|
||||
});
|
||||
|
||||
test("waiting for a model is a wait, with a reply owed here", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "asleep", status_line: BotWaitingStatusLine }),
|
||||
awaiting_reply: true,
|
||||
});
|
||||
|
||||
expect(state.waiting).toBe(true);
|
||||
expect(state.thinking).toBe(false);
|
||||
expect(state.busy).toBe(true);
|
||||
});
|
||||
|
||||
test("waiting for a model is a wait, with a task running", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "asleep", status_line: BotWaitingStatusLine }),
|
||||
has_task: true,
|
||||
});
|
||||
|
||||
expect(state.waiting).toBe(true);
|
||||
expect(state.busy).toBe(true);
|
||||
});
|
||||
|
||||
test("waiting for its computer is a wait only while a task runs", () => {
|
||||
const waiting = botBusyState({
|
||||
bot: bot({ face: "asleep", status_line: BotComputerBusyStatusLine }),
|
||||
has_task: true,
|
||||
});
|
||||
const idle = botBusyState({
|
||||
bot: bot({ face: "asleep", status_line: BotComputerBusyStatusLine }),
|
||||
});
|
||||
|
||||
expect(waiting.waiting).toBe(true);
|
||||
expect(idle.busy).toBe(false);
|
||||
});
|
||||
|
||||
test("a message still on its way wins over the wait, so the row says what the user just did", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "asleep", status_line: BotWaitingStatusLine }),
|
||||
awaiting_reply: true,
|
||||
receiving: true,
|
||||
});
|
||||
|
||||
expect(state.getting).toBe(true);
|
||||
expect(state.waiting).toBe(true);
|
||||
});
|
||||
|
||||
test("an idle bot with nothing owed and no task shows nothing", () => {
|
||||
const state = botBusyState({ bot: bot({ face: "idle" }) });
|
||||
|
||||
expect(state.busy).toBe(false);
|
||||
});
|
||||
|
||||
test("a done face with no task shows nothing", () => {
|
||||
expect(botBusyState({ bot: bot({ face: "done" }) }).busy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botBusyState stoppable", () => {
|
||||
test("busy in this thread is stoppable", () => {
|
||||
expect(
|
||||
botBusyState({
|
||||
bot: bot({ face: "idle" }),
|
||||
awaiting_reply: true,
|
||||
}).stoppable,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("working somewhere else is stoppable too, since the face is per user", () => {
|
||||
const state = botBusyState({ bot: bot({ face: "working" }) });
|
||||
|
||||
expect(state.busy).toBe(false);
|
||||
expect(state.stoppable).toBe(true);
|
||||
});
|
||||
|
||||
test("asleep on a wait line is stoppable, even when this thread shows nothing", () => {
|
||||
for (const status_line of [BotWaitingStatusLine, BotComputerBusyStatusLine]) {
|
||||
expect(botBusyState({ bot: bot({ face: "asleep", status_line }) }).stoppable).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a bot simply resting is not stoppable", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "asleep", status_line: "Until tomorrow" }),
|
||||
});
|
||||
|
||||
expect(state.busy).toBe(false);
|
||||
expect(state.stoppable).toBe(false);
|
||||
});
|
||||
|
||||
test("a message only being collected is not stoppable: its reply is queued after it", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "idle" }),
|
||||
awaiting_reply: true,
|
||||
receiving: true,
|
||||
});
|
||||
|
||||
expect(state.getting).toBe(true);
|
||||
expect(state.stoppable).toBe(false);
|
||||
});
|
||||
|
||||
test("done, needs-you and idle mean nothing is running", () => {
|
||||
for (const face of ["done", "needs-you", "idle"] as const) {
|
||||
const state = botBusyState({ bot: bot({ face }) });
|
||||
|
||||
expect(state.stoppable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("a silently stopped bot is not stoppable", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "idle" }),
|
||||
awaiting_reply: false,
|
||||
has_task: false,
|
||||
});
|
||||
|
||||
expect(state.stoppable).toBe(false);
|
||||
});
|
||||
|
||||
test("a reply job's own turn with no task owed is not stoppable", () => {
|
||||
/**
|
||||
* The face says working while a reply runs somewhere else, which is
|
||||
* real work: it's stoppable even though this thread owes nothing.
|
||||
*/
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "working", status_line: BotReplyJobTitle }),
|
||||
});
|
||||
|
||||
expect(state.stoppable).toBe(true);
|
||||
});
|
||||
|
||||
test("the getting line still labels the row", () => {
|
||||
const state = botBusyState({
|
||||
bot: bot({ face: "idle", status_line: BotGettingMessageStatusLine }),
|
||||
awaiting_reply: true,
|
||||
receiving: true,
|
||||
});
|
||||
|
||||
expect(state.getting).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import {
|
||||
BotComputerBusyStatusLine,
|
||||
BotReplyJobTitle,
|
||||
BotWaitingStatusLine,
|
||||
} from "@/src/dict/bot-queue-dict";
|
||||
|
||||
type Params = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The last message in the open thread is the user's, so a reply is due
|
||||
* here.
|
||||
*/
|
||||
awaiting_reply?: boolean;
|
||||
/**
|
||||
* A message is still on its way to the server.
|
||||
*/
|
||||
receiving?: boolean;
|
||||
/**
|
||||
* A task of the bot's is running.
|
||||
*/
|
||||
has_task?: boolean;
|
||||
};
|
||||
|
||||
export type BotBusyState = {
|
||||
/**
|
||||
* Waiting for a model to come back, or for the bot's computer.
|
||||
*/
|
||||
waiting: boolean;
|
||||
/**
|
||||
* The user's message hasn't reached the server yet.
|
||||
*/
|
||||
getting: boolean;
|
||||
/**
|
||||
* A reply is due in this thread.
|
||||
*/
|
||||
thinking: boolean;
|
||||
/**
|
||||
* A task is running and the bot has the last word.
|
||||
*/
|
||||
working: boolean;
|
||||
/**
|
||||
* Anything of the above, so the row has something to say.
|
||||
*/
|
||||
busy: boolean;
|
||||
/**
|
||||
* There's work of the bot's to pull the plug on. A message still on
|
||||
* its way doesn't count: its reply is queued after it lands, so a stop
|
||||
* now would cancel nothing and be overtaken a moment later. The face
|
||||
* covers work in the user's other conversations too, since it's one
|
||||
* per user; `asleep` only counts on the two status lines the queue
|
||||
* sets while it waits, never a bot that's simply resting.
|
||||
*/
|
||||
stoppable: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a bot is doing in a conversation, read from the thread and its face.
|
||||
* The thread decides the reply states: every user message queues one, so
|
||||
* while the user has the last word a reply is due here, even while the bot
|
||||
* works elsewhere. Only the waits (for a model, for its computer) come from
|
||||
* the face. The thinking row reads it for its line, and the conversation
|
||||
* reads it for the stop control.
|
||||
*/
|
||||
export default function botBusyState({
|
||||
bot,
|
||||
awaiting_reply,
|
||||
receiving,
|
||||
has_task,
|
||||
}: Params): BotBusyState {
|
||||
const asleep = bot.face == "asleep";
|
||||
const waiting_model =
|
||||
asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
|
||||
const waiting_computer =
|
||||
asleep &&
|
||||
bot.status_line == BotComputerBusyStatusLine &&
|
||||
Boolean(has_task) &&
|
||||
!awaiting_reply;
|
||||
const waiting = waiting_model || waiting_computer;
|
||||
const getting = Boolean(receiving) && Boolean(awaiting_reply);
|
||||
const thinking = Boolean(awaiting_reply) && !waiting && !getting;
|
||||
const working =
|
||||
!awaiting_reply &&
|
||||
bot.face == "working" &&
|
||||
bot.status_line != BotReplyJobTitle &&
|
||||
Boolean(has_task);
|
||||
|
||||
/**
|
||||
* Work runs here (a reply owed, a task of this thread's) or somewhere
|
||||
* else of this user's (the face says `working`, or `asleep` on a wait
|
||||
* line).
|
||||
*/
|
||||
const stoppable =
|
||||
waiting ||
|
||||
thinking ||
|
||||
working ||
|
||||
bot.face == "working" ||
|
||||
(asleep &&
|
||||
(bot.status_line == BotWaitingStatusLine ||
|
||||
bot.status_line == BotComputerBusyStatusLine));
|
||||
|
||||
return {
|
||||
waiting,
|
||||
getting,
|
||||
thinking,
|
||||
working,
|
||||
busy: getting || thinking || working || waiting,
|
||||
stoppable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import RoutineEditModal from "../(partials)/routine-edit-modal";
|
||||
import RoutineRow from "../(partials)/routine-row";
|
||||
import RoutineEditModal from "@/src/components/general/routine-edit-modal";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
|
||||
/**
|
||||
* Every routine the user's bots have: chat with the bot, pause or resume
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { RunningVersion } from "@/src/utils/running-version";
|
||||
import versionLabel from "../(utils)/version-label";
|
||||
|
||||
type Props = {
|
||||
running?: RunningVersion | null;
|
||||
@@ -7,21 +8,14 @@ type Props = {
|
||||
|
||||
/**
|
||||
* The release this server runs, in words: settings are where someone
|
||||
* checks whether an update landed. A development server says so beside
|
||||
* its version, so the two are never mistaken.
|
||||
* checks whether an update landed. The stage and, on a development server,
|
||||
* that it is one ride along, so neither what it runs nor what it is can be
|
||||
* mistaken.
|
||||
*/
|
||||
export default function VersionLine({ running }: Props) {
|
||||
if (!running) return null;
|
||||
const label = versionLabel({ running });
|
||||
|
||||
const version = running.version
|
||||
? `Cobble ${running.version}`
|
||||
: "Cobble";
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<Span className="text-sm leading-5 text-ink">
|
||||
{running.mode == "development"
|
||||
? `${version} (development)`
|
||||
: version}
|
||||
</Span>
|
||||
);
|
||||
return <Span className="text-sm leading-5 text-ink">{label}</Span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import versionLabel from "./version-label";
|
||||
|
||||
describe("versionLabel", () => {
|
||||
test("a release names its version and that it is alpha", () => {
|
||||
expect(
|
||||
versionLabel({
|
||||
running: { version: "v0.1.5", mode: "production" },
|
||||
}),
|
||||
).toBe("Cobble v0.1.5 (alpha)");
|
||||
});
|
||||
|
||||
test("a development server says so beside the stage", () => {
|
||||
expect(
|
||||
versionLabel({
|
||||
running: { version: "0.1.5", mode: "development" },
|
||||
}),
|
||||
).toBe("Cobble 0.1.5 (development, alpha)");
|
||||
});
|
||||
|
||||
test("a server with no usable version still names the stage", () => {
|
||||
expect(
|
||||
versionLabel({ running: { version: null, mode: "production" } }),
|
||||
).toBe("Cobble (alpha)");
|
||||
});
|
||||
|
||||
test("no running version says nothing at all", () => {
|
||||
expect(versionLabel({ running: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RunningVersion } from "@/src/utils/running-version";
|
||||
|
||||
/**
|
||||
* The stage every release is published as. Cobble is still alpha: the
|
||||
* schema, the settings and the screens change between releases, so the app
|
||||
* says so wherever it names its version, not only in the README.
|
||||
*/
|
||||
export const ReleaseStage = "alpha";
|
||||
|
||||
type Params = {
|
||||
running?: RunningVersion | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The running release in words, for the General settings page: "Cobble
|
||||
* v0.1.5 (alpha)", and a development server adds that it is one, so a dev
|
||||
* build is never mistaken for a release.
|
||||
*/
|
||||
export default function versionLabel({ running }: Params): string | null {
|
||||
if (!running) return null;
|
||||
|
||||
const parts = [running.mode == "development" ? "development" : "", ReleaseStage].filter(
|
||||
(part) => part.length > 0,
|
||||
);
|
||||
|
||||
return `${running.version ? `Cobble ${running.version}` : "Cobble"} (${parts.join(", ")})`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* One conversation's routines: the schedules that run there, a bot's own
|
||||
* ones in its chat (`?workspace_id=` left out) or the ones that run in that
|
||||
* workspace. Only a user who chats there sees them. The modal loads them
|
||||
* when it opens, so the conversation page's props stay small.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<CronJobWithNames>> = async ({ req }) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
return { success: false, msg: `Method not allowed` };
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const bot_id = Number(url.searchParams.get("bot_id")) || undefined;
|
||||
const workspace_id = Number(url.searchParams.get("workspace_id")) || undefined;
|
||||
|
||||
if (!bot_id) {
|
||||
return { success: false, msg: `Pick a bot.` };
|
||||
}
|
||||
|
||||
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: await grabCronJobs({
|
||||
user_id: user.id,
|
||||
bot_id: conversation.bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import countOpenBotJobs from "@/src/functions/backend/db/bots-queue/count-open-bot-jobs";
|
||||
import stopBotJobs from "@/src/functions/backend/db/bots-queue/stop-bot-jobs";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { BotStoppedPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Pulls the plug on a bot for the session user: every job of it that's
|
||||
* queued or running in their conversations with it stops, its tasks close
|
||||
* and it goes idle, so a bot stuck waiting for a model (or anything else)
|
||||
* comes back at once. Stopping from a workspace stops the bot there and in
|
||||
* its own chat: the user asked for the bot to stop, not one tab of it.
|
||||
* A bot with nothing open is a no-op. The messages that end the wait go
|
||||
* out live, to this tab and the user's others.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BotStoppedPayload>> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id } = parseWithSchema({
|
||||
schema: BotConversationRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
/**
|
||||
* Checked against the conversation it was asked from, so the stop
|
||||
* button works exactly where the conversation opens.
|
||||
*/
|
||||
const conversation = await grabUserConversation({
|
||||
user_id: user.id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
const stopped = await stopBotJobs({
|
||||
bot_id: conversation.bot.id,
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
if (stopped) {
|
||||
/**
|
||||
* Live only: the user just pressed the button in the app, so
|
||||
* their devices don't need a notification per conversation.
|
||||
*/
|
||||
for (const message of stopped.messages) {
|
||||
await pushBotNotice({
|
||||
bot_id: conversation.bot.id,
|
||||
message,
|
||||
quiet_push: true,
|
||||
});
|
||||
}
|
||||
|
||||
await pushBotNotice({ bot_id: conversation.bot.id, bot: stopped.bot });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: {
|
||||
bot_id: conversation.bot.id,
|
||||
stopped: stopped?.jobs.length || 0,
|
||||
remaining:
|
||||
(await countOpenBotJobs({
|
||||
bot_id: conversation.bot.id,
|
||||
user_id: user.id,
|
||||
})) || 0,
|
||||
},
|
||||
msg: stopped
|
||||
? `Stopped ${stopped.jobs.length} ${stopped.jobs.length == 1 ? "job" : "jobs"}.`
|
||||
: `Nothing is running.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -209,6 +209,17 @@ export type BotTaskWithSteps = BUN_SQLITE_COBBLE_BOT_TASKS & {
|
||||
updates: BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* What stopping a bot reports back: how many of its jobs were stopped and
|
||||
* how many of them are still open (an attempt that has to notice its
|
||||
* lock is gone can take a heartbeat).
|
||||
*/
|
||||
export type BotStoppedPayload = {
|
||||
bot_id: number;
|
||||
stopped: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* `server:bot-job-update` data: a step a running job just took, with the
|
||||
* task it's for (null for a reply).
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import { dropRoutine, replaceRoutine } from "./routine-list-changes";
|
||||
|
||||
function routine({ id, title }: { id: number | string; title: string }): CronJobWithNames {
|
||||
return { ...({ id, title, enabled: 1, schedule: "0 9 * * *" } as CronJobWithNames) };
|
||||
}
|
||||
|
||||
const routines = [routine({ id: 1, title: "Morning" }), routine({ id: 2, title: "Evening" })];
|
||||
|
||||
describe("replaceRoutine", () => {
|
||||
test("swaps in the saved version in the same place", () => {
|
||||
const saved = routine({ id: 2, title: "Evening, paused" });
|
||||
const next = replaceRoutine({ routines, cron_job: saved });
|
||||
|
||||
expect(next.map((item) => item.id)).toEqual([1, 2]);
|
||||
expect(next[1]!.title).toBe("Evening, paused");
|
||||
});
|
||||
|
||||
test("matches a string id against a numeric one", () => {
|
||||
const next = replaceRoutine({
|
||||
routines: [routine({ id: "1", title: "Morning" }), routine({ id: 2, title: "Evening" })],
|
||||
cron_job: routine({ id: 1, title: "Morning, paused" }),
|
||||
});
|
||||
|
||||
expect(next[0]!.title).toBe("Morning, paused");
|
||||
expect(next.length).toBe(2);
|
||||
});
|
||||
|
||||
test("leaves every other routine alone", () => {
|
||||
expect(replaceRoutine({ routines, cron_job: routine({ id: 99, title: "Not here" }) })).toEqual(routines);
|
||||
});
|
||||
|
||||
test("does not change the list it was given", () => {
|
||||
const copy = [...routines];
|
||||
replaceRoutine({ routines, cron_job: routine({ id: 1, title: "Changed" }) });
|
||||
|
||||
expect(routines).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropRoutine", () => {
|
||||
test("removes the deleted one and keeps the rest", () => {
|
||||
expect(dropRoutine({ routines, cron_job_id: 1 }).map((item) => item.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
test("matches a string id", () => {
|
||||
expect(dropRoutine({ routines, cron_job_id: "2" }).map((item) => item.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("an id that isn't there changes nothing", () => {
|
||||
expect(dropRoutine({ routines, cron_job_id: 42 })).toEqual(routines);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Replaces one routine in a list with its saved version, keeping the order.
|
||||
* Only the row that changed moves, so a pause or an edit never reshuffles
|
||||
* the list under the pointer.
|
||||
*/
|
||||
export function replaceRoutine({
|
||||
routines,
|
||||
cron_job,
|
||||
}: {
|
||||
routines: CronJobWithNames[];
|
||||
cron_job: CronJobWithNames;
|
||||
}): CronJobWithNames[] {
|
||||
return routines.map((item) => (item.id == cron_job.id ? cron_job : item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a deleted routine from a list.
|
||||
*/
|
||||
export function dropRoutine({
|
||||
routines,
|
||||
cron_job_id,
|
||||
}: {
|
||||
routines: CronJobWithNames[];
|
||||
cron_job_id: string | number | undefined;
|
||||
}): CronJobWithNames[] {
|
||||
return routines.filter((item) => item.id != cron_job_id);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import routineScopeSql from "./routine-scope-sql";
|
||||
import type { SqlCondition } from "./types";
|
||||
|
||||
const db = new Database(":memory:");
|
||||
|
||||
beforeAll(() => {
|
||||
db.run(`CREATE TABLE cron_jobs (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`);
|
||||
db.run(`INSERT INTO cron_jobs VALUES
|
||||
(1, 2, NULL),
|
||||
(2, 2, 10),
|
||||
(3, 2, 11),
|
||||
(4, 3, NULL),
|
||||
(5, 3, 10)`);
|
||||
});
|
||||
|
||||
function ids({ condition }: { condition: SqlCondition }) {
|
||||
return db
|
||||
.query(`SELECT c.id FROM cron_jobs c WHERE ${condition.sql} ORDER BY c.id`)
|
||||
.all(...condition.values)
|
||||
.map((row) => (row as { id: number }).id);
|
||||
}
|
||||
|
||||
describe("routineScopeSql", () => {
|
||||
test("a bot's own chat holds only the routines that run nowhere", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 2 }) })).toEqual([1]);
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: null }) })).toEqual([4]);
|
||||
});
|
||||
|
||||
test("a workspace holds only that bot's routines there", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 10 }) })).toEqual([2]);
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: 10 }) })).toEqual([5]);
|
||||
});
|
||||
|
||||
test("another bot's routines in the same workspace are not this conversation's", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 11 }) })).toEqual([3]);
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 9, workspace_id: 11 }) })).toEqual([]);
|
||||
});
|
||||
|
||||
test("a string id from a query string still matches", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: "2", workspace_id: "10" }) })).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RoutineScopeSqlParams, SqlCondition } from "./types";
|
||||
|
||||
/**
|
||||
* The routines of one conversation: that bot's schedules in that workspace,
|
||||
* or, for its own chat, the ones that run nowhere (a routine made there
|
||||
* stays there even if the bot later joins a workspace). `workspace_id` is
|
||||
* NULL in SQLite rather than 0, so the two cases can't share a comparison.
|
||||
*/
|
||||
export default function routineScopeSql({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
alias = "c",
|
||||
}: RoutineScopeSqlParams): SqlCondition {
|
||||
if (workspace_id) {
|
||||
return {
|
||||
sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id = ?)`,
|
||||
values: [Number(bot_id), Number(workspace_id)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)`,
|
||||
values: [Number(bot_id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SqlCondition } from "@/src/utils/task-access/types";
|
||||
|
||||
/**
|
||||
* The conversation a routines list belongs to: a bot's own chat (no
|
||||
* workspace), or that bot's conversation in one workspace.
|
||||
*/
|
||||
export type RoutineConversationScope = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined for the bot's own chat. A workspace id keeps only
|
||||
* the routines running there.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
export type RoutineScopeSqlParams = RoutineConversationScope & {
|
||||
/**
|
||||
* The table alias the condition reads.
|
||||
*/
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
export type { SqlCondition };
|
||||
+17
@@ -30,4 +30,21 @@ describe("routineSubtitle", () => {
|
||||
"Scout · @daily UTC",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves out the bot and workspace in a conversation's own modal", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: {
|
||||
bot_name: "Scout",
|
||||
workspace_name: "Launch",
|
||||
schedule: "0 9 * * *",
|
||||
enabled: 1,
|
||||
next_run_at: now + 30 * 60 * 1000,
|
||||
},
|
||||
now,
|
||||
show_bot: false,
|
||||
show_workspace: false,
|
||||
}),
|
||||
).toBe("0 9 * * * UTC · next in 30m");
|
||||
});
|
||||
});
|
||||
+15
-4
@@ -1,22 +1,33 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import timeUntil from "@/src/utils/time-until";
|
||||
import timeUntil from "./time-until";
|
||||
|
||||
type Params = {
|
||||
cron_job: CronJobWithNames;
|
||||
now?: number;
|
||||
/**
|
||||
* Shown by default; left out where the conversation already is the
|
||||
* bot's (or the workspace's).
|
||||
*/
|
||||
show_bot?: boolean;
|
||||
show_workspace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The line under a routine's name: whose it is, where it runs, its
|
||||
* schedule, then when it runs next or why it last couldn't.
|
||||
*/
|
||||
export default function routineSubtitle({ cron_job, now = Date.now() }: Params) {
|
||||
export default function routineSubtitle({
|
||||
cron_job,
|
||||
now = Date.now(),
|
||||
show_bot = true,
|
||||
show_workspace = true,
|
||||
}: Params) {
|
||||
const next =
|
||||
cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : "";
|
||||
|
||||
return [
|
||||
cron_job.bot_name || "A bot",
|
||||
cron_job.workspace_name || "",
|
||||
show_bot ? cron_job.bot_name || "A bot" : "",
|
||||
show_workspace ? cron_job.workspace_name || "" : "",
|
||||
`${cron_job.schedule} UTC`,
|
||||
cron_job.last_error || next,
|
||||
]
|
||||
Reference in New Issue
Block a user