Add computer switching, crew reporting, job steps and markdown chat

- Bots have a current computer (bots.computer_id), switched from the chat
  header beside the model button or by clicking a computer the bot names
  ([computer:ID]); tasks run on it by default
- search_computers runs one read-only command on all of a bot's computers;
  chat replies know their computers (compose-computers-note)
- The coordinator can use every computer and grants or revokes crew access
  with assign_computer
- Delegated jobs record requested_by_bot_id; the worker's result or failure
  is reported back in the coordinator's conversation
- bots_queue_updates records each model turn of a job, pushed live and shown
  in the working-on panel
- Live relay: every process pushes new messages, job steps and bot changes
  from the database to its own sockets, so work done elsewhere shows live
- Bot messages render as markdown (react-markdown + remark-gfm, no raw HTML)
  with twui components; rail previews are plain text
- Fix header button overflow in the model and computer selects
- Bump @moduletrace/twui to 0.1.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 04:44:35 +01:00
co-authored by Claude Opus 5.5
parent 6a2665dae9
commit 7b580d782e
64 changed files with 1792 additions and 86 deletions
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import composeComputersNote from "./compose-computers-note";
describe("composeComputersNote", () => {
test("lists each computer by ref, the current one first", () => {
const note = composeComputersNote({
computers: [
{ id: 4, name: "Scraper", kind: "sandbox", description: "Runs the crawlers" },
{ id: 1, name: "Main computer", kind: "main" },
],
});
expect(note).toContain(
"- [computer:4] Scraper (you're on it): Runs the crawlers\n- [computer:1] Main computer (main)",
);
expect(note).toContain("search_computers");
expect(note).toContain("start_task");
});
test("tells the coordinator it can use and assign every computer", () => {
const computers = [{ id: 1, name: "Main computer", kind: "main" as const }];
expect(composeComputersNote({ computers, is_coordinator: true })).toContain("assign_computer");
expect(composeComputersNote({ computers })).not.toContain("assign_computer");
});
test("says so when the bot has no computer", () => {
expect(composeComputersNote({ computers: [] })).toContain("You have no computer");
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import computerRef from "@/src/utils/computer-ref";
type Params = {
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The coordinator can use every computer and give the crew access.
*/
is_coordinator?: boolean;
};
/**
* Added to a bot's chat prompt so it knows which computers it has, which
* one it's on, and how it reaches them (markdown). Without it a bot asked
* "can you see the computer?" says it can't.
*/
export default function composeComputersNote({ computers, is_coordinator }: Params) {
if (!computers.length) {
return `# Your computers\n\nYou have no computer, so you can't run commands, open files or browse. If the user asks for that, say so.`;
}
const listed = computers
.map(
(computer, index) =>
`- ${computerRef({ id: computer.id || 0 })} ${computer.name || "Computer"}${
[index == 0 ? "you're on it" : "", computer.kind == "main" ? "main" : ""]
.filter(Boolean)
.map((tag) => ` (${tag})`)
.join("")
}${computer.description ? `: ${computer.description}` : ""}`,
)
.join("\n");
return `# Your computers
${listed}
Each is a Linux sandbox of your own (a shell, files, code runners and a browser), set up for the crew. It isn't the user's desktop: you can't see their screen or their own files unless they're put on it. The user picks which one you're on; your tasks run there.${
is_coordinator
? ` As the coordinator you can use every computer the user has, and you decide which ones each crew member can use with assign_computer.`
: ""
}
- To find which computer holds something (a file, a running app, a logged-in session), use search_computers: it runs one read-only command on all of them at once.
- For anything else on a computer, start_task: the task runs on it with its tools and you post what you found here. Don't say you can't see a computer; search it or start a task.
- When you name a computer, write its ref exactly, e.g. ${computerRef({ id: computers[0]!.id || 0 })}. The user sees it as a button that switches you to that computer, so when they're looking for one, say which and that they can click it.`;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import type { HarnessCandidate } from "@/src/harness/types";
import describeJobStep from "./describe-job-step";
const candidate = { model: { slug: "deepseek/deepseek-chat:free" } } as HarnessCandidate;
describe("describeJobStep", () => {
test("names each tool once, never its arguments", () => {
expect(
describeJobStep({
index: 2,
text: "Let me look.",
candidate,
tool_calls: [
{ id: "a", name: "sandbox_execute_bash", arguments: '{"command":"cat secret"}' },
{ id: "b", name: "sandbox_execute_bash", arguments: "{}" },
{ id: "c", name: "update_memory", arguments: "{}" },
],
}),
).toEqual({
kind: "tool",
text: "Using sandbox_execute_bash, update_memory",
metadata: {
step: 2,
model: "deepseek/deepseek-chat:free",
tools: ["sandbox_execute_bash", "update_memory"],
},
});
});
test("keeps the first line of a text-only turn, cut to one line", () => {
const step = describeJobStep({
index: 0,
text: `\n ${"a".repeat(250)}\nSecond line`,
candidate,
tool_calls: [],
});
expect(step?.kind).toBe("note");
expect(step?.text).toHaveLength(200);
expect(step?.text.endsWith("…")).toBe(true);
});
test("skips an empty turn", () => {
expect(describeJobStep({ index: 0, text: " ", candidate, tool_calls: [] })).toBeNull();
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { BotJobUpdateKind } from "@/src/dict/bot-queue-dict";
import type { TextLoopStep } from "@/src/harness/text/run-text-loop";
/**
* Longest step line kept: the panel shows one line per step.
*/
const MaxStepChars = 200;
/**
* One model turn as a `bots_queue_updates` row: the tools it called, or
* the first line of what it wrote. Null for an empty turn. Never the tool
* arguments, which can hold anything the bot typed.
*/
export default function describeJobStep({
text,
tool_calls,
index,
candidate,
}: TextLoopStep): {
kind: BotJobUpdateKind;
text: string;
metadata: Record<string, unknown>;
} | null {
const metadata = { step: index, model: candidate.model.slug };
if (tool_calls.length) {
const tools = [...new Set(tool_calls.map((call) => call.name))];
return {
kind: "tool",
text: `Using ${tools.join(", ")}`,
metadata: { ...metadata, tools },
};
}
const line = text?.trim().split("\n").find((l) => l.trim())?.trim();
if (!line) return null;
return {
kind: "note",
text: line.length > MaxStepChars ? `${line.slice(0, MaxStepChars - 1)}…` : line,
metadata,
};
}
+27
View File
@@ -4,15 +4,19 @@ import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
import completeBotJob from "@/src/functions/backend/db/bots-queue/complete-bot-job";
import failBotJob from "@/src/functions/backend/db/bots-queue/fail-bot-job";
import updateBotJob from "@/src/functions/backend/db/bots-queue/update-bot-job";
import insertBotJobUpdate from "@/src/functions/backend/db/bots-queue/insert-bot-job-update";
import { logError } from "@/src/functions/backend/logger";
import createKeyLock from "@/src/utils/create-key-lock";
import HarnessError from "@/src/harness/harness-error";
import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job";
import { BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
import BotJobFatalError from "./bot-job-fatal-error";
import describeJobStep from "./describe-job-step";
import pushToBotOwner from "./push-to-bot-owner";
import pushBotNotice from "./push-bot-notice";
import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json";
import type { BotJobUpdateData } from "@/src/types";
import type { BotJobContext, BotJobRunner } from "./types";
/**
@@ -66,6 +70,28 @@ export default async function processBotJob({
return held;
};
const report: BotJobContext["report"] = async (step) => {
const described = describeJobStep(step);
if (!described || controller.signal.aborted) return;
try {
if (!(await update({ progress: described.text }))) return;
const row = await insertBotJobUpdate({ job_id, bot_id, ...described });
await pushToBotOwner({
bot_id,
data: {
event: "server:bot-job-update",
data: { ...row, task_id: job.task_id || null } satisfies BotJobUpdateData,
},
});
} catch (error) {
await logError({ title: `Bot job #${job_id} step not saved`, error }).catch(() => {});
}
};
const heartbeat = setInterval(() => {
update({}).catch(() => {});
}, HEARTBEAT_MS);
@@ -91,6 +117,7 @@ export default async function processBotJob({
checkpoint: parseJobJson({ text: job.checkpoint }),
signal: controller.signal,
update,
report,
});
};
+15 -2
View File
@@ -8,14 +8,20 @@ import pushToBotOwner from "./push-to-bot-owner";
type Params = {
bot_id: number | string;
message?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null;
bot?: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line"> | null;
bot?: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id"> | null;
/**
* A job's result reported to the bot that handed it over
* (`relayJobToRequester`): a message in that bot's conversation.
*/
relay?: { bot_id: number; message: BUN_SQLITE_COBBLE_BOT_MESSAGES } | null;
};
/**
* Pushes a new bot message and/or face change to the owner's open tabs.
* A face change carries the bot's active task, for the working-on panel.
* A relayed report goes to the conversation it was posted in.
*/
export default async function pushBotNotice({ bot_id, message, bot }: Params) {
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
if (message) {
await pushToBotOwner({
bot_id,
@@ -23,6 +29,13 @@ export default async function pushBotNotice({ bot_id, message, bot }: Params) {
});
}
if (relay) {
await pushToBotOwner({
bot_id: relay.bot_id,
data: { event: "server:bot-message", data: relay.message },
});
}
if (bot) {
await pushToBotOwner({
bot_id,
+8 -1
View File
@@ -3,9 +3,11 @@ import { AppData } from "@/src/data/app-data";
import { ChatReplyNote, CoordinatorReplyNote } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import runTextLoop from "@/src/harness/text/run-text-loop";
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
import botMessagesToHarness from "./bot-messages-to-harness";
import composeComputersNote from "./compose-computers-note";
import loadAttachmentParts from "./load-attachment-parts";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -16,7 +18,7 @@ type Params = BotJobContext & {
/**
* Answers the user in chat: the bot reads the recent conversation (and
* the files of the latest messages, `loadAttachmentParts`) and
* replies, with its chat tools (memory, instructions, start_task, and for
* replies, knowing its computers (`composeComputersNote`), with its chat tools (memory, instructions, start_task, and for
* the coordinator delegate_task). Finishes silently when the last message
* is already the bot's, i.e. an earlier reply answered everything.
*/
@@ -24,6 +26,7 @@ export default async function runBotReply({
bot,
job,
signal,
report,
}: Params): Promise<BotJobOutcome> {
const history = await grabBotMessages({
bot_id: bot.id,
@@ -35,11 +38,13 @@ export default async function runBotReply({
}
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const result = await runTextLoop({
system: [
prompt,
ChatReplyNote,
composeComputersNote({ computers, is_coordinator: bot.kind == "coordinator" }),
bot.kind == "coordinator" ? CoordinatorReplyNote : "",
`Now: ${new Date().toUTCString()}.`,
]
@@ -52,11 +57,13 @@ export default async function runBotReply({
tools: await grabBotChatTools({
bot,
user_id: bot.user_id,
computers,
message_id: job.message_id || undefined,
}),
max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id,
signal,
onStep: report,
});
return { result: result.text };
+14 -11
View File
@@ -1,8 +1,11 @@
import { TaskRunNote } from "@/src/data/harness-prompts";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import runTextLoop from "@/src/harness/text/run-text-loop";
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
import computerRef from "@/src/utils/computer-ref";
import composeJobBrief from "./compose-job-brief";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -12,16 +15,16 @@ type Params = BotJobContext & {
/**
* Works through a task: the bot follows its brief with its full prompt,
* its own tools and, when the job has a computer, that computer's tools
* (over MCP), until it answers. That answer is the report for the user.
* Progress is saved after every model turn.
* its own tools, search_computers across all its computers and, when the
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
* Every model turn is reported as a step (`report`).
*/
export default async function runBotTask({
bot,
job,
params,
signal,
update,
report,
}: Params): Promise<BotJobOutcome> {
const computer = job.computer_id
? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal })
@@ -29,6 +32,7 @@ export default async function runBotTask({
try {
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const result = await runTextLoop({
system: [
@@ -37,6 +41,9 @@ export default async function runBotTask({
computer
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything.`
: `You have no computer for this task.`,
computers.length > 1
? `search_computers runs a read-only command on every computer you have. When you name one in the report, write its ref, e.g. ${computerRef({ id: computers[0]!.id || 0 })}: the user can click it to switch you to that computer.`
: "",
computer?.envs.length
? `Environment variables set on this computer (use them by name, e.g. $KEY; never print their values):\n${computer.envs
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
@@ -57,17 +64,13 @@ export default async function runBotTask({
],
tools: [
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }),
...(computers.length ? [searchComputersTool({ bot_id: bot.id, computers })] : []),
...(computer?.tools || []),
],
bot_id: bot.id,
signal,
onStep: async ({ text, tool_calls }) => {
await update({
progress: tool_calls.length
? `Using ${tool_calls.map((call) => call.name).join(", ")}`
: text?.slice(0, 500) || undefined,
});
},
onStep: (step) =>
report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }),
});
return { result: computer ? computer.redact(result.text) : result.text };
+7
View File
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import type { TextLoopStep } from "@/src/harness/text/run-text-loop";
export type BotJobContext = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -21,6 +22,12 @@ export type BotJobContext = {
progress?: string;
checkpoint?: Record<string, unknown>;
}) => Promise<boolean>;
/**
* Records a model turn as a step (`bots_queue_updates`), saves it as
* the job's progress and pushes it live. Pass it as the text loop's
* `onStep`. Best effort: a failed write never stops the job.
*/
report: (step: TextLoopStep) => Promise<void>;
};
export type BotJobOutcome = {
+16
View File
@@ -108,6 +108,22 @@ export const AppData = {
* One MCP tool call gives up after this long without progress.
*/
McpToolTimeoutMs: 5 * 60 * 1000,
/**
* `search_computers`: how long the command may run on each computer,
* and how much of each computer's output the bot reads.
*/
ComputerSearchTimeoutMs: 30 * 1000,
ComputerSearchMaxOutputChars: 4000,
/**
* How many of a job's latest steps (`bots_queue_updates`) the
* working-on panel shows.
*/
BotJobUpdatesShown: 12,
/**
* How often each server process reads new bot messages, job steps and
* bot changes from the database for its own sockets (`liveRelay`).
*/
LiveRelayPollMs: 1500,
/**
* How much of the conversation a bot sees when it replies.
*/
+1 -1
View File
@@ -32,7 +32,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
/**
* Added to the coordinator's chat prompt: it hands work to the crew.
*/
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, so tell the user who is on it.`;
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user and follow up on anything left.`;
/**
* Added to a bot's prompt when it works through a queued task.
+11
View File
@@ -21,6 +21,17 @@ export const BotJobKinds = [
export type BotJobKind = (typeof BotJobKinds)[number]["value"];
/**
* A `bots_queue_updates` row: a model turn that called tools, or one that
* only wrote text.
*/
export const BotJobUpdateKinds = [
{ title: "Tool", value: "tool" },
{ title: "Note", value: "note" },
] as const;
export type BotJobUpdateKind = (typeof BotJobUpdateKinds)[number]["value"];
/**
* A reply job's title, and the bot's status line while it runs. The chat
* shows a thinking row while a bot has it.
@@ -4,6 +4,7 @@ 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";
type Params = {
job_id: number | string;
@@ -27,8 +28,8 @@ 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. Throws if this handler no longer holds
* the job.
* a reply. 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({
job_id,
@@ -87,5 +88,7 @@ export default async function completeBotJob({
face: job.kind == "reply" ? "idle" : "done",
});
return { message, bot };
const relay = silent ? null : await relayJobToRequester({ job, text: result });
return { message, bot, relay };
}
@@ -41,6 +41,11 @@ type Params = {
* Milliseconds to hold the job back before it can be claimed.
*/
delay?: number;
/**
* The user's bot handing this job over (the coordinator). It gets the
* result or failure in its own conversation (`relayJobToRequester`).
*/
requested_by_bot_id?: number | string;
};
/**
@@ -60,6 +65,7 @@ export default async function enqueueBotJob({
task_id,
computer_id,
delay,
requested_by_bot_id,
}: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -67,6 +73,10 @@ export default async function enqueueBotJob({
throw new AppError(`Bot not found.`);
}
if (requested_by_bot_id && !(await grabUserBot({ bot_id: requested_by_bot_id, user_id }))?.id) {
throw new AppError(`Bot not found.`);
}
if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) {
throw new AppError(`Message not found.`);
}
@@ -103,6 +113,7 @@ export default async function enqueueBotJob({
message_id: message_id ? Number(message_id) : undefined,
task_id: final_task_id ? Number(final_task_id) : undefined,
computer_id: computer_id ? Number(computer_id) : undefined,
requested_by_bot_id: requested_by_bot_id ? Number(requested_by_bot_id) : undefined,
},
],
delay,
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
type Params = {
task_id: number | string;
};
/**
* The latest steps of the newest job working on a task, oldest first,
* capped at `AppData.BotJobUpdatesShown`.
*/
export default async function grabBotJobUpdates({
task_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES[]> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES>({
sql: `SELECT * FROM (
SELECT u.* FROM bots_queue_updates u
WHERE u.bots_queue_id = (
SELECT id FROM bots_queue WHERE task_id = ? ORDER BY id DESC LIMIT 1
)
ORDER BY u.created_at DESC, u.id DESC LIMIT ?
) ORDER BY created_at ASC, id ASC`,
values: [task_id, AppData.BotJobUpdatesShown],
});
return res.payload || [];
}
@@ -0,0 +1,47 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES, BunSQLiteTables } from "@/db/types/db";
import type { BotJobUpdateKind } from "@/src/dict/bot-queue-dict";
type Params = {
job_id: number;
bot_id: number;
kind: BotJobUpdateKind;
text: string;
metadata?: Record<string, unknown>;
};
/**
* Records one step of a running job (`bots_queue_updates`) and returns the
* row, for the live push.
*/
export default async function insertBotJobUpdate({
job_id,
bot_id,
kind,
text,
metadata,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES> {
const row: BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES = {
bots_queue_id: job_id,
bot_id,
kind,
text,
metadata: metadata ? JSON.stringify(metadata) : undefined,
};
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES,
(typeof BunSQLiteTables)[number]
>({
table: "bots_queue_updates",
data: [row],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Failed to save the job update: ${res.msg}`);
}
return { ...row, id, created_at: Date.now() };
}
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -11,7 +12,8 @@ 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.
* bot to needs-you. 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({
job,
@@ -47,5 +49,11 @@ export default async function notifyBotJobFailed({
status_line: "Waiting on you",
});
return { message, bot };
const relay = await relayJobToRequester({
job,
text: failure_message || job.last_error || "",
failed: true,
});
return { message, bot, relay };
}
@@ -0,0 +1,53 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
/**
* The worker's report, or why it failed (markdown).
*/
text: string;
failed?: boolean;
};
/**
* Reports a handed-over job back to the bot that handed it over
* (`requested_by_bot_id`, the coordinator): a message in its conversation
* 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.
*/
export default async function relayJobToRequester({ job, text, failed }: Params) {
if (!job.requested_by_bot_id || !job.bot_id || job.requested_by_bot_id == job.bot_id) {
return null;
}
const worker = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
values: [job.bot_id],
})
).payload?.[0];
const requester = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id FROM bots WHERE id = ? LIMIT 1`,
values: [job.requested_by_bot_id],
})
).payload?.[0];
if (!requester?.id) return null;
const name = worker?.name || "A crew member";
const title = job.title || "the job";
const message = await insertBotMessage({
bot_id: requester.id,
text: failed
? `${name} couldn't finish **${title}**. ${text}`.trim()
: `${name} finished **${title}**. Their report:\n\n${text}`,
needs_you: failed,
});
return { bot_id: requester.id, message };
}
@@ -4,13 +4,15 @@ import type {
} from "@/db/types/db";
import type { BotTaskWithSteps } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import grabBotJobUpdates from "../bots-queue/grab-bot-job-updates";
type Params = {
bot_id: number | string;
};
/**
* The bot's most recent active task and its steps, for the working-on panel.
* The bot's most recent active task, its steps and what its job has done
* so far, for the working-on panel.
*/
export default async function grabActiveBotTask({
bot_id,
@@ -31,5 +33,9 @@ export default async function grabActiveBotTask({
values: [task.id],
});
return { ...task, steps: steps_res.payload || [] };
return {
...task,
steps: steps_res.payload || [],
updates: await grabBotJobUpdates({ task_id: task.id }),
};
}
@@ -9,7 +9,10 @@ type Params = {
/**
* Every computer a bot can use: the ones it was given directly
* (`bot_computers`) plus the ones assigned to any workspace it's in
* (`workspace_computers`). Only the owner's computers, each once.
* (`workspace_computers`); the coordinator can use all of them. Only the
* owner's computers, each once. 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.
*/
export default async function grabBotComputers({
bot_id,
@@ -19,7 +22,8 @@ export default async function grabBotComputers({
sql: `SELECT c.* FROM computers c
WHERE c.user_id = ?
AND (
c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
OR c.id IN (
SELECT wc.computer_id FROM workspace_computers wc
JOIN workspace_bots wb ON wb.workspace_id = wc.workspace_id
@@ -27,8 +31,9 @@ export default async function grabBotComputers({
WHERE wb.bot_id = ? AND w.archived = 0
)
)
ORDER BY c.kind = 'main' DESC, c.id ASC`,
values: [user_id, bot_id, 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, bot_id],
});
return res.payload || [];
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabBotComputers from "./grab-bot-computers";
type Params = {
bot_id: number | string;
user_id: number | string;
computer_id: number | string;
};
/**
* Switches the computer a bot works on (`bots.computer_id`) to one of its
* own (`grabBotComputers`). Returns its computers, the new one first.
*/
export default async function setBotComputer({ bot_id, user_id, computer_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
const computer = computers.find((c) => String(c.id) == String(computer_id));
if (!computer?.id) {
throw new AppError(`${bot.name || "This bot"} has no access to that computer.`);
}
await BunSQLite.sql({
sql: `UPDATE bots SET computer_id = ?, updated_at = ? WHERE id = ?`,
values: [computer.id, Date.now(), bot.id],
});
return {
bot,
computer,
computers: [computer, ...computers.filter((c) => c.id != computer.id)],
};
}
@@ -0,0 +1,89 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
import computerRef from "@/src/utils/computer-ref";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The user's worker bots.
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
/**
* Every computer the user has (the coordinator's).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* Each worker's computers now, by bot id, for the description.
*/
access: Record<number, BUN_SQLITE_COBBLE_COMPUTERS[]>;
};
type Args = {
bot_id: number;
computer_id: number;
access: "grant" | "revoke";
};
/**
* Lets the coordinator give a crew member access to one of the user's
* computers (`bot_computers`), or take a direct grant away. Access that
* comes through a workspace stays, and the result says so.
*/
export default function assignComputerTool({
user_id,
workers,
computers,
access,
}: Params): HarnessTool<Args> {
const crew = workers
.map((bot) => {
const has = access[bot.id || 0] || [];
return `${bot.id}: ${bot.name} (${has.length ? has.map((c) => computerRef({ id: c.id || 0 })).join(", ") : "no computer"})`;
})
.join("; ");
return {
name: "assign_computer",
description: `Give a crew member access to a computer, or take it away. A bot works on its computers and its delegated tasks run on the one it's on. Crew and their computers now: ${crew}. Computers: ${computers
.map((c) => `${computerRef({ id: c.id || 0 })} ${c.name || "Computer"}`)
.join(", ")}.`,
schema: z.object({
bot_id: z.number().int(),
computer_id: z.number().int(),
access: z.enum(["grant", "revoke"]),
}),
run: async ({ args }) => {
const worker = workers.find((bot) => bot.id == args.bot_id);
const computer = computers.find((c) => c.id == args.computer_id);
if (!worker?.id) {
throw new Error(`No crew member has id ${args.bot_id}.`);
}
if (!computer?.id) {
throw new Error(`No computer has id ${args.computer_id}.`);
}
const ref = computerRef({ id: computer.id });
if (args.access == "grant") {
await addBotToComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
return `${worker.name} can use ${ref} now.`;
}
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
const still = (await grabBotComputers({ bot_id: worker.id, user_id })).some(
(c) => c.id == computer.id,
);
return still
? `Removed ${worker.name}'s own access to ${ref}, but it still has it through a workspace.`
: `${worker.name} can't use ${ref} any more.`;
},
};
}
+11 -4
View File
@@ -8,6 +8,10 @@ import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The coordinator: the worker's result comes back to its conversation.
*/
coordinator_id: number | string;
/**
* The coordinator's name, shown in the worker's conversation.
*/
@@ -26,11 +30,13 @@ type Args = {
/**
* Lets the coordinator hand a task to a worker bot. The worker runs it on
* its own main computer (if it has one) and posts the result in its own
* conversation, where a note says who asked.
* the computer it's on (if it has one) and posts the result in its own
* conversation, where a note says who asked; the result (or failure) is
* also reported back in the coordinator's conversation.
*/
export default function delegateTaskTool({
user_id,
coordinator_id,
coordinator_name,
workers,
}: Params): HarnessTool<Args> {
@@ -40,7 +46,7 @@ export default function delegateTaskTool({
return {
name: "delegate_task",
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.`,
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.`,
schema: z.object({
bot_id: z.number().int(),
title: z.string().min(1).max(80),
@@ -61,6 +67,7 @@ export default function delegateTaskTool({
title: args.title,
instructions: args.instructions,
computer_id: computers[0]?.id || undefined,
requested_by_bot_id: coordinator_id,
});
await pushBotNotice({
@@ -71,7 +78,7 @@ export default function delegateTaskTool({
}),
});
return `Task #${job_id} is with ${worker.name}. Their result will be in their conversation.`;
return `Task #${job_id} is with ${worker.name}. Their result will be in their conversation and reported back here.`;
},
};
}
+28 -4
View File
@@ -1,7 +1,9 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import type { HarnessTool } from "../../types";
import searchComputersTool from "../computer/search-computers-tool";
import assignComputerTool from "./assign-computer-tool";
import delegateTaskTool from "./delegate-task-tool";
import grabBotTools from "./grab-bot-tools";
import startTaskTool from "./start-task-tool";
@@ -9,6 +11,10 @@ import startTaskTool from "./start-task-tool";
type Params = {
bot: BUN_SQLITE_COBBLE_BOTS;
user_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The user message being answered.
*/
@@ -17,12 +23,15 @@ type Params = {
/**
* The tools a bot replies in chat with: its own (memory, instructions),
* start_task for longer work, and for the coordinator delegate_task to
* the crew. Computers are reached through tasks, never mid-reply.
* start_task for longer work, search_computers to find what's on its
* computers, and for the coordinator delegate_task and assign_computer
* (which computers each crew member can use). Anything
* else on a computer goes through a task, never mid-reply.
*/
export default async function grabBotChatTools({
bot,
user_id,
computers,
message_id,
}: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0;
@@ -31,9 +40,10 @@ export default async function grabBotChatTools({
startTaskTool({
user_id,
bot_id,
computers: await grabBotComputers({ bot_id, user_id }),
computers,
message_id,
}),
...(computers.length ? [searchComputersTool({ bot_id, computers })] : []),
];
if (bot.kind != "coordinator") return tools;
@@ -50,10 +60,24 @@ export default async function grabBotChatTools({
tools.push(
delegateTaskTool({
user_id,
coordinator_id: bot_id,
coordinator_name: bot.name || "The coordinator",
workers,
}),
);
if (computers.length) {
const access = Object.fromEntries(
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
]),
),
);
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
}
return tools;
+2 -2
View File
@@ -7,7 +7,7 @@ type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The bot's computers, its main one first (`grabBotComputers`).
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
@@ -40,7 +40,7 @@ export default function startTaskTool({
name: "start_task",
description: `Queue work that takes a while or needs a computer. It runs in the background and its result is posted in this chat. Write the instructions as a full brief for yourself: what to do, where, and how you'll know it's done. ${
computers.length
? `Computers you can use: ${listed}. Defaults to ${computers[0]!.id}.`
? `Computers you can use: ${listed}. Defaults to ${computers[0]!.id}, the one you're on.`
: `You have no computer, so the task can't run commands or browse.`
}`,
schema: z.object({
@@ -0,0 +1,41 @@
type Params = {
/**
* The sandbox's REST address (no `/mcp`).
*/
base_url: string;
command: string;
timeout_ms: number;
signal?: AbortSignal;
};
/**
* Runs one shell command on a computer over the sandbox's REST API
* (`/v1/shell/exec`), outside any MCP session. Throws when the computer
* can't be reached or refuses the command.
*/
export default async function runComputerCommand({
base_url,
command,
timeout_ms,
signal,
}: Params): Promise<{ output: string; exit_code: number | null }> {
const res = await fetch(`${base_url}/v1/shell/exec`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ command }),
signal: AbortSignal.any([
...(signal ? [signal] : []),
AbortSignal.timeout(timeout_ms),
]),
});
const body: any = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
throw new Error(`The computer refused the command: ${body?.message || res.status}`);
}
return {
output: String(body?.data?.output ?? ""),
exit_code: typeof body?.data?.exit_code == "number" ? body.data.exit_code : null,
};
}
@@ -0,0 +1,82 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerRef from "@/src/utils/computer-ref";
import redactSecrets from "@/src/utils/redact-secrets";
import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command";
type Params = {
bot_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
type Args = {
command: string;
};
/**
* Lets a bot look for something across every computer it has: one shell
* command runs on each at once (`runComputerCommand`), and the outputs come
* back labelled with each computer's ref (`computerRef`), secrets redacted,
* so the bot can tell the user exactly which computer holds what.
*/
export default function searchComputersTool({
bot_id,
computers,
}: Params): HarnessTool<Args> {
return {
name: "search_computers",
description: `Run one read-only shell command (bash) on every computer you have at once, to find which one holds something: a file, a running app, a logged-in browser session, a setting. Each computer's output comes back under its ref, e.g. ${computerRef({ id: computers[0]?.id || 1 })}. For a logged-in site, look for its domain in the browser profiles' cookie databases (copy the file first, since the browser locks it) and list only host names, never cookie values. Don't change anything with it; use start_task for that. Output past ${AppData.ComputerSearchMaxOutputChars} characters per computer is cut, so filter in the command.`,
schema: z.object({
command: z.string().min(1).max(4000),
}),
run: async ({ args, signal }) => {
const results = await Promise.all(
computers.map(async (computer) => {
const label = `## ${computerRef({ id: computer.id || 0 })} ${computer.name || "Computer"}`;
try {
const target = await grabComputerMcpUrl({ computer_id: computer.id || 0 });
if (!target) return `${label}\nNot reachable: it has no container.`;
const [result, envs] = await Promise.all([
runComputerCommand({
base_url: target.base_url,
command: args.command,
timeout_ms: AppData.ComputerSearchTimeoutMs,
signal,
}),
grabComputerEnvs({ computer_id: computer.id || 0, bot_id }),
]);
const output = redactSecrets({
text: result.output.trim(),
secrets: envs.filter((env) => env.secret),
});
const cut = output.length > AppData.ComputerSearchMaxOutputChars;
return [
label,
`Exit code: ${result.exit_code ?? "unknown"}`,
output ? output.slice(0, AppData.ComputerSearchMaxOutputChars) : "(no output)",
cut ? `(output cut at ${AppData.ComputerSearchMaxOutputChars} characters)` : "",
]
.filter(Boolean)
.join("\n");
} catch (error) {
return `${label}\nNot reachable: ${error instanceof Error ? error.message : String(error)}`;
}
}),
);
return results.join("\n\n");
},
};
}
+6 -1
View File
@@ -6,6 +6,7 @@ import { twMerge } from "tailwind-merge";
import BotStone from "@/src/components/general/bot-stone";
import type { BotWithLastMessage } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot";
@@ -24,7 +25,11 @@ type Props = {
*/
export default function BotRow({ bot, selected, subtitle }: Props) {
const hue = (bot.hue || null) as BotHue | null;
const snippet = subtitle ?? bot.last_message_text ?? bot.role ?? "";
const snippet =
subtitle ??
(bot.last_message_text ? messagePreview({ text: bot.last_message_text }) : null) ??
bot.role ??
"";
const at = bot.last_message_at || bot.last_activity_at;
return (
+61
View File
@@ -0,0 +1,61 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import { logError } from "@/src/functions/backend/logger";
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
import relayChangedBots from "./relay-changed-bots";
import relayNewBotMessages from "./relay-new-bot-messages";
import relayNewJobUpdates from "./relay-new-job-updates";
import type { LiveRelayCursor } from "./types";
/**
* Keeps this process's sockets current with work done anywhere: every
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps and
* changed bots from the database and pushes them to their owners'
* sockets here. The process that did the work pushes at once
* (`pushBotNotice`); this catches the rest, e.g. a job run by another
* server on the same database. Clients ignore what they already have.
* Runs in every server process, ungated.
*/
export default async function liveRelay() {
const latest = async (table: "bot_messages" | "bots_queue_updates") =>
Number(
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT MAX(id) AS id FROM ${table}`,
})
).payload?.[0]?.id,
) || 0;
const cursor: LiveRelayCursor = {
message_id: await latest("bot_messages"),
update_id: await latest("bots_queue_updates"),
bots_at: Date.now(),
};
let running = false;
setInterval(async () => {
if (running) return;
running = true;
try {
const users = grabConnectedUserIds();
const now = Date.now();
cursor.message_id = await relayNewBotMessages({ after_id: cursor.message_id, users });
cursor.update_id = await relayNewJobUpdates({ after_id: cursor.update_id, users });
/**
* Overlap one interval, so a change written in the same
* millisecond as the last look isn't missed.
*/
await relayChangedBots({ since: cursor.bots_at - AppData.LiveRelayPollMs, users });
cursor.bots_at = now;
} catch (error) {
await logError({ title: `Live relay failed`, error }).catch(() => {});
} finally {
running = false;
}
}, AppData.LiveRelayPollMs);
}
+47
View File
@@ -0,0 +1,47 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import type { BotUpdatedData } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
/**
* Unix ms: bots updated since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* Pushes `server:bot-updated` (face, status line, active task) for every
* bot of a connected user that changed since `since`. Pushes are
* idempotent, so overlapping windows only repeat them.
*/
export default async function relayChangedBots({ since, users }: Params) {
if (!users.size) return;
const ids = [...users];
const bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, user_id, face, status_line, computer_id FROM bots
WHERE updated_at > ? AND user_id IN (${ids.map(() => "?").join(", ")})`,
values: [since, ...ids],
})
).payload || [];
for (const bot of bots) {
if (!bot.id || !bot.user_id) continue;
const data: BotUpdatedData = {
id: bot.id,
face: bot.face,
status_line: bot.status_line || "",
task: await grabActiveBotTask({ bot_id: bot.id }),
computer_id: bot.computer_id || null,
};
sendToUser({ user_id: bot.user_id, data: { event: "server:bot-updated", data } });
}
}
+31
View File
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
after_id: number;
users: Set<string>;
};
/**
* Pushes `server:bot-message` for every message newer than `after_id` to
* its owner's sockets on this process. Returns the newest id read.
*/
export default async function relayNewBotMessages({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES & { user_id: number }>({
sql: `SELECT m.*, b.user_id FROM bot_messages m JOIN bots b ON b.id = m.bot_id
WHERE m.id > ? ORDER BY m.id ASC LIMIT 500`,
values: [after_id],
})
).payload || [];
for (const { user_id, ...message } of rows) {
if (users.has(String(user_id))) {
sendToUser({ user_id, data: { event: "server:bot-message", data: message } });
}
}
return Number(rows[rows.length - 1]?.id) || after_id;
}
+33
View File
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotJobUpdateData } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
after_id: number;
users: Set<string>;
};
/**
* Pushes `server:bot-job-update` for every job step newer than `after_id`
* to its owner's sockets on this process. Returns the newest id read.
*/
export default async function relayNewJobUpdates({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BotJobUpdateData & { user_id: number }>({
sql: `SELECT u.*, q.task_id, b.user_id FROM bots_queue_updates u
JOIN bots_queue q ON q.id = u.bots_queue_id
JOIN bots b ON b.id = u.bot_id
WHERE u.id > ? ORDER BY u.id ASC LIMIT 500`,
values: [after_id],
})
).payload || [];
for (const { user_id, ...update } of rows) {
if (users.has(String(user_id))) {
sendToUser({ user_id, data: { event: "server:bot-job-update", data: update } });
}
}
return Number(rows[rows.length - 1]?.id) || after_id;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* How far the relay has read: the newest message and job step it pushed,
* and when it last looked for changed bots.
*/
export type LiveRelayCursor = {
message_id: number;
update_id: number;
bots_at: number;
};
@@ -0,0 +1,11 @@
import React from "react";
import type { BotComputers } from "../(hooks)/use-bot-computers";
/**
* The open bot's computers and the switch, shared by the header's
* computer select and the computer buttons in its messages.
*/
export const BotComputersContext = React.createContext<BotComputers>({
computers: [],
switchComputer: async () => {},
});
@@ -0,0 +1,70 @@
import type { Components } from "react-markdown";
import Divider from "@moduletrace/twui/layout/Divider";
import H3 from "@moduletrace/twui/layout/H3";
import H4 from "@moduletrace/twui/layout/H4";
import H5 from "@moduletrace/twui/layout/H5";
import P from "@moduletrace/twui/layout/P";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import MarkdownCodeBlock from "../(partials)/markdown-code-block";
import MarkdownLink from "../(partials)/markdown-link";
import MarkdownList from "../(partials)/markdown-list";
const HeadingClassName = "my-0! text-[15px]! leading-6! tracking-normal! font-bold text-ink";
/**
* How a bot message's markdown renders: chat-sized twui elements (headings
* no bigger than the bot's name), lists through twui's `List`, code on the
* sunken surface, and links that open in a new tab or switch computers.
* Tables (GFM) have no children-based twui equivalent, so they're styled
* elements.
*/
export const MessageMarkdownComponents: Components = {
p: ({ children }) => (
<P noMargin className="text-sm leading-5 text-ink">
{children}
</P>
),
h1: ({ children }) => <H3 className={HeadingClassName}>{children}</H3>,
h2: ({ children }) => <H3 className={HeadingClassName}>{children}</H3>,
h3: ({ children }) => <H4 className={HeadingClassName}>{children}</H4>,
h4: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
h5: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
h6: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
strong: ({ children }) => <Span className="font-bold">{children}</Span>,
em: ({ children }) => <Span className="italic">{children}</Span>,
del: ({ children }) => <Span className="line-through">{children}</Span>,
a: MarkdownLink,
ul: ({ children }) => <MarkdownList>{children}</MarkdownList>,
ol: ({ children, start }) => (
<MarkdownList ordered start={start}>
{children}
</MarkdownList>
),
code: ({ children }) => (
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink break-words">
{children}
</Span>
),
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
blockquote: ({ children }) => (
<Stack className="gap-2! items-stretch pl-3 border-0 border-l-2 border-solid border-hairline-strong text-ink-secondary">
{children}
</Stack>
),
hr: () => <Divider className="my-1 border-hairline dark:border-hairline" />,
img: ({ src, alt }) => (
<MarkdownLink href={typeof src == "string" ? src : undefined}>{alt || "Image"}</MarkdownLink>
),
table: ({ children }) => (
<Stack className="w-full max-w-full overflow-x-auto items-start">
<table className="text-[13px] leading-5 text-ink border-collapse">{children}</table>
</Stack>
),
th: ({ children }) => (
<th className="px-3 py-1.5 text-left font-bold border border-solid border-hairline">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 align-top border border-solid border-hairline">{children}</td>
),
};
@@ -0,0 +1,18 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { SetBotComputerRequest } from "@/src/schemas/bot-schema";
/**
* Switches the computer a bot works on. Returns its computers, the new
* one first.
*/
export default async function setBotComputer({ bot_id, computer_id }: SetBotComputerRequest) {
return await fetchApi<
SetBotComputerRequest,
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
>(`/api/admin/bots/set-computer`, {
method: "POST",
body: { bot_id, computer_id },
});
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotUpdatedData } from "@/src/types";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import setBotComputer from "../(functions)/set-bot-computer";
export type BotComputers = {
/**
* The bot's computers, the one it's on first.
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The computer being switched to, while the switch runs.
*/
switching_id?: number;
error?: string;
switchComputer: (params: { computer_id: number }) => Promise<void>;
};
type Params = {
bot_id: number;
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
/**
* Keeps the open bot's computers and switches the one it's on, in place:
* no reload, the list comes back reordered. A switch made in another tab
* arrives as `server:bot-updated` with the bot's `computer_id`.
*/
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
const [computers, setComputers] = useState(initial);
const [switching_id, setSwitchingId] = useState<number>();
const [error, setError] = useState<string>();
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) => {
const computer_id = data.computer_id;
if (data.id != bot_id || !computer_id) return;
setComputers((prev) => {
const picked = prev.find((c) => c.id == computer_id);
return !picked || prev[0]?.id == computer_id
? prev
: [picked, ...prev.filter((c) => c.id != computer_id)];
});
},
});
const switchComputer = async ({ computer_id }: { computer_id: number }) => {
if (switching_id || computers[0]?.id == computer_id) return;
setSwitchingId(computer_id);
setError(undefined);
const res = await setBotComputer({ bot_id, computer_id }).catch(() => null);
if (res?.success && res.payload?.length) {
setComputers(res.payload);
} else {
setError(res?.msg || "Couldn't switch computers");
}
setSwitchingId(undefined);
};
return { computers, switching_id, error, switchComputer };
}
@@ -0,0 +1,50 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check } from "lucide-react";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
type Props = {
computer: BUN_SQLITE_COBBLE_COMPUTERS;
/**
* The computer the bot is on.
*/
current: boolean;
onPick: () => void;
};
/**
* One computer in the header's computer select, checked when it's the
* one the bot is on.
*/
export default function BotComputerOption({ computer, current, onPick }: Props) {
return (
<Row
role="menuitemradio"
aria-checked={current}
tabIndex={0}
onClick={current ? undefined : onPick}
onKeyDown={(e) => {
if (!current && (e.key == "Enter" || e.key == " ")) {
e.preventDefault();
onPick();
}
}}
className={`w-full min-h-9 gap-2.5! flex-nowrap items-center px-4 py-1.5 text-ink transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink ${
current ? "cursor-default" : "cursor-pointer hover:bg-sunken"
}`}
>
<Check size={16} aria-hidden className={`shrink-0 ${current ? "" : "invisible"}`} />
<Stack className="min-w-0 gap-0!">
<Span className="text-sm leading-5 text-ink truncate w-full">
{computer.name || `Computer ${computer.id}`}
</Span>
{computer.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
{computer.description}
</Span>
) : null}
</Stack>
</Row>
);
}
@@ -0,0 +1,90 @@
import { useContext, useState } from "react";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import Paper from "@moduletrace/twui/elements/Paper";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronDown, Monitor } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
type Props = {
bot: BotWithLastMessage;
};
/**
* The header's computer button, beside the model button: shows the
* computer the bot is on and, with more than one, switches it.
*/
export default function BotComputerSelect({ bot }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
if (!current?.id) return null;
const name = bot.name || "This bot";
const can_switch = computers.length > 1;
const target = (
<Button
title={
can_switch
? `${name} is on ${current.name}. Switch computers`
: `${name} is on ${current.name}, its only computer`
}
type="button"
variant="outlined"
color="gray"
size="small"
disabled={!can_switch}
loading={Boolean(switching_id)}
buttonContentProps={{ className: "min-w-0" }}
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
>
<Monitor size={16} aria-hidden className="shrink-0 -mt-0.5" />
<Span className="truncate">{current.name || "Computer"}</Span>
{can_switch ? <ChevronDown size={14} aria-hidden className="shrink-0" /> : null}
</Button>
);
if (!can_switch) {
return <Stack className="max-w-[200px] min-w-0 shrink">{target}</Stack>;
}
return (
<Dropdown
target={target}
position="bottom-right"
externalOpen={open}
externalSetOpen={setOpen}
targetWrapperProps={{ className: "max-w-full min-w-0" }}
className="max-w-[200px] min-w-0 shrink items-stretch"
>
<Paper
role="menu"
aria-label={`${name}'s computers`}
className="w-[260px] p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
>
{computers.map((computer, index) => (
<BotComputerOption
key={computer.id}
computer={computer}
current={index == 0}
onPick={async () => {
setOpen(false);
await switchComputer({ computer_id: computer.id || 0 });
}}
/>
))}
{error ? (
<Span role="alert" className="px-4 py-2 text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Paper>
</Dropdown>
);
}
@@ -1,5 +1,8 @@
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import NeedsYouChip from "./needs-you-chip";
type Props = {
@@ -7,12 +10,19 @@ type Props = {
needs_you?: boolean;
};
/**
* One bot message, rendered from markdown (GFM; raw HTML is never
* rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them.
*/
export default function BotMessageLine({ text, needs_you }: Props) {
return (
<Stack className="gap-1.5! items-start max-w-full">
<P noMargin className="text-sm leading-5 text-ink whitespace-pre-wrap break-words">
{text}
</P>
<Stack className="w-full gap-2.5! items-start break-words">
<Markdown remarkPlugins={[remarkGfm]} components={MessageMarkdownComponents}>
{linkComputerRefs({ text })}
</Markdown>
</Stack>
{needs_you ? <NeedsYouChip /> : null}
</Stack>
);
@@ -49,6 +49,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
color="gray"
size="small"
disabled={!can_edit}
buttonContentProps={{ className: "min-w-0" }}
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
>
<CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" />
@@ -61,7 +62,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
if (!can_edit) {
return (
<Stack className="ml-auto max-w-[40%] min-w-0 shrink">
<Stack className="max-w-[280px] min-w-0 shrink">
{target}
</Stack>
);
@@ -71,7 +72,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
<Modal
target={target}
targetWrapperProps={{
className: "ml-auto max-w-[40%] min-w-0 shrink",
className: "max-w-[280px] min-w-0 shrink",
}}
open={open}
setOpen={setOpen}
@@ -0,0 +1,56 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import { Check, Monitor } from "lucide-react";
import { BotComputersContext } from "../(data)/bot-computers-context";
type Props = {
computer_id: number;
};
/**
* A computer the bot named in a message (`computerRef`): a button that
* switches the bot to it at once, or a checked chip when it's already on
* it. A computer the bot can't use (any more) is shown by name only.
*/
export default function ComputerRefChip({ computer_id }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const index = computers.findIndex((c) => c.id == computer_id);
const computer = computers[index];
const name = computer?.name || `computer ${computer_id}`;
if (!computer) {
return <Span className="font-bold">{name}</Span>;
}
if (index == 0) {
return (
<Span
title="The computer this bot is on"
className="inline-flex h-6 gap-1.5 items-center px-2 align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap"
>
<Monitor size={14} aria-hidden />
{name}
<Check size={14} aria-hidden />
</Span>
);
}
return (
<Button
type="button"
variant="outlined"
color="gray"
size="smaller"
title={error && switching_id == undefined ? error : `Switch to ${name}`}
loading={switching_id == computer_id}
disabled={Boolean(switching_id)}
onClick={() => switchComputer({ computer_id })}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
>
<Monitor size={14} aria-hidden className="shrink-0" />
{name}
</Button>
);
}
@@ -6,6 +6,7 @@ import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import { crewStatusSentence } from "@/src/utils/crew-status";
import BotComputerSelect from "./bot-computer-select";
import BotModelsModal from "./bot-models-modal";
type Props = {
@@ -19,8 +20,9 @@ type Props = {
/**
* 64px header. The coordinator's status is the crew sentence. On the
* right, the model button that opens the bot's models (the defaults on
* the coordinator, for super admins).
* right, the computer the bot is on (`BotComputerSelect`), then the model
* button that opens the bot's models (the defaults on the coordinator,
* for super admins).
*/
export default function ConversationHeader({ bot, bots, harnesses }: Props) {
const status =
@@ -43,7 +45,10 @@ export default function ConversationHeader({ bot, bots, harnesses }: Props) {
{status}
</Span>
</Stack>
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
<Row className="ml-auto min-w-0 shrink gap-2! flex-nowrap justify-end">
<BotComputerSelect bot={bot} />
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
</Row>
</Row>
);
}
@@ -0,0 +1,26 @@
import { isValidElement, type ReactNode } from "react";
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
/**
* A fenced code block in a bot message, on the sunken surface with
* twui's copy button (its icon is drawn white; recoloured to the theme).
*/
export default function MarkdownCodeBlock({ children }: { children?: ReactNode }) {
const code = isValidElement(children)
? (children.props as { children?: ReactNode }).children
: children;
return (
<CodeBlock
singleBlock
backgroundColor="var(--color-sunken)"
wrapperProps={{
className:
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
}}
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10"
>
{code}
</CodeBlock>
);
}
@@ -0,0 +1,27 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerRefHrefPrefix } from "@/src/utils/link-computer-refs";
import ComputerRefChip from "./computer-ref-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
const computer_id = Number(href.slice(ComputerRefHrefPrefix.length));
if (computer_id) return <ComputerRefChip computer_id={computer_id} />;
}
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline underline-offset-2 hover:opacity-80 break-words"
>
{children}
</Link>
);
}
@@ -0,0 +1,29 @@
import { Children, isValidElement, type ReactNode } from "react";
import List from "@moduletrace/twui/layout/List";
type Props = {
children?: ReactNode;
ordered?: boolean;
start?: number;
};
/**
* A list in a bot message, through twui's `List` (which takes items, not
* `li` children): each item's content is lifted out of its `li`.
*/
export default function MarkdownList({ children, ordered, start }: Props) {
const items = Children.toArray(children)
.filter(isValidElement)
.map((item) => ({
content: (item.props as { children?: ReactNode }).children,
}));
return (
<List
ordered={ordered}
start={start}
items={items}
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
/>
);
}
@@ -5,12 +5,16 @@ import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context";
import useBotComputers from "../(hooks)/use-bot-computers";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
/**
* The 900px conversation column: header, bottom-anchored thread, composer.
* The bot's computers are shared with the header's select and the computer
* buttons in the thread, so a switch from either shows in both.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -22,6 +26,10 @@ export default function Conversation() {
const bots = useLiveBots({ bots: pageProps?.bots || [], open_bot_id: bot?.id || undefined });
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
const bot_computers = useBotComputers({
bot_id: bot?.id || 0,
computers: pageProps?.bot_computers || [],
});
useServerEvent({
event: "server:bot-message",
@@ -44,21 +52,23 @@ export default function Conversation() {
if (!bot?.id || !live_bot) return null;
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader
bot={live_bot}
bots={bots}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread bot={live_bot} messages={messages} />
<Composer
bot={bot}
onSent={({ message }) =>
setMessages((prev) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
)
}
/>
</Stack>
<BotComputersContext.Provider value={bot_computers}>
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader
bot={live_bot}
bots={bots}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread bot={live_bot} messages={messages} />
<Composer
bot={bot}
onSent={({ message }) =>
setMessages((prev) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
)
}
/>
</Stack>
</BotComputersContext.Provider>
);
}
@@ -4,13 +4,17 @@ import Span from "@moduletrace/twui/layout/Span";
import H2 from "@moduletrace/twui/layout/H2";
import { AppContext } from "@/src/pages/__root";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotUpdatedData } from "@/src/types";
import { AppData } from "@/src/data/app-data";
import type { BotJobUpdateData, BotUpdatedData } from "@/src/types";
import TaskStepRow from "../(partials)/task-step-row";
import OpenComputerButton from "../(partials)/open-computer-button";
/**
* 280px panel listing the steps the bot is taking, kept current by the
* active task each `server:bot-updated` push carries. Hidden below 1280px.
* active task each `server:bot-updated` push carries. Without planned
* steps it shows what the task's job has done (`bots_queue_updates`), the
* newest as in progress, appended live from `server:bot-job-update`.
* Hidden below 1280px.
*/
export default function WorkingOnPanel() {
const { pageProps } = useContext(AppContext);
@@ -24,6 +28,17 @@ export default function WorkingOnPanel() {
},
});
useServerEvent({
event: "server:bot-job-update",
onData: (data: BotJobUpdateData) => {
setTask((prev) =>
prev?.id && prev.id == data.task_id && !prev.updates.some((u) => u.id == data.id)
? { ...prev, updates: [...prev.updates, data].slice(-AppData.BotJobUpdatesShown) }
: prev,
);
},
});
return (
<Stack
className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline"
@@ -38,13 +53,21 @@ export default function WorkingOnPanel() {
{task.title}
</H2>
<Stack className="mt-4 gap-0! items-stretch">
{task.steps.map((step) => (
<TaskStepRow
key={step.id}
label={step.label || ""}
state={step.state || "pending"}
/>
))}
{task.steps.length
? task.steps.map((step) => (
<TaskStepRow
key={step.id}
label={step.label || ""}
state={step.state || "pending"}
/>
))
: task.updates.map((update, index) => (
<TaskStepRow
key={update.id}
label={update.text || ""}
state={index == task.updates.length - 1 ? "active" : "done"}
/>
))}
</Stack>
</>
) : (
@@ -3,6 +3,7 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
import type { PagePropsType, PageQueryObject } from "@/src/types";
@@ -40,13 +41,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
* The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers).
*/
const [bot_messages, bot_task, bot_harness_overview] = await Promise.all([
const [bot_messages, bot_task, bot_harness_overview, bot_computers] = await Promise.all([
grabBotMessages({ bot_id: bot.id }),
grabActiveBotTask({ bot_id: bot.id }),
(bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id: user.id }),
]);
return {
@@ -56,6 +58,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_messages,
bot_task,
bot_harness_overview,
bot_computers,
},
};
};
+80
View File
@@ -0,0 +1,80 @@
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 setBotComputer from "@/src/functions/backend/db/computers/set-bot-computer";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetBotComputerRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Switches the computer one of the session user's bots works on, and
* tells the user's other tabs. Returns the bot's computers, the new one
* first.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, computer_id } = parseWithSchema({
schema: SetBotComputerRequestSchema,
data: body,
});
const { bot, computer, computers } = await setBotComputer({
bot_id,
computer_id,
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 logEvent({
title: "Bot computer switched",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} switched ${bot.name} to ${computer.name || `computer #${computer.id}`}.`,
metadata: { bot_id: bot.id, computer_id: computer.id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `${bot.name} is on ${computer.name || "that computer"} now.`,
payload: computers,
};
} catch (error: unknown) {
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
+7
View File
@@ -18,6 +18,13 @@ export const BotIdRequestSchema = z.object({
export type BotIdRequest = z.infer<typeof BotIdRequestSchema>;
export const SetBotComputerRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
computer_id: z.coerce.number().int().positive("Pick a computer"),
});
export type SetBotComputerRequest = z.infer<typeof SetBotComputerRequestSchema>;
export const UploadBotAttachmentRequestSchema = z.object({
/**
* The file as a base64 data URL.
+3 -1
View File
@@ -6,6 +6,7 @@ import userAuth from "./functions/backend/auth/user-auth";
import type { WebSocketSession } from "./websocket/types";
import { logError, grabRequestIP } from "./functions/backend/logger";
import botsQueue from "./bots-queue";
import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
declare global {}
@@ -78,4 +79,5 @@ bunext.bunextLog.info(
);
cron();
botsQueue();
botsQueue();
liveRelay();
+27
View File
@@ -4,6 +4,8 @@ import type {
BUN_SQLITE_COBBLE_BOT_TASK_STEPS,
BUN_SQLITE_COBBLE_BOT_TASKS,
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES,
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_USER_TYPES,
BUN_SQLITE_COBBLE_USERS,
@@ -49,6 +51,18 @@ export type BotWithLastMessage = BUN_SQLITE_COBBLE_BOTS & {
*/
export type BotTaskWithSteps = BUN_SQLITE_COBBLE_BOT_TASKS & {
steps: BUN_SQLITE_COBBLE_BOT_TASK_STEPS[];
/**
* What its newest job has done so far, oldest first (`grabBotJobUpdates`).
*/
updates: BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES[];
};
/**
* `server:bot-job-update` data: a step a running job just took, with the
* task it's for (null for a reply).
*/
export type BotJobUpdateData = BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & {
task_id: number | null;
};
/**
@@ -60,6 +74,11 @@ export type BotUpdatedData = {
face: BUN_SQLITE_COBBLE_BOTS["face"];
status_line: string;
task: BotTaskWithSteps | null;
/**
* 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;
};
/**
@@ -255,6 +274,10 @@ export type PagePropsType = {
bot?: BotWithLastMessage | null;
bot_messages?: BotMessageWithAttachments[] | null;
bot_task?: BotTaskWithSteps | null;
/**
* The open bot's computers, the one it's on first.
*/
bot_computers?: BUN_SQLITE_COBBLE_COMPUTERS[] | null;
environment?: string;
has_password?: boolean;
backups?: DbBackup[] | null;
@@ -420,6 +443,10 @@ export const WebSocketEvents = [
* A bot's face or status line changed (`data`: `BotUpdatedData`)
*/
"server:bot-updated",
/**
* A running job took a step (`data`: `BotJobUpdateData`)
*/
"server:bot-job-update",
] as const;
export type WebSocketDataType = {
+7
View File
@@ -0,0 +1,7 @@
/**
* How a bot names one of its computers in a message: `[computer:3]`. The
* chat shows it as a button that switches the bot to that computer.
*/
export default function computerRef({ id }: { id: number | string }) {
return `[computer:${id}]`;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
describe("linkComputerRefs", () => {
test("links every computer a message names", () => {
expect(
linkComputerRefs({
text: `Your GitHub session is on ${computerRef({ id: 3 })}, not ${computerRef({ id: 12 })}.`,
}),
).toBe("Your GitHub session is on [computer 3](#computer-3), not [computer 12](#computer-12).");
});
test("leaves look-alikes and existing links alone", () => {
const text = "See [computer:x], [link](url) and [computer:4](https://example.com)";
expect(linkComputerRefs({ text })).toBe(text);
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* The link target a computer ref (`computerRef`) becomes, e.g.
* `#computer-3`. The chat's markdown renders these links as buttons that
* switch the bot to that computer.
*/
export const ComputerRefHrefPrefix = "#computer-";
/**
* Turns every computer ref in a message into a markdown link to
* `#computer-<id>`, so it survives markdown rendering as a link the chat
* can swap for a switch button.
*/
export default function linkComputerRefs({ text }: { text: string }) {
return text.replace(
/\[computer:(\d+)\](?!\()/g,
(_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`,
);
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import messagePreview from "./message-preview";
describe("messagePreview", () => {
test("strips markdown and names computers plainly", () => {
expect(
messagePreview({
text: "## Found it\n\nYour **GitHub** session is on [computer:3]. See [the docs](https://x.y) and `search_computers`.\n\n- *one*\n- two",
}),
).toBe("Found it Your GitHub session is on a computer. See the docs and search_computers. one two");
});
test("keeps code text without its fences", () => {
expect(messagePreview({ text: "Run:\n```bash\nls ~\n```" })).toBe("Run: ls ~");
});
test("leaves plain text alone", () => {
expect(messagePreview({ text: "All done, 3 files_moved." })).toBe("All done, 3 files_moved.");
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* A bot message as one line of plain text, for the rail's preview: no
* markdown syntax, links as their text, computer refs (`computerRef`) as
* "a computer", whitespace collapsed.
*/
export default function messagePreview({ text }: { text: string }) {
return text
.replace(/```[^\n]*\n?/g, "")
.replace(/\[computer:\d+\](?!\()/g, "a computer")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, "")
.replace(/(\*\*|__|~~|`)/g, "")
.replace(/(^|\s)[*_](\S[^*_]*\S|\S)[*_](?=\s|$|[.,;:!?])/g, "$1$2")
.replace(/\|/g, " ")
.replace(/\s+/g, " ")
.trim();
}
@@ -0,0 +1,17 @@
import { getAllRooms } from "../(data)/rooms";
/**
* The users with an open socket on this process (every socket joins
* `public` on open).
*/
export default function grabConnectedUserIds() {
const ids = new Set<string>();
for (const ws of getAllRooms().get("public") || []) {
if (ws.data.user?.id && ws.readyState === WebSocket.OPEN) {
ids.add(String(ws.data.user.id));
}
}
return ids;
}