Let workers ask each other through the coordinator in chatrooms

A worker asks for another bot's help with ask_crew. The coordinator
reads the request, asks the right bot (in a workspace, only its
members), and sends the answer back into the asking bot's
conversation, waking it. Each exchange is kept as a read-only
chatroom: listed in the main rail after the workspaces, or in the
workspace's rail for requests made there, with live status rows for
each bot while it's open and a Stop button.

Room sessions are hidden from every conversation list, other bots'
words reach the model fenced as data, and turns, nesting and open
rooms are capped. Closed rooms are pruned after a retention period
and past a cap per place (Settings > General).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 18:57:34 +01:00
co-authored by Claude Opus 5.5
parent 2d3c15e48b
commit 7b762fd384
139 changed files with 4246 additions and 64 deletions
@@ -0,0 +1,50 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import { abortRunningJob } from "@/src/bots-queue/job-abort-registry";
type Params = {
chatroom_id: number | string;
/**
* Kept as each job's error.
*/
reason: string;
/**
* The job closing the room (the coordinator's turn), which finishes on
* its own.
*/
except_job_id?: number | string | null;
};
/**
* Fails every job in a room that could still run (pending or running),
* stops the running ones here and now, and closes their tasks. The room
* has its outcome already, so their results would go nowhere.
*/
export default async function cancelChatroomJobs({ chatroom_id, reason, except_job_id }: Params) {
const now = Date.now();
const cancelled =
(
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 status IN ('pending', 'processing')
AND session_id IN (SELECT id FROM bot_sessions WHERE chatroom_id = ?)
AND id != ?
RETURNING *`,
values: [reason, now, now, chatroom_id, Number(except_job_id) || 0],
})
).payload || [];
for (const job of cancelled) {
abortRunningJob({ job_id: Number(job.id) });
if (job.task_id) {
await BunSQLite.sql({
sql: `UPDATE bot_tasks SET status = 'done', updated_at = ? WHERE id = ?`,
values: [now, Number(job.task_id)],
});
}
}
return cancelled;
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
type Params = {
chatroom_id?: number | string | null;
};
/**
* A room and the rooms it was asked from, nearest first: the chain a
* nested request sits in. Stops past `AppData.ChatroomMaxDepth`, which is
* as far as anything needs to look.
*/
export default async function chatroomAncestry({ chatroom_id }: Params) {
const chain: BUN_SQLITE_COBBLE_CHATROOMS[] = [];
let next = chatroom_id ? Number(chatroom_id) : null;
while (next && chain.length <= AppData.ChatroomMaxDepth) {
const room = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `SELECT * FROM chatrooms WHERE id = ? LIMIT 1`,
values: [next],
})
).payload?.[0];
if (!room?.id || chain.some((seen) => seen.id == room.id)) break;
chain.push(room);
next = room.parent_chatroom_id ? Number(room.parent_chatroom_id) : null;
}
return chain;
}
@@ -0,0 +1,40 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabCrewWorkers from "../bots/grab-crew-workers";
import grabUserBot from "../bots/grab-user-bot";
type Params = {
/**
* The user the room works for: only bots they reach can be asked.
*/
user_id: number | string;
/**
* The bot asking, never asked itself.
*/
requester_bot_id: number | string;
/**
* In a workspace, only its members can be asked.
*/
workspace_id?: number | string | null;
};
/**
* The workers a bot may have asked through the coordinator for a user:
* in a workspace its members, otherwise every worker the user reaches.
* Read fresh each time, so a bot that left or lost the user's grant is
* never asked.
*/
export default async function chatroomAskableBots({
user_id,
requester_bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
const workers = (await grabCrewWorkers({ workspace_id: workspace_id || null })).filter(
(bot) => bot.id != Number(requester_bot_id),
);
const reached = await Promise.all(
workers.map(async (bot) => ((await grabUserBot({ bot_id: bot.id!, user_id }))?.id ? bot : null)),
);
return reached.filter((bot): bot is BUN_SQLITE_COBBLE_BOTS => Boolean(bot));
}
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import chatroomOutcomeMessage from "./chatroom-outcome-message";
describe("chatroomOutcomeMessage", () => {
test("an answer names the request, links the room and carries the answer", () => {
expect(chatroomOutcomeMessage({ chatroom_id: 4, title: "Specs", status: "answered", outcome: "Here." })).toBe(
`The coordinator answered your request "Specs" ([room:4]):\n\nHere.`,
);
});
test("a refusal and a failure say why", () => {
expect(chatroomOutcomeMessage({ chatroom_id: 4, title: "Specs", status: "refused", outcome: "No one has them." })).toContain(
"turned down",
);
expect(chatroomOutcomeMessage({ chatroom_id: 4, title: "Specs", status: "failed", outcome: "Out of turns." })).toBe(
`Your request "Specs" ([room:4]) couldn't be answered:\n\nOut of turns.`,
);
});
test("a stopped room only says so", () => {
expect(chatroomOutcomeMessage({ chatroom_id: 4, title: "Specs", status: "stopped", outcome: "ignored" })).toBe(
`Your request "Specs" ([room:4]) was stopped by the user.`,
);
});
});
@@ -0,0 +1,28 @@
import type { ChatroomStatus } from "@/src/dict/chatroom-dict";
type Params = {
chatroom_id: number | string;
title?: string | null;
status: ChatroomStatus;
outcome?: string | null;
};
/**
* The message that brings a room's outcome back to the bot that asked,
* naming the request and linking the room.
*/
export default function chatroomOutcomeMessage({ chatroom_id, title, status, outcome }: Params) {
const request = `"${(title || "your request").trim()}" ([room:${chatroom_id}])`;
const body = (outcome || "").trim();
const heading =
status == "answered"
? `The coordinator answered your request ${request}:`
: status == "refused"
? `The coordinator turned down your request ${request}:`
: status == "stopped"
? `Your request ${request} was stopped by the user.`
: `Your request ${request} couldn't be answered:`;
return body && status != "stopped" ? `${heading}\n\n${body}` : heading;
}
@@ -0,0 +1,77 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import type { ChatroomBot, ChatroomJob, ChatroomSummary } from "@/src/types";
type Params = {
chatrooms: BUN_SQLITE_COBBLE_CHATROOMS[];
};
/**
* Rooms with the bots in them, asking bot first, then the coordinator,
* then the bots it asked in the order they joined, the jobs still open in
* the room, and the workspace it's in by name.
*/
export default async function chatroomSummaries({ chatrooms }: Params): Promise<ChatroomSummary[]> {
if (!chatrooms.length) return [];
const ids = chatrooms.map((room) => Number(room.id));
const marks = ids.map(() => "?").join(", ");
const rows =
(
await BunSQLite.sql<ChatroomBot & { chatroom_id: number; session_id: number }>({
sql: `SELECT s.chatroom_id, s.id AS session_id, b.id, b.name, b.kind, b.color, b.stone, b.hue, b.image_media_id
FROM bot_sessions s JOIN bots b ON b.id = s.bot_id
WHERE s.chatroom_id IN (${marks})
ORDER BY s.id ASC`,
values: ids,
})
).payload || [];
const jobs =
(
await BunSQLite.sql<ChatroomJob & { chatroom_id: number }>({
sql: `SELECT s.chatroom_id, q.bot_id, q.session_id, q.status, q.progress
FROM bots_queue q JOIN bot_sessions s ON s.id = q.session_id
WHERE s.chatroom_id IN (${marks}) AND q.status IN ('pending', 'processing')
ORDER BY q.id ASC`,
values: ids,
})
).payload || [];
const workspace_ids = [...new Set(chatrooms.map((room) => Number(room.workspace_id)).filter(Boolean))];
const workspace_names = new Map(
(workspace_ids.length
? (
await BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM workspaces WHERE id IN (${workspace_ids.map(() => "?").join(", ")})`,
values: workspace_ids,
})
).payload || []
: []
).map((row) => [Number(row.id), row.name]),
);
return chatrooms.map((room) => {
const bots = rows
.filter((row) => row.chatroom_id == room.id)
.map(({ chatroom_id: _room, session_id: _session, ...bot }) => bot);
const order = (bot: ChatroomBot) =>
bot.id == room.requester_bot_id ? 0 : bot.kind == "coordinator" ? 1 : 2;
return {
...room,
bots: [...bots].sort((a, b) => order(a) - order(b)),
working: jobs.some((job) => job.chatroom_id == room.id),
jobs: jobs
.filter((job) => job.chatroom_id == room.id)
.map(({ chatroom_id: _room, ...job }) => ({
...job,
bot_id: Number(job.bot_id),
session_id: Number(job.session_id),
})),
workspace_name: room.workspace_id ? workspace_names.get(Number(room.workspace_id)) || null : null,
};
});
}
@@ -0,0 +1,165 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS, BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import type { ChatroomStatus } from "@/src/dict/chatroom-dict";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import { logError } from "@/src/functions/backend/logger";
import enqueueBotReply from "../bots-queue/enqueue-bot-reply";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
import grabUserBot from "../bots/grab-user-bot";
import insertUserMessage from "../bots/insert-user-message";
import cancelChatroomJobs from "./cancel-chatroom-jobs";
import chatroomOutcomeMessage from "./chatroom-outcome-message";
import pushChatroomUpdated from "./push-chatroom-updated";
import pruneChatrooms from "./prune-chatrooms";
type Params = {
chatroom_id: number | string;
status: Exclude<ChatroomStatus, "open">;
/**
* What goes back to the asking bot: the answer, or why there is none.
*/
outcome: string;
/**
* The job closing it (the coordinator's turn), left to finish.
*/
job_id?: number | string | null;
};
/**
* Closes an open room once: its outcome is kept, whatever in it could
* still run is cancelled, and the outcome goes back to the asking bot in
* the conversation it asked from (its active session there if that one
* was closed since, or the room it was itself working in), on the user's
* side so the bot answers it, which it is woken to do. A room the user
* stopped only tells it. Returns false when the room was already closed.
*/
export default async function closeChatroom({ chatroom_id, status, outcome, job_id }: Params) {
const now = Date.now();
const room = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `UPDATE chatrooms SET status = ?, outcome = ?, closed_at = ?, last_activity_at = ?, unread = 1, updated_at = ?
WHERE id = ? AND status = 'open'
RETURNING *`,
values: [status, outcome, now, now, now, chatroom_id],
})
).payload?.[0];
if (!room?.id) return false;
await cancelChatroomJobs({
chatroom_id: room.id,
reason: status == "stopped" ? "Stopped by the user" : "The chatroom closed",
except_job_id: job_id,
});
try {
await tellRequester({ room, status, outcome });
} catch (error) {
await logError({
title: `Chatroom #${room.id}'s outcome didn't reach the asking bot`,
error,
user_id: room.user_id || undefined,
metadata: { chatroom_id: room.id, status },
}).catch(() => {});
}
await pushChatroomUpdated({ chatroom_id: room.id });
/**
* A place keeps only so many rooms: closing one may push the oldest
* closed ones there out.
*/
await pruneChatrooms({ workspace_id: Number(room.workspace_id) || null }).catch(async (error) => {
await logError({ title: "Chatrooms weren't pruned", error, metadata: { chatroom_id: room.id } }).catch(() => {});
});
return true;
}
async function tellRequester({
room,
status,
outcome,
}: {
room: BUN_SQLITE_COBBLE_CHATROOMS;
status: ChatroomStatus;
outcome: string;
}) {
if (!room.user_id || !room.requester_bot_id) return;
const requester = await grabUserBot({ bot_id: room.requester_bot_id, user_id: room.user_id });
if (!requester?.id) return;
const session = await pickRequesterSession({ room, bot_id: requester.id });
const message = await insertUserMessage({
bot_id: requester.id,
session_id: Number(session.id),
workspace_id: session.workspace_id || null,
text: chatroomOutcomeMessage({ chatroom_id: room.id!, title: room.title, status, outcome }),
from_chatroom_id: Number(room.id),
});
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET unread = 1 WHERE id = ?`,
values: [session.id!],
});
await pushBotNotice({ bot_id: requester.id, message });
if (status == "stopped") return;
await enqueueBotReply({
user_id: room.user_id,
bot_id: requester.id,
session_id: Number(session.id),
message_id: Number(message.id),
});
}
/**
* The asking bot's session the outcome goes in: the one it asked from
* while that's still where it talks (active, or its part of the room it
* was working in), else its conversation's active session there.
*/
async function pickRequesterSession({
room,
bot_id,
}: {
room: BUN_SQLITE_COBBLE_CHATROOMS;
bot_id: number;
}): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const origin = room.origin_session_id
? (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT * FROM bot_sessions WHERE id = ? AND bot_id = ? AND user_id = ? LIMIT 1`,
values: [room.origin_session_id, bot_id, room.user_id!],
})
).payload?.[0]
: null;
if (origin?.id && origin.status == "active") return origin;
/**
* Asked from inside another room: the answer goes back there while
* that room is still waiting on it.
*/
if (origin?.id && origin.chatroom_id) {
const parent = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `SELECT status FROM chatrooms WHERE id = ? LIMIT 1`,
values: [origin.chatroom_id],
})
).payload?.[0];
if (parent?.status == "open") return origin;
}
return await ensureBotSession({
user_id: room.user_id!,
bot_id,
workspace_id: origin?.id ? origin.workspace_id || null : room.workspace_id || null,
});
}
@@ -0,0 +1,125 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_CHATROOMS, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import AppError from "@/src/utils/app-error";
import insertBotMessage from "../bots/insert-bot-message";
import grabUserBotSession from "../bot-sessions/grab-user-bot-session";
import chatroomAncestry from "./chatroom-ancestry";
import enqueueChatroomTurn from "./enqueue-chatroom-turn";
import ensureChatroomSession from "./ensure-chatroom-session";
import pushChatroomUpdated from "./push-chatroom-updated";
type Params = {
user_id: number | string;
/**
* The worker asking. Never the coordinator, which hands work over
* itself.
*/
requester: BUN_SQLITE_COBBLE_BOTS;
/**
* The session it asks from: the answer goes back there.
*/
origin_session_id: number | string;
title: string;
request: string;
};
/**
* Opens a chatroom for a worker's request: the room (in the workspace the
* request came from, if any), the request as the asking bot's first
* message there, and the coordinator's first turn. Refused for the
* coordinator, past `AppData.ChatroomMaxOpenPerBot` open rooms for that
* bot and user, and past `AppData.ChatroomMaxDepth` nested rooms.
*/
export default async function createChatroom({
user_id,
requester,
origin_session_id,
title,
request,
}: Params): Promise<BUN_SQLITE_COBBLE_CHATROOMS> {
if (!requester.id || requester.kind == "coordinator") {
throw new AppError(`Only a worker asks the crew through the coordinator.`);
}
const origin = await grabUserBotSession({ session_id: origin_session_id, user_id });
if (!origin?.id || origin.bot_id != requester.id) {
throw new AppError(`Session not found.`);
}
const open = Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM chatrooms WHERE user_id = ? AND requester_bot_id = ? AND status = 'open'`,
values: [user_id, requester.id],
})
).payload?.[0]?.n,
);
if (open >= AppData.ChatroomMaxOpenPerBot) {
throw new AppError(
`You already have ${open} requests open with the coordinator. Wait for an answer before asking again.`,
);
}
const parent_chatroom_id = origin.chatroom_id ? Number(origin.chatroom_id) : null;
const ancestry = await chatroomAncestry({ chatroom_id: parent_chatroom_id });
if (ancestry.length >= AppData.ChatroomMaxDepth) {
throw new AppError(
`This request was already passed along ${ancestry.length} times. Answer with what you have instead of asking someone else.`,
);
}
const now = Date.now();
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_CHATROOMS, (typeof BunSQLiteTables)[number]>({
table: "chatrooms",
data: [
{
user_id: Number(user_id),
workspace_id: origin.workspace_id || undefined,
requester_bot_id: requester.id,
origin_session_id: origin.id,
parent_chatroom_id: parent_chatroom_id || undefined,
title,
request,
status: "open",
coordinator_turns: 0,
unread: 1,
last_activity_at: now,
},
],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Couldn't open the chatroom: ${res.msg}`);
}
const room = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `SELECT * FROM chatrooms WHERE id = ? LIMIT 1`,
values: [id],
})
).payload?.[0];
if (!room?.id) {
throw new Error(`Couldn't open the chatroom.`);
}
const session = await ensureChatroomSession({ chatroom: room, bot_id: requester.id });
await insertBotMessage({
bot_id: requester.id,
session_id: Number(session.id),
workspace_id: room.workspace_id || null,
text: `**${title}**\n\n${request}`,
});
await enqueueChatroomTurn({ chatroom: room });
await pushChatroomUpdated({ chatroom_id: room.id });
return room;
}
@@ -0,0 +1,42 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* A query for the ids of the rooms to delete, with its values.
*/
rooms: { sql: string; values: (string | number)[] };
};
/**
* Deletes rooms with their bots' sessions there, the messages and jobs in
* them. Explicitly, child rows first, since foreign keys are off outside
* the server process. A room's outcome already posted into the asking
* bot's own conversation stays there.
*/
export default async function deleteChatroomRows({ rooms }: Params) {
const ids = ((await BunSQLite.sql<{ id: number }>({ sql: rooms.sql, values: rooms.values })).payload || []).map(
(row) => Number(row.id),
);
if (!ids.length) return [];
const list = ids.join(", ");
const sessions = `SELECT id FROM bot_sessions WHERE chatroom_id IN (${list})`;
await BunSQLite.sql({
sql: `DELETE FROM bots_queue_updates WHERE bots_queue_id IN (SELECT id FROM bots_queue WHERE session_id IN (${sessions}))`,
});
await BunSQLite.sql({ sql: `DELETE FROM bots_queue WHERE session_id IN (${sessions})` });
await BunSQLite.sql({ sql: `DELETE FROM bot_tasks WHERE session_id IN (${sessions})` });
await BunSQLite.sql({
sql: `DELETE FROM bot_message_reactions WHERE message_id IN (SELECT id FROM bot_messages WHERE session_id IN (${sessions}))`,
});
await BunSQLite.sql({
sql: `DELETE FROM bot_message_media WHERE message_id IN (SELECT id FROM bot_messages WHERE session_id IN (${sessions}))`,
});
await BunSQLite.sql({ sql: `DELETE FROM bot_messages WHERE session_id IN (${sessions})` });
await BunSQLite.sql({ sql: `DELETE FROM bot_sessions WHERE chatroom_id IN (${list})` });
await BunSQLite.sql({ sql: `DELETE FROM chatrooms WHERE id IN (${list})` });
return ids;
}
@@ -0,0 +1,23 @@
import deleteChatroomRows from "./delete-chatroom-rows";
type Params = {
/**
* Every room in this workspace, or every room of this user.
*/
workspace_id?: number | string;
user_id?: number | string;
};
/**
* Deletes every room of a workspace or a user, with everything in them
* (`deleteChatroomRows`).
*/
export default async function deleteChatrooms({ workspace_id, user_id }: Params) {
if (!workspace_id && !user_id) return;
await deleteChatroomRows({
rooms: workspace_id
? { sql: `SELECT id FROM chatrooms WHERE workspace_id = ?`, values: [workspace_id] }
: { sql: `SELECT id FROM chatrooms WHERE user_id = ?`, values: [user_id!] },
});
}
@@ -0,0 +1,99 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE, BUN_SQLITE_COBBLE_CHATROOMS, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { ChatroomTurnJobTitle } from "@/src/dict/chatroom-dict";
import wakeBotsQueue from "@/src/bots-queue/wake-bots-queue";
import pushChatroomUpdated from "./push-chatroom-updated";
import closeChatroom from "./close-chatroom";
import ensureChatroomSession from "./ensure-chatroom-session";
import grabCoordinatorBot from "./grab-coordinator-bot";
type Params = {
chatroom: BUN_SQLITE_COBBLE_CHATROOMS;
};
/**
* Queues the coordinator's next turn in an open room: it reads the room
* and asks someone, answers or refuses. Queued straight onto the queue
* rather than through `enqueueBotJob`, since in a member's room the
* coordinator works for a user who doesn't reach it otherwise; the room
* is what was checked. A turn already waiting is reused. Past
* `AppData.ChatroomMaxCoordinatorTurns` the room closes as failed.
*/
export default async function enqueueChatroomTurn({ chatroom }: Params) {
if (!chatroom.id || chatroom.status != "open") return null;
const coordinator = await grabCoordinatorBot();
if (!coordinator?.id) {
await closeChatroom({
chatroom_id: chatroom.id,
status: "failed",
outcome: `There is no coordinator to pass the request on.`,
});
return null;
}
const session = await ensureChatroomSession({ chatroom, bot_id: coordinator.id });
const pending = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
sql: `SELECT id FROM bots_queue WHERE session_id = ? AND status = 'pending' LIMIT 1`,
values: [session.id!],
})
).payload?.[0];
if (pending?.id) return Number(pending.id);
/**
* Counted as it's queued, atomically, so two jobs ending at once can't
* both slip under the cap.
*/
const counted = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `UPDATE chatrooms SET coordinator_turns = coordinator_turns + 1, updated_at = ?
WHERE id = ? AND status = 'open' AND coordinator_turns < ?
RETURNING id`,
values: [Date.now(), chatroom.id, AppData.ChatroomMaxCoordinatorTurns],
})
).payload?.[0];
if (!counted?.id) {
await closeChatroom({
chatroom_id: chatroom.id,
status: "failed",
outcome: `The coordinator ran out of turns on this request (${AppData.ChatroomMaxCoordinatorTurns}) without an answer.`,
});
return null;
}
const res = await BunSQLite.queue.add<BUN_SQLITE_COBBLE_BOTS_QUEUE, (typeof BunSQLiteTables)[number]>({
table: "bots_queue",
data: [
{
kind: "reply",
bot_id: coordinator.id,
session_id: session.id,
workspace_id: chatroom.workspace_id || undefined,
title: ChatroomTurnJobTitle,
instructions: `Work the request in chatroom #${chatroom.id}.`,
params: JSON.stringify({ chatroom_id: chatroom.id }),
},
],
});
const job_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !job_id) {
throw new Error(`Couldn't queue the coordinator's turn: ${res.msg || res.error}`);
}
wakeBotsQueue();
/**
* The room's status rows show the coordinator is up.
*/
await pushChatroomUpdated({ chatroom_id: chatroom.id! }).catch(() => {});
return Number(job_id);
}
@@ -0,0 +1,57 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS, BUN_SQLITE_COBBLE_CHATROOMS, BunSQLiteTables } from "@/db/types/db";
type Params = {
chatroom: Pick<BUN_SQLITE_COBBLE_CHATROOMS, "id" | "user_id" | "workspace_id" | "title">;
bot_id: number | string;
};
/**
* A bot's part of a chatroom: its session there (status `chatroom`), made
* the first time it takes part. It holds the bot's messages and jobs in
* the room and never shows in the bot's own conversation.
*/
export default async function ensureChatroomSession({
chatroom,
bot_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const found = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT * FROM bot_sessions WHERE chatroom_id = ? AND bot_id = ? LIMIT 1`,
values: [chatroom.id!, bot_id],
})
).payload?.[0];
if (found?.id) return found;
const now = Date.now();
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_SESSIONS, (typeof BunSQLiteTables)[number]>({
table: "bot_sessions",
data: [
{
user_id: chatroom.user_id,
bot_id: Number(bot_id),
workspace_id: chatroom.workspace_id || undefined,
chatroom_id: chatroom.id,
status: "chatroom",
title: chatroom.title || undefined,
last_activity_at: now,
},
],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Couldn't start the bot's part of the chatroom: ${res.msg}`);
}
return (
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT * FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [id],
})
).payload?.[0] || { id, user_id: chatroom.user_id, bot_id: Number(bot_id), chatroom_id: chatroom.id, status: "chatroom" }
);
}
@@ -0,0 +1,23 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
type Params = {
chatroom_id: number | string;
};
/**
* Every message in a chatroom, from every bot in it, oldest first. Rooms
* are short (the coordinator's turns are capped), so they're read whole.
*/
export default async function grabChatroomMessages({ chatroom_id }: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES[]> {
return (
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql: `SELECT m.* FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE s.chatroom_id = ?
ORDER BY m.created_at ASC, m.id ASC`,
values: [chatroom_id],
})
).payload || []
);
}
@@ -0,0 +1,27 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
type Params = {
chatroom_id: number | string;
/**
* Only the room's own user's. Left out for server-side work (the queue).
*/
user_id?: number | string;
};
/**
* A chatroom by id, only if it's the user's own when one is given.
*/
export default async function grabChatroom({
chatroom_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_CHATROOMS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: user_id
? `SELECT * FROM chatrooms WHERE id = ? AND user_id = ? LIMIT 1`
: `SELECT * FROM chatrooms WHERE id = ? LIMIT 1`,
values: user_id ? [chatroom_id, user_id] : [chatroom_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,14 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
/**
* The install's one coordinator, whoever reaches it. Server-side work only
* (chatrooms, where it works for the room's user).
*/
export default async function grabCoordinatorBot(): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE kind = 'coordinator' ORDER BY id ASC LIMIT 1`,
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
session_id?: number | string | null;
};
/**
* The chatroom a session is a bot's part of, or null for an ordinary
* session.
*/
export default async function grabSessionChatroomId({ session_id }: Params) {
if (!session_id) return null;
const chatroom_id = (
await BunSQLite.sql<{ chatroom_id: number | null }>({
sql: `SELECT chatroom_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [session_id],
})
).payload?.[0]?.chatroom_id;
return chatroom_id ? Number(chatroom_id) : null;
}
@@ -0,0 +1,19 @@
import type { ChatroomSummary } from "@/src/types";
import chatroomSummaries from "./chatroom-summaries";
import grabChatroom from "./grab-chatroom";
type Params = {
chatroom_id: number | string;
user_id: number | string;
};
/**
* One of the user's own chatrooms with the bots in it, or null.
*/
export default async function grabUserChatroom({ chatroom_id, user_id }: Params): Promise<ChatroomSummary | null> {
const room = await grabChatroom({ chatroom_id, user_id });
if (!room?.id) return null;
return (await chatroomSummaries({ chatrooms: [room] }))[0] || null;
}
@@ -0,0 +1,41 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import type { ChatroomSummary } from "@/src/types";
import chatroomSummaries from "./chatroom-summaries";
type Params = {
user_id: number | string;
/**
* At most this many, open ones first, then the latest.
*/
limit?: number;
/**
* Only one place's rooms: a workspace's, or (null) those asked from
* bots' own chats. Left out, every room.
*/
workspace_id?: number | null;
};
/**
* The user's own chatrooms (all, or one place's): open ones first, then by
* latest activity.
*/
export default async function grabUserChatrooms({ user_id, limit = 200, workspace_id }: Params): Promise<ChatroomSummary[]> {
const scoped = workspace_id !== undefined;
const rooms =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `SELECT * FROM chatrooms WHERE user_id = ?${scoped ? ` AND workspace_id IS ?` : ""}
ORDER BY (status = 'open') DESC, COALESCE(last_activity_at, created_at) DESC, id DESC
LIMIT ?`,
values: [
user_id,
...(scoped ? [workspace_id ? Number(workspace_id) : null] : []),
Math.max(1, Math.floor(limit)),
],
})
).payload || [];
return await chatroomSummaries({ chatrooms: rooms });
}
@@ -0,0 +1,16 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
chatroom_id: number | string;
user_id: number | string;
};
/**
* Marks one of the user's rooms read, as opening it does.
*/
export default async function markChatroomRead({ chatroom_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE chatrooms SET unread = 0 WHERE id = ? AND user_id = ? AND unread = 1`,
values: [chatroom_id, user_id],
});
}
@@ -0,0 +1,88 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import { logError } from "@/src/functions/backend/logger";
import closeChatroom from "./close-chatroom";
import enqueueChatroomTurn from "./enqueue-chatroom-turn";
import grabChatroom from "./grab-chatroom";
import grabSessionChatroomId from "./grab-session-chatroom-id";
import pushChatroomUpdated from "./push-chatroom-updated";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
/**
* What the job ended with: its result, or why it failed.
*/
text?: string | null;
failed?: boolean;
};
/**
* Moves a room on when one of its jobs ends (done, failed, lost). An asked
* bot's job wakes the coordinator, which reads what it said. A coordinator
* turn that failed closes the room as failed; one that ended without
* answering, refusing or leaving anyone at work answers with what it
* said, so a room never waits on nothing. Not a room's job: nothing.
*/
export default async function onChatroomJobEnded({ job, text, failed }: Params) {
try {
const chatroom_id = await grabSessionChatroomId({ session_id: job.session_id });
if (!chatroom_id) return;
const room = await grabChatroom({ chatroom_id });
if (!room?.id) return;
if (room.status != "open") {
await pushChatroomUpdated({ chatroom_id: room.id });
return;
}
const is_coordinator = (
await BunSQLite.sql<{ kind: string }>({
sql: `SELECT kind FROM bots WHERE id = ? LIMIT 1`,
values: [job.bot_id || 0],
})
).payload?.[0]?.kind == "coordinator";
if (!is_coordinator) {
await enqueueChatroomTurn({ chatroom: room });
await pushChatroomUpdated({ chatroom_id: room.id });
return;
}
if (failed) {
await closeChatroom({
chatroom_id: room.id,
status: "failed",
outcome: `The coordinator couldn't work on it${text ? `: ${text}` : "."}`,
});
return;
}
const still_open = (
await BunSQLite.sql({
sql: `SELECT q.id FROM bots_queue q JOIN bot_sessions s ON s.id = q.session_id
WHERE s.chatroom_id = ? AND q.status IN ('pending', 'processing') AND q.id != ? LIMIT 1`,
values: [room.id, job.id || 0],
})
).payload?.[0];
if (still_open) {
await pushChatroomUpdated({ chatroom_id: room.id });
return;
}
await closeChatroom({
chatroom_id: room.id,
status: text?.trim() ? "answered" : "failed",
outcome: text?.trim() || `The coordinator ended without an answer.`,
});
} catch (error) {
await logError({
title: `Chatroom didn't move on after job #${job.id}`,
error,
metadata: { job_id: job.id, session_id: job.session_id },
}).catch(() => {});
}
}
@@ -0,0 +1,68 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import pruneChatroomsSql from "./prune-chatrooms-sql";
const DAY = 24 * 60 * 60 * 1000;
const now = 100 * DAY;
function seed() {
const db = new Database(":memory:");
db.run(`CREATE TABLE chatrooms (id INTEGER PRIMARY KEY, workspace_id INTEGER, parent_chatroom_id INTEGER,
status TEXT, created_at INTEGER, last_activity_at INTEGER, closed_at INTEGER)`);
const add = (id: number, workspace_id: number | null, status: string, days_ago: number, parent: number | null = null) =>
db.run(`INSERT INTO chatrooms VALUES (?, ?, ?, ?, ?, ?, ?)`, [
id,
workspace_id,
parent,
status,
now - days_ago * DAY,
now - days_ago * DAY,
status == "open" ? null : now - days_ago * DAY,
]);
return { db, add };
}
function pruned({ db, ...params }: { db: Database } & Parameters<typeof pruneChatroomsSql>[0]) {
const { sql, values } = pruneChatroomsSql(params);
return (db.query(sql).all(...values) as { id: number }[]).map((row) => row.id).sort((a, b) => a - b);
}
describe("pruneChatroomsSql", () => {
test("closed rooms past the cutoff go; open ones never do", () => {
const { db, add } = seed();
add(1, null, "answered", 40);
add(2, null, "open", 40);
add(3, null, "failed", 5);
expect(pruned({ db, cutoff: now - 30 * DAY, max_per_place: 100 })).toEqual([1]);
});
test("past the cap, the oldest closed rooms of that place go, counted per place", () => {
const { db, add } = seed();
add(1, null, "answered", 4);
add(2, null, "open", 3);
add(3, null, "answered", 2);
add(4, null, "answered", 1);
add(5, 9, "answered", 9);
add(6, 9, "answered", 8);
expect(pruned({ db, cutoff: 0, max_per_place: 2 })).toEqual([1]);
expect(pruned({ db, cutoff: 0, max_per_place: 2, workspace_id: 9 })).toEqual([]);
expect(pruned({ db, cutoff: 0, max_per_place: 1, workspace_id: 9 })).toEqual([5]);
});
test("a room an open room was asked from stays", () => {
const { db, add } = seed();
add(1, null, "answered", 40);
add(2, null, "open", 1, 1);
expect(pruned({ db, cutoff: now - 30 * DAY, max_per_place: 100 })).toEqual([]);
});
});
@@ -0,0 +1,46 @@
type Params = {
/**
* Closed rooms that last moved before this are deleted.
*/
cutoff: number;
/**
* How many rooms one place keeps, open ones counted.
*/
max_per_place: number;
/**
* Only this place: a workspace, or (null) bots' own chats. Left out,
* every place.
*/
workspace_id?: number | null;
};
/**
* The ids of the rooms past retention: closed ones older than the cutoff,
* and closed ones past the newest `max_per_place` rooms of their place
* (ranked by last activity, open rooms counted but never picked). A room a
* still-open room was asked from is kept, so a chain is never cut while it
* runs.
*/
export default function pruneChatroomsSql({ cutoff, max_per_place, workspace_id }: Params) {
const scoped = workspace_id !== undefined;
return {
sql: `SELECT id FROM (
SELECT c.id, c.status,
COALESCE(c.closed_at, c.last_activity_at, c.created_at) AS moved_at,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(c.workspace_id, 0)
ORDER BY COALESCE(c.last_activity_at, c.created_at) DESC, c.id DESC
) AS place_rank
FROM chatrooms c
${scoped ? `WHERE c.workspace_id IS ?` : ""}
) ranked
WHERE status != 'open'
AND (moved_at < ? OR place_rank > ?)
AND NOT EXISTS (SELECT 1 FROM chatrooms child WHERE child.parent_chatroom_id = ranked.id AND child.status = 'open')`,
values: [...(scoped ? [workspace_id ? Number(workspace_id) : null] : []), cutoff, Math.max(1, max_per_place)] as (
| string
| number
)[],
};
}
@@ -0,0 +1,25 @@
import { grabChatroomKeepDays, grabChatroomMaxPerPlace } from "../app-settings/grab-chatroom-retention";
import deleteChatroomRows from "./delete-chatroom-rows";
import pruneChatroomsSql from "./prune-chatrooms-sql";
type Params = {
/**
* Only this place: a workspace, or (null) bots' own chats. Left out,
* every place.
*/
workspace_id?: number | null;
};
/**
* Deletes the rooms past the install's retention (`pruneChatroomsSql`):
* closed rooms older than `grabChatroomKeepDays`, and the oldest closed
* ones once a place holds more than `grabChatroomMaxPerPlace`. Returns the
* ids deleted.
*/
export default async function pruneChatrooms({ workspace_id }: Params = {}) {
const cutoff = Date.now() - (await grabChatroomKeepDays()) * 24 * 60 * 60 * 1000;
return await deleteChatroomRows({
rooms: pruneChatroomsSql({ cutoff, max_per_place: await grabChatroomMaxPerPlace(), workspace_id }),
});
}
@@ -0,0 +1,26 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
import grabChatroom from "./grab-chatroom";
import pushChatroomUpdated from "./push-chatroom-updated";
type Params = {
chatroom_id: number | string;
message: BUN_SQLITE_COBBLE_BOT_MESSAGES;
};
/**
* Pushes a message posted in a room (`server:chatroom-message`) and the
* room as it is now to the room's user.
*/
export default async function pushChatroomMessage({ chatroom_id, message }: Params) {
const room = await grabChatroom({ chatroom_id });
if (!room?.id || !room.user_id) return;
sendToUser({
user_id: room.user_id,
data: { event: "server:chatroom-message", data: { chatroom_id: Number(room.id), message } },
});
await pushChatroomUpdated({ chatroom_id: room.id });
}
@@ -0,0 +1,28 @@
import { logError } from "@/src/functions/backend/logger";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
import grabChatroom from "./grab-chatroom";
import chatroomSummaries from "./chatroom-summaries";
type Params = {
chatroom_id: number | string;
};
/**
* Pushes a room as it is now (`server:chatroom-updated`) to its user's
* open tabs, for the rail and the room's header. Best effort.
*/
export default async function pushChatroomUpdated({ chatroom_id }: Params) {
try {
const room = await grabChatroom({ chatroom_id });
if (!room?.id || !room.user_id) return;
const [summary] = await chatroomSummaries({ chatrooms: [room] });
if (summary) {
sendToUser({ user_id: room.user_id, data: { event: "server:chatroom-updated", data: summary } });
}
} catch (error) {
await logError({ title: "Chatroom update wasn't pushed", error, metadata: { chatroom_id } }).catch(() => {});
}
}
@@ -0,0 +1,24 @@
import AppError from "@/src/utils/app-error";
import closeChatroom from "./close-chatroom";
import grabChatroom from "./grab-chatroom";
type Params = {
chatroom_id: number | string;
user_id: number | string;
};
/**
* The room's user stops it: everything in it that could still run is
* cancelled and the asking bot is told, without being woken.
*/
export default async function stopChatroom({ chatroom_id, user_id }: Params) {
const room = await grabChatroom({ chatroom_id, user_id });
if (!room?.id) {
throw new AppError(`Chatroom not found.`);
}
if (room.status != "open") return false;
return await closeChatroom({ chatroom_id: room.id, status: "stopped", outcome: "" });
}