diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 551915357..3fd0e513a 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -112,6 +112,8 @@ fn agent_record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 9b8ef16ec..1c730b171 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -823,6 +823,8 @@ pub async fn create_managed_agent( source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 54a03e2ba..13a11321e 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -59,6 +59,8 @@ fn bare_agent_record( catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs b/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs index 47cea3c97..da10d5127 100644 --- a/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs +++ b/desktop/src-tauri/src/commands/global_agent_config_epoch_tests.rs @@ -102,6 +102,8 @@ async fn test_full_tail_stop_spawn_receipt_register_save() { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Default::default(), definition_parallelism: None, @@ -540,6 +542,8 @@ async fn test_relay_mesh_preflight_precedes_stop() { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Default::default(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/global_agent_config_tests.rs b/desktop/src-tauri/src/commands/global_agent_config_tests.rs index 851b68315..f36cd2268 100644 --- a/desktop/src-tauri/src/commands/global_agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/global_agent_config_tests.rs @@ -629,6 +629,8 @@ async fn test_record_mesh_change_after_preflight_aborts_before_stop() { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Default::default(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9..56a290de8 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -67,6 +67,8 @@ fn make_agent( catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a8343..65276319c 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -211,6 +211,8 @@ fn local_agent() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index fe79b90f9..5ad69765d 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -3,10 +3,10 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, - save_managed_agents, save_personas, stop_managed_agent_process, + current_instance_id, delete_agent_key, load_managed_agents, load_persona_views, + load_personas, load_teams, save_managed_agents, save_personas, stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, - validate_persona_deletion, AgentDefinition, ManagedAgentRecord, + validate_persona_deletion, AgentDefinition, ManagedAgentRecord, PersonaView, }, util::now_iso, }; @@ -41,7 +41,7 @@ mod inbound; pub use inbound::reconcile_inbound_persona_event; #[tauri::command] -pub async fn list_personas(app: AppHandle) -> Result, String> { +pub async fn list_personas(app: AppHandle) -> Result, String> { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -49,9 +49,23 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas); - Ok(personas) + let views = load_persona_views(&app)?; + // Share state is a command-layer projection over the definition view; + // project it onto the definitions, then re-pair with each view's + // library metadata. Order is the deterministic `load_personas` sort and + // is preserved through both halves. + let mut definitions: Vec = + views.iter().map(|view| view.definition.clone()).collect(); + pending::project_active_persona_sharing(&app, &state, &mut definitions); + Ok(definitions + .into_iter() + .zip(views) + .map(|(definition, view)| PersonaView { + definition, + library_ref: view.library_ref, + library_applied_revision: view.library_applied_revision, + }) + .collect()) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7..22fae21b2 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -60,6 +60,8 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 3b916bdab..4ba44f1a3 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -645,6 +645,8 @@ where source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 084d9ee18..59c73b746 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -69,6 +69,8 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4..f1599604b 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -54,6 +54,8 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 8e37d9903..f1d692c71 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -637,6 +637,8 @@ where source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index d9ad73f73..50b7f911b 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -225,6 +225,8 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079..159ae1870 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -212,6 +212,8 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c2707..0b3fc372b 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -412,6 +412,8 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e..da3d910ac 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -67,6 +67,8 @@ fn minimal_record() -> ManagedAgentRecord { shared: false, source_team: Some("team-id-123".to_string()), // MUST NOT appear source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + library_ref: None, + library_applied_revision: None, definition_respond_to: Some("allowlist".to_string()), catalog_source: None, definition_respond_to_allowlist: vec!["abc123def".to_string()], diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2..0d2c265a0 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -111,6 +111,8 @@ fn test_record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a7752..0aa29d538 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -222,8 +222,7 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests. Only the resolution -/// inputs (runtime / persona_id / agent_command_override) vary. +/// Minimal record for `record_agent_command` tests; only runtime/persona_id/agent_command_override vary. fn record_with( runtime: Option<&str>, persona_id: Option<&str>, @@ -279,6 +278,8 @@ fn record_with( source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -288,8 +289,7 @@ fn record_with( #[test] fn record_agent_command_own_runtime_wins_over_persona() { - // A record with its own materialized runtime never consults the - // persona list — the unified-model resolution. + // A record with its own materialized runtime never consults the persona list. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let record = record_with(Some("claude"), Some("p1"), None); assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809..1a95960c0 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -89,6 +89,8 @@ fn record( catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e22..5ebec06a4 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -349,6 +349,8 @@ fn bare_record() -> ManagedAgentRecord { catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6..2c57e7f16 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -498,6 +498,8 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b..baa1b23c9 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -113,6 +113,8 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 0580b12ce..e72ad5011 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -54,6 +54,8 @@ pub(super) fn sample_record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 2405038f5..2f5be7053 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -1,6 +1,29 @@ use std::fs; -use crate::{managed_agents::AgentDefinition, util::now_iso}; +use crate::{ + managed_agents::{AgentDefinition, ManagedAgentRecord}, + util::now_iso, +}; +use serde::Serialize; + +/// Read-only persona view for the list/get command boundary (§2.7, resolves +/// P4-C1). `definition` is the unchanged mutation-input shape; it flattens onto +/// the wire so the frontend keeps consuming a flat `RawPersona` with two new +/// optional fields rather than a nested shape. +/// +/// The metadata fields are OUTPUT-ONLY: no command input, client payload, or +/// inbound event may author them, and they are never round-tripped into a save +/// — §3's library operations are their only writers. They surface the shared +/// indicator without changing the persona mutation contract. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PersonaView { + #[serde(flatten)] + pub definition: AgentDefinition, + #[serde(skip_serializing_if = "Option::is_none")] + pub library_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub library_applied_revision: Option, +} struct BuiltInPersona { id: &'static str, @@ -385,19 +408,79 @@ pub(crate) fn load_personas_from_path( .map_err(|error| format!("failed to parse persona store: {error}")) } +/// Read personas with their cross-workspace library metadata (§2.7 read-side +/// exposure, resolves P4-C1). The list/get command boundary calls this instead +/// of [`load_personas`]; the ~78 non-command readers keep the flat +/// `Vec` loader unchanged. +/// +/// The definition projection is IDENTICAL to [`load_personas`] (same built-in +/// merge and write-back); each merged definition is then paired with its raw +/// record's `library_ref`/`library_applied_revision` by slug. Built-in +/// personas added by the merge have no raw record and so carry no metadata — +/// exactly right, they are never library projections. +pub(crate) fn load_persona_views( + app: &tauri::AppHandle, +) -> Result, String> { + let definitions = load_personas(app)?; + let raw = crate::managed_agents::storage::load_agent_definitions(app)?; + Ok(pair_persona_views(definitions, &raw)) +} + +/// Scoped variant of [`load_persona_views`]. Part of the scoped `_at()` API: +/// exercised by tests now, consumed by §3 activation reconcile once the library +/// read side lands. +#[allow(dead_code)] +pub(crate) fn load_persona_views_at( + definitions_dir: &std::path::Path, +) -> Result, String> { + let definitions = load_personas_at(definitions_dir)?; + let raw = crate::managed_agents::storage::load_agent_definitions_at(definitions_dir)?; + Ok(pair_persona_views(definitions, &raw)) +} + +/// Pair each merged definition with the library metadata of the raw record that +/// shares its slug. Pure and total: a definition with no matching raw record +/// (a built-in the merge just added) yields `None`/`None`, and library metadata +/// is read from the raw record ONLY — never from the definition view, which +/// cannot carry it. +fn pair_persona_views( + definitions: Vec, + raw: &[ManagedAgentRecord], +) -> Vec { + let metadata: std::collections::HashMap<&str, (&Option, &Option)> = raw + .iter() + .filter_map(|record| { + record.slug.as_deref().map(|slug| { + ( + slug, + (&record.library_ref, &record.library_applied_revision), + ) + }) + }) + .collect(); + + definitions + .into_iter() + .map(|definition| { + let (library_ref, library_applied_revision) = metadata + .get(definition.id.as_str()) + .map(|(r, rev)| ((*r).clone(), **rev)) + .unwrap_or((None, None)); + PersonaView { + definition, + library_ref, + library_applied_revision, + } + }) + .collect() +} + pub fn save_personas( app: &tauri::AppHandle, records: &[AgentDefinition], ) -> Result<(), String> { - let mut sorted = records.to_vec(); - sort_personas(&mut sorted); - - // Post-fold: persona saves write key-less definition records into the - // unified agent store (instances preserved by `save_agent_definitions`). - let definitions: Vec<_> = sorted - .into_iter() - .map(|persona| persona.into_agent_record()) - .collect(); + let existing = crate::managed_agents::storage::load_agent_definitions(app)?; + let definitions = merge_preserving_definitions(existing, records); crate::managed_agents::storage::save_agent_definitions(app, &definitions) } @@ -408,15 +491,55 @@ pub(crate) fn save_personas_at( definitions_dir: &std::path::Path, records: &[AgentDefinition], ) -> Result<(), String> { - let mut sorted = records.to_vec(); - sort_personas(&mut sorted); - - let definitions: Vec<_> = sorted - .into_iter() - .map(|persona| persona.into_agent_record()) - .collect(); + let existing = crate::managed_agents::storage::load_agent_definitions_at(definitions_dir)?; + let definitions = merge_preserving_definitions(existing, records); crate::managed_agents::storage::save_agent_definitions_at(definitions_dir, &definitions) } +/// Build the definition half of a persona save so that every field living only +/// on [`ManagedAgentRecord`] — `library_ref`, `library_applied_revision`, +/// `last_completed_deploy_attempt_id`, and any future non-view slot — survives +/// an ordinary save (§2.7, resolves P3-C1). +/// +/// At head, `save_personas` reconstructed every record wholesale through +/// [`AgentDefinition::into_agent_record`], so one unrelated local edit erased +/// the projection metadata on every OTHER definition — silently detaching every +/// shared agent. Here each view is instead applied onto the canonical raw +/// record of the same slug via [`ManagedAgentRecord::apply_definition_view`], +/// which writes ONLY the view-carried slots and leaves the rest intact. A +/// genuinely new persona (no matching raw record) is projected fresh through +/// `into_agent_record` — it has no metadata to lose. +/// +/// This makes every writer that funnels through `save_personas[_at]` (create, +/// update, activation toggle, delete, inbound upsert/tombstone, snapshot/team +/// import, team deletion) merge-preserving *by construction*. +/// +/// A raw record absent from `views` is a deletion, dropped from the result — +/// the head-identical plain path. Library-aware deletion routing (§3.4's +/// `ExcludePending` state machine) and the library-authoritative inbound branch +/// (§2.7 P4-C2) layer on at §3, where `apply_shared_definition` exists; at +/// Phase 0 no production path authors `library_ref`, so the plain path is the +/// only reachable one and matches head exactly. +fn merge_preserving_definitions( + existing: Vec, + views: &[AgentDefinition], +) -> Vec { + let mut by_slug: std::collections::HashMap = existing + .into_iter() + .filter_map(|record| record.slug.clone().map(|slug| (slug, record))) + .collect(); + + views + .iter() + .map(|view| match by_slug.remove(&view.id) { + Some(mut record) => { + record.apply_definition_view(view); + record + } + None => view.clone().into_agent_record(), + }) + .collect() +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c..96d80c6b1 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -410,3 +410,188 @@ fn fizz_builtin_resolves_to_buzz_agent() { "Fizz must resolve to buzz-agent specifically" ); } + +// ── §2.7 merge-preserving persona save (P3-C1 / P4-C1 acceptance) ───────────── + +use super::{ + load_persona_views_at, load_personas_at, merge_preserving_definitions, save_personas_at, +}; +use crate::managed_agents::storage::{load_agent_definitions_at, save_agent_definitions_at}; +use crate::managed_agents::ManagedAgentRecord; + +/// A keyless definition projected from a shared library entry: it carries +/// `library_ref`/`library_applied_revision`, which live ONLY on +/// `ManagedAgentRecord` and are exactly what head's wholesale save erased. +fn projected_record(slug: &str, revision: u64) -> ManagedAgentRecord { + let mut record = custom_persona(slug, "Shared Agent").into_agent_record(); + record.library_ref = Some(format!("lib-{slug}")); + record.library_applied_revision = Some(revision); + record +} + +/// Assert the projected record survives a save with its library metadata and +/// index invariant intact. `label` names the writer shape for failure output. +fn assert_projection_survived(saved: &[ManagedAgentRecord], label: &str) { + let projected = saved + .iter() + .find(|record| record.slug.as_deref() == Some("shared")) + .unwrap_or_else(|| panic!("{label}: projected record must survive the save")); + assert_eq!( + projected.library_ref.as_deref(), + Some("lib-shared"), + "{label}: library_ref must survive an unrelated writer", + ); + assert_eq!( + projected.library_applied_revision, + Some(3), + "{label}: library_applied_revision must survive an unrelated writer", + ); + assert_eq!( + saved + .iter() + .filter(|record| record.library_ref.as_deref() == Some("lib-shared")) + .count(), + 1, + "{label}: exactly one keyless record may link the library entry", + ); +} + +/// P3-C1 acceptance: every persona writer funnels through `save_personas[_at]`, +/// so a save that touches ONLY an unrelated persona must leave a projected +/// record's library metadata untouched. At head, `into_agent_record` +/// reconstructed the whole vector and erased it. Each closure models one +/// writer's vector transformation (create, update, activation toggle, delete, +/// inbound upsert, inbound tombstone, team import, team delete) applied to the +/// unrelated persona — never the projected one. +#[test] +fn merge_preserving_save_keeps_library_metadata_across_every_writer_shape() { + let seed = || { + vec![ + projected_record("shared", 3), + custom_persona("custom:plain", "Plain").into_agent_record(), + ] + }; + + type Shape = ( + &'static str, + fn(Vec) -> Vec, + ); + let shapes: Vec = vec![ + ("create", |mut views| { + views.push(custom_persona("custom:new", "New")); + views + }), + ("update", |mut views| { + for view in &mut views { + if view.id == "custom:plain" { + view.display_name = "Renamed".to_string(); + } + } + views + }), + ("activation_toggle", |mut views| { + for view in &mut views { + if view.id == "custom:plain" { + view.is_active = !view.is_active; + } + } + views + }), + ("delete_unrelated", |views| { + views + .into_iter() + .filter(|v| v.id != "custom:plain") + .collect() + }), + ("inbound_upsert_unrelated", |mut views| { + views.push(custom_persona("custom:inbound", "Inbound")); + views + }), + ("inbound_tombstone_unrelated", |views| { + views + .into_iter() + .filter(|v| v.id != "custom:plain") + .collect() + }), + ("team_import", |mut views| { + let mut member = custom_persona("team:member", "Team Member"); + member.source_team = Some("team-1".to_string()); + views.push(member); + views + }), + ("team_delete", |views| { + views + .into_iter() + .filter(|v| !v.id.starts_with("team:")) + .collect() + }), + ]; + + for (label, transform) in shapes { + let existing = seed(); + // A writer always passes the definition VIEW of the current store — + // library metadata is stripped because `AgentDefinition` cannot carry + // it. The seam must re-merge it from the canonical raw record. + let views: Vec = existing + .iter() + .filter_map(|record| record.to_definition_view()) + .collect(); + let saved = merge_preserving_definitions(existing, &transform(views)); + assert_projection_survived(&saved, label); + } +} + +/// The same guarantee through the on-disk `_at` seam — proving the storage +/// layer and the built-in-merge write-back preserve the metadata too — plus the +/// §2.7 read-side exposure (P4-C1): `load_persona_views_at` surfaces the +/// metadata for a projected record and reports none for a plain one. +#[test] +fn save_personas_at_preserves_library_metadata_on_disk() { + let dir = tempfile::tempdir().expect("temp dir"); + let definitions_dir = dir.path(); + + save_agent_definitions_at( + definitions_dir, + &[ + projected_record("shared", 3), + custom_persona("custom:plain", "Plain").into_agent_record(), + ], + ) + .expect("seed raw store"); + + // A full writer cycle: load the views (metadata stripped), edit the + // unrelated persona, save back through the merge-preserving seam. The load + // also merges built-ins and writes them back — another writer exercised. + let mut views = load_personas_at(definitions_dir).expect("load personas"); + for view in &mut views { + if view.id == "custom:plain" { + view.display_name = "Renamed".to_string(); + } + } + save_personas_at(definitions_dir, &views).expect("save personas"); + + let raw = load_agent_definitions_at(definitions_dir).expect("reload raw store"); + let projected = raw + .iter() + .find(|record| record.slug.as_deref() == Some("shared")) + .expect("projected record survives on disk"); + assert_eq!(projected.library_ref.as_deref(), Some("lib-shared")); + assert_eq!(projected.library_applied_revision, Some(3)); + + let persona_views = load_persona_views_at(definitions_dir).expect("load persona views"); + let shared_view = persona_views + .iter() + .find(|view| view.definition.id == "shared") + .expect("shared view present"); + assert_eq!(shared_view.library_ref.as_deref(), Some("lib-shared")); + assert_eq!(shared_view.library_applied_revision, Some(3)); + + let plain_view = persona_views + .iter() + .find(|view| view.definition.id == "custom:plain") + .expect("plain view present"); + assert!( + plain_view.library_ref.is_none() && plain_view.library_applied_revision.is_none(), + "a plain persona must surface no library metadata", + ); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff..deff0ee6d 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1465,9 +1465,7 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. + // env_vars provider/model must win over baked defaults (empty in OSS). let mut env_vars = BTreeMap::new(); env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); env_vars.insert( @@ -1526,6 +1524,8 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 9836d983e..63d33cd40 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -85,6 +85,8 @@ pub(super) fn fixture( source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands_concurrency_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_commands_concurrency_tests.rs index 634ecafb5..b7179a937 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands_concurrency_tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands_concurrency_tests.rs @@ -87,6 +87,8 @@ fn test_compensate_drain_writer_vs_compensation_deterministic() { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Default::default(), definition_parallelism: None, @@ -335,6 +337,8 @@ fn test_compensate_drain_concurrent_start_is_blocked() { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Default::default(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372..b5fc27d73 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -66,6 +66,8 @@ fn record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc7..66c4aa1a1 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -304,6 +304,8 @@ mod tests { shared: false, source_team: Some("SENTINEL_SOURCE_TEAM".to_string()), // MUST NOT appear source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear + library_ref: None, + library_applied_revision: None, definition_respond_to: None, catalog_source: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda..a3d42e37f 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -213,6 +213,8 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + library_ref: None, + library_applied_revision: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fe..7b6abab8d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -92,107 +92,6 @@ pub struct AgentDefinition { pub updated_at: String, } -impl AgentDefinition { - /// Project this persona onto a key-less unified [`ManagedAgentRecord`] - /// (Phase 1A store fold). Identity fields stay empty — keys are minted on - /// first start. `AgentDefinition.id` becomes `slug`, preserving the 30175 - /// event coordinate (`d_tag = slug`) across the fold. - pub fn into_agent_record(self) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: String::new(), - name: self.display_name.clone(), - persona_id: None, - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: self.avatar_url, - acp_command: DEFAULT_ACP_COMMAND.to_string(), - agent_command: String::new(), - agent_command_override: None, - agent_args: Vec::new(), - mcp_command: String::new(), - turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: default_agent_parallelism(), - system_prompt: (!self.system_prompt.is_empty()).then_some(self.system_prompt), - model: self.model, - provider: self.provider, - persona_source_version: None, - env_vars: self.env_vars, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: self.created_at, - updated_at: self.updated_at, - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::default(), - respond_to_allowlist: Vec::new(), - display_name: Some(self.display_name), - slug: Some(self.id), - runtime: self.runtime, - name_pool: self.name_pool, - is_builtin: self.is_builtin, - is_active: self.is_active, - // Catalog visibility is relay+owner scoped, not definition-global. - shared: false, - source_team: self.source_team, - source_team_persona_slug: self.source_team_persona_slug, - catalog_source: self.catalog_source, - definition_respond_to: self.respond_to, - definition_respond_to_allowlist: self.respond_to_allowlist, - definition_parallelism: self.parallelism, - relay_mesh: None, - } - } -} - -impl ManagedAgentRecord { - /// Present a key-less definition record back in the legacy - /// [`AgentDefinition`] shape — the compatibility view the persona command - /// surface serves until Phase 1B unifies the UI. Inverse of - /// [`AgentDefinition::into_agent_record`] for the fields personas carry. - pub fn to_definition_view(&self) -> Option { - let slug = self.slug.clone()?; - Some(AgentDefinition { - id: slug, - display_name: self - .display_name - .clone() - .unwrap_or_else(|| self.name.clone()), - avatar_url: self.avatar_url.clone(), - system_prompt: self.system_prompt.clone().unwrap_or_default(), - runtime: self.runtime.clone(), - model: self.model.clone(), - provider: self.provider.clone(), - name_pool: self.name_pool.clone(), - is_builtin: self.is_builtin, - is_active: self.is_active, - // Projected by `list_personas` from the active retention scope. - shared: false, - source_team: self.source_team.clone(), - source_team_persona_slug: self.source_team_persona_slug.clone(), - catalog_source: self.catalog_source.clone(), - env_vars: self.env_vars.clone(), - respond_to: self.definition_respond_to.clone(), - respond_to_allowlist: self.definition_respond_to_allowlist.clone(), - parallelism: self.definition_parallelism, - created_at: self.created_at.clone(), - updated_at: self.updated_at.clone(), - }) - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayAgentInfo { pub pubkey: String, @@ -409,6 +308,22 @@ pub struct ManagedAgentRecord { /// definition was copied from, when it came from another owner's catalog. #[serde(default, skip_serializing_if = "Option::is_none")] pub catalog_source: Option, + /// Cross-workspace library linkage (§2.6). `Some(library_id)` marks this + /// keyless definition as a projection of a shared library entry — the + /// mutation seam (§2.7) routes edits/deletes of such a record through the + /// library machinery instead of the plain local path, and the read seam + /// (`load_persona_views`) surfaces it as a shared indicator. Only §3's + /// library operations author this; no command input or inbound event may. + /// `#[serde(default, skip_serializing_if)]` keeps records without it + /// byte-identical to head. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_ref: Option, + /// The library-entry revision this projection's cached content reflects + /// (§2.6). Paired with `library_ref`; meaningless without it. Written only + /// by §3's `apply_shared_definition`/materialization; never round-tripped + /// through a persona save. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_applied_revision: Option, /// NIP-AP definition-level behavioral defaults, absorbed from /// `AgentDefinition` in WIRE shape (kebab-case string / optional u32), /// distinct from the instance-side `respond_to`/`respond_to_allowlist`/ @@ -990,6 +905,7 @@ pub fn resolve_mint_behavioral_defaults( mod catalog_source; pub use catalog_source::CatalogSource; +mod conversions; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/conversions.rs b/desktop/src-tauri/src/managed_agents/types/conversions.rs new file mode 100644 index 000000000..d6280b80f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/conversions.rs @@ -0,0 +1,157 @@ +//! Conversions between the persona-command shape [`AgentDefinition`] and the +//! unified store record [`ManagedAgentRecord`], split from `types.rs` +//! (file-size cap). These are the §2.7 compatibility seam: `into_agent_record` +//! projects a fresh persona into a keyless record, `to_definition_view` +//! presents a record back in the legacy command shape, and +//! `apply_definition_view` is the merge-preserving inverse that keeps +//! record-only fields intact across an ordinary persona save. + +use super::{ + default_agent_parallelism, AgentDefinition, BackendKind, ManagedAgentRecord, RespondTo, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, +}; + +impl AgentDefinition { + /// Project this persona onto a key-less unified [`ManagedAgentRecord`] + /// (Phase 1A store fold). Identity fields stay empty — keys are minted on + /// first start. `AgentDefinition.id` becomes `slug`, preserving the 30175 + /// event coordinate (`d_tag = slug`) across the fold. + pub fn into_agent_record(self) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: self.display_name.clone(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: self.avatar_url, + acp_command: DEFAULT_ACP_COMMAND.to_string(), + agent_command: String::new(), + agent_command_override: None, + agent_args: Vec::new(), + mcp_command: String::new(), + turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: default_agent_parallelism(), + system_prompt: (!self.system_prompt.is_empty()).then_some(self.system_prompt), + model: self.model, + provider: self.provider, + persona_source_version: None, + env_vars: self.env_vars, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: self.created_at, + updated_at: self.updated_at, + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: Vec::new(), + display_name: Some(self.display_name), + slug: Some(self.id), + runtime: self.runtime, + name_pool: self.name_pool, + is_builtin: self.is_builtin, + is_active: self.is_active, + // Catalog visibility is relay+owner scoped, not definition-global. + shared: false, + source_team: self.source_team, + source_team_persona_slug: self.source_team_persona_slug, + catalog_source: self.catalog_source, + // Library linkage is authored only by §3's projection machinery; + // a freshly projected definition carries none. + library_ref: None, + library_applied_revision: None, + definition_respond_to: self.respond_to, + definition_respond_to_allowlist: self.respond_to_allowlist, + definition_parallelism: self.parallelism, + relay_mesh: None, + } + } +} + +impl ManagedAgentRecord { + /// Present a key-less definition record back in the legacy + /// [`AgentDefinition`] shape — the compatibility view the persona command + /// surface serves until Phase 1B unifies the UI. Inverse of + /// [`AgentDefinition::into_agent_record`] for the fields personas carry. + pub fn to_definition_view(&self) -> Option { + let slug = self.slug.clone()?; + Some(AgentDefinition { + id: slug, + display_name: self + .display_name + .clone() + .unwrap_or_else(|| self.name.clone()), + avatar_url: self.avatar_url.clone(), + system_prompt: self.system_prompt.clone().unwrap_or_default(), + runtime: self.runtime.clone(), + model: self.model.clone(), + provider: self.provider.clone(), + name_pool: self.name_pool.clone(), + is_builtin: self.is_builtin, + is_active: self.is_active, + // Projected by `list_personas` from the active retention scope. + shared: false, + source_team: self.source_team.clone(), + source_team_persona_slug: self.source_team_persona_slug.clone(), + catalog_source: self.catalog_source.clone(), + env_vars: self.env_vars.clone(), + respond_to: self.definition_respond_to.clone(), + respond_to_allowlist: self.definition_respond_to_allowlist.clone(), + parallelism: self.definition_parallelism, + created_at: self.created_at.clone(), + updated_at: self.updated_at.clone(), + }) + } + + /// Inverse of [`to_definition_view`](Self::to_definition_view) for EXACTLY + /// the fields the persona view carries. Every other field of `self` — the + /// instance-side slots, and (once §3 lands) `library_ref`, + /// `library_applied_revision`, `last_completed_deploy_attempt_id`, plus any + /// future non-view field — is untouched by construction: this writes only + /// the slots [`to_definition_view`](Self::to_definition_view) reads. + /// + /// This is the seam that makes an ordinary persona save merge-preserving. + /// At head, `save_personas` reconstructed every record wholesale through + /// [`into_agent_record`](AgentDefinition::into_agent_record), so any field + /// living only on `ManagedAgentRecord` was erased by an unrelated save. + /// Applying the view onto the canonical raw record instead keeps those + /// fields intact. The value mapping mirrors `into_agent_record` so a record + /// updated this way is byte-identical to one freshly projected. + pub(crate) fn apply_definition_view(&mut self, view: &AgentDefinition) { + self.slug = Some(view.id.clone()); + self.display_name = Some(view.display_name.clone()); + self.name = view.display_name.clone(); + self.avatar_url = view.avatar_url.clone(); + self.system_prompt = (!view.system_prompt.is_empty()).then(|| view.system_prompt.clone()); + self.runtime = view.runtime.clone(); + self.model = view.model.clone(); + self.provider = view.provider.clone(); + self.name_pool = view.name_pool.clone(); + self.is_builtin = view.is_builtin; + self.is_active = view.is_active; + // Catalog visibility is relay+owner scoped, never definition-global — + // `view.shared` is a command projection and must not be persisted. + self.shared = false; + self.source_team = view.source_team.clone(); + self.source_team_persona_slug = view.source_team_persona_slug.clone(); + self.catalog_source = view.catalog_source.clone(); + self.env_vars = view.env_vars.clone(); + self.definition_respond_to = view.respond_to.clone(); + self.definition_respond_to_allowlist = view.respond_to_allowlist.clone(); + self.definition_parallelism = view.parallelism; + self.created_at = view.created_at.clone(); + self.updated_at = view.updated_at.clone(); + } +}