7 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 89c4e26d06 Release v0.2.2
Release image / image (release) Canceled after 0s
CI / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:58:21 +01:00
tbenandClaude Opus 5.5 d67cd6a27a Close the proposal modal once a proposal is decided
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:57:28 +01:00
tbenandClaude Opus 5.5 2cbf6a6e6c Compress AGENTS.md to its rules, invariants and pointers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:56:18 +01:00
tbenandClaude Opus 5.5 373210c2a5 Make the skill runbook limits a general setting
The longest runbook a skill may save and how much of it load_skill hands
back are now set on Settings > General (skill_body_max_chars,
skill_body_shown_chars), defaulting to AppData.SkillBodyMaxChars and
SkillBodyShownChars. Saving, confirming and proposing a skill check the
install's limit; a skill saved before it was lowered can still be toggled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:43:00 +01:00
tbenandClaude Opus 5.5 2408185a65 Release v0.2.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:25:43 +01:00
tbenandClaude Opus 5.5 577523de3c Only ignore the root logs folder
The bare "logs" pattern ignored every folder named logs, so
api/admin/logs/clear-logs.ts never reached the repository or the
production build. Anchor it to the root and add the route.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:24:55 +01:00
tbenandClaude Opus 5.5 b9a49ee09c Fix data archives and restores failing on their own lock
startDataBackupRun already holds the backup run lock, but the archive
(createDataBackup) and restoreDataBackup took it again, so every run
failed a few seconds in with "Another backup or restore is already
running". The writer and the restore now take no lock of their own.

The run's notice (archiving, restoring, or why it failed) moves to its
own line under the actions row so the buttons don't shift, and archiving
reads in gray rather than the warning colour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:24:55 +01:00
36 changed files with 802 additions and 383 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ coverage
*.lcov *.lcov
# logs # logs
logs /logs
!src/pages/admin/logs/ !src/pages/admin/logs/
_.log _.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+172 -238
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.2.0", "version": "0.2.2",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
+2
View File
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings/grab-skill-limits";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { import {
ActionCheckNote, ActionCheckNote,
@@ -204,6 +205,7 @@ export default async function runBotReply({
*/ */
const skills = openBotSkills({ const skills = openBotSkills({
skills: extensions.skills, skills: extensions.skills,
shown_chars: await grabSkillBodyShownChars(),
taken: [...tools, ...extensions.tools].map((tool) => tool.name), taken: [...tools, ...extensions.tools].map((tool) => tool.name),
}); });
+2
View File
@@ -1,4 +1,5 @@
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers"; import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings/grab-skill-limits";
import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools"; import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools";
import workspaceAdminsNote from "./workspace-admins-note"; import workspaceAdminsNote from "./workspace-admins-note";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
@@ -207,6 +208,7 @@ export default async function runBotTask({
skills = openBotSkills({ skills = openBotSkills({
skills: extensions.skills, skills: extensions.skills,
shown_chars: await grabSkillBodyShownChars(),
taken: [...tools, ...extensions.tools].map((tool) => tool.name), taken: [...tools, ...extensions.tools].map((tool) => tool.name),
}); });
+5 -1
View File
@@ -309,10 +309,14 @@ export const AppData = {
/** /**
* Skills (Settings, under Skills): how long one's body may be, and how * Skills (Settings, under Skills): how long one's body may be, and how
* much of it `load_skill` hands back in one call. A runbook past that * much of it `load_skill` hands back in one call. A runbook past that
* is worth splitting into two skills. * is worth splitting into two skills. These are the defaults: the
* install sets its own on Settings > General (`skill_body_max_chars`,
* `skill_body_shown_chars`, `grab-skill-limits.ts`), never past
* `SkillBodyLimitMax`.
*/ */
SkillBodyMaxChars: 60000, SkillBodyMaxChars: 60000,
SkillBodyShownChars: 30000, SkillBodyShownChars: 30000,
SkillBodyLimitMax: 1000000,
SkillDescriptionMaxChars: 300, SkillDescriptionMaxChars: 300,
/** /**
* How long the super admin has to confirm an MCP server or tool the * How long the super admin has to confirm an MCP server or tool the
+8
View File
@@ -14,3 +14,11 @@ export const LoadSkillToolName = "load_skill";
* The slug format, for the settings form and its hint. * The slug format, for the settings form and its hint.
*/ */
export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
/**
* The `app_settings` keys for the longest runbook a skill may save, and
* how much of it `load_skill` hands back at once (Settings > General).
* Unset, `AppData.SkillBodyMaxChars` and `AppData.SkillBodyShownChars`.
*/
export const SkillBodyMaxCharsSettingKey = "skill_body_max_chars";
export const SkillBodyShownCharsSettingKey = "skill_body_shown_chars";
@@ -1,21 +0,0 @@
import withBackupRunLock from "./with-backup-run-lock";
import writeDataBackupArchive from "./write-data-backup-archive";
/**
* Archives the whole `DATA_DIR` (database, media, computer homes, bot and
* workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as
* `cobble-data-{timestamp}.tar.gz`, then keeps only the newest
* `AppData.DataBackupsKept`. Every entry sits under one root folder named
* after the archive (`cobble-data-{timestamp}/`), so extracting it lands
* one folder, never a heap of files in the current directory. The
* database's own snapshots in `db/.backups` are inside `DATA_DIR`, so they
* ride along and are left alone on disk.
*
* The archive is a filesystem copy of a live volume, so the SQLite file
* inside it is only as consistent as the moment tar read it; a consistent
* database snapshot is what the `db/.backups` list is for. Restoring a
* whole-data archive is `restoreDataBackup` (`restore-data-backup.ts`).
*/
export default async function createDataBackup() {
return await withBackupRunLock({ task: writeDataBackupArchive });
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run"; import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run";
import { isBackupRunning } from "./with-backup-run-lock"; import { isBackupRunning } from "./with-backup-run-lock";
import restoreDataBackup from "./restore-data-backup";
function deferred() { function deferred() {
let resolve!: (name: string) => void; let resolve!: (name: string) => void;
@@ -80,4 +81,19 @@ describe("startDataBackupRun", () => {
await settle(); await settle();
expect(grabDataBackupRun().status).toBe("done"); expect(grabDataBackupRun().status).toBe("done");
}); });
test("a restore runs under the run's lock instead of taking it again", async () => {
startDataBackupRun({
kind: "restore",
task: async () =>
(await restoreDataBackup({ backup_name: "missing.tar.gz" }))
.restored_name,
});
await settle();
const failed = grabDataBackupRun();
expect(failed.status).toBe("failed");
expect(failed.error).toBe("Archive not found");
expect(isBackupRunning()).toBe(false);
});
}); });
@@ -5,7 +5,6 @@ import { AppData } from "@/src/data/app-data";
import grabDirNames from "@/src/utils/grab-dir-names"; import grabDirNames from "@/src/utils/grab-dir-names";
import writeDataBackupArchive from "./write-data-backup-archive"; import writeDataBackupArchive from "./write-data-backup-archive";
import resolveDataBackupPath from "./resolve-data-backup-path"; import resolveDataBackupPath from "./resolve-data-backup-path";
import withBackupRunLock from "./with-backup-run-lock";
export type DataBackupRestoreResult = { export type DataBackupRestoreResult = {
/** /**
@@ -61,89 +60,86 @@ function findArchiveRoot({ staging_dir }: RootParams) {
* is taken as a safety copy, then the data directory is emptied (the * is taken as a safety copy, then the data directory is emptied (the
* database snapshots included) and filled from the archive. * database snapshots included) and filled from the archive.
* *
* Runs only inside `startDataBackupRun`, which holds the backup run lock
* for it (taking it again here would refuse every restore).
*
* The caller restarts the process afterwards: the open SQLite handle still * The caller restarts the process afterwards: the open SQLite handle still
* points at the replaced file, so nothing may keep reading it. * points at the replaced file, so nothing may keep reading it.
*/ */
export default async function restoreDataBackup({ export default async function restoreDataBackup({
backup_name, backup_name,
}: Params): Promise<DataBackupRestoreResult> { }: Params): Promise<DataBackupRestoreResult> {
return await withBackupRunLock({ const { DATA_DIR } = grabDirNames();
task: async () => { const archive_path = resolveDataBackupPath({ backup_name });
const { DATA_DIR } = grabDirNames(); const archive_name = path.basename(archive_path);
const archive_path = resolveDataBackupPath({ backup_name }); const staging_dir = fs.mkdtempSync(
const archive_name = path.basename(archive_path); path.join(os.tmpdir(), "cobble-restore-"),
const staging_dir = fs.mkdtempSync( );
path.join(os.tmpdir(), "cobble-restore-"),
try {
const extract = Bun.spawn(
["tar", "-xzf", archive_path, "-C", staging_dir],
{
cwd: process.cwd(),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr, exit_code] = await Promise.all([
new Response(extract.stdout).text(),
new Response(extract.stderr).text(),
extract.exited,
]);
if (exit_code !== 0) {
throw new Error(
stderr.trim() ||
stdout.trim() ||
`tar failed (exit ${exit_code})`,
); );
}
try { // The archive's own name may differ from its file name: an
const extract = Bun.spawn( // upload is saved under a fresh timestamp. Its one root
["tar", "-xzf", archive_path, "-C", staging_dir], // folder is what holds the data, so find it rather than
{ // assume it matches.
cwd: process.cwd(), const source_dir = findArchiveRoot({ staging_dir });
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr, exit_code] = await Promise.all([ if (!fs.existsSync(path.join(source_dir, "db"))) {
new Response(extract.stdout).text(), throw new Error(
new Response(extract.stderr).text(), `The archive has no database, so nothing was restored`,
extract.exited, );
]); }
if (exit_code !== 0) { // The archive's snapshots would otherwise vanish with the
throw new Error( // current ones, so the safety copy is taken before them.
stderr.trim() || const safety = await writeDataBackupArchive();
stdout.trim() ||
`tar failed (exit ${exit_code})`,
);
}
// The archive's own name may differ from its file name: an // Everything goes, the snapshots in `db/.backups` included:
// upload is saved under a fresh timestamp. Its one root // the archive's own set replaces them.
// folder is what holds the data, so find it rather than for (const entry of fs.readdirSync(DATA_DIR)) {
// assume it matches. fs.rmSync(path.join(DATA_DIR, entry), {
const source_dir = findArchiveRoot({ staging_dir }); recursive: true,
force: true,
});
}
if (!fs.existsSync(path.join(source_dir, "db"))) { for (const entry of fs.readdirSync(source_dir)) {
throw new Error( fs.cpSync(
`The archive has no database, so nothing was restored`, path.join(source_dir, entry),
); path.join(DATA_DIR, entry),
} { recursive: true, force: true },
);
}
// The archive's snapshots would otherwise vanish with the return {
// current ones, so the safety copy is taken before them. safety_name: safety.name,
// The run lock is already held here, hence the unlocked restored_name: archive_name,
// writer rather than `createDataBackup`. };
const safety = await writeDataBackupArchive(); } finally {
fs.rmSync(staging_dir, { recursive: true, force: true });
// Everything goes, the snapshots in `db/.backups` included: }
// the archive's own set replaces them.
for (const entry of fs.readdirSync(DATA_DIR)) {
fs.rmSync(path.join(DATA_DIR, entry), {
recursive: true,
force: true,
});
}
for (const entry of fs.readdirSync(source_dir)) {
fs.cpSync(
path.join(source_dir, entry),
path.join(DATA_DIR, entry),
{ recursive: true, force: true },
);
}
return {
safety_name: safety.name,
restored_name: archive_name,
};
} finally {
fs.rmSync(staging_dir, { recursive: true, force: true });
}
},
});
} }
@@ -11,9 +11,17 @@ export type CreatedDataBackup = {
}; };
/** /**
* Writes one archive of the whole `DATA_DIR` and prunes the old ones. * Writes one archive of the whole `DATA_DIR` (database, media, computer
* Callers already hold the backup run lock (`withBackupRunLock`), so a * workspaces, bot and workspace files, MCP installs) into
* restore can take its safety copy without deadlocking on it. * `BACKUP_ARCHIVE_DIR` and keeps only the newest `AppData.DataBackupsKept`.
* Every entry sits under one root folder named after the archive, so
* extracting it lands one folder. The SQLite file inside is only as
* consistent as the moment tar read it; `db/.backups` is for consistent
* snapshots.
*
* It takes no lock: callers run inside `startDataBackupRun`, which holds
* the backup run lock (`withBackupRunLock`) for the whole run, so the
* archive route and a restore's safety copy never take it twice.
*/ */
export default async function writeDataBackupArchive(): Promise<CreatedDataBackup> { export default async function writeDataBackupArchive(): Promise<CreatedDataBackup> {
const { DATA_DIR } = grabDirNames(); const { DATA_DIR } = grabDirNames();
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
import { SkillBodyMaxCharsSettingKey, SkillBodyShownCharsSettingKey } from "@/src/dict/skill-dict";
import readStepLimit from "@/src/utils/read-step-limit";
import type { SkillLimitsOverview } from "@/src/types";
import grabAppSetting from "./grab-app-setting";
/**
* The longest runbook a skill may save: the install's own limit
* (`skill_body_max_chars`, Settings > General), else
* `AppData.SkillBodyMaxChars`.
*/
export async function grabSkillBodyMaxChars() {
return readStepLimit({
saved: await grabAppSetting({ key: SkillBodyMaxCharsSettingKey }),
fallback: AppData.SkillBodyMaxChars,
max: AppData.SkillBodyLimitMax,
});
}
/**
* How much of a runbook `load_skill` hands back in one call: the install's
* own limit (`skill_body_shown_chars`, Settings > General), else
* `AppData.SkillBodyShownChars`.
*/
export async function grabSkillBodyShownChars() {
return readStepLimit({
saved: await grabAppSetting({ key: SkillBodyShownCharsSettingKey }),
fallback: AppData.SkillBodyShownChars,
max: AppData.SkillBodyLimitMax,
});
}
/**
* Both limits with their defaults, for the settings pages.
*/
export default async function grabSkillLimitsOverview() {
const [body_max_chars, body_shown_chars] = await Promise.all([
grabSkillBodyMaxChars(),
grabSkillBodyShownChars(),
]);
const overview: SkillLimitsOverview = {
body_max_chars,
body_shown_chars,
body_max_default: AppData.SkillBodyMaxChars,
body_shown_default: AppData.SkillBodyShownChars,
max: AppData.SkillBodyLimitMax,
};
return overview;
}
export type { SkillLimitsOverview };
@@ -0,0 +1,20 @@
import { grabSkillBodyMaxChars } from "../app-settings/grab-skill-limits";
import AppError from "@/src/utils/app-error";
type Params = {
body: string;
};
/**
* Refuses a runbook longer than the install's limit (Settings > General).
* The request schemas only hold the ceiling, since the limit is a setting.
*/
export default async function assertSkillBodyFits({ body }: Params) {
const max = await grabSkillBodyMaxChars();
if (body.length > max) {
throw new AppError(
`Keep the runbook under ${max} characters (it has ${body.length}). A super admin can raise the limit on Settings > General.`,
);
}
}
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillsOverviewPayload } from "@/src/types"; import type { SkillsOverviewPayload } from "@/src/types";
import grabSkillLimitsOverview from "../app-settings/grab-skill-limits";
import grabCrewRole from "../access/grab-crew-role"; import grabCrewRole from "../access/grab-crew-role";
import grabExtensionAccess from "./grab-extension-access"; import grabExtensionAccess from "./grab-extension-access";
import grabExtensionTargets from "./grab-extension-targets"; import grabExtensionTargets from "./grab-extension-targets";
@@ -17,14 +18,16 @@ type Params = {
*/ */
export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> { export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> {
const role = await grabCrewRole({ user_id }); const role = await grabCrewRole({ user_id });
const [rows, access, targets] = await Promise.all([ const [rows, access, targets, limits] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }), BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }),
grabExtensionAccess({ kind: "skill" }), grabExtensionAccess({ kind: "skill" }),
grabExtensionTargets({ role }), grabExtensionTargets({ role }),
grabSkillLimitsOverview(),
]); ]);
return { return {
...targets, ...targets,
limits,
skills: (rows.payload || []).map((row) => { skills: (rows.payload || []).map((row) => {
const { all_bots, ...skill } = parseSkillRow({ row }); const { all_bots, ...skill } = parseSkillRow({ row });
@@ -3,6 +3,7 @@ import attachToAllBots from "./attach-to-all-bots";
import type { BUN_SQLITE_COBBLE_SKILLS, BunSQLiteTables } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_SKILLS, BunSQLiteTables } from "@/db/types/db";
import type { SaveSkillRequest } from "@/src/schemas/extension-schema"; import type { SaveSkillRequest } from "@/src/schemas/extension-schema";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import assertSkillBodyFits from "./assert-skill-body-fits";
import grabSkill from "./grab-skill"; import grabSkill from "./grab-skill";
type Params = { type Params = {
@@ -15,7 +16,9 @@ type Params = {
/** /**
* Adds or edits a skill and returns its id. Slugs are unique: a bot loads a * Adds or edits a skill and returns its id. Slugs are unique: a bot loads a
* skill by its slug, so two skills can't answer to the same one. * skill by its slug, so two skills can't answer to the same one. A new or
* changed runbook must fit the install's limit; one saved before the limit
* was lowered keeps saving while its runbook is left as it is.
*/ */
export default async function saveSkill({ user_id, data }: Params) { export default async function saveSkill({ user_id, data }: Params) {
const clash = ( const clash = (
@@ -38,6 +41,9 @@ export default async function saveSkill({ user_id, data }: Params) {
if (data.skill_id) { if (data.skill_id) {
const skill = await grabSkill({ skill_id: data.skill_id }); const skill = await grabSkill({ skill_id: data.skill_id });
if (data.body != skill.body) await assertSkillBodyFits({ body: data.body });
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({ const res = await BunSQLite.update<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills", table: "skills",
targetId: skill.id, targetId: skill.id,
@@ -51,6 +57,8 @@ export default async function saveSkill({ user_id, data }: Params) {
return skill.id; return skill.id;
} }
await assertSkillBodyFits({ body: data.body });
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({ const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills", table: "skills",
data: [{ ...fields, user_id: Number(user_id) }], data: [{ ...fields, user_id: Number(user_id) }],
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal"; import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view"; import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
import assertSkillBodyFits from "@/src/functions/backend/db/extensions/assert-skill-body-fits";
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill"; import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema"; import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
@@ -51,7 +52,7 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac
name: z.string().trim().max(80).optional(), name: z.string().trim().max(80).optional(),
slug: z.string().trim().max(80).optional(), slug: z.string().trim().max(80).optional(),
description: z.string().trim().max(AppData.SkillDescriptionMaxChars).optional(), description: z.string().trim().max(AppData.SkillDescriptionMaxChars).optional(),
body: z.string().max(AppData.SkillBodyMaxChars).optional().describe("The runbook, markdown"), body: z.string().max(AppData.SkillBodyLimitMax).optional().describe("The runbook, markdown"),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
all_bots: z.boolean().optional(), all_bots: z.boolean().optional(),
...SkillReviewFields, ...SkillReviewFields,
@@ -73,6 +74,8 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac
}, },
}); });
if (data.body != current?.body) await assertSkillBodyFits({ body: data.body });
const id = await createExtensionProposal({ const id = await createExtensionProposal({
user_id, user_id,
bot_id, bot_id,
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import type { SkillRecord } from "@/src/types"; import type { SkillRecord } from "@/src/types";
import openBotSkills from "./open-bot-skills"; import openBotSkills from "./open-bot-skills";
const shown_chars = 30000;
function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord { function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord {
return { return {
id: 1, id: 1,
@@ -17,18 +18,18 @@ function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord
describe("openBotSkills", () => { describe("openBotSkills", () => {
test("no skills means no tool and no note", () => { test("no skills means no tool and no note", () => {
expect(openBotSkills({ skills: [] })).toEqual({ tools: [], note: "" }); expect(openBotSkills({ skills: [], shown_chars })).toEqual({ tools: [], note: "" });
}); });
test("a disabled skill is left out entirely", () => { test("a disabled skill is left out entirely", () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })] }); const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })], shown_chars });
expect(opened.tools).toHaveLength(0); expect(opened.tools).toHaveLength(0);
expect(opened.note).toBe(""); expect(opened.note).toBe("");
}); });
test("one tool, named load_skill, whose description lists the slugs", () => { test("one tool, named load_skill, whose description lists the slugs", () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })] }); const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })], shown_chars });
expect(opened.tools).toHaveLength(1); expect(opened.tools).toHaveLength(1);
expect(opened.tools[0]!.name).toBe("load_skill"); expect(opened.tools[0]!.name).toBe("load_skill");
@@ -38,21 +39,21 @@ describe("openBotSkills", () => {
}); });
test("a built-in that took the name wins", () => { test("a built-in that took the name wins", () => {
expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"] })).toEqual({ expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"], shown_chars })).toEqual({
tools: [], tools: [],
note: "", note: "",
}); });
}); });
test("the tool returns the body of the slug it's given", async () => { test("the tool returns the body of the slug it's given", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })] }); const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })], shown_chars });
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toBe("1. Pull rankings"); expect(output).toBe("1. Pull rankings");
}); });
test("an unknown slug fails with the slugs it does have", async () => { test("an unknown slug fails with the slugs it does have", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo" })] }); const opened = openBotSkills({ skills: [skill({ slug: "seo" })], shown_chars });
await expect(opened.tools[0]!.run!({ args: { slug: "nope" }, signal: AbortSignal.timeout(1000) })).rejects.toThrow( await expect(opened.tools[0]!.run!({ args: { slug: "nope" }, signal: AbortSignal.timeout(1000) })).rejects.toThrow(
/no skill called "nope".*seo/, /no skill called "nope".*seo/,
@@ -61,16 +62,24 @@ describe("openBotSkills", () => {
test("a long body is cut at the limit and says so", async () => { test("a long body is cut at the limit and says so", async () => {
const opened = openBotSkills({ const opened = openBotSkills({
skills: [skill({ slug: "seo", body: "x".repeat(AppData.SkillBodyShownChars + 500) })], skills: [skill({ slug: "seo", body: "x".repeat(shown_chars + 500) })],
shown_chars,
}); });
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toContain("Cut at"); expect(output).toContain("Cut at");
expect(output.length).toBeLessThan(AppData.SkillBodyShownChars + 200); expect(output.length).toBeLessThan(shown_chars + 200);
});
test("the cut follows the install's limit", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "x".repeat(1500) })], shown_chars: 1000 });
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toContain("Cut at 1000 characters");
}); });
test("an empty body says it is empty rather than returning nothing", async () => { test("an empty body says it is empty rather than returning nothing", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })] }); const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })], shown_chars });
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toContain("is empty"); expect(output).toContain("is empty");
+8 -4
View File
@@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import { SkillsNote } from "@/src/data/harness-prompts"; import { SkillsNote } from "@/src/data/harness-prompts";
import { LoadSkillToolName } from "@/src/dict/skill-dict"; import { LoadSkillToolName } from "@/src/dict/skill-dict";
import type { SkillRecord } from "@/src/types"; import type { SkillRecord } from "@/src/types";
@@ -10,6 +9,11 @@ type Params = {
* The skills the run reaches (`grabBotExtensions`). * The skills the run reaches (`grabBotExtensions`).
*/ */
skills: SkillRecord[]; skills: SkillRecord[];
/**
* How much of a runbook `load_skill` hands back at once
* (`grabSkillBodyShownChars`).
*/
shown_chars: number;
/** /**
* Names the run's built-in tools already use: they win. * Names the run's built-in tools already use: they win.
*/ */
@@ -30,7 +34,7 @@ export type OpenBotSkills = {
* which ones exist. A skill's body costs nothing until the bot asks for it, * which ones exist. A skill's body costs nothing until the bot asks for it,
* so a run that never needs a long runbook never pays for it. * so a run that never needs a long runbook never pays for it.
*/ */
export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSkills { export default function openBotSkills({ skills, shown_chars, taken = [] }: Params): OpenBotSkills {
const enabled = skills.filter((skill) => skill.enabled); const enabled = skills.filter((skill) => skill.enabled);
if (!enabled.length || taken.includes(LoadSkillToolName)) return { tools: [], note: "" }; if (!enabled.length || taken.includes(LoadSkillToolName)) return { tools: [], note: "" };
@@ -57,9 +61,9 @@ export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSk
if (!body) return `The skill ${skill.name} (${skill.slug}) is empty.`; if (!body) return `The skill ${skill.name} (${skill.slug}) is empty.`;
if (body.length <= AppData.SkillBodyShownChars) return body; if (body.length <= shown_chars) return body;
return `${body.slice(0, AppData.SkillBodyShownChars)}\n\n[Cut at ${AppData.SkillBodyShownChars} characters. The skill continues: ask the user to split it if you need the rest.]`; return `${body.slice(0, shown_chars)}\n\n[Cut at ${shown_chars} characters. The skill continues: ask the user to split it if you need the rest.]`;
}, },
}; };
@@ -5,6 +5,7 @@ import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import { BookOpen, PlugZap, Wrench } from "lucide-react"; import { BookOpen, PlugZap, Wrench } from "lucide-react";
import type { ExtensionProposalView } from "@/src/types";
import useExtensionProposal from "../(hooks)/use-extension-proposal"; import useExtensionProposal from "../(hooks)/use-extension-proposal";
import proposalStatusLabel from "../(utils)/proposal-status-label"; import proposalStatusLabel from "../(utils)/proposal-status-label";
import proposalTitle from "../(utils)/proposal-title"; import proposalTitle from "../(utils)/proposal-title";
@@ -24,6 +25,11 @@ export default function ProposalRefChip({ proposal_id }: Props) {
const { view, loading, setView } = useExtensionProposal({ proposal_id }); const { view, loading, setView } = useExtensionProposal({ proposal_id });
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const decided = (next: ExtensionProposalView) => {
setView(next);
setOpen(false);
};
if (!view) { if (!view) {
return <Span className="font-bold">{loading ? `proposal ${proposal_id}` : `proposal ${proposal_id} (not available)`}</Span>; return <Span className="font-bold">{loading ? `proposal ${proposal_id}` : `proposal ${proposal_id} (not available)`}</Span>;
} }
@@ -60,7 +66,7 @@ export default function ProposalRefChip({ proposal_id }: Props) {
</Stack> </Stack>
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto"> <Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
<ProposalCard view={view} onDecided={setView} /> <ProposalCard view={view} onDecided={decided} />
</Stack> </Stack>
</Modal> </Modal>
); );
@@ -6,6 +6,7 @@ import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-passwor
import type { PagePropsType } from "@/src/types"; import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabRunLimitsOverview from "@/src/functions/backend/db/app-settings/grab-run-limits"; import grabRunLimitsOverview from "@/src/functions/backend/db/app-settings/grab-run-limits";
import grabSkillLimitsOverview from "@/src/functions/backend/db/app-settings/grab-skill-limits";
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview"; import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append"; import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
import runningVersion from "@/src/utils/running-version"; import runningVersion from "@/src/utils/running-version";
@@ -47,6 +48,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
storage_quota: await grabStorageQuotaOverview().catch(() => null), storage_quota: await grabStorageQuotaOverview().catch(() => null),
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null), system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
run_limits: await grabRunLimitsOverview().catch(() => null), run_limits: await grabRunLimitsOverview().catch(() => null),
skill_limits: await grabSkillLimitsOverview().catch(() => null),
running_version: runningVersion({ env: process.env }), running_version: runningVersion({ env: process.env }),
}, },
}; };
@@ -14,6 +14,10 @@ export type DataBackupRunState = {
* Follows a run a request just started until it ends. * Follows a run a request just started until it ends.
*/ */
track: (run: DataBackupRun) => void; track: (run: DataBackupRun) => void;
/**
* Shows why a run couldn't start (or clears it with `undefined`).
*/
report: (error?: string) => void;
}; };
/** /**
@@ -23,4 +27,5 @@ export type DataBackupRunState = {
*/ */
export const DataBackupRunContext = React.createContext<DataBackupRunState>({ export const DataBackupRunContext = React.createContext<DataBackupRunState>({
track: () => {}, track: () => {},
report: () => {},
}); });
@@ -94,5 +94,6 @@ export default function useDataBackupRun(): DataBackupRunState {
setError(undefined); setError(undefined);
setRun(next); setRun(next);
}, },
report: setError,
}; };
} }
@@ -1,6 +1,5 @@
import { Archive, Upload } from "lucide-react"; import { Archive, Upload } from "lucide-react";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import useStatus from "@moduletrace/twui/hooks/useStatus"; import useStatus from "@moduletrace/twui/hooks/useStatus";
import createDataBackup from "../(functions)/create-data-backup"; import createDataBackup from "../(functions)/create-data-backup";
@@ -10,18 +9,17 @@ import { useContext, useState } from "react";
/** /**
* The data archive list's two ways in: make a fresh archive, or restore * The data archive list's two ways in: make a fresh archive, or restore
* one from a file. Also says how the archive or restore running now is * one from a file. How a run is going, and why one failed, is shown under
* going, and why the last one failed. * the row by `DataBackupRunNotice`, so the buttons never move.
*/ */
export default function DataBackupListActions() { export default function DataBackupListActions() {
const { loading, setLoading, status, setStatus } = useStatus(); const { loading, setLoading } = useStatus();
const { run, error, track } = useContext(DataBackupRunContext); const { run, track, report } = useContext(DataBackupRunContext);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const archiving = run?.kind == "archive"; const archiving = run?.kind == "archive";
const restoring = run?.kind == "restore";
const create_backup = async () => { const create_backup = async () => {
setStatus(undefined); report(undefined);
setLoading(true); setLoading(true);
const res = await createDataBackup(); const res = await createDataBackup();
@@ -34,20 +32,12 @@ export default function DataBackupListActions() {
} }
if (res.msg) { if (res.msg) {
setStatus({ error: true, msg: res.msg }); report(res.msg);
} }
}; };
return ( return (
<Row className="gap-2! flex-nowrap"> <Row className="gap-2! flex-nowrap">
{status?.error && <Tag color="error">{status.msg}</Tag>}
{!status?.error && error && <Tag color="error">{error}</Tag>}
{archiving && (
<Tag color="warning">Archiving, this can take a while...</Tag>
)}
{restoring && (
<Tag color="warning">Restoring, the app restarts soon...</Tag>
)}
<Button <Button
title="Restore from an archive file" title="Restore from an archive file"
variant="outlined" variant="outlined"
@@ -0,0 +1,26 @@
import { useContext } from "react";
import Tag from "@moduletrace/twui/elements/Tag";
import { DataBackupRunContext } from "../(data)/data-backup-run-context";
/**
* One line under the data archives' actions: the archive or restore
* running now, else why the last one failed. Nothing when neither, so the
* actions row keeps its layout either way.
*/
export default function DataBackupRunNotice() {
const { run, error } = useContext(DataBackupRunContext);
if (run?.kind == "archive") {
return <Tag color="gray">Archiving, this can take a while...</Tag>;
}
if (run?.kind == "restore") {
return <Tag color="warning">Restoring, the app restarts soon...</Tag>;
}
if (error) {
return <Tag color="error">{error}</Tag>;
}
return null;
}
@@ -4,6 +4,7 @@ import Span from "@moduletrace/twui/layout/Span";
import SettingsGroup from "../../(partials)/settings-group"; import SettingsGroup from "../../(partials)/settings-group";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import DataBackupListActions from "../(partials)/data-backup-list-actions"; import DataBackupListActions from "../(partials)/data-backup-list-actions";
import DataBackupRunNotice from "../(partials)/data-backup-run-notice";
import DataBackupsTable from "../(partials)/data-backups-table"; import DataBackupsTable from "../(partials)/data-backups-table";
import { DataBackupRunContext } from "../(data)/data-backup-run-context"; import { DataBackupRunContext } from "../(data)/data-backup-run-context";
import useDataBackupRun from "../(hooks)/use-data-backup-run"; import useDataBackupRun from "../(hooks)/use-data-backup-run";
@@ -33,6 +34,7 @@ export default function DataBackupsSection() {
</Span> </Span>
<DataBackupListActions /> <DataBackupListActions />
</Row> </Row>
<DataBackupRunNotice />
{backups[0] ? ( {backups[0] ? (
<DataBackupsTable backups={backups} /> <DataBackupsTable backups={backups} />
) : ( ) : (
@@ -0,0 +1,137 @@
import { useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Stack from "@moduletrace/twui/layout/Stack";
import type { SkillLimitsOverview } from "@/src/types";
import SettingsSaveBar from "../../(partials)/settings-save-bar";
type Props = {
limits?: SkillLimitsOverview | null;
};
/**
* The longest runbook a skill may save and how much of it a bot reads at
* once, saved on its own without a reload. Zero puts the code's default
* back for that one.
*/
export default function SkillLimitsForm({ limits }: Props) {
const [overview, setOverview] = useState(limits || undefined);
const [dirty, setDirty] = useState(false);
const [body_max_chars, setBodyMaxChars] = useState(String(limits?.body_max_chars ?? ""));
const [body_shown_chars, setBodyShownChars] = useState(String(limits?.body_shown_chars ?? ""));
/**
* The inputs only read `defaultValue` when they mount, so discarding or
* saving remounts them with the saved numbers.
*/
const [form_key, setFormKey] = useState(0);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{
msg?: string;
error?: boolean;
success?: boolean;
}>();
const save = async () => {
if (!window.confirm("Save the skill limits?")) return;
setLoading(true);
setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<SkillLimitsOverview>>(
"/api/admin/settings/skill-limits",
{
method: "POST",
body: {
body_max_chars: Number(body_max_chars) || 0,
body_shown_chars: Number(body_shown_chars) || 0,
},
},
).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setStatus({
error: true,
msg: res?.msg || `Couldn't save the limits.`,
});
return;
}
setOverview(res.singleRes);
setBodyMaxChars(String(res.singleRes.body_max_chars));
setBodyShownChars(String(res.singleRes.body_shown_chars));
setFormKey((key) => key + 1);
setDirty(false);
setStatus({
success: true,
msg: `Saved: runbooks up to ${res.singleRes.body_max_chars} characters, ${res.singleRes.body_shown_chars} read at once.`,
});
};
const discard = () => {
setBodyMaxChars(String(overview?.body_max_chars ?? ""));
setBodyShownChars(String(overview?.body_shown_chars ?? ""));
setFormKey((key) => key + 1);
setDirty(false);
setStatus(undefined);
};
return (
<Form
onSubmit={(event) => {
event.preventDefault();
save().catch(() => {});
}}
className="w-full gap-3! items-stretch"
>
<Stack className="w-full gap-3! items-stretch">
<Input
key={`max-${form_key}`}
label="Longest runbook"
type="number"
min={0}
max={overview?.max}
step={1}
numberText
rawNumber
defaultValue={body_max_chars}
onChange={(event) => {
setBodyMaxChars(event.target.value);
setDirty(true);
setStatus(undefined);
}}
suffix={`characters`}
info={`How long a skill's runbook may be when it's saved or proposed. A skill already longer keeps working until its runbook is edited. 0 uses the default (${overview?.body_max_default ?? ""}).`}
/>
<Input
key={`shown-${form_key}`}
label="Runbook a bot reads at once"
type="number"
min={0}
max={overview?.max}
step={1}
numberText
rawNumber
defaultValue={body_shown_chars}
onChange={(event) => {
setBodyShownChars(event.target.value);
setDirty(true);
setStatus(undefined);
}}
suffix={`characters`}
info={`How much of a runbook a bot gets when it loads the skill; past that it's cut and the bot is told so. All of it goes into the model's context. 0 uses the default (${overview?.body_shown_default ?? ""}).`}
/>
</Stack>
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={status}
save_label="Save limits"
onDiscard={discard}
/>
</Form>
);
}
@@ -0,0 +1,25 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import SettingsGroup from "../../(partials)/settings-group";
import SkillLimitsForm from "../(partials)/skill-limits-form";
/**
* How long a skill's runbook may be, and how much of it a bot reads at
* once. Only the super admin sees it, since it changes every skill.
*/
export default function SkillLimitsSection() {
const { pageProps } = useContext(AppContext);
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
return (
<SettingsGroup
title="Skill limits"
description="How long a skill's runbook may be, and how much of it a bot reads when it loads the skill. A longer read costs more context on every load."
id="skill-limits"
>
<SkillLimitsForm limits={pageProps?.skill_limits} />
</SettingsGroup>
);
}
@@ -2,6 +2,7 @@ import AppearanceSection from "./(sections)/appearance-section";
import NotificationsSection from "./(sections)/notifications-section"; import NotificationsSection from "./(sections)/notifications-section";
import PromptSection from "./(sections)/prompt-section"; import PromptSection from "./(sections)/prompt-section";
import RunLimitsSection from "./(sections)/run-limits-section"; import RunLimitsSection from "./(sections)/run-limits-section";
import SkillLimitsSection from "./(sections)/skill-limits-section";
import StorageSection from "./(sections)/storage-section"; import StorageSection from "./(sections)/storage-section";
import VersionSection from "./(sections)/version-section"; import VersionSection from "./(sections)/version-section";
import SettingsShell from "../(partials)/settings-shell"; import SettingsShell from "../(partials)/settings-shell";
@@ -17,6 +18,7 @@ export default function AdminSettingsGeneralPage() {
<NotificationsSection /> <NotificationsSection />
<PromptSection /> <PromptSection />
<RunLimitsSection /> <RunLimitsSection />
<SkillLimitsSection />
<StorageSection /> <StorageSection />
<VersionSection /> <VersionSection />
</SettingsShell> </SettingsShell>
@@ -6,15 +6,18 @@ import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input"; import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea"; import Textarea from "@moduletrace/twui/form/Textarea";
import CodeEditor from "@/src/components/general/code-editor"; import CodeEditor from "@/src/components/general/code-editor";
import { AppData } from "@/src/data/app-data";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { PublicSkill, SkillsOverviewPayload } from "@/src/types"; import type { PublicSkill, SkillLimitsOverview, SkillsOverviewPayload } from "@/src/types";
type Props = { type Props = {
/** /**
* Edits this skill; adds one when not given. * Edits this skill; adds one when not given.
*/ */
skill?: PublicSkill; skill?: PublicSkill;
/**
* The install's runbook limits, for the hint.
*/
limits: SkillLimitsOverview;
onSaved: (overview: SkillsOverviewPayload, msg: string) => void; onSaved: (overview: SkillsOverviewPayload, msg: string) => void;
onCancel: () => void; onCancel: () => void;
}; };
@@ -24,7 +27,7 @@ type Props = {
* loads it by, the one line that tells the bot when it applies, and the * loads it by, the one line that tells the bot when it applies, and the
* runbook itself in markdown. * runbook itself in markdown.
*/ */
export default function SkillModal({ skill, onSaved, onCancel }: Props) { export default function SkillModal({ skill, limits, onSaved, onCancel }: Props) {
const [name, setName] = useState(skill?.name || ""); const [name, setName] = useState(skill?.name || "");
const [slug, setSlug] = useState(skill?.slug || ""); const [slug, setSlug] = useState(skill?.slug || "");
const [description, setDescription] = useState(skill?.description || ""); const [description, setDescription] = useState(skill?.description || "");
@@ -103,7 +106,7 @@ export default function SkillModal({ skill, onSaved, onCancel }: Props) {
className="h-80" className="h-80"
title="Runbook" title="Runbook"
content={body} content={body}
info={`Markdown. The steps, the checks, and what good looks like. A bot reads it only when it loads the skill, up to ${AppData.SkillBodyShownChars} characters of it at a time.`} info={`Markdown. The steps, the checks, and what good looks like. Up to ${limits.body_max_chars} characters. A bot reads it only when it loads the skill, up to ${limits.body_shown_chars} characters of it at a time.`}
onChange={(value) => setBody(value)} onChange={(value) => setBody(value)}
/> />
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} /> <Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
@@ -3,7 +3,7 @@ import Stack from "@moduletrace/twui/layout/Stack";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile"; import IconTile from "@/src/components/general/icon-tile";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ExtensionTargets, PublicSkill, SkillsOverviewPayload } from "@/src/types"; import type { ExtensionTargets, PublicSkill, SkillLimitsOverview, SkillsOverviewPayload } from "@/src/types";
import ExtensionAccessModal from "../../(partials)/extension-access-modal"; import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row"; import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action"; import SettingsRowAction from "../../(partials)/settings-row-action";
@@ -13,6 +13,7 @@ import SkillModal from "./skill-modal";
type Props = { type Props = {
skill: PublicSkill; skill: PublicSkill;
targets: ExtensionTargets; targets: ExtensionTargets;
limits: SkillLimitsOverview;
onChanged: (overview: SkillsOverviewPayload, msg: string) => void; onChanged: (overview: SkillsOverviewPayload, msg: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
}; };
@@ -21,7 +22,7 @@ type Props = {
* One skill: its slug and when it applies, and who gets it. Edit and Access * One skill: its slug and when it applies, and who gets it. Edit and Access
* open in a modal. * open in a modal.
*/ */
export default function SkillRow({ skill, targets, onChanged, onError }: Props) { export default function SkillRow({ skill, targets, limits, onChanged, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access">(); const [open, setOpen] = useState<"edit" | "access">();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -73,7 +74,7 @@ export default function SkillRow({ skill, targets, onChanged, onError }: Props)
) : null} ) : null}
</SettingsListRow> </SettingsListRow>
{open == "edit" ? ( {open == "edit" ? (
<SkillModal skill={skill} onSaved={saved} onCancel={() => setOpen(undefined)} /> <SkillModal skill={skill} limits={limits} onSaved={saved} onCancel={() => setOpen(undefined)} />
) : null} ) : null}
{open == "access" ? ( {open == "access" ? (
<ExtensionAccessModal<SkillsOverviewPayload> <ExtensionAccessModal<SkillsOverviewPayload>
@@ -41,6 +41,7 @@ export default function SkillsSettingsSection() {
> >
{adding ? ( {adding ? (
<SkillModal <SkillModal
limits={overview.limits}
onSaved={(next, msg) => { onSaved={(next, msg) => {
setAdding(false); setAdding(false);
changed(next, msg); changed(next, msg);
@@ -54,6 +55,7 @@ export default function SkillsSettingsSection() {
key={`${skill.id}-${version}`} key={`${skill.id}-${version}`}
skill={skill} skill={skill}
targets={overview} targets={overview}
limits={overview.limits}
onChanged={changed} onChanged={changed}
onError={failed} onError={failed}
/> />
+4 -3
View File
@@ -1,5 +1,5 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import createDataBackup from "@/src/functions/backend/backups/create-data-backup"; import writeDataBackupArchive from "@/src/functions/backend/backups/write-data-backup-archive";
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run"; import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger"; import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -12,7 +12,8 @@ import type {
/** /**
* Starts archiving the whole `DATA_DIR` (Settings > Backups > Data * Starts archiving the whole `DATA_DIR` (Settings > Backups > Data
* archives) and answers at once: the page polls `data-status` until the * archives) and answers at once: the page polls `data-status` until the
* archive is written. * archive is written. `startDataBackupRun` holds the backup run lock, so
* the task calls the unlocked writer.
*/ */
export const handler: BunextAPIRouteHandler< export const handler: BunextAPIRouteHandler<
APIResponseObject<DataBackupRun> APIResponseObject<DataBackupRun>
@@ -33,7 +34,7 @@ export const handler: BunextAPIRouteHandler<
kind: "archive", kind: "archive",
task: async () => { task: async () => {
try { try {
const backup = await createDataBackup(); const backup = await writeDataBackupArchive();
await logEvent({ await logEvent({
title: "Data backup created", title: "Data backup created",
+62
View File
@@ -0,0 +1,62 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { LogCategories } from "@/src/dict/log-categories";
import { ClearLogsRequestSchema } from "@/src/schemas/log-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Super admin only: deletes every log in the Activity page's open tab (a
* category, or all of them), then logs that it did, so wiping the audit
* trail still leaves a trace.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<{ deleted: number }>> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") throw new AppError(`Method not allowed`);
const user = await grabSuperAdminUser({ req });
if (!user?.id) throw new AppError(`Only a super admin can delete logs.`);
const { category } = parseWithSchema({ schema: ClearLogsRequestSchema, data: body });
const counted = await BunSQLite.sql<{ count: number }>({
sql: category
? `SELECT COUNT(*) AS count FROM logs WHERE category = ?`
: `SELECT COUNT(*) AS count FROM logs`,
values: category ? [category] : [],
});
const deleted = Number(counted.payload?.[0]?.count) || 0;
const res = await BunSQLite.sql({
sql: category ? `DELETE FROM logs WHERE category = ?` : `DELETE FROM logs`,
values: category ? [category] : [],
});
if (!res.success) throw new Error(`Logs not deleted: ${res.msg || res.error}`);
const category_title = category
? LogCategories.find((lc) => lc.value == category)?.title || category
: "All";
await logEvent({
title: "Logs deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted ${deleted} log${deleted == 1 ? "" : "s"} (${category_title}).`,
metadata: { category: category || null, deleted },
ip_address: grabRequestIP(req),
});
return { success: true, singleRes: { deleted } };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,57 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabSkillLimitsOverview, {
type SkillLimitsOverview,
} from "@/src/functions/backend/db/app-settings/grab-skill-limits";
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SkillBodyMaxCharsSettingKey, SkillBodyShownCharsSettingKey } from "@/src/dict/skill-dict";
import { SaveSkillLimitsRequestSchema } from "@/src/schemas/extension-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Super admin only: the longest runbook a skill may save, and how much of
* it `load_skill` hands back at once. Zero puts the code's default back for
* that one.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<SkillLimitsOverview>> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") throw new AppError(`Method not allowed`);
const user = await grabSuperAdminUser({ req });
if (!user?.id) throw new AppError(`Only a super admin can change the skill limits.`);
const { body_max_chars, body_shown_chars } = parseWithSchema({
schema: SaveSkillLimitsRequestSchema,
data: body,
});
await saveAppSetting({
key: SkillBodyMaxCharsSettingKey,
value: body_max_chars > 0 ? String(body_max_chars) : null,
});
await saveAppSetting({
key: SkillBodyShownCharsSettingKey,
value: body_shown_chars > 0 ? String(body_shown_chars) : null,
});
await logEvent({
title: "Skill limits changed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} set the skill runbook limit to ${body_max_chars || "default"} characters, ${body_shown_chars || "default"} read at once.`,
metadata: { body_max_chars, body_shown_chars },
ip_address: grabRequestIP(req),
});
return { success: true, singleRes: await grabSkillLimitsOverview() };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+14 -1
View File
@@ -193,11 +193,24 @@ export const SaveSkillRequestSchema = z.object({
.string() .string()
.trim() .trim()
.min(1, "Write the runbook") .min(1, "Write the runbook")
.max(AppData.SkillBodyMaxChars, `Keep the runbook under ${AppData.SkillBodyMaxChars} characters`), /**
* Only the ceiling: the install's own limit is a setting, checked by
* `assertSkillBodyFits` when a runbook is saved or proposed.
*/
.max(AppData.SkillBodyLimitMax, `Keep the runbook under ${AppData.SkillBodyLimitMax} characters`),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
all_bots: z.boolean().default(false), all_bots: z.boolean().default(false),
}); });
/**
* The install's skill limits (Settings > General). Zero puts the code's
* default back for that one.
*/
export const SaveSkillLimitsRequestSchema = z.object({
body_max_chars: z.coerce.number().int().min(0).max(AppData.SkillBodyLimitMax),
body_shown_chars: z.coerce.number().int().min(0).max(AppData.SkillBodyLimitMax),
});
export const SkillIdRequestSchema = z.object({ export const SkillIdRequestSchema = z.object({
skill_id: Id, skill_id: Id,
}); });
+35
View File
@@ -803,8 +803,38 @@ export type McpServersOverviewPayload = ExtensionTargets & {
servers: PublicMcpServer[]; servers: PublicMcpServer[];
}; };
/**
* The install's skill limits (Settings > General, `grab-skill-limits.ts`).
*/
export type SkillLimitsOverview = {
/**
* The longest runbook a skill may save right now.
*/
body_max_chars: number;
/**
* How much of a runbook `load_skill` hands back right now.
*/
body_shown_chars: number;
/**
* The longest runbook when the install saved no limit.
*/
body_max_default: number;
/**
* What `load_skill` hands back when the install saved no limit.
*/
body_shown_default: number;
/**
* The most the settings page accepts for either.
*/
max: number;
};
export type SkillsOverviewPayload = ExtensionTargets & { export type SkillsOverviewPayload = ExtensionTargets & {
skills: PublicSkill[]; skills: PublicSkill[];
/**
* The install's runbook limits, for the skill form's hints.
*/
limits: SkillLimitsOverview;
}; };
export type CustomToolsOverviewPayload = ExtensionTargets & { export type CustomToolsOverviewPayload = ExtensionTargets & {
@@ -1403,6 +1433,11 @@ export type PagePropsType = {
reply_default: number; reply_default: number;
max: number; max: number;
} | null; } | null;
/**
* The longest runbook a skill may save and how much of it a bot reads
* at once, with the code's defaults (the General settings page).
*/
skill_limits?: SkillLimitsOverview | null;
/** /**
* The release the server is running, for the General settings page. * The release the server is running, for the General settings page.
*/ */