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