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 match filesystem.is_dir(&root) {
252 Ok(true) => {}
253 Ok(false) => {
254 let _ = filesystem.remove_file(p).ok();
255 continue;
256 }
257 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
258 let _ = filesystem.remove_file(p).ok();
259 continue;
260 }
261 Err(_) => continue,
262 }
263 let label = val
264 .get("label")
265 .and_then(|v| v.as_str())
266 .map(|s| s.to_string())
267 .unwrap_or_else(|| basename_label(&root));
268 let plugin_state: SessionState = val
269 .get("session_plugin_state")
270 .and_then(|v| serde_json::from_value(v.clone()).ok())
271 .unwrap_or_default();
272 let authority_spec: crate::services::authority::SessionAuthoritySpec = val
275 .get("authority_spec")
276 .and_then(|v| serde_json::from_value(v.clone()).ok())
277 .unwrap_or_default();
278 found.push((root, label, plugin_state, authority_spec));
279 }
280 found.sort_by(|a, b| canonical_key(&a.0).cmp(&canonical_key(&b.0)));
281 found
282 .into_iter()
283 .enumerate()
284 .map(|(i, (root, label, plugin_state, authority_spec))| {
285 let (project_path, shared_worktree) = read_orch_session_meta(&plugin_state);
286 PersistedWindow {
287 id: (i as u64) + 1,
288 label,
289 root,
290 project_path,
291 shared_worktree,
292 authority_spec,
293 plugin_state,
294 }
295 })
296 .collect()
297}
298
299fn migrate_windows_json_into_workspaces(
306 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
307 data_dir: &Path,
308) {
309 let global_p = global_windows_path(data_dir);
310 if !filesystem.exists(&global_p) {
311 return;
312 }
313 let Ok(bytes) = filesystem.read_file(&global_p) else {
314 return;
315 };
316 let Ok(env) = serde_json::from_slice::<PersistedWindows>(&bytes) else {
317 return; };
319 for w in &env.windows {
320 let ws_path = workspace_file_for(data_dir, &w.root);
321 if !filesystem.exists(&ws_path) {
322 continue;
323 }
324 let Ok(wbytes) = filesystem.read_file(&ws_path) else {
325 continue;
326 };
327 let Ok(mut val) = serde_json::from_slice::<serde_json::Value>(&wbytes) else {
328 continue;
329 };
330 if let Some(obj) = val.as_object_mut() {
331 obj.entry("label")
332 .or_insert_with(|| serde_json::Value::String(w.label.clone()));
333 if !obj.contains_key("session_plugin_state") && !w.plugin_state.is_empty() {
334 if let Ok(ps) = serde_json::to_value(&w.plugin_state) {
335 obj.insert("session_plugin_state".into(), ps);
336 }
337 }
338 }
339 if let Ok(out) = serde_json::to_vec_pretty(&val) {
340 let _ = filesystem.write_file(&ws_path, &out).ok();
342 }
343 }
344 let bak = global_p.with_extension("json.retired.bak");
346 if filesystem.rename(&global_p, &bak).is_err() {
347 let _ = filesystem.remove_file(&global_p).ok();
349 }
350}
351
352pub(crate) fn pick_active_window_for_cwd<'a>(
380 env: Option<&'a PersistedWindows>,
381 cwd: &Path,
382) -> Option<&'a PersistedWindow> {
383 let env = env?;
384 if let Some(w) = env
385 .windows
386 .iter()
387 .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
388 {
389 return Some(w);
390 }
391 env.windows
392 .iter()
393 .filter(|w| window_matches_cwd(w, cwd))
394 .max_by_key(|w| w.id)
395}
396
397fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
398 paths_equal(&w.root, cwd)
399}
400
401fn paths_equal(a: &Path, b: &Path) -> bool {
402 canonical_key(a) == canonical_key(b)
403}
404
405pub(crate) fn canonical_key(path: &Path) -> PathBuf {
412 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
413}
414
415fn migrate_legacy_windows(
429 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
430 data_dir: &Path,
431) {
432 let orch_root = data_dir.join("orchestrator");
433 if !filesystem.exists(&orch_root) {
434 return;
435 }
436 let entries = match filesystem.read_dir(&orch_root) {
437 Ok(es) => es,
438 Err(_) => return,
439 };
440 let mut merged_windows: Vec<PersistedWindow> = Vec::new();
441 let mut merged_active: u64 = 1;
442 let mut merged_next_id: u64 = 2;
443 let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
444 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
445
446 for entry in entries {
447 let dir = entry.path;
448 if !filesystem.is_dir(&dir).unwrap_or(false) {
449 continue;
450 }
451 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
455 Some(n) => n.to_string(),
456 None => continue,
457 };
458 if dir_name == "state" {
459 continue;
460 }
461 let legacy_p = dir.join("windows.json");
462 if !filesystem.exists(&legacy_p) {
463 continue;
464 }
465 let bytes = match filesystem.read_file(&legacy_p) {
466 Ok(b) => b,
467 Err(_) => continue,
468 };
469 let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
470 Ok(e) => e,
471 Err(_) => continue,
472 };
473 let project_path = crate::workspace::decode_filename_to_path(&dir_name)
474 .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
475
476 let mut local_renum: HashMap<u64, u64> = HashMap::new();
477 for mut w in env.windows.into_iter() {
478 if w.project_path.is_none() {
482 w.project_path = Some(project_path.clone());
483 }
484 if used_ids.contains(&w.id) {
485 let new_id = merged_next_id;
486 local_renum.insert(w.id, new_id);
487 merged_next_id = merged_next_id.saturating_add(1);
488 used_ids.insert(new_id);
489 w.id = new_id;
490 } else {
491 used_ids.insert(w.id);
492 merged_next_id = merged_next_id.max(w.id.saturating_add(1));
493 }
494 merged_windows.push(w);
495 }
496 let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
501 merged_active = active_id;
502 legacy_to_rename.push(legacy_p);
503 }
504
505 if merged_windows.is_empty() {
506 return;
507 }
508 merged_windows.sort_by_key(|w| w.id);
509 let envelope = PersistedWindows {
510 version: CURRENT_VERSION,
511 active: merged_active,
512 next_id: merged_next_id,
513 windows: merged_windows,
514 };
515 let global_p = global_windows_path(data_dir);
516 if let Err(e) = filesystem.create_dir_all(&orch_root) {
517 tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
518 return;
519 }
520 let bytes = match serde_json::to_vec_pretty(&envelope) {
521 Ok(b) => b,
522 Err(e) => {
523 tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
524 return;
525 }
526 };
527 if let Err(e) = filesystem.write_file(&global_p, &bytes) {
528 tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
529 return;
530 }
531 for legacy_p in legacy_to_rename {
532 let backup = legacy_p.with_extension("json.migrated.bak");
533 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
534 tracing::warn!(
535 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
536 );
537 }
538 }
539 tracing::info!(
540 "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
541 envelope.windows.len(),
542 global_p
543 );
544}
545
546pub(crate) fn read_persisted_plugin_state(
559 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
560 data_dir: &Path,
561 _working_dir: &Path,
562) -> HashMap<String, HashMap<String, serde_json::Value>> {
563 let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
564 let state_dir = global_state_dir(data_dir);
565 if !filesystem.exists(&state_dir) {
566 migrate_legacy_plugin_state(filesystem, data_dir);
567 }
568 if !filesystem.exists(&state_dir) {
569 return out;
570 }
571 let entries = match filesystem.read_dir(&state_dir) {
572 Ok(es) => es,
573 Err(e) => {
574 tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
575 return out;
576 }
577 };
578 for entry in entries {
579 let path = entry.path;
580 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
581 continue;
582 };
583 if !plugin_name_is_safe(stem) {
584 continue;
585 }
586 if path.extension().and_then(|e| e.to_str()) != Some("json") {
587 continue;
588 }
589 match filesystem.read_file(&path) {
590 Ok(bytes) => {
591 match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
592 Ok(map) if !map.is_empty() => {
593 out.insert(stem.to_owned(), map);
594 }
595 Ok(_) => {}
596 Err(e) => {
597 tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
598 }
599 }
600 }
601 Err(e) => {
602 tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
603 }
604 }
605 }
606 out
607}
608
609fn orchestrator_dir(data_dir: &Path) -> PathBuf {
614 data_dir.join("orchestrator")
615}
616
617fn global_windows_path(data_dir: &Path) -> PathBuf {
618 orchestrator_dir(data_dir).join("windows.json")
619}
620
621fn global_state_dir(data_dir: &Path) -> PathBuf {
622 orchestrator_dir(data_dir).join("state")
623}
624
625fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
626 global_state_dir(data_dir).join(format!("{plugin}.json"))
632}
633
634fn plugin_name_is_safe(name: &str) -> bool {
635 !name.is_empty()
636 && name
637 .chars()
638 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
639 && !name.starts_with('.')
640}
641
642fn migrate_legacy_plugin_state(
649 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
650 data_dir: &Path,
651) {
652 let orch_root = data_dir.join("orchestrator");
653 if !filesystem.exists(&orch_root) {
654 return;
655 }
656 let cwd_entries = match filesystem.read_dir(&orch_root) {
657 Ok(es) => es,
658 Err(_) => return,
659 };
660 let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
661 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
662 for cwd_entry in cwd_entries {
663 let dir = cwd_entry.path;
664 if !filesystem.is_dir(&dir).unwrap_or(false) {
665 continue;
666 }
667 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
668 Some(n) => n.to_string(),
669 None => continue,
670 };
671 if dir_name == "state" {
672 continue;
673 }
674 let state_dir = dir.join("state");
675 if !filesystem.exists(&state_dir) {
676 continue;
677 }
678 let plugin_entries = match filesystem.read_dir(&state_dir) {
679 Ok(es) => es,
680 Err(_) => continue,
681 };
682 for pe in plugin_entries {
683 let p = pe.path;
684 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
685 continue;
686 };
687 if !plugin_name_is_safe(stem) {
688 continue;
689 }
690 if p.extension().and_then(|e| e.to_str()) != Some("json") {
691 continue;
692 }
693 let bytes = match filesystem.read_file(&p) {
694 Ok(b) => b,
695 Err(_) => continue,
696 };
697 let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
698 Ok(m) => m,
699 Err(_) => continue,
700 };
701 let slot = merged.entry(stem.to_owned()).or_default();
702 for (k, v) in map {
703 slot.insert(k, v);
704 }
705 legacy_to_rename.push(p);
706 }
707 }
708 if merged.is_empty() {
709 return;
710 }
711 let target_state_dir = global_state_dir(data_dir);
712 if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
713 tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
714 return;
715 }
716 for (plugin, map) in &merged {
717 let path = global_plugin_state_path(data_dir, plugin);
718 let bytes = match serde_json::to_vec_pretty(map) {
719 Ok(b) => b,
720 Err(e) => {
721 tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
722 continue;
723 }
724 };
725 if let Err(e) = filesystem.write_file(&path, &bytes) {
726 tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
727 }
728 }
729 for legacy_p in legacy_to_rename {
730 let backup = legacy_p.with_extension("json.migrated.bak");
731 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
732 tracing::warn!(
733 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
734 );
735 }
736 }
737 tracing::info!(
738 "orchestrator persistence: migrated plugin state for {} plugins",
739 merged.len()
740 );
741}
742
743impl Editor {
744 pub fn save_orchestrator_state(&self) {
748 let data_dir = self.dir_context.data_dir.clone();
749 let orch_dir = orchestrator_dir(&data_dir);
750 if let Err(e) = self.authority().filesystem.create_dir_all(&orch_dir) {
751 tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
752 return;
753 }
754
755 let state_dir = global_state_dir(&data_dir);
767 if !self.plugin_global_state.is_empty() {
768 if let Err(e) = self.authority().filesystem.create_dir_all(&state_dir) {
769 tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
770 return;
771 }
772 }
773 for (plugin, map) in &self.plugin_global_state {
774 if !plugin_name_is_safe(plugin) {
775 tracing::warn!(
776 "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
777 );
778 continue;
779 }
780 if map.is_empty() {
781 continue;
782 }
783 match serde_json::to_vec_pretty(map) {
784 Ok(bytes) => {
785 let path = global_plugin_state_path(&data_dir, plugin);
786 let tmp = path.with_extension("json.tmp");
787 if let Err(e) = self.authority().filesystem.write_file(&tmp, &bytes) {
788 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
789 continue;
790 }
791 if let Err(e) = self.authority().filesystem.rename(&tmp, &path) {
792 tracing::warn!(
793 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
794 );
795 }
796 }
797 Err(e) => {
798 tracing::warn!(
799 "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
800 );
801 }
802 }
803 }
804 }
805}
806
807fn read_orch_session_meta(
813 plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
814) -> (Option<PathBuf>, bool) {
815 let slot = plugin_state.get("orchestrator");
816 let project_path = slot
817 .and_then(|m| m.get("project_path"))
818 .and_then(|v| v.as_str())
819 .map(PathBuf::from);
820 let shared_worktree = slot
821 .and_then(|m| m.get("shared_worktree"))
822 .and_then(|v| v.as_bool())
823 .unwrap_or(false);
824 (project_path, shared_worktree)
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn paths_live_under_data_dir_not_working_dir() {
833 let data_dir = Path::new("/tmp/fresh-data");
836 let working_dir = Path::new("/home/user/project");
837
838 let wp = global_windows_path(data_dir);
839 let sd = global_state_dir(data_dir);
840 let psp = global_plugin_state_path(data_dir, "orchestrator");
841
842 assert!(
843 wp.starts_with(data_dir),
844 "windows_path must live under data_dir, got {wp:?}"
845 );
846 assert!(
847 sd.starts_with(data_dir),
848 "state_dir must live under data_dir, got {sd:?}"
849 );
850 assert!(
851 psp.starts_with(data_dir),
852 "plugin_state_path must live under data_dir, got {psp:?}"
853 );
854
855 for p in [&wp, &sd, &psp] {
856 assert!(
857 !p.starts_with(working_dir),
858 "orchestrator path must not be inside the working tree: {p:?}"
859 );
860 for component in p.components() {
861 if let std::path::Component::Normal(c) = component {
862 assert_ne!(
863 c, ".fresh",
864 "orchestrator path must not contain a `.fresh` component: {p:?}"
865 );
866 }
867 }
868 }
869 }
870
871 fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
872 PersistedWindow {
873 id,
874 label: String::new(),
875 root: PathBuf::from(root),
876 project_path: project_path.map(PathBuf::from),
877 shared_worktree: false,
878 authority_spec: Default::default(),
879 plugin_state: HashMap::new(),
880 }
881 }
882
883 fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
884 PersistedWindows {
885 version: CURRENT_VERSION,
886 active,
887 next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
888 windows,
889 }
890 }
891
892 #[test]
893 fn pick_active_never_crosses_projects() {
894 let env = env_with(
898 2,
899 vec![
900 make_window(1, "/repoA", Some("/repoA")),
901 make_window(2, "/repoA", Some("/repoA")),
902 make_window(3, "/repoB", Some("/repoB")),
903 ],
904 );
905 let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
906 .expect("a /repoB session exists");
907 assert_eq!(
908 picked.id, 3,
909 "must pick the /repoB session, not env.active=2"
910 );
911 }
912
913 #[test]
914 fn pick_active_reopens_last_used_for_cwd() {
915 let env = env_with(
918 2,
919 vec![
920 make_window(2, "/repoA", Some("/repoA")),
921 make_window(5, "/repoA", Some("/repoA")),
922 ],
923 );
924 let picked =
925 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
926 assert_eq!(
927 picked.id, 2,
928 "env.active is the last-used session for the cwd"
929 );
930 }
931
932 #[test]
933 fn pick_active_falls_back_to_most_recent_session_for_cwd() {
934 let env = env_with(
938 9,
939 vec![
940 make_window(2, "/repoA", Some("/repoA")),
941 make_window(7, "/repoA", Some("/repoA")),
942 make_window(9, "/repoB", Some("/repoB")),
943 ],
944 );
945 let picked =
946 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
947 assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
948 }
949
950 #[test]
951 fn pick_active_returns_none_when_no_window_matches_cwd() {
952 let env = env_with(
954 1,
955 vec![
956 make_window(1, "/repoA", Some("/repoA")),
957 make_window(2, "/repoB", Some("/repoB")),
958 ],
959 );
960 assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
961 }
962
963 #[test]
964 fn pick_active_falls_back_to_root_when_project_path_missing() {
965 let env = env_with(
967 2,
968 vec![
969 make_window(1, "/repoA", None),
970 make_window(2, "/repoB", None),
971 ],
972 );
973 let picked =
974 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
975 assert_eq!(picked.id, 1);
976 }
977
978 #[test]
979 fn global_paths_are_independent_of_working_dir() {
980 let data_dir = Path::new("/tmp/fresh-data");
985 let a = global_windows_path(data_dir);
986 let b = global_windows_path(data_dir);
987 assert_eq!(a, b);
988 assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
989 }
990
991 #[test]
992 fn discover_gcs_missing_dirs_and_yields_one_session_per_existing_dir() {
993 use crate::model::filesystem::StdFileSystem;
994 let data = tempfile::tempdir().unwrap();
995 let data_dir = data.path();
996 let ws_dir = workspaces_dir(data_dir);
997 std::fs::create_dir_all(&ws_dir).unwrap();
998
999 let live = tempfile::tempdir().unwrap();
1001 let live_root = live.path().canonicalize().unwrap();
1002 let live_file = ws_dir.join("live.json");
1003 std::fs::write(
1004 &live_file,
1005 serde_json::to_vec(&serde_json::json!({
1006 "working_dir": live_root, "label": "live-session",
1007 }))
1008 .unwrap(),
1009 )
1010 .unwrap();
1011
1012 let dead_file = ws_dir.join("dead.json");
1014 std::fs::write(
1015 &dead_file,
1016 serde_json::to_vec(&serde_json::json!({
1017 "working_dir": "/no/such/dir/anywhere", "label": "dead",
1018 }))
1019 .unwrap(),
1020 )
1021 .unwrap();
1022
1023 let fs = StdFileSystem;
1024 let sessions = discover_sessions(&fs, data_dir);
1025
1026 assert_eq!(sessions.len(), 1, "only the existing dir yields a session");
1027 assert_eq!(sessions[0].root, live_root);
1028 assert_eq!(sessions[0].label, "live-session");
1029 assert!(!dead_file.exists(), "the dead dir's cache file was GC'd");
1030 assert!(live_file.exists(), "the live cache file is kept");
1031 }
1032
1033 #[test]
1034 fn discover_reads_authority_spec_so_remote_sessions_arent_lost() {
1035 use crate::model::filesystem::StdFileSystem;
1041 use crate::services::authority::{
1042 AuthorityPayload, FilesystemSpec, SessionAuthoritySpec, SpawnerSpec,
1043 TerminalWrapperSpec,
1044 };
1045 let data = tempfile::tempdir().unwrap();
1046 let data_dir = data.path();
1047 let ws_dir = workspaces_dir(data_dir);
1048 std::fs::create_dir_all(&ws_dir).unwrap();
1049
1050 let remote_root = tempfile::tempdir().unwrap();
1051 let remote_root = remote_root.path().canonicalize().unwrap();
1052 let spec = SessionAuthoritySpec::Plugin(AuthorityPayload {
1053 filesystem: FilesystemSpec::Local,
1054 spawner: SpawnerSpec::DockerExec {
1055 container_id: "abc123".into(),
1056 user: Some("vscode".into()),
1057 workspace: Some("/workspaces/proj".into()),
1058 env: Vec::new(),
1059 },
1060 terminal_wrapper: TerminalWrapperSpec::HostShell,
1061 display_label: "Container:abc123".into(),
1062 path_translation: None,
1063 });
1064 std::fs::write(
1065 ws_dir.join("remote.json"),
1066 serde_json::to_vec(&serde_json::json!({
1067 "working_dir": remote_root,
1068 "label": "remote-session",
1069 "authority_spec": spec,
1070 }))
1071 .unwrap(),
1072 )
1073 .unwrap();
1074
1075 let local_root = tempfile::tempdir().unwrap();
1077 let local_root = local_root.path().canonicalize().unwrap();
1078 std::fs::write(
1079 ws_dir.join("local.json"),
1080 serde_json::to_vec(&serde_json::json!({
1081 "working_dir": local_root, "label": "local-session",
1082 }))
1083 .unwrap(),
1084 )
1085 .unwrap();
1086
1087 let fs = StdFileSystem;
1088 let sessions = discover_sessions(&fs, data_dir);
1089
1090 let remote = sessions
1091 .iter()
1092 .find(|s| s.label == "remote-session")
1093 .expect("remote session discovered");
1094 assert_eq!(
1095 remote.authority_spec, spec,
1096 "the remote backend spec round-trips through discovery"
1097 );
1098 let local = sessions
1099 .iter()
1100 .find(|s| s.label == "local-session")
1101 .expect("local session discovered");
1102 assert_eq!(
1103 local.authority_spec,
1104 SessionAuthoritySpec::Local,
1105 "a session with no persisted spec reads back as Local"
1106 );
1107 }
1108
1109 #[test]
1110 fn migrate_folds_windows_json_into_workspace_files_and_retires_it() {
1111 use crate::model::filesystem::StdFileSystem;
1112 let data = tempfile::tempdir().unwrap();
1113 let data_dir = data.path();
1114 let proj = tempfile::tempdir().unwrap();
1115 let proj_root = proj.path().canonicalize().unwrap();
1116
1117 let ws_path = workspace_file_for(data_dir, &proj_root);
1119 std::fs::create_dir_all(ws_path.parent().unwrap()).unwrap();
1120 std::fs::write(
1121 &ws_path,
1122 serde_json::to_vec(&serde_json::json!({ "working_dir": proj_root })).unwrap(),
1123 )
1124 .unwrap();
1125
1126 let global_p = global_windows_path(data_dir);
1128 std::fs::create_dir_all(global_p.parent().unwrap()).unwrap();
1129 std::fs::write(
1130 &global_p,
1131 serde_json::to_vec(&serde_json::json!({
1132 "version": 2, "active": 1, "next_id": 2,
1133 "windows": [ { "id": 1, "label": "from-windows-json", "root": proj_root } ],
1134 }))
1135 .unwrap(),
1136 )
1137 .unwrap();
1138
1139 let fs = StdFileSystem;
1140 migrate_windows_json_into_workspaces(&fs, data_dir);
1141
1142 assert!(!global_p.exists(), "windows.json is retired");
1143 assert!(
1144 global_p.with_extension("json.retired.bak").exists(),
1145 "a .retired.bak is kept"
1146 );
1147 let val: serde_json::Value =
1148 serde_json::from_slice(&std::fs::read(&ws_path).unwrap()).unwrap();
1149 assert_eq!(
1150 val.get("label").and_then(|v| v.as_str()),
1151 Some("from-windows-json"),
1152 "the label was folded into the per-dir workspace file"
1153 );
1154 }
1155}