feat(desktop): add cross-workspace agent-library compatibility seam (Phase 0)

Phase 0 of the cross-workspace agent library (spec §2.7) — the compatibility
seam that lets §3's library machinery land without a data-loss regression.
No library code exists yet; this is the pure refactor that makes every persona
writer merge-preserving by construction.

- `ManagedAgentRecord` gains `library_ref` / `library_applied_revision`
  (§2.6), both `#[serde(default, skip_serializing_if)]` so records without
  them stay byte-identical to head. Only §3 authors them.
- `apply_definition_view` writes ONLY the view-carried slots onto a canonical
  raw record, so any record-only field survives an unrelated save. At head,
  `save_personas` reconstructed every record wholesale through
  `into_agent_record`, silently erasing such fields on every other definition.
- `save_personas[_at]` now merges views onto existing raw records by slug via
  `merge_preserving_definitions`; a genuinely new persona is still projected
  fresh. Loader signatures are untouched (P5-I2).
- `load_persona_views[_at]` + `PersonaView` (flattened wire, skip-none) expose
  the library metadata at the list/get command boundary; `list_personas`
  returns `PersonaView`. The ~78 non-command readers keep the flat loader.
- Two preservation tests: every writer shape through the in-memory seam, and a
  full on-disk save/reload cycle through `_at` plus the read-side exposure.

The `AgentDefinition <-> ManagedAgentRecord` conversions move to a new
`types/conversions.rs` submodule (following the existing `types/` split), which
keeps `types.rs` under the 1000-line file-size cap after the two new fields.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-11 04:40:17 -04:00
co-authored by Will Pfleger
parent 9bac71b65b
commit 2f539397a8
34 changed files with 585 additions and 132 deletions
@@ -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,
+2
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+21 -7
View File
@@ -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<Vec<AgentDefinition>, String> {
pub async fn list_personas(app: AppHandle) -> Result<Vec<PersonaView>, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
@@ -49,9 +49,23 @@ pub async fn list_personas(app: AppHandle) -> Result<Vec<AgentDefinition>, 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<AgentDefinition> =
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}"))?
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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()],
@@ -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,
@@ -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");
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+140 -17
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub library_applied_revision: Option<u64>,
}
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<AgentDefinition>` 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<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> Result<Vec<PersonaView>, 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<Vec<PersonaView>, 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<AgentDefinition>,
raw: &[ManagedAgentRecord],
) -> Vec<PersonaView> {
let metadata: std::collections::HashMap<&str, (&Option<String>, &Option<u64>)> = 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<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
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<crate::managed_agents::ManagedAgentRecord>,
views: &[AgentDefinition],
) -> Vec<crate::managed_agents::ManagedAgentRecord> {
let mut by_slug: std::collections::HashMap<String, _> = 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;
@@ -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<AgentDefinition>) -> Vec<AgentDefinition>,
);
let shapes: Vec<Shape> = 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<AgentDefinition> = 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",
);
}
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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![],
@@ -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,
+17 -101
View File
@@ -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<AgentDefinition> {
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<CatalogSource>,
/// 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<String>,
/// 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<u64>,
/// 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::*;
@@ -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<AgentDefinition> {
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();
}
}