refactor(desktop): extract identity-transition coordinator to its own module

The C5 coordinator additions (`run_identity_transition`, `commit_under_fence`)
pushed `commands/identity.rs` past the 1000-line desktop file-size ratchet.
Move the coordinator cluster — the drain helper, `run_identity_transition`,
`commit_under_fence`, and `import_identity_blocking` — into a `#[path]` sibling
`identity_transition.rs`, mirroring the `agents_scoped.rs` split. The two
`pub(crate)` entry points are re-exported so external call paths (`import_identity`,
phone recovery, the fence tests) are unchanged. No behavior change.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-18 09:15:15 -04:00
parent 9d2882b782
commit b0da3e8568
2 changed files with 345 additions and 331 deletions

View File

@@ -371,23 +371,11 @@ pub async fn save_ncryptsec_copy(
Ok(Some(dest.display().to_string()))
}
/// Drain live managed-agent runtimes for identity import (Layer 2 protocol).
/// Caller must hold `managed_agent_runtime_transition`. Returns stopped entries
/// or `Err((stopped, msg))` on failure.
fn drain_managed_agent_runtimes_for_import(
app: &tauri::AppHandle,
state: &AppState,
) -> Result<
Vec<crate::managed_agents::DrainJournalEntry>,
(Vec<crate::managed_agents::DrainJournalEntry>, String),
> {
let (stopped, _remaining, drain_error) =
crate::managed_agents::drain_scope_runtimes(app, state);
match drain_error {
None => Ok(stopped),
Some(e) => Err((stopped, e)),
}
}
#[path = "identity_transition.rs"]
mod transition;
#[cfg(test)]
pub(crate) use transition::commit_under_fence;
pub(crate) use transition::run_identity_transition;
#[tauri::command]
pub async fn import_identity(
@@ -399,320 +387,6 @@ pub async fn import_identity(
run_identity_transition(app_handle, nsec, password, None, || Ok(())).await
}
/// The shared identity-transition coordinator (P25-C1): the ONE sink both
/// runtime identity-swap callers route through. Acquires the transition locks
/// in the prescribed order (`identity_mutation` → `workspace_transition`
/// [+ Mesh preflight when active]), captures the active-scope snapshot under
/// the held transition guard, then runs the journaled drain + fenced commit in
/// [`import_identity_blocking`].
///
/// `commit_fence` + `validity_check` are threaded to the pre-commit boundary:
/// normal import supplies `None` + always-`Ok`; the phone-recovery continuation
/// supplies the pairing `generation_fence` + its generation-current check, so a
/// superseded recovery compensates the drain and commits NO identity while a
/// recovery that wins the fence commits durably (P26-C1).
pub(crate) async fn run_identity_transition(
app_handle: tauri::AppHandle,
nsec: String,
password: Option<String>,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
validity_check: impl FnOnce() -> Result<(), String> + Send + 'static,
) -> Result<IdentityInfo, String> {
// ── Layer 1: identity_mutation (async serialization lock) ────────────────
// Held for the full import to prevent a concurrent stale persist from
// overwriting the imported key. Lock order: identity_mutation →
// workspace_transition (when active scope present).
//
// Use a cloned handle for lock acquisition so the original `app_handle` is
// free for the spawned blocking body below (no borrow conflict).
let lock_handle = app_handle.clone();
let lock_state = lock_handle.state::<AppState>();
let _mutation_guard = lock_state.identity_mutation.lock().await;
// Capture whether an active scope exists BEFORE branching.
let has_active_scope = lock_state.capture_active_scope().is_some();
// ── Layer 1b: workspace_transition + mesh preflight (active-scope path) ──
// When a workspace scope is live, route through the production
// `with_workspace_transition_preflight` helper (when the `mesh-llm`
// feature is enabled): it acquires `workspace_transition`, runs
// `fail_if_client_mesh_active`, then invokes the body while the lock
// remains held — the same orchestration path used by `apply_workspace`.
//
// Without `mesh-llm`, manually acquire `workspace_transition` (no mesh
// check needed) and invoke the blocking body.
//
// When no scope is active, skip the lock — there is no workspace to
// serialize against.
let result = if has_active_scope {
let app_for_preflight_body = app_handle.clone();
let nsec_for_body = nsec;
let password_for_body = password;
let fence_for_body = commit_fence;
#[cfg(feature = "mesh-llm")]
let branch_result =
crate::commands::mesh_llm::scope_impl::with_workspace_transition_preflight(
&app_handle,
move || {
Box::pin(async move {
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_for_preflight_body,
nsec_for_body,
password_for_body,
true,
fence_for_body,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
})
},
)
.await;
#[cfg(not(feature = "mesh-llm"))]
let branch_result = {
let _transition_guard = lock_state.workspace_transition.lock().await;
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_for_preflight_body,
nsec_for_body,
password_for_body,
true,
fence_for_body,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
};
branch_result
} else {
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_handle,
nsec,
password,
false,
commit_fence,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
};
// identity_mutation must outlive spawn_blocking — drop explicitly here so
// the compiler can see the guard's lifetime covers both branches.
drop(_mutation_guard);
result
}
/// Run `commit` under the transition commit fence, gated on `validity_check`.
///
/// When `fence` is supplied it is locked FIRST and held for the whole call, so
/// the validity check and the durable commit are indivisible against a racing
/// supersession that must take the same fence to invalidate the transition
/// (P26-C1). The check runs under the held fence; on `Err` it short-circuits
/// and `commit` is NEVER run. This is the single fence-guarded commit primitive
/// shared by the identity-transition coordinator and its supersession tests.
pub(crate) fn commit_under_fence<T>(
fence: Option<&std::sync::Mutex<()>>,
validity_check: impl FnOnce() -> Result<(), String>,
commit: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let _fence = match fence.map(|m| m.lock()) {
Some(Ok(guard)) => Some(guard),
Some(Err(e)) => return Err(format!("identity transition fence poisoned: {e}")),
None => None,
};
validity_check()?;
commit()
}
/// Blocking body of [`import_identity`]: key recovery, journaled drain (when
/// `has_active_scope`), identity commit, and scope clear. The caller has
/// already acquired `workspace_transition` when `has_active_scope` is true.
///
/// `validity_check` runs at the pre-commit boundary — after a successful drain,
/// immediately before the durable identity commit — while `commit_fence` (when
/// supplied) is held. When it returns `Err`, the drained runtimes are
/// compensated and NO identity is committed. `commit_fence` is held ALIVE
/// across the durable commit (P26-C1): for the phone-recovery caller this is
/// the pairing `generation_fence`, so a supersession that races the commit
/// either compensates (cancelled during drain, before the fence is taken) or
/// loses to the committed recovery (cancelled after the fenced commit begins).
/// Normal import supplies `None` + an always-`Ok(())` check.
fn import_identity_blocking(
app_handle: tauri::AppHandle,
nsec: String,
password: Option<String>,
has_active_scope: bool,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
validity_check: impl FnOnce() -> Result<(), String>,
) -> Result<IdentityInfo, String> {
// NIP-49 backups require a passphrase and decrypt entirely in Rust.
// Raw nsec/hex input follows the existing parser path unchanged.
let password = password.map(zeroize::Zeroizing::new);
let keys = crate::key_backup::recover_keys_from_input(
&nsec,
password.as_ref().map(|value| value.as_str()),
)?;
let state = app_handle.state::<AppState>();
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
// ── Live-active path: journaled drain before swapping identity ─────────
// Drain all managed-agent runtimes under `managed_agent_runtime_transition`
// (Layer 2) BEFORE persisting the new identity — same protocol as
// `apply_workspace`. The store lock is held through drain/save; on drain
// failure the transition guard is passed into compensate_drain so
// compensation runs without any interleave window.
let _rt_transition_guard = if has_active_scope {
Some(
state
.managed_agent_runtime_transition
.lock()
.map_err(|e| format!("managed_agent_runtime_transition poisoned: {e}"))?,
)
} else {
None
};
let _store_guard = if has_active_scope {
Some(
state
.managed_agents_store_lock
.lock()
.map_err(|e| format!("managed_agents_store_lock poisoned: {e}"))?,
)
} else {
None
};
// Capture the pre-import scope for compensation validation.
let pre_import_scope = state.capture_active_scope();
let stopped_entries = if has_active_scope {
match drain_managed_agent_runtimes_for_import(&app_handle, &state) {
Ok(stopped) => stopped,
Err((stopped, drain_err)) => {
// Drain failed — drop the store lock BEFORE compensating
// (compensate_drain re-acquires it), but pass the transition
// guard into compensate_drain so there is no interleave window.
drop(_store_guard);
let comp_err = match (pre_import_scope.as_ref(), _rt_transition_guard) {
(Some(scope), Some(rt_guard)) => crate::managed_agents::compensate_drain(
&app_handle,
&stopped,
scope,
rt_guard,
),
(_, leftover_guard) => {
drop(leftover_guard);
None
}
};
let msg = match comp_err {
Some(comp) => format!(
"identity import drain failed: {drain_err}; compensation failed: {comp}"
),
None => format!("identity import drain failed: {drain_err}"),
};
return Err(msg);
}
}
} else {
vec![]
};
// ── Fenced pre-commit gate + durable commit (P25-C1 / P26-C1) ─────────
// After a successful drain, immediately before the durable commit. The
// commit fence (when supplied) is acquired FIRST, the validity check runs
// under it, and the durable commit runs under the SAME held guard — so a
// racing supersession can neither slip between the check and the commit nor
// interleave with it. A failed check short-circuits and NEVER commits.
let commit_result = commit_under_fence(commit_fence.as_deref(), validity_check, || {
commit_imported_identity(&state, &data_dir, keys, |keys| {
// Persist into the OS keyring first (store → read-back verify →
// marker → delete file). Falls back to the 0o600 file when the
// keyring is unavailable; returns Err only when both backends fail.
let store =
crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
})
});
// If the fenced gate or the durable persist failed after a successful
// drain, compensate the drained runtimes with NO durable identity write.
// Drop the store lock BEFORE calling compensate_drain; pass the transition
// guard into it so there is no interleave window.
let (pubkey, storage) = match commit_result {
Ok(result) => result,
Err(e) => {
if !stopped_entries.is_empty() {
drop(_store_guard);
let comp_err = match (pre_import_scope.as_ref(), _rt_transition_guard) {
(Some(scope), Some(rt_guard)) => crate::managed_agents::compensate_drain(
&app_handle,
&stopped_entries,
scope,
rt_guard,
),
(_, leftover_guard) => {
drop(leftover_guard);
None
}
};
if let Some(comp_err) = comp_err {
eprintln!(
"buzz-desktop: identity import persist failed, compensation failed: {comp_err}"
);
}
}
return Err(e);
}
};
// ── Clear active scope and bump generation ────────────────────────────
// For no-active-scope path: no scope was ever set; clearing is a no-op
// but bumping generation invalidates any in-flight stale operations.
// For live-active path: agents are stopped; clearing scope makes all
// agent commands fail closed until the frontend re-applies a workspace.
//
// Invariant: the fallback relay can never claim legacy data — claims
// are only written inside apply_workspace's prepare stage.
//
// `clear_active_scope()` internally calls `next_scope_generation()` —
// no additional bump is needed here.
state.clear_active_scope();
let pubkey_hex = pubkey.to_hex();
let display_name = truncated_display_name(&pubkey)?;
eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex);
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
})
}
/// Commit an imported identity: durably persist, swap in-memory keys, clear
/// recovery flags, then remove the previous identity's stale app-managed
/// backup. Caller must hold `state.identity_mutation`.

View File

@@ -0,0 +1,340 @@
//! Identity-transition coordinator (P25-C1): the shared sink both runtime
//! identity-swap callers (normal import and phone recovery) route through, split
//! from `identity.rs` (file-size guard). Owns lock-ordering, the journaled
//! managed-agent runtime drain, the fenced pre-commit gate, and scope clear.
use crate::app_state::AppState;
use crate::models::IdentityInfo;
use tauri::Manager;
/// Drain live managed-agent runtimes for identity import (Layer 2 protocol).
/// Caller must hold `managed_agent_runtime_transition`. Returns stopped entries
/// or `Err((stopped, msg))` on failure.
fn drain_managed_agent_runtimes_for_import(
app: &tauri::AppHandle,
state: &AppState,
) -> Result<
Vec<crate::managed_agents::DrainJournalEntry>,
(Vec<crate::managed_agents::DrainJournalEntry>, String),
> {
let (stopped, _remaining, drain_error) =
crate::managed_agents::drain_scope_runtimes(app, state);
match drain_error {
None => Ok(stopped),
Some(e) => Err((stopped, e)),
}
}
/// The shared identity-transition coordinator (P25-C1): the ONE sink both
/// runtime identity-swap callers route through. Acquires the transition locks
/// in the prescribed order (`identity_mutation` → `workspace_transition`
/// [+ Mesh preflight when active]), captures the active-scope snapshot under
/// the held transition guard, then runs the journaled drain + fenced commit in
/// [`import_identity_blocking`].
///
/// `commit_fence` + `validity_check` are threaded to the pre-commit boundary:
/// normal import supplies `None` + always-`Ok`; the phone-recovery continuation
/// supplies the pairing `generation_fence` + its generation-current check, so a
/// superseded recovery compensates the drain and commits NO identity while a
/// recovery that wins the fence commits durably (P26-C1).
pub(crate) async fn run_identity_transition(
app_handle: tauri::AppHandle,
nsec: String,
password: Option<String>,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
validity_check: impl FnOnce() -> Result<(), String> + Send + 'static,
) -> Result<IdentityInfo, String> {
// ── Layer 1: identity_mutation (async serialization lock) ────────────────
// Held for the full import to prevent a concurrent stale persist from
// overwriting the imported key. Lock order: identity_mutation →
// workspace_transition (when active scope present).
//
// Use a cloned handle for lock acquisition so the original `app_handle` is
// free for the spawned blocking body below (no borrow conflict).
let lock_handle = app_handle.clone();
let lock_state = lock_handle.state::<AppState>();
let _mutation_guard = lock_state.identity_mutation.lock().await;
// Capture whether an active scope exists BEFORE branching.
let has_active_scope = lock_state.capture_active_scope().is_some();
// ── Layer 1b: workspace_transition + mesh preflight (active-scope path) ──
// When a workspace scope is live, route through the production
// `with_workspace_transition_preflight` helper (when the `mesh-llm`
// feature is enabled): it acquires `workspace_transition`, runs
// `fail_if_client_mesh_active`, then invokes the body while the lock
// remains held — the same orchestration path used by `apply_workspace`.
//
// Without `mesh-llm`, manually acquire `workspace_transition` (no mesh
// check needed) and invoke the blocking body.
//
// When no scope is active, skip the lock — there is no workspace to
// serialize against.
let result = if has_active_scope {
let app_for_preflight_body = app_handle.clone();
let nsec_for_body = nsec;
let password_for_body = password;
let fence_for_body = commit_fence;
#[cfg(feature = "mesh-llm")]
let branch_result =
crate::commands::mesh_llm::scope_impl::with_workspace_transition_preflight(
&app_handle,
move || {
Box::pin(async move {
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_for_preflight_body,
nsec_for_body,
password_for_body,
true,
fence_for_body,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
})
},
)
.await;
#[cfg(not(feature = "mesh-llm"))]
let branch_result = {
let _transition_guard = lock_state.workspace_transition.lock().await;
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_for_preflight_body,
nsec_for_body,
password_for_body,
true,
fence_for_body,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
};
branch_result
} else {
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_handle,
nsec,
password,
false,
commit_fence,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
};
// identity_mutation must outlive spawn_blocking — drop explicitly here so
// the compiler can see the guard's lifetime covers both branches.
drop(_mutation_guard);
result
}
/// Run `commit` under the transition commit fence, gated on `validity_check`.
///
/// When `fence` is supplied it is locked FIRST and held for the whole call, so
/// the validity check and the durable commit are indivisible against a racing
/// supersession that must take the same fence to invalidate the transition
/// (P26-C1). The check runs under the held fence; on `Err` it short-circuits
/// and `commit` is NEVER run. This is the single fence-guarded commit primitive
/// shared by the identity-transition coordinator and its supersession tests.
pub(crate) fn commit_under_fence<T>(
fence: Option<&std::sync::Mutex<()>>,
validity_check: impl FnOnce() -> Result<(), String>,
commit: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let _fence = match fence.map(|m| m.lock()) {
Some(Ok(guard)) => Some(guard),
Some(Err(e)) => return Err(format!("identity transition fence poisoned: {e}")),
None => None,
};
validity_check()?;
commit()
}
/// Blocking body of [`import_identity`]: key recovery, journaled drain (when
/// `has_active_scope`), identity commit, and scope clear. The caller has
/// already acquired `workspace_transition` when `has_active_scope` is true.
///
/// `validity_check` runs at the pre-commit boundary — after a successful drain,
/// immediately before the durable identity commit — while `commit_fence` (when
/// supplied) is held. When it returns `Err`, the drained runtimes are
/// compensated and NO identity is committed. `commit_fence` is held ALIVE
/// across the durable commit (P26-C1): for the phone-recovery caller this is
/// the pairing `generation_fence`, so a supersession that races the commit
/// either compensates (cancelled during drain, before the fence is taken) or
/// loses to the committed recovery (cancelled after the fenced commit begins).
/// Normal import supplies `None` + an always-`Ok(())` check.
fn import_identity_blocking(
app_handle: tauri::AppHandle,
nsec: String,
password: Option<String>,
has_active_scope: bool,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
validity_check: impl FnOnce() -> Result<(), String>,
) -> Result<IdentityInfo, String> {
// NIP-49 backups require a passphrase and decrypt entirely in Rust.
// Raw nsec/hex input follows the existing parser path unchanged.
let password = password.map(zeroize::Zeroizing::new);
let keys = crate::key_backup::recover_keys_from_input(
&nsec,
password.as_ref().map(|value| value.as_str()),
)?;
let state = app_handle.state::<AppState>();
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
// ── Live-active path: journaled drain before swapping identity ─────────
// Drain all managed-agent runtimes under `managed_agent_runtime_transition`
// (Layer 2) BEFORE persisting the new identity — same protocol as
// `apply_workspace`. The store lock is held through drain/save; on drain
// failure the transition guard is passed into compensate_drain so
// compensation runs without any interleave window.
let _rt_transition_guard = if has_active_scope {
Some(
state
.managed_agent_runtime_transition
.lock()
.map_err(|e| format!("managed_agent_runtime_transition poisoned: {e}"))?,
)
} else {
None
};
let _store_guard = if has_active_scope {
Some(
state
.managed_agents_store_lock
.lock()
.map_err(|e| format!("managed_agents_store_lock poisoned: {e}"))?,
)
} else {
None
};
// Capture the pre-import scope for compensation validation.
let pre_import_scope = state.capture_active_scope();
let stopped_entries = if has_active_scope {
match drain_managed_agent_runtimes_for_import(&app_handle, &state) {
Ok(stopped) => stopped,
Err((stopped, drain_err)) => {
// Drain failed — drop the store lock BEFORE compensating
// (compensate_drain re-acquires it), but pass the transition
// guard into compensate_drain so there is no interleave window.
drop(_store_guard);
let comp_err = match (pre_import_scope.as_ref(), _rt_transition_guard) {
(Some(scope), Some(rt_guard)) => crate::managed_agents::compensate_drain(
&app_handle,
&stopped,
scope,
rt_guard,
),
(_, leftover_guard) => {
drop(leftover_guard);
None
}
};
let msg = match comp_err {
Some(comp) => format!(
"identity import drain failed: {drain_err}; compensation failed: {comp}"
),
None => format!("identity import drain failed: {drain_err}"),
};
return Err(msg);
}
}
} else {
vec![]
};
// ── Fenced pre-commit gate + durable commit (P25-C1 / P26-C1) ─────────
// After a successful drain, immediately before the durable commit. The
// commit fence (when supplied) is acquired FIRST, the validity check runs
// under it, and the durable commit runs under the SAME held guard — so a
// racing supersession can neither slip between the check and the commit nor
// interleave with it. A failed check short-circuits and NEVER commits.
let commit_result = commit_under_fence(commit_fence.as_deref(), validity_check, || {
super::commit_imported_identity(&state, &data_dir, keys, |keys| {
// Persist into the OS keyring first (store → read-back verify →
// marker → delete file). Falls back to the 0o600 file when the
// keyring is unavailable; returns Err only when both backends fail.
let store =
crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
})
});
// If the fenced gate or the durable persist failed after a successful
// drain, compensate the drained runtimes with NO durable identity write.
// Drop the store lock BEFORE calling compensate_drain; pass the transition
// guard into it so there is no interleave window.
let (pubkey, storage) = match commit_result {
Ok(result) => result,
Err(e) => {
if !stopped_entries.is_empty() {
drop(_store_guard);
let comp_err = match (pre_import_scope.as_ref(), _rt_transition_guard) {
(Some(scope), Some(rt_guard)) => crate::managed_agents::compensate_drain(
&app_handle,
&stopped_entries,
scope,
rt_guard,
),
(_, leftover_guard) => {
drop(leftover_guard);
None
}
};
if let Some(comp_err) = comp_err {
eprintln!(
"buzz-desktop: identity import persist failed, compensation failed: {comp_err}"
);
}
}
return Err(e);
}
};
// ── Clear active scope and bump generation ────────────────────────────
// For no-active-scope path: no scope was ever set; clearing is a no-op
// but bumping generation invalidates any in-flight stale operations.
// For live-active path: agents are stopped; clearing scope makes all
// agent commands fail closed until the frontend re-applies a workspace.
//
// Invariant: the fallback relay can never claim legacy data — claims
// are only written inside apply_workspace's prepare stage.
//
// `clear_active_scope()` internally calls `next_scope_generation()` —
// no additional bump is needed here.
state.clear_active_scope();
let pubkey_hex = pubkey.to_hex();
let display_name = super::truncated_display_name(&pubkey)?;
eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex);
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
})
}