diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 1e68fc3..789ae73 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -448,6 +448,15 @@ fn bundle_myownmesh_sidecar() -> Result<(), Box> { .map(|s| s.trim().to_string()) .ok() .filter(|s| !s.is_empty()); + // Surface the pinned rev to the runtime so the daemon-version gate + // (`mesh::daemon_commands::mesh_daemon_status`) can tell when the + // live `myownmesh` daemon is older than the rev this build was + // tested against, and nudge it to update. Raw pin + // (`vMAJOR.MINOR.PATCH`, or a SHA in dev); the runtime strips the + // `v` and ignores anything that isn't semver. + if let Some(want) = &want_rev { + println!("cargo:rustc-env=MYOWNMESH_PIN={want}"); + } if let Some(want) = &want_rev { let bundled = fs::read_to_string(&bundled_rev_sentinel) .map(|s| s.trim().to_string()) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fca57ac..d51fb88 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1535,6 +1535,7 @@ fn main() { web_search::agent_web_search, // ---- daemon-backed mesh commands (PR migrating off Trystero) mesh::daemon_commands::mesh_daemon_status, + mesh::daemon_commands::mesh_daemon_update_to_pin, mesh::daemon_commands::mesh_daemon_identity_show, mesh::daemon_commands::mesh_daemon_identity_set_label, mesh::daemon_commands::mesh_daemon_network_id_generate, diff --git a/src-tauri/src/mesh/daemon.rs b/src-tauri/src/mesh/daemon.rs index f6b4738..10142a5 100644 --- a/src-tauri/src/mesh/daemon.rs +++ b/src-tauri/src/mesh/daemon.rs @@ -453,6 +453,100 @@ impl ControlClient { } } +// ---- daemon version gate --------------------------------------------- + +/// The MyOwnMesh release this build was pinned to (`.myownmesh-rev`, +/// surfaced by `build.rs`). `None` for dev builds with no pin. May be a +/// `vMAJOR.MINOR.PATCH` tag or a raw SHA; comparisons go through +/// [`version_meets`], which ignores anything not parseable as semver. +pub fn pinned_mesh_version() -> Option<&'static str> { + option_env!("MYOWNMESH_PIN") +} + +/// True when daemon version `have` satisfies requirement `want` +/// (`have >= want`). Both accept an optional leading `v` and ignore any +/// `-pre` / `+build` suffix. Returns `true` ("don't nag") when either +/// side isn't a parseable `MAJOR.MINOR.PATCH` — e.g. a SHA pin in dev — +/// since no meaningful claim can be made then. +pub fn version_meets(have: &str, want: &str) -> bool { + fn parse(s: &str) -> Option<(u64, u64, u64)> { + let s = s.trim(); + let s = s.strip_prefix('v').unwrap_or(s); + let core = s.split(|c| c == '-' || c == '+').next().unwrap_or(s); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) + } + match (parse(have), parse(want)) { + (Some(h), Some(w)) => h >= w, + _ => true, + } +} + +/// Best-effort: nudge the live `myownmesh` daemon toward (at least) the +/// pinned version. Advisory only — mismatched revs still peer, since the +/// wire protocol negotiates features per-peer — so this never blocks. +/// +/// `own` = MyOwnLLM spawned this daemon, so it runs against our isolated +/// `MYOWNMESH_HOME` and it's safe to `apply` the staged binary (which +/// takes effect on the daemon's next start). For a shared daemon we only +/// `enable` + `check` (stage); its own process applies on its restart — +/// we never swap another app's binary out from under it. +/// +/// Returns a small JSON summary. Every step is fail-soft, so a missing +/// binary, a read-only install, or an offline box degrades quietly +/// rather than surfacing an error to the user. +pub fn drive_daemon_update(own: bool) -> serde_json::Value { + let Some(bin) = daemon_binary_candidates().into_iter().find(|p| p.is_file()) else { + return serde_json::json!({ "ok": false, "error": "no myownmesh binary found" }); + }; + // Own-LLM daemon lives under our isolated home; a shared daemon uses + // its own default `MYOWNMESH_HOME` (don't override it). + let home = if own { + dirs::home_dir().map(|h| h.join(".myownllm").join(".myownmesh")) + } else { + None + }; + let run = |args: &[&str]| -> Result { + let mut cmd = std::process::Command::new(&bin); + cmd.args(args); + if let Some(h) = &home { + cmd.env("MYOWNMESH_HOME", h); + } + match cmd.output() { + Ok(o) if o.status.success() => { + Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } + Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()), + Err(e) => Err(e.to_string()), + } + }; + // 1. Make sure the daemon's own background updater is on ("let it"). + let _ = run(&["update", "enable"]); + // 2. Force a check now so we don't wait out the 6h interval; stages + // the latest release when the daemon's apply policy allows. + let check = run(&["update", "check", "--json"]); + // 3. For a daemon we own, apply the staged binary (effective on its + // next start). Leave a shared daemon's binary alone. + let apply = if own { + Some(run(&["update", "apply"])) + } else { + None + }; + serde_json::json!({ + "ok": true, + "own": own, + "binary": bin.display().to_string(), + "check": check.as_ref().ok(), + "check_error": check.as_ref().err(), + "applied": apply.as_ref().map(|r| r.is_ok()), + "apply": apply.as_ref().and_then(|r| r.as_ref().ok().cloned()), + "apply_error": apply.as_ref().and_then(|r| r.as_ref().err().cloned()), + }) +} + // ---- daemon child lifecycle ------------------------------------------ /// Owned wrapper around a spawned `myownmesh serve` child. diff --git a/src-tauri/src/mesh/daemon_commands.rs b/src-tauri/src/mesh/daemon_commands.rs index a09bbf4..089a880 100644 --- a/src-tauri/src/mesh/daemon_commands.rs +++ b/src-tauri/src/mesh/daemon_commands.rs @@ -53,10 +53,44 @@ pub async fn mesh_daemon_status(state: State<'_, Arc>) -> CmdResult< "daemon_mode".to_string(), Value::String(daemon.client.mode_str().to_string()), ); + // Daemon-version gate. Surface the rev this build was pinned to + // and whether the live daemon meets it, so the frontend can show + // a non-blocking "update your mesh" notice and kick off + // `mesh_daemon_update_to_pin`. Mismatched revs still peer (the + // wire protocol negotiates features per-peer), so this is + // advisory only — never a hard gate. + if let Some(pin) = super::daemon::pinned_mesh_version() { + // Compute the comparison before mutating `obj` so `have`'s + // borrow of it ends before the inserts below. + let meets = { + let have = obj.get("version").and_then(|v| v.as_str()).unwrap_or(""); + super::daemon::version_meets(have, pin) + }; + obj.insert("pinned_version".to_string(), Value::String(pin.to_string())); + obj.insert("meets_pin".to_string(), Value::Bool(meets)); + } } Ok(data) } +/// Best-effort nudge the live `myownmesh` daemon toward at least the +/// pinned version: enable its background updater, force a check, and — +/// for a daemon we spawned ourselves — apply the staged binary so it +/// lands on the daemon's next start. Advisory and non-blocking; see +/// [`super::daemon::drive_daemon_update`]. Runs on a blocking thread +/// since it shells out to the daemon CLI. Returns a small status object. +#[tauri::command] +pub async fn mesh_daemon_update_to_pin(state: State<'_, Arc>) -> CmdResult { + let daemon = state.inner().clone(); + let own = daemon.client.mode_str() == "own_llm"; + match tauri::async_runtime::spawn_blocking(move || super::daemon::drive_daemon_update(own)) + .await + { + Ok(v) => Ok(v), + Err(_) => Err("daemon update task did not complete".to_string()), + } +} + // ---- identity -------------------------------------------------------- #[tauri::command] diff --git a/src/mesh-daemon.svelte.ts b/src/mesh-daemon.svelte.ts index 5288570..1d6b274 100644 --- a/src/mesh-daemon.svelte.ts +++ b/src/mesh-daemon.svelte.ts @@ -258,6 +258,11 @@ interface DaemonStatus { ipc_client_id: string; daemon_socket: string; daemon_mode: "shared" | "own_llm"; + /** The `.myownmesh-rev` version this build was tested against, and + * whether the live daemon meets it. Both absent on older backends — + * treated as "fine". See `noteDaemonVersion`. */ + pinned_version?: string; + meets_pin?: boolean; } // ---------------------------------------------------------------------- @@ -445,6 +450,18 @@ class MeshDaemonClient { diag_quiet = $state(false); /** Peer roster + per-peer state, hydrated from `mesh://event`. */ peers = $state([]); + /** Daemon-version gate vs the `.myownmesh-rev` pin (surfaced by the + * backend in `mesh_daemon_status`). `meets_pin` false means the live + * daemon is older than the rev this build was tested against — it + * still peers, but newer mesh features may be unavailable until it + * catches up, so we auto-nudge it to update (see `noteDaemonVersion`). */ + daemon_version = $state(""); + pinned_version = $state(null); + meets_pin = $state(true); + daemon_update = $state<{ + state: "idle" | "updating" | "done" | "failed"; + detail?: string; + }>({ state: "idle" }); /** True while a forced `stop → start` cycle is mid-flight. */ is_rediscovering = $state(false); /** Wall-clock ms of the most recent ICE failure observed across any @@ -586,6 +603,9 @@ class MeshDaemonClient { // later (the retry budget bounded so it's not unbounded). const status = await this.fetchDaemonStatusWithRetry(); this.clientId = status.ipc_client_id; + // Check the live daemon against this build's pin and, if it's + // older, help it update in the background (non-blocking). + this.noteDaemonVersion(status); // Bridge the frontend's saved-network catalog into the // daemon. The daemon's own config is empty on first launch @@ -903,6 +923,53 @@ class MeshDaemonClient { * surfacing a confusing "state not managed" error. Aborts after * ~6s so a daemon that genuinely failed to start still bubbles * the error up. */ + /** Latched so the auto-update is attempted at most once per app run. */ + private daemonUpdateTried = false; + + /** Record the daemon's version against the build's pin and, if it's + * behind, kick off a best-effort background update. Advisory only — + * the mesh keeps working meanwhile (mismatched revs still peer), so + * this never blocks startup. Surfaces progress in the activity log + * and via the reactive `meets_pin` / `daemon_update` fields. */ + private noteDaemonVersion(status: DaemonStatus): void { + this.daemon_version = status.version ?? ""; + this.pinned_version = status.pinned_version ?? null; + // Absent (older backend or non-semver pin) is treated as "fine". + this.meets_pin = status.meets_pin !== false; + if (this.meets_pin || this.daemonUpdateTried) return; + this.daemonUpdateTried = true; + this.daemon_update = { state: "updating" }; + this.appendDiag( + "warn", + `mesh daemon ${this.daemon_version || "?"} is older than the pinned ${this.pinned_version ?? "?"} — updating in the background`, + ); + void invoke("mesh_daemon_update_to_pin") + .then((r) => { + const res = (r ?? {}) as { + ok?: boolean; + applied?: boolean | null; + check?: string | null; + check_error?: string | null; + apply_error?: string | null; + }; + // `applied` is null for a shared daemon (we only stage, never + // apply someone else's binary) — that's still success here. + const ok = res.ok !== false && res.applied !== false; + const detail = res.apply_error ?? res.check_error ?? res.check ?? undefined; + this.daemon_update = { state: ok ? "done" : "failed", detail }; + this.appendDiag( + ok ? "info" : "warn", + ok + ? `mesh daemon update staged — restart to finish updating to ${this.pinned_version ?? "the pinned version"}` + : `mesh daemon auto-update couldn't complete${detail ? `: ${detail}` : ""} — update MyOwnMesh manually to ${this.pinned_version ?? "the pinned version"}`, + ); + }) + .catch((e) => { + this.daemon_update = { state: "failed", detail: String(e) }; + this.appendDiag("warn", `mesh daemon auto-update failed: ${String(e)}`); + }); + } + private async fetchDaemonStatusWithRetry(): Promise { let lastErr: unknown = null; for (let attempt = 0; attempt < 30; attempt++) { diff --git a/src/ui/settings/CloudMeshStatus.svelte b/src/ui/settings/CloudMeshStatus.svelte index b3d49bb..7bedacf 100644 --- a/src/ui/settings/CloudMeshStatus.svelte +++ b/src/ui/settings/CloudMeshStatus.svelte @@ -335,6 +335,34 @@ import { APP_VERSION } from "../../mesh-capabilities"; + + {#if meshClient.pinned_version && !meshClient.meets_pin} +
+
+ +
+ + {meshClient.daemon_update.state === "updating" + ? "Updating mesh engine…" + : meshClient.daemon_update.state === "done" + ? "Mesh engine update ready" + : "Mesh engine is out of date"} + + + The myownmesh daemon is {meshClient.daemon_version || "older"} but + this app expects {meshClient.pinned_version}. It still connects — + newer mesh features may be unavailable until it updates.{#if meshClient.daemon_update.state === "done"} + Restart to finish.{/if}{#if meshClient.daemon_update.state === "failed" && meshClient.daemon_update.detail} + ({meshClient.daemon_update.detail}){/if} + +
+
+
+ {/if} +