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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 11:37:14 +01:00
co-authored by Claude Opus 5.5
parent 4dd34ed251
commit e45d54d30b
133 changed files with 1854 additions and 436 deletions
@@ -0,0 +1,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;