mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
spawn_event_sync returns Result<(), String> so dispatch failure can be captured
by the workspace-apply post-commit section rather than being silently ignored.
The value is always Ok(()) since tauri::async_runtime::spawn is infallible; this
establishes the typed interface for future signaling.
try_regenerate_nest returns Result<(), String> instead of swallowing errors.
All fire-and-forget callers updated to .ok() to explicitly discard the Result.
apply_workspace post-commit:
- try_regenerate_nest moved out of the spawn_blocking closure into the async
post-commit section so its Result can populate the degraded vec.
- spawn_event_sync Result captured; dispatch failure pushed to degraded.
- Nest failure reported as 'nest context regeneration failed: ...' degradation.
useCommunityInit.ts: post-commit degraded items now emit a toast.warning (8 s)
via sonner so the user sees partial failures. Previously only console.warn.
AppShell.tsx: useManagedAgentRuntimeReconciliation key changed from
String(reinitKey) to `${activeCommunity?.id}-${reinitKey}`. A same-relay
identity swap (new communityId, unchanged reinitKey) now correctly re-triggers
runtime reconciliation. Destructured activeCommunity and reinitKey from
communitiesHook and updated two other call sites for consistency.
dead pub use exports in migration.rs removed (fold, backfill, detach, strip,
materialize — all now accessed only through scoped _in_dir/_at variants).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1376 lines
55 KiB
Rust
1376 lines
55 KiB
Rust
use nostr::{Keys, ToBech32};
|
|
use tauri::{AppHandle, State};
|
|
|
|
use crate::{
|
|
app_state::AppState,
|
|
managed_agents::{
|
|
build_managed_agent_summary, current_instance_id, discover_provider_candidates,
|
|
ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas,
|
|
load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy,
|
|
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, sync_managed_agent_profile},
|
|
util::now_iso,
|
|
};
|
|
|
|
/// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER`
|
|
/// as a fallback for legacy agent records that have no NIP-OA `auth_tag`.
|
|
pub(super) fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
|
|
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
|
Ok(keys.public_key().to_hex())
|
|
}
|
|
|
|
/// Retain a freshly authored managed-agent event in the local store, flagged
|
|
/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held
|
|
/// body after `save_managed_agents`, NEVER across an `.await`: it acquires
|
|
/// `state.keys` and a retention-db connection, both `std::sync` guards, and
|
|
/// drops them before returning.
|
|
///
|
|
/// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the
|
|
/// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is
|
|
/// `30177:<owner>:<agent_pubkey>`. The event content is the opt-IN
|
|
/// [`agent_event_content`] projection — the retention upsert's content-equality
|
|
/// guard compares this projection, so an operational start/stop that mutates
|
|
/// only runtime fields produces an identical row and never re-enqueues a
|
|
/// publish. Best-effort: a failure here is logged and swallowed so a retention
|
|
/// hiccup never blocks the disk-authoritative write.
|
|
pub(super) fn retain_managed_agent_pending(
|
|
app: &AppHandle,
|
|
state: &AppState,
|
|
record: &ManagedAgentRecord,
|
|
) {
|
|
use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db};
|
|
|
|
let result = (|| -> Result<(), String> {
|
|
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
|
|
let conn = open_retention_db(&scope.db_path)?;
|
|
// Shared engine with the boot-time reconcile: projection content diff
|
|
// (no republish for runtime-only churn) + monotonic created_at bump
|
|
// past the retained head (NIP-AP step 3).
|
|
retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ())
|
|
})();
|
|
if let Err(e) = result {
|
|
eprintln!("buzz-desktop: agent-retain: {e}");
|
|
}
|
|
}
|
|
|
|
/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both
|
|
/// inside the `managed_agents_store_lock`-held delete body and NEVER across an
|
|
/// `.await`.
|
|
///
|
|
/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at
|
|
/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can
|
|
/// never resurrect it after the tombstone publishes, then the kind:5 tombstone
|
|
/// is retained at its own `(5, owner, agent_pubkey)` coordinate with
|
|
/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a
|
|
/// failure is logged and swallowed so a retention hiccup never blocks the
|
|
/// disk-authoritative delete.
|
|
pub(super) fn tombstone_managed_agent_pending(
|
|
app: &AppHandle,
|
|
state: &AppState,
|
|
agent_pubkey: &str,
|
|
) {
|
|
use crate::managed_agents::{
|
|
agent_events::build_agent_delete,
|
|
retention::{
|
|
delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag,
|
|
RetainedEvent,
|
|
},
|
|
};
|
|
use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
|
|
use nostr::JsonUtil;
|
|
|
|
const KIND_DELETE: u32 = 5;
|
|
|
|
let result = (|| -> Result<(), String> {
|
|
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
|
|
let owner_pubkey = scope.owner_keys.public_key().to_hex();
|
|
let event = build_agent_delete(agent_pubkey, &owner_pubkey)?
|
|
.sign_with_keys(&scope.owner_keys)
|
|
.map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?;
|
|
let conn = open_retention_db(&scope.db_path)?;
|
|
delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?;
|
|
retain_event(
|
|
&conn,
|
|
&RetainedEvent {
|
|
kind: KIND_DELETE,
|
|
pubkey: owner_pubkey,
|
|
// Key by the target coordinate so cross-kind d-tag tombstones
|
|
// occupy distinct rows (F2c).
|
|
d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey),
|
|
content: event.content.to_string(),
|
|
created_at: event.created_at.as_secs() as i64,
|
|
raw_event: event.as_json(),
|
|
pending_sync: true,
|
|
},
|
|
)
|
|
})();
|
|
if let Err(e) = result {
|
|
eprintln!("buzz-desktop: agent-tombstone: {e}");
|
|
}
|
|
}
|
|
|
|
/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an
|
|
/// agent is deleted. Pure given the keys — unit-testable without an
|
|
/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action
|
|
/// (`events::build_archive_identity_request`); the machine-readable reason is
|
|
/// `retired` (NIP-IA suggested code for a deliberately decommissioned key).
|
|
///
|
|
/// The owner auth tag is minted locally from the same keys used to sign the
|
|
/// request, avoiding a network fetch while the managed-agent store lock is
|
|
/// held. The relay still independently verifies it against the agent's live
|
|
/// kind:0.
|
|
pub(super) fn build_agent_archive_request(
|
|
keys: &nostr::Keys,
|
|
agent_pubkey: &str,
|
|
) -> Result<nostr::Event, String> {
|
|
let auth_tag = if keys
|
|
.public_key()
|
|
.to_hex()
|
|
.eq_ignore_ascii_case(agent_pubkey)
|
|
{
|
|
None
|
|
} else {
|
|
let agent = nostr::PublicKey::from_hex(agent_pubkey)
|
|
.map_err(|e| format!("invalid agent pubkey: {e}"))?;
|
|
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "")
|
|
.map_err(|e| format!("failed to build owner auth tag: {e}"))?;
|
|
let parts: Vec<String> = serde_json::from_str(&tag_json)
|
|
.map_err(|e| format!("failed to parse owner auth tag: {e}"))?;
|
|
Some(
|
|
<[String; 4]>::try_from(parts)
|
|
.map_err(|_| "owner auth tag must have four elements".to_string())?,
|
|
)
|
|
};
|
|
crate::events::build_archive_identity_request(
|
|
agent_pubkey,
|
|
"",
|
|
Some("retired"),
|
|
None,
|
|
auth_tag.as_ref(),
|
|
)?
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| format!("failed to sign archive request: {e}"))
|
|
}
|
|
|
|
/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained
|
|
/// next to its kind:5 tombstone with `pending_sync = 1`.
|
|
///
|
|
/// The tombstone removes the agent's 30177 record cross-device, but the
|
|
/// agent's `kind:0` and channel membership keep populating member pickers and
|
|
/// autocomplete on the relay until the identity is archived. Retaining the
|
|
/// request here gives archival the same offline durability as the tombstone;
|
|
/// the flush loop is the sole publisher and re-signs the request with a fresh
|
|
/// `created_at` at publish time, because the relay enforces a ±120s freshness
|
|
/// window on 9035s.
|
|
///
|
|
/// Same contract as `tombstone_managed_agent_pending`: called inside the
|
|
/// `managed_agents_store_lock`-held delete body, never across an `.await`,
|
|
/// best-effort — a failure is logged and swallowed so it never blocks the
|
|
/// disk-authoritative delete.
|
|
pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) {
|
|
use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent};
|
|
use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST;
|
|
use nostr::JsonUtil;
|
|
|
|
let result = (|| -> Result<(), String> {
|
|
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
|
|
let owner_pubkey = scope.owner_keys.public_key().to_hex();
|
|
let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?;
|
|
let conn = open_retention_db(&scope.db_path)?;
|
|
retain_event(
|
|
&conn,
|
|
&RetainedEvent {
|
|
kind: KIND_IA_ARCHIVE_REQUEST,
|
|
pubkey: owner_pubkey,
|
|
d_tag: agent_pubkey.to_string(),
|
|
content: event.content.to_string(),
|
|
created_at: event.created_at.as_secs() as i64,
|
|
raw_event: event.as_json(),
|
|
pending_sync: true,
|
|
},
|
|
)
|
|
})();
|
|
if let Err(e) = result {
|
|
eprintln!("buzz-desktop: agent-archive: {e}");
|
|
}
|
|
}
|
|
|
|
fn normalize_relay_mesh(
|
|
config: Option<&RelayMeshConfig>,
|
|
backend: &BackendKind,
|
|
) -> Result<Option<RelayMeshConfig>, String> {
|
|
let Some(config) = config else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let model_ref = config.model_ref.trim();
|
|
if model_ref.is_empty() {
|
|
return Err("Buzz shared compute model is required".to_string());
|
|
}
|
|
if backend != &BackendKind::Local {
|
|
return Err("Buzz shared compute agents must use the local backend".to_string());
|
|
}
|
|
|
|
Ok(Some(RelayMeshConfig {
|
|
model_ref: model_ref.to_string(),
|
|
}))
|
|
}
|
|
|
|
fn trim_to_optional_string(value: &str) -> Option<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
fn resolve_created_avatar_url(
|
|
requested_avatar_url: Option<&str>,
|
|
persona_avatar_url: Option<String>,
|
|
agent_command: &str,
|
|
) -> Option<String> {
|
|
requested_avatar_url
|
|
.and_then(trim_to_optional_string)
|
|
.or_else(|| {
|
|
persona_avatar_url
|
|
.as_deref()
|
|
.and_then(trim_to_optional_string)
|
|
})
|
|
.or_else(|| managed_agent_avatar_url(agent_command))
|
|
}
|
|
|
|
#[cfg(feature = "mesh-llm")]
|
|
async fn ensure_relay_mesh_for_record(
|
|
app: &AppHandle,
|
|
model_id: Option<&str>,
|
|
allow_fresh_create_start: bool,
|
|
) -> Result<(), String> {
|
|
crate::commands::ensure_relay_mesh_for_record(app, model_id, allow_fresh_create_start).await
|
|
}
|
|
|
|
#[cfg(not(feature = "mesh-llm"))]
|
|
async fn ensure_relay_mesh_for_record(
|
|
_app: &AppHandle,
|
|
_model_id: Option<&str>,
|
|
_allow_fresh_create_start: bool,
|
|
) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn start_local_agent_pairs_with_preflight(
|
|
app: &AppHandle,
|
|
state: &AppState,
|
|
pubkey: &str,
|
|
relay_urls: &[String],
|
|
) -> Result<ManagedAgentSummary, String> {
|
|
let record_snapshot = {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
load_managed_agents(app)?
|
|
.into_iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?
|
|
};
|
|
if record_snapshot.backend != BackendKind::Local {
|
|
return Err(format!("agent {pubkey} is not a local agent"));
|
|
}
|
|
let personas_for_preflight = load_personas(app).unwrap_or_default();
|
|
let global_for_preflight =
|
|
crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
|
let mesh_model_id =
|
|
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
|
|
&record_snapshot,
|
|
&personas_for_preflight,
|
|
&global_for_preflight,
|
|
);
|
|
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?;
|
|
|
|
{
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let mut records = load_managed_agents(app)?;
|
|
let record = find_managed_agent_mut(&mut records, pubkey)?;
|
|
let personas = load_personas(app).unwrap_or_default();
|
|
if let Some(persona_id) = record.persona_id.clone() {
|
|
if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) {
|
|
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
|
|
record.updated_at = crate::util::now_iso();
|
|
}
|
|
}
|
|
save_managed_agents(app, &records)?;
|
|
if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) {
|
|
retain_managed_agent_pending(app, state, saved_record);
|
|
}
|
|
}
|
|
|
|
let mut errors = Vec::new();
|
|
for relay_url in relay_urls {
|
|
if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy(
|
|
pubkey.to_string(),
|
|
relay_url.clone(),
|
|
app.clone(),
|
|
) {
|
|
errors.push(format!("{relay_url}: {error}"));
|
|
}
|
|
}
|
|
if !errors.is_empty() {
|
|
return Err(format!(
|
|
"failed to restart one or more managed-agent runtime pairs: {}",
|
|
errors.join("; ")
|
|
));
|
|
}
|
|
|
|
let _store_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 personas = load_personas(app).unwrap_or_default();
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
|
build_managed_agent_summary(
|
|
app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
pub(super) async fn start_local_agent_with_preflight(
|
|
app: &AppHandle,
|
|
state: &AppState,
|
|
pubkey: &str,
|
|
owner_hex: &str,
|
|
allow_fresh_create_start: bool,
|
|
) -> Result<ManagedAgentSummary, String> {
|
|
let record_snapshot = {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let records = load_managed_agents(app)?;
|
|
records
|
|
.iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.cloned()
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?
|
|
};
|
|
|
|
if record_snapshot.backend != BackendKind::Local {
|
|
return Err(format!("agent {pubkey} is not a local agent"));
|
|
}
|
|
|
|
// Preflight against the same resolution spawn uses — `resolve_effective_config`
|
|
// (definition → global fallback). A linked instance's own `provider`/`model`/
|
|
// `relay_mesh` bytes never contribute: this reads the CURRENT definition
|
|
// directly, so a definition edit that flips `provider` to/from relay-mesh
|
|
// between saves is reflected here without needing a prospective re-snapshot;
|
|
// for a global-inherited blank definition, it also folds in the global
|
|
// default, which record-byte sniffing could never see.
|
|
let personas = load_personas(app).unwrap_or_default();
|
|
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
|
let mesh_model_id =
|
|
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
|
|
&record_snapshot,
|
|
&personas,
|
|
&global,
|
|
);
|
|
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
|
|
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let mut records = load_managed_agents(app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let record = find_managed_agent_mut(&mut records, pubkey)?;
|
|
if record.backend != BackendKind::Local {
|
|
return Err(format!("agent {pubkey} is no longer a local agent"));
|
|
}
|
|
// Re-snapshot the persona onto the record at every spawn so the agent always
|
|
// starts with the current persona config (system_prompt, model, provider,
|
|
// runtime). This clears the "out of date" drift badge without requiring a
|
|
// delete+recreate. See `apply_persona_snapshot` for the precedence and
|
|
// env-override self-heal rules.
|
|
// Load personas once: used for snapshot application below and summary build
|
|
// at the end — avoids a second disk read for the same file in the same call.
|
|
let personas = load_personas(app).unwrap_or_default();
|
|
if let Some(persona_id) = record.persona_id.clone() {
|
|
match personas.iter().find(|p| p.id == persona_id) {
|
|
Some(persona) => {
|
|
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
|
|
record.updated_at = crate::util::now_iso();
|
|
}
|
|
None => {
|
|
return Err(
|
|
crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?;
|
|
save_managed_agents(app, &records)?;
|
|
if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) {
|
|
retain_managed_agent_pending(app, state, saved_record);
|
|
}
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
|
build_managed_agent_summary(
|
|
app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
|
|
/// spawn_blocking, and persists the result (backend_agent_id or last_error).
|
|
///
|
|
/// Idempotency: calling deploy on an already-deployed agent sends the same payload
|
|
/// again. Providers are expected to handle this as an update-in-place or no-op —
|
|
/// the protocol does not include an explicit `undeploy` operation (deferred to v2).
|
|
///
|
|
/// Returns Ok(()) on success, Err(message) on failure. Either way the record is
|
|
/// updated and saved before returning.
|
|
async fn deploy_to_provider(
|
|
app: &AppHandle,
|
|
state: &AppState,
|
|
pubkey: &str,
|
|
provider_id: &str,
|
|
config: &serde_json::Value,
|
|
agent_json: serde_json::Value,
|
|
cached_binary_path: Option<&str>,
|
|
) -> Result<(), String> {
|
|
// Resolve via discovered candidates only. Cached path must match BOTH
|
|
// "is a discovered candidate" AND "belongs to this provider_id". A tampered
|
|
// record cannot redirect deploys to a different provider's binary.
|
|
let bin_path = cached_binary_path
|
|
.map(std::path::PathBuf::from)
|
|
.filter(|p| p.exists())
|
|
.map(|p| p.canonicalize().unwrap_or(p))
|
|
.filter(|canonical| {
|
|
discover_provider_candidates().iter().any(|(id, cp)| {
|
|
id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical)
|
|
})
|
|
})
|
|
.map_or_else(|| resolve_provider_binary(provider_id), Ok)?;
|
|
|
|
let config_clone = config.clone();
|
|
let deploy_result =
|
|
tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone))
|
|
.await
|
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
|
|
|
|
// Persist result under lock.
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let mut records = load_managed_agents(app)?;
|
|
let rec = records
|
|
.iter_mut()
|
|
.find(|r| r.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
|
|
|
match deploy_result {
|
|
Ok(backend_agent_id) => {
|
|
rec.backend_agent_id = Some(backend_agent_id);
|
|
rec.last_started_at = Some(now_iso());
|
|
rec.updated_at = now_iso();
|
|
rec.last_error = None;
|
|
}
|
|
Err(ref e) => {
|
|
rec.last_error = Some(e.clone());
|
|
rec.updated_at = now_iso();
|
|
save_managed_agents(app, &records)?;
|
|
return Err(e.clone());
|
|
}
|
|
}
|
|
save_managed_agents(app, &records)?;
|
|
Ok(())
|
|
}
|
|
|
|
// Async so the blocking body (disk reads of agent/persona records, per-agent
|
|
// process-liveness syscalls, and a possible save) runs on Tauri's worker pool
|
|
// via spawn_blocking instead of the main UI thread — it was a beachball on the
|
|
// agents menu mount and after every start/stop/edit refetch. State is re-derived
|
|
// from the owned AppHandle inside the closure because `State<'_, _>` is borrowed
|
|
// and `std::sync::MutexGuard` is not `Send`.
|
|
#[tauri::command]
|
|
pub async fn list_managed_agents(app: AppHandle) -> Result<Vec<ManagedAgentSummary>, String> {
|
|
use tauri::Manager;
|
|
tokio::task::spawn_blocking(move || {
|
|
let state = app.state::<AppState>();
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
let (sync_changed, exited_pubkeys) =
|
|
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
// One disk read for the whole list — build_managed_agent_summary takes
|
|
// the config as a parameter precisely so this poll-every-5s call does
|
|
// not re-read it per record.
|
|
let global_config =
|
|
crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
|
|
records
|
|
.iter()
|
|
.map(|record| {
|
|
build_managed_agent_summary(&app, record, &runtimes, &personas, &global_config)
|
|
})
|
|
.collect()
|
|
})
|
|
.await
|
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_managed_agent(
|
|
input: CreateManagedAgentRequest,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<CreateManagedAgentResponse, String> {
|
|
let name = input.name.trim().to_string();
|
|
if name.is_empty() {
|
|
return Err("agent name is required".to_string());
|
|
}
|
|
let requested_persona_id = input
|
|
.persona_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string);
|
|
if let Some(parallelism) = input.parallelism {
|
|
if !(1..=32).contains(¶llelism) {
|
|
return Err("parallelism must be between 1 and 32".to_string());
|
|
}
|
|
}
|
|
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
|
|
|
|
// Validate & normalize the respond-to allowlist BEFORE any side effects.
|
|
// The harness has its own validator (buzz-acp/src/config.rs) but we want
|
|
// to catch malformed input at the boundary so the agent never tries to
|
|
// start with a list that will crash it on launch. The mode/allowlist
|
|
// pairing (and the definition-default fallback) is resolved later at the
|
|
// mint site via `resolve_mint_behavioral_defaults`, where the linked
|
|
// definition is in hand.
|
|
let respond_to_allowlist =
|
|
crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?;
|
|
if input.respond_to == Some(crate::managed_agents::RespondTo::Allowlist)
|
|
&& respond_to_allowlist.is_empty()
|
|
{
|
|
return Err(
|
|
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
|
|
);
|
|
}
|
|
|
|
// Snapshot the workspace owner pubkey for the legacy-record auth_tag
|
|
// fallback. Computed outside the records lock to keep lock ordering simple.
|
|
let owner_hex = workspace_owner_hex(&state)?;
|
|
|
|
// ── Phase 1: generate keys (sync lock) ────────────────────────────────────
|
|
let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
let (sync_changed, exited_pubkeys) =
|
|
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
if let Some(persona_id) = requested_persona_id.as_deref() {
|
|
let personas = load_personas(&app)?;
|
|
ensure_persona_is_active(&personas, persona_id)?;
|
|
}
|
|
let keys = Keys::generate();
|
|
let pubkey = keys.public_key().to_hex();
|
|
if records.iter().any(|record| record.pubkey == pubkey) {
|
|
return Err(format!("agent {pubkey} already exists"));
|
|
}
|
|
let private_key_nsec = keys
|
|
.secret_key()
|
|
.to_bech32()
|
|
.map_err(|error| format!("failed to encode private key: {error}"))?;
|
|
|
|
// Store the relay override exactly as supplied (trimmed). An explicit
|
|
// value pins the agent; empty stays empty and resolves to the active
|
|
// workspace relay at read-time. Uniform for Local and Provider.
|
|
let resolved_relay_url = input
|
|
.relay_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
(keys, private_key_nsec, pubkey, resolved_relay_url, input)
|
|
};
|
|
|
|
// ── Pre-Phase 2: validate provider config BEFORE any side effects ────────
|
|
if let BackendKind::Provider { ref config, ref id } = input.backend {
|
|
validate_provider_config(config)?;
|
|
// Validate via discovered candidates — not raw resolve_command.
|
|
resolve_provider_binary(id)?;
|
|
}
|
|
|
|
let relay_mesh = normalize_relay_mesh(input.relay_mesh.as_ref(), &input.backend)?;
|
|
|
|
// ── Phase 2: compute NIP-OA auth tag (sync) ──────────────────────────────
|
|
// Agents authenticate via the auth tag in their kind:0 profile event.
|
|
// No tokens are minted. Fail closed: bad auth tag → don't create agent.
|
|
let auth_tag = {
|
|
let owner_keys = state.signing_keys()?;
|
|
// Bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip.
|
|
let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex())
|
|
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
|
|
let compat_agent = nostr::PublicKey::from_hex(&agent_keys.public_key().to_hex())
|
|
.map_err(|e| format!("failed to bridge agent pubkey: {e}"))?;
|
|
let tag = buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "")
|
|
.map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?;
|
|
Some(tag)
|
|
};
|
|
|
|
// ── Phase 3: save record (sync lock) ───────────────────────────────────────
|
|
let (agent, resolved_avatar_url) = {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
let (sync_changed, exited_pubkeys) =
|
|
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
|
|
// Guard against a duplicate pubkey appearing between phase 1 and phase 3
|
|
// (extremely unlikely but safe to check).
|
|
if records.iter().any(|record| record.pubkey == pubkey) {
|
|
return Err(format!("agent {pubkey} already exists"));
|
|
}
|
|
// Provider config was already validated in Pre-Phase 2; cache the discovered binary path for deploy_to_provider.
|
|
let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend {
|
|
// Use resolve_provider_binary (discovered candidates only).
|
|
resolve_provider_binary(id)
|
|
.ok()
|
|
.map(|p| p.display().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Load personas once for harness/pack/avatar resolution below.
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
|
|
// Harness resolution: the persona's runtime is authoritative. A
|
|
// persona-backed create stores an `agent_command_override` ONLY when the
|
|
// user deliberately picked a divergent runtime (`harness_override`) —
|
|
// e.g. AddChannelBotDialog's runtime selector. A divergence WITHOUT that
|
|
// flag is a missing-runtime fallback from `resolvePersonaRuntime`, not a
|
|
// pin, and must inherit so it doesn't freeze on the fallback harness once
|
|
// the persona's runtime is installed. A persona-less create always
|
|
// preserves the picked command as a real pin.
|
|
let agent_command_override = crate::managed_agents::create_time_agent_command_override(
|
|
requested_persona_id.as_deref(),
|
|
&personas,
|
|
input.agent_command.as_deref(),
|
|
input.harness_override,
|
|
);
|
|
// The create-time snapshot used for arg/mcp/avatar derivations and
|
|
// legacy reconcile. Authoritative spawn resolution re-derives this via
|
|
// `effective_agent_command` at use-time.
|
|
let agent_command = crate::managed_agents::effective_agent_command(
|
|
requested_persona_id.as_deref(),
|
|
&personas,
|
|
agent_command_override.as_deref(),
|
|
);
|
|
let agent_args = normalize_agent_args(
|
|
&agent_command,
|
|
input
|
|
.agent_args
|
|
.iter()
|
|
.map(|arg| arg.trim().to_string())
|
|
.filter(|arg| !arg.is_empty())
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
|
|
// Derive MCP command exclusively from the runtime catalog — the
|
|
// per-record field is never read at spawn time so user-supplied input
|
|
// is silently discarded. Always sourcing from the catalog ensures
|
|
// new agents pick up the correct value without any stored override.
|
|
let mcp_command = match crate::managed_agents::known_acp_runtime(&agent_command) {
|
|
Some(p) => p.mcp_command.unwrap_or("").to_string(),
|
|
None => String::new(),
|
|
};
|
|
|
|
let team_id = input
|
|
.team_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string);
|
|
if let Some(team_id) = &team_id {
|
|
if !load_teams(&app)?.iter().any(|team| &team.id == team_id) {
|
|
return Err(format!("team {team_id} not found"));
|
|
}
|
|
}
|
|
|
|
// Resolve the avatar URL once at creation and persist it on the record.
|
|
// Explicit input wins, then the persona's own avatar, then the runtime
|
|
// fallback. Storing it lets reconciliation compare against what was
|
|
// actually published instead of re-deriving it.
|
|
let persona_avatar_url = requested_persona_id.as_ref().and_then(|persona_id| {
|
|
personas
|
|
.iter()
|
|
.find(|persona| persona.id == *persona_id)?
|
|
.avatar_url
|
|
.clone()
|
|
});
|
|
let resolved_avatar_url = resolve_created_avatar_url(
|
|
input.avatar_url.as_deref(),
|
|
persona_avatar_url,
|
|
&agent_command,
|
|
);
|
|
|
|
// Pin the persona config onto the record at create. After this, spawn
|
|
// and deploy read these snapshotted fields, never the live persona, so
|
|
// the agent stays on the config it was created with across restarts;
|
|
// delete+respawn re-runs create and rewrites the snapshot. env_vars are
|
|
// NOT pinned: `record.env_vars` holds agent-level overrides only
|
|
// (input.env_vars), and the live persona env is merged underneath at
|
|
// read time (spawn / readiness / deploy) so persona credential edits
|
|
// refresh on the next spawn like prompt/model/provider already do.
|
|
let linked_persona = requested_persona_id.as_deref().and_then(|pid| {
|
|
load_personas(&app)
|
|
.ok()?
|
|
.into_iter()
|
|
.find(|persona| persona.id == pid)
|
|
});
|
|
let persona_snapshot = linked_persona
|
|
.as_ref()
|
|
.map(crate::managed_agents::persona_events::persona_snapshot);
|
|
let snapshot_prompt = persona_snapshot
|
|
.as_ref()
|
|
.and_then(|s| s.system_prompt.clone());
|
|
let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone());
|
|
let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone());
|
|
let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone());
|
|
let effective_provider = snapshot_provider
|
|
.or_else(|| input.provider.as_deref().and_then(trim_to_optional_string));
|
|
let mut effective_model =
|
|
snapshot_model.or_else(|| input.model.as_deref().and_then(trim_to_optional_string));
|
|
if effective_provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID)
|
|
&& effective_model.is_none()
|
|
{
|
|
effective_model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string());
|
|
}
|
|
|
|
// Mint-time behavioral quad: explicit input wins, then the linked
|
|
// definition's NIP-AP defaults, then client defaults. The ONLY parse
|
|
// point for definition behavioral strings — fails loudly on a bad
|
|
// mode/range instead of minting an agent the author didn't describe.
|
|
let minted = crate::managed_agents::resolve_mint_behavioral_defaults(
|
|
input.respond_to,
|
|
respond_to_allowlist.clone(),
|
|
input.parallelism,
|
|
linked_persona.as_ref(),
|
|
)?;
|
|
|
|
let record = crate::managed_agents::ManagedAgentRecord {
|
|
pubkey: pubkey.clone(),
|
|
name: name.clone(),
|
|
persona_id: requested_persona_id.clone(),
|
|
team_id,
|
|
private_key_nsec: private_key_nsec.clone(),
|
|
auth_tag: auth_tag.clone(),
|
|
relay_url: resolved_relay_url.clone(),
|
|
avatar_url: resolved_avatar_url.clone(),
|
|
acp_command: input
|
|
.acp_command
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(DEFAULT_ACP_COMMAND)
|
|
.to_string(),
|
|
agent_command,
|
|
agent_command_override,
|
|
agent_args,
|
|
mcp_command,
|
|
// BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness;
|
|
// store the schema default only. Use idle_timeout_seconds or
|
|
// max_turn_duration_seconds for actual turn-length control.
|
|
turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
|
// 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum.
|
|
idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0),
|
|
max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0),
|
|
parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM),
|
|
system_prompt: snapshot_prompt.or_else(|| {
|
|
input
|
|
.system_prompt
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string)
|
|
}),
|
|
model: effective_model.clone(),
|
|
provider: effective_provider.clone(),
|
|
persona_source_version: snapshot_source_version,
|
|
// Provider agents are managed externally — force false.
|
|
start_on_app_launch: if input.backend != BackendKind::Local {
|
|
false
|
|
} else {
|
|
input.start_on_app_launch
|
|
},
|
|
auto_restart_on_config_change: true,
|
|
runtime_pid: None,
|
|
backend: input.backend.clone(),
|
|
backend_agent_id: None,
|
|
provider_binary_path,
|
|
persona_team_dir: None,
|
|
persona_name_in_team: None,
|
|
env_vars: input.env_vars.clone(),
|
|
created_at: now_iso(),
|
|
updated_at: now_iso(),
|
|
last_started_at: None,
|
|
last_stopped_at: None,
|
|
last_exit_code: None,
|
|
last_error: None,
|
|
last_error_code: None,
|
|
respond_to: minted.respond_to,
|
|
respond_to_allowlist: minted.respond_to_allowlist.clone(),
|
|
display_name: None,
|
|
slug: None,
|
|
runtime: None,
|
|
name_pool: Vec::new(),
|
|
is_builtin: false,
|
|
is_active: true,
|
|
shared: false,
|
|
source_team: None,
|
|
source_team_persona_slug: None,
|
|
catalog_source: None,
|
|
definition_respond_to: None,
|
|
definition_respond_to_allowlist: Vec::new(),
|
|
definition_parallelism: None,
|
|
relay_mesh: if effective_provider.as_deref()
|
|
== Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID)
|
|
{
|
|
effective_model
|
|
.clone()
|
|
.map(|model_ref| RelayMeshConfig { model_ref })
|
|
} else {
|
|
relay_mesh.clone()
|
|
},
|
|
};
|
|
|
|
records.push(record);
|
|
|
|
save_managed_agents(&app, &records)?;
|
|
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
|
// Publish the agent to the relay. Inside the Phase-3 lock, after save,
|
|
// before any .await — owner-authored, every agent (Will's ruling: no
|
|
// is_builtin/persona-membership gate).
|
|
retain_managed_agent_pending(&app, &state, record);
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
(
|
|
build_managed_agent_summary(
|
|
&app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
|
)?,
|
|
resolved_avatar_url,
|
|
)
|
|
};
|
|
|
|
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
|
|
let mut spawn_error = None;
|
|
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
|
|
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true).await {
|
|
Ok(agent) => agent,
|
|
Err(error) => {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
|
record.updated_at = now_iso();
|
|
record.last_error = Some(error.clone());
|
|
save_managed_agents(&app, &records)?;
|
|
spawn_error = Some(error);
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
build_managed_agent_summary(
|
|
&app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
|
)?
|
|
}
|
|
}
|
|
} else {
|
|
agent
|
|
};
|
|
|
|
try_regenerate_nest(&app).ok();
|
|
|
|
// ── Phase 4: sync agent profile on relay (async, outside lock) ───────────
|
|
// Use the avatar persisted on the record so the published profile and any
|
|
// later reconciliation agree on the same value.
|
|
let profile_relay_url = crate::relay::effective_agent_relay_url(
|
|
&resolved_relay_url,
|
|
&relay_ws_url_with_override(&state),
|
|
);
|
|
let profile_sync_error = (sync_managed_agent_profile(
|
|
&state,
|
|
&profile_relay_url,
|
|
&agent_keys,
|
|
&name,
|
|
resolved_avatar_url.as_deref(),
|
|
auth_tag.as_deref(),
|
|
)
|
|
.await)
|
|
.err();
|
|
|
|
// ── Phase 5: provider deploy (async, outside lock) ───────────────────────
|
|
let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local {
|
|
if let BackendKind::Provider { ref id, ref config } = input.backend {
|
|
// Read the saved record to build the deploy payload (record has the
|
|
// canonical field values after Phase 3 normalization).
|
|
let agent_json = {
|
|
let _g = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let records = load_managed_agents(&app)?;
|
|
let rec = records
|
|
.iter()
|
|
.find(|r| r.pubkey == pubkey)
|
|
.ok_or_else(|| "agent disappeared".to_string())?;
|
|
build_deploy_payload(&app, &state, rec)?
|
|
};
|
|
match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await {
|
|
Ok(()) => spawn_error,
|
|
Err(e) => Some(e),
|
|
}
|
|
} else {
|
|
spawn_error
|
|
}
|
|
} else {
|
|
spawn_error
|
|
};
|
|
|
|
// Rebuild summary if provider deploy may have updated backend_agent_id.
|
|
let final_agent = if input.backend != BackendKind::Local && spawn_error.is_none() {
|
|
let _store_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(|| "agent disappeared".to_string())?;
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
build_managed_agent_summary(
|
|
&app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
|
)?
|
|
} else {
|
|
agent
|
|
};
|
|
|
|
Ok(CreateManagedAgentResponse {
|
|
agent: final_agent,
|
|
private_key_nsec,
|
|
profile_sync_error,
|
|
spawn_error,
|
|
})
|
|
}
|
|
|
|
/// Data needed for background profile reconciliation after agent start.
|
|
#[tauri::command]
|
|
pub async fn start_managed_agent(
|
|
pubkey: String,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<ManagedAgentSummary, String> {
|
|
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
|
|
// Read outside the records lock to keep lock ordering simple.
|
|
let owner_hex = workspace_owner_hex(&state)?;
|
|
enum StartTarget {
|
|
Local,
|
|
Provider {
|
|
backend: BackendKind,
|
|
cached_binary_path: Option<String>,
|
|
agent_json: serde_json::Value,
|
|
},
|
|
}
|
|
|
|
// Collect backend info under lock; async preflight/spawn happens below.
|
|
// Also snapshot profile reconciliation data for the background task.
|
|
let (target, reconcile_data) = {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
let (sync_changed, exited_pubkeys) =
|
|
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
|
|
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
|
|
|
// Resolve the effective harness for the avatar-fallback derivation in
|
|
// profile reconcile (the create-time snapshot may be empty or stale for
|
|
// a persona-inherited harness).
|
|
let reconcile_personas = load_personas(&app).unwrap_or_default();
|
|
let reconcile_effective_command =
|
|
crate::managed_agents::record_agent_command(record, &reconcile_personas);
|
|
|
|
let reconcile = ProfileReconcileData {
|
|
private_key_nsec: record.private_key_nsec.clone(),
|
|
name: record.name.clone(),
|
|
relay_url: record.relay_url.clone(),
|
|
avatar_url: record.avatar_url.clone(),
|
|
auth_tag: record.auth_tag.clone(),
|
|
pubkey: record.pubkey.clone(),
|
|
agent_command: reconcile_effective_command,
|
|
persona_id: record.persona_id.clone(),
|
|
};
|
|
|
|
let target = if record.backend == BackendKind::Local {
|
|
StartTarget::Local
|
|
} else {
|
|
StartTarget::Provider {
|
|
backend: record.backend.clone(),
|
|
cached_binary_path: record.provider_binary_path.clone(),
|
|
agent_json: build_deploy_payload(&app, &state, record)?,
|
|
}
|
|
};
|
|
|
|
(target, reconcile)
|
|
};
|
|
|
|
let result = match target {
|
|
StartTarget::Local => {
|
|
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, false).await
|
|
}
|
|
StartTarget::Provider {
|
|
backend: BackendKind::Provider { id, config },
|
|
cached_binary_path,
|
|
agent_json,
|
|
} => {
|
|
deploy_to_provider(
|
|
&app,
|
|
&state,
|
|
&pubkey,
|
|
&id,
|
|
&config,
|
|
agent_json,
|
|
cached_binary_path.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
// Return updated summary.
|
|
let _store_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"))?;
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
build_managed_agent_summary(
|
|
&app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
|
)
|
|
}
|
|
StartTarget::Provider { backend, .. } => Err(format!(
|
|
"agent {pubkey} has unsupported backend kind: {backend:?}"
|
|
)),
|
|
};
|
|
|
|
// ── Profile reconciliation (fire-and-forget) ────────────────────────────
|
|
// On successful start, spawn a background task to ensure the agent's kind:0
|
|
// profile is published on the relay. This self-heals cases where the initial
|
|
// profile sync at creation time failed silently. For legacy records (pre-PR-921)
|
|
// with no persisted avatar, this also backfills the avatar from the relay.
|
|
if result.is_ok()
|
|
&& state
|
|
.managed_agent_profile_reconcile_enabled
|
|
.load(std::sync::atomic::Ordering::Acquire)
|
|
{
|
|
let reconcile_pubkey = pubkey.clone();
|
|
let reconcile_app = app.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
use tauri::Manager;
|
|
let state = reconcile_app.state::<AppState>();
|
|
if let Err(e) =
|
|
reconcile_agent_profile(&state, &reconcile_app, &reconcile_pubkey, &reconcile_data)
|
|
.await
|
|
{
|
|
eprintln!(
|
|
"buzz-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_managed_agent(
|
|
pubkey: String,
|
|
app: AppHandle,
|
|
) -> Result<ManagedAgentSummary, String> {
|
|
use tauri::Manager;
|
|
tokio::task::spawn_blocking(move || {
|
|
let state = app.state::<AppState>();
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
let (sync_changed, exited_pubkeys) =
|
|
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
|
|
{
|
|
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
|
// Remote agents are stopped via !shutdown @mention from the frontend,
|
|
// not via this backend command. Reject the call.
|
|
if record.backend != BackendKind::Local {
|
|
return Err(
|
|
"remote agents are stopped via !shutdown message, not this command".to_string(),
|
|
);
|
|
}
|
|
// Pair-scoped: stops only the active workspace's pair; delete and
|
|
// the config-restart flows still drain every pair.
|
|
stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?;
|
|
}
|
|
save_managed_agents(&app, &records)?;
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
build_managed_agent_summary(
|
|
&app,
|
|
record,
|
|
&runtimes,
|
|
&personas,
|
|
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
|
}
|
|
|
|
// Async so the blocking body (disk reads/writes, process termination, keyring
|
|
// delete, nest regeneration) runs off the main UI thread via spawn_blocking.
|
|
#[tauri::command]
|
|
pub async fn delete_managed_agent(
|
|
pubkey: String,
|
|
force_remote_delete: Option<bool>,
|
|
app: AppHandle,
|
|
) -> Result<(), String> {
|
|
use tauri::Manager;
|
|
tokio::task::spawn_blocking(move || {
|
|
let state = app.state::<AppState>();
|
|
{
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(
|
|
&mut records,
|
|
&mut runtimes,
|
|
¤t_instance_id(&app),
|
|
);
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
|
|
// Guard: reject deletion of deployed remote agents unless explicitly forced.
|
|
// This turns "don't orphan remote infra" from a UI convention into a backend
|
|
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
|
|
// remote deployment. The frontend sends force_remote_delete: true only after
|
|
// the user confirms the orphan warning.
|
|
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
|
|
if record.backend != BackendKind::Local
|
|
&& record.backend_agent_id.is_some()
|
|
&& !force_remote_delete.unwrap_or(false)
|
|
{
|
|
return Err(
|
|
"cannot delete a deployed remote agent without force_remote_delete: true"
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return Err(format!("agent {pubkey} not found"));
|
|
}
|
|
save_managed_agents(&app, &records)?;
|
|
// Remove the agent's nsec from the keyring after the record is gone.
|
|
crate::managed_agents::delete_agent_key(&pubkey);
|
|
// Tombstone-after-validation: only reached past the deployed-remote
|
|
// guard above and a confirmed removal — never orphan a live remote
|
|
// deployment's relay record. Inside the lock, before the block closes
|
|
// (no .await here). Every agent published, so every delete tombstones.
|
|
tombstone_managed_agent_pending(&app, &state, &pubkey);
|
|
// NIP-IA: archive the deleted agent's identity on the relay so it
|
|
// stops appearing in member pickers and autocomplete. Same
|
|
// best-effort, inside-the-lock contract as the tombstone above.
|
|
archive_managed_agent_pending(&app, &state, &pubkey);
|
|
}
|
|
try_regenerate_nest(&app).ok();
|
|
Ok(())
|
|
})
|
|
.await
|
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
|
}
|
|
|
|
// Remote agent shutdown is handled entirely by the frontend:
|
|
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
|
|
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
|
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
|
|
// No backend Tauri command needed. Presence IS the status.
|
|
|
|
#[path = "agents_deploy.rs"]
|
|
mod deploy;
|
|
use deploy::build_deploy_payload;
|
|
#[cfg(test)]
|
|
use deploy::deploy_payload_json;
|
|
#[cfg(test)]
|
|
use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider};
|
|
|
|
#[path = "agents_profile.rs"]
|
|
mod profile;
|
|
#[cfg(test)]
|
|
use profile::{profile_needs_sync, resolve_legacy_avatar};
|
|
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};
|
|
|
|
#[cfg(test)]
|
|
#[path = "agents_tests.rs"]
|
|
mod tests;
|