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
1 change: 1 addition & 0 deletions desktop/.env.e2e
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_BUZZ_BESTIE=1
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default defineConfig({
"**/hosted-communities-settings-screenshots.spec.ts",
"**/invites-settings-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/bestie.spec.ts",
"**/message-feedback-snapshots.spec.ts",
"**/message-copy-link.spec.ts",
"**/custom-emoji.spec.ts",
Expand Down
51 changes: 37 additions & 14 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ use super::managed_agent_definition::validate_create_definition;
use crate::{
app_state::AppState,
managed_agents::{
bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared},
build_managed_agent_summary, current_instance_id, ensure_persona_is_active,
find_managed_agent_mut, load_managed_agents, load_personas, load_teams,
managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary,
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest,
validate_provider_config, BackendKind, CreateManagedAgentRequest,
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args,
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, stop_managed_agent_workspace_pair,
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord,
ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
relay::relay_ws_url_with_override,
util::now_iso,
Expand Down Expand Up @@ -1087,6 +1089,20 @@ pub async fn stop_managed_agent(

// Async so the blocking body (disk reads/writes, process termination, keyring
// delete, nest regeneration) runs off the main UI thread via spawn_blocking.
fn run_managed_agent_deletion<T>(
base_dir: &std::path::Path,
pubkey: &str,
records: &mut Vec<ManagedAgentRecord>,
delete: impl FnOnce(&mut Vec<ManagedAgentRecord>) -> Result<T, String>,
) -> Result<T, String> {
recover_pending_assignment_cleanup(base_dir, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})?;
with_agent_assignments_cleared(base_dir, pubkey, || delete(records))
}

#[tauri::command]
pub async fn delete_managed_agent(
pubkey: String,
Expand All @@ -1102,6 +1118,12 @@ pub async fn delete_managed_agent(
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let base_dir = managed_agents_base_dir(&app)?;
recover_pending_assignment_cleanup(&base_dir, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})?;
let mut runtimes = state
.managed_agent_processes
.lock()
Expand Down Expand Up @@ -1135,16 +1157,17 @@ pub async fn delete_managed_agent(
}
}

if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_agent_session_caches(&pubkey);
let initial_len = records.len();
records.retain(|record| record.pubkey != pubkey);
if records.len() == initial_len {
if !records.iter().any(|record| record.pubkey == pubkey) {
return Err(format!("agent {pubkey} not found"));
}
save_managed_agents(&app, &records)?;
run_managed_agent_deletion(&base_dir, &pubkey, &mut records, |records| {
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_agent_session_caches(&pubkey);
records.retain(|record| record.pubkey != pubkey);
save_managed_agents(&app, records)
})?;
crate::managed_agents::delete_agent_key(&pubkey);
// Tombstone after confirmed removal (inside lock; every published
// agent tombstones). The NIP-IA kind:9035 archive request — which
Expand Down
39 changes: 39 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,45 @@ fn deploy_resolver_inherits_global_when_definition_blank() {
);
}

#[test]
fn production_delete_orchestration_restores_bestie_when_agent_save_fails() {
use crate::managed_agents::{
bestie_assignment::{assignment_matches, replace_assignment},
retention::open_retention_db,
};

let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}"));
let retention_dir = dir.path().join("retention");
std::fs::create_dir_all(&retention_dir)
.unwrap_or_else(|error| panic!("create retention dir: {error}"));
let db_path = retention_dir.join("owner.db");
let pubkey = "a".repeat(64);
replace_assignment(
&mut open_retention_db(&db_path)
.unwrap_or_else(|error| panic!("open assignment DB: {error}")),
&pubkey,
)
.unwrap_or_else(|error| panic!("seed assignment: {error}"));
let mut record = bare_agent_record(None, None, None);
record.pubkey.clone_from(&pubkey);
let mut records = vec![record];

let result = run_managed_agent_deletion(dir.path(), &pubkey, &mut records, |_records| {
Err::<(), _>("injected managed-agent save failure".to_string())
});

assert_eq!(
result,
Err("injected managed-agent save failure".to_string())
);
assert!(assignment_matches(
&open_retention_db(&db_path)
.unwrap_or_else(|error| panic!("reopen assignment DB: {error}")),
&pubkey,
)
.unwrap_or_else(|error| panic!("read restored assignment: {error}")));
}

/// Deploy resolver falls back to global when both definition and record have none.
#[test]
fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() {
Expand Down
218 changes: 218 additions & 0 deletions desktop/src-tauri/src/commands/bestie.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
use std::sync::atomic::Ordering;

use tauri::{AppHandle, State};

use crate::{
app_state::AppState,
managed_agents::{
bestie_assignment::{
assignment_matches, clear_assignment, get_assignment,
recover_pending_assignment_cleanup, replace_assignment, BestieAssignment,
},
load_managed_agents, managed_agents_base_dir,
retention::{active_retention_scope, open_retention_db, RetentionScope},
BackendKind, ManagedAgentRecord,
},
models::ChannelInfo,
};

fn canonical_relay(relay_url: &str) -> Result<String, String> {
buzz_core_pkg::relay::normalize_relay_url(relay_url).map_err(|error| error.to_string())
}

fn assert_expected_scope(
scope: &RetentionScope,
expected_relay_url: Option<&str>,
expected_signer_pubkey: Option<&str>,
) -> Result<(), String> {
if let Some(expected) = expected_relay_url {
if canonical_relay(expected)? != canonical_relay(&scope.relay_url)? {
return Err("active community changed while resolving Bestie".to_string());
}
}
if let Some(expected) = expected_signer_pubkey {
if expected.trim().to_ascii_lowercase() != scope.owner_keys.public_key().to_hex() {
return Err("active identity changed while resolving Bestie".to_string());
}
}
Ok(())
}

fn validate_agent_pubkey(pubkey: &str) -> Result<String, String> {
let normalized = pubkey.trim().to_ascii_lowercase();
if normalized.len() != 64
|| !normalized
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err("Bestie agent pubkey must be 64 hexadecimal characters".to_string());
}
Ok(normalized)
}

fn require_eligible_local_agent(
records: &[ManagedAgentRecord],
pubkey: &str,
) -> Result<(), String> {
let record = records
.iter()
.find(|record| record.pubkey.eq_ignore_ascii_case(pubkey))
.ok_or_else(|| "assigned Bestie agent no longer exists on this device".to_string())?;
if record.backend != BackendKind::Local {
return Err("only a local managed agent can be your Bestie".to_string());
}
Ok(())
}

fn recover_pending_cleanup(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> {
recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})
}

#[tauri::command]
pub fn get_bestie_assignment(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Option<BestieAssignment>, String> {
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
let conn = open_retention_db(&scope.db_path)?;
get_assignment(&conn)
}

#[tauri::command]
pub fn assign_bestie(
agent_pubkey: String,
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<BestieAssignment, String> {
let pubkey = validate_agent_pubkey(&agent_pubkey)?;
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
require_eligible_local_agent(&records, &pubkey)?;
let mut conn = open_retention_db(&scope.db_path)?;
replace_assignment(&mut conn, &pubkey)
}

#[tauri::command]
pub fn clear_bestie_assignment(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
let mut conn = open_retention_db(&scope.db_path)?;
clear_assignment(&mut conn)
}

#[tauri::command]
pub async fn resolve_bestie_conversation(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ChannelInfo, String> {
let generation = state.workspace_apply_generation.load(Ordering::Acquire);
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let assignment = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
let conn = open_retention_db(&scope.db_path)?;
let assignment = get_assignment(&conn)?
.ok_or_else(|| "choose an agent before opening Bestie".to_string())?;
require_eligible_local_agent(&records, &assignment.agent_pubkey)?;
assignment
};

let owner_pubkey = scope.owner_keys.public_key().to_hex();
let channel = super::dms::open_dm_with_scope(
vec![assignment.agent_pubkey.clone()],
Some(&scope.relay_url),
Some(&owner_pubkey),
&state,
)
.await?;

if state.workspace_apply_generation.load(Ordering::Acquire) != generation {
return Err("active workspace changed while resolving Bestie".to_string());
}
let current_scope = active_retention_scope(&app, &state)?;
assert_expected_scope(&current_scope, Some(&scope.relay_url), Some(&owner_pubkey))?;
let conn = open_retention_db(&scope.db_path)?;
if !assignment_matches(&conn, &assignment.agent_pubkey)? {
return Err("Bestie assignment changed while opening the conversation".to_string());
}
Ok(channel)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn relay_scope_accepts_runtime_equivalences() {
assert_eq!(
canonical_relay(" WSS://LOCALHOST:443/ ")
.unwrap_or_else(|error| panic!("canonical relay: {error}")),
"wss://127.0.0.1"
);
}

#[test]
fn pubkeys_are_normalized_and_validated() {
assert_eq!(
validate_agent_pubkey(&"A".repeat(64))
.unwrap_or_else(|error| panic!("valid pubkey: {error}")),
"a".repeat(64)
);
assert!(validate_agent_pubkey("short").is_err());
}
}
Loading