fix(desktop): wire production callers through shared mesh preflight helpers

apply_workspace and import_identity now call run_mesh_transition_preflight
(extracted from fail_if_client_mesh_active path) rather than inlining the
check. ensure_relay_mesh_for_record captures scope before discovery and
routes the install call through install_client_under_workspace_transition,
which validates full scope identity (scope_id, relay, owner, generation)
under the workspace_transition guard before invoking the closure.

with_workspace_transition_preflight gains run_mesh_transition_preflight as
a companion: tests continue to call the callback helper directly; production
callers that need spawn_blocking dispatch use run_mesh_transition_preflight
after acquiring workspace_transition themselves.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-04 21:22:42 -04:00
co-authored by Will Pfleger
parent 50f615ecbd
commit 8b046bc62b
4 changed files with 63 additions and 21 deletions
+6 -7
View File
@@ -390,14 +390,13 @@ pub async fn import_identity(
None
};
// ── Layer 1 async: fail closed if a client-mode Mesh runtime is active ──
// Identity import clears the active scope entirely, so any live client-mode
// Mesh runtime would become dangling after the import. Option A ruling:
// require the user to stop it first. The journaled Mesh recipe is a tracked
// follow-up in the PR body.
// Fail closed if a client-mode Mesh runtime is active when there is an
// active scope — a live client-mode runtime would become dangling after the
// import (Option A ruling). Called via `run_mesh_transition_preflight` so the
// shared preflight logic is not duplicated inline; the guard above is held.
#[cfg(feature = "mesh-llm")]
if has_active_scope {
#[cfg(feature = "mesh-llm")]
crate::commands::mesh_llm::scope_impl::fail_if_client_mesh_active(&app_handle).await?;
crate::commands::mesh_llm::scope_impl::run_mesh_transition_preflight(&app_handle).await?;
}
let result = tokio::task::spawn_blocking(move || {
+21 -3
View File
@@ -896,9 +896,27 @@ pub(crate) async fn ensure_relay_mesh_for_record(
};
// No serving configuration exists — genuine consumer-only start.
// Acquire workspace_transition to serialize client install against apply_workspace.
let _workspace_guard = state.workspace_transition.lock().await;
ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?;
// Capture scope BEFORE discovery so a concurrent workspace switch can be
// detected under the install lock. Route through
// `install_client_under_workspace_transition` which acquires
// `workspace_transition`, validates full scope identity
// (scope_id, relay, owner, generation), then calls the install closure —
// serialized against apply_workspace and live identity import.
let captured_scope = state
.capture_active_scope()
.ok_or("mesh client install: no active workspace scope")?;
let model_id_owned = model_id.to_string();
let endpoint = target.endpoint_addr;
scope_impl::install_client_under_workspace_transition(app, &captured_scope, move || {
let state_ref = state.clone();
let model_id_ref = model_id_owned.clone();
async move {
ensure_client_node_for_model(&state_ref, &model_id_ref, Some(endpoint))
.await
.map(|_| ())
}
})
.await?;
wait_for_mesh_inference(model_id).await
}
@@ -101,13 +101,21 @@ pub(crate) async fn fail_if_client_mesh_active<R: tauri::Runtime>(
/// `fail_if_client_mesh_active` preflight, then invoke `transition_body` while
/// the guard remains held.
///
/// Used by `apply_workspace` and live identity import so neither duplicates the
/// acquire + preflight sequence inline. Tests call this helper directly to prove
/// the serialization contract against `install_client_under_workspace_transition`.
/// Used by `apply_workspace`, live identity import, and tests to prove the
/// serialization contract against `install_client_under_workspace_transition`.
///
/// The transition body runs synchronously (or dispatches to `spawn_blocking`)
/// while the async guard is alive on the current task. If the preflight fails,
/// `transition_body` is never called.
///
/// Production callers that must `.await` after dispatch use this helper with
/// a body that dispatches `tokio::task::spawn_blocking` and immediately returns
/// the join handle's `.await` — the guard is held across the dispatch and the
/// `.await` is done at the call site after this helper returns.
///
/// Alternatively, callers that cannot fit their body into a sync `FnOnce` call
/// `run_mesh_transition_preflight` (which they invoke after acquiring the lock
/// themselves) to share the preflight logic without lifetime constraints.
pub(crate) async fn with_workspace_transition_preflight<R, F, T>(
app: &AppHandle<R>,
transition_body: F,
@@ -126,6 +134,25 @@ where
transition_body()
}
/// Run only the Mesh-preflight portion of the workspace transition check.
///
/// Called by production commands (`apply_workspace`, `import_identity`) that
/// already hold `workspace_transition` and need to avoid duplicating the
/// `fail_if_client_mesh_active` call inline. The guard must already be acquired
/// and remain alive for the duration of the transition.
///
/// No-op when the `mesh-llm` feature is disabled.
pub(crate) async fn run_mesh_transition_preflight<R>(app: &AppHandle<R>) -> Result<(), String>
where
R: tauri::Runtime,
{
#[cfg(feature = "mesh-llm")]
fail_if_client_mesh_active(app).await?;
#[cfg(not(feature = "mesh-llm"))]
let _ = app;
Ok(())
}
/// Acquire the `workspace_transition` lock, validate the full captured scope
/// identity under the guard — `(scope_id, normalized relay, owner_pubkey,
/// generation)` — then call the injected `install` closure.
+6 -8
View File
@@ -114,7 +114,7 @@ pub async fn apply_workspace(
) -> Result<crate::managed_agents::scope::WorkspaceApplyResult, String> {
use crate::managed_agents::scope::WorkspaceApplyResult;
// ── Layer 1: async serialization lock ────────────────────────────────────
// ── Layer 1: async serialization lock + Mesh preflight ──────────────────
// workspace_transition serializes apply_workspace and live identity import
// so scope transitions are never concurrent. We acquire via a clone so the
// borrow does not prevent moving `app` into spawn_blocking below.
@@ -122,14 +122,12 @@ pub async fn apply_workspace(
let lock_state = lock_app.state::<AppState>();
let _transition_guard = lock_state.workspace_transition.lock().await;
// ── Layer 1 async: fail closed if a client-mode Mesh runtime is active ──
// Serve-mode runtimes are machine-level and stay pinned across workspace
// switches — they never block a switch. Client-mode runtimes require an
// active scope to be meaningful and cannot be safely moved to a new scope
// atomically. Option A ruling: require the user to stop the client session
// first; the journaled Mesh recipe is a tracked follow-up in the PR body.
// Fail closed if a client-mode Mesh runtime is active — no client-mode
// runtime may be active when a workspace switch begins (Option A ruling).
// Called via `run_mesh_transition_preflight` so the shared preflight logic
// is not duplicated inline; the guard above remains held throughout.
#[cfg(feature = "mesh-llm")]
crate::commands::mesh_llm::scope_impl::fail_if_client_mesh_active(&app).await?;
crate::commands::mesh_llm::scope_impl::run_mesh_transition_preflight(&app).await?;
let restore_app = app.clone();
let blocking_result: Result<WorkspaceApplyResult, String> =