Skip to content
Draft
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
115 changes: 115 additions & 0 deletions desktop/src-tauri/src/commands/agent_backend_update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use std::collections::HashMap;

use tauri::AppHandle;

use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, load_managed_agents, load_personas,
stop_managed_agent_process, BackendKind, ManagedAgentPairRuntime, ManagedAgentRecord,
ManagedAgentRuntimeKey, ManagedAgentSummary,
},
};

pub(super) fn requested_backend_update(
current: &BackendKind,
backend_agent_id: Option<&str>,
requested: Option<BackendKind>,
) -> Result<Option<BackendKind>, String> {
let Some(requested) = requested else {
return Ok(None);
};
if requested == BackendKind::Local
&& *current != BackendKind::Local
&& backend_agent_id.is_some()
{
return Err(
"cannot move a deployed provider agent to local: the provider protocol does not support undeploy"
.to_string(),
);
}
Ok(Some(requested))
}

pub(super) fn apply_backend_update(
app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
requested: Option<BackendKind>,
) -> Result<Option<BackendKind>, String> {
let Some(backend) = requested_backend_update(
&record.backend,
record.backend_agent_id.as_deref(),
requested,
)?
else {
return Ok(None);
};
if record.backend == BackendKind::Local && backend != BackendKind::Local {
stop_managed_agent_process(app, record, runtimes)?;
}
record.provider_binary_path = match &backend {
BackendKind::Provider { config, id } => {
crate::managed_agents::validate_provider_config(config)?;
Some(
crate::managed_agents::resolve_provider_binary(id)?
.display()
.to_string(),
)
}
BackendKind::Local => None,
};
if backend != record.backend {
record.backend_agent_id = None;
}
record.start_on_app_launch = backend == BackendKind::Local;
record.backend = backend;
Ok(match &record.backend {
BackendKind::Provider { .. } => Some(record.backend.clone()),
BackendKind::Local => None,
})
}

pub(super) async fn deploy_updated_backend(
app: &AppHandle,
state: &AppState,
pubkey: &str,
backend: Option<BackendKind>,
fallback: ManagedAgentSummary,
) -> Result<ManagedAgentSummary, String> {
let Some(BackendKind::Provider { id, config }) = backend else {
return Ok(fallback);
};
let agent_json = {
let _guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(app)?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
super::agents::build_deploy_payload(app, state, record)?
};
super::agents::deploy_to_provider(app, state, pubkey, &id, &config, agent_json, None).await?;
let _guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
build_managed_agent_summary(
app,
record,
&runtimes,
&load_personas(app).unwrap_or_default(),
)
}
36 changes: 25 additions & 11 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
use std::collections::{BTreeMap, HashSet};

use super::agent_model_process::run_agent_models_command;
use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback};
use nostr::Keys;
use serde::Deserialize;
use std::collections::{BTreeMap, HashSet};
use tauri::{AppHandle, State};

use super::agent_model_process::run_agent_models_command;
use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback};

use crate::{
app_state::AppState,
managed_agents::{
Expand Down Expand Up @@ -778,8 +776,9 @@ pub async fn update_managed_agent(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<UpdateManagedAgentResponse, String> {
let pubkey = input.pubkey.clone();
// Phase 1: local save (synchronous, under lock)
let (summary, sync_params, rollback) = {
let (summary, sync_params, rollback, provider_deploy) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
Expand All @@ -795,9 +794,8 @@ pub async fn update_managed_agent(
state.clear_agent_session_caches(pubkey);
}

let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
let record = find_managed_agent_mut(&mut records, &pubkey)?;
let previous_record = record.clone();

let mut name_changed = false;
if let Some(name_update) = input.name {
let trimmed = name_update.trim().to_string();
Expand Down Expand Up @@ -897,14 +895,21 @@ pub async fn update_managed_agent(
record.respond_to_allowlist = prospective_allowlist;
}

let backend_deploy = super::agent_backend_update::apply_backend_update(
&app,
record,
&mut runtimes,
input.backend,
)?;

record.updated_at = now_iso();

save_managed_agents(&app, &records)?;

let record = records
.iter()
.find(|r| r.pubkey == input.pubkey)
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {} not found", pubkey))?;

// Publish the edit to the relay. After-save, inside the lock, before
// any .await. The retention upsert hashes the opt-IN projection, so an
Expand Down Expand Up @@ -941,11 +946,20 @@ pub async fn update_managed_agent(
build_managed_agent_summary(&app, record, &runtimes, &personas)?
};
let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record));
(summary, sync_params, rollback)
(summary, sync_params, rollback, backend_deploy)
}; // lock dropped here

try_regenerate_nest(&app);

let summary = super::agent_backend_update::deploy_updated_backend(
&app,
&state,
&pubkey,
provider_deploy,
summary,
)
.await?;

// Phase 2: relay profile sync (async, outside lock). A rename is committed
// only when this succeeds; otherwise restore the complete pre-edit record
// so Desktop and the relay keep one authoritative name.
Expand Down
45 changes: 45 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use crate::{commands::agent_backend_update, managed_agents::BackendKind};

#[test]
fn openai_model_normalization_keeps_agent_text_models() {
Expand Down Expand Up @@ -260,3 +261,47 @@ fn is_databricks_provider_matches_both_variants() {
assert!(!is_databricks_provider(Some("anthropic")));
assert!(!is_databricks_provider(None));
}

#[test]
fn absent_backend_update_preserves_current_backend() {
let current = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({"name": "existing"}),
};
assert_eq!(
agent_backend_update::requested_backend_update(&current, None, None).unwrap(),
None
);
}

#[test]
fn provider_backend_update_requests_deploy() {
let requested = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({"name": "changed"}),
};
assert_eq!(
agent_backend_update::requested_backend_update(
&BackendKind::Local,
None,
Some(requested.clone())
)
.unwrap(),
Some(requested)
);
}

#[test]
fn deployed_provider_cannot_move_local_without_undeploy_protocol() {
let current = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({}),
};
let error = agent_backend_update::requested_backend_update(
&current,
Some("remote-1"),
Some(BackendKind::Local),
)
.unwrap_err();
assert!(error.contains("does not support undeploy"));
}
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ pub(super) async fn start_local_agent_with_preflight(
///
/// Returns Ok(()) on success, Err(message) on failure. Either way the record is
/// updated and saved before returning.
async fn deploy_to_provider(
pub(super) async fn deploy_to_provider(
app: &AppHandle,
state: &AppState,
pubkey: &str,
Expand Down Expand Up @@ -1320,7 +1320,7 @@ pub async fn delete_managed_agent(

#[path = "agents_deploy.rs"]
mod deploy;
use deploy::build_deploy_payload;
pub(super) use deploy::build_deploy_payload;
#[cfg(test)]
use deploy::deploy_payload_json;
#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub(crate) fn resolve_deploy_model_provider<'a>(
/// serialize `"private_key_nsec": ""` and launch the agent with no
/// identity — the same hazard the local spawn path refuses via
/// `spawn_key_refusal`.
pub(super) fn build_deploy_payload(
pub(crate) fn build_deploy_payload(
app: &AppHandle,
state: &AppState,
record: &ManagedAgentRecord,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod agent_auth;
mod agent_backend_update;
mod agent_config;
mod agent_discovery;
mod agent_logs;
Expand Down
51 changes: 45 additions & 6 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@ use uuid::Uuid;

use crate::{
app_state::AppState,
commands::{export_util::save_bytes_with_dialog, personas::resolve_snapshot_import_behavior},
commands::{
agents::{build_deploy_payload, deploy_to_provider},
export_util::save_bytes_with_dialog,
personas::resolve_snapshot_import_behavior,
},
managed_agents::team_snapshot::{
build_team_snapshot, decode_team_snapshot_json, decode_team_snapshot_png,
encode_team_snapshot_json, encode_team_snapshot_png, TeamSnapshot,
},
managed_agents::{
agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel},
load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents,
save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord,
save_personas, save_teams, AgentDefinition, BackendKind, ManagedAgentRecord, TeamRecord,
},
relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
Expand Down Expand Up @@ -218,6 +222,9 @@ pub struct TeamSnapshotImportConfirm {
pub file_bytes: Vec<u8>,
/// Applied uniformly to every member in v1.
pub keep_allowlist: bool,
/// Applied uniformly to every imported member. Absent preserves local.
#[serde(default)]
pub backend: Option<BackendKind>,
}

/// Per-member outcome reported after a confirmed team snapshot import.
Expand Down Expand Up @@ -250,6 +257,10 @@ pub struct EncodedTeamSnapshotPayload {
pub file_name: String,
}

fn import_backend_or_local(backend: Option<BackendKind>) -> BackendKind {
backend.unwrap_or_default()
}

/// Per-member minted key material, assembled before entering the store lock.
struct MintedMember {
definition: AgentDefinition,
Expand Down Expand Up @@ -502,6 +513,11 @@ pub async fn confirm_team_snapshot_import(
) -> Result<TeamSnapshotImportResult, String> {
// ── Phase 1: validate (no I/O) ───────────────────────────────────────────
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
let import_backend = import_backend_or_local(input.backend);
if let BackendKind::Provider { config, id } = &import_backend {
crate::managed_agents::validate_provider_config(config)?;
crate::managed_agents::resolve_provider_binary(id)?;
}
let now = now_iso();

// Resolve behavioral defaults for every member before any key generation.
Expand Down Expand Up @@ -575,9 +591,15 @@ pub async fn confirm_team_snapshot_import(
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: crate::managed_agents::BackendKind::Local,
backend: import_backend.clone(),
backend_agent_id: None,
provider_binary_path: None,
provider_binary_path: if let BackendKind::Provider { ref id, .. } = import_backend {
crate::managed_agents::resolve_provider_binary(id)
.ok()
.map(|path| path.display().to_string())
} else {
None
},
team_id: Some(imported_team.id.clone()),
persona_team_dir: None,
persona_name_in_team: None,
Expand Down Expand Up @@ -750,7 +772,7 @@ pub async fn confirm_team_snapshot_import(
imported_team
};

// ── Phase 4 & 5: profile sync + memory restore (async, outside lock) ────
// ── Phase 4-6: profile sync, provider deploy, memory restore ──────────
let relay_ws = relay_ws_url_with_override(&state);
let mut member_results: Vec<TeamSnapshotImportMemberResult> = Vec::with_capacity(minted.len());

Expand All @@ -769,7 +791,24 @@ pub async fn confirm_team_snapshot_import(
.await
.err();

// Phase 5: memory restore (best-effort).
// Phase 5: provider deploy for remote imports (best-effort).
if let BackendKind::Provider { id, config } = &m.record.backend {
let agent_json = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let record = records
.iter()
.find(|record| record.pubkey == m.pubkey)
.ok_or_else(|| format!("agent {} not found", m.pubkey))?;
build_deploy_payload(&app, &state, record)?
};
let _ = deploy_to_provider(&app, &state, &m.pubkey, id, config, agent_json, None).await;
}

// Phase 6: memory restore (best-effort).
let memory_total = snap_member.memory.entries.len();
let mut memory_written = 0usize;
let mut memory_errors: Vec<String> = Vec::new();
Expand Down
Loading
Loading