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:
@@ -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],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user