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
@@ -39,6 +39,7 @@ export const handler: BunextAPIRouteHandler<
return {
success: true,
payload: await grabBotSessions({
user_id: user.id,
bot_id: conversation.bot.id,
workspace_id: conversation.workspace_id,
query,
+1
View File
@@ -44,6 +44,7 @@ export const handler: BunextAPIRouteHandler<
return {
success: true,
singleRes: await startBotSession({
user_id: user.id,
bot_id: conversation.bot.id,
workspace_id: conversation.workspace_id,
}),
@@ -9,6 +9,7 @@ import type { BotHarnessOverviewPayload } 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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
@@ -37,6 +38,10 @@ export const handler: BunextAPIRouteHandler<
throw new AppError(`This user can only read records.`);
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only an admin can do that.`);
}
const { bot_id, settings } = parseWithSchema({
schema: SaveBotHarnessSettingsRequestSchema,
data: body,
+3 -2
View File
@@ -17,8 +17,8 @@ import type {
} from "@moduletrace/bunext/types";
/**
* Posts a message from the session user to one of their own bots, in its
* own chat or in a workspace it's in, and queues the bot's reply, which
* Posts a message from the session user to a bot they chat with, in its
* own chat or in a workspace, and queues the bot's reply, which
* arrives over the WebSocket. The message goes in the conversation's
* active session (the next one when it's full) and also to the user's
* other open tabs. Files are uploaded first (`upload-attachment`) and
@@ -57,6 +57,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
const media = await grabAttachableMedia({ user_id: Number(user.id), media_ids });
const session = await ensureBotSession({
user_id: user.id,
bot_id: bot.id,
workspace_id: conversation.workspace_id,
rollover: true,
+15 -21
View File
@@ -1,10 +1,9 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import setUserAccess from "@/src/functions/backend/db/access/set-user-access";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import saveUser from "@/src/functions/backend/db/users/save-user";
import setUserProfileImage from "@/src/functions/backend/db/users/set-user-profile-image";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import AppError from "@/src/utils/app-error";
@@ -22,8 +21,10 @@ import type {
/**
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
* name, email, username, password, user types (each checked against the
* assignment matrix) and, when one is sent, their profile photo. A new user gets their coordinator and main computer
* straight away, as at signup. Returns the user.
* assignment matrix), when one is sent their profile photo, and when sent
* the workers assigned to them and the workspaces they're in
* (`setUserAccess`). Whoever may create or update users manages the crew
* too (super admins and admins). Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
try {
@@ -64,21 +65,8 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
await setUserProfileImage({ user_id, data_url: data.image });
}
if (created) {
/**
* The account exists now; `/admin/bots` creates both on their
* first visit if this fails.
*/
await ensureCoordinatorBot({ user_id })
.then((coordinator_id) => ensureMainComputer({ user_id, coordinator_id }))
.catch((error) =>
logError({
title: "Coordinator or main computer not created with the user",
error,
user_id,
ip_address: grabRequestIP(req),
}).catch(() => {}),
);
if (data.bot_ids || data.workspace_ids) {
await setUserAccess({ user_id, bot_ids: data.bot_ids, workspace_ids: data.workspace_ids });
}
await logEvent({
@@ -86,7 +74,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} ${created ? "created" : "updated"} user #${user_id}.`,
metadata: { user_id, user_types: data.user_types, password_changed: Boolean(data.password) },
metadata: {
user_id,
user_types: data.user_types,
password_changed: Boolean(data.password),
bot_ids: data.bot_ids,
workspace_ids: data.workspace_ids,
},
ip_address: grabRequestIP(req),
});
@@ -9,6 +9,7 @@ import type { BotHarnessOverviewPayload } 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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
@@ -38,6 +39,10 @@ export const handler: BunextAPIRouteHandler<
throw new AppError(`This user can only read records.`);
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only an admin can do that.`);
}
const { workspace_id, settings } = parseWithSchema({
schema: SaveWorkspaceHarnessSettingsRequestSchema,
data: body,
@@ -6,6 +6,7 @@ import { UpdateWorkspaceBotRequestSchema } from "@/src/schemas/workspace-schema"
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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
@@ -32,6 +33,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = a
throw new AppError(`This user can only read records.`);
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only an admin can do that.`);
}
const { workspace_id, bot_id, system_prompt, memory } = parseWithSchema({ schema: UpdateWorkspaceBotRequestSchema, data: body });
await updateWorkspaceBot({ user_id: user.id, workspace_id, bot_id, system_prompt, memory });