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
@@ -0,0 +1,14 @@
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
/**
* Shown to a user who reaches no bot yet: an admin assigns them bots or
* adds them to a workspace.
*/
export default function NoBotsSection() {
return (
<SettingsShell title="Bots" description="The bots you chat with.">
<EmptyContent title="No bots for you yet. Ask an admin to assign you a bot or add you to a workspace." />
</SettingsShell>
);
}
@@ -27,9 +27,10 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
}
/**
* The latest step of the open bot's active task (a `bots_queue_updates`
* row's text), kept current from `server:bot-job-update`. Starts over when
* `server:bot-updated` brings a different task, or none.
* The open session's active task and its latest step (a
* `bots_queue_updates` row's text), kept current from
* `server:bot-job-update`. Starts over when `server:bot-updated` brings a
* different task, or none.
*/
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
@@ -49,11 +50,15 @@ export default function useTaskProgress({ bot_id, session_id, task }: Params) {
onData: (data: BotUpdatedData) => {
if (data.id != bot_id) return;
setProgress((prev) =>
(data.task?.id || null) == prev.task_id ? prev : fromTask({ task: data.task }),
);
/**
* The user's active task with the bot may be in another of
* their conversations with it.
*/
const task = data.task && (!session_id || data.task.session_id == session_id) ? data.task : null;
setProgress((prev) => ((task?.id || null) == prev.task_id ? prev : fromTask({ task })));
},
});
return progress.text;
return progress;
}
@@ -7,6 +7,7 @@ import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form";
import HeaderIconButton from "./header-icon-button";
@@ -24,8 +25,9 @@ type Props = {
/**
* The header's model icon: opens every model the bot uses (text, media
* reading, transcription, speech, images) in the Models settings form. On
* the coordinator that form is the defaults, for super admins only;
* everyone else gets the icon disabled, its title saying why.
* the coordinator that form is the defaults, for super admins only; a
* worker's (or a workspace's) models are for super admins and admins.
* Everyone else gets the icon disabled, its title saying why.
*/
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
@@ -35,9 +37,9 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
const in_workspace = Boolean(workspace?.id);
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
const can_edit =
!is_coordinator ||
isPassThroughUser({ user_types: pageProps?.user_types });
const can_edit = is_coordinator
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
const name = bot.name || "This bot";
const target = (
@@ -50,7 +52,9 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
: is_coordinator
? "Change the default models"
: `Change ${name}'s models`
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
: is_coordinator
? `${name} uses the default models. A super admin can change them in Settings, under Models.`
: `An admin can change the models ${name} uses.`
}
disabled={!can_edit}
/>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
@@ -8,6 +8,8 @@ import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
type Props = {
bot: BotWithLastMessage;
@@ -15,12 +17,27 @@ type Props = {
/**
* The header's avatar: opens the bot's profile (picture, name, job,
* colour, instructions) to edit.
* colour, instructions) to edit, for super admins and admins. Everyone
* else sees the avatar alone.
*/
export default function BotProfileModal({ bot }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const name = bot.name || "This bot";
const avatar = (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
);
if (!canManageCrew({ user_types: pageProps?.user_types })) {
return <Stack className="shrink-0">{avatar}</Stack>;
}
return (
<Modal
target={
@@ -32,12 +49,7 @@ export default function BotProfileModal({ bot }: Props) {
aria-label={`Edit ${name}`}
className="shrink-0 p-0! rounded-default hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
{avatar}
</Button>
}
targetWrapperProps={{ className: "shrink-0" }}
@@ -12,18 +12,29 @@ type Props = {
* The latest step of the bot's active task.
*/
progress?: string | null;
/**
* The thread's last message is the user's, so a reply is due here.
*/
awaiting_reply?: boolean;
/**
* This session has an active task.
*/
has_task?: boolean;
};
/**
* Under the last message while the bot is working on a reply or a task
* (or waiting for a model to come back), so slow work doesn't look like
* none. A task shows its latest step, else its title.
* none. A task shows its latest step. The bot's face is shared by
* everyone who sees it, so the row only shows for work in this
* conversation: a reply while the user has the last word, or its own
* task.
*/
export default function BotThinkingRow({ bot, progress }: Props) {
export default function BotThinkingRow({ bot, progress, awaiting_reply, has_task }: Props) {
const name = bot.name || "The bot";
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
const working = bot.face == "working" && !thinking;
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle && Boolean(awaiting_reply);
const working = bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task);
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
if (!thinking && !working && !waiting) return null;
@@ -32,7 +43,7 @@ export default function BotThinkingRow({ bot, progress }: Props) {
: working
? `${name} is working…`
: `${BotWaitingStatusLine}…`;
const detail = working ? progress || bot.status_line : null;
const detail = working ? progress : null;
return (
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
@@ -14,6 +14,10 @@ type Props = {
* The latest step of the bot's active task, shown while it works.
*/
progress?: string | null;
/**
* This session has an active task.
*/
has_task?: boolean;
};
/**
@@ -22,7 +26,7 @@ type Props = {
* While the bot works on a reply or a task, a status row sits under the
* last one.
*/
export default function MessageThread({ bot, messages, progress }: Props) {
export default function MessageThread({ bot, messages, progress, has_task }: Props) {
const groups = groupMessages({ messages });
return (
@@ -40,7 +44,12 @@ export default function MessageThread({ bot, messages, progress }: Props) {
group={group}
/>
))}
<BotThinkingRow bot={bot} progress={progress} />
<BotThinkingRow
bot={bot}
progress={progress}
awaiting_reply={messages[messages.length - 1]?.author == "user"}
has_task={has_task}
/>
</Stack>
</Stack>
);
@@ -106,7 +106,8 @@ export default function Conversation() {
<MessageThread
bot={live_bot}
messages={messages}
progress={progress}
progress={progress.text}
has_task={Boolean(progress.task_id)}
/>
<Composer
bot={bot}
@@ -3,15 +3,17 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A worker bot's model settings. The coordinator always uses the defaults,
* so its page goes back to its conversation.
* A worker bot's model settings, for super admins and admins. The
* coordinator always uses the defaults, so its page (and anyone else's)
* goes back to the conversation.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
@@ -24,7 +26,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return { redirect: { destination: "/admin/bots" } };
}
if (bot.kind == "coordinator") {
if (bot.kind == "coordinator" || !canManageCrew({ user_types })) {
return { redirect: { destination: `/admin/bots/${bot.id}` } };
}
+28 -10
View File
@@ -1,15 +1,20 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType } from "@/src/types";
import crewRole from "@/src/utils/rbac/crew-role";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the coordinator's conversation, creating the coordinator and the
* main computer first if this account doesn't have them yet.
* Opens a conversation. The super admin lands on the coordinator, which
* (with the main computer) is created first if the install has none.
* Anyone else lands on the latest bot they chat with, else their first
* workspace; with neither, the page says to ask an admin.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
const { user, user_types } = await userAuth({ req });
if (!user?.id) {
return {
@@ -19,12 +24,25 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
};
}
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
await ensureMainComputer({ user_id: user.id, coordinator_id });
if (crewRole({ user_types }) == "super_admin") {
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
await ensureMainComputer({ user_id: user.id, coordinator_id });
return {
redirect: {
destination: `/admin/bots/${coordinator_id}`,
},
};
return { redirect: { destination: `/admin/bots/${coordinator_id}` } };
}
const [bots, workspaces] = await Promise.all([
grabBots({ user_id: user.id }),
grabUserWorkspaces({ user_id: user.id }),
]);
if (bots[0]?.id) {
return { redirect: { destination: `/admin/bots/${bots[0].id}` } };
}
if (workspaces[0]?.id) {
return { redirect: { destination: `/admin/workspaces/${workspaces[0].id}` } };
}
return { props: { bots, workspaces } };
};
+4 -2
View File
@@ -1,11 +1,13 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import NoBotsSection from "./(sections)/no-bots-section";
/**
* Never rendered: `index.server.ts` always redirects to the coordinator.
* Only rendered for a user who reaches no bot yet: `index.server.ts`
* otherwise redirects to a conversation.
*/
export default function BotsPage() {
return null;
return <NoBotsSection />;
}
export const meta: BunextPageModuleMeta = {
@@ -20,6 +20,7 @@ export default async function submitUserForm({
setLoading,
setStatus,
onSaved,
app_context,
}: Params) {
if (loading) return;
@@ -44,6 +45,13 @@ export default async function submitUserForm({
confirm_password: form.confirm_password,
user_types: form.user_types,
image: image?.imageBase64Full,
/**
* Only where the form offers them (`UserFormAccess`), so a
* form without them never clears them.
*/
...(app_context.pageProps?.crew_bots
? { bot_ids: form.bot_ids || [], workspace_ids: form.workspace_ids || [] }
: {}),
},
});
@@ -4,6 +4,7 @@ import useFormInit from "@/src/hooks/use-form-init";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseIdList from "@/src/utils/parse-id-list";
import parseUserTypes from "@/src/utils/parse-user-types";
import submitUserForm from "../../(functions)/submit-user-form";
import UserFormName from "./user-form-name";
@@ -11,6 +12,7 @@ import UserFormAction from "./user-form-action";
import UserFormEmail from "./user-form-email";
import UserFormUsername from "./user-form-username";
import UserFormUserTypes from "./user-form-user-types";
import UserFormAccess from "./user-form-access";
import UserFormImage from "./user-form-image";
import UserFormPassword from "./user-form-password";
@@ -27,8 +29,9 @@ type Props = {
};
/**
* A user's photo, name, email, username, user types and password, saved
* in one go through `users/save-user`.
* A user's photo, name, email, username, user types, what they reach
* (bots and workspaces) and password, saved in one go through
* `users/save-user`.
*/
export default function UserForm({ person, onSaved, onCancel }: Props) {
const init = useFormInit<UserFormObject>({
@@ -41,6 +44,8 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
user_types: parseUserTypes({ user_types: person.user_types }).filter(
(type) => !UneditableUserTypes.includes(type),
),
bot_ids: parseIdList({ ids: person.bot_ids }),
workspace_ids: parseIdList({ ids: person.workspace_ids }),
}
: undefined,
existing_full: person ? { id: person.id } : undefined,
@@ -56,6 +61,7 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
<UserFormUsername {...init} />
</Row>
<UserFormUserTypes {...init} person={person} />
<UserFormAccess {...init} person={person} />
<UserFormPassword {...init} />
<UserFormAction {...init} onCancel={onCancel} />
</Form>
@@ -0,0 +1,68 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
};
/**
* Picks the workers a user chats with and the workspaces they work in.
* Shown where the page carries the crew (`crew_bots`, `crew_workspaces`),
* and only for a user who'd reach nothing without it: super admins and
* admins reach every worker and workspace anyway.
*/
export default function UserFormAccess({ setForm, form, person, app_context }: Props) {
const bots = app_context.pageProps?.crew_bots;
const workspaces = app_context.pageProps?.crew_workspaces;
if (!bots || !workspaces) return null;
const types = [...parseUserTypes({ user_types: person?.user_types }), ...(form.user_types || [])];
if (types.includes("super_admin") || types.includes("admin")) return null;
return (
<Stack className="w-full gap-4! items-stretch">
<SearchSelect
id="user-form-bots"
title="Bots"
label="Bots"
showLabel
multiple
placeholder="Pick the bots they chat with"
options={bots.map((bot) => ({
value: String(bot.id),
title: bot.role ? `${bot.name} (${bot.role})` : bot.name || `Bot ${bot.id}`,
}))}
defaultValues={(form.bot_ids || []).map(String)}
info="They chat with these in their own chat. They reach no bot until one is picked here or they're in a workspace."
changeHandler={(value) => {
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
setForm((prev) => ({ ...prev, bot_ids: ids }));
}}
/>
<SearchSelect
id="user-form-workspaces"
title="Workspaces"
label="Workspaces"
showLabel
multiple
placeholder="Pick the workspaces they work in"
options={workspaces.map((workspace) => ({
value: String(workspace.id),
title: workspace.name || `Workspace ${workspace.id}`,
}))}
defaultValues={(form.workspace_ids || []).map(String)}
info="They chat with every bot in these workspaces, there."
changeHandler={(value) => {
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
setForm((prev) => ({ ...prev, workspace_ids: ids }));
}}
/>
</Stack>
);
}
@@ -0,0 +1,27 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import grabSettingsServerProps from "./grab-settings-server-props";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
/**
* `grabSettingsServerProps` for the pages that manage the crew (bots,
* workspaces, computers): super admins and admins only, everyone else
* lands on the general settings.
*/
export default async function grabCrewSettingsServerProps(params: Params) {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
const { user_types } = await userAuth({ req: params.req });
if (!canManageCrew({ user_types })) {
return { redirect: { destination: SettingsDefaultPath, permanent: false }, props: undefined };
}
return settings;
}
@@ -1,10 +1,10 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
@@ -1,10 +1,10 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
@@ -4,11 +4,13 @@ import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import canListUsers from "@/src/utils/rbac/can-list-users";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* For those who may list users (`canListUsers`: super admins, admins);
* everyone else lands on the general settings.
* everyone else lands on the general settings. Carries every worker and
* workspace, to pick what each user reaches.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
@@ -25,6 +27,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
props: {
...settings.props,
persons: (await grabUsers()).payload || [],
...(await grabCrewOptions()),
},
};
};
@@ -1,10 +1,10 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
@@ -1,5 +1,7 @@
import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
@@ -11,7 +13,8 @@ import WorkspaceSectionHeading from "../(partials)/workspace-section-heading";
/**
* A workspace's page beside its rail: its context, bots, computers, then
* its models, and deleting it.
* its models, and deleting it. A member only lands here while it has no
* bots yet.
*/
export default function WorkspaceOverview() {
const { pageProps } = useContext(AppContext);
@@ -19,6 +22,14 @@ export default function WorkspaceOverview() {
if (!workspace?.id) return null;
if (!canManageCrew({ user_types: pageProps?.user_types })) {
return (
<SettingsShell title={workspace.name || "Workspace"} description={workspace.description || "A workspace."}>
<EmptyContent title="No bots here yet. An admin adds them." />
</SettingsShell>
);
}
const workspace_id = Number(workspace.id);
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
@@ -6,16 +6,18 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A workspace's page: its details, crew, computers and models, with the
* rail showing its crew.
* rail showing its crew. Only super admins and admins manage it; a member
* opens its first bot instead.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
@@ -30,6 +32,19 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const workspace_id = Number(workspace.id);
/**
* Members don't manage it: they go straight to its first bot.
*/
if (!canManageCrew({ user_types })) {
const workspace_bots = await grabWorkspaceBots({ user_id: user.id, workspace_id });
if (workspace_bots[0]?.id) {
return { redirect: { destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}` } };
}
return { props: { workspaces, workspace, bots: workspace_bots, workspace_bots } };
}
const [workspace_bots, all_bots, workspace_computers, computers, workspace_harness_overview] =
await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }),
@@ -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 });