mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user