Skip to main content

fresh/app/
orchestrator_persistence.rs

1//! Cross-restart persistence for Orchestrator sessions and
2//! plugin global state.
3//!
4//! ## Storage layout (v2, current)
5//!
6//!   - `<data_dir>/orchestrator/windows.json` — **global**, per-
7//!     user list of every Orchestrator session the user has
8//!     ever created. Each entry carries a `project_path` so the
9//!     Open dialog can scope its default view to the current
10//!     project while still allowing an "all projects" toggle.
11//!     One file means the user can see their full orchestration
12//!     history across projects without scanning a directory
13//!     tree, and avoids the "cd to different paths sees disjoint
14//!     state" surprise of the old per-cwd layout.
15//!
16//!   - `<data_dir>/orchestrator/state/<plugin>.json` — plugin
17//!     global state, one file per plugin. Same shape as before;
18//!     it's not per-project, so it lives at the new global
19//!     location too.
20//!
21//! ## Migration from v1 (per-cwd) layout
22//!
23//! v1 wrote `<data>/orchestrator/<encoded_cwd>/windows.json`
24//! and `<data>/orchestrator/<encoded_cwd>/state/<plugin>.json`.
25//! On first read at the new global path, the loader detects any
26//! v1 files and folds them into the global store with
27//! `project_path = decoded_cwd` (the slug → original path
28//! reverse). The legacy files are renamed to
29//! `windows.json.migrated.bak` (and similarly for plugin state)
30//! so a downgrade isn't a one-way trip. Migration is idempotent
31//! — once the global file exists, the legacy files are ignored.
32//!
33//! The state lives under the platform data directory
34//! (`$XDG_DATA_HOME/fresh/` on Linux); this keeps the user's
35//! working tree free of stray dotfiles (issue #1991).
36//!
37//! On startup, [`read_persisted_windows_env`] +
38//! [`read_persisted_plugin_state`] are called from
39//! `Editor::with_options` (see `editor_init.rs`) *before* the
40//! editor struct is built. The factory uses the parsed envelope
41//! to pick the active window's id and root (so the spawned LSP
42//! targets the right project), to attach the seed buffer +
43//! split layout to the active window directly, and to populate
44//! `plugin_global_state` so plugins reading `getGlobalState`
45//! during their on-load handler see the previous run's values.
46//! All non-active persisted windows come back as inert shells
47//! (no splits, no LSP); first dive into one re-warms it on
48//! demand exactly like a freshly-`createWindow`-ed session.
49//!
50//! The "warm" half of warm-swap (split layout, LSP, file
51//! explorer state) is intentionally *not* persisted: the only
52//! purpose of warmth is "fast switch within one editor
53//! lifetime"; serialising those across restarts buys nothing
54//! and is a large amount of fragile state-machine work.
55//! Re-warming on first dive is fast enough.
56
57use serde::{Deserialize, Serialize};
58use std::collections::HashMap;
59use std::path::{Path, PathBuf};
60
61use super::Editor;
62
63/// One session as it appears on disk.
64#[derive(Serialize, Deserialize, Debug, Clone)]
65pub(crate) struct PersistedWindow {
66    pub(crate) id: u64,
67    pub(crate) label: String,
68    pub(crate) root: PathBuf,
69    /// Project this session belongs to — the canonical repo
70    /// root (or arbitrary directory for non-git sessions) the
71    /// user pointed the new-session form at. `None` for legacy
72    /// v1-migrated entries where the project_path wasn't
73    /// recorded; the migration synthesises it from the
74    /// per-cwd directory name. The Open dialog filters by this
75    /// field so sessions for the current project surface first
76    /// without an explicit toggle.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub(crate) project_path: Option<PathBuf>,
79    /// `true` when the session shares its working tree with
80    /// other sessions (or runs in-place inside a non-git
81    /// directory); `false` when it has its own dedicated
82    /// `git worktree add`. Defaults to `false` for v1-migrated
83    /// entries (the v1 flow always created a fresh worktree).
84    #[serde(default, skip_serializing_if = "is_false")]
85    pub(crate) shared_worktree: bool,
86    /// Per-session plugin state (the same map kept in
87    /// `Session.plugin_state`). Empty plugins / empty keys are
88    /// stripped on save.
89    #[serde(default)]
90    pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
91}
92
93fn is_false(b: &bool) -> bool {
94    !b
95}
96
97/// Top-level shape of `windows.json`.
98#[derive(Serialize, Deserialize, Debug, Clone)]
99pub(crate) struct PersistedWindows {
100    /// Schema version. `1` (or missing) = legacy per-cwd file
101    /// without `project_path` / `shared_worktree`. `2` = global
102    /// store with both fields populated. The loader handles
103    /// either shape; the writer always emits `2`.
104    #[serde(default = "default_version")]
105    pub(crate) version: u32,
106    /// Last active session id at quit time. The loader makes
107    /// this session the active one again. If missing or
108    /// dangling, falls back to the base session.
109    pub(crate) active: u64,
110    /// `next_window_id` at quit time — preserved so newly
111    /// created sessions after restart don't collide with ids
112    /// the user might still see in plugin state.
113    pub(crate) next_id: u64,
114    pub(crate) windows: Vec<PersistedWindow>,
115}
116
117fn default_version() -> u32 {
118    1
119}
120
121const CURRENT_VERSION: u32 = 2;
122
123/// Read the global `windows.json` and return the parsed
124/// envelope. Returns `None` when the file doesn't exist or
125/// fails to parse — those are not error cases at the editor
126/// level (a missing or corrupted file just means "no persisted
127/// state").
128///
129/// Migrates v1 (per-cwd) files into the global store on first
130/// load and renames each to `.migrated.bak`. The `working_dir`
131/// argument is no longer used for the file location (it's
132/// global now); it's kept in the signature so the factory can
133/// later pass it to the orchestrator plugin as the
134/// "default project filter" hint without a second IO pass.
135///
136/// Pure file IO + JSON parse. Used by the editor factory to
137/// decide how to build the initial windows map before any
138/// `Editor` instance exists.
139pub(crate) fn read_persisted_windows_env(
140    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
141    data_dir: &Path,
142    _working_dir: &Path,
143) -> Option<PersistedWindows> {
144    // Trigger migration if the global file doesn't yet exist
145    // and we find at least one legacy per-cwd file.
146    let global_p = global_windows_path(data_dir);
147    if !filesystem.exists(&global_p) {
148        migrate_legacy_windows(filesystem, data_dir);
149    }
150    if !filesystem.exists(&global_p) {
151        return None;
152    }
153    match filesystem.read_file(&global_p) {
154        Ok(bytes) => match serde_json::from_slice::<PersistedWindows>(&bytes) {
155            Ok(env) => Some(env),
156            Err(e) => {
157                tracing::warn!("orchestrator persistence: failed to parse {global_p:?}: {e}");
158                None
159            }
160        },
161        Err(e) => {
162            tracing::warn!("orchestrator persistence: failed to read {global_p:?}: {e}");
163            None
164        }
165    }
166}
167
168/// Pick which persisted window the active-window slot should be
169/// restored to at boot time, scoped to the editor's launch cwd.
170///
171/// The persisted `env.active` is cross-project: it names the last
172/// window the user touched anywhere, not necessarily one rooted at
173/// the cwd. Honoring it blindly when the user re-launches inside a
174/// different project leaves the active window pointed at an
175/// unrelated tree — "Open Terminal" spawns a shell in the other
176/// project, the LSP starts under the wrong root. So we re-pick:
177///
178///   1. If `env.active`'s window belongs to `cwd`, keep it.
179///   2. Else, pick any persisted window that belongs to `cwd`.
180///   3. Else, `None` — caller falls back to a fresh window at cwd.
181///
182/// A window "belongs to" `cwd` when its `project_path` (preferred
183/// for orchestrator sessions, which always carry one) or its
184/// `root` (legacy / non-orchestrator windows) equals `cwd` after
185/// canonicalization.
186pub(crate) fn pick_active_window_for_cwd<'a>(
187    env: Option<&'a PersistedWindows>,
188    cwd: &Path,
189) -> Option<&'a PersistedWindow> {
190    let env = env?;
191    if let Some(w) = env
192        .windows
193        .iter()
194        .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
195    {
196        return Some(w);
197    }
198    env.windows.iter().find(|w| window_matches_cwd(w, cwd))
199}
200
201fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
202    let candidate = w.project_path.as_deref().unwrap_or(&w.root);
203    paths_equal(candidate, cwd)
204}
205
206fn paths_equal(a: &Path, b: &Path) -> bool {
207    let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
208    let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
209    ca == cb
210}
211
212/// Scan `<data>/orchestrator/*/windows.json` for legacy v1
213/// per-cwd files. Fold every session into one v2 envelope, with
214/// `project_path` derived by reverse-decoding the slug
215/// directory name back into the original cwd path. Write the
216/// global file, then rename each legacy file to
217/// `windows.json.migrated.bak` so a downgrade isn't a one-way
218/// trip.
219///
220/// Conflicts: two cwd-keyed files with the same session id
221/// collide rarely (sessions are interactively created and ids
222/// monotonic per-store), but if they do the file with the more
223/// recent mtime wins; the loser's id is re-numbered to
224/// `next_id` of the winning envelope.
225fn migrate_legacy_windows(
226    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
227    data_dir: &Path,
228) {
229    let orch_root = data_dir.join("orchestrator");
230    if !filesystem.exists(&orch_root) {
231        return;
232    }
233    let entries = match filesystem.read_dir(&orch_root) {
234        Ok(es) => es,
235        Err(_) => return,
236    };
237    let mut merged_windows: Vec<PersistedWindow> = Vec::new();
238    let mut merged_active: u64 = 1;
239    let mut merged_next_id: u64 = 2;
240    let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
241    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
242
243    for entry in entries {
244        let dir = entry.path;
245        if !filesystem.is_dir(&dir).unwrap_or(false) {
246            continue;
247        }
248        // Only look at directories that look like slug-encoded
249        // paths (i.e. not the `state/` plugin dir, which lives
250        // alongside but isn't a per-cwd bucket).
251        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
252            Some(n) => n.to_string(),
253            None => continue,
254        };
255        if dir_name == "state" {
256            continue;
257        }
258        let legacy_p = dir.join("windows.json");
259        if !filesystem.exists(&legacy_p) {
260            continue;
261        }
262        let bytes = match filesystem.read_file(&legacy_p) {
263            Ok(b) => b,
264            Err(_) => continue,
265        };
266        let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
267            Ok(e) => e,
268            Err(_) => continue,
269        };
270        let project_path = crate::workspace::decode_filename_to_path(&dir_name)
271            .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
272
273        let mut local_renum: HashMap<u64, u64> = HashMap::new();
274        for mut w in env.windows.into_iter() {
275            // Default project_path to the decoded cwd unless
276            // the entry already carries one (a partial migration
277            // re-running on the same data).
278            if w.project_path.is_none() {
279                w.project_path = Some(project_path.clone());
280            }
281            if used_ids.contains(&w.id) {
282                let new_id = merged_next_id;
283                local_renum.insert(w.id, new_id);
284                merged_next_id = merged_next_id.saturating_add(1);
285                used_ids.insert(new_id);
286                w.id = new_id;
287            } else {
288                used_ids.insert(w.id);
289                merged_next_id = merged_next_id.max(w.id.saturating_add(1));
290            }
291            merged_windows.push(w);
292        }
293        // Most-recently-modified per-cwd file decides which
294        // session id becomes "active" in the merged store.
295        // Stat the file; if we can't, the last file scanned
296        // wins by virtue of being last.
297        let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
298        merged_active = active_id;
299        legacy_to_rename.push(legacy_p);
300    }
301
302    if merged_windows.is_empty() {
303        return;
304    }
305    merged_windows.sort_by_key(|w| w.id);
306    let envelope = PersistedWindows {
307        version: CURRENT_VERSION,
308        active: merged_active,
309        next_id: merged_next_id,
310        windows: merged_windows,
311    };
312    let global_p = global_windows_path(data_dir);
313    if let Err(e) = filesystem.create_dir_all(&orch_root) {
314        tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
315        return;
316    }
317    let bytes = match serde_json::to_vec_pretty(&envelope) {
318        Ok(b) => b,
319        Err(e) => {
320            tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
321            return;
322        }
323    };
324    if let Err(e) = filesystem.write_file(&global_p, &bytes) {
325        tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
326        return;
327    }
328    for legacy_p in legacy_to_rename {
329        let backup = legacy_p.with_extension("json.migrated.bak");
330        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
331            tracing::warn!(
332                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
333            );
334        }
335    }
336    tracing::info!(
337        "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
338        envelope.windows.len(),
339        global_p
340    );
341}
342
343/// Read every `state/<plugin>.json` into a flat
344/// `plugin → key → value` map. Skips files with unsafe names,
345/// non-JSON extensions, parse errors, and empty maps. Same
346/// motivations as [`read_persisted_windows_env`] — used by the
347/// editor factory pre-construction.
348///
349/// Reads from the global `<data>/orchestrator/state/` directory.
350/// The legacy per-cwd plugin state files (under
351/// `<data>/orchestrator/<encoded_cwd>/state/`) are folded into
352/// the global directory the first time we encounter no global
353/// state and at least one legacy file — see
354/// `migrate_legacy_plugin_state`.
355pub(crate) fn read_persisted_plugin_state(
356    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
357    data_dir: &Path,
358    _working_dir: &Path,
359) -> HashMap<String, HashMap<String, serde_json::Value>> {
360    let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
361    let state_dir = global_state_dir(data_dir);
362    if !filesystem.exists(&state_dir) {
363        migrate_legacy_plugin_state(filesystem, data_dir);
364    }
365    if !filesystem.exists(&state_dir) {
366        return out;
367    }
368    let entries = match filesystem.read_dir(&state_dir) {
369        Ok(es) => es,
370        Err(e) => {
371            tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
372            return out;
373        }
374    };
375    for entry in entries {
376        let path = entry.path;
377        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
378            continue;
379        };
380        if !plugin_name_is_safe(stem) {
381            continue;
382        }
383        if path.extension().and_then(|e| e.to_str()) != Some("json") {
384            continue;
385        }
386        match filesystem.read_file(&path) {
387            Ok(bytes) => {
388                match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
389                    Ok(map) if !map.is_empty() => {
390                        out.insert(stem.to_owned(), map);
391                    }
392                    Ok(_) => {}
393                    Err(e) => {
394                        tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
395                    }
396                }
397            }
398            Err(e) => {
399                tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
400            }
401        }
402    }
403    out
404}
405
406/// Global orchestrator state location under the platform data
407/// dir. v2 stores everything in one tree regardless of the
408/// editor's cwd; see issue #1991 for why this is no longer
409/// rooted at `<working_dir>/.fresh`.
410fn orchestrator_dir(data_dir: &Path) -> PathBuf {
411    data_dir.join("orchestrator")
412}
413
414fn global_windows_path(data_dir: &Path) -> PathBuf {
415    orchestrator_dir(data_dir).join("windows.json")
416}
417
418fn global_state_dir(data_dir: &Path) -> PathBuf {
419    orchestrator_dir(data_dir).join("state")
420}
421
422fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
423    // Plugin names are short identifiers (`orchestrator`,
424    // `live_grep`, …) so no escaping is needed for typical
425    // input. Reject anything that would escape the state dir to
426    // avoid `../`-style traversal in case a plugin picks a
427    // pathological name.
428    global_state_dir(data_dir).join(format!("{plugin}.json"))
429}
430
431fn plugin_name_is_safe(name: &str) -> bool {
432    !name.is_empty()
433        && name
434            .chars()
435            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
436        && !name.starts_with('.')
437}
438
439/// Fold legacy per-cwd plugin state into the global
440/// `<data>/orchestrator/state/` directory. Per-plugin files
441/// with the same name are merged key-by-key; the most recently
442/// modified cwd's file wins on conflict. Legacy files are
443/// renamed to `<plugin>.json.migrated.bak`. Best-effort: any
444/// filesystem error logs WARN and continues.
445fn migrate_legacy_plugin_state(
446    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
447    data_dir: &Path,
448) {
449    let orch_root = data_dir.join("orchestrator");
450    if !filesystem.exists(&orch_root) {
451        return;
452    }
453    let cwd_entries = match filesystem.read_dir(&orch_root) {
454        Ok(es) => es,
455        Err(_) => return,
456    };
457    let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
458    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
459    for cwd_entry in cwd_entries {
460        let dir = cwd_entry.path;
461        if !filesystem.is_dir(&dir).unwrap_or(false) {
462            continue;
463        }
464        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
465            Some(n) => n.to_string(),
466            None => continue,
467        };
468        if dir_name == "state" {
469            continue;
470        }
471        let state_dir = dir.join("state");
472        if !filesystem.exists(&state_dir) {
473            continue;
474        }
475        let plugin_entries = match filesystem.read_dir(&state_dir) {
476            Ok(es) => es,
477            Err(_) => continue,
478        };
479        for pe in plugin_entries {
480            let p = pe.path;
481            let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
482                continue;
483            };
484            if !plugin_name_is_safe(stem) {
485                continue;
486            }
487            if p.extension().and_then(|e| e.to_str()) != Some("json") {
488                continue;
489            }
490            let bytes = match filesystem.read_file(&p) {
491                Ok(b) => b,
492                Err(_) => continue,
493            };
494            let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
495                Ok(m) => m,
496                Err(_) => continue,
497            };
498            let slot = merged.entry(stem.to_owned()).or_default();
499            for (k, v) in map {
500                slot.insert(k, v);
501            }
502            legacy_to_rename.push(p);
503        }
504    }
505    if merged.is_empty() {
506        return;
507    }
508    let target_state_dir = global_state_dir(data_dir);
509    if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
510        tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
511        return;
512    }
513    for (plugin, map) in &merged {
514        let path = global_plugin_state_path(data_dir, plugin);
515        let bytes = match serde_json::to_vec_pretty(map) {
516            Ok(b) => b,
517            Err(e) => {
518                tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
519                continue;
520            }
521        };
522        if let Err(e) = filesystem.write_file(&path, &bytes) {
523            tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
524        }
525    }
526    for legacy_p in legacy_to_rename {
527        let backup = legacy_p.with_extension("json.migrated.bak");
528        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
529            tracing::warn!(
530                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
531            );
532        }
533    }
534    tracing::info!(
535        "orchestrator persistence: migrated plugin state for {} plugins",
536        merged.len()
537    );
538}
539
540impl Editor {
541    /// Persist `sessions` + `plugin_global_state` to disk. Best-
542    /// effort: filesystem errors are logged at WARN and swallowed
543    /// so a transient permission glitch doesn't block quit.
544    pub fn save_orchestrator_state(&self) {
545        let data_dir = self.dir_context.data_dir.clone();
546        let orch_dir = orchestrator_dir(&data_dir);
547        if let Err(e) = self.authority.filesystem.create_dir_all(&orch_dir) {
548            tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
549            return;
550        }
551
552        // Read the existing on-disk windows.json (if any) so we
553        // merge in sessions belonging to OTHER projects rather
554        // than clobbering them. Single-user but multi-project
555        // safety: another editor instance might have written
556        // sessions for a different project_path while we were
557        // running.
558        let existing: Option<PersistedWindows> = {
559            let p = global_windows_path(&data_dir);
560            if self.authority.filesystem.exists(&p) {
561                match self.authority.filesystem.read_file(&p) {
562                    Ok(bytes) => serde_json::from_slice::<PersistedWindows>(&bytes).ok(),
563                    Err(_) => None,
564                }
565            } else {
566                None
567            }
568        };
569        let our_ids: std::collections::HashSet<u64> = self.windows.keys().map(|id| id.0).collect();
570
571        // Our process's sessions, snapshotted from runtime state.
572        let mut windows: Vec<PersistedWindow> = self
573            .windows
574            .values()
575            .map(|s| {
576                // project_path / shared_worktree live in
577                // plugin_state under "orchestrator". Read them
578                // back if the orchestrator plugin set them
579                // (post-Phase 5 sessions); fall back to None /
580                // false for sessions created before the schema
581                // bump or by external paths.
582                let (project_path, shared_worktree) = read_orch_session_meta(&s.plugin_state);
583                PersistedWindow {
584                    id: s.id.0,
585                    label: s.label.clone(),
586                    root: s.root.clone(),
587                    project_path,
588                    shared_worktree,
589                    plugin_state: s.plugin_state.clone(),
590                }
591            })
592            .collect();
593
594        // Splice in other-process sessions from the existing
595        // file (anything whose id we don't currently own).
596        if let Some(env) = existing {
597            for w in env.windows.into_iter() {
598                if !our_ids.contains(&w.id) {
599                    windows.push(w);
600                }
601            }
602        }
603        // Stable on-disk order — `HashMap` iteration order would
604        // make the file diff differently every quit, producing
605        // noisy diffs for anyone inspecting the persisted state.
606        windows.sort_by_key(|s| s.id);
607        let envelope = PersistedWindows {
608            version: CURRENT_VERSION,
609            active: self.active_window.0,
610            next_id: self.next_window_id,
611            windows,
612        };
613        match serde_json::to_vec_pretty(&envelope) {
614            Ok(bytes) => {
615                let path = global_windows_path(&data_dir);
616                // Atomic rename to avoid a torn write if two
617                // editor processes happen to quit at the same
618                // moment. The `.tmp` file is in the same dir so
619                // `rename` is an atomic syscall on every
620                // filesystem we support.
621                let tmp = path.with_extension("json.tmp");
622                if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
623                    tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
624                    return;
625                }
626                if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
627                    tracing::warn!(
628                        "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
629                    );
630                }
631            }
632            Err(e) => {
633                tracing::warn!("orchestrator persistence: failed to serialise sessions: {e}");
634            }
635        }
636
637        // Plugin global state — one file per plugin. Single
638        // global directory now (no per-cwd split), so two
639        // editor processes writing the same plugin's state
640        // still need atomic-rename safety.
641        let state_dir = global_state_dir(&data_dir);
642        if !self.plugin_global_state.is_empty() {
643            if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
644                tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
645                return;
646            }
647        }
648        for (plugin, map) in &self.plugin_global_state {
649            if !plugin_name_is_safe(plugin) {
650                tracing::warn!(
651                    "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
652                );
653                continue;
654            }
655            if map.is_empty() {
656                continue;
657            }
658            match serde_json::to_vec_pretty(map) {
659                Ok(bytes) => {
660                    let path = global_plugin_state_path(&data_dir, plugin);
661                    let tmp = path.with_extension("json.tmp");
662                    if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
663                        tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
664                        continue;
665                    }
666                    if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
667                        tracing::warn!(
668                            "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
669                        );
670                    }
671                }
672                Err(e) => {
673                    tracing::warn!(
674                        "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
675                    );
676                }
677            }
678        }
679    }
680}
681
682/// Pull `project_path` (PathBuf) and `shared_worktree` (bool)
683/// out of a session's per-plugin state, if the orchestrator
684/// plugin has set them via `setWindowState`. Both keys live
685/// under the `"orchestrator"` plugin slot; the keys are
686/// `"project_path"` and `"shared_worktree"`.
687fn read_orch_session_meta(
688    plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
689) -> (Option<PathBuf>, bool) {
690    let slot = plugin_state.get("orchestrator");
691    let project_path = slot
692        .and_then(|m| m.get("project_path"))
693        .and_then(|v| v.as_str())
694        .map(PathBuf::from);
695    let shared_worktree = slot
696        .and_then(|m| m.get("shared_worktree"))
697        .and_then(|v| v.as_bool())
698        .unwrap_or(false);
699    (project_path, shared_worktree)
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn paths_live_under_data_dir_not_working_dir() {
708        // Regression test for issue #1991: orchestrator persistence
709        // must never write inside the user's working tree.
710        let data_dir = Path::new("/tmp/fresh-data");
711        let working_dir = Path::new("/home/user/project");
712
713        let wp = global_windows_path(data_dir);
714        let sd = global_state_dir(data_dir);
715        let psp = global_plugin_state_path(data_dir, "orchestrator");
716
717        assert!(
718            wp.starts_with(data_dir),
719            "windows_path must live under data_dir, got {wp:?}"
720        );
721        assert!(
722            sd.starts_with(data_dir),
723            "state_dir must live under data_dir, got {sd:?}"
724        );
725        assert!(
726            psp.starts_with(data_dir),
727            "plugin_state_path must live under data_dir, got {psp:?}"
728        );
729
730        for p in [&wp, &sd, &psp] {
731            assert!(
732                !p.starts_with(working_dir),
733                "orchestrator path must not be inside the working tree: {p:?}"
734            );
735            for component in p.components() {
736                if let std::path::Component::Normal(c) = component {
737                    assert_ne!(
738                        c, ".fresh",
739                        "orchestrator path must not contain a `.fresh` component: {p:?}"
740                    );
741                }
742            }
743        }
744    }
745
746    fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
747        PersistedWindow {
748            id,
749            label: String::new(),
750            root: PathBuf::from(root),
751            project_path: project_path.map(PathBuf::from),
752            shared_worktree: false,
753            plugin_state: HashMap::new(),
754        }
755    }
756
757    fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
758        PersistedWindows {
759            version: CURRENT_VERSION,
760            active,
761            next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
762            windows,
763        }
764    }
765
766    #[test]
767    fn pick_active_falls_through_when_persisted_active_belongs_to_another_project() {
768        // Regression for issue #2026: launching fresh in /repoA must
769        // not activate a persisted window rooted at /repoB just
770        // because it was the last window the user touched anywhere.
771        let env = env_with(
772            2,
773            vec![
774                make_window(1, "/repoA", Some("/repoA")),
775                make_window(2, "/repoB", Some("/repoB")),
776            ],
777        );
778        let cwd = Path::new("/repoA");
779        let picked = pick_active_window_for_cwd(Some(&env), cwd).expect("should pick a window");
780        assert_eq!(
781            picked.id, 1,
782            "must pick the /repoA-rooted window, not env.active=2"
783        );
784    }
785
786    #[test]
787    fn pick_active_keeps_env_active_when_it_matches_cwd() {
788        let env = env_with(
789            2,
790            vec![
791                make_window(1, "/repoA", Some("/repoA")),
792                make_window(2, "/repoA", Some("/repoA")),
793            ],
794        );
795        let picked =
796            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
797        assert_eq!(picked.id, 2, "env.active wins when it matches");
798    }
799
800    #[test]
801    fn pick_active_returns_none_when_no_window_matches_cwd() {
802        let env = env_with(
803            1,
804            vec![
805                make_window(1, "/repoA", Some("/repoA")),
806                make_window(2, "/repoB", Some("/repoB")),
807            ],
808        );
809        assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
810    }
811
812    #[test]
813    fn pick_active_falls_back_to_root_when_project_path_missing() {
814        // Legacy v1-migrated entries may lack project_path; match on root.
815        let env = env_with(
816            2,
817            vec![
818                make_window(1, "/repoA", None),
819                make_window(2, "/repoB", None),
820            ],
821        );
822        let picked =
823            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
824        assert_eq!(picked.id, 1);
825    }
826
827    #[test]
828    fn global_paths_are_independent_of_working_dir() {
829        // v2: persistence is global, not per-cwd. Two different
830        // cwds resolve to the same file path so the user sees
831        // their full session history regardless of where the
832        // editor was launched from.
833        let data_dir = Path::new("/tmp/fresh-data");
834        let a = global_windows_path(data_dir);
835        let b = global_windows_path(data_dir);
836        assert_eq!(a, b);
837        assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
838    }
839}