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
64    // A portable/installed build launched from INSIDE another AgentMux pane
65    // inherits the parent's AGENTMUX_CHANNEL + AGENTMUX_RUNTIME_MODE (the srv
66    // sets AGENTMUX=1 and the full AGENTMUX_* path env for every pane shell —
67    // `agentmux-srv/.../blockcontroller/shell.rs`). Honoring that *leaked*
68    // channel makes the new build adopt the PARENT's data dir + cef-cache, so
69    // Chromium's user-data-dir singleton forwards it into the parent and it exits
70    // ("Opening in existing browser session", CEF exit 24) — the build you
71    // launched never runs. Treat a nested launch like a dev build: ignore the
72    // ambient channel/mode and resolve from this binary's BAKED per-build channel
73    // (`AGENTMUX_BUILD_CHANNEL_DEFAULT`). An EXPLICIT, *standalone*
74    // `AGENTMUX_CHANNEL=… ./agentmux` (not nested) is still honored — that is the
75    // intentional parallel-channel override (PR #1027); only the leak is
76    // suppressed. `AGENTMUX` is the canonical "inside a pane" sentinel.
77    let nested = std::env::var_os("AGENTMUX").is_some();
78    let ignore_ambient = is_dev || nested;
79
80    let mode = if ignore_ambient {
81        RuntimeMode::current_path_only(launcher_exe_dir)
82    } else {
83        RuntimeMode::current(launcher_exe_dir)
84    };
85    // The launcher MUST use `resolve_path_only` (not `resolve`) whenever it
86    // ignores the ambient channel — so AGENTMUX_CHANNEL is dropped symmetrically
87    // with the host's dev/nested branch in agentmux-cef/src/main.rs and
88    // sidecar.rs. Without this the launcher would honor a leaked `AGENTMUX_CHANNEL`
89    // and write the lockfile + IPC files into `channels/<override>/runtime/`,
90    // while the host (path-only) looks elsewhere — launcher/host disagreement on
91    // the single-instance lock breaks isolation. The launcher's resolved env is
92    // authoritative for the host + srv it spawns (`to_env_vars()` overwrites the
93    // inherited AGENTMUX_* at every spawn site), so fixing it here fixes the whole
94    // chain.
95    let common = if ignore_ambient {
96        CommonDataPaths::resolve_path_only(version, &mode)?
97    } else {
98        CommonDataPaths::resolve(version, &mode)?
99    };
100
101    let portable_root = if mode == RuntimeMode::Portable {
102        Some(launcher_exe_dir.to_path_buf())
103    } else {
104        None
105    };
106
107    Ok(DataPaths {
108        data_dir: common.data_dir.clone(),
109        config_dir: common.config_dir.clone(),
110        // The launcher's `config.toml` (saga retention etc.) lives
111        // at `~/.agentmux/config.toml` — account-wide, version-
112        // independent, predates the unified layout. Map onto the
113        // resolved root, NOT shared_dir or any version-keyed subdir.
114        user_home_dir: common.home_dir.clone(),
115        portable_root,
116        common,
117    })
118}
119
120/// Read-only resolver for the launcher saga log path. Returns
121/// whichever location actually holds the data: the canonical
122/// `<data-dir>/db/launcher-sagas.db` if present, otherwise the
123/// legacy `<data-dir>/launcher-sagas.db` if THAT exists, otherwise
124/// the canonical path (for the fresh-install case).
125///
126/// Does NOT touch the filesystem — safe for read-only callers like
127/// `--diag sagas` which document themselves as passive.
128pub fn launcher_saga_log_path_read_only(data_dir: &Path) -> PathBuf {
129    let new_path = data_dir.join("db").join("launcher-sagas.db");
130    if new_path.exists() {
131        return new_path;
132    }
133    let legacy_path = data_dir.join("launcher-sagas.db");
134    if legacy_path.exists() {
135        return legacy_path;
136    }
137    new_path
138}
139
140/// Canonical path for the launcher saga log
141/// (`<data-dir>/db/launcher-sagas.db`). Performs a one-shot back-
142/// compat migration: launcher releases prior to this change wrote
143/// the saga log directly under `<data-dir>/launcher-sagas.db` (with
144/// srv DBs alongside in `<data-dir>/db/`, an inconsistency flagged
145/// by AUDIT_SQLITE_SYSTEMS §8.3). If only the legacy path exists,
146/// move it into `db/`.
147///
148/// This variant has WRITE side effects (rename + mkdir). Callers
149/// that must stay read-only (e.g. `--diag sagas` is documented as
150/// a passive on-disk inspector) should use
151/// `launcher_saga_log_path_read_only` instead. The launcher's own
152/// startup path uses this one — the rename is welcome there.
153///
154/// Idempotent + safe to call repeatedly. Returns the canonical
155/// (post-migration) path the caller should open.
156pub fn launcher_saga_log_path(data_dir: &Path) -> PathBuf {
157    let db_dir = data_dir.join("db");
158    let new_path = db_dir.join("launcher-sagas.db");
159    let legacy_path = data_dir.join("launcher-sagas.db");
160
161    // Migrate iff only the legacy file is present. Don't overwrite a
162    // non-empty new file even if both exist — that would be a
163    // surprising data loss and likely indicates a multi-process race
164    // we'd want to investigate.
165    if legacy_path.exists() && !new_path.exists() {
166        // Best-effort `mkdir -p`. If this fails the rename will fail
167        // and we'll log + fall back to the legacy path below.
168        let _ = std::fs::create_dir_all(&db_dir);
169        if let Err(e) = std::fs::rename(&legacy_path, &new_path) {
170            // Migration failed — keep using the legacy path so we
171            // don't drop saga state. The next launch retries.
172            eprintln!(
173                "[launcher-saga-log] migration of {} → {} failed: {} \
174                 (continuing with legacy path)",
175                legacy_path.display(),
176                new_path.display(),
177                e
178            );
179            return legacy_path;
180        }
181    }
182    new_path
183}
184
185/// Create every directory the launcher + srv expect to exist.
186/// Idempotent. Delegates to the common implementation.
187pub fn ensure_dirs(paths: &DataPaths) -> Result<(), String> {
188    // Migrate BEFORE ensure_dirs creates the destination dirs.
189    // ensure_dirs creates <new>/data/db/ as an empty directory; if we
190    // checked for db dir existence AFTER that, we'd always see it and
191    // skip the copy (codex P1 on #1227).
192    migrate_legacy_data_dir(paths);
193    paths.common.ensure_dirs()
194}
195
196/// One-time migration: copy `channels/<ch>/data/db/` into the new
197/// `channels/<ch>/versions/<v>/data/db/` when:
198///   (a) the new versioned db dir has no DB files yet, AND
199///   (b) the old unversioned db dir does exist (pre-Phase-2 install).
200///
201/// Uses *copy* not *move* so the old data is preserved for older
202/// binaries that may still be installed. Silent on any I/O error —
203/// a fresh DB is safer than a partially migrated one blocking launch.
204fn migrate_legacy_data_dir(paths: &DataPaths) {
205    let new_db = paths.data_dir.join("db");
206    // Check for an actual DB file, not just the directory — ensure_dirs
207    // may have already created the empty dir on a previous failed launch.
208    let has_db_files = new_db.is_dir()
209        && std::fs::read_dir(&new_db)
210            .map(|mut d| d.any(|e| e.map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("db")).unwrap_or(false)))
211            .unwrap_or(false);
212    if has_db_files {
213        return; // already migrated or fresh install with data
214    }
215    // The legacy dir is the parent of data_dir in the old layout:
216    // channels/<ch>/data/db/ (instance_dir/data/db/).
217    // After Phase 2, data_dir is channels/<ch>/versions/<v>/data/.
218    // The old path was channels/<ch>/data/.  Walk up from data_dir's
219    // grandparent (versions/) to reach instance_dir.
220    let old_db = match paths.data_dir.parent().and_then(|v| v.parent()) {
221        Some(versions_dir) => versions_dir.parent().map(|ch| ch.join("data").join("db")),
222        None => None,
223    };
224    let old_db = match old_db {
225        Some(p) if p.is_dir() => p,
226        _ => return, // no legacy data — fresh install
227    };
228    crate::log(&format!(
229        "[migrate] copying legacy data dir {} → {}",
230        old_db.display(),
231        new_db.display()
232    ));
233    if let Err(e) = copy_dir_recursive(&old_db, &new_db) {
234        crate::log(&format!("[migrate] copy failed (fresh db will be used): {}", e));
235    }
236}
237
238fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
239    std::fs::create_dir_all(dst)?;
240    for entry in std::fs::read_dir(src)? {
241        let entry = entry?;
242        let ty = entry.file_type()?;
243        let dst_path = dst.join(entry.file_name());
244        if ty.is_dir() {
245            copy_dir_recursive(&entry.path(), &dst_path)?;
246        } else {
247            std::fs::copy(entry.path(), dst_path)?;
248        }
249    }
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use tempfile::tempdir;
257
258    #[test]
259    fn launcher_saga_log_path_returns_db_subdir_on_fresh_install() {
260        let tmp = tempdir().unwrap();
261        let p = launcher_saga_log_path(tmp.path());
262        assert_eq!(p, tmp.path().join("db").join("launcher-sagas.db"));
263        // No legacy file → no rename attempt; new path simply returned.
264        assert!(!tmp.path().join("launcher-sagas.db").exists());
265    }
266
267    #[test]
268    fn launcher_saga_log_path_migrates_legacy_file() {
269        let tmp = tempdir().unwrap();
270        let legacy = tmp.path().join("launcher-sagas.db");
271        std::fs::write(&legacy, b"legacy bytes").unwrap();
272
273        let p = launcher_saga_log_path(tmp.path());
274
275        assert_eq!(p, tmp.path().join("db").join("launcher-sagas.db"));
276        assert!(p.exists());
277        assert_eq!(std::fs::read(&p).unwrap(), b"legacy bytes");
278        assert!(!legacy.exists(), "legacy path should be removed by rename");
279    }
280
281    #[test]
282    fn launcher_saga_log_path_does_not_overwrite_existing_new_file() {
283        // Both files exist (theoretical race / aborted migration).
284        // Keep the new file untouched and leave the legacy alone.
285        let tmp = tempdir().unwrap();
286        let legacy = tmp.path().join("launcher-sagas.db");
287        let new_dir = tmp.path().join("db");
288        std::fs::create_dir_all(&new_dir).unwrap();
289        let new_path = new_dir.join("launcher-sagas.db");
290        std::fs::write(&legacy, b"legacy").unwrap();
291        std::fs::write(&new_path, b"newer").unwrap();
292
293        let p = launcher_saga_log_path(tmp.path());
294
295        assert_eq!(p, new_path);
296        assert_eq!(std::fs::read(&p).unwrap(), b"newer");
297        // Legacy stays (user can clean up manually); we don't trash it.
298        assert!(legacy.exists());
299    }
300
301    #[test]
302    fn read_only_resolver_returns_canonical_when_neither_file_exists() {
303        let tmp = tempdir().unwrap();
304        let p = launcher_saga_log_path_read_only(tmp.path());
305        assert_eq!(p, tmp.path().join("db").join("launcher-sagas.db"));
306        // Crucially, no side effects — no `db/` dir created.
307        assert!(!tmp.path().join("db").exists());
308    }
309
310    #[test]
311    fn read_only_resolver_returns_legacy_path_when_only_legacy_exists() {
312        let tmp = tempdir().unwrap();
313        let legacy = tmp.path().join("launcher-sagas.db");
314        std::fs::write(&legacy, b"legacy").unwrap();
315
316        let p = launcher_saga_log_path_read_only(tmp.path());
317
318        assert_eq!(p, legacy);
319        assert!(legacy.exists(), "read-only resolver must not migrate");
320        assert!(!tmp.path().join("db").exists());
321    }
322
323    #[test]
324    fn read_only_resolver_returns_canonical_when_canonical_exists() {
325        let tmp = tempdir().unwrap();
326        let db_dir = tmp.path().join("db");
327        std::fs::create_dir_all(&db_dir).unwrap();
328        let new_path = db_dir.join("launcher-sagas.db");
329        std::fs::write(&new_path, b"current").unwrap();
330
331        let p = launcher_saga_log_path_read_only(tmp.path());
332        assert_eq!(p, new_path);
333    }
334
335    #[test]
336    fn launcher_saga_log_path_is_idempotent() {
337        let tmp = tempdir().unwrap();
338        let legacy = tmp.path().join("launcher-sagas.db");
339        std::fs::write(&legacy, b"data").unwrap();
340
341        let first = launcher_saga_log_path(tmp.path());
342        let second = launcher_saga_log_path(tmp.path());
343        assert_eq!(first, second);
344        assert!(first.exists());
345    }
346}