Local, password-only auth: - No public pages; everything outside /admin and /auth redirects in - First run (no users) sends every page to /auth/signup, which creates the super admin (user #1) once and then locks - Login is email/username + password; removed the emailed-code flow, /auth/sso and email password reset - Google login kept for existing accounts only, never creates them - seed-super-admin now requires SEED_SUPER_ADMIN_PASSWORD Bots-first admin: - /admin and closed sections (people, logs, backups) redirect to /admin/bots; only bots and settings are live (AdminSections) - Removed the old admin layout; every /admin page uses the bots rail - Coordinator bot created at signup/seed and by /admin/bots (ensureCoordinatorBot), which redirects to its chat - bots.system_prompt (markdown) with a default coordinator prompt - seed-bots reuses the existing coordinator Settings: - Sub pages general (default), profile, change-password, listed in the rail with a back link; unknown settings paths redirect to general - Redesigned settings UI: pane header, grouped rows, sticky save bar - General: System/Light/Dark theme, saved per device Co-Authored-By: Claude Opus 5.5 <[email protected]>
364 lines
9.2 KiB
TypeScript
364 lines
9.2 KiB
TypeScript
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
import type {
|
|
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
|
BUN_SQLITE_COBBLE_BOT_TASK_STEPS,
|
|
BUN_SQLITE_COBBLE_BOT_TASKS,
|
|
BUN_SQLITE_COBBLE_BOTS,
|
|
BUN_SQLITE_COBBLE_USERS,
|
|
BunSQLiteTables,
|
|
} from "@/db/types/db";
|
|
import type {
|
|
BotFace,
|
|
BotHue,
|
|
BotMessageAuthor,
|
|
BotTaskStepState,
|
|
} from "@/src/dict/bot-dict";
|
|
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
|
|
|
|
/**
|
|
* Seeds the sample crew from the approved chat design into one user's
|
|
* workspace. The coordinator already exists (it's made with the account),
|
|
* so its sample messages go onto it. Idempotent: skips a user who already
|
|
* has worker bots.
|
|
*
|
|
* Usage: `bun run seed:bots [email]` (defaults to SEED_SUPER_ADMIN_EMAIL).
|
|
*/
|
|
|
|
const EMAIL =
|
|
process.argv[2] ||
|
|
process.env.SEED_SUPER_ADMIN_EMAIL ||
|
|
"[email protected]";
|
|
|
|
const MINUTE = 60 * 1000;
|
|
const HOUR = 60 * MINUTE;
|
|
const DAY = 24 * HOUR;
|
|
|
|
type SeedMessage = {
|
|
author: BotMessageAuthor;
|
|
text: string;
|
|
ago: number;
|
|
needs_you?: boolean;
|
|
};
|
|
|
|
type SeedTask = {
|
|
title: string;
|
|
steps: { label: string; state: BotTaskStepState }[];
|
|
};
|
|
|
|
type SeedBot = {
|
|
name: string;
|
|
hue: BotHue | null;
|
|
role: string | null;
|
|
face: BotFace;
|
|
status_line: string;
|
|
unread?: boolean;
|
|
messages: SeedMessage[];
|
|
task?: SeedTask;
|
|
};
|
|
|
|
const Crew: SeedBot[] = [
|
|
{
|
|
name: "Coordinator",
|
|
hue: null,
|
|
role: null,
|
|
face: "idle",
|
|
status_line: "",
|
|
messages: [
|
|
{
|
|
author: "bot",
|
|
text: "Three bots are working. One needs you.",
|
|
ago: 20 * 1000,
|
|
},
|
|
{
|
|
author: "bot",
|
|
text: "Shelf and Draft are done. Inbox needs you on the Zendesk login.",
|
|
ago: 10 * 1000,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "Inbox",
|
|
hue: "ember",
|
|
role: "Triage your mail",
|
|
face: "needs-you",
|
|
status_line: "Waiting on you",
|
|
messages: [
|
|
{
|
|
author: "user",
|
|
text: "Chase the Zendesk queue today. Anything you can't get into, tell me.",
|
|
ago: 7 * MINUTE,
|
|
},
|
|
{
|
|
author: "bot",
|
|
text: "Signed in. 14 tickets triaged, 3 need a decision from you.",
|
|
ago: 4 * MINUTE,
|
|
},
|
|
{
|
|
author: "bot",
|
|
text: "Zendesk SSO wants a code I can't read. Send it and I'll finish the queue.",
|
|
ago: 30 * 1000,
|
|
needs_you: true,
|
|
},
|
|
],
|
|
task: {
|
|
title: "Zendesk queue",
|
|
steps: [
|
|
{ label: "Signed in", state: "done" },
|
|
{ label: "Triaged 14 tickets", state: "done" },
|
|
{ label: "Waiting on SSO code", state: "active" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "Ledger",
|
|
hue: "amber",
|
|
role: "Match receipts to the ledger",
|
|
face: "working",
|
|
status_line: "Working",
|
|
unread: true,
|
|
messages: [
|
|
{ author: "bot", text: "Matched 14 receipts", ago: 2 * MINUTE },
|
|
],
|
|
task: {
|
|
title: "October receipts",
|
|
steps: [
|
|
{ label: "Pulled 31 receipts", state: "done" },
|
|
{ label: "Matched 14 receipts", state: "active" },
|
|
{ label: "Flag the unmatched", state: "pending" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "Watcher",
|
|
hue: "cobalt",
|
|
role: "Watch the build queue",
|
|
face: "working",
|
|
status_line: "Working",
|
|
unread: true,
|
|
messages: [
|
|
{
|
|
author: "bot",
|
|
text: "Nothing new since 06:40",
|
|
ago: 14 * MINUTE,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "Shelf",
|
|
hue: "moss",
|
|
role: "File your downloads",
|
|
face: "done",
|
|
status_line: "Done",
|
|
messages: [{ author: "bot", text: "Filed 32 downloads", ago: HOUR }],
|
|
},
|
|
{
|
|
name: "Reader",
|
|
hue: "violet",
|
|
role: "Summarise papers you save",
|
|
face: "working",
|
|
status_line: "Working",
|
|
messages: [
|
|
{ author: "bot", text: "Summarised 3 papers", ago: 3 * HOUR },
|
|
],
|
|
},
|
|
{
|
|
name: "Draft",
|
|
hue: "rose",
|
|
role: "Draft replies for review",
|
|
face: "done",
|
|
status_line: "Done",
|
|
unread: true,
|
|
messages: [
|
|
{
|
|
author: "bot",
|
|
text: "Two replies ready to review",
|
|
ago: 5 * HOUR,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "Sweep",
|
|
hue: "teal",
|
|
role: "Clear stale branches overnight",
|
|
face: "asleep",
|
|
status_line: "Asleep until 08:00",
|
|
messages: [
|
|
{ author: "bot", text: "Asleep until 08:00", ago: 5 * DAY },
|
|
],
|
|
},
|
|
];
|
|
|
|
type Table = (typeof BunSQLiteTables)[number];
|
|
|
|
async function insertBot({
|
|
user_id,
|
|
bot,
|
|
now,
|
|
}: {
|
|
user_id: number;
|
|
bot: SeedBot;
|
|
now: number;
|
|
}) {
|
|
const last_ago = Math.min(...bot.messages.map((m) => m.ago));
|
|
|
|
if (!bot.hue) {
|
|
const coordinator_id = await ensureCoordinatorBot({ user_id });
|
|
|
|
await BunSQLite.sql({
|
|
sql: `UPDATE bots SET face = ?, status_line = ?, last_activity_at = ? WHERE id = ?`,
|
|
values: [bot.face, bot.status_line, now - last_ago, coordinator_id],
|
|
});
|
|
|
|
await insertMessages({ bot_id: coordinator_id, bot, now });
|
|
|
|
console.log(` ${bot.name} (#${coordinator_id}, existing)`);
|
|
return;
|
|
}
|
|
|
|
const bot_res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOTS, Table>({
|
|
table: "bots",
|
|
data: [
|
|
{
|
|
user_id,
|
|
name: bot.name,
|
|
kind: bot.hue ? "worker" : "coordinator",
|
|
hue: bot.hue || undefined,
|
|
role: bot.role || undefined,
|
|
face: bot.face,
|
|
status_line: bot.status_line,
|
|
unread: bot.unread ? 1 : 0,
|
|
last_activity_at: now - last_ago,
|
|
},
|
|
],
|
|
});
|
|
|
|
const bot_id = bot_res.postInsertReturn?.insertId;
|
|
|
|
if (!bot_res.success || !bot_id) {
|
|
throw new Error(`Failed to create bot ${bot.name}: ${bot_res.msg}`);
|
|
}
|
|
|
|
await insertMessages({ bot_id, bot, now });
|
|
|
|
if (bot.task) {
|
|
await insertTask({ bot_id, task: bot.task });
|
|
}
|
|
|
|
console.log(` ${bot.name} (#${bot_id})`);
|
|
}
|
|
|
|
async function insertMessages({
|
|
bot_id,
|
|
bot,
|
|
now,
|
|
}: {
|
|
bot_id: number;
|
|
bot: SeedBot;
|
|
now: number;
|
|
}) {
|
|
const messages_res = await BunSQLite.insert<
|
|
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
|
Table
|
|
>({
|
|
table: "bot_messages",
|
|
data: bot.messages.map((message) => ({
|
|
bot_id,
|
|
author: message.author,
|
|
text: message.text,
|
|
needs_you: message.needs_you ? 1 : 0,
|
|
created_at: now - message.ago,
|
|
updated_at: now - message.ago,
|
|
})),
|
|
});
|
|
|
|
if (!messages_res.success) {
|
|
throw new Error(
|
|
`Failed to add messages for ${bot.name}: ${messages_res.msg}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function insertTask({
|
|
bot_id,
|
|
task,
|
|
}: {
|
|
bot_id: number;
|
|
task: SeedTask;
|
|
}) {
|
|
const task_res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_TASKS, Table>(
|
|
{
|
|
table: "bot_tasks",
|
|
data: [{ bot_id, title: task.title, status: "active" }],
|
|
},
|
|
);
|
|
|
|
const task_id = task_res.postInsertReturn?.insertId;
|
|
|
|
if (!task_res.success || !task_id) {
|
|
throw new Error(`Failed to create task ${task.title}: ${task_res.msg}`);
|
|
}
|
|
|
|
const steps_res = await BunSQLite.insert<
|
|
BUN_SQLITE_COBBLE_BOT_TASK_STEPS,
|
|
Table
|
|
>({
|
|
table: "bot_task_steps",
|
|
data: task.steps.map((step, position) => ({
|
|
task_id,
|
|
label: step.label,
|
|
state: step.state,
|
|
position,
|
|
})),
|
|
});
|
|
|
|
if (!steps_res.success) {
|
|
throw new Error(`Failed to add steps for ${task.title}`);
|
|
}
|
|
}
|
|
|
|
async function seedBots() {
|
|
const user = (
|
|
await BunSQLite.select<BUN_SQLITE_COBBLE_USERS, Table>({
|
|
table: "users",
|
|
query: { query: { email: { value: EMAIL } } },
|
|
})
|
|
).payload?.[0];
|
|
|
|
if (!user?.id) {
|
|
console.error(
|
|
`No user with email ${EMAIL}. Run \`bun run seed\` first or pass an email.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const existing_workers = (
|
|
await BunSQLite.select<BUN_SQLITE_COBBLE_BOTS, Table>({
|
|
table: "bots",
|
|
query: {
|
|
query: {
|
|
user_id: { value: user.id },
|
|
kind: { value: "worker" },
|
|
},
|
|
},
|
|
})
|
|
).payload;
|
|
|
|
if (existing_workers?.length) {
|
|
console.log(
|
|
`User #${user.id} <${EMAIL}> already has ${existing_workers.length} worker bots. Skipping.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`Seeding crew for user #${user.id} <${EMAIL}>:`);
|
|
|
|
const now = Date.now();
|
|
|
|
for (const bot of Crew) {
|
|
await insertBot({ user_id: Number(user.id), bot, now });
|
|
}
|
|
}
|
|
|
|
await seedBots();
|
|
process.exit(0);
|