diff --git a/core/server/src/db.ts b/core/server/src/db.ts index 22fd79e5..397f1b42 100644 --- a/core/server/src/db.ts +++ b/core/server/src/db.ts @@ -559,8 +559,10 @@ function handOffMedia(d: Database.Database): void { d.prepare("DELETE FROM settings WHERE key IN ('image_generation', 'video_generation')").run(); d.exec("DROP TABLE images"); d.exec("DROP TABLE IF EXISTS videos"); - // Core filed its picture and video tools as one set of their own. + // Core filed its picture and video tools as one set of their own: "", and in v0.1.0, before there were + // videos, "". A name given to the newer one wins. setMoved(d, "", "pithagoras-media"); + setMoved(d, "", "pithagoras-media"); mediaToolsSeenMoved(d); mediaToldOfRemovals(d); } diff --git a/core/server/test/mcp-edited-outside.test.mjs b/core/server/test/mcp-edited-outside.test.mjs index b5c59376..962abdfd 100644 --- a/core/server/test/mcp-edited-outside.test.mjs +++ b/core/server/test/mcp-edited-outside.test.mjs @@ -246,3 +246,17 @@ test("an edit made while the portal was not running is noticed when it starts", assert.ok(!tools.includes("wiki_page"), tools.join(", ")); assert.ok(tools.includes("jira_search")); }); + +test("a tool left behind by a server taken out by hand before the first look goes once the server is added again under its name and removed in Settings → MCP", () => { + // As the first look left `old_lookup` above: remembered, listed, and claimed by no server of the file. + writeMcpFile({ mcpServers: { jira: JIRA } }); + db.rememberTools([adapter("gone_lookup")]); + assert.ok(listed().includes("gone_lookup")); + assert.ok(!configured().includes("gone")); + // Added again in the panel, with any entry, and removed there: its tools go as any removed server's do. + writeMcpFile({ mcpServers: { jira: JIRA, gone: { command: "true" } } }); + writeMcpFile({ mcpServers: { jira: JIRA } }); + assert.ok(!listed().includes("gone_lookup")); + assert.ok(!known().includes("gone_lookup"), "forgotten, not only hidden"); + assert.ok(removed().includes("gone")); +}); diff --git a/core/server/test/schema-fingerprint.test.mjs b/core/server/test/schema-fingerprint.test.mjs index d412aeb6..4248a2aa 100644 --- a/core/server/test/schema-fingerprint.test.mjs +++ b/core/server/test/schema-fingerprint.test.mjs @@ -1322,3 +1322,113 @@ test("a database of version 10 is backed up and given chat modes: every chat is now.close(); assert.deepEqual(modesIn(dir), { chats: [{ id: "c1", mode: null }, { id: "c2", mode: null }], made: [] }); }); + +// A data folder of v0.1.0 that used every part an extension holds now, each kept as v0.1.0 kept it, on fixtures/portal-v3.db: +// a chat with a canvas, a picture of the Media page with its file, the image endpoint, voice mode on at the voice service's +// addresses with the person's own remembered as connecting it kept them, a designed voice and a clone, the GPU chosen, +// Understory's four settings and its MCP entry, the browser's six settings and the local Chrome's profile, the allowlist, +// a heartbeat's rule for a tool that runs as the primary user, the tools as a chat reported them, and names given to the +// pictures' and the browser's sets. One start takes it to this version, with one backup, and leaves each part for its +// extension; what the extensions do with it is in their own tests. +test("a data folder of v0.1.0 that used every part is brought to this version in one start, with one backup, and each part left for its extension", async () => { + const dir = path.join(home, "everything-from-v3"); + const site = path.join(dir, "ws", "site"); + mkdirSync(site, { recursive: true }); + const file = path.join(dir, "portal.db"); + copyFileSync(new URL("./fixtures/portal-v3.db", import.meta.url), file); + const before = new Database(file); + before.prepare("INSERT INTO sessions (id, title, workspace, kind) VALUES ('c1', 'Kites', ?, 'task')").run(site); + before.prepare("INSERT INTO canvases (id, session_id, title, content) VALUES ('k1', 'c1', 'Plan', '# Plan\n\nFly it.')").run(); + before.prepare(`INSERT INTO images (${IMAGE_COLUMNS}) VALUES (${IMAGE_COLUMNS.map(() => "?")})`).run(["aaaaaaaaaaa1", "page", null, null, "aaaaaaaaaaa1.png", "generated", "a red kite", "{}", null, 14, 1000]); + const calm = { id: "voice-5b1e0c9a-0d7e-4f3c-8a2b-6c4d2e1f0a9b", name: "Calm", kind: "design", instruction: "Slow and low.", transcript: "", audio: null }; + const voice = before.prepare("INSERT INTO voice_presets (id, name, kind, instruction, transcript, audio) VALUES (?, ?, ?, ?, ?, ?)"); + for (const v of [calm, ARIA]) voice.run(v.id, v.name, v.kind, v.instruction, v.transcript, v.audio); + const rule = before.prepare("INSERT INTO tool_rules (id, role, tool, pattern, note, person_key) VALUES (?, ?, ?, ?, '', NULL)"); + rule.run("r3", "heartbeat", "routine_update", "{*"); + rule.run("r4", "colleague", "bash", "echo up-*"); + // Voice mode as v0.1.0 saved it once the voice service was connected (api/voice.ts there). + const voiceMode = { + vad: VAD, lazyLoad: true, runtime: "audio-cpp", voice: ARIA.id, language: "en", cfgScale: 1, sttModel: "", exaggeration: 0.5, kokoroVoice: "af_heart", speed: 1, + responseInstructions: "", skipThinkingProviders: ["llama-swap"], enabled: true, whisperUrl: SERVICE.whisper, breezeUrl: SERVICE.speech, instruction: DEFAULT_INSTRUCTION, + }; + const remembered = { listening: { whisperUrl: OWN.whisper, sttModel: "", enabled: true }, speaking: { breezeUrl: OWN.speech, runtime: "kokoro", enabled: true }, noSpeech: false }; + const browserKept = { browser_user: "agent", browser_password: BROWSER_PASSWORD, browser_port: "3010", browser_https_port: "3011", browser_cursor: "0", browser_tools: "1" }; + const seen = [ + { name: "browser_navigate", source: "browser", package: null, inline: true }, + { name: "show_image", source: "pictures", package: null, inline: true }, + { name: "web_fetch", source: "pi-web-access", package: "npm:pi-web-access", inline: false }, + ]; + const setting = before.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"); + for (const [key, value] of Object.entries({ + voice: JSON.stringify(voiceMode), + voice_gpu: "GPU-648bf6c5-346e-81f5-f155-dc8061172da3", + voice_before_managed: JSON.stringify(remembered), + image_generation: IMAGE_SETTINGS, + ...UNDERSTORY_KEPT, + ...browserKept, + browser_allowlist: "*.example.com", + tools_seen: JSON.stringify(seen), + // v0.1.0 filed its picture tools as "" (web/src/tool-groups.ts there). + tool_group_names: JSON.stringify({ "": "Pictures", browser: "Web browser", "pi-web-access": "Web" }), + })) setting.run(key, value); + before.close(); + mkdirSync(path.join(dir, "images"), { recursive: true }); + writeFileSync(path.join(dir, "images", "aaaaaaaaaaa1.png"), "the page's kite"); + mkdirSync(path.join(dir, "browser-profile", "Default"), { recursive: true }); + writeFileSync(path.join(dir, "browser-profile", "Default", "Cookies"), "the agent's logins"); + assert.deepEqual(upgradeCheck(file), { needed: true, from: 3 }); + + assert.match(await upgradedWith(dir, UNDERSTORY_ENTRY), new RegExp(`upgrading the database from version 3 to ${SCHEMA_VERSION}`)); + const backups = backupsIn(path.join(dir, "backups")); + assert.equal(backups.length, 1, "one backup, of version 3"); + assert.match(path.basename(backups[0]), /^portal-v3-\d{8}-\d{6}\.db$/); + const copy = new Database(backups[0], { readonly: true }); + assert.deepEqual( + ["canvases", "images", "voice_presets", "settings"].map((table) => copy.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n), + [1, 1, 2, 2 + 3 + 1 + 4 + 6 + 3], + "the copy from before holds all of it", + ); + copy.close(); + + const d = new Database(file, { readonly: true }); + assert.deepEqual([d.pragma("user_version", { simple: true }), schemaFingerprint(d)], [SCHEMA_VERSION, PINNED.fingerprint]); + for (const table of ["canvases", "images", "voice_presets"]) assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE name = ?").get(table), `${table} is gone`); + const settings = Object.fromEntries(d.prepare("SELECT key, value FROM settings").all().map((row) => [row.key, row.value])); + for (const key of ["voice_gpu", "voice_before_managed", "image_generation", ...Object.keys(UNDERSTORY_KEPT), ...Object.keys(browserKept)]) assert.equal(settings[key], undefined, key); + assert.deepEqual(JSON.parse(settings.voice), { provider: "pithagoras-voice", vad: VAD, responseInstructions: "", skipThinkingProviders: ["llama-swap"] }); + assert.equal(settings.browser_allowlist, "*.example.com", "the allowlist stays the guard's"); + assert.equal(settings.guard_trusted_mcp_servers, JSON.stringify(["understory"])); + assert.equal(settings.tools_off_default, "codemode\nweb_search"); + assert.deepEqual(JSON.parse(settings.tool_group_names), { "pithagoras-media": "Pictures", "pithagoras-browser": "Web browser", "pi-web-access": "Web" }); + assert.deepEqual(JSON.parse(settings.tools_seen), [ + { ...seen[0], source: "pithagoras-browser" }, + { ...seen[1], source: "pithagoras-media" }, + seen[2], + ]); + assert.deepEqual(d.prepare("SELECT id FROM tool_rules ORDER BY id").all(), [{ id: "r4" }], "a heartbeat's rule for a tool that runs as the primary user goes too"); + d.close(); + assert.deepEqual(modesIn(dir), { chats: [{ id: "c1", mode: null }], made: [] }); + assert.equal(readFileSync(path.join(site, "canvases", "Plan.md"), "utf8"), "# Plan\n\nFly it."); + + // Each part left for its extension, at the schema that left it. + assert.deepEqual(leftAt(dir), ["browser", "media", "understory", "voice-addresses", "voice-service"], "nothing half done beside them"); + for (const [name, schema] of [["media", 7], ["browser", 8], ["voice-service", 9], ["voice-addresses", 9], ["understory", 10]]) { + const { made: _made, ...about } = JSON.parse(readFileSync(path.join(dir, "handoff", name, "handoff.json"), "utf8")); + assert.deepEqual(about, { format: 1, name, schema }, name); + } + const left = (name, rel) => readFileSync(path.join(dir, "handoff", name, rel), "utf8"); + assert.deepEqual(JSON.parse(left("media", "settings.json")), { image_generation: IMAGE_SETTINGS, video_generation: null }); + assert.deepEqual(JSON.parse(left("media", "images.json")).rows.map((row) => row.id), ["aaaaaaaaaaa1"]); + assert.equal(left("media", "files/images/aaaaaaaaaaa1.png"), "the page's kite"); + assert.deepEqual(JSON.parse(left("browser", "settings.json")), browserKept); + assert.equal(left("browser", "profile/Default/Cookies"), "the agent's logins"); + assert.equal(existsSync(path.join(dir, "browser-profile")), false); + assert.deepEqual(voiceLeft(dir, "voice-service", "settings.json"), serviceOf(voiceMode, "GPU-648bf6c5-346e-81f5-f155-dc8061172da3")); + const { audio: _audio, ...aria } = ARIA; + assert.deepEqual(voiceLeft(dir, "voice-service", "voices.json"), [calm, { ...aria, audio: "voices/1.wav" }]); + assert.equal(sha256(readFileSync(path.join(dir, "handoff", "voice-service", "voices", "1.wav"))), sha256(ARIA.audio)); + assert.deepEqual(voiceLeft(dir, "voice-addresses", "settings.json"), { + whisperUrl: OWN.whisper, sttModel: "", breezeUrl: OWN.speech, runtime: "kokoro", dictationUrl: null, dictationModel: null, language: "en", kokoroVoice: "af_heart", + }); + assert.deepEqual(understoryLeft(dir, "settings.json"), UNDERSTORY_KEPT); +}); diff --git a/docs/guide/deploying.md b/docs/guide/deploying.md index 05dc315f..f23220f8 100644 --- a/docs/guide/deploying.md +++ b/docs/guide/deploying.md @@ -501,7 +501,7 @@ Everything here is optional except, with the Compose files, `WORKSPACES_DIR`. | `PORTAL_UPDATE_RESTART` | — | From source: how the **Update** button in Settings → About restarts the portal after it has built the new version, and whether the **Restart** button may start a copy of it itself (`self`) or only stop it (`exit`, see [Restarting](#restarting)). `exit` only stops it, with status 75, for a portal that something else starts again (pm2, a restart policy); `self` starts the new one itself. Unset, a portal that is a systemd service's own process exits; one that only is in a service's control group (`npm start` under dash, a wrapper script, a cron job) starts its own copy and exits with status 75 as well; any other starts its own. A systemd unit needs `Restart=on-failure` or `Restart=always`. See [Settings](/guide/settings#update). | | `TZ` | UTC | The time zone of the portal's clock, such as `Europe/Berlin`. A container keeps UTC unless it is told one. The quiet hours of an [agent's heartbeat](/guide/agents#its-heartbeat) and the schedule of a repeating [routine](/guide/routines) are read on this clock. Both Compose files pass it from `.env` or the shell. | | `PORTAL_UPGRADE_BACKUP` | — | `skip` upgrades the database without the backup it is otherwise made first, for a volume with no room for the copy. Both Compose files pass it; see [Upgrading](/guide/upgrading#no-room-for-the-backup). | -| `PORTAL_DEFAULT_EXTENSIONS` | — | `off` keeps the portal from installing the extensions it starts with (the terminal, the theme pack) on its first start: you choose every extension yourself, in Settings → Extensions. See [What a portal starts with](/guide/extensions#what-a-portal-starts-with). | +| `PORTAL_DEFAULT_EXTENSIONS` | — | `off` keeps the portal from installing the extensions it starts with (the terminal, Git, Files, Canvases, Media and the theme pack) on its first start: you choose every extension yourself, in Settings → Extensions. See [What a portal starts with](/guide/extensions#what-a-portal-starts-with). | | `PORTAL_UPDATE_CHECK` | — | From source: `off` stops the portal from fetching `origin/main` by itself at the start and every six hours to see whether there is something new. The **Update** button in Settings → About still asks when it is pressed. See [Settings](/guide/settings#update). | | `EXECUTOR` | `host` | `host` or `container` — see [Architecture](/reference/architecture#executors). | | `WORKSPACE_ROOT` | `/workspaces` | Where workspaces live inside the container. | diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 45840aa5..8ddf3b17 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -139,7 +139,8 @@ from anywhere else is not judged, though where its name is that of a server taken out of this file, or starts with it and an underscore, its tools are taken for that server's. The first time the portal looks at the file it has nothing to compare it with, so a tool left behind by a server taken out by hand before then -stays listed, until it is removed from `tools_seen` in the portal's database. +stays listed. To be rid of it, add the server again under its name here, with +any entry, and remove it. ::: tip The adapter has its own commands `/mcp` opens its status panel, `/mcp tools` lists what is available, and diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 6c5dbc40..5ad5fbd7 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -7,7 +7,7 @@ German. Navigation runs down the left edge, in four groups: | Group | Pages | | --- | --- | | **Models** | **Providers** — where models come from, see [Models and providers](/guide/models); **Defaults** — model, effort and context for new chats | -| **Agent** | **Tools**, **Modes**, **Skills**, **MCP**, **Extensions** | +| **Agent** | **Tools**, **Modes**, **Sandbox**, **Guard**, **Skills**, **MCP**, **Extensions** | | **Reach** | **Channels**, **People** | | **Portal** | **This browser**, **Authentication**, **Shortcuts**, **Logs**, **About** (with **Update** and **Restart**), **Advanced** | diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index 83fa812d..7b8c0454 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -63,6 +63,130 @@ mv portal.db portal-damaged.db && rm -f portal.db-wal portal.db-shm && mv portal The portal checks the recovered database before upgrading it. Rows on damaged pages can't be recovered, which usually means part of the event history of some conversations. Once you're happy with the result, delete `portal-damaged.db`. +## From 0.1.0 to 0.2.0 + +A portal at 0.1.0 takes every step of this release in its first start. The +database goes from schema 3 to 11 in one transaction, after one check and one +backup, `backups/portal-v3--