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        // The session's backend spec (how to reconnect on restore). Absent /
245        // unparseable → `Local`, so a malformed entry degrades safely. Read
246        // *before* the GC check: a remote session's `root` lives on the
247        // remote host, so it can't be validated against the local filesystem.
248        let authority_spec: crate::services::authority::SessionAuthoritySpec = val
249            .get("authority_spec")
250            .and_then(|v| serde_json::from_value(v.clone()).ok())
251            .unwrap_or_default();
252        // GC only local sessions, and only on a *definitive* answer that the
253        // root is unusable: `NotFound` (the directory is gone) or `Ok(false)`
254        // (the path was replaced by a non-dir). Drop the stale cache file then
255        // — best-effort, a failed delete just leaves a harmless file to retry
256        // next boot. Any *other* `Err` (permission, IO, an unreachable
257        // remote/unmounted FS) is ambiguous but recoverable, so keep the file
258        // rather than irreversibly losing the session.
259        //
260        // Remote sessions (SSH / kube) are *never* GC'd against the local
261        // filesystem: their `root` is a path on the remote host that the local
262        // `filesystem` here can't see, so `is_dir` would answer `Ok(false)`
263        // and silently delete every remote session's workspace file on the
264        // next boot — the session would vanish from the Orchestrator dock
265        // after a restart. Whether the remote dir still exists is only knowable
266        // after reconnecting, so we keep the entry and let restore decide.
267        if !authority_spec.is_remote() {
268            match filesystem.is_dir(&root) {
269                Ok(true) => {}
270                Ok(false) => {
271                    let _ = filesystem.remove_file(p).ok();
272                    continue;
273                }
274                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
275                    let _ = filesystem.remove_file(p).ok();
276                    continue;
277                }
278                Err(_) => continue,
279            }
280        }
281        let label = val
282            .get("label")
283            .and_then(|v| v.as_str())
284            .map(|s| s.to_string())
285            .unwrap_or_else(|| basename_label(&root));
286        let plugin_state: SessionState = val
287            .get("session_plugin_state")
288            .and_then(|v| serde_json::from_value(v.clone()).ok())
289            .unwrap_or_default();
290        found.push((root, label, plugin_state, authority_spec));
291    }
292    found.sort_by(|a, b| canonical_key(&a.0).cmp(&canonical_key(&b.0)));
293    found
294        .into_iter()
295        .enumerate()
296        .map(|(i, (root, label, plugin_state, authority_spec))| {
297            let (project_path, shared_worktree) = read_orch_session_meta(&plugin_state);
298            PersistedWindow {
299                id: (i as u64) + 1,
300                label,
301                root,
302                project_path,
303                shared_worktree,
304                authority_spec,
305                plugin_state,
306            }
307        })
308        .collect()
309}
310
311/// Fold legacy `windows.json` session metadata (label + per-session
312/// plugin state) into the per-dir workspace files, then retire the
313/// file. After this the workspace cache is the sole registry. Only
314/// existing workspace files are backfilled; entries with no workspace
315/// file are dropped (they carried no buffer content to restore). No-op
316/// once `windows.json` is gone.
317fn migrate_windows_json_into_workspaces(
318    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
319    data_dir: &Path,
320) {
321    let global_p = global_windows_path(data_dir);
322    if !filesystem.exists(&global_p) {
323        return;
324    }
325    let Ok(bytes) = filesystem.read_file(&global_p) else {
326        return;
327    };
328    let Ok(env) = serde_json::from_slice::<PersistedWindows>(&bytes) else {
329        return; // leave an unparseable file in place rather than lose it
330    };
331    for w in &env.windows {
332        let ws_path = workspace_file_for(data_dir, &w.root);
333        if !filesystem.exists(&ws_path) {
334            continue;
335        }
336        let Ok(wbytes) = filesystem.read_file(&ws_path) else {
337            continue;
338        };
339        let Ok(mut val) = serde_json::from_slice::<serde_json::Value>(&wbytes) else {
340            continue;
341        };
342        if let Some(obj) = val.as_object_mut() {
343            obj.entry("label")
344                .or_insert_with(|| serde_json::Value::String(w.label.clone()));
345            if !obj.contains_key("session_plugin_state") && !w.plugin_state.is_empty() {
346                if let Ok(ps) = serde_json::to_value(&w.plugin_state) {
347                    obj.insert("session_plugin_state".into(), ps);
348                }
349            }
350        }
351        if let Ok(out) = serde_json::to_vec_pretty(&val) {
352            // Best-effort backfill: on failure the workspace keeps its pre-migration content.
353            let _ = filesystem.write_file(&ws_path, &out).ok();
354        }
355    }
356    // Retire windows.json (keep a .bak so a downgrade isn't one-way).
357    let bak = global_p.with_extension("json.retired.bak");
358    if filesystem.rename(&global_p, &bak).is_err() {
359        // Best-effort: if delete also fails the file stays and migration reruns (idempotent).
360        let _ = filesystem.remove_file(&global_p).ok();
361    }
362}
363
364/// Pick which persisted session to bring up at boot, scoped to the
365/// editor's launch cwd.
366///
367/// The rule the user expects: re-opening the editor in a project
368/// should reopen the session they last used **in that project** —
369/// but never a session from a *different* project (that cross-project
370/// bleed is what made one day's work leak into the next). So we only
371/// ever consider windows that belong to `cwd`:
372///
373///   1. If `env.active` (the globally last-used session at quit)
374///      belongs to `cwd`, that's the last-used session for this
375///      project — bring it up.
376///   2. Else pick the most-recently-*created* window belonging to
377///      `cwd` (highest id — orchestrator ids are monotonic). This is
378///      the fallback for "your last-used session was in another
379///      project, but this one has sessions of its own."
380///   3. Else `None` — the caller boots a clean base window at `cwd`.
381///
382/// A window "belongs to" `cwd` when its **`root`** — the directory the
383/// window actually opens in — equals `cwd` after canonicalization. We
384/// match on `root`, NOT `project_path`: an orchestrator worktree session
385/// carries `project_path == <parent project>` but `root == <worktree>`,
386/// so matching on `project_path` would resurrect a worktree-rooted window
387/// when the user launched in the project dir (issue #2056). `project_path`
388/// stays purely as orchestrator-dialog grouping metadata. The previous
389/// base (id 1) is eligible too — if it was the user's last-used window in
390/// this cwd, reopening it is just a clean editor at the cwd.
391pub(crate) fn pick_active_window_for_cwd<'a>(
392    env: Option<&'a PersistedWindows>,
393    cwd: &Path,
394) -> Option<&'a PersistedWindow> {
395    let env = env?;
396    if let Some(w) = env
397        .windows
398        .iter()
399        .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
400    {
401        return Some(w);
402    }
403    env.windows
404        .iter()
405        .filter(|w| window_matches_cwd(w, cwd))
406        .max_by_key(|w| w.id)
407}
408
409fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
410    paths_equal(&w.root, cwd)
411}
412
413fn paths_equal(a: &Path, b: &Path) -> bool {
414    canonical_key(a) == canonical_key(b)
415}
416
417/// Canonicalized identity for a session root. Sessions are
418/// identified by directory (one session per dir), so every root
419/// comparison and dedup goes through this: it resolves symlinks
420/// and normalizes trailing slashes so `/repos/inty` and
421/// `/repos/inty/` (and a symlinked tmpdir vs its real path) map to
422/// the same session.
423pub(crate) fn canonical_key(path: &Path) -> PathBuf {
424    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
425}
426
427/// Scan `<data>/orchestrator/*/windows.json` for legacy v1
428/// per-cwd files. Fold every session into one v2 envelope, with
429/// `project_path` derived by reverse-decoding the slug
430/// directory name back into the original cwd path. Write the
431/// global file, then rename each legacy file to
432/// `windows.json.migrated.bak` so a downgrade isn't a one-way
433/// trip.
434///
435/// Conflicts: two cwd-keyed files with the same session id
436/// collide rarely (sessions are interactively created and ids
437/// monotonic per-store), but if they do the file with the more
438/// recent mtime wins; the loser's id is re-numbered to
439/// `next_id` of the winning envelope.
440fn migrate_legacy_windows(
441    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
442    data_dir: &Path,
443) {
444    let orch_root = data_dir.join("orchestrator");
445    if !filesystem.exists(&orch_root) {
446        return;
447    }
448    let entries = match filesystem.read_dir(&orch_root) {
449        Ok(es) => es,
450        Err(_) => return,
451    };
452    let mut merged_windows: Vec<PersistedWindow> = Vec::new();
453    let mut merged_active: u64 = 1;
454    let mut merged_next_id: u64 = 2;
455    let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
456    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
457
458    for entry in entries {
459        let dir = entry.path;
460        if !filesystem.is_dir(&dir).unwrap_or(false) {
461            continue;
462        }
463        // Only look at directories that look like slug-encoded
464        // paths (i.e. not the `state/` plugin dir, which lives
465        // alongside but isn't a per-cwd bucket).
466        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
467            Some(n) => n.to_string(),
468            None => continue,
469        };
470        if dir_name == "state" {
471            continue;
472        }
473        let legacy_p = dir.join("windows.json");
474        if !filesystem.exists(&legacy_p) {
475            continue;
476        }
477        let bytes = match filesystem.read_file(&legacy_p) {
478            Ok(b) => b,
479            Err(_) => continue,
480        };
481        let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
482            Ok(e) => e,
483            Err(_) => continue,
484        };
485        let project_path = crate::workspace::decode_filename_to_path(&dir_name)
486            .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
487
488        let mut local_renum: HashMap<u64, u64> = HashMap::new();
489        for mut w in env.windows.into_iter() {
490            // Default project_path to the decoded cwd unless
491            // the entry already carries one (a partial migration
492            // re-running on the same data).
493            if w.project_path.is_none() {
494                w.project_path = Some(project_path.clone());
495            }
496            if used_ids.contains(&w.id) {
497                let new_id = merged_next_id;
498                local_renum.insert(w.id, new_id);
499                merged_next_id = merged_next_id.saturating_add(1);
500                used_ids.insert(new_id);
501                w.id = new_id;
502            } else {
503                used_ids.insert(w.id);
504                merged_next_id = merged_next_id.max(w.id.saturating_add(1));
505            }
506            merged_windows.push(w);
507        }
508        // Most-recently-modified per-cwd file decides which
509        // session id becomes "active" in the merged store.
510        // Stat the file; if we can't, the last file scanned
511        // wins by virtue of being last.
512        let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
513        merged_active = active_id;
514        legacy_to_rename.push(legacy_p);
515    }
516
517    if merged_windows.is_empty() {
518        return;
519    }
520    merged_windows.sort_by_key(|w| w.id);
521    let envelope = PersistedWindows {
522        version: CURRENT_VERSION,
523        active: merged_active,
524        next_id: merged_next_id,
525        windows: merged_windows,
526    };
527    let global_p = global_windows_path(data_dir);
528    if let Err(e) = filesystem.create_dir_all(&orch_root) {
529        tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
530        return;
531    }
532    let bytes = match serde_json::to_vec_pretty(&envelope) {
533        Ok(b) => b,
534        Err(e) => {
535            tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
536            return;
537        }
538    };
539    if let Err(e) = filesystem.write_file(&global_p, &bytes) {
540        tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
541        return;
542    }
543    for legacy_p in legacy_to_rename {
544        let backup = legacy_p.with_extension("json.migrated.bak");
545        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
546            tracing::warn!(
547                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
548            );
549        }
550    }
551    tracing::info!(
552        "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
553        envelope.windows.len(),
554        global_p
555    );
556}
557
558/// Read every `state/<plugin>.json` into a flat
559/// `plugin → key → value` map. Skips files with unsafe names,
560/// non-JSON extensions, parse errors, and empty maps. Same
561/// motivations as [`read_persisted_windows_env`] — used by the
562/// editor factory pre-construction.
563///
564/// Reads from the global `<data>/orchestrator/state/` directory.
565/// The legacy per-cwd plugin state files (under
566/// `<data>/orchestrator/<encoded_cwd>/state/`) are folded into
567/// the global directory the first time we encounter no global
568/// state and at least one legacy file — see
569/// `migrate_legacy_plugin_state`.
570pub(crate) fn read_persisted_plugin_state(
571    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
572    data_dir: &Path,
573    _working_dir: &Path,
574) -> HashMap<String, HashMap<String, serde_json::Value>> {
575    let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
576    let state_dir = global_state_dir(data_dir);
577    if !filesystem.exists(&state_dir) {
578        migrate_legacy_plugin_state(filesystem, data_dir);
579    }
580    if !filesystem.exists(&state_dir) {
581        return out;
582    }
583    let entries = match filesystem.read_dir(&state_dir) {
584        Ok(es) => es,
585        Err(e) => {
586            tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
587            return out;
588        }
589    };
590    for entry in entries {
591        let path = entry.path;
592        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
593            continue;
594        };
595        if !plugin_name_is_safe(stem) {
596            continue;
597        }
598        if path.extension().and_then(|e| e.to_str()) != Some("json") {
599            continue;
600        }
601        match filesystem.read_file(&path) {
602            Ok(bytes) => {
603                match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
604                    Ok(map) if !map.is_empty() => {
605                        out.insert(stem.to_owned(), map);
606                    }
607                    Ok(_) => {}
608                    Err(e) => {
609                        tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
610                    }
611                }
612            }
613            Err(e) => {
614                tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
615            }
616        }
617    }
618    out
619}
620
621/// Global orchestrator state location under the platform data
622/// dir. v2 stores everything in one tree regardless of the
623/// editor's cwd; see issue #1991 for why this is no longer
624/// rooted at `<working_dir>/.fresh`.
625fn orchestrator_dir(data_dir: &Path) -> PathBuf {
626    data_dir.join("orchestrator")
627}
628
629fn global_windows_path(data_dir: &Path) -> PathBuf {
630    orchestrator_dir(data_dir).join("windows.json")
631}
632
633fn global_state_dir(data_dir: &Path) -> PathBuf {
634    orchestrator_dir(data_dir).join("state")
635}
636
637fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
638    // Plugin names are short identifiers (`orchestrator`,
639    // `live_grep`, …) so no escaping is needed for typical
640    // input. Reject anything that would escape the state dir to
641    // avoid `../`-style traversal in case a plugin picks a
642    // pathological name.
643    global_state_dir(data_dir).join(format!("{plugin}.json"))
644}
645
646fn plugin_name_is_safe(name: &str) -> bool {
647    !name.is_empty()
648        && name
649            .chars()
650            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
651        && !name.starts_with('.')
652}
653
654/// Fold legacy per-cwd plugin state into the global
655/// `<data>/orchestrator/state/` directory. Per-plugin files
656/// with the same name are merged key-by-key; the most recently
657/// modified cwd's file wins on conflict. Legacy files are
658/// renamed to `<plugin>.json.migrated.bak`. Best-effort: any
659/// filesystem error logs WARN and continues.
660fn migrate_legacy_plugin_state(
661    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
662    data_dir: &Path,
663) {
664    let orch_root = data_dir.join("orchestrator");
665    if !filesystem.exists(&orch_root) {
666        return;
667    }
668    let cwd_entries = match filesystem.read_dir(&orch_root) {
669        Ok(es) => es,
670        Err(_) => return,
671    };
672    let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
673    let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
674    for cwd_entry in cwd_entries {
675        let dir = cwd_entry.path;
676        if !filesystem.is_dir(&dir).unwrap_or(false) {
677            continue;
678        }
679        let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
680            Some(n) => n.to_string(),
681            None => continue,
682        };
683        if dir_name == "state" {
684            continue;
685        }
686        let state_dir = dir.join("state");
687        if !filesystem.exists(&state_dir) {
688            continue;
689        }
690        let plugin_entries = match filesystem.read_dir(&state_dir) {
691            Ok(es) => es,
692            Err(_) => continue,
693        };
694        for pe in plugin_entries {
695            let p = pe.path;
696            let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
697                continue;
698            };
699            if !plugin_name_is_safe(stem) {
700                continue;
701            }
702            if p.extension().and_then(|e| e.to_str()) != Some("json") {
703                continue;
704            }
705            let bytes = match filesystem.read_file(&p) {
706                Ok(b) => b,
707                Err(_) => continue,
708            };
709            let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
710                Ok(m) => m,
711                Err(_) => continue,
712            };
713            let slot = merged.entry(stem.to_owned()).or_default();
714            for (k, v) in map {
715                slot.insert(k, v);
716            }
717            legacy_to_rename.push(p);
718        }
719    }
720    if merged.is_empty() {
721        return;
722    }
723    let target_state_dir = global_state_dir(data_dir);
724    if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
725        tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
726        return;
727    }
728    for (plugin, map) in &merged {
729        let path = global_plugin_state_path(data_dir, plugin);
730        let bytes = match serde_json::to_vec_pretty(map) {
731            Ok(b) => b,
732            Err(e) => {
733                tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
734                continue;
735            }
736        };
737        if let Err(e) = filesystem.write_file(&path, &bytes) {
738            tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
739        }
740    }
741    for legacy_p in legacy_to_rename {
742        let backup = legacy_p.with_extension("json.migrated.bak");
743        if let Err(e) = filesystem.rename(&legacy_p, &backup) {
744            tracing::warn!(
745                "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
746            );
747        }
748    }
749    tracing::info!(
750        "orchestrator persistence: migrated plugin state for {} plugins",
751        merged.len()
752    );
753}
754
755impl Editor {
756    /// Persist `sessions` + `plugin_global_state` to disk. Best-
757    /// effort: filesystem errors are logged at WARN and swallowed
758    /// so a transient permission glitch doesn't block quit.
759    pub fn save_orchestrator_state(&self) {
760        let data_dir = self.dir_context.data_dir.clone();
761        let orch_dir = orchestrator_dir(&data_dir);
762        if let Err(e) = self.authority().filesystem.create_dir_all(&orch_dir) {
763            tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
764            return;
765        }
766
767        // Sessions are no longer written to a central windows.json:
768        // each window's identity (label, per-session plugin_state) is
769        // persisted in its own per-dir workspace file by
770        // `save_all_windows_workspaces` (called just before this on
771        // quit), and the session list is rediscovered from those files
772        // at boot. Only editor-global plugin state is written here.
773
774        // Plugin global state — one file per plugin. Single
775        // global directory now (no per-cwd split), so two
776        // editor processes writing the same plugin's state
777        // still need atomic-rename safety.
778        let state_dir = global_state_dir(&data_dir);
779        if !self.plugin_global_state.is_empty() {
780            if let Err(e) = self.authority().filesystem.create_dir_all(&state_dir) {
781                tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
782                return;
783            }
784        }
785        for (plugin, map) in &self.plugin_global_state {
786            if !plugin_name_is_safe(plugin) {
787                tracing::warn!(
788                    "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
789                );
790                continue;
791            }
792            if map.is_empty() {
793                continue;
794            }
795            match serde_json::to_vec_pretty(map) {
796                Ok(bytes) => {
797                    let path = global_plugin_state_path(&data_dir, plugin);
798                    let tmp = path.with_extension("json.tmp");
799                    if let Err(e) = self.authority().filesystem.write_file(&tmp, &bytes) {
800                        tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
801                        continue;
802                    }
803                    if let Err(e) = self.authority().filesystem.rename(&tmp, &path) {
804                        tracing::warn!(
805                            "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
806                        );
807                    }
808                }
809                Err(e) => {
810                    tracing::warn!(
811                        "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
812                    );
813                }
814            }
815        }
816    }
817}
818
819/// Pull `project_path` (PathBuf) and `shared_worktree` (bool)
820/// out of a session's per-plugin state, if the orchestrator
821/// plugin has set them via `setWindowState`. Both keys live
822/// under the `"orchestrator"` plugin slot; the keys are
823/// `"project_path"` and `"shared_worktree"`.
824fn read_orch_session_meta(
825    plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
826) -> (Option<PathBuf>, bool) {
827    let slot = plugin_state.get("orchestrator");
828    let project_path = slot
829        .and_then(|m| m.get("project_path"))
830        .and_then(|v| v.as_str())
831        .map(PathBuf::from);
832    let shared_worktree = slot
833        .and_then(|m| m.get("shared_worktree"))
834        .and_then(|v| v.as_bool())
835        .unwrap_or(false);
836    (project_path, shared_worktree)
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn paths_live_under_data_dir_not_working_dir() {
845        // Regression test for issue #1991: orchestrator persistence
846        // must never write inside the user's working tree.
847        let data_dir = Path::new("/tmp/fresh-data");
848        let working_dir = Path::new("/home/user/project");
849
850        let wp = global_windows_path(data_dir);
851        let sd = global_state_dir(data_dir);
852        let psp = global_plugin_state_path(data_dir, "orchestrator");
853
854        assert!(
855            wp.starts_with(data_dir),
856            "windows_path must live under data_dir, got {wp:?}"
857        );
858        assert!(
859            sd.starts_with(data_dir),
860            "state_dir must live under data_dir, got {sd:?}"
861        );
862        assert!(
863            psp.starts_with(data_dir),
864            "plugin_state_path must live under data_dir, got {psp:?}"
865        );
866
867        for p in [&wp, &sd, &psp] {
868            assert!(
869                !p.starts_with(working_dir),
870                "orchestrator path must not be inside the working tree: {p:?}"
871            );
872            for component in p.components() {
873                if let std::path::Component::Normal(c) = component {
874                    assert_ne!(
875                        c, ".fresh",
876                        "orchestrator path must not contain a `.fresh` component: {p:?}"
877                    );
878                }
879            }
880        }
881    }
882
883    fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
884        PersistedWindow {
885            id,
886            label: String::new(),
887            root: PathBuf::from(root),
888            project_path: project_path.map(PathBuf::from),
889            shared_worktree: false,
890            authority_spec: Default::default(),
891            plugin_state: HashMap::new(),
892        }
893    }
894
895    fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
896        PersistedWindows {
897            version: CURRENT_VERSION,
898            active,
899            next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
900            windows,
901        }
902    }
903
904    #[test]
905    fn pick_active_never_crosses_projects() {
906        // Regression for the orchestration bug: launching in /repoB
907        // must never bring up a session rooted in /repoA, even when
908        // /repoA holds the globally last-used session (env.active).
909        let env = env_with(
910            2,
911            vec![
912                make_window(1, "/repoA", Some("/repoA")),
913                make_window(2, "/repoA", Some("/repoA")),
914                make_window(3, "/repoB", Some("/repoB")),
915            ],
916        );
917        let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
918            .expect("a /repoB session exists");
919        assert_eq!(
920            picked.id, 3,
921            "must pick the /repoB session, not env.active=2"
922        );
923    }
924
925    #[test]
926    fn pick_active_reopens_last_used_for_cwd() {
927        // env.active points at this project's last-used session — it
928        // wins even though it isn't the highest id.
929        let env = env_with(
930            2,
931            vec![
932                make_window(2, "/repoA", Some("/repoA")),
933                make_window(5, "/repoA", Some("/repoA")),
934            ],
935        );
936        let picked =
937            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
938        assert_eq!(
939            picked.id, 2,
940            "env.active is the last-used session for the cwd"
941        );
942    }
943
944    #[test]
945    fn pick_active_falls_back_to_most_recent_session_for_cwd() {
946        // The globally last-used session (env.active=9) is in another
947        // project, so for /repoA we fall back to the most-recently-
948        // created /repoA session (highest id), not the first.
949        let env = env_with(
950            9,
951            vec![
952                make_window(2, "/repoA", Some("/repoA")),
953                make_window(7, "/repoA", Some("/repoA")),
954                make_window(9, "/repoB", Some("/repoB")),
955            ],
956        );
957        let picked =
958            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
959        assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
960    }
961
962    #[test]
963    fn pick_active_returns_none_when_no_window_matches_cwd() {
964        // No session for this cwd → caller boots a clean base window.
965        let env = env_with(
966            1,
967            vec![
968                make_window(1, "/repoA", Some("/repoA")),
969                make_window(2, "/repoB", Some("/repoB")),
970            ],
971        );
972        assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
973    }
974
975    #[test]
976    fn pick_active_falls_back_to_root_when_project_path_missing() {
977        // Legacy v1-migrated entries may lack project_path; match on root.
978        let env = env_with(
979            2,
980            vec![
981                make_window(1, "/repoA", None),
982                make_window(2, "/repoB", None),
983            ],
984        );
985        let picked =
986            pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
987        assert_eq!(picked.id, 1);
988    }
989
990    #[test]
991    fn global_paths_are_independent_of_working_dir() {
992        // v2: persistence is global, not per-cwd. Two different
993        // cwds resolve to the same file path so the user sees
994        // their full session history regardless of where the
995        // editor was launched from.
996        let data_dir = Path::new("/tmp/fresh-data");
997        let a = global_windows_path(data_dir);
998        let b = global_windows_path(data_dir);
999        assert_eq!(a, b);
1000        assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
1001    }
1002
1003    #[test]
1004    fn discover_gcs_missing_dirs_and_yields_one_session_per_existing_dir() {
1005        use crate::model::filesystem::StdFileSystem;
1006        let data = tempfile::tempdir().unwrap();
1007        let data_dir = data.path();
1008        let ws_dir = workspaces_dir(data_dir);
1009        std::fs::create_dir_all(&ws_dir).unwrap();
1010
1011        // A workspace file for an existing dir...
1012        let live = tempfile::tempdir().unwrap();
1013        let live_root = live.path().canonicalize().unwrap();
1014        let live_file = ws_dir.join("live.json");
1015        std::fs::write(
1016            &live_file,
1017            serde_json::to_vec(&serde_json::json!({
1018                "working_dir": live_root, "label": "live-session",
1019            }))
1020            .unwrap(),
1021        )
1022        .unwrap();
1023
1024        // ...and one for a directory that does not exist.
1025        let dead_file = ws_dir.join("dead.json");
1026        std::fs::write(
1027            &dead_file,
1028            serde_json::to_vec(&serde_json::json!({
1029                "working_dir": "/no/such/dir/anywhere", "label": "dead",
1030            }))
1031            .unwrap(),
1032        )
1033        .unwrap();
1034
1035        let fs = StdFileSystem;
1036        let sessions = discover_sessions(&fs, data_dir);
1037
1038        assert_eq!(sessions.len(), 1, "only the existing dir yields a session");
1039        assert_eq!(sessions[0].root, live_root);
1040        assert_eq!(sessions[0].label, "live-session");
1041        assert!(!dead_file.exists(), "the dead dir's cache file was GC'd");
1042        assert!(live_file.exists(), "the live cache file is kept");
1043    }
1044
1045    #[test]
1046    fn discover_reads_authority_spec_so_remote_sessions_arent_lost() {
1047        // A session that was running on a remote backend persists an
1048        // `authority_spec` in its workspace file; discovery must surface it
1049        // (so restore can reconnect rather than degrade to local). A file
1050        // without the field reads back as `Local` — back-compat for sessions
1051        // written before per-session backends existed.
1052        use crate::model::filesystem::StdFileSystem;
1053        use crate::services::authority::{
1054            AuthorityPayload, FilesystemSpec, SessionAuthoritySpec, SpawnerSpec,
1055            TerminalWrapperSpec,
1056        };
1057        let data = tempfile::tempdir().unwrap();
1058        let data_dir = data.path();
1059        let ws_dir = workspaces_dir(data_dir);
1060        std::fs::create_dir_all(&ws_dir).unwrap();
1061
1062        let remote_root = tempfile::tempdir().unwrap();
1063        let remote_root = remote_root.path().canonicalize().unwrap();
1064        let spec = SessionAuthoritySpec::Plugin(AuthorityPayload {
1065            filesystem: FilesystemSpec::Local,
1066            spawner: SpawnerSpec::DockerExec {
1067                container_id: "abc123".into(),
1068                user: Some("vscode".into()),
1069                workspace: Some("/workspaces/proj".into()),
1070                env: Vec::new(),
1071            },
1072            terminal_wrapper: TerminalWrapperSpec::HostShell,
1073            display_label: "Container:abc123".into(),
1074            path_translation: None,
1075        });
1076        std::fs::write(
1077            ws_dir.join("remote.json"),
1078            serde_json::to_vec(&serde_json::json!({
1079                "working_dir": remote_root,
1080                "label": "remote-session",
1081                "authority_spec": spec,
1082            }))
1083            .unwrap(),
1084        )
1085        .unwrap();
1086
1087        // A plain local session with no `authority_spec` field at all.
1088        let local_root = tempfile::tempdir().unwrap();
1089        let local_root = local_root.path().canonicalize().unwrap();
1090        std::fs::write(
1091            ws_dir.join("local.json"),
1092            serde_json::to_vec(&serde_json::json!({
1093                "working_dir": local_root, "label": "local-session",
1094            }))
1095            .unwrap(),
1096        )
1097        .unwrap();
1098
1099        let fs = StdFileSystem;
1100        let sessions = discover_sessions(&fs, data_dir);
1101
1102        let remote = sessions
1103            .iter()
1104            .find(|s| s.label == "remote-session")
1105            .expect("remote session discovered");
1106        assert_eq!(
1107            remote.authority_spec, spec,
1108            "the remote backend spec round-trips through discovery"
1109        );
1110        let local = sessions
1111            .iter()
1112            .find(|s| s.label == "local-session")
1113            .expect("local session discovered");
1114        assert_eq!(
1115            local.authority_spec,
1116            SessionAuthoritySpec::Local,
1117            "a session with no persisted spec reads back as Local"
1118        );
1119    }
1120
1121    #[test]
1122    fn discover_keeps_remote_session_whose_root_is_absent_locally() {
1123        // Regression: a running SSH session persists a `working_dir` that is a
1124        // path on the *remote* host — it does not (and need not) exist on the
1125        // local filesystem. Discovery runs the GC check against the local
1126        // filesystem, so before the fix `is_dir` answered `Ok(false)` and the
1127        // remote session's workspace file was deleted on the next boot,
1128        // dropping it from the Orchestrator dock. A remote session must survive
1129        // discovery even though its root is absent locally.
1130        use crate::model::filesystem::StdFileSystem;
1131        use crate::services::authority::{
1132            RemoteAgentSpec, RemoteTransportSpec, SessionAuthoritySpec,
1133        };
1134        let data = tempfile::tempdir().unwrap();
1135        let data_dir = data.path();
1136        let ws_dir = workspaces_dir(data_dir);
1137        std::fs::create_dir_all(&ws_dir).unwrap();
1138
1139        // A path that does not exist on the local filesystem — it lives on the
1140        // remote host the SSH session is rooted at.
1141        let remote_only_root = "/home/remote-user/project-on-remote-host";
1142        assert!(
1143            !Path::new(remote_only_root).exists(),
1144            "test precondition: the remote root must not exist locally"
1145        );
1146        let spec = SessionAuthoritySpec::RemoteAgent(RemoteAgentSpec {
1147            transport: RemoteTransportSpec::Ssh {
1148                user: Some("remote-user".into()),
1149                host: "example.com".into(),
1150                port: None,
1151                identity_file: None,
1152                remote_path: Some(remote_only_root.into()),
1153                extra_args: Vec::new(),
1154            },
1155            base_env: Vec::new(),
1156            window: true,
1157            label: Some("ssh-session".into()),
1158            command: None,
1159        });
1160        std::fs::write(
1161            ws_dir.join("ssh.json"),
1162            serde_json::to_vec(&serde_json::json!({
1163                "working_dir": remote_only_root,
1164                "label": "ssh-session",
1165                "authority_spec": spec,
1166            }))
1167            .unwrap(),
1168        )
1169        .unwrap();
1170
1171        let fs = StdFileSystem;
1172        let sessions = discover_sessions(&fs, data_dir);
1173
1174        let ssh = sessions
1175            .iter()
1176            .find(|s| s.label == "ssh-session")
1177            .expect("the SSH session survives discovery despite a remote-only root");
1178        assert_eq!(ssh.authority_spec, spec);
1179        assert!(
1180            ws_dir.join("ssh.json").exists(),
1181            "the remote session's workspace file must not be GC'd"
1182        );
1183    }
1184
1185    #[test]
1186    fn migrate_folds_windows_json_into_workspace_files_and_retires_it() {
1187        use crate::model::filesystem::StdFileSystem;
1188        let data = tempfile::tempdir().unwrap();
1189        let data_dir = data.path();
1190        let proj = tempfile::tempdir().unwrap();
1191        let proj_root = proj.path().canonicalize().unwrap();
1192
1193        // An existing per-dir workspace file with no label yet.
1194        let ws_path = workspace_file_for(data_dir, &proj_root);
1195        std::fs::create_dir_all(ws_path.parent().unwrap()).unwrap();
1196        std::fs::write(
1197            &ws_path,
1198            serde_json::to_vec(&serde_json::json!({ "working_dir": proj_root })).unwrap(),
1199        )
1200        .unwrap();
1201
1202        // A legacy windows.json naming that session with a label.
1203        let global_p = global_windows_path(data_dir);
1204        std::fs::create_dir_all(global_p.parent().unwrap()).unwrap();
1205        std::fs::write(
1206            &global_p,
1207            serde_json::to_vec(&serde_json::json!({
1208                "version": 2, "active": 1, "next_id": 2,
1209                "windows": [ { "id": 1, "label": "from-windows-json", "root": proj_root } ],
1210            }))
1211            .unwrap(),
1212        )
1213        .unwrap();
1214
1215        let fs = StdFileSystem;
1216        migrate_windows_json_into_workspaces(&fs, data_dir);
1217
1218        assert!(!global_p.exists(), "windows.json is retired");
1219        assert!(
1220            global_p.with_extension("json.retired.bak").exists(),
1221            "a .retired.bak is kept"
1222        );
1223        let val: serde_json::Value =
1224            serde_json::from_slice(&std::fs::read(&ws_path).unwrap()).unwrap();
1225        assert_eq!(
1226            val.get("label").and_then(|v| v.as_str()),
1227            Some("from-windows-json"),
1228            "the label was folded into the per-dir workspace file"
1229        );
1230    }
1231}