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,41 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import { BotJobCancelledError } from "@/src/dict/bot-queue-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
job_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels one of the user's pending or running jobs. It's marked failed
|
||||
* with `BotJobCancelledError`; a running handler finds out on its next
|
||||
* `updateBotJob` (which returns false) and stops. Its task is closed.
|
||||
*/
|
||||
export default async function cancelBotJob({ user_id, job_id }: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `UPDATE bots_queue SET status = 'failed', last_error = ?, locked_at = NULL, locked_by = NULL, finished_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'processing')
|
||||
AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)
|
||||
RETURNING *`,
|
||||
values: [BotJobCancelledError, now, now, job_id, user_id],
|
||||
});
|
||||
|
||||
const job = res.payload?.[0];
|
||||
|
||||
if (!job?.id) {
|
||||
throw new AppError(`No pending or running job to cancel.`);
|
||||
}
|
||||
|
||||
if (job.task_id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_tasks SET status = 'done', updated_at = ? WHERE id = ?`,
|
||||
values: [now, job.task_id],
|
||||
});
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Unique id of the handler claiming, e.g. `main:<pid>`. Every later
|
||||
* update, completion or failure must pass the same id.
|
||||
*/
|
||||
worker: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claims the next pending jobs for a queue handler. Jobs whose handler
|
||||
* stopped sending updates are released back to the queue first, so a lost
|
||||
* job is picked up again (resume from its `checkpoint`).
|
||||
*/
|
||||
export default async function claimBotJobs({ worker, limit = 1 }: Params) {
|
||||
const res = await BunSQLite.queue.claim<
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bots_queue",
|
||||
worker,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't claim jobs: ${res.msg || res.error}`);
|
||||
}
|
||||
|
||||
const jobs = res.payload || [];
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.id && !job.started_at) {
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET started_at = ? WHERE id = ?`,
|
||||
values: [now, job.id],
|
||||
});
|
||||
|
||||
job.started_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
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";
|
||||
|
||||
type Params = {
|
||||
job_id: number | string;
|
||||
worker: string;
|
||||
/**
|
||||
* The completion message for the user (markdown). Also kept as the
|
||||
* job's `result`.
|
||||
*/
|
||||
result: string;
|
||||
/**
|
||||
* Flag the message as needing the user.
|
||||
*/
|
||||
needs_you?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks a job done, closes its task and sends the completion message back
|
||||
* to the user in the bot's conversation. The bot shows done (or needs-you)
|
||||
* unless it has other jobs open. Throws if this handler no longer holds
|
||||
* the job.
|
||||
*/
|
||||
export default async function completeBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
result,
|
||||
needs_you,
|
||||
}: Params) {
|
||||
const job = await grabLockedBotJob({ job_id, worker });
|
||||
|
||||
if (!job?.id || !job.bot_id) {
|
||||
throw new AppError(`This job is no longer held by ${worker}.`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.queue.complete({
|
||||
table: "bots_queue",
|
||||
id: job.id,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't complete the job: ${res.msg || res.error}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET result = ?, finished_at = ? WHERE id = ?`,
|
||||
values: [result, now, job.id],
|
||||
});
|
||||
|
||||
if (job.task_id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_tasks SET status = 'done', updated_at = ? WHERE id = ?`,
|
||||
values: [now, job.task_id],
|
||||
});
|
||||
}
|
||||
|
||||
const message = await insertBotMessage({
|
||||
bot_id: job.bot_id,
|
||||
text: result,
|
||||
needs_you,
|
||||
});
|
||||
|
||||
const bot = needs_you
|
||||
? await setBotFace({
|
||||
bot_id: job.bot_id,
|
||||
face: "needs-you",
|
||||
status_line: "Waiting on you",
|
||||
})
|
||||
: (await countOpenBotJobs({ bot_id: job.bot_id }))
|
||||
? null
|
||||
: await setBotFace({ bot_id: job.bot_id, face: "done" });
|
||||
|
||||
return { message, bot };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many of a bot's jobs are still pending or processing.
|
||||
*/
|
||||
export default async function countOpenBotJobs({ bot_id }: Params) {
|
||||
const res = await BunSQLite.sql<{ open: number }>({
|
||||
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')`,
|
||||
values: [bot_id],
|
||||
});
|
||||
|
||||
return Number(res.payload?.[0]?.open || 0);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_TASKS,
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBot from "../bots/grab-user-bot";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
title: string;
|
||||
/**
|
||||
* The brief the bot writes for itself (markdown): what to do and how
|
||||
* it knows it's done.
|
||||
*/
|
||||
instructions: string;
|
||||
params?: Record<string, unknown>;
|
||||
/**
|
||||
* The user message that asked for this work.
|
||||
*/
|
||||
message_id?: number | string;
|
||||
/**
|
||||
* The task for the working-on panel. One is created from `title` when
|
||||
* not given.
|
||||
*/
|
||||
task_id?: number | string;
|
||||
/**
|
||||
* Must be one of the bot's computers (direct or through a workspace).
|
||||
*/
|
||||
computer_id?: number | string;
|
||||
/**
|
||||
* Milliseconds to hold the job back before it can be claimed.
|
||||
*/
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Queues long-running work for one of the user's bots and returns the job
|
||||
* id. The bot replies to the user straight away (that it's on it); the
|
||||
* queue handler does the work and `completeBotJob` posts the result. Not
|
||||
* every message needs a job; quick answers are replied to directly.
|
||||
*/
|
||||
export default async function enqueueBotJob({
|
||||
user_id,
|
||||
bot_id,
|
||||
title,
|
||||
instructions,
|
||||
params,
|
||||
message_id,
|
||||
task_id,
|
||||
computer_id,
|
||||
delay,
|
||||
}: Params) {
|
||||
const bot = await grabUserBot({ bot_id, user_id });
|
||||
|
||||
if (!bot?.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.`);
|
||||
}
|
||||
|
||||
if (task_id && !(await belongsToBot({ table: "bot_tasks", id: task_id, bot_id: bot.id }))) {
|
||||
throw new AppError(`Task not found.`);
|
||||
}
|
||||
|
||||
if (computer_id) {
|
||||
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
|
||||
|
||||
if (!computers.some((c) => String(c.id) == String(computer_id))) {
|
||||
throw new AppError(`This bot has no access to that computer.`);
|
||||
}
|
||||
}
|
||||
|
||||
const final_task_id = task_id || (await createJobTask({ bot_id: bot.id, title }));
|
||||
|
||||
const res = await BunSQLite.queue.add<
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bots_queue",
|
||||
data: [
|
||||
{
|
||||
bot_id: bot.id,
|
||||
title,
|
||||
instructions,
|
||||
params: params ? JSON.stringify(params) : undefined,
|
||||
message_id: message_id ? Number(message_id) : undefined,
|
||||
task_id: Number(final_task_id),
|
||||
computer_id: computer_id ? Number(computer_id) : undefined,
|
||||
},
|
||||
],
|
||||
delay,
|
||||
});
|
||||
|
||||
const job_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !job_id) {
|
||||
throw new Error(`Couldn't queue the job: ${res.msg || res.error}`);
|
||||
}
|
||||
|
||||
return job_id;
|
||||
}
|
||||
|
||||
async function belongsToBot({
|
||||
table,
|
||||
id,
|
||||
bot_id,
|
||||
}: {
|
||||
table: "bot_messages" | "bot_tasks";
|
||||
id: number | string;
|
||||
bot_id: number;
|
||||
}) {
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `SELECT id FROM ${table} WHERE id = ? AND bot_id = ? LIMIT 1`,
|
||||
values: [id, bot_id],
|
||||
});
|
||||
|
||||
return Boolean(res.payload?.[0]);
|
||||
}
|
||||
|
||||
async function createJobTask({
|
||||
bot_id,
|
||||
title,
|
||||
}: {
|
||||
bot_id: number;
|
||||
title: string;
|
||||
}) {
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_BOT_TASKS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bot_tasks",
|
||||
data: [{ bot_id, title, status: "active" }],
|
||||
});
|
||||
|
||||
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !task_id) {
|
||||
throw new Error(`Couldn't create the job's task: ${res.msg}`);
|
||||
}
|
||||
|
||||
return task_id;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabLockedBotJob from "./grab-locked-bot-job";
|
||||
import notifyBotJobFailed from "./notify-bot-job-failed";
|
||||
|
||||
type Params = {
|
||||
job_id: number | string;
|
||||
worker: string;
|
||||
error: string;
|
||||
/**
|
||||
* Retrying won't help (bad parameters, missing access): fail now
|
||||
* instead of using the remaining attempts.
|
||||
*/
|
||||
fatal?: boolean;
|
||||
/**
|
||||
* What to tell the user if the job is out of attempts. Defaults to a
|
||||
* short note with the job title and error.
|
||||
*/
|
||||
failure_message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Records a failed attempt. The job goes back to the queue with a backoff
|
||||
* delay, or, once out of attempts (or `fatal`), is marked failed and the
|
||||
* bot tells the user it needs them. Returns the job's new status and the
|
||||
* failure notice, if one was sent.
|
||||
*/
|
||||
export default async function failBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
error,
|
||||
fatal,
|
||||
failure_message,
|
||||
}: Params) {
|
||||
const job = await grabLockedBotJob({ job_id, worker });
|
||||
|
||||
if (!job?.id || !job.bot_id) {
|
||||
throw new AppError(`This job is no longer held by ${worker}.`);
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET attempts = MAX(attempts, ?) WHERE id = ?`,
|
||||
values: [AppData.BotQueueMaxAttempts, job.id],
|
||||
});
|
||||
}
|
||||
|
||||
const res = await BunSQLite.queue.fail<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
table: "bots_queue",
|
||||
id: job.id,
|
||||
error,
|
||||
});
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
throw new Error(`Couldn't fail the job: ${res.msg || res.error}`);
|
||||
}
|
||||
|
||||
const status = res.singleRes.status;
|
||||
|
||||
if (status != "failed") {
|
||||
return { status, notice: null };
|
||||
}
|
||||
|
||||
const notice = await notifyBotJobFailed({
|
||||
job: res.singleRes,
|
||||
failure_message,
|
||||
});
|
||||
|
||||
return { status, notice };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
job_id: number | string;
|
||||
worker: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A job only while the given handler still holds it. Null once its lock
|
||||
* timed out and it went back to the queue (or another handler took it).
|
||||
*/
|
||||
export default async function grabLockedBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS_QUEUE | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `SELECT * FROM bots_queue WHERE id = ? AND status = 'processing' AND locked_by = ? LIMIT 1`,
|
||||
values: [job_id, worker],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
|
||||
/**
|
||||
* Failed jobs the user hasn't been told about: the ones bun-sqlite failed
|
||||
* itself after a lock timeout on their last attempt. Jobs failed through
|
||||
* `failBotJob` or cancelled already have `finished_at`.
|
||||
*/
|
||||
export default async function grabUnreportedFailedBotJobs() {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `SELECT * FROM bots_queue WHERE status = 'failed' AND finished_at IS NULL ORDER BY id ASC`,
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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";
|
||||
|
||||
type Params = {
|
||||
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
||||
failure_message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default async function notifyBotJobFailed({
|
||||
job,
|
||||
failure_message,
|
||||
}: Params) {
|
||||
if (!job.id || !job.bot_id) {
|
||||
throw new Error(`Job has no id or bot.`);
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET finished_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), job.id],
|
||||
});
|
||||
|
||||
const message = await insertBotMessage({
|
||||
bot_id: job.bot_id,
|
||||
text:
|
||||
failure_message ||
|
||||
[`I couldn't finish "${job.title || "the job"}".`, job.last_error]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
needs_you: true,
|
||||
});
|
||||
|
||||
const bot = await setBotFace({
|
||||
bot_id: job.bot_id,
|
||||
face: "needs-you",
|
||||
status_line: "Waiting on you",
|
||||
});
|
||||
|
||||
return { message, bot };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
worker: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Puts the jobs a handler was running back in the queue straight away,
|
||||
* instead of waiting out the lock timeout. For startup, after a restart
|
||||
* cut those jobs off. The cut-off attempt isn't counted.
|
||||
*/
|
||||
export default async function releaseWorkerBotJobs({ worker }: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET status = 'pending', attempts = MAX(attempts - 1, 0), available_at = ?, locked_at = NULL, locked_by = NULL, updated_at = ? WHERE status = 'processing' AND locked_by = ? RETURNING id`,
|
||||
values: [now, now, worker],
|
||||
});
|
||||
|
||||
return res.payload?.length || 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
job_id: number | string;
|
||||
worker: string;
|
||||
/**
|
||||
* Latest progress note (markdown), e.g. "Installed deps, running tests".
|
||||
*/
|
||||
progress?: string;
|
||||
/**
|
||||
* Whatever the bot needs to resume if the job is picked up again.
|
||||
*/
|
||||
checkpoint?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bot's "still working" update while it processes a job. Renews the
|
||||
* lock (so the job isn't presumed lost) and saves progress and a
|
||||
* checkpoint. Call it more often than `AppData.BotQueueLockTimeoutMs`.
|
||||
*
|
||||
* Returns false if this handler no longer holds the job: stop working on
|
||||
* it, since it has gone back to the queue.
|
||||
*/
|
||||
export default async function updateBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
progress,
|
||||
checkpoint,
|
||||
}: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET locked_at = ?, updated_at = ?, progress = COALESCE(?, progress), checkpoint = COALESCE(?, checkpoint) WHERE id = ? AND status = 'processing' AND locked_by = ? RETURNING id`,
|
||||
values: [
|
||||
now,
|
||||
now,
|
||||
progress ?? null,
|
||||
checkpoint ? JSON.stringify(checkpoint) : null,
|
||||
job_id,
|
||||
worker,
|
||||
],
|
||||
});
|
||||
|
||||
return Boolean(res.payload?.[0]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
/**
|
||||
* SQLite leaves foreign keys off per connection, so the schema's
|
||||
* `ON DELETE CASCADE` rules never fire unless this runs first. bun-sqlite
|
||||
* reuses one connection per process, so once at startup is enough.
|
||||
*/
|
||||
export default async function enableForeignKeys() {
|
||||
const res = await BunSQLite.sql({ sql: `PRAGMA foreign_keys = ON` });
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't enable foreign keys: ${res.msg}`);
|
||||
}
|
||||
}
|
||||
@@ -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 grabUserWorkspace from "./grab-user-workspace";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns one of the user's bots to one of their workspaces. Assigning a
|
||||
* bot that is already there is a no-op.
|
||||
*/
|
||||
export default async function addBotToWorkspace({
|
||||
user_id,
|
||||
workspace_id,
|
||||
bot_id,
|
||||
}: Params) {
|
||||
const bot = await grabUserBot({ bot_id, user_id });
|
||||
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||
|
||||
if (!bot?.id || !workspace?.id) {
|
||||
throw new AppError(`Bot or workspace not found.`);
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO workspace_bots (workspace_id, bot_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
values: [workspace.id, bot.id, Date.now(), Date.now()],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserComputer from "../computers/grab-user-computer";
|
||||
import grabUserWorkspace from "./grab-user-workspace";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns one of the user's computers to one of their workspaces, so every
|
||||
* bot in the workspace gets access to it. Assigning it twice is a no-op.
|
||||
*/
|
||||
export default async function addComputerToWorkspace({
|
||||
user_id,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
}: Params) {
|
||||
const computer = await grabUserComputer({ computer_id, user_id });
|
||||
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||
|
||||
if (!computer?.id || !workspace?.id) {
|
||||
throw new AppError(`Computer or workspace not found.`);
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO workspace_computers (workspace_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
values: [workspace.id, computer.id, Date.now(), Date.now()],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a workspace owned by the user and returns its id.
|
||||
*/
|
||||
export default async function createWorkspace({
|
||||
user_id,
|
||||
name,
|
||||
description,
|
||||
system_prompt,
|
||||
}: Params) {
|
||||
assertPromptLength({ label: "System prompt", text: system_prompt });
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "workspaces",
|
||||
data: [
|
||||
{
|
||||
user_id: Number(user_id),
|
||||
name,
|
||||
description,
|
||||
system_prompt,
|
||||
archived: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const workspace_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !workspace_id) {
|
||||
throw new Error(`Couldn't create the workspace: ${res.msg}`);
|
||||
}
|
||||
|
||||
return workspace_id;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
workspace_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace only if it belongs to the given owner.
|
||||
*/
|
||||
export default async function grabUserWorkspace({
|
||||
workspace_id,
|
||||
user_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_WORKSPACES | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
values: [workspace_id, user_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserWorkspace from "./grab-user-workspace";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a bot from one of the user's workspaces.
|
||||
*/
|
||||
export default async function removeBotFromWorkspace({
|
||||
user_id,
|
||||
workspace_id,
|
||||
bot_id,
|
||||
}: Params) {
|
||||
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||
|
||||
if (!workspace?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`,
|
||||
values: [workspace.id, bot_id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserWorkspace from "./grab-user-workspace";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a computer off one of the user's workspaces. Its bots keep any
|
||||
* access they were given directly.
|
||||
*/
|
||||
export default async function removeComputerFromWorkspace({
|
||||
user_id,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
}: Params) {
|
||||
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||
|
||||
if (!workspace?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM workspace_computers WHERE workspace_id = ? AND computer_id = ?`,
|
||||
values: [workspace.id, computer_id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||
import grabUserWorkspace from "./grab-user-workspace";
|
||||
import _ from "lodash";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
/**
|
||||
* Only the fields given are changed.
|
||||
*/
|
||||
data: Pick<
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
"name" | "description" | "system_prompt" | "archived"
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates one of the user's workspaces. Its `system_prompt` is shared
|
||||
* context for every bot in it.
|
||||
*/
|
||||
export default async function updateWorkspace({
|
||||
user_id,
|
||||
workspace_id,
|
||||
data,
|
||||
}: Params) {
|
||||
assertPromptLength({ label: "System prompt", text: data.system_prompt });
|
||||
|
||||
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||
|
||||
if (!workspace?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.update<
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "workspaces",
|
||||
targetId: workspace.id,
|
||||
/**
|
||||
* `BunSQLite.update` writes every key it gets, so a field left out
|
||||
* here would be set to null.
|
||||
*/
|
||||
data: _.omitBy(
|
||||
{
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
system_prompt: data.system_prompt,
|
||||
archived: data.archived,
|
||||
},
|
||||
_.isUndefined,
|
||||
),
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't update the workspace: ${res.msg}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user