mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
feat(desktop): write persona-snapshot engram at instantiation; gate legacy team sync
Part A: each managed agent writes a `mem/persona` engram (kind:30174) when created, snapshotting the persona it was instantiated from. The engram is provenance/audit only this release — live resolve stays the runtime prompt source, so the write is best-effort and off the spawn critical path (never on the restore std:🧵:scope path). Provenance lives inside the NIP-44 encrypted body, not a plaintext tag, so the agent-persona linkage is not leaked to relay observers and the HMAC-blinded d tag stays the only correlation surface. The write is verified end-to-end: assert the relay accepted flag, then re-fetch via select_head and validate_and_decrypt, asserting the decrypted body matches what was published. Reuses sprout_core::engram helpers rather than hand-rolling the envelope. Part C: the launch sequence no longer clobbers personas via sync_team_personas. That path keyed persona->team correctly but overwrote local edits on every boot; it is now behind the legacy_team_sync feature (default-on for rollback). Launch instead runs an idempotent dedup that heals duplicate team-sourced personas left by a prior broken sync, keyed on (source_team, slug) for records where both are present — UI-created personas (both None) are never treated as duplicates of one another. import_team_from_directory now also publishes each persona as a kind:30175 event (best-effort) after persisting locally. relay::submit_signed_event is added because the existing submit_event signs with the owner keys and collapses accepted==false into an error; the engram write needs to sign with the agent keypair and inspect the accept/reject flag. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
a2e9c8c077
commit
64598699c3
@@ -17,8 +17,12 @@ 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 launch-time directory→persona file sync. The engram/event path is
|
||||
# replacing it; kept compilable (and on by default this release) so the old
|
||||
# path can be re-enabled for rollback without a revert.
|
||||
legacy_team_sync = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -27,6 +27,41 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
|
||||
Ok(keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
/// Resolve `persona_id` to its record and write the agent's `mem/persona`
|
||||
/// snapshot engram. Skips silently if the persona is no longer present (it may
|
||||
/// have been deleted between key generation and this point). All relay I/O is
|
||||
/// the caller's responsibility to treat as best-effort.
|
||||
async fn write_persona_engram_for_agent(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
agent_keys: &Keys,
|
||||
owner_hex: &str,
|
||||
persona_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let record = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let personas = load_personas(app)?;
|
||||
match personas.into_iter().find(|p| p.id == persona_id) {
|
||||
Some(record) => record,
|
||||
None => return Ok(()),
|
||||
}
|
||||
};
|
||||
let owner_pubkey =
|
||||
nostr::PublicKey::from_hex(owner_hex).map_err(|e| format!("invalid owner pubkey: {e}"))?;
|
||||
let relay_url = relay_ws_url_with_override(state);
|
||||
crate::managed_agents::persona_events::write_persona_engram(
|
||||
agent_keys,
|
||||
&owner_pubkey,
|
||||
&record,
|
||||
&relay_url,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn normalize_relay_mesh(
|
||||
config: Option<&RelayMeshConfig>,
|
||||
backend: &BackendKind,
|
||||
@@ -618,6 +653,23 @@ pub async fn create_managed_agent(
|
||||
.await)
|
||||
.err();
|
||||
|
||||
// ── Phase 4b: persona-snapshot engram (async, best-effort) ───────────────
|
||||
// Write a `mem/persona` engram capturing the persona this agent was
|
||||
// instantiated from. Snapshot/provenance only — never read back into the
|
||||
// prompt path this release (live resolve stays the runtime source). This
|
||||
// runs after `create_managed_agent` has done its synchronous work, on the
|
||||
// command's own async path — never on the restore `std::thread::scope`
|
||||
// path — so a slow or unreachable relay cannot stall agent spawn. Any
|
||||
// failure is logged and ignored: NIP-33 replaceable semantics mean a
|
||||
// rejected write never destroys prior state.
|
||||
if let Some(persona_id) = requested_persona_id.as_deref() {
|
||||
if let Err(e) =
|
||||
write_persona_engram_for_agent(&app, &state, &agent_keys, &owner_hex, persona_id).await
|
||||
{
|
||||
eprintln!("sprout-desktop: persona engram write for {pubkey}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
|
||||
@@ -7,12 +7,15 @@ use crate::{
|
||||
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,
|
||||
save_teams, try_regenerate_nest, CreateTeamRequest, ParsedTeamPreview, SyncResult,
|
||||
TeamRecord, UpdateTeamRequest,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
use crate::managed_agents::sync_team_from_dir as do_sync_team;
|
||||
|
||||
fn trim_required(value: &str, label: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -115,21 +118,17 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_team_from_directory(
|
||||
pub async fn install_team_from_directory(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
symlink: Option<bool>,
|
||||
) -> Result<TeamRecord, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let source = std::path::PathBuf::from(&path);
|
||||
if !source.is_dir() {
|
||||
return Err(format!("team path is not a directory: {path}"));
|
||||
}
|
||||
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
|
||||
let result = do_import_team(&app, &state, &source, symlink.unwrap_or(false)).await?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -140,13 +139,21 @@ pub fn sync_team_directory(
|
||||
state: State<'_, AppState>,
|
||||
team_id: String,
|
||||
) -> Result<SyncResult, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = do_sync_team(&app, &team_id)?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
{
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = do_sync_team(&app, &team_id)?;
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
}
|
||||
#[cfg(not(feature = "legacy_team_sync"))]
|
||||
{
|
||||
let _ = (&app, &state, &team_id);
|
||||
Err("team sync moved to directory re-import; reinstall the team instead".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -543,8 +543,8 @@ 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}");
|
||||
if let Err(e) = managed_agents::dedup_team_personas(&app_handle) {
|
||||
eprintln!("sprout-desktop: dedup-team-personas: {e}");
|
||||
}
|
||||
|
||||
// Store the AppHandle so huddle commands can emit `huddle-state-changed`
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
use nostr::{EventBuilder, Keys, Kind, PublicKey, Tag};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sprout_core::kind::KIND_PERSONA;
|
||||
use sprout_core::engram::{self, Body};
|
||||
use sprout_core::kind::{KIND_AGENT_ENGRAM, KIND_PERSONA};
|
||||
|
||||
use super::PersonaRecord;
|
||||
use crate::app_state::AppState;
|
||||
|
||||
/// Engram slug under which a managed agent stores the snapshot of the persona
|
||||
/// it was instantiated from. A `mem/` slug so it passes `engram::validate_slug`
|
||||
/// and the relay's envelope check.
|
||||
pub const PERSONA_ENGRAM_SLUG: &str = "mem/persona";
|
||||
|
||||
/// The JSON body stored in a persona event's content field.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonaEventContent {
|
||||
@@ -132,6 +138,166 @@ pub async fn fetch_persona_events(state: &AppState) -> Result<Vec<nostr::Event>,
|
||||
crate::relay::query_relay(state, &[filter]).await
|
||||
}
|
||||
|
||||
// ── Persona-snapshot engram ──────────────────────────────────────────────────
|
||||
|
||||
/// Provenance recorded inside the encrypted engram body, linking the snapshot
|
||||
/// back to the persona definition it was taken from. Kept inside the NIP-44
|
||||
/// ciphertext (never a plaintext tag) so the agent→persona linkage is not
|
||||
/// leaked to relay observers and the HMAC-blinded `d` tag stays the only
|
||||
/// correlation surface.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PersonaProvenance {
|
||||
/// Owner (workspace) pubkey, hex.
|
||||
pub owner: String,
|
||||
/// Source persona event kind (`KIND_PERSONA`, 30175).
|
||||
pub kind: u32,
|
||||
/// Source persona slug (the kind:30175 `d` tag).
|
||||
pub slug: String,
|
||||
/// Content version of the source persona — its `updated_at`. Changes
|
||||
/// whenever the persona definition changes, so a future fleet-update pass
|
||||
/// can diff snapshots without a timestamp comparison.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// The decrypted payload of a `mem/persona` engram: the persona definition
|
||||
/// plus its provenance. Serialized to JSON and carried as the engram body's
|
||||
/// `value` string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PersonaSnapshot {
|
||||
#[serde(flatten)]
|
||||
pub content: PersonaEventContent,
|
||||
pub provenance: PersonaProvenance,
|
||||
}
|
||||
|
||||
impl PartialEq for PersonaEventContent {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.display_name == other.display_name
|
||||
&& self.avatar_url == other.avatar_url
|
||||
&& self.system_prompt == other.system_prompt
|
||||
&& self.runtime == other.runtime
|
||||
&& self.model == other.model
|
||||
&& self.provider == other.provider
|
||||
&& self.name_pool == other.name_pool
|
||||
&& self.env_vars == other.env_vars
|
||||
}
|
||||
}
|
||||
impl Eq for PersonaEventContent {}
|
||||
|
||||
/// Build the `PersonaSnapshot` for a persona record and owner.
|
||||
fn persona_snapshot(record: &PersonaRecord, owner_pubkey: &PublicKey) -> PersonaSnapshot {
|
||||
PersonaSnapshot {
|
||||
content: 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(),
|
||||
},
|
||||
provenance: PersonaProvenance {
|
||||
owner: owner_pubkey.to_hex(),
|
||||
kind: KIND_PERSONA,
|
||||
slug: persona_d_tag(record),
|
||||
version: record.updated_at.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a signed `mem/persona` engram (kind:30174) snapshotting `record`.
|
||||
///
|
||||
/// The agent signs the event; the owner is the NIP-44 counterparty. The body
|
||||
/// is `Body::Memory { slug: "mem/persona", value: <PersonaSnapshot JSON> }`,
|
||||
/// so it satisfies `validate_and_decrypt` (the body slug re-derives to the
|
||||
/// HMAC-blinded `d` tag).
|
||||
pub fn build_persona_engram(
|
||||
agent_keys: &Keys,
|
||||
owner_pubkey: &PublicKey,
|
||||
record: &PersonaRecord,
|
||||
created_at: u64,
|
||||
) -> Result<nostr::Event, String> {
|
||||
let snapshot = persona_snapshot(record, owner_pubkey);
|
||||
let value = serde_json::to_string(&snapshot)
|
||||
.map_err(|e| format!("failed to serialize persona snapshot: {e}"))?;
|
||||
let body = Body::Memory {
|
||||
slug: PERSONA_ENGRAM_SLUG.to_string(),
|
||||
value: Some(value),
|
||||
};
|
||||
engram::build_event(agent_keys, owner_pubkey, &body, created_at)
|
||||
.map_err(|e| format!("failed to build persona engram: {e}"))
|
||||
}
|
||||
|
||||
/// Write the persona-snapshot engram at instantiation, best-effort.
|
||||
///
|
||||
/// Sequence per the canonical spec: publish → assert the relay `accepted`
|
||||
/// flag → re-fetch and pick the head with `select_head` (latest-wins, not
|
||||
/// `events[0]`) → `validate_and_decrypt` → assert the decrypted body equals
|
||||
/// what we published. Any failure is returned as `Err`; the caller logs and
|
||||
/// continues — a bad write never blocks agent creation, and NIP-33 replaceable
|
||||
/// semantics mean a rejected write leaves prior state intact.
|
||||
pub async fn write_persona_engram(
|
||||
agent_keys: &Keys,
|
||||
owner_pubkey: &PublicKey,
|
||||
record: &PersonaRecord,
|
||||
relay_url: &str,
|
||||
state: &AppState,
|
||||
) -> Result<(), String> {
|
||||
let created_at = engram::monotonic_created_at(nostr::Timestamp::now().as_secs(), None);
|
||||
let event = build_persona_engram(agent_keys, owner_pubkey, record, created_at)?;
|
||||
|
||||
let response = crate::relay::submit_signed_event(&event, agent_keys, relay_url, state).await?;
|
||||
if !response.accepted {
|
||||
return Err(format!(
|
||||
"relay rejected persona engram: {}",
|
||||
response.message
|
||||
));
|
||||
}
|
||||
|
||||
// Re-fetch by the HMAC-blinded d tag and verify the round-trip. We
|
||||
// re-derive the d tag from (agent_sk, owner_pk, slug) rather than trust
|
||||
// the event we built, so the read path is genuinely end-to-end.
|
||||
let k_c = engram::conversation_key(agent_keys.secret_key(), owner_pubkey);
|
||||
let d = engram::d_tag(&k_c, PERSONA_ENGRAM_SLUG);
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [KIND_AGENT_ENGRAM],
|
||||
"authors": [agent_keys.public_key().to_hex()],
|
||||
"#d": [d],
|
||||
});
|
||||
let events = crate::relay::query_relay_at(
|
||||
state,
|
||||
&crate::relay::relay_http_base_url(relay_url),
|
||||
&[filter],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let head = engram::select_head(events)
|
||||
.ok_or_else(|| "persona engram not found on re-fetch".to_string())?;
|
||||
let body = engram::validate_and_decrypt(
|
||||
&head,
|
||||
&agent_keys.public_key(),
|
||||
owner_pubkey,
|
||||
agent_keys.secret_key(),
|
||||
owner_pubkey,
|
||||
)
|
||||
.map_err(|e| format!("persona engram failed validation on re-fetch: {e}"))?;
|
||||
|
||||
let Body::Memory {
|
||||
value: Some(read_value),
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return Err("persona engram re-fetch returned an unexpected body shape".to_string());
|
||||
};
|
||||
let read_snapshot: PersonaSnapshot = serde_json::from_str(&read_value)
|
||||
.map_err(|e| format!("persona engram re-fetch body is not a PersonaSnapshot: {e}"))?;
|
||||
|
||||
if read_snapshot != persona_snapshot(record, owner_pubkey) {
|
||||
return Err("persona engram re-fetch body does not match what was published".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,4 +409,105 @@ mod tests {
|
||||
assert!(!restored.is_builtin);
|
||||
assert!(restored.is_active);
|
||||
}
|
||||
|
||||
// ── Persona-snapshot engram ──────────────────────────────────────────────
|
||||
|
||||
/// The engram built at instantiation must survive the full validation the
|
||||
/// relay enforces: kind, agent author, single 64-hex `d`, single owner `p`,
|
||||
/// NIP-44 decrypt, and body-slug re-derivation — and decrypt back to the
|
||||
/// exact persona plus provenance we put in.
|
||||
#[test]
|
||||
fn persona_engram_round_trips_through_validate_and_decrypt() {
|
||||
let record = sample_persona();
|
||||
let agent = nostr::Keys::generate();
|
||||
let owner = nostr::Keys::generate();
|
||||
let owner_pk = owner.public_key();
|
||||
|
||||
let event = build_persona_engram(&agent, &owner_pk, &record, 1_700_000_000).unwrap();
|
||||
|
||||
// Relay-side envelope: kind 30174, agent author, one 64-hex d, one p == owner.
|
||||
assert_eq!(event.kind.as_u16() as u32, KIND_AGENT_ENGRAM);
|
||||
assert_eq!(event.pubkey, agent.public_key());
|
||||
let d_tags: Vec<_> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind().to_string() == "d")
|
||||
.collect();
|
||||
assert_eq!(d_tags.len(), 1);
|
||||
assert_eq!(d_tags[0].content().unwrap().len(), 64);
|
||||
assert!(d_tags[0]
|
||||
.content()
|
||||
.unwrap()
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()));
|
||||
|
||||
// Owner decrypts with its own seckey against the agent pubkey (K_c is symmetric).
|
||||
let body = engram::validate_and_decrypt(
|
||||
&event,
|
||||
&agent.public_key(),
|
||||
&owner_pk,
|
||||
owner.secret_key(),
|
||||
&agent.public_key(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let Body::Memory {
|
||||
slug,
|
||||
value: Some(value),
|
||||
} = body
|
||||
else {
|
||||
panic!("expected memory body with value");
|
||||
};
|
||||
assert_eq!(slug, PERSONA_ENGRAM_SLUG);
|
||||
|
||||
let snapshot: PersonaSnapshot = serde_json::from_str(&value).unwrap();
|
||||
assert_eq!(snapshot, persona_snapshot(&record, &owner_pk));
|
||||
assert_eq!(snapshot.content.system_prompt, "You are a test assistant.");
|
||||
assert_eq!(snapshot.provenance.owner, owner_pk.to_hex());
|
||||
assert_eq!(snapshot.provenance.kind, KIND_PERSONA);
|
||||
assert_eq!(snapshot.provenance.slug, "test-slug");
|
||||
assert_eq!(snapshot.provenance.version, record.updated_at);
|
||||
}
|
||||
|
||||
/// Provenance lives inside the ciphertext, never as a plaintext tag — the
|
||||
/// only tags are the HMAC-blinded `d` and the owner `p`.
|
||||
#[test]
|
||||
fn persona_engram_leaks_no_plaintext_provenance() {
|
||||
let record = sample_persona();
|
||||
let agent = nostr::Keys::generate();
|
||||
let owner = nostr::Keys::generate();
|
||||
|
||||
let event =
|
||||
build_persona_engram(&agent, &owner.public_key(), &record, 1_700_000_000).unwrap();
|
||||
|
||||
let tag_names: Vec<String> = event.tags.iter().map(|t| t.kind().to_string()).collect();
|
||||
assert_eq!(tag_names, vec!["d".to_string(), "p".to_string()]);
|
||||
// No persona id, slug, kind, or "provenance" string in any plaintext field.
|
||||
let plaintext_tags = format!("{:?}", event.tags);
|
||||
assert!(!plaintext_tags.contains("test-slug"));
|
||||
assert!(!plaintext_tags.contains("provenance"));
|
||||
assert!(!plaintext_tags.contains(&KIND_PERSONA.to_string()));
|
||||
}
|
||||
|
||||
/// The d tag must re-derive from (agent_sk, owner_pk, slug), so a fleet
|
||||
/// update can locate the engram without any stored tag.
|
||||
#[test]
|
||||
fn persona_engram_d_tag_is_derivable() {
|
||||
let record = sample_persona();
|
||||
let agent = nostr::Keys::generate();
|
||||
let owner = nostr::Keys::generate();
|
||||
|
||||
let event =
|
||||
build_persona_engram(&agent, &owner.public_key(), &record, 1_700_000_000).unwrap();
|
||||
|
||||
let k_c = engram::conversation_key(agent.secret_key(), &owner.public_key());
|
||||
let expected = engram::d_tag(&k_c, PERSONA_ENGRAM_SLUG);
|
||||
let actual = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind().to_string() == "d")
|
||||
.and_then(|t| t.content())
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::{fs, path::PathBuf};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord},
|
||||
app_state::AppState,
|
||||
managed_agents::{managed_agents_base_dir, persona_events, PersonaRecord, TeamRecord},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
@@ -225,8 +226,9 @@ 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.
|
||||
pub fn import_team_from_directory(
|
||||
pub async fn import_team_from_directory(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
source_dir: &std::path::Path,
|
||||
symlink: bool,
|
||||
) -> Result<TeamRecord, String> {
|
||||
@@ -314,9 +316,23 @@ pub fn import_team_from_directory(
|
||||
|
||||
// 7. Save personas
|
||||
let mut personas = super::load_personas(app)?;
|
||||
personas.extend(new_personas);
|
||||
personas.extend(new_personas.iter().cloned());
|
||||
super::save_personas(app, &personas)?;
|
||||
|
||||
// 7b. Publish each persona as a kind:30175 event (best-effort). The local
|
||||
// store is authoritative this release; the relay copy is the durable,
|
||||
// shareable definition. A publish failure (relay unreachable, oversize
|
||||
// body) must not fail the import — the personas are already persisted
|
||||
// locally and a later edit-save re-publishes.
|
||||
for record in &new_personas {
|
||||
if let Err(e) = persona_events::publish_persona_event(record, state).await {
|
||||
eprintln!(
|
||||
"sprout-desktop: persona event publish for {}: {e}",
|
||||
record.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Create and save TeamRecord
|
||||
let symlink_target = if use_symlink {
|
||||
fs::canonicalize(source_dir)
|
||||
@@ -415,6 +431,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 +732,8 @@ 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).
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
#[allow(dead_code)] // Kept compilable for rollback; no caller after the launch swap to dedup_team_personas.
|
||||
pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> {
|
||||
let teams = load_teams(app)?;
|
||||
for team in &teams {
|
||||
@@ -727,11 +746,49 @@ pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Heal duplicate team-sourced personas left by a prior broken sync.
|
||||
///
|
||||
/// Runs on launch in place of the old `sync_team_personas` clobber. Only
|
||||
/// team-sourced records (both `source_team` and `source_team_persona_slug`
|
||||
/// present) are deduplicated, keyed by `(source_team, source_team_persona_slug)`
|
||||
/// — the canonical persona→team join. UI-created personas, which carry neither
|
||||
/// field, are never considered duplicates of one another and are left
|
||||
/// untouched. On a collision the most recently updated record is kept
|
||||
/// (`updated_at` descending; lexical `id` ascending as a deterministic
|
||||
/// tiebreak so the outcome does not depend on load order).
|
||||
pub fn dedup_team_personas(app: &AppHandle) -> Result<(), String> {
|
||||
let personas = super::load_personas(app)?;
|
||||
let deduped = dedup_team_sourced(personas);
|
||||
super::save_personas(app, &deduped)
|
||||
}
|
||||
|
||||
/// Pure dedup over a persona list. Extracted so the launch path and tests share
|
||||
/// one implementation.
|
||||
fn dedup_team_sourced(mut personas: Vec<PersonaRecord>) -> Vec<PersonaRecord> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Most-recent-first, then lexical id, so the first record seen per key is
|
||||
// the survivor. `updated_at` is canonical UTC ISO-8601, lexically ordered.
|
||||
personas.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
|
||||
let mut seen: HashMap<(String, String), ()> = HashMap::new();
|
||||
personas.retain(|p| match (&p.source_team, &p.source_team_persona_slug) {
|
||||
(Some(team), Some(slug)) => seen.insert((team.clone(), slug.clone()), ()).is_none(),
|
||||
// UI-created (or otherwise un-keyed) personas are never duplicates.
|
||||
_ => true,
|
||||
});
|
||||
personas
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion,
|
||||
BUILT_IN_TEAMS,
|
||||
dedup_team_sourced, encode_team_json, merge_teams, parse_team_json, sort_teams,
|
||||
validate_team_deletion, BUILT_IN_TEAMS,
|
||||
};
|
||||
use crate::managed_agents::{PersonaRecord, TeamRecord};
|
||||
|
||||
@@ -996,4 +1053,67 @@ mod tests {
|
||||
let custom = team("user-uuid", "My Team");
|
||||
assert!(validate_team_deletion(&custom).is_ok());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// dedup_team_sourced tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A team-sourced persona, identified by `(source_team, slug)`.
|
||||
fn team_persona(id: &str, team: &str, slug: &str, updated_at: &str) -> PersonaRecord {
|
||||
let mut p = persona(id, id, "prompt");
|
||||
p.source_team = Some(team.to_string());
|
||||
p.source_team_persona_slug = Some(slug.to_string());
|
||||
p.updated_at = updated_at.to_string();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_collapses_team_duplicates_keeping_latest() {
|
||||
let older = team_persona("a", "team-1", "agent", "2026-01-01T00:00:00Z");
|
||||
let newer = team_persona("b", "team-1", "agent", "2026-06-01T00:00:00Z");
|
||||
|
||||
let result = dedup_team_sourced(vec![older, newer]);
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].id, "b"); // latest updated_at survives
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_breaks_updated_at_ties_by_lowest_id() {
|
||||
let same_time = "2026-06-01T00:00:00Z";
|
||||
let p_b = team_persona("b", "team-1", "agent", same_time);
|
||||
let p_a = team_persona("a", "team-1", "agent", same_time);
|
||||
|
||||
let result = dedup_team_sourced(vec![p_b, p_a]);
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].id, "a"); // deterministic: lowest id wins on tie
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_keeps_distinct_team_personas() {
|
||||
let one = team_persona("a", "team-1", "agent-one", "2026-01-01T00:00:00Z");
|
||||
let two = team_persona("b", "team-1", "agent-two", "2026-01-01T00:00:00Z");
|
||||
|
||||
let result = dedup_team_sourced(vec![one, two]);
|
||||
|
||||
assert_eq!(result.len(), 2); // different slugs are not duplicates
|
||||
}
|
||||
|
||||
/// UI-created personas carry `(None, None)` source fields and must never be
|
||||
/// collapsed into one another — an ungated `(source_team, slug)` dedup would
|
||||
/// bucket them all together and delete all but one.
|
||||
#[test]
|
||||
fn dedup_never_collapses_ui_created_personas() {
|
||||
let one = persona("a", "Assistant One", "prompt one");
|
||||
let two = persona("b", "Assistant Two", "prompt two");
|
||||
assert!(one.source_team.is_none() && one.source_team_persona_slug.is_none());
|
||||
|
||||
let result = dedup_team_sourced(vec![one, two]);
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
let ids: Vec<&str> = result.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(ids.contains(&"a"));
|
||||
assert!(ids.contains(&"b"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +398,44 @@ pub async fn submit_event(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// POST an already-signed event to `/events`, authenticating the HTTP request
|
||||
/// with a NIP-98 header signed by `signer_keys` (the event's author).
|
||||
///
|
||||
/// Unlike [`submit_event`], this returns the parsed [`SubmitEventResponse`]
|
||||
/// without treating `accepted == false` as an error — callers that need to act
|
||||
/// on the relay's accept/reject decision (e.g. the best-effort engram write,
|
||||
/// which verifies acceptance before a round-trip read) inspect the flag
|
||||
/// themselves rather than collapsing it into a transport error.
|
||||
pub async fn submit_signed_event(
|
||||
event: &nostr::Event,
|
||||
signer_keys: &Keys,
|
||||
relay_url: &str,
|
||||
state: &AppState,
|
||||
) -> Result<SubmitEventResponse, String> {
|
||||
let url = format!("{}/events", relay_http_base_url(relay_url));
|
||||
let body_bytes = event.as_json().into_bytes();
|
||||
let auth = build_nip98_auth_header_for_keys(signer_keys, &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!("request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay_error_message(response).await);
|
||||
}
|
||||
|
||||
response
|
||||
.json::<SubmitEventResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {e}"))
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user