mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
302 lines
10 KiB
Rust
302 lines
10 KiB
Rust
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);
|
|
}
|