Skip to main content

fresh/app/
orchestrator_persistence.rs

1//! Cross-restart persistence for Orchestrator sessions and
2//! plugin global state.
3//!
4//! ## The session registry is the directory set
5//!
6//! There is no central session-list file. A session *is* a
7//! directory (one session per dir), and the registry is the
8//! per-dir workspace cache:
9//!
10//!   - `<data_dir>/workspaces/<encoded-root>.json` — one file per
11//!     directory ever opened. Each carries that window's identity
12//!     (`label`, `session_plugin_state`) plus its buffer/split
13//!     layout. [`discover_sessions`] scans this directory at boot,
14//!     garbage-collects entries whose directory no longer exists,
15//!     and returns one [`PersistedWindow`] per survivor (ids
16//!     assigned by sorted canonical root for run-to-run stability).
17//!
18//!   - `<data_dir>/orchestrator/state/<plugin>.json` — editor-wide
19//!     plugin global state, one file per plugin (not per-project).
20//!
21//! `PersistedWindow` / `PersistedWindows` are now in-memory shapes
22//! produced by discovery (and still the parse target of a legacy
23//! `windows.json` during migration), not an on-disk schema.
24//!
25//! ## Migration
26//!
27//! Older builds kept a central `<data_dir>/orchestrator/windows.json`
28//! (and, before that, per-cwd `<data>/orchestrator/<encoded_cwd>/
29//! windows.json`). On first read, [`migrate_legacy_windows`] folds any
30//! per-cwd files into a single windows.json, then
31//! [`migrate_windows_json_into_workspaces`] backfills its
32//! `label` / per-session plugin state into the matching per-dir
33//! workspace files and retires the file to `windows.json.retired.bak`.
34//! After that the workspace cache is the sole registry.
35//!
36//! State lives under the platform data dir (`$XDG_DATA_HOME/fresh/`),
37//! never the working tree (issue #1991).
38//!
39//! ## Startup
40//!
41//! [`read_persisted_windows_env`] + [`read_persisted_plugin_state`]
42//! run from `editor_init` before the editor struct exists. The
43//! foreground window is the one whose `root` matches the launch cwd
44//! ([`pick_active_window_for_cwd`]) — authoritatively, regardless of
45//! which session was last used; if none matches, a clean window is
46//! booted at the cwd. Every other discovered session comes back as an
47//! inert shell (no splits/LSP) restored lazily on first dive/preview.
48//! The "warm" layout is intentionally not persisted across restarts —
49//! re-warming on first dive is fast enough.
50
51use serde::{Deserialize, Serialize};
52use std::collections::HashMap;
53use std::path::{Path, PathBuf};
54
55use super::Editor;
56
57/// One session as it appears on disk.
58#[derive(Serialize, Deserialize, Debug, Clone)]
59pub(crate) struct PersistedWindow {
60    pub(crate) id: u64,
61    pub(crate) label: String,
62    pub(crate) root: PathBuf,
63    /// Project this session belongs to — the canonical repo
64    /// root (or arbitrary directory for non-git sessions) the
65    /// user pointed the new-session form at. `None` for legacy
66    /// v1-migrated entries where the project_path wasn't
67    /// recorded; the migration synthesises it from the
68    /// per-cwd directory name. The Open dialog filters by this
69    /// field so sessions for the current project surface first
70    /// without an explicit toggle.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub(crate) project_path: Option<PathBuf>,
73    /// `true` when the session shares its working tree with
74    /// other sessions (or runs in-place inside a non-git
75    /// directory); `false` when it has its own dedicated
76    /// `git worktree add`. Defaults to `false` for v1-migrated
77    /// entries (the v1 flow always created a fresh worktree).
78    #[serde(default, skip_serializing_if = "is_false")]
79    pub(crate) shared_worktree: bool,
80    /// Per-session plugin state (the same map kept in
81    /// `Session.plugin_state`). Empty plugins / empty keys are
82    /// stripped on save.
83    #[serde(default)]
84    pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
85    /// How to rebuild/reconnect this session's backend on restore (read
86    /// from the workspace file's `authority_spec`). `Local` for an ordinary
87    /// host session. Threaded into the window at construction so an
88    /// unmaterialized background session still knows its backend (and a
89    /// later save doesn't clobber it back to local).
90    #[serde(default, skip_serializing_if = "is_local_authority_spec")]
91    pub(crate) authority_spec: crate::services::authority::SessionAuthoritySpec,
92}
93
94fn is_local_authority_spec(spec: &crate::services::authority::SessionAuthoritySpec) -> bool {
95    matches!(
96        spec,
97        crate::services::authority::SessionAuthoritySpec::Local
98    )
99}
100
101fn is_false(b: &bool) -> bool {
102    !b
103}
104
105/// Top-level shape of `windows.json`.
106#[derive(Serialize, Deserialize, Debug, Clone)]
107pub(crate) struct PersistedWindows {
108    /// Schema version. `1` (or missing) = legacy per-cwd file
109    /// without `project_path` / `shared_worktree`. `2` = global
110    /// store with both fields populated. The loader handles
111    /// either shape; the writer always emits `2`.
112    #[serde(default = "default_version")]
113    pub(crate) version: u32,
114    /// Last active session id at quit time. The loader makes
115    /// this session the active one again. If missing or
116    /// dangling, falls back to the base session.
117    pub(crate) active: u64,
118    /// `next_window_id` at quit time — preserved so newly
119    /// created sessions after restart don't collide with ids
120    /// the user might still see in plugin state.
121    pub(crate) next_id: u64,
122    pub(crate) windows: Vec<PersistedWindow>,
123}
124
125fn default_version() -> u32 {
126    1
127}
128
129const CURRENT_VERSION: u32 = 2;
130
131/// Read the global `windows.json` and return the parsed
132/// envelope. Returns `None` when the file doesn't exist or
133/// fails to parse — those are not error cases at the editor
134/// level (a missing or corrupted file just means "no persisted
135/// state").
136///
137/// Migrates v1 (per-cwd) files into the global store on first
138/// load and renames each to `.migrated.bak`. The `working_dir`
139/// argument is no longer used for the file location (it's
140/// global now); it's kept in the signature so the factory can
141/// later pass it to the orchestrator plugin as the
142/// "default project filter" hint without a second IO pass.
143///
144/// Pure file IO + JSON parse. Used by the editor factory to
145/// decide how to build the initial windows map before any
146/// `Editor` instance exists.
147pub(crate) fn read_persisted_windows_env(
148    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
149    data_dir: &Path,
150    _working_dir: &Path,
151) -> Option<PersistedWindows> {
152    // Legacy v1 (per-cwd) → windows.json, if any survive. windows.json
153    // is itself legacy now; the next step folds it into the per-dir
154    // workspace files and retires it.
155    let global_p = global_windows_path(data_dir);
156    if !filesystem.exists(&global_p) {
157        migrate_legacy_windows(filesystem, data_dir);
158    }
159    migrate_windows_json_into_workspaces(filesystem, data_dir);
160
161    // The per-dir workspace cache is the session registry now: one
162    // session per directory, discovered from disk. GC dead entries and
163    // build a window per survivor.
164    let windows = discover_sessions(filesystem, data_dir);
165    if windows.is_empty() {
166        return None;
167    }
168    let next_id = windows.iter().map(|w| w.id).max().unwrap_or(0) + 1;
169    // One session per directory is enforced upstream by the workspace
170    // cache itself: `get_workspace_path` keys each file on the
171    // canonical root, so discovery yields at most one window per
172    // canonical dir. No post-hoc dedup is needed.
173    //
174    // `active` is decided downstream by the launch cwd
175    // (`pick_active_window_for_cwd`); 0 means "no stored hint", so the
176    // cwd-match branch governs which session is foregrounded.
177    Some(PersistedWindows {
178        version: CURRENT_VERSION,
179        active: 0,
180        next_id,
181        windows,
182    })
183}
184
185fn workspaces_dir(data_dir: &Path) -> PathBuf {
186    data_dir.join("workspaces")
187}
188
189/// Per-dir workspace file path for `root` under `data_dir` — mirrors
190/// `crate::workspace::get_workspace_path` but honours the passed data
191/// dir rather than the process-global one.
192fn workspace_file_for(data_dir: &Path, root: &Path) -> PathBuf {
193    let filename = format!(
194        "{}.json",
195        crate::workspace::encode_path_for_filename(&canonical_key(root))
196    );
197    workspaces_dir(data_dir).join(filename)
198}
199
200fn basename_label(root: &Path) -> String {
201    root.file_name()
202        .and_then(|s| s.to_str())
203        .map(|s| s.to_string())
204        .unwrap_or_else(|| root.to_string_lossy().into_owned())
205}
206
207/// One session per existing directory: scan the workspace-file cache,
208/// garbage-collect entries whose directory no longer exists, and return
209/// one `PersistedWindow` per survivor. Ids are assigned by sorted
210/// canonical root so they stay stable across runs for a stable dir set.
211fn discover_sessions(
212    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
213    data_dir: &Path,
214) -> Vec<PersistedWindow> {
215    type SessionState = HashMap<String, HashMap<String, serde_json::Value>>;
216    let dir = workspaces_dir(data_dir);
217    let entries = match filesystem.read_dir(&dir) {
218        Ok(e) => e,
219        Err(_) => return Vec::new(),
220    };
221    let mut found: Vec<(
222        PathBuf,
223        String,
224        SessionState,
225        crate::services::authority::SessionAuthoritySpec,
226    )> = Vec::new();
227    for entry in entries {
228        let p = &entry.path;
229        // Only real workspace files. A torn `*.json.tmp` write or a
230        // `*.retired.bak` already fails the `.json` suffix test.
231        if !entry.name.ends_with(".json") {
232            continue;
233        }
234        let Ok(bytes) = filesystem.read_file(p) else {
235            continue;
236        };
237        let Ok(val) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
238            continue;
239        };
240        let Some(root) = val.get("working_dir").and_then(|v| v.as_str()) else {
241            continue;
242        };
243        let root = PathBuf::from(root);
244        // GC only on a *definitive* answer that the root is unusable:
245        // `NotFound` (the directory is gone) or `Ok(false)` (the path
246        // was replaced by a non-dir). Drop the stale cache file then —
247        // best-effort, a failed delete just leaves a harmless file to
248        // retry next boot. Any *other* `Err` (permission, IO, an
249        // unreachable remote/unmounted FS) is ambiguous but recoverable,
250        // so keep the file rather than irreversibly losing the session.
251        match filesystem.is_dir(&root) {
252            Ok(true) => {}
253            Ok(false) => {
254                let _ = filesystem.remove_file(p).ok();
255                continue;
256            }
257            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
258                let _ = filesystem.remove_file(p).ok();
259                continue;
260            }
261            Err(_) => continue,
262        }
263        let label = val
264            .get("label")
265            .and_then(|v| v.as_str())
266            .map(|s| s.to_string())
267            .unwrap_or_else(|| basename_label(&root));
268        let plugin_state: SessionState = val
269            .get("session_plugin_state")
270            .and_then(|v| serde_json::from_value(v.clone()).ok())
271            .unwrap_or_default();
272        // The session's backend spec (how to reconnect on restore). Absent /
273        // unparseable → `Local`, so a malformed entry degrades safely.
274        let authority_spec: crate::services::authority::SessionAuthoritySpec = val
275            .get("authority_spec")
276            .and_then(|v| serde_json::from_value(v.clone()).ok())
277            .unwrap_or_default();
278        found.push((root, label, plugin_state, authority_spec));
279    }
280    found.sort_by(|a, b| canonical_key(&a.0).cmp(&canonical_key(&b.0)));
281    found
282        .into_iter()
283        .enumerate()
284        .map(|(i, (root, label, plugin_state, authority_spec))| {
285            let (project_path, shared_worktree) = read_orch_session_meta(&plugin_state);
286            PersistedWindow {
287                id: (i as u64) + 1,
288                label,
289                root,
290                project_path,
291                shared_worktree,
292                authority_spec,
293                plugin_state,
294            }
295        })
296        .collect()
297}
298
299/// Fold legacy `windows.json` session metadata (label + per-session
300/// plugin state) into the per-dir workspace files, then retire the
301/// file. After this the workspace cache is the sole registry. Only
302/// existing workspace files are backfilled; entries with no workspace
303/// file are dropped (they carried no buffer content to restore). No-op
304/// once `windows.json` is gone.
305fn migrate_windows_json_into_workspaces(
306    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
307    data_dir: &Path,
308) {
309    let global_p = global_windows_path(data_dir);
310    if !filesystem.exists(&global_p) {
311        return;
312    }
313    let Ok(bytes) = filesystem.read_file(&global_p) else {
314        return;
315    };
316    let Ok(env) = serde_json::from_slice::<PersistedWindows>(&bytes) else {
317        return; // leave an unparseable file in place rather than lose it
318    };
319    for w in &env.windows {
320        let ws_path = workspace_file_for(data_dir, &w.root);
321        if !filesystem.exists(&ws_path) {
322            continue;
323        }
324        let Ok(wbytes) = filesystem.read_file(&ws_path) else {
325            continue;
326        };
327        let Ok(mut val) = serde_json::from_slice::<serde_json::Value>(&wbytes) else {
328            continue;
329        };
330        if let Some(obj) = val.as_object_mut() {
331            obj.entry("label")
332                .or_insert_with(|| serde_json::Value::String(w.label.clone()));
333            if !obj.contains_key("session_plugin_state") && !w.plugin_state.is_empty() {
334                if let Ok(ps) = serde_json::to_value(&w.plugin_state) {
335                    obj.insert("session_plugin_state".into(), ps);
336                }
337            }
338        }
339        if let Ok(out) = serde_json::to_vec_pretty(&val) {
340            // Best-effort backfill: on failure the workspace keeps its pre-migration content.
341            let _ = filesystem.write_file(&ws_path, &out).ok();
342        }
343    }
344    // Retire windows.json (keep a .bak so a downgrade isn't one-way).
345    let bak = global_p.with_extension("json.retired.bak");
346    if filesystem.rename(&global_p, &bak).is_err() {
347        // Best-effort: if delete also fails the file stays and migration reruns (idempotent).
348        let _ = filesystem.remove_file(&global_p).ok();
349    }
350}
351
352/// Pick which persisted session to bring up at boot, scoped to the
353/// editor's launch cwd.
354///
355/// The rule the user expects: re-opening the editor in a project
356/// should reopen the session they last used **in that project** —
357/// but never a session from a *different* project (that cross-project
358/// bleed is what made one day's work leak into the next). So we only
359/// ever consider windows that belong to `cwd`:
360///
361///   1. If `env.active` (the globally last-used session at quit)
362///      belongs to `cwd`, that's the last-used session for this
363///      project — bring it up.
364///   2. Else pick the most-recently-*created* window belonging to
365///      `cwd` (highest id — orchestrator ids are monotonic). This is
366///      the fallback for "your last-used session was in another
367///      project, but this one has sessions of its own."
368///   3. Else `None` — the caller boots a clean base window at `cwd`.
369///
370/// A window "belongs to" `cwd` when its **`root`** — the directory the
371/// window actually opens in — equals `cwd` after canonicalization. We
372/// match on `root`, NOT `project_path`: an orchestrator worktree session
373/// carries `project_path == <parent project>` but `root == <worktree>`,
374/// so matching on `project_path` would resurrect a worktree-rooted window
375/// when the user launched in the project dir (issue #2056). `project_path`
376/// stays purely as orchestrator-dialog grouping metadata. The previous
377/// base (id 1) is eligible too — if it was the user's last-used window in
378/// this cwd, reopening it is just a clean editor at the cwd.
379pub(crate) fn pick_active_window_for_cwd<'a>(
380    env: Option<&'a PersistedWindows>,
381    cwd: &Path,
382) -> Option<&'a PersistedWindow> {
383    let env = env?;
384    if let Some(w) = env
385        .windows
386        .iter()
387        .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
388    {
389        return Some(w);
390    }
391    env.windows
392        .iter()
393        .filter(|w| window_matches_cwd(w, cwd))
394        .max_by_key(|w| w.id)
395}
396
397fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
398    paths_equal(&w.root, cwd)
399}
400
401fn paths_equal(a: &Path, b: &Path) -> bool {
402    canonical_key(a) == canonical_key(b)
403}
404
405/// Canonicalized identity for a session root. Sessions are
406/// identified by directory (one session per dir), so every root
407/// comparison and dedup goes through this: it resolves symlinks
408/// and normalizes trailing slashes so `/repos/inty` and
409/// `/repos/inty/` (and a symlinked tmpdir vs its real path) map to
410/// the same session.
411pub(crate) fn canonical_key(path: &Path) -> PathBuf {
412    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
413}
414
415/// Scan `<data>/orchestrator/*/windows.json` for legacy v1
416/// per-cwd files. Fold every session into one v2 envelope, with
417/// `project_path` derived by reverse-decoding the slug
418/// directory name back into the original cwd path. Write the
419/// global file, then rename each legacy file to
420/// `windows.json.migrated.bak` so a downgrade isn't a one-way
421/// trip.
422///
423/// Conflicts: two cwd-keyed files with the same session id
424/// collide rarely (sessions are interactively created and ids
425/// monotonic per-store), but if they do the file with the more
426/// recent mtime wins; the loser's id is re-numbered to
427/// `next_id` of the winning envelope.
428fn migrate_legacy_windows(
429    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
430    data_dir: &Path,
431) {
432    let orch_root = data_dir.join("orchestrator");
433    if !filesystem.exists(&orch_root) {
434        return;
435    }
436    let entries = match filesystem.read_dir(&orch_root) {
437        Ok(es) => es,
438        Err(_) => return,
439    };
440    let mut merged_windows: Vec<PersistedWindow> = Vec::new();
441    let mut merged_active: u64 = 1;
442    let mut merged_next_id: u64 = 2;
443    let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
444    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
445
446    for entry in entries {
447        let dir = entry.path;
448        if !filesystem.is_dir(&dir).unwrap_or(false) {
449            continue;
450        }
451        // Only look at directories that look like slug-encoded
452        // paths (i.e. not the `state/` plugin dir, which lives
453        // alongside but isn't a per-cwd bucket).
454        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
455            Some(n) => n.to_string(),
456            None => continue,
457        };
458        if dir_name == "state" {
459            continue;
460        }
461        let legacy_p = dir.join("windows.json");
462        if !filesystem.exists(&legacy_p) {
463            continue;
464        }
465        let bytes = match filesystem.read_file(&legacy_p) {
466            Ok(b) => b,
467            Err(_) => continue,
468        };
469        let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
470            Ok(e) => e,
471            Err(_) => continue,
472        };
473        let project_path = crate::workspace::decode_filename_to_path(&dir_name)
474            .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
475
476        let mut local_renum: HashMap<u64, u64> = HashMap::new();
477        for mut w in env.windows.into_iter() {
478            // Default project_path to the decoded cwd unless
479            // the entry already carries one (a partial migration
480            // re-running on the same data).
481            if w.project_path.is_none() {
482                w.project_path = Some(project_path.clone());
483            }
484            if used_ids.contains(&w.id) {
485                let new_id = merged_next_id;
486                local_renum.insert(w.id, new_id);
487                merged_next_id = merged_next_id.saturating_add(1);
488                used_ids.insert(new_id);
489                w.id = new_id;
490            } else {
491                used_ids.insert(w.id);
492                merged_next_id = merged_next_id.max(w.id.saturating_add(1));
493            }
494            merged_windows.push(w);
495        }
496        // Most-recently-modified per-cwd file decides which
497        // session id becomes "active" in the merged store.
498        // Stat the file; if we can't, the last file scanned
499        // wins by virtue of being last.
500        let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
501        merged_active = active_id;
502        legacy_to_rename.push(legacy_p);
503    }
504
505    if merged_windows.is_empty() {
506        return;
507    }
508    merged_windows.sort_by_key(|w| w.id);
509    let envelope = PersistedWindows {
510        version: CURRENT_VERSION,
511        active: merged_active,
512        next_id: merged_next_id,
513        windows: merged_windows,
514    };
515    let global_p = global_windows_path(data_dir);
516    if let Err(e) = filesystem.create_dir_all(&orch_root) {
517        tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
518        return;
519    }
520    let bytes = match serde_json::to_vec_pretty(&envelope) {
521        Ok(b) => b,
522        Err(e) => {
523            tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
524            return;
525        }
526    };
527    if let Err(e) = filesystem.write_file(&global_p, &bytes) {
528        tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
529        return;
530    }
531    for legacy_p in legacy_to_rename {
532        let backup = legacy_p.with_extension("json.migrated.bak");
533        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
534            tracing::warn!(
535                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
536            );
537        }
538    }
539    tracing::info!(
540        "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
541        envelope.windows.len(),
542        global_p
543    );
544}
545
546/// Read every `state/<plugin>.json` into a flat
547/// `plugin → key → value` map. Skips files with unsafe names,
548/// non-JSON extensions, parse errors, and empty maps. Same
549/// motivations as [`read_persisted_windows_env`] — used by the
550/// editor factory pre-construction.
551///
552/// Reads from the global `<data>/orchestrator/state/` directory.
553/// The legacy per-cwd plugin state files (under
554/// `<data>/orchestrator/<encoded_cwd>/state/`) are folded into
555/// the global directory the first time we encounter no global
556/// state and at least one legacy file — see
557/// `migrate_legacy_plugin_state`.
558pub(crate) fn read_persisted_plugin_state(
559    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
560    data_dir: &Path,
561    _working_dir: &Path,
562) -> HashMap<String, HashMap<String, serde_json::Value>> {
563    let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
564    let state_dir = global_state_dir(data_dir);
565    if !filesystem.exists(&state_dir) {
566        migrate_legacy_plugin_state(filesystem, data_dir);
567    }
568    if !filesystem.exists(&state_dir) {
569        return out;
570    }
571    let entries = match filesystem.read_dir(&state_dir) {
572        Ok(es) => es,
573        Err(e) => {
574            tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
575            return out;
576        }
577    };
578    for entry in entries {
579        let path = entry.path;
580        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
581            continue;
582        };
583        if !plugin_name_is_safe(stem) {
584            continue;
585        }
586        if path.extension().and_then(|e| e.to_str()) != Some("json") {
587            continue;
588        }
589        match filesystem.read_file(&path) {
590            Ok(bytes) => {
591                match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
592                    Ok(map) if !map.is_empty() => {
593                        out.insert(stem.to_owned(), map);
594                    }
595                    Ok(_) => {}
596                    Err(e) => {
597                        tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
598                    }
599                }
600            }
601            Err(e) => {
602                tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
603            }
604        }
605    }
606    out
607}
608
609/// Global orchestrator state location under the platform data
610/// dir. v2 stores everything in one tree regardless of the
611/// editor's cwd; see issue #1991 for why this is no longer
612/// rooted at `<working_dir>/.fresh`.
613fn orchestrator_dir(data_dir: &Path) -> PathBuf {
614    data_dir.join("orchestrator")
615}
616
617fn global_windows_path(data_dir: &Path) -> PathBuf {
618    orchestrator_dir(data_dir).join("windows.json")
619}
620
621fn global_state_dir(data_dir: &Path) -> PathBuf {
622    orchestrator_dir(data_dir).join("state")
623}
624
625fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
626    // Plugin names are short identifiers (`orchestrator`,
627    // `live_grep`, …) so no escaping is needed for typical
628    // input. Reject anything that would escape the state dir to
629    // avoid `../`-style traversal in case a plugin picks a
630    // pathological name.
631    global_state_dir(data_dir).join(format!("{plugin}.json"))
632}
633
634fn plugin_name_is_safe(name: &str) -> bool {
635    !name.is_empty()
636        && name
637            .chars()
638            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
639        && !name.starts_with('.')
640}
641
642/// Fold legacy per-cwd plugin state into the global
643/// `<data>/orchestrator/state/` directory. Per-plugin files
644/// with the same name are merged key-by-key; the most recently
645/// modified cwd's file wins on conflict. Legacy files are
646/// renamed to `<plugin>.json.migrated.bak`. Best-effort: any
647/// filesystem error logs WARN and continues.
648fn migrate_legacy_plugin_state(
649    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
650    data_dir: &Path,
651) {
652    let orch_root = data_dir.join("orchestrator");
653    if !filesystem.exists(&orch_root) {
654        return;
655    }
656    let cwd_entries = match filesystem.read_dir(&orch_root) {
657        Ok(es) => es,
658        Err(_) => return,
659    };
660    let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
661    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
662    for cwd_entry in cwd_entries {
663        let dir = cwd_entry.path;
664        if !filesystem.is_dir(&dir).unwrap_or(false) {
665            continue;
666        }
667        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
668            Some(n) => n.to_string(),
669            None => continue,
670        };
671        if dir_name == "state" {
672            continue;
673        }
674        let state_dir = dir.join("state");
675        if !filesystem.exists(&state_dir) {
676            continue;
677        }
678        let plugin_entries = match filesystem.read_dir(&state_dir) {
679            Ok(es) => es,
680            Err(_) => continue,
681        };
682        for pe in plugin_entries {
683            let p = pe.path;
684            let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
685                continue;
686            };
687            if !plugin_name_is_safe(stem) {
688                continue;
689            }
690            if p.extension().and_then(|e| e.to_str()) != Some("json") {
691                continue;
692            }
693            let bytes = match filesystem.read_file(&p) {
694                Ok(b) => b,
695                Err(_) => continue,
696            };
697            let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
698                Ok(m) => m,
699                Err(_) => continue,
700            };
701            let slot = merged.entry(stem.to_owned()).or_default();
702            for (k, v) in map {
703                slot.insert(k, v);
704            }
705            legacy_to_rename.push(p);
706        }
707    }
708    if merged.is_empty() {
709        return;
710    }
711    let target_state_dir = global_state_dir(data_dir);
712    if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
713        tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
714        return;
715    }
716    for (plugin, map) in &merged {
717        let path = global_plugin_state_path(data_dir, plugin);
718        let bytes = match serde_json::to_vec_pretty(map) {
719            Ok(b) => b,
720            Err(e) => {
721                tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
722                continue;
723            }
724        };
725        if let Err(e) = filesystem.write_file(&path, &bytes) {
726            tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
727        }
728    }
729    for legacy_p in legacy_to_rename {
730        let backup = legacy_p.with_extension("json.migrated.bak");
731        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
732            tracing::warn!(
733                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
734            );
735        }
736    }
737    tracing::info!(
738        "orchestrator persistence: migrated plugin state for {} plugins",
739        merged.len()
740    );
741}
742
743impl Editor {
744    /// Persist `sessions` + `plugin_global_state` to disk. Best-
745    /// effort: filesystem errors are logged at WARN and swallowed
746    /// so a transient permission glitch doesn't block quit.
747    pub fn save_orchestrator_state(&self) {
748        let data_dir = self.dir_context.data_dir.clone();
749        let orch_dir = orchestrator_dir(&data_dir);
750        if let Err(e) = self.authority().filesystem.create_dir_all(&orch_dir) {
751            tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
752            return;
753        }
754
755        // Sessions are no longer written to a central windows.json:
756        // each window's identity (label, per-session plugin_state) is
757        // persisted in its own per-dir workspace file by
758        // `save_all_windows_workspaces` (called just before this on
759        // quit), and the session list is rediscovered from those files
760        // at boot. Only editor-global plugin state is written here.
761
762        // Plugin global state — one file per plugin. Single
763        // global directory now (no per-cwd split), so two
764        // editor processes writing the same plugin's state
765        // still need atomic-rename safety.
766        let state_dir = global_state_dir(&data_dir);
767        if !self.plugin_global_state.is_empty() {
768            if let Err(e) = self.authority().filesystem.create_dir_all(&state_dir) {
769                tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
770                return;
771            }
772        }
773        for (plugin, map) in &self.plugin_global_state {
774            if !plugin_name_is_safe(plugin) {
775                tracing::warn!(
776                    "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
777                );
778                continue;
779            }
780            if map.is_empty() {
781                continue;
782            }
783            match serde_json::to_vec_pretty(map) {
784                Ok(bytes) => {
785                    let path = global_plugin_state_path(&data_dir, plugin);
786                    let tmp = path.with_extension("json.tmp");
787                    if let Err(e) = self.authority().filesystem.write_file(&tmp, &bytes) {
788                        tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
789                        continue;
790                    }
791                    if let Err(e) = self.authority().filesystem.rename(&tmp, &path) {
792                        tracing::warn!(
793                            "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
794                        );
795                    }
796                }
797                Err(e) => {
798                    tracing::warn!(
799                        "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
800                    );
801                }
802            }
803        }
804    }
805}
806
807/// Pull `project_path` (PathBuf) and `shared_worktree` (bool)
808/// out of a session's per-plugin state, if the orchestrator
809/// plugin has set them via `setWindowState`. Both keys live
810/// under the `"orchestrator"` plugin slot; the keys are
811/// `"project_path"` and `"shared_worktree"`.
812fn read_orch_session_meta(
813    plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
814) -> (Option<PathBuf>, bool) {
815    let slot = plugin_state.get("orchestrator");
816    let project_path = slot
817        .and_then(|m| m.get("project_path"))
818        .and_then(|v| v.as_str())
819        .map(PathBuf::from);
820    let shared_worktree = slot
821        .and_then(|m| m.get("shared_worktree"))
822        .and_then(|v| v.as_bool())
823        .unwrap_or(false);
824    (project_path, shared_worktree)
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn paths_live_under_data_dir_not_working_dir() {
833        // Regression test for issue #1991: orchestrator persistence
834        // must never write inside the user's working tree.
835        let data_dir = Path::new("/tmp/fresh-data");
836        let working_dir = Path::new("/home/user/project");
837
838        let wp = global_windows_path(data_dir);
839        let sd = global_state_dir(data_dir);
840        let psp = global_plugin_state_path(data_dir, "orchestrator");
841
842        assert!(
843            wp.starts_with(data_dir),
844            "windows_path must live under data_dir, got {wp:?}"
845        );
846        assert!(
847            sd.starts_with(data_dir),
848            "state_dir must live under data_dir, got {sd:?}"
849        );
850        assert!(
851            psp.starts_with(data_dir),
852            "plugin_state_path must live under data_dir, got {psp:?}"
853        );
854
855        for p in [&wp, &sd, &psp] {
856            assert!(
857                !p.starts_with(working_dir),
858                "orchestrator path must not be inside the working tree: {p:?}"
859            );
860            for component in p.components() {
861                if let std::path::Component::Normal(c) = component {
862                    assert_ne!(
863                        c, ".fresh",
864                        "orchestrator path must not contain a `.fresh` component: {p:?}"
865                    );
866                }
867            }
868        }
869    }
870
871    fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
872        PersistedWindow {
873            id,
874            label: String::new(),
875            root: PathBuf::from(root),
876            project_path: project_path.map(PathBuf::from),
877            shared_worktree: false,
878            authority_spec: Default::default(),
879            plugin_state: HashMap::new(),
880        }
881    }
882
883    fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
884        PersistedWindows {
885            version: CURRENT_VERSION,
886            active,
887            next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
888            windows,
889        }
890    }
891
892    #[test]
893    fn pick_active_never_crosses_projects() {
894        // Regression for the orchestration bug: launching in /repoB
895        // must never bring up a session rooted in /repoA, even when
896        // /repoA holds the globally last-used session (env.active).
897        let env = env_with(
898            2,
899            vec![
900                make_window(1, "/repoA", Some("/repoA")),
901                make_window(2, "/repoA", Some("/repoA")),
902                make_window(3, "/repoB", Some("/repoB")),
903            ],
904        );
905        let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
906            .expect("a /repoB session exists");
907        assert_eq!(
908            picked.id, 3,
909            "must pick the /repoB session, not env.active=2"
910        );
911    }
912
913    #[test]
914    fn pick_active_reopens_last_used_for_cwd() {
915        // env.active points at this project's last-used session — it
916        // wins even though it isn't the highest id.
917        let env = env_with(
918            2,
919            vec![
920                make_window(2, "/repoA", Some("/repoA")),
921                make_window(5, "/repoA", Some("/repoA")),
922            ],
923        );
924        let picked =
925            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
926        assert_eq!(
927            picked.id, 2,
928            "env.active is the last-used session for the cwd"
929        );
930    }
931
932    #[test]
933    fn pick_active_falls_back_to_most_recent_session_for_cwd() {
934        // The globally last-used session (env.active=9) is in another
935        // project, so for /repoA we fall back to the most-recently-
936        // created /repoA session (highest id), not the first.
937        let env = env_with(
938            9,
939            vec![
940                make_window(2, "/repoA", Some("/repoA")),
941                make_window(7, "/repoA", Some("/repoA")),
942                make_window(9, "/repoB", Some("/repoB")),
943            ],
944        );
945        let picked =
946            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
947        assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
948    }
949
950    #[test]
951    fn pick_active_returns_none_when_no_window_matches_cwd() {
952        // No session for this cwd → caller boots a clean base window.
953        let env = env_with(
954            1,
955            vec![
956                make_window(1, "/repoA", Some("/repoA")),
957                make_window(2, "/repoB", Some("/repoB")),
958            ],
959        );
960        assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
961    }
962
963    #[test]
964    fn pick_active_falls_back_to_root_when_project_path_missing() {
965        // Legacy v1-migrated entries may lack project_path; match on root.
966        let env = env_with(
967            2,
968            vec![
969                make_window(1, "/repoA", None),
970                make_window(2, "/repoB", None),
971            ],
972        );
973        let picked =
974            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
975        assert_eq!(picked.id, 1);
976    }
977
978    #[test]
979    fn global_paths_are_independent_of_working_dir() {
980        // v2: persistence is global, not per-cwd. Two different
981        // cwds resolve to the same file path so the user sees
982        // their full session history regardless of where the
983        // editor was launched from.
984        let data_dir = Path::new("/tmp/fresh-data");
985        let a = global_windows_path(data_dir);
986        let b = global_windows_path(data_dir);
987        assert_eq!(a, b);
988        assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
989    }
990
991    #[test]
992    fn discover_gcs_missing_dirs_and_yields_one_session_per_existing_dir() {
993        use crate::model::filesystem::StdFileSystem;
994        let data = tempfile::tempdir().unwrap();
995        let data_dir = data.path();
996        let ws_dir = workspaces_dir(data_dir);
997        std::fs::create_dir_all(&ws_dir).unwrap();
998
999        // A workspace file for an existing dir...
1000        let live = tempfile::tempdir().unwrap();
1001        let live_root = live.path().canonicalize().unwrap();
1002        let live_file = ws_dir.join("live.json");
1003        std::fs::write(
1004            &live_file,
1005            serde_json::to_vec(&serde_json::json!({
1006                "working_dir": live_root, "label": "live-session",
1007            }))
1008            .unwrap(),
1009        )
1010        .unwrap();
1011
1012        // ...and one for a directory that does not exist.
1013        let dead_file = ws_dir.join("dead.json");
1014        std::fs::write(
1015            &dead_file,
1016            serde_json::to_vec(&serde_json::json!({
1017                "working_dir": "/no/such/dir/anywhere", "label": "dead",
1018            }))
1019            .unwrap(),
1020        )
1021        .unwrap();
1022
1023        let fs = StdFileSystem;
1024        let sessions = discover_sessions(&fs, data_dir);
1025
1026        assert_eq!(sessions.len(), 1, "only the existing dir yields a session");
1027        assert_eq!(sessions[0].root, live_root);
1028        assert_eq!(sessions[0].label, "live-session");
1029        assert!(!dead_file.exists(), "the dead dir's cache file was GC'd");
1030        assert!(live_file.exists(), "the live cache file is kept");
1031    }
1032
1033    #[test]
1034    fn discover_reads_authority_spec_so_remote_sessions_arent_lost() {
1035        // A session that was running on a remote backend persists an
1036        // `authority_spec` in its workspace file; discovery must surface it
1037        // (so restore can reconnect rather than degrade to local). A file
1038        // without the field reads back as `Local` — back-compat for sessions
1039        // written before per-session backends existed.
1040        use crate::model::filesystem::StdFileSystem;
1041        use crate::services::authority::{
1042            AuthorityPayload, FilesystemSpec, SessionAuthoritySpec, SpawnerSpec,
1043            TerminalWrapperSpec,
1044        };
1045        let data = tempfile::tempdir().unwrap();
1046        let data_dir = data.path();
1047        let ws_dir = workspaces_dir(data_dir);
1048        std::fs::create_dir_all(&ws_dir).unwrap();
1049
1050        let remote_root = tempfile::tempdir().unwrap();
1051        let remote_root = remote_root.path().canonicalize().unwrap();
1052        let spec = SessionAuthoritySpec::Plugin(AuthorityPayload {
1053            filesystem: FilesystemSpec::Local,
1054            spawner: SpawnerSpec::DockerExec {
1055                container_id: "abc123".into(),
1056                user: Some("vscode".into()),
1057                workspace: Some("/workspaces/proj".into()),
1058                env: Vec::new(),
1059            },
1060            terminal_wrapper: TerminalWrapperSpec::HostShell,
1061            display_label: "Container:abc123".into(),
1062            path_translation: None,
1063        });
1064        std::fs::write(
1065            ws_dir.join("remote.json"),
1066            serde_json::to_vec(&serde_json::json!({
1067                "working_dir": remote_root,
1068                "label": "remote-session",
1069                "authority_spec": spec,
1070            }))
1071            .unwrap(),
1072        )
1073        .unwrap();
1074
1075        // A plain local session with no `authority_spec` field at all.
1076        let local_root = tempfile::tempdir().unwrap();
1077        let local_root = local_root.path().canonicalize().unwrap();
1078        std::fs::write(
1079            ws_dir.join("local.json"),
1080            serde_json::to_vec(&serde_json::json!({
1081                "working_dir": local_root, "label": "local-session",
1082            }))
1083            .unwrap(),
1084        )
1085        .unwrap();
1086
1087        let fs = StdFileSystem;
1088        let sessions = discover_sessions(&fs, data_dir);
1089
1090        let remote = sessions
1091            .iter()
1092            .find(|s| s.label == "remote-session")
1093            .expect("remote session discovered");
1094        assert_eq!(
1095            remote.authority_spec, spec,
1096            "the remote backend spec round-trips through discovery"
1097        );
1098        let local = sessions
1099            .iter()
1100            .find(|s| s.label == "local-session")
1101            .expect("local session discovered");
1102        assert_eq!(
1103            local.authority_spec,
1104            SessionAuthoritySpec::Local,
1105            "a session with no persisted spec reads back as Local"
1106        );
1107    }
1108
1109    #[test]
1110    fn migrate_folds_windows_json_into_workspace_files_and_retires_it() {
1111        use crate::model::filesystem::StdFileSystem;
1112        let data = tempfile::tempdir().unwrap();
1113        let data_dir = data.path();
1114        let proj = tempfile::tempdir().unwrap();
1115        let proj_root = proj.path().canonicalize().unwrap();
1116
1117        // An existing per-dir workspace file with no label yet.
1118        let ws_path = workspace_file_for(data_dir, &proj_root);
1119        std::fs::create_dir_all(ws_path.parent().unwrap()).unwrap();
1120        std::fs::write(
1121            &ws_path,
1122            serde_json::to_vec(&serde_json::json!({ "working_dir": proj_root })).unwrap(),
1123        )
1124        .unwrap();
1125
1126        // A legacy windows.json naming that session with a label.
1127        let global_p = global_windows_path(data_dir);
1128        std::fs::create_dir_all(global_p.parent().unwrap()).unwrap();
1129        std::fs::write(
1130            &global_p,
1131            serde_json::to_vec(&serde_json::json!({
1132                "version": 2, "active": 1, "next_id": 2,
1133                "windows": [ { "id": 1, "label": "from-windows-json", "root": proj_root } ],
1134            }))
1135            .unwrap(),
1136        )
1137        .unwrap();
1138
1139        let fs = StdFileSystem;
1140        migrate_windows_json_into_workspaces(&fs, data_dir);
1141
1142        assert!(!global_p.exists(), "windows.json is retired");
1143        assert!(
1144            global_p.with_extension("json.retired.bak").exists(),
1145            "a .retired.bak is kept"
1146        );
1147        let val: serde_json::Value =
1148            serde_json::from_slice(&std::fs::read(&ws_path).unwrap()).unwrap();
1149        assert_eq!(
1150            val.get("label").and_then(|v| v.as_str()),
1151            Some("from-windows-json"),
1152            "the label was folded into the per-dir workspace file"
1153        );
1154    }
1155}