1use crate::registry::{
18 DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA,
19};
20
21use super::agents::AgentDefinition;
22use super::content::AgentContent;
23use super::error::StoreError;
24use super::skills::AgentSkill;
25use super::store::Store;
26
27pub(super) fn agent_definition_to_record(
31 def: &AgentDefinition,
32 content: &[AgentContent],
33 skills: &[AgentSkill],
34) -> DefinitionRecord {
35 let data = DefinitionRecordV1 {
36 id: def.id.clone(),
37 slug: def.slug.clone(),
38 name: def.name.clone(),
39 icon: def.icon.clone(),
40 provider: def.provider.clone(),
41 description: def.description.clone(),
42 working_directory: def.working_directory.clone(),
43 shell: def.shell.clone(),
44 provider_flags: def.provider_flags.clone(),
45 auto_start: def.auto_start,
46 restart_on_crash: def.restart_on_crash,
47 idle_timeout_minutes: def.idle_timeout_minutes,
48 created_at: def.created_at,
49 agent_type: def.agent_type.clone(),
50 environment: def.environment.clone(),
51 agent_bus_id: def.agent_bus_id.clone(),
52 is_seeded: def.is_seeded,
53 accounts: def.accounts.clone(),
54 parent_id: def.parent_id.clone(),
55 branch_label: def.branch_label.clone(),
56 updated_at: def.updated_at,
57 user_hidden: def.user_hidden,
58 container_image: def.container_image.clone(),
59 container_volumes: def.container_volumes.clone(),
60 container_name: def.container_name.clone(),
61 use_ambient_login: def.use_ambient_login,
62 auto_continue_enabled: def.auto_continue_enabled,
63 content: content
64 .iter()
65 .map(|c| DefContentBlob {
66 content_type: c.content_type.clone(),
67 content: c.content.clone(),
68 })
69 .collect(),
70 skills: skills
71 .iter()
72 .map(|s| DefSkillBlob {
73 id: s.id.clone(),
74 name: s.name.clone(),
75 trigger: s.trigger.clone(),
76 skill_type: s.skill_type.clone(),
77 description: s.description.clone(),
78 content: s.content.clone(),
79 })
80 .collect(),
81 };
82 DefinitionRecord {
83 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
84 data,
85 }
86}
87
88pub(super) fn record_to_agent_definition(rec: &DefinitionRecord) -> AgentDefinition {
94 let d = &rec.data;
95 AgentDefinition {
96 id: d.id.clone(),
97 slug: d.slug.clone(),
98 name: d.name.clone(),
99 icon: d.icon.clone(),
100 provider: d.provider.clone(),
101 description: d.description.clone(),
102 working_directory: d.working_directory.clone(),
103 shell: d.shell.clone(),
104 provider_flags: d.provider_flags.clone(),
105 auto_start: d.auto_start,
106 restart_on_crash: d.restart_on_crash,
107 idle_timeout_minutes: d.idle_timeout_minutes,
108 created_at: d.created_at,
109 agent_type: d.agent_type.clone(),
110 environment: d.environment.clone(),
111 agent_bus_id: d.agent_bus_id.clone(),
112 is_seeded: d.is_seeded,
113 accounts: d.accounts.clone(),
114 parent_id: d.parent_id.clone(),
115 branch_label: d.branch_label.clone(),
116 updated_at: d.updated_at,
117 user_hidden: d.user_hidden,
118 container_image: d.container_image.clone(),
119 container_volumes: d.container_volumes.clone(),
120 container_name: d.container_name.clone(),
121 use_ambient_login: d.use_ambient_login,
122 model_vendor_base_url: String::new(),
127 auto_continue_enabled: d.auto_continue_enabled,
128 memory_id: String::new(),
134 conversation_visibility: super::agents::default_conversation_visibility(),
143 }
144}
145
146impl Store {
147 pub(super) fn registry_def_upsert(&self, def_id: &str) {
155 let Some(reg) = self.shared_def_registry() else {
156 return;
157 };
158 let def = match self.agent_def_get(def_id) {
159 Ok(Some(d)) => d,
160 Ok(None) => return,
161 Err(e) => {
162 tracing::warn!(def_id, error = %e, "def registry: read definition failed, skipping mirror");
163 return;
164 }
165 };
166 if def.is_seeded != 0 {
170 return;
171 }
172 let content = self.agent_content_get_all_local(def_id).unwrap_or_default();
176 let skills = self.agent_skill_list_local(def_id).unwrap_or_default();
177 let rec = agent_definition_to_record(&def, &content, &skills);
178 if let Err(e) = reg.upsert(&rec) {
179 tracing::warn!(def_id, error = %e, "def registry: mirror upsert failed");
180 }
181 }
182
183 pub(super) fn registry_def_refresh_if_mirrored(&self, def_id: &str) {
209 let Some(reg) = self.shared_def_registry() else {
210 return;
211 };
212 if !reg.exists(def_id) {
213 return;
214 }
215 let def = match self.agent_def_get(def_id) {
216 Ok(Some(d)) => d,
217 Ok(None) => return,
218 Err(e) => {
219 tracing::warn!(def_id, error = %e, "def registry: read definition failed, skipping refresh");
220 return;
221 }
222 };
223 if def.is_seeded != 0 {
224 return;
225 }
226 let content = self.agent_content_get_all_local(def_id).unwrap_or_default();
227 let skills = self.agent_skill_list_local(def_id).unwrap_or_default();
228 let rec = agent_definition_to_record(&def, &content, &skills);
229 if let Err(e) = reg.upsert(&rec) {
230 tracing::warn!(def_id, error = %e, "def registry: mirror refresh failed");
231 }
232 }
233
234 pub(super) fn registry_def_retire(&self, def_id: &str) -> bool {
238 let Some(reg) = self.shared_def_registry() else {
239 return false;
240 };
241 let existed = reg.exists(def_id);
245 if let Err(e) = reg.retire(def_id) {
246 tracing::warn!(def_id, error = %e, "def registry: mirror retire failed");
247 return false;
248 }
249 existed
250 }
251
252 pub(super) fn registry_def_update_definition_fields(&self, agent: &AgentDefinition) -> bool {
258 let Some(reg) = self.shared_def_registry() else {
259 return false;
260 };
261 let mut rec = match reg.get(&agent.id) {
262 Ok(Some(r)) => r,
263 _ => return false,
264 };
265 let kept_content = std::mem::take(&mut rec.data.content);
267 let kept_skills = std::mem::take(&mut rec.data.skills);
268 let mut new_rec = agent_definition_to_record(agent, &[], &[]);
269 new_rec.data.content = kept_content;
270 new_rec.data.skills = kept_skills;
271 if let Err(e) = reg.upsert(&new_rec) {
272 tracing::warn!(def_id = %agent.id, error = %e, "def registry: cross-channel update failed");
273 return false;
274 }
275 true
276 }
277
278 pub(super) fn registry_def_update_content_field(
286 &self,
287 agent_id: &str,
288 content_type: &str,
289 value: Option<&str>,
290 ) -> Result<(), StoreError> {
291 let Some(reg) = self.shared_def_registry() else {
292 return Err(StoreError::Other("no global registry available".into()));
293 };
294 let mut rec = match reg.get(agent_id) {
295 Ok(Some(r)) => r,
296 Ok(None) => {
297 return Err(StoreError::Other(format!(
298 "agent {agent_id} not in global registry"
299 )))
300 }
301 Err(e) => {
302 return Err(StoreError::Other(format!(
303 "global registry read failed: {e}"
304 )))
305 }
306 };
307 match value {
308 Some(v) => {
309 if let Some(blob) = rec.data.content.iter_mut().find(|b| b.content_type == content_type) {
310 blob.content = v.to_string();
311 } else {
312 rec.data.content.push(DefContentBlob {
313 content_type: content_type.to_string(),
314 content: v.to_string(),
315 });
316 }
317 }
318 None => {
319 rec.data.content.retain(|b| b.content_type != content_type);
320 }
321 }
322 reg.upsert(&rec)
323 .map_err(|e| StoreError::Other(format!("global registry write failed: {e}")))?;
324 Ok(())
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::registry::{
332 DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DefinitionStore,
333 DEF_MAX_SUPPORTED_SCHEMA,
334 };
335 use std::sync::Arc;
336
337 fn global_user_agent(id: &str, name: &str) -> DefinitionRecord {
338 DefinitionRecord {
339 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
340 data: DefinitionRecordV1 {
341 id: id.to_string(),
342 name: name.to_string(),
343 provider: "claude".to_string(),
344 is_seeded: 0,
345 content: vec![DefContentBlob {
346 content_type: "agentmd".to_string(),
347 content: "be helpful".to_string(),
348 }],
349 skills: vec![DefSkillBlob {
350 id: "sk1".to_string(),
351 name: "greet".to_string(),
352 ..Default::default()
353 }],
354 ..Default::default()
355 },
356 }
357 }
358
359 #[test]
360 fn read_first_surfaces_cross_channel_agent_with_content_and_skills() {
361 let store = Store::open_in_memory().unwrap();
362 let tmp = tempfile::tempdir().unwrap();
363 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
364 def_store
366 .upsert(&global_user_agent("remote-1", "Remote"))
367 .unwrap();
368 store.set_def_registry(def_store);
369
370 let list = store.agent_def_list().unwrap();
372 assert!(
373 list.iter().any(|d| d.id == "remote-1" && d.name == "Remote"),
374 "agent_def_list must include the global-only user agent"
375 );
376
377 let content = store.agent_content_get_all("remote-1").unwrap();
380 assert_eq!(content.len(), 1);
381 assert_eq!(content[0].content, "be helpful");
382 let skills = store.agent_skill_list("remote-1").unwrap();
383 assert_eq!(skills.len(), 1);
384 assert_eq!(skills[0].name, "greet");
385 }
386
387 #[test]
398 fn agent_def_list_preserves_local_model_vendor_base_url_over_the_global_overlay() {
399 let store = Store::open_in_memory().unwrap();
400 let tmp = tempfile::tempdir().unwrap();
401 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
402
403 let mut def = local_def("agent-1", "Vendor Agent");
404 def.model_vendor_base_url = "https://my-proxy.example.com".to_string();
405 store.agent_def_insert(&mut def).unwrap();
406 def_store.upsert(&global_user_agent("agent-1", "Vendor Agent")).unwrap();
409 store.set_def_registry(def_store);
410
411 let list = store.agent_def_list().unwrap();
412 let found = list.iter().find(|d| d.id == "agent-1").expect("agent must be listed");
413 assert_eq!(
414 found.model_vendor_base_url, "https://my-proxy.example.com",
415 "the global overlay must not wipe a same-channel agent's vendor override"
416 );
417 }
418
419 #[test]
430 fn agent_def_list_preserves_local_memory_id_over_the_global_overlay() {
431 let store = Store::open_in_memory().unwrap();
432 let tmp = tempfile::tempdir().unwrap();
433 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
434
435 let mut def = local_def("agent-1", "Bundle Agent");
436 store.agent_def_insert(&mut def).unwrap();
437 store.agent_def_set_memory_id_if_empty("agent-1", "bundle-123").unwrap();
438 def_store.upsert(&global_user_agent("agent-1", "Bundle Agent")).unwrap();
440 store.set_def_registry(def_store);
441
442 let list = store.agent_def_list().unwrap();
443 let found = list.iter().find(|d| d.id == "agent-1").expect("agent must be listed");
444 assert_eq!(
445 found.memory_id, "bundle-123",
446 "the global overlay must not wipe a same-channel agent's bundle binding"
447 );
448 }
449
450 fn local_def(id: &str, name: &str) -> AgentDefinition {
451 AgentDefinition {
452 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
453 id: id.to_string(),
454 slug: id.to_string(),
455 name: name.to_string(),
456 icon: "✦".to_string(),
457 provider: "claude".to_string(),
458 description: String::new(),
459 working_directory: String::new(),
460 shell: String::new(),
461 provider_flags: String::new(),
462 auto_start: 0,
463 restart_on_crash: 0,
464 idle_timeout_minutes: 0,
465 created_at: 1,
466 agent_type: "host".to_string(),
467 environment: String::new(),
468 agent_bus_id: String::new(),
469 is_seeded: 0,
470 accounts: String::new(),
471 parent_id: String::new(),
472 branch_label: String::new(),
473 updated_at: 1,
474 user_hidden: 0,
475 container_image: String::new(),
476 container_volumes: "[]".to_string(),
477 container_name: String::new(),
478 use_ambient_login: 0,
479 model_vendor_base_url: String::new(),
480 auto_continue_enabled: 0,
481 memory_id: String::new(),
482 }
483 }
484
485 fn skill(id: &str, agent_id: &str, name: &str) -> AgentSkill {
486 AgentSkill {
487 id: id.to_string(),
488 agent_id: agent_id.to_string(),
489 name: name.to_string(),
490 trigger: String::new(),
491 skill_type: "prompt".to_string(),
492 description: String::new(),
493 content: String::new(),
494 created_at: 1,
495 }
496 }
497
498 #[test]
499 fn local_skill_delete_does_not_resurrect_from_global() {
500 let store = Store::open_in_memory().unwrap();
501 let tmp = tempfile::tempdir().unwrap();
502 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
503 store.set_def_registry(def_store.clone());
504
505 let mut def = local_def("local-1", "Local");
507 store.agent_def_insert(&mut def).unwrap();
508 store
509 .agent_skill_insert(&skill("sk-1", "local-1", "greet"))
510 .unwrap();
511 assert_eq!(
512 def_store.get("local-1").unwrap().unwrap().data.skills.len(),
513 1,
514 "skill mirrored to global"
515 );
516
517 store.agent_skill_delete("sk-1").unwrap();
519
520 assert!(
523 store.agent_skill_list("local-1").unwrap().is_empty(),
524 "deleted local skill must not reappear from the global record"
525 );
526 assert!(
529 def_store.get("local-1").unwrap().unwrap().data.skills.is_empty(),
530 "deletion must propagate cross-channel"
531 );
532 }
533
534 #[test]
535 fn local_content_delete_propagates_to_global() {
536 let store = Store::open_in_memory().unwrap();
537 let tmp = tempfile::tempdir().unwrap();
538 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
539 store.set_def_registry(def_store.clone());
540
541 let mut def = local_def("local-z", "LocalZ");
543 store.agent_def_insert(&mut def).unwrap();
544 store
545 .agent_content_set(&AgentContent {
546 agent_id: "local-z".into(),
547 content_type: "ui:zoom".into(),
548 content: "1.5".into(),
549 updated_at: 1,
550 })
551 .unwrap();
552 assert!(
553 def_store
554 .get("local-z")
555 .unwrap()
556 .unwrap()
557 .data
558 .content
559 .iter()
560 .any(|c| c.content_type == "ui:zoom"),
561 "content mirrored to global"
562 );
563
564 store.agent_content_delete("local-z", "ui:zoom").unwrap();
566
567 assert!(
569 store.agent_content_get("local-z", "ui:zoom").unwrap().is_none(),
570 "deleted local content must not reappear from the global record"
571 );
572 assert!(
576 def_store
577 .get("local-z")
578 .unwrap()
579 .unwrap()
580 .data
581 .content
582 .iter()
583 .all(|c| c.content_type != "ui:zoom"),
584 "content deletion must propagate cross-channel"
585 );
586 }
587
588 #[test]
589 fn delete_tombstones_a_cross_channel_only_agent() {
590 let store = Store::open_in_memory().unwrap();
591 let tmp = tempfile::tempdir().unwrap();
592 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
593 def_store
595 .upsert(&global_user_agent("remote-1", "Remote"))
596 .unwrap();
597 store.set_def_registry(def_store.clone());
598 assert!(store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"));
600
601 let deleted = store.agent_def_delete("remote-1").unwrap();
604 assert!(deleted, "cross-channel delete must report success");
605 assert!(!def_store.exists("remote-1"), "global record tombstoned");
606 assert!(
607 !store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"),
608 "deleted cross-channel agent must not reappear in the roster"
609 );
610 }
611
612 #[test]
613 fn update_edits_a_cross_channel_only_agent_preserving_content_skills() {
614 let store = Store::open_in_memory().unwrap();
615 let tmp = tempfile::tempdir().unwrap();
616 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
617 def_store
619 .upsert(&global_user_agent("remote-1", "Remote"))
620 .unwrap();
621 store.set_def_registry(def_store.clone());
622
623 let mut edited = record_to_agent_definition(&global_user_agent("remote-1", "Remote"));
625 edited.name = "Renamed".to_string();
626 let ok = store.agent_def_update(&mut edited).unwrap();
627 assert!(ok, "cross-channel update must report success, not 'not found'");
628
629 let rec = def_store.get("remote-1").unwrap().unwrap();
631 assert_eq!(rec.data.name, "Renamed");
632 assert_eq!(rec.data.content.len(), 1, "content preserved");
633 assert_eq!(rec.data.skills.len(), 1, "skills preserved");
634 assert!(store
636 .agent_def_list()
637 .unwrap()
638 .iter()
639 .any(|d| d.id == "remote-1" && d.name == "Renamed"));
640 }
641
642 #[test]
643 fn cross_channel_content_set_writes_to_global_registry() {
644 let store = Store::open_in_memory().unwrap();
648 let tmp = tempfile::tempdir().unwrap();
649 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
650 def_store
651 .upsert(&global_user_agent("remote-1", "Remote"))
652 .unwrap();
653 store.set_def_registry(def_store.clone());
654
655 store
657 .agent_content_set(&AgentContent {
658 agent_id: "remote-1".into(),
659 content_type: "ui:zoom".into(),
660 content: "1.4".into(),
661 updated_at: 100,
662 })
663 .unwrap();
664
665 let rec = def_store.get("remote-1").unwrap().unwrap();
667 let zoom_blob = rec.data.content.iter().find(|c| c.content_type == "ui:zoom");
668 assert!(zoom_blob.is_some(), "ui:zoom must be in the global record");
669 assert_eq!(zoom_blob.unwrap().content, "1.4");
670 let agentmd_blob = rec.data.content.iter().find(|c| c.content_type == "agentmd");
671 assert!(agentmd_blob.is_some(), "existing agentmd content must be preserved");
672
673 let got = store.agent_content_get("remote-1", "ui:zoom").unwrap();
675 assert!(got.is_some(), "agent_content_get must find the zoom via registry fallback");
676 assert_eq!(got.unwrap().content, "1.4");
677 }
678
679 #[test]
680 fn cross_channel_content_delete_removes_from_global_registry() {
681 let store = Store::open_in_memory().unwrap();
684 let tmp = tempfile::tempdir().unwrap();
685 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
686 let mut rec = global_user_agent("remote-1", "Remote");
688 rec.data.content.push(DefContentBlob {
689 content_type: "ui:zoom".into(),
690 content: "1.4".into(),
691 });
692 def_store.upsert(&rec).unwrap();
693 store.set_def_registry(def_store.clone());
694
695 store.agent_content_delete("remote-1", "ui:zoom").unwrap();
697
698 let after = def_store.get("remote-1").unwrap().unwrap();
700 assert!(
701 after.data.content.iter().all(|c| c.content_type != "ui:zoom"),
702 "ui:zoom must be removed from global record"
703 );
704 assert!(
705 after.data.content.iter().any(|c| c.content_type == "agentmd"),
706 "agentmd content must be preserved"
707 );
708
709 assert!(
711 store.agent_content_get("remote-1", "ui:zoom").unwrap().is_none(),
712 "deleted zoom must not reappear"
713 );
714 }
715
716 #[test]
732 fn a_template_launchs_first_content_write_does_not_create_a_global_registry_entry() {
733 let store = Store::open_in_memory().unwrap();
734 let tmp = tempfile::tempdir().unwrap();
735 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
736 store.set_def_registry(def_store.clone());
737
738 let mut tpl = local_def("tpl-1", "Coder");
739 tpl.is_seeded = 1;
740 store.agent_def_insert(&mut tpl).unwrap();
741 assert!(!def_store.exists("tpl-1"));
743
744 let launch = crate::backend::storage::store::AgentInstance {
745 id: "launch-1".to_string(),
746 definition_id: "tpl-1".to_string(),
747 parent_instance_id: String::new(),
748 block_id: String::new(),
749 session_id: String::new(),
750 status: "running".to_string(),
751 github_context: String::new(),
752 started_at: 1,
753 ended_at: 0,
754 created_at: 1,
755 identity_id: String::new(),
756 memory_id: String::new(),
757 instance_name: "Coder".to_string(),
758 working_directory: String::new(),
759 display_hidden: false,
760 };
761 let canonical = store.instance_create(&launch).unwrap();
762 assert_eq!(canonical.id, "launch-1", "sanity: a template launch creates its own row");
763
764 store
767 .agent_content_set(&crate::backend::storage::store::AgentContent {
768 agent_id: "launch-1".to_string(),
769 content_type: "ui:zoom".to_string(),
770 content: "1.4".to_string(),
771 updated_at: 2,
772 })
773 .unwrap();
774
775 assert!(
776 !def_store.exists("launch-1"),
777 "a template launch's content write must never create a global registry entry",
778 );
779 let local = store.agent_content_get("launch-1", "ui:zoom").unwrap();
781 assert_eq!(local.map(|c| c.content), Some("1.4".to_string()));
782 }
783}