Let members view a workspace's dashboard, editing still for managers only

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 15:48:23 +01:00
co-authored by Claude Opus 5.5
parent 391943a1c2
commit dd1e9e0cfe
7 changed files with 25 additions and 39 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
### Bots (the chat shell) ### Bots (the chat shell)
- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`, and on a workspace's page is sent to its first bot. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. - **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this.
- **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
@@ -17,6 +17,10 @@ type Props = {
* What can be done with it, after the open button. * What can be done with it, after the open button.
*/ */
children?: ReactNode; children?: ReactNode;
/**
* Link the title to its settings page. Off for people who can't open it.
*/
linked?: boolean;
}; };
/** /**
@@ -24,14 +28,14 @@ type Props = {
* settings page from the title. Every list uses it, so the row behaves the * settings page from the title. Every list uses it, so the row behaves the
* same, and the open button shows it in the viewer over the page. * same, and the open button shows it in the viewer over the page.
*/ */
export default function ComputerListRow({ computer, badge, subtitle, children }: Props) { export default function ComputerListRow({ computer, badge, subtitle, children, linked = true }: Props) {
const name = computer.name || `Computer ${computer.id}`; const name = computer.name || `Computer ${computer.id}`;
const ready = computer.status == "running"; const ready = computer.status == "running";
return ( return (
<SettingsListRow <SettingsListRow
avatar={<ComputerIconTile />} avatar={<ComputerIconTile />}
href={`/admin/settings/computers/${computer.id}`} href={linked ? `/admin/settings/computers/${computer.id}` : undefined}
title={name} title={name}
badge={badge} badge={badge}
subtitle={ subtitle={
@@ -8,13 +8,17 @@ type Props = {
* The computers linked to the workspace, its default first. * The computers linked to the workspace, its default first.
*/ */
computers: ComputerWithStatus[]; computers: ComputerWithStatus[];
/**
* Whether the viewer can open each computer's settings page.
*/
linked: boolean;
}; };
/** /**
* The computers bots work on here, with their state. Display only: * The computers bots work on here, with their state. Display only:
* computers are linked and set up under Settings. * computers are linked and set up under Settings.
*/ */
export default function WorkspaceComputersListGroup({ computers }: Props) { export default function WorkspaceComputersListGroup({ computers, linked }: Props) {
return ( return (
<SettingsGroup <SettingsGroup
id="computers" id="computers"
@@ -30,6 +34,7 @@ export default function WorkspaceComputersListGroup({ computers }: Props) {
key={computer.id} key={computer.id}
computer={computer} computer={computer}
is_default={index == 0} is_default={index == 0}
linked={linked}
/> />
))} ))}
</SettingsList> </SettingsList>
@@ -4,12 +4,16 @@ import type { ComputerWithStatus } from "@/src/types";
type Props = { type Props = {
computer: ComputerWithStatus; computer: ComputerWithStatus;
is_default: boolean; is_default: boolean;
/**
* Whether the viewer can open its settings page.
*/
linked: boolean;
}; };
/** /**
* A computer in the workspace dashboard: its state, resources and what * A computer in the workspace dashboard: its state, resources and what
* it's for, opening its settings page. * it's for, opening its settings page for those who manage the crew.
*/ */
export default function WorkspaceDashboardComputerRow({ computer, is_default }: Props) { export default function WorkspaceDashboardComputerRow({ computer, is_default, linked }: Props) {
return <ComputerListRow computer={computer} badge={is_default ? "Default" : undefined} />; return <ComputerListRow computer={computer} badge={is_default ? "Default" : undefined} linked={linked} />;
} }
@@ -48,6 +48,7 @@ export default function WorkspaceDashboard() {
/> />
<WorkspaceComputersListGroup <WorkspaceComputersListGroup
computers={pageProps?.workspace_computers || []} computers={pageProps?.workspace_computers || []}
linked={can_manage}
/> />
</SettingsShell> </SettingsShell>
); );
@@ -29,7 +29,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
redirect: { redirect: {
destination: canManageCrew({ user_types }) destination: canManageCrew({ user_types })
? `/admin/settings/workspaces/${query.workspace_id}` ? `/admin/settings/workspaces/${query.workspace_id}`
: "/admin/bots", : `/admin/workspaces/${query.workspace_id}`,
}, },
}; };
} }
@@ -4,18 +4,17 @@ import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-wo
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers"; import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import { AdminHomePath } from "@/src/dict/admin-sections-dict"; 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 { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/** /**
* A workspace's dashboard: its picture, context, latest activity, bots and * A workspace's dashboard: its picture, context, latest activity, bots and
* computers, all read-only. Editing lives under Settings. Only super admins * computers, all read-only. Everyone who reaches the workspace sees it; editing lives
* and admins see it; a member opens its first bot instead. * under Settings, for super admins and admins only.
*/ */
export const server: BunextPageServerFn<PagePropsType> = async (params) => { export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query; const query: PageQueryObject = params.query;
const { user, user_types } = await userAuth({ req: params.req }); const { user } = await userAuth({ req: params.req });
if (!user?.id) { if (!user?.id) {
return { redirect: { destination: "/auth/login" } }; return { redirect: { destination: "/auth/login" } };
@@ -32,33 +31,6 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const workspace_id = Number(workspace.id); 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, workspace_computers, workspace_activity] = const [workspace_bots, workspace_computers, workspace_activity] =
await Promise.all([ await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }), grabWorkspaceBots({ user_id: user.id, workspace_id }),