Add workspaces, computers, bot memory and the bots queue

- Schema: workspaces (+ workspace_bots, workspace_computers), containers,
  computers, bot_computers, bots.memory, workspaces.system_prompt, and
  bots_queue as a bun-sqlite isQueue table
- Main computer (and its container record) ensured with the coordinator on
  signup, seed and /admin/bots; the coordinator's access to it is locked
- Effective computer access: direct grants plus workspace computers
- Full bot prompt: core instructions, workspace prompts, then memory;
  bots can rewrite their memory and system prompt
- Queue helpers on the native BunSQLite.queue API, plus a handler that
  heartbeats, serializes per computer, recovers after restarts, reports
  lost jobs, supports cancelling and pushes outcomes over the WebSocket
- Enable SQLite foreign keys at startup so schema cascades fire
- Prune finished jobs daily

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 18:33:54 +01:00
co-authored by Claude Opus 5.5
parent 694c83346f
commit d49f524fe8
72 changed files with 2961 additions and 31 deletions
@@ -9,7 +9,7 @@ type Params = {
/**
* Returns the id of the user's coordinator, creating it first (with the
* default `CoordinatorSystemPrompt`) if the workspace has none. A
* default `CoordinatorSystemPrompt`) if the user has none. A
* coordinator without a prompt gets the default; an edited prompt is never
* overwritten. Safe to call on every visit.
*/
@@ -0,0 +1,39 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* The full prompt for one of the user's bots: its core instructions, the
* system prompt of every non-archived workspace it's in, and its memory.
*/
export default async function grabBotPrompt({ bot_id, user_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const workspaces =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT w.name, w.system_prompt FROM workspaces w
JOIN workspace_bots wb ON wb.workspace_id = w.id
WHERE wb.bot_id = ? AND w.user_id = ? AND w.archived = 0
ORDER BY w.id ASC`,
values: [bot.id, user_id],
})
).payload || [];
return composeBotPrompt({
system_prompt: bot.system_prompt,
memory: bot.memory,
workspaces,
});
}
+1 -1
View File
@@ -6,7 +6,7 @@ type Params = {
};
/**
* A workspace's crew for the rail: the coordinator pinned first, then
* A user's crew for the rail: the coordinator pinned first, then
* workers by latest activity. Each row carries its latest message.
*/
export default async function grabBots({
@@ -7,7 +7,7 @@ type Params = {
};
/**
* A bot only if it belongs to the given workspace owner.
* A bot only if it belongs to the given owner.
*/
export default async function grabUserBot({
bot_id,
@@ -0,0 +1,53 @@
import type {
BUN_SQLITE_COBBLE_BOT_MESSAGES,
BunSQLiteTables,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number;
text: string;
needs_you?: boolean;
};
/**
* Appends a bot message to its conversation, marks the bot unread and
* bumps its activity. Callers must check the bot first.
*/
export default async function insertBotMessage({
bot_id,
text,
needs_you,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const now = Date.now();
const needs_you_flag = needs_you ? 1 : 0;
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_MESSAGES,
(typeof BunSQLiteTables)[number]
>({
table: "bot_messages",
data: [{ bot_id, author: "bot", text, needs_you: needs_you_flag }],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Failed to save message: ${res.msg}`);
}
await BunSQLite.sql({
sql: `UPDATE bots SET last_activity_at = ?, unread = 1 WHERE id = ?`,
values: [now, bot_id],
});
return {
id,
bot_id,
author: "bot",
text,
needs_you: needs_you_flag,
created_at: now,
updated_at: now,
};
}
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
face: BotFace;
status_line?: string;
};
/**
* Sets a bot's face (its status) and status line. Callers must check the
* bot first.
*/
export default async function setBotFace({
bot_id,
face,
status_line = "",
}: Params) {
await BunSQLite.sql({
sql: `UPDATE bots SET face = ?, status_line = ?, updated_at = ? WHERE id = ?`,
values: [face, status_line, Date.now(), bot_id],
});
return { id: Number(bot_id), face, status_line };
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The whole new memory (markdown), replacing the old one.
*/
memory: string;
};
/**
* Replaces a bot's memory. The bot calls this as it learns what it must
* keep in mind; the user can edit it too.
*/
export default async function updateBotMemory({
user_id,
bot_id,
memory,
}: Params) {
assertPromptLength({ label: "Memory", text: memory });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
await BunSQLite.sql({
sql: `UPDATE bots SET memory = ?, updated_at = ? WHERE id = ?`,
values: [memory, Date.now(), bot.id],
});
}
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The whole new core instructions (markdown), replacing the old ones.
*/
system_prompt: string;
};
/**
* Replaces a bot's core instructions. The bot calls this to adapt how it
* works to the user's workflow; the user can edit it too.
*/
export default async function updateBotSystemPrompt({
user_id,
bot_id,
system_prompt,
}: Params) {
if (!system_prompt.trim()) {
throw new AppError(`A bot's core instructions can't be empty.`);
}
assertPromptLength({ label: "System prompt", text: system_prompt });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
await BunSQLite.sql({
sql: `UPDATE bots SET system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [system_prompt, Date.now(), bot.id],
});
}