feat(desktop): boot-time reconcile of managed agents to relay events (#1601)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-07 13:53:41 -07:00
committed by GitHub
co-authored by Pinky
parent 777babf393
commit c5a541ee2d
11 changed files with 983 additions and 575 deletions
+8 -3
View File
@@ -153,11 +153,16 @@ const overrides = new Map([
// migration_tests.rs carries the harness-sync migration coverage plus the
// patch_json_records owner-only writeback regression test (SECURITY.md:90
// crash-safe 0o600 fallback). Load-bearing security + feature coverage, not
// generic debt growth. Approved override; still queued to split.
["src-tauri/src/migration_tests.rs", 1410],
// generic debt growth. Approved override; still queued to split. Event-sync
// (persona/team event reconcile) tests were split out to event_sync_tests.rs
// and the limit ratcheted 1410 → 1110.
["src-tauri/src/migration_tests.rs", 1110],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1575],
// Boot-time event sync (persona/team/agent event reconcile) was split out
// to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is
// the pre-identity data migrations; still queued to split further.
["src-tauri/src/migration.rs", 1310],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
+280
View File
@@ -0,0 +1,280 @@
//! Boot-time disk→relay event reconcile ("event sync").
//!
//! Reconciles the on-disk JSON stores (`personas.json`, `teams.json`,
//! `managed-agents.json`) into signed retention events queued for relay
//! publish. Runs after identity resolution (event signing needs the owner
//! keys), unlike the pre-identity migrations in [`crate::migration`].
use std::path::Path;
/// Reconcile personas, teams, and managed agents into signed retention
/// events. All readers consume the already-synced
/// `personas.json`/`teams.json`/`managed-agents.json` that
/// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`]
/// (see its `# Ordering` guard). Event signing needs the resolved owner keys,
/// so this runs after identity resolution, not in the boot migrations.
pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) {
migrate_personas_to_events(app, owner_keys);
migrate_teams_to_events(app, owner_keys);
crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys);
}
/// Reconcile `personas.json` into the persona-event retention store.
///
/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being
/// complete) and AFTER the persisted identity is resolved (it signs every
/// retained event with the owner's keys).
///
/// Per-record reconcile: for each non-builtin persona it compares the freshly
/// serialized event content against the retained row at the same coordinate
/// and re-retains (marking `pending_sync = 1`) only when the row is absent or
/// its content differs. An unchanged persona is left untouched, so a launch
/// after a no-op edit does not churn `pending_sync`; a persona added or edited
/// on disk between launches is picked up and republished. There is no
/// whole-store sentinel — comparing per coordinate is what lets newly added
/// personas reach the relay.
///
/// Strategy: write to local SQLite retention first (durable copy), mark as
/// `pending_sync = 1` for later relay publish. Migration succeeds on local
/// write, not relay acknowledgment. Every retained row is a real signed
/// event — there is no placeholder path.
pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
use crate::managed_agents::managed_agents_base_dir;
let Ok(base_dir) = managed_agents_base_dir(app) else {
return;
};
match migrate_personas_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(migrated) => {
eprintln!(
"buzz-desktop: persona-event-migration: {migrated} personas migrated to retention"
);
}
Err(e) => {
eprintln!("buzz-desktop: persona-event-migration: {e}");
}
}
}
/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Returns the number of personas (re)written to the retention store. Returns
/// `Ok(0)` when every non-builtin persona already has a matching retained row
/// (or there are none to reconcile).
fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
use crate::managed_agents::{
persona_events::{build_persona_event, monotonic_created_at, persona_d_tag},
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
PersonaRecord,
};
use buzz_core_pkg::kind::KIND_PERSONA;
use nostr::JsonUtil;
let pubkey = keys.public_key().to_hex();
// Read personas.json fresh at reconcile time. Nothing to do if absent.
let personas_path = base_dir.join("personas.json");
if !personas_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&personas_path)
.map_err(|e| format!("failed to read personas.json: {e}"))?;
let records: Vec<PersonaRecord> = serde_json::from_str(&content)
.map_err(|e| format!("failed to parse personas.json: {e}"))?;
if records.is_empty() {
return Ok(0);
}
// Open (or create) the retention database.
let db_path = base_dir.join("retention.db");
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
let mut migrated = 0u32;
for record in &records {
// Skip built-in personas — they're always available from code.
if record.is_builtin {
continue;
}
let d_tag = persona_d_tag(record);
// Fetch the retained head first so the rebuilt event can supersede it:
// build at the default `now` and a future-dated head (clock skew, or an
// interactive same-second `max(now, head+1)` bump) would make
// `retain_event`'s `created_at >= ...` guard SILENTLY skip the UPDATE
// while `migrated` over-reports. Mirror the interactive sites' monotonic
// bump (F1) so a changed body always lands.
let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?;
let event = build_persona_event(record)
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?
.custom_created_at(monotonic_created_at(
existing.as_ref().map(|row| row.created_at),
))
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?;
// Per-coordinate reconcile: skip when an identical body is already
// retained, so an unchanged persona doesn't reset `pending_sync`.
// Content is timestamp-independent, so the monotonic bump above never
// forces a spurious republish.
let event_content = event.content.to_string();
if existing
.as_ref()
.is_some_and(|row| row.content == event_content)
{
continue;
}
let retained = RetainedEvent {
kind: KIND_PERSONA,
pubkey: pubkey.clone(),
d_tag,
content: event_content,
// Safety: nostr timestamps are seconds and stay below i64::MAX
// until year 2262.
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
};
// The monotonic bump guarantees `created_at > head`, so the upsert's
// `>=` guard always lands the UPDATE — `migrated` counts only real,
// retained republishes.
retain_event(&conn, &retained)
.map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?;
migrated += 1;
}
Ok(migrated)
}
/// Reconcile `teams.json` into kind:30176 team events in the retention store.
///
/// Mirrors [`migrate_personas_to_events`] for teams: it picks up team metadata
/// edits (name/description/persona_ids) made on disk between launches and
/// queues them for relay publish. Managed agents (kind:30177) are deliberately
/// NOT reconciled here — they have no pack/dir source and are backfilled from
/// `managed-agents.json` elsewhere.
///
/// Must run after the persisted identity is resolved (it signs each event with
/// the owner's keys).
pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
use crate::managed_agents::managed_agents_base_dir;
let Ok(base_dir) = managed_agents_base_dir(app) else {
return;
};
match migrate_teams_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(migrated) => {
eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention");
}
Err(e) => {
eprintln!("buzz-desktop: team-event-migration: {e}");
}
}
}
/// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Returns the number of teams (re)written to the retention store. The
/// per-coordinate content compare matches [`migrate_personas_in_dir`]: an
/// unchanged team is skipped so a launch does not churn `pending_sync`.
fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
use crate::managed_agents::{
persona_events::monotonic_created_at,
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
team_events::build_team_event,
TeamRecord,
};
use buzz_core_pkg::kind::KIND_TEAM;
use nostr::JsonUtil;
let pubkey = keys.public_key().to_hex();
let teams_path = base_dir.join("teams.json");
if !teams_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&teams_path)
.map_err(|e| format!("failed to read teams.json: {e}"))?;
let records: Vec<TeamRecord> =
serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?;
if records.is_empty() {
return Ok(0);
}
let db_path = base_dir.join("retention.db");
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
let mut migrated = 0u32;
for record in &records {
// Skip built-in teams — they're always available from code.
if record.is_builtin {
continue;
}
// Team d-tag is the team id (team_events.rs: no slug fallback).
let d_tag = record.id.clone();
// Fetch the head first so the monotonic bump can supersede a
// future-dated head — see migrate_personas_in_dir (F1/F8).
let existing = get_retained_event(&conn, KIND_TEAM, &pubkey, &d_tag)?;
let event = build_team_event(record)
.map_err(|e| format!("failed to build event for team '{}': {e}", record.name))?
.custom_created_at(monotonic_created_at(
existing.as_ref().map(|row| row.created_at),
))
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for team '{}': {e}", record.name))?;
let event_content = event.content.to_string();
if existing
.as_ref()
.is_some_and(|row| row.content == event_content)
{
continue;
}
let retained = RetainedEvent {
kind: KIND_TEAM,
pubkey: pubkey.clone(),
d_tag,
content: event_content,
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
};
// Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts
// only real republishes.
retain_event(&conn, &retained)
.map_err(|e| format!("failed to retain team '{}': {e}", record.name))?;
migrated += 1;
}
Ok(migrated)
}
#[cfg(test)]
#[path = "event_sync_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "event_sync_team_events_tests.rs"]
mod team_events_tests;
+301
View File
@@ -0,0 +1,301 @@
use super::*;
/// Helper: write a `personas.json` directly in `base_dir` (the migration
/// reads `base_dir/personas.json`, where `base_dir` is the `agents` dir).
fn write_base_personas(base_dir: &Path, records: &serde_json::Value) {
std::fs::write(
base_dir.join("personas.json"),
serde_json::to_string_pretty(records).unwrap(),
)
.unwrap();
}
fn one_persona() -> serde_json::Value {
serde_json::json!([{
"id": "code-reviewer",
"display_name": "Code Reviewer",
"system_prompt": "You review code.",
"is_builtin": false,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}])
}
#[test]
fn migrate_personas_writes_signed_retention_rows() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap();
assert_eq!(migrated, 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &pubkey).unwrap();
assert_eq!(rows.len(), 1);
// Row holds a real signed event for the owner — not a placeholder.
assert_eq!(rows[0].pubkey, pubkey);
let event: nostr::Event = nostr::JsonUtil::from_json(&rows[0].raw_event).unwrap();
assert!(event.verify().is_ok());
assert!(rows[0].pending_sync);
}
#[test]
fn migrate_personas_skips_builtins() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(
base.path(),
&serde_json::json!([{
"id": "builtin:solo",
"display_name": "Solo",
"system_prompt": "x",
"is_builtin": true,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}]),
);
let keys = nostr::Keys::generate();
let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap();
assert_eq!(migrated, 0);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &keys.public_key().to_hex()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn migrate_personas_unchanged_second_run_is_noop() {
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
// First run retains; second run with identical personas re-retains
// nothing — the per-coordinate content matches, so `pending_sync` is
// not churned.
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0);
assert!(!base.path().join("migration_state.json").exists());
}
#[test]
fn migrate_personas_new_persona_after_first_run_gets_retained() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
// A persona added to personas.json after the first reconcile must be
// picked up — the whole-store sentinel that previously short-circuited
// this is gone.
let mut two = one_persona();
two.as_array_mut().unwrap().push(serde_json::json!({
"id": "test-writer",
"display_name": "Test Writer",
"system_prompt": "You write tests.",
"is_builtin": false,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-02T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z"
}));
write_base_personas(base.path(), &two);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &pubkey).unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn migrate_personas_edited_persona_re_retains_pending() {
use crate::managed_agents::retention::{get_retained_event, mark_synced, open_retention_db};
use buzz_core_pkg::kind::KIND_PERSONA;
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
// Simulate the flush loop confirming the first publish.
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
mark_synced(
&conn,
KIND_PERSONA,
&pubkey,
"code-reviewer",
row.created_at,
&row.content,
)
.unwrap();
drop(conn);
// Editing the persona on disk must re-retain it as pending so the edit
// reaches the relay on the next flush.
let mut edited = one_persona();
edited.as_array_mut().unwrap()[0]["system_prompt"] =
serde_json::json!("You review code carefully.");
write_base_personas(base.path(), &edited);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
assert!(row.pending_sync);
assert!(row.content.contains("carefully"));
}
#[test]
fn migrate_personas_no_file_is_noop() {
let base = tempfile::tempdir().unwrap();
let keys = nostr::Keys::generate();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0);
}
/// F8: a future-dated retained head must be SUPERSEDED on a changed-content
/// migration, not silently skipped by `retain_event`'s `>=` guard. Without the
/// monotonic `created_at` bump the rebuilt event lands at `now <= head`, the
/// upsert's `WHERE excluded.created_at >= ...` drops the UPDATE, and `migrated`
/// over-reports. The bump (max(now, head+1)) guarantees supersession.
#[test]
fn migrate_personas_supersedes_future_dated_head() {
use crate::managed_agents::retention::{
get_retained_event, open_retention_db, retain_event, RetainedEvent,
};
use buzz_core_pkg::kind::KIND_PERSONA;
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
// First migrate retains the persona at ~now.
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
// Force the retained head far into the future, simulating a clock-skewed or
// same-second `max(now, head+1)` interactive bump.
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let head = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
let future = nostr::Timestamp::now().as_secs() as i64 + 100_000;
retain_event(
&conn,
&RetainedEvent {
created_at: future,
pending_sync: false,
..head
},
)
.unwrap();
// Change the persona body on disk, then migrate again.
let mut edited = one_persona();
edited.as_array_mut().unwrap()[0]["system_prompt"] =
serde_json::json!("You review code very carefully.");
write_base_personas(base.path(), &edited);
assert_eq!(
migrate_personas_in_dir(base.path(), &keys).unwrap(),
1,
"changed content over a future-dated head must report a real migration"
);
let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
// The new body actually landed (not silently skipped) ...
assert!(
row.content.contains("very carefully"),
"changed body must supersede the future-dated head, not be dropped"
);
// ... at a created_at strictly past the future head (monotonic bump) ...
assert_eq!(row.created_at, future + 1);
// ... and is queued for republish.
assert!(row.pending_sync, "superseding row must be pending_sync");
}
fn write_base_teams(base_dir: &Path, records: &serde_json::Value) {
std::fs::write(
base_dir.join("teams.json"),
serde_json::to_string_pretty(records).unwrap(),
)
.unwrap();
}
/// F8 for the team migration site — same supersede guarantee as personas.
#[test]
fn migrate_teams_supersedes_future_dated_head() {
use crate::managed_agents::retention::{
get_retained_event, open_retention_db, retain_event, RetainedEvent,
};
use buzz_core_pkg::kind::KIND_TEAM;
let base = tempfile::tempdir().unwrap();
let team = serde_json::json!([{
"id": "my-team",
"name": "My Team",
"description": "first",
"persona_ids": ["code-reviewer"],
"is_builtin": false,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}]);
write_base_teams(base.path(), &team);
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let head = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team")
.unwrap()
.unwrap();
let future = nostr::Timestamp::now().as_secs() as i64 + 100_000;
retain_event(
&conn,
&RetainedEvent {
created_at: future,
pending_sync: false,
..head
},
)
.unwrap();
let mut edited = team.clone();
edited.as_array_mut().unwrap()[0]["description"] = serde_json::json!("second");
write_base_teams(base.path(), &edited);
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team")
.unwrap()
.unwrap();
assert!(row.content.contains("second"));
assert_eq!(row.created_at, future + 1);
assert!(row.pending_sync);
}
+2 -1
View File
@@ -2,6 +2,7 @@ mod app_state;
mod archive;
mod commands;
mod deep_link;
mod event_sync;
mod events;
mod huddle;
mod managed_agents;
@@ -232,7 +233,7 @@ pub fn run() {
.lock()
.map(|k| k.clone())
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
migration::run_event_sync(&app_handle, &owner_keys);
event_sync::run_event_sync(&app_handle, &owner_keys);
// Backfill the pinned persona snapshot for any pre-existing agent
// that predates the record-authoritative-spawn cutover (persona_id
@@ -15,6 +15,7 @@ mod personas;
#[cfg(windows)]
mod process_lifecycle;
pub(crate) mod readiness;
pub(crate) mod reconcile;
#[cfg(feature = "mesh-llm")]
mod relay_mesh;
mod repos;
@@ -0,0 +1,139 @@
//! Boot-time disk↔relay reconcile for managed-agent (kind:30177) events.
//!
//! `run_event_sync` already reconciles personas (30175) and teams (30176)
//! into the retention store at boot; managed agents were the missing leg —
//! their events were enqueued only on the interactive save path
//! (`retain_managed_agent_pending`), so a record edited on disk between
//! launches, or a save whose publish was missed, silently diverged from the
//! relay. This module mirrors `migrate_personas_in_dir`: per-coordinate
//! content diff, monotonic `created_at` bump, retain with `pending_sync = 1`
//! for the existing flush loop.
//!
//! Best-effort contract (decided in #centralize-personas-and-agents):
//! - No file watcher — hand edits are picked up at next boot only.
//! - No deletion reconcile — a record absent from `managed-agents.json` is
//! left untouched in retention; a truncated or partial file must never
//! trigger tombstones.
//! - A malformed store fails loudly: the broken file is preserved as
//! `managed-agents.json.invalid` (see [`super::storage::backup_invalid_store`])
//! and an error is returned, never silently skipped.
use std::path::Path;
use super::{
agent_events::build_agent_event,
persona_events::monotonic_created_at,
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
ManagedAgentRecord,
};
use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
use nostr::JsonUtil;
/// Reconcile `managed-agents.json` into kind:30177 events in the retention
/// store. Boot-time entry point, called from `event_sync::run_event_sync`
/// after the persona and team legs.
pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
let Ok(base_dir) = super::managed_agents_base_dir(app) else {
return;
};
match reconcile_agents_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(reconciled) => {
eprintln!(
"buzz-desktop: agent-event-reconcile: {reconciled} agents reconciled to retention"
);
}
Err(e) => {
eprintln!("buzz-desktop: agent-event-reconcile: {e}");
}
}
}
/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Reads `managed-agents.json` raw — no keyring hydration: the published
/// projection ([`super::agent_events::agent_event_content`]) is the opt-IN
/// no-secrets allowlist, so keys are never needed here. For each record it
/// compares the freshly built event's content against the retained row at
/// `(30177, owner, agent_pubkey)` and re-retains (marking `pending_sync = 1`)
/// only when the row is absent or its content differs — an unchanged agent
/// never churns `pending_sync`.
///
/// Returns the number of agents (re)written to the retention store.
pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
let store_path = base_dir.join("managed-agents.json");
if !store_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&store_path)
.map_err(|e| format!("failed to read managed-agents.json: {e}"))?;
let records: Vec<ManagedAgentRecord> = serde_json::from_str(&content).map_err(|e| {
super::storage::backup_invalid_store(&store_path);
format!("failed to parse managed-agents.json (preserved as .invalid): {e}")
})?;
if records.is_empty() {
return Ok(0);
}
let owner_pubkey = keys.public_key().to_hex();
let db_path = base_dir.join("retention.db");
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
let mut reconciled = 0u32;
for record in &records {
// A record without a pubkey has no event coordinate yet (key-less
// agents mint keys on first start) — nothing to reconcile.
if record.pubkey.is_empty() {
continue;
}
let existing =
get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?;
// Build the event first and compare ITS content, so the comparison and
// the retained row share one serialization of the projection (mirrors
// `migrate_personas_in_dir`). Serializing the projection independently
// here would silently diverge if `build_agent_event` ever changed how
// it serializes — republishing every agent every boot. Content is
// timestamp-independent, so the monotonic bump below never forces a
// spurious republish; an unchanged agent is still a true no-op.
let event = build_agent_event(record)?
.custom_created_at(monotonic_created_at(
existing.as_ref().map(|row| row.created_at),
))
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?;
let content = event.content.clone();
if existing.as_ref().is_some_and(|row| row.content == content) {
continue;
}
retain_event(
&conn,
&RetainedEvent {
kind: KIND_MANAGED_AGENT,
pubkey: owner_pubkey.clone(),
d_tag: record.pubkey.clone(),
content,
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
},
)
.map_err(|e| format!("failed to retain '{}': {e}", record.name))?;
reconciled += 1;
}
Ok(reconciled)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,221 @@
use super::*;
use crate::managed_agents::retention::{get_pending_sync, get_retained_event, mark_synced};
use std::collections::BTreeMap;
use tempfile::TempDir;
fn sample_record(pubkey: &str, name: &str) -> ManagedAgentRecord {
serde_json::from_str(&format!(
r#"{{
"pubkey": "{pubkey}",
"name": "{name}",
"relay_url": "wss://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"system_prompt": "You are a test agent.",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_started_at": null,
"last_stopped_at": null,
"last_exit_code": null,
"last_error": null
}}"#
))
.unwrap()
}
fn write_store(dir: &TempDir, records: &[ManagedAgentRecord]) {
std::fs::write(
dir.path().join("managed-agents.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
#[test]
fn missing_store_is_noop() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0);
}
#[test]
fn fresh_record_is_retained_pending() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
write_store(&dir, &[sample_record("a".repeat(64).as_str(), "agent-one")]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let pending = get_pending_sync(&conn).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].kind, KIND_MANAGED_AGENT);
assert_eq!(pending[0].d_tag, "a".repeat(64));
// The retained content is the opt-IN projection — never secrets.
assert!(!pending[0].raw_event.contains("nsec"));
}
#[test]
fn unchanged_record_does_not_churn_pending_sync() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
write_store(&dir, &[sample_record("b".repeat(64).as_str(), "agent-two")]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
// Simulate the flush loop confirming the publish.
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let row = get_retained_event(
&conn,
KIND_MANAGED_AGENT,
&keys.public_key().to_hex(),
&"b".repeat(64),
)
.unwrap()
.unwrap();
mark_synced(
&conn,
row.kind,
&row.pubkey,
&row.d_tag,
row.created_at,
&row.content,
)
.unwrap();
drop(conn);
// Second boot with identical disk state: no re-retain, no pending churn.
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0);
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
assert!(get_pending_sync(&conn).unwrap().is_empty());
}
#[test]
fn edited_record_is_republished() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
let mut record = sample_record("c".repeat(64).as_str(), "agent-three");
write_store(&dir, &[record.clone()]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
// Hand-edit a published field between launches.
record.system_prompt = Some("You are an edited agent.".to_string());
write_store(&dir, &[record]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let row = get_retained_event(
&conn,
KIND_MANAGED_AGENT,
&keys.public_key().to_hex(),
&"c".repeat(64),
)
.unwrap()
.unwrap();
assert!(row.content.contains("edited agent"));
assert!(row.pending_sync);
}
#[test]
fn excluded_field_edit_is_noop() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
let mut record = sample_record("d".repeat(64).as_str(), "agent-four");
write_store(&dir, &[record.clone()]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
// env_vars is excluded from the projection — editing it must not republish.
record.env_vars = BTreeMap::from([("SOME_KEY".to_string(), "value".to_string())]);
write_store(&dir, &[record]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0);
}
#[test]
fn missing_record_is_never_tombstoned() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
let one = sample_record("e".repeat(64).as_str(), "agent-five");
let two = sample_record("f".repeat(64).as_str(), "agent-six");
write_store(&dir, &[one.clone(), two]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 2);
// A truncated store (one of two records) must leave the missing record's
// retained row untouched — absence never tombstones.
write_store(&dir, &[one]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0);
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let survivor = get_retained_event(
&conn,
KIND_MANAGED_AGENT,
&keys.public_key().to_hex(),
&"f".repeat(64),
)
.unwrap();
assert!(survivor.is_some(), "missing record must stay retained");
}
#[test]
fn keyless_record_is_skipped() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
write_store(&dir, &[sample_record("", "keyless-agent")]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0);
}
#[test]
fn malformed_store_errors_and_preserves_invalid_backup() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
let store_path = dir.path().join("managed-agents.json");
std::fs::write(&store_path, b"[{ this is not json").unwrap();
let err = reconcile_agents_in_dir(dir.path(), &keys).unwrap_err();
assert!(err.contains("failed to parse"), "unexpected error: {err}");
let backup = dir.path().join("managed-agents.json.invalid");
assert!(backup.exists(), "malformed store must be preserved");
assert_eq!(
std::fs::read(&backup).unwrap(),
b"[{ this is not json".to_vec()
);
// Original stays in place so the next boot fails loudly again.
assert!(store_path.exists());
}
#[test]
fn monotonic_bump_supersedes_future_dated_head() {
let dir = TempDir::new().unwrap();
let keys = nostr::Keys::generate();
let mut record = sample_record("1".repeat(64).as_str(), "agent-seven");
write_store(&dir, &[record.clone()]);
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
// Future-date the retained head (clock skew / interactive same-second bump).
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let owner = keys.public_key().to_hex();
let head = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &"1".repeat(64))
.unwrap()
.unwrap();
let future = RetainedEvent {
created_at: head.created_at + 3600,
..head
};
crate::managed_agents::retention::retain_event(&conn, &future).unwrap();
drop(conn);
record.system_prompt = Some("New prompt after skew.".to_string());
write_store(&dir, &[record]);
// The changed body must land despite the future-dated head.
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let row = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &"1".repeat(64))
.unwrap()
.unwrap();
assert!(row.content.contains("New prompt after skew"));
}
@@ -155,13 +155,38 @@ pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, S
let content = fs::read_to_string(&path)
.map_err(|error| format!("failed to read agent store: {error}"))?;
let mut records: Vec<ManagedAgentRecord> = serde_json::from_str(&content)
.map_err(|error| format!("failed to parse agent store: {error}"))?;
let mut records: Vec<ManagedAgentRecord> = serde_json::from_str(&content).map_err(|error| {
// Fail loudly and preserve the evidence: a later in-app save rewrites
// this file wholesale, which would silently destroy a malformed hand
// edit. Best-effort file-authoring contract (see managed_agents::
// reconcile): the broken content survives as `.invalid` for the user
// to recover, and the parse error propagates instead of being
// swallowed into an empty store.
backup_invalid_store(&path);
format!("failed to parse agent store (preserved as .invalid): {error}")
})?;
hydrate_keys(&mut records);
Ok(records)
}
/// Preserve a malformed store file as `<name>.invalid` before the error path
/// unwinds. Copy, not rename: the original stays in place so repeated boots
/// keep failing loudly (rename would make the next launch look like a fresh
/// install and mint an empty store over the evidence). Overwrites any prior
/// `.invalid` — the newest broken content is the one worth keeping. Failure
/// here is logged and swallowed; it must never mask the parse error itself.
pub(crate) fn backup_invalid_store(path: &Path) {
let backup = path.with_extension("json.invalid");
if let Err(e) = fs::copy(path, &backup) {
eprintln!(
"buzz-desktop: failed to preserve malformed store {} as {}: {e}",
path.display(),
backup.display()
);
}
}
/// Fill in each record's in-memory `private_key_nsec` from the keyring, and
/// opportunistically re-migrate any key that is still inline.
///
+4 -269
View File
@@ -101,16 +101,6 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
Ok(())
}
/// Reconcile personas and teams into signed retention events. Both readers
/// consume the already-synced `personas.json`/`teams.json` that
/// `sync_team_personas` wrote in [`run_boot_migrations`] (see its `# Ordering`
/// guard). Event signing needs the resolved owner keys, so this runs after
/// identity resolution, not in [`run_boot_migrations`].
pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) {
migrate_personas_to_events(app, owner_keys);
migrate_teams_to_events(app, owner_keys);
}
/// Run every data migration that must complete before identity resolution and
/// agent restore. Ordering is load-bearing: `migrate_legacy_app_data_dir` must
/// precede any disk read, and `sync_shared_agent_data` must precede
@@ -124,9 +114,10 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) {
/// files. The pre-identity reader is `reconcile_provider_mcp_commands` (derives
/// `mcp_command` from each persona's effective harness); the post-identity
/// readers are `migrate_personas_to_events`/`migrate_teams_to_events` in
/// [`run_event_sync`]. Sync touches only JSON (no owner keys, no `retention.db`),
/// so it runs pre-identity here ahead of all readers — reader-first loses a
/// launch (stale harness/`mcp_command` until the next boot).
/// [`crate::event_sync::run_event_sync`]. Sync touches only JSON (no owner
/// keys, no `retention.db`), so it runs pre-identity here ahead of all
/// readers — reader-first loses a launch (stale harness/`mcp_command` until
/// the next boot).
pub fn run_boot_migrations(app: &tauri::AppHandle) {
// Initialize the process-lifetime nest directory before any filesystem
// operation that calls nest_dir(). The discriminator matches the existing
@@ -1301,258 +1292,6 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
rename_provider_to_runtime_in_personas(&path);
}
/// Reconcile `personas.json` into the persona-event retention store.
///
/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being
/// complete) and AFTER the persisted identity is resolved (it signs every
/// retained event with the owner's keys).
///
/// Per-record reconcile: for each non-builtin persona it compares the freshly
/// serialized event content against the retained row at the same coordinate
/// and re-retains (marking `pending_sync = 1`) only when the row is absent or
/// its content differs. An unchanged persona is left untouched, so a launch
/// after a no-op edit does not churn `pending_sync`; a persona added or edited
/// on disk between launches is picked up and republished. There is no
/// whole-store sentinel — comparing per coordinate is what lets newly added
/// personas reach the relay.
///
/// Strategy: write to local SQLite retention first (durable copy), mark as
/// `pending_sync = 1` for later relay publish. Migration succeeds on local
/// write, not relay acknowledgment. Every retained row is a real signed
/// event — there is no placeholder path.
pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
use crate::managed_agents::managed_agents_base_dir;
let Ok(base_dir) = managed_agents_base_dir(app) else {
return;
};
match migrate_personas_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(migrated) => {
eprintln!(
"buzz-desktop: persona-event-migration: {migrated} personas migrated to retention"
);
}
Err(e) => {
eprintln!("buzz-desktop: persona-event-migration: {e}");
}
}
}
/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Returns the number of personas (re)written to the retention store. Returns
/// `Ok(0)` when every non-builtin persona already has a matching retained row
/// (or there are none to reconcile).
fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
use crate::managed_agents::{
persona_events::{build_persona_event, monotonic_created_at, persona_d_tag},
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
PersonaRecord,
};
use buzz_core_pkg::kind::KIND_PERSONA;
use nostr::JsonUtil;
let pubkey = keys.public_key().to_hex();
// Read personas.json fresh at reconcile time. Nothing to do if absent.
let personas_path = base_dir.join("personas.json");
if !personas_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&personas_path)
.map_err(|e| format!("failed to read personas.json: {e}"))?;
let records: Vec<PersonaRecord> = serde_json::from_str(&content)
.map_err(|e| format!("failed to parse personas.json: {e}"))?;
if records.is_empty() {
return Ok(0);
}
// Open (or create) the retention database.
let db_path = base_dir.join("retention.db");
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
let mut migrated = 0u32;
for record in &records {
// Skip built-in personas — they're always available from code.
if record.is_builtin {
continue;
}
let d_tag = persona_d_tag(record);
// Fetch the retained head first so the rebuilt event can supersede it:
// build at the default `now` and a future-dated head (clock skew, or an
// interactive same-second `max(now, head+1)` bump) would make
// `retain_event`'s `created_at >= ...` guard SILENTLY skip the UPDATE
// while `migrated` over-reports. Mirror the interactive sites' monotonic
// bump (F1) so a changed body always lands.
let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?;
let event = build_persona_event(record)
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?
.custom_created_at(monotonic_created_at(
existing.as_ref().map(|row| row.created_at),
))
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?;
// Per-coordinate reconcile: skip when an identical body is already
// retained, so an unchanged persona doesn't reset `pending_sync`.
// Content is timestamp-independent, so the monotonic bump above never
// forces a spurious republish.
let event_content = event.content.to_string();
if existing
.as_ref()
.is_some_and(|row| row.content == event_content)
{
continue;
}
let retained = RetainedEvent {
kind: KIND_PERSONA,
pubkey: pubkey.clone(),
d_tag,
content: event_content,
// Safety: nostr timestamps are seconds and stay below i64::MAX
// until year 2262.
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
};
// The monotonic bump guarantees `created_at > head`, so the upsert's
// `>=` guard always lands the UPDATE — `migrated` counts only real,
// retained republishes.
retain_event(&conn, &retained)
.map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?;
migrated += 1;
}
Ok(migrated)
}
/// Reconcile `teams.json` into kind:30176 team events in the retention store.
///
/// Mirrors [`migrate_personas_to_events`] for teams: it picks up team metadata
/// edits (name/description/persona_ids) made on disk between launches and
/// queues them for relay publish. Managed agents (kind:30177) are deliberately
/// NOT reconciled here — they have no pack/dir source and are backfilled from
/// `managed-agents.json` elsewhere.
///
/// Must run after the persisted identity is resolved (it signs each event with
/// the owner's keys).
pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
use crate::managed_agents::managed_agents_base_dir;
let Ok(base_dir) = managed_agents_base_dir(app) else {
return;
};
match migrate_teams_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(migrated) => {
eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention");
}
Err(e) => {
eprintln!("buzz-desktop: team-event-migration: {e}");
}
}
}
/// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Returns the number of teams (re)written to the retention store. The
/// per-coordinate content compare matches [`migrate_personas_in_dir`]: an
/// unchanged team is skipped so a launch does not churn `pending_sync`.
fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
use crate::managed_agents::{
persona_events::monotonic_created_at,
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
team_events::build_team_event,
TeamRecord,
};
use buzz_core_pkg::kind::KIND_TEAM;
use nostr::JsonUtil;
let pubkey = keys.public_key().to_hex();
let teams_path = base_dir.join("teams.json");
if !teams_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&teams_path)
.map_err(|e| format!("failed to read teams.json: {e}"))?;
let records: Vec<TeamRecord> =
serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?;
if records.is_empty() {
return Ok(0);
}
let db_path = base_dir.join("retention.db");
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
let mut migrated = 0u32;
for record in &records {
// Skip built-in teams — they're always available from code.
if record.is_builtin {
continue;
}
// Team d-tag is the team id (team_events.rs: no slug fallback).
let d_tag = record.id.clone();
// Fetch the head first so the monotonic bump can supersede a
// future-dated head — see migrate_personas_in_dir (F1/F8).
let existing = get_retained_event(&conn, KIND_TEAM, &pubkey, &d_tag)?;
let event = build_team_event(record)
.map_err(|e| format!("failed to build event for team '{}': {e}", record.name))?
.custom_created_at(monotonic_created_at(
existing.as_ref().map(|row| row.created_at),
))
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for team '{}': {e}", record.name))?;
let event_content = event.content.to_string();
if existing
.as_ref()
.is_some_and(|row| row.content == event_content)
{
continue;
}
let retained = RetainedEvent {
kind: KIND_TEAM,
pubkey: pubkey.clone(),
d_tag,
content: event_content,
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
};
// Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts
// only real republishes.
retain_event(&conn, &retained)
.map_err(|e| format!("failed to retain team '{}': {e}", record.name))?;
migrated += 1;
}
Ok(migrated)
}
#[cfg(test)]
#[path = "migration_test_support.rs"]
mod test_support;
@@ -1568,7 +1307,3 @@ mod command_tests;
#[cfg(test)]
#[path = "migration_team_dir_tests.rs"]
mod team_dir_tests;
#[cfg(test)]
#[path = "migration_team_events_tests.rs"]
mod team_events_tests;
-300
View File
@@ -1107,303 +1107,3 @@ fn migrate_legacy_nest_preserves_user_edited_agents_md() {
"a user-edited live AGENTS.md must never be clobbered"
);
}
/// Helper: write a `personas.json` directly in `base_dir` (the migration
/// reads `base_dir/personas.json`, where `base_dir` is the `agents` dir).
fn write_base_personas(base_dir: &Path, records: &serde_json::Value) {
std::fs::write(
base_dir.join("personas.json"),
serde_json::to_string_pretty(records).unwrap(),
)
.unwrap();
}
fn one_persona() -> serde_json::Value {
serde_json::json!([{
"id": "code-reviewer",
"display_name": "Code Reviewer",
"system_prompt": "You review code.",
"is_builtin": false,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}])
}
#[test]
fn migrate_personas_writes_signed_retention_rows() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap();
assert_eq!(migrated, 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &pubkey).unwrap();
assert_eq!(rows.len(), 1);
// Row holds a real signed event for the owner — not a placeholder.
assert_eq!(rows[0].pubkey, pubkey);
let event: nostr::Event = nostr::JsonUtil::from_json(&rows[0].raw_event).unwrap();
assert!(event.verify().is_ok());
assert!(rows[0].pending_sync);
}
#[test]
fn migrate_personas_skips_builtins() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(
base.path(),
&serde_json::json!([{
"id": "builtin:solo",
"display_name": "Solo",
"system_prompt": "x",
"is_builtin": true,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}]),
);
let keys = nostr::Keys::generate();
let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap();
assert_eq!(migrated, 0);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &keys.public_key().to_hex()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn migrate_personas_unchanged_second_run_is_noop() {
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
// First run retains; second run with identical personas re-retains
// nothing — the per-coordinate content matches, so `pending_sync` is
// not churned.
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0);
assert!(!base.path().join("migration_state.json").exists());
}
#[test]
fn migrate_personas_new_persona_after_first_run_gets_retained() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
// A persona added to personas.json after the first reconcile must be
// picked up — the whole-store sentinel that previously short-circuited
// this is gone.
let mut two = one_persona();
two.as_array_mut().unwrap().push(serde_json::json!({
"id": "test-writer",
"display_name": "Test Writer",
"system_prompt": "You write tests.",
"is_builtin": false,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-02T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z"
}));
write_base_personas(base.path(), &two);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &pubkey).unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn migrate_personas_edited_persona_re_retains_pending() {
use crate::managed_agents::retention::{get_retained_event, mark_synced, open_retention_db};
use buzz_core_pkg::kind::KIND_PERSONA;
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
// Simulate the flush loop confirming the first publish.
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
mark_synced(
&conn,
KIND_PERSONA,
&pubkey,
"code-reviewer",
row.created_at,
&row.content,
)
.unwrap();
drop(conn);
// Editing the persona on disk must re-retain it as pending so the edit
// reaches the relay on the next flush.
let mut edited = one_persona();
edited.as_array_mut().unwrap()[0]["system_prompt"] =
serde_json::json!("You review code carefully.");
write_base_personas(base.path(), &edited);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
assert!(row.pending_sync);
assert!(row.content.contains("carefully"));
}
#[test]
fn migrate_personas_no_file_is_noop() {
let base = tempfile::tempdir().unwrap();
let keys = nostr::Keys::generate();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0);
}
/// F8: a future-dated retained head must be SUPERSEDED on a changed-content
/// migration, not silently skipped by `retain_event`'s `>=` guard. Without the
/// monotonic `created_at` bump the rebuilt event lands at `now <= head`, the
/// upsert's `WHERE excluded.created_at >= ...` drops the UPDATE, and `migrated`
/// over-reports. The bump (max(now, head+1)) guarantees supersession.
#[test]
fn migrate_personas_supersedes_future_dated_head() {
use crate::managed_agents::retention::{
get_retained_event, open_retention_db, retain_event, RetainedEvent,
};
use buzz_core_pkg::kind::KIND_PERSONA;
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
// First migrate retains the persona at ~now.
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
// Force the retained head far into the future, simulating a clock-skewed or
// same-second `max(now, head+1)` interactive bump.
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let head = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
let future = nostr::Timestamp::now().as_secs() as i64 + 100_000;
retain_event(
&conn,
&RetainedEvent {
created_at: future,
pending_sync: false,
..head
},
)
.unwrap();
// Change the persona body on disk, then migrate again.
let mut edited = one_persona();
edited.as_array_mut().unwrap()[0]["system_prompt"] =
serde_json::json!("You review code very carefully.");
write_base_personas(base.path(), &edited);
assert_eq!(
migrate_personas_in_dir(base.path(), &keys).unwrap(),
1,
"changed content over a future-dated head must report a real migration"
);
let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer")
.unwrap()
.unwrap();
// The new body actually landed (not silently skipped) ...
assert!(
row.content.contains("very carefully"),
"changed body must supersede the future-dated head, not be dropped"
);
// ... at a created_at strictly past the future head (monotonic bump) ...
assert_eq!(row.created_at, future + 1);
// ... and is queued for republish.
assert!(row.pending_sync, "superseding row must be pending_sync");
}
fn write_base_teams(base_dir: &Path, records: &serde_json::Value) {
std::fs::write(
base_dir.join("teams.json"),
serde_json::to_string_pretty(records).unwrap(),
)
.unwrap();
}
/// F8 for the team migration site — same supersede guarantee as personas.
#[test]
fn migrate_teams_supersedes_future_dated_head() {
use crate::managed_agents::retention::{
get_retained_event, open_retention_db, retain_event, RetainedEvent,
};
use buzz_core_pkg::kind::KIND_TEAM;
let base = tempfile::tempdir().unwrap();
let team = serde_json::json!([{
"id": "my-team",
"name": "My Team",
"description": "first",
"persona_ids": ["code-reviewer"],
"is_builtin": false,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}]);
write_base_teams(base.path(), &team);
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let head = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team")
.unwrap()
.unwrap();
let future = nostr::Timestamp::now().as_secs() as i64 + 100_000;
retain_event(
&conn,
&RetainedEvent {
created_at: future,
pending_sync: false,
..head
},
)
.unwrap();
let mut edited = team.clone();
edited.as_array_mut().unwrap()[0]["description"] = serde_json::json!("second");
write_base_teams(base.path(), &edited);
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team")
.unwrap()
.unwrap();
assert!(row.content.contains("second"));
assert_eq!(row.created_at, future + 1);
assert!(row.pending_sync);
}