Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ cairn.db-wal

# test mock vaults
/tests/fixtures/obsidian-vault/
scripts/runtime-baselines/
35 changes: 35 additions & 0 deletions changelogs/v2.1.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## What's new

### Features

- **Splash screen with boot sequence**: New `BootSplash` window shown immediately on launch while the main window is created hidden (`show: false`). A multi-step boot sequence runs before the main interface appears: (1) auto-updater check, (2) workspace migrations, (3) embeddings reindex, (4) notes sync, (5) semantic relationship computation. Each step reports live progress (label + detail + percent) to the splash UI. The main window only shows after the renderer has painted and a minimum 3-second display time has elapsed. Migration and reindex modals previously shown inside the app are now handled during boot — removed from `page.tsx`.

- **Auto-updater with live download progress**: The boot sequence checks for updates *before* the main window opens, so a known-broken build can heal itself via auto-update without ever loading the crashing renderer code. When an update is found, the 172 MB download shows live progress on the splash: `84.3 / 172.9 MB (12.4 MB/s)` with a moving progress bar (15–95% range). After download completes, `quitAndInstall()` restarts the app automatically. Timeout is 300s (5 min) for slow connections; on timeout or network failure, the boot continues with the existing version rather than leaving the user stuck.

- **Unified runtime architecture**: Replaced the separate embeddings-server and llama-server child processes with a single `runtime-server` process spawned by Electron on boot. The runtime-server hosts both adapters behind one HTTP API on a single port: embeddings run in-process via `@huggingface/transformers` + `onnxruntime-node`, while the LLM adapter spawns and proxies to `llama-server` as a grandchild. A new adapter interface (`RuntimeAdapter` / `ModelManagingAdapter` in `electron/runtime/adapters/types.ts`) makes future runtimes (e.g. MLX on Apple Silicon) a drop-in addition with zero Electron or IPC changes. The MCP server auto-discovers the runtime via `runtime-port.json` (with pid liveness check to reject stale ports) and can self-spawn the runtime in dev mode when Cairn isn't running.

- **Runtime auto-start on boot**: `boot-sequence.ts` calls `runtime.ensureStarted()` when embeddings are enabled, so the unified runtime is ready for search and semantic features immediately on launch — no lazy-start latency on the first search.

- **SHA256 model verification**: Every model download (LLM GGUFs and embedding ONNX) streams to a `.tmp` file, verifies against the HuggingFace LFS SHA256 checksum, then atomically renames on success. A `manifest.json` per models directory tracks `status`, `downloadProgress`, `verifiedAt`, and `error` per entry. `migrateManifest()` runs on every runtime startup, marking stale entries and backfilling `verifiedAt` for pre-existing files. `verifyOnDisk()` is called from `listModels` / `isModelInstalled` so a corrupted file is detected before inference, not during.

### Changes

- **`runtime:*` IPC channel namespace**: New unified IPC surface in `electron/ipc/runtime-handlers.ts` with full preload API (`window.electron.runtime.{onProgress, embeddings.*, llm.*}`): model install/remove/setDefault, status probes, progress streaming, LLM binary install/clearInactive/setDefault. Legacy `embeddings:*` / `llama:*` channels retained for DB-touching operations (reindex, search, projections, recompute).

- **`defaultEmbed` routes through the unified runtime**: `electron/embeddings/service.ts` now calls `runtime.embed()` instead of the legacy embeddings-server client, so every embedding inference in the app goes through the single runtime port automatically.

- **MCP `search_notes_semantic` uses the runtime**: The MCP tool now embeds queries via `embedViaRuntime()` in `electron/runtime/port-discovery.ts`, with graceful self-spawn fallback in dev mode. SIGTERM/SIGINT handlers in `mcp-server.ts` call `disposeSpawnedRuntime()` for clean shutdown.

- **UI migrated to `runtime:*` channels**: `AISettings.tsx`, `EmbeddingsSettings.tsx`, `StepEmbeddings.tsx`, `onboarding/index.tsx`, `ReindexModal.tsx`, `search-panel.tsx`, and `SemanticMapCanvas.tsx` all use the new `window.electron.runtime.*` API. Enabling embeddings in settings now calls `runtime.embeddings.ensureStarted()` instead of just saving settings.

- **Packaged app ships runtime dependencies**: `electron-builder.yml` now ships `@huggingface/transformers`, `onnxruntime-common`, `sharp` (stub), and `umap-js` inside the app asar, with `onnxruntime-node` unpacked from asar (via `asarUnpack`) so its native `.node` binding can load. The runtime-server bundle (`dist-electron/runtime-server.bundle.js`) runs as an `ELECTRON_RUN_AS_NODE=1` child of the Electron binary, so no separate Node.js runtime is needed. Updated `electron/bundle-guard.test.ts` with a `RUNTIME_SHIPPED` classification that verifies these packages are present in `electron-builder.yml` but never leak into `main.js` requires. `scripts/build.js` now builds `runtime-server.bundle.js` as part of the standard compile step.

## Baseline (packaged macOS app, M5 Pro, 64 GB RAM)

| State | Pre-Refactor (v2.1.6) | Post-Refactor (v2.1.7) | Delta |
|-------|----------------------|------------------------|-------|
| Idle | 719 MB | 877 MB | +158 MB |
| Embeddings active | 921 MB | 967 MB | +46 MB |
| LLM + embeddings active | 8,917 MB | 8,892 MB | −25 MB |

Idle overhead is the always-on runtime-server process (103 MB). Both-active is 25 MB *less* than pre-refactor because the unified process is more memory-efficient than separate embeddings + LLM processes. See `scripts/runtime-baselines/` for raw captures.
10 changes: 9 additions & 1 deletion electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ files:
- node_modules/file-uri-to-path/**/*
# node-pty — native PTY addon, must be present at runtime
- node_modules/node-pty/**/*
# Runtime server externals — transformers.js + onnxruntime + sharp stub (in-process embeddings)
- node_modules/@huggingface/transformers/**/*
- node_modules/onnxruntime-node/**/*
- node_modules/onnxruntime-common/**/*
- node_modules/sharp/**/*
- node_modules/umap-js/**/*
- electron/sharp-stub/**/*
# Exclude everything else in node_modules
- "!node_modules/!(better-sqlite3|bindings|file-uri-to-path|electron-updater|node-pty)/**"
- "!node_modules/!(better-sqlite3|bindings|file-uri-to-path|electron-updater|node-pty|@huggingface|onnxruntime-node|onnxruntime-common|sharp|umap-js)/**"

# Unpack from asar — native .node files and anything that needs real filesystem paths
asarUnpack:
Expand All @@ -41,6 +48,7 @@ asarUnpack:
- node_modules/file-uri-to-path/**/*
- node_modules/electron-updater/**/*
- node_modules/node-pty/**/*
- node_modules/onnxruntime-node/**/*

# Rebuild native modules against Electron's Node version
npmRebuild: true
Expand Down
33 changes: 17 additions & 16 deletions electron/bundle-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const ROOT = path.resolve(__dirname, "..");
const BUNDLES = [
"dist-electron/main.js",
"dist-electron/embeddings-server.bundle.js",
"dist-electron/runtime-server.bundle.js",
] as const;

/* ──────────────────────────────────────────────────────────────────────── *
Expand All @@ -57,13 +58,13 @@ const OPTIONAL_TRANSITIVE = new Set([
]);

/**
* Used only by the embeddings-server worker subprocess, never by the Electron
* main bundle. The worker (`embeddings-server.bundle.js`) has its own runtime
* resolution path (injected via `TRANSFORMERS_CACHE` / `pkg.config.js`), so
* these packages MUST NOT be listed in electron-builder.yml either — otherwise
* we'd bloat the main app with packages no main-bundle code can resolve.
* Used by the unified runtime server subprocess, which runs as an
* ELECTRON_RUN_AS_NODE child of Electron. These packages MUST be shipped
* via electron-builder.yml because the runtime-server resolves them from
* the app's node_modules at runtime. They must NOT appear in the Electron
* main bundle requires (main.js) — only in runtime-server.bundle.js.
*/
const SUBPROCESS_ONLY = new Set([
const RUNTIME_SHIPPED = new Set([
"@huggingface/transformers",
"onnxruntime-node",
]);
Expand All @@ -78,13 +79,13 @@ const SHIPPED_NATIVE = new Set([
]);

/**
* The full allowlist. Union of the four groups above. Any `require()` in any
* The full allowlist. Union of the groups above. Any `require()` in any
* bundle that isn't in this set (or a Node built-in) fails the test.
*/
const ALLOWED_EXTERNALS = new Set<string>([
...RUNTIME_PROVIDED,
...OPTIONAL_TRANSITIVE,
...SUBPROCESS_ONLY,
...RUNTIME_SHIPPED,
...SHIPPED_NATIVE,
]);

Expand Down Expand Up @@ -212,30 +213,30 @@ describe("allowlist drift checks", () => {
).toHaveLength(0);
});

it("every SUBPROCESS_ONLY external is NOT shipped via electron-builder.yml", () => {
const leaks = [...SUBPROCESS_ONLY].filter((x) => shipped.has(x));
it("every RUNTIME_SHIPPED external ships via electron-builder.yml", () => {
const missing = [...RUNTIME_SHIPPED].filter((x) => !shipped.has(x));
expect(
leaks,
"These are classified SUBPROCESS_ONLY (used solely by the embeddings worker, which resolves them via its own node_modules/pkg runtime) but electron-builder.yml \`files\`/\`asarUnpack\` is shipping them too. Shipping them in the main Electron app is either wasted space or a sign of misclassification — they shouldn't be on main-bundle disk.\n Leaks:\n - " + leaks.join("\n - "),
missing,
"These are classified RUNTIME_SHIPPED (used by the runtime-server subprocess which runs as ELECTRON_RUN_AS_NODE) but electron-builder.yml `files`/`asarUnpack` doesn't include them. Add a `node_modules/<pkg>/**/*` entry.\n Missing:\n - " + missing.join("\n - "),
).toHaveLength(0);
});

it("SUBPROCESS_ONLY externals never appear in the Electron main bundle requires", () => {
it("RUNTIME_SHIPPED externals never appear in the Electron main bundle requires", () => {
const mainPath = path.join(ROOT, "dist-electron/main.js");
if (!fs.existsSync(mainPath)) return;
const mainReqs = new Set(extractRequires(mainPath));
const leaks = [...SUBPROCESS_ONLY].filter((x) => mainReqs.has(x));
const leaks = [...RUNTIME_SHIPPED].filter((x) => mainReqs.has(x));
expect(
leaks,
"dist-electron/main.js \`require()\`s a package marked SUBPROCESS_ONLY. These packages are not shipped with the Electron app (they only live in the embeddings worker) — the main bundle must not import them. If a main-bundle module genuinely needs them, reclassify as SHIPPED_NATIVE and verify they ship in electron-builder.yml.\n Leaks:\n - " + leaks.join("\n - "),
"dist-electron/main.js `require()`s a package marked RUNTIME_SHIPPED. These packages are only for the runtime-server subprocess — the main bundle must not import them directly. If a main-bundle module genuinely needs them, reclassify as SHIPPED_NATIVE and verify they ship in electron-builder.yml.\n Leaks:\n - " + leaks.join("\n - "),
).toHaveLength(0);
});

it("no allowlisted external is silently duplicated across groups", () => {
const groups = [
["RUNTIME_PROVIDED", RUNTIME_PROVIDED],
["OPTIONAL_TRANSITIVE", OPTIONAL_TRANSITIVE],
["SUBPROCESS_ONLY", SUBPROCESS_ONLY],
["RUNTIME_SHIPPED", RUNTIME_SHIPPED],
["SHIPPED_NATIVE", SHIPPED_NATIVE],
] as const;
const seen = new Map<string, string>();
Expand Down
6 changes: 4 additions & 2 deletions electron/embeddings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
deleteNoteEmbeddingSections,
} from "../db/queries";
import type { NoteEmbeddingRecord } from "../db/queries";
import { embed as clientEmbed } from "./client";
import { projectTo2d, normaliseProjection } from "./projection";
import { toFloat32 } from "./cosine";
import { splitIntoSections } from "./sections";
Expand All @@ -20,7 +19,10 @@ import type { EmbedTask } from "./types";

export type EmbedFn = (texts: string[], task: EmbedTask, model?: string) => Promise<number[][]>;

const defaultEmbed: EmbedFn = (texts, task, model) => clientEmbed(texts, task, model);
const defaultEmbed: EmbedFn = async (texts, task, model) => {
const { embed: runtimeEmbed } = await import("../runtime/client");
return runtimeEmbed(texts, task, model);
};

const BATCH_SIZE = 16;
const CHUNK_CHAR_LIMIT = 4000;
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { registerAiHandlers } from "./ai-handlers";
import { registerLlamaHandlers } from "./llama-handlers";
import { registerGraphHandlers } from "./graph-handlers";
import { registerEmbeddingsHandlers } from "./embeddings-handlers";
import { registerRuntimeHandlers } from "./runtime-handlers";
import { registerChatHandler } from "./chat";
import { registerPdfExportHandler } from "./pdf-export";
import { registerUrlMetadataHandler } from "./url-metadata";
Expand All @@ -60,6 +61,7 @@ export function registerIpcHandlers(ctx: DbContext): void {
registerLlamaHandlers(ctx);
registerGraphHandlers(ctx);
registerEmbeddingsHandlers(ctx);
registerRuntimeHandlers(ctx);
}

/**
Expand Down
128 changes: 128 additions & 0 deletions electron/ipc/runtime-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { registerIpcHandle } from "./registry";
import { handle, type DbContext } from "./result-helpers";
import * as runtime from "../runtime/client";
import { BrowserWindow } from "electron";

let progressForwarderSetUp = false;

function ensureProgressForwarder(getWin: () => BrowserWindow | null): void {
if (progressForwarderSetUp) return;
progressForwarderSetUp = true;
runtime.onProgress((ev) => {
const win = getWin();
if (!win || win.isDestroyed()) return;
if (ev.kind === "progress") {
win.webContents.send("runtime:download-progress", {
modelId: ev.modelId,
status: ev.status,
file: ev.file,
progress: ev.progress,
loaded: ev.loaded,
total: ev.total,
});
} else if (ev.kind === "ready") {
win.webContents.send("runtime:download-progress", {
modelId: ev.modelId,
status: "ready",
progress: 100,
});
} else if (ev.kind === "binary-progress") {
win.webContents.send("runtime:binary-progress", {
progress: ev.progress,
speed: ev.speed,
status: ev.status,
error: ev.error,
});
}
});
}

export function registerRuntimeHandlers(ctx: DbContext): void {
ensureProgressForwarder(ctx.getWin);
// ── Runtime health & lifecycle ─────────────────────────────
registerIpcHandle("runtime:status", () => handle(async () => {
return runtime.getRuntimeStatus();
}));

registerIpcHandle("runtime:stop", () => handle(async () => {
await runtime.stopRuntime({ force: true });
return { ok: true };
}));

// ── Embedding model management (via unified runtime) ────────
registerIpcHandle("runtime:embeddings:status", () => handle(() => {
return runtime.getEmbeddingsStatus();
}));

registerIpcHandle("runtime:embeddings:ensureStarted", () => handle(async () => {
await runtime.ensureStarted();
return { ok: true };
}));

registerIpcHandle("runtime:embeddings:models", () => handle(async () => {
return { models: await runtime.listEmbeddingModels() };
}));

registerIpcHandle("runtime:embeddings:install", (_e, { modelId }: { modelId: string }) => handle(async () => {
await runtime.installEmbeddingModel(modelId);
return { ok: true };
}));

registerIpcHandle("runtime:embeddings:remove", (_e, { modelId }: { modelId: string }) => handle(async () => {
await runtime.removeEmbeddingModel(modelId);
return { ok: true };
}));

registerIpcHandle("runtime:embeddings:setDefault", (_e, { modelId }: { modelId: string }) => handle(async () => {
await runtime.setDefaultEmbeddingModel(modelId);
return { ok: true };
}));

// ── LLM model management (via unified runtime) ─────────────
registerIpcHandle("runtime:llm:models", () => handle(async () => {
return { models: await runtime.listLLMModels() };
}));

registerIpcHandle("runtime:llm:install", (_e, { modelId, useMirror }: { modelId: string; useMirror?: boolean }) => handle(async () => {
await runtime.installLLMModel(modelId, useMirror);
return { ok: true };
}));

registerIpcHandle("runtime:llm:remove", (_e, { modelId }: { modelId: string }) => handle(async () => {
await runtime.removeLLMModel(modelId);
return { ok: true };
}));

registerIpcHandle("runtime:llm:start", (_e, { modelId, contextLimit }: { modelId: string; contextLimit?: number }) => handle(async () => {
const port = await runtime.startLLMServer(modelId, contextLimit);
return { port };
}));

registerIpcHandle("runtime:llm:stop", () => handle(async () => {
await runtime.stopLLMServer();
return { ok: true };
}));

registerIpcHandle("runtime:llm:status", () => handle(async () => {
return runtime.getLLMStatus();
}));

registerIpcHandle("runtime:llm:checkUpdate", () => handle(async () => {
return runtime.checkLLMBinaryUpdate();
}));

registerIpcHandle("runtime:llm:binary:install", () => handle(async () => {
await runtime.installLLMBinary();
return { ok: true };
}));

registerIpcHandle("runtime:llm:clearInactive", () => handle(async () => {
await runtime.clearInactiveLLMModels();
return { ok: true };
}));

registerIpcHandle("runtime:llm:server:setDefault", (_e, { modelId }: { modelId: string }) => handle(async () => {
await runtime.setDefaultLLMModel(modelId);
return { ok: true };
}));
}
Loading