diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d8f228d8e..6a0aaa70e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,7 @@ const rules = [ // Do not add to this list; split the file instead. Remove each entry as its // file is broken up. Tracked as a follow-up. const overrides = new Map([ - ["src-tauri/src/commands/agents.rs", 1287], + ["src-tauri/src/commands/agents.rs", 1370], ["src-tauri/src/managed_agents/nest.rs", 1420], ["src-tauri/src/managed_agents/runtime.rs", 1465], ["src-tauri/src/managed_agents/personas.rs", 1080], diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 557887529..876ea307d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -17,8 +17,9 @@ name = "sprout_lib" crate-type = ["staticlib", "cdylib", "rlib"] [features] -default = [] +default = ["legacy_team_sync"] mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"] +legacy_team_sync = [] [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 9bbc7f6c7..87bcca618 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -618,6 +618,27 @@ pub async fn create_managed_agent( .await) .err(); + // ── Phase 4b: write persona engram (async, best-effort) ───────────────── + // If the agent was created from a persona, snapshot it into a `mem/persona` + // engram. This is provenance-only for now (the live resolve path still reads + // the persona catalog at spawn); fleet update keeps it current. + if requested_persona_id.is_some() { + if let Err(e) = crate::managed_agents::fleet_update::write_persona_engram_at_creation( + &state, + &agent_keys, + &resolved_relay_url, + requested_persona_id.as_deref().unwrap(), + &app, + ) + .await + { + eprintln!( + "sprout-desktop: create-agent: persona engram write failed for {}: {e}", + &pubkey[..pubkey.len().min(8)] + ); + } + } + // ── 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 { diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 56f6742f4..32ecb881d 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -140,6 +140,19 @@ pub fn update_persona( .find(|record| record.id == input.id) .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; try_regenerate_nest(&app); + + // Fleet update: propagate persona edits to agent engrams (best-effort). + let fleet_app = app.clone(); + let persona_id = input.id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = + crate::managed_agents::fleet_update::fleet_update_for_persona(&fleet_app, &persona_id) + .await + { + eprintln!("sprout-desktop: fleet-update-on-save: {e}"); + } + }); + Ok(result) } diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ad8930f9f..01ef06752 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -2,13 +2,16 @@ use tauri::{AppHandle, State}; use uuid::Uuid; use super::export_util::save_json_with_dialog; +#[cfg(feature = "legacy_team_sync")] +use crate::managed_agents::{ + import_team_from_directory as do_import_team, sync_team_from_dir as do_sync_team, SyncResult, +}; use crate::{ app_state::AppState, managed_agents::{ - delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, - import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json, - save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest, - ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest, + delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, load_personas, + load_teams, parse_team_json, save_teams, try_regenerate_nest, CreateTeamRequest, + ParsedTeamPreview, TeamRecord, UpdateTeamRequest, }, util::now_iso, }; @@ -114,6 +117,7 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re Ok(()) } +#[cfg(feature = "legacy_team_sync")] #[tauri::command] pub fn install_team_from_directory( app: AppHandle, @@ -134,6 +138,7 @@ pub fn install_team_from_directory( Ok(result) } +#[cfg(feature = "legacy_team_sync")] #[tauri::command] pub fn sync_team_directory( app: AppHandle, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8749f5545..6dcba27b6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -543,9 +543,20 @@ pub fn run() { .map_err(|e| -> Box { e.to_string().into() })?; migration::migrate_personas_to_events(&app_handle, &owner_keys); - if let Err(e) = managed_agents::sync_team_personas(&app_handle) { - eprintln!("sprout-desktop: sync-team-personas: {e}"); - } + // Fleet update: reconcile every agent's `mem/persona` engram against + // its persona's current content. Replaces the old directory-based + // `sync_team_personas`. Best-effort and relay-dependent, so it runs + // off-thread — engram drift is non-fatal and resolves next launch. + let fleet_app = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match managed_agents::fleet_update::check_fleet_updates(&fleet_app).await { + Ok(0) => {} + Ok(n) => { + eprintln!("sprout-desktop: fleet-update: rewrote {n} persona engram(s)") + } + Err(e) => eprintln!("sprout-desktop: fleet-update: {e}"), + } + }); // Store the AppHandle so huddle commands can emit `huddle-state-changed` // events via `huddle::emit_huddle_state` without threading the handle @@ -744,7 +755,9 @@ pub fn run() { create_team, update_team, delete_team, + #[cfg(feature = "legacy_team_sync")] install_team_from_directory, + #[cfg(feature = "legacy_team_sync")] sync_team_directory, pick_team_directory, export_team_to_json, diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 5abe4118b..8785623d9 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -25,6 +25,7 @@ use std::collections::BTreeMap; /// /// Non-structured knobs (`GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT`) are NOT /// in this list — they have no structured counterpart and must be preserved. +#[cfg_attr(not(feature = "legacy_team_sync"), allow(dead_code))] pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[ "GOOSE_MODEL", "GOOSE_PROVIDER", @@ -34,6 +35,7 @@ pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[ /// Returns `true` if `key` is a derived provider/model env key that should be /// filtered out of persisted `PersonaRecord.env_vars` at pack import time. +#[cfg(feature = "legacy_team_sync")] pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { DERIVED_PROVIDER_MODEL_ENV_KEYS .iter() @@ -46,6 +48,7 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { /// The structured `PersonaRecord.provider` / `PersonaRecord.model` fields are /// the authoritative source of truth. Keeping the derived copies would cause /// stale env values to override updated structured fields at spawn/deploy time. +#[cfg(feature = "legacy_team_sync")] pub(crate) fn filter_derived_provider_model_env_vars( env_vars: impl IntoIterator, ) -> BTreeMap { diff --git a/desktop/src-tauri/src/managed_agents/fleet_update.rs b/desktop/src-tauri/src/managed_agents/fleet_update.rs new file mode 100644 index 000000000..e770ac157 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/fleet_update.rs @@ -0,0 +1,306 @@ +//! Fleet update: keep agent `mem/persona` engrams in sync with persona edits. +//! +//! When a persona's content changes, each agent instantiated from it carries a +//! stale snapshot in its `mem/persona` engram. Fleet update detects the drift +//! (by comparing content hashes, not timestamps — timestamps are fragile across +//! clock skew and export/import) and rewrites the affected engrams. +//! +//! This runs on persona save (immediate propagation) and at app launch (catches +//! edits made while the app was closed). It never restarts running agents — the +//! live resolve path still reads the persona catalog at spawn, so a running +//! agent already sees edits on its next session. The engram is provenance and +//! future-proofing for when it becomes the runtime source; keeping it current +//! is the only job here. + +use nostr::PublicKey; +use tauri::{AppHandle, Manager}; + +use super::persona_events::{ + build_persona_engram, persona_content_hash, persona_engram_from_event_as_owner, + persona_event_content, PERSONA_ENGRAM_SLUG, +}; +use super::personas::load_personas; +use super::storage::load_managed_agents; +use super::{ManagedAgentRecord, PersonaRecord}; +use crate::app_state::AppState; +use crate::relay; +use sprout_core::engram::{conversation_key, d_tag, select_head}; +use sprout_core::kind::KIND_AGENT_ENGRAM; + +/// What fleet update should do with one agent's persona engram. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FleetAction { + /// Engram already matches the current persona — nothing to do. + NoOp, + /// Engram is missing or stale — (re)write it. + Write, +} + +/// Decide whether an agent's persona engram needs rewriting. +/// +/// Pure and side-effect free so the decision can be unit-tested in isolation: +/// a missing engram (`None`) always writes; otherwise we write iff the stored +/// content version differs from the persona's current content hash. +pub fn fleet_action_for(current_hash: &str, stored_version: Option<&str>) -> FleetAction { + match stored_version { + Some(version) if version == current_hash => FleetAction::NoOp, + _ => FleetAction::Write, + } +} + +/// Run fleet update across all managed agents (launch-time reconciliation). +/// +/// Best-effort: relay errors for one agent are logged and skipped, never fatal. +/// Returns the number of engrams written. +pub async fn check_fleet_updates(app: &AppHandle) -> Result { + let state = app.state::(); + let personas = load_personas(app)?; + let agents = load_managed_agents(app)?; + + let (owner_keys, relay_url) = owner_context(&state)?; + let mut written = 0; + + for agent in &agents { + let Some(persona) = persona_for_agent(agent, &personas) else { + continue; + }; + match reconcile_agent(&state, &owner_keys, &relay_url, agent, persona).await { + Ok(true) => written += 1, + Ok(false) => {} + Err(e) => eprintln!( + "sprout-desktop: fleet-update: skipped agent {}: {e}", + short(&agent.pubkey) + ), + } + } + + Ok(written) +} + +/// Run fleet update for the agents tied to a single persona (on persona save). +/// +/// Targeted variant of [`check_fleet_updates`] — only touches agents whose +/// `persona_id` matches. Best-effort with the same skip-on-error posture. +pub async fn fleet_update_for_persona(app: &AppHandle, persona_id: &str) -> Result { + let state = app.state::(); + let personas = load_personas(app)?; + let agents = load_managed_agents(app)?; + + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + return Ok(0); // Persona deleted between save and update — nothing to do. + }; + + let (owner_keys, relay_url) = owner_context(&state)?; + let mut written = 0; + + for agent in &agents { + if agent.persona_id.as_deref() != Some(persona_id) { + continue; + } + match reconcile_agent(&state, &owner_keys, &relay_url, agent, persona).await { + Ok(true) => written += 1, + Ok(false) => {} + Err(e) => eprintln!( + "sprout-desktop: fleet-update: skipped agent {}: {e}", + short(&agent.pubkey) + ), + } + } + + Ok(written) +} + +/// Reconcile one agent's engram against a persona. Returns `Ok(true)` if a +/// rewrite was published, `Ok(false)` for a no-op. +async fn reconcile_agent( + state: &AppState, + owner_keys: &nostr::Keys, + relay_url: &str, + agent: &ManagedAgentRecord, + persona: &PersonaRecord, +) -> Result { + let agent_keys = nostr::Keys::parse(&agent.private_key_nsec) + .map_err(|e| format!("invalid agent key: {e}"))?; + let agent_pubkey = agent_keys.public_key(); + + let current_hash = persona_content_hash(&persona_event_content(persona)); + let stored_version = read_engram_source_version(state, owner_keys, &agent_pubkey).await; + + if fleet_action_for(¤t_hash, stored_version.as_deref()) == FleetAction::NoOp { + return Ok(false); + } + + apply_fleet_update( + state, + &agent_keys, + &owner_keys.public_key(), + relay_url, + persona, + ) + .await?; + eprintln!( + "sprout-desktop: fleet-update: rewrote mem/persona engram for agent {} (persona {})", + short(&agent.pubkey), + persona.id + ); + Ok(true) +} + +/// Write the initial `mem/persona` engram for a freshly created agent. +/// +/// Called from `create_managed_agent` once the persona is known. Best-effort: +/// the caller logs and continues on error so engram failure never blocks agent +/// creation. The owner key comes from app state; the persona is resolved by id. +pub async fn write_persona_engram_at_creation( + state: &AppState, + agent_keys: &nostr::Keys, + relay_url: &str, + persona_id: &str, + app: &AppHandle, +) -> Result<(), String> { + let personas = load_personas(app)?; + let persona = personas + .iter() + .find(|p| p.id == persona_id) + .ok_or_else(|| format!("persona {persona_id} not found"))?; + + let owner_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key() + }; + apply_fleet_update(state, agent_keys, &owner_pubkey, relay_url, persona).await +} + +/// Build and publish a fresh `mem/persona` engram for one agent. +async fn apply_fleet_update( + state: &AppState, + agent_keys: &nostr::Keys, + owner_pubkey: &PublicKey, + relay_url: &str, + persona: &PersonaRecord, +) -> Result<(), String> { + let now = now_secs(); + let event = build_persona_engram(agent_keys, owner_pubkey, persona, now)?; + publish_agent_event(state, agent_keys, relay_url, &event).await +} + +/// Fetch the agent's current `mem/persona` engram from the relay and return the +/// recorded `provenance.source_version`. `None` if no engram exists or it can't +/// be read/decrypted — both cases trigger a (re)write, which is safe under +/// NIP-33 latest-wins replacement. +/// +/// The owner holds the only key needed: the agent↔owner conversation key is +/// symmetric, so the owner decrypts with its own secret key and the agent's +/// pubkey. No agent secret key is required at read time. +async fn read_engram_source_version( + state: &AppState, + owner_keys: &nostr::Keys, + agent_pubkey: &PublicKey, +) -> Option { + let owner_pubkey = owner_keys.public_key(); + let k_c = conversation_key(owner_keys.secret_key(), agent_pubkey); + let expected_d = d_tag(&k_c, PERSONA_ENGRAM_SLUG); + + // Read-gated kind:30174 query: owner-authored auth, #p = owner (the owner + // reading engrams addressed to them). #d narrows to the persona slot. + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_ENGRAM], + "authors": [agent_pubkey.to_hex()], + "#p": [owner_pubkey.to_hex()], + "#d": [expected_d], + }); + let events = relay::query_relay(state, &[filter]).await.ok()?; + + let head = select_head(events)?; + let body = persona_engram_from_event_as_owner(&head, agent_pubkey, owner_keys).ok()?; + Some(body.provenance.source_version) +} + +/// Publish an engram event signed by the agent, authenticating the write with +/// the agent's keys (NIP-98) and NIP-OA auth tag. +async fn publish_agent_event( + state: &AppState, + agent_keys: &nostr::Keys, + relay_url: &str, + event: &nostr::Event, +) -> Result<(), String> { + let url = format!("{}/events", relay::relay_http_base_url(relay_url)); + let body_bytes = nostr::JsonUtil::as_json(event).into_bytes(); + let auth = relay::build_nip98_auth_header_for_keys( + agent_keys, + &reqwest::Method::POST, + &url, + &body_bytes, + )?; + + let response = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await + .map_err(|e| format!("publish request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay::relay_error_message(response).await); + } + Ok(()) +} + +/// Resolve the persona an agent was instantiated from, if any. +fn persona_for_agent<'a>( + agent: &ManagedAgentRecord, + personas: &'a [PersonaRecord], +) -> Option<&'a PersonaRecord> { + let persona_id = agent.persona_id.as_deref()?; + personas.iter().find(|p| p.id == persona_id) +} + +/// Snapshot the owner's keys and relay URL for a fleet-update pass. +fn owner_context(state: &AppState) -> Result<(nostr::Keys, String), String> { + let owner_keys = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.clone() + }; + let relay_url = relay::relay_ws_url_with_override(state); + Ok((owner_keys, relay_url)) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn short(pubkey: &str) -> &str { + &pubkey[..pubkey.len().min(8)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_engram_writes() { + assert_eq!(fleet_action_for("abc123", None), FleetAction::Write); + } + + #[test] + fn matching_hash_is_noop() { + assert_eq!( + fleet_action_for("abc123", Some("abc123")), + FleetAction::NoOp + ); + } + + #[test] + fn differing_hash_writes() { + assert_eq!( + fleet_action_for("abc123", Some("def456")), + FleetAction::Write + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 143bb7831..e8b132027 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,7 @@ mod backend; mod discovery; mod env_vars; +pub(crate) mod fleet_update; mod nest; mod persona_avatars; mod persona_card; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 1f9f69eb1..f188fb419 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,15 +5,19 @@ use std::collections::BTreeMap; -use nostr::{EventBuilder, Kind, Tag}; +use nostr::{EventBuilder, Kind, PublicKey, Tag}; use serde::{Deserialize, Serialize}; use sprout_core::kind::KIND_PERSONA; use super::PersonaRecord; use crate::app_state::AppState; +/// The slug for the per-agent persona memory engram. The agent stores a +/// snapshot of the persona it was instantiated from under this memory slot. +pub const PERSONA_ENGRAM_SLUG: &str = "mem/persona"; + /// The JSON body stored in a persona event's content field. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PersonaEventContent { pub display_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -132,6 +136,153 @@ pub async fn fetch_persona_events(state: &AppState) -> Result, crate::relay::query_relay(state, &[filter]).await } +/// Provenance recorded inside a persona engram's encrypted body. Identifies the +/// source persona event the agent was instantiated from and a content digest +/// used by fleet update to detect drift. Kept inside the encrypted body (not as +/// a plaintext tag) so the engram's blinding guarantee is preserved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaProvenance { + pub owner_pubkey: String, + pub kind: u32, + pub slug: String, + /// SHA-256 of the canonical persona content JSON at the time of the write. + pub source_version: String, +} + +/// The decrypted body of a `mem/persona` engram: the persona snapshot plus its +/// provenance. Serialized as the engram's memory `value` string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaEngramBody { + #[serde(flatten)] + pub content: PersonaEventContent, + pub provenance: PersonaProvenance, +} + +/// SHA-256 (lowercase hex) of a persona's canonical content JSON. +/// +/// Fleet update compares this digest, not event timestamps, to decide whether +/// an agent's engram is stale — timestamps are fragile across clock skew and +/// export/import round-trips. `PersonaEventContent` field order is fixed by the +/// struct definition, so `serde_json` produces a stable canonical encoding. +pub fn persona_content_hash(content: &PersonaEventContent) -> String { + use sha2::{Digest, Sha256}; + let json = serde_json::to_vec(content).unwrap_or_default(); + let digest = Sha256::digest(&json); + hex::encode(digest) +} + +/// Project a `PersonaRecord` onto the content fields published in persona +/// events and engrams. Centralizes the field mapping so a new persona field is +/// added in exactly one place. +pub fn persona_event_content(record: &PersonaRecord) -> PersonaEventContent { + PersonaEventContent { + display_name: record.display_name.clone(), + avatar_url: record.avatar_url.clone(), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: record.name_pool.clone(), + env_vars: record.env_vars.clone(), + } +} + +/// Build the decrypted body for a persona engram from a `PersonaRecord`. +fn persona_engram_body(record: &PersonaRecord, owner_pubkey: &PublicKey) -> PersonaEngramBody { + let content = persona_event_content(record); + let source_version = persona_content_hash(&content); + let provenance = PersonaProvenance { + owner_pubkey: owner_pubkey.to_hex(), + kind: KIND_PERSONA, + slug: persona_d_tag(record), + source_version, + }; + PersonaEngramBody { + content, + provenance, + } +} + +/// Build a signed `mem/persona` engram (kind:30174) for an agent. +/// +/// The engram is authored by the agent and addressed to the owner: its content +/// is NIP-44 encrypted under `ECDH(agent_seckey, owner_pubkey)` and the d-tag is +/// HMAC-blinded over the `mem/persona` slug. The persona snapshot and its +/// provenance live inside the encrypted body. +pub fn build_persona_engram( + agent_keys: &nostr::Keys, + owner_pubkey: &PublicKey, + record: &PersonaRecord, + created_at: u64, +) -> Result { + let body = persona_engram_body(record, owner_pubkey); + let value = serde_json::to_string(&body) + .map_err(|e| format!("failed to serialize engram body: {e}"))?; + + let engram_body = sprout_core::engram::Body::Memory { + slug: PERSONA_ENGRAM_SLUG.to_string(), + value: Some(value), + }; + + sprout_core::engram::build_event(agent_keys, owner_pubkey, &engram_body, created_at) + .map_err(|e| format!("failed to build persona engram: {e}")) +} + +/// Decode a persona engram body from a relay event, validating the envelope and +/// decrypting under the agent↔owner conversation key. +pub fn persona_engram_from_event( + event: &nostr::Event, + agent_keys: &nostr::Keys, + owner_pubkey: &PublicKey, +) -> Result { + let body = sprout_core::engram::validate_and_decrypt( + event, + &agent_keys.public_key(), + owner_pubkey, + agent_keys.secret_key(), + owner_pubkey, + ) + .map_err(|e| format!("failed to validate persona engram: {e}"))?; + + engram_body_from_decrypted(body) +} + +/// Decode a persona engram as the owner (fleet update reads engrams using the +/// owner's secret key + agent's pubkey — the conversation key is symmetric). +pub fn persona_engram_from_event_as_owner( + event: &nostr::Event, + agent_pubkey: &PublicKey, + owner_keys: &nostr::Keys, +) -> Result { + let body = sprout_core::engram::validate_and_decrypt( + event, + agent_pubkey, + &owner_keys.public_key(), + owner_keys.secret_key(), + agent_pubkey, + ) + .map_err(|e| format!("failed to validate persona engram: {e}"))?; + + engram_body_from_decrypted(body) +} + +fn engram_body_from_decrypted( + body: sprout_core::engram::Body, +) -> Result { + match body { + sprout_core::engram::Body::Memory { + value: Some(value), .. + } => serde_json::from_str(&value) + .map_err(|e| format!("failed to parse persona engram body: {e}")), + sprout_core::engram::Body::Memory { value: None, .. } => { + Err("persona engram is a tombstone".to_string()) + } + sprout_core::engram::Body::Core { .. } => { + Err("expected memory engram, got core".to_string()) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -243,4 +394,70 @@ mod tests { assert!(!restored.is_builtin); assert!(restored.is_active); } + + #[test] + fn persona_engram_round_trip() { + let record = sample_persona(); + let agent_keys = nostr::Keys::generate(); + let owner_keys = nostr::Keys::generate(); + let owner_pubkey = owner_keys.public_key(); + + let now = 1_700_000_000u64; + let event = build_persona_engram(&agent_keys, &owner_pubkey, &record, now).unwrap(); + + // Verify it's a kind:30174 event + assert_eq!( + event.kind.as_u16() as u32, + sprout_core::kind::KIND_AGENT_ENGRAM + ); + // Verify it's authored by the agent + assert_eq!(event.pubkey, agent_keys.public_key()); + + // Decrypt and verify content + let body = persona_engram_from_event(&event, &agent_keys, &owner_pubkey).unwrap(); + assert_eq!(body.content.display_name, "Test Persona"); + assert_eq!(body.content.system_prompt, "You are a test assistant."); + assert_eq!(body.provenance.owner_pubkey, owner_pubkey.to_hex()); + assert_eq!(body.provenance.kind, KIND_PERSONA); + assert_eq!(body.provenance.slug, "test-slug"); + assert!(!body.provenance.source_version.is_empty()); + } + + #[test] + fn persona_content_hash_is_deterministic() { + let content = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + env_vars: BTreeMap::new(), + }; + let hash1 = persona_content_hash(&content); + let hash2 = persona_content_hash(&content); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex + } + + #[test] + fn persona_content_hash_changes_on_edit() { + let content1 = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + env_vars: BTreeMap::new(), + }; + let mut content2 = content1.clone(); + content2.system_prompt = "Goodbye".to_string(); + assert_ne!( + persona_content_hash(&content1), + persona_content_hash(&content2) + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 0d9bcd6e9..4ecaecb17 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -165,6 +165,7 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String> // --------------------------------------------------------------------------- /// Teams directory: `/agents/teams/` +#[cfg(feature = "legacy_team_sync")] fn teams_dir(app: &AppHandle) -> Result { let dir = managed_agents_base_dir(app)?.join("teams"); fs::create_dir_all(&dir).map_err(|e| format!("failed to create teams dir: {e}"))?; @@ -172,6 +173,7 @@ fn teams_dir(app: &AppHandle) -> Result { } /// Validate team/pack ID: only `[a-zA-Z0-9._-]+` allowed (zip-slip defense). +#[cfg(feature = "legacy_team_sync")] pub(crate) fn validate_team_id(id: &str) -> Result<(), String> { if id.is_empty() { return Err("team ID is empty".into()); @@ -199,6 +201,7 @@ pub(crate) fn validate_team_id(id: &str) -> Result<(), String> { } /// Copy a directory tree, skipping symlinks (zip-slip defense). +#[cfg(feature = "legacy_team_sync")] fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?; for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? { @@ -225,6 +228,7 @@ fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result< /// /// Copies the directory into `/agents/teams//`, /// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set. +#[cfg(feature = "legacy_team_sync")] pub fn import_team_from_directory( app: &AppHandle, source_dir: &std::path::Path, @@ -415,6 +419,7 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<(), St } /// Re-reads a directory-backed team and reconciles with stored records. +#[cfg(feature = "legacy_team_sync")] pub fn sync_team_from_dir( app: &AppHandle, team_id: &str, @@ -715,6 +720,13 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { /// Sync all directory-backed teams on launch — the team equivalent of the /// former `sync_pack_personas`. Silently skips teams whose source directory /// is missing (e.g., external drive unmounted). +/// +/// Superseded by `fleet_update::check_fleet_updates`, which propagates persona +/// edits to agent engrams instead of directory files. Retained behind +/// `legacy_team_sync` for one release as a rollback path; `#[allow(dead_code)]` +/// because the launch call site moved to fleet update. +#[cfg(feature = "legacy_team_sync")] +#[allow(dead_code)] pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> { let teams = load_teams(app)?; for team in &teams { diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 544c344f8..5123142e8 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -525,6 +525,7 @@ pub struct UpdateTeamRequest { } /// Result of syncing a directory-backed team with its backing directory. +#[cfg_attr(not(feature = "legacy_team_sync"), allow(dead_code))] #[derive(Debug, Clone, Serialize)] pub struct SyncResult { pub personas_added: Vec,