Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
410d05e0b1 | ||
|
|
19fac97d18 | ||
|
|
ea62dd11f0 | ||
|
|
4161863587 | ||
|
|
86becd50bc | ||
|
|
fefc295d15 | ||
|
|
e6c278198b | ||
|
|
3a9e57f682 | ||
|
|
b0fe619ae8 | ||
|
|
c13b9ea826 | ||
|
|
feec69adca | ||
|
|
d542b4e9ad | ||
|
|
cdb93ac1bc | ||
|
|
2e8e8de976 | ||
|
|
e75ab28041 | ||
|
|
4261535741 | ||
|
|
0166f31cfc | ||
|
|
caa9ad87e4 | ||
|
|
30a4df183c | ||
|
|
ee687e1d31 | ||
|
|
024d847b9a |
@@ -126,6 +126,10 @@ point that at `http://<this machine>:7227`:
|
|||||||
sign-in cookies `Secure`; Caddy does this by default, and nginx needs
|
sign-in cookies `Secure`; Caddy does this by default, and nginx needs
|
||||||
`proxy_set_header X-Forwarded-Proto $scheme;`.
|
`proxy_set_header X-Forwarded-Proto $scheme;`.
|
||||||
- Pass `/ws` through as well as ordinary requests, including the upgrade.
|
- Pass `/ws` through as well as ordinary requests, including the upgrade.
|
||||||
|
- Keep the address the browser used in `Host` (or send it as
|
||||||
|
`X-Forwarded-Host`). Live updates and the computers' terminals only accept
|
||||||
|
a websocket from the app's own address; Caddy and Cloudflare Tunnel keep
|
||||||
|
it, and nginx needs `proxy_set_header Host $host;`.
|
||||||
- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
|
- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
|
||||||
the installable app, offline support, Web Push notifications and voice
|
the installable app, offline support, Web Push notifications and voice
|
||||||
recording stay off until the address is HTTPS.
|
recording stay off until the address is HTTPS.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||||
"@moduletrace/bun-sqlite": "^1.3.0",
|
"@moduletrace/bun-sqlite": "^1.3.0",
|
||||||
"@moduletrace/bunext": "^1.3.3",
|
"@moduletrace/bunext": "^1.3.3",
|
||||||
"@moduletrace/twui": "^0.1.6",
|
"@moduletrace/twui": "^0.1.7",
|
||||||
"@tiptap/extension-bubble-menu": "3.32.0",
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
||||||
"@tiptap/extension-mention": "3.32.0",
|
"@tiptap/extension-mention": "3.32.0",
|
||||||
"@tiptap/extension-placeholder": "3.32.0",
|
"@tiptap/extension-placeholder": "3.32.0",
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
|
|
||||||
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
|
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
|
||||||
|
|
||||||
"@moduletrace/twui": ["@moduletrace/[email protected].6", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.6/twui-0.1.6.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-BFNIV1itkIYXmhq1rBJA8E8MjwN9AP7MESbOnzM0yUb6xbsOFIBbmlKfUu6kTr7QCl1TWNxWTGFPVmQ2S54cDA=="],
|
"@moduletrace/twui": ["@moduletrace/[email protected].7", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.7/twui-0.1.7.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-/Y9QyhtQsoEAJO+ahlGYQaYMx3gUWS+DlSkaBVjAIY5V9OQ6i7qhhqMNcmnfOi9m3eyAze1uW00jox+RDUqLpA=="],
|
||||||
|
|
||||||
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
|
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
|
||||||
|
|
||||||
|
|||||||
+150
-1
@@ -1,4 +1,5 @@
|
|||||||
import { LogCategories } from "@/src/dict/log-categories";
|
import { LogCategories } from "@/src/dict/log-categories";
|
||||||
|
import { BotTypes } from "@/src/dict/bot-types-dict";
|
||||||
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
|
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
|
||||||
import {
|
import {
|
||||||
BotFaces,
|
BotFaces,
|
||||||
@@ -230,7 +231,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "kind",
|
fieldName: "kind",
|
||||||
fieldDescription: `One coordinator per owner; everything else is a worker`,
|
fieldDescription: `Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from \`bot_types\``,
|
||||||
dataType: "TEXT",
|
dataType: "TEXT",
|
||||||
options: BotKinds.map((bk) => bk.value),
|
options: BotKinds.map((bk) => bk.value),
|
||||||
defaultValue: "worker",
|
defaultValue: "worker",
|
||||||
@@ -439,6 +440,13 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
options: [0, 1],
|
options: [0, 1],
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "interim",
|
||||||
|
fieldDescription: `Sent mid-reply with send_message (an acknowledgement, a heads-up): the bot is still working on the reply, so the thinking row stays up while its face is working`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "reply_to_message_id",
|
fieldName: "reply_to_message_id",
|
||||||
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
|
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
|
||||||
@@ -1224,6 +1232,60 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_types",
|
||||||
|
tableDescription: `A bot's types, like \`user_types\` for users: what it can do (\`BotTypePermissionMatrix\`). A row with no workspace holds everywhere (the coordinator); one with a workspace only there (a workspace admin). A bot with no row is a worker. One per bot, type and workspace, enforced in \`setBotType\` since SQLite treats NULL ids as distinct`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_types_bot_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "bot_type",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotTypes.map((bt) => bt.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The workspace the type holds in; null for install-wide`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_types_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "granted_by_user_id",
|
||||||
|
fieldDescription: `The user who granted it (or for whom the granting bot ran). Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "granted_by_bot_id",
|
||||||
|
fieldDescription: `The bot that granted it from chat (the coordinator), if any. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_types_bot_id_workspace_id",
|
||||||
|
indexTableFields: ["bot_id", "workspace_id"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_types_workspace_id",
|
||||||
|
indexTableFields: ["workspace_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "workspace_users",
|
tableName: "workspace_users",
|
||||||
tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`,
|
tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`,
|
||||||
@@ -2209,6 +2271,88 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "extension_proposals",
|
||||||
|
tableDescription: `An MCP server, custom tool or skill the coordinator proposed adding or changing, waiting for the super admin to confirm or reject it from the chat. Nothing is saved, connected or run until they confirm: the card shows where it sends, which envs it reads (saved or missing), for a skill what its runbook tells bots to do and what changed, and the bot's background check`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "kind",
|
||||||
|
fieldDescription: `mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: ["mcp", "tool", "skill"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "extension_id",
|
||||||
|
fieldDescription: `The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "name",
|
||||||
|
fieldDescription: `The server's or tool's name, for the chip`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "data",
|
||||||
|
fieldDescription: `JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "research",
|
||||||
|
fieldDescription: `The bot's background check, markdown: who publishes it, whether it's official, what it needs`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "sources",
|
||||||
|
fieldDescription: `JSON array of the URLs the background check read`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "status",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: ["pending", "confirmed", "rejected", "replaced", "failed"],
|
||||||
|
defaultValue: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "outcome",
|
||||||
|
fieldDescription: `What happened on confirm (saved, connected, keys missing) or why it failed`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "result_id",
|
||||||
|
fieldDescription: `The server or tool it saved, once confirmed`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "expires_at",
|
||||||
|
fieldDescription: `A pending proposal can't be confirmed after this (Unix ms)`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "decided_at",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `The super admin whose conversation it was proposed in`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
fieldDescription: `The bot that proposed it (the coordinator). Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The conversation it was proposed in, where the outcome is reported. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The session's workspace, null for the bot's own chat. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "bot_mcp_servers",
|
tableName: "bot_mcp_servers",
|
||||||
tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
|
tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
|
||||||
@@ -2985,6 +3129,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
fieldDescription: `The provider's price in USD, when it reports one`,
|
fieldDescription: `The provider's price in USD, when it reports one`,
|
||||||
dataType: "REAL",
|
dataType: "REAL",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "settings",
|
||||||
|
fieldDescription: `JSON (\`HarnessCallSettings\`): how the call was made. The generation settings sent (temperature, reasoning effort, max tokens, voice), where they came from, whether the model was picked or automatic, and the models that failed before it`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
|
|||||||
+110
-2
@@ -19,6 +19,7 @@ export const BunSQLiteTables = [
|
|||||||
"cron_jobs",
|
"cron_jobs",
|
||||||
"workspaces",
|
"workspaces",
|
||||||
"workspace_bots",
|
"workspace_bots",
|
||||||
|
"bot_types",
|
||||||
"workspace_users",
|
"workspace_users",
|
||||||
"bot_users",
|
"bot_users",
|
||||||
"bot_user_memories",
|
"bot_user_memories",
|
||||||
@@ -36,6 +37,7 @@ export const BunSQLiteTables = [
|
|||||||
"mcp_servers",
|
"mcp_servers",
|
||||||
"custom_tools",
|
"custom_tools",
|
||||||
"skills",
|
"skills",
|
||||||
|
"extension_proposals",
|
||||||
"bot_mcp_servers",
|
"bot_mcp_servers",
|
||||||
"workspace_mcp_servers",
|
"workspace_mcp_servers",
|
||||||
"bot_custom_tools",
|
"bot_custom_tools",
|
||||||
@@ -192,7 +194,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
|
|||||||
user_id?: number | "";
|
user_id?: number | "";
|
||||||
name?: string;
|
name?: string;
|
||||||
/**
|
/**
|
||||||
* One coordinator per owner; everything else is a worker
|
* Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from `bot_types`
|
||||||
*/
|
*/
|
||||||
kind?: "worker" | "coordinator" | "";
|
kind?: "worker" | "coordinator" | "";
|
||||||
/**
|
/**
|
||||||
@@ -309,6 +311,10 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
|
|||||||
* Renders the Needs you chip under the message
|
* Renders the Needs you chip under the message
|
||||||
*/
|
*/
|
||||||
needs_you?: 0 | 1 | "";
|
needs_you?: 0 | 1 | "";
|
||||||
|
/**
|
||||||
|
* Sent mid-reply with send_message (an acknowledgement, a heads-up): the bot is still working on the reply, so the thinking row stays up while its face is working
|
||||||
|
*/
|
||||||
|
interim?: 0 | 1 | "";
|
||||||
/**
|
/**
|
||||||
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
|
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
|
||||||
*/
|
*/
|
||||||
@@ -762,6 +768,35 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
|
|||||||
memory?: string;
|
memory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_TYPES = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
bot_id?: number | "";
|
||||||
|
bot_type?: "coordinator" | "admin" | "worker" | "";
|
||||||
|
/**
|
||||||
|
* The workspace the type holds in; null for install-wide
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The user who granted it (or for whom the granting bot ran). Not a foreign key
|
||||||
|
*/
|
||||||
|
granted_by_user_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot that granted it from chat (the coordinator), if any. Not a foreign key
|
||||||
|
*/
|
||||||
|
granted_by_bot_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
|
export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1359,6 +1394,75 @@ export type BUN_SQLITE_COBBLE_SKILLS = {
|
|||||||
user_id?: number | "";
|
user_id?: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)
|
||||||
|
*/
|
||||||
|
kind?: "mcp" | "tool" | "skill" | "";
|
||||||
|
/**
|
||||||
|
* The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm
|
||||||
|
*/
|
||||||
|
extension_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The server's or tool's name, for the chip
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)
|
||||||
|
*/
|
||||||
|
data?: string;
|
||||||
|
/**
|
||||||
|
* The bot's background check, markdown: who publishes it, whether it's official, what it needs
|
||||||
|
*/
|
||||||
|
research?: string;
|
||||||
|
/**
|
||||||
|
* JSON array of the URLs the background check read
|
||||||
|
*/
|
||||||
|
sources?: string;
|
||||||
|
status?: "pending" | "confirmed" | "rejected" | "replaced" | "failed" | "";
|
||||||
|
/**
|
||||||
|
* What happened on confirm (saved, connected, keys missing) or why it failed
|
||||||
|
*/
|
||||||
|
outcome?: string;
|
||||||
|
/**
|
||||||
|
* The server or tool it saved, once confirmed
|
||||||
|
*/
|
||||||
|
result_id?: number | "";
|
||||||
|
/**
|
||||||
|
* A pending proposal can't be confirmed after this (Unix ms)
|
||||||
|
*/
|
||||||
|
expires_at?: number | "";
|
||||||
|
decided_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The super admin whose conversation it was proposed in
|
||||||
|
*/
|
||||||
|
user_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot that proposed it (the coordinator). Not a foreign key
|
||||||
|
*/
|
||||||
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The conversation it was proposed in, where the outcome is reported. Not a foreign key
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session's workspace, null for the bot's own chat. Not a foreign key
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
|
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1787,6 +1891,10 @@ export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
|
|||||||
* The provider's price in USD, when it reports one
|
* The provider's price in USD, when it reports one
|
||||||
*/
|
*/
|
||||||
cost?: number | "";
|
cost?: number | "";
|
||||||
|
/**
|
||||||
|
* JSON (`HarnessCallSettings`): how the call was made. The generation settings sent (temperature, reasoning effort, max tokens, voice), where they came from, whether the model was picked or automatic, and the models that failed before it
|
||||||
|
*/
|
||||||
|
settings?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
|
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
|
||||||
@@ -1872,4 +1980,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cobble",
|
"name": "cobble",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||||
"@moduletrace/bun-sqlite": "^1.3.0",
|
"@moduletrace/bun-sqlite": "^1.3.0",
|
||||||
"@moduletrace/bunext": "^1.3.3",
|
"@moduletrace/bunext": "^1.3.3",
|
||||||
"@moduletrace/twui": "^0.1.6",
|
"@moduletrace/twui": "^0.1.7",
|
||||||
"@tiptap/extension-bubble-menu": "3.32.0",
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
||||||
"@tiptap/extension-mention": "3.32.0",
|
"@tiptap/extension-mention": "3.32.0",
|
||||||
"@tiptap/extension-placeholder": "3.32.0",
|
"@tiptap/extension-placeholder": "3.32.0",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import hasUnansweredMessage from "./has-unanswered-message";
|
||||||
|
|
||||||
|
const user = (id: number) => ({ id, author: "user" as const });
|
||||||
|
const bot = (id: number) => ({ id, author: "bot" as const });
|
||||||
|
|
||||||
|
describe("hasUnansweredMessage", () => {
|
||||||
|
test("a message sent while the last reply ran is answered, though the thread ends with the bot", () => {
|
||||||
|
/**
|
||||||
|
* A (10) was being answered when B (11) came in; the reply (12)
|
||||||
|
* was saved after B but its run never read it.
|
||||||
|
*/
|
||||||
|
expect(hasUnansweredMessage({ history: [user(10), user(11), bot(12)], answered_id: 10 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a message the last reply took in is not answered twice", () => {
|
||||||
|
expect(hasUnansweredMessage({ history: [user(10), user(11), bot(12)], answered_id: 11 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a burst after the answer is answered", () => {
|
||||||
|
expect(
|
||||||
|
hasUnansweredMessage({ history: [user(10), bot(12), user(13), user(14)], answered_id: 10 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a record, the user having the last word decides", () => {
|
||||||
|
expect(hasUnansweredMessage({ history: [user(1)], answered_id: null })).toBe(true);
|
||||||
|
expect(hasUnansweredMessage({ history: [user(1), bot(2)], answered_id: null })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing from the user, nothing to answer", () => {
|
||||||
|
expect(hasUnansweredMessage({ history: [], answered_id: null })).toBe(false);
|
||||||
|
expect(hasUnansweredMessage({ history: [bot(4)], answered_id: 2 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The session's latest messages, oldest first.
|
||||||
|
*/
|
||||||
|
history: Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "id" | "author">[];
|
||||||
|
/**
|
||||||
|
* The newest user message a finished reply answered
|
||||||
|
* (`grabAnsweredMessageId`), null when none is on record.
|
||||||
|
*/
|
||||||
|
answered_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a reply has anything left to answer. A user message that came in
|
||||||
|
* while the last reply ran is saved before that reply, so the thread ends
|
||||||
|
* with the bot's message even though the bot never read it: what the last
|
||||||
|
* reply read decides, not who wrote last. Without a record (no reply has
|
||||||
|
* finished in the session), the user having the last word does.
|
||||||
|
*/
|
||||||
|
export default function hasUnansweredMessage({ history, answered_id }: Params) {
|
||||||
|
const user_ids = history
|
||||||
|
.filter((message) => message.author == "user")
|
||||||
|
.map((message) => Number(message.id));
|
||||||
|
|
||||||
|
if (!user_ids.length) return false;
|
||||||
|
|
||||||
|
if (answered_id) {
|
||||||
|
return Math.max(...user_ids) > answered_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return history[history.length - 1]?.author == "user";
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import path from "node:path";
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabMessagesMedia, {
|
import grabMessagesMedia, {
|
||||||
type MessageMediaRow,
|
type MessageMediaRow,
|
||||||
} from "@/src/functions/backend/db/bots/grab-messages-media";
|
} from "@/src/functions/backend/db/bots/grab-messages-media";
|
||||||
import type { HarnessContentPart } from "@/src/harness/types";
|
import type { HarnessContentPart } from "@/src/harness/types";
|
||||||
import type { BotMessageWithAttachments } from "@/src/types";
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import describeAttachment from "./describe-attachment";
|
import describeAttachment from "./describe-attachment";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -74,9 +73,10 @@ async function fileToParts({ file }: { file: MessageMediaRow }): Promise<Harness
|
|||||||
return [{ type: "text", text: `[Attached video ${label}: videos can't be read yet.]` }];
|
return [{ type: "text", text: `[Attached video ${label}: videos can't be read yet.]` }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const disk = Bun.file(path.join(grabDirNames().DATA_DIR, file.media_write_path || ""));
|
const absolute = mediaFilePath({ write_path: file.media_write_path });
|
||||||
|
const disk = absolute ? Bun.file(absolute) : null;
|
||||||
|
|
||||||
if (!file.media_write_path || !(await disk.exists())) {
|
if (!disk || !(await disk.exists())) {
|
||||||
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
|
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,38 @@ export default async function processBotJob({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every failed try is on the Activity page, so a model or provider
|
||||||
|
* that keeps failing shows there and not only in one chat.
|
||||||
|
*/
|
||||||
|
if (failed) {
|
||||||
|
const final = failed.status == "failed";
|
||||||
|
|
||||||
|
await logError({
|
||||||
|
title: final
|
||||||
|
? `Bot job #${job_id} failed`
|
||||||
|
: `Bot job #${job_id} failed, retrying (try ${tries})`,
|
||||||
|
error: timed_out ? new Error(timeout_message) : error,
|
||||||
|
user_id: user_id || undefined,
|
||||||
|
details: [
|
||||||
|
{ label: "Bot", value: bot_id },
|
||||||
|
{ label: "Kind", value: job.kind },
|
||||||
|
{ label: "Title", value: job.title || "" },
|
||||||
|
{ label: "Try", value: tries },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
job_id,
|
||||||
|
bot_id,
|
||||||
|
kind: job.kind,
|
||||||
|
session_id: job.session_id,
|
||||||
|
workspace_id: job.workspace_id,
|
||||||
|
schedule_id,
|
||||||
|
fatal: error instanceof BotJobFatalError,
|
||||||
|
harness_code: error instanceof HarnessError ? error.code : undefined,
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (failed?.notice) {
|
if (failed?.notice) {
|
||||||
await pushBotNotice({ bot_id, ...failed.notice });
|
await pushBotNotice({ bot_id, ...failed.notice });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ async function push({
|
|||||||
})
|
})
|
||||||
).payload?.[0];
|
).payload?.[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspaces it leads, so each crew shows its shield.
|
||||||
|
*/
|
||||||
|
const admin_workspace_ids = (
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ workspace_id: number }>({
|
||||||
|
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin' AND workspace_id IS NOT NULL`,
|
||||||
|
values: [bot_id],
|
||||||
|
})
|
||||||
|
).payload || []
|
||||||
|
).map((row) => Number(row.workspace_id));
|
||||||
|
|
||||||
for (const viewer_id of audience) {
|
for (const viewer_id of audience) {
|
||||||
if (users && !users.has(String(viewer_id))) continue;
|
if (users && !users.has(String(viewer_id))) continue;
|
||||||
|
|
||||||
@@ -87,6 +99,7 @@ async function push({
|
|||||||
color: identity?.color || null,
|
color: identity?.color || null,
|
||||||
stone: identity?.stone || null,
|
stone: identity?.stone || null,
|
||||||
image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : null,
|
image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : null,
|
||||||
|
admin_workspace_ids,
|
||||||
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
||||||
...(workspace_ids !== undefined ? { workspace_ids } : {}),
|
...(workspace_ids !== undefined ? { workspace_ids } : {}),
|
||||||
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs";
|
import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs";
|
||||||
import notifyBotJobFailed from "@/src/functions/backend/db/bots-queue/notify-bot-job-failed";
|
import notifyBotJobFailed from "@/src/functions/backend/db/bots-queue/notify-bot-job-failed";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
import pushBotNotice from "./push-bot-notice";
|
import pushBotNotice from "./push-bot-notice";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the user about jobs that ran out of attempts by going silent
|
* Tells the user about jobs that ran out of attempts by going silent
|
||||||
* (bun-sqlite fails those itself, without a message).
|
* (bun-sqlite fails those itself, without a message), and logs each.
|
||||||
*/
|
*/
|
||||||
export default async function reportLostBotJobs() {
|
export default async function reportLostBotJobs() {
|
||||||
const jobs = await grabUnreportedFailedBotJobs();
|
const jobs = await grabUnreportedFailedBotJobs();
|
||||||
@@ -18,5 +19,16 @@ export default async function reportLostBotJobs() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await pushBotNotice({ bot_id: job.bot_id, ...notice });
|
await pushBotNotice({ bot_id: job.bot_id, ...notice });
|
||||||
|
|
||||||
|
await logError({
|
||||||
|
title: `Bot job #${job.id} lost`,
|
||||||
|
error: job.last_error || "The job's lock timed out.",
|
||||||
|
details: [
|
||||||
|
{ label: "Bot", value: job.bot_id },
|
||||||
|
{ label: "Kind", value: job.kind },
|
||||||
|
{ label: "Title", value: job.title || "" },
|
||||||
|
],
|
||||||
|
metadata: { job_id: job.id, bot_id: job.bot_id, kind: job.kind },
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-28
@@ -2,15 +2,20 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
BotFilesNote,
|
botFilesNote,
|
||||||
ChatReplyNote,
|
chatReplyNote,
|
||||||
ComputerFilesNote,
|
ComputerFilesNote,
|
||||||
CoordinatorReplyNote,
|
coordinatorReplyNote,
|
||||||
CoordinatorSoloNote,
|
CoordinatorSoloNote,
|
||||||
TasksNote,
|
TasksNote,
|
||||||
TaskReminderNote,
|
TaskReminderNote,
|
||||||
|
workspaceAdminNote,
|
||||||
WorkspaceLearningNote,
|
WorkspaceLearningNote,
|
||||||
} from "@/src/data/harness-prompts";
|
} from "@/src/data/harness-prompts";
|
||||||
|
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
|
||||||
|
import workspaceAdminsNote from "./workspace-admins-note";
|
||||||
|
import type { WorkspaceAdminTools } from "@/src/harness/tools/bot/grab-workspace-admin-tools";
|
||||||
|
import { grabReplyMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
@@ -27,7 +32,14 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
|||||||
import loadAttachmentParts from "./load-attachment-parts";
|
import loadAttachmentParts from "./load-attachment-parts";
|
||||||
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
|
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
|
||||||
import withTakeOverRefs from "./with-take-over-refs";
|
import withTakeOverRefs from "./with-take-over-refs";
|
||||||
|
import hasUnansweredMessage from "./has-unanswered-message";
|
||||||
|
import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id";
|
||||||
|
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
|
||||||
|
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
|
||||||
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
|
import type { HarnessMessage } from "@/src/harness/types";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
|
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
type Params = BotJobContext & {
|
type Params = BotJobContext & {
|
||||||
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
|
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
|
||||||
@@ -46,8 +58,12 @@ type Params = BotJobContext & {
|
|||||||
* instructions, start_task, and for the coordinator delegate_task) plus
|
* instructions, start_task, and for the coordinator delegate_task) plus
|
||||||
* its MCP servers' and custom tools (`openBotExtensions`, script tools on
|
* its MCP servers' and custom tools (`openBotExtensions`, script tools on
|
||||||
* its current computer).
|
* its current computer).
|
||||||
* Finishes silently when the last message is already the bot's, i.e. an
|
* Messages the user sends while it works join the run (`incoming`): one
|
||||||
* earlier reply answered everything.
|
* that changes the work changes the answer before it goes out, and one
|
||||||
|
* that's separate can be acknowledged at once (`send_message`) and done
|
||||||
|
* after the current work. The job records the newest
|
||||||
|
* one it read (`markReplyAnswered`). Finishes silently when an earlier
|
||||||
|
* reply already read everything (`hasUnansweredMessage`).
|
||||||
*/
|
*/
|
||||||
export default async function runBotReply({
|
export default async function runBotReply({
|
||||||
bot,
|
bot,
|
||||||
@@ -64,10 +80,48 @@ export default async function runBotReply({
|
|||||||
limit: AppData.BotReplyHistoryMessages,
|
limit: AppData.BotReplyHistoryMessages,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (history[history.length - 1]?.author != "user") {
|
const answered_id = await grabAnsweredMessageId({ session_id });
|
||||||
|
|
||||||
|
if (!hasUnansweredMessage({ history, answered_id })) {
|
||||||
return { result: "", silent: true };
|
return { result: "", silent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest message the run has read, and of those the newest user
|
||||||
|
* message, which is what the reply answers.
|
||||||
|
*/
|
||||||
|
let seen_id = Math.max(0, ...history.map((message) => Number(message.id)));
|
||||||
|
let answering_id = newestUserMessageId({ messages: history });
|
||||||
|
|
||||||
|
const toTurns = async ({ messages }: { messages: BotMessageWithAttachments[] }) =>
|
||||||
|
botMessagesToHarness({
|
||||||
|
messages,
|
||||||
|
parts: await loadAttachmentParts({ messages }),
|
||||||
|
refs: await grabMessageRefNames({
|
||||||
|
texts: messages.filter((m) => m.author == "user").map((m) => m.text || ""),
|
||||||
|
user_id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the user wrote since the run last looked.
|
||||||
|
*/
|
||||||
|
const incoming = async (): Promise<HarnessMessage[]> => {
|
||||||
|
const fresh = await grabBotMessages({
|
||||||
|
session_id,
|
||||||
|
after_id: seen_id,
|
||||||
|
author: "user",
|
||||||
|
limit: AppData.BotReplyHistoryMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fresh.length) return [];
|
||||||
|
|
||||||
|
seen_id = Math.max(seen_id, ...fresh.map((message) => Number(message.id)));
|
||||||
|
answering_id = Math.max(answering_id, newestUserMessageId({ messages: fresh }));
|
||||||
|
|
||||||
|
return await toTurns({ messages: fresh });
|
||||||
|
};
|
||||||
|
|
||||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
|
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
|
||||||
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
||||||
|
|
||||||
@@ -83,6 +137,11 @@ export default async function runBotReply({
|
|||||||
}).catch(() => "");
|
}).catch(() => "");
|
||||||
|
|
||||||
const take_overs: string[] = [];
|
const take_overs: string[] = [];
|
||||||
|
/**
|
||||||
|
* The MCP servers and tools the coordinator proposed: their cards need
|
||||||
|
* the user just like a take-over.
|
||||||
|
*/
|
||||||
|
const proposals: string[] = [];
|
||||||
/**
|
/**
|
||||||
* Files the bot wrote and asked to send with its reply.
|
* Files the bot wrote and asked to send with its reply.
|
||||||
*/
|
*/
|
||||||
@@ -103,8 +162,16 @@ export default async function runBotReply({
|
|||||||
onAttach: ({ media_id }) => attachments.push(media_id),
|
onAttach: ({ media_id }) => attachments.push(media_id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the bot's types let it do here, and what it leads when it's this
|
||||||
|
* workspace's admin.
|
||||||
|
*/
|
||||||
|
const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
|
||||||
|
let led: WorkspaceAdminTools | null = null;
|
||||||
|
|
||||||
const tools = await grabBotChatTools({
|
const tools = await grabBotChatTools({
|
||||||
bot,
|
bot,
|
||||||
|
powers,
|
||||||
user_id,
|
user_id,
|
||||||
computers,
|
computers,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -112,12 +179,22 @@ export default async function runBotReply({
|
|||||||
scopes,
|
scopes,
|
||||||
message_id: job.message_id || undefined,
|
message_id: job.message_id || undefined,
|
||||||
onTakeOver: ({ ref }) => take_overs.push(ref),
|
onTakeOver: ({ ref }) => take_overs.push(ref),
|
||||||
|
onPropose: ({ ref }) => proposals.push(ref),
|
||||||
|
onWorkspaceAdmin: (admin) => {
|
||||||
|
led = admin;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the run works on a computer: the user's folder in the bot's.
|
||||||
|
*/
|
||||||
|
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
|
||||||
|
|
||||||
const extensions = await openBotExtensions({
|
const extensions = await openBotExtensions({
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id: computers[0]?.id || null,
|
computer_id: computers[0]?.id || null,
|
||||||
|
dirs,
|
||||||
taken: tools.map((tool) => tool.name),
|
taken: tools.map((tool) => tool.name),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -131,30 +208,35 @@ export default async function runBotReply({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* delegate_task is only there while the coordinator has workers.
|
* delegate_task is only there while the coordinator, or a workspace's
|
||||||
|
* admin, has bots to hand work to. Anyone else in a workspace with an
|
||||||
|
* admin is told who leads it.
|
||||||
*/
|
*/
|
||||||
const crew_note =
|
const can_delegate = tools.some((tool) => tool.name == "delegate_task");
|
||||||
bot.kind != "coordinator"
|
const lead = led as WorkspaceAdminTools | null;
|
||||||
? ""
|
const crew_note = powers.is_coordinator
|
||||||
: tools.some((tool) => tool.name == "delegate_task")
|
? can_delegate
|
||||||
? CoordinatorReplyNote
|
? coordinatorReplyNote({ bot_id: bot.id })
|
||||||
: CoordinatorSoloNote;
|
: CoordinatorSoloNote
|
||||||
|
: lead?.workspace && can_delegate
|
||||||
|
? workspaceAdminNote({ workspace_name: lead.workspace.name || "this workspace", members: lead.members })
|
||||||
|
: await workspaceAdminsNote({ bot_id: bot.id, workspace_id });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runTextLoop({
|
const result = await runTextLoop({
|
||||||
system: [
|
system: [
|
||||||
prompt,
|
prompt,
|
||||||
carry_over,
|
carry_over,
|
||||||
ChatReplyNote,
|
chatReplyNote({ bot_id: bot.id }),
|
||||||
BotFilesNote,
|
botFilesNote({ bot_id: bot.id }),
|
||||||
ComputerFilesNote({ computer_id: computers[0]?.id || 0, can_import: false }),
|
ComputerFilesNote({ computer_id: computers[0]?.id || 0, dirs, can_import: false }),
|
||||||
TasksNote,
|
TasksNote,
|
||||||
TaskReminderNote,
|
TaskReminderNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
composeComputersNote({
|
composeComputersNote({
|
||||||
computers,
|
computers,
|
||||||
is_coordinator: bot.kind == "coordinator",
|
is_coordinator: powers.is_coordinator,
|
||||||
in_workspace: Boolean(workspace_id),
|
in_workspace: Boolean(workspace_id),
|
||||||
}),
|
}),
|
||||||
crew_note,
|
crew_note,
|
||||||
@@ -164,27 +246,38 @@ export default async function runBotReply({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n"),
|
.join("\n\n"),
|
||||||
messages: botMessagesToHarness({
|
messages: await toTurns({ messages: history }),
|
||||||
messages: history,
|
tools: [
|
||||||
parts: await loadAttachmentParts({ messages: history }),
|
...tools,
|
||||||
refs: await grabMessageRefNames({
|
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
|
||||||
texts: history.filter((m) => m.author == "user").map((m) => m.text || ""),
|
...file_tools,
|
||||||
user_id,
|
...extensions.tools,
|
||||||
}),
|
...skills.tools,
|
||||||
}),
|
],
|
||||||
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
|
max_steps: await grabReplyMaxToolSteps(),
|
||||||
max_steps: AppData.BotReplyMaxToolSteps,
|
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
onStep: report,
|
onStep: report,
|
||||||
|
incoming,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (job.id && answering_id) {
|
||||||
|
await markReplyAnswered({ job_id: job.id, message_id: answering_id });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...withTakeOverRefs({ text: result.text, refs: take_overs }),
|
...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }),
|
||||||
attachments: attachments.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await extensions.close();
|
await extensions.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newestUserMessageId({ messages }: { messages: Pick<BotMessageWithAttachments, "id" | "author">[] }) {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
...messages.filter((message) => message.author == "user").map((message) => Number(message.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
|
||||||
|
import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools";
|
||||||
|
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";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
BotFilesNote,
|
botFilesNote,
|
||||||
ComputerFilesNote,
|
ComputerFilesNote,
|
||||||
ScheduledRunNote,
|
ScheduledRunNote,
|
||||||
TasksNote,
|
TasksNote,
|
||||||
TaskRunNote,
|
TaskRunNote,
|
||||||
WorkspaceLearningNote,
|
WorkspaceLearningNote,
|
||||||
} from "@/src/data/harness-prompts";
|
} from "@/src/data/harness-prompts";
|
||||||
|
import { grabHarnessMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
@@ -31,6 +35,7 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
|||||||
import withTakeOverRefs from "./with-take-over-refs";
|
import withTakeOverRefs from "./with-take-over-refs";
|
||||||
import ScheduledRunFailedError from "./scheduled-run-failed-error";
|
import ScheduledRunFailedError from "./scheduled-run-failed-error";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
|
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
type Params = BotJobContext & {
|
type Params = BotJobContext & {
|
||||||
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
|
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
|
||||||
@@ -61,12 +66,17 @@ export default async function runBotTask({
|
|||||||
report,
|
report,
|
||||||
}: Params): Promise<BotJobOutcome> {
|
}: Params): Promise<BotJobOutcome> {
|
||||||
const workspace_id = job.workspace_id || null;
|
const workspace_id = job.workspace_id || null;
|
||||||
|
/**
|
||||||
|
* Where the run works on a computer: the user's folder in the bot's.
|
||||||
|
*/
|
||||||
|
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
|
||||||
|
|
||||||
const computer = job.computer_id
|
const computer = job.computer_id
|
||||||
? await openComputerTools({
|
? await openComputerTools({
|
||||||
computer_id: job.computer_id,
|
computer_id: job.computer_id,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
dirs,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -80,11 +90,13 @@ export default async function runBotTask({
|
|||||||
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A coordinator's task can also change workspaces: the findings
|
* What the bot's types let it do here. A coordinator's task can
|
||||||
* it was asked to record go there, not only in the report.
|
* also change workspaces, and a workspace admin's its workspace:
|
||||||
|
* the findings it was asked to record go there, not only in the
|
||||||
|
* report.
|
||||||
*/
|
*/
|
||||||
const coordinator_workspaces =
|
const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
|
||||||
bot.kind == "coordinator"
|
const coordinator_workspaces = powers.is_coordinator
|
||||||
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
|
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
|
||||||
: [];
|
: [];
|
||||||
const carry_over = await grabSessionCarryOver({
|
const carry_over = await grabSessionCarryOver({
|
||||||
@@ -114,6 +126,16 @@ export default async function runBotTask({
|
|||||||
*/
|
*/
|
||||||
const scopes = await grabRunScopes({ bot, user_id, workspace_id });
|
const scopes = await grabRunScopes({ bot, user_id, workspace_id });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace admin's task keeps the workspace's context and its
|
||||||
|
* members' instructions; it hands nothing over (no session to report
|
||||||
|
* back to mid-task).
|
||||||
|
*/
|
||||||
|
const admin_tools =
|
||||||
|
!powers.is_coordinator && workspace_id && powers.is_workspace_admin
|
||||||
|
? (await grabWorkspaceAdminTools({ bot, user_id, workspace_id, powers, scopes })).tools
|
||||||
|
: [];
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
...(await grabBotTools({
|
...(await grabBotTools({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -121,10 +143,12 @@ export default async function runBotTask({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
computers,
|
computers,
|
||||||
session_id,
|
session_id,
|
||||||
is_coordinator: bot.kind == "coordinator",
|
is_coordinator: powers.is_coordinator,
|
||||||
|
admin_workspace_ids: powers.admin_workspace_ids,
|
||||||
scopes,
|
scopes,
|
||||||
})),
|
})),
|
||||||
...(bot.kind == "coordinator"
|
...admin_tools,
|
||||||
|
...(powers.is_coordinator
|
||||||
? [
|
? [
|
||||||
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
||||||
...(coordinator_workspaces.length
|
...(coordinator_workspaces.length
|
||||||
@@ -141,7 +165,7 @@ export default async function runBotTask({
|
|||||||
: []),
|
: []),
|
||||||
...(computers.length
|
...(computers.length
|
||||||
? [
|
? [
|
||||||
searchComputersTool({ bot_id: bot.id, computers, workspace_id }),
|
searchComputersTool({ bot_id: bot.id, computers, workspace_id, dirs }),
|
||||||
askToTakeOverTool({
|
askToTakeOverTool({
|
||||||
computers: take_over_computers,
|
computers: take_over_computers,
|
||||||
in_task: true,
|
in_task: true,
|
||||||
@@ -158,6 +182,7 @@ export default async function runBotTask({
|
|||||||
scopes,
|
scopes,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
user_id,
|
user_id,
|
||||||
|
dirs,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -175,6 +200,7 @@ export default async function runBotTask({
|
|||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id: job.computer_id || null,
|
computer_id: job.computer_id || null,
|
||||||
|
dirs,
|
||||||
taken: tools.map((tool) => tool.name),
|
taken: tools.map((tool) => tool.name),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -190,14 +216,19 @@ export default async function runBotTask({
|
|||||||
carry_over,
|
carry_over,
|
||||||
TaskRunNote,
|
TaskRunNote,
|
||||||
scheduled ? ScheduledRunNote : "",
|
scheduled ? ScheduledRunNote : "",
|
||||||
BotFilesNote,
|
botFilesNote({ bot_id: bot.id }),
|
||||||
ComputerFilesNote({ computer_id: job.computer_id || 0 }),
|
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
|
||||||
TasksNote,
|
TasksNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
bot.kind == "coordinator"
|
powers.is_coordinator
|
||||||
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
|
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
|
||||||
|
: admin_tools.some((tool) => tool.name == "update_workspace")
|
||||||
|
? `If this brief asked you to record something in this workspace's context, do it yourself with update_workspace before you report: the report alone changes nothing.`
|
||||||
: "",
|
: "",
|
||||||
|
powers.is_coordinator || powers.is_workspace_admin
|
||||||
|
? ""
|
||||||
|
: await workspaceAdminsNote({ bot_id: bot.id, workspace_id }),
|
||||||
computer
|
computer
|
||||||
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
|
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
|
||||||
: workspace_id
|
: workspace_id
|
||||||
@@ -227,6 +258,7 @@ export default async function runBotTask({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tools: [...tools, ...extensions.tools, ...skills.tools],
|
tools: [...tools, ...extensions.tools, ...skills.tools],
|
||||||
|
max_steps: await grabHarnessMaxToolSteps(),
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
type Params = {
|
type Params = {
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/**
|
||||||
* The take-over refs the bot asked for in this run (`askToTakeOverTool`).
|
* The take-over refs the bot asked for in this run (`askToTakeOverTool`),
|
||||||
|
* and the proposals it made for the user to confirm (`proposalRef`).
|
||||||
*/
|
*/
|
||||||
refs: string[];
|
refs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A job's message with every take-over the bot asked for in it: refs the
|
* A job's message with every take-over the bot asked for in it and every
|
||||||
* bot didn't write itself are added at the end, so the user always gets
|
* proposal it made: refs the bot didn't write itself are added at the
|
||||||
* the button. A message asking for a take-over needs the user.
|
* end, so the user always gets the button. A message asking for either
|
||||||
|
* needs the user.
|
||||||
*/
|
*/
|
||||||
export default function withTakeOverRefs({ text, refs }: Params) {
|
export default function withTakeOverRefs({ text, refs }: Params) {
|
||||||
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
|
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { workspaceAdminsLine } from "@/src/data/harness-prompts";
|
||||||
|
import grabWorkspaceAdmins from "@/src/functions/backend/db/bot-types/grab-workspace-admins";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a bot in a workspace that has admins other than itself: the line
|
||||||
|
* naming them (`workspaceAdminsLine`).
|
||||||
|
*/
|
||||||
|
export default async function workspaceAdminsNote({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: {
|
||||||
|
bot_id: number | string | "" | undefined;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
}) {
|
||||||
|
if (!workspace_id) return "";
|
||||||
|
|
||||||
|
const admins = (await grabWorkspaceAdmins({ workspace_id })).filter((admin) => admin.id != Number(bot_id));
|
||||||
|
|
||||||
|
return admins.length ? workspaceAdminsLine({ admins }) : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||||
|
import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail";
|
||||||
|
import Divider from "@moduletrace/twui/layout/Divider";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The address the page is at, e.g. `/admin/settings/bots/3`.
|
||||||
|
*/
|
||||||
|
pathname?: string | null;
|
||||||
|
/**
|
||||||
|
* The page's own name, for the last crumb: what the address can't say,
|
||||||
|
* since its last segment is an id (`Scout`, not `3`) or a slug that
|
||||||
|
* differs from the name the page shows (`MCPs`, not `Mcps`).
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trail above a page's content: `Home`, then every section the address
|
||||||
|
* passes through, then the page itself, named by `title`.
|
||||||
|
*
|
||||||
|
* It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the
|
||||||
|
* sections come from the address and only the page's own name is given
|
||||||
|
* here; `hasBreadcrumbTrail` says which pages get one at all.
|
||||||
|
*/
|
||||||
|
export default function AdminBreadcrumbs({ pathname, title }: Props) {
|
||||||
|
if (!hasBreadcrumbTrail({ pathname })) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className={twMerge("w-full shrink-0 pt-5 pb-1")}>
|
||||||
|
<Breadcrumbs
|
||||||
|
pageUrl={pathname || AdminHomePath}
|
||||||
|
currentTitle={title}
|
||||||
|
excludeRegexMatch={/^admin$/}
|
||||||
|
linkProps={{
|
||||||
|
className:
|
||||||
|
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||||
|
}}
|
||||||
|
currentLinkProps={{
|
||||||
|
title,
|
||||||
|
className:
|
||||||
|
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||||
|
}}
|
||||||
|
divider={
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 text-ink-secondary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Its size in pixels; 14 sits beside a 14px name.
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
/**
|
||||||
|
* The tooltip and accessible name.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark of a workspace's admin bot: a filled success-green shield with
|
||||||
|
* its check cut out in the background's colour, set beside the bot's name
|
||||||
|
* (never on its stone, which takes no badges). A role, not a status, and
|
||||||
|
* never the bot's own colour.
|
||||||
|
*/
|
||||||
|
export default function AdminShield({
|
||||||
|
size = 17,
|
||||||
|
label = "Workspace admin",
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<ShieldCheck
|
||||||
|
size={size}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
className={twMerge(
|
||||||
|
"shrink-0 inline-block align-middle",
|
||||||
|
"fill-success text-on-ink dark:fill-success-dark",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
</ShieldCheck>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin line sweeping across, set just under a pane header while its
|
||||||
|
* page loads. Takes no height (it overlays the top of what follows), so
|
||||||
|
* nothing moves when it goes. Under reduced motion it holds still.
|
||||||
|
*/
|
||||||
|
export default function LoadingLine({ label = "Loading", className }: Props) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={label}
|
||||||
|
className={twMerge("relative h-0 w-full shrink-0 gap-0! z-10", className)}
|
||||||
|
>
|
||||||
|
<Stack className="absolute inset-x-0 top-0 h-0.5 gap-0! overflow-hidden bg-ink/5">
|
||||||
|
<Span
|
||||||
|
className={twMerge(
|
||||||
|
"block h-full w-1/3 bg-linear-to-r from-transparent via-ink to-transparent",
|
||||||
|
"animate-loading-line",
|
||||||
|
"motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
@@ -15,9 +14,5 @@ export default async function pruneBotsQueue() {
|
|||||||
values: [cutoff],
|
values: [cutoff],
|
||||||
});
|
});
|
||||||
|
|
||||||
bunext.bunextLog.info(
|
|
||||||
`Cron: pruned ${res.payload?.length || 0} finished bot job(s)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
import bunext from "@moduletrace/bunext";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||||
|
import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout";
|
||||||
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
||||||
|
|
||||||
type Row = Pick<
|
type Row = Pick<
|
||||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||||
"id" | "docker_id" | "status" | "ip_address"
|
"id" | "docker_id" | "status" | "ip_address" | "metadata"
|
||||||
> & { computer_id: number };
|
> & { computer_id: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads every computer container's state from docker and records it
|
* Reads every computer container's state from docker and records it
|
||||||
* (`refreshComputerContainer`), telling owners about changes. Containers
|
* (`refreshComputerContainer`), telling owners about changes. Containers
|
||||||
* still being set up, or that failed, are left alone.
|
* still being set up, or that failed, are left alone. A container that
|
||||||
|
* still mounts its computer's whole home is recreated with only the
|
||||||
|
* workspace (`migrateComputerLayout`, once per computer).
|
||||||
*/
|
*/
|
||||||
export default async function syncComputers() {
|
export default async function syncComputers() {
|
||||||
const rows =
|
const rows =
|
||||||
(
|
(
|
||||||
await BunSQLite.sql<Row>({
|
await BunSQLite.sql<Row>({
|
||||||
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, c.id AS computer_id
|
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, co.metadata, c.id AS computer_id
|
||||||
FROM containers co JOIN computers c ON c.container_id = co.id
|
FROM containers co JOIN computers c ON c.container_id = co.id
|
||||||
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
|
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
|
||||||
})
|
})
|
||||||
@@ -25,7 +28,9 @@ export default async function syncComputers() {
|
|||||||
|
|
||||||
let changed = 0;
|
let changed = 0;
|
||||||
|
|
||||||
for (const { computer_id, ...container } of rows) {
|
for (const { computer_id, metadata, ...container } of rows) {
|
||||||
|
if (await migrateComputerLayout({ computer_id, metadata })) continue;
|
||||||
|
|
||||||
const refreshed = await refreshComputerContainer({
|
const refreshed = await refreshComputerContainer({
|
||||||
computer_id,
|
computer_id,
|
||||||
container,
|
container,
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
|
||||||
import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models";
|
import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes every enabled provider's model list. Free models on OpenRouter
|
* Refreshes every enabled provider's model list. Free models on OpenRouter
|
||||||
* come and go, and automatic picks follow them from this list.
|
* come and go, and automatic picks follow them from this list. A provider
|
||||||
|
* that fails doesn't fail the run, so its error goes to the logs here.
|
||||||
*/
|
*/
|
||||||
export default async function cronSyncHarnessModels() {
|
export default async function cronSyncHarnessModels() {
|
||||||
const results = await syncAllHarnessModels();
|
const results = await syncAllHarnessModels();
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.error) {
|
if (!result.error) continue;
|
||||||
bunext.bunextLog.error(
|
|
||||||
`Cron: model sync for provider #${result.provider_id} failed: ${result.error}`,
|
await logError({
|
||||||
);
|
title: `Cron: model sync for provider #${result.provider_id} failed`,
|
||||||
} else {
|
category: "cron",
|
||||||
bunext.bunextLog.info(
|
error: result.error,
|
||||||
`Cron: provider #${result.provider_id} lists ${result.count} model(s)`,
|
metadata: { provider_id: result.provider_id },
|
||||||
);
|
}).catch(() => {});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
+79
-5
@@ -96,12 +96,34 @@ export const AppData = {
|
|||||||
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
|
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
|
||||||
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
|
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
|
||||||
/**
|
/**
|
||||||
* A computer's home directory (`ComputerHomeDir` in its container) is
|
* Only a computer's workspace (`ComputerWorkspaceDir` in its
|
||||||
* kept at `DATA_DIR/<ComputerDataDir>/<id>`, owned by the sandbox's
|
* container) is kept, at `DATA_DIR/<ComputerDataDir>/<id>`, owned by
|
||||||
* user. It stays when the computer is deleted.
|
* the sandbox's user. It stays when the computer is deleted. The rest
|
||||||
|
* of its home (`ComputerHomeDir`: dotfiles, browser profile, the env
|
||||||
|
* files) lives in the container and goes with it. Every bot works in
|
||||||
|
* `bots/<bot id>` there, a user's work in `bots/<bot id>/users/<user
|
||||||
|
* id>`, and a workspace's bots share `workspaces/<workspace id>`
|
||||||
|
* (`computerRunDirs`).
|
||||||
*/
|
*/
|
||||||
ComputerDataDir: "computers",
|
ComputerDataDir: "computers",
|
||||||
ComputerHomeDir: "/home/gem",
|
ComputerHomeDir: "/home/gem",
|
||||||
|
ComputerWorkspaceDir: "/home/gem/workspace",
|
||||||
|
/**
|
||||||
|
* Written at the root of a computer's workspace once it uses that
|
||||||
|
* layout, so an older computer's whole home is told apart and moved
|
||||||
|
* aside (`prepareComputerDataDir`).
|
||||||
|
*/
|
||||||
|
ComputerWorkspaceMarker: ".cobble-workspace",
|
||||||
|
/**
|
||||||
|
* An older computer's whole home is moved aside to
|
||||||
|
* `<id><ComputerOldHomeSuffix><timestamp>` and kept, never archived.
|
||||||
|
*/
|
||||||
|
ComputerOldHomeSuffix: "-home-",
|
||||||
|
/**
|
||||||
|
* Recorded in a container's `metadata.layout` once it mounts only the
|
||||||
|
* workspace.
|
||||||
|
*/
|
||||||
|
ComputerWorkspaceLayout: "workspace",
|
||||||
ComputerUserUid: 1000,
|
ComputerUserUid: 1000,
|
||||||
ComputerUserGid: 1000,
|
ComputerUserGid: 1000,
|
||||||
/**
|
/**
|
||||||
@@ -246,7 +268,10 @@ export const AppData = {
|
|||||||
HarnessCooldownMs: 60 * 1000,
|
HarnessCooldownMs: 60 * 1000,
|
||||||
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
||||||
/**
|
/**
|
||||||
* Model turns in one text run before it must stop and report.
|
* Model turns in one text run before it must stop and report. The
|
||||||
|
* install may save its own limit (the `harness_max_tool_steps` setting,
|
||||||
|
* Settings > General), which wins over this one; a task run reads it
|
||||||
|
* through `grabHarnessMaxToolSteps`.
|
||||||
*/
|
*/
|
||||||
HarnessMaxToolSteps: 200,
|
HarnessMaxToolSteps: 200,
|
||||||
/**
|
/**
|
||||||
@@ -289,6 +314,20 @@ export const AppData = {
|
|||||||
SkillBodyMaxChars: 60000,
|
SkillBodyMaxChars: 60000,
|
||||||
SkillBodyShownChars: 30000,
|
SkillBodyShownChars: 30000,
|
||||||
SkillDescriptionMaxChars: 300,
|
SkillDescriptionMaxChars: 300,
|
||||||
|
/**
|
||||||
|
* How long the super admin has to confirm an MCP server or tool the
|
||||||
|
* coordinator proposed (`extension_proposals`). After that its card
|
||||||
|
* reads expired and the bot has to propose it again, so a card left in
|
||||||
|
* an old thread can't be confirmed weeks later.
|
||||||
|
*/
|
||||||
|
ExtensionProposalTtlMs: 24 * 60 * 60 * 1000,
|
||||||
|
/**
|
||||||
|
* A proposal's background check: the bot's summary, and the sources it
|
||||||
|
* read (at least one).
|
||||||
|
*/
|
||||||
|
ExtensionResearchMinChars: 40,
|
||||||
|
ExtensionResearchMaxChars: 6000,
|
||||||
|
ExtensionResearchSourcesMax: 10,
|
||||||
/**
|
/**
|
||||||
* `search_computers`: how long the command may run on each computer,
|
* `search_computers`: how long the command may run on each computer,
|
||||||
* and how much of each computer's output the bot reads.
|
* and how much of each computer's output the bot reads.
|
||||||
@@ -328,6 +367,11 @@ export const AppData = {
|
|||||||
* How many older messages each scroll to the top of the thread loads.
|
* How many older messages each scroll to the top of the thread loads.
|
||||||
*/
|
*/
|
||||||
ConversationOlderMessages: 30,
|
ConversationOlderMessages: 30,
|
||||||
|
/**
|
||||||
|
* The most model calls a message's details modal lists one by one. A
|
||||||
|
* long task can take hundreds; the totals still count every one.
|
||||||
|
*/
|
||||||
|
MessageDetailsMaxCalls: 200,
|
||||||
/**
|
/**
|
||||||
* Days of usage the usage page totals by default, and the most it will
|
* Days of usage the usage page totals by default, and the most it will
|
||||||
* total at once.
|
* total at once.
|
||||||
@@ -389,8 +433,23 @@ export const AppData = {
|
|||||||
WorkspaceActivityItems: 6,
|
WorkspaceActivityItems: 6,
|
||||||
/**
|
/**
|
||||||
* Tool turns a chat reply may take. Long work goes to a task instead.
|
* Tool turns a chat reply may take. Long work goes to a task instead.
|
||||||
|
* The install may save its own limit (the `bot_reply_max_tool_steps`
|
||||||
|
* setting, Settings > General), which wins over this one; a reply
|
||||||
|
* reads it through `grabReplyMaxToolSteps`.
|
||||||
*/
|
*/
|
||||||
BotReplyMaxToolSteps: 8,
|
BotReplyMaxToolSteps: 20,
|
||||||
|
/**
|
||||||
|
* Messages a chat reply may send while it works (`send_message`):
|
||||||
|
* acknowledging something the user wrote meanwhile, a heads-up. Its
|
||||||
|
* answer still comes at the end.
|
||||||
|
*/
|
||||||
|
BotReplyInterimMessagesMax: 3,
|
||||||
|
/**
|
||||||
|
* The most steps a saved run limit may ask for, for a task run or a
|
||||||
|
* chat reply. A typo in the settings page can't turn one run into an
|
||||||
|
* hour of tool calls, and a huge limit still ends with a report.
|
||||||
|
*/
|
||||||
|
BotStepLimitMax: 1000,
|
||||||
/**
|
/**
|
||||||
* Chat attachments: per file, per message, and how much of a
|
* Chat attachments: per file, per message, and how much of a
|
||||||
* document's text a reply reads.
|
* document's text a reply reads.
|
||||||
@@ -451,6 +510,12 @@ export const AppData = {
|
|||||||
* Most folders deep a path may go, so nothing buries a file.
|
* Most folders deep a path may go, so nothing buries a file.
|
||||||
*/
|
*/
|
||||||
BotFilePathMaxDepth: 8,
|
BotFilePathMaxDepth: 8,
|
||||||
|
/**
|
||||||
|
* Most folders deep the files browser opens: a computer's workspace
|
||||||
|
* starts four down (`bots/<bot>/users/<user>`) before a bot's own
|
||||||
|
* folders begin.
|
||||||
|
*/
|
||||||
|
StorageBrowserMaxDepth: 16,
|
||||||
/**
|
/**
|
||||||
* The files modal and the bot's file list: one page of them.
|
* The files modal and the bot's file list: one page of them.
|
||||||
*/
|
*/
|
||||||
@@ -542,6 +607,13 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
DataBackupNamePrefix: "cobble-data-",
|
DataBackupNamePrefix: "cobble-data-",
|
||||||
DataBackupsKept: 5,
|
DataBackupsKept: 5,
|
||||||
|
/**
|
||||||
|
* How often the backups page asks how a whole-data archive or restore
|
||||||
|
* is going, and how long it waits for the app to come back after a
|
||||||
|
* restore restarted it before it reloads anyway.
|
||||||
|
*/
|
||||||
|
DataBackupPollMs: 2000,
|
||||||
|
DataRestoreWaitMs: 120000,
|
||||||
/**
|
/**
|
||||||
* Responses smaller than this go out uncompressed: the encoding
|
* Responses smaller than this go out uncompressed: the encoding
|
||||||
* costs more than it saves.
|
* costs more than it saves.
|
||||||
@@ -560,4 +632,6 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
CompressCacheMaxFiles: 600,
|
CompressCacheMaxFiles: 600,
|
||||||
CompressCacheMaxBytes: 32 * 1024 * 1024,
|
CompressCacheMaxBytes: 32 * 1024 * 1024,
|
||||||
|
|
||||||
|
DefaultIconSize: 16,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Permission to draft is not permission to send, permission to analyze is not perm
|
|||||||
- A workspace is where bots share one project's context. Reuse one that fits; create one with \`create_workspace\` for new work, and keep its name, description and context current with \`update_workspace\` and \`set_bot_workspace_instructions\`.
|
- A workspace is where bots share one project's context. Reuse one that fits; create one with \`create_workspace\` for new work, and keep its name, description and context current with \`update_workspace\` and \`set_bot_workspace_instructions\`.
|
||||||
- Check membership with the tools. Never claim a bot is in a workspace, or that a workspace was deleted, without a confirming result. Explain what a deletion removes before asking for it.
|
- Check membership with the tools. Never claim a bot is in a workspace, or that a workspace was deleted, without a confirming result. Explain what a deletion removes before asking for it.
|
||||||
- Workspace context is information, not authorization.
|
- Workspace context is information, not authorization.
|
||||||
|
- The install's skills (runbooks), MCP servers and custom tools are yours to keep: read them with \`list_extensions\`, decide who gets one with \`set_extension_access\`, and remove one with \`delete_extension\`. Adding one, or changing what it does (a skill's words, where a server or tool sends), is the user's call: run a background check first (for a server or tool the publisher's own docs, the official URL, what it needs; for a skill a review of every step and where its content came from), then \`propose_skill\`, \`propose_mcp_server\` or \`propose_custom_tool\`, which puts a card in your reply for the user to confirm. Tell them what it does, where it sends, which keys it uses (saved and missing), and what your check found. Never ask for a key's value in chat: missing keys are added in Settings, under Environment. It's added only once the user's message says they confirmed it. \`update_skill\`, \`update_mcp_server\` and \`update_custom_tool\` change the rest (on or off, and for a server or tool its name, description and arguments) directly.
|
||||||
|
|
||||||
Instruction layers are separate, and changing one does not change the others:
|
Instruction layers are separate, and changing one does not change the others:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
botFilesNote,
|
||||||
|
chatReplyNote,
|
||||||
|
ComputerFilesNote,
|
||||||
|
coordinatorReplyNote,
|
||||||
|
TasksNote,
|
||||||
|
} from "./harness-prompts";
|
||||||
|
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every note a bot reads names it by its own id, so a bot copying an
|
||||||
|
* example never points at another bot's folder, conversation or task.
|
||||||
|
*/
|
||||||
|
const bot_ids = [1, 5, 42] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bot ids a note's own-folder examples point at, in order.
|
||||||
|
*/
|
||||||
|
function folderOwners(note: string) {
|
||||||
|
return [...note.matchAll(/\[folder:bot:(\d+):/g)].map((match) => match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("harness prompts", () => {
|
||||||
|
for (const bot_id of bot_ids) {
|
||||||
|
test(`chat reply note names bot ${bot_id}'s own folder`, () => {
|
||||||
|
const owners = folderOwners(chatReplyNote({ bot_id }));
|
||||||
|
|
||||||
|
expect(owners).toEqual([String(bot_id)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`files note names bot ${bot_id}'s own folder`, () => {
|
||||||
|
const owners = folderOwners(botFilesNote({ bot_id }));
|
||||||
|
|
||||||
|
expect(owners).toEqual([String(bot_id)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`coordinator note names itself as bot ${bot_id}`, () => {
|
||||||
|
const note = coordinatorReplyNote({ bot_id });
|
||||||
|
|
||||||
|
expect(note).toContain(`[bot:${bot_id}]`);
|
||||||
|
expect(note).not.toContain("[bot:3]");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a note that already names the running bot is unchanged by its id", () => {
|
||||||
|
expect(botFilesNote({ bot_id: 5 })).toContain("[folder:bot:5:reports]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the computer files note names the computer and the run's folders", () => {
|
||||||
|
const note = ComputerFilesNote({
|
||||||
|
computer_id: 8,
|
||||||
|
dirs: computerRunDirs({ bot_id: 3, user_id: 7, workspace_id: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(note).toContain("[folder:computer:8:bots/3/users/7/seo/coderank/raw]");
|
||||||
|
expect(note).toContain("/home/gem/workspace/bots/3/users/7");
|
||||||
|
expect(note).toContain("/home/gem/workspace/workspaces/2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notes with nothing bot-specific carry no ref example", () => {
|
||||||
|
expect(TasksNote).not.toContain("[folder:bot:");
|
||||||
|
expect(TasksNote).not.toContain("[bot:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import botRef from "@/src/utils/bot-ref";
|
import botRef from "@/src/utils/bot-ref";
|
||||||
import folderRef from "@/src/utils/folder-ref";
|
import folderRef from "@/src/utils/folder-ref";
|
||||||
|
import { computerWorkspaceRelative, type ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompts the helper harnesses send. The text model gets their output in
|
* Prompts the helper harnesses send. The text model gets their output in
|
||||||
@@ -19,9 +20,19 @@ export const ReadImagePrompt = `Describe this image for someone who can't see it
|
|||||||
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
|
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to a bot's prompt when it answers the user in chat.
|
* The folder-ref example a note shows, always over the running bot's own
|
||||||
|
* folder: a literal owner id in the prompt teaches the bot to name another
|
||||||
|
* bot's folder as if it were its own.
|
||||||
*/
|
*/
|
||||||
export const ChatReplyNote = `# Replying in chat
|
function ownFolderRef({ bot_id }: { bot_id: number | string }) {
|
||||||
|
return folderRef({ volume: "bot", owner_id: bot_id, path: "reports" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a bot's prompt when it answers the user in chat. `bot_id` is the
|
||||||
|
* bot itself: the examples it reads are its own refs.
|
||||||
|
*/
|
||||||
|
export const chatReplyNote = ({ bot_id }: { bot_id: number | string }) => `# Replying in chat
|
||||||
|
|
||||||
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
|
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
|
||||||
|
|
||||||
@@ -33,7 +44,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
|
|||||||
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
|
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
|
||||||
- Never claim you did something you didn't do.
|
- Never claim you did something you didn't do.
|
||||||
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
|
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
|
||||||
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: that opens the folder in Files and downloads nothing.
|
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${ownFolderRef({ bot_id })}: that opens the folder in Files and downloads nothing.
|
||||||
|
|
||||||
## Keep the work moving
|
## Keep the work moving
|
||||||
|
|
||||||
@@ -41,9 +52,10 @@ Be proactive. Follow where the conversation is heading and, once you've answered
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to every run, chat replies and tasks alike: where its files live,
|
* Added to every run, chat replies and tasks alike: where its files live,
|
||||||
* that they persist, and how to search them.
|
* that they persist, and how to search them. `bot_id` is the bot itself,
|
||||||
|
* so the folder-ref example it reads is its own folder.
|
||||||
*/
|
*/
|
||||||
export const BotFilesNote = `# Your files
|
export const botFilesNote = ({ bot_id }: { bot_id: number | string }) => `# Your files
|
||||||
|
|
||||||
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
|
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
|
||||||
|
|
||||||
@@ -53,7 +65,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
|
|||||||
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
|
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
|
||||||
- A file and a folder are two different refs, and you never guess which one from the name:
|
- A file and a folder are two different refs, and you never guess which one from the name:
|
||||||
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
||||||
- A folder is a folder ref, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
||||||
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
||||||
- A file you only described is not saved. Save it first, then say it's saved.
|
- A file you only described is not saved. Save it first, then say it's saved.
|
||||||
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
||||||
@@ -71,15 +83,28 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
|
|||||||
*/
|
*/
|
||||||
export const ComputerFilesNote = ({
|
export const ComputerFilesNote = ({
|
||||||
computer_id,
|
computer_id,
|
||||||
|
dirs,
|
||||||
can_import = true,
|
can_import = true,
|
||||||
}: {
|
}: {
|
||||||
computer_id: number | string;
|
computer_id: number | string;
|
||||||
|
/**
|
||||||
|
* The run's folders on the computer (`computerRunDirs`).
|
||||||
|
*/
|
||||||
|
dirs: ComputerRunDirs;
|
||||||
can_import?: boolean;
|
can_import?: boolean;
|
||||||
}) => `# Files on the computer
|
}) => `# Files on the computer
|
||||||
|
|
||||||
What you make on the computer (downloads, screenshots, a crawl, raw evidence, logs) lives in its home, not in your files. It survives restarts of the computer, but it is not searchable and it goes if the computer is rebuilt.
|
You work only in your own folders on the computer, never elsewhere in it: every shell, code and file tool starts in ${dirs.cwd} (also ~), and a path outside your folders is refused.
|
||||||
|
|
||||||
- The user can open any of it: name a folder on the computer as a folder ref, e.g. ${folderRef({ volume: "computer", owner_id: computer_id, path: "seo/coderank/raw" })}. It opens that folder in Files, so it downloads nothing. Point at the folder holding the evidence they may want to see.
|
- ${dirs.cwd}: this user's work. Everything for them goes here.
|
||||||
|
- ${dirs.bot_dir}: only what is the same for every user you work for (other users' folders in it are not yours).${
|
||||||
|
dirs.shared_dir ? `\n- ${dirs.shared_dir}: shared with every bot in this workspace.` : ""
|
||||||
|
}
|
||||||
|
- The browser downloads to its own folder: move what you download into your folder straight away.
|
||||||
|
|
||||||
|
What you make there (downloads, screenshots, a crawl, raw evidence, logs) is kept with the computer, not in your files, and it is not searchable.
|
||||||
|
|
||||||
|
- The user can open any of it: name a folder on the computer as a folder ref, its path from the computer's workspace (${AppData.ComputerWorkspaceDir}), e.g. ${folderRef({ volume: "computer", owner_id: computer_id, path: `${computerWorkspaceRelative({ absolute: dirs.cwd })}/seo/coderank/raw` })}. It opens that folder in Files, so it downloads nothing. Point at the folder holding the evidence they may want to see.
|
||||||
${
|
${
|
||||||
can_import
|
can_import
|
||||||
? `- import_file copies a file or a folder off the computer into your own files, in one go, and the copy is searchable straight away. Import the finished work (the report, the spreadsheet, the final document), attach it when it is what they asked for, and name it with its [file:ID] ref.\n- The raw evidence can stay on the computer: import it only when the user needs it kept, or when the computer may be rebuilt.\n`
|
? `- import_file copies a file or a folder off the computer into your own files, in one go, and the copy is searchable straight away. Import the finished work (the report, the spreadsheet, the final document), attach it when it is what they asked for, and name it with its [file:ID] ref.\n- The raw evidence can stay on the computer: import it only when the user needs it kept, or when the computer may be rebuilt.\n`
|
||||||
@@ -114,8 +139,10 @@ Every action counts here: saving memory or instructions, creating or changing a
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to the coordinator's chat prompt: it hands work to the crew.
|
* Added to the coordinator's chat prompt: it hands work to the crew.
|
||||||
|
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
||||||
|
* own rather than a worker's.
|
||||||
*/
|
*/
|
||||||
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
|
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to the coordinator's chat prompt instead while it has no workers,
|
* Added to the coordinator's chat prompt instead while it has no workers,
|
||||||
@@ -123,6 +150,28 @@ export const CoordinatorReplyNote = `As the coordinator, hand work that fits a c
|
|||||||
*/
|
*/
|
||||||
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`;
|
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a workspace admin's chat prompt in the workspace it leads, while
|
||||||
|
* it has members to hand work to (`delegate_task` is there). `members` are
|
||||||
|
* the bots it directs, written as refs.
|
||||||
|
*/
|
||||||
|
export const workspaceAdminNote = ({
|
||||||
|
workspace_name,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
workspace_name: string;
|
||||||
|
members: { id?: number | "" }[];
|
||||||
|
}) => `# Leading ${workspace_name}
|
||||||
|
|
||||||
|
You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself; they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a workspace member's prompt while the workspace has an admin,
|
||||||
|
* so it knows who hands it work there.
|
||||||
|
*/
|
||||||
|
export const workspaceAdminsLine = ({ admins }: { admins: { id?: number | "" }[] }) =>
|
||||||
|
`In this workspace, ${admins.map((a) => botRef({ id: a.id || 0 })).join(" and ")} ${admins.length == 1 ? "is the admin and hands" : "are the admins and hand"} out work: a task from them is part of your job here.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to a bot's prompt when it works through a queued task.
|
* Added to a bot's prompt when it works through a queued task.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ export const CoordinatorBotName = "Coordinator";
|
|||||||
*/
|
*/
|
||||||
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `app_settings` key holding the install's own limit on the tool turns
|
||||||
|
* one chat reply may take. Unset means `AppData.BotReplyMaxToolSteps`.
|
||||||
|
*/
|
||||||
|
export const BotReplyMaxToolStepsSettingKey = "bot_reply_max_tool_steps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bot's face is its status. There are no badges or spinners.
|
* A bot's face is its status. There are no badges or spinners.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const JobStepToolLabels: Record<string, string> = {
|
|||||||
update_memory: "Updating memory",
|
update_memory: "Updating memory",
|
||||||
update_instructions: "Updating instructions",
|
update_instructions: "Updating instructions",
|
||||||
load_skill: "Reading a skill",
|
load_skill: "Reading a skill",
|
||||||
|
send_message: "Sending a message",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { BotPermission, BotType, TypeAssignmentMatrixType } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every bot type. A bot with no `bot_types` row is a worker. A type holds
|
||||||
|
* install-wide (`scope: "install"`, its row has no workspace) or in one
|
||||||
|
* workspace (`scope: "workspace"`, its row names it). `bots.kind` stays the
|
||||||
|
* legacy marker of the one coordinator for who sees it; what a bot can do
|
||||||
|
* comes from these.
|
||||||
|
*/
|
||||||
|
export const BotTypes = [
|
||||||
|
{
|
||||||
|
value: "coordinator",
|
||||||
|
title: "Coordinator",
|
||||||
|
description: "Coordinates the whole crew: hands work to every bot, grows the crew, runs workspaces and extensions. One per install, the super admin's alone.",
|
||||||
|
scope: "install",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "admin",
|
||||||
|
title: "Workspace admin",
|
||||||
|
description: "Leads one workspace: hands tasks to the other bots there, sets their instructions there, runs the workspace's task list and keeps its context.",
|
||||||
|
scope: "workspace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "worker",
|
||||||
|
title: "Worker",
|
||||||
|
description: "Does its own job. Every bot without another type.",
|
||||||
|
scope: "install",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a bot type lets a bot do.
|
||||||
|
*/
|
||||||
|
export const BotPermissions = [
|
||||||
|
{
|
||||||
|
value: "delegate_task",
|
||||||
|
title: "Hand tasks to other bots",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "set_member_instructions",
|
||||||
|
title: "Set other bots' workspace instructions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_workspace_tasks",
|
||||||
|
title: "Run the workspace's task list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "edit_workspace",
|
||||||
|
title: "Edit the workspace's name, description and context",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_crew",
|
||||||
|
title: "Create, edit and delete bots, their computers and workspaces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_workspaces",
|
||||||
|
title: "Create and delete workspaces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_extensions",
|
||||||
|
title: "Manage skills, MCP servers and tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "set_bot_types",
|
||||||
|
title: "Make bots workspace admins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "set_any_picture",
|
||||||
|
title: "Set any bot's picture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "reach_all_tasks",
|
||||||
|
title: "Reach every task on the install",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each bot type may do, where it holds (everywhere for an
|
||||||
|
* install-wide type, in its workspace for a workspace one).
|
||||||
|
*/
|
||||||
|
export const BotTypePermissionMatrix: Record<BotType, BotPermission[]> = {
|
||||||
|
coordinator: BotPermissions.map((bp) => bp.value),
|
||||||
|
admin: [
|
||||||
|
"delegate_task",
|
||||||
|
"set_member_instructions",
|
||||||
|
"manage_workspace_tasks",
|
||||||
|
"edit_workspace",
|
||||||
|
],
|
||||||
|
worker: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which bots a bot may act on (hand a task to, set instructions for), keyed
|
||||||
|
* by the target's type. Same shape as `UserTypeAssignmentMatrix`: the
|
||||||
|
* coordinator passes through. A workspace admin directs workers only, so
|
||||||
|
* two admins never hand work back and forth.
|
||||||
|
*/
|
||||||
|
export const BotTypeTargetMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
|
coordinator: {},
|
||||||
|
admin: {
|
||||||
|
allowed_actors: [],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
allowed_actors: ["admin"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may grant a bot type: user types (`admin`; the super admin passes
|
||||||
|
* through) and bot types (`coordinator`). The coordinator type is never
|
||||||
|
* granted: `ensureCoordinatorBot` writes it.
|
||||||
|
*/
|
||||||
|
export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
|
coordinator: {},
|
||||||
|
admin: {
|
||||||
|
allowed_actors: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
worker: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
|
coordinator: {},
|
||||||
|
admin: {
|
||||||
|
allowed_actors: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
worker: {},
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* A computer is an AIO sandbox running in its own docker container on the
|
* A computer is an AIO sandbox running in its own docker container on the
|
||||||
* cobble network, created by the app (`createComputer`), its home
|
* cobble network, created by the app (`createComputer`), only its
|
||||||
* directory kept under `DATA_DIR/computers/<id>`.
|
* workspace (`AppData.ComputerWorkspaceDir`) kept under
|
||||||
|
* `DATA_DIR/computers/<id>`.
|
||||||
*/
|
*/
|
||||||
export const ComputerKinds = [
|
export const ComputerKinds = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,18 +64,28 @@ export const ReservedToolNames = [
|
|||||||
"create_schedule",
|
"create_schedule",
|
||||||
"create_workspace",
|
"create_workspace",
|
||||||
"delegate_task",
|
"delegate_task",
|
||||||
|
"delete_extension",
|
||||||
"delete_schedule",
|
"delete_schedule",
|
||||||
|
"list_extensions",
|
||||||
"list_schedules",
|
"list_schedules",
|
||||||
"load_skill",
|
"load_skill",
|
||||||
|
"propose_custom_tool",
|
||||||
|
"propose_mcp_server",
|
||||||
|
"propose_skill",
|
||||||
"report_run_failed",
|
"report_run_failed",
|
||||||
"search_computers",
|
"search_computers",
|
||||||
|
"send_message",
|
||||||
"set_bot_workspace_instructions",
|
"set_bot_workspace_instructions",
|
||||||
|
"set_extension_access",
|
||||||
"set_picture",
|
"set_picture",
|
||||||
"set_workspace_computer",
|
"set_workspace_computer",
|
||||||
"start_task",
|
"start_task",
|
||||||
|
"update_custom_tool",
|
||||||
"update_instructions",
|
"update_instructions",
|
||||||
|
"update_mcp_server",
|
||||||
"update_memory",
|
"update_memory",
|
||||||
"update_schedule",
|
"update_schedule",
|
||||||
|
"update_skill",
|
||||||
"update_workspace",
|
"update_workspace",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const EnvDict: readonly KnownEnv[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "MAIN_COMPUTER_DATA_DIR",
|
key: "MAIN_COMPUTER_DATA_DIR",
|
||||||
description: `Where the main computer's home (/home/gem) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/<id>.`,
|
description: `Where the main computer's workspace (/home/gem/workspace, the only folder bots work in) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/<id>.`,
|
||||||
required: false,
|
required: false,
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Where an MCP server, tool or skill the coordinator proposed stands
|
||||||
|
* (`extension_proposals.status`): `pending` until the super admin
|
||||||
|
* confirms (`confirmed`, saved) or rejects it, `replaced` when the bot
|
||||||
|
* proposed it again, `failed` when confirming couldn't save it. A pending
|
||||||
|
* one past its `expires_at` reads as expired.
|
||||||
|
*/
|
||||||
|
export const ExtensionProposalStatuses = ["pending", "confirmed", "rejected", "replaced", "failed"] as const;
|
||||||
|
|
||||||
|
export type ExtensionProposalStatus = (typeof ExtensionProposalStatuses)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a proposal adds or changes.
|
||||||
|
*/
|
||||||
|
export const ExtensionProposalKinds = [
|
||||||
|
{ value: "mcp", title: "MCP server" },
|
||||||
|
{ value: "tool", title: "tool" },
|
||||||
|
{ value: "skill", title: "skill" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ExtensionProposalKind = (typeof ExtensionProposalKinds)[number]["value"];
|
||||||
@@ -53,6 +53,12 @@ export const Harnesses = [
|
|||||||
|
|
||||||
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `app_settings` key holding the install's own limit on model turns in
|
||||||
|
* one run (a task). Unset means `AppData.HarnessMaxToolSteps`.
|
||||||
|
*/
|
||||||
|
export const HarnessMaxToolStepsSettingKey = "harness_max_tool_steps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vendors preferred for automatic picks, best first. Free models from any
|
* Vendors preferred for automatic picks, best first. Free models from any
|
||||||
* other vendor come after these, and only if they look competent.
|
* other vendor come after these, and only if they look competent.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run";
|
||||||
|
import { isBackupRunning } from "./with-backup-run-lock";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (name: string) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<string>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
describe("startDataBackupRun", () => {
|
||||||
|
test("answers at once, then records the finished archive", async () => {
|
||||||
|
const task = deferred();
|
||||||
|
|
||||||
|
const run = startDataBackupRun({
|
||||||
|
kind: "archive",
|
||||||
|
task: () => task.promise,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.status).toBe("running");
|
||||||
|
expect(run.kind).toBe("archive");
|
||||||
|
expect(run.boot_id).toBe(BootId);
|
||||||
|
expect(isBackupRunning()).toBe(true);
|
||||||
|
|
||||||
|
task.resolve("cobble-data-1.tar.gz");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const done = grabDataBackupRun();
|
||||||
|
expect(done.status).toBe("done");
|
||||||
|
expect(done.name).toBe("cobble-data-1.tar.gz");
|
||||||
|
expect(isBackupRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a second run while one is going, before it starts", async () => {
|
||||||
|
const task = deferred();
|
||||||
|
let second_started = false;
|
||||||
|
|
||||||
|
startDataBackupRun({ kind: "archive", task: () => task.promise });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
startDataBackupRun({
|
||||||
|
kind: "restore",
|
||||||
|
task: async () => {
|
||||||
|
second_started = true;
|
||||||
|
return "x";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow(AppError);
|
||||||
|
expect(second_started).toBe(false);
|
||||||
|
|
||||||
|
task.resolve("cobble-data-2.tar.gz");
|
||||||
|
await settle();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records a failure and frees the lock for the next run", async () => {
|
||||||
|
const task = deferred();
|
||||||
|
|
||||||
|
startDataBackupRun({ kind: "restore", task: () => task.promise });
|
||||||
|
task.reject(new Error("tar: broken"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const failed = grabDataBackupRun();
|
||||||
|
expect(failed.status).toBe("failed");
|
||||||
|
expect(failed.kind).toBe("restore");
|
||||||
|
expect(failed.error).toBe("tar: broken");
|
||||||
|
expect(isBackupRunning()).toBe(false);
|
||||||
|
|
||||||
|
const next = startDataBackupRun({
|
||||||
|
kind: "archive",
|
||||||
|
task: async () => "cobble-data-3.tar.gz",
|
||||||
|
});
|
||||||
|
expect(next.status).toBe("running");
|
||||||
|
await settle();
|
||||||
|
expect(grabDataBackupRun().status).toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import type { DataBackupRun, DataBackupRunKind } from "@/src/types";
|
||||||
|
import withBackupRunLock, { isBackupRunning } from "./with-backup-run-lock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes every time the process starts, so the page can tell a restore
|
||||||
|
* restarted the app (the run it started belonged to another boot).
|
||||||
|
*/
|
||||||
|
export const BootId = crypto.randomUUID();
|
||||||
|
|
||||||
|
let run: Omit<DataBackupRun, "boot_id"> | null = null;
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
kind: DataBackupRunKind;
|
||||||
|
/**
|
||||||
|
* The archive or restore itself. It resolves to the archive's name.
|
||||||
|
*/
|
||||||
|
task: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a whole-data archive or restore in the background and hands back
|
||||||
|
* its state at once. Both take far longer than a request may stay silent
|
||||||
|
* (Bun closes an idle socket after 10s, a proxy after about 100s), so the
|
||||||
|
* route answers straight away and the page polls `data-status` until the
|
||||||
|
* run is done or failed. Throws while any backup or restore is running,
|
||||||
|
* before anything starts.
|
||||||
|
*/
|
||||||
|
export function startDataBackupRun({ kind, task }: Params): DataBackupRun {
|
||||||
|
if (isBackupRunning() || run?.status == "running") {
|
||||||
|
throw new AppError(`Another backup or restore is already running`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = {
|
||||||
|
kind,
|
||||||
|
status: "running" as const,
|
||||||
|
started_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
run = started;
|
||||||
|
|
||||||
|
withBackupRunLock({ task })
|
||||||
|
.then((name) => {
|
||||||
|
if (run !== started) return;
|
||||||
|
run = { ...started, status: "done", name, finished_at: Date.now() };
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (run !== started) return;
|
||||||
|
run = {
|
||||||
|
...started,
|
||||||
|
status: "failed",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
finished_at: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return grabDataBackupRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last whole-data run this process started, if any, with this boot's
|
||||||
|
* id.
|
||||||
|
*/
|
||||||
|
export function grabDataBackupRun(): DataBackupRun {
|
||||||
|
return { ...(run ?? { status: "idle" }), boot_id: BootId };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
|
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -18,13 +19,13 @@ const GZIP_MAGIC = [0x1f, 0x8b];
|
|||||||
*/
|
*/
|
||||||
export default async function saveUploadedDataBackup({ file }: Params) {
|
export default async function saveUploadedDataBackup({ file }: Params) {
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
throw new Error(`The uploaded archive is empty`);
|
throw new AppError(`The uploaded archive is empty`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||||
|
|
||||||
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) {
|
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) {
|
||||||
throw new Error(`Only a gzipped tar archive can be restored`);
|
throw new AppError(`Only a gzipped tar archive can be restored`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const archive_dir = grabBackupArchiveDir();
|
const archive_dir = grabBackupArchiveDir();
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ type Params<T> = {
|
|||||||
task: () => Promise<T>;
|
task: () => Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a backup or restore of any kind holds the lock right now, so a
|
||||||
|
* background run can be refused before it starts.
|
||||||
|
*/
|
||||||
|
export function isBackupRunning() {
|
||||||
|
return is_running;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one backup or restore at a time, whichever kind it is, so a
|
* Runs one backup or restore at a time, whichever kind it is, so a
|
||||||
* database snapshot, a database restore and a whole-data archive never
|
* database snapshot, a database restore and a whole-data archive never
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export default async function writeDataBackupArchive(): Promise<CreatedDataBacku
|
|||||||
// after it and an extract drops a single folder.
|
// after it and an extract drops a single folder.
|
||||||
"--transform",
|
"--transform",
|
||||||
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
|
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
|
||||||
|
// A computer's old whole home, moved aside when only its
|
||||||
|
// workspace became the mount (`prepareComputerDataDir`),
|
||||||
|
// is kept on disk but never archived: only workspaces are.
|
||||||
|
// `*` stops at `/`, so a file inside a workspace whose name
|
||||||
|
// happens to hold the suffix is still archived.
|
||||||
|
"--no-wildcards-match-slash",
|
||||||
|
`--exclude=./${AppData.ComputerDataDir}/*${AppData.ComputerOldHomeSuffix}*`,
|
||||||
"-C",
|
"-C",
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
".",
|
".",
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ beforeAll(() => {
|
|||||||
(5, 7, NULL, 0),
|
(5, 7, NULL, 0),
|
||||||
(6, 7, NULL, 0),
|
(6, 7, NULL, 0),
|
||||||
(7, 7, NULL, 0),
|
(7, 7, NULL, 0),
|
||||||
(8, 7, NULL, 0)`);
|
(8, 7, NULL, 0),
|
||||||
|
(10, 7, NULL, 0),
|
||||||
|
(11, 7, NULL, 0)`);
|
||||||
|
|
||||||
// 1: user 7's current photo, 2: user 8's, 3: an old photo of user 8's (no longer primary)
|
// 1: user 7's current photo, 2: user 8's, 3: an old photo of user 8's (no longer primary)
|
||||||
db.run(`INSERT INTO media_paradigms VALUES
|
db.run(`INSERT INTO media_paradigms VALUES
|
||||||
@@ -29,12 +31,14 @@ beforeAll(() => {
|
|||||||
(5, 'bot-image'),
|
(5, 'bot-image'),
|
||||||
(6, 'workspace-image'),
|
(6, 'workspace-image'),
|
||||||
(7, 'workspace-image'),
|
(7, 'workspace-image'),
|
||||||
(8, 'bot-file')`);
|
(8, 'bot-file'),
|
||||||
|
(10, 'bot-attachment')`);
|
||||||
|
|
||||||
// 4: still bot 2's picture, 5: a bot picture no bot points at any more
|
// 4: still bot 2's picture, 5: a bot picture no bot points at any more
|
||||||
db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`);
|
db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`);
|
||||||
// 6: still workspace 10's picture, 7: a workspace picture nothing points at
|
// 6: still workspace 10's picture, 7: a workspace picture nothing points at
|
||||||
db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL)`);
|
// 10: an older picture set straight from a chat upload, still workspace 12's
|
||||||
|
db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL), (12, 10)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function visible({ archived }: { archived?: number } = {}) {
|
function visible({ archived }: { archived?: number } = {}) {
|
||||||
@@ -52,12 +56,12 @@ function visible({ archived }: { archived?: number } = {}) {
|
|||||||
|
|
||||||
describe("sharedPictureMediaSql", () => {
|
describe("sharedPictureMediaSql", () => {
|
||||||
test("every user's current photo, and every bot's and workspace's picture", () => {
|
test("every user's current photo, and every bot's and workspace's picture", () => {
|
||||||
expect(visible()).toEqual([1, 2, 4, 6]);
|
expect(visible()).toEqual([1, 2, 4, 6, 10]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("archived media is not a picture anyone gets", () => {
|
test("archived media is not a picture anyone gets", () => {
|
||||||
db.run(`UPDATE media SET archived = 1 WHERE id = 2`);
|
db.run(`UPDATE media SET archived = 1 WHERE id = 2`);
|
||||||
expect(visible({ archived: 0 })).toEqual([1, 4, 6]);
|
expect(visible({ archived: 0 })).toEqual([1, 4, 6, 10]);
|
||||||
db.run(`UPDATE media SET archived = 0 WHERE id = 2`);
|
db.run(`UPDATE media SET archived = 0 WHERE id = 2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +76,11 @@ describe("sharedPictureMediaSql", () => {
|
|||||||
|
|
||||||
test("anything else stays private, a bot's file among them", () => {
|
test("anything else stays private, a bot's file among them", () => {
|
||||||
expect(visible()).not.toContain(8);
|
expect(visible()).not.toContain(8);
|
||||||
|
expect(visible()).not.toContain(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a picture a workspace points at is shared whatever its paradigm", () => {
|
||||||
|
expect(visible()).toContain(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removing a picture row closes it at once", () => {
|
test("removing a picture row closes it at once", () => {
|
||||||
|
|||||||
@@ -13,28 +13,29 @@ type Params = {
|
|||||||
*
|
*
|
||||||
* - a member's current profile photo (their primary `user-profile-image`,
|
* - a member's current profile photo (their primary `user-profile-image`,
|
||||||
* the one `grabUsers` reports as `profile_media_id`),
|
* the one `grabUsers` reports as `profile_media_id`),
|
||||||
* - a bot's picture (`bots.image_media_id`),
|
* - a bot's picture (whatever `bots.image_media_id` points at),
|
||||||
* - a workspace's picture (`workspaces.image_media_id`).
|
* - a workspace's picture (whatever `workspaces.image_media_id` points at).
|
||||||
*
|
*
|
||||||
* Bots and workspaces belong to the install, and people know each other by
|
* Bots and workspaces belong to the install, and people know each other by
|
||||||
* their photo, so a picture is never limited to whoever happens to reach
|
* their photo, so a picture is never limited to whoever happens to reach
|
||||||
* its owner. It does have to be one still in use: the paradigm alone is not
|
* its owner. It does have to be one still in use: a profile photo's
|
||||||
* enough, since a removed picture leaves its paradigm row behind.
|
* paradigm alone is not enough, since a removed photo leaves its paradigm
|
||||||
|
* row behind. A bot's or workspace's picture is shared because the bot or
|
||||||
|
* workspace points at it, not by its paradigm: a picture set before
|
||||||
|
* pictures were copied into their own `bot-image`/`workspace-image` row is
|
||||||
|
* the chat upload itself, and it is still the face everyone sees.
|
||||||
*/
|
*/
|
||||||
export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition {
|
export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition {
|
||||||
return {
|
return {
|
||||||
sql: `EXISTS (
|
sql: `(
|
||||||
|
EXISTS (
|
||||||
SELECT 1 FROM media_paradigms mp
|
SELECT 1 FROM media_paradigms mp
|
||||||
WHERE mp.media_id = ${alias}.id
|
WHERE mp.media_id = ${alias}.id
|
||||||
AND (
|
AND mp.media_paradigm = 'user-profile-image'
|
||||||
(mp.media_paradigm = 'user-profile-image' AND ${alias}.is_primary = 1)
|
AND ${alias}.is_primary = 1
|
||||||
OR (mp.media_paradigm = 'bot-image' AND EXISTS (
|
|
||||||
SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id
|
|
||||||
))
|
|
||||||
OR (mp.media_paradigm = 'workspace-image' AND EXISTS (
|
|
||||||
SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id
|
|
||||||
))
|
|
||||||
)
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id)
|
||||||
)`,
|
)`,
|
||||||
values: [],
|
values: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { BotReplyMaxToolStepsSettingKey } from "@/src/dict/bot-dict";
|
||||||
|
import { HarnessMaxToolStepsSettingKey } from "@/src/dict/harness-dict";
|
||||||
|
import readStepLimit from "@/src/utils/read-step-limit";
|
||||||
|
import grabAppSetting from "./grab-app-setting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model turns a task run may take: the install's own limit
|
||||||
|
* (`harness_max_tool_steps`, Settings > General), else
|
||||||
|
* `AppData.HarnessMaxToolSteps`. `runTextLoop`'s default is the code's one,
|
||||||
|
* so a task run passes this in.
|
||||||
|
*/
|
||||||
|
export async function grabHarnessMaxToolSteps() {
|
||||||
|
return readStepLimit({
|
||||||
|
saved: await grabAppSetting({ key: HarnessMaxToolStepsSettingKey }),
|
||||||
|
fallback: AppData.HarnessMaxToolSteps,
|
||||||
|
max: AppData.BotStepLimitMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model turns a chat reply may take: the install's own limit
|
||||||
|
* (`bot_reply_max_tool_steps`, Settings > General), else
|
||||||
|
* `AppData.BotReplyMaxToolSteps`.
|
||||||
|
*/
|
||||||
|
export async function grabReplyMaxToolSteps() {
|
||||||
|
return readStepLimit({
|
||||||
|
saved: await grabAppSetting({ key: BotReplyMaxToolStepsSettingKey }),
|
||||||
|
fallback: AppData.BotReplyMaxToolSteps,
|
||||||
|
max: AppData.BotStepLimitMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both limits with their defaults, for the settings page.
|
||||||
|
*/
|
||||||
|
export default async function grabRunLimitsOverview() {
|
||||||
|
const [task_steps, reply_steps] = await Promise.all([
|
||||||
|
grabHarnessMaxToolSteps(),
|
||||||
|
grabReplyMaxToolSteps(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overview: RunLimitsOverview = {
|
||||||
|
task_steps,
|
||||||
|
reply_steps,
|
||||||
|
task_default: AppData.HarnessMaxToolSteps,
|
||||||
|
reply_default: AppData.BotReplyMaxToolSteps,
|
||||||
|
max: AppData.BotStepLimitMax,
|
||||||
|
};
|
||||||
|
|
||||||
|
return overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunLimitsOverview = {
|
||||||
|
/**
|
||||||
|
* The limit a task run uses right now.
|
||||||
|
*/
|
||||||
|
task_steps: number;
|
||||||
|
/**
|
||||||
|
* The limit a chat reply uses right now.
|
||||||
|
*/
|
||||||
|
reply_steps: number;
|
||||||
|
/**
|
||||||
|
* What a task run uses when the install saved no limit.
|
||||||
|
*/
|
||||||
|
task_default: number;
|
||||||
|
/**
|
||||||
|
* What a chat reply uses when the install saved no limit.
|
||||||
|
*/
|
||||||
|
reply_default: number;
|
||||||
|
/**
|
||||||
|
* The most the settings page accepts.
|
||||||
|
*/
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import type { StorageScope } from "@/src/utils/storage-access";
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
import { canReachScope } from "@/src/utils/storage-access";
|
import { canReachScope } from "@/src/utils/storage-access";
|
||||||
import grabBotFile from "./grab-bot-file";
|
import grabBotFile from "./grab-bot-file";
|
||||||
@@ -42,12 +42,13 @@ export default async function copyBotFile({
|
|||||||
|
|
||||||
const file = await grabBotFile({ scopes, file_id });
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
if (!file?.id || !file.media?.media_write_path) {
|
const absolute = mediaFilePath({ write_path: file?.media?.media_write_path });
|
||||||
|
|
||||||
|
if (!file?.id || !absolute) {
|
||||||
throw new AppError(`That file isn't in your files.`);
|
throw new AppError(`That file isn't in your files.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
const source = await Bun.file(absolute).arrayBuffer();
|
||||||
const source = await Bun.file(`${DATA_DIR}/${file.media.media_write_path}`).arrayBuffer();
|
|
||||||
|
|
||||||
return await recordBotFile({
|
return await recordBotFile({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
import { rmSync } from "node:fs";
|
import { rmSync } from "node:fs";
|
||||||
@@ -24,7 +25,6 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
|
|||||||
throw new AppError(`That file isn't in your files.`);
|
throw new AppError(`That file isn't in your files.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
|
||||||
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
|
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
|
||||||
const absolute = path.join(root, file.path || "");
|
const absolute = path.join(root, file.path || "");
|
||||||
|
|
||||||
@@ -55,12 +55,10 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
|
|||||||
values: [file.media_id],
|
values: [file.media_id],
|
||||||
}).then((res) => res.payload?.[0]);
|
}).then((res) => res.payload?.[0]);
|
||||||
|
|
||||||
if (media?.media_write_path) {
|
for (const write_path of [media?.media_write_path, media?.media_thumbnail_write_path]) {
|
||||||
rmSync(path.join(DATA_DIR, media.media_write_path), { force: true });
|
const absolute = mediaFilePath({ write_path });
|
||||||
}
|
|
||||||
|
|
||||||
if (media?.media_thumbnail_write_path) {
|
if (absolute) rmSync(absolute, { force: true });
|
||||||
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), { force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
|
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
|
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
|
||||||
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
import { logError } from "@/src/functions/backend/logger";
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import recordBotFile from "./record-bot-file";
|
import recordBotFile from "./record-bot-file";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -26,14 +26,15 @@ export default async function storeChatAttachments({
|
|||||||
}: Params) {
|
}: Params) {
|
||||||
if (!media.length) return;
|
if (!media.length) return;
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
|
||||||
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
|
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
|
||||||
|
|
||||||
for (const file of media) {
|
for (const file of media) {
|
||||||
try {
|
try {
|
||||||
if (!file.media_write_path) continue;
|
const absolute = mediaFilePath({ write_path: file.media_write_path });
|
||||||
|
|
||||||
const source = Bun.file(`${DATA_DIR}/${file.media_write_path}`);
|
if (!absolute) continue;
|
||||||
|
|
||||||
|
const source = Bun.file(absolute);
|
||||||
|
|
||||||
if (!(await source.exists())) continue;
|
if (!(await source.exists())) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { ConversationHistory } from "@/src/types";
|
||||||
|
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||||
|
import grabBotMessages from "../bots/grab-bot-messages";
|
||||||
|
import ensureBotSession from "./ensure-bot-session";
|
||||||
|
import grabUserConversation from "./grab-user-conversation";
|
||||||
|
import markBotSessionRead from "./mark-bot-session-read";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the conversation is in; none for the bot's own chat.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's history, which the conversation pages load on the
|
||||||
|
* client once they render (`api/admin/bots/conversation-history`): the
|
||||||
|
* bot's active session there (marked read), its latest
|
||||||
|
* `AppData.ConversationFirstMessages` messages (the thread loads older
|
||||||
|
* ones as it scrolls) and the bot's active task in it. Everything else on
|
||||||
|
* the page comes with it from the server (`grabConversationProps`).
|
||||||
|
* Null when the user can't chat with the bot there.
|
||||||
|
*/
|
||||||
|
export default async function grabConversationHistory({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<ConversationHistory | null> {
|
||||||
|
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!conversation?.bot?.id) return null;
|
||||||
|
|
||||||
|
const open_bot_id = conversation.bot.id;
|
||||||
|
const session = await ensureBotSession({
|
||||||
|
user_id,
|
||||||
|
bot_id: open_bot_id,
|
||||||
|
workspace_id: conversation.workspace_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.unread) {
|
||||||
|
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||||
|
session.unread = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [messages, task] = await Promise.all([
|
||||||
|
grabBotMessages({
|
||||||
|
session_id: session.id!,
|
||||||
|
user_id,
|
||||||
|
limit: AppData.ConversationFirstMessages + 1,
|
||||||
|
}),
|
||||||
|
grabActiveBotTask({ bot_id: open_bot_id, session_id: session.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const has_more = messages.length > AppData.ConversationFirstMessages;
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
messages: has_more ? messages.slice(1) : messages,
|
||||||
|
has_more,
|
||||||
|
task,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
|
||||||
import type { BotTextModel, PagePropsType } from "@/src/types";
|
import type { BotTextModel, PagePropsType } from "@/src/types";
|
||||||
import railBots from "@/src/utils/rail-bots";
|
import railBots from "@/src/utils/rail-bots";
|
||||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||||
import grabBots from "../bots/grab-bots";
|
import grabBots from "../bots/grab-bots";
|
||||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
|
||||||
import grabBotMessages from "../bots/grab-bot-messages";
|
|
||||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||||
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
||||||
import grabBotComputers from "../computers/grab-bot-computers";
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||||
import ensureBotSession from "./ensure-bot-session";
|
|
||||||
import grabUserConversation from "./grab-user-conversation";
|
import grabUserConversation from "./grab-user-conversation";
|
||||||
import markBotSessionRead from "./mark-bot-session-read";
|
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -27,11 +22,12 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* The page props of a conversation (`/admin/bots/<id>` or
|
* The page props of a conversation (`/admin/bots/<id>` or
|
||||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||||
* in a workspace) and workspaces, the bot, its active session (marked
|
* in a workspace) and workspaces, the bot, the text model it runs on, its
|
||||||
* read) and its latest `AppData.ConversationFirstMessages` messages (the
|
* computers, tools, MCP servers and files there, and whether its mic and
|
||||||
* thread loads older ones as it scrolls), the text model it runs on, its computers,
|
* read-aloud have a model to run on. The history (its session, messages
|
||||||
* tools and MCP servers and active task there, and whether its mic and
|
* and active task) is left to the client (`grabConversationHistory`,
|
||||||
* read-aloud have a model to run on. The full model settings are left to
|
* `api/admin/bots/conversation-history`), so a page the back button
|
||||||
|
* restores never shows an old thread. The full model settings are left to
|
||||||
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
||||||
* and the conversation's small setting
|
* and the conversation's small setting
|
||||||
* (`api/admin/bots/harness-overview`). They are most of a page's weight
|
* (`api/admin/bots/harness-overview`). They are most of a page's weight
|
||||||
@@ -50,12 +46,6 @@ export default async function grabConversationProps({
|
|||||||
if (!conversation) return null;
|
if (!conversation) return null;
|
||||||
|
|
||||||
const scope = conversation.workspace_id;
|
const scope = conversation.workspace_id;
|
||||||
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
|
|
||||||
|
|
||||||
if (session.unread) {
|
|
||||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
|
||||||
session.unread = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [bots, workspaces] = await Promise.all([
|
const [bots, workspaces] = await Promise.all([
|
||||||
grabBots({ user_id, workspace_id: scope }),
|
grabBots({ user_id, workspace_id: scope }),
|
||||||
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
|
|||||||
|
|
||||||
if (!bot?.id) return null;
|
if (!bot?.id) return null;
|
||||||
|
|
||||||
|
/** Loading its history marks the open session read. */
|
||||||
bot.unread = 0;
|
bot.unread = 0;
|
||||||
|
|
||||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||||
grabBotMessages({
|
|
||||||
session_id: session.id!,
|
|
||||||
user_id,
|
|
||||||
limit: AppData.ConversationFirstMessages + 1,
|
|
||||||
}),
|
|
||||||
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
|
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
|
||||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
|
||||||
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||||
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
||||||
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
||||||
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bots: railBots({ bots }),
|
bots: railBots({ bots }),
|
||||||
bot: railBots({ bots: [bot] })[0],
|
bot: railBots({ bots: [bot] })[0],
|
||||||
workspaces: railWorkspaces({ workspaces }),
|
workspaces: railWorkspaces({ workspaces }),
|
||||||
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
|
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
|
||||||
bot_session: session,
|
|
||||||
bot_messages: bot_messages_more ? bot_messages.slice(1) : bot_messages,
|
|
||||||
bot_messages_more,
|
|
||||||
bot_text_model,
|
bot_text_model,
|
||||||
bot_computers,
|
bot_computers,
|
||||||
bot_task,
|
|
||||||
bot_extensions,
|
bot_extensions,
|
||||||
bot_files,
|
bot_files,
|
||||||
bot_voice: { transcription, speech },
|
bot_voice: { transcription, speech },
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { BotTypes } from "@/src/dict/bot-types-dict";
|
||||||
|
import type { BotType } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import botTypeAssignment from "@/src/utils/rbac/bot-type-assignment";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabUserTypes from "../users/grab-user-types";
|
||||||
|
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
||||||
|
import grabBotTypes from "./grab-bot-types";
|
||||||
|
|
||||||
|
export type BotTypeChangeParams = {
|
||||||
|
/**
|
||||||
|
* The user making the change, or the user a granting bot runs for.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot making the change from chat (the coordinator), if any: it
|
||||||
|
* must be allowed as well as the user.
|
||||||
|
*/
|
||||||
|
actor_bot_id?: number | string | null;
|
||||||
|
bot_id: number | string;
|
||||||
|
bot_type: BotType;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a grant or removal of a bot type: the user (and the bot, when one
|
||||||
|
* acts) may do it (`botTypeAssignment`), only workspace types are ever
|
||||||
|
* changed (the coordinator's is written by `ensureCoordinatorBot`), and the
|
||||||
|
* bot works in the workspace. Returns the bot and the workspace id.
|
||||||
|
*/
|
||||||
|
export default async function assertBotTypeChange({
|
||||||
|
user_id,
|
||||||
|
actor_bot_id,
|
||||||
|
bot_id,
|
||||||
|
bot_type,
|
||||||
|
workspace_id,
|
||||||
|
action,
|
||||||
|
}: BotTypeChangeParams & { action: "assign" | "delete" }) {
|
||||||
|
const type = BotTypes.find((bt) => bt.value == bot_type);
|
||||||
|
|
||||||
|
if (!type || type.scope != "workspace") {
|
||||||
|
throw new AppError(`Only a workspace type can be given or taken away.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspace_id) {
|
||||||
|
throw new AppError(`Pick the workspace the bot is ${type.title.toLowerCase()} of.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user_types = await grabUserTypes({ user_id });
|
||||||
|
|
||||||
|
if (!botTypeAssignment({ actor_user_types: user_types, target_bot_type: bot_type, action })) {
|
||||||
|
throw new AppError(`Only an admin can do that.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor_bot_id) {
|
||||||
|
const actor_bot_types = await grabBotTypes({ bot_id: actor_bot_id });
|
||||||
|
|
||||||
|
if (!botTypeAssignment({ actor_bot_types, target_bot_type: bot_type, action })) {
|
||||||
|
throw new AppError(`Only the coordinator can do that.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bot.kind == "coordinator") {
|
||||||
|
throw new AppError(`The coordinator already leads every workspace.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "assign") {
|
||||||
|
const member = await BunSQLite.sql<{ n: number }>({
|
||||||
|
sql: `SELECT COUNT(*) AS n FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`,
|
||||||
|
values: [workspace.id, bot.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member.payload?.[0]?.n) {
|
||||||
|
throw new AppError(`${bot.name} isn't in ${workspace.name}. Add it to the workspace first.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bot, workspace };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives the coordinator its install-wide `coordinator` bot type, once.
|
||||||
|
* The only place that type is ever written.
|
||||||
|
*/
|
||||||
|
export default async function ensureCoordinatorType({ bot_id }: Params) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, created_at, updated_at)
|
||||||
|
SELECT ?, 'coordinator', NULL, ?, ? WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM bot_types WHERE bot_id = ? AND bot_type = 'coordinator' AND workspace_id IS NULL
|
||||||
|
)`,
|
||||||
|
values: [Number(bot_id), Date.now(), Date.now(), Number(bot_id)],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { BotPermission } from "@/src/types";
|
||||||
|
import botCan from "@/src/utils/rbac/bot-can";
|
||||||
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
|
import grabBotTypes from "./grab-bot-types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Where the run works: its own chat (null) or a workspace.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotRunPowers = {
|
||||||
|
bot_types: BotTypeRow[];
|
||||||
|
/**
|
||||||
|
* Holds the install-wide coordinator type.
|
||||||
|
*/
|
||||||
|
is_coordinator: boolean;
|
||||||
|
/**
|
||||||
|
* The workspaces it's admin of.
|
||||||
|
*/
|
||||||
|
admin_workspace_ids: number[];
|
||||||
|
/**
|
||||||
|
* Admin of the workspace the run is in.
|
||||||
|
*/
|
||||||
|
is_workspace_admin: boolean;
|
||||||
|
/**
|
||||||
|
* Whether its types allow `permission` where the run works. The bot's
|
||||||
|
* side only: the run's user must still be allowed.
|
||||||
|
*/
|
||||||
|
can: (permission: BotPermission) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a bot's types let it do on one run, read once (`grabBotTypes`).
|
||||||
|
*/
|
||||||
|
export default async function grabBotRunPowers({ bot_id, workspace_id }: Params): Promise<BotRunPowers> {
|
||||||
|
const bot_types = await grabBotTypes({ bot_id });
|
||||||
|
|
||||||
|
const admin_workspace_ids = bot_types
|
||||||
|
.filter((bt) => bt.bot_type == "admin" && bt.workspace_id)
|
||||||
|
.map((bt) => Number(bt.workspace_id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
bot_types,
|
||||||
|
is_coordinator: bot_types.some((bt) => bt.bot_type == "coordinator" && !bt.workspace_id),
|
||||||
|
admin_workspace_ids,
|
||||||
|
is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))),
|
||||||
|
can: (permission) => botCan({ bot_types, permission, workspace_id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot's types (`bot_types`), plus the install-wide coordinator type for
|
||||||
|
* the legacy `kind = 'coordinator'` bot, so the coordinator keeps its
|
||||||
|
* powers before `ensureCoordinatorBot` has written its row.
|
||||||
|
*/
|
||||||
|
export default async function grabBotTypes({ bot_id }: Params): Promise<BotTypeRow[]> {
|
||||||
|
const res = await BunSQLite.sql<BotTypeRow>({
|
||||||
|
sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ?
|
||||||
|
UNION SELECT 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id = ? AND kind = 'coordinator'`,
|
||||||
|
values: [Number(bot_id), Number(bot_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_ids: (number | string | "" | undefined)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Row = BotTypeRow & { bot_id: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `grabBotTypes` for a list of bots at once, keyed by bot id. A bot with no
|
||||||
|
* row is missing from the map (a worker).
|
||||||
|
*/
|
||||||
|
export default async function grabBotsTypes({ bot_ids }: Params): Promise<Map<number, BotTypeRow[]>> {
|
||||||
|
const ids = [...new Set(bot_ids.map(Number).filter(Boolean))];
|
||||||
|
const map = new Map<number, BotTypeRow[]>();
|
||||||
|
|
||||||
|
if (!ids.length) return map;
|
||||||
|
|
||||||
|
const marks = ids.map(() => "?").join(", ");
|
||||||
|
const res = await BunSQLite.sql<Row>({
|
||||||
|
sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks})
|
||||||
|
UNION SELECT id AS bot_id, 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id IN (${marks}) AND kind = 'coordinator'`,
|
||||||
|
values: [...ids, ...ids],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of res.payload || []) {
|
||||||
|
const list = map.get(row.bot_id) || [];
|
||||||
|
list.push({ bot_type: row.bot_type, workspace_id: row.workspace_id });
|
||||||
|
map.set(row.bot_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import botCanTarget from "@/src/utils/rbac/bot-can-target";
|
||||||
|
import grabCrewWorkers from "../bots/grab-crew-workers";
|
||||||
|
import grabBotTypes from "./grab-bot-types";
|
||||||
|
import grabBotsTypes from "./grab-bots-types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The bot handing out the work.
|
||||||
|
*/
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots in a workspace that a bot may direct there (hand a task to, set
|
||||||
|
* instructions for): read fresh, so a member added mid-run is there, and
|
||||||
|
* filtered through `BotTypeTargetMatrix` (`botCanTarget`), never itself.
|
||||||
|
*/
|
||||||
|
export default async function grabDirectableBots({ bot_id, workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||||
|
const members = (await grabCrewWorkers({ workspace_id })).filter((bot) => bot.id != Number(bot_id));
|
||||||
|
|
||||||
|
if (!members.length) return [];
|
||||||
|
|
||||||
|
const actor_bot_types = await grabBotTypes({ bot_id });
|
||||||
|
const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) });
|
||||||
|
|
||||||
|
return members.filter((bot) =>
|
||||||
|
botCanTarget({
|
||||||
|
actor_bot_types,
|
||||||
|
target_bot_types: types.get(Number(bot.id)) || [],
|
||||||
|
workspace_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots that are admins of a workspace and still work in it.
|
||||||
|
*/
|
||||||
|
export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT b.* FROM bots b
|
||||||
|
JOIN bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?
|
||||||
|
JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = bt.workspace_id
|
||||||
|
GROUP BY b.id ORDER BY b.id ASC`,
|
||||||
|
values: [Number(workspace_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a workspace type away from a bot. Taking one it doesn't have is a
|
||||||
|
* no-op. Checked by `assertBotTypeChange`.
|
||||||
|
*/
|
||||||
|
export default async function removeBotType(params: BotTypeChangeParams) {
|
||||||
|
const { bot, workspace } = await assertBotTypeChange({ ...params, action: "delete" });
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ?`,
|
||||||
|
values: [Number(bot.id), params.bot_type, Number(workspace.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`Couldn't remove the bot's type: ${res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: `Bot type removed`,
|
||||||
|
category: "system",
|
||||||
|
user_id: Number(params.user_id),
|
||||||
|
plain_text: `${bot.name} (#${bot.id}) is no longer ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return { bot, workspace };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a bot a workspace type (makes it admin of a workspace it works
|
||||||
|
* in). Giving it again is a no-op. Checked by `assertBotTypeChange`.
|
||||||
|
*/
|
||||||
|
export default async function setBotType(params: BotTypeChangeParams) {
|
||||||
|
const { bot, workspace } = await assertBotTypeChange({ ...params, action: "assign" });
|
||||||
|
|
||||||
|
const existing = await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `SELECT id FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ? LIMIT 1`,
|
||||||
|
values: [Number(bot.id), params.bot_type, Number(workspace.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.payload?.[0]?.id) return { bot, workspace, changed: false };
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, granted_by_user_id, granted_by_bot_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
values: [
|
||||||
|
Number(bot.id),
|
||||||
|
params.bot_type,
|
||||||
|
Number(workspace.id),
|
||||||
|
Number(params.user_id),
|
||||||
|
params.actor_bot_id ? Number(params.actor_bot_id) : null,
|
||||||
|
Date.now(),
|
||||||
|
Date.now(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`Couldn't save the bot's type: ${res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: `Bot type given`,
|
||||||
|
category: "system",
|
||||||
|
user_id: Number(params.user_id),
|
||||||
|
plain_text: `${bot.name} (#${bot.id}) is now ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return { bot, workspace, changed: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import answeredMessageIdSql from "./answered-message-id-sql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What decides whether a queued reply still has something to answer, run
|
||||||
|
* against real SQLite: only finished replies in the same session count, so
|
||||||
|
* a failed or still running one never hides a message.
|
||||||
|
*/
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`CREATE TABLE bots_queue (id INTEGER PRIMARY KEY, session_id INTEGER, kind TEXT, status TEXT, message_id INTEGER)`,
|
||||||
|
);
|
||||||
|
db.run(`INSERT INTO bots_queue VALUES
|
||||||
|
(1, 100, 'reply', 'completed', 10),
|
||||||
|
(2, 100, 'reply', 'completed', 14),
|
||||||
|
(3, 100, 'reply', 'processing', 20),
|
||||||
|
(4, 100, 'reply', 'failed', 22),
|
||||||
|
(5, 100, 'task', 'completed', 30),
|
||||||
|
(6, 101, 'reply', 'completed', 40),
|
||||||
|
(7, 102, 'reply', 'pending', 50)`);
|
||||||
|
|
||||||
|
function answered(session_id: number) {
|
||||||
|
const { sql, values } = answeredMessageIdSql({ session_id });
|
||||||
|
|
||||||
|
return (db.query(sql).get(...values) as { id: number | null }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("answeredMessageIdSql", () => {
|
||||||
|
test("is the newest message a finished reply in the session read", () => {
|
||||||
|
expect(answered(100)).toBe(14);
|
||||||
|
expect(answered(101)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores running and failed replies, and tasks", () => {
|
||||||
|
expect(answered(100)).not.toBe(20);
|
||||||
|
expect(answered(100)).not.toBe(22);
|
||||||
|
expect(answered(100)).not.toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is null where no reply has finished", () => {
|
||||||
|
expect(answered(102)).toBe(null);
|
||||||
|
expect(answered(999)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SqlCondition } from "@/src/utils/crew-access/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SQL for the newest user message a finished reply in the session
|
||||||
|
* answered: a reply job's `message_id` is the newest one it read
|
||||||
|
* (`markReplyAnswered`), so a message with a higher id came in after the
|
||||||
|
* bot last looked and still needs an answer, wherever its reply landed in
|
||||||
|
* the thread. Selects one `id` column, null when no reply has finished.
|
||||||
|
*/
|
||||||
|
export default function answeredMessageIdSql({ session_id }: Params): SqlCondition {
|
||||||
|
return {
|
||||||
|
sql: `SELECT MAX(message_id) AS id FROM bots_queue
|
||||||
|
WHERE session_id = ? AND kind = 'reply' AND status = 'completed'`,
|
||||||
|
values: [Number(session_id)],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import relayJobToRequester from "./relay-job-to-requester";
|
|||||||
import jobSessionId from "./job-session-id";
|
import jobSessionId from "./job-session-id";
|
||||||
import grabJobUserId from "./grab-job-user-id";
|
import grabJobUserId from "./grab-job-user-id";
|
||||||
import grabRunningBotJobFace from "./grab-running-bot-job-face";
|
import grabRunningBotJobFace from "./grab-running-bot-job-face";
|
||||||
|
import withMessageUsage from "../usage/with-message-usage";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
job_id: number | string;
|
job_id: number | string;
|
||||||
@@ -78,7 +79,7 @@ export default async function completeBotJob({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = silent
|
const inserted = silent
|
||||||
? null
|
? null
|
||||||
: await insertBotMessage({
|
: await insertBotMessage({
|
||||||
bot_id: job.bot_id,
|
bot_id: job.bot_id,
|
||||||
@@ -89,7 +90,7 @@ export default async function completeBotJob({
|
|||||||
media_ids: attachments,
|
media_ids: attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (job.id && message?.id) {
|
if (job.id && inserted?.id) {
|
||||||
/**
|
/**
|
||||||
* The job's model calls were written down as they happened, before
|
* The job's model calls were written down as they happened, before
|
||||||
* its message existed. Now that it does, they belong to it, so the
|
* its message existed. Now that it does, they belong to it, so the
|
||||||
@@ -97,10 +98,18 @@ export default async function completeBotJob({
|
|||||||
*/
|
*/
|
||||||
await BunSQLite.sql({
|
await BunSQLite.sql({
|
||||||
sql: `UPDATE token_usages SET message_id = ? WHERE job_id = ? AND message_id IS NULL`,
|
sql: `UPDATE token_usages SET message_id = ? WHERE job_id = ? AND message_id IS NULL`,
|
||||||
values: [message.id, job.id],
|
values: [inserted.id, job.id],
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushed live as it is returned, so it carries its usage now: the
|
||||||
|
* thread shows the tokens without a reload.
|
||||||
|
*/
|
||||||
|
const message = inserted
|
||||||
|
? ((await withMessageUsage({ messages: [inserted] }).catch(() => [inserted]))[0] ?? inserted)
|
||||||
|
: null;
|
||||||
|
|
||||||
const user_id = await grabJobUserId({ job });
|
const user_id = await grabJobUserId({ job });
|
||||||
|
|
||||||
const bot = !user_id
|
const bot = !user_id
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import answeredMessageIdSql from "./answered-message-id-sql";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest user message a finished reply in the session answered
|
||||||
|
* (`answeredMessageIdSql`), or null when none is on record.
|
||||||
|
*/
|
||||||
|
export default async function grabAnsweredMessageId({ session_id }: Params) {
|
||||||
|
const { sql, values } = answeredMessageIdSql({ session_id });
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<{ id: number | null }>({ sql, values });
|
||||||
|
const id = res.payload?.[0]?.id;
|
||||||
|
|
||||||
|
return id ? Number(id) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
job_id: number | string;
|
||||||
|
/**
|
||||||
|
* The newest user message the reply read.
|
||||||
|
*/
|
||||||
|
message_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the newest user message a running reply read, so a reply queued
|
||||||
|
* for a message it already took in finishes silently and one it missed is
|
||||||
|
* still answered (`grabAnsweredMessageId`). Never lowers it.
|
||||||
|
*/
|
||||||
|
export default async function markReplyAnswered({ job_id, message_id }: Params) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bots_queue SET message_id = ?
|
||||||
|
WHERE id = ? AND status = 'processing' AND (message_id IS NULL OR message_id < ?)`,
|
||||||
|
values: [message_id, job_id, message_id],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
);
|
);
|
||||||
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly, since foreign keys are off outside the server process.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
|||||||
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
||||||
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
||||||
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||||
|
import ensureCoordinatorType from "../bot-types/ensure-coordinator-type";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +17,9 @@ type Params = {
|
|||||||
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
|
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
|
||||||
* if there is none. A
|
* if there is none. A
|
||||||
* coordinator without a prompt gets the default; an edited prompt is never
|
* coordinator without a prompt gets the default; an edited prompt is never
|
||||||
* overwritten. Safe to call on every visit.
|
* overwritten. Its install-wide `coordinator` bot type is written too
|
||||||
|
* (`ensureCoordinatorType`), which backfills older installs. Safe to call
|
||||||
|
* on every visit.
|
||||||
*/
|
*/
|
||||||
export default async function ensureCoordinatorBot({ user_id }: Params) {
|
export default async function ensureCoordinatorBot({ user_id }: Params) {
|
||||||
const existing = (
|
const existing = (
|
||||||
@@ -43,6 +46,8 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureCoordinatorType({ bot_id: existing.id });
|
||||||
|
|
||||||
return existing.id;
|
return existing.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +73,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await attachDefaultExtensions({ bot_id });
|
await attachDefaultExtensions({ bot_id });
|
||||||
|
await ensureCoordinatorType({ bot_id });
|
||||||
|
|
||||||
return bot_id;
|
return bot_id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ type Params = {
|
|||||||
* Only messages older than this one, for loading further back.
|
* Only messages older than this one, for loading further back.
|
||||||
*/
|
*/
|
||||||
before_id?: number | null;
|
before_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Only messages saved after this one (by id, which is insertion
|
||||||
|
* order), for what arrived while a reply ran.
|
||||||
|
*/
|
||||||
|
after_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Only one side's messages.
|
||||||
|
*/
|
||||||
|
author?: "user" | "bot";
|
||||||
/**
|
/**
|
||||||
* Whose conversation it is, so a reaction can show as the user's own.
|
* Whose conversation it is, so a reaction can show as the user's own.
|
||||||
* Left out for the bot's own history reads, which don't need it.
|
* Left out for the bot's own history reads, which don't need it.
|
||||||
@@ -30,9 +39,12 @@ export default async function grabBotMessages({
|
|||||||
session_id,
|
session_id,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
before_id,
|
before_id,
|
||||||
|
after_id,
|
||||||
|
author,
|
||||||
user_id,
|
user_id,
|
||||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||||
let sql = ``;
|
let sql = ``;
|
||||||
|
const values: (string | number)[] = [session_id];
|
||||||
|
|
||||||
sql += `SELECT * FROM (\n`;
|
sql += `SELECT * FROM (\n`;
|
||||||
sql += ` SELECT * FROM bot_messages\n`;
|
sql += ` SELECT * FROM bot_messages\n`;
|
||||||
@@ -40,6 +52,17 @@ export default async function grabBotMessages({
|
|||||||
|
|
||||||
if (before_id) {
|
if (before_id) {
|
||||||
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
|
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
|
||||||
|
values.push(before_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (after_id) {
|
||||||
|
sql += ` AND id > ?\n`;
|
||||||
|
values.push(after_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (author) {
|
||||||
|
sql += ` AND author = ?\n`;
|
||||||
|
values.push(author);
|
||||||
}
|
}
|
||||||
|
|
||||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||||
@@ -48,7 +71,7 @@ export default async function grabBotMessages({
|
|||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||||
sql,
|
sql,
|
||||||
values: before_id ? [session_id, before_id, limit] : [session_id, limit],
|
values: [...values, limit],
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages = res.payload || [];
|
const messages = res.payload || [];
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full prompt for one of the user's bots (`composeBotPrompt`): its
|
* The full prompt for one of the user's bots (`composeBotPrompt`): who it
|
||||||
* core instructions, the install-wide appendix, its memory, what it
|
* is (its name and id), its core instructions, the install-wide appendix,
|
||||||
* remembers about this user, and in a workspace that workspace's context
|
* its memory, what it remembers about this user, and in a workspace that
|
||||||
* and what the bot has for it.
|
* workspace's context and what the bot has for it.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
|
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
|
||||||
const bot = await grabUserBot({ bot_id, user_id });
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
@@ -40,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
|
|||||||
}
|
}
|
||||||
|
|
||||||
return composeBotPrompt({
|
return composeBotPrompt({
|
||||||
|
bot: { id: bot.id, name: bot.name, kind: bot.kind, role: bot.role },
|
||||||
system_prompt: bot.system_prompt,
|
system_prompt: bot.system_prompt,
|
||||||
appendix: await grabSystemPromptAppend(),
|
appendix: await grabSystemPromptAppend(),
|
||||||
memory: bot.memory,
|
memory: bot.memory,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ type Params = {
|
|||||||
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
|
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
|
||||||
* coordinator is pinned first, then workers by latest activity. Each row
|
* coordinator is pinned first, then workers by latest activity. Each row
|
||||||
* carries the user's own face (`botFaceSql`) and conversation there: its
|
* carries the user's own face (`botFaceSql`) and conversation there: its
|
||||||
* unread flag, latest activity and latest message.
|
* unread flag, latest activity and latest message, and in a workspace
|
||||||
|
* whether it's that workspace's admin (`is_admin`).
|
||||||
*/
|
*/
|
||||||
export default async function grabBots({
|
export default async function grabBots({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -40,7 +41,8 @@ export default async function grabBots({
|
|||||||
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
|
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
|
||||||
sql += ` t.last_activity_at AS last_activity_at,\n`;
|
sql += ` t.last_activity_at AS last_activity_at,\n`;
|
||||||
sql += ` lm.text AS last_message_text,\n`;
|
sql += ` lm.text AS last_message_text,\n`;
|
||||||
sql += ` lm.created_at AS last_message_at\n`;
|
sql += ` lm.created_at AS last_message_at,\n`;
|
||||||
|
sql += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?) AS is_admin\n`;
|
||||||
sql += `FROM bots b\n`;
|
sql += `FROM bots b\n`;
|
||||||
sql += `LEFT JOIN (\n`;
|
sql += `LEFT JOIN (\n`;
|
||||||
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
|
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
|
||||||
@@ -59,8 +61,8 @@ export default async function grabBots({
|
|||||||
|
|
||||||
const res = await BunSQLite.sql<BotWithLastMessage>({
|
const res = await BunSQLite.sql<BotWithLastMessage>({
|
||||||
sql,
|
sql,
|
||||||
values: [...face.values, scope, user_id, scope, user_id, ...access.values],
|
values: [...face.values, scope, scope, user_id, scope, user_id, ...access.values],
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.payload || [];
|
return (res.payload || []).map((bot) => ({ ...bot, is_admin: Boolean(bot.is_admin) }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type Params = {
|
|||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
needs_you?: boolean;
|
needs_you?: boolean;
|
||||||
|
/**
|
||||||
|
* Sent mid-reply (`send_message`): the reply is still being worked on.
|
||||||
|
*/
|
||||||
|
interim?: boolean;
|
||||||
/**
|
/**
|
||||||
* Files the bot saved and sent with it (`media` ids): a PDF it made, a
|
* Files the bot saved and sent with it (`media` ids): a PDF it made, a
|
||||||
* generated image, a document it wrote.
|
* generated image, a document it wrote.
|
||||||
@@ -30,10 +34,12 @@ export default async function insertBotMessage({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
text,
|
text,
|
||||||
needs_you,
|
needs_you,
|
||||||
|
interim,
|
||||||
media_ids = [],
|
media_ids = [],
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const needs_you_flag = needs_you ? 1 : 0;
|
const needs_you_flag = needs_you ? 1 : 0;
|
||||||
|
const interim_flag = interim ? 1 : 0;
|
||||||
|
|
||||||
const res = await BunSQLite.insert<
|
const res = await BunSQLite.insert<
|
||||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||||
@@ -48,6 +54,7 @@ export default async function insertBotMessage({
|
|||||||
author: "bot",
|
author: "bot",
|
||||||
text,
|
text,
|
||||||
needs_you: needs_you_flag,
|
needs_you: needs_you_flag,
|
||||||
|
interim: interim_flag,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -78,6 +85,7 @@ export default async function insertBotMessage({
|
|||||||
author: "bot",
|
author: "bot",
|
||||||
text,
|
text,
|
||||||
needs_you: needs_you_flag,
|
needs_you: needs_you_flag,
|
||||||
|
interim: interim_flag,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import provisionComputer from "./provision-computer";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number;
|
||||||
|
/**
|
||||||
|
* Its container's `metadata`.
|
||||||
|
*/
|
||||||
|
metadata?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreates a computer whose container still mounts its whole home (set up
|
||||||
|
* before only `AppData.ComputerWorkspaceDir` was mounted), so its bots work
|
||||||
|
* in the workspace alone. `provisionComputer` moves the old home aside
|
||||||
|
* (`prepareComputerDataDir`) and records the layout, so this runs once per
|
||||||
|
* computer. Returns whether it did.
|
||||||
|
*/
|
||||||
|
export default async function migrateComputerLayout({ computer_id, metadata }: Params) {
|
||||||
|
const layout = parseJobJson({ text: metadata || "" })?.layout;
|
||||||
|
|
||||||
|
if (layout == AppData.ComputerWorkspaceLayout) return false;
|
||||||
|
|
||||||
|
await provisionComputer({ computer_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: `Computer #${computer_id} now mounts only its workspace`,
|
||||||
|
category: "system",
|
||||||
|
plain_text: `Recreated computer #${computer_id} so only ${AppData.ComputerWorkspaceDir} is kept; its old home was moved aside and kept on disk.`,
|
||||||
|
metadata: { computer_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabComputerDataDir from "./grab-computer-data-dir";
|
import grabComputerDataDir from "./grab-computer-data-dir";
|
||||||
|
|
||||||
@@ -13,28 +14,45 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a computer's home directory, owned by the sandbox's user so it
|
* Creates a computer's workspace directory (mounted at
|
||||||
* can write there, and returns its path. Docker would otherwise create a
|
* `AppData.ComputerWorkspaceDir`), with its `bots` and `workspaces`
|
||||||
* missing mount source owned by root.
|
* folders, owned by the sandbox's user so it can write there, and returns
|
||||||
|
* its path. Docker would otherwise create a missing mount source owned by
|
||||||
|
* root.
|
||||||
|
*
|
||||||
|
* A directory without the layout marker (`AppData.ComputerWorkspaceMarker`)
|
||||||
|
* is a computer's whole home from before only the workspace was mounted:
|
||||||
|
* it is moved aside to `<id>-home-<timestamp>` and kept on disk (left out
|
||||||
|
* of data backups), so the computer starts with a clean workspace.
|
||||||
*/
|
*/
|
||||||
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
|
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
|
||||||
const dir = grabComputerDataDir({ computer_id });
|
const dir = grabComputerDataDir({ computer_id });
|
||||||
|
const marker = path.join(dir, AppData.ComputerWorkspaceMarker);
|
||||||
if (fresh) {
|
|
||||||
const existing = await fs.readdir(dir).catch(() => null);
|
const existing = await fs.readdir(dir).catch(() => null);
|
||||||
|
|
||||||
if (existing?.length) {
|
if (existing?.length) {
|
||||||
|
if (fresh) {
|
||||||
await fs.rename(dir, `${dir}-${Date.now()}`);
|
await fs.rename(dir, `${dir}-${Date.now()}`);
|
||||||
|
} else if (!(await fs.stat(marker).catch(() => null))) {
|
||||||
|
await fs.rename(dir, `${dir}${AppData.ComputerOldHomeSuffix}${Date.now()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
for (const folder of [dir, path.join(dir, "bots"), path.join(dir, "workspaces")]) {
|
||||||
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
await chownToSandbox({ target: folder });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
await fs.writeFile(marker, "", { flag: "a" });
|
||||||
* Only root can hand a directory to another user; a dev server
|
await chownToSandbox({ target: marker });
|
||||||
* running as that user already owns it.
|
|
||||||
*/
|
|
||||||
await fs.chown(dir, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
|
|
||||||
|
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only root can hand a file to another user; a dev server running as that
|
||||||
|
* user already owns it.
|
||||||
|
*/
|
||||||
|
async function chownToSandbox({ target }: { target: string }) {
|
||||||
|
await fs.chown(target, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,18 +10,20 @@ import pushComputerUpdate from "./push-computer-update";
|
|||||||
type Params = {
|
type Params = {
|
||||||
computer_id: number;
|
computer_id: number;
|
||||||
/**
|
/**
|
||||||
* A brand new computer: start from an empty home directory.
|
* A brand new computer: start from an empty workspace.
|
||||||
*/
|
*/
|
||||||
fresh?: boolean;
|
fresh?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up a computer's container: prepares its home directory, pulls the
|
* Sets up a computer's container: prepares its workspace directory, pulls the
|
||||||
* sandbox image and starts the container with the computer's resources
|
* sandbox image and starts the container with the computer's resources
|
||||||
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
||||||
* docker id, state, and its IP address on the network). A failure is
|
* docker id, state, and its IP address on the network). A failure is
|
||||||
* recorded as `failed` with its reason, which the computer lists show. The
|
* recorded as `failed` with its reason, which the computer lists show. The
|
||||||
* owner's tabs hear about it either way. Never throws.
|
* owner's tabs hear about it either way. The metadata's `layout` says the
|
||||||
|
* container mounts only the workspace (`syncComputers` recreates one that
|
||||||
|
* doesn't). Never throws.
|
||||||
*/
|
*/
|
||||||
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
||||||
const row = (
|
const row = (
|
||||||
@@ -65,7 +67,12 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
|
|||||||
created.ip_address,
|
created.ip_address,
|
||||||
AppData.ComputerPort,
|
AppData.ComputerPort,
|
||||||
created.status,
|
created.status,
|
||||||
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
|
JSON.stringify({
|
||||||
|
...kept,
|
||||||
|
data_dir,
|
||||||
|
host_dir: created.host_dir,
|
||||||
|
layout: AppData.ComputerWorkspaceLayout,
|
||||||
|
}),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
row.id,
|
row.id,
|
||||||
|
|||||||
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import resolveComputerHomeDir from "./resolve-computer-home-dir";
|
import resolveComputerWorkspaceDir from "./resolve-computer-workspace-dir";
|
||||||
|
|
||||||
const data_dir = "/data";
|
const data_dir = "/data";
|
||||||
|
|
||||||
describe("resolveComputerHomeDir", () => {
|
describe("resolveComputerWorkspaceDir", () => {
|
||||||
test("uses the directory the app bind-mounted", () => {
|
test("uses the directory the app bind-mounted", () => {
|
||||||
/**
|
/**
|
||||||
* The truth for a container the app provisioned: on a bind mount
|
* The truth for a container the app provisioned: on a bind mount
|
||||||
* that can be anywhere on the docker host, not under DATA_DIR.
|
* that can be anywhere on the docker host, not under DATA_DIR.
|
||||||
*/
|
*/
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({
|
resolveComputerWorkspaceDir({
|
||||||
computer_id: 2,
|
computer_id: 2,
|
||||||
kind: "sandbox",
|
kind: "sandbox",
|
||||||
metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }),
|
metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }),
|
||||||
@@ -22,7 +22,7 @@ describe("resolveComputerHomeDir", () => {
|
|||||||
|
|
||||||
test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => {
|
test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({
|
resolveComputerWorkspaceDir({
|
||||||
computer_id: 1,
|
computer_id: 1,
|
||||||
kind: "main",
|
kind: "main",
|
||||||
metadata: null,
|
metadata: null,
|
||||||
@@ -34,7 +34,7 @@ describe("resolveComputerHomeDir", () => {
|
|||||||
|
|
||||||
test("leaves a sandbox alone whatever the main setting says", () => {
|
test("leaves a sandbox alone whatever the main setting says", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({
|
resolveComputerWorkspaceDir({
|
||||||
computer_id: 3,
|
computer_id: 3,
|
||||||
kind: "sandbox",
|
kind: "sandbox",
|
||||||
metadata: null,
|
metadata: null,
|
||||||
@@ -45,26 +45,26 @@ describe("resolveComputerHomeDir", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("uses the conventional folder when nothing is recorded", () => {
|
test("uses the conventional folder when nothing is recorded", () => {
|
||||||
expect(resolveComputerHomeDir({ computer_id: 4, kind: "main", data_dir })).toBe(
|
expect(resolveComputerWorkspaceDir({ computer_id: 4, kind: "main", data_dir })).toBe(
|
||||||
"/data/computers/4",
|
"/data/computers/4",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores an empty or unreadable record", () => {
|
test("ignores an empty or unreadable record", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
|
resolveComputerWorkspaceDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
|
||||||
).toBe("/data/computers/5");
|
).toBe("/data/computers/5");
|
||||||
expect(resolveComputerHomeDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
|
expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
|
||||||
"/data/computers/5",
|
"/data/computers/5",
|
||||||
);
|
);
|
||||||
expect(resolveComputerHomeDir({ computer_id: 5, metadata: null, data_dir })).toBe(
|
expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: null, data_dir })).toBe(
|
||||||
"/data/computers/5",
|
"/data/computers/5",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes a recorded relative path", () => {
|
test("normalizes a recorded relative path", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
|
resolveComputerWorkspaceDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
|
||||||
).toBe("/srv/b");
|
).toBe("/srv/b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+7
-5
@@ -27,8 +27,9 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a computer's home (`AppData.ComputerHomeDir` in its container)
|
* Where a computer's workspace (`AppData.ComputerWorkspaceDir` in its
|
||||||
* actually is on this app's disk, for the files browser and for anything
|
* container, the only directory mounted from the data volume) actually is
|
||||||
|
* on this app's disk, for the files browser and for anything
|
||||||
* else that has to read a computer's files.
|
* else that has to read a computer's files.
|
||||||
*
|
*
|
||||||
* A container the app provisioned recorded the directory it bind-mounted
|
* A container the app provisioned recorded the directory it bind-mounted
|
||||||
@@ -36,15 +37,16 @@ type Params = {
|
|||||||
* that path can be anywhere the docker host allows. A computer the app
|
* that path can be anywhere the docker host allows. A computer the app
|
||||||
* didn't provision has no record. That is the install's main computer on a
|
* didn't provision has no record. That is the install's main computer on a
|
||||||
* server reached at `MAIN_COMPUTER_URL` (a dev server outside docker),
|
* server reached at `MAIN_COMPUTER_URL` (a dev server outside docker),
|
||||||
* whose sandbox keeps its home wherever its compose file says, so
|
* whose sandbox keeps its workspace wherever its compose file says, so
|
||||||
* `MAIN_COMPUTER_DATA_DIR` names that directory for this app. Without
|
* `MAIN_COMPUTER_DATA_DIR` names that directory (the host side of
|
||||||
|
* `AppData.ComputerWorkspaceDir`) for this app. Without
|
||||||
* either, the conventional place every app-provisioned computer uses:
|
* either, the conventional place every app-provisioned computer uses:
|
||||||
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
|
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
|
||||||
*
|
*
|
||||||
* Kept apart from the docker and database calls so the precedence is
|
* Kept apart from the docker and database calls so the precedence is
|
||||||
* testable on its own.
|
* testable on its own.
|
||||||
*/
|
*/
|
||||||
export default function resolveComputerHomeDir({
|
export default function resolveComputerWorkspaceDir({
|
||||||
computer_id,
|
computer_id,
|
||||||
kind,
|
kind,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -87,7 +87,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
|
|||||||
metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id },
|
metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.` }) });
|
await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.`, log: false }) });
|
||||||
|
|
||||||
await logError({
|
await logError({
|
||||||
title: `Schedule #${id} couldn't run`,
|
title: `Schedule #${id} couldn't run`,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import hasEnvTemplate from "@/src/utils/has-env-template";
|
||||||
|
import literalUrlHost from "@/src/utils/literal-url-host";
|
||||||
|
|
||||||
|
type Params =
|
||||||
|
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||||
|
| { kind: "tool"; data: SaveCustomToolRequest };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws for what a bot may never propose, checked when it proposes and
|
||||||
|
* again on confirm: a stdio server (a command in the app's container), a
|
||||||
|
* URL whose host a template decides (where a key goes must be readable,
|
||||||
|
* and a bot must not pick it per call), and envs in a prompt tool (the
|
||||||
|
* model's answer would carry the secret back past redaction).
|
||||||
|
*/
|
||||||
|
export default function checkProposalData(params: Params) {
|
||||||
|
if (params.kind == "mcp") {
|
||||||
|
if (params.data.transport != "remote") {
|
||||||
|
throw new AppError(`Only remote MCP servers can be proposed. A stdio one is added by the user in Settings, under MCPs.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!literalUrlHost({ url: params.data.url })) {
|
||||||
|
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = params.data;
|
||||||
|
|
||||||
|
if (tool.kind == "http" && !literalUrlHost({ url: tool.config.url })) {
|
||||||
|
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.kind == "prompt" && hasEnvTemplate({ values: [tool.config.prompt] })) {
|
||||||
|
throw new AppError(`A prompt tool can't read envs: the model's answer would carry the value back.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
proposal: ExtensionProposalRecord;
|
||||||
|
status: "confirmed" | "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides a proposal exactly once: only a pending one moves, and only a
|
||||||
|
* proposal that hasn't expired can be confirmed, so two tabs or a double
|
||||||
|
* click never save it twice. Throws why when it can't.
|
||||||
|
*/
|
||||||
|
export default async function claimExtensionProposal({ proposal, status }: Params) {
|
||||||
|
const now = Date.now();
|
||||||
|
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `UPDATE extension_proposals SET status = ?, decided_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'pending' ${status == "confirmed" ? "AND expires_at > ?" : ""} RETURNING id`,
|
||||||
|
values: [status, now, now, proposal.id, ...(status == "confirmed" ? [now] : [])],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimed.payload?.[0]) return;
|
||||||
|
|
||||||
|
if (proposal.status != "pending") throw new AppError(`This proposal was already ${proposal.status}.`);
|
||||||
|
|
||||||
|
throw new AppError(`This proposal expired. Ask the bot to propose it again.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import grabAllEnvs from "../envs/grab-all-envs";
|
||||||
|
import grabCustomTool from "../extensions/grab-custom-tool";
|
||||||
|
import grabMcpServer from "../extensions/grab-mcp-server";
|
||||||
|
import saveCustomTool from "../extensions/save-custom-tool";
|
||||||
|
import saveMcpServer from "../extensions/save-mcp-server";
|
||||||
|
import grabSkill from "../extensions/grab-skill";
|
||||||
|
import saveSkill from "../extensions/save-skill";
|
||||||
|
import syncMcpServer from "../extensions/sync-mcp-server";
|
||||||
|
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||||
|
import checkProposalData from "./check-proposal-data";
|
||||||
|
import claimExtensionProposal from "./claim-extension-proposal";
|
||||||
|
import grabExtensionProposal from "./grab-extension-proposal";
|
||||||
|
import grabProposalEnvs from "./grab-proposal-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
proposal_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user confirmed a proposal from its card: claims it (once, and only
|
||||||
|
* while it's pending and not expired), checks it again, then saves it
|
||||||
|
* with the settings pages' own helpers. A skill is saved as proposed. A server or tool that sends a key
|
||||||
|
* that isn't saved yet is saved switched off, and the outcome names the
|
||||||
|
* keys to add. A remote server that's on is connected and its tools
|
||||||
|
* listed. Whatever happens is recorded on the proposal as its `outcome`.
|
||||||
|
*/
|
||||||
|
export default async function confirmExtensionProposal({ user_id, proposal_id }: Params) {
|
||||||
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||||
|
|
||||||
|
await claimExtensionProposal({ proposal, status: "confirmed" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (proposal.kind == "skill") {
|
||||||
|
if (proposal.extension_id) await grabSkill({ skill_id: proposal.extension_id });
|
||||||
|
|
||||||
|
const result_id = await saveSkill({
|
||||||
|
user_id,
|
||||||
|
data: { ...proposal.data, skill_id: proposal.extension_id || undefined },
|
||||||
|
});
|
||||||
|
const outcome = `Saved as skill #${result_id} (slug ${proposal.data.slug}).${
|
||||||
|
!proposal.extension_id && !proposal.data.all_bots ? " No bot gets it yet." : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [outcome, result_id, Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkProposalData(proposal);
|
||||||
|
|
||||||
|
const { sends } = extensionEnvUses(proposal);
|
||||||
|
const missing = [
|
||||||
|
...new Set(
|
||||||
|
(await grabProposalEnvs({ sends })).filter((env) => !env.saved_in.length).map((env) => env.key),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const keys_note = missing.length
|
||||||
|
? ` It's switched off until ${missing.length == 1 ? "this key is" : "these keys are"} added in Settings, under Environment: ${missing.join(", ")}. Then switch it on in Settings, under ${proposal.kind == "mcp" ? "MCPs" : "Tools"}.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
let outcome = "";
|
||||||
|
let result_id = 0;
|
||||||
|
|
||||||
|
if (proposal.kind == "mcp") {
|
||||||
|
if (proposal.extension_id) {
|
||||||
|
const current = await grabMcpServer({ mcp_server_id: proposal.extension_id });
|
||||||
|
|
||||||
|
if (current.transport != "remote") throw new AppError(`${current.name} runs a command here, so it can't be changed from a proposal.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { ...proposal.data, mcp_server_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
||||||
|
|
||||||
|
result_id = await saveMcpServer({ user_id, data });
|
||||||
|
|
||||||
|
const sync = data.enabled ? await syncMcpServer({ mcp_server_id: result_id }) : null;
|
||||||
|
|
||||||
|
outcome = `Saved as MCP server #${result_id}.${keys_note}${
|
||||||
|
!sync
|
||||||
|
? missing.length
|
||||||
|
? ""
|
||||||
|
: " It's off, so it wasn't connected."
|
||||||
|
: sync.ok
|
||||||
|
? ` It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||||
|
: ` It didn't connect: ${sync.error}`
|
||||||
|
}`;
|
||||||
|
} else {
|
||||||
|
if (proposal.extension_id) await grabCustomTool({ custom_tool_id: proposal.extension_id });
|
||||||
|
|
||||||
|
const data = { ...proposal.data, custom_tool_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
||||||
|
|
||||||
|
result_id = await saveCustomTool({ user_id, data });
|
||||||
|
outcome = `Saved as tool #${result_id}.${keys_note}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proposal.extension_id && !proposal.data.all_bots) outcome += ` No bot gets it yet.`;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [outcome, result_id, Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
|
||||||
|
const message = redactSecrets({
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
secrets,
|
||||||
|
}).slice(0, 2000);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { SaveCustomToolRequest, SaveMcpServerRequest, SaveSkillRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import checkProposalData from "./check-proposal-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
session_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
research: string;
|
||||||
|
sources: string[];
|
||||||
|
} & (
|
||||||
|
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||||
|
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||||
|
| { kind: "skill"; data: SaveSkillRequest }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores what a bot proposes adding or changing (an MCP server, a tool or
|
||||||
|
* a skill), pending until the user
|
||||||
|
* confirms it from the chat. Nothing is saved, connected or run here. A
|
||||||
|
* proposal it makes again for the same server or tool in the same
|
||||||
|
* conversation replaces the one before, so only the latest card can be
|
||||||
|
* confirmed. Returns its id.
|
||||||
|
*/
|
||||||
|
export default async function createExtensionProposal(params: Params) {
|
||||||
|
if (params.kind != "skill") checkProposalData(params);
|
||||||
|
|
||||||
|
const extension_id =
|
||||||
|
(params.kind == "mcp"
|
||||||
|
? params.data.mcp_server_id
|
||||||
|
: params.kind == "skill"
|
||||||
|
? params.data.skill_id
|
||||||
|
: params.data.custom_tool_id) || undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "extension_proposals",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
kind: params.kind,
|
||||||
|
extension_id,
|
||||||
|
name: params.data.name,
|
||||||
|
data: JSON.stringify(params.data),
|
||||||
|
research: params.research,
|
||||||
|
sources: JSON.stringify(params.sources),
|
||||||
|
status: "pending",
|
||||||
|
outcome: "",
|
||||||
|
expires_at: now + AppData.ExtensionProposalTtlMs,
|
||||||
|
user_id: Number(params.user_id),
|
||||||
|
bot_id: Number(params.bot_id),
|
||||||
|
session_id: Number(params.session_id),
|
||||||
|
workspace_id: params.workspace_id ? Number(params.workspace_id) : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
|
||||||
|
|
||||||
|
if (!res.success || !id) throw new Error(`Couldn't store the proposal: ${res.msg}`);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET status = 'replaced', outcome = ?, decided_at = ?, updated_at = ?
|
||||||
|
WHERE session_id = ? AND kind = ? AND status = 'pending' AND id != ?
|
||||||
|
AND (${extension_id ? "extension_id = ?" : "extension_id IS NULL AND name = ? COLLATE NOCASE"})`,
|
||||||
|
values: [
|
||||||
|
`Replaced by proposal #${id}.`,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
Number(params.session_id),
|
||||||
|
params.kind,
|
||||||
|
id,
|
||||||
|
extension_id || params.data.name,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ExtensionProposalView } from "@/src/types";
|
||||||
|
import describeProposalDetails from "@/src/utils/describe-proposal-details";
|
||||||
|
import describeSkillProposal from "@/src/utils/describe-skill-proposal";
|
||||||
|
import grabSkill from "../extensions/grab-skill";
|
||||||
|
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||||
|
import grabExtensionProposal from "./grab-extension-proposal";
|
||||||
|
import grabProposalEnvs from "./grab-proposal-envs";
|
||||||
|
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
proposal_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A proposal as its chip and confirm card show it: the facts (host, envs
|
||||||
|
* and whether each is saved, every detail of the config; for a skill what
|
||||||
|
* its runbook points at and what changed) worked out from the config now,
|
||||||
|
* so a key added since shows as saved; the bot's research beside them,
|
||||||
|
* labelled as its own.
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionProposalView({ proposal_id, user_id }: Params): Promise<ExtensionProposalView> {
|
||||||
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||||
|
const facts = await grabProposalFacts({ proposal });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: proposal.id,
|
||||||
|
kind: proposal.kind,
|
||||||
|
extension_id: proposal.extension_id,
|
||||||
|
name: proposal.name,
|
||||||
|
status: proposal.status,
|
||||||
|
expired: proposal.status == "pending" && proposal.expires_at < Date.now(),
|
||||||
|
expires_at: proposal.expires_at,
|
||||||
|
outcome: proposal.outcome,
|
||||||
|
result_id: proposal.result_id,
|
||||||
|
...facts,
|
||||||
|
research: proposal.research,
|
||||||
|
sources: proposal.sources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The facts a proposal's card shows, worked out now: for a server or tool
|
||||||
|
* its host, its envs and whether each is saved, and its config; for a
|
||||||
|
* skill what its runbook points bots at and what changed against the
|
||||||
|
* saved one (a deleted skill shows as new).
|
||||||
|
*/
|
||||||
|
async function grabProposalFacts({ proposal }: { proposal: ExtensionProposalRecord }) {
|
||||||
|
if (proposal.kind == "skill") {
|
||||||
|
const current = proposal.extension_id
|
||||||
|
? await grabSkill({ skill_id: proposal.extension_id }).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { host: null, envs: [], details: describeSkillProposal({ data: proposal.data, current }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const typed =
|
||||||
|
proposal.kind == "mcp"
|
||||||
|
? ({ kind: "mcp", data: proposal.data } as const)
|
||||||
|
: ({ kind: "tool", data: proposal.data } as const);
|
||||||
|
const { host, sends } = extensionEnvUses(typed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
envs: await grabProposalEnvs({ sends }),
|
||||||
|
details: describeProposalDetails({ ...typed, is_new: !proposal.extension_id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseProposalRow from "./parse-proposal-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
proposal_id: number | string;
|
||||||
|
/**
|
||||||
|
* The super admin it was proposed to: nobody else reads it.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One proposal made to this user, or an `AppError`.
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionProposal({ proposal_id, user_id }: Params) {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS>({
|
||||||
|
sql: `SELECT * FROM extension_proposals WHERE id = ? AND user_id = ?`,
|
||||||
|
values: [proposal_id, user_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row) throw new AppError(`That proposal doesn't exist.`);
|
||||||
|
|
||||||
|
return parseProposalRow({ row });
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { EnvDict } from "@/src/dict/env-dict";
|
||||||
|
import type { ExtensionProposalEnv } from "@/src/types";
|
||||||
|
import type { ExtensionEnvUse } from "@/src/utils/extension-env-uses";
|
||||||
|
import grabAllEnvs from "../envs/grab-all-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
sends: ExtensionEnvUse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each env a proposed server or tool sends, with where it's saved: the
|
||||||
|
* scopes templates read (`grabRunEnvs`: general, a bot's own, a
|
||||||
|
* workspace's), named. App settings never fill a template, so one of
|
||||||
|
* those keys reads as missing.
|
||||||
|
*/
|
||||||
|
export default async function grabProposalEnvs({ sends }: Params): Promise<ExtensionProposalEnv[]> {
|
||||||
|
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||||
|
const envs = (await grabAllEnvs()).filter(
|
||||||
|
(env) => !app_keys.has(env.key) && ["general", "bot", "workspace"].includes(env.paradigm),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bot_ids = [...new Set(envs.map((env) => env.bot_id).filter(Boolean))];
|
||||||
|
const workspace_ids = [...new Set(envs.map((env) => env.workspace_id).filter(Boolean))];
|
||||||
|
const [bots, workspaces] = await Promise.all([
|
||||||
|
names({ table: "bots", ids: bot_ids as number[] }),
|
||||||
|
names({ table: "workspaces", ids: workspace_ids as number[] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return sends.map((use) => ({
|
||||||
|
...use,
|
||||||
|
saved_in: envs
|
||||||
|
.filter((env) => env.key == use.key)
|
||||||
|
.map((env) =>
|
||||||
|
env.paradigm == "general"
|
||||||
|
? "general"
|
||||||
|
: env.paradigm == "bot"
|
||||||
|
? `bot ${bots.get(Number(env.bot_id)) || env.bot_id} only`
|
||||||
|
: `workspace ${workspaces.get(Number(env.workspace_id)) || env.workspace_id} only`,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function names({ table, ids }: { table: "bots" | "workspaces"; ids: number[] }) {
|
||||||
|
if (!ids.length) return new Map<number, string>();
|
||||||
|
|
||||||
|
const rows = await BunSQLite.sql<{ id: number; name: string }>({
|
||||||
|
sql: `SELECT id, name FROM ${table} WHERE id IN (${ids.map(() => "?").join(", ")})`,
|
||||||
|
values: ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map((rows.payload || []).map((row) => [Number(row.id), row.name || ""]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||||
|
import type { ExtensionProposalKind, ExtensionProposalStatus } from "@/src/dict/extension-proposal-dict";
|
||||||
|
import {
|
||||||
|
SaveCustomToolRequestSchema,
|
||||||
|
SaveMcpServerRequestSchema,
|
||||||
|
SaveSkillRequestSchema,
|
||||||
|
type SaveCustomToolRequest,
|
||||||
|
type SaveMcpServerRequest,
|
||||||
|
type SaveSkillRequest,
|
||||||
|
} from "@/src/schemas/extension-schema";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import parseJsonColumn from "../extensions/parse-json-column";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
row: BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtensionProposalRecord = {
|
||||||
|
id: number;
|
||||||
|
extension_id: number | null;
|
||||||
|
name: string;
|
||||||
|
research: string;
|
||||||
|
sources: string[];
|
||||||
|
status: ExtensionProposalStatus;
|
||||||
|
outcome: string;
|
||||||
|
result_id: number | null;
|
||||||
|
expires_at: number;
|
||||||
|
user_id: number;
|
||||||
|
bot_id: number;
|
||||||
|
session_id: number;
|
||||||
|
} & (
|
||||||
|
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||||
|
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||||
|
| { kind: "skill"; data: SaveSkillRequest }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `extension_proposals` row with its config read and checked again by
|
||||||
|
* the settings forms' schemas, so what confirm saves is what they'd allow.
|
||||||
|
*/
|
||||||
|
export default function parseProposalRow({ row }: Params): ExtensionProposalRecord {
|
||||||
|
const kind = (row.kind || "mcp") as ExtensionProposalKind;
|
||||||
|
const raw = parseJsonColumn<Record<string, unknown>>({ text: row.data, fallback: {} });
|
||||||
|
const base = {
|
||||||
|
id: Number(row.id),
|
||||||
|
extension_id: row.extension_id ? Number(row.extension_id) : null,
|
||||||
|
name: row.name || "",
|
||||||
|
research: row.research || "",
|
||||||
|
sources: parseJsonColumn<string[]>({ text: row.sources, fallback: [] }),
|
||||||
|
status: (row.status || "pending") as ExtensionProposalStatus,
|
||||||
|
outcome: row.outcome || "",
|
||||||
|
result_id: row.result_id ? Number(row.result_id) : null,
|
||||||
|
expires_at: Number(row.expires_at) || 0,
|
||||||
|
user_id: Number(row.user_id),
|
||||||
|
bot_id: Number(row.bot_id),
|
||||||
|
session_id: Number(row.session_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind == "mcp") return { ...base, kind, data: parseWithSchema({ schema: SaveMcpServerRequestSchema, data: raw }) };
|
||||||
|
|
||||||
|
if (kind == "skill") return { ...base, kind, data: parseWithSchema({ schema: SaveSkillRequestSchema, data: raw }) };
|
||||||
|
|
||||||
|
return { ...base, kind: "tool", data: parseWithSchema({ schema: SaveCustomToolRequestSchema, data: raw }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import claimExtensionProposal from "./claim-extension-proposal";
|
||||||
|
import grabExtensionProposal from "./grab-extension-proposal";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
proposal_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user rejected a proposal from its card: nothing is saved.
|
||||||
|
*/
|
||||||
|
export default async function rejectExtensionProposal({ user_id, proposal_id }: Params) {
|
||||||
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||||
|
|
||||||
|
await claimExtensionProposal({ proposal, status: "rejected" });
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET outcome = 'Rejected. Nothing was saved.', updated_at = ? WHERE id = ?`,
|
||||||
|
values: [Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
|
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
|
||||||
import type { MediaParadigmType, TableType, User } from "@/src/types";
|
import type { MediaParadigmType, TableType, User } from "@/src/types";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import { rmSync } from "fs";
|
import { rmSync } from "fs";
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
@@ -22,7 +21,6 @@ type Params = {
|
|||||||
export default async function deleteMedia(params: Params) {
|
export default async function deleteMedia(params: Params) {
|
||||||
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
|
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
|
||||||
|
|
||||||
let media_to_delete = new Set<number>();
|
let media_to_delete = new Set<number>();
|
||||||
|
|
||||||
@@ -87,18 +85,13 @@ export default async function deleteMedia(params: Params) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target_media.media_write_path) {
|
for (const write_path of [
|
||||||
rmSync(
|
target_media.media_write_path,
|
||||||
path.join(DATA_DIR, target_media.media_write_path),
|
target_media.media_thumbnail_write_path,
|
||||||
{ force: true },
|
]) {
|
||||||
);
|
const absolute = mediaFilePath({ write_path });
|
||||||
}
|
|
||||||
|
|
||||||
if (target_media.media_thumbnail_write_path) {
|
if (absolute) rmSync(absolute, { force: true });
|
||||||
rmSync(
|
|
||||||
path.join(DATA_DIR, target_media.media_thumbnail_write_path),
|
|
||||||
{ force: true },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
|
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import path from "node:path";
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { extensionOf } from "@/src/functions/backend/db/bot-files/media-type-for-file";
|
import { extensionOf } from "@/src/functions/backend/db/bot-files/media-type-for-file";
|
||||||
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
|
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
|
||||||
import type { MediaParadigm } from "@/src/types";
|
import type { MediaParadigm } from "@/src/types";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
|
|
||||||
export type PictureTarget = "bot" | "workspace";
|
export type PictureTarget = "bot" | "workspace";
|
||||||
|
|
||||||
@@ -102,13 +101,13 @@ export default async function copyMediaAsPicture({
|
|||||||
* The image's bytes, read from where `media` keeps them.
|
* The image's bytes, read from where `media` keeps them.
|
||||||
*/
|
*/
|
||||||
async function readMediaBytes({ media }: { media: BUN_SQLITE_COBBLE_MEDIA }) {
|
async function readMediaBytes({ media }: { media: BUN_SQLITE_COBBLE_MEDIA }) {
|
||||||
const { DATA_DIR } = grabDirNames();
|
const absolute = mediaFilePath({ write_path: media.media_write_path });
|
||||||
|
|
||||||
if (!media.media_write_path) {
|
if (!absolute) {
|
||||||
throw new AppError(`Its file is missing on the server. Save it again.`);
|
throw new AppError(`Its file is missing on the server. Save it again.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(path.join(DATA_DIR, media.media_write_path));
|
const file = Bun.file(absolute);
|
||||||
|
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
throw new AppError(`Its file is missing on the server. Save it again.`);
|
throw new AppError(`Its file is missing on the server. Save it again.`);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
is_coordinator: boolean;
|
is_coordinator: boolean;
|
||||||
|
/**
|
||||||
|
* The workspaces the bot is admin of (`grabBotRunPowers`).
|
||||||
|
*/
|
||||||
|
admin_workspace_ids?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +26,9 @@ export default async function grabRunTask({
|
|||||||
task_id,
|
task_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
is_coordinator,
|
is_coordinator,
|
||||||
|
admin_workspace_ids,
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
|
||||||
const access = botTaskScopesSql({ bot_id, is_coordinator });
|
const access = botTaskScopesSql({ bot_id, is_coordinator, admin_workspace_ids });
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
||||||
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
|
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
is_coordinator?: boolean;
|
is_coordinator?: boolean;
|
||||||
|
/**
|
||||||
|
* The workspaces the bot is admin of (`grabBotRunPowers`).
|
||||||
|
*/
|
||||||
|
admin_workspace_ids?: number[];
|
||||||
/**
|
/**
|
||||||
* Only one collection.
|
* Only one collection.
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +48,7 @@ export default async function grabTasks({
|
|||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
is_coordinator,
|
is_coordinator,
|
||||||
|
admin_workspace_ids,
|
||||||
only_bot_id,
|
only_bot_id,
|
||||||
only_workspace_id,
|
only_workspace_id,
|
||||||
statuses,
|
statuses,
|
||||||
@@ -58,7 +63,7 @@ export default async function grabTasks({
|
|||||||
where.push(access.sql);
|
where.push(access.sql);
|
||||||
values.push(...access.values);
|
values.push(...access.values);
|
||||||
} else if (bot_id != null) {
|
} else if (bot_id != null) {
|
||||||
const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator) });
|
const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator), admin_workspace_ids });
|
||||||
where.push(access.sql);
|
where.push(access.sql);
|
||||||
values.push(...access.values);
|
values.push(...access.values);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { MessageDetails, MessageDetailsCall, MessageDetailsJob } from "@/src/types";
|
||||||
|
import groupMessageCalls from "@/src/utils/group-message-calls";
|
||||||
|
import parseCallSettings from "@/src/utils/parse-call-settings";
|
||||||
|
import grabMessageUsage from "./grab-message-usage";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* A message the caller already checked the user reaches.
|
||||||
|
*/
|
||||||
|
message_id: number;
|
||||||
|
created_at?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CallRow = {
|
||||||
|
id: number;
|
||||||
|
created_at: number;
|
||||||
|
harness: string | null;
|
||||||
|
provider_id: number | null;
|
||||||
|
provider_name: string | null;
|
||||||
|
provider_kind: string | null;
|
||||||
|
model_slug: string | null;
|
||||||
|
model_name: string | null;
|
||||||
|
prompt_tokens: number | null;
|
||||||
|
completion_tokens: number | null;
|
||||||
|
cost: number | null;
|
||||||
|
settings: string | null;
|
||||||
|
job_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JobRow = {
|
||||||
|
id: number;
|
||||||
|
kind: string | null;
|
||||||
|
title: string | null;
|
||||||
|
status: string | null;
|
||||||
|
attempts: number | null;
|
||||||
|
started_at: number | null;
|
||||||
|
finished_at: number | null;
|
||||||
|
steps: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one message was generated: every model call it took (`token_usages`)
|
||||||
|
* with the provider and model that answered, summed per model, and the
|
||||||
|
* queued job that ran it, while it is kept. The provider's kind and the
|
||||||
|
* model's display name come from their live rows, so a deleted provider
|
||||||
|
* or an unlisted model still reads by the name it was called with.
|
||||||
|
*/
|
||||||
|
export default async function grabMessageDetails({ message_id, created_at }: Params): Promise<MessageDetails> {
|
||||||
|
const rows =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<CallRow>({
|
||||||
|
sql: `SELECT t.id, t.created_at, t.harness, t.provider_id, t.provider_name,
|
||||||
|
p.kind AS provider_kind, t.model_slug, m.name AS model_name,
|
||||||
|
t.prompt_tokens, t.completion_tokens, t.cost, t.settings, t.job_id
|
||||||
|
FROM token_usages t
|
||||||
|
LEFT JOIN harness_providers p ON p.id = t.provider_id
|
||||||
|
LEFT JOIN harness_models m ON m.id = t.model_id
|
||||||
|
WHERE t.message_id = ?
|
||||||
|
ORDER BY t.created_at ASC, t.id ASC`,
|
||||||
|
values: [message_id],
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
const calls: MessageDetailsCall[] = rows.map((row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
created_at: Number(row.created_at) || 0,
|
||||||
|
harness: row.harness || "text",
|
||||||
|
provider_id: row.provider_id ? Number(row.provider_id) : null,
|
||||||
|
provider_name: row.provider_name || null,
|
||||||
|
provider_kind: row.provider_kind || null,
|
||||||
|
model_slug: row.model_slug || null,
|
||||||
|
model_name: row.model_name || null,
|
||||||
|
prompt_tokens: Number(row.prompt_tokens) || 0,
|
||||||
|
completion_tokens: Number(row.completion_tokens) || 0,
|
||||||
|
cost: Number.isFinite(Number(row.cost)) && row.cost !== null ? Number(row.cost) : null,
|
||||||
|
settings: parseCallSettings({ json: row.settings }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totals = (await grabMessageUsage({ message_ids: [message_id] })).get(message_id) || {
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
calls: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The job is the one its calls ran for: the latest, since reading a
|
||||||
|
* message aloud later is a call without one.
|
||||||
|
*/
|
||||||
|
const job_id = [...rows].reverse().find((row) => row.job_id)?.job_id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message_id,
|
||||||
|
created_at: created_at || null,
|
||||||
|
totals,
|
||||||
|
models: groupMessageCalls({ calls }),
|
||||||
|
calls: calls.slice(0, AppData.MessageDetailsMaxCalls),
|
||||||
|
job: job_id ? await grabJob({ job_id: Number(job_id) }) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grabJob({ job_id }: { job_id: number }): Promise<MessageDetailsJob | null> {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<JobRow>({
|
||||||
|
sql: `SELECT q.id, q.kind, q.title, q.status, q.attempts, q.started_at, q.finished_at,
|
||||||
|
(SELECT COUNT(*) FROM bots_queue_updates u WHERE u.bots_queue_id = q.id) AS steps
|
||||||
|
FROM bots_queue q WHERE q.id = ? LIMIT 1`,
|
||||||
|
values: [job_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row?.id) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
kind: row.kind || null,
|
||||||
|
title: row.title || null,
|
||||||
|
status: row.status || null,
|
||||||
|
attempts: Number(row.attempts) || 0,
|
||||||
|
started_at: Number(row.started_at) || null,
|
||||||
|
finished_at: Number(row.finished_at) || null,
|
||||||
|
steps: Number(row.steps) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||||
|
import type { TokenUsageSummary } from "@/src/harness/usage/types";
|
||||||
|
import grabMessageUsage from "./grab-message-usage";
|
||||||
|
|
||||||
|
type Params<T extends BUN_SQLITE_COBBLE_BOT_MESSAGES> = {
|
||||||
|
messages: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages with what their model calls spent (`usage`), for a live
|
||||||
|
* push: a pushed bot message must carry it like a loaded one
|
||||||
|
* (`grabBotMessages`), or the thread shows its tokens only after a reload.
|
||||||
|
* Messages with no calls (the user's own among them) are left as they are.
|
||||||
|
*/
|
||||||
|
export default async function withMessageUsage<T extends BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||||
|
messages,
|
||||||
|
}: Params<T>): Promise<(T & { usage?: TokenUsageSummary })[]> {
|
||||||
|
const ids = messages.filter((m) => m.id && m.author == "bot").map((m) => Number(m.id));
|
||||||
|
|
||||||
|
if (!ids.length) return messages;
|
||||||
|
|
||||||
|
const usage = await grabMessageUsage({ message_ids: ids });
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
const spent = usage.get(Number(message.id));
|
||||||
|
|
||||||
|
return spent ? { ...message, usage: spent } : message;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -57,22 +57,17 @@ export default async function grabUsers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query?.search_term_user_type) {
|
if (query?.search_term_user_type) {
|
||||||
|
const user_type_terms = (
|
||||||
|
Array.isArray(query.search_term_user_type)
|
||||||
|
? query.search_term_user_type
|
||||||
|
: [query.search_term_user_type]
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
sql += ` AND EXISTS (\n`;
|
sql += ` AND EXISTS (\n`;
|
||||||
sql += ` SELECT 1 FROM user_types WHERE user_id = u.id AND user_type LIKE`;
|
sql += ` SELECT 1 FROM user_types WHERE user_id = u.id AND (${
|
||||||
|
user_type_terms.map(() => "user_type LIKE ?").join(" OR ") || "0"
|
||||||
if (Array.isArray(query.search_term_user_type)) {
|
})\n`;
|
||||||
sql += `(`;
|
values.push(...user_type_terms.map((ut) => `%${ut}%`));
|
||||||
for (let i = 0; i < query.search_term_user_type.length; i++) {
|
|
||||||
const ut = query.search_term_user_type[i];
|
|
||||||
sql += ` ?`;
|
|
||||||
values.push(`%${ut}%`);
|
|
||||||
}
|
|
||||||
sql += `)`;
|
|
||||||
} else {
|
|
||||||
sql += ` ?`;
|
|
||||||
values.push(`%${query.search_term_user_type}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += ` )\n`;
|
sql += ` )\n`;
|
||||||
} else if (query?.target_user_type) {
|
} else if (query?.target_user_type) {
|
||||||
sql += ` AND EXISTS (
|
sql += ` AND EXISTS (
|
||||||
@@ -94,21 +89,21 @@ export default async function grabUsers(
|
|||||||
const match_value = params?.exact_match ? "=" : "LIKE";
|
const match_value = params?.exact_match ? "=" : "LIKE";
|
||||||
|
|
||||||
if (Array.isArray(term.value)) {
|
if (Array.isArray(term.value)) {
|
||||||
sql += ` AND (`;
|
const term_values = term.value.filter(Boolean);
|
||||||
for (let i = 0; i < term.value.length; i++) {
|
|
||||||
const term_value = term.value[i];
|
|
||||||
if (!term_value) continue;
|
|
||||||
const match_value_param = params?.exact_match
|
|
||||||
? term_value
|
|
||||||
: `%${term_value.replace(/ /g, "%")}%`;
|
|
||||||
|
|
||||||
sql += ` u.${term.field} ${isId ? "=" : match_value} ?`;
|
if (!term_values[0]) continue;
|
||||||
if (i < term.value.length - 1) {
|
|
||||||
sql += ` OR`;
|
sql += ` AND (${term_values
|
||||||
|
.map(() => `u.${term.field} ${isId ? "=" : match_value} ?`)
|
||||||
|
.join(" OR ")})\n`;
|
||||||
|
|
||||||
|
for (const term_value of term_values) {
|
||||||
|
values.push(
|
||||||
|
isId || params?.exact_match
|
||||||
|
? term_value
|
||||||
|
: `%${term_value.replace(/ /g, "%")}%`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
values.push(isId ? term_value : match_value_param);
|
|
||||||
}
|
|
||||||
sql += ` )\n`;
|
|
||||||
} else {
|
} else {
|
||||||
const match_value_param = params?.exact_match
|
const match_value_param = params?.exact_match
|
||||||
? term.value
|
? term.value
|
||||||
@@ -121,8 +116,10 @@ export default async function grabUsers(
|
|||||||
|
|
||||||
let sql_limit = ``;
|
let sql_limit = ``;
|
||||||
sql_limit += `LIMIT ${LIMIT}\n`;
|
sql_limit += `LIMIT ${LIMIT}\n`;
|
||||||
if (query?.page) {
|
const page = Math.floor(Number(query?.page));
|
||||||
const offset = LIMIT * (query.page - 1);
|
|
||||||
|
if (page > 1) {
|
||||||
|
const offset = LIMIT * (page - 1);
|
||||||
sql_limit += `OFFSET ${offset}\n`;
|
sql_limit += `OFFSET ${offset}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import grabDirNames from "@/src/utils/grab-dir-names";
|
|||||||
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
|
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import { existsSync, mkdirSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import _ from "lodash";
|
||||||
import sharp, { type Sharp } from "sharp";
|
import sharp, { type Sharp } from "sharp";
|
||||||
import canExtractText from "@/src/utils/can-extract-text";
|
import canExtractText from "@/src/utils/can-extract-text";
|
||||||
import capTextContent from "@/src/utils/cap-text-content";
|
import capTextContent from "@/src/utils/cap-text-content";
|
||||||
@@ -81,8 +82,14 @@ export default async function uploadAndRecordMedia({
|
|||||||
media_text_content: passed_media_text_content,
|
media_text_content: passed_media_text_content,
|
||||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>> {
|
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let target_file_name = `${media_name || now}`;
|
/**
|
||||||
let target_file_thumbnail_name = `${media_name || now}-thumb`;
|
* The name becomes part of a path on disk, so only letters, digits,
|
||||||
|
* `-` and `_` are kept: a name like `../../db/cobble` must never
|
||||||
|
* write outside the user's media folder.
|
||||||
|
*/
|
||||||
|
const safe_name = (media_name || "").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 120);
|
||||||
|
let target_file_name = `${safe_name || now}`;
|
||||||
|
let target_file_thumbnail_name = `${safe_name || now}-thumb`;
|
||||||
|
|
||||||
const { USER_MEDIA_RELATIVE_DIR: MEDIA_RELATIVE_DIR, DATA_DIR } =
|
const { USER_MEDIA_RELATIVE_DIR: MEDIA_RELATIVE_DIR, DATA_DIR } =
|
||||||
grabDirNames({
|
grabDirNames({
|
||||||
@@ -127,6 +134,8 @@ export default async function uploadAndRecordMedia({
|
|||||||
* a transparent PNG stays a PNG (`imageOutputFormat`). Every other file
|
* a transparent PNG stays a PNG (`imageOutputFormat`). Every other file
|
||||||
* keeps its own extension.
|
* keeps its own extension.
|
||||||
*/
|
*/
|
||||||
|
media_ext = media_ext?.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 16) || undefined;
|
||||||
|
|
||||||
const image_output = media_type == "image" ? imageOutputFormat({ ext: media_ext }) : null;
|
const image_output = media_type == "image" ? imageOutputFormat({ ext: media_ext }) : null;
|
||||||
const stored_ext = image_output ? image_output.ext : media_ext;
|
const stored_ext = image_output ? image_output.ext : media_ext;
|
||||||
|
|
||||||
@@ -261,7 +270,11 @@ export default async function uploadAndRecordMedia({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
|
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
|
||||||
...media,
|
/**
|
||||||
|
* Only what describes the file: an `id` or a path in `media`
|
||||||
|
* would point the row at another one.
|
||||||
|
*/
|
||||||
|
..._.pick(media || {}, ["title", "description", "summary"]),
|
||||||
user_id: Number(user.id),
|
user_id: Number(user.id),
|
||||||
/**
|
/**
|
||||||
* NULL, not 0, when not primary: the (user_id, is_primary) unique
|
* NULL, not 0, when not primary: the (user_id, is_primary) unique
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
|
|||||||
*/
|
*/
|
||||||
const task_media_ids = await grabTaskMediaIds({ workspace_id: workspace.id! }).catch(() => []);
|
const task_media_ids = await grabTaskMediaIds({ workspace_id: workspace.id! }).catch(() => []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly, since foreign keys are off outside the server process.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE workspace_id = ?`, values: [workspace.id] });
|
||||||
|
|
||||||
const res = await BunSQLite.sql({
|
const res = await BunSQLite.sql({
|
||||||
sql: `DELETE FROM workspaces WHERE id = ?`,
|
sql: `DELETE FROM workspaces WHERE id = ?`,
|
||||||
values: [workspace.id],
|
values: [workspace.id],
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* The workspaces a bot works in, for its settings page. The coordinator
|
* The workspaces a bot works in, for its settings page. The coordinator
|
||||||
* works in every workspace; a worker only in the ones it was added to.
|
* works in every workspace; a worker only in the ones it was added to.
|
||||||
* Each row carries what the bot has for that workspace alone.
|
* Each row carries what the bot has for that workspace alone, and whether
|
||||||
|
* it's that workspace's admin.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotWorkspaces({
|
export default async function grabBotWorkspaces({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -36,6 +37,16 @@ export default async function grabBotWorkspaces({
|
|||||||
).payload || [];
|
).payload || [];
|
||||||
|
|
||||||
const by_workspace = new Map(rows.map((row) => [Number(row.workspace_id), row]));
|
const by_workspace = new Map(rows.map((row) => [Number(row.workspace_id), row]));
|
||||||
|
const admin_of = new Set(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ workspace_id: number }>({
|
||||||
|
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin'`,
|
||||||
|
values: [Number(bot.id)],
|
||||||
|
})
|
||||||
|
).payload || []
|
||||||
|
).map((row) => Number(row.workspace_id)),
|
||||||
|
);
|
||||||
|
|
||||||
return workspaces
|
return workspaces
|
||||||
.filter((workspace) => by_workspace.has(Number(workspace.id)))
|
.filter((workspace) => by_workspace.has(Number(workspace.id)))
|
||||||
@@ -46,6 +57,7 @@ export default async function grabBotWorkspaces({
|
|||||||
...workspace,
|
...workspace,
|
||||||
workspace_system_prompt: row?.system_prompt || null,
|
workspace_system_prompt: row?.system_prompt || null,
|
||||||
workspace_memory: row?.memory || null,
|
workspace_memory: row?.memory || null,
|
||||||
|
is_admin: admin_of.has(Number(workspace.id)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* A workspace's crew (the coordinator first, then its bots by latest
|
* A workspace's crew (the coordinator first, then its bots by latest
|
||||||
* activity there), each with its instructions and memory for the
|
* activity there), each with its instructions and memory for the
|
||||||
* workspace.
|
* workspace (whether it's the workspace's admin comes with `grabBots`).
|
||||||
*/
|
*/
|
||||||
export default async function grabWorkspaceBots({
|
export default async function grabWorkspaceBots({
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -29,4 +29,12 @@ export default async function removeBotFromWorkspace({
|
|||||||
sql: `DELETE FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`,
|
sql: `DELETE FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`,
|
||||||
values: [workspace.id, bot_id],
|
values: [workspace.id, bot_id],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Its types there (workspace admin) go with its place.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM bot_types WHERE workspace_id = ? AND bot_id = ?`,
|
||||||
|
values: [workspace.id, bot_id],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* The computer's home directory as this app sees it.
|
* The computer's workspace directory as this app sees it.
|
||||||
*/
|
*/
|
||||||
data_dir: string;
|
data_dir: string;
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ type Params = {
|
|||||||
* Creates and starts (the API's `docker run`) an AIO sandbox container
|
* Creates and starts (the API's `docker run`) an AIO sandbox container
|
||||||
* for a computer (`AppData.ComputerImage`), attached to the computers'
|
* for a computer (`AppData.ComputerImage`), attached to the computers'
|
||||||
* network (created first when missing), with its home directory
|
* network (created first when missing), with its home directory
|
||||||
* bind-mounted from `data_dir`, limited to `cpus` and `memory_mb`, with a
|
* bind-mounted from `data_dir` (only its workspace, `AppData.ComputerWorkspaceDir`: the rest of its home lives and dies with the container), limited to `cpus` and `memory_mb`, with a
|
||||||
* health check (`AppData.ComputerHealthcheck`), and restarted with
|
* health check (`AppData.ComputerHealthcheck`), and restarted with
|
||||||
* docker. A container left by an earlier try is stopped and replaced.
|
* docker. A container left by an earlier try is stopped and replaced.
|
||||||
* Resolves once the health check passes, with its IP address on the network, which a dev
|
* Resolves once the health check passes, with its IP address on the network, which a dev
|
||||||
@@ -78,7 +78,7 @@ export default async function createComputerContainer({
|
|||||||
Retries: AppData.ComputerHealthcheck.Retries,
|
Retries: AppData.ComputerHealthcheck.Retries,
|
||||||
},
|
},
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
|
Binds: [`${host_dir}:${AppData.ComputerWorkspaceDir}`],
|
||||||
NetworkMode: network,
|
NetworkMode: network,
|
||||||
RestartPolicy: { Name: "unless-stopped" },
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
SecurityOpt: ["seccomp=unconfined"],
|
SecurityOpt: ["seccomp=unconfined"],
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { logError } from "./logger";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an error nobody meant to show (anything but an `AppError`) to the
|
||||||
|
* logs, so a failure the client only sees as "Something went wrong" is
|
||||||
|
* still on the Activity page with its stack. Never during tests, which
|
||||||
|
* throw plain errors on purpose, and never throws.
|
||||||
|
*/
|
||||||
|
export default function logUnexpectedError({ error }: Params) {
|
||||||
|
if (process.env.NODE_ENV == "test") return;
|
||||||
|
|
||||||
|
const name = error instanceof Error ? error.name : typeof error;
|
||||||
|
|
||||||
|
void logError({
|
||||||
|
title: `Unexpected server error (${name})`,
|
||||||
|
error,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import clientIP from "@/src/utils/client-ip";
|
||||||
|
import { grabRequestPeerIP } from "@/src/utils/request-peer-ip";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import { LogCategories } from "@/src/dict/log-categories";
|
import { LogCategories } from "@/src/dict/log-categories";
|
||||||
|
|
||||||
@@ -8,13 +10,11 @@ export function grabRequestIP(req?: Request): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const forwarded = req.headers.get("x-forwarded-for");
|
return clientIP({
|
||||||
|
peer: grabRequestPeerIP({ req }),
|
||||||
if (forwarded) {
|
forwarded_for: req.headers.get("x-forwarded-for"),
|
||||||
return forwarded.split(",").shift()?.trim();
|
real_ip: req.headers.get("x-real-ip"),
|
||||||
}
|
});
|
||||||
|
|
||||||
return req.headers.get("x-real-ip") || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogDetail = {
|
export type LogDetail = {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import reachableBotsSql from "@/src/utils/crew-access/reachable-bots-sql";
|
||||||
|
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
|
||||||
|
import type { CrewRole } from "@/src/utils/rbac/crew-role";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
role: CrewRole;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComputerFolderAccess = {
|
||||||
|
/**
|
||||||
|
* What `computerPathAllowed` needs about the user.
|
||||||
|
*/
|
||||||
|
manages_crew: boolean;
|
||||||
|
user_id: number;
|
||||||
|
bot_ids: number[];
|
||||||
|
workspace_ids: number[];
|
||||||
|
/**
|
||||||
|
* Names for the workspace's id folders (`bots/<id>`, `users/<id>`,
|
||||||
|
* `workspaces/<id>`), the ones the user may see.
|
||||||
|
*/
|
||||||
|
names: {
|
||||||
|
bots: Record<string, string>;
|
||||||
|
users: Record<string, string>;
|
||||||
|
workspaces: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of a computer's workspace folders a user may open in the files
|
||||||
|
* browser, and what they are called: the bots and workspaces they reach,
|
||||||
|
* by the same crew SQL as the rest of the app. Crew managers see every
|
||||||
|
* one, and every user's folder in them.
|
||||||
|
*/
|
||||||
|
export default async function grabComputerFolderAccess({
|
||||||
|
role,
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<ComputerFolderAccess> {
|
||||||
|
const manages_crew = role != "member";
|
||||||
|
const bot_access = reachableBotsSql({ role, user_id });
|
||||||
|
const workspace_access = workspacesSql({ role, user_id });
|
||||||
|
|
||||||
|
const [bots, workspaces, users] = await Promise.all([
|
||||||
|
rows<{ id: number; name?: string | null }>({
|
||||||
|
sql: `SELECT b.id, b.name FROM bots b WHERE ${bot_access.sql}`,
|
||||||
|
values: bot_access.values,
|
||||||
|
}),
|
||||||
|
rows<{ id: number; name?: string | null }>({
|
||||||
|
sql: `SELECT w.id, w.name FROM workspaces w WHERE ${workspace_access.sql}`,
|
||||||
|
values: workspace_access.values,
|
||||||
|
}),
|
||||||
|
rows<{ id: number; first_name?: string | null; last_name?: string | null; username?: string | null }>({
|
||||||
|
sql: `SELECT id, first_name, last_name, username FROM users${manages_crew ? "" : " WHERE id = ?"}`,
|
||||||
|
values: manages_crew ? [] : [Number(user_id)],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
manages_crew,
|
||||||
|
user_id: Number(user_id),
|
||||||
|
bot_ids: bots.map((bot) => Number(bot.id)),
|
||||||
|
workspace_ids: workspaces.map((workspace) => Number(workspace.id)),
|
||||||
|
names: {
|
||||||
|
bots: Object.fromEntries(bots.map((bot) => [String(bot.id), bot.name || `Bot ${bot.id}`])),
|
||||||
|
workspaces: Object.fromEntries(
|
||||||
|
workspaces.map((workspace) => [String(workspace.id), workspace.name || `Workspace ${workspace.id}`]),
|
||||||
|
),
|
||||||
|
users: Object.fromEntries(
|
||||||
|
users.map((user) => [
|
||||||
|
String(user.id),
|
||||||
|
[user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || `User ${user.id}`,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rows<T extends { [k: string]: any }>({
|
||||||
|
sql,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
sql: string;
|
||||||
|
values: (string | number)[];
|
||||||
|
}) {
|
||||||
|
return (await BunSQLite.sql<T>({ sql, values })).payload || [];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { statSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { mimeTypeForFile } from "../db/bot-files/media-type-for-file";
|
import { mimeTypeForFile } from "../db/bot-files/media-type-for-file";
|
||||||
|
import { canSeePath } from "./list-storage-entries";
|
||||||
import resolveStoragePath from "./resolve-storage-path";
|
import resolveStoragePath from "./resolve-storage-path";
|
||||||
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
|
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ export type StorageFile = {
|
|||||||
/**
|
/**
|
||||||
* One file for the browser to open: checked to be inside the volume (the
|
* One file for the browser to open: checked to be inside the volume (the
|
||||||
* same rules a bot's own path gets), to exist, and to be a regular file
|
* same rules a bot's own path gets), to exist, and to be a regular file
|
||||||
* rather than a folder. Null when it isn't one, so a caller can answer
|
* rather than a folder, and one the user may see in a computer's workspace
|
||||||
|
* (`canSeePath`). Null when it isn't one, so a caller can answer
|
||||||
* "not found" without leaking what is there.
|
* "not found" without leaking what is there.
|
||||||
*/
|
*/
|
||||||
export default function grabStorageFile({ resolved, path: requested }: Params): StorageFile | null {
|
export default function grabStorageFile({ resolved, path: requested }: Params): StorageFile | null {
|
||||||
@@ -39,6 +41,8 @@ export default function grabStorageFile({ resolved, path: requested }: Params):
|
|||||||
|
|
||||||
if (!resolved_path.name) return null;
|
if (!resolved_path.name) return null;
|
||||||
|
|
||||||
|
if (!canSeePath({ resolved, path: resolved_path.relative })) return null;
|
||||||
|
|
||||||
let stats;
|
let stats;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabMediaURL from "../media/grab-media-url";
|
import grabMediaURL from "../media/grab-media-url";
|
||||||
|
import computerFolderLabel from "@/src/utils/computer-folder-label";
|
||||||
|
import computerPathAllowed from "@/src/utils/computer-path-allowed";
|
||||||
import { isInsideRoot } from "./resolve-storage-path";
|
import { isInsideRoot } from "./resolve-storage-path";
|
||||||
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
|
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
|
||||||
import {
|
import {
|
||||||
@@ -52,13 +54,20 @@ export type StorageEntry = {
|
|||||||
* browser's own streaming route.
|
* browser's own streaming route.
|
||||||
*/
|
*/
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/**
|
||||||
|
* What an id folder in a computer's workspace is: the bot's, user's or
|
||||||
|
* workspace's name.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One directory on the data volume, for the files browser: its entries
|
* One directory on the data volume, for the files browser: its entries
|
||||||
* (folders first), a page of them, and a `bot_files` row attached to any
|
* (folders first), a page of them, and a `bot_files` row attached to any
|
||||||
* that has one. Entries a symlink leads out of the volume with are left
|
* that has one. Entries a symlink leads out of the volume with are left
|
||||||
* out, so nothing outside the folder is ever listed.
|
* out, so nothing outside the folder is ever listed. In a computer's
|
||||||
|
* workspace only what the user may see is listed (`computerPathAllowed`),
|
||||||
|
* and a folder they may not see reads as missing.
|
||||||
*/
|
*/
|
||||||
export default async function listStorageEntries({
|
export default async function listStorageEntries({
|
||||||
resolved,
|
resolved,
|
||||||
@@ -74,7 +83,9 @@ export default async function listStorageEntries({
|
|||||||
* all, which a stale folder link lands on (the page says so, instead of
|
* all, which a stale folder link lands on (the page says so, instead of
|
||||||
* claiming the folder is empty).
|
* claiming the folder is empty).
|
||||||
*/
|
*/
|
||||||
if (!existsSync(absolute)) return { entries: [], total: 0, offset, missing: true };
|
if (!existsSync(absolute) || !canSeePath({ resolved, path: relative })) {
|
||||||
|
return { entries: [], total: 0, offset, missing: true };
|
||||||
|
}
|
||||||
|
|
||||||
const rows = readdirSync(absolute, { withFileTypes: true });
|
const rows = readdirSync(absolute, { withFileTypes: true });
|
||||||
const indexed = await botFileRows({ resolved, paths: rows.map((row) => joinPath({ relative, name: row.name })) });
|
const indexed = await botFileRows({ resolved, paths: rows.map((row) => joinPath({ relative, name: row.name })) });
|
||||||
@@ -86,6 +97,8 @@ export default async function listStorageEntries({
|
|||||||
|
|
||||||
const entry_path = joinPath({ relative, name: row.name });
|
const entry_path = joinPath({ relative, name: row.name });
|
||||||
|
|
||||||
|
if (!canSeePath({ resolved, path: entry_path })) continue;
|
||||||
|
|
||||||
if (needle && !entry_path.toLowerCase().includes(needle)) continue;
|
if (needle && !entry_path.toLowerCase().includes(needle)) continue;
|
||||||
|
|
||||||
const entry_absolute = path.join(absolute, row.name);
|
const entry_absolute = path.join(absolute, row.name);
|
||||||
@@ -114,6 +127,9 @@ export default async function listStorageEntries({
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified_at: Math.round(stats.mtimeMs),
|
modified_at: Math.round(stats.mtimeMs),
|
||||||
url: storageSpecHref({ spec: resolved.spec, path: entry_path }),
|
url: storageSpecHref({ spec: resolved.spec, path: entry_path }),
|
||||||
|
label: resolved.computer
|
||||||
|
? computerFolderLabel({ path: entry_path, names: resolved.computer.names }) || undefined
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -197,6 +213,14 @@ async function botFileRows({
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user may see a path in the folder: anything in it, except
|
||||||
|
* inside a computer's workspace (`computerPathAllowed`).
|
||||||
|
*/
|
||||||
|
export function canSeePath({ resolved, path }: { resolved: ResolvedStorageSpec; path: string }) {
|
||||||
|
return !resolved.computer || computerPathAllowed({ ...resolved.computer, path });
|
||||||
|
}
|
||||||
|
|
||||||
function joinPath({ relative, name }: { relative: string; name: string }) {
|
function joinPath({ relative, name }: { relative: string; name: string }) {
|
||||||
return relative ? `${relative}/${name}` : name;
|
return relative ? `${relative}/${name}` : name;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user