Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
@@ -7,7 +7,7 @@ type Params = {
};
/**
* Removes a provider and its models. Harnesses (default and per bot) that
* Removes a provider and its models. Harnesses (default, per bot and per workspace) that
* picked one of its models, or were limited to it, go back to automatic
* on any provider.
*/
@@ -18,7 +18,7 @@ export default async function deleteHarnessProvider({ provider_id }: Params) {
throw new AppError(`Provider not found.`);
}
for (const table of ["harness_settings", "bot_harness_settings"]) {
for (const table of ["harness_settings", "bot_harness_settings", "workspace_harness_settings"]) {
await BunSQLite.sql({
sql: `UPDATE ${table} SET model_id = NULL, provider_id = NULL, updated_at = ?
WHERE provider_id = ? OR model_id IN (SELECT id FROM harness_models WHERE provider_id = ?)`,
@@ -8,23 +8,44 @@ type Params = {
/**
* A worker bot the caller has checked belongs to the session user.
*/
bot_id: number;
bot_id?: number;
/**
* A workspace the caller has checked belongs to the session user. Then
* the settings shown are the workspace's own, over what it inherits:
* the bot's (when given) or the defaults.
*/
workspace_id?: number;
};
/**
* A worker bot's model settings page: per harness, the bot's own setting
* (or the default it inherits), the models it can pick, what it runs on
* now, and what the default runs on.
* A worker bot's (or a workspace's) model settings: per harness, its own
* setting (or what it inherits), the models it can pick, what it runs on
* now, and what it would run on without its own setting.
*/
export default async function grabBotHarnessOverview({
bot_id,
workspace_id,
}: Params): Promise<BotHarnessOverviewPayload> {
/**
* In a workspace, "the default" is what a run there gets without the
* workspace's own setting: the bot's, else the defaults.
*/
const inherited_bot_id = workspace_id ? bot_id : undefined;
const [own, defaults] = await Promise.all([
Promise.all(
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value, bot_id })),
Harnesses.map((harness) =>
grabHarnessSetting({
harness: harness.value,
bot_id: workspace_id ? undefined : bot_id,
workspace_id,
}),
),
),
Promise.all(
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
Harnesses.map((harness) =>
grabHarnessSetting({ harness: harness.value, bot_id: inherited_bot_id }),
),
),
]);
@@ -38,11 +59,18 @@ export default async function grabBotHarnessOverview({
harnesses: await Promise.all(
own.map(async (setting, index) => {
const [item, default_item] = await Promise.all([
grabHarnessOverviewItem({ harness: setting.harness, setting, entries, bot_id }),
grabHarnessOverviewItem({
harness: setting.harness,
setting,
entries,
bot_id,
workspace_id,
}),
grabHarnessOverviewItem({
harness: setting.harness,
setting: defaults[index]!,
entries,
bot_id: inherited_bot_id,
}),
]);
@@ -14,6 +14,7 @@ type Params = {
* Resolve what runs now for this bot instead of the default.
*/
bot_id?: number | string;
workspace_id?: number | string;
};
/**
@@ -25,10 +26,11 @@ export default async function grabHarnessOverviewItem({
setting,
entries,
bot_id,
workspace_id,
}: Params): Promise<HarnessOverview> {
const meta = Harnesses.find((h) => h.value == harness)!;
const candidates = setting.enabled
? await resolveHarnessCandidates({ harness, bot_id }).catch(() => [])
? await resolveHarnessCandidates({ harness, bot_id, workspace_id }).catch(() => [])
: [];
const first = candidates[0];
const serving = entries.filter(({ model }) => modelServesHarness({ model, harness }));
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS,
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS,
} from "@/db/types/db";
import type { HarnessKind } from "@/src/dict/harness-dict";
import parseJobJson from "@/src/bots-queue/parse-job-json";
@@ -22,18 +23,24 @@ export type HarnessSetting = {
enabled: boolean;
options: Record<string, unknown>;
/**
* `bot` when a worker bot overrides the default for this harness.
* `workspace` when the call's workspace sets this harness, `bot` when a
* worker bot overrides the default for it.
*/
source: "default" | "bot";
source: "default" | "bot" | "workspace";
};
type Params = {
harness: HarnessKind;
/**
* The bot the call is for. A worker bot's own setting wins; the
* coordinator always uses the default.
* The bot the call is for. A worker bot's own setting wins over the
* default; the coordinator always uses the default.
*/
bot_id?: number | string;
/**
* The workspace the call is in. Its setting wins over the bot's, for
* the coordinator too.
*/
workspace_id?: number | string | null;
};
type SettingRow = Pick<
@@ -42,13 +49,23 @@ type SettingRow = Pick<
>;
/**
* A harness's setting for a call. With no row it is automatic on the
* default provider (OpenRouter), on, with fallback. Text is always on.
* A harness's setting for a call: the workspace's, else the bot's, else
* the default. With no row it is automatic on the default provider
* (OpenRouter), on, with fallback. Text is always on.
*/
export default async function grabHarnessSetting({
harness,
bot_id,
workspace_id,
}: Params): Promise<HarnessSetting> {
const workspace_row = workspace_id
? await grabWorkspaceOverride({ harness, workspace_id })
: undefined;
if (workspace_row) {
return toSetting({ harness, row: workspace_row, source: "workspace" });
}
const bot_row = bot_id
? await grabBotOverride({ harness, bot_id })
: undefined;
@@ -74,6 +91,21 @@ export default async function grabHarnessSetting({
return toSetting({ harness, row, source: "default" });
}
async function grabWorkspaceOverride({
harness,
workspace_id,
}: {
harness: HarnessKind;
workspace_id: number | string;
}) {
return (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS>({
sql: `SELECT * FROM workspace_harness_settings WHERE workspace_id = ? AND harness = ? LIMIT 1`,
values: [workspace_id, harness],
})
).payload?.[0];
}
async function grabBotOverride({
harness,
bot_id,
@@ -0,0 +1,87 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { HarnessKind } from "@/src/dict/harness-dict";
import AppError from "@/src/utils/app-error";
import assertHarnessModel from "./assert-harness-model";
import assertHarnessProvider from "./assert-harness-provider";
type Params = {
/**
* A workspace the caller has checked belongs to the session user.
*/
workspace_id: number;
harness: HarnessKind;
/**
* Drop the workspace's setting: each bot uses its own or the default
* again.
*/
inherit: boolean;
model_id: number | null;
/**
* Limits the harness to one provider; null: any.
*/
provider_id?: number | null;
fallback: boolean;
enabled: boolean;
options?: Record<string, unknown>;
};
/**
* Saves (or, with `inherit`, removes) a workspace's setting for one
* harness, which wins over its bots' own. Same rules as the default: the
* model must be available and fit the job, and text can't be turned off.
*/
export default async function saveWorkspaceHarnessSetting({
workspace_id,
harness,
inherit,
model_id,
provider_id = null,
fallback,
enabled,
options,
}: Params) {
if (inherit) {
await BunSQLite.sql({
sql: `DELETE FROM workspace_harness_settings WHERE workspace_id = ? AND harness = ?`,
values: [workspace_id, harness],
});
return;
}
if (harness == "text" && !enabled) {
throw new AppError(`The text model can't be turned off.`);
}
if (provider_id) {
await assertHarnessProvider({ provider_id });
}
if (model_id) {
await assertHarnessModel({ harness, model_id, provider_id });
}
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO workspace_harness_settings (created_at, updated_at, workspace_id, harness, model_id, provider_id, fallback, enabled, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id, harness) DO UPDATE SET updated_at = excluded.updated_at,
model_id = excluded.model_id, provider_id = excluded.provider_id, fallback = excluded.fallback,
enabled = excluded.enabled, options = excluded.options`,
values: [
now,
now,
workspace_id,
harness,
model_id,
provider_id,
fallback ? 1 : 0,
enabled ? 1 : 0,
JSON.stringify(options || {}),
],
});
if (!res.success) {
throw new Error(`Couldn't save the workspace's model setting: ${res.error}`);
}
}