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
9 changes: 9 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,15 @@ fn bundle_myownmesh_sidecar() -> Result<(), Box<dyn std::error::Error>> {
.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())
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions src-tauri/src/mesh/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
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.
Expand Down
34 changes: 34 additions & 0 deletions src-tauri/src/mesh/daemon_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,44 @@ pub async fn mesh_daemon_status(state: State<'_, Arc<MeshDaemon>>) -> 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<MeshDaemon>>) -> CmdResult<Value> {
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]
Expand Down
67 changes: 67 additions & 0 deletions src/mesh-daemon.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

// ----------------------------------------------------------------------
Expand Down Expand Up @@ -445,6 +450,18 @@ class MeshDaemonClient {
diag_quiet = $state<boolean>(false);
/** Peer roster + per-peer state, hydrated from `mesh://event`. */
peers = $state<PeerEntry[]>([]);
/** 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<string>("");
pinned_version = $state<string | null>(null);
meets_pin = $state<boolean>(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<boolean>(false);
/** Wall-clock ms of the most recent ICE failure observed across any
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<DaemonStatus> {
let lastErr: unknown = null;
for (let attempt = 0; attempt < 30; attempt++) {
Expand Down
60 changes: 60 additions & 0 deletions src/ui/settings/CloudMeshStatus.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,34 @@ import { APP_VERSION } from "../../mesh-capabilities";
</div>
</section>

<!-- Daemon-version gate: non-blocking notice when the live myownmesh
daemon is older than the rev this build was tested against. The
mesh still connects; this just flags that newer features may be
unavailable until it updates (auto-nudged in the background). -->
{#if meshClient.pinned_version && !meshClient.meets_pin}
<section class="block">
<div class="daemon-stale" role="status" aria-live="polite">
<span class="daemon-stale-icon" aria-hidden="true">⬆</span>
<div class="daemon-stale-text">
<strong>
{meshClient.daemon_update.state === "updating"
? "Updating mesh engine…"
: meshClient.daemon_update.state === "done"
? "Mesh engine update ready"
: "Mesh engine is out of date"}
</strong>
<span>
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}
</span>
</div>
</div>
</section>
{/if}

<!-- 2. Status pill + accepting policy. One row: the live mesh
state on the left, the per-network accepting dropdown on
the right. Accepting is disabled when no network is
Expand Down Expand Up @@ -1109,4 +1137,36 @@ import { APP_VERSION } from "../../mesh-capabilities";
border-color: #3a4a6a;
}
.modal-actions .primary:hover { background: #344566; }
/* Daemon-version gate notice — purple "needs attention" accent,
matching the pending-approval surfaces. */
.daemon-stale {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
background: #1a1626;
border: 1px solid #3a3157;
border-left: 3px solid #a78bfa;
border-radius: 7px;
}
.daemon-stale-icon {
font-size: 0.95rem;
line-height: 1.3;
color: #c9b8f5;
}
.daemon-stale-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.daemon-stale-text strong {
font-size: 0.8rem;
color: #e9e3ff;
}
.daemon-stale-text span {
font-size: 0.72rem;
color: #9a92b8;
line-height: 1.5;
}
</style>
Loading