agentmux_launcher/
data_dir.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Compatibility shim around `agentmux_common::DataPaths`.
5//!
6//! Historically the launcher computed its own paths via the
7//! launcher-local `resolve_paths()` function. After the data-dir
8//! unification (see docs/specs/SPEC_DATA_DIR_UNIFICATION_2026-05-05.md
9//! and PR #695), path resolution is centralized in
10//! `agentmux_common::DataPaths`. This module keeps the launcher's
11//! existing public API surface (`DataPaths` struct with 4 fields,
12//! `resolve_paths()`, `ensure_dirs()`) so call sites in main.rs,
13//! diag.rs, srv_spawner.rs etc. don't need to be rewritten — they
14//! see the same shape, populated from the common implementation.
15//!
16//! Field mapping:
17//! - launcher.data_dir       = common.data_dir
18//! - launcher.config_dir     = common.config_dir
19//! - launcher.user_home_dir  = common.home_dir   (the agentmux root,
20//!                                                e.g. `~/.agentmux/`,
21//!                                                where `config.toml`
22//!                                                lives)
23//! - launcher.portable_root  = exe_dir when mode == Portable, else None
24//!
25//! The launcher continues to use these field names internally; the
26//! env vars it passes to host + srv are switched in main.rs to the
27//! canonical AGENTMUX_* names emitted by `DataPaths::to_env_vars`.
28
29use agentmux_common::{DataPaths as CommonDataPaths, RuntimeMode};
30use std::path::{Path, PathBuf};
31
32/// Resolved per-instance paths. Compat shape — see module doc.
33#[derive(Debug, Clone)]
34pub struct DataPaths {
35    pub data_dir: PathBuf,
36    pub config_dir: PathBuf,
37    pub user_home_dir: PathBuf,
38    pub portable_root: Option<PathBuf>,
39    /// Full common-paths value — exposes the new fields
40    /// (`cef_cache_dir`, `agents_dir`, `instance_runtime_dir`,
41    /// `logs_dir`, `instance_dir`, `mode`) for the launcher's
42    /// env-var passing in main.rs without re-resolving.
43    pub common: CommonDataPaths,
44}
45
46/// Resolve all paths from the launcher's vantage point.
47///
48/// Detects [`RuntimeMode`] from `launcher_exe_dir`, resolves the
49/// canonical paths via [`agentmux_common::DataPaths::resolve`], and
50/// projects them onto the launcher-local field names.
51///
52/// Mode detection is authoritative here — `cfg!(debug_assertions)` is
53/// unreliable across binaries built with different profiles, so we let
54/// `RuntimeMode::current` decide and propagate the answer downstream
55/// via the `AGENTMUX_RUNTIME_MODE` env var. The legacy `is_dev`
56/// parameter from the pre-PR-#695 signature has been removed; callers
57/// no longer need to compute it.
58pub fn resolve_paths(launcher_exe_dir: &Path, version: &str) -> Result<DataPaths, String> {
59    // Path-only when the exe is a dev build — see SPEC_DEV_ENV_ISOLATION.
60    // Prevents inheriting AGENTMUX_RUNTIME_MODE from a parent AgentMux
61    // process when `task dev` is launched from inside an existing pane.
62    let is_dev = agentmux_common::is_dev_build_exe(launcher_exe_dir);
63    let mode = if is_dev {
64        RuntimeMode::current_path_only(launcher_exe_dir)
65    } else {
66        RuntimeMode::current(launcher_exe_dir)
67    };
68    // For dev builds the launcher MUST use `resolve_path_only` too —
69    // not just `resolve` — so that AGENTMUX_CHANNEL is ignored
70    // symmetrically with the host's dev-build branch in
71    // agentmux-cef/src/main.rs and sidecar.rs. Without this, the
72    // launcher would honor a leaked `AGENTMUX_CHANNEL` from a parent
73    // agentmux pane and write the lockfile + IPC files into
74    // `channels/<override>/runtime/`, while the host (running its own
75    // path-only resolution) would look for them under
76    // `dev/<branch>/runtime/`. Launcher/host disagreement on the
77    // single-instance lock breaks dev-mode isolation. Channel
78    // override is intentionally an Installed/Portable-only feature in
79    // this design (codex P2 follow-up on PR #1027); dev mode keeps
80    // its per-branch isolation as the sole identity axis.
81    let common = if is_dev {
82        CommonDataPaths::resolve_path_only(version, &mode)?
83    } else {
84        CommonDataPaths::resolve(version, &mode)?
85    };
86
87    let portable_root = if mode == RuntimeMode::Portable {
88        Some(launcher_exe_dir.to_path_buf())
89    } else {
90        None
91    };
92
93    Ok(DataPaths {
94        data_dir: common.data_dir.clone(),
95        config_dir: common.config_dir.clone(),
96        // The launcher's `config.toml` (saga retention etc.) lives
97        // at `~/.agentmux/config.toml` — account-wide, version-
98        // independent, predates the unified layout. Map onto the
99        // resolved root, NOT shared_dir or any version-keyed subdir.
100        user_home_dir: common.home_dir.clone(),
101        portable_root,
102        common,
103    })
104}
105
106/// Read-only resolver for the launcher saga log path. Returns
107/// whichever location actually holds the data: the canonical
108/// `<data-dir>/db/launcher-sagas.db` if present, otherwise the
109/// legacy `<data-dir>/launcher-sagas.db` if THAT exists, otherwise
110/// the canonical path (for the fresh-install case).
111///
112/// Does NOT touch the filesystem — safe for read-only callers like
113/// `--diag sagas` which document themselves as passive.
114pub fn launcher_saga_log_path_read_only(data_dir: &Path) -> PathBuf {
115    let new_path = data_dir.join("db").join("launcher-sagas.db");
116    if new_path.exists() {
117        return new_path;
118    }
119    let legacy_path = data_dir.join("launcher-sagas.db");
120    if legacy_path.exists() {
121        return legacy_path;
122    }
123    new_path
124}
125
126/// Canonical path for the launcher saga log
127/// (`<data-dir>/db/launcher-sagas.db`). Performs a one-shot back-
128/// compat migration: launcher releases prior to this change wrote
129/// the saga log directly under `<data-dir>/launcher-sagas.db` (with
130/// srv DBs alongside in `<data-dir>/db/`, an inconsistency flagged
131/// by AUDIT_SQLITE_SYSTEMS §8.3). If only the legacy path exists,
132/// move it into `db/`.
133///
134/// This variant has WRITE side effects (rename + mkdir). Callers
135/// that must stay read-only (e.g. `--diag sagas` is documented as
136/// a passive on-disk inspector) should use
137/// `launcher_saga_log_path_read_only` instead. The launcher's own
138/// startup path uses this one — the rename is welcome there.
139///
140/// Idempotent + safe to call repeatedly. Returns the canonical
141/// (post-migration) path the caller should open.
142pub fn launcher_saga_log_path(data_dir: &Path) -> PathBuf {
143    let db_dir = data_dir.join("db");
144    let new_path = db_dir.join("launcher-sagas.db");
145    let legacy_path = data_dir.join("launcher-sagas.db");
146
147    // Migrate iff only the legacy file is present. Don't overwrite a
148    // non-empty new file even if both exist — that would be a
149    // surprising data loss and likely indicates a multi-process race
150    // we'd want to investigate.
151    if legacy_path.exists() && !new_path.exists() {
152        // Best-effort `mkdir -p`. If this fails the rename will fail
153        // and we'll log + fall back to the legacy path below.
154        let _ = std::fs::create_dir_all(&db_dir);
155        if let Err(e) = std::fs::rename(&legacy_path, &new_path) {
156            // Migration failed — keep using the legacy path so we
157            // don't drop saga state. The next launch retries.
158            eprintln!(
159                "[launcher-saga-log] migration of {} → {} failed: {} \
160                 (continuing with legacy path)",
161                legacy_path.display(),
162                new_path.display(),
163                e
164            );
165            return legacy_path;
166        }
167    }
168    new_path
169}
170
171/// Create every directory the launcher + srv expect to exist.
172/// Idempotent. Delegates to the common implementation.
173pub fn ensure_dirs(paths: &DataPaths) -> Result<(), String> {
174    paths.common.ensure_dirs()
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use tempfile::tempdir;
181
182    #[test]
183    fn launcher_saga_log_path_returns_db_subdir_on_fresh_install() {
184        let tmp = tempdir().unwrap();
185        let p = launcher_saga_log_path(tmp.path());
186        assert_eq!(p, tmp.path().join("db").join("launcher-sagas.db"));
187        // No legacy file → no rename attempt; new path simply returned.
188        assert!(!tmp.path().join("launcher-sagas.db").exists());
189    }
190
191    #[test]
192    fn launcher_saga_log_path_migrates_legacy_file() {
193        let tmp = tempdir().unwrap();
194        let legacy = tmp.path().join("launcher-sagas.db");
195        std::fs::write(&legacy, b"legacy bytes").unwrap();
196
197        let p = launcher_saga_log_path(tmp.path());
198
199        assert_eq!(p, tmp.path().join("db").join("launcher-sagas.db"));
200        assert!(p.exists());
201        assert_eq!(std::fs::read(&p).unwrap(), b"legacy bytes");
202        assert!(!legacy.exists(), "legacy path should be removed by rename");
203    }
204
205    #[test]
206    fn launcher_saga_log_path_does_not_overwrite_existing_new_file() {
207        // Both files exist (theoretical race / aborted migration).
208        // Keep the new file untouched and leave the legacy alone.
209        let tmp = tempdir().unwrap();
210        let legacy = tmp.path().join("launcher-sagas.db");
211        let new_dir = tmp.path().join("db");
212        std::fs::create_dir_all(&new_dir).unwrap();
213        let new_path = new_dir.join("launcher-sagas.db");
214        std::fs::write(&legacy, b"legacy").unwrap();
215        std::fs::write(&new_path, b"newer").unwrap();
216
217        let p = launcher_saga_log_path(tmp.path());
218
219        assert_eq!(p, new_path);
220        assert_eq!(std::fs::read(&p).unwrap(), b"newer");
221        // Legacy stays (user can clean up manually); we don't trash it.
222        assert!(legacy.exists());
223    }
224
225    #[test]
226    fn read_only_resolver_returns_canonical_when_neither_file_exists() {
227        let tmp = tempdir().unwrap();
228        let p = launcher_saga_log_path_read_only(tmp.path());
229        assert_eq!(p, tmp.path().join("db").join("launcher-sagas.db"));
230        // Crucially, no side effects — no `db/` dir created.
231        assert!(!tmp.path().join("db").exists());
232    }
233
234    #[test]
235    fn read_only_resolver_returns_legacy_path_when_only_legacy_exists() {
236        let tmp = tempdir().unwrap();
237        let legacy = tmp.path().join("launcher-sagas.db");
238        std::fs::write(&legacy, b"legacy").unwrap();
239
240        let p = launcher_saga_log_path_read_only(tmp.path());
241
242        assert_eq!(p, legacy);
243        assert!(legacy.exists(), "read-only resolver must not migrate");
244        assert!(!tmp.path().join("db").exists());
245    }
246
247    #[test]
248    fn read_only_resolver_returns_canonical_when_canonical_exists() {
249        let tmp = tempdir().unwrap();
250        let db_dir = tmp.path().join("db");
251        std::fs::create_dir_all(&db_dir).unwrap();
252        let new_path = db_dir.join("launcher-sagas.db");
253        std::fs::write(&new_path, b"current").unwrap();
254
255        let p = launcher_saga_log_path_read_only(tmp.path());
256        assert_eq!(p, new_path);
257    }
258
259    #[test]
260    fn launcher_saga_log_path_is_idempotent() {
261        let tmp = tempdir().unwrap();
262        let legacy = tmp.path().join("launcher-sagas.db");
263        std::fs::write(&legacy, b"data").unwrap();
264
265        let first = launcher_saga_log_path(tmp.path());
266        let second = launcher_saga_log_path(tmp.path());
267        assert_eq!(first, second);
268        assert!(first.exists());
269    }
270}