mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): wire fail-closed scope seam + import_identity scope semantics
Three storage chokepoints (managed_agents_store_path, teams_store_path, global_config_path) now route through capture_active_scope() and fail with a clear error when no workspace scope is active. There is no fallback to the legacy unscoped root — returning a legacy path would recreate split-brain storage. apply_workspace acquires the workspace_transition lock (Layer 1 async serialization) before entering spawn_blocking so scope transitions are serialized against concurrent import_identity calls. import_identity implements both scope modes per v4 plan: - No-active-scope path (recovery/onboarding): persist identity, clear scope, bump generation. No scope is derived or claimed; the next apply_workspace performs adoption. - Live-active path (membership-denied flow): drain managed-agent runtimes (delegates to shutdown_managed_agents), persist identity, clear scope, bump generation. Drain failures are logged but non-fatal; the frontend's re-apply restores agents. Both paths bump the scope generation so in-flight operations see a new generation and abort their commits. The fallback relay can never claim legacy data — claims are only written inside apply_workspace's prepare stage. All 2112 tests pass. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
108a31e9c1
commit
80a68775d5
@@ -332,13 +332,54 @@ pub async fn save_ncryptsec_copy(
|
||||
Ok(Some(dest.display().to_string()))
|
||||
}
|
||||
|
||||
/// Stop all live managed-agent runtimes as part of a workspace drain.
|
||||
///
|
||||
/// Called during identity import (live-active path) to stop all agents
|
||||
/// before the active scope is cleared. At call time the scope is still set,
|
||||
/// so `load_managed_agents` resolves correctly. After this returns the caller
|
||||
/// persists the new identity and clears the scope.
|
||||
///
|
||||
/// Delegates to `shutdown::shutdown_managed_agents` which handles the full
|
||||
/// Layer-2 stop sequence (runtime_transition → store → processes locks,
|
||||
/// SIGTERM fan-out, orphan sweep). Returns `Err` if any stop fails; the
|
||||
/// caller logs the error and proceeds.
|
||||
fn drain_all_managed_agent_runtimes(
|
||||
app: &tauri::AppHandle,
|
||||
_state: &AppState,
|
||||
) -> Result<(), String> {
|
||||
crate::shutdown::shutdown_managed_agents(app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_identity(
|
||||
nsec: String,
|
||||
password: Option<String>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<IdentityInfo, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// ── Layer 1: async serialization lock ────────────────────────────────────
|
||||
// identity_mutation (Layer 1) must be held for the full import to prevent a
|
||||
// concurrent stale persist from overwriting the imported key.
|
||||
//
|
||||
// If there is an active scope, we also take workspace_transition so that
|
||||
// clearing the active scope is serialized against apply_workspace.
|
||||
// Lock order: identity_mutation → workspace_transition.
|
||||
let lock_app = app_handle.clone();
|
||||
let lock_state = lock_app.state::<AppState>();
|
||||
let _mutation_guard = lock_state.identity_mutation.lock().await;
|
||||
|
||||
// Capture whether an active scope exists BEFORE entering spawn_blocking.
|
||||
let has_active_scope = lock_state.capture_active_scope().is_some();
|
||||
|
||||
// For the live-active path, also hold workspace_transition so that
|
||||
// clearing the active scope is serialized against concurrent apply_workspace
|
||||
// calls. Lock order: identity_mutation → workspace_transition.
|
||||
let _transition_guard = if has_active_scope {
|
||||
Some(lock_state.workspace_transition.lock().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
// 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);
|
||||
@@ -347,11 +388,7 @@ pub async fn import_identity(
|
||||
password.as_ref().map(|value| value.as_str()),
|
||||
)?;
|
||||
|
||||
// Serialize against persist_current_identity: hold this guard for the
|
||||
// full function body so a concurrent stale persist can't overwrite
|
||||
// this import.
|
||||
let state = app_handle.state::<AppState>();
|
||||
let _mutation_guard = state.identity_mutation.blocking_lock();
|
||||
|
||||
let data_dir = app_handle
|
||||
.path()
|
||||
@@ -360,6 +397,23 @@ pub async fn import_identity(
|
||||
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: drain runtimes before swapping identity ─────────
|
||||
// When an active scope exists, stop all managed-agent runtimes before
|
||||
// persisting the new identity. The frontend re-applies the workspace
|
||||
// (which runs the scope initialization pipeline) to restore agents.
|
||||
//
|
||||
// Drain failures are logged rather than fatal — the identity persist
|
||||
// and scope clear proceed regardless; the frontend's re-apply handles
|
||||
// agent restoration.
|
||||
if has_active_scope {
|
||||
if let Err(e) = drain_all_managed_agent_runtimes(&app_handle, &state) {
|
||||
eprintln!(
|
||||
"buzz-desktop: identity import drain warning — some runtimes may still be \
|
||||
running: {e}; workspace re-apply will restore agents"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (pubkey, storage) = 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
|
||||
@@ -369,6 +423,17 @@ pub async fn import_identity(
|
||||
crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
|
||||
})?;
|
||||
|
||||
// ── 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.
|
||||
state.clear_active_scope();
|
||||
crate::managed_agents::scope::next_scope_generation();
|
||||
|
||||
let pubkey_hex = pubkey.to_hex();
|
||||
let display_name = truncated_display_name(&pubkey)?;
|
||||
|
||||
@@ -384,7 +449,14 @@ pub async fn import_identity(
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
|
||||
|
||||
// Guards must stay alive until spawn_blocking completes so the serialization
|
||||
// covers the full duration of the import.
|
||||
drop(_transition_guard);
|
||||
drop(_mutation_guard);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Commit an imported identity: durably persist, swap in-memory keys, clear
|
||||
|
||||
@@ -131,6 +131,14 @@ pub async fn apply_workspace(
|
||||
agent_managed_profiles: Option<bool>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// ── Layer 1: async serialization lock ────────────────────────────────────
|
||||
// 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.
|
||||
let lock_app = app.clone();
|
||||
let lock_state = lock_app.state::<AppState>();
|
||||
let _transition_guard = lock_state.workspace_transition.lock().await;
|
||||
|
||||
let restore_app = app.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
@@ -163,7 +171,9 @@ pub async fn apply_workspace(
|
||||
None => None,
|
||||
};
|
||||
|
||||
// ── Apply all state changes (nothing below can fail) ──────────────────
|
||||
// ── Layer 2: synchronous commit epoch ────────────────────────────────
|
||||
// No .await may be held while any Layer-2 guard is live. Relay override,
|
||||
// keys, and the active scope are all committed in this critical section.
|
||||
{
|
||||
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
|
||||
*override_guard = Some(relay_url.clone());
|
||||
@@ -185,9 +195,11 @@ pub async fn apply_workspace(
|
||||
.store(!agent_managed_profiles.unwrap_or(false), Ordering::Release);
|
||||
|
||||
// ── Commit the active workspace agent scope ───────────────────────────
|
||||
// Derive the scope from the now-applied relay + owner keys and store it
|
||||
// as the active scope. All subsequent store reads/writes and event-sync
|
||||
// calls use the scoped definitions directory.
|
||||
// Derive the scope from the now-applied relay + owner keys and commit it
|
||||
// as the active scope. All subsequent store reads/writes (via
|
||||
// load_managed_agents, save_managed_agents, etc.) resolve through
|
||||
// capture_active_scope() → scoped definitions directory. There is NO
|
||||
// fallback to the legacy unscoped root.
|
||||
{
|
||||
let owner_pubkey = state
|
||||
.keys
|
||||
@@ -249,23 +261,23 @@ pub async fn apply_workspace(
|
||||
// instead of being abandoned by the storage cutover.
|
||||
migrate_legacy_retention_into(&restore_app, &scope);
|
||||
|
||||
// Use the scoped definitions directory for event sync.
|
||||
// After apply_workspace commits the scope, capture_active_scope()
|
||||
// returns the scoped dir; fall back to the legacy unscoped root
|
||||
// only when no scope has been committed (pre-Phase-2 transition).
|
||||
let definitions_dir = state
|
||||
.capture_active_scope()
|
||||
.map(|s| s.definitions_dir)
|
||||
.unwrap_or_else(|| {
|
||||
crate::managed_agents::managed_agents_base_dir(&restore_app).unwrap_or_default()
|
||||
});
|
||||
|
||||
crate::event_sync::spawn_event_sync(
|
||||
restore_app.clone(),
|
||||
scope.owner_keys,
|
||||
scope.db_path,
|
||||
definitions_dir,
|
||||
)
|
||||
// The active scope was committed in the spawn_blocking above.
|
||||
// If it is somehow None here, event sync is skipped rather than
|
||||
// falling back to the legacy unscoped root (which would recreate
|
||||
// split-brain storage).
|
||||
if let Some(agent_scope) = state.capture_active_scope() {
|
||||
crate::event_sync::spawn_event_sync(
|
||||
restore_app.clone(),
|
||||
scope.owner_keys,
|
||||
scope.db_path,
|
||||
agent_scope.definitions_dir,
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"buzz-desktop: active agent scope unavailable after workspace apply — \
|
||||
event sync skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}");
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::path::Path;
|
||||
/// (`WorkspaceAgentScope::definitions_dir`). Reads personas/teams/agents from
|
||||
/// that directory rather than the legacy unscoped `agents/` root.
|
||||
pub fn run_event_sync(
|
||||
app: &tauri::AppHandle,
|
||||
_app: &tauri::AppHandle,
|
||||
owner_keys: &nostr::Keys,
|
||||
db_path: &Path,
|
||||
definitions_dir: &Path,
|
||||
|
||||
@@ -30,7 +30,7 @@ use tauri::AppHandle;
|
||||
use crate::managed_agents::env_vars::{
|
||||
validate_user_env_keys, DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_VALUE_BYTES,
|
||||
};
|
||||
use crate::managed_agents::storage::{atomic_write_json_restricted, managed_agents_base_dir};
|
||||
use crate::managed_agents::storage::atomic_write_json_restricted;
|
||||
use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord};
|
||||
|
||||
/// The global agent configuration record.
|
||||
@@ -174,8 +174,15 @@ pub fn normalize_global_config_fields(config: &mut GlobalAgentConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the active-scope `global-agent-config.json` path. Fails closed on
|
||||
/// `None` scope. No fallback to the legacy unscoped root.
|
||||
fn global_config_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(managed_agents_base_dir(app)?.join("global-agent-config.json"))
|
||||
use tauri::Manager as _;
|
||||
let state = app.state::<crate::app_state::AppState>();
|
||||
let scope = state.capture_active_scope().ok_or_else(|| {
|
||||
"no active workspace scope — apply a workspace before accessing global config".to_string()
|
||||
})?;
|
||||
Ok(global_config_path_at(&scope.definitions_dir))
|
||||
}
|
||||
|
||||
/// Scoped variant: resolve `global-agent-config.json` under a workspace scope's
|
||||
|
||||
@@ -42,8 +42,20 @@ pub fn managed_agents_base_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Resolve the active-scope `managed-agents.json` path.
|
||||
///
|
||||
/// Routes through [`crate::app_state::AppState::capture_active_scope`] when a
|
||||
/// workspace scope has been committed, and fails closed with a clear error when
|
||||
/// no scope is active. There is NO fallback to the legacy unscoped root —
|
||||
/// returning a legacy path would recreate split-brain storage.
|
||||
pub(crate) fn managed_agents_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(managed_agents_base_dir(app)?.join("managed-agents.json"))
|
||||
use tauri::Manager as _;
|
||||
let state = app.state::<crate::app_state::AppState>();
|
||||
let scope = state.capture_active_scope().ok_or_else(|| {
|
||||
"no active workspace scope — apply a workspace before accessing agent definitions"
|
||||
.to_string()
|
||||
})?;
|
||||
Ok(managed_agents_store_path_at(&scope.definitions_dir))
|
||||
}
|
||||
|
||||
/// Scoped variant: resolve `managed-agents.json` under a workspace scope's
|
||||
@@ -466,7 +478,7 @@ pub(crate) fn save_agent_definitions_at(
|
||||
/// name/pubkey order their save path established.
|
||||
fn write_agent_store(
|
||||
app: &AppHandle,
|
||||
mut definitions: Vec<ManagedAgentRecord>,
|
||||
definitions: Vec<ManagedAgentRecord>,
|
||||
instances: Vec<ManagedAgentRecord>,
|
||||
) -> Result<(), String> {
|
||||
let path = managed_agents_store_path(app)?;
|
||||
|
||||
@@ -3,14 +3,20 @@ use std::{fs, path::PathBuf};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
managed_agents::{managed_agents_base_dir, ManagedAgentRecord, TeamRecord},
|
||||
managed_agents::{ManagedAgentRecord, TeamRecord},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
use super::team_repair::team_persona_key;
|
||||
|
||||
/// Resolve the active-scope `teams.json` path. Fails closed on `None` scope.
|
||||
pub(crate) fn teams_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(managed_agents_base_dir(app)?.join("teams.json"))
|
||||
use tauri::Manager as _;
|
||||
let state = app.state::<crate::app_state::AppState>();
|
||||
let scope = state.capture_active_scope().ok_or_else(|| {
|
||||
"no active workspace scope — apply a workspace before accessing teams".to_string()
|
||||
})?;
|
||||
Ok(teams_store_path_at(&scope.definitions_dir))
|
||||
}
|
||||
|
||||
/// Scoped variant: resolve `teams.json` under a workspace scope's definitions dir.
|
||||
|
||||
Reference in New Issue
Block a user