Skip to main content

outrig_cli/
session.rs

1//! SessionStore: layout under `<session-root>/<sid>/`.
2//!
3//! Two materialization modes, both produce the same on-disk record:
4//!
5//! - **Auto** (`create(.., None, ..)`): `<root>/<sid>/session.json` plus
6//!   `<root>/<sid>/logs/`.
7//! - **Explicit** (`create(.., Some(dir), ..)`): writes to `<dir>/session.json`
8//!   directly and creates `<root>/<sid>` as a symlink to `<dir>` so
9//!   `outrig ls` finds it uniformly.
10
11use std::fs;
12use std::io::Write as _;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime};
15
16use serde::{Deserialize, Serialize};
17
18use crate::paths::{default_session_root, resolve_repo_config};
19use outrig::config::Config;
20use outrig::error::{OutrigError, Result};
21
22const SESSION_JSON: &str = "session.json";
23
24/// Stable, sortable session id. Format: `yyyymmddTHHMMSS-rrrr` (UTC, four
25/// hex digits of randomness). Lexicographic order matches chronological
26/// order modulo collisions, so listings can sort by string.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct SessionId(pub String);
29
30impl SessionId {
31    pub fn new() -> Self {
32        use jiff::Zoned;
33        use rand::Rng;
34
35        let ts = Zoned::now()
36            .with_time_zone(jiff::tz::TimeZone::UTC)
37            .strftime("%Y%m%dT%H%M%S");
38        let mut buf = [0u8; 2];
39        rand::rng().fill_bytes(&mut buf);
40        Self(format!("{ts}-{:02x}{:02x}", buf[0], buf[1]))
41    }
42
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48impl std::fmt::Display for SessionId {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(&self.0)
51    }
52}
53
54impl From<String> for SessionId {
55    fn from(s: String) -> Self {
56        Self(s)
57    }
58}
59
60impl Default for SessionId {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66/// On-disk session record. Mirrors `doc/usage/sessions.md`'s `session.json`.
67/// `link_target` is in-memory only -- populated by `list`/`get_by_id` when
68/// the entry under `<root>/<sid>` is a symlink, never written to disk.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70#[serde(rename_all = "snake_case")]
71pub struct Session {
72    pub id: SessionId,
73    #[serde(with = "iso_systime")]
74    pub started_at: SystemTime,
75    #[serde(
76        default,
77        skip_serializing_if = "Option::is_none",
78        with = "iso_systime_opt"
79    )]
80    pub ended_at: Option<SystemTime>,
81    pub container_name: String,
82    pub image_tag: String,
83    pub image_config_name: String,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub agent_name: Option<String>,
86    pub working_dir: PathBuf,
87    pub session_dir: PathBuf,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub exit_code: Option<i32>,
90    #[serde(skip)]
91    pub link_target: Option<PathBuf>,
92}
93
94#[derive(Debug, Clone)]
95pub struct SessionStore {
96    root: PathBuf,
97}
98
99impl SessionStore {
100    pub fn new(root: PathBuf) -> Self {
101        Self { root }
102    }
103
104    /// Path of the `<root>/<sid>` entry -- the symlink for explicitly-pathed
105    /// sessions, the directory itself for auto-allocated ones. Exposed so CLI
106    /// commands can name the path in user-facing messages without the store's
107    /// `root` field leaking out.
108    pub fn symlink_path(&self, id: &SessionId) -> PathBuf {
109        self.root.join(&id.0)
110    }
111
112    /// Materialize a session on disk. `create` overwrites `session.session_dir`
113    /// with the resolved path so the caller doesn't need to compute it twice
114    /// and the in-memory `Session` matches what got persisted. Returns the
115    /// same path for convenience.
116    pub fn create(
117        &self,
118        sid: &SessionId,
119        explicit_dir: Option<&Path>,
120        session: &mut Session,
121    ) -> Result<PathBuf> {
122        fs::create_dir_all(&self.root)?;
123
124        let actual_dir = match explicit_dir {
125            Some(dir) => {
126                let canon = fs::canonicalize(dir)?;
127                let target_json = canon.join(SESSION_JSON);
128                if target_json.exists() {
129                    return Err(OutrigError::Configuration(format!(
130                        "--session-dir {} already contains session.json",
131                        canon.display()
132                    )));
133                }
134                session.session_dir = canon.clone();
135                write_session_json_atomic(&canon, session)?;
136                let link = self.root.join(&sid.0);
137                std::os::unix::fs::symlink(&canon, &link)?;
138                canon
139            }
140            None => {
141                let dir = self.root.join(&sid.0);
142                session.session_dir = dir.clone();
143                fs::create_dir_all(&dir)?;
144                write_session_json_atomic(&dir, session)?;
145                dir
146            }
147        };
148
149        Ok(actual_dir)
150    }
151
152    /// Update `ended_at` + `exit_code` on a session that's already on disk.
153    /// Read-modify-write; atomic on POSIX via `tempfile::persist`. Single-writer
154    /// assumption -- if a future task adds heartbeating, revisit for lost-update.
155    pub fn finalize(&self, id: &SessionId, ended_at: SystemTime, exit_code: i32) -> Result<()> {
156        let (dir, mut session) = self.get_by_id(id)?;
157        session.ended_at = Some(ended_at);
158        session.exit_code = Some(exit_code);
159        session.link_target = None; // never persist
160        write_session_json_atomic(&dir, &session)
161    }
162
163    /// Newest-first. Symlinked entries get `link_target = Some(read_link_result)`.
164    /// Entries without a `session.json` are skipped silently (foreign content under
165    /// the root shouldn't crash listings).
166    pub fn list(&self) -> Result<Vec<Session>> {
167        let mut out = Vec::new();
168        let entries = match fs::read_dir(&self.root) {
169            Ok(e) => e,
170            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
171            Err(e) => return Err(e.into()),
172        };
173        for entry in entries {
174            let entry = entry?;
175            let Some((resolved, link_target)) = resolve_entry(&entry.path())? else {
176                continue;
177            };
178            match read_session_json(&resolved.join(SESSION_JSON)) {
179                Ok(mut session) => {
180                    session.link_target = link_target;
181                    out.push(session);
182                }
183                Err(OutrigError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
184                Err(e) => return Err(e),
185            }
186        }
187        out.sort_by(|a, b| b.id.0.cmp(&a.id.0));
188        Ok(out)
189    }
190
191    /// Resolve `<root>/<id>` (symlink-aware), return `(actual_dir, Session)`
192    /// with `link_target` set when applicable.
193    pub fn get_by_id(&self, id: &SessionId) -> Result<(PathBuf, Session)> {
194        let entry = self.root.join(&id.0);
195        let (resolved, link_target) = resolve_entry(&entry)?
196            .ok_or_else(|| OutrigError::Configuration(format!("session {id} not found")))?;
197        let mut session = read_session_json(&resolved.join(SESSION_JSON))?;
198        session.link_target = link_target;
199        Ok((resolved, session))
200    }
201
202    /// Read `<dir>/session.json`. `link_target` left `None`; the caller already
203    /// has the directory in hand, so symlink-status is its concern.
204    pub fn get_by_path(&self, dir: &Path) -> Result<Session> {
205        read_session_json(&dir.join(SESSION_JSON))
206    }
207
208    /// Auto session: remove the dir.
209    /// Symlinked: remove the link target's contents *and* the symlink.
210    pub fn remove_by_id(&self, id: &SessionId) -> Result<()> {
211        let entry = self.root.join(&id.0);
212        let meta = fs::symlink_metadata(&entry)?;
213        if meta.file_type().is_symlink() {
214            let target = fs::read_link(&entry)?;
215            if target.exists() {
216                fs::remove_dir_all(&target)?;
217            }
218            fs::remove_file(&entry)?;
219        } else {
220            fs::remove_dir_all(&entry)?;
221        }
222        Ok(())
223    }
224
225    /// `rm -rf <dir>`, then sweep `<root>` for any symlink whose target was
226    /// `<dir>` and remove it too. O(n) over root entries; fine for v0.
227    pub fn remove_by_path(&self, dir: &Path) -> Result<()> {
228        // Resolve once *before* removing so we can match dangling symlinks
229        // even after the target is gone.
230        let canon_dir = fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
231        if dir.exists() {
232            fs::remove_dir_all(dir)?;
233        }
234        let entries = match fs::read_dir(&self.root) {
235            Ok(e) => e,
236            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
237            Err(e) => return Err(e.into()),
238        };
239        for entry in entries {
240            let entry = entry?;
241            let path = entry.path();
242            let Ok(meta) = fs::symlink_metadata(&path) else {
243                continue;
244            };
245            if !meta.file_type().is_symlink() {
246                continue;
247            }
248            let Ok(target) = fs::read_link(&path) else {
249                continue;
250            };
251            if target == canon_dir {
252                fs::remove_file(&path)?;
253            }
254        }
255        Ok(())
256    }
257}
258
259/// Resolve a path under the session root. `Ok(None)` when the entry is
260/// neither a symlink nor a directory (foreign content -- skipped by callers).
261/// For a symlink, returns `(target, Some(target))`; for a dir,
262/// `(path, None)`.
263fn resolve_entry(path: &Path) -> Result<Option<(PathBuf, Option<PathBuf>)>> {
264    let meta = match fs::symlink_metadata(path) {
265        Ok(m) => m,
266        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
267        Err(e) => return Err(e.into()),
268    };
269    if meta.file_type().is_symlink() {
270        let tgt = fs::read_link(path)?;
271        Ok(Some((tgt.clone(), Some(tgt))))
272    } else if meta.is_dir() {
273        Ok(Some((path.to_path_buf(), None)))
274    } else {
275        Ok(None)
276    }
277}
278
279/// Cascade: CLI flag > config's `session-root` > caller-supplied default.
280/// The default itself is computed once at the call site (see
281/// `default_session_root`); passing it in keeps this function pure
282/// and unit-testable without env-var manipulation.
283pub fn resolve_session_root(flag: Option<&Path>, cfg: &Config, default: &Path) -> PathBuf {
284    if let Some(p) = flag {
285        return p.to_path_buf();
286    }
287    if let Some(p) = cfg.session_root.as_deref() {
288        return p.to_path_buf();
289    }
290    default.to_path_buf()
291}
292
293/// Same cascade as [`resolve_session_root`], but suitable for read-only session
294/// commands (`ls`, `logs`, `discard`) that may run outside any repo. Skips the
295/// validation+merge that [`Config::load`] performs (we only need the
296/// `session-root` key) and degrades silently when the repo or global config
297/// file is missing -- the user might have neither and just want the XDG
298/// default.
299pub fn resolve_session_root_for_cli(
300    flag: Option<&Path>,
301    repo_cfg_override: Option<&Path>,
302    global_cfg_path: &Path,
303    cwd: &Path,
304) -> Result<PathBuf> {
305    if let Some(p) = flag {
306        return Ok(p.to_path_buf());
307    }
308    let repo_cfg_path = match resolve_repo_config(repo_cfg_override, cwd) {
309        Ok(p) => Some(p),
310        Err(OutrigError::NoRepoConfig) => None,
311        Err(e) => return Err(e),
312    };
313    if let Some(p) = repo_cfg_path
314        && let Some(root) = read_session_root(&p)?
315    {
316        return Ok(root);
317    }
318    if let Some(root) = read_session_root(global_cfg_path)? {
319        return Ok(root);
320    }
321    Ok(default_session_root())
322}
323
324fn read_session_root(path: &Path) -> Result<Option<PathBuf>> {
325    let text = match fs::read_to_string(path) {
326        Ok(s) => s,
327        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
328        Err(e) => return Err(e.into()),
329    };
330    let cfg = Config::load_from_str(&text)?;
331    Ok(cfg.session_root)
332}
333
334/// `2026-05-01 14:19:07` (UTC). Display-only; the on-disk format is
335/// ISO 8601 via `iso_systime`. Returns `?` if the timestamp can't be
336/// converted (only happens for SystemTimes outside jiff's representable
337/// range -- effectively never in practice).
338pub fn format_started_at(t: SystemTime) -> String {
339    match jiff::Timestamp::try_from(t) {
340        Ok(ts) => ts.strftime("%Y-%m-%d %H:%M:%S").to_string(),
341        Err(_) => "?".to_string(),
342    }
343}
344
345/// `Mm SSs` for sub-hour, `Hh MMm` for an hour or more. Matches the
346/// example in `doc/usage/sessions.md` (`0m44s`, `2m18s`, `6m02s`,
347/// `1h05m`).
348pub fn format_duration(d: Duration) -> String {
349    let total = d.as_secs();
350    if total < 3600 {
351        let m = total / 60;
352        let s = total % 60;
353        format!("{m}m{s:02}s")
354    } else {
355        let h = total / 3600;
356        let m = (total % 3600) / 60;
357        format!("{h}h{m:02}m")
358    }
359}
360
361fn read_session_json(path: &Path) -> Result<Session> {
362    let bytes = fs::read(path)?;
363    serde_json::from_slice::<Session>(&bytes).map_err(|e| {
364        OutrigError::Configuration(format!("session.json at {}: {}", path.display(), e))
365    })
366}
367
368/// Atomic write: temp file in the same dir, fsync, rename. Skipping the
369/// parent-dir fsync is a deliberate v0 punt; outrig is a developer tool, not
370/// a database.
371fn write_session_json_atomic(dir: &Path, session: &Session) -> Result<()> {
372    let payload = serde_json::to_vec_pretty(session)
373        .map_err(|e| OutrigError::Configuration(format!("encoding session.json: {e}")))?;
374    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
375    tmp.as_file_mut().write_all(&payload)?;
376    tmp.as_file_mut().sync_all()?;
377    tmp.persist(dir.join(SESSION_JSON))?;
378    Ok(())
379}
380
381mod iso_systime {
382    use std::time::SystemTime;
383
384    use serde::{Deserialize, Deserializer, Serialize, Serializer};
385
386    pub fn to_iso(t: SystemTime) -> Result<String, jiff::Error> {
387        Ok(jiff::Timestamp::try_from(t)?.to_string())
388    }
389
390    pub fn from_iso(s: &str) -> Result<SystemTime, jiff::Error> {
391        Ok(SystemTime::from(s.parse::<jiff::Timestamp>()?))
392    }
393
394    pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
395        to_iso(*t).map_err(serde::ser::Error::custom)?.serialize(s)
396    }
397
398    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
399        from_iso(&String::deserialize(d)?).map_err(serde::de::Error::custom)
400    }
401}
402
403mod iso_systime_opt {
404    use std::time::SystemTime;
405
406    use serde::{Deserialize, Deserializer, Serialize, Serializer};
407
408    pub fn serialize<S: Serializer>(t: &Option<SystemTime>, s: S) -> Result<S::Ok, S::Error> {
409        let v = match t {
410            Some(t) => Some(super::iso_systime::to_iso(*t).map_err(serde::ser::Error::custom)?),
411            None => None,
412        };
413        v.serialize(s)
414    }
415
416    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SystemTime>, D::Error> {
417        match Option::<String>::deserialize(d)? {
418            Some(s) => super::iso_systime::from_iso(&s)
419                .map(Some)
420                .map_err(serde::de::Error::custom),
421            None => Ok(None),
422        }
423    }
424}