1use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13
14use rusqlite::{params, Connection};
15use serde::{Deserialize, Serialize};
16
17use crate::backend::obj::{wave_obj_from_json, wave_obj_to_json, StoreObj};
18use crate::registry::{DefinitionStore, Registry};
19
20use super::error::StoreError;
21use super::migrations::{check_schema_compat, run_object_schema, stamp_version, OBJECT_SCHEMA_VERSION};
22
23pub struct Store {
25 pub(super) conn: Mutex<Connection>,
29 registry: Mutex<Option<Arc<Registry>>>,
34 def_registry: Mutex<Option<Arc<DefinitionStore>>>,
40 registry_agents_base: Mutex<Option<PathBuf>>,
65}
66
67impl Store {
68 pub fn open(path: &Path) -> Result<Self, StoreError> {
71 let conn = Connection::open(path)?;
72 Self::configure_and_migrate(conn)
73 }
74
75 #[allow(dead_code)]
77 pub fn open_in_memory() -> Result<Self, StoreError> {
78 let conn = Connection::open_in_memory()?;
79 Self::configure_and_migrate(conn)
80 }
81
82 pub(crate) fn conn(&self) -> &Mutex<Connection> {
87 &self.conn
88 }
89
90 pub fn run_agents_consolidate(
94 &self,
95 data_dir: Option<&Path>,
96 ) -> Result<super::agents_consolidate::ConsolidateStats, StoreError> {
97 let mut conn = self.conn.lock().unwrap();
98 super::agents_consolidate::run_consolidate_migration(&mut conn, data_dir)
99 }
100
101 pub fn repair_agent_def_gaps(&self) -> Result<usize, StoreError> {
105 let mut conn = self.conn.lock().unwrap();
106 super::agents_consolidate::repair_def_gaps(&mut *conn)
107 }
108
109 fn configure_and_migrate(conn: Connection) -> Result<Self, StoreError> {
110 conn.execute_batch(
111 "PRAGMA journal_mode=WAL;
119 PRAGMA busy_timeout=5000;
120 PRAGMA foreign_keys=ON;
121 PRAGMA synchronous=NORMAL;
122 PRAGMA cache_size=-8000;
123 PRAGMA mmap_size=268435456;
124 PRAGMA temp_store=MEMORY;",
125 )?;
126 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
133 run_object_schema(&conn)?;
134 stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
135 Ok(Self {
136 conn: Mutex::new(conn),
137 registry: Mutex::new(None),
138 def_registry: Mutex::new(None),
139 registry_agents_base: Mutex::new(None),
140 })
141 }
142
143 pub fn set_registry(&self, registry: Arc<Registry>) {
149 *self.registry.lock().unwrap_or_else(|e| e.into_inner()) = Some(registry);
150 }
151
152 pub(super) fn registry(&self) -> Option<Arc<Registry>> {
153 self.registry
154 .lock()
155 .unwrap_or_else(|e| e.into_inner())
156 .clone()
157 }
158
159 pub fn set_registry_agents_base(&self, base: PathBuf) {
164 *self
165 .registry_agents_base
166 .lock()
167 .unwrap_or_else(|e| e.into_inner()) = Some(base);
168 }
169
170 pub fn registry_agents_base(&self) -> Option<PathBuf> {
178 if let Some(base) = self
179 .registry_agents_base
180 .lock()
181 .unwrap_or_else(|e| e.into_inner())
182 .clone()
183 {
184 return Some(base);
185 }
186 self.registry()
187 .and_then(|r| r.agents_root().map(|p| p.to_path_buf()))
188 }
189
190 pub fn shared_agent_registry(&self) -> Option<Arc<Registry>> {
195 self.registry()
196 }
197
198 pub fn set_def_registry(&self, def_registry: Arc<DefinitionStore>) {
202 *self.def_registry.lock().unwrap_or_else(|e| e.into_inner()) = Some(def_registry);
203 }
204
205 pub fn shared_def_registry(&self) -> Option<Arc<DefinitionStore>> {
209 self.def_registry
210 .lock()
211 .unwrap_or_else(|e| e.into_inner())
212 .clone()
213 }
214
215 fn table_name<T: StoreObj>() -> String {
217 format!("db_{}", T::get_otype())
218 }
219
220 pub fn get<T: StoreObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
222 let conn = self.conn.lock().unwrap();
223 let table = Self::table_name::<T>();
224 let mut stmt =
225 conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
226
227 let result = stmt.query_row(params![oid], |row| {
228 let version: i64 = row.get(0)?;
229 let data: Vec<u8> = row.get(1)?;
230 Ok((version, data))
231 });
232
233 match result {
234 Ok((version, data)) => {
235 let mut obj: T = wave_obj_from_json(&data)?;
236 obj.set_version(version);
237 Ok(Some(obj))
238 }
239 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
240 Err(e) => Err(StoreError::Sqlite(e)),
241 }
242 }
243
244 pub fn must_get<T: StoreObj>(&self, oid: &str) -> Result<T, StoreError> {
246 self.get::<T>(oid)?.ok_or(StoreError::NotFound)
247 }
248
249 pub fn get_raw(&self, otype: &str, oid: &str) -> Result<Option<serde_json::Value>, StoreError> {
252 let conn = self.conn.lock().unwrap();
253 let table = format!("db_{}", otype);
254 let mut stmt =
255 conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
256
257 let result = stmt.query_row(params![oid], |row| {
258 let version: i64 = row.get(0)?;
259 let data: Vec<u8> = row.get(1)?;
260 Ok((version, data))
261 });
262
263 match result {
264 Ok((version, data)) => {
265 let mut val: serde_json::Value = serde_json::from_slice(&data)
266 .map_err(|e| StoreError::Json(e))?;
267 if let Some(obj) = val.as_object_mut() {
268 obj.insert("version".to_string(), serde_json::json!(version));
269 obj.insert("otype".to_string(), serde_json::json!(otype));
270 }
271 Ok(Some(val))
272 }
273 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
274 Err(e) => Err(StoreError::Sqlite(e)),
275 }
276 }
277
278 #[allow(dead_code)]
280 pub fn exists_raw(&self, otype: &str, oid: &str) -> Result<bool, StoreError> {
281 let conn = self.conn.lock().unwrap();
282 let table = format!("db_{}", otype);
283 let count: i64 = conn.query_row(
284 &format!("SELECT COUNT(*) FROM {table} WHERE oid = ?1"),
285 params![oid],
286 |row| row.get(0),
287 )?;
288 Ok(count > 0)
289 }
290
291 pub fn insert<T: StoreObj>(&self, obj: &mut T) -> Result<(), StoreError> {
293 let oid = obj.get_oid().to_string();
294 if oid.is_empty() {
295 return Err(StoreError::EmptyOID);
296 }
297
298 obj.set_version(1);
299 let data = wave_obj_to_json(obj)?;
300
301 let conn = self.conn.lock().unwrap();
302 let table = Self::table_name::<T>();
303 conn.execute(
304 &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
305 params![oid, data],
306 )?;
307
308 Ok(())
309 }
310
311 pub fn update<T: StoreObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
314 let oid = obj.get_oid().to_string();
315 if oid.is_empty() {
316 return Err(StoreError::EmptyOID);
317 }
318
319 let data = wave_obj_to_json(obj)?;
320
321 let conn = self.conn.lock().unwrap();
322 let table = Self::table_name::<T>();
323
324 let new_version: i64 = conn.query_row(
327 &format!(
328 "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
329 ),
330 params![data, oid],
331 |row| row.get(0),
332 )?;
333
334 obj.set_version(new_version);
335 Ok(new_version)
336 }
337
338 pub fn update_raw(&self, otype: &str, oid: &str, value: &serde_json::Value) -> Result<i64, StoreError> {
342 if oid.is_empty() {
343 return Err(StoreError::EmptyOID);
344 }
345 let data = serde_json::to_vec(value)?;
346 let conn = self.conn.lock().unwrap();
347 let table = format!("db_{}", otype);
348 let new_version: i64 = conn.query_row(
349 &format!(
350 "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
351 ),
352 params![data, oid],
353 |row| row.get(0),
354 )?;
355 Ok(new_version)
356 }
357
358 #[allow(dead_code)]
360 pub fn delete<T: StoreObj>(&self, oid: &str) -> Result<(), StoreError> {
361 let conn = self.conn.lock().unwrap();
362 let table = Self::table_name::<T>();
363 conn.execute(
364 &format!("DELETE FROM {table} WHERE oid = ?1"),
365 params![oid],
366 )?;
367 Ok(())
368 }
369
370 #[allow(dead_code)]
373 pub fn delete_by_otype(&self, otype: &str, oid: &str) -> Result<(), StoreError> {
374 if !crate::backend::obj::VALID_OTYPES.contains(&otype) {
375 return Err(StoreError::Other(format!("unknown otype: {otype:?}")));
376 }
377 let conn = self.conn.lock().unwrap();
378 let table = format!("db_{otype}");
379 conn.execute(
380 &format!("DELETE FROM {table} WHERE oid = ?1"),
381 params![oid],
382 )?;
383 Ok(())
384 }
385
386 pub fn get_all<T: StoreObj>(&self) -> Result<Vec<T>, StoreError> {
388 let conn = self.conn.lock().unwrap();
389 let table = Self::table_name::<T>();
390 let mut stmt = conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
391 let rows = stmt.query_map([], |row| {
392 let version: i64 = row.get(1)?;
393 let data: Vec<u8> = row.get(2)?;
394 Ok((version, data))
395 })?;
396
397 let mut result = Vec::new();
398 for row in rows {
399 let (version, data) = row?;
400 let mut obj: T = wave_obj_from_json(&data)?;
401 obj.set_version(version);
402 result.push(obj);
403 }
404 Ok(result)
405 }
406
407 #[allow(dead_code)]
409 pub fn count<T: StoreObj>(&self) -> Result<i64, StoreError> {
410 let conn = self.conn.lock().unwrap();
411 let table = Self::table_name::<T>();
412 let count: i64 =
413 conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
414 row.get(0)
415 })?;
416 Ok(count)
417 }
418
419 pub fn with_tx<F, R>(&self, f: F) -> Result<R, StoreError>
426 where
427 F: FnOnce(&StoreTx) -> Result<R, StoreError>,
428 {
429 let conn = self.conn.lock().unwrap();
430 conn.execute_batch("BEGIN")?;
431 let tx = StoreTx { conn: &conn };
432 match f(&tx) {
433 Ok(result) => {
434 conn.execute_batch("COMMIT")?;
435 Ok(result)
436 }
437 Err(e) => {
438 let _ = conn.execute_batch("ROLLBACK");
439 Err(e)
440 }
441 }
442 }
443}
444
445pub struct StoreTx<'a> {
449 conn: &'a Connection,
450}
451
452impl<'a> StoreTx<'a> {
453 fn table_name<T: StoreObj>() -> String {
454 format!("db_{}", T::get_otype())
455 }
456
457 pub fn get<T: StoreObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
458 let table = Self::table_name::<T>();
459 let mut stmt =
460 self.conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
461
462 let result = stmt.query_row(params![oid], |row| {
463 let version: i64 = row.get(0)?;
464 let data: Vec<u8> = row.get(1)?;
465 Ok((version, data))
466 });
467
468 match result {
469 Ok((version, data)) => {
470 let mut obj: T = wave_obj_from_json(&data)?;
471 obj.set_version(version);
472 Ok(Some(obj))
473 }
474 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
475 Err(e) => Err(StoreError::Sqlite(e)),
476 }
477 }
478
479 pub fn must_get<T: StoreObj>(&self, oid: &str) -> Result<T, StoreError> {
480 self.get::<T>(oid)?.ok_or(StoreError::NotFound)
481 }
482
483 pub fn insert<T: StoreObj>(&self, obj: &mut T) -> Result<(), StoreError> {
484 let oid = obj.get_oid().to_string();
485 if oid.is_empty() {
486 return Err(StoreError::EmptyOID);
487 }
488
489 obj.set_version(1);
490 let data = wave_obj_to_json(obj)?;
491
492 let table = Self::table_name::<T>();
493 self.conn.execute(
494 &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
495 params![oid, data],
496 )?;
497
498 Ok(())
499 }
500
501 pub fn update<T: StoreObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
502 let oid = obj.get_oid().to_string();
503 if oid.is_empty() {
504 return Err(StoreError::EmptyOID);
505 }
506
507 let data = wave_obj_to_json(obj)?;
508
509 let table = Self::table_name::<T>();
510 let new_version: i64 = self.conn.query_row(
511 &format!(
512 "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
513 ),
514 params![data, oid],
515 |row| row.get(0),
516 )?;
517
518 obj.set_version(new_version);
519 Ok(new_version)
520 }
521
522 pub fn get_all<T: StoreObj>(&self) -> Result<Vec<T>, StoreError> {
523 let table = Self::table_name::<T>();
524 let mut stmt = self.conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
525 let rows = stmt.query_map([], |row| {
526 let version: i64 = row.get(1)?;
527 let data: Vec<u8> = row.get(2)?;
528 Ok((version, data))
529 })?;
530
531 let mut result = Vec::new();
532 for row in rows {
533 let (version, data) = row?;
534 let mut obj: T = wave_obj_from_json(&data)?;
535 obj.set_version(version);
536 result.push(obj);
537 }
538 Ok(result)
539 }
540
541 #[allow(dead_code)]
542 pub fn delete<T: StoreObj>(&self, oid: &str) -> Result<(), StoreError> {
543 let table = Self::table_name::<T>();
544 self.conn.execute(
545 &format!("DELETE FROM {table} WHERE oid = ?1"),
546 params![oid],
547 )?;
548 Ok(())
549 }
550}
551
552pub use super::agents::{derive_slug, AgentDefinition, AgentInstance, InstanceStatus};
554pub use super::memory_bundles::Memory;
555pub use super::content::AgentContent;
556pub use super::history::AgentHistory;
557pub use super::skills::AgentSkill;
558
559pub use super::identities::{
561 AgentIdentityLink, Identity, IdentityAccount, IdentityBinding, SecretRef,
562};
563
564
565
566
567#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::backend::obj::*;
575
576 fn make_store() -> Store {
577 Store::open_in_memory().unwrap()
578 }
579
580 #[test]
581 fn test_insert_and_get_client() {
582 let store = make_store();
583 let mut client = Client {
584 oid: "test-client-oid".to_string(),
585 version: 0,
586 windowids: vec!["w1".to_string()],
587 meta: MetaMapType::new(),
588 tosagreed: 1700000000000,
589 ..Default::default()
590 };
591 store.insert(&mut client).unwrap();
592 assert_eq!(client.get_version(), 1);
593
594 let loaded = store.must_get::<Client>("test-client-oid").unwrap();
595 assert_eq!(loaded.oid, "test-client-oid");
596 assert_eq!(loaded.version, 1);
597 assert_eq!(loaded.windowids, vec!["w1"]);
598 assert_eq!(loaded.tosagreed, 1700000000000);
599 }
600
601 #[test]
602 fn test_insert_and_get_window() {
603 let store = make_store();
604 let mut win = Window {
605 oid: "win-1".to_string(),
606 workspaceid: "ws-1".to_string(),
607 pos: Point { x: 10, y: 20 },
608 winsize: WinSize {
609 width: 800,
610 height: 600,
611 },
612 meta: MetaMapType::new(),
613 ..Default::default()
614 };
615 store.insert(&mut win).unwrap();
616
617 let loaded = store.must_get::<Window>("win-1").unwrap();
618 assert_eq!(loaded.workspaceid, "ws-1");
619 assert_eq!(loaded.pos.x, 10);
620 assert_eq!(loaded.winsize.width, 800);
621 }
622
623 #[test]
624 fn test_insert_and_get_workspace() {
625 let store = make_store();
626 let mut ws = Workspace {
627 oid: "ws-1".to_string(),
628 name: "Test WS".to_string(),
629 tabids: vec!["t1".to_string()],
630 activetabid: "t1".to_string(),
631 meta: MetaMapType::new(),
632 ..Default::default()
633 };
634 store.insert(&mut ws).unwrap();
635
636 let loaded = store.must_get::<Workspace>("ws-1").unwrap();
637 assert_eq!(loaded.name, "Test WS");
638 assert_eq!(loaded.tabids, vec!["t1"]);
639 }
640
641 #[test]
642 fn test_insert_and_get_tab() {
643 let store = make_store();
644 let mut tab = Tab {
645 oid: "tab-1".to_string(),
646 name: "Shell".to_string(),
647 layoutstate: "ls-1".to_string(),
648 blockids: vec!["b1".to_string()],
649 meta: MetaMapType::new(),
650 ..Default::default()
651 };
652 store.insert(&mut tab).unwrap();
653
654 let loaded = store.must_get::<Tab>("tab-1").unwrap();
655 assert_eq!(loaded.name, "Shell");
656 }
657
658 #[test]
659 fn test_insert_and_get_block() {
660 let store = make_store();
661 let mut block = Block {
662 oid: "blk-1".to_string(),
663 parentoref: "tab:tab-1".to_string(),
664 meta: {
665 let mut m = MetaMapType::new();
666 m.insert("view".into(), serde_json::json!("term"));
667 m
668 },
669 ..Default::default()
670 };
671 store.insert(&mut block).unwrap();
672
673 let loaded = store.must_get::<Block>("blk-1").unwrap();
674 assert_eq!(loaded.parentoref, "tab:tab-1");
675 assert_eq!(loaded.meta.get("view").unwrap(), "term");
676 }
677
678 #[test]
679 fn test_insert_and_get_layout_state() {
680 let store = make_store();
681 let mut ls = LayoutState {
683 oid: "ls-1".to_string(),
684 rootnode: Some(crate::backend::obj::LayoutNode {
685 id: "n1".into(),
686 flex_direction: crate::backend::obj::FlexDirection::Row,
687 size: 1.0,
688 children: Vec::new(),
689 data: None,
690 ..Default::default()
691 }),
692 magnifiednodeid: "n1".to_string(),
693 ..Default::default()
694 };
695 store.insert(&mut ls).unwrap();
696
697 let loaded = store.must_get::<LayoutState>("ls-1").unwrap();
698 assert_eq!(loaded.magnifiednodeid, "n1");
699 assert!(loaded.rootnode.is_some());
700 }
701
702 #[test]
703 fn test_get_nonexistent_returns_none() {
704 let store = make_store();
705 let result = store.get::<Client>("nonexistent").unwrap();
706 assert!(result.is_none());
707 }
708
709 #[test]
710 fn test_must_get_nonexistent_returns_error() {
711 let store = make_store();
712 let result = store.must_get::<Client>("nonexistent");
713 assert!(matches!(result, Err(StoreError::NotFound)));
714 }
715
716 #[test]
717 fn test_update_increments_version() {
718 let store = make_store();
719 let mut client = Client {
720 oid: "c1".to_string(),
721 meta: MetaMapType::new(),
722 ..Default::default()
723 };
724 store.insert(&mut client).unwrap();
725 assert_eq!(client.version, 1);
726
727 client.windowids = vec!["w-new".to_string()];
728 let v2 = store.update(&mut client).unwrap();
729 assert_eq!(v2, 2);
730 assert_eq!(client.version, 2);
731
732 let v3 = store.update(&mut client).unwrap();
733 assert_eq!(v3, 3);
734 }
735
736 #[test]
737 fn test_delete() {
738 let store = make_store();
739 let mut client = Client {
740 oid: "del-me".to_string(),
741 meta: MetaMapType::new(),
742 ..Default::default()
743 };
744 store.insert(&mut client).unwrap();
745 assert!(store.get::<Client>("del-me").unwrap().is_some());
746
747 store.delete::<Client>("del-me").unwrap();
748 assert!(store.get::<Client>("del-me").unwrap().is_none());
749 }
750
751 #[test]
752 fn test_get_all() {
753 let store = make_store();
754 for i in 0..3 {
755 let mut tab = Tab {
756 oid: format!("tab-{i}"),
757 name: format!("Tab {i}"),
758 meta: MetaMapType::new(),
759 ..Default::default()
760 };
761 store.insert(&mut tab).unwrap();
762 }
763
764 let all = store.get_all::<Tab>().unwrap();
765 assert_eq!(all.len(), 3);
766 }
767
768 #[test]
769 fn test_count() {
770 let store = make_store();
771 assert_eq!(store.count::<Client>().unwrap(), 0);
772
773 let mut c = Client {
774 oid: "c1".to_string(),
775 meta: MetaMapType::new(),
776 ..Default::default()
777 };
778 store.insert(&mut c).unwrap();
779 assert_eq!(store.count::<Client>().unwrap(), 1);
780 }
781
782 #[test]
783 fn test_insert_empty_oid_fails() {
784 let store = make_store();
785 let mut client = Client {
786 oid: String::new(),
787 meta: MetaMapType::new(),
788 ..Default::default()
789 };
790 let result = store.insert(&mut client);
791 assert!(matches!(result, Err(StoreError::EmptyOID)));
792 }
793
794 #[test]
795 fn test_with_tx_commits_on_success() {
796 let store = make_store();
797 store
798 .with_tx(|tx| {
799 let mut ws = Workspace {
800 oid: "ws-tx".to_string(),
801 name: "TX Workspace".to_string(),
802 meta: MetaMapType::new(),
803 ..Default::default()
804 };
805 tx.insert(&mut ws)?;
806
807 let mut tab = Tab {
808 oid: "tab-tx".to_string(),
809 name: "tab1".to_string(),
811 layoutstate: "ls-tx".to_string(),
812 meta: MetaMapType::new(),
813 ..Default::default()
814 };
815 tx.insert(&mut tab)?;
816
817 ws.tabids.push("tab-tx".to_string());
819 tx.update(&mut ws)?;
820
821 Ok(())
822 })
823 .unwrap();
824
825 let ws = store.must_get::<Workspace>("ws-tx").unwrap();
827 assert_eq!(ws.name, "TX Workspace");
828 assert_eq!(ws.tabids, vec!["tab-tx"]);
829 assert_eq!(ws.version, 2); let tab = store.must_get::<Tab>("tab-tx").unwrap();
832 assert_eq!(tab.name, "tab1");
833 }
834
835 #[test]
836 fn test_with_tx_rollbacks_on_error() {
837 let store = make_store();
838 let result: Result<(), StoreError> = store.with_tx(|tx| {
839 let mut ws = Workspace {
840 oid: "ws-rollback".to_string(),
841 name: "Should Not Exist".to_string(),
842 meta: MetaMapType::new(),
843 ..Default::default()
844 };
845 tx.insert(&mut ws)?;
846
847 Err(StoreError::Other("intentional failure".to_string()))
849 });
850 assert!(result.is_err());
851
852 let ws = store.get::<Workspace>("ws-rollback").unwrap();
854 assert!(ws.is_none());
855 }
856
857 #[test]
858 fn test_agent_def_insert_collision_resolves_at_runtime() {
859 let store = make_store();
865
866 let mut a1 = AgentDefinition {
867 id: "id-a".to_string(),
868 slug: String::new(),
869 name: "Agent X".to_string(),
870 icon: "✦".to_string(),
871 provider: "claude".to_string(),
872 description: String::new(),
873 working_directory: String::new(),
874 shell: String::new(),
875 provider_flags: String::new(),
876 auto_start: 0,
877 restart_on_crash: 0,
878 idle_timeout_minutes: 0,
879 created_at: 0,
880 agent_type: "host".to_string(),
881 environment: String::new(),
882 agent_bus_id: String::new(),
883 is_seeded: 0,
884 accounts: String::new(),
885 parent_id: String::new(),
886 branch_label: String::new(),
887 updated_at: 0,
888 user_hidden: 0,
889 container_image: String::new(),
890 container_volumes: "[]".to_string(),
891 container_name: String::new(),
892 };
893 store.agent_def_insert(&mut a1).unwrap();
894 assert_eq!(a1.slug, "agent-x");
896
897 let mut a2 = AgentDefinition {
898 id: "id-b".to_string(),
899 name: "agent x".to_string(),
901 ..a1.clone()
902 };
903 a2.slug = String::new();
904 store.agent_def_insert(&mut a2).unwrap();
905 assert_eq!(a2.slug, "agent-x-2");
906
907 let mut a3 = AgentDefinition {
908 id: "id-c".to_string(),
909 name: "AGENT-X".to_string(),
910 ..a1.clone()
911 };
912 a3.slug = String::new();
913 store.agent_def_insert(&mut a3).unwrap();
914 assert_eq!(a3.slug, "agent-x-3");
915
916 let listed = store.agent_def_list().unwrap();
918 let slugs: Vec<&str> = listed.iter().map(|a| a.slug.as_str()).collect();
919 assert!(slugs.contains(&"agent-x"));
920 assert!(slugs.contains(&"agent-x-2"));
921 assert!(slugs.contains(&"agent-x-3"));
922 }
923
924 #[test]
925 fn test_agent_def_insert_explicit_slug_collision_resolves() {
926 let store = make_store();
931
932 let mut a1 = AgentDefinition {
933 id: "id-a".to_string(),
934 slug: "explicit".to_string(),
935 name: "First".to_string(),
936 icon: "✦".to_string(),
937 provider: "claude".to_string(),
938 description: String::new(),
939 working_directory: String::new(),
940 shell: String::new(),
941 provider_flags: String::new(),
942 auto_start: 0,
943 restart_on_crash: 0,
944 idle_timeout_minutes: 0,
945 created_at: 0,
946 agent_type: "host".to_string(),
947 environment: String::new(),
948 agent_bus_id: String::new(),
949 is_seeded: 0,
950 accounts: String::new(),
951 parent_id: String::new(),
952 branch_label: String::new(),
953 updated_at: 0,
954 user_hidden: 0,
955 container_image: String::new(),
956 container_volumes: "[]".to_string(),
957 container_name: String::new(),
958 };
959 store.agent_def_insert(&mut a1).unwrap();
960 assert_eq!(a1.slug, "explicit");
961
962 let mut a2 = AgentDefinition {
963 id: "id-b".to_string(),
964 ..a1.clone()
965 };
966 a2.slug = "explicit".to_string();
967 store.agent_def_insert(&mut a2).unwrap();
968 assert_eq!(a2.slug, "explicit-2");
969 }
970
971 fn v6_test_store() -> Store {
974 Store::open_in_memory().unwrap()
975 }
976
977 fn sample_account(id: &str, provider: &str) -> IdentityAccount {
978 IdentityAccount {
979 id: id.to_string(),
980 name: format!("asaf-{provider}"),
981 provider: provider.to_string(),
982 kind: "pat".to_string(),
983 display_name: "".to_string(),
984 secret_ref: SecretRef::Env { env_var: format!("{}_TOKEN", provider.to_uppercase()) },
985 context: serde_json::json!({"username": "asaf"}),
986 status: "unknown".to_string(),
987 created_at: 0,
988 updated_at: 0,
989 }
990 }
991
992 fn sample_agent(id: &str, slug: &str) -> AgentDefinition {
993 AgentDefinition {
994 id: id.to_string(),
995 slug: slug.to_string(),
996 name: id.to_string(),
997 icon: "✦".to_string(),
998 provider: "claude".to_string(),
999 description: "".to_string(),
1000 working_directory: "".to_string(),
1001 shell: "".to_string(),
1002 provider_flags: "".to_string(),
1003 auto_start: 0,
1004 restart_on_crash: 0,
1005 idle_timeout_minutes: 0,
1006 created_at: 0,
1007 agent_type: "host".to_string(),
1008 environment: "".to_string(),
1009 agent_bus_id: "".to_string(),
1010 is_seeded: 0,
1011 accounts: String::new(),
1012 parent_id: String::new(),
1013 branch_label: String::new(),
1014 updated_at: 0,
1015 user_hidden: 0,
1016 container_image: String::new(),
1017 container_volumes: "[]".to_string(),
1018 container_name: String::new(),
1019 }
1020 }
1021
1022 #[test]
1023 fn test_identity_upsert_round_trip() {
1024 let store = v6_test_store();
1025 let acct = sample_account("id-gh", "github");
1026 store.identity_upsert(&acct).unwrap();
1027
1028 let fetched = store.identity_get("id-gh").unwrap().expect("row");
1029 assert_eq!(fetched.name, "asaf-github");
1030 assert_eq!(fetched.provider, "github");
1031 assert!(matches!(fetched.secret_ref, SecretRef::Env { ref env_var } if env_var == "GITHUB_TOKEN"));
1032 assert_eq!(fetched.context["username"], "asaf");
1033 }
1034
1035 #[test]
1036 fn test_identity_list_filtered_by_provider() {
1037 let store = v6_test_store();
1038 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1039 store.identity_upsert(&sample_account("id-aws", "aws")).unwrap();
1040 store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
1041
1042 let all = store.identity_list(None).unwrap();
1043 assert_eq!(all.len(), 3);
1044 let gh = store.identity_list(Some("github")).unwrap();
1045 assert_eq!(gh.len(), 2);
1046 assert!(gh.iter().all(|a| a.provider == "github"));
1047 }
1048
1049 #[test]
1050 fn test_identity_delete() {
1051 let store = v6_test_store();
1052 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1053 assert!(store.identity_delete("id-gh").unwrap());
1054 assert!(store.identity_get("id-gh").unwrap().is_none());
1055 assert!(!store.identity_delete("id-gh").unwrap());
1057 }
1058
1059 #[test]
1060 fn test_agent_identity_link_and_unlink() {
1061 let store = v6_test_store();
1062 let mut agent = sample_agent("ag1", "agent-x");
1063 store.agent_def_insert(&mut agent).unwrap();
1064 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1065
1066 store.agent_identity_link("ag1", "id-gh", "github").unwrap();
1067 let links = store.agent_identity_list_for_agent("ag1").unwrap();
1068 assert_eq!(links.len(), 1);
1069 assert_eq!(links[0].account_id, "id-gh");
1070
1071 store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
1073 store.agent_identity_link("ag1", "id-gh2", "github").unwrap();
1074 let links = store.agent_identity_list_for_agent("ag1").unwrap();
1075 assert_eq!(links.len(), 1);
1076 assert_eq!(links[0].account_id, "id-gh2");
1077
1078 assert!(store.agent_identity_unlink("ag1", "github").unwrap());
1079 assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
1080 }
1081
1082 #[test]
1083 fn test_agent_identity_cascade_on_agent_delete() {
1084 let store = v6_test_store();
1085 let mut agent = sample_agent("ag1", "agent-x");
1086 store.agent_def_insert(&mut agent).unwrap();
1087 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
1088 store.agent_identity_link("ag1", "id-gh", "github").unwrap();
1089
1090 store.agent_def_delete("ag1").unwrap();
1091 assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
1092 }
1093
1094 #[test]
1095 fn test_instance_create_update_filter() {
1096 let store = v6_test_store();
1097 let mut agent = sample_agent("def1", "agent-x");
1098 store.agent_def_insert(&mut agent).unwrap();
1099
1100 let inst = AgentInstance {
1101 id: "inst1".to_string(),
1102 definition_id: "def1".to_string(),
1103 parent_instance_id: String::new(),
1104 block_id: "block-abc".to_string(),
1105 session_id: String::new(),
1106 status: InstanceStatus::Running.as_str().to_string(),
1107 github_context: String::new(),
1108 started_at: 1000,
1109 ended_at: 0,
1110 created_at: 1000,
1111 identity_id: String::new(),
1112 memory_id: String::new(),
1113 instance_name: String::new(),
1114 working_directory: String::new(),
1115 display_hidden: false,
1116 };
1117 store.instance_create(&inst).unwrap();
1118
1119 let fetched = store.instance_get("inst1").unwrap().expect("row");
1120 assert_eq!(fetched.block_id, "block-abc");
1121 assert_eq!(fetched.status, "running");
1122
1123 let mut updated = fetched.clone();
1125 updated.status = InstanceStatus::Stopped.as_str().to_string();
1126 updated.ended_at = 2000;
1127 assert!(store.instance_update(&updated).unwrap());
1128 assert_eq!(store.instance_get("inst1").unwrap().unwrap().status, "stopped");
1129
1130 let all = store.instance_list(None, None).unwrap();
1132 assert_eq!(all.len(), 1);
1133 let by_def = store.instance_list(Some("def1"), None).unwrap();
1134 assert_eq!(by_def.len(), 1);
1135 let running = store.instance_list(None, Some("running")).unwrap();
1136 assert_eq!(running.len(), 0);
1137 let stopped = store.instance_list(None, Some("stopped")).unwrap();
1138 assert_eq!(stopped.len(), 1);
1139 }
1140
1141 #[test]
1142 fn test_instance_update_partial() {
1143 use crate::backend::storage::InstanceUpdate;
1144 let store = v6_test_store();
1145 let mut agent = sample_agent("defp", "agent-p");
1146 store.agent_def_insert(&mut agent).unwrap();
1147 let inst = AgentInstance {
1148 id: "instp".to_string(),
1149 definition_id: "defp".to_string(),
1150 parent_instance_id: String::new(),
1151 block_id: "block-1".to_string(),
1152 session_id: "sess-1".to_string(),
1153 status: InstanceStatus::Running.as_str().to_string(),
1154 github_context: "ctx-1".to_string(),
1155 started_at: 1000,
1156 ended_at: 0,
1157 created_at: 1000,
1158 identity_id: String::new(),
1159 memory_id: String::new(),
1160 instance_name: String::new(),
1161 working_directory: String::new(),
1162 display_hidden: false,
1163 };
1164 store.instance_create(&inst).unwrap();
1165
1166 let fresh = store
1168 .instance_update_partial(
1169 "instp",
1170 &InstanceUpdate { status: Some("stopped".into()), ..Default::default() },
1171 )
1172 .unwrap()
1173 .expect("row");
1174 assert_eq!(fresh.status, "stopped");
1175 assert_eq!(fresh.block_id, "block-1", "block_id must be untouched");
1176 assert_eq!(fresh.session_id, "sess-1", "session_id must be untouched");
1177 assert_eq!(fresh.github_context, "ctx-1", "github_context must be untouched");
1178
1179 let fresh = store
1181 .instance_update_partial(
1182 "instp",
1183 &InstanceUpdate { session_id: Some("sess-2".into()), ..Default::default() },
1184 )
1185 .unwrap()
1186 .expect("row");
1187 assert_eq!(fresh.session_id, "sess-2");
1188 assert_eq!(fresh.status, "stopped", "status from prior partial write persists");
1189
1190 let fresh = store
1192 .instance_update_partial(
1193 "instp",
1194 &InstanceUpdate { github_context: Some(String::new()), ..Default::default() },
1195 )
1196 .unwrap()
1197 .expect("row");
1198 assert_eq!(fresh.github_context, "", "Some(\"\") clears");
1199
1200 let noop = store
1203 .instance_update_partial("instp", &InstanceUpdate::default())
1204 .unwrap();
1205 assert!(noop.is_some(), "no-op on an existing id returns the row");
1206 assert_eq!(noop.unwrap().session_id, "sess-2");
1207
1208 let missing = store
1210 .instance_update_partial("nope", &InstanceUpdate { status: Some("x".into()), ..Default::default() })
1211 .unwrap();
1212 assert!(missing.is_none(), "not-found returns None");
1213 }
1214
1215 #[test]
1216 fn test_agent_def_list_orders_by_last_used() {
1217 let store = v6_test_store();
1218 let mut a = sample_agent("def-a", "agent-a");
1220 let mut b = sample_agent("def-b", "agent-b");
1221 let mut c = sample_agent("def-c", "agent-c");
1222 store.agent_def_insert(&mut a).unwrap();
1223 store.agent_def_insert(&mut b).unwrap();
1224 store.agent_def_insert(&mut c).unwrap();
1225
1226 let mk = |id: &str, def: &str, started: i64| AgentInstance {
1227 id: id.to_string(),
1228 definition_id: def.to_string(),
1229 parent_instance_id: String::new(),
1230 block_id: String::new(),
1231 session_id: String::new(),
1232 status: InstanceStatus::Running.as_str().to_string(),
1233 github_context: String::new(),
1234 started_at: started,
1235 ended_at: 0,
1236 created_at: started,
1237 identity_id: String::new(),
1238 memory_id: String::new(),
1239 instance_name: String::new(),
1240 working_directory: String::new(),
1241 display_hidden: false,
1242 };
1243 store.instance_create(&mk("i-a", "def-a", 500)).unwrap();
1245 store.instance_create(&mk("i-b", "def-b", 600)).unwrap();
1246
1247 let ids = |s: &Store| -> Vec<String> {
1248 s.agent_def_list().unwrap().into_iter().map(|d| d.id).collect()
1249 };
1250 assert_eq!(ids(&store), vec!["def-b", "def-a", "def-c"]);
1252
1253 store.instance_create(&mk("i-a2", "def-a", 700)).unwrap();
1255 assert_eq!(ids(&store), vec!["def-a", "def-b", "def-c"]);
1256 }
1257
1258 #[test]
1259 fn test_instance_cascade_on_definition_delete() {
1260 let store = v6_test_store();
1261 let mut agent = sample_agent("def1", "agent-x");
1262 store.agent_def_insert(&mut agent).unwrap();
1263 let inst = AgentInstance {
1264 id: "inst1".to_string(),
1265 definition_id: "def1".to_string(),
1266 parent_instance_id: String::new(),
1267 block_id: String::new(),
1268 session_id: String::new(),
1269 status: "running".to_string(),
1270 github_context: String::new(),
1271 started_at: 0,
1272 ended_at: 0,
1273 created_at: 0,
1274 identity_id: String::new(),
1275 memory_id: String::new(),
1276 instance_name: String::new(),
1277 working_directory: String::new(),
1278 display_hidden: false,
1279 };
1280 store.instance_create(&inst).unwrap();
1281
1282 store.agent_def_delete("def1").unwrap();
1283 assert!(store.instance_get("inst1").unwrap().is_none());
1284 }
1285
1286 #[test]
1287 fn test_instance_status_enum_roundtrip() {
1288 for s in &[
1289 InstanceStatus::Running,
1290 InstanceStatus::Paused,
1291 InstanceStatus::Stopped,
1292 InstanceStatus::Crashed,
1293 InstanceStatus::Detached,
1294 ] {
1295 assert_eq!(Some(*s), InstanceStatus::parse(s.as_str()));
1296 }
1297 assert_eq!(None, InstanceStatus::parse("nonsense"));
1298 }
1299
1300 #[test]
1303 fn test_bundle_identity_lifecycle() {
1304 let store = make_store();
1305
1306 let initial = store.bundle_identity_list().unwrap();
1308 assert_eq!(initial.len(), 1);
1309 assert!(initial[0].is_blank);
1310 assert_eq!(initial[0].id, "blank");
1311
1312 let work = Identity {
1314 id: "id-work".to_string(),
1315 name: "Work".to_string(),
1316 description: "Office laptop credentials".to_string(),
1317 is_blank: false,
1318 created_at: 100,
1319 updated_at: 100,
1320 };
1321 store.bundle_identity_upsert(&work).unwrap();
1322
1323 let listed = store.bundle_identity_list().unwrap();
1325 assert_eq!(listed.len(), 2);
1326 assert_eq!(listed[0].id, "id-work");
1327 assert_eq!(listed[1].id, "blank");
1328
1329 let fetched = store.bundle_identity_get("id-work").unwrap().unwrap();
1331 assert_eq!(fetched.name, "Work");
1332
1333 let blank_delete = store.bundle_identity_delete("blank");
1335 assert!(blank_delete.is_err());
1336
1337 assert!(store.bundle_identity_delete("id-work").unwrap());
1339 assert_eq!(store.bundle_identity_list().unwrap().len(), 1);
1340 }
1341
1342 #[test]
1343 fn test_bundle_identity_bindings_round_trip() {
1344 let store = make_store();
1345
1346 let acct = IdentityAccount {
1348 id: "acct-1".to_string(),
1349 name: "asaf-github".to_string(),
1350 provider: "github".to_string(),
1351 kind: "pat".to_string(),
1352 display_name: String::new(),
1353 secret_ref: SecretRef::Env {
1354 env_var: "GITHUB_TOKEN".to_string(),
1355 },
1356 context: serde_json::json!({}),
1357 status: "unknown".to_string(),
1358 created_at: 0,
1359 updated_at: 0,
1360 };
1361 store.identity_upsert(&acct).unwrap();
1362
1363 let identity = Identity {
1364 id: "id-work".to_string(),
1365 name: "Work".to_string(),
1366 description: String::new(),
1367 is_blank: false,
1368 created_at: 0,
1369 updated_at: 0,
1370 };
1371 store.bundle_identity_upsert(&identity).unwrap();
1372
1373 store
1375 .bundle_identity_bind("id-work", "github", "acct-1")
1376 .unwrap();
1377 let bindings = store.bundle_identity_bindings("id-work").unwrap();
1378 assert_eq!(bindings.len(), 1);
1379 assert_eq!(bindings[0].provider, "github");
1380 assert_eq!(bindings[0].account_id, "acct-1");
1381
1382 let acct2 = IdentityAccount {
1384 id: "acct-2".to_string(),
1385 name: "asaf-github-2".to_string(),
1386 provider: "github".to_string(),
1387 kind: "pat".to_string(),
1388 display_name: String::new(),
1389 secret_ref: SecretRef::Env {
1390 env_var: "GITHUB_TOKEN_ALT".to_string(),
1391 },
1392 context: serde_json::json!({}),
1393 status: "unknown".to_string(),
1394 created_at: 0,
1395 updated_at: 0,
1396 };
1397 store.identity_upsert(&acct2).unwrap();
1398 store
1399 .bundle_identity_bind("id-work", "github", "acct-2")
1400 .unwrap();
1401 let rebound = store.bundle_identity_bindings("id-work").unwrap();
1402 assert_eq!(rebound.len(), 1);
1403 assert_eq!(rebound[0].account_id, "acct-2");
1404
1405 assert!(store.bundle_identity_unbind("id-work", "github").unwrap());
1407 assert_eq!(
1408 store.bundle_identity_bindings("id-work").unwrap().len(),
1409 0
1410 );
1411 }
1412
1413 #[test]
1414 fn test_bundle_identity_bindings_cascade_on_account_delete() {
1415 let store = make_store();
1416
1417 let acct = IdentityAccount {
1418 id: "acct-1".to_string(),
1419 name: "asaf-github".to_string(),
1420 provider: "github".to_string(),
1421 kind: "pat".to_string(),
1422 display_name: String::new(),
1423 secret_ref: SecretRef::Env {
1424 env_var: "GITHUB_TOKEN".to_string(),
1425 },
1426 context: serde_json::json!({}),
1427 status: "unknown".to_string(),
1428 created_at: 0,
1429 updated_at: 0,
1430 };
1431 store.identity_upsert(&acct).unwrap();
1432
1433 let identity = Identity {
1434 id: "id-work".to_string(),
1435 name: "Work".to_string(),
1436 description: String::new(),
1437 is_blank: false,
1438 created_at: 0,
1439 updated_at: 0,
1440 };
1441 store.bundle_identity_upsert(&identity).unwrap();
1442 store
1443 .bundle_identity_bind("id-work", "github", "acct-1")
1444 .unwrap();
1445
1446 store.identity_delete("acct-1").unwrap();
1448 assert_eq!(
1449 store.bundle_identity_bindings("id-work").unwrap().len(),
1450 0
1451 );
1452 }
1453
1454 #[test]
1457 fn test_bundle_memory_lifecycle() {
1458 let store = make_store();
1459
1460 let initial = store.bundle_memory_list().unwrap();
1462 assert_eq!(initial.len(), 1);
1463 assert!(initial[0].is_blank);
1464 assert_eq!(initial[0].id, "blank");
1465
1466 let coder = Memory {
1468 id: "mem-coder".to_string(),
1469 name: "Claude-coder".to_string(),
1470 description: "Pair-programming setup".to_string(),
1471 is_blank: false,
1472 is_global: false,
1473 provider: "claude".to_string(),
1474 model: "claude-sonnet-4-6".to_string(),
1475 instructions: "You are a careful refactorer.".to_string(),
1476 context_files: "[]".to_string(),
1477 mcp_servers: "[]".to_string(),
1478 skills: "[]".to_string(),
1479 sort_order: 0,
1480 created_at: 100,
1481 updated_at: 100,
1482 };
1483 store.bundle_memory_upsert(&coder).unwrap();
1484
1485 let listed = store.bundle_memory_list().unwrap();
1486 assert_eq!(listed.len(), 2);
1487 assert_eq!(listed[0].id, "mem-coder");
1488 assert_eq!(listed[1].id, "blank");
1489
1490 let fetched = store.bundle_memory_get("mem-coder").unwrap().unwrap();
1491 assert_eq!(fetched.provider, "claude");
1492 assert_eq!(fetched.instructions, "You are a careful refactorer.");
1493
1494 assert!(store.bundle_memory_delete("blank").is_err());
1496
1497 assert!(store.bundle_memory_delete("mem-coder").unwrap());
1499 assert_eq!(store.bundle_memory_list().unwrap().len(), 1);
1500 }
1501
1502 #[test]
1503 fn test_global_brain_order_and_format() {
1504 let store = make_store();
1505
1506 let mk = |id: &str, name: &str, order: i64| Memory {
1507 id: id.to_string(),
1508 name: name.to_string(),
1509 description: String::new(),
1510 is_blank: false,
1511 is_global: true,
1512 provider: String::new(),
1513 model: String::new(),
1514 instructions: format!("rules for {name}"),
1515 context_files: "[]".to_string(),
1516 mcp_servers: "[]".to_string(),
1517 skills: "[]".to_string(),
1518 sort_order: order,
1519 created_at: 0,
1520 updated_at: 0,
1521 };
1522 store.bundle_memory_upsert(&mk("g-a", "Alpha", 1)).unwrap();
1524 store.bundle_memory_upsert(&mk("g-b", "Beta", 0)).unwrap();
1525
1526 let g = store.bundle_memory_list_global().unwrap();
1528 assert_eq!(
1529 g.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1530 vec!["g-b", "g-a"]
1531 );
1532
1533 let updated = store
1535 .bundle_memory_reorder(&["g-a".to_string(), "g-b".to_string()])
1536 .unwrap();
1537 assert_eq!(updated, 2);
1538 let g = store.bundle_memory_list_global().unwrap();
1539 assert_eq!(
1540 g.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1541 vec!["g-a", "g-b"]
1542 );
1543
1544 let mut edited = g[0].clone();
1546 edited.instructions = "edited".to_string();
1547 edited.sort_order = 999; store.bundle_memory_upsert(&edited).unwrap();
1549 let g = store.bundle_memory_list_global().unwrap();
1550 assert_eq!(g[0].id, "g-a", "edit must keep position");
1551 assert_eq!(g[0].sort_order, 0, "sort_order owned by reorder, not upsert");
1552
1553 let block = super::super::format_global_brain_block(&g);
1555 let expected = "# [Workspace] Alpha\n\nedited\n\n---\n\n# [Workspace] Beta\n\nrules for Beta";
1556 assert_eq!(block, expected);
1557 }
1558
1559 fn make_named_inst(id: &str, name: &str, agents_root: &Path) -> AgentInstance {
1562 let wd = agents_root.join(format!("{name}-fixture")).to_string_lossy().to_string();
1565 AgentInstance {
1566 id: id.to_string(),
1567 definition_id: "def-mirror".to_string(),
1568 parent_instance_id: String::new(),
1569 block_id: String::new(),
1570 session_id: String::new(),
1571 status: "running".to_string(),
1572 github_context: String::new(),
1573 started_at: 1_000,
1574 ended_at: 0,
1575 created_at: 900,
1576 identity_id: String::new(),
1577 memory_id: String::new(),
1578 instance_name: name.to_string(),
1579 working_directory: wd,
1580 display_hidden: false,
1581 }
1582 }
1583
1584 fn store_with_registry() -> (tempfile::TempDir, Store, Arc<crate::registry::Registry>) {
1585 let tmp = tempfile::tempdir().unwrap();
1586 let agents_root = tmp.path().join("agents");
1587 std::fs::create_dir_all(&agents_root).unwrap();
1588 let reg_root = agents_root.join("registry");
1589 let reg = Arc::new(crate::registry::Registry::open(reg_root).unwrap());
1590 let store = Store::open_in_memory().unwrap();
1591 store.set_registry(reg.clone());
1592 let mut agent = sample_agent("def-mirror", "mirror");
1594 store.agent_def_insert(&mut agent).unwrap();
1595 (tmp, store, reg)
1596 }
1597
1598 #[test]
1599 fn instance_create_named_writes_registry_file() {
1600 let (tmp, store, reg) = store_with_registry();
1601 let agents_root = tmp.path().join("agents");
1602 let inst = make_named_inst("inst-1", "demo", &agents_root);
1603 store.instance_create(&inst).unwrap();
1604 let records = reg.list_active().unwrap();
1605 assert_eq!(records.len(), 1);
1606 assert_eq!(records[0].data.instance_id, "inst-1");
1607 assert_eq!(records[0].data.instance_name, "demo");
1608 assert_eq!(records[0].data.identity_id, None);
1609 assert_eq!(records[0].data.memory_id, None);
1610 assert_eq!(records[0].data.working_dir, "demo-fixture");
1611 assert_eq!(
1614 records[0].data.source_agents_base.as_deref(),
1615 Some(agents_root.to_string_lossy().as_ref())
1616 );
1617 }
1618
1619 #[test]
1620 fn registry_agents_base_falls_back_to_registry_parent() {
1621 let (_tmp, store, _reg) = store_with_registry();
1625 let base = store.registry_agents_base().unwrap();
1626 assert!(base.ends_with("agents"), "fallback is the registry parent");
1627 assert!(!base.ends_with("registry"));
1628 }
1629
1630 #[test]
1631 fn registry_agents_base_override_decouples_from_registry_parent() {
1632 let tmp = tempfile::tempdir().unwrap();
1639 let global_reg_root = tmp.path().join("shared").join("agents").join("registry");
1640 let reg = Arc::new(crate::registry::Registry::open(global_reg_root).unwrap());
1641 let store = Store::open_in_memory().unwrap();
1642 store.set_registry(reg.clone());
1643 let channel_agents = tmp.path().join("channels").join("local-x").join("agents");
1644 std::fs::create_dir_all(&channel_agents).unwrap();
1645 store.set_registry_agents_base(channel_agents.clone());
1646 assert_ne!(
1648 store.registry_agents_base().unwrap(),
1649 reg.agents_root().unwrap()
1650 );
1651 let mut agent = sample_agent("def-mirror", "mirror");
1653 store.agent_def_insert(&mut agent).unwrap();
1654 let inst = make_named_inst("inst-base", "demo", &channel_agents);
1656 store.instance_create(&inst).unwrap();
1657 let records = reg.list_active().unwrap();
1658 assert_eq!(records.len(), 1, "instance mirrors via the explicit base");
1659 assert_eq!(records[0].data.working_dir, "demo-fixture");
1662 }
1663
1664 fn store_with_global_registry() -> (tempfile::TempDir, Store, Arc<crate::registry::Registry>) {
1669 let tmp = tempfile::tempdir().unwrap();
1670 std::fs::create_dir_all(tmp.path().join("agents")).unwrap();
1671 let reg_root = tmp.path().join("shared").join("agents").join("registry");
1672 let reg = Arc::new(crate::registry::Registry::open(reg_root).unwrap());
1673 let store = Store::open_in_memory().unwrap();
1674 store.set_registry(reg.clone());
1675 let channel_agents = tmp.path().join("channels").join("local-x").join("agents");
1676 std::fs::create_dir_all(&channel_agents).unwrap();
1677 store.set_registry_agents_base(channel_agents);
1678 let mut agent = sample_agent("def-mirror", "mirror");
1679 store.agent_def_insert(&mut agent).unwrap();
1680 (tmp, store, reg)
1681 }
1682
1683 #[test]
1684 fn mirror_anchors_global_workspace() {
1685 let (tmp, store, reg) = store_with_global_registry();
1690 let global_agents = tmp.path().join("agents"); let inst = make_named_inst("inst-g", "qooma", &global_agents);
1692 store.instance_create(&inst).unwrap();
1693 let records = reg.list_active().unwrap();
1694 assert_eq!(records.len(), 1, "global workspace must mirror, not be dropped");
1695 assert_eq!(records[0].data.working_dir, "qooma-fixture");
1696 assert_eq!(
1697 records[0].data.source_agents_base.as_deref(),
1698 Some(global_agents.to_string_lossy().as_ref()),
1699 "anchored on the GLOBAL workspace root, not the channel base"
1700 );
1701 }
1702
1703 #[test]
1704 fn mirror_per_channel_legacy_workspace_still_works() {
1705 let (tmp, store, reg) = store_with_global_registry();
1708 let channel_agents = tmp.path().join("channels").join("local-x").join("agents");
1709 let inst = make_named_inst("inst-c", "legacy", &channel_agents);
1710 store.instance_create(&inst).unwrap();
1711 let records = reg.list_active().unwrap();
1712 assert_eq!(records.len(), 1);
1713 assert_eq!(records[0].data.working_dir, "legacy-fixture");
1714 assert_eq!(
1715 records[0].data.source_agents_base.as_deref(),
1716 Some(channel_agents.to_string_lossy().as_ref()),
1717 "fell back to the per-channel base"
1718 );
1719 }
1720
1721 #[test]
1722 fn mirror_skips_workspace_under_neither_root() {
1723 let (tmp, store, reg) = store_with_global_registry();
1726 let outside = tmp.path().join("projects").join("foo");
1727 let inst = make_named_inst("inst-o", "stray", &outside);
1728 store.instance_create(&inst).unwrap();
1729 assert!(
1730 reg.list_active().unwrap().is_empty(),
1731 "non-agent workspace must not be mirrored"
1732 );
1733 }
1734
1735 #[test]
1736 fn instance_create_unnamed_does_not_mirror() {
1737 let (tmp, store, reg) = store_with_registry();
1738 let agents_root = tmp.path().join("agents");
1739 let mut inst = make_named_inst("inst-2", "demo2", &agents_root);
1740 inst.instance_name = String::new(); store.instance_create(&inst).unwrap();
1742 assert!(reg.list_active().unwrap().is_empty());
1743 }
1744
1745 #[test]
1746 fn instance_set_hidden_retires_then_unretires() {
1747 let (tmp, store, reg) = store_with_registry();
1748 let agents_root = tmp.path().join("agents");
1749 let inst = make_named_inst("inst-3", "demo3", &agents_root);
1750 store.instance_create(&inst).unwrap();
1751 store.instance_set_hidden("inst-3", true).unwrap();
1752 assert!(reg.list_active().unwrap().is_empty());
1753 store.instance_set_hidden("inst-3", false).unwrap();
1754 assert_eq!(reg.list_active().unwrap().len(), 1);
1755 }
1756
1757 #[test]
1758 fn instance_update_refreshes_registry_record() {
1759 let (tmp, store, reg) = store_with_registry();
1760 let agents_root = tmp.path().join("agents");
1761 let inst = make_named_inst("inst-4", "demo4", &agents_root);
1762 store.instance_create(&inst).unwrap();
1763 let mut updated = inst.clone();
1764 updated.status = "paused".to_string();
1765 updated.session_id = "sess-xyz".to_string();
1766 store.instance_update(&updated).unwrap();
1767 let records = reg.list_active().unwrap();
1771 assert_eq!(records.len(), 1);
1772 assert_eq!(records[0].data.instance_id, "inst-4");
1773 }
1774
1775 #[test]
1776 fn instance_delete_removes_registry_file() {
1777 let (tmp, store, reg) = store_with_registry();
1778 let agents_root = tmp.path().join("agents");
1779 let inst = make_named_inst("inst-5", "demo5", &agents_root);
1780 store.instance_create(&inst).unwrap();
1781 store.instance_delete("inst-5").unwrap();
1782 assert!(reg.list_active().unwrap().is_empty());
1783 }
1784
1785 #[test]
1786 fn instance_create_outside_agents_dir_skips_mirror() {
1787 let (tmp, store, reg) = store_with_registry();
1788 let mut inst = make_named_inst("inst-6", "demo6", tmp.path());
1789 inst.working_directory = tmp.path().join("projects").join("myrepo").to_string_lossy().to_string();
1791 store.instance_create(&inst).unwrap();
1792 assert!(reg.list_active().unwrap().is_empty());
1795 }
1796
1797 #[test]
1798 fn instance_create_user_path_with_agents_segment_is_skipped() {
1799 let (tmp, store, reg) = store_with_registry();
1805 let outside = tmp.path().join("code").join("agents").join("myproject");
1808 let mut inst = make_named_inst("inst-pathconfuse", "confuse", tmp.path());
1809 inst.working_directory = outside.to_string_lossy().to_string();
1810 store.instance_create(&inst).unwrap();
1811 assert!(reg.list_active().unwrap().is_empty(),
1812 "user path with inner 'agents' segment must not be mirrored");
1813 }
1814
1815 #[test]
1816 fn instance_create_continuation_row_does_not_mirror() {
1817 let (tmp, store, reg) = store_with_registry();
1826 let agents_root = tmp.path().join("agents");
1827 let parent = make_named_inst("inst-parent", "demoP", &agents_root);
1828 store.instance_create(&parent).unwrap();
1829 assert_eq!(reg.list_active().unwrap().len(), 1);
1830
1831 let mut child = make_named_inst("inst-child", "demoP", &agents_root);
1832 child.parent_instance_id = "inst-parent".to_string();
1833 store.instance_create(&child).unwrap();
1834 let recs = reg.list_active().unwrap();
1836 assert_eq!(recs.len(), 1);
1837 assert_eq!(recs[0].data.instance_id, "inst-parent");
1838 }
1839
1840 #[test]
1841 fn instance_list_named_picker_mode_dedupes_continuation_chain() {
1842 let (tmp, store, _reg) = store_with_registry();
1853 let agents_root = tmp.path().join("agents");
1854
1855 let mut head = make_named_inst("inst-head", "Maks", &agents_root);
1856 head.started_at = 100;
1857 store.instance_create(&head).unwrap();
1858
1859 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
1860 cont.parent_instance_id = "inst-head".to_string();
1861 cont.started_at = 200;
1862 store.instance_create(&cont).unwrap();
1863
1864 let picker_rows = store.instance_list_named(10, None, None, true).unwrap();
1865 assert_eq!(
1866 picker_rows.len(),
1867 1,
1868 "picker mode must collapse continuation chain to ONE entry"
1869 );
1870 assert_eq!(picker_rows[0].id, "inst-cont");
1871 assert_eq!(picker_rows[0].parent_instance_id, "inst-head");
1875
1876 let scoped = store
1878 .instance_list_named(10, Some("def-mirror"), None, true)
1879 .unwrap();
1880 assert_eq!(scoped.len(), 1);
1881 assert_eq!(scoped[0].id, "inst-cont");
1882 }
1883
1884 #[test]
1885 fn instance_list_named_picker_mode_dedupes_long_chain() {
1886 let (tmp, store, _reg) = store_with_registry();
1891 let agents_root = tmp.path().join("agents");
1892
1893 let mut head = make_named_inst("inst-root", "Claude", &agents_root);
1894 head.started_at = 100;
1895 store.instance_create(&head).unwrap();
1896
1897 for (i, parent) in [("c1", "inst-root"), ("c2", "c1"), ("c3", "c2"), ("c4", "c3")] {
1899 let mut c = make_named_inst(i, "Claude", &agents_root);
1900 c.parent_instance_id = parent.to_string();
1901 c.started_at = 100 + (i.chars().last().unwrap().to_digit(10).unwrap() as i64) * 100;
1902 store.instance_create(&c).unwrap();
1903 }
1904
1905 let picker_rows = store.instance_list_named(10, None, None, true).unwrap();
1906 assert_eq!(
1907 picker_rows.len(),
1908 1,
1909 "five-row chain (1 head + 4 continuations) must collapse to one entry"
1910 );
1911 assert_eq!(picker_rows[0].id, "c4", "newest continuation wins");
1912 }
1913
1914 #[test]
1915 fn instance_get_by_name_reads_from_db_agents() {
1916 let (tmp, store, _reg) = store_with_registry();
1924 let agents_root = tmp.path().join("agents");
1925
1926 let mut head = make_named_inst("inst-1", "Maks", &agents_root);
1927 head.identity_id = "id-1".to_string();
1928 head.memory_id = "mem-1".to_string();
1929 head.github_context = "ghctx".to_string();
1930 store.instance_create(&head).unwrap();
1931
1932 let got = store
1933 .instance_get_by_name("Maks")
1934 .unwrap()
1935 .expect("should find by name");
1936 assert_eq!(got.id, "def-mirror");
1942 assert_eq!(got.definition_id, "def-mirror");
1943 assert_eq!(got.instance_name, "Maks");
1944 assert_eq!(got.identity_id, "id-1");
1945 assert_eq!(got.memory_id, "mem-1");
1946 assert_eq!(got.github_context, "ghctx");
1947 assert!(!got.display_hidden);
1948 assert_eq!(got.parent_instance_id, "");
1950 assert_eq!(got.block_id, "");
1951 assert_eq!(got.session_id, "");
1952 assert_eq!(got.status, "");
1953 assert_eq!(got.ended_at, 0);
1954 }
1955
1956 #[test]
1957 fn instance_get_by_name_returns_none_for_missing_name() {
1958 let (_tmp, store, _reg) = store_with_registry();
1959 assert!(store.instance_get_by_name("does-not-exist").unwrap().is_none());
1960 }
1961
1962 #[test]
1963 fn instance_get_by_name_excludes_hidden_rows() {
1964 let (tmp, store, _reg) = store_with_registry();
1968 let agents_root = tmp.path().join("agents");
1969 let mut inst = make_named_inst("inst-hidden", "Ghost", &agents_root);
1970 inst.display_hidden = true;
1971 store.instance_create(&inst).unwrap();
1972 assert!(store.instance_get_by_name("Ghost").unwrap().is_none());
1973 }
1974
1975 #[test]
1976 fn instance_get_by_name_empty_input_returns_none() {
1977 let (_tmp, store, _reg) = store_with_registry();
1978 assert!(store.instance_get_by_name("").unwrap().is_none());
1979 }
1980
1981 #[test]
1982 fn continuation_mirrors_bindings_into_db_agents_user_clone_path() {
1983 let (tmp, store, _reg) = store_with_registry();
1991 let agents_root = tmp.path().join("agents");
1992
1993 let mut head = make_named_inst("inst-head", "Maks", &agents_root);
1995 head.identity_id = "id-original".to_string();
1996 head.memory_id = "mem-original".to_string();
1997 store.instance_create(&head).unwrap();
1998
1999 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2001 cont.parent_instance_id = "inst-head".to_string();
2002 cont.identity_id = "id-NEW".to_string();
2003 cont.memory_id = "mem-NEW".to_string();
2004 cont.github_context = "ghctx-NEW".to_string();
2005 store.instance_create(&cont).unwrap();
2006
2007 let got = store.instance_get_by_name("Maks").unwrap().expect("found");
2009 assert_eq!(got.id, "def-mirror");
2010 assert_eq!(
2011 got.identity_id, "id-NEW",
2012 "continuation bindings must overwrite the head's"
2013 );
2014 assert_eq!(got.memory_id, "mem-NEW");
2015 assert_eq!(got.github_context, "ghctx-NEW");
2016 }
2017
2018 #[test]
2019 fn instance_get_by_name_collapses_continuation_chain_to_one_row() {
2020 let (tmp, store, _reg) = store_with_registry();
2027 let agents_root = tmp.path().join("agents");
2028
2029 let head = make_named_inst("inst-head", "Maks", &agents_root);
2030 store.instance_create(&head).unwrap();
2031
2032 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2033 cont.parent_instance_id = "inst-head".to_string();
2034 store.instance_create(&cont).unwrap();
2035
2036 let got = store.instance_get_by_name("Maks").unwrap().expect("found");
2037 assert_eq!(got.id, "def-mirror");
2041 assert_eq!(got.instance_name, "Maks");
2042 }
2043
2044 #[test]
2045 fn instance_get_active_for_block_phase_3b4_resolves_via_block_meta() {
2046 let (tmp, store, _reg) = store_with_registry();
2049 let agents_root = tmp.path().join("agents");
2050
2051 let mut inst = make_named_inst("inst-x", "Maks", &agents_root);
2053 inst.identity_id = "id-resolved".to_string();
2054 store.instance_create(&inst).unwrap();
2055
2056 let mut block = crate::backend::obj::Block {
2058 oid: "block-1".to_string(),
2059 parentoref: String::new(),
2060 version: 0,
2061 runtimeopts: None,
2062 stickers: None,
2063 meta: {
2064 let mut m = crate::backend::obj::MetaMapType::new();
2065 m.insert("view".to_string(), serde_json::json!("agent"));
2066 m.insert("agentId".to_string(), serde_json::json!("def-mirror"));
2067 m
2068 },
2069 subblockids: None,
2070 };
2071 store.insert(&mut block).unwrap();
2072
2073 let got = store
2074 .instance_get_active_for_block("block-1")
2075 .unwrap()
2076 .expect("expected the block's agent to resolve");
2077 assert_eq!(got.id, "def-mirror");
2078 assert_eq!(got.identity_id, "id-resolved");
2079 assert_eq!(got.block_id, "block-1"); assert_eq!(got.status, "");
2082 }
2083
2084 #[test]
2085 fn instance_get_active_for_block_phase_3b4_returns_none_when_block_missing() {
2086 let (_tmp, store, _reg) = store_with_registry();
2087 assert!(store
2088 .instance_get_active_for_block("no-such-block")
2089 .unwrap()
2090 .is_none());
2091 }
2092
2093 #[test]
2094 fn instance_get_active_for_block_phase_3b4_returns_none_when_no_agent_id() {
2095 let (_tmp, store, _reg) = store_with_registry();
2098 let mut block = crate::backend::obj::Block {
2099 oid: "block-naked".to_string(),
2100 parentoref: String::new(),
2101 version: 0,
2102 runtimeopts: None,
2103 stickers: None,
2104 meta: crate::backend::obj::MetaMapType::new(),
2105 subblockids: None,
2106 };
2107 store.insert(&mut block).unwrap();
2108 assert!(store
2109 .instance_get_active_for_block("block-naked")
2110 .unwrap()
2111 .is_none());
2112 }
2113
2114 #[test]
2115 fn instance_get_active_for_block_phase_3b4_resolves_template_launch_via_legacy_fallback() {
2116 let (tmp, store, _reg) = store_with_registry();
2122 let agents_root = tmp.path().join("agents");
2123
2124 let mut tpl = sample_agent("tpl-coder", "tpl-coder");
2125 tpl.is_seeded = 1;
2126 store.agent_def_insert(&mut tpl).unwrap();
2127 let mut inst = make_named_inst("inst-tpl", "FromTemplate", &agents_root);
2128 inst.definition_id = "tpl-coder".to_string();
2129 inst.identity_id = "id-template-launch".to_string();
2130 inst.block_id = "block-tpl".to_string();
2131 store.instance_create(&inst).unwrap();
2132
2133 let mut block = crate::backend::obj::Block {
2134 oid: "block-tpl".to_string(),
2135 parentoref: String::new(),
2136 version: 0,
2137 runtimeopts: None,
2138 stickers: None,
2139 meta: {
2140 let mut m = crate::backend::obj::MetaMapType::new();
2141 m.insert("agentId".to_string(), serde_json::json!("tpl-coder"));
2142 m
2143 },
2144 subblockids: None,
2145 };
2146 store.insert(&mut block).unwrap();
2147
2148 let got = store
2149 .instance_get_active_for_block("block-tpl")
2150 .unwrap()
2151 .expect("template-launched block resolves via legacy fallback");
2152 assert_eq!(got.identity_id, "id-template-launch");
2153 }
2154
2155 #[test]
2156 fn instance_get_active_for_block_phase_3b4_ignores_stale_agent_instance_id() {
2157 let (tmp, store, _reg) = store_with_registry();
2164 let agents_root = tmp.path().join("agents");
2165
2166 let mut prior_def = sample_agent("def-prior", "prior");
2168 store.agent_def_insert(&mut prior_def).unwrap();
2169 let mut prior_inst = make_named_inst("inst-prior", "Prior", &agents_root);
2170 prior_inst.definition_id = "def-prior".to_string();
2171 prior_inst.identity_id = "id-PRIOR-DO-NOT-USE".to_string();
2172 store.instance_create(&prior_inst).unwrap();
2173
2174 let mut current_inst = make_named_inst("inst-current", "Current", &agents_root);
2177 current_inst.identity_id = "id-current-correct".to_string();
2178 store.instance_create(¤t_inst).unwrap();
2179
2180 let mut block = crate::backend::obj::Block {
2183 oid: "block-reused".to_string(),
2184 parentoref: String::new(),
2185 version: 0,
2186 runtimeopts: None,
2187 stickers: None,
2188 meta: {
2189 let mut m = crate::backend::obj::MetaMapType::new();
2190 m.insert("agentId".to_string(), serde_json::json!("def-mirror"));
2191 m.insert(
2192 "agentInstanceId".to_string(),
2193 serde_json::json!("inst-prior"),
2194 );
2195 m
2196 },
2197 subblockids: None,
2198 };
2199 store.insert(&mut block).unwrap();
2200
2201 let got = store
2202 .instance_get_active_for_block("block-reused")
2203 .unwrap()
2204 .expect("must resolve to the current agent, not the stale instance id");
2205 assert_eq!(got.identity_id, "id-current-correct");
2206 assert_ne!(got.identity_id, "id-PRIOR-DO-NOT-USE");
2207 }
2208
2209 #[test]
2210 fn instance_get_active_for_block_phase_3b4_resolves_hidden_agents_for_existing_panes() {
2211 let (tmp, store, _reg) = store_with_registry();
2217 let agents_root = tmp.path().join("agents");
2218 let mut inst = make_named_inst("inst-hide", "ToBeHidden", &agents_root);
2219 inst.identity_id = "id-still-valid".to_string();
2220 inst.display_hidden = true; store.instance_create(&inst).unwrap();
2222
2223 let mut block = crate::backend::obj::Block {
2224 oid: "block-hidden-agent".to_string(),
2225 parentoref: String::new(),
2226 version: 0,
2227 runtimeopts: None,
2228 stickers: None,
2229 meta: {
2230 let mut m = crate::backend::obj::MetaMapType::new();
2231 m.insert("agentId".to_string(), serde_json::json!("def-mirror"));
2232 m
2233 },
2234 subblockids: None,
2235 };
2236 store.insert(&mut block).unwrap();
2237
2238 let got = store
2239 .instance_get_active_for_block("block-hidden-agent")
2240 .unwrap()
2241 .expect("hidden agent must still resolve for existing pane");
2242 assert_eq!(got.identity_id, "id-still-valid");
2243 }
2244
2245 #[test]
2246 fn instance_get_active_for_block_phase_3b4_legacy_template_block_falls_back_to_instances() {
2247 let (tmp, store, _reg) = store_with_registry();
2254 let agents_root = tmp.path().join("agents");
2255
2256 let mut tpl = sample_agent("tpl-legacy", "tpl-legacy");
2259 tpl.is_seeded = 1;
2260 store.agent_def_insert(&mut tpl).unwrap();
2261 let mut inst = make_named_inst("inst-legacy-tpl", "OldTplLaunch", &agents_root);
2262 inst.definition_id = "tpl-legacy".to_string();
2263 inst.identity_id = "id-legacy-tpl".to_string();
2264 inst.block_id = "block-old-tpl".to_string();
2265 store.instance_create(&inst).unwrap();
2266
2267 let mut block = crate::backend::obj::Block {
2269 oid: "block-old-tpl".to_string(),
2270 parentoref: String::new(),
2271 version: 0,
2272 runtimeopts: None,
2273 stickers: None,
2274 meta: {
2275 let mut m = crate::backend::obj::MetaMapType::new();
2276 m.insert("agentId".to_string(), serde_json::json!("tpl-legacy"));
2277 m
2278 },
2279 subblockids: None,
2280 };
2281 store.insert(&mut block).unwrap();
2282
2283 let got = store
2284 .instance_get_active_for_block("block-old-tpl")
2285 .unwrap()
2286 .expect("legacy template block must resolve via db_agent_instances fallback");
2287 assert_eq!(got.identity_id, "id-legacy-tpl");
2288 }
2289
2290 #[test]
2291 fn instance_get_active_for_block_phase_3b4_honors_legacy_agent_id_meta_key() {
2292 let (tmp, store, _reg) = store_with_registry();
2295 let agents_root = tmp.path().join("agents");
2296 let mut inst = make_named_inst("inst-legacy", "Maks", &agents_root);
2297 store.instance_create(&inst).unwrap();
2298
2299 let mut block = crate::backend::obj::Block {
2300 oid: "block-legacy".to_string(),
2301 parentoref: String::new(),
2302 version: 0,
2303 runtimeopts: None,
2304 stickers: None,
2305 meta: {
2306 let mut m = crate::backend::obj::MetaMapType::new();
2307 m.insert("agent:id".to_string(), serde_json::json!("def-mirror"));
2308 m
2309 },
2310 subblockids: None,
2311 };
2312 store.insert(&mut block).unwrap();
2313
2314 let got = store
2315 .instance_get_active_for_block("block-legacy")
2316 .unwrap()
2317 .expect("legacy agent:id meta key must resolve");
2318 assert_eq!(got.id, "def-mirror");
2319 }
2320
2321 #[test]
2322 fn instance_list_phase_3b3a_reads_from_db_agents_no_status_filter() {
2323 let (tmp, store, _reg) = store_with_registry();
2328 let agents_root = tmp.path().join("agents");
2329
2330 let a = make_named_inst("inst-a", "Maks", &agents_root);
2333 store.instance_create(&a).unwrap();
2334 let b = {
2335 let mut def2 = sample_agent("def-mirror-2", "mirror-2");
2337 store.agent_def_insert(&mut def2).unwrap();
2338 let mut x = make_named_inst("inst-b", "DSad", &agents_root);
2339 x.definition_id = "def-mirror-2".to_string();
2340 x
2341 };
2342 store.instance_create(&b).unwrap();
2343 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2345 cont.parent_instance_id = "inst-a".to_string();
2346 store.instance_create(&cont).unwrap();
2347
2348 let rows = store.instance_list(None, None).unwrap();
2349 let names: Vec<&str> = rows.iter().map(|r| r.instance_name.as_str()).collect();
2350 assert_eq!(rows.len(), 2);
2351 assert!(names.contains(&"Maks"));
2352 assert!(names.contains(&"DSad"));
2353 for row in &rows {
2355 assert_eq!(row.status, "");
2356 assert_eq!(row.block_id, "");
2357 assert_eq!(row.session_id, "");
2358 }
2359 }
2360
2361 #[test]
2362 fn instance_list_phase_3b3a_filters_by_definition_lineage() {
2363 let (tmp, store, _reg) = store_with_registry();
2368 let agents_root = tmp.path().join("agents");
2369
2370 let a = make_named_inst("inst-a", "Maks", &agents_root);
2371 store.instance_create(&a).unwrap();
2372 let mut def2 = sample_agent("def-other", "other");
2373 store.agent_def_insert(&mut def2).unwrap();
2374 let mut b = make_named_inst("inst-b", "Other", &agents_root);
2375 b.definition_id = "def-other".to_string();
2376 store.instance_create(&b).unwrap();
2377
2378 let filtered = store.instance_list(Some("def-mirror"), None).unwrap();
2380 assert_eq!(filtered.len(), 1);
2381 assert_eq!(filtered[0].instance_name, "Maks");
2382
2383 let filtered = store.instance_list(Some("def-other"), None).unwrap();
2384 assert_eq!(filtered.len(), 1);
2385 assert_eq!(filtered[0].instance_name, "Other");
2386 }
2387
2388 #[test]
2389 fn instance_list_phase_3b3a_definition_id_filter_matches_agent_id_only() {
2390 let (tmp, store, _reg) = store_with_registry();
2398 let agents_root = tmp.path().join("agents");
2399
2400 let mut tpl = sample_agent("tpl-coder", "tpl-coder");
2403 tpl.is_seeded = 1;
2404 store.agent_def_insert(&mut tpl).unwrap();
2405 let mut clone = sample_agent("clone-of-tpl", "clone-of-tpl");
2406 clone.parent_id = "tpl-coder".to_string();
2407 store.agent_def_insert(&mut clone).unwrap();
2408 let mut tpl_inst = make_named_inst("inst-direct", "DirectTpl", &agents_root);
2409 tpl_inst.definition_id = "tpl-coder".to_string();
2410 store.instance_create(&tpl_inst).unwrap();
2411
2412 let by_tpl = store.instance_list(Some("tpl-coder"), None).unwrap();
2416 assert!(by_tpl.is_empty(), "template id should not surface any agent");
2417
2418 let by_clone = store.instance_list(Some("clone-of-tpl"), None).unwrap();
2420 assert_eq!(by_clone.len(), 1);
2421 assert_eq!(by_clone[0].id, "clone-of-tpl");
2422
2423 let by_inst = store.instance_list(Some("inst-direct"), None).unwrap();
2425 assert_eq!(by_inst.len(), 1);
2426 assert_eq!(by_inst[0].id, "inst-direct");
2427 assert_eq!(by_inst[0].instance_name, "DirectTpl");
2428
2429 let none = store.instance_list(Some("does-not-exist"), None).unwrap();
2431 assert!(none.is_empty());
2432 }
2433
2434 #[test]
2435 fn instance_list_phase_3b3a_orders_by_updated_at_for_continuations() {
2436 let (tmp, store, _reg) = store_with_registry();
2443 let agents_root = tmp.path().join("agents");
2444
2445 let older_head = make_named_inst("inst-old-head", "Older", &agents_root);
2447 store.instance_create(&older_head).unwrap();
2448
2449 let mut def2 = sample_agent("def-newer", "newer");
2451 store.agent_def_insert(&mut def2).unwrap();
2452 let mut newer = make_named_inst("inst-newer", "Newer", &agents_root);
2453 newer.definition_id = "def-newer".to_string();
2454 store.instance_create(&newer).unwrap();
2455
2456 let mut cont = make_named_inst("inst-old-cont", "Older", &agents_root);
2459 cont.parent_instance_id = "inst-old-head".to_string();
2460 store.instance_create(&cont).unwrap();
2461
2462 let rows = store.instance_list(None, None).unwrap();
2463 assert_eq!(rows.len(), 2);
2464 assert_eq!(rows[0].instance_name, "Older");
2466 assert_eq!(rows[1].instance_name, "Newer");
2467 }
2468
2469 #[test]
2470 fn instance_list_phase_3b3a_definition_id_projects_row_id_for_user_clones_with_template_lineage() {
2471 let (tmp, store, _reg) = store_with_registry();
2477 let agents_root = tmp.path().join("agents");
2478
2479 let mut clone = sample_agent("clone-from-tpl", "clone-from-tpl");
2483 clone.parent_id = "tpl-some-template".to_string();
2484 store.agent_def_insert(&mut clone).unwrap();
2485 let mut launch = make_named_inst("inst-1", "ClonedAgent", &agents_root);
2486 launch.definition_id = "clone-from-tpl".to_string();
2487 store.instance_create(&launch).unwrap();
2488
2489 let rows = store.instance_list(None, None).unwrap();
2490 let row = rows.iter().find(|r| r.instance_name == "ClonedAgent").unwrap();
2491 assert_eq!(row.definition_id, "clone-from-tpl");
2494 assert_eq!(row.id, "clone-from-tpl");
2495 }
2496
2497 #[test]
2498 fn instance_list_phase_3b3a_excludes_hidden() {
2499 let (tmp, store, _reg) = store_with_registry();
2500 let agents_root = tmp.path().join("agents");
2501 let mut inst = make_named_inst("inst-h", "Ghost", &agents_root);
2502 inst.display_hidden = true;
2503 store.instance_create(&inst).unwrap();
2504 let rows = store.instance_list(None, None).unwrap();
2505 assert!(rows.is_empty(), "hidden rows must not surface");
2506 }
2507
2508 #[test]
2509 fn instance_list_phase_3b3b_status_filter_falls_back_to_legacy() {
2510 let (tmp, store, _reg) = store_with_registry();
2516 let agents_root = tmp.path().join("agents");
2517 let mut a = make_named_inst("inst-a", "Maks", &agents_root);
2518 a.status = "running".to_string();
2519 store.instance_create(&a).unwrap();
2520
2521 let running = store.instance_list(None, Some("running")).unwrap();
2522 assert_eq!(running.len(), 1);
2523 assert_eq!(running[0].status, "running");
2524 assert_eq!(running[0].id, "inst-a"); let stopped = store.instance_list(None, Some("stopped")).unwrap();
2527 assert!(stopped.is_empty());
2528 }
2529
2530 #[test]
2531 fn instance_list_named_picker_mode_keeps_distinct_agents_separate() {
2532 let (tmp, store, _reg) = store_with_registry();
2535 let agents_root = tmp.path().join("agents");
2536
2537 let mut head_a = make_named_inst("a-head", "AgentA", &agents_root);
2538 head_a.started_at = 100;
2539 store.instance_create(&head_a).unwrap();
2540 let mut cont_a = make_named_inst("a-cont", "AgentA", &agents_root);
2541 cont_a.parent_instance_id = "a-head".to_string();
2542 cont_a.started_at = 150;
2543 store.instance_create(&cont_a).unwrap();
2544
2545 let mut head_b = make_named_inst("b-head", "AgentB", &agents_root);
2546 head_b.started_at = 200;
2547 store.instance_create(&head_b).unwrap();
2548
2549 let rows = store.instance_list_named(10, None, None, true).unwrap();
2550 assert_eq!(rows.len(), 2);
2551 assert_eq!(rows[0].id, "b-head");
2553 assert_eq!(rows[1].id, "a-cont");
2554 }
2555
2556 #[test]
2557 fn instance_list_named_picker_mode_orphan_continuation_surfaces() {
2558 let (tmp, store, _reg) = store_with_registry();
2571 let agents_root = tmp.path().join("agents");
2572
2573 let mut head = make_named_inst("inst-deleted-head", "Claude", &agents_root);
2575 head.started_at = 100;
2576 store.instance_create(&head).unwrap();
2577
2578 let mut cont1 = make_named_inst("inst-orphan-cont1", "Claude", &agents_root);
2579 cont1.parent_instance_id = "inst-deleted-head".to_string();
2580 cont1.started_at = 200;
2581 store.instance_create(&cont1).unwrap();
2582
2583 let mut cont2 = make_named_inst("inst-orphan-cont2", "Claude", &agents_root);
2584 cont2.parent_instance_id = "inst-orphan-cont1".to_string();
2585 cont2.started_at = 300;
2586 store.instance_create(&cont2).unwrap();
2587
2588 store.instance_delete("inst-deleted-head").unwrap();
2592
2593 let rows = store.instance_list_named(10, None, None, true).unwrap();
2594 assert_eq!(
2598 rows.len(),
2599 1,
2600 "orphan chain must remain reachable after head deletion"
2601 );
2602 assert_eq!(rows[0].id, "inst-orphan-cont2");
2603 }
2604
2605 #[test]
2606 fn instance_list_named_picker_mode_forget_suppresses_whole_chain() {
2607 let (tmp, store, _reg) = store_with_registry();
2619 let agents_root = tmp.path().join("agents");
2620
2621 let mut head = make_named_inst("inst-head", "Claude", &agents_root);
2622 head.started_at = 100;
2623 store.instance_create(&head).unwrap();
2624
2625 let mut cont1 = make_named_inst("inst-cont1", "Claude", &agents_root);
2626 cont1.parent_instance_id = "inst-head".to_string();
2627 cont1.started_at = 200;
2628 store.instance_create(&cont1).unwrap();
2629
2630 let mut cont2 = make_named_inst("inst-cont2", "Claude", &agents_root);
2631 cont2.parent_instance_id = "inst-cont1".to_string();
2632 cont2.started_at = 300;
2633 store.instance_create(&cont2).unwrap();
2634
2635 let before = store.instance_list_named(10, None, None, true).unwrap();
2637 assert_eq!(before.len(), 1);
2638 assert_eq!(before[0].id, "inst-cont2");
2639
2640 store.instance_set_hidden("inst-cont2", true).unwrap();
2642
2643 let after = store.instance_list_named(10, None, None, true).unwrap();
2646 assert_eq!(
2647 after.len(),
2648 0,
2649 "hiding the surfaced row must suppress the entire chain — \
2650 older visible siblings must NOT be promoted to rn=1"
2651 );
2652 }
2653
2654 #[test]
2655 fn instance_list_named_picker_mode_skips_hidden_chains() {
2656 let (tmp, store, _reg) = store_with_registry();
2660 let agents_root = tmp.path().join("agents");
2661
2662 let mut head = make_named_inst("v-head", "Visible", &agents_root);
2664 head.started_at = 100;
2665 store.instance_create(&head).unwrap();
2666 let mut cont = make_named_inst("v-cont", "Visible", &agents_root);
2667 cont.parent_instance_id = "v-head".to_string();
2668 cont.started_at = 200;
2669 store.instance_create(&cont).unwrap();
2670
2671 let mut hidden_head = make_named_inst("h-head", "Hidden", &agents_root);
2673 hidden_head.started_at = 50;
2674 hidden_head.display_hidden = true;
2675 store.instance_create(&hidden_head).unwrap();
2676 let mut hidden_cont = make_named_inst("h-cont", "Hidden", &agents_root);
2677 hidden_cont.parent_instance_id = "h-head".to_string();
2678 hidden_cont.started_at = 60;
2679 hidden_cont.display_hidden = true;
2680 store.instance_create(&hidden_cont).unwrap();
2681
2682 let rows = store.instance_list_named(10, None, None, true).unwrap();
2683 assert_eq!(rows.len(), 1);
2684 assert_eq!(rows[0].id, "v-cont");
2685 }
2686
2687 #[test]
2688 fn instance_list_named_picker_mode_identity_filter_in_ranking() {
2689 let (tmp, store, _reg) = store_with_registry();
2697 let agents_root = tmp.path().join("agents");
2698
2699 let mut head = make_named_inst("inst-head", "Claude", &agents_root);
2702 head.identity_id = "identity-a".to_string();
2703 head.started_at = 100;
2704 store.instance_create(&head).unwrap();
2705
2706 let mut cont_b = make_named_inst("inst-cont-b", "Claude", &agents_root);
2707 cont_b.parent_instance_id = "inst-head".to_string();
2708 cont_b.identity_id = "identity-b".to_string();
2709 cont_b.started_at = 200;
2710 store.instance_create(&cont_b).unwrap();
2711
2712 let mut cont_a2 = make_named_inst("inst-cont-a2", "Claude", &agents_root);
2713 cont_a2.parent_instance_id = "inst-cont-b".to_string();
2714 cont_a2.identity_id = "identity-a".to_string();
2715 cont_a2.started_at = 300;
2716 store.instance_create(&cont_a2).unwrap();
2717
2718 let rows_a = store
2720 .instance_list_named(10, None, Some("identity-a"), true)
2721 .unwrap();
2722 assert_eq!(rows_a.len(), 1);
2723 assert_eq!(rows_a[0].id, "inst-cont-a2");
2724
2725 let rows_b = store
2727 .instance_list_named(10, None, Some("identity-b"), true)
2728 .unwrap();
2729 assert_eq!(rows_b.len(), 1);
2730 assert_eq!(rows_b[0].id, "inst-cont-b");
2731
2732 let rows_all = store.instance_list_named(10, None, None, true).unwrap();
2734 assert_eq!(rows_all.len(), 1);
2735 assert_eq!(rows_all[0].id, "inst-cont-a2");
2736 }
2737
2738 #[test]
2739 fn instance_list_named_picker_mode_identity_filter_recovers_older_match() {
2740 let (tmp, store, _reg) = store_with_registry();
2748 let agents_root = tmp.path().join("agents");
2749
2750 let mut head = make_named_inst("inst-head", "Claude", &agents_root);
2751 head.identity_id = "identity-a".to_string();
2752 head.started_at = 100;
2753 store.instance_create(&head).unwrap();
2754
2755 let mut cont_newer_b = make_named_inst("inst-cont-newer-b", "Claude", &agents_root);
2756 cont_newer_b.parent_instance_id = "inst-head".to_string();
2757 cont_newer_b.identity_id = "identity-b".to_string();
2758 cont_newer_b.started_at = 200;
2759 store.instance_create(&cont_newer_b).unwrap();
2760
2761 let rows = store
2762 .instance_list_named(10, None, Some("identity-a"), true)
2763 .unwrap();
2764 assert_eq!(
2765 rows.len(),
2766 1,
2767 "older identity-a row must survive even though the newest row uses identity-b"
2768 );
2769 assert_eq!(rows[0].id, "inst-head");
2770 }
2771
2772 #[test]
2773 fn instance_list_named_dropdown_mode_excludes_continuations() {
2774 let (tmp, store, _reg) = store_with_registry();
2783 let agents_root = tmp.path().join("agents");
2784
2785 let mut head = make_named_inst("inst-head", "Maks", &agents_root);
2786 head.started_at = 100;
2787 store.instance_create(&head).unwrap();
2788 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
2789 cont.parent_instance_id = "inst-head".to_string();
2790 cont.started_at = 200;
2791 store.instance_create(&cont).unwrap();
2792
2793 let dropdown_rows = store.instance_list_named(10, None, None, false).unwrap();
2794 assert_eq!(
2795 dropdown_rows.len(),
2796 1,
2797 "legacy dropdown mode must drop continuation rows"
2798 );
2799 assert_eq!(dropdown_rows[0].id, "inst-head");
2800
2801 let scoped_dropdown = store
2803 .instance_list_named(10, Some("def-mirror"), None, false)
2804 .unwrap();
2805 assert_eq!(scoped_dropdown.len(), 1);
2806 assert_eq!(scoped_dropdown[0].id, "inst-head");
2807 }
2808
2809 #[test]
2810 fn instance_update_does_not_resurrect_hidden_row() {
2811 let (tmp, store, reg) = store_with_registry();
2814 let agents_root = tmp.path().join("agents");
2815 let inst = make_named_inst("inst-resurrect", "demoR", &agents_root);
2816 store.instance_create(&inst).unwrap();
2817 assert_eq!(reg.list_active().unwrap().len(), 1);
2818
2819 store.instance_set_hidden("inst-resurrect", true).unwrap();
2820 assert!(reg.list_active().unwrap().is_empty(),
2821 "after set_hidden(true), record must be in retired/");
2822 assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
2823
2824 let mut updated = inst.clone();
2827 updated.status = "stopped".to_string();
2828 updated.ended_at = 9999;
2829 store.instance_update(&updated).unwrap();
2830
2831 assert!(reg.list_active().unwrap().is_empty(),
2832 "instance_update on a hidden row must NOT resurrect it");
2833 assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
2834 }
2835
2836 #[test]
2837 fn instance_create_with_display_hidden_writes_retired() {
2838 let (tmp, store, reg) = store_with_registry();
2839 let agents_root = tmp.path().join("agents");
2840 let mut inst = make_named_inst("inst-bornhidden", "demoH", &agents_root);
2841 inst.display_hidden = true;
2842 store.instance_create(&inst).unwrap();
2843 assert!(reg.list_active().unwrap().is_empty());
2844 assert!(reg.root().join("retired").join("inst-bornhidden.json").exists());
2845 }
2846
2847 #[test]
2848 fn instance_update_toggling_hidden_off_unretires() {
2849 let (tmp, store, reg) = store_with_registry();
2852 let agents_root = tmp.path().join("agents");
2853 let inst = make_named_inst("inst-toggle", "demoT", &agents_root);
2854 store.instance_create(&inst).unwrap();
2855 store.instance_set_hidden("inst-toggle", true).unwrap();
2856 store.instance_set_hidden("inst-toggle", false).unwrap();
2857 assert_eq!(reg.list_active().unwrap().len(), 1);
2858
2859 let mut updated = inst.clone();
2861 updated.status = "paused".to_string();
2862 store.instance_update(&updated).unwrap();
2863 assert_eq!(reg.list_active().unwrap().len(), 1);
2864 assert!(!reg.root().join("retired").join("inst-toggle.json").exists(),
2865 "no orphan retired file alongside active");
2866 }
2867
2868 #[test]
2869 fn instance_set_hidden_acts_on_registry_only_row() {
2870 let (tmp, store, reg) = store_with_registry();
2875 let agents_root = tmp.path().join("agents");
2877 std::fs::create_dir_all(&agents_root).unwrap();
2878 let wd = agents_root.join("cross-ver");
2879 std::fs::create_dir_all(&wd).unwrap();
2880 reg.upsert(&crate::registry::NamedAgentRecord {
2881 schema_version: crate::registry::MAX_SUPPORTED_SCHEMA,
2882 data: crate::registry::NamedAgentRecordV1 {
2883 instance_id: "inst-crossver".to_string(),
2884 instance_name: "crossver".to_string(),
2885 definition_id: "claude-code".to_string(),
2886 identity_id: None,
2887 memory_id: None,
2888 session_id: None,
2889 working_dir: "cross-ver".to_string(),
2890 source_agents_base: None,
2891 created_at_ms: 100,
2892 last_launched_at_ms: 100,
2893 created_by_version: "0.33.821".to_string(),
2894 last_launched_by_version: "0.33.821".to_string(),
2895 },
2896 })
2897 .unwrap();
2898 assert_eq!(reg.list_active().unwrap().len(), 1);
2899 assert!(store.instance_get("inst-crossver").unwrap().is_none(),
2900 "precondition: no SQLite row for cross-version agent");
2901
2902 let result = store.instance_set_hidden("inst-crossver", true).unwrap();
2903 assert!(result, "must report success even when only registry was affected");
2904 assert!(reg.list_active().unwrap().is_empty(),
2905 "registry record must be retired");
2906 assert!(reg.root().join("retired").join("inst-crossver.json").exists());
2907 }
2908
2909 #[test]
2910 fn agent_def_delete_cascade_removes_registry_files() {
2911 let (tmp, store, reg) = store_with_registry();
2912 let agents_root = tmp.path().join("agents");
2913 let inst_a = make_named_inst("inst-cascade-a", "demoA", &agents_root);
2914 let inst_b = make_named_inst("inst-cascade-b", "demoB", &agents_root);
2915 store.instance_create(&inst_a).unwrap();
2916 store.instance_create(&inst_b).unwrap();
2917 assert_eq!(reg.list_active().unwrap().len(), 2);
2918
2919 store.agent_def_delete("def-mirror").unwrap();
2922 assert!(reg.list_active().unwrap().is_empty(),
2923 "agent_def_delete cascade must remove all child instance registry files");
2924 }
2925
2926 fn count_agents(store: &Store, where_clause: &str) -> i64 {
2931 let conn = store.conn.lock().unwrap();
2932 let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
2933 conn.query_row(&sql, [], |row| row.get(0)).unwrap()
2934 }
2935
2936 fn read_agent_field(store: &Store, id: &str, field: &str) -> Option<String> {
2937 let conn = store.conn.lock().unwrap();
2938 let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
2939 let mut stmt = conn.prepare(&sql).unwrap();
2940 let r = stmt.query_row(params![id], |row| row.get::<_, String>(0));
2941 match r {
2942 Ok(s) => Some(s),
2943 Err(rusqlite::Error::QueryReturnedNoRows) => None,
2944 Err(e) => panic!("query failed: {e}"),
2945 }
2946 }
2947
2948 fn read_agent_int(store: &Store, id: &str, field: &str) -> Option<i64> {
2949 let conn = store.conn.lock().unwrap();
2950 let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
2951 let mut stmt = conn.prepare(&sql).unwrap();
2952 let r = stmt.query_row(params![id], |row| row.get::<_, i64>(0));
2953 match r {
2954 Ok(v) => Some(v),
2955 Err(rusqlite::Error::QueryReturnedNoRows) => None,
2956 Err(e) => panic!("query failed: {e}"),
2957 }
2958 }
2959
2960 #[test]
2961 fn dual_write_agent_def_insert_seeded_creates_template_row() {
2962 let store = make_store();
2963 let mut def = AgentDefinition {
2964 id: "tpl-dw-seeded".to_string(),
2965 slug: String::new(),
2966 name: "Coder".to_string(),
2967 icon: "✦".to_string(),
2968 provider: "claude".to_string(),
2969 description: "desc".to_string(),
2970 working_directory: String::new(),
2971 shell: "bash".to_string(),
2972 provider_flags: String::new(),
2973 auto_start: 0,
2974 restart_on_crash: 0,
2975 idle_timeout_minutes: 0,
2976 created_at: 1000,
2977 agent_type: "standalone".to_string(),
2978 environment: String::new(),
2979 agent_bus_id: String::new(),
2980 is_seeded: 1,
2981 accounts: String::new(),
2982 parent_id: String::new(),
2983 branch_label: String::new(),
2984 updated_at: 1000,
2985 user_hidden: 0,
2986 container_image: String::new(),
2987 container_volumes: "[]".to_string(),
2988 container_name: String::new(),
2989 };
2990 store.agent_def_insert(&mut def).unwrap();
2991 assert_eq!(read_agent_int(&store, "tpl-dw-seeded", "is_template"), Some(1));
2993 assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "parent_template_id"), Some(String::new()));
2994 assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "name"), Some("Coder".to_string()));
2995 assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "provider"), Some("claude".to_string()));
2996 }
2997
2998 #[test]
2999 fn dual_write_agent_def_insert_user_clone_carries_parent() {
3000 let store = make_store();
3001 let mut tpl = AgentDefinition {
3003 id: "tpl-parent".to_string(),
3004 slug: String::new(),
3005 name: "Coder".to_string(),
3006 icon: "✦".to_string(),
3007 provider: "claude".to_string(),
3008 description: String::new(),
3009 working_directory: String::new(),
3010 shell: "bash".to_string(),
3011 provider_flags: String::new(),
3012 auto_start: 0,
3013 restart_on_crash: 0,
3014 idle_timeout_minutes: 0,
3015 created_at: 1000,
3016 agent_type: "standalone".to_string(),
3017 environment: String::new(),
3018 agent_bus_id: String::new(),
3019 is_seeded: 1,
3020 accounts: String::new(),
3021 parent_id: String::new(),
3022 branch_label: String::new(),
3023 updated_at: 1000,
3024 user_hidden: 0,
3025 container_image: String::new(),
3026 container_volumes: "[]".to_string(),
3027 container_name: String::new(),
3028 };
3029 store.agent_def_insert(&mut tpl).unwrap();
3030 let mut user_def = tpl.clone();
3032 user_def.id = "def-user".to_string();
3033 user_def.slug = String::new();
3034 user_def.is_seeded = 0;
3035 user_def.parent_id = "tpl-parent".to_string();
3036 store.agent_def_insert(&mut user_def).unwrap();
3037 assert_eq!(read_agent_int(&store, "def-user", "is_template"), Some(0));
3038 assert_eq!(
3039 read_agent_field(&store, "def-user", "parent_template_id"),
3040 Some("tpl-parent".to_string())
3041 );
3042 }
3043
3044 #[test]
3045 fn dual_write_agent_def_update_refreshes_name_in_db_agents() {
3046 let store = make_store();
3047 let mut def = AgentDefinition {
3048 id: "tpl-update".to_string(),
3049 slug: String::new(),
3050 name: "Old Name".to_string(),
3051 icon: "✦".to_string(),
3052 provider: "claude".to_string(),
3053 description: String::new(),
3054 working_directory: String::new(),
3055 shell: "bash".to_string(),
3056 provider_flags: String::new(),
3057 auto_start: 0,
3058 restart_on_crash: 0,
3059 idle_timeout_minutes: 0,
3060 created_at: 1000,
3061 agent_type: "standalone".to_string(),
3062 environment: String::new(),
3063 agent_bus_id: String::new(),
3064 is_seeded: 1,
3065 accounts: String::new(),
3066 parent_id: String::new(),
3067 branch_label: String::new(),
3068 updated_at: 1000,
3069 user_hidden: 0,
3070 container_image: String::new(),
3071 container_volumes: "[]".to_string(),
3072 container_name: String::new(),
3073 };
3074 store.agent_def_insert(&mut def).unwrap();
3075 def.name = "New Name".to_string();
3076 assert!(store.agent_def_update(&mut def).unwrap());
3077 assert_eq!(
3078 read_agent_field(&store, "tpl-update", "name"),
3079 Some("New Name".to_string())
3080 );
3081 }
3082
3083 #[test]
3084 fn dual_write_agent_def_delete_removes_db_agents_row() {
3085 let store = make_store();
3086 let mut def = AgentDefinition {
3087 id: "tpl-del".to_string(),
3088 slug: String::new(),
3089 name: "Goner".to_string(),
3090 icon: "✦".to_string(),
3091 provider: "claude".to_string(),
3092 description: String::new(),
3093 working_directory: String::new(),
3094 shell: "bash".to_string(),
3095 provider_flags: String::new(),
3096 auto_start: 0,
3097 restart_on_crash: 0,
3098 idle_timeout_minutes: 0,
3099 created_at: 1000,
3100 agent_type: "standalone".to_string(),
3101 environment: String::new(),
3102 agent_bus_id: String::new(),
3103 is_seeded: 1,
3104 accounts: String::new(),
3105 parent_id: String::new(),
3106 branch_label: String::new(),
3107 updated_at: 1000,
3108 user_hidden: 0,
3109 container_image: String::new(),
3110 container_volumes: "[]".to_string(),
3111 container_name: String::new(),
3112 };
3113 store.agent_def_insert(&mut def).unwrap();
3114 assert_eq!(count_agents(&store, "id = 'tpl-del'"), 1);
3115 store.agent_def_delete("tpl-del").unwrap();
3116 assert_eq!(count_agents(&store, "id = 'tpl-del'"), 0);
3117 }
3118
3119 #[test]
3120 fn dual_write_instance_create_inserts_user_clone_row() {
3121 let store = make_store();
3122 let mut tpl = AgentDefinition {
3124 id: "tpl-for-inst".to_string(),
3125 slug: String::new(),
3126 name: "Coder".to_string(),
3127 icon: "✦".to_string(),
3128 provider: "claude".to_string(),
3129 description: "desc".to_string(),
3130 working_directory: "/wd/tpl-cfg".to_string(),
3131 shell: "bash".to_string(),
3132 provider_flags: String::new(),
3133 auto_start: 0,
3134 restart_on_crash: 0,
3135 idle_timeout_minutes: 0,
3136 created_at: 1000,
3137 agent_type: "standalone".to_string(),
3138 environment: String::new(),
3139 agent_bus_id: String::new(),
3140 is_seeded: 1,
3141 accounts: String::new(),
3142 parent_id: String::new(),
3143 branch_label: String::new(),
3144 updated_at: 1000,
3145 user_hidden: 0,
3146 container_image: String::new(),
3147 container_volumes: "[]".to_string(),
3148 container_name: String::new(),
3149 };
3150 store.agent_def_insert(&mut tpl).unwrap();
3151
3152 let inst = AgentInstance {
3153 id: "inst-dw".to_string(),
3154 definition_id: "tpl-for-inst".to_string(),
3155 parent_instance_id: String::new(),
3156 block_id: "blk-head".to_string(),
3157 session_id: String::new(),
3158 status: "running".to_string(),
3159 github_context: String::new(),
3160 started_at: 2000,
3161 ended_at: 0,
3162 created_at: 2000,
3163 identity_id: "id-1".to_string(),
3164 memory_id: "mem-1".to_string(),
3165 instance_name: "Maks".to_string(),
3166 working_directory: "/wd/maks".to_string(),
3167 display_hidden: false,
3168 };
3169 store.instance_create(&inst).unwrap();
3170 assert_eq!(read_agent_int(&store, "inst-dw", "is_template"), Some(0));
3173 assert_eq!(
3174 read_agent_field(&store, "inst-dw", "parent_template_id"),
3175 Some("tpl-for-inst".to_string())
3176 );
3177 assert_eq!(read_agent_field(&store, "inst-dw", "name"), Some("Maks".to_string()));
3178 assert_eq!(read_agent_field(&store, "inst-dw", "identity_id"), Some("id-1".to_string()));
3179 assert_eq!(read_agent_field(&store, "inst-dw", "memory_id"), Some("mem-1".to_string()));
3180 assert_eq!(read_agent_field(&store, "inst-dw", "working_directory"), Some("/wd/tpl-cfg".to_string()));
3184 assert_eq!(read_agent_field(&store, "inst-dw", "last_block_id"), Some("blk-head".to_string()));
3188 let cont = AgentInstance {
3190 id: "inst-cont".to_string(),
3191 parent_instance_id: "inst-dw".to_string(),
3192 ..inst.clone()
3193 };
3194 store.instance_create(&cont).unwrap();
3195 assert_eq!(count_agents(&store, "id = 'inst-cont'"), 0);
3196 }
3197
3198 #[test]
3205 fn dual_write_instance_create_folds_into_user_clone_def() {
3206 let store = make_store();
3207 let mut tpl = AgentDefinition {
3209 id: "tpl-folded".to_string(),
3210 slug: String::new(),
3211 name: "Coder".to_string(),
3212 icon: "✦".to_string(),
3213 provider: "claude".to_string(),
3214 description: "desc".to_string(),
3215 working_directory: String::new(),
3216 shell: "bash".to_string(),
3217 provider_flags: String::new(),
3218 auto_start: 0,
3219 restart_on_crash: 0,
3220 idle_timeout_minutes: 0,
3221 created_at: 1000,
3222 agent_type: "standalone".to_string(),
3223 environment: String::new(),
3224 agent_bus_id: String::new(),
3225 is_seeded: 1,
3226 accounts: String::new(),
3227 parent_id: String::new(),
3228 branch_label: String::new(),
3229 updated_at: 1000,
3230 user_hidden: 0,
3231 container_image: String::new(),
3232 container_volumes: "[]".to_string(),
3233 container_name: String::new(),
3234 };
3235 store.agent_def_insert(&mut tpl).unwrap();
3236
3237 let mut clone = AgentDefinition {
3239 id: "user-clone-1".to_string(),
3240 slug: String::new(),
3241 name: "Maks".to_string(),
3242 is_seeded: 0,
3243 parent_id: "tpl-folded".to_string(),
3244 working_directory: "/wd/clone-cfg".to_string(),
3245 created_at: 1500,
3246 updated_at: 1500,
3247 ..tpl.clone()
3248 };
3249 store.agent_def_insert(&mut clone).unwrap();
3250 assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some(String::new()));
3252 assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some(String::new()));
3253
3254 let inst = AgentInstance {
3259 id: "inst-fold-1".to_string(),
3260 definition_id: "user-clone-1".to_string(),
3261 parent_instance_id: String::new(),
3262 block_id: "blk-fold".to_string(),
3263 session_id: String::new(),
3264 status: "running".to_string(),
3265 github_context: "gh-ctx-A".to_string(),
3266 started_at: 2000,
3267 ended_at: 0,
3268 created_at: 2000,
3269 identity_id: "id-folded".to_string(),
3270 memory_id: "mem-folded".to_string(),
3271 instance_name: "Maks v2".to_string(),
3272 working_directory: "/wd/folded".to_string(),
3273 display_hidden: false,
3274 };
3275 store.instance_create(&inst).unwrap();
3276
3277 assert_eq!(count_agents(&store, "id = 'inst-fold-1'"), 0);
3280
3281 assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some("id-folded".to_string()));
3283 assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some("mem-folded".to_string()));
3284 assert_eq!(read_agent_field(&store, "user-clone-1", "working_directory"), Some("/wd/clone-cfg".to_string()));
3288 assert_eq!(read_agent_field(&store, "user-clone-1", "github_context"), Some("gh-ctx-A".to_string()));
3289 assert_eq!(read_agent_field(&store, "user-clone-1", "last_block_id"), Some("blk-fold".to_string()));
3292 assert_eq!(read_agent_field(&store, "user-clone-1", "instance_name"), Some("Maks v2".to_string()));
3293 assert_eq!(read_agent_field(&store, "user-clone-1", "name"), Some("Maks v2".to_string()));
3294 assert_eq!(read_agent_int(&store, "user-clone-1", "is_template"), Some(0));
3297 }
3298
3299 #[test]
3300 fn dual_write_instance_set_hidden_flips_user_hidden_bit() {
3301 let store = make_store();
3302 let mut tpl = AgentDefinition {
3303 id: "tpl-hide".to_string(),
3304 slug: String::new(),
3305 name: "Coder".to_string(),
3306 icon: "✦".to_string(),
3307 provider: "claude".to_string(),
3308 description: String::new(),
3309 working_directory: String::new(),
3310 shell: "bash".to_string(),
3311 provider_flags: String::new(),
3312 auto_start: 0,
3313 restart_on_crash: 0,
3314 idle_timeout_minutes: 0,
3315 created_at: 1000,
3316 agent_type: "standalone".to_string(),
3317 environment: String::new(),
3318 agent_bus_id: String::new(),
3319 is_seeded: 1,
3320 accounts: String::new(),
3321 parent_id: String::new(),
3322 branch_label: String::new(),
3323 updated_at: 1000,
3324 user_hidden: 0,
3325 container_image: String::new(),
3326 container_volumes: "[]".to_string(),
3327 container_name: String::new(),
3328 };
3329 store.agent_def_insert(&mut tpl).unwrap();
3330 let inst = AgentInstance {
3331 id: "inst-hide".to_string(),
3332 definition_id: "tpl-hide".to_string(),
3333 parent_instance_id: String::new(),
3334 block_id: String::new(),
3335 session_id: String::new(),
3336 status: "running".to_string(),
3337 github_context: String::new(),
3338 started_at: 2000,
3339 ended_at: 0,
3340 created_at: 2000,
3341 identity_id: String::new(),
3342 memory_id: String::new(),
3343 instance_name: "H".to_string(),
3344 working_directory: "/wd/h".to_string(),
3345 display_hidden: false,
3346 };
3347 store.instance_create(&inst).unwrap();
3348 assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
3349 store.instance_set_hidden("inst-hide", true).unwrap();
3350 assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(1));
3351 store.instance_set_hidden("inst-hide", false).unwrap();
3352 assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
3353 }
3354
3355 #[test]
3356 fn dual_write_instance_delete_drops_db_agents_row() {
3357 let store = make_store();
3358 let mut tpl = AgentDefinition {
3359 id: "tpl-instdel".to_string(),
3360 slug: String::new(),
3361 name: "Coder".to_string(),
3362 icon: "✦".to_string(),
3363 provider: "claude".to_string(),
3364 description: String::new(),
3365 working_directory: String::new(),
3366 shell: "bash".to_string(),
3367 provider_flags: String::new(),
3368 auto_start: 0,
3369 restart_on_crash: 0,
3370 idle_timeout_minutes: 0,
3371 created_at: 1000,
3372 agent_type: "standalone".to_string(),
3373 environment: String::new(),
3374 agent_bus_id: String::new(),
3375 is_seeded: 1,
3376 accounts: String::new(),
3377 parent_id: String::new(),
3378 branch_label: String::new(),
3379 updated_at: 1000,
3380 user_hidden: 0,
3381 container_image: String::new(),
3382 container_volumes: "[]".to_string(),
3383 container_name: String::new(),
3384 };
3385 store.agent_def_insert(&mut tpl).unwrap();
3386 let inst = AgentInstance {
3387 id: "inst-del".to_string(),
3388 definition_id: "tpl-instdel".to_string(),
3389 parent_instance_id: String::new(),
3390 block_id: String::new(),
3391 session_id: String::new(),
3392 status: "running".to_string(),
3393 github_context: String::new(),
3394 started_at: 2000,
3395 ended_at: 0,
3396 created_at: 2000,
3397 identity_id: String::new(),
3398 memory_id: String::new(),
3399 instance_name: "D".to_string(),
3400 working_directory: "/wd/d".to_string(),
3401 display_hidden: false,
3402 };
3403 store.instance_create(&inst).unwrap();
3404 assert_eq!(count_agents(&store, "id = 'inst-del'"), 1);
3405 store.instance_delete("inst-del").unwrap();
3406 assert_eq!(count_agents(&store, "id = 'inst-del'"), 0);
3407 }
3408
3409 #[test]
3410 fn dual_write_instance_repoint_updates_parent_template_id() {
3411 let store = make_store();
3412 let mut tpl_a = AgentDefinition {
3413 id: "tpl-A".to_string(),
3414 slug: String::new(),
3415 name: "A".to_string(),
3416 icon: "✦".to_string(),
3417 provider: "claude".to_string(),
3418 description: String::new(),
3419 working_directory: String::new(),
3420 shell: "bash".to_string(),
3421 provider_flags: String::new(),
3422 auto_start: 0,
3423 restart_on_crash: 0,
3424 idle_timeout_minutes: 0,
3425 created_at: 1000,
3426 agent_type: "standalone".to_string(),
3427 environment: String::new(),
3428 agent_bus_id: String::new(),
3429 is_seeded: 1,
3430 accounts: String::new(),
3431 parent_id: String::new(),
3432 branch_label: String::new(),
3433 updated_at: 1000,
3434 user_hidden: 0,
3435 container_image: String::new(),
3436 container_volumes: "[]".to_string(),
3437 container_name: String::new(),
3438 };
3439 store.agent_def_insert(&mut tpl_a).unwrap();
3440 let mut tpl_b = tpl_a.clone();
3441 tpl_b.id = "tpl-B".to_string();
3442 tpl_b.slug = String::new();
3443 store.agent_def_insert(&mut tpl_b).unwrap();
3444
3445 let inst = AgentInstance {
3446 id: "inst-rp".to_string(),
3447 definition_id: "tpl-A".to_string(),
3448 parent_instance_id: String::new(),
3449 block_id: String::new(),
3450 session_id: String::new(),
3451 status: "running".to_string(),
3452 github_context: String::new(),
3453 started_at: 2000,
3454 ended_at: 0,
3455 created_at: 2000,
3456 identity_id: String::new(),
3457 memory_id: String::new(),
3458 instance_name: "R".to_string(),
3459 working_directory: "/wd/r".to_string(),
3460 display_hidden: false,
3461 };
3462 store.instance_create(&inst).unwrap();
3463 assert_eq!(
3464 read_agent_field(&store, "inst-rp", "parent_template_id"),
3465 Some("tpl-A".to_string())
3466 );
3467 store.instance_repoint_definition("tpl-A", "tpl-B").unwrap();
3468 assert_eq!(
3469 read_agent_field(&store, "inst-rp", "parent_template_id"),
3470 Some("tpl-B".to_string())
3471 );
3472 }
3473
3474 #[test]
3475 fn dual_write_agent_def_delete_seeded_drops_all_template_rows() {
3476 let store = make_store();
3477 for id in &["s1", "s2", "s3"] {
3478 let mut d = AgentDefinition {
3479 id: id.to_string(),
3480 slug: String::new(),
3481 name: id.to_string(),
3482 icon: "✦".to_string(),
3483 provider: "claude".to_string(),
3484 description: String::new(),
3485 working_directory: String::new(),
3486 shell: "bash".to_string(),
3487 provider_flags: String::new(),
3488 auto_start: 0,
3489 restart_on_crash: 0,
3490 idle_timeout_minutes: 0,
3491 created_at: 1000,
3492 agent_type: "standalone".to_string(),
3493 environment: String::new(),
3494 agent_bus_id: String::new(),
3495 is_seeded: 1,
3496 accounts: String::new(),
3497 parent_id: String::new(),
3498 branch_label: String::new(),
3499 updated_at: 1000,
3500 user_hidden: 0,
3501 container_image: String::new(),
3502 container_volumes: "[]".to_string(),
3503 container_name: String::new(),
3504 };
3505 store.agent_def_insert(&mut d).unwrap();
3506 }
3507 assert_eq!(count_agents(&store, "is_template = 1"), 3);
3508 store.agent_def_delete_seeded().unwrap();
3509 assert_eq!(count_agents(&store, "is_template = 1"), 0);
3510 }
3511
3512 #[test]
3516 fn dual_write_seeded_delete_preserves_user_clone_def_projections() {
3517 let store = make_store();
3518 let mut tpl = AgentDefinition {
3520 id: "tpl-keep-check".to_string(),
3521 slug: String::new(),
3522 name: "TplCheck".to_string(),
3523 icon: "✦".to_string(),
3524 provider: "claude".to_string(),
3525 description: String::new(),
3526 working_directory: String::new(),
3527 shell: "bash".to_string(),
3528 provider_flags: String::new(),
3529 auto_start: 0,
3530 restart_on_crash: 0,
3531 idle_timeout_minutes: 0,
3532 created_at: 1000,
3533 agent_type: "standalone".to_string(),
3534 environment: String::new(),
3535 agent_bus_id: String::new(),
3536 is_seeded: 1,
3537 accounts: String::new(),
3538 parent_id: String::new(),
3539 branch_label: String::new(),
3540 updated_at: 1000,
3541 user_hidden: 0,
3542 container_image: String::new(),
3543 container_volumes: "[]".to_string(),
3544 container_name: String::new(),
3545 };
3546 store.agent_def_insert(&mut tpl).unwrap();
3547 let mut clone = AgentDefinition {
3549 id: "user-clone-keep".to_string(),
3550 slug: String::new(),
3551 name: "MaksKeeper".to_string(),
3552 is_seeded: 0,
3553 parent_id: "tpl-keep-check".to_string(),
3554 created_at: 1500,
3555 updated_at: 1500,
3556 ..tpl.clone()
3557 };
3558 store.agent_def_insert(&mut clone).unwrap();
3559 let inst_on_tpl = AgentInstance {
3561 id: "inst-on-tpl-keep".to_string(),
3562 definition_id: "tpl-keep-check".to_string(),
3563 parent_instance_id: String::new(),
3564 block_id: String::new(),
3565 session_id: String::new(),
3566 status: "running".to_string(),
3567 github_context: String::new(),
3568 started_at: 2000,
3569 ended_at: 0,
3570 created_at: 2000,
3571 identity_id: String::new(),
3572 memory_id: String::new(),
3573 instance_name: String::new(),
3574 working_directory: String::new(),
3575 display_hidden: false,
3576 };
3577 store.instance_create(&inst_on_tpl).unwrap();
3578 store.agent_def_delete_seeded().unwrap();
3580 assert_eq!(count_agents(&store, "id = 'tpl-keep-check'"), 0, "template projection gone");
3581 assert_eq!(count_agents(&store, "id = 'inst-on-tpl-keep'"), 0, "cascaded instance projection gone");
3582 assert_eq!(count_agents(&store, "id = 'user-clone-keep'"), 1, "user-clone def projection survives");
3583 }
3584
3585 #[test]
3589 fn dual_write_instance_lifecycle_on_user_clone_def_routes_to_folded_row() {
3590 let store = make_store();
3591 let mut tpl = AgentDefinition {
3593 id: "tpl-rt".to_string(),
3594 slug: String::new(),
3595 name: "Tpl".to_string(),
3596 icon: "✦".to_string(),
3597 provider: "claude".to_string(),
3598 description: String::new(),
3599 working_directory: String::new(),
3600 shell: "bash".to_string(),
3601 provider_flags: String::new(),
3602 auto_start: 0,
3603 restart_on_crash: 0,
3604 idle_timeout_minutes: 0,
3605 created_at: 1000,
3606 agent_type: "standalone".to_string(),
3607 environment: String::new(),
3608 agent_bus_id: String::new(),
3609 is_seeded: 1,
3610 accounts: String::new(),
3611 parent_id: String::new(),
3612 branch_label: String::new(),
3613 updated_at: 1000,
3614 user_hidden: 0,
3615 container_image: String::new(),
3616 container_volumes: "[]".to_string(),
3617 container_name: String::new(),
3618 };
3619 store.agent_def_insert(&mut tpl).unwrap();
3620 let mut clone = AgentDefinition {
3621 id: "user-rt".to_string(),
3622 slug: String::new(),
3623 name: "Maks".to_string(),
3624 is_seeded: 0,
3625 parent_id: "tpl-rt".to_string(),
3626 created_at: 1500,
3627 updated_at: 1500,
3628 ..tpl.clone()
3629 };
3630 store.agent_def_insert(&mut clone).unwrap();
3631 let inst = AgentInstance {
3632 id: "inst-rt".to_string(),
3633 definition_id: "user-rt".to_string(),
3634 parent_instance_id: String::new(),
3635 block_id: String::new(),
3636 session_id: String::new(),
3637 status: "running".to_string(),
3638 github_context: "gh-initial".to_string(),
3639 started_at: 2000,
3640 ended_at: 0,
3641 created_at: 2000,
3642 identity_id: "id-init".to_string(),
3643 memory_id: "mem-init".to_string(),
3644 instance_name: "Maks v1".to_string(),
3645 working_directory: "/wd".to_string(),
3646 display_hidden: false,
3647 };
3648 store.instance_create(&inst).unwrap();
3649 assert_eq!(count_agents(&store, "id = 'inst-rt'"), 0);
3651 assert_eq!(read_agent_field(&store, "user-rt", "github_context"), Some("gh-initial".to_string()));
3652
3653 let updated = AgentInstance {
3655 github_context: "gh-updated".to_string(),
3656 ..inst.clone()
3657 };
3658 store.instance_update(&updated).unwrap();
3659 assert_eq!(
3660 read_agent_field(&store, "user-rt", "github_context"),
3661 Some("gh-updated".to_string()),
3662 "instance_update on user-clone-def routes to folded row",
3663 );
3664
3665 store.instance_set_hidden("inst-rt", true).unwrap();
3667 assert_eq!(
3668 read_agent_int(&store, "user-rt", "user_hidden"),
3669 Some(1),
3670 "instance_set_hidden routes to folded row",
3671 );
3672
3673 store.instance_delete("inst-rt").unwrap();
3675 assert_eq!(
3676 count_agents(&store, "id = 'user-rt'"),
3677 1,
3678 "instance_delete on user-clone-def is a no-op (def projection persists)",
3679 );
3680 }
3681}