From 100bb7481c78357eb3583c2bb61ef736ffd09824 Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 21:45:10 -0400 Subject: [PATCH 1/7] chore: refresh uv lock for 0.29.12 --- uv.lock | 74 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/uv.lock b/uv.lock index 8ecb3631..4e65f56a 100644 --- a/uv.lock +++ b/uv.lock @@ -652,7 +652,7 @@ wheels = [ [[package]] name = "cortana-brain" -version = "0.27.0" +version = "0.29.12" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -856,7 +856,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version < '3.15' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -889,43 +889,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufile = [ - { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] curand = [ - { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] [[package]] @@ -2696,14 +2696,14 @@ name = "mlx-lm" version = "0.31.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinja2", marker = "sys_platform != 'win32'" }, + { name = "jinja2" }, { name = "mlx", marker = "sys_platform == 'darwin'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'win32'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform != 'win32'" }, - { name = "protobuf", marker = "sys_platform != 'win32'" }, - { name = "pyyaml", marker = "sys_platform != 'win32'" }, - { name = "sentencepiece", marker = "sys_platform != 'win32'" }, - { name = "transformers", marker = "sys_platform != 'win32'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "protobuf" }, + { name = "pyyaml" }, + { name = "sentencepiece" }, + { name = "transformers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/94/9a38d6b0c6fcca995b9136c94eb7da1e9c5165652edf228b96b29960fa7a/mlx_lm-0.31.3.tar.gz", hash = "sha256:61eb0e3ba09444f77f874aff295401d7ccd20b39495cbbce0c782a15474ce733", size = 304318, upload-time = "2026-04-22T07:37:27.922Z" } wheels = [ @@ -3081,7 +3081,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3120,7 +3120,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3132,7 +3132,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3162,9 +3162,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3176,7 +3176,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4986,7 +4986,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5067,7 +5067,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -5118,8 +5118,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ From 4b90fbc1e5cafcea08d3df1ee17200c6cabb36e8 Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 22:17:26 -0400 Subject: [PATCH 2/7] test: keep desktop fixtures aligned with releases --- apps/web/src/test/fixtures.ts | 7 ++++--- docs/desktop-ux-audit.md | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/web/src/test/fixtures.ts b/apps/web/src/test/fixtures.ts index 17e55065..116d3baa 100644 --- a/apps/web/src/test/fixtures.ts +++ b/apps/web/src/test/fixtures.ts @@ -1,4 +1,5 @@ import { demoEvidence } from '../demo' +import webPackage from '../../package.json' import type { AnswerResponse, AuditEvent, @@ -161,7 +162,7 @@ export const desktopSettings: DesktopSettings = { } export const desktopInfo: DesktopInfo = { - desktop_version: '0.11.2', + desktop_version: webPackage.version, backend_origin: 'http://127.0.0.1:7331', autostart_enabled: false, platform: 'macos', @@ -177,11 +178,11 @@ export const desktopAuditEvents: AuditEvent[] = [ ] export const desktopUpdate: DesktopUpdate = { - current_version: '0.11.2', + current_version: webPackage.version, available_version: '9.9.9', release_date: '2026-07-30T00:00:00Z', release_notes: 'Fixes and improvements.', - changelog: '0.11.2 release notes.', + changelog: `${webPackage.version} release notes.`, github_url: 'https://github.com/0xPlayerOne/cortana', phase: 'available', downloaded_bytes: 0, diff --git a/docs/desktop-ux-audit.md b/docs/desktop-ux-audit.md index b3115a64..1e3c778e 100644 --- a/docs/desktop-ux-audit.md +++ b/docs/desktop-ux-audit.md @@ -75,14 +75,16 @@ is not part of a visual/UI change. passes `codesign --verify --deep --strict`, but remains ad-hoc signed (`TeamIdentifier` is unset) and is rejected by `spctl --assess`. Developer ID signing/notarization therefore remains a release blocker. -- The v0.29.11-line local validation passed the confirmed counts: `cargo test --lib` 260 passed; - `bun test --max-concurrency=1` 250 passed; `uv run pytest -q` 149 passed; `bun run typecheck` - passed. These are per-suite local figures on the v0.29.11 line, not a deduplicated aggregate and - not the promotion-run tallies previously reported for v0.29.8. -- The v0.29.11 line includes Desktop changes, so the v0.29.8 "no Desktop changes, matrix skipped" - note does not apply. No packaged Desktop build/test matrix results are claimed for v0.29.11 - beyond the control-plane drill evidence above; the 19/11 focused readiness figures were - specific to the v0.29.8 readiness-hardening patch and are not re-asserted. +- The prepared-sidecar v0.29.12 Desktop native suite passed 123 tests. The previously confirmed + v0.29.11-line figures remain: `cargo test --lib` 260 passed; `bun test --max-concurrency=1` + 250 passed; `uv run pytest -q` 149 passed; and `bun run typecheck` passed. These are prior + per-suite figures, not a deduplicated aggregate or a fresh claim about this checkout's full + Web suite; the refreshed `uv.lock` itself passes `uv lock --check`. +- The v0.29.12 line includes Desktop changes, so the v0.29.8 "no Desktop changes, matrix skipped" + note does not apply. The 123-test native suite is a headless Tauri IPC check after preparing the + release sidecar; it does not claim packaged GUI, browser, OS-service, or signed-updater behavior. + The 19/11 focused readiness figures were specific to the v0.29.8 readiness-hardening patch and + are not re-asserted. - Full `cortana readiness` is a read-only, comprehensive check that includes roughly 1 GB of SQLite integrity and backup scanning. In the observed run, the database integrity scan took about 130 seconds and the backup scan about 80 seconds. `GET /healthz` is only an From 3a347450882205b1c19ca0988e5254a58b0f7203 Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 22:21:27 -0400 Subject: [PATCH 3/7] fix(desktop): label Slack OAuth terminal summaries --- apps/desktop/src-tauri/src/source_jobs.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src-tauri/src/source_jobs.rs b/apps/desktop/src-tauri/src/source_jobs.rs index 98da24d7..a6774aa7 100644 --- a/apps/desktop/src-tauri/src/source_jobs.rs +++ b/apps/desktop/src-tauri/src/source_jobs.rs @@ -1664,21 +1664,27 @@ fn terminal_summary(operation: &str, status: &str, disconnected: bool, kind: &st "github" => { "GitHub authorization completed and the token was stored privately.".into() } + "slack" => { + "Slack authorization completed and the user token was stored privately.".into() + } _ => "Google authorization completed and the token was stored privately.".into(), }, ("authorization", "cancelled", _) => match kind { "discord" => "Discord authorization was cancelled.".into(), "github" => "GitHub authorization was cancelled.".into(), + "slack" => "Slack authorization was cancelled.".into(), _ => "Google authorization was cancelled.".into(), }, ("authorization", _, true) => match kind { "discord" => "Discord authorization ended without a process result.".into(), "github" => "GitHub authorization ended without a process result.".into(), + "slack" => "Slack authorization ended without a process result.".into(), _ => "Google authorization ended without a process result.".into(), }, ("authorization", _, false) => match kind { "discord" => "Discord authorization failed.".into(), "github" => "GitHub authorization failed.".into(), + "slack" => "Slack authorization failed.".into(), _ => "Google authorization failed.".into(), }, (_, "succeeded", _) => "Source validation passed. No documents were indexed.".into(), @@ -2783,6 +2789,10 @@ mod tests { terminal_summary("authorization", "succeeded", false, "github") .contains("GitHub authorization completed") ); + assert!( + terminal_summary("authorization", "succeeded", false, "slack") + .contains("Slack authorization completed") + ); assert!( terminal_summary("authorization", "succeeded", false, "gmail") .contains("Google authorization completed") @@ -2791,14 +2801,26 @@ mod tests { terminal_summary("authorization", "cancelled", false, "discord") .contains("Discord authorization was cancelled") ); + assert!( + terminal_summary("authorization", "cancelled", false, "slack") + .contains("Slack authorization was cancelled") + ); assert!( terminal_summary("authorization", "failed", true, "discord") .contains("Discord authorization ended without a process result") ); + assert!( + terminal_summary("authorization", "failed", true, "slack") + .contains("Slack authorization ended without a process result") + ); assert!( terminal_summary("authorization", "failed", false, "discord") .contains("Discord authorization failed") ); + assert!( + terminal_summary("authorization", "failed", false, "slack") + .contains("Slack authorization failed") + ); } #[test] From a547e29ce3963e92d01644b884a27c9b21aa3d6f Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 22:31:18 -0400 Subject: [PATCH 4/7] docs: refresh v0.29.13 desktop evidence --- docs/desktop-ux-audit.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/desktop-ux-audit.md b/docs/desktop-ux-audit.md index 1e3c778e..54567c89 100644 --- a/docs/desktop-ux-audit.md +++ b/docs/desktop-ux-audit.md @@ -64,13 +64,13 @@ is not part of a visual/UI change. ## Evidence limits -- The v0.29.12 promotion (`3836df7`) is merged on both `main` and `staging` and tagged - `v0.29.12`. Release-assets run `30965178674` completed successfully and the local verifier passed: +- The v0.29.13 promotion (`2eb1f45`) is merged on both `main` and `staging` and tagged + `v0.29.13`. Release-assets run `30967558201` completed successfully and the local verifier passed: all 18 assets, core checksums, the macOS sidecar/resources bundle, all six minisign updater signatures, the updater manifest, and the Linux runner `--version` check against the release tag are verified. - The installed CLI `/Users/amf/.local/bin/cortana` and the installed packaged Desktop app - `/Applications/Cortana.app` both report `cortana 0.29.12`; the CLI passes `cortana doctor`, the + `/Applications/Cortana.app` both report `cortana 0.29.13`; the CLI passes `cortana doctor`, the offline evaluation checks, and the disposable desktop control-plane drill. The packaged app passes `codesign --verify --deep --strict`, but remains ad-hoc signed (`TeamIdentifier` is unset) and is rejected by `spctl --assess`. Developer ID signing/notarization therefore remains From 85e9d2f5b32690f04fa673400e80e1d52e421346 Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 22:56:41 -0400 Subject: [PATCH 5/7] test: derive desktop footer version expectations --- apps/web/src/App.desktop.test.tsx | 47 +++++++++++++------------------ 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/apps/web/src/App.desktop.test.tsx b/apps/web/src/App.desktop.test.tsx index 341234bc..00ed704d 100644 --- a/apps/web/src/App.desktop.test.tsx +++ b/apps/web/src/App.desktop.test.tsx @@ -22,6 +22,13 @@ import type { SourceSettings, } from './types' +// The footer label tracks the installed desktop version; the fixture derives +// it from package.json so this matcher cannot drift from the release. +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') +const updatesButtonName = new RegExp( + `Cortana ${escapeRegExp(desktopInfo.desktop_version)} · Updates` +) + afterEach(cleanup) afterEach(() => { window.localStorage.removeItem('cortana.workspace-selection.v1') @@ -606,9 +613,7 @@ test('desktop settings navigation opens the audit trail and renders both event s render() // Desktop chrome: version and updates shortcut live in the footer. - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) // Rail navigation into the settings view. fireEvent.click(screen.getByRole('button', { name: 'Settings' })) @@ -662,7 +667,7 @@ test('audit trail export downloads exactly the loaded redacted events as JSON', try { render() await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() + expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy() ) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => @@ -705,9 +710,7 @@ test('audit trail export downloads exactly the loaded redacted events as JSON', test('advanced settings export is blocked while draft is dirty', async () => { render() - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeTruthy() @@ -736,9 +739,7 @@ test('advanced settings export shows redacted notice and calls the export bridge omitted_external_sources: ['s3-uploader'], } render() - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeTruthy() @@ -772,7 +773,7 @@ test('advanced import preview cancellation keeps draft values unchanged', async try { render() await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() + expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy() ) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => @@ -809,7 +810,7 @@ test('advanced settings import preview applies as unsaved draft and requires exp try { render() await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() + expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy() ) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => @@ -1271,11 +1272,9 @@ test('late desktop bootstrap settings cannot overwrite a shell-reconciled snapsh test('the footer updates shortcut opens the updates section directly', async () => { render() - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) - fireEvent.click(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })) + fireEvent.click(screen.getByRole('button', { name: updatesButtonName })) await waitFor(() => expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeTruthy() ) @@ -1325,7 +1324,7 @@ test('the footer updates shortcut respects unsaved settings changes', async () = fireEvent.change(screen.getAllByLabelText('Display name')[0], { target: { value: 'Unsaved workspace' }, }) - fireEvent.click(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })) + fireEvent.click(screen.getByRole('button', { name: updatesButtonName })) expect(screen.getByRole('heading', { name: 'Workspaces' })).toBeTruthy() expect(screen.queryByText('Installed version')).toBeNull() } finally { @@ -2523,7 +2522,7 @@ test('running source jobs stay visible in the shell after leaving the settings v state.settings = { ...desktopSettings, sources: [workSource] } render() await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() + expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy() ) // No jobs have started yet, so no shell indicator is shown. @@ -2603,9 +2602,7 @@ test('completed source jobs refresh source health without waiting for the status test('hindsight status section remains explicit about being optional', async () => { render() - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => @@ -2633,9 +2630,7 @@ test('hindsight status section remains explicit about being optional', async () test('honcho settings section exposes a disabled-by-default session sidecar', async () => { render() - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => @@ -2655,9 +2650,7 @@ test('honcho settings section exposes a disabled-by-default session sidecar', as test('local runtime section opens active secret file path in desktop', async () => { render() - await waitFor(() => - expect(screen.getByRole('button', { name: /Cortana 0\.11\.2 · Updates/ })).toBeTruthy() - ) + await waitFor(() => expect(screen.getByRole('button', { name: updatesButtonName })).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) await waitFor(() => From 4b01cd6854fce93081811ef1e1682072f785976e Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 23:05:27 -0400 Subject: [PATCH 6/7] chore: align uv lock with release version --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 4e65f56a..5d4d0f8e 100644 --- a/uv.lock +++ b/uv.lock @@ -652,7 +652,7 @@ wheels = [ [[package]] name = "cortana-brain" -version = "0.29.12" +version = "0.29.13" source = { editable = "." } dependencies = [ { name = "httpx" }, From 6646680347a59f7bf968b3530fc75bc2d64499f7 Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Tue, 4 Aug 2026 23:07:25 -0400 Subject: [PATCH 7/7] feat(desktop): add verified database backup controls --- apps/desktop/src-tauri/src/backups.rs | 528 +++++++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 19 + apps/desktop/src-tauri/src/paths.rs | 13 + apps/web/src/App.desktop.test.tsx | 105 +++++ apps/web/src/api.ts | 15 + apps/web/src/components/SettingsView.tsx | 101 ++++- apps/web/src/types.ts | 7 + 7 files changed, 787 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src-tauri/src/backups.rs diff --git a/apps/desktop/src-tauri/src/backups.rs b/apps/desktop/src-tauri/src/backups.rs new file mode 100644 index 00000000..1c73f57c --- /dev/null +++ b/apps/desktop/src-tauri/src/backups.rs @@ -0,0 +1,528 @@ +use std::{ + fs, + path::{Component, Path, PathBuf}, + time::Duration, +}; + +use serde::Serialize; +use tauri::AppHandle; +use tauri_plugin_shell::{ShellExt, process::CommandEvent}; +use tokio::time::timeout; + +use crate::{paths, services, settings}; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); +const MAX_OUTPUT_BYTES: usize = 64 * 1024; +const MAX_DETAIL_BYTES: usize = 4 * 1024; +/// The Desktop control plane refuses snapshots larger than this bound before +/// restore and after backup. This keeps picker-selected paths and sidecar +/// output bounded even when a machine contains an unexpectedly large index. +pub const MAX_BACKUP_BYTES: u64 = 8 * 1024 * 1024 * 1024; + +#[derive(Clone, Debug, Serialize)] +pub struct DatabaseActionResult { + pub action: String, + pub path: String, + pub bytes: u64, + pub detail: String, +} + +pub async fn backup( + app: &AppHandle, + approved: bool, +) -> Result, String> { + if !approved { + return Err("database backup requires explicit approval".into()); + } + let Some(path) = paths::pick(app.clone(), "backup-export").await? else { + return Ok(None); + }; + let path = PathBuf::from(path); + let snapshot = settings::load()?; + let config_path = PathBuf::from(snapshot.config_path); + if let Err(error) = validate_export_path(&path) { + audit(&config_path, "backup", &path, "failed", None, Some(&error)); + return Err(error); + } + + let args = vec![ + "--config".to_string(), + config_path.display().to_string(), + "backup".to_string(), + path.display().to_string(), + ]; + let output = match sidecar_output(app, &args).await { + Ok(output) => output, + Err(error) => { + audit(&config_path, "backup", &path, "failed", None, Some(&error)); + return Err(error); + } + }; + if !output.success { + let error = bounded_error(&output.stderr); + audit(&config_path, "backup", &path, "failed", None, Some(&error)); + return Err(error); + } + let bytes = match validate_snapshot_file(&path, false) { + Ok(bytes) => bytes, + Err(error) => { + audit(&config_path, "backup", &path, "failed", None, Some(&error)); + return Err(error); + } + }; + let detail = bounded_output(&output.stdout); + if !detail.contains("backup verified") { + let error = "bundled Cortana backup returned an unexpected result".to_string(); + audit( + &config_path, + "backup", + &path, + "failed", + Some(bytes), + Some(&error), + ); + return Err(error); + } + audit( + &config_path, + "backup", + &path, + "succeeded", + Some(bytes), + None, + ); + Ok(Some(DatabaseActionResult { + action: "backup".into(), + path: path.display().to_string(), + bytes, + detail, + })) +} + +pub async fn restore( + app: &AppHandle, + approved: bool, +) -> Result, String> { + if !approved { + return Err("database restore requires explicit approval".into()); + } + let Some(path) = paths::pick(app.clone(), "backup-import").await? else { + return Ok(None); + }; + let path = PathBuf::from(path); + let snapshot = settings::load()?; + let config_path = PathBuf::from(snapshot.config_path); + let bytes = match validate_snapshot_file(&path, true) { + Ok(bytes) => bytes, + Err(error) => { + audit(&config_path, "restore", &path, "failed", None, Some(&error)); + return Err(error); + } + }; + + let report = match services::status(app).await { + Ok(report) => report, + Err(error) => { + audit( + &config_path, + "restore", + &path, + "failed", + Some(bytes), + Some(&error), + ); + return Err(error); + } + }; + if let Err(error) = ensure_services_stopped(&report) { + audit( + &config_path, + "restore", + &path, + "failed", + Some(bytes), + Some(&error), + ); + return Err(error); + } + + let args = vec![ + "--config".to_string(), + config_path.display().to_string(), + "restore".to_string(), + path.display().to_string(), + "--force".to_string(), + ]; + let output = match sidecar_output(app, &args).await { + Ok(output) => output, + Err(error) => { + audit( + &config_path, + "restore", + &path, + "failed", + Some(bytes), + Some(&error), + ); + return Err(error); + } + }; + if !output.success { + let error = bounded_error(&output.stderr); + audit( + &config_path, + "restore", + &path, + "failed", + Some(bytes), + Some(&error), + ); + return Err(error); + } + let detail = bounded_output(&output.stdout); + if !detail.contains("database restored") { + let error = "bundled Cortana restore returned an unexpected result".to_string(); + audit( + &config_path, + "restore", + &path, + "failed", + Some(bytes), + Some(&error), + ); + return Err(error); + } + audit( + &config_path, + "restore", + &path, + "succeeded", + Some(bytes), + None, + ); + Ok(Some(DatabaseActionResult { + action: "restore".into(), + path: path.display().to_string(), + bytes, + detail, + })) +} + +fn validate_export_path(path: &Path) -> Result<(), String> { + validate_absolute_path(path)?; + validate_extension(path)?; + let parent = path + .parent() + .ok_or_else(|| "backup destination has no parent directory".to_string())?; + if !parent.is_dir() { + return Err("backup destination parent directory does not exist".into()); + } + reject_symlink_components(path)?; + if path.exists() { + return Err("backup destination already exists; choose a new snapshot path".into()); + } + Ok(()) +} + +fn validate_snapshot_file(path: &Path, must_exist: bool) -> Result { + validate_absolute_path(path)?; + validate_extension(path)?; + reject_symlink_components(path)?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !must_exist => { + return Err("backup command did not create a snapshot".into()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err("selected backup snapshot does not exist".into()); + } + Err(error) => return Err(format!("inspect backup snapshot: {error}")), + }; + if metadata.file_type().is_symlink() { + return Err("backup snapshot must not be a symlink".into()); + } + if !metadata.is_file() { + return Err("backup snapshot must be a regular file".into()); + } + if metadata.len() == 0 { + return Err("backup snapshot is empty".into()); + } + if metadata.len() > MAX_BACKUP_BYTES { + return Err(format!( + "backup snapshot exceeds the {MAX_BACKUP_BYTES} byte Desktop limit" + )); + } + Ok(metadata.len()) +} + +fn validate_absolute_path(path: &Path) -> Result<(), String> { + if !path.is_absolute() + || path.parent().is_none() + || path.parent().is_none_or(|parent| parent.parent().is_none()) + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) + { + return Err("backup paths require an absolute non-root path".into()); + } + Ok(()) +} + +fn validate_extension(path: &Path) -> Result<(), String> { + if path.extension().and_then(|value| value.to_str()) != Some("sqlite3") { + return Err("backup paths must use a .sqlite3 file".into()); + } + Ok(()) +} + +fn reject_symlink_components(path: &Path) -> Result<(), String> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "refusing symlinked backup path component {}", + current.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => return Err(format!("inspect backup path: {error}")), + } + } + Ok(()) +} + +fn ensure_services_stopped(report: &services::ServiceReport) -> Result<(), String> { + if !report.supported { + return Err("restore requires supported local service control".into()); + } + let running = report + .services + .iter() + .filter(|service| { + matches!( + service.name.as_str(), + "embedding" | "server" | "sync" | "backup" + ) && service_is_running(service) + }) + .map(|service| service.name.as_str()) + .collect::>(); + if running.is_empty() { + Ok(()) + } else { + Err(format!( + "stop Cortana services before restore: {}", + running.join(", ") + )) + } +} + +fn service_is_running(service: &services::ServiceStatus) -> bool { + service.state.as_deref() == Some("running") || (service.loaded && service.state.is_none()) +} + +async fn sidecar_output(app: &AppHandle, args: &[String]) -> Result { + let command = app + .shell() + .sidecar("cortana") + .map_err(|error| format!("locate bundled Cortana runtime: {error}"))? + .args(args) + .env("CORTANA_DESKTOP_PROCESS_GROUP", "1") + .set_raw_out(true); + let (mut receiver, child) = command + .spawn() + .map_err(|error| format!("run bundled Cortana runtime: {error}"))?; + match timeout(COMMAND_TIMEOUT, async { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut success = false; + while let Some(event) = receiver.recv().await { + match event { + CommandEvent::Stdout(bytes) => append_bounded(&mut stdout, &bytes), + CommandEvent::Stderr(bytes) => append_bounded(&mut stderr, &bytes), + CommandEvent::Error(error) => { + return Err(format!("run bundled Cortana runtime: {error}")); + } + CommandEvent::Terminated(payload) => { + success = payload.code == Some(0); + break; + } + _ => {} + } + } + Ok(SidecarOutput { + success, + stdout, + stderr, + }) + }) + .await + { + Ok(result) => result, + Err(_) => { + terminate_process_group(child); + Err("Cortana database command timed out".into()) + } + } +} + +struct SidecarOutput { + success: bool, + stdout: Vec, + stderr: Vec, +} + +fn append_bounded(buffer: &mut Vec, bytes: &[u8]) { + let remaining = MAX_OUTPUT_BYTES.saturating_sub(buffer.len()); + buffer.extend_from_slice(&bytes[..bytes.len().min(remaining)]); +} + +fn terminate_process_group(child: tauri_plugin_shell::process::CommandChild) { + #[cfg(unix)] + { + let pid = child.pid(); + if pid > 0 && pid <= i32::MAX as u32 { + let _ = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) }; + } + } + let _ = child.kill(); +} + +fn bounded_output(bytes: &[u8]) -> String { + bounded_text(bytes, MAX_DETAIL_BYTES) +} + +fn bounded_error(bytes: &[u8]) -> String { + let value = bounded_text(bytes, MAX_DETAIL_BYTES); + if value.is_empty() { + "Cortana database command failed".into() + } else { + value + } +} + +fn bounded_text(bytes: &[u8], max_bytes: usize) -> String { + let end = bytes.len().min(max_bytes); + String::from_utf8_lossy(&bytes[..end]) + .chars() + .filter(|character| *character == '\n' || *character == '\t' || !character.is_control()) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn audit( + config_path: &Path, + action: &str, + path: &Path, + outcome: &str, + bytes: Option, + detail: Option<&str>, +) { + let event = serde_json::json!({ + "at_unix_seconds": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + "event": format!("desktop.database.{action}"), + "action": action, + "path": path.display().to_string(), + "bytes": bytes, + "outcome": outcome, + "detail": detail.map(|value| bounded_text(value.as_bytes(), MAX_DETAIL_BYTES)), + "secret_values_recorded": false, + }); + let _ = settings::append_audit_event(config_path, &event); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn report(supported: bool, running: &[&str]) -> services::ServiceReport { + services::ServiceReport { + platform: "test".into(), + supported, + services: ["embedding", "server", "sync", "backup"] + .into_iter() + .map(|name| services::ServiceStatus { + name: name.into(), + label: name.into(), + installed: true, + loaded: running.contains(&name), + state: running.contains(&name).then(|| "running".into()), + pid: None, + last_exit_status: None, + }) + .collect(), + } + } + + #[test] + fn backup_paths_require_regular_sqlite3_paths_and_reject_symlinks() { + let temp = tempfile::tempdir().expect("temp directory"); + // macOS exposes `/var` and `/tmp` as symlinks; validate the physical + // temp root so this fixture exercises the selected path itself rather + // than rejecting an OS-owned parent alias. + let root = temp + .path() + .canonicalize() + .expect("canonical temp directory"); + let destination = root.join("backup.sqlite3"); + assert!(validate_export_path(&destination).is_ok()); + assert!(validate_export_path(&root.join("backup.db")).is_err()); + assert!(validate_export_path(Path::new("relative.sqlite3")).is_err()); + + let target = root.join("existing.sqlite3"); + fs::write(&target, b"snapshot").expect("snapshot"); + assert!(validate_export_path(&target).is_err()); + assert_eq!( + validate_snapshot_file(&target, true).expect("valid snapshot"), + 8 + ); + + #[cfg(unix)] + { + let link = root.join("link.sqlite3"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + assert!(validate_snapshot_file(&link, true).is_err()); + } + } + + #[test] + fn restore_requires_supported_and_stopped_services() { + assert!(ensure_services_stopped(&report(false, &[])).is_err()); + assert!(ensure_services_stopped(&report(true, &[])).is_ok()); + let error = ensure_services_stopped(&report(true, &["server"])) + .expect_err("running core service must block restore"); + assert!(error.contains("server")); + let error = ensure_services_stopped(&report(true, &["sync"])) + .expect_err("running sync must block restore"); + assert!(error.contains("sync")); + + let mut loaded_not_running = report(true, &[]); + let backup = loaded_not_running + .services + .iter_mut() + .find(|service| service.name == "backup") + .expect("backup service"); + backup.loaded = true; + backup.state = Some("not running".into()); + assert!(ensure_services_stopped(&loaded_not_running).is_ok()); + } + + #[test] + fn sidecar_output_and_audit_detail_are_bounded() { + let mut output = Vec::new(); + append_bounded(&mut output, &vec![b'x'; MAX_OUTPUT_BYTES + 1]); + assert_eq!(output.len(), MAX_OUTPUT_BYTES); + assert!(bounded_output(&vec![b'x'; MAX_DETAIL_BYTES + 100]).len() <= MAX_DETAIL_BYTES); + let event = + json!({"detail": bounded_text(&vec![b'x'; MAX_DETAIL_BYTES + 100], MAX_DETAIL_BYTES)}); + assert!(event["detail"].as_str().unwrap().len() <= MAX_DETAIL_BYTES); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bbc88226..ce2cdd9f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ use tauri::{ }; use tauri_plugin_autostart::ManagerExt; +mod backups; mod hindsight; mod honcho; mod installer; @@ -630,6 +631,22 @@ async fn desktop_services_action_all( services::action_all(&app, &action, approved).await } +#[tauri::command] +async fn desktop_database_backup( + app: AppHandle, + approved: bool, +) -> Result, String> { + backups::backup(&app, approved).await +} + +#[tauri::command] +async fn desktop_database_restore( + app: AppHandle, + approved: bool, +) -> Result, String> { + backups::restore(&app, approved).await +} + #[tauri::command] fn desktop_update_status(updater: State<'_, updater::UpdaterState>) -> updater::UpdateSnapshot { updater.status() @@ -1145,6 +1162,8 @@ pub fn run() { desktop_honcho_status, desktop_service_action, desktop_services_action_all, + desktop_database_backup, + desktop_database_restore, desktop_update_status, desktop_update_check, desktop_update_install, diff --git a/apps/desktop/src-tauri/src/paths.rs b/apps/desktop/src-tauri/src/paths.rs index 494a87b0..5e734ac3 100644 --- a/apps/desktop/src-tauri/src/paths.rs +++ b/apps/desktop/src-tauri/src/paths.rs @@ -60,6 +60,19 @@ pub async fn pick(app: AppHandle, kind: &str) -> Result, String> .pick_file(move |path| { let _ = sender.send(path); }), + "backup-export" => dialog + .set_title("Export a verified Cortana database backup") + .set_file_name("cortana-backup.sqlite3") + .add_filter("Cortana database backup", &["sqlite3"]) + .save_file(move |path| { + let _ = sender.send(path); + }), + "backup-import" => dialog + .set_title("Restore a Cortana database backup") + .add_filter("Cortana database backup", &["sqlite3"]) + .pick_file(move |path| { + let _ = sender.send(path); + }), _ => return Err("unsupported native path picker".into()), } let selected = receiver diff --git a/apps/web/src/App.desktop.test.tsx b/apps/web/src/App.desktop.test.tsx index 00ed704d..ccfc16b4 100644 --- a/apps/web/src/App.desktop.test.tsx +++ b/apps/web/src/App.desktop.test.tsx @@ -12,6 +12,7 @@ import { import type { AuditEvent, DesktopServiceReport, + DesktopDatabaseActionResult, DesktopSettings, DesktopPortableSettings, DesktopSettingsExport, @@ -41,6 +42,20 @@ afterEach(() => { state.lastSettingsUpdate = null state.exportDesktopSettingsCalls = 0 state.importDesktopSettingsCalls = 0 + state.databaseBackupCalls = 0 + state.databaseRestoreCalls = 0 + state.databaseBackupResult = { + action: 'backup', + path: '/tmp/cortana-backup.sqlite3', + bytes: 4096, + detail: 'backup verified', + } + state.databaseRestoreResult = { + action: 'restore', + path: '/tmp/cortana-backup.sqlite3', + bytes: 4096, + detail: 'database restored', + } state.exportDesktopSettingsResult = { path: '/tmp/cortana-settings.toml', format_version: 2, @@ -167,6 +182,20 @@ const state = { preserved_external_sources: [], settings: buildImportedSettings('/tmp/imported-runtime-dir'), } as DesktopSettingsImport, + databaseBackupCalls: 0, + databaseRestoreCalls: 0, + databaseBackupResult: { + action: 'backup', + path: '/tmp/cortana-backup.sqlite3', + bytes: 4096, + detail: 'backup verified', + } as DesktopDatabaseActionResult, + databaseRestoreResult: { + action: 'restore', + path: '/tmp/cortana-backup.sqlite3', + bytes: 4096, + detail: 'database restored', + } as DesktopDatabaseActionResult, serviceInstallCalls: 0, serviceSyncInstallCalls: 0, schedule: { sync_interval_seconds: 900, backup_interval_seconds: 86400 }, @@ -360,6 +389,14 @@ mock.module('./api', () => ({ state.importDesktopSettingsCalls += 1 return Promise.resolve(state.importDesktopSettingsResult) }, + backupDesktopDatabase: () => { + state.databaseBackupCalls += 1 + return Promise.resolve(state.databaseBackupResult) + }, + restoreDesktopDatabase: () => { + state.databaseRestoreCalls += 1 + return Promise.resolve(state.databaseRestoreResult) + }, scanDesktopReadiness: () => state.readinessScan ? state.readinessScan() @@ -1779,6 +1816,74 @@ test('services settings reuses the shell service snapshot without a duplicate po expect(state.getDesktopServicesCalls).toBe(1) }) +test('services settings exports a verified database backup with explicit confirmation', async () => { + const originalConfirm = window.confirm + window.confirm = () => true + state.databaseBackupCalls = 0 + try { + render() + await waitFor(() => expect(screen.getByLabelText('Search your knowledge')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Settings' })).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Services' })) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Services' })).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Backup database' })) + await waitFor(() => expect(state.databaseBackupCalls).toBe(1)) + expect( + screen.getByText(/Verified backup exported to \/tmp\/cortana-backup\.sqlite3/) + ).toBeTruthy() + expect(screen.getByText(/4096 bytes/)).toBeTruthy() + } finally { + window.confirm = originalConfirm + } +}) + +test('services settings permits restore with an installed but idle backup job and blocks running core services', async () => { + const originalConfirm = window.confirm + const originalServices = serviceReport.services.map((service) => ({ ...service })) + window.confirm = () => true + state.databaseRestoreCalls = 0 + serviceReport.services[3] = { + ...serviceReport.services[3], + installed: true, + loaded: true, + state: 'not running', + } + try { + render() + await waitFor(() => expect(screen.getByLabelText('Search your knowledge')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Settings' })).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Services' })) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Services' })).toBeTruthy()) + + const restore = screen.getByRole('button', { name: 'Restore database' }) + expect(restore.hasAttribute('disabled')).toBe(false) + fireEvent.click(restore) + await waitFor(() => expect(state.databaseRestoreCalls).toBe(1)) + expect(screen.getByText(/Database restored to \/tmp\/cortana-backup\.sqlite3/)).toBeTruthy() + + serviceReport.services[1] = { + ...serviceReport.services[1], + installed: true, + loaded: true, + state: 'running', + } + fireEvent.click(screen.getByRole('button', { name: 'Readiness' })) + fireEvent.click(screen.getByRole('button', { name: 'Services' })) + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Restore database' }).hasAttribute('disabled') + ).toBe(true) + ) + expect(state.databaseRestoreCalls).toBe(1) + } finally { + serviceReport.services.splice(0, serviceReport.services.length, ...originalServices) + window.confirm = originalConfirm + } +}) + test('settings view reuses the shell settings snapshot without a duplicate read', async () => { render() await waitFor(() => expect(screen.getByLabelText('Search your knowledge')).toBeTruthy()) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8eab9941..6aad7b8b 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -18,6 +18,7 @@ import type { DesktopHonchoStatus, DesktopReadiness, DesktopServiceReport, + DesktopDatabaseActionResult, DesktopSchedule, DesktopSettings, DesktopSettingsExport, @@ -140,6 +141,20 @@ export async function runDesktopServicesActionAll( }) } +export async function backupDesktopDatabase(): Promise { + if (!isDesktopApp) throw new Error('Database backup is available in Cortana Desktop') + return invokeDesktop('desktop_database_backup', { + approved: true, + }) +} + +export async function restoreDesktopDatabase(): Promise { + if (!isDesktopApp) throw new Error('Database restore is available in Cortana Desktop') + return invokeDesktop('desktop_database_restore', { + approved: true, + }) +} + export async function getDesktopUpdate(): Promise { if (!isDesktopApp) throw new Error('Updates are available in Cortana Desktop') return invokeDesktop('desktop_update_status') diff --git a/apps/web/src/components/SettingsView.tsx b/apps/web/src/components/SettingsView.tsx index 61e16c4c..ae4351eb 100644 --- a/apps/web/src/components/SettingsView.tsx +++ b/apps/web/src/components/SettingsView.tsx @@ -68,6 +68,8 @@ import { startDesktopSourceValidation, runDesktopServiceAction, runDesktopServicesActionAll, + backupDesktopDatabase, + restoreDesktopDatabase, } from '../api' import { buildSetupSteps } from '../setup' import { INITIAL_SYNC_BUDGETS, type ProviderModelKind, type ProviderModelEntry } from '../types' @@ -82,6 +84,7 @@ import type { DesktopReadinessActivity, DesktopServiceActivity, DesktopServiceReport, + DesktopDatabaseActionResult, DesktopSchedule, DesktopSettings, DesktopSettingsUpdate, @@ -999,6 +1002,9 @@ function ServicesSection({ const [scheduleError, setScheduleError] = useState('') const [scheduleSaving, setScheduleSaving] = useState(false) const [scheduleApplyPending, setScheduleApplyPending] = useState(false) + const [databaseBusy, setDatabaseBusy] = useState<'backup' | 'restore' | ''>('') + const [databaseResult, setDatabaseResult] = useState(null) + const [databaseError, setDatabaseError] = useState('') const error = localError || externalServicesError || '' const refreshInFlightRef = useRef(false) const actionInFlightRef = useRef(false) @@ -1095,6 +1101,9 @@ function ServicesSection({ } } + const serviceIsRunning = (service: DesktopServiceReport['services'][number]) => + service.state === 'running' || (service.loaded && service.state === null) + const serviceAction = async ( service: DesktopServiceReport['services'][number], action: 'start' | 'stop' | 'restart' @@ -1200,6 +1209,35 @@ function ServicesSection({ } } + const databaseAction = async (action: 'backup' | 'restore') => { + if (dirty) { + setDatabaseError('Save or discard draft changes before using database recovery.') + return + } + const activeServices = report?.services.filter((service) => serviceIsRunning(service)) ?? [] + if (action === 'restore' && (report?.supported !== true || activeServices.length > 0)) { + setDatabaseError('Stop all Cortana services before restoring a database snapshot.') + return + } + const confirmation = + action === 'backup' + ? 'Export a verified Cortana database snapshot?\n\nThe native picker will choose a new .sqlite3 file. No sync or service is started.' + : 'Restore this Cortana database snapshot?\n\nThis replaces the active index and keeps a pre-restore recovery copy. All Cortana services must already be stopped. No sync is run.' + if (!window.confirm(confirmation)) return + setDatabaseBusy(action) + setDatabaseResult(null) + setDatabaseError('') + try { + const result = + action === 'backup' ? await backupDesktopDatabase() : await restoreDesktopDatabase() + if (result) setDatabaseResult(result) + } catch (caught) { + setDatabaseError(caught instanceof Error ? caught.message : `Database ${action} failed`) + } finally { + setDatabaseBusy('') + } + } + const install = async () => { if ( !window.confirm( @@ -1330,7 +1368,8 @@ function ServicesSection({ report?.supported === true && report.services.some((service) => service.name === 'sync' && !service.installed) const syncScheduleNeedsApply = needsSyncInstall || scheduleApplyPending - const actionInFlight = Boolean(busy) || serviceActivity?.status === 'running' + const actionInFlight = + Boolean(busy) || Boolean(databaseBusy) || serviceActivity?.status === 'running' const actionMessage = serviceActivity ? `${serviceActivity.action === 'install' ? 'Install' : serviceActivity.action[0].toUpperCase() + serviceActivity.action.slice(1)} ${serviceActivity.target}${serviceActivity.status === 'running' ? ' in progress…' : serviceActivity.status === 'succeeded' ? ' completed.' : ` failed: ${serviceActivity.detail || 'unknown error'}`}` : '' @@ -1487,6 +1526,66 @@ function ServicesSection({ )} +
+
+ Database recovery +

+ Export a verified SQLite snapshot or restore one into the active index. Restore is + blocked while any Cortana service is running and never starts recurring sync. +

+
+
+ + +
+
+ {(databaseResult || databaseError) && ( +
+ {databaseError ? : } + + {databaseError || + `${databaseResult?.action === 'backup' ? 'Verified backup exported' : 'Database restored'} to ${databaseResult?.path} (${databaseResult?.bytes} bytes).`} + +
+ )}
{report?.services.map((service) => { const running = service.loaded && service.state === 'running' diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index f78f3212..c96178fa 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -537,6 +537,13 @@ export type DesktopServiceReport = { }> } +export type DesktopDatabaseActionResult = { + action: 'backup' | 'restore' + path: string + bytes: number + detail: string +} + export type DesktopSchedule = { sync_interval_seconds: number backup_interval_seconds: number