1use serde::{Deserialize, Serialize};
52use std::collections::HashMap;
53use std::path::{Path, PathBuf};
54
55use super::Editor;
56
57#[derive(Serialize, Deserialize, Debug, Clone)]
59pub(crate) struct PersistedWindow {
60 pub(crate) id: u64,
61 pub(crate) label: String,
62 pub(crate) root: PathBuf,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub(crate) project_path: Option<PathBuf>,
73 #[serde(default, skip_serializing_if = "is_false")]
79 pub(crate) shared_worktree: bool,
80 #[serde(default)]
84 pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
85 #[serde(default, skip_serializing_if = "is_local_authority_spec")]
91 pub(crate) authority_spec: crate::services::authority::SessionAuthoritySpec,
92}
93
94fn is_local_authority_spec(spec: &crate::services::authority::SessionAuthoritySpec) -> bool {
95 matches!(
96 spec,
97 crate::services::authority::SessionAuthoritySpec::Local
98 )
99}
100
101fn is_false(b: &bool) -> bool {
102 !b
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone)]
107pub(crate) struct PersistedWindows {
108 #[serde(default = "default_version")]
113 pub(crate) version: u32,
114 pub(crate) active: u64,
118 pub(crate) next_id: u64,
122 pub(crate) windows: Vec<PersistedWindow>,
123}
124
125fn default_version() -> u32 {
126 1
127}
128
129const CURRENT_VERSION: u32 = 2;
130
131pub(crate) fn read_persisted_windows_env(
148 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
149 data_dir: &Path,
150 _working_dir: &Path,
151) -> Option<PersistedWindows> {
152 let global_p = global_windows_path(data_dir);
156 if !filesystem.exists(&global_p) {
157 migrate_legacy_windows(filesystem, data_dir);
158 }
159 migrate_windows_json_into_workspaces(filesystem, data_dir);
160
161 let windows = discover_sessions(filesystem, data_dir);
165 if windows.is_empty() {
166 return None;
167 }
168 let next_id = windows.iter().map(|w| w.id).max().unwrap_or(0) + 1;
169 Some(PersistedWindows {
178 version: CURRENT_VERSION,
179 active: 0,
180 next_id,
181 windows,
182 })
183}
184
185fn workspaces_dir(data_dir: &Path) -> PathBuf {
186 data_dir.join("workspaces")
187}
188
189fn workspace_file_for(data_dir: &Path, root: &Path) -> PathBuf {
193 let filename = format!(
194 "{}.json",
195 crate::workspace::encode_path_for_filename(&canonical_key(root))
196 );
197 workspaces_dir(data_dir).join(filename)
198}
199
200fn basename_label(root: &Path) -> String {
201 root.file_name()
202 .and_then(|s| s.to_str())
203 .map(|s| s.to_string())
204 .unwrap_or_else(|| root.to_string_lossy().into_owned())
205}
206
207fn discover_sessions(
212 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
213 data_dir: &Path,
214) -> Vec<PersistedWindow> {
215 type SessionState = HashMap<String, HashMap<String, serde_json::Value>>;
216 let dir = workspaces_dir(data_dir);
217 let entries = match filesystem.read_dir(&dir) {
218 Ok(e) => e,
219 Err(_) => return Vec::new(),
220 };
221 let mut found: Vec<(
222 PathBuf,
223 String,
224 SessionState,
225 crate::services::authority::SessionAuthoritySpec,
226 )> = Vec::new();
227 for entry in entries {
228 let p = &entry.path;
229 if !entry.name.ends_with(".json") {
232 continue;
233 }
234 let Ok(bytes) = filesystem.read_file(p) else {
235 continue;
236 };
237 let Ok(val) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
238 continue;
239 };
240 let Some(root) = val.get("working_dir").and_then(|v| v.as_str()) else {
241 continue;
242 };
243 let root = PathBuf::from(root);
244 let authority_spec: crate::services::authority::SessionAuthoritySpec = val
249 .get("authority_spec")
250 .and_then(|v| serde_json::from_value(v.clone()).ok())
251 .unwrap_or_default();
252 if !authority_spec.is_remote() {
268 match filesystem.is_dir(&root) {
269 Ok(true) => {}
270 Ok(false) => {
271 let _ = filesystem.remove_file(p).ok();
272 continue;
273 }
274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
275 let _ = filesystem.remove_file(p).ok();
276 continue;
277 }
278 Err(_) => continue,
279 }
280 }
281 let label = val
282 .get("label")
283 .and_then(|v| v.as_str())
284 .map(|s| s.to_string())
285 .unwrap_or_else(|| basename_label(&root));
286 let plugin_state: SessionState = val
287 .get("session_plugin_state")
288 .and_then(|v| serde_json::from_value(v.clone()).ok())
289 .unwrap_or_default();
290 found.push((root, label, plugin_state, authority_spec));
291 }
292 found.sort_by(|a, b| canonical_key(&a.0).cmp(&canonical_key(&b.0)));
293 found
294 .into_iter()
295 .enumerate()
296 .map(|(i, (root, label, plugin_state, authority_spec))| {
297 let (project_path, shared_worktree) = read_orch_session_meta(&plugin_state);
298 PersistedWindow {
299 id: (i as u64) + 1,
300 label,
301 root,
302 project_path,
303 shared_worktree,
304 authority_spec,
305 plugin_state,
306 }
307 })
308 .collect()
309}
310
311fn migrate_windows_json_into_workspaces(
318 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
319 data_dir: &Path,
320) {
321 let global_p = global_windows_path(data_dir);
322 if !filesystem.exists(&global_p) {
323 return;
324 }
325 let Ok(bytes) = filesystem.read_file(&global_p) else {
326 return;
327 };
328 let Ok(env) = serde_json::from_slice::<PersistedWindows>(&bytes) else {
329 return; };
331 for w in &env.windows {
332 let ws_path = workspace_file_for(data_dir, &w.root);
333 if !filesystem.exists(&ws_path) {
334 continue;
335 }
336 let Ok(wbytes) = filesystem.read_file(&ws_path) else {
337 continue;
338 };
339 let Ok(mut val) = serde_json::from_slice::<serde_json::Value>(&wbytes) else {
340 continue;
341 };
342 if let Some(obj) = val.as_object_mut() {
343 obj.entry("label")
344 .or_insert_with(|| serde_json::Value::String(w.label.clone()));
345 if !obj.contains_key("session_plugin_state") && !w.plugin_state.is_empty() {
346 if let Ok(ps) = serde_json::to_value(&w.plugin_state) {
347 obj.insert("session_plugin_state".into(), ps);
348 }
349 }
350 }
351 if let Ok(out) = serde_json::to_vec_pretty(&val) {
352 let _ = filesystem.write_file(&ws_path, &out).ok();
354 }
355 }
356 let bak = global_p.with_extension("json.retired.bak");
358 if filesystem.rename(&global_p, &bak).is_err() {
359 let _ = filesystem.remove_file(&global_p).ok();
361 }
362}
363
364pub(crate) fn pick_active_window_for_cwd<'a>(
392 env: Option<&'a PersistedWindows>,
393 cwd: &Path,
394) -> Option<&'a PersistedWindow> {
395 let env = env?;
396 if let Some(w) = env
397 .windows
398 .iter()
399 .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
400 {
401 return Some(w);
402 }
403 env.windows
404 .iter()
405 .filter(|w| window_matches_cwd(w, cwd))
406 .max_by_key(|w| w.id)
407}
408
409fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
410 paths_equal(&w.root, cwd)
411}
412
413fn paths_equal(a: &Path, b: &Path) -> bool {
414 canonical_key(a) == canonical_key(b)
415}
416
417pub(crate) fn canonical_key(path: &Path) -> PathBuf {
424 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
425}
426
427fn migrate_legacy_windows(
441 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
442 data_dir: &Path,
443) {
444 let orch_root = data_dir.join("orchestrator");
445 if !filesystem.exists(&orch_root) {
446 return;
447 }
448 let entries = match filesystem.read_dir(&orch_root) {
449 Ok(es) => es,
450 Err(_) => return,
451 };
452 let mut merged_windows: Vec<PersistedWindow> = Vec::new();
453 let mut merged_active: u64 = 1;
454 let mut merged_next_id: u64 = 2;
455 let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
456 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
457
458 for entry in entries {
459 let dir = entry.path;
460 if !filesystem.is_dir(&dir).unwrap_or(false) {
461 continue;
462 }
463 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
467 Some(n) => n.to_string(),
468 None => continue,
469 };
470 if dir_name == "state" {
471 continue;
472 }
473 let legacy_p = dir.join("windows.json");
474 if !filesystem.exists(&legacy_p) {
475 continue;
476 }
477 let bytes = match filesystem.read_file(&legacy_p) {
478 Ok(b) => b,
479 Err(_) => continue,
480 };
481 let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
482 Ok(e) => e,
483 Err(_) => continue,
484 };
485 let project_path = crate::workspace::decode_filename_to_path(&dir_name)
486 .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
487
488 let mut local_renum: HashMap<u64, u64> = HashMap::new();
489 for mut w in env.windows.into_iter() {
490 if w.project_path.is_none() {
494 w.project_path = Some(project_path.clone());
495 }
496 if used_ids.contains(&w.id) {
497 let new_id = merged_next_id;
498 local_renum.insert(w.id, new_id);
499 merged_next_id = merged_next_id.saturating_add(1);
500 used_ids.insert(new_id);
501 w.id = new_id;
502 } else {
503 used_ids.insert(w.id);
504 merged_next_id = merged_next_id.max(w.id.saturating_add(1));
505 }
506 merged_windows.push(w);
507 }
508 let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
513 merged_active = active_id;
514 legacy_to_rename.push(legacy_p);
515 }
516
517 if merged_windows.is_empty() {
518 return;
519 }
520 merged_windows.sort_by_key(|w| w.id);
521 let envelope = PersistedWindows {
522 version: CURRENT_VERSION,
523 active: merged_active,
524 next_id: merged_next_id,
525 windows: merged_windows,
526 };
527 let global_p = global_windows_path(data_dir);
528 if let Err(e) = filesystem.create_dir_all(&orch_root) {
529 tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
530 return;
531 }
532 let bytes = match serde_json::to_vec_pretty(&envelope) {
533 Ok(b) => b,
534 Err(e) => {
535 tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
536 return;
537 }
538 };
539 if let Err(e) = filesystem.write_file(&global_p, &bytes) {
540 tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
541 return;
542 }
543 for legacy_p in legacy_to_rename {
544 let backup = legacy_p.with_extension("json.migrated.bak");
545 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
546 tracing::warn!(
547 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
548 );
549 }
550 }
551 tracing::info!(
552 "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
553 envelope.windows.len(),
554 global_p
555 );
556}
557
558pub(crate) fn read_persisted_plugin_state(
571 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
572 data_dir: &Path,
573 _working_dir: &Path,
574) -> HashMap<String, HashMap<String, serde_json::Value>> {
575 let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
576 let state_dir = global_state_dir(data_dir);
577 if !filesystem.exists(&state_dir) {
578 migrate_legacy_plugin_state(filesystem, data_dir);
579 }
580 if !filesystem.exists(&state_dir) {
581 return out;
582 }
583 let entries = match filesystem.read_dir(&state_dir) {
584 Ok(es) => es,
585 Err(e) => {
586 tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
587 return out;
588 }
589 };
590 for entry in entries {
591 let path = entry.path;
592 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
593 continue;
594 };
595 if !plugin_name_is_safe(stem) {
596 continue;
597 }
598 if path.extension().and_then(|e| e.to_str()) != Some("json") {
599 continue;
600 }
601 match filesystem.read_file(&path) {
602 Ok(bytes) => {
603 match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
604 Ok(map) if !map.is_empty() => {
605 out.insert(stem.to_owned(), map);
606 }
607 Ok(_) => {}
608 Err(e) => {
609 tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
610 }
611 }
612 }
613 Err(e) => {
614 tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
615 }
616 }
617 }
618 out
619}
620
621fn orchestrator_dir(data_dir: &Path) -> PathBuf {
626 data_dir.join("orchestrator")
627}
628
629fn global_windows_path(data_dir: &Path) -> PathBuf {
630 orchestrator_dir(data_dir).join("windows.json")
631}
632
633fn global_state_dir(data_dir: &Path) -> PathBuf {
634 orchestrator_dir(data_dir).join("state")
635}
636
637fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
638 global_state_dir(data_dir).join(format!("{plugin}.json"))
644}
645
646fn plugin_name_is_safe(name: &str) -> bool {
647 !name.is_empty()
648 && name
649 .chars()
650 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
651 && !name.starts_with('.')
652}
653
654fn migrate_legacy_plugin_state(
661 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
662 data_dir: &Path,
663) {
664 let orch_root = data_dir.join("orchestrator");
665 if !filesystem.exists(&orch_root) {
666 return;
667 }
668 let cwd_entries = match filesystem.read_dir(&orch_root) {
669 Ok(es) => es,
670 Err(_) => return,
671 };
672 let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
673 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
674 for cwd_entry in cwd_entries {
675 let dir = cwd_entry.path;
676 if !filesystem.is_dir(&dir).unwrap_or(false) {
677 continue;
678 }
679 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
680 Some(n) => n.to_string(),
681 None => continue,
682 };
683 if dir_name == "state" {
684 continue;
685 }
686 let state_dir = dir.join("state");
687 if !filesystem.exists(&state_dir) {
688 continue;
689 }
690 let plugin_entries = match filesystem.read_dir(&state_dir) {
691 Ok(es) => es,
692 Err(_) => continue,
693 };
694 for pe in plugin_entries {
695 let p = pe.path;
696 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
697 continue;
698 };
699 if !plugin_name_is_safe(stem) {
700 continue;
701 }
702 if p.extension().and_then(|e| e.to_str()) != Some("json") {
703 continue;
704 }
705 let bytes = match filesystem.read_file(&p) {
706 Ok(b) => b,
707 Err(_) => continue,
708 };
709 let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
710 Ok(m) => m,
711 Err(_) => continue,
712 };
713 let slot = merged.entry(stem.to_owned()).or_default();
714 for (k, v) in map {
715 slot.insert(k, v);
716 }
717 legacy_to_rename.push(p);
718 }
719 }
720 if merged.is_empty() {
721 return;
722 }
723 let target_state_dir = global_state_dir(data_dir);
724 if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
725 tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
726 return;
727 }
728 for (plugin, map) in &merged {
729 let path = global_plugin_state_path(data_dir, plugin);
730 let bytes = match serde_json::to_vec_pretty(map) {
731 Ok(b) => b,
732 Err(e) => {
733 tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
734 continue;
735 }
736 };
737 if let Err(e) = filesystem.write_file(&path, &bytes) {
738 tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
739 }
740 }
741 for legacy_p in legacy_to_rename {
742 let backup = legacy_p.with_extension("json.migrated.bak");
743 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
744 tracing::warn!(
745 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
746 );
747 }
748 }
749 tracing::info!(
750 "orchestrator persistence: migrated plugin state for {} plugins",
751 merged.len()
752 );
753}
754
755impl Editor {
756 pub fn save_orchestrator_state(&self) {
760 let data_dir = self.dir_context.data_dir.clone();
761 let orch_dir = orchestrator_dir(&data_dir);
762 if let Err(e) = self.authority().filesystem.create_dir_all(&orch_dir) {
763 tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
764 return;
765 }
766
767 let state_dir = global_state_dir(&data_dir);
779 if !self.plugin_global_state.is_empty() {
780 if let Err(e) = self.authority().filesystem.create_dir_all(&state_dir) {
781 tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
782 return;
783 }
784 }
785 for (plugin, map) in &self.plugin_global_state {
786 if !plugin_name_is_safe(plugin) {
787 tracing::warn!(
788 "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
789 );
790 continue;
791 }
792 if map.is_empty() {
793 continue;
794 }
795 match serde_json::to_vec_pretty(map) {
796 Ok(bytes) => {
797 let path = global_plugin_state_path(&data_dir, plugin);
798 let tmp = path.with_extension("json.tmp");
799 if let Err(e) = self.authority().filesystem.write_file(&tmp, &bytes) {
800 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
801 continue;
802 }
803 if let Err(e) = self.authority().filesystem.rename(&tmp, &path) {
804 tracing::warn!(
805 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
806 );
807 }
808 }
809 Err(e) => {
810 tracing::warn!(
811 "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
812 );
813 }
814 }
815 }
816 }
817}
818
819fn read_orch_session_meta(
825 plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
826) -> (Option<PathBuf>, bool) {
827 let slot = plugin_state.get("orchestrator");
828 let project_path = slot
829 .and_then(|m| m.get("project_path"))
830 .and_then(|v| v.as_str())
831 .map(PathBuf::from);
832 let shared_worktree = slot
833 .and_then(|m| m.get("shared_worktree"))
834 .and_then(|v| v.as_bool())
835 .unwrap_or(false);
836 (project_path, shared_worktree)
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn paths_live_under_data_dir_not_working_dir() {
845 let data_dir = Path::new("/tmp/fresh-data");
848 let working_dir = Path::new("/home/user/project");
849
850 let wp = global_windows_path(data_dir);
851 let sd = global_state_dir(data_dir);
852 let psp = global_plugin_state_path(data_dir, "orchestrator");
853
854 assert!(
855 wp.starts_with(data_dir),
856 "windows_path must live under data_dir, got {wp:?}"
857 );
858 assert!(
859 sd.starts_with(data_dir),
860 "state_dir must live under data_dir, got {sd:?}"
861 );
862 assert!(
863 psp.starts_with(data_dir),
864 "plugin_state_path must live under data_dir, got {psp:?}"
865 );
866
867 for p in [&wp, &sd, &psp] {
868 assert!(
869 !p.starts_with(working_dir),
870 "orchestrator path must not be inside the working tree: {p:?}"
871 );
872 for component in p.components() {
873 if let std::path::Component::Normal(c) = component {
874 assert_ne!(
875 c, ".fresh",
876 "orchestrator path must not contain a `.fresh` component: {p:?}"
877 );
878 }
879 }
880 }
881 }
882
883 fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
884 PersistedWindow {
885 id,
886 label: String::new(),
887 root: PathBuf::from(root),
888 project_path: project_path.map(PathBuf::from),
889 shared_worktree: false,
890 authority_spec: Default::default(),
891 plugin_state: HashMap::new(),
892 }
893 }
894
895 fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
896 PersistedWindows {
897 version: CURRENT_VERSION,
898 active,
899 next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
900 windows,
901 }
902 }
903
904 #[test]
905 fn pick_active_never_crosses_projects() {
906 let env = env_with(
910 2,
911 vec![
912 make_window(1, "/repoA", Some("/repoA")),
913 make_window(2, "/repoA", Some("/repoA")),
914 make_window(3, "/repoB", Some("/repoB")),
915 ],
916 );
917 let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
918 .expect("a /repoB session exists");
919 assert_eq!(
920 picked.id, 3,
921 "must pick the /repoB session, not env.active=2"
922 );
923 }
924
925 #[test]
926 fn pick_active_reopens_last_used_for_cwd() {
927 let env = env_with(
930 2,
931 vec![
932 make_window(2, "/repoA", Some("/repoA")),
933 make_window(5, "/repoA", Some("/repoA")),
934 ],
935 );
936 let picked =
937 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
938 assert_eq!(
939 picked.id, 2,
940 "env.active is the last-used session for the cwd"
941 );
942 }
943
944 #[test]
945 fn pick_active_falls_back_to_most_recent_session_for_cwd() {
946 let env = env_with(
950 9,
951 vec![
952 make_window(2, "/repoA", Some("/repoA")),
953 make_window(7, "/repoA", Some("/repoA")),
954 make_window(9, "/repoB", Some("/repoB")),
955 ],
956 );
957 let picked =
958 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
959 assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
960 }
961
962 #[test]
963 fn pick_active_returns_none_when_no_window_matches_cwd() {
964 let env = env_with(
966 1,
967 vec![
968 make_window(1, "/repoA", Some("/repoA")),
969 make_window(2, "/repoB", Some("/repoB")),
970 ],
971 );
972 assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
973 }
974
975 #[test]
976 fn pick_active_falls_back_to_root_when_project_path_missing() {
977 let env = env_with(
979 2,
980 vec![
981 make_window(1, "/repoA", None),
982 make_window(2, "/repoB", None),
983 ],
984 );
985 let picked =
986 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
987 assert_eq!(picked.id, 1);
988 }
989
990 #[test]
991 fn global_paths_are_independent_of_working_dir() {
992 let data_dir = Path::new("/tmp/fresh-data");
997 let a = global_windows_path(data_dir);
998 let b = global_windows_path(data_dir);
999 assert_eq!(a, b);
1000 assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
1001 }
1002
1003 #[test]
1004 fn discover_gcs_missing_dirs_and_yields_one_session_per_existing_dir() {
1005 use crate::model::filesystem::StdFileSystem;
1006 let data = tempfile::tempdir().unwrap();
1007 let data_dir = data.path();
1008 let ws_dir = workspaces_dir(data_dir);
1009 std::fs::create_dir_all(&ws_dir).unwrap();
1010
1011 let live = tempfile::tempdir().unwrap();
1013 let live_root = live.path().canonicalize().unwrap();
1014 let live_file = ws_dir.join("live.json");
1015 std::fs::write(
1016 &live_file,
1017 serde_json::to_vec(&serde_json::json!({
1018 "working_dir": live_root, "label": "live-session",
1019 }))
1020 .unwrap(),
1021 )
1022 .unwrap();
1023
1024 let dead_file = ws_dir.join("dead.json");
1026 std::fs::write(
1027 &dead_file,
1028 serde_json::to_vec(&serde_json::json!({
1029 "working_dir": "/no/such/dir/anywhere", "label": "dead",
1030 }))
1031 .unwrap(),
1032 )
1033 .unwrap();
1034
1035 let fs = StdFileSystem;
1036 let sessions = discover_sessions(&fs, data_dir);
1037
1038 assert_eq!(sessions.len(), 1, "only the existing dir yields a session");
1039 assert_eq!(sessions[0].root, live_root);
1040 assert_eq!(sessions[0].label, "live-session");
1041 assert!(!dead_file.exists(), "the dead dir's cache file was GC'd");
1042 assert!(live_file.exists(), "the live cache file is kept");
1043 }
1044
1045 #[test]
1046 fn discover_reads_authority_spec_so_remote_sessions_arent_lost() {
1047 use crate::model::filesystem::StdFileSystem;
1053 use crate::services::authority::{
1054 AuthorityPayload, FilesystemSpec, SessionAuthoritySpec, SpawnerSpec,
1055 TerminalWrapperSpec,
1056 };
1057 let data = tempfile::tempdir().unwrap();
1058 let data_dir = data.path();
1059 let ws_dir = workspaces_dir(data_dir);
1060 std::fs::create_dir_all(&ws_dir).unwrap();
1061
1062 let remote_root = tempfile::tempdir().unwrap();
1063 let remote_root = remote_root.path().canonicalize().unwrap();
1064 let spec = SessionAuthoritySpec::Plugin(AuthorityPayload {
1065 filesystem: FilesystemSpec::Local,
1066 spawner: SpawnerSpec::DockerExec {
1067 container_id: "abc123".into(),
1068 user: Some("vscode".into()),
1069 workspace: Some("/workspaces/proj".into()),
1070 env: Vec::new(),
1071 },
1072 terminal_wrapper: TerminalWrapperSpec::HostShell,
1073 display_label: "Container:abc123".into(),
1074 path_translation: None,
1075 });
1076 std::fs::write(
1077 ws_dir.join("remote.json"),
1078 serde_json::to_vec(&serde_json::json!({
1079 "working_dir": remote_root,
1080 "label": "remote-session",
1081 "authority_spec": spec,
1082 }))
1083 .unwrap(),
1084 )
1085 .unwrap();
1086
1087 let local_root = tempfile::tempdir().unwrap();
1089 let local_root = local_root.path().canonicalize().unwrap();
1090 std::fs::write(
1091 ws_dir.join("local.json"),
1092 serde_json::to_vec(&serde_json::json!({
1093 "working_dir": local_root, "label": "local-session",
1094 }))
1095 .unwrap(),
1096 )
1097 .unwrap();
1098
1099 let fs = StdFileSystem;
1100 let sessions = discover_sessions(&fs, data_dir);
1101
1102 let remote = sessions
1103 .iter()
1104 .find(|s| s.label == "remote-session")
1105 .expect("remote session discovered");
1106 assert_eq!(
1107 remote.authority_spec, spec,
1108 "the remote backend spec round-trips through discovery"
1109 );
1110 let local = sessions
1111 .iter()
1112 .find(|s| s.label == "local-session")
1113 .expect("local session discovered");
1114 assert_eq!(
1115 local.authority_spec,
1116 SessionAuthoritySpec::Local,
1117 "a session with no persisted spec reads back as Local"
1118 );
1119 }
1120
1121 #[test]
1122 fn discover_keeps_remote_session_whose_root_is_absent_locally() {
1123 use crate::model::filesystem::StdFileSystem;
1131 use crate::services::authority::{
1132 RemoteAgentSpec, RemoteTransportSpec, SessionAuthoritySpec,
1133 };
1134 let data = tempfile::tempdir().unwrap();
1135 let data_dir = data.path();
1136 let ws_dir = workspaces_dir(data_dir);
1137 std::fs::create_dir_all(&ws_dir).unwrap();
1138
1139 let remote_only_root = "/home/remote-user/project-on-remote-host";
1142 assert!(
1143 !Path::new(remote_only_root).exists(),
1144 "test precondition: the remote root must not exist locally"
1145 );
1146 let spec = SessionAuthoritySpec::RemoteAgent(RemoteAgentSpec {
1147 transport: RemoteTransportSpec::Ssh {
1148 user: Some("remote-user".into()),
1149 host: "example.com".into(),
1150 port: None,
1151 identity_file: None,
1152 remote_path: Some(remote_only_root.into()),
1153 extra_args: Vec::new(),
1154 },
1155 base_env: Vec::new(),
1156 window: true,
1157 label: Some("ssh-session".into()),
1158 command: None,
1159 });
1160 std::fs::write(
1161 ws_dir.join("ssh.json"),
1162 serde_json::to_vec(&serde_json::json!({
1163 "working_dir": remote_only_root,
1164 "label": "ssh-session",
1165 "authority_spec": spec,
1166 }))
1167 .unwrap(),
1168 )
1169 .unwrap();
1170
1171 let fs = StdFileSystem;
1172 let sessions = discover_sessions(&fs, data_dir);
1173
1174 let ssh = sessions
1175 .iter()
1176 .find(|s| s.label == "ssh-session")
1177 .expect("the SSH session survives discovery despite a remote-only root");
1178 assert_eq!(ssh.authority_spec, spec);
1179 assert!(
1180 ws_dir.join("ssh.json").exists(),
1181 "the remote session's workspace file must not be GC'd"
1182 );
1183 }
1184
1185 #[test]
1186 fn migrate_folds_windows_json_into_workspace_files_and_retires_it() {
1187 use crate::model::filesystem::StdFileSystem;
1188 let data = tempfile::tempdir().unwrap();
1189 let data_dir = data.path();
1190 let proj = tempfile::tempdir().unwrap();
1191 let proj_root = proj.path().canonicalize().unwrap();
1192
1193 let ws_path = workspace_file_for(data_dir, &proj_root);
1195 std::fs::create_dir_all(ws_path.parent().unwrap()).unwrap();
1196 std::fs::write(
1197 &ws_path,
1198 serde_json::to_vec(&serde_json::json!({ "working_dir": proj_root })).unwrap(),
1199 )
1200 .unwrap();
1201
1202 let global_p = global_windows_path(data_dir);
1204 std::fs::create_dir_all(global_p.parent().unwrap()).unwrap();
1205 std::fs::write(
1206 &global_p,
1207 serde_json::to_vec(&serde_json::json!({
1208 "version": 2, "active": 1, "next_id": 2,
1209 "windows": [ { "id": 1, "label": "from-windows-json", "root": proj_root } ],
1210 }))
1211 .unwrap(),
1212 )
1213 .unwrap();
1214
1215 let fs = StdFileSystem;
1216 migrate_windows_json_into_workspaces(&fs, data_dir);
1217
1218 assert!(!global_p.exists(), "windows.json is retired");
1219 assert!(
1220 global_p.with_extension("json.retired.bak").exists(),
1221 "a .retired.bak is kept"
1222 );
1223 let val: serde_json::Value =
1224 serde_json::from_slice(&std::fs::read(&ws_path).unwrap()).unwrap();
1225 assert_eq!(
1226 val.get("label").and_then(|v| v.as_str()),
1227 Some("from-windows-json"),
1228 "the label was folded into the per-dir workspace file"
1229 );
1230 }
1231}