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