From 99c423c0fd126b1bf73919abb4f8ddd43791aa81 Mon Sep 17 00:00:00 2001 From: karl Date: Mon, 12 Oct 2026 21:30:10 +0200 Subject: [PATCH] fix: verify complete offline assets before replacing staged runtime --- docs/ROADMAP.md | 3 ++ docs/VALIDATION.md | 6 +++ tools/embeddinggemma2/stage_runtime.py | 34 ++++++++++----- tools/embeddinggemma2/test_stage_runtime.py | 48 +++++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 tools/embeddinggemma2/test_stage_runtime.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb9ef9f..e692b31 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,6 +49,9 @@ Status: implementation started; no production release yet. Windows VM tests use - [ ] Isolated extraction workers with timeouts and resource ceilings. ## 5. Release gates +- [x] Preflight native license and the complete offline Google model pair before + modifying staged resources; corrupted/missing assets preserve existing worker. +- [ ] Atomic resource staging and interrupted-copy recovery for release bundles. - [x] Shared monotonic Basic/Debug search metrics, granular bounded engine traces, actual worker query diagnostics, and headless JSON performance output. - [ ] Verify detailed performance UI interaction on native desktop platforms; diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index ed5fdd0..9da6f92 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -7,6 +7,12 @@ and `SEARCHI_FORCE_CPU=1`. ## Current integration checkpoint (2026-10-12) +Offline staging now verifies the native license and every bundled Google asset +before modifying resources. Two Python regressions verify missing-model rejection +and same-size corruption in the second model leaving the existing staged worker +untouched. Actual CPU worker staging passed. This preflight does not prove atomic +recovery from interruptions during the subsequent copies; that gate remains open. + Gitea Actions were disabled for **karl/searchi** at Karl's explicit request; the API read-back confirmed `has_actions:false`. Fifteen old stuck runs and the subsequent repair run were cancelled. Diagnosis found unavailable native diff --git a/tools/embeddinggemma2/stage_runtime.py b/tools/embeddinggemma2/stage_runtime.py index f9aef2b..ca57c66 100644 --- a/tools/embeddinggemma2/stage_runtime.py +++ b/tools/embeddinggemma2/stage_runtime.py @@ -9,6 +9,20 @@ import shutil ROOT = Path(__file__).resolve().parents[2] +def verify_bundle(root, manifest): + """Verify the complete offline model pair before modifying staged resources.""" + artifacts = [] + for spec in manifest["files"]: + artifact = root / ".searchi/models/embeddinggemma2" / spec["path"] + if artifact.stat().st_size != spec["bytes"]: + raise RuntimeError("Model size mismatch") + with artifact.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != spec["sha256"]: + raise RuntimeError("Model checksum mismatch") + artifacts.append(artifact) + return artifacts + + def main(): parser = argparse.ArgumentParser(description="Stage Searchi runtime resources") parser.add_argument("--backend", choices=["cpu", "auto", "metal", "vulkan", "cuda"], default="cpu") @@ -21,23 +35,23 @@ def main(): source /= name if not source.is_file(): raise RuntimeError("Build the pinned native worker before staging") + license_path = ROOT / ".searchi/references/llama.cpp/LICENSE" + if not license_path.is_file(): + raise RuntimeError("Native worker license is missing") + artifacts = [] + if args.bundle_model: + manifest = json.loads(Path(__file__).with_name("model-manifest.json").read_text()) + artifacts = verify_bundle(ROOT, manifest) destination = ROOT / "apps/desktop/src-tauri/resources" destination.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination / name) # Include the upstream runtime license with the statically linked helper. - shutil.copy2(ROOT / ".searchi/references/llama.cpp/LICENSE", destination / "LLAMA-LICENSE.txt") + shutil.copy2(license_path, destination / "LLAMA-LICENSE.txt") if args.bundle_model: - manifest = json.loads(Path(__file__).with_name("model-manifest.json").read_text()) model_destination = destination / "embeddinggemma2" model_destination.mkdir(exist_ok=True) - for spec in manifest["files"]: - artifact = ROOT / ".searchi/models/embeddinggemma2" / spec["path"] - if artifact.stat().st_size != spec["bytes"]: - raise RuntimeError("Model size mismatch") - with artifact.open("rb") as stream: - if hashlib.file_digest(stream, "sha256").hexdigest() != spec["sha256"]: - raise RuntimeError("Model checksum mismatch") - shutil.copy2(artifact, model_destination / spec["path"]) + for artifact in artifacts: + shutil.copy2(artifact, model_destination / artifact.name) print(f"Staged native worker: {destination / name}") diff --git a/tools/embeddinggemma2/test_stage_runtime.py b/tools/embeddinggemma2/test_stage_runtime.py new file mode 100644 index 0000000..5ffcb17 --- /dev/null +++ b/tools/embeddinggemma2/test_stage_runtime.py @@ -0,0 +1,48 @@ +"""Offline installation regressions; no model downloads or inference.""" +import hashlib +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import stage_runtime + + +class StagingTests(unittest.TestCase): + def test_corrupt_second_model_preserves_existing_staged_worker(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + name = "searchi-gemma-worker.exe" if os.name == "nt" else "searchi-gemma-worker" + runtime = root / ".searchi/runtime/cpu" + if os.name == "nt": + runtime /= "Release" + runtime.mkdir(parents=True) + (runtime / name).write_bytes(b"new worker") + license_path = root / ".searchi/references/llama.cpp/LICENSE" + license_path.parent.mkdir(parents=True) + license_path.write_text("license") + destination = root / "apps/desktop/src-tauri/resources" + destination.mkdir(parents=True) + (destination / name).write_bytes(b"previous worker") + models = root / ".searchi/models/embeddinggemma2" + models.mkdir(parents=True) + specs = [] + for filename, content in [("model.gguf", b"verified"), ("projector.gguf", b"original")]: + (models / filename).write_bytes(content) + specs.append({"path": filename, "bytes": len(content), "sha256": hashlib.sha256(content).hexdigest()}) + (models / "projector.gguf").write_bytes(b"corrupt!") + with patch.object(stage_runtime, "ROOT", root), patch("sys.argv", ["stage_runtime", "--bundle-model"]), patch.object(stage_runtime.json, "loads", return_value={"files": specs}): + with self.assertRaisesRegex(RuntimeError, "checksum mismatch"): + stage_runtime.main() + self.assertEqual((destination / name).read_bytes(), b"previous worker") + self.assertFalse((destination / "embeddinggemma2").exists()) + + def test_missing_model_rejects_complete_bundle(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaises(FileNotFoundError): + stage_runtime.verify_bundle(Path(directory), {"files": [{"path": "missing.gguf", "bytes": 1, "sha256": "unused"}]}) + + +if __name__ == "__main__": + unittest.main()