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
+5 -5
View File
@@ -9,10 +9,10 @@ import { logError } from "@/src/functions/backend/logger";
import createKeyLock from "@/src/utils/create-key-lock";
import HarnessError from "@/src/harness/harness-error";
import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job";
import { BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
import { BotReplyJobTitle, BotTaskStatusLine, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
import BotJobFatalError from "./bot-job-fatal-error";
import describeJobStep from "./describe-job-step";
import pushToBotOwner from "./push-to-bot-owner";
import pushToSessionUser from "./push-to-session-user";
import pushBotNotice from "./push-bot-notice";
import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json";
@@ -80,8 +80,8 @@ export default async function processBotJob({
const row = await insertBotJobUpdate({ job_id, bot_id, ...described });
await pushToBotOwner({
bot_id,
await pushToSessionUser({
session_id: job.session_id,
data: {
event: "server:bot-job-update",
data: {
@@ -111,7 +111,7 @@ export default async function processBotJob({
bot: await setBotFace({
bot_id,
face: "working",
status_line: job.title || "",
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
}),
});
+11 -16
View File
@@ -2,8 +2,8 @@ import type {
BUN_SQLITE_COBBLE_BOT_MESSAGES,
BUN_SQLITE_COBBLE_BOTS,
} from "@/db/types/db";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import pushToBotOwner from "./push-to-bot-owner";
import pushBotUpdated from "./push-bot-updated";
import pushToSessionUser from "./push-to-session-user";
type Params = {
bot_id: number | string;
@@ -17,32 +17,27 @@ type Params = {
};
/**
* Pushes a new bot message and/or face change to the owner's open tabs.
* A face change carries the bot's active task, for the working-on panel.
* A relayed report goes to the conversation it was posted in.
* Pushes a new bot message to the open tabs of the user whose
* conversation it's in, and a face change to everyone who sees the bot
* (`pushBotUpdated`, each with their own active task). A relayed report
* goes to the conversation it was posted in.
*/
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
if (message) {
await pushToBotOwner({
bot_id,
await pushToSessionUser({
session_id: message.session_id,
data: { event: "server:bot-message", data: message },
});
}
if (relay) {
await pushToBotOwner({
bot_id: relay.bot_id,
await pushToSessionUser({
session_id: relay.message.session_id,
data: { event: "server:bot-message", data: relay.message },
});
}
if (bot) {
await pushToBotOwner({
bot_id,
data: {
event: "server:bot-updated",
data: { ...bot, task: await grabActiveBotTask({ bot_id }) },
},
});
await pushBotUpdated({ bot: { ...bot, id: bot.id || Number(bot_id) } });
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id">;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the bot.
*/
users?: Set<string>;
};
/**
* Pushes `server:bot-updated` (face, status line, computer) to everyone
* who sees the bot (`grabBotAudience`), each with their own active task
* with it, never another user's.
*/
export default async function pushBotUpdated({ bot, users }: Params) {
if (!bot.id) return;
for (const user_id of await grabBotAudience({ bot_id: bot.id })) {
if (users && !users.has(String(user_id))) continue;
sendToUser({
user_id,
data: {
event: "server:bot-updated",
data: {
id: bot.id,
face: bot.face,
status_line: bot.status_line || "",
...(bot.computer_id !== undefined ? { computer_id: bot.computer_id || null } : {}),
task: await grabActiveBotTask({ bot_id: bot.id, user_id }),
},
},
});
}
}
-26
View File
@@ -1,26 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { WebSocketDataType } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot_id: number | string;
data: WebSocketDataType;
};
/**
* Live update to the bot owner's open tabs. Best effort: nothing is queued
* for offline users, who see the change on their next page load.
*/
export default async function pushToBotOwner({ bot_id, data }: Params) {
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT user_id FROM bots WHERE id = ? LIMIT 1`,
values: [bot_id],
})
).payload?.[0];
if (!bot?.user_id) return;
sendToUser({ user_id: bot.user_id, data });
}
+29
View File
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import type { WebSocketDataType } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
session_id?: number | string | null;
data: WebSocketDataType;
};
/**
* Live update to the open tabs of the user a session belongs to: only
* they see that conversation. Best effort: nothing is queued for offline
* users, who see the change on their next page load.
*/
export default async function pushToSessionUser({ session_id, data }: Params) {
if (!session_id) return;
const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [session_id],
})
).payload?.[0];
if (!session?.user_id) return;
sendToUser({ user_id: session.user_id, data });
}
+22 -4
View File
@@ -1,4 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import HarnessError from "@/src/harness/harness-error";
import BotJobFatalError from "./bot-job-fatal-error";
import runBotReply from "./run-bot-reply";
@@ -9,6 +12,9 @@ import type { BotJobRunner } from "./types";
* Does a job's actual work on the harness: a `reply` answers the user in
* chat (`runBotReply`), a `task` works through its brief (`runBotTask`).
*
* It works for the user whose conversation the job is in: their memory,
* and only what they can reach.
*
* Harness failures that waiting can fix (rate limits, outages) are thrown
* as they are, so the queue retries with backoff; the rest are fatal.
*/
@@ -16,16 +22,28 @@ const runBotJob: BotJobRunner = async (context) => {
const { job } = context;
const bot = job.bot_id ? await grabBotById({ bot_id: job.bot_id }) : null;
if (!bot?.id || !bot.user_id) {
if (!bot?.id) {
throw new BotJobFatalError(`The bot for this job no longer exists.`);
}
const owned = { ...bot, id: bot.id, user_id: bot.user_id };
const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [jobSessionId({ job })],
})
).payload?.[0];
if (!session?.user_id) {
throw new BotJobFatalError(`The conversation for this job no longer exists.`);
}
const owned = { ...bot, id: bot.id };
const user_id = session.user_id;
try {
return job.kind == "reply"
? await runBotReply({ ...context, bot: owned })
: await runBotTask({ ...context, bot: owned });
? await runBotReply({ ...context, bot: owned, user_id })
: await runBotTask({ ...context, bot: owned, user_id });
} catch (error: unknown) {
if (
error instanceof HarnessError &&
+9 -4
View File
@@ -20,7 +20,11 @@ import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number; user_id: number };
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
/**
* The user whose conversation the job is in.
*/
user_id: number;
};
/**
@@ -35,6 +39,7 @@ type Params = BotJobContext & {
*/
export default async function runBotReply({
bot,
user_id,
job,
signal,
report,
@@ -51,8 +56,8 @@ export default async function runBotReply({
return { result: "", silent: true };
}
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
/**
* A summary that can't be written now (no model free) is left out
@@ -69,7 +74,7 @@ export default async function runBotReply({
const tools = await grabBotChatTools({
bot,
user_id: bot.user_id,
user_id,
computers,
session_id,
workspace_id,
+9 -4
View File
@@ -14,7 +14,11 @@ import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
bot: { id: number; user_id: number };
bot: { id: number };
/**
* The user whose conversation the job is in.
*/
user_id: number;
};
/**
@@ -28,6 +32,7 @@ type Params = BotJobContext & {
*/
export default async function runBotTask({
bot,
user_id,
job,
params,
signal,
@@ -46,8 +51,8 @@ export default async function runBotTask({
try {
const session_id = await jobSessionId({ job });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
const carry_over = await grabSessionCarryOver({
session_id,
bot_id: bot.id,
@@ -97,7 +102,7 @@ export default async function runBotTask({
},
],
tools: [
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id, workspace_id, computers }),
...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers }),
...(computers.length
? [
searchComputersTool({ computers, workspace_id }),
+2 -2
View File
@@ -27,7 +27,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
- Work that takes more than a moment, or anything on a computer (running commands, editing files, browsing): call start_task with a clear brief, then tell the user in one sentence, in the first person, what you're doing, e.g. "I'm going through your inbox now. I'll report back when I'm done." A task you start is your own work: never say you handed it to anyone else. The result is posted here when it's done.
- When a message mixes both, answer the quick parts in this reply and start a task for the rest.
- When only the user can do something on a computer (log in, solve a captcha, enter a code), ask them to take over with ask_user_to_take_over and say exactly what to do. They'll tell you when they hand it back; then carry on.
- When the user tells you something worth keeping, save it with update_memory. When they correct how you work, update your instructions with update_instructions.
- When the user tells you something worth keeping, save it with update_memory: about them in your memory of this user, never in your core memory, which every user you work for shares. When they correct how you work for everyone, update your instructions with update_instructions; a correction that's only their preference goes in your memory of them.
- Never claim you did something you didn't do.`;
/**
@@ -54,7 +54,7 @@ You are working through a task on your own; the user isn't watching. Use your to
*/
export const WorkspaceLearningNote = `# Learning in this workspace
update_memory and update_instructions save to this workspace by default: what you learn here applies here. Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. When something learned here looks useful everywhere, suggest saving it to your core and do it once the user agrees.`;
update_memory and update_instructions save to this workspace by default: what you learn here applies here. What you learn about the user that holds outside this workspace goes in your memory of them (update_memory, scope "user"). Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. Your core memory is shared with every user you work for, so nothing about this user goes there. When something learned here looks useful everywhere, suggest saving it and do it once the user agrees.`;
/**
* Asks the text model for a closed session's summary, carried into the
+7
View File
@@ -38,6 +38,13 @@ export type BotJobUpdateKind = (typeof BotJobUpdateKinds)[number]["value"];
*/
export const BotReplyJobTitle = "Thinking";
/**
* The bot's status line while a task runs. Everyone who sees the bot sees
* it, so it never names the task: each user sees their own task's title
* and steps in their conversation.
*/
export const BotTaskStatusLine = "Working on a task";
/**
* The bot's status line while its job waits for a rate-limited model.
*/
+4 -3
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import canListUsers from "@/src/utils/rbac/can-list-users";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
export type SettingsPage = {
title: string;
@@ -45,9 +46,9 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/change-password",
Icon: KeyRound,
},
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor },
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot, can_view: canManageCrew },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, can_view: canManageCrew },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
{
title: "Users",
url: "/admin/settings/users",
+4 -4
View File
@@ -7,17 +7,17 @@ export const UserTypes = [
{
value: "super_admin",
title: "Super admin",
description: "Runs the install: models, environment, computers and users. The first account.",
description: "Runs the install: the coordinator, models, environment, computers and users. The first account.",
},
{
value: "admin",
title: "Admin",
description: "Adds and manages members.",
description: "Manages members, bots and workspaces, and picks which bots and workspaces each member reaches.",
},
{
value: "member",
title: "Member",
description: "Has their own crew, workspaces and main computer.",
description: "Chats with the bots assigned to them and in the workspaces they were added to.",
},
{
value: "read_only",
@@ -27,7 +27,7 @@ export const UserTypes = [
{
value: "revoked",
title: "Revoked",
description: "Can't sign in. Their crew and files are kept.",
description: "Can't sign in. Their conversations and files are kept.",
},
] as const;
@@ -0,0 +1,20 @@
import AppError from "@/src/utils/app-error";
import grabCrewRole from "./grab-crew-role";
type Params = {
user_id: number | string;
};
/**
* Throws unless the user manages the crew (a super admin or an admin).
* Returns their role.
*/
export default async function assertCrewManager({ user_id }: Params) {
const role = await grabCrewRole({ user_id });
if (role == "member") {
throw new AppError(`Only an admin can do that.`);
}
return role;
}
@@ -0,0 +1,30 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
};
/**
* Every user who sees a bot (`grabUserBot`), for live updates about it:
* the super admin, and for a worker the admins, the users it's assigned
* to and the members of its workspaces.
*/
export default async function grabBotAudience({ bot_id }: Params): Promise<number[]> {
const res = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM user_types WHERE user_type = 'super_admin'
UNION
SELECT ut.user_id FROM user_types ut JOIN bots b ON b.id = ? AND b.kind != 'coordinator'
WHERE ut.user_type = 'admin'
UNION
SELECT bu.user_id FROM bot_users bu JOIN bots b ON b.id = bu.bot_id AND b.kind != 'coordinator'
WHERE bu.bot_id = ?
UNION
SELECT wu.user_id FROM workspace_users wu
JOIN workspace_bots wb ON wb.workspace_id = wu.workspace_id AND wb.bot_id = ?
JOIN workspaces w ON w.id = wu.workspace_id AND w.archived = 0
JOIN bots b ON b.id = wb.bot_id AND b.kind != 'coordinator'`,
values: [bot_id, bot_id, bot_id],
});
return (res.payload || []).map((row) => Number(row.user_id)).filter(Boolean);
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
computer_id: number | string;
};
/**
* Every user who sees a computer (`computersSql`), for live updates about
* it: super admins and admins, and the users whose workspaces or assigned
* workers use it.
*/
export default async function grabComputerAudience({ computer_id }: Params): Promise<number[]> {
const res = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM user_types WHERE user_type IN ('super_admin', 'admin')
UNION
SELECT wu.user_id FROM workspace_users wu
JOIN workspace_computers wc ON wc.workspace_id = wu.workspace_id AND wc.computer_id = ?
UNION
SELECT bu.user_id FROM bot_users bu
JOIN bot_computers bc ON bc.bot_id = bu.bot_id AND bc.computer_id = ?
JOIN bots b ON b.id = bu.bot_id AND b.kind != 'coordinator'`,
values: [computer_id, computer_id],
});
return (res.payload || []).map((row) => Number(row.user_id)).filter(Boolean);
}
@@ -0,0 +1,19 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
/**
* Every worker and every workspace (not archived), for picking what a
* user may reach. Never the coordinator: it's the super admin's alone.
*/
export default async function grabCrewOptions() {
const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, name, role FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`,
}),
]);
return { crew_bots: bots.payload || [], crew_workspaces: workspaces.payload || [] };
}
@@ -0,0 +1,13 @@
import crewRole from "@/src/utils/rbac/crew-role";
import grabUserTypes from "../users/grab-user-types";
type Params = {
user_id: number | string;
};
/**
* A user's `crewRole`, read from their user types.
*/
export default async function grabCrewRole({ user_id }: Params) {
return crewRole({ user_types: await grabUserTypes({ user_id }) });
}
@@ -0,0 +1,41 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import grabUserBot from "../bots/grab-user-bot";
import grabUserWorkspace from "../workspaces/grab-user-workspace";
type Params = {
media_id: number | string;
user_id: number | string;
};
/**
* A bot's or workspace's picture (private media, uploaded by whoever set
* it) for a user who reaches that bot or workspace, or null. Bots and
* workspaces belong to the install, so their pictures show to everyone
* who sees them.
*/
export default async function grabSharedPictureMedia({ media_id, user_id }: Params) {
const [bot, workspace] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id FROM bots WHERE image_media_id = ? LIMIT 1`,
values: [media_id],
}).then((res) => res.payload?.[0]),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id FROM workspaces WHERE image_media_id = ? LIMIT 1`,
values: [media_id],
}).then((res) => res.payload?.[0]),
]);
const allowed =
(bot?.id && (await grabUserBot({ bot_id: bot.id, user_id }))?.id) ||
(workspace?.id && (await grabUserWorkspace({ workspace_id: workspace.id, user_id }))?.id);
if (!allowed) return null;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [media_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,49 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
/**
* Every worker assigned to them, replacing the old list. The
* coordinator is never assigned. Left out: unchanged.
*/
bot_ids?: number[];
/**
* Every workspace they're in, replacing the old list. Left out:
* unchanged.
*/
workspace_ids?: number[];
};
/**
* Sets which workers a user chats with in their own chat (`bot_users`)
* and which workspaces they work in (`workspace_users`). The caller checks
* the actor manages the crew. Ids that aren't a worker or a workspace are
* skipped.
*/
export default async function setUserAccess({ user_id, bot_ids, workspace_ids }: Params) {
const now = Date.now();
if (bot_ids) {
await BunSQLite.sql({ sql: `DELETE FROM bot_users WHERE user_id = ?`, values: [user_id] });
for (const bot_id of new Set(bot_ids)) {
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_users (created_at, updated_at, bot_id, user_id)
SELECT ?, ?, id, ? FROM bots WHERE id = ? AND kind = 'worker'`,
values: [now, now, user_id, bot_id],
});
}
}
if (workspace_ids) {
await BunSQLite.sql({ sql: `DELETE FROM workspace_users WHERE user_id = ?`, values: [user_id] });
for (const workspace_id of new Set(workspace_ids)) {
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO workspace_users (created_at, updated_at, workspace_id, user_id)
SELECT ?, ?, id, ? FROM workspaces WHERE id = ?`,
values: [now, now, user_id, workspace_id],
});
}
}
}
@@ -5,6 +5,10 @@ import type {
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -14,10 +18,11 @@ type Params = {
};
/**
* Starts an active session in a conversation. Callers close the old one
* Starts an active session in a user's conversation. Callers close the old one
* first (`startBotSession`).
*/
export default async function createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id,
@@ -25,6 +30,7 @@ export default async function createBotSession({
const now = Date.now();
const row: BUN_SQLITE_COBBLE_BOT_SESSIONS = {
user_id: Number(user_id),
bot_id: Number(bot_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
previous_session_id: previous_session_id || undefined,
@@ -6,6 +6,10 @@ import grabActiveBotSession from "./grab-active-bot-session";
import startBotSession from "./start-bot-session";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -17,16 +21,17 @@ type Params = {
};
/**
* The conversation's active session, started first when there is none
* The user's conversation's active session, started first when there is none
* (carrying on from its latest closed one) or, with `rollover`, when the
* active one is full.
*/
export default async function ensureBotSession({
user_id,
bot_id,
workspace_id,
rollover,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (active?.id) {
if (!rollover) return active;
@@ -43,18 +48,19 @@ export default async function ensureBotSession({
return count < AppData.BotSessionMaxMessages
? active
: await startBotSession({ bot_id, workspace_id });
: await startBotSession({ user_id, bot_id, workspace_id });
}
const latest = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ?
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ?
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
})
).payload?.[0];
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: latest?.id || null,
@@ -2,6 +2,10 @@ import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
/**
* Null or undefined: the bot's own chat.
@@ -10,18 +14,19 @@ type Params = {
};
/**
* The active session of one conversation (a bot in its own chat or in one
* workspace), or null when it has none yet.
* The active session of one user's conversation (with a bot in its own
* chat or in one workspace), or null when it has none yet.
*/
export default async function grabActiveBotSession({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT * FROM bot_sessions
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'
ORDER BY id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
});
return res.payload?.[0] || null;
@@ -3,6 +3,10 @@ import { AppData } from "@/src/data/app-data";
import type { BotSessionListItem } from "@/src/types";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -13,11 +17,12 @@ type Params = {
};
/**
* A conversation's sessions for the sessions modal, latest activity first,
* A user's conversation's sessions for the sessions modal, latest activity first,
* each with its message count and latest message. Empty sessions are left
* out unless active.
*/
export default async function grabBotSessions({
user_id,
bot_id,
workspace_id,
query,
@@ -33,7 +38,7 @@ export default async function grabBotSessions({
sql += ` (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,\n`;
sql += ` (SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text\n`;
sql += `FROM bot_sessions s\n`;
sql += `WHERE s.bot_id = ? AND s.workspace_id IS ?\n`;
sql += `WHERE s.user_id = ? AND s.bot_id = ? AND s.workspace_id IS ?\n`;
sql += `AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))\n`;
if (like) {
@@ -47,6 +52,7 @@ export default async function grabBotSessions({
const res = await BunSQLite.sql<BotSessionListItem>({
sql,
values: [
user_id,
bot_id,
workspace_id ? Number(workspace_id) : null,
...(like ? [like, like, like] : []),
@@ -24,7 +24,7 @@ type Params = {
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, its models, computers and active task there.
* Null when the bot isn't the user's or isn't in the workspace.
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationProps({
user_id,
@@ -38,7 +38,7 @@ export default async function grabConversationProps({
if (!conversation) return null;
const scope = conversation.workspace_id;
const session = await ensureBotSession({ bot_id: conversation.bot.id, workspace_id: scope });
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
@@ -71,7 +71,7 @@ export default async function grabConversationProps({
workspace_id: scope || undefined,
})
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id, workspace_id: scope }),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
]);
@@ -7,15 +7,14 @@ type Params = {
};
/**
* A session only if its bot belongs to the given owner.
* A session only if it's the given user's own.
*/
export default async function grabUserBotSession({
session_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT s.* FROM bot_sessions s JOIN bots b ON b.id = s.bot_id
WHERE s.id = ? AND b.user_id = ? LIMIT 1`,
sql: `SELECT s.* FROM bot_sessions s WHERE s.id = ? AND s.user_id = ? LIMIT 1`,
values: [session_id, user_id],
});
@@ -1,6 +1,6 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabChatBot from "../bots/grab-chat-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
type Params = {
@@ -19,9 +19,9 @@ export type UserConversation = {
};
/**
* One of the user's conversations: their bot, in its own chat or in one of
* their workspaces it works in. Throws when the bot isn't theirs or isn't
* in that workspace.
* One of the user's conversations: a bot they chat with in its own chat
* (`grabChatBot`) or in a workspace they're in (`grabWorkspaceBot`).
* Throws when they can't.
*/
export default async function grabUserConversation({
user_id,
@@ -42,7 +42,7 @@ export default async function grabUserConversation({
};
}
const bot = await grabUserBot({ bot_id, user_id });
const bot = await grabChatBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found`);
@@ -6,12 +6,12 @@ type Params = {
};
/**
* Clears a session's unread flag when its owner has the conversation open.
* Clears a session's unread flag when its user has the conversation open.
*/
export default async function markBotSessionRead({ session_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET unread = 0
WHERE id = ? AND unread = 1 AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)`,
WHERE id = ? AND unread = 1 AND user_id = ?`,
values: [session_id, user_id],
});
}
@@ -29,15 +29,15 @@ export default async function reopenBotSession({
const workspace_id = session.workspace_id || null;
await BunSQLite.sql({
sql: `DELETE FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
sql: `DELETE FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'
AND NOT EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = bot_sessions.id)`,
values: [session.bot_id!, workspace_id],
values: [session.user_id!, session.bot_id!, workspace_id],
});
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ?
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'`,
values: [now, session.bot_id!, workspace_id],
WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'`,
values: [now, session.user_id!, session.bot_id!, workspace_id],
});
await BunSQLite.sql({
@@ -4,6 +4,10 @@ import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
};
@@ -14,10 +18,11 @@ type Params = {
* An active session with no messages yet is kept as it is.
*/
export default async function startBotSession({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (active?.id) {
const has_messages = (
@@ -36,6 +41,7 @@ export default async function startBotSession({
}
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: active?.id || null,
@@ -9,7 +9,7 @@ type Params = {
};
/**
* Cancels one of the user's pending or running jobs. It's marked failed
* Cancels a pending or running job in one of the user's conversations. It's marked failed
* with `BotJobCancelledError`; a running handler finds out on its next
* `updateBotJob` (which returns false) and stops. Its task is closed.
*/
@@ -19,7 +19,7 @@ export default async function cancelBotJob({ user_id, job_id }: Params) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
sql: `UPDATE bots_queue SET status = 'failed', last_error = ?, locked_at = NULL, locked_by = NULL, finished_at = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'processing')
AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)
AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)
RETURNING *`,
values: [BotJobCancelledError, now, now, job_id, user_id],
});
@@ -115,7 +115,7 @@ export default async function enqueueBotJob({
}
if (computer_id) {
const computers = await grabBotComputers({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
if (!computers.some((c) => String(c.id) == String(computer_id))) {
throw new AppError(`This bot has no access to that computer.`);
@@ -1,21 +1,18 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
import AppError from "@/src/utils/app-error";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "bot_id" | "session_id" | "workspace_id">;
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "session_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.
* The session a job's result goes in. Every job is queued in one
* (`enqueueBotJob`), which says whose conversation it is.
*/
export default async function jobSessionId({ job }: Params) {
if (job.session_id) return Number(job.session_id);
export default function jobSessionId({ job }: Params) {
if (!job.session_id) {
throw new AppError(`This job has no conversation.`);
}
const session = await ensureBotSession({
bot_id: job.bot_id!,
workspace_id: job.workspace_id || null,
});
return Number(session.id);
return Number(job.session_id);
}
@@ -19,8 +19,8 @@ type Params = {
/**
* Reports a handed-over job back to the bot that handed it 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
* it was handed over from (that user's 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.
*/
@@ -48,14 +48,16 @@ export default async function relayJobToRequester({ job, text, failed }: Params)
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`,
sql: `SELECT user_id, 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 });
if (!requested_from?.user_id) return null;
const workspace_id = requested_from.workspace_id || null;
const session = await ensureBotSession({ user_id: requested_from.user_id, bot_id: requester.id, workspace_id });
const name = worker?.name || "A crew member";
const title = job.title || "the job";
+9 -1
View File
@@ -2,8 +2,12 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import type { BotHue } from "@/src/dict/bot-dict";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
/**
* Who creates it (a super admin or admin). Bots belong to the install.
*/
user_id: number | string;
name: string;
role?: string;
@@ -15,11 +19,15 @@ type Params = {
};
/**
* Adds a worker bot to the user's crew and returns its id. It has no
* Adds a worker bot to the install and returns its id. Nobody but super
* admins and admins reaches it until it's assigned to a user or added to
* a workspace. It has no
* computer of its own until it's given one; in a workspace it uses the
* workspace's.
*/
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
const res = await BunSQLite.insert<
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -14,6 +15,8 @@ type Params = {
* its row. The coordinator can't be deleted.
*/
export default async function deleteBot({ user_id, bot_id }: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
@@ -4,12 +4,16 @@ import { CoordinatorBotName } from "@/src/dict/bot-dict";
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
type Params = {
/**
* Who it's created by when it's missing (the super admin).
*/
user_id: number | string;
};
/**
* Returns the id of the user's coordinator, creating it first (with the
* default `CoordinatorSystemPrompt`) if the user has none. A
* Returns the id of the install's one coordinator (the super admin's
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
* if there is none. A
* coordinator without a prompt gets the default; an edited prompt is never
* overwritten. Safe to call on every visit.
*/
@@ -22,7 +26,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
table: "bots",
query: {
query: {
user_id: { value: user_id },
kind: { value: "coordinator" },
},
order: { field: "id", strategy: "ASC" },
@@ -12,21 +12,28 @@ type Params = {
* Only a task started from this session.
*/
session_id?: number | string | null;
/**
* Only a task in this user's conversations.
*/
user_id?: number | string | null;
};
/**
* The bot's most recent active task (in one session, when given), its
* The bot's most recent active task (in one session or one user's
* conversations, when given), its
* steps and what its job has done so far, for the working-on panel.
*/
export default async function grabActiveBotTask({
bot_id,
session_id,
user_id,
}: Params): Promise<BotTaskWithSteps | null> {
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active'
${session_id ? `AND session_id = ?` : ""}
${user_id ? `AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)` : ""}
ORDER BY created_at DESC, id DESC LIMIT 1`,
values: session_id ? [bot_id, session_id] : [bot_id],
values: [bot_id, ...(session_id ? [session_id] : []), ...(user_id ? [user_id] : [])],
});
const task = task_res.singleRes || task_res.payload?.[0];
@@ -1,10 +1,14 @@
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
import grabBotUserMemory from "./grab-bot-user-memory";
import grabUserBot from "./grab-user-bot";
type Params = {
bot_id: number | string;
/**
* The user the bot works with: only their memory joins the prompt.
*/
user_id: number | string;
/**
* The workspace the bot is working in. Its context and the bot's
@@ -15,8 +19,8 @@ type Params = {
/**
* The full prompt for one of the user's bots (`composeBotPrompt`): its
* core instructions and memory, and in a workspace that workspace's
* context and what the bot has for it.
* core instructions and memory, what it remembers about this user, and in
* a workspace that workspace's context and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -36,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
return composeBotPrompt({
system_prompt: bot.system_prompt,
memory: bot.memory,
user_memory: await grabBotUserMemory({ bot_id: bot.id, user_id }),
workspace: found?.workspace,
workspace_bot: found?.membership,
});
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_USER_MEMORIES } from "@/db/types/db";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* What a bot remembers about one user, or null when it has nothing yet.
* The caller checks the user may talk to the bot.
*/
export default async function grabBotUserMemory({ bot_id, user_id }: Params) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_USER_MEMORIES>({
sql: `SELECT memory FROM bot_user_memories WHERE bot_id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
});
return res.payload?.[0]?.memory || null;
}
+19 -17
View File
@@ -1,26 +1,33 @@
import type { BotWithLastMessage } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
/**
* A workspace's crew instead: the coordinator and the bots in it, with
* their conversations in that workspace.
* A workspace's crew instead: the bots the user chats with there
* (check they're in the workspace first), with their conversations
* there.
*/
workspace_id?: number | string | null;
};
/**
* A user's crew for the rail: the coordinator pinned first, then
* workers by latest activity. Each row carries its conversation's unread
* flag, latest activity and latest message: in its own chat, or in the
* workspace when one is given.
* The bots a user chats with, for the rail: in their own chat
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
* coordinator is pinned first, then workers by latest activity. Each row
* carries the user's own conversation there: its unread flag, latest
* activity and latest message.
*/
export default async function grabBots({
user_id,
workspace_id,
}: Params): Promise<BotWithLastMessage[]> {
const scope = workspace_id ? Number(workspace_id) : null;
const role = await grabCrewRole({ user_id });
const access = scope ? workspaceBotsSql({ role, workspace_id: scope }) : chatBotsSql({ role, user_id });
let sql = ``;
@@ -33,27 +40,22 @@ export default async function grabBots({
sql += `FROM bots b\n`;
sql += `LEFT JOIN (\n`;
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
sql += ` FROM bot_sessions WHERE workspace_id IS ? GROUP BY bot_id\n`;
sql += ` FROM bot_sessions WHERE workspace_id IS ? AND user_id = ? GROUP BY bot_id\n`;
sql += `) t ON t.bot_id = b.id\n`;
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
sql += ` SELECT id FROM bot_messages\n`;
sql += ` WHERE bot_id = b.id AND workspace_id IS ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` SELECT m.id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id\n`;
sql += ` WHERE m.bot_id = b.id AND m.workspace_id IS ? AND s.user_id = ?\n`;
sql += ` ORDER BY m.created_at DESC, m.id DESC\n`;
sql += ` LIMIT 1\n`;
sql += `)\n`;
sql += `WHERE b.user_id = ?\n`;
if (scope) {
sql += `AND (b.kind = 'coordinator' OR b.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?))\n`;
}
sql += `WHERE ${access.sql}\n`;
sql += `ORDER BY\n`;
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
sql += ` COALESCE(lm.created_at, t.last_activity_at, b.created_at) DESC\n`;
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [scope, scope, user_id, ...(scope ? [scope] : [])],
values: [scope, user_id, scope, user_id, ...access.values],
});
return res.payload || [];
@@ -0,0 +1,26 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot only if the user chats with it in its own chat (`chatBotsSql`).
*/
export default async function grabChatBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [bot_id, ...access.values],
});
return res.payload?.[0] || null;
}
+13 -3
View File
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
@@ -7,15 +9,23 @@ type Params = {
};
/**
* A bot only if it belongs to the given owner.
* A bot only if the user reaches it somewhere: in its own chat
* (`chatBotsSql`) or in a workspace they're in. Super admins and admins
* reach every worker; only the super admin reaches the coordinator.
*/
export default async function grabUserBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND (${access.sql} OR (b.kind != 'coordinator' AND b.id IN (
SELECT wb.bot_id FROM workspace_bots wb
JOIN workspace_users wu ON wu.workspace_id = wb.workspace_id AND wu.user_id = ?
JOIN workspaces w ON w.id = wb.workspace_id AND w.archived = 0
))) LIMIT 1`,
values: [bot_id, ...access.values, user_id],
});
return res.payload?.[0] || null;
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The whole new memory about this user (markdown), replacing the old one.
*/
memory: string;
};
/**
* Replaces what a bot remembers about one user. Only that user's runs see
* it; the bot's core memory stays the same for everyone.
*/
export default async function updateBotUserMemory({
user_id,
bot_id,
memory,
}: Params) {
assertPromptLength({ label: "Memory", text: memory });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO bot_user_memories (created_at, updated_at, bot_id, user_id, memory)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET updated_at = excluded.updated_at, memory = excluded.memory`,
values: [now, now, bot.id, user_id, memory],
});
if (!res.success) {
throw new Error(`Couldn't save the bot's memory: ${res.error}`);
}
}
@@ -3,6 +3,7 @@ import type { BotHue } from "@/src/dict/bot-dict";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -19,6 +20,8 @@ type Params = {
* change. Returns the saved bot.
*/
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
const bot = await grabUserBot({ bot_id, user_id });
@@ -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) {
@@ -49,8 +49,8 @@ export default async function createCronJob({
const count = Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM cron_jobs WHERE bot_id = ?`,
values: [bot_id_num],
sql: `SELECT COUNT(*) AS n FROM cron_jobs WHERE bot_id = ? AND user_id = ?`,
values: [bot_id_num, user_id],
})
).payload?.[0]?.n || 0,
);
@@ -64,7 +64,6 @@ export default async function createCronJob({
if (computer_id) {
const computers = await grabBotComputers({
bot_id: bot_id_num,
user_id,
workspace_id: conversation.workspace_id,
});
@@ -48,6 +48,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
}
const session = await ensureBotSession({
user_id: cron_job.user_id!,
bot_id: cron_job.bot_id!,
workspace_id: cron_job.workspace_id || null,
});
@@ -57,7 +58,6 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
(
await grabBotComputers({
bot_id: cron_job.bot_id!,
user_id: cron_job.user_id!,
workspace_id: cron_job.workspace_id || null,
})
)[0]?.id;
@@ -8,25 +8,25 @@ import type { EnvsOverviewPayload } from "@/src/types";
import grabAllEnvs from "./grab-all-envs";
type Params = {
/**
* The super admin looking: workspaces and computers belong to the
* install, so they see every one.
*/
user_id: number;
};
/**
* The Environment settings page: where envs can be set (general, the
* user's workspaces and computers), every env (secret values left out),
* The Environment settings page: where envs can be set (general, every
* workspace and computer), every env (secret values left out),
* and the app settings with where each comes from.
*/
export default async function grabEnvsOverview({
user_id,
}: Params): Promise<EnvsOverviewPayload> {
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> {
const [workspaces, computers, envs] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name, archived FROM workspaces WHERE user_id = ? ORDER BY archived ASC, id ASC`,
values: [user_id],
sql: `SELECT id, name, archived FROM workspaces ORDER BY archived ASC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT id, name, kind FROM computers WHERE user_id = ? ORDER BY kind = 'main' DESC, id ASC`,
values: [user_id],
sql: `SELECT id, name, kind FROM computers ORDER BY kind = 'main' DESC, id ASC`,
}),
grabAllEnvs(),
]);
+3 -3
View File
@@ -156,14 +156,14 @@ async function resolveScope({
const owned = id
? (
await BunSQLite.sql({
sql: `SELECT id FROM ${table} WHERE id = ? AND user_id = ? LIMIT 1`,
values: [id, user_id],
sql: `SELECT id FROM ${table} WHERE id = ? LIMIT 1`,
values: [id],
})
).payload?.[0]
: undefined;
if (!owned) {
throw new AppError(`Pick one of your ${table}.`);
throw new AppError(`Pick one of the ${table}.`);
}
return paradigm == "workspace"
@@ -7,6 +7,7 @@ import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-w
import type { ProfileImageTarget } from "@/src/schemas/profile-image-schema";
import AppError from "@/src/utils/app-error";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -28,6 +29,8 @@ const Targets = {
* a private image, the old one deleted. Returns the new `image_media_id`.
*/
export default async function setProfileImage({ user_id, target, id, data_url }: Params) {
await assertCrewManager({ user_id });
const record =
target == "bot"
? await grabUserBot({ bot_id: id, user_id })
+9 -29
View File
@@ -1,7 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
import AppError from "@/src/utils/app-error";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
@@ -21,11 +19,12 @@ type Params = {
};
/**
* Deletes a user with everything that's theirs (bots, workspaces,
* conversations, computers, user types, password, media and its files):
* their computers' containers are removed first, their home directories
* stay on disk. Their audit log entries stay too. The super admin can't be
* deleted, and nobody deletes themselves.
* Deletes a user with everything that's theirs (their conversations,
* routines, access, what bots remember about them, user types, password,
* media and its files). Bots, workspaces and computers belong to the
* install and stay, pictures they uploaded for them too. Their audit log
* entries stay as well. The super admin can't be deleted, and nobody
* deletes themselves.
*/
export default async function deleteUser({ actor_user_id, actor_user_types, user_id }: Params) {
if (String(actor_user_id) == String(user_id)) {
@@ -48,24 +47,12 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
throw new AppError(`You can't delete ${user.first_name || "this user"}.`);
}
const containers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.user_id = ?`,
values: [user.id],
})
).payload || [];
for (const container of containers) {
if (container.docker_id || container.name) {
await removeDockerContainer({ container: container.docker_id || container.name! });
}
}
const media_ids =
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM media WHERE user_id = ?`,
sql: `SELECT id FROM media WHERE user_id = ?
AND id NOT IN (SELECT image_media_id FROM bots WHERE image_media_id IS NOT NULL)
AND id NOT IN (SELECT image_media_id FROM workspaces WHERE image_media_id IS NOT NULL)`,
values: [user.id],
})
).payload?.map((media) => media.id) || [];
@@ -87,12 +74,5 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}
if (containers.length) {
await BunSQLite.sql({
sql: `DELETE FROM containers WHERE id IN (${containers.map(() => "?").join(", ")})`,
values: containers.map((container) => container.id!),
});
}
return user;
}
@@ -29,6 +29,8 @@ export default async function grabUsers(
sql += `SELECT\n`;
sql += ` u.*,\n`;
sql += ` ut_agg.user_types,\n`;
sql += ` (SELECT GROUP_CONCAT(bot_id) FROM bot_users WHERE user_id = u.id) AS bot_ids,\n`;
sql += ` (SELECT GROUP_CONCAT(workspace_id) FROM workspace_users WHERE user_id = u.id) AS workspace_ids,\n`;
/**
* Their profile photo only: other primary media (a bot's picture)
* must not stand in for it.
@@ -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 grabUserWorkspace from "./grab-user-workspace";
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 addBotToWorkspace({
workspace_id,
bot_id,
}: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserComputer from "../computers/grab-user-computer";
import grabUserWorkspace from "./grab-user-workspace";
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 addComputerToWorkspace({
workspace_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const computer = await grabUserComputer({ computer_id, user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
@@ -4,8 +4,13 @@ import type {
BunSQLiteTables,
} from "@/db/types/db";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
/**
* Who creates it (a super admin or admin). Workspaces belong to the
* install.
*/
user_id: number | string;
name: string;
description?: string;
@@ -13,7 +18,8 @@ type Params = {
};
/**
* Creates a workspace owned by the user and returns its id.
* Creates a workspace and returns its id. Members reach it once they're
* added to it (`workspace_users`).
*/
export default async function createWorkspace({
user_id,
@@ -21,6 +27,8 @@ export default async function createWorkspace({
description,
system_prompt,
}: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "System prompt", text: system_prompt });
const res = await BunSQLite.insert<
@@ -2,6 +2,7 @@ import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -13,6 +14,8 @@ type Params = {
* links and its models. Its bots and computers stay.
*/
export default async function deleteWorkspace({ user_id, workspace_id }: Params) {
await assertCrewManager({ user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
workspace_id: number | string;
@@ -7,15 +9,17 @@ type Params = {
};
/**
* A workspace only if it belongs to the given owner.
* A workspace only if the user works in it (`workspacesSql`).
*/
export default async function grabUserWorkspace({
workspace_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_WORKSPACES | null> {
const access = workspacesSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? LIMIT 1`,
values: [workspace_id, user_id],
sql: `SELECT w.* FROM workspaces w WHERE w.id = ? AND ${access.sql} LIMIT 1`,
values: [workspace_id, ...access.values],
});
return res.payload?.[0] || null;
@@ -1,25 +1,30 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { WorkspaceWithActivity } from "@/src/types";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
};
/**
* The user's workspaces for the rail, not archived, oldest first, each
* unread while any conversation in it is.
* The workspaces the user works in (`workspacesSql`), for the rail: not
* archived, oldest first, each unread while any of the user's
* conversations in it is.
*/
export default async function grabUserWorkspaces({
user_id,
}: Params): Promise<WorkspaceWithActivity[]> {
const access = workspacesSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<WorkspaceWithActivity>({
sql: `SELECT w.*,
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id), 0) AS unread,
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id) AS last_activity_at
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id AND s.user_id = ?), 0) AS unread,
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id AND s.user_id = ?) AS last_activity_at
FROM workspaces w
WHERE w.user_id = ? AND w.archived = 0
WHERE w.archived = 0 AND ${access.sql}
ORDER BY w.id ASC`,
values: [user_id],
values: [user_id, user_id, ...access.values],
});
return res.payload || [];
@@ -4,6 +4,9 @@ import type {
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
workspace_id: number | string;
@@ -22,29 +25,37 @@ export type WorkspaceBotMembership = {
};
/**
* One of the user's bots in one of their (non-archived) workspaces: a
* member, or the coordinator, which is in all of them. Null otherwise.
* A bot the user chats with in a workspace they're in (not archived): one
* of its workers, or for the super admin the coordinator, which is in all
* of them (`workspaceBotsSql`). Null otherwise.
*/
export default async function grabWorkspaceBot({
workspace_id,
bot_id,
user_id,
}: Params): Promise<WorkspaceBotMembership | null> {
const role = await grabCrewRole({ user_id });
const workspace_access = workspacesSql({ role, user_id });
const workspace = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? AND archived = 0 LIMIT 1`,
values: [workspace_id, user_id],
sql: `SELECT w.* FROM workspaces w WHERE w.id = ? AND w.archived = 0 AND ${workspace_access.sql} LIMIT 1`,
values: [workspace_id, ...workspace_access.values],
})
).payload?.[0];
if (!workspace?.id) return null;
const bot_access = workspaceBotsSql({ role, workspace_id: workspace.id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [bot_id, ...bot_access.values],
})
).payload?.[0];
if (!workspace?.id || !bot?.id) return null;
if (!bot?.id) return null;
const membership =
(
@@ -54,7 +65,5 @@ export default async function grabWorkspaceBot({
})
).payload?.[0] || null;
if (!membership && bot.kind != "coordinator") return null;
return { workspace, bot, membership };
}
@@ -16,7 +16,7 @@ export default async function grabWorkspaceComputers({
const res = await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata 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
JOIN workspaces w ON w.id = wc.workspace_id
LEFT JOIN containers co ON co.id = c.container_id
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id],
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -16,6 +17,8 @@ export default async function removeBotFromWorkspace({
workspace_id,
bot_id,
}: Params) {
await assertCrewManager({ user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
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 removeComputerFromWorkspace({
workspace_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
@@ -7,6 +7,7 @@ import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserWorkspace from "./grab-user-workspace";
import _ from "lodash";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -29,6 +30,8 @@ export default async function updateWorkspace({
workspace_id,
data,
}: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "System prompt", text: data.system_prompt });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
@@ -4,6 +4,7 @@ import grabDirNames from "@/src/utils/grab-dir-names";
import { existsSync } from "node:fs";
import path from "node:path";
import userAuth from "../../backend/auth/user-auth";
import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media";
const { DATA_DIR } = grabDirNames();
@@ -48,7 +49,10 @@ export default async function handleMediaServer({
targetId: media_id,
});
media = media_res.singleRes || undefined;
media =
media_res.singleRes ||
(media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) ||
undefined;
} else {
const media_res = await BunSQLite.select<
BUN_SQLITE_COBBLE_MEDIA,
@@ -62,7 +66,8 @@ export default async function handleMediaServer({
/**
* A private file is only served through `/media/private`, to its
* owner.
* owner (or, for a bot's or workspace's picture, to whoever sees
* it).
*/
if (media?.is_private) {
media = undefined;
+2 -2
View File
@@ -71,8 +71,8 @@ export default function delegateTaskTool({
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
}
const computers = await grabBotComputers({ bot_id: worker.id, user_id, workspace_id });
const worker_session = await ensureBotSession({ bot_id: worker.id, workspace_id });
const computers = await grabBotComputers({ bot_id: worker.id, workspace_id });
const worker_session = await ensureBotSession({ user_id, bot_id: worker.id, workspace_id });
const job_id = await enqueueBotJob({
user_id,
+6 -6
View File
@@ -43,8 +43,8 @@ type Params = {
* in a workspace switching its default computer), start_task for longer
* work, search_computers to find what's on its computers,
* ask_user_to_take_over when only the user can do something on one, and
* for the coordinator delegate_task (to the workspace's crew in a
* workspace) plus, in its own chat, assign_computer (which computers each
* for the coordinator (the super admin's alone) delegate_task to every
* worker (the workspace's in a workspace) plus, in its own chat, assign_computer (which computers each
* crew member can use) or, in a workspace, set_bot_workspace_instructions.
* Anything else on a computer goes through a task, never mid-reply.
*/
@@ -88,9 +88,9 @@ export default async function grabBotChatTools({
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: workspace_id
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
WHERE b.user_id = ? AND b.kind = 'worker' ORDER BY b.id ASC`
: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
values: workspace_id ? [workspace_id, user_id] : [user_id],
WHERE b.kind = 'worker' ORDER BY b.id ASC`
: `SELECT * FROM bots WHERE kind = 'worker' ORDER BY id ASC`,
values: workspace_id ? [workspace_id] : [],
})
).payload || [];
@@ -117,7 +117,7 @@ export default async function grabBotChatTools({
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
await grabBotComputers({ bot_id: worker.id || 0 }),
]),
),
);
@@ -34,8 +34,8 @@ export default function updateInstructionsTool({
return {
name: "update_instructions",
description: workspace_id
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Both are shared: the workspace's with everyone working in it, your core ones with every user you work for. Change them only for how you should work for all of them, such as a standing rule for your job; a preference of this one user goes in your memory of them (update_memory, scope "user"). Keep everything that still applies.`
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory, shared with every user you work for. Change them only for how you should work for every user you work for, such as a standing rule for your job; a preference of this one user goes in your memory of them (update_memory, scope "user"). Keep everything that still applies.`,
schema: workspace_id
? z.object({ instructions, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ instructions: instructions.min(1) }),
+27 -11
View File
@@ -1,27 +1,38 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import updateBotMemory from "@/src/functions/backend/db/bots/update-bot-memory";
import updateBotUserMemory from "@/src/functions/backend/db/bots/update-bot-user-memory";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
/**
* The user the run is for: `user` memory is theirs alone.
*/
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: then the bot rewrites its memory for
* the workspace unless it picks `core`.
* the workspace unless it picks another scope.
*/
workspace_id?: number | string | null;
};
type Args = {
memory: string;
scope?: "workspace" | "core";
scope?: "user" | "workspace" | "core";
};
const UserScopeNote = `what you remember about this user, under "# Memory about the user": their preferences, facts about them and their work, decisions they made. Only runs for this user see it.`;
const CoreScopeNote = `scope "core" is your core memory, under "# Memory", shared with every user you work for: only general knowledge about your job that holds for anyone, never anything about a particular user.`;
const ReplaceNote = `Anything you leave out is forgotten, so include the parts that still matter.`;
/**
* Lets a bot rewrite its own memory: its core memory (under `# Memory`),
* or in a workspace (by default) its memory for that workspace.
* Lets a bot rewrite one of its memories: about the user it works for
* (the default outside a workspace), for the workspace (the default in
* one), or its core memory, shared by every user.
*/
export default function updateMemoryTool({
user_id,
@@ -33,19 +44,24 @@ export default function updateMemoryTool({
return {
name: "update_memory",
description: workspace_id
? `Replace a memory with this full text (markdown). scope "workspace" (the default) is your memory for this workspace, under "# Memory in ..."; "core" is your core memory, under "# Memory", in every workspace and your own chat. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`
: `Replace your memory with this full text (markdown). It's in your prompt under "# Memory" on every run. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`,
? `Replace a memory with this full text (markdown). scope "workspace" (the default) is your memory for this workspace, under "# Memory in ...": what holds for the work here. scope "user" is ${UserScopeNote} ${CoreScopeNote} ${ReplaceNote}`
: `Replace a memory with this full text (markdown). scope "user" (the default) is ${UserScopeNote} ${CoreScopeNote} ${ReplaceNote}`,
schema: workspace_id
? z.object({ memory, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ memory }),
? z.object({ memory, scope: z.enum(["workspace", "user", "core"]).default("workspace") })
: z.object({ memory, scope: z.enum(["user", "core"]).default("user") }),
run: async ({ args }) => {
if (workspace_id && args.scope != "core") {
if (args.scope == "core") {
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Core memory saved.`;
}
if (workspace_id && args.scope == "workspace") {
await updateWorkspaceBot({ user_id, workspace_id, bot_id, memory: args.memory });
return `Memory for this workspace saved.`;
}
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Core memory saved.`;
await updateBotUserMemory({ user_id, bot_id, memory: args.memory });
return `Memory about the user saved.`;
},
};
}
@@ -1,14 +1,19 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import { Plus } from "lucide-react";
import { AddFlowContext } from "../(data)/add-flow-context";
/**
* The rail header's Add icon: opens the Add modal (a bot, a workspace or a
* computer).
* computer), for super admins and admins.
*/
export default function RailAddButton() {
const { openAdd } = useContext(AddFlowContext);
const { pageProps } = useContext(AppContext);
if (!canManageCrew({ user_types: pageProps?.user_types })) return null;
return (
<Button
@@ -1,5 +1,7 @@
import { useContext } from "react";
import { AddFlowContext } from "../(data)/add-flow-context";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import CoordinatorRow from "./coordinator-row";
import RailBotsSection from "./rail-bots-section";
@@ -18,6 +20,7 @@ type Props = {
*/
export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
const { pageProps } = useContext(AppContext);
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
@@ -36,7 +39,11 @@ export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
workers={workers}
selected_id={selected_id}
hrefFor={(bot) => `/admin/bots/${bot.id}`}
empty="No bots yet. Add one for a job you hand out often."
empty={
canManageCrew({ user_types: pageProps?.user_types })
? "No bots yet. Add one for a job you hand out often."
: "No bots for you yet. An admin assigns them."
}
/>
<RailWorkspacesSection workspaces={workspaces} />
</>
@@ -1,4 +1,7 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import { Plus } from "lucide-react";
type Props = {
@@ -7,9 +10,14 @@ type Props = {
};
/**
* The "+" beside a rail section's label.
* The "+" beside a rail section's label, for super admins and admins (who
* add bots and workspaces).
*/
export default function RailSectionAddButton({ title, onClick }: Props) {
const { pageProps } = useContext(AppContext);
if (!canManageCrew({ user_types: pageProps?.user_types })) return null;
return (
<Button
title={title}
@@ -51,7 +51,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
workers={workers}
selected_id={on_workspace_page ? undefined : selected_id}
hrefFor={(bot) => `${base}/bots/${bot.id}`}
empty="No bots here yet. Add one, or put an existing bot in from the workspace's page."
empty="No bots here yet."
/>
</>
);
+2 -2
View File
@@ -10,8 +10,8 @@ import type { LiveRelayCursor } from "./types";
/**
* Keeps this process's sockets current with work done anywhere: every
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps and
* changed bots from the database and pushes them to their owners'
* sockets here. The process that did the work pushes at once
* changed bots from the database and pushes them to the sockets here
* of the users they're for. The process that did the work pushes at once
* (`pushBotNotice`); this catches the rest, e.g. a job run by another
* server on the same database. Clients ignore what they already have.
* Runs in every server process, ungated.
+8 -22
View File
@@ -1,8 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import type { BotUpdatedData } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
type Params = {
/**
@@ -13,35 +11,23 @@ type Params = {
};
/**
* Pushes `server:bot-updated` (face, status line, active task) for every
* bot of a connected user that changed since `since`. Pushes are
* idempotent, so overlapping windows only repeat them.
* Pushes `server:bot-updated` (face, status line, the user's own active
* task) for every bot that changed since `since` to the connected users
* who see it (`pushBotUpdated`). Pushes are idempotent, so overlapping
* windows only repeat them.
*/
export default async function relayChangedBots({ since, users }: Params) {
if (!users.size) return;
const ids = [...users];
const bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, user_id, face, status_line, computer_id FROM bots
WHERE updated_at > ? AND user_id IN (${ids.map(() => "?").join(", ")})`,
values: [since, ...ids],
sql: `SELECT id, face, status_line, computer_id FROM bots WHERE updated_at > ?`,
values: [since],
})
).payload || [];
for (const bot of bots) {
if (!bot.id || !bot.user_id) continue;
const data: BotUpdatedData = {
id: bot.id,
face: bot.face,
status_line: bot.status_line || "",
task: await grabActiveBotTask({ bot_id: bot.id }),
computer_id: bot.computer_id || null,
};
sendToUser({ user_id: bot.user_id, data: { event: "server:bot-updated", data } });
await pushBotUpdated({ bot, users });
}
}
+2 -2
View File
@@ -9,13 +9,13 @@ type Params = {
/**
* Pushes `server:bot-message` for every message newer than `after_id` to
* its owner's sockets on this process. Returns the newest id read.
* the sockets on this process of the user whose conversation it's in. Returns the newest id read.
*/
export default async function relayNewBotMessages({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES & { user_id: number }>({
sql: `SELECT m.*, b.user_id FROM bot_messages m JOIN bots b ON b.id = m.bot_id
sql: `SELECT m.*, s.user_id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE m.id > ? ORDER BY m.id ASC LIMIT 500`,
values: [after_id],
})
+4 -3
View File
@@ -9,15 +9,16 @@ type Params = {
/**
* Pushes `server:bot-job-update` for every job step newer than `after_id`
* to its owner's sockets on this process. Returns the newest id read.
* to the sockets on this process of the user whose conversation the job
* is in. Returns the newest id read.
*/
export default async function relayNewJobUpdates({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BotJobUpdateData & { user_id: number }>({
sql: `SELECT u.*, q.task_id, q.session_id, b.user_id FROM bots_queue_updates u
sql: `SELECT u.*, q.task_id, q.session_id, s.user_id FROM bots_queue_updates u
JOIN bots_queue q ON q.id = u.bots_queue_id
JOIN bots b ON b.id = u.bot_id
JOIN bot_sessions s ON s.id = q.session_id
WHERE u.id > ? ORDER BY u.id ASC LIMIT 500`,
values: [after_id],
})
@@ -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;

Some files were not shown because too many files have changed in this diff Show More