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
@@ -0,0 +1,32 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabUserComputer from "./grab-user-computer";
type Params = {
user_id: number | string;
bot_id: number | string;
computer_id: number | string;
};
/**
* Gives one of the user's bots access to one of their computers. Adding a
* bot that already has access is a no-op.
*/
export default async function addBotToComputer({
user_id,
bot_id,
computer_id,
}: Params) {
const bot = await grabUserBot({ bot_id, user_id });
const computer = await grabUserComputer({ computer_id, user_id });
if (!bot?.id || !computer?.id) {
throw new AppError(`Bot or computer not found.`);
}
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_computers (bot_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
values: [bot.id, computer.id, Date.now(), Date.now()],
});
}
@@ -0,0 +1,60 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_CONTAINERS,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
/**
* Returns the id of the main computer's container record, creating it if
* missing. The container itself is created at install, named
* `AppData.MainComputerContainerName` on the `AppData.DockerNetworkName`
* network; this only records it. Safe to call on every visit.
*/
export default async function ensureMainComputerContainer() {
const name = AppData.MainComputerContainerName;
const existing = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_CONTAINERS,
(typeof BunSQLiteTables)[number]
>({
table: "containers",
query: {
query: {
name: { value: name },
},
limit: 1,
},
})
).payload?.[0];
if (existing?.id) {
return existing.id;
}
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_CONTAINERS,
(typeof BunSQLiteTables)[number]
>({
table: "containers",
data: [
{
name,
purpose: "computer",
network: AppData.DockerNetworkName,
hostname: name,
port: AppData.ComputerPort,
status: "unknown",
},
],
});
const container_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !container_id) {
throw new Error(`Couldn't record the main computer's container: ${res.msg}`);
}
return container_id;
}
@@ -0,0 +1,85 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BunSQLiteTables,
} from "@/db/types/db";
import { MainComputerName } from "@/src/dict/computer-dict";
import ensureMainComputerContainer from "./ensure-main-computer-container";
type Params = {
user_id: number | string;
/**
* The owner's coordinator (from `ensureCoordinatorBot`). It always has
* access to the main computer.
*/
coordinator_id: number | string;
};
/**
* Returns the id of the user's main computer, recording it (and its
* container) first if missing, and makes sure the coordinator has access
* to it. Safe to call on every visit.
*/
export default async function ensureMainComputer({
user_id,
coordinator_id,
}: Params) {
const computer_id = await grabOrCreateMainComputer({ user_id });
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_computers (bot_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
values: [coordinator_id, computer_id, Date.now(), Date.now()],
});
return computer_id;
}
async function grabOrCreateMainComputer({
user_id,
}: Pick<Params, "user_id">) {
const existing = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
query: {
query: {
user_id: { value: user_id },
kind: { value: "main" },
},
order: { field: "id", strategy: "ASC" },
limit: 1,
},
})
).payload?.[0];
if (existing?.id) {
return existing.id;
}
const container_id = await ensureMainComputerContainer();
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
data: [
{
user_id: Number(user_id),
container_id: Number(container_id),
name: MainComputerName,
kind: "main",
},
],
});
const computer_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !computer_id) {
throw new Error(`Couldn't create the main computer: ${res.msg}`);
}
return computer_id;
}
@@ -0,0 +1,35 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* 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.
*/
export default async function grabBotComputers({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
WHERE c.user_id = ?
AND (
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
JOIN workspaces w ON w.id = wc.workspace_id
WHERE wb.bot_id = ? AND w.archived = 0
)
)
ORDER BY c.kind = 'main' DESC, c.id ASC`,
values: [user_id, bot_id, bot_id],
});
return res.payload || [];
}
@@ -0,0 +1,22 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
computer_id: number | string;
user_id: number | string;
};
/**
* A computer only if it belongs to the given owner.
*/
export default async function grabUserComputer({
computer_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT * FROM computers WHERE id = ? AND user_id = ? LIMIT 1`,
values: [computer_id, user_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import isLockedComputerAccess from "@/src/utils/is-locked-computer-access";
import grabUserBot from "../bots/grab-user-bot";
import grabUserComputer from "./grab-user-computer";
type Params = {
user_id: number | string;
bot_id: number | string;
computer_id: number | string;
};
/**
* Takes a bot's access to a computer away. The coordinator's access to the
* main computer can't be removed.
*/
export default async function removeBotFromComputer({
user_id,
bot_id,
computer_id,
}: Params) {
const bot = await grabUserBot({ bot_id, user_id });
const computer = await grabUserComputer({ computer_id, user_id });
if (!bot?.id || !computer?.id) {
throw new AppError(`Bot or computer not found.`);
}
if (
isLockedComputerAccess({
bot_kind: bot.kind,
computer_kind: computer.kind,
})
) {
throw new AppError(
`The coordinator always has access to the main computer.`,
);
}
await BunSQLite.sql({
sql: `DELETE FROM bot_computers WHERE bot_id = ? AND computer_id = ?`,
values: [bot.id, computer.id],
});
}