1use std::path::{Path, PathBuf};
41use std::sync::Arc;
42use std::time::{SystemTime, UNIX_EPOCH};
43
44use crate::backend::providers::{get_provider, get_provider_list};
45use crate::backend::storage::store::{
46 Identity, IdentityAccount, SecretRef, Store,
47};
48use crate::backend::wps::{Broker, WaveEvent};
49use crate::identity::resolver::{
50 oauth_status, probe_oauth_status, provider_class, OAuthProbeStatus, ProviderClass,
51};
52
53pub const DEFAULT_BUNDLE_ID: &str = "default";
58
59pub const DEFAULT_BUNDLE_NAME: &str = "Default";
61
62#[derive(Debug, Default, Clone, PartialEq, Eq)]
66pub struct MigrationStats {
67 pub providers_examined: usize,
69 pub providers_skipped_existing: usize,
72 pub providers_skipped_no_ambient: usize,
75 pub providers_seeded: usize,
77 pub providers_repointed: usize,
80 pub default_bundle_created: bool,
82 pub instances_backfilled: usize,
85}
86
87pub fn run_default_bundle_migration(
96 wstore: &Arc<Store>,
97 broker: Option<&Arc<Broker>>,
98 home_dir_override: Option<PathBuf>,
99) -> MigrationStats {
100 let mut stats = MigrationStats::default();
101
102 let home = match home_dir_override.or_else(dirs::home_dir) {
107 Some(h) => h,
108 None => {
109 tracing::debug!(
110 target: "identity",
111 "oauth-bundles migration: no home_dir resolvable — skipping"
112 );
113 return stats;
114 }
115 };
116
117 let oauth_providers: Vec<&str> = get_provider_list()
125 .filter_map(|p| match provider_class(p.id) {
126 Some(ProviderClass::OAuth { .. }) => Some(p.id),
127 _ => None,
128 })
129 .collect();
130
131 stats.providers_examined = oauth_providers.len();
132
133 let covered_providers: std::collections::HashSet<String> = bound_providers(wstore);
141
142 let now_ms = SystemTime::now()
143 .duration_since(UNIX_EPOCH)
144 .map(|d| d.as_millis() as i64)
145 .unwrap_or(0);
146
147 let mut default_ready: Option<()> = None;
152
153 for provider_id in oauth_providers {
154 if covered_providers.contains(provider_id) {
160 stats.providers_skipped_existing += 1;
161 tracing::debug!(
162 target: "identity",
163 provider_id,
164 "oauth-bundles migration: provider already bound — skipping"
165 );
166 continue;
167 }
168
169 let provider_cfg = match get_provider(provider_id) {
174 Some(p) => p,
175 None => {
176 tracing::warn!(
179 target: "identity",
180 provider_id,
181 "oauth-bundles migration: provider missing from registry mid-iteration — skipping"
182 );
183 continue;
184 }
185 };
186 let ambient_dir = home.join(format!(".{}", provider_cfg.auth_dir_name));
187 let creds_file = ambient_dir.join(".credentials.json");
188 if !creds_file.exists() {
189 stats.providers_skipped_no_ambient += 1;
190 tracing::debug!(
191 target: "identity",
192 provider_id,
193 path = %creds_file.display(),
194 "oauth-bundles migration: no ambient credentials file — skipping"
195 );
196 continue;
197 }
198
199 if default_ready.is_none() {
202 match ensure_default_bundle(wstore, now_ms) {
203 Ok(created) => {
204 stats.default_bundle_created = created;
205 default_ready = Some(());
206 }
207 Err(e) => {
208 tracing::warn!(
209 target: "identity",
210 error = %e,
211 "oauth-bundles migration: failed to upsert Default bundle — aborting migration this run"
212 );
213 return stats;
217 }
218 }
219 }
220
221 let dest_dir = provider_auth_dir(&home, &provider_cfg.auth_dir_name);
228 let dest_str = dest_dir.to_string_lossy().to_string();
229 if let Err(e) = import_ambient_once(&creds_file, &dest_dir) {
230 tracing::warn!(
231 target: "identity",
232 provider_id,
233 error = %e,
234 "oauth-bundles migration: failed to import ambient creds into the AgentMux dir — skipping provider"
235 );
236 continue;
237 }
238
239 let probed_status = probe_oauth_status(provider_id, &dest_str, now_ms)
242 .map(|s| s.as_str())
243 .unwrap_or(oauth_status::UNKNOWN);
244
245 let account_id = wstore
248 .bundle_identity_bindings(DEFAULT_BUNDLE_ID)
249 .ok()
250 .into_iter()
251 .flatten()
252 .find(|b| b.provider == provider_id)
253 .map(|b| b.account_id)
254 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
255
256 let account = IdentityAccount {
257 id: account_id.clone(),
258 name: format!("{provider_id}-oauth"),
259 provider: provider_id.to_string(),
260 kind: "oauth".to_string(),
261 display_name: String::new(),
262 secret_ref: SecretRef::OAuthConfigDir { dir: dest_str.clone() },
264 context: serde_json::json!({}),
265 status: probed_status.to_string(),
266 created_at: now_ms,
267 updated_at: now_ms,
268 };
269
270 if let Err(e) = wstore.identity_upsert(&account) {
271 tracing::warn!(
272 target: "identity",
273 provider_id,
274 error = %e,
275 "oauth-bundles migration: identity_upsert failed — skipping provider"
276 );
277 continue;
278 }
279 if let Err(e) = wstore.bundle_identity_bind(DEFAULT_BUNDLE_ID, provider_id, &account_id) {
280 tracing::warn!(
281 target: "identity",
282 provider_id,
283 account_id,
284 error = %e,
285 "oauth-bundles migration: bundle_identity_bind failed — account row persisted but no binding"
286 );
287 continue;
288 }
289
290 stats.providers_seeded += 1;
291 tracing::info!(
292 target: "identity",
293 provider_id,
294 account_id,
295 dir = %dest_str,
296 status = probed_status,
297 "oauth-bundles migration: imported ambient credentials into the AgentMux dir + bound into Default bundle"
298 );
299
300 if let Some(b) = broker {
304 b.publish(WaveEvent {
305 event: format!("identitybundlebindings:changed:{DEFAULT_BUNDLE_ID}"),
306 scopes: vec![],
307 sender: String::new(),
308 persist: 0,
309 data: None,
310 });
311 }
312
313 if let Some(probed) = OAuthProbeStatus::from_str(probed_status) {
318 tracing::info!(
319 target: "identity",
320 provider_id,
321 ?probed,
322 "oauth-bundles migration: probe status"
323 );
324 }
325 }
326
327 sweep_default_accounts_off_ambient(wstore, broker, &home, now_ms, &mut stats);
333
334 let default_bundle_exists = default_ready.is_some()
346 || wstore
347 .bundle_identity_list()
348 .ok()
349 .map(|bs| bs.iter().any(|b| b.id == DEFAULT_BUNDLE_ID))
350 .unwrap_or(false);
351 if default_bundle_exists {
352 match wstore.instance_backfill_identity_id(DEFAULT_BUNDLE_ID) {
353 Ok(rows) => {
354 stats.instances_backfilled = rows;
355 if rows > 0 {
356 tracing::info!(
357 target: "identity",
358 rows,
359 bundle_id = DEFAULT_BUNDLE_ID,
360 "oauth-bundles migration: back-filled empty/blank identity_id rows"
361 );
362 }
363 }
364 Err(e) => {
365 tracing::warn!(
366 target: "identity",
367 error = %e,
368 "oauth-bundles migration: instance_backfill_identity_id failed — rows unchanged"
369 );
370 }
371 }
372 }
373
374 tracing::info!(
375 target: "identity",
376 ?stats,
377 "oauth-bundles migration: complete"
378 );
379 stats
380}
381
382fn provider_auth_dir(home: &Path, auth_dir_name: &str) -> PathBuf {
387 home.join(".agentmux")
388 .join("shared")
389 .join("providers")
390 .join(auth_dir_name)
391}
392
393fn import_ambient_once(ambient_creds: &Path, dest_dir: &Path) -> std::io::Result<bool> {
400 let sentinel = dest_dir.join(".agentmux-cred-seeded");
401 if sentinel.exists() {
402 return Ok(false);
403 }
404 std::fs::create_dir_all(dest_dir)?;
405 let mut copied = false;
406 if ambient_creds.exists() {
407 std::fs::copy(ambient_creds, dest_dir.join(".credentials.json"))?;
408 copied = true;
409 }
410 std::fs::write(&sentinel, b"")?;
413 Ok(copied)
414}
415
416fn sweep_default_accounts_off_ambient(
423 wstore: &Arc<Store>,
424 broker: Option<&Arc<Broker>>,
425 home: &Path,
426 now_ms: i64,
427 stats: &mut MigrationStats,
428) {
429 let bindings = match wstore.bundle_identity_bindings(DEFAULT_BUNDLE_ID) {
430 Ok(b) => b,
431 Err(_) => return,
432 };
433 for b in bindings {
434 let provider_cfg = match get_provider(&b.provider) {
435 Some(p) => p,
436 None => continue,
437 };
438 let ambient_dir = home.join(format!(".{}", provider_cfg.auth_dir_name));
439 let ambient_str = ambient_dir.to_string_lossy().to_string();
440 let dest_dir = provider_auth_dir(home, &provider_cfg.auth_dir_name);
441 let dest_str = dest_dir.to_string_lossy().to_string();
442
443 let mut acct = match wstore.identity_get(&b.account_id) {
444 Ok(Some(a)) => a,
445 _ => continue,
446 };
447 let points_at_ambient = matches!(
448 &acct.secret_ref,
449 SecretRef::OAuthConfigDir { dir } if *dir == ambient_str
450 );
451 if !points_at_ambient {
452 continue; }
454
455 if let Err(e) = import_ambient_once(&ambient_dir.join(".credentials.json"), &dest_dir) {
456 tracing::warn!(
457 target: "identity",
458 provider = %b.provider,
459 error = %e,
460 "isolation sweep: import failed — leaving account pointed at ambient"
461 );
462 continue;
463 }
464 acct.secret_ref = SecretRef::OAuthConfigDir {
465 dir: dest_str.clone(),
466 };
467 acct.status = probe_oauth_status(&b.provider, &dest_str, now_ms)
468 .map(|s| s.as_str())
469 .unwrap_or(oauth_status::UNKNOWN)
470 .to_string();
471 acct.updated_at = now_ms;
472 if let Err(e) = wstore.identity_upsert(&acct) {
473 tracing::warn!(
474 target: "identity",
475 provider = %b.provider,
476 error = %e,
477 "isolation sweep: identity_upsert failed"
478 );
479 continue;
480 }
481 stats.providers_repointed += 1;
482 tracing::info!(
483 target: "identity",
484 provider = %b.provider,
485 from = %ambient_str,
486 to = %dest_str,
487 "isolation sweep: repointed Default account off the user's ambient dir to the AgentMux dir"
488 );
489 if let Some(br) = broker {
490 br.publish(WaveEvent {
491 event: format!("identitybundlebindings:changed:{DEFAULT_BUNDLE_ID}"),
492 scopes: vec![],
493 sender: String::new(),
494 persist: 0,
495 data: None,
496 });
497 }
498 }
499}
500
501fn bound_providers(wstore: &Arc<Store>) -> std::collections::HashSet<String> {
505 let mut out = std::collections::HashSet::new();
506 let bundles = match wstore.bundle_identity_list() {
507 Ok(b) => b,
508 Err(e) => {
509 tracing::warn!(
510 target: "identity",
511 error = %e,
512 "oauth-bundles migration: bundle_identity_list failed — treating all providers as uncovered"
513 );
514 return out;
515 }
516 };
517 for bundle in bundles {
518 match wstore.bundle_identity_bindings(&bundle.id) {
519 Ok(bindings) => {
520 for b in bindings {
521 out.insert(b.provider);
522 }
523 }
524 Err(e) => {
525 tracing::warn!(
526 target: "identity",
527 bundle_id = %bundle.id,
528 error = %e,
529 "oauth-bundles migration: bundle_identity_bindings failed for bundle — skipping"
530 );
531 }
532 }
533 }
534 out
535}
536
537fn ensure_default_bundle(
541 wstore: &Arc<Store>,
542 now_ms: i64,
543) -> Result<bool, crate::backend::storage::error::StoreError> {
544 if let Some(existing) = wstore.bundle_identity_get(DEFAULT_BUNDLE_ID)? {
545 let _ = existing;
548 return Ok(false);
549 }
550 let identity = Identity {
551 id: DEFAULT_BUNDLE_ID.to_string(),
552 name: DEFAULT_BUNDLE_NAME.to_string(),
553 description: "Seeded from ambient OAuth credentials on first launch.".to_string(),
554 is_blank: false,
555 created_at: now_ms,
556 updated_at: now_ms,
557 };
558 wstore.bundle_identity_upsert(&identity)?;
559 Ok(true)
560}
561
562impl OAuthProbeStatus {
566 fn from_str(s: &str) -> Option<Self> {
567 match s {
568 oauth_status::VALID => Some(Self::Valid),
569 oauth_status::EXPIRED => Some(Self::Expired),
570 oauth_status::NEEDS_REAUTH => Some(Self::NeedsReauth),
571 _ => None,
572 }
573 }
574}
575
576#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::backend::storage::store::{IdentityAccount, SecretRef};
582
583 fn make_store() -> Arc<Store> {
586 Arc::new(Store::open_in_memory().unwrap())
587 }
588
589 fn plant_ambient_claude_creds(home: &std::path::Path) {
593 let dir = home.join(".claude");
594 std::fs::create_dir_all(&dir).unwrap();
595 let body = serde_json::json!({
598 "claudeAiOauth": {
599 "accessToken": "test-access",
600 "refreshToken": "test-refresh",
601 "expiresAt": 99_999_999_999_999_i64,
602 }
603 });
604 std::fs::write(
605 dir.join(".credentials.json"),
606 serde_json::to_string(&body).unwrap(),
607 )
608 .unwrap();
609 }
610
611 #[test]
612 fn no_home_dir_skips_silently() {
613 let store = make_store();
620 let tmp = tempfile::tempdir().unwrap();
621 let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
622 assert!(stats.providers_examined > 0); assert_eq!(stats.providers_seeded, 0);
624 assert_eq!(stats.providers_skipped_no_ambient, stats.providers_examined);
625 assert_eq!(stats.providers_skipped_existing, 0);
626 assert!(!stats.default_bundle_created);
627 assert_eq!(stats.instances_backfilled, 0);
628 }
629
630 #[test]
631 fn ambient_claude_creds_create_default_bundle_and_bind() {
632 let store = make_store();
633 let tmp = tempfile::tempdir().unwrap();
634 plant_ambient_claude_creds(tmp.path());
635
636 let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
637
638 assert!(stats.default_bundle_created);
639 assert_eq!(stats.providers_seeded, 1);
640
641 let default = store.bundle_identity_get(DEFAULT_BUNDLE_ID).unwrap();
643 assert!(default.is_some(), "Default bundle should be created");
644 let default = default.unwrap();
645 assert_eq!(default.name, DEFAULT_BUNDLE_NAME);
646 assert!(!default.is_blank);
647
648 let bindings = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
650 assert_eq!(bindings.len(), 1);
651 let claude_binding = &bindings[0];
652 assert_eq!(claude_binding.provider, "claude");
653
654 let account = store.identity_get(&claude_binding.account_id).unwrap().unwrap();
657 assert_eq!(account.provider, "claude");
658 assert_eq!(account.kind, "oauth");
659 assert_eq!(account.status, oauth_status::VALID);
660 let agentmux_dir = tmp
661 .path()
662 .join(".agentmux")
663 .join("shared")
664 .join("providers")
665 .join("claude");
666 match account.secret_ref {
667 SecretRef::OAuthConfigDir { dir } => {
668 assert_eq!(dir, agentmux_dir.to_string_lossy());
669 }
670 other => panic!("expected OAuthConfigDir, got {:?}", other),
671 }
672
673 let dest_creds = agentmux_dir.join(".credentials.json");
676 assert!(dest_creds.exists(), "credential should be copied into the AgentMux dir");
677 assert!(
678 agentmux_dir.join(".agentmux-cred-seeded").exists(),
679 "import sentinel should be written"
680 );
681 let ambient_creds = tmp.path().join(".claude").join(".credentials.json");
682 assert!(ambient_creds.exists(), "ambient ~/.claude creds must remain (read-only import)");
683 assert_eq!(
684 std::fs::read(&ambient_creds).unwrap(),
685 std::fs::read(&dest_creds).unwrap(),
686 "copy must be byte-identical to the ambient source"
687 );
688 }
689
690 #[test]
691 fn sweep_repoints_existing_ambient_account_to_agentmux_dir() {
692 let store = make_store();
697 let tmp = tempfile::tempdir().unwrap();
698 plant_ambient_claude_creds(tmp.path());
699 let ambient_dir = tmp.path().join(".claude");
700
701 let legacy = IdentityAccount {
703 id: "acct-legacy".to_string(),
704 name: "claude-oauth".to_string(),
705 provider: "claude".to_string(),
706 kind: "oauth".to_string(),
707 display_name: String::new(),
708 secret_ref: SecretRef::OAuthConfigDir {
709 dir: ambient_dir.to_string_lossy().to_string(),
710 },
711 context: serde_json::json!({}),
712 status: oauth_status::VALID.to_string(),
713 created_at: 1,
714 updated_at: 1,
715 };
716 store.identity_upsert(&legacy).unwrap();
717 ensure_default_bundle(&store, 1).unwrap();
718 store
719 .bundle_identity_bind(DEFAULT_BUNDLE_ID, "claude", "acct-legacy")
720 .unwrap();
721
722 let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
723 assert_eq!(stats.providers_seeded, 0);
725 assert_eq!(stats.providers_repointed, 1);
726
727 let updated = store.identity_get("acct-legacy").unwrap().unwrap();
728 let agentmux_dir = tmp
729 .path()
730 .join(".agentmux")
731 .join("shared")
732 .join("providers")
733 .join("claude");
734 match updated.secret_ref {
735 SecretRef::OAuthConfigDir { dir } => {
736 assert_eq!(dir, agentmux_dir.to_string_lossy());
737 }
738 other => panic!("expected OAuthConfigDir, got {:?}", other),
739 }
740 assert!(
741 agentmux_dir.join(".credentials.json").exists(),
742 "creds copied into the AgentMux dir on sweep"
743 );
744
745 let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
747 assert_eq!(s2.providers_repointed, 0);
748 }
749
750 #[test]
751 fn idempotent_second_run_is_noop() {
752 let store = make_store();
756 let tmp = tempfile::tempdir().unwrap();
757 plant_ambient_claude_creds(tmp.path());
758
759 let s1 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
760 assert_eq!(s1.providers_seeded, 1);
761 assert!(s1.default_bundle_created);
762
763 let bindings_after_first = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
764 assert_eq!(bindings_after_first.len(), 1);
765
766 let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
767 assert_eq!(s2.providers_seeded, 0);
768 assert!(!s2.default_bundle_created);
771 assert_eq!(s2.providers_skipped_existing, 1);
772
773 let bindings_after_second = store.bundle_identity_bindings(DEFAULT_BUNDLE_ID).unwrap();
774 assert_eq!(bindings_after_second.len(), 1);
775 assert_eq!(
777 bindings_after_first[0].account_id,
778 bindings_after_second[0].account_id,
779 );
780 }
781
782 #[test]
783 fn existing_binding_in_other_bundle_skips_provider() {
784 let store = make_store();
790 let tmp = tempfile::tempdir().unwrap();
791 plant_ambient_claude_creds(tmp.path());
792
793 let work_bundle = Identity {
795 id: "work-bundle".to_string(),
796 name: "Work".to_string(),
797 description: String::new(),
798 is_blank: false,
799 created_at: 0,
800 updated_at: 0,
801 };
802 store.bundle_identity_upsert(&work_bundle).unwrap();
803 let work_account = IdentityAccount {
804 id: "acct-work-claude".to_string(),
805 name: "work-claude".to_string(),
806 provider: "claude".to_string(),
807 kind: "oauth".to_string(),
808 display_name: String::new(),
809 secret_ref: SecretRef::OAuthConfigDir {
810 dir: "/somewhere/work/claude".to_string(),
811 },
812 context: serde_json::json!({}),
813 status: oauth_status::VALID.to_string(),
814 created_at: 0,
815 updated_at: 0,
816 };
817 store.identity_upsert(&work_account).unwrap();
818 store
819 .bundle_identity_bind("work-bundle", "claude", "acct-work-claude")
820 .unwrap();
821
822 let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
823
824 assert_eq!(stats.providers_seeded, 0);
826 assert!(stats.providers_skipped_existing >= 1);
827
828 assert!(!stats.default_bundle_created);
832 let default = store.bundle_identity_get(DEFAULT_BUNDLE_ID).unwrap();
833 assert!(default.is_none(), "Default bundle must not be auto-created when nothing to seed");
834 }
835
836 #[test]
837 fn backfills_empty_identity_id_rows_after_seed() {
838 let store = make_store();
842 let tmp = tempfile::tempdir().unwrap();
843 plant_ambient_claude_creds(tmp.path());
844
845 let mut def = crate::backend::storage::store::AgentDefinition {
847 id: "def-1".to_string(),
848 slug: String::new(),
849 name: "T".to_string(),
850 icon: "✦".to_string(),
851 provider: "claude".to_string(),
852 description: String::new(),
853 working_directory: String::new(),
854 shell: String::new(),
855 provider_flags: String::new(),
856 auto_start: 0,
857 restart_on_crash: 0,
858 idle_timeout_minutes: 0,
859 created_at: 0,
860 agent_type: String::new(),
861 environment: String::new(),
862 agent_bus_id: String::new(),
863 is_seeded: 0,
864 accounts: String::new(),
865 parent_id: String::new(),
866 branch_label: String::new(),
867 updated_at: 0,
868 user_hidden: 0,
869 container_image: String::new(),
870 container_volumes: "[]".to_string(),
871 container_name: String::new(),
872 };
873 store.agent_def_insert(&mut def).unwrap();
874
875 let inst_empty = crate::backend::storage::store::AgentInstance {
879 id: "inst-empty".to_string(),
880 definition_id: "def-1".to_string(),
881 parent_instance_id: String::new(),
882 block_id: "block-empty".to_string(),
883 session_id: String::new(),
884 status: "running".to_string(),
885 github_context: String::new(),
886 started_at: 0,
887 ended_at: 0,
888 created_at: 0,
889 identity_id: String::new(),
890 memory_id: String::new(),
891 instance_name: String::new(),
892 working_directory: String::new(),
893 display_hidden: false,
894 };
895 store.instance_create(&inst_empty).unwrap();
896
897 let inst_blank = crate::backend::storage::store::AgentInstance {
898 id: "inst-blank".to_string(),
899 definition_id: "def-1".to_string(),
900 parent_instance_id: String::new(),
901 block_id: "block-blank".to_string(),
902 session_id: String::new(),
903 status: "running".to_string(),
904 github_context: String::new(),
905 started_at: 0,
906 ended_at: 0,
907 created_at: 0,
908 identity_id: "blank".to_string(),
909 memory_id: String::new(),
910 instance_name: String::new(),
911 working_directory: String::new(),
912 display_hidden: false,
913 };
914 store.instance_create(&inst_blank).unwrap();
915
916 let inst_set = crate::backend::storage::store::AgentInstance {
919 id: "inst-set".to_string(),
920 definition_id: "def-1".to_string(),
921 parent_instance_id: String::new(),
922 block_id: "block-set".to_string(),
923 session_id: String::new(),
924 status: "running".to_string(),
925 github_context: String::new(),
926 started_at: 0,
927 ended_at: 0,
928 created_at: 0,
929 identity_id: "some-existing-bundle".to_string(),
930 memory_id: String::new(),
931 instance_name: String::new(),
932 working_directory: String::new(),
933 display_hidden: false,
934 };
935 store.instance_create(&inst_set).unwrap();
936
937 let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
938
939 assert!(stats.default_bundle_created);
940 assert_eq!(stats.instances_backfilled, 2);
941
942 let after_empty = store.instance_get("inst-empty").unwrap().unwrap();
944 assert_eq!(after_empty.identity_id, DEFAULT_BUNDLE_ID);
945 let after_blank = store.instance_get("inst-blank").unwrap().unwrap();
946 assert_eq!(after_blank.identity_id, DEFAULT_BUNDLE_ID);
947 let after_set = store.instance_get("inst-set").unwrap().unwrap();
949 assert_eq!(after_set.identity_id, "some-existing-bundle");
950 }
951
952 #[test]
953 fn backfills_legacy_rows_added_between_runs() {
954 let store = make_store();
963 let tmp = tempfile::tempdir().unwrap();
964 plant_ambient_claude_creds(tmp.path());
965
966 let mut def = crate::backend::storage::store::AgentDefinition {
968 id: "def-1".to_string(),
969 slug: String::new(),
970 name: "T".to_string(),
971 icon: "✦".to_string(),
972 provider: "claude".to_string(),
973 description: String::new(),
974 working_directory: String::new(),
975 shell: String::new(),
976 provider_flags: String::new(),
977 auto_start: 0,
978 restart_on_crash: 0,
979 idle_timeout_minutes: 0,
980 created_at: 0,
981 agent_type: String::new(),
982 environment: String::new(),
983 agent_bus_id: String::new(),
984 is_seeded: 0,
985 accounts: String::new(),
986 parent_id: String::new(),
987 branch_label: String::new(),
988 updated_at: 0,
989 user_hidden: 0,
990 container_image: String::new(),
991 container_volumes: "[]".to_string(),
992 container_name: String::new(),
993 };
994 store.agent_def_insert(&mut def).unwrap();
995
996 let s1 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
997 assert!(s1.default_bundle_created);
998 assert_eq!(s1.instances_backfilled, 0); let inst_late = crate::backend::storage::store::AgentInstance {
1003 id: "inst-late".to_string(),
1004 definition_id: "def-1".to_string(),
1005 parent_instance_id: String::new(),
1006 block_id: "block-late".to_string(),
1007 session_id: String::new(),
1008 status: "running".to_string(),
1009 github_context: String::new(),
1010 started_at: 0,
1011 ended_at: 0,
1012 created_at: 0,
1013 identity_id: String::new(),
1014 memory_id: String::new(),
1015 instance_name: String::new(),
1016 working_directory: String::new(),
1017 display_hidden: false,
1018 };
1019 store.instance_create(&inst_late).unwrap();
1020
1021 let s2 = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
1025 assert!(!s2.default_bundle_created); assert_eq!(
1027 s2.instances_backfilled, 1,
1028 "subsequent-run back-fill must repair newly-added legacy rows"
1029 );
1030 let after = store.instance_get("inst-late").unwrap().unwrap();
1031 assert_eq!(after.identity_id, DEFAULT_BUNDLE_ID);
1032 }
1033
1034 #[test]
1035 fn no_ambient_no_default_bundle_no_backfill() {
1036 let store = make_store();
1040 let tmp = tempfile::tempdir().unwrap();
1041 let mut def = crate::backend::storage::store::AgentDefinition {
1045 id: "def-1".to_string(),
1046 slug: String::new(),
1047 name: "T".to_string(),
1048 icon: "✦".to_string(),
1049 provider: "claude".to_string(),
1050 description: String::new(),
1051 working_directory: String::new(),
1052 shell: String::new(),
1053 provider_flags: String::new(),
1054 auto_start: 0,
1055 restart_on_crash: 0,
1056 idle_timeout_minutes: 0,
1057 created_at: 0,
1058 agent_type: String::new(),
1059 environment: String::new(),
1060 agent_bus_id: String::new(),
1061 is_seeded: 0,
1062 accounts: String::new(),
1063 parent_id: String::new(),
1064 branch_label: String::new(),
1065 updated_at: 0,
1066 user_hidden: 0,
1067 container_image: String::new(),
1068 container_volumes: "[]".to_string(),
1069 container_name: String::new(),
1070 };
1071 store.agent_def_insert(&mut def).unwrap();
1072 let inst = crate::backend::storage::store::AgentInstance {
1073 id: "inst-empty".to_string(),
1074 definition_id: "def-1".to_string(),
1075 parent_instance_id: String::new(),
1076 block_id: "block-empty".to_string(),
1077 session_id: String::new(),
1078 status: "running".to_string(),
1079 github_context: String::new(),
1080 started_at: 0,
1081 ended_at: 0,
1082 created_at: 0,
1083 identity_id: String::new(),
1084 memory_id: String::new(),
1085 instance_name: String::new(),
1086 working_directory: String::new(),
1087 display_hidden: false,
1088 };
1089 store.instance_create(&inst).unwrap();
1090
1091 let stats = run_default_bundle_migration(&store, None, Some(tmp.path().to_path_buf()));
1092
1093 assert_eq!(stats.providers_seeded, 0);
1094 assert!(!stats.default_bundle_created);
1095 assert_eq!(stats.instances_backfilled, 0);
1096
1097 let after = store.instance_get("inst-empty").unwrap().unwrap();
1099 assert_eq!(after.identity_id, "");
1100 }
1101}