Share one crew across the install, keep user memory per user

Bots, workspaces and computers now belong to the install. Users reach
workers only through bot_users (assigned by a super admin or admin) and
workspace_users; the coordinator and main computer are one per install,
and only the super admin sees the coordinator. Conversations are per
user (bot_sessions.user_id), jobs run for the session's user, and live
updates go only to who should see them.

A bot's core memory is now general knowledge shared by every user it
works for; what it learns about a user lives in bot_user_memories.

One-off scripts move existing installs over: move-bot-memory-to-owners
(run first), then share-crew.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 11:37:14 +01:00
co-authored by Claude Opus 5.5
parent 4dd34ed251
commit e45d54d30b
133 changed files with 1854 additions and 436 deletions
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabUserComputer from "./grab-user-computer";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -18,6 +19,8 @@ export default async function addBotToComputer({
bot_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
const computer = await grabUserComputer({ computer_id, user_id });
@@ -10,11 +10,14 @@ import grabEnv from "../envs/grab-env";
import provisionComputer from "./provision-computer";
type Params = {
/**
* Who creates it. Computers belong to the install.
*/
user_id: number | string;
name: string;
description?: string;
/**
* `main` only for the owner's one main computer (`ensureMainComputer`).
* `main` only for the install's one main computer (`ensureMainComputer`).
*/
kind?: ComputerKind;
/**
@@ -29,7 +32,7 @@ type Params = {
};
/**
* Records a new computer for the user and its container
* Records a new computer and its container
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
* in the background (`provisionComputer`). Returns the computer at once,
* still `creating`, and the setup to await when the caller must (a CLI
@@ -10,9 +10,12 @@ import createComputer from "./create-computer";
import provisionComputer from "./provision-computer";
type Params = {
/**
* Who it's created by when it's missing (the super admin).
*/
user_id: number | string;
/**
* The owner's coordinator (from `ensureCoordinatorBot`). It always has
* The coordinator (from `ensureCoordinatorBot`). It always has
* access to the main computer.
*/
coordinator_id: number | string;
@@ -24,7 +27,7 @@ type Params = {
};
/**
* Returns the id of the user's main computer, creating it first if
* Returns the id of the install's one main computer, creating it first if
* missing (`createComputer`, which sets its container up in the
* background), and makes sure the coordinator has access to it. A main
* computer recorded before the app managed containers (no docker id, no
@@ -44,7 +47,6 @@ export default async function ensureMainComputer({
table: "computers",
query: {
query: {
user_id: { value: user_id },
kind: { value: "main" },
},
order: { field: "id", strategy: "ASC" },
@@ -3,7 +3,6 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
/**
* The workspace the bot is working in: then its computers are the
* workspace's, the default first, and nothing else.
@@ -15,27 +14,26 @@ type Params = {
* Every computer a bot can use where it's working. In a workspace, the
* workspace's computers (none linked: none at all), its default first. In
* its own chat, the ones it was given directly (`bot_computers`); the
* coordinator can use all of the owner's. There the one it works on now
* (`bots.computer_id`) comes first, else its main one, so `computers[0]`
* is where its tasks run by default.
* coordinator can use all of them. There the one it works on now
* (`bots.computer_id`) comes first, else the main one, so `computers[0]`
* is where its tasks run by default. The same for every user it works
* for: callers check the user reaches the bot.
*/
export default async function grabBotComputers({
bot_id,
user_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
if (workspace_id) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id AND w.archived = 0
WHERE c.user_id = ?
AND (
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
JOIN workspaces w ON w.id = wc.workspace_id AND w.archived = 0
WHERE (
(SELECT kind FROM bots WHERE id = ?) = 'coordinator'
OR ? IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = w.id)
)
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id, user_id, bot_id, Number(bot_id)],
values: [workspace_id, bot_id, Number(bot_id)],
});
return res.payload || [];
@@ -43,14 +41,13 @@ export default async function grabBotComputers({
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
WHERE c.user_id = ?
AND (
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
WHERE (
(SELECT kind FROM bots WHERE id = ?) = 'coordinator'
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
)
ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC,
c.kind = 'main' DESC, c.id ASC`,
values: [user_id, bot_id, bot_id, bot_id],
values: [bot_id, bot_id, bot_id],
});
return res.payload || [];
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import computersSql from "@/src/utils/crew-access/computers-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
computer_id: number | string;
@@ -7,15 +9,17 @@ type Params = {
};
/**
* A computer only if it belongs to the given owner.
* A computer only if the user sees it (`computersSql`).
*/
export default async function grabUserComputer({
computer_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS | null> {
const access = computersSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT * FROM computers WHERE id = ? AND user_id = ? LIMIT 1`,
values: [computer_id, user_id],
sql: `SELECT c.* FROM computers c WHERE c.id = ? AND ${access.sql} LIMIT 1`,
values: [computer_id, ...access.values],
});
return res.payload?.[0] || null;
@@ -1,10 +1,12 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import type { ComputerWithStatus } from "@/src/types";
import computersSql from "@/src/utils/crew-access/computers-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
/**
* Every computer of this owner, main first.
* Every computer this user sees (`computersSql`), main first.
*/
user_id?: number | string;
/**
@@ -22,14 +24,18 @@ export default async function grabUserComputers({
}: Params): Promise<ComputerWithStatus[]> {
if (!user_id && !computer_id) return [];
const access = computer_id
? { sql: `c.id = ?`, values: [Number(computer_id)] }
: computersSql({ role: await grabCrewRole({ user_id: user_id! }), user_id: user_id! });
const rows =
(
await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
LEFT JOIN containers co ON co.id = c.container_id
WHERE ${computer_id ? `c.id = ?` : `c.user_id = ?`}
WHERE ${access.sql}
ORDER BY c.kind = 'main' DESC, c.id ASC`,
values: [computer_id || user_id!],
values: access.values,
})
).payload || [];
@@ -1,4 +1,5 @@
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
import grabComputerAudience from "../access/grab-computer-audience";
import grabUserComputers from "./grab-user-computers";
type Params = {
@@ -6,16 +7,15 @@ type Params = {
};
/**
* Pushes a computer's current state (`server:computer-updated`) to its
* owner's open tabs. Best effort.
* Pushes a computer's current state (`server:computer-updated`) to the
* open tabs of everyone who sees it (`grabComputerAudience`). Best effort.
*/
export default async function pushComputerUpdate({ computer_id }: Params) {
const computer = (await grabUserComputers({ computer_id }))[0];
if (!computer?.user_id) return;
if (!computer?.id) return;
sendToUser({
user_id: computer.user_id,
data: { event: "server:computer-updated", data: computer },
});
for (const user_id of await grabComputerAudience({ computer_id: computer.id })) {
sendToUser({ user_id, data: { event: "server:computer-updated", data: computer } });
}
}
@@ -3,6 +3,7 @@ import AppError from "@/src/utils/app-error";
import isLockedComputerAccess from "@/src/utils/is-locked-computer-access";
import grabUserBot from "../bots/grab-user-bot";
import grabUserComputer from "./grab-user-computer";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -19,6 +20,8 @@ export default async function removeBotFromComputer({
bot_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
const computer = await grabUserComputer({ computer_id, user_id });
@@ -22,7 +22,7 @@ export default async function setBotComputer({ bot_id, user_id, computer_id }: P
throw new AppError(`Bot not found.`);
}
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
const computers = await grabBotComputers({ bot_id: bot.id });
const computer = computers.find((c) => String(c.id) == String(computer_id));
if (!computer?.id) {