fix(desktop): sign every migrated persona event and drop the sentinel file

The persona-event migration stored synthetic placeholder events (zero
pubkey/sig, non-hex id) whenever signing keys were unavailable, producing
retention rows that fail event parsing and signature verification — the
read path would silently skip every such persona. It also tracked
idempotency with a migration_state.json sentinel that was written even on
partial failure and reset pending_sync on every boot if the write failed.

Migration now takes the owner's resolved Keys and runs after
resolve_persisted_identity, so every retained row is a real signed event;
the no-keys branch is gone. Idempotency keys on whether the retention
store already holds rows for the owner pubkey — the data is the sentinel.
Core logic is extracted into migrate_personas_in_dir for unit testing.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-10 10:27:44 -04:00
parent 3c6cd9c3f0
commit a2e9c8c077
3 changed files with 183 additions and 175 deletions

View File

@@ -524,17 +524,28 @@ pub fn run() {
migration::reconcile_persona_team_dirs(&app_handle);
migration::reconcile_provider_mcp_commands(&app_handle);
migration::migrate_persona_provider_to_runtime(&app_handle);
migration::migrate_personas_to_events(&app_handle);
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
eprintln!("sprout-desktop: sync-team-personas: {e}");
}
// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel
// memberships, DMs, and relay identity.
let state = app_handle.state::<AppState>();
resolve_persisted_identity(&app_handle, &state)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
// Persona-event migration signs every retained event with the
// owner's keys, so it must run after the persisted identity is
// resolved (not before — at that point keys may still be ephemeral).
let owner_keys = state
.keys
.lock()
.map(|k| k.clone())
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
migration::migrate_personas_to_events(&app_handle, &owner_keys);
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
eprintln!("sprout-desktop: sync-team-personas: {e}");
}
// Store the AppHandle so huddle commands can emit `huddle-state-changed`
// events via `huddle::emit_huddle_state` without threading the handle
@@ -543,9 +554,6 @@ pub fn run() {
*guard = Some(app_handle.clone());
}
resolve_persisted_identity(&app_handle, &state)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
// Bring up the runtime-owned relay-mesh call-me-now listener now,
// before any saved agent restore can request a connection. Its
// lifetime is tied to the runtime, not a UI mount — this is what

View File

@@ -573,91 +573,83 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
/// retention store.
///
/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being
/// complete). Idempotent: checks a sentinel file before running.
/// complete) and AFTER the persisted identity is resolved (it signs every
/// retained event with the owner's keys).
///
/// Idempotent: skips when the retention store already holds events for the
/// owner pubkey — the data is the sentinel, so no separate sentinel file is
/// needed. This avoids re-running (and resetting `pending_sync`) on every
/// launch when a sentinel write silently fails.
///
/// 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.
pub fn migrate_personas_to_events(app: &tauri::AppHandle) {
use crate::managed_agents::{
managed_agents_base_dir,
persona_events::{build_persona_event, persona_d_tag},
retention::{open_retention_db, retain_event, RetainedEvent},
PersonaRecord,
};
use nostr::JsonUtil;
use sprout_core::kind::KIND_PERSONA;
/// 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;
};
// Check sentinel — skip if already migrated.
let sentinel_path = base_dir.join("migration_state.json");
if sentinel_path.exists() {
if let Ok(content) = std::fs::read_to_string(&sentinel_path) {
if content.contains(r#""persona_events_migrated":true"#)
|| content.contains(r#""persona_events_migrated": true"#)
{
return;
}
match migrate_personas_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(migrated) => {
eprintln!(
"sprout-desktop: persona-event-migration: {migrated} personas migrated to retention"
);
}
Err(e) => {
eprintln!("sprout-desktop: persona-event-migration: {e}");
}
}
}
// Read personas.json fresh at migration time.
/// Core migration logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Returns the number of personas written to the retention store. Returns
/// `Ok(0)` when migration has already run (retention store has rows for the
/// owner pubkey) or when there are no non-builtin personas to migrate.
fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
use crate::managed_agents::{
persona_events::{build_persona_event, persona_d_tag},
retention::{has_retained_personas, open_retention_db, retain_event, RetainedEvent},
PersonaRecord,
};
use nostr::JsonUtil;
use sprout_core::kind::KIND_PERSONA;
let pubkey = keys.public_key().to_hex();
// Read personas.json fresh at migration time. Nothing to migrate if the
// file is absent.
let personas_path = base_dir.join("personas.json");
if !personas_path.exists() {
// No personas to migrate — write sentinel and return.
let _ = std::fs::write(&sentinel_path, r#"{"persona_events_migrated":true}"#);
return;
return Ok(0);
}
let content = match std::fs::read_to_string(&personas_path) {
Ok(c) => c,
Err(e) => {
eprintln!("sprout-desktop: persona-event-migration: failed to read personas.json: {e}");
return;
}
};
let content = std::fs::read_to_string(&personas_path)
.map_err(|e| format!("failed to read personas.json: {e}"))?;
let records: Vec<PersonaRecord> = match serde_json::from_str(&content) {
Ok(r) => r,
Err(e) => {
eprintln!(
"sprout-desktop: persona-event-migration: failed to parse personas.json: {e}"
);
return;
}
};
let records: Vec<PersonaRecord> = serde_json::from_str(&content)
.map_err(|e| format!("failed to parse personas.json: {e}"))?;
if records.is_empty() {
let _ = std::fs::write(&sentinel_path, r#"{"persona_events_migrated":true}"#);
return;
return Ok(0);
}
// Open (or create) the retention database.
let db_path = base_dir.join("retention.db");
let conn = match open_retention_db(&db_path) {
Ok(c) => c,
Err(e) => {
eprintln!("sprout-desktop: persona-event-migration: failed to open retention db: {e}");
return;
}
};
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
// Get the user's pubkey for the event. We need keys to sign events for
// the raw_event field, but during migration we may not have the app state
// initialized yet. Use a deterministic placeholder approach: store the
// persona content without a real signature. The raw_event will be a
// minimal JSON structure that persona_from_event can parse.
//
// We use the keys from the environment or identity file if available.
let keys = std::env::var("SPROUT_PRIVATE_KEY")
.ok()
.and_then(|k| k.parse::<nostr::Keys>().ok());
// Idempotency: the retention rows themselves are the sentinel. If the
// owner already has retained personas, migration ran on a prior launch.
if has_retained_personas(&conn, &pubkey)? {
return Ok(0);
}
let mut migrated = 0u32;
let mut errors = 0u32;
for record in &records {
// Skip built-in personas — they're always available from code.
@@ -667,118 +659,31 @@ pub fn migrate_personas_to_events(app: &tauri::AppHandle) {
let d_tag = persona_d_tag(record);
match &keys {
Some(keys) => {
// Build and sign a real event.
let builder = match build_persona_event(record) {
Ok(b) => b,
Err(e) => {
eprintln!(
"sprout-desktop: persona-event-migration: failed to build event for '{}': {e}",
record.display_name
);
errors += 1;
continue;
}
};
let builder = build_persona_event(record)
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?;
let event = match builder.sign_with_keys(keys) {
Ok(e) => e,
Err(e) => {
eprintln!(
"sprout-desktop: persona-event-migration: failed to sign event for '{}': {e}",
record.display_name
);
errors += 1;
continue;
}
};
let event = builder
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?;
let raw_event = event.as_json();
let retained = RetainedEvent {
kind: KIND_PERSONA,
pubkey: keys.public_key().to_hex(),
d_tag,
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event,
pending_sync: true,
};
let retained = RetainedEvent {
kind: KIND_PERSONA,
pubkey: pubkey.clone(),
d_tag,
content: event.content.to_string(),
// 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,
};
if let Err(e) = retain_event(&conn, &retained) {
eprintln!(
"sprout-desktop: persona-event-migration: failed to retain '{}': {e}",
record.display_name
);
errors += 1;
} else {
migrated += 1;
}
}
None => {
// No keys available — store a synthetic event structure.
// This will be re-signed and published when keys become available.
let content_json = serde_json::json!({
"display_name": record.display_name,
"avatar_url": record.avatar_url,
"system_prompt": record.system_prompt,
"runtime": record.runtime,
"model": record.model,
"provider": record.provider,
"name_pool": record.name_pool,
"env_vars": record.env_vars,
});
let now = chrono::Utc::now().timestamp();
let raw_event = serde_json::json!({
"id": format!("migration-placeholder-{}", d_tag),
"pubkey": "0000000000000000000000000000000000000000000000000000000000000000",
"kind": KIND_PERSONA,
"created_at": now,
"content": content_json.to_string(),
"tags": [["d", d_tag]],
"sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
});
let retained = RetainedEvent {
kind: KIND_PERSONA,
pubkey: "0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
d_tag,
content: content_json.to_string(),
created_at: now,
raw_event: raw_event.to_string(),
pending_sync: true,
};
if let Err(e) = retain_event(&conn, &retained) {
eprintln!(
"sprout-desktop: persona-event-migration: failed to retain '{}': {e}",
record.display_name
);
errors += 1;
} else {
migrated += 1;
}
}
}
retain_event(&conn, &retained)
.map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?;
migrated += 1;
}
// Write sentinel regardless of partial errors — individual failures are
// logged and the migration is best-effort. Re-running won't help if a
// specific persona can't be serialized.
let _ = std::fs::write(&sentinel_path, r#"{"persona_events_migrated":true}"#);
if migrated > 0 || errors > 0 {
eprintln!(
"sprout-desktop: persona-event-migration: {migrated} personas migrated to retention{}",
if errors > 0 {
format!(", {errors} errors")
} else {
String::new()
}
);
}
Ok(migrated)
}
#[cfg(test)]

View File

@@ -831,3 +831,98 @@ fn reconcile_mcp_commands_skips_record_without_agent_command() {
reconcile_mcp_commands_in_file(&path);
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}
/// 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_skips_when_retention_already_populated() {
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
// First run migrates; second run is a no-op (retention rows are the
// sentinel — no separate sentinel file).
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_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);
}