Give every user their own bot face

A bot's face and status line live in bot_user_faces, one row per bot and
user, set by jobs for the user whose conversation they're in. Needs-you
and working only show to that user; every bot read overlays the viewer's
face (botFaceSql) and pushes go to the user a face change is for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 13:58:26 +01:00
co-authored by Claude Opus 5.5
parent 47c58bc76b
commit ab25158d87
32 changed files with 448 additions and 181 deletions
+16 -15
View File
@@ -14,6 +14,7 @@ import BotJobFatalError from "./bot-job-fatal-error";
import describeJobStep from "./describe-job-step";
import pushToSessionUser from "./push-to-session-user";
import pushBotNotice from "./push-bot-notice";
import grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json";
import type { BotJobUpdateData } from "@/src/types";
@@ -58,6 +59,17 @@ export default async function processBotJob({
const job_id = job.id;
const bot_id = job.bot_id;
/**
* Its face changes only for the user it works for.
*/
const user_id = await grabJobUserId({ job }).catch(() => null);
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
if (!user_id) return;
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
};
const controller = new AbortController();
const update: BotJobContext["update"] = async (params) => {
@@ -106,13 +118,9 @@ export default async function processBotJob({
* its computer.
*/
const run = async () => {
await pushBotNotice({
bot_id,
bot: await setBotFace({
bot_id,
face: "working",
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
}),
await setFace({
face: "working",
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
});
return await runner({
@@ -159,14 +167,7 @@ export default async function processBotJob({
refund: true,
});
await pushBotNotice({
bot_id,
bot: await setBotFace({
bot_id,
face: "asleep",
status_line: BotWaitingStatusLine,
}),
});
await setFace({ face: "asleep", status_line: BotWaitingStatusLine });
return;
}
+9 -8
View File
@@ -1,14 +1,15 @@
import type {
BUN_SQLITE_COBBLE_BOT_MESSAGES,
BUN_SQLITE_COBBLE_BOTS,
} from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face";
import pushBotUpdated from "./push-bot-updated";
import pushToSessionUser from "./push-to-session-user";
type Params = {
bot_id: number | string;
message?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null;
bot?: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id"> | null;
/**
* A face change (`setBotFace`), pushed to the user it's for.
*/
bot?: BotFaceChange | null;
/**
* A job's result reported to the bot that handed it over
* (`relayJobToRequester`): a message in that bot's conversation.
@@ -18,8 +19,8 @@ type Params = {
/**
* 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
* conversation it's in, and a face change to the user it's for
* (`pushBotUpdated`). A relayed report
* goes to the conversation it was posted in.
*/
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
@@ -38,6 +39,6 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
}
if (bot) {
await pushBotUpdated({ bot: { ...bot, id: bot.id || Number(bot_id) } });
await pushBotUpdated({ bot_id: bot.id || bot_id, user_id: bot.user_id });
}
}
+27 -17
View File
@@ -1,38 +1,48 @@
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 grabBotFace from "@/src/functions/backend/db/bots/grab-bot-face";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id">;
bot_id: number | string;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the bot.
* Only this user: a face change, which is theirs alone. Else everyone
* who sees the bot.
*/
user_id?: number | null;
/**
* Only these users (the connected ones, for the live relay).
*/
users?: Set<string>;
/**
* The computer the bot is on, when the push knows it (a switch, or the
* live relay). Null means its main one.
*/
computer_id?: number | null;
};
/**
* 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.
* Pushes `server:bot-updated` to one user, or everyone who sees the bot
* (`grabBotAudience`), each with their own face, status line and active
* task with it, never another user's.
*/
export default async function pushBotUpdated({ bot, users }: Params) {
if (!bot.id) return;
export default async function pushBotUpdated({ bot_id, user_id, users, computer_id }: Params) {
if (!bot_id) return;
for (const user_id of await grabBotAudience({ bot_id: bot.id })) {
if (users && !users.has(String(user_id))) continue;
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
for (const viewer_id of audience) {
if (users && !users.has(String(viewer_id))) continue;
sendToUser({
user_id,
user_id: viewer_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 }),
id: Number(bot_id),
...(await grabBotFace({ bot_id, user_id: viewer_id })),
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
},
},
});
+3 -11
View File
@@ -1,7 +1,5 @@
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 grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
import HarnessError from "@/src/harness/harness-error";
import BotJobFatalError from "./bot-job-fatal-error";
import runBotReply from "./run-bot-reply";
@@ -26,19 +24,13 @@ const runBotJob: BotJobRunner = async (context) => {
throw new BotJobFatalError(`The bot for this job no longer exists.`);
}
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];
const user_id = await grabJobUserId({ job });
if (!session?.user_id) {
if (!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"
+5
View File
@@ -45,6 +45,11 @@ export const BotReplyJobTitle = "Thinking";
*/
export const BotTaskStatusLine = "Working on a task";
/**
* The status line while a bot waits on the user its job is for.
*/
export const BotNeedsYouStatusLine = "Waiting on you";
/**
* The bot's status line while its job waits for a rate-limited model.
*/
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabChatBot from "../bots/grab-chat-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
@@ -13,7 +14,10 @@ type Params = {
};
export type UserConversation = {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
/**
* With the user's own face.
*/
bot: BotWithFace & { id: number };
workspace: BUN_SQLITE_COBBLE_WORKSPACES | null;
workspace_id: number | null;
};
@@ -1,11 +1,13 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
type Params = {
job_id: number | string;
@@ -27,9 +29,9 @@ type Params = {
/**
* Marks a job done, closes its task and sends the completion message back
* to the user in the bot's conversation. Unless it has other jobs open,
* the bot shows needs-you if asked, else done after a task and idle after
* a reply. A handed-over job's result also goes to the bot that handed
* to the user in the bot's conversation. Unless it has other jobs open
* for that user, the bot shows that user needs-you if asked, else done
* after a task and idle after a reply; nobody else's face changes. A handed-over job's result also goes to the bot that handed
* it over (`relay`). Throws if this handler no longer holds the job.
*/
export default async function completeBotJob({
@@ -78,18 +80,24 @@ export default async function completeBotJob({
needs_you,
});
const bot = needs_you
? await setBotFace({
bot_id: job.bot_id,
face: "needs-you",
status_line: "Waiting on you",
})
: (await countOpenBotJobs({ bot_id: job.bot_id }))
? null
: await setBotFace({
const user_id = await grabJobUserId({ job });
const bot = !user_id
? null
: needs_you
? await setBotFace({
bot_id: job.bot_id,
face: job.kind == "reply" ? "idle" : "done",
});
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: (await countOpenBotJobs({ bot_id: job.bot_id, user_id }))
? null
: await setBotFace({
bot_id: job.bot_id,
user_id,
face: job.kind == "reply" ? "idle" : "done",
});
const relay = silent ? null : await relayJobToRequester({ job, text: result });
@@ -2,15 +2,20 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
/**
* Only jobs in this user's conversations.
*/
user_id: number | string;
};
/**
* How many of a bot's jobs are still pending or processing.
* How many of a bot's jobs for one user are still pending or processing.
*/
export default async function countOpenBotJobs({ bot_id }: Params) {
export default async function countOpenBotJobs({ bot_id, user_id }: Params) {
const res = await BunSQLite.sql<{ open: number }>({
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')`,
values: [bot_id],
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')
AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)`,
values: [bot_id, user_id],
});
return Number(res.payload?.[0]?.open || 0);
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import jobSessionId from "./job-session-id";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "session_id">;
};
/**
* The user a job works for: the one whose conversation it's in. Null once
* that conversation is gone.
*/
export default async function grabJobUserId({ job }: Params) {
const user_id = (
await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [jobSessionId({ job })],
})
).payload?.[0]?.user_id;
return user_id ? Number(user_id) : null;
}
@@ -4,6 +4,8 @@ import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -13,7 +15,7 @@ type Params = {
/**
* Wraps up a job that is out of attempts: stamps `finished_at` (so it's
* reported once), tells the user in the bot's conversation and sets the
* bot to needs-you. Its task stays active, since the work isn't done. A
* bot to needs-you for that user. Its task stays active, since the work isn't done. A
* handed-over job's failure also goes to the bot that handed it over.
*/
export default async function notifyBotJobFailed({
@@ -46,11 +48,16 @@ export default async function notifyBotJobFailed({
needs_you: true,
});
const bot = await setBotFace({
bot_id: job.bot_id,
face: "needs-you",
status_line: "Waiting on you",
});
const user_id = await grabJobUserId({ job });
const bot = user_id
? await setBotFace({
bot_id: job.bot_id,
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: null;
const relay = await relayJobToRequester({
job,
@@ -42,8 +42,6 @@ export default async function createBot({ user_id, name, role, hue, system_promp
kind: "worker",
hue,
role: role || undefined,
face: "idle",
status_line: "",
system_prompt,
},
],
@@ -55,8 +55,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
user_id: Number(user_id),
name: CoordinatorBotName,
kind: "coordinator",
face: "idle",
status_line: "",
system_prompt: CoordinatorSystemPrompt,
},
],
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot's face and status line as one user sees it: idle when it has
* none for them.
*/
export default async function grabBotFace({ bot_id, user_id }: Params) {
const row = (
await BunSQLite.sql<{ face: BotFace; status_line: string }>({
sql: `SELECT face, status_line FROM bot_user_faces WHERE bot_id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
})
).payload?.[0];
return { face: (row?.face || "idle") as BotFace, status_line: row?.status_line || "" };
}
+7 -3
View File
@@ -2,6 +2,7 @@ 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 botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -18,8 +19,8 @@ type Params = {
* 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.
* carries the user's own face (`botFaceSql`) and conversation there: its
* unread flag, latest activity and latest message.
*/
export default async function grabBots({
user_id,
@@ -29,10 +30,13 @@ export default async function grabBots({
const role = await grabCrewRole({ user_id });
const access = scope ? workspaceBotsSql({ role, workspace_id: scope }) : chatBotsSql({ role, user_id });
const face = botFaceSql({ user_id });
let sql = ``;
sql += `SELECT\n`;
sql += ` b.*,\n`;
sql += ` ${face.sql},\n`;
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
sql += ` t.last_activity_at AS last_activity_at,\n`;
sql += ` lm.text AS last_message_text,\n`;
@@ -55,7 +59,7 @@ export default async function grabBots({
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [scope, user_id, scope, user_id, ...access.values],
values: [...face.values, scope, user_id, scope, user_id, ...access.values],
});
return res.payload || [];
+10 -6
View File
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -9,17 +10,20 @@ type Params = {
};
/**
* A bot only if the user chats with it in its own chat (`chatBotsSql`).
* A bot, with the user's own face (`botFaceSql`), 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> {
}: Params): Promise<BotWithFace | 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],
const face = botFaceSql({ user_id });
const res = await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [...face.values, bot_id, ...access.values],
});
return res.payload?.[0] || null;
+12 -8
View File
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -9,23 +10,26 @@ type Params = {
};
/**
* 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.
* A bot, with the user's own face (`botFaceSql`), 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> {
}: Params): Promise<BotWithFace | 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} OR (b.kind != 'coordinator' AND b.id IN (
const face = botFaceSql({ user_id });
const res = await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} 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],
values: [...face.values, bot_id, ...access.values, user_id],
});
return res.payload?.[0] || null;
+22 -5
View File
@@ -3,23 +3,40 @@ import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
/**
* The user who sees it: the one whose conversation the job is in.
*/
user_id: number | string;
face: BotFace;
status_line?: string;
};
export type BotFaceChange = {
id: number;
user_id: number;
face: BotFace;
status_line: string;
};
/**
* Sets a bot's face (its status) and status line. Callers must check the
* Sets a bot's face (its status) and status line as one user sees it
* (`bot_user_faces`). Other users keep their own. Callers must check the
* bot first.
*/
export default async function setBotFace({
bot_id,
user_id,
face,
status_line = "",
}: Params) {
}: Params): Promise<BotFaceChange> {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bots SET face = ?, status_line = ?, updated_at = ? WHERE id = ?`,
values: [face, status_line, Date.now(), bot_id],
sql: `INSERT INTO bot_user_faces (created_at, updated_at, bot_id, user_id, face, status_line)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET face = excluded.face, status_line = excluded.status_line, updated_at = excluded.updated_at`,
values: [now, now, bot_id, user_id, face, status_line],
});
return { id: Number(bot_id), face, status_line };
return { id: Number(bot_id), user_id: Number(user_id), face, status_line };
}
@@ -1,15 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotWithFace } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
type Params = {
computer_id: number | string;
/**
* Whose faces the bots show.
*/
user_id: number | string;
};
export type ComputerAccess = {
bots: BUN_SQLITE_COBBLE_BOTS[];
bots: BotWithFace[];
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
@@ -20,14 +25,17 @@ export type ComputerAccess = {
*/
export default async function grabComputerAccess({
computer_id,
user_id,
}: Params): Promise<ComputerAccess> {
const face = botFaceSql({ user_id });
const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b
BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b
JOIN bot_computers bc ON bc.bot_id = b.id
WHERE bc.computer_id = ?
ORDER BY b.kind = 'coordinator' DESC, b.name COLLATE NOCASE ASC, b.id ASC`,
values: [computer_id],
values: [...face.values, computer_id],
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT w.* FROM workspaces w
@@ -1,5 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { CronJobWithNames } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
type Params = {
user_id: number | string;
@@ -14,10 +15,12 @@ type Params = {
};
/**
* The user's schedules with their bot and workspace names, the next to
* The user's schedules with their bot (its face as the user sees it) and
* workspace names, the next to
* run first and paused ones last.
*/
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const where = [`c.user_id = ?`];
const values: (number | string)[] = [user_id];
@@ -32,14 +35,14 @@ export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Par
}
const res = await BunSQLite.sql<CronJobWithNames>({
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.face AS bot_face,
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, ${face.sql},
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
FROM cron_jobs c
JOIN bots b ON b.id = c.bot_id
LEFT JOIN workspaces w ON w.id = c.workspace_id
WHERE ${where.join(" AND ")}
ORDER BY c.enabled DESC, c.next_run_at IS NULL, c.next_run_at ASC, c.id ASC`,
values,
values: [...face.values, ...values],
});
return res.payload || [];
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { WorkspaceActivityItem } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
type Params = {
user_id: number | string;
@@ -9,19 +10,21 @@ type Params = {
/**
* The user's latest conversations in a workspace, most recent first, each
* with the bot it's with, its message count and latest message. Empty
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty
* sessions are left out unless active.
*/
export default async function grabWorkspaceActivity({
user_id,
workspace_id,
}: Params): Promise<WorkspaceActivityItem[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const res = await BunSQLite.sql<WorkspaceActivityItem>({
sql: `SELECT s.*,
b.name AS bot_name,
b.image_media_id AS bot_image_media_id,
b.hue AS bot_hue,
b.face AS bot_face,
${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(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
FROM bot_sessions s
@@ -33,7 +36,7 @@ export default async function grabWorkspaceActivity({
))
ORDER BY COALESCE(s.last_activity_at, s.created_at) DESC, s.id DESC
LIMIT ?`,
values: [workspace_id, user_id, AppData.WorkspaceActivityItems],
values: [...face.values, workspace_id, user_id, AppData.WorkspaceActivityItems],
});
return res.payload || [];
@@ -1,11 +1,12 @@
import type { BotWithFace } from "@/src/types";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACE_BOTS,
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 botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -16,7 +17,10 @@ type Params = {
export type WorkspaceBotMembership = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
bot: BUN_SQLITE_COBBLE_BOTS;
/**
* With the user's own face (`botFaceSql`).
*/
bot: BotWithFace;
/**
* Its `workspace_bots` row. Null for the coordinator, which works in
* every workspace without one until it's given instructions there.
@@ -47,11 +51,12 @@ export default async function grabWorkspaceBot({
if (!workspace?.id) return null;
const bot_access = workspaceBotsSql({ role, workspace_id: workspace.id });
const face = botFaceSql({ user_id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [bot_id, ...bot_access.values],
await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [...face.values, bot_id, ...bot_access.values],
})
).payload?.[0];
+27 -15
View File
@@ -1,33 +1,45 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOT_USER_FACES, BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
type Params = {
/**
* Unix ms: bots updated since then are pushed.
* Unix ms: bots and faces updated since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* 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.
* Pushes `server:bot-updated` (the user's own face, status line and
* active task) for every face that changed since `since` to the connected
* user it's for, and for every bot that changed (e.g. its computer) 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 bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, face, status_line, computer_id FROM bots WHERE updated_at > ?`,
values: [since],
})
).payload || [];
const [faces, bots] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_USER_FACES>({
sql: `SELECT bot_id, user_id FROM bot_user_faces WHERE updated_at > ?`,
values: [since],
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, computer_id FROM bots WHERE updated_at > ?`,
values: [since],
}),
]);
for (const bot of bots) {
await pushBotUpdated({ bot, users });
for (const face of faces.payload || []) {
if (!face.bot_id || !face.user_id || !users.has(String(face.user_id))) continue;
await pushBotUpdated({ bot_id: face.bot_id, user_id: face.user_id });
}
for (const bot of bots.payload || []) {
if (!bot.id) continue;
await pushBotUpdated({ bot_id: bot.id, users, computer_id: bot.computer_id || null });
}
}
@@ -1,11 +1,11 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import SettingsListRow from "../../../(partials)/settings-list-row";
import SettingsRowAction from "../../../(partials)/settings-row-action";
type Props = {
bot: BUN_SQLITE_COBBLE_BOTS;
bot: BotWithFace;
/**
* The bot's settings page, when the actor may open it.
*/
@@ -45,7 +45,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
),
grabBots({ user_id }),
grabUserWorkspaces({ user_id }),
grabComputerAccess({ computer_id: allowed.id }),
grabComputerAccess({ computer_id: allowed.id, user_id }),
]);
return {
+2 -10
View File
@@ -1,6 +1,6 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import userAuth from "@/src/functions/backend/auth/user-auth";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
import setBotComputer from "@/src/functions/backend/db/computers/set-bot-computer";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
@@ -54,15 +54,7 @@ export const handler: BunextAPIRouteHandler<
user_id: user.id,
});
await pushBotNotice({
bot_id: bot.id || 0,
bot: {
id: bot.id,
face: bot.face,
status_line: bot.status_line,
computer_id: computer.id,
},
});
await pushBotUpdated({ bot_id: bot.id || 0, computer_id: computer.id || null });
await logEvent({
title: "Bot computer switched",
+4 -6
View File
@@ -13,6 +13,7 @@ import type {
BotMessageAuthor,
BotTaskStepState,
} from "@/src/dict/bot-dict";
import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
@@ -205,10 +206,7 @@ async function insertBot({
if (!bot.hue) {
const coordinator_id = await ensureCoordinatorBot({ user_id });
await BunSQLite.sql({
sql: `UPDATE bots SET face = ?, status_line = ? WHERE id = ?`,
values: [bot.face, bot.status_line, coordinator_id],
});
await setBotFace({ bot_id: coordinator_id, user_id, face: bot.face, status_line: bot.status_line });
await insertMessages({ user_id, bot_id: coordinator_id, bot, now, last_ago });
@@ -225,8 +223,6 @@ async function insertBot({
kind: bot.hue ? "worker" : "coordinator",
hue: bot.hue || undefined,
role: bot.role || undefined,
face: bot.face,
status_line: bot.status_line,
},
],
});
@@ -246,6 +242,8 @@ async function insertBot({
values: [now, now, bot_id, user_id],
});
await setBotFace({ bot_id, user_id, face: bot.face, status_line: bot.status_line });
const session_id = await insertMessages({ user_id, bot_id, bot, now, last_ago });
if (bot.task) {
+14 -3
View File
@@ -26,6 +26,7 @@ import type { HarnessKind } from "../dict/harness-dict";
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
import type { ContainerStatus } from "../dict/computer-dict";
import type { BotFace } from "../dict/bot-dict";
export type User = {
id: number;
@@ -42,11 +43,21 @@ export type User = {
roles?: string;
};
/**
* A bot row with its face and status line as the user it's for sees them
* (`botFaceSql`). Faces are per user, so a bot row read without one has
* neither.
*/
export type BotWithFace = BUN_SQLITE_COBBLE_BOTS & {
face?: BotFace;
status_line?: string;
};
/**
* A bot row plus its conversation (in its own chat or one workspace):
* unread, latest activity and latest message, for the rail.
*/
export type BotWithLastMessage = BUN_SQLITE_COBBLE_BOTS & {
export type BotWithLastMessage = BotWithFace & {
unread?: number | null;
last_activity_at?: number | null;
last_message_text?: string | null;
@@ -169,7 +180,7 @@ export type BotRemovedData = {
*/
export type BotUpdatedData = {
id: number;
face: BUN_SQLITE_COBBLE_BOTS["face"];
face: BotFace;
status_line: string;
task: BotTaskWithSteps | null;
/**
@@ -413,7 +424,7 @@ export type PagePropsType = {
* The computer a settings page manages, with its access.
*/
computer?: ComputerWithStatus | null;
computer_bots?: BUN_SQLITE_COBBLE_BOTS[] | null;
computer_bots?: BotWithFace[] | null;
computer_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null;
workspace_harness_overview?: BotHarnessOverviewPayload | null;
/**
+38
View File
@@ -0,0 +1,38 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import botFaceSql from "./bot-face-sql";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY)`);
db.run(`CREATE TABLE bot_user_faces (bot_id INTEGER, user_id INTEGER, face TEXT, status_line TEXT)`);
db.run(`INSERT INTO bots VALUES (1), (2)`);
db.run(`INSERT INTO bot_user_faces VALUES (1, 7, 'needs-you', 'Waiting on you'), (2, 8, 'working', 'Working on a task')`);
});
function faces({ user_id, prefix }: { user_id: number; prefix?: string }) {
const columns = botFaceSql({ user_id, prefix });
return db.query(`SELECT b.id, ${columns.sql} FROM bots b ORDER BY b.id`).all(...columns.values);
}
describe("botFaceSql", () => {
test("each user sees only their own face of a bot", () => {
expect(faces({ user_id: 7 })).toEqual([
{ id: 1, face: "needs-you", status_line: "Waiting on you" },
{ id: 2, face: "idle", status_line: "" },
]);
expect(faces({ user_id: 8 })).toEqual([
{ id: 1, face: "idle", status_line: "" },
{ id: 2, face: "working", status_line: "Working on a task" },
]);
});
test("a user with no faces sees every bot idle, under the prefix", () => {
expect(faces({ user_id: 9, prefix: "bot_" })).toEqual([
{ id: 1, bot_face: "idle", bot_status_line: "" },
{ id: 2, bot_face: "idle", bot_status_line: "" },
]);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { SqlCondition } from "./crew-access/types";
type Params = {
user_id: number | string;
/**
* The bots table alias.
*/
alias?: string;
/**
* Put before the column names, e.g. `bot_` for `bot_face`.
*/
prefix?: string;
};
/**
* Select columns for a bot's face and status line as one user sees them
* (`bot_user_faces`): idle and no status line when the bot has none for
* them. Faces are per user, so a job one user's waiting on never shows
* on anyone else's screen.
*/
export default function botFaceSql({ user_id, alias = "b", prefix = "" }: Params): SqlCondition {
const column = (name: string) =>
`(SELECT f.${name} FROM bot_user_faces f WHERE f.bot_id = ${alias}.id AND f.user_id = ?)`;
return {
sql: `COALESCE(${column("face")}, 'idle') AS ${prefix}face, COALESCE(${column("status_line")}, '') AS ${prefix}status_line`,
values: [Number(user_id), Number(user_id)],
};
}