Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
@@ -5,6 +5,7 @@ import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
type Params = {
job_id: number | string;
@@ -71,6 +72,8 @@ export default async function completeBotJob({
? null
: await insertBotMessage({
bot_id: job.bot_id,
session_id: await jobSessionId({ job }),
workspace_id: job.workspace_id || null,
text: result,
needs_you,
});
@@ -8,6 +8,7 @@ import type { BotJobKind } from "@/src/dict/bot-queue-dict";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabBotComputers from "../computers/grab-bot-computers";
import grabUserBotSession from "../bot-sessions/grab-user-bot-session";
type Params = {
/**
@@ -17,6 +18,11 @@ type Params = {
kind?: BotJobKind;
user_id: number | string;
bot_id: number | string;
/**
* The bot's session the result is posted in. The job runs in its
* workspace (context, computers, models), if it has one.
*/
session_id: number | string;
title: string;
/**
* The brief the bot writes for itself (markdown): what to do and how
@@ -34,7 +40,8 @@ type Params = {
*/
task_id?: number | string;
/**
* Must be one of the bot's computers (direct or through a workspace).
* Must be one of the bot's computers where the session is
* (`grabBotComputers`).
*/
computer_id?: number | string;
/**
@@ -46,6 +53,10 @@ type Params = {
* result or failure in its own conversation (`relayJobToRequester`).
*/
requested_by_bot_id?: number | string;
/**
* The requesting bot's session; its report goes to that conversation.
*/
requested_by_session_id?: number | string;
};
/**
@@ -58,6 +69,7 @@ export default async function enqueueBotJob({
kind = "task",
user_id,
bot_id,
session_id,
title,
instructions,
params,
@@ -66,6 +78,7 @@ export default async function enqueueBotJob({
computer_id,
delay,
requested_by_bot_id,
requested_by_session_id,
}: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -73,10 +86,26 @@ export default async function enqueueBotJob({
throw new AppError(`Bot not found.`);
}
const session = await grabUserBotSession({ session_id, user_id });
if (!session?.id || session.bot_id != bot.id) {
throw new AppError(`Session not found.`);
}
const workspace_id = session.workspace_id || null;
if (requested_by_bot_id && !(await grabUserBot({ bot_id: requested_by_bot_id, user_id }))?.id) {
throw new AppError(`Bot not found.`);
}
if (requested_by_session_id) {
const requested_from = await grabUserBotSession({ session_id: requested_by_session_id, user_id });
if (!requested_from?.id || requested_from.bot_id != Number(requested_by_bot_id)) {
throw new AppError(`Session not found.`);
}
}
if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) {
throw new AppError(`Message not found.`);
}
@@ -86,7 +115,7 @@ export default async function enqueueBotJob({
}
if (computer_id) {
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id, workspace_id });
if (!computers.some((c) => String(c.id) == String(computer_id))) {
throw new AppError(`This bot has no access to that computer.`);
@@ -96,7 +125,8 @@ export default async function enqueueBotJob({
const final_task_id =
kind == "reply"
? undefined
: task_id || (await createJobTask({ bot_id: bot.id, title }));
: task_id ||
(await createJobTask({ bot_id: bot.id, session_id: session.id, workspace_id, title }));
const res = await BunSQLite.queue.add<
BUN_SQLITE_COBBLE_BOTS_QUEUE,
@@ -107,6 +137,8 @@ export default async function enqueueBotJob({
{
kind,
bot_id: bot.id,
session_id: session.id,
workspace_id: workspace_id || undefined,
title,
instructions,
params: params ? JSON.stringify(params) : undefined,
@@ -114,6 +146,9 @@ export default async function enqueueBotJob({
task_id: final_task_id ? Number(final_task_id) : undefined,
computer_id: computer_id ? Number(computer_id) : undefined,
requested_by_bot_id: requested_by_bot_id ? Number(requested_by_bot_id) : undefined,
requested_by_session_id: requested_by_session_id
? Number(requested_by_session_id)
: undefined,
},
],
delay,
@@ -147,9 +182,13 @@ async function belongsToBot({
async function createJobTask({
bot_id,
session_id,
workspace_id,
title,
}: {
bot_id: number;
session_id: number;
workspace_id: number | null;
title: string;
}) {
const res = await BunSQLite.insert<
@@ -157,7 +196,7 @@ async function createJobTask({
(typeof BunSQLiteTables)[number]
>({
table: "bot_tasks",
data: [{ bot_id, title, status: "active" }],
data: [{ bot_id, session_id, workspace_id: workspace_id || undefined, title, status: "active" }],
});
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
@@ -6,6 +6,10 @@ import { BotReplyJobTitle } from "@/src/dict/bot-queue-dict";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The session the user wrote in.
*/
session_id: number | string;
/**
* The user message to answer.
*/
@@ -13,21 +17,23 @@ type Params = {
};
/**
* Queues the bot's chat reply to the user's latest message(s). A reply
* reads the whole recent conversation when it runs, so a burst of
* messages shares one reply: while one is still pending, it is reused.
* Queues the bot's chat reply to the user's latest message(s) in a
* session. A reply reads the session's recent messages when it runs, so a
* burst of messages shares one reply: while one is still pending in the
* session, it is reused.
*/
export default async function enqueueBotReply({
user_id,
bot_id,
session_id,
message_id,
}: Params) {
const pending = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
sql: `SELECT id FROM bots_queue
WHERE bot_id = ? AND kind = 'reply' AND status = 'pending'
WHERE bot_id = ? AND session_id = ? AND kind = 'reply' AND status = 'pending'
ORDER BY id DESC LIMIT 1`,
values: [bot_id],
values: [bot_id, session_id],
})
).payload?.[0];
@@ -44,6 +50,7 @@ export default async function enqueueBotReply({
kind: "reply",
user_id,
bot_id,
session_id,
title: BotReplyJobTitle,
instructions: "Reply to the user's latest messages.",
message_id,
@@ -0,0 +1,21 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "bot_id" | "session_id" | "workspace_id">;
};
/**
* The session a job's result goes in: its own, or (for a job queued
* without one) the active session of its bot's conversation there.
*/
export default async function jobSessionId({ job }: Params) {
if (job.session_id) return Number(job.session_id);
const session = await ensureBotSession({
bot_id: job.bot_id!,
workspace_id: job.workspace_id || null,
});
return Number(session.id);
}
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -30,6 +31,8 @@ export default async function notifyBotJobFailed({
const message = await insertBotMessage({
bot_id: job.bot_id,
session_id: await jobSessionId({ job }),
workspace_id: job.workspace_id || null,
text:
failure_message ||
[
@@ -1,6 +1,11 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import type {
BUN_SQLITE_COBBLE_BOT_SESSIONS,
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_BOTS_QUEUE,
} from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -13,9 +18,11 @@ type Params = {
/**
* Reports a handed-over job back to the bot that handed it over
* (`requested_by_bot_id`, the coordinator): a message in its conversation
* with the worker's report, flagged needs-you when the job failed. Returns
* the message for the live push, or null when nobody handed the job over.
* (`requested_by_bot_id`, the coordinator): a message in the conversation
* it was handed over from (the active session there, which may have moved
* on since), with the worker's report, flagged needs-you when the job
* failed. Returns the message for the live push, or null when nobody
* handed the job over.
*/
export default async function relayJobToRequester({ job, text, failed }: Params) {
if (!job.requested_by_bot_id || !job.bot_id || job.requested_by_bot_id == job.bot_id) {
@@ -38,11 +45,25 @@ export default async function relayJobToRequester({ job, text, failed }: Params)
if (!requester?.id) return null;
const requested_from = job.requested_by_session_id
? (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT workspace_id FROM bot_sessions WHERE id = ? AND bot_id = ? LIMIT 1`,
values: [job.requested_by_session_id, requester.id],
})
).payload?.[0]
: null;
const workspace_id = requested_from ? requested_from.workspace_id || null : job.workspace_id || null;
const session = await ensureBotSession({ bot_id: requester.id, workspace_id });
const name = worker?.name || "A crew member";
const title = job.title || "the job";
const message = await insertBotMessage({
bot_id: requester.id,
session_id: Number(session.id),
workspace_id,
text: failed
? `${name} couldn't finish **${title}**. ${text}`.trim()
: `${name} finished **${title}**. Their report:\n\n${text}`,