Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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