From 260c7f66fb9578817df5373916474e3c55da9c0c Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Sat, 10 Oct 2026 22:38:17 +0000 Subject: [PATCH] Opening a store migrates AI chats without a default agent Resource::save_as validated the commit for the default agent instead of the agent that signed it. The AI chat migration in Db::open saves the chat with the migration's own signer before the server has loaded its agent, so a node with an old AI chat failed to start with "No agent set". apply_signed_commit now takes the signing agent. Test: a store with an old AI chat reopens without an agent and migrates it. --- lib/src/db/ai_chat_migration_test.rs | 52 ++++++++++++++++++++++++++++ lib/src/resources.rs | 6 ++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/src/db/ai_chat_migration_test.rs b/lib/src/db/ai_chat_migration_test.rs index 20249fd04..74dc7a39e 100644 --- a/lib/src/db/ai_chat_migration_test.rs +++ b/lib/src/db/ai_chat_migration_test.rs @@ -379,3 +379,55 @@ async fn migrating_an_ai_chat_shrinks_the_store() { ); assert!(after < before, "{before} -> {after}"); } + +/// A server opens its store before it loads its agent. Opening a store with an +/// old AI chat must migrate it without a default agent (node1 crashed on +/// "No agent set" here). +#[tokio::test] +async fn opening_a_store_without_an_agent_migrates_ai_chats() { + let w = world("ai_chat_migration_open").await; + let chat = old_chat(&w).await; + let first = old_ai_message(&w, &chat, "user", "first", 0).await; + let second = old_ai_message(&w, &chat, "assistant", "second", 1).await; + set_list(&w.db, &chat, MESSAGES, &[first.clone(), second.clone()]).await; + // As a store from before: not marked done. + w.db.kv.remove(Tree::PluginMeta, AI_DONE_KEY).unwrap(); + w.db.kv.flush().unwrap(); + drop(w); + + let path = std::path::Path::new(".temp/db/ai_chat_migration_open"); + let uploads = std::path::Path::new(".temp/db/ai_chat_migration_open/uploads"); + let mut reopened = None; + let mut last_err = String::new(); + // The file lock can outlive the drop by a moment. + for _ in 0..50 { + match Db::init_redb_file(path, Some("https://localhost".into()), uploads).await { + Ok(db) => { + reopened = Some(db); + break; + } + Err(e) => { + last_err = e.to_string(); + // Only the file lock is worth waiting for. + assert!( + !last_err.contains("No agent set"), + "opening needs no agent: {last_err}" + ); + tokio::time::sleep(std::time::Duration::from_millis(200)).await + } + } + } + let db = reopened.unwrap_or_else(|| panic!("the store reopens: {last_err}")); + assert!(db.get_default_agent().is_err(), "opened without an agent"); + assert!(!db.ai_chat_migration_pending().unwrap()); + let found = pages_of(&db, &chat).await; + assert_eq!(found.iter().map(|(_, e)| e.len()).sum::(), 2); + assert!(!exists(&db, &first).await); + assert!(!exists(&db, &second).await); + assert!(db + .get_resource(&chat.as_str().into()) + .await + .unwrap() + .get(MESSAGES) + .is_err()); +} diff --git a/lib/src/resources.rs b/lib/src/resources.rs index 5f726d8fa..eff1af42c 100644 --- a/lib/src/resources.rs +++ b/lib/src/resources.rs @@ -1312,8 +1312,8 @@ impl Resource { &mut self, store: &impl Storelike, commit: crate::Commit, + agent: &crate::agents::Agent, ) -> AtomicResult { - let agent = store.get_default_agent()?; let opts = CommitOpts { validate_schema: true, validate_constraints: true, @@ -1400,7 +1400,7 @@ impl Resource { if should_post { crate::client::post_commit(&commit, store).await?; } - self.apply_signed_commit(store, commit).await + self.apply_signed_commit(store, commit, agent).await } /// Saves the resource (with all the changes) to the store by creating a Commit. @@ -1420,7 +1420,7 @@ impl Resource { .clone() .sign(&agent, store, self) .await?; - self.apply_signed_commit(store, commit).await + self.apply_signed_commit(store, commit, &agent).await } /// Saves the resource as a new DID-native resource.