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/archive/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 // `crates/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 crates/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/// Create every directory the launcher + srv expect to exist.
121/// Idempotent. Delegates to the common implementation.
122pub fn ensure_dirs(paths: &DataPaths) -> Result<(), String> {
123 // Migrate BEFORE ensure_dirs creates the destination dirs.
124 // ensure_dirs creates <new>/data/db/ as an empty directory; if we
125 // checked for db dir existence AFTER that, we'd always see it and
126 // skip the copy (codex P1 on #1227).
127 migrate_legacy_data_dir(paths);
128 paths.common.ensure_dirs()
129}
130
131/// One-time migration: copy `channels/<ch>/data/db/` into the new
132/// `channels/<ch>/versions/<v>/data/db/` when:
133/// (a) the new versioned db dir has no DB files yet, AND
134/// (b) the old unversioned db dir does exist (pre-Phase-2 install).
135///
136/// Uses *copy* not *move* so the old data is preserved for older
137/// binaries that may still be installed. Silent on any I/O error —
138/// a fresh DB is safer than a partially migrated one blocking launch.
139fn migrate_legacy_data_dir(paths: &DataPaths) {
140 let new_db = paths.data_dir.join("db");
141 // Check for an actual DB file, not just the directory — ensure_dirs
142 // may have already created the empty dir on a previous failed launch.
143 let has_db_files = new_db.is_dir()
144 && std::fs::read_dir(&new_db)
145 .map(|mut d| d.any(|e| e.map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("db")).unwrap_or(false)))
146 .unwrap_or(false);
147 if has_db_files {
148 return; // already migrated or fresh install with data
149 }
150 // The legacy dir is the parent of data_dir in the old layout:
151 // channels/<ch>/data/db/ (instance_dir/data/db/).
152 // After Phase 2, data_dir is channels/<ch>/versions/<v>/data/.
153 // The old path was channels/<ch>/data/. Walk up from data_dir's
154 // grandparent (versions/) to reach instance_dir.
155 let old_db = match paths.data_dir.parent().and_then(|v| v.parent()) {
156 Some(versions_dir) => versions_dir.parent().map(|ch| ch.join("data").join("db")),
157 None => None,
158 };
159 let old_db = match old_db {
160 Some(p) if p.is_dir() => p,
161 _ => return, // no legacy data — fresh install
162 };
163 crate::log(&format!(
164 "[migrate] copying legacy data dir {} → {}",
165 old_db.display(),
166 new_db.display()
167 ));
168 if let Err(e) = copy_dir_recursive(&old_db, &new_db) {
169 crate::log(&format!("[migrate] copy failed (fresh db will be used): {}", e));
170 }
171}
172
173fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
174 std::fs::create_dir_all(dst)?;
175 for entry in std::fs::read_dir(src)? {
176 let entry = entry?;
177 let ty = entry.file_type()?;
178 let dst_path = dst.join(entry.file_name());
179 if ty.is_dir() {
180 copy_dir_recursive(&entry.path(), &dst_path)?;
181 } else {
182 std::fs::copy(entry.path(), dst_path)?;
183 }
184 }
185 Ok(())
186}