feat(desktop): persona engram instantiation + fleet update + legacy sync gate

Write a mem/persona engram (kind:30174) when creating an agent from a
persona. The engram is NIP-44 encrypted under the agent-owner
conversation key with an HMAC-blinded d-tag, conforming to the existing
engram envelope contract. Content includes the persona snapshot plus
provenance (owner pubkey, kind:30175, slug, content SHA-256 digest).

Fleet update reconciles agent engrams against current persona content on
app launch and on persona save. Comparison is digest-based (not
timestamp) to handle clock skew and export/import round-trips. No agent
restart -- the live resolve path still reads the persona catalog at spawn;
the engram is provenance and future-proofing for when it becomes the
runtime source.

Gate the directory-backed team sync machinery (import_team_from_directory,
sync_team_from_dir, sync_team_personas) behind a legacy_team_sync feature
flag (default-enabled). Fleet update replaces sync_team_personas in the
launch sequence. The old code remains compilable for one release cycle as
a rollback path.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-10 11:13:08 -04:00
co-authored by Will Pfleger
parent a2e9c8c077
commit 45b0782011
12 changed files with 604 additions and 11 deletions
+1 -1
View File
@@ -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],
+2 -1
View File
@@ -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 = [] }
+21
View File
@@ -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 {
@@ -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)
}
+9 -4
View File
@@ -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,
+16 -3
View File
@@ -543,9 +543,20 @@ pub fn run() {
.map_err(|e| -> Box<dyn std::error::Error> { 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,
@@ -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<Item = (String, String)>,
) -> BTreeMap<String, String> {
@@ -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<usize, String> {
let state = app.state::<AppState>();
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<usize, String> {
let state = app.state::<AppState>();
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<bool, String> {
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(&current_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<String> {
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
);
}
}
@@ -1,6 +1,7 @@
mod backend;
mod discovery;
mod env_vars;
pub(crate) mod fleet_update;
mod nest;
mod persona_avatars;
mod persona_card;
@@ -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<Vec<nostr::Event>,
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<nostr::Event, String> {
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<PersonaEngramBody, String> {
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<PersonaEngramBody, String> {
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<PersonaEngramBody, String> {
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)
);
}
}
@@ -165,6 +165,7 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
// ---------------------------------------------------------------------------
/// Teams directory: `<AppDataDir>/agents/teams/`
#[cfg(feature = "legacy_team_sync")]
fn teams_dir(app: &AppHandle) -> Result<PathBuf, String> {
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<PathBuf, String> {
}
/// 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 `<data>/agents/teams/<resolved.id>/`,
/// 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<ParsedTeamPreview, String> {
/// 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 {
@@ -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<String>,