1use 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#[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#[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 pub fn symlink_path(&self, id: &SessionId) -> PathBuf {
109 self.root.join(&id.0)
110 }
111
112 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 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; write_session_json_atomic(&dir, &session)
161 }
162
163 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 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 pub fn get_by_path(&self, dir: &Path) -> Result<Session> {
205 read_session_json(&dir.join(SESSION_JSON))
206 }
207
208 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 pub fn remove_by_path(&self, dir: &Path) -> Result<()> {
228 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
259fn 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
279pub 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
293pub 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
334pub 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
345pub 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
368fn 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}