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 **`root`** — the directory the
192/// window actually opens in — equals `cwd` after canonicalization. We
193/// match on `root`, NOT `project_path`: an orchestrator worktree session
194/// carries `project_path == <parent project>` but `root == <worktree>`,
195/// so matching on `project_path` would resurrect a worktree-rooted window
196/// when the user launched in the project dir (issue #2056). `project_path`
197/// stays purely as orchestrator-dialog grouping metadata. The previous
198/// base (id 1) is eligible too — if it was the user's last-used window in
199/// this cwd, reopening it is just a clean editor at the cwd.
200pub(crate) fn pick_active_window_for_cwd<'a>(
201    env: Option<&'a PersistedWindows>,
202    cwd: &Path,
203) -> Option<&'a PersistedWindow> {
204    let env = env?;
205    if let Some(w) = env
206        .windows
207        .iter()
208        .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
209    {
210        return Some(w);
211    }
212    env.windows
213        .iter()
214        .filter(|w| window_matches_cwd(w, cwd))
215        .max_by_key(|w| w.id)
216}
217
218fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
219    paths_equal(&w.root, cwd)
220}
221
222fn paths_equal(a: &Path, b: &Path) -> bool {
223    let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
224    let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
225    ca == cb
226}
227
228/// Scan `<data>/orchestrator/*/windows.json` for legacy v1
229/// per-cwd files. Fold every session into one v2 envelope, with
230/// `project_path` derived by reverse-decoding the slug
231/// directory name back into the original cwd path. Write the
232/// global file, then rename each legacy file to
233/// `windows.json.migrated.bak` so a downgrade isn't a one-way
234/// trip.
235///
236/// Conflicts: two cwd-keyed files with the same session id
237/// collide rarely (sessions are interactively created and ids
238/// monotonic per-store), but if they do the file with the more
239/// recent mtime wins; the loser's id is re-numbered to
240/// `next_id` of the winning envelope.
241fn migrate_legacy_windows(
242    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
243    data_dir: &Path,
244) {
245    let orch_root = data_dir.join("orchestrator");
246    if !filesystem.exists(&orch_root) {
247        return;
248    }
249    let entries = match filesystem.read_dir(&orch_root) {
250        Ok(es) => es,
251        Err(_) => return,
252    };
253    let mut merged_windows: Vec<PersistedWindow> = Vec::new();
254    let mut merged_active: u64 = 1;
255    let mut merged_next_id: u64 = 2;
256    let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
257    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
258
259    for entry in entries {
260        let dir = entry.path;
261        if !filesystem.is_dir(&dir).unwrap_or(false) {
262            continue;
263        }
264        // Only look at directories that look like slug-encoded
265        // paths (i.e. not the `state/` plugin dir, which lives
266        // alongside but isn't a per-cwd bucket).
267        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
268            Some(n) => n.to_string(),
269            None => continue,
270        };
271        if dir_name == "state" {
272            continue;
273        }
274        let legacy_p = dir.join("windows.json");
275        if !filesystem.exists(&legacy_p) {
276            continue;
277        }
278        let bytes = match filesystem.read_file(&legacy_p) {
279            Ok(b) => b,
280            Err(_) => continue,
281        };
282        let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
283            Ok(e) => e,
284            Err(_) => continue,
285        };
286        let project_path = crate::workspace::decode_filename_to_path(&dir_name)
287            .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
288
289        let mut local_renum: HashMap<u64, u64> = HashMap::new();
290        for mut w in env.windows.into_iter() {
291            // Default project_path to the decoded cwd unless
292            // the entry already carries one (a partial migration
293            // re-running on the same data).
294            if w.project_path.is_none() {
295                w.project_path = Some(project_path.clone());
296            }
297            if used_ids.contains(&w.id) {
298                let new_id = merged_next_id;
299                local_renum.insert(w.id, new_id);
300                merged_next_id = merged_next_id.saturating_add(1);
301                used_ids.insert(new_id);
302                w.id = new_id;
303            } else {
304                used_ids.insert(w.id);
305                merged_next_id = merged_next_id.max(w.id.saturating_add(1));
306            }
307            merged_windows.push(w);
308        }
309        // Most-recently-modified per-cwd file decides which
310        // session id becomes "active" in the merged store.
311        // Stat the file; if we can't, the last file scanned
312        // wins by virtue of being last.
313        let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
314        merged_active = active_id;
315        legacy_to_rename.push(legacy_p);
316    }
317
318    if merged_windows.is_empty() {
319        return;
320    }
321    merged_windows.sort_by_key(|w| w.id);
322    let envelope = PersistedWindows {
323        version: CURRENT_VERSION,
324        active: merged_active,
325        next_id: merged_next_id,
326        windows: merged_windows,
327    };
328    let global_p = global_windows_path(data_dir);
329    if let Err(e) = filesystem.create_dir_all(&orch_root) {
330        tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
331        return;
332    }
333    let bytes = match serde_json::to_vec_pretty(&envelope) {
334        Ok(b) => b,
335        Err(e) => {
336            tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
337            return;
338        }
339    };
340    if let Err(e) = filesystem.write_file(&global_p, &bytes) {
341        tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
342        return;
343    }
344    for legacy_p in legacy_to_rename {
345        let backup = legacy_p.with_extension("json.migrated.bak");
346        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
347            tracing::warn!(
348                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
349            );
350        }
351    }
352    tracing::info!(
353        "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
354        envelope.windows.len(),
355        global_p
356    );
357}
358
359/// Read every `state/<plugin>.json` into a flat
360/// `plugin → key → value` map. Skips files with unsafe names,
361/// non-JSON extensions, parse errors, and empty maps. Same
362/// motivations as [`read_persisted_windows_env`] — used by the
363/// editor factory pre-construction.
364///
365/// Reads from the global `<data>/orchestrator/state/` directory.
366/// The legacy per-cwd plugin state files (under
367/// `<data>/orchestrator/<encoded_cwd>/state/`) are folded into
368/// the global directory the first time we encounter no global
369/// state and at least one legacy file — see
370/// `migrate_legacy_plugin_state`.
371pub(crate) fn read_persisted_plugin_state(
372    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
373    data_dir: &Path,
374    _working_dir: &Path,
375) -> HashMap<String, HashMap<String, serde_json::Value>> {
376    let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
377    let state_dir = global_state_dir(data_dir);
378    if !filesystem.exists(&state_dir) {
379        migrate_legacy_plugin_state(filesystem, data_dir);
380    }
381    if !filesystem.exists(&state_dir) {
382        return out;
383    }
384    let entries = match filesystem.read_dir(&state_dir) {
385        Ok(es) => es,
386        Err(e) => {
387            tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
388            return out;
389        }
390    };
391    for entry in entries {
392        let path = entry.path;
393        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
394            continue;
395        };
396        if !plugin_name_is_safe(stem) {
397            continue;
398        }
399        if path.extension().and_then(|e| e.to_str()) != Some("json") {
400            continue;
401        }
402        match filesystem.read_file(&path) {
403            Ok(bytes) => {
404                match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
405                    Ok(map) if !map.is_empty() => {
406                        out.insert(stem.to_owned(), map);
407                    }
408                    Ok(_) => {}
409                    Err(e) => {
410                        tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
411                    }
412                }
413            }
414            Err(e) => {
415                tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
416            }
417        }
418    }
419    out
420}
421
422/// Global orchestrator state location under the platform data
423/// dir. v2 stores everything in one tree regardless of the
424/// editor's cwd; see issue #1991 for why this is no longer
425/// rooted at `<working_dir>/.fresh`.
426fn orchestrator_dir(data_dir: &Path) -> PathBuf {
427    data_dir.join("orchestrator")
428}
429
430fn global_windows_path(data_dir: &Path) -> PathBuf {
431    orchestrator_dir(data_dir).join("windows.json")
432}
433
434fn global_state_dir(data_dir: &Path) -> PathBuf {
435    orchestrator_dir(data_dir).join("state")
436}
437
438fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
439    // Plugin names are short identifiers (`orchestrator`,
440    // `live_grep`, …) so no escaping is needed for typical
441    // input. Reject anything that would escape the state dir to
442    // avoid `../`-style traversal in case a plugin picks a
443    // pathological name.
444    global_state_dir(data_dir).join(format!("{plugin}.json"))
445}
446
447fn plugin_name_is_safe(name: &str) -> bool {
448    !name.is_empty()
449        && name
450            .chars()
451            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
452        && !name.starts_with('.')
453}
454
455/// Fold legacy per-cwd plugin state into the global
456/// `<data>/orchestrator/state/` directory. Per-plugin files
457/// with the same name are merged key-by-key; the most recently
458/// modified cwd's file wins on conflict. Legacy files are
459/// renamed to `<plugin>.json.migrated.bak`. Best-effort: any
460/// filesystem error logs WARN and continues.
461fn migrate_legacy_plugin_state(
462    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
463    data_dir: &Path,
464) {
465    let orch_root = data_dir.join("orchestrator");
466    if !filesystem.exists(&orch_root) {
467        return;
468    }
469    let cwd_entries = match filesystem.read_dir(&orch_root) {
470        Ok(es) => es,
471        Err(_) => return,
472    };
473    let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
474    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
475    for cwd_entry in cwd_entries {
476        let dir = cwd_entry.path;
477        if !filesystem.is_dir(&dir).unwrap_or(false) {
478            continue;
479        }
480        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
481            Some(n) => n.to_string(),
482            None => continue,
483        };
484        if dir_name == "state" {
485            continue;
486        }
487        let state_dir = dir.join("state");
488        if !filesystem.exists(&state_dir) {
489            continue;
490        }
491        let plugin_entries = match filesystem.read_dir(&state_dir) {
492            Ok(es) => es,
493            Err(_) => continue,
494        };
495        for pe in plugin_entries {
496            let p = pe.path;
497            let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
498                continue;
499            };
500            if !plugin_name_is_safe(stem) {
501                continue;
502            }
503            if p.extension().and_then(|e| e.to_str()) != Some("json") {
504                continue;
505            }
506            let bytes = match filesystem.read_file(&p) {
507                Ok(b) => b,
508                Err(_) => continue,
509            };
510            let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
511                Ok(m) => m,
512                Err(_) => continue,
513            };
514            let slot = merged.entry(stem.to_owned()).or_default();
515            for (k, v) in map {
516                slot.insert(k, v);
517            }
518            legacy_to_rename.push(p);
519        }
520    }
521    if merged.is_empty() {
522        return;
523    }
524    let target_state_dir = global_state_dir(data_dir);
525    if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
526        tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
527        return;
528    }
529    for (plugin, map) in &merged {
530        let path = global_plugin_state_path(data_dir, plugin);
531        let bytes = match serde_json::to_vec_pretty(map) {
532            Ok(b) => b,
533            Err(e) => {
534                tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
535                continue;
536            }
537        };
538        if let Err(e) = filesystem.write_file(&path, &bytes) {
539            tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
540        }
541    }
542    for legacy_p in legacy_to_rename {
543        let backup = legacy_p.with_extension("json.migrated.bak");
544        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
545            tracing::warn!(
546                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
547            );
548        }
549    }
550    tracing::info!(
551        "orchestrator persistence: migrated plugin state for {} plugins",
552        merged.len()
553    );
554}
555
556impl Editor {
557    /// Persist `sessions` + `plugin_global_state` to disk. Best-
558    /// effort: filesystem errors are logged at WARN and swallowed
559    /// so a transient permission glitch doesn't block quit.
560    pub fn save_orchestrator_state(&self) {
561        let data_dir = self.dir_context.data_dir.clone();
562        let orch_dir = orchestrator_dir(&data_dir);
563        if let Err(e) = self.authority.filesystem.create_dir_all(&orch_dir) {
564            tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
565            return;
566        }
567
568        // Read the existing on-disk windows.json (if any) so we
569        // merge in sessions belonging to OTHER projects rather
570        // than clobbering them. Single-user but multi-project
571        // safety: another editor instance might have written
572        // sessions for a different project_path while we were
573        // running.
574        let existing: Option<PersistedWindows> = {
575            let p = global_windows_path(&data_dir);
576            if self.authority.filesystem.exists(&p) {
577                match self.authority.filesystem.read_file(&p) {
578                    Ok(bytes) => serde_json::from_slice::<PersistedWindows>(&bytes).ok(),
579                    Err(_) => None,
580                }
581            } else {
582                None
583            }
584        };
585        let our_ids: std::collections::HashSet<u64> = self.windows.keys().map(|id| id.0).collect();
586
587        // Our process's sessions, snapshotted from runtime state.
588        let mut windows: Vec<PersistedWindow> = self
589            .windows
590            .values()
591            .map(|s| {
592                // project_path / shared_worktree live in
593                // plugin_state under "orchestrator". Read them
594                // back if the orchestrator plugin set them
595                // (post-Phase 5 sessions); fall back to None /
596                // false for sessions created before the schema
597                // bump or by external paths.
598                let (project_path, shared_worktree) = read_orch_session_meta(&s.plugin_state);
599                PersistedWindow {
600                    id: s.id.0,
601                    label: s.label.clone(),
602                    root: s.root.clone(),
603                    project_path,
604                    shared_worktree,
605                    plugin_state: s.plugin_state.clone(),
606                }
607            })
608            .collect();
609
610        // Splice in other-process sessions from the existing
611        // file (anything whose id we don't currently own).
612        if let Some(env) = existing {
613            for w in env.windows.into_iter() {
614                if !our_ids.contains(&w.id) {
615                    windows.push(w);
616                }
617            }
618        }
619        // Stable on-disk order — `HashMap` iteration order would
620        // make the file diff differently every quit, producing
621        // noisy diffs for anyone inspecting the persisted state.
622        windows.sort_by_key(|s| s.id);
623        let envelope = PersistedWindows {
624            version: CURRENT_VERSION,
625            active: self.active_window.0,
626            next_id: self.next_window_id,
627            windows,
628        };
629        match serde_json::to_vec_pretty(&envelope) {
630            Ok(bytes) => {
631                let path = global_windows_path(&data_dir);
632                // Atomic rename to avoid a torn write if two
633                // editor processes happen to quit at the same
634                // moment. The `.tmp` file is in the same dir so
635                // `rename` is an atomic syscall on every
636                // filesystem we support.
637                let tmp = path.with_extension("json.tmp");
638                if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
639                    tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
640                    return;
641                }
642                if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
643                    tracing::warn!(
644                        "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
645                    );
646                }
647            }
648            Err(e) => {
649                tracing::warn!("orchestrator persistence: failed to serialise sessions: {e}");
650            }
651        }
652
653        // Plugin global state — one file per plugin. Single
654        // global directory now (no per-cwd split), so two
655        // editor processes writing the same plugin's state
656        // still need atomic-rename safety.
657        let state_dir = global_state_dir(&data_dir);
658        if !self.plugin_global_state.is_empty() {
659            if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
660                tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
661                return;
662            }
663        }
664        for (plugin, map) in &self.plugin_global_state {
665            if !plugin_name_is_safe(plugin) {
666                tracing::warn!(
667                    "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
668                );
669                continue;
670            }
671            if map.is_empty() {
672                continue;
673            }
674            match serde_json::to_vec_pretty(map) {
675                Ok(bytes) => {
676                    let path = global_plugin_state_path(&data_dir, plugin);
677                    let tmp = path.with_extension("json.tmp");
678                    if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
679                        tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
680                        continue;
681                    }
682                    if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
683                        tracing::warn!(
684                            "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
685                        );
686                    }
687                }
688                Err(e) => {
689                    tracing::warn!(
690                        "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
691                    );
692                }
693            }
694        }
695    }
696}
697
698/// Pull `project_path` (PathBuf) and `shared_worktree` (bool)
699/// out of a session's per-plugin state, if the orchestrator
700/// plugin has set them via `setWindowState`. Both keys live
701/// under the `"orchestrator"` plugin slot; the keys are
702/// `"project_path"` and `"shared_worktree"`.
703fn read_orch_session_meta(
704    plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
705) -> (Option<PathBuf>, bool) {
706    let slot = plugin_state.get("orchestrator");
707    let project_path = slot
708        .and_then(|m| m.get("project_path"))
709        .and_then(|v| v.as_str())
710        .map(PathBuf::from);
711    let shared_worktree = slot
712        .and_then(|m| m.get("shared_worktree"))
713        .and_then(|v| v.as_bool())
714        .unwrap_or(false);
715    (project_path, shared_worktree)
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn paths_live_under_data_dir_not_working_dir() {
724        // Regression test for issue #1991: orchestrator persistence
725        // must never write inside the user's working tree.
726        let data_dir = Path::new("/tmp/fresh-data");
727        let working_dir = Path::new("/home/user/project");
728
729        let wp = global_windows_path(data_dir);
730        let sd = global_state_dir(data_dir);
731        let psp = global_plugin_state_path(data_dir, "orchestrator");
732
733        assert!(
734            wp.starts_with(data_dir),
735            "windows_path must live under data_dir, got {wp:?}"
736        );
737        assert!(
738            sd.starts_with(data_dir),
739            "state_dir must live under data_dir, got {sd:?}"
740        );
741        assert!(
742            psp.starts_with(data_dir),
743            "plugin_state_path must live under data_dir, got {psp:?}"
744        );
745
746        for p in [&wp, &sd, &psp] {
747            assert!(
748                !p.starts_with(working_dir),
749                "orchestrator path must not be inside the working tree: {p:?}"
750            );
751            for component in p.components() {
752                if let std::path::Component::Normal(c) = component {
753                    assert_ne!(
754                        c, ".fresh",
755                        "orchestrator path must not contain a `.fresh` component: {p:?}"
756                    );
757                }
758            }
759        }
760    }
761
762    fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
763        PersistedWindow {
764            id,
765            label: String::new(),
766            root: PathBuf::from(root),
767            project_path: project_path.map(PathBuf::from),
768            shared_worktree: false,
769            plugin_state: HashMap::new(),
770        }
771    }
772
773    fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
774        PersistedWindows {
775            version: CURRENT_VERSION,
776            active,
777            next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
778            windows,
779        }
780    }
781
782    #[test]
783    fn pick_active_never_crosses_projects() {
784        // Regression for the orchestration bug: launching in /repoB
785        // must never bring up a session rooted in /repoA, even when
786        // /repoA holds the globally last-used session (env.active).
787        let env = env_with(
788            2,
789            vec![
790                make_window(1, "/repoA", Some("/repoA")),
791                make_window(2, "/repoA", Some("/repoA")),
792                make_window(3, "/repoB", Some("/repoB")),
793            ],
794        );
795        let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
796            .expect("a /repoB session exists");
797        assert_eq!(
798            picked.id, 3,
799            "must pick the /repoB session, not env.active=2"
800        );
801    }
802
803    #[test]
804    fn pick_active_reopens_last_used_for_cwd() {
805        // env.active points at this project's last-used session — it
806        // wins even though it isn't the highest id.
807        let env = env_with(
808            2,
809            vec![
810                make_window(2, "/repoA", Some("/repoA")),
811                make_window(5, "/repoA", Some("/repoA")),
812            ],
813        );
814        let picked =
815            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
816        assert_eq!(
817            picked.id, 2,
818            "env.active is the last-used session for the cwd"
819        );
820    }
821
822    #[test]
823    fn pick_active_falls_back_to_most_recent_session_for_cwd() {
824        // The globally last-used session (env.active=9) is in another
825        // project, so for /repoA we fall back to the most-recently-
826        // created /repoA session (highest id), not the first.
827        let env = env_with(
828            9,
829            vec![
830                make_window(2, "/repoA", Some("/repoA")),
831                make_window(7, "/repoA", Some("/repoA")),
832                make_window(9, "/repoB", Some("/repoB")),
833            ],
834        );
835        let picked =
836            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
837        assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
838    }
839
840    #[test]
841    fn pick_active_returns_none_when_no_window_matches_cwd() {
842        // No session for this cwd → caller boots a clean base window.
843        let env = env_with(
844            1,
845            vec![
846                make_window(1, "/repoA", Some("/repoA")),
847                make_window(2, "/repoB", Some("/repoB")),
848            ],
849        );
850        assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
851    }
852
853    #[test]
854    fn pick_active_falls_back_to_root_when_project_path_missing() {
855        // Legacy v1-migrated entries may lack project_path; match on root.
856        let env = env_with(
857            2,
858            vec![
859                make_window(1, "/repoA", None),
860                make_window(2, "/repoB", None),
861            ],
862        );
863        let picked =
864            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
865        assert_eq!(picked.id, 1);
866    }
867
868    #[test]
869    fn global_paths_are_independent_of_working_dir() {
870        // v2: persistence is global, not per-cwd. Two different
871        // cwds resolve to the same file path so the user sees
872        // their full session history regardless of where the
873        // editor was launched from.
874        let data_dir = Path::new("/tmp/fresh-data");
875        let a = global_windows_path(data_dir);
876        let b = global_windows_path(data_dir);
877        assert_eq!(a, b);
878        assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
879    }
880}