Repository navigation
Copy the bundled embedding model into the daemon's model directory on launch - #3611
Conversation
Pragmatic Code Review — PR #3611Review Type: Initial Review Scope: core PR #3611 ( Requirements Check
Code Review FindingsNo 🔴 or 🟡 findings. All four are optional. 🟢 S1: Windows: two plausible ways the copy fails on every launch
🟢 S2: The no-overwrite guarantee has a small check-then-act window
🟢 S3: Stale-temp cleanup contradicts the pid comment (Nit:)
🟢 S4: The rotation comments still say "both" (Nit:)
Docs PR #103 findingsChecked against the code, all accurate:
The ADR-045 amendment follows the existing convention: a status-line note as in ADR-065, and a 🟢 D1: Say that the app reads
Nit D2: ADR-045 amendment wording
Follow-upsThe PR body lists two out-of-scope follow-ups, and I found no open issue for either:
Worth filing them at merge time so they don't stay only in this PR's description. What's good
RecommendationAPPROVE Correct, well-scoped and well-tested, and a net positive: fresh installs get semantic search instead of carrying 146 MB of dead weight. All findings are optional. S1 is the one most worth doing before the first Windows release that includes this. 🤖 Generated with Claude Code |
mstomar125
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review: APPROVE (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.
Address review: response to the pragmatic review (round 1)Code fixes are in ✅ S1.1 🟢: Windows read-only attribute
⏭️ S1.2 🟢: rename retry on Windows (skipped)
⏭️ S2 🟢:
|
Pragmatic Code Re-Review — PR #3611 (round 2)Review Type: Re-Review Scope. Only the commits since round 1:
Both are confirmed as the PRs' current heads. I reviewed from the code; nothing was built or run, per the shared-machine rules. Previous Review Summary
The steps now run copy → flush → hash → rename. That changes nothing for correctness: the hash reads the page cache either way. The module doc, the Code Review Findings (new commits only)No 🔴 or 🟡 findings. 🟢 T1: Nothing tests the macOS copy from a read-only bundle
Checked, no issues:
Docs PR #103 (
|
mstomar125
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review (round 2): APPROVE (posted as a comment — GitHub blocks self-approval). Full findings in the re-review comment above.
Address review: response to the re-review (round 2)Round 2 was APPROVE, with one optional finding. It is addressed in ✅ T1 🟢: the macOS copy path from a read-only bundleNew test
Verification (head
|
… launch The app bundle carries nomic-embed-text-v1.5.Q8_0.gguf under resources/models/, but the daemon only looks in models/ under its state directory, so a fresh install ran with semantic search off. New app-lib module bundled_model: before the startup task starts or reconnects to the daemon, provision_bundled_model resolves the bundled file through Tauri's resource directory and the target through the daemon's home rule (NODESPACE_HOME, else the user's home). When nothing is at the target it copies the file to a pid-named temporary file beside it, checks the copy against the pinned SHA-256, fsyncs it and renames it into place. Anything already at the target, a broken link included, is kept; a failure removes the temporary file, is logged, and never stops startup. Temporary files from a copy whose process died are removed by the next copy. The digest is repeated from nlp-engine's pin (the app does not link nlp-engine); tests tie it, the file name and the bundle path to nlp-engine, the daemon's lookup, release.yml and tauri.conf.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate the commit window - Off macOS the bundled model is copied by creating the temporary file and streaming into it, flushing through that handle. fs::copy on Windows carries the installed file's read-only attribute over, and Windows flushes only through a handle opened for writing. macOS keeps fs::copy, which clones on APFS, and flushes through a read-only descriptor. - commit's doc states the check-then-rename window and why it is harmless. - temp_path's doc no longer claims the pid keeps concurrent copies off each other's file; the stale-file cleanup can remove a live one. - The digest rotation comments in nlp-engine's config and download-models.ts name the app's copy too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rm's path On macOS the clone keeps the bundle's read-only mode, so clone_to_temp must flush without opening for writing; nothing tested that on the path that ships. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41da40c to
382ba5c
Compare
Closes #2781 (Part B; Part A landed in #3601).
The macOS and Windows installers ship
nomic-embed-text-v1.5.Q8_0.ggufin the app bundle underresources/models/, but nothing read it. The daemon only looks inmodels/under its state directory, so every fresh install ran with semantic search off. This PR implements option 1 from the Part B findings: on launch the app copies its bundled model into that directory when nothing is there yet.Summary
New module
packages/desktop-app/app-lib/src/bundled_model.rs, called once from the startup task inlib.rs. The call sits after thedaemon-status: startingemit and beforeensure_daemon_running, because the daemon looks for the model only when it starts.provision_bundled_model(app)never fails, and it runs the file work inspawn_blocking. It:BaseDirectory::Resource(resources/models/<file>);<home>/.nodespace/models/<file>with the daemon's home rule:NODESPACE_HOMEwhen set (read withstd::env::var, as the daemon does), else the user's home;symlink_metadata, so even a broken link or a file that fails verification is left alone;<file>.tmp-<pid>beside the target andfsyncs it:std::fs::copy, which clones on APFS;fs::copywould carry the installed file's read-only attribute over, and Windows flushes only through a handle opened for writing;fsyncs the directory. The check and the rename are two steps, so something written in the microseconds between them would be replaced. The only writer expected there is another launch's copy of the same verified bytes.Any failure removes the temp file and is logged as a warning. The daemon then starts with semantic search off, and the next launch retries. Temp files left by a copy whose process died are removed by the next copy.
Integrity. The copy must pass the same pinned digest the daemon checks (ADR-058). The app never replaces an existing model, so if it placed a bad file, that file would stay there for good. The daemon still verifies at load: on first load it re-hashes the copy and writes its
<model>.verified.jsonnext to it in~/.nodespace/models/. Nothing writes inside the signed bundle.Pinned values are repeated, with drift tests.
nodespace-nlp-engineowns the digest. Making it a normal dependency of app-lib, even withdefault-features = false(no llama.cpp), puts it undercargo clippy -p nodespace-app-lib -- -D warnings, because that also lints workspace-member dependencies, and nlp-engine has 13 dead-code warnings without its features. So the digest and file name are repeated as private constants. Tests read the other sources and fail if any of these drift:EMBEDDING_MODEL_SHA256;assembly.rs;gh release download … resources/modelsline inrelease.yml;resources/models/**/*entry intauri.conf.json.sha2(0.10.9, already in the lockfile) is the only new dependency.When the daemon picks the model up. On an install's first launch,
ensure_daemon_runningextracts the daemon sidecar into~/.nodespace/bin/and starts or restarts the daemon, so the copy is in place before that daemon starts. That includes the.pkg, whose postinstall starts a daemon before the app first runs. If a copy happens while a healthy daemon with an unchanged binary keeps running, that daemon loads the model at its next start.Tests
.tools/bin/cargo-nextest nextest run -p nodespace-app-lib. All the new tests are inbundled_model::testsand use tiny placeholder files with their own digest. No model is loaded and nothing touches the GPU.copies_the_bundled_model_when_none_is_presentCopied; target bytes equal the bundle; the model dir holds only the modelan_existing_model_is_never_overwrittenAlreadyPresent; bytes unchangeda_broken_link_at_the_target_is_kept(unix)AlreadyPresent; still the same linka_model_that_appears_during_the_copy_is_keptcopy_verifiedcalled with a file already at the target (a copy that started before it appeared)AlreadyPresent; bytes unchanged; no temp filea_failed_copy_leaves_no_partial_filethe_streamed_copy_is_writable_even_from_a_read_only_bundle(added in review)stream_to_temp, the copy used off macOS, from a read-only bundlea_read_only_bundle_is_copied(added in review round 2)provision(the clone path on macOS)Copied; same bytesa_dead_copys_temporary_file_is_removed<file>.tmp-1in the model dirCopied; only the model remainsa_missing_bundled_model_is_a_no_opNoBundledModel; nothing created in the homenodespace_home_wins_over_the_user_homeNODESPACE_HOMEfirst, then the user homethe_copy_follows_nodespace_homeNODESPACE_HOMEandHOMEset to two scratch dirs (restored before asserting)NODESPACE_HOME; the stand-in user home stays emptythe_target_is_the_daemons_model_path<home>/.nodespace/models/nomic-embed-text-v1.5.Q8_0.ggufthe_pinned_digest_matches_nlp_enginesnlp-engine/src/config.rsthe_file_name_matches_the_daemon_and_the_releaseassembly.rs,release.yml,tauri.conf.jsonresources/models/**/*is bundledstartup_copies_the_model_before_it_starts_the_daemonlib.rsensure_daemon_runningMutation checks
Each mutation was applied on its own, the module's 13 tests were run, and the file was restored from git.
entry_existsalways falsean_existing_model_is_never_overwritten,a_broken_link_at_the_target_is_kept,a_model_that_appears_during_the_copy_is_keptcommitdrops its re-checka_model_that_appears_during_the_copy_is_keptsymlink_metadata→metadata(follow links)a_broken_link_at_the_target_is_kepta_failed_copy_leaves_no_partial_file,a_model_that_appears_during_the_copy_is_kepta_failed_copy_leaves_no_partial_filea_dead_copys_temporary_file_is_removedcopies_the_bundled_model_when_none_is_present,a_dead_copys_temporary_file_is_removed,the_copy_follows_nodespace_homea_missing_bundled_model_is_a_no_opNODESPACE_HOMEnodespace_home_wins_over_the_user_home,the_copy_follows_nodespace_homethe_target_is_the_daemons_model_paththe_pinned_digest_matches_nlp_engines.f16.gguf)the_file_name_matches_the_daemon_and_the_release,the_target_is_the_daemons_model_pathlib.rsstartup_copies_the_model_before_it_starts_the_daemonstream_to_tempgoes back tofs::copyand reopens for writingthe_streamed_copy_is_writable_even_from_a_read_only_bundle| M15 (review round 2)
clone_to_tempreopens for writing |a_read_only_bundle_is_copied|All 14 mutations were re-run on the review-round code (head
22ecaa90), with the same results. M15 was checked on41da40c8. Every one of the 15 tests fails under at least one mutation.Checks
cargo clippy -p nodespace-app-lib --all-targets -- -D warnings -D clippy::unused-async: clean.cargo fmt -p nodespace-app-lib --check: clean.bun run scripts/check-pro-boundary.ts: clean. The push check (lint) passed.nextest run -p nodespace-app-lib, excluding the two files that load the real Gemma chat model onto the GPU (ai_chat_send_to_idle_test,model_download_terminal_state_test): 374 passed, 6 skipped (head22ecaa90).HOMEandNODESPACE_HOME, withCARGO_HOMEandRUSTUP_HOMEpinned to the real toolchain.nodespacedthroughNODESPACED_TEST_BIN. It was built from the Resolve the embedding model under NODESPACE_HOME #3601 stack, whose daemon, agent, core, nlp-engine, types and proto sources match this branch's base8827dc82, so llama.cpp wasn't rebuilt. The only daemon-side change since then is this PR's comment innlp-engine/src/config.rs.skill_setupfailures were this fresh worktree missingpackages/skill/dist/install.js. Afterbun run --cwd packages/skill build, all 27skill_setuptests passed.bun run test:changedandbun run mergewere not run. Both load the real embedding model.Not verified
std::fsthroughout. The Windows-only branches usecfg!(), not#[cfg], so they are type-checked on macOS too:sync_diris skipped, becauseFile::opencan't open a directory there.Not checked on a real install:
resources\models\next to the exe);/Applicationsand launching it against the real~/.nodespace, which is off-limits on this shared machine. The resource path matchesskill_setup.rs's existingresources/skillresolution, which works in shipped builds.Out of scope (follow-ups worth filing)
release.ymldownloads the model withgh release downloadand checks no digest (:100-108,:343-351). ADR-058's build-time check lives inscripts/download-models.ts, which the release doesn't use. A corrupt bundle is now caught at copy time and never placed, but the release should fail instead of shipping it.ensure_daemon_running:bin/,logs/,database/, socket) and the launchd plist ignoreNODESPACE_HOME. This module follows the daemon's rule, so an app run withNODESPACE_HOMEset writes the model only under that home.Documentation
models/undernodespace_dir()(Part A's docs) and is unchanged.🤖 Generated with Claude Code