1use std::io::{BufRead, BufWriter, Write};
8use std::path::{Path, PathBuf};
9
10pub(crate) const SESSION_LOCK_FILE: &str = ".lock";
13
14#[derive(Debug, Clone)]
22pub struct SessionLockBusy {
23 pub pid: u32,
24 pub hostname: String,
25 pub started_at_unix: u64,
26 pub session_dir: PathBuf,
27}
28
29impl std::fmt::Display for SessionLockBusy {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 write!(
32 f,
33 "session at {} is being written by another process \
34 (pid {}, host {}, started {}). \
35 If you believe this is stale, remove {}/{} and retry.",
36 self.session_dir.display(),
37 self.pid,
38 self.hostname,
39 self.started_at_unix,
40 self.session_dir.display(),
41 SESSION_LOCK_FILE,
42 )
43 }
44}
45
46impl std::error::Error for SessionLockBusy {}
47
48pub(crate) struct SentinelInfo {
53 pub(crate) pid: u32,
54 pub(crate) hostname: String,
55 pub(crate) started_at_unix: u64,
56}
57
58impl SentinelInfo {
59 fn for_self() -> Self {
63 Self {
64 pid: std::process::id(),
65 hostname: current_hostname(),
66 started_at_unix: std::time::SystemTime::now()
67 .duration_since(std::time::UNIX_EPOCH)
68 .map(|d| d.as_secs())
69 .unwrap_or(0),
70 }
71 }
72
73 pub(crate) fn parse(text: &str) -> Option<Self> {
74 let mut lines = text.lines();
75 let pid: u32 = lines.next()?.trim().parse().ok()?;
76 let hostname = lines.next()?.trim().to_string();
77 let started_at_unix: u64 = lines.next().unwrap_or("0").trim().parse().unwrap_or(0);
78 Some(Self {
79 pid,
80 hostname,
81 started_at_unix,
82 })
83 }
84
85 pub(crate) fn serialise(&self) -> String {
86 format!(
87 "{}\n{}\n{}\n",
88 self.pid, self.hostname, self.started_at_unix
89 )
90 }
91}
92
93pub(crate) fn current_hostname() -> String {
100 let raw = std::env::var("HOSTNAME")
101 .or_else(|_| std::env::var("COMPUTERNAME"))
102 .unwrap_or_else(|_| {
103 std::process::Command::new("hostname")
104 .output()
105 .ok()
106 .and_then(|o| String::from_utf8(o.stdout).ok())
107 .unwrap_or_default()
108 });
109 raw.replace(['\n', '\r'], "_").trim().to_string()
110}
111
112fn is_pid_alive(pid: u32) -> bool {
121 #[cfg(target_os = "linux")]
122 {
123 std::path::Path::new(&format!("/proc/{pid}")).exists()
126 }
127 #[cfg(all(unix, not(target_os = "linux")))]
128 {
129 std::process::Command::new("/bin/kill")
130 .arg("-0")
131 .arg(pid.to_string())
132 .stdout(std::process::Stdio::null())
133 .stderr(std::process::Stdio::null())
134 .status()
135 .map(|s| s.success())
136 .unwrap_or(false)
137 }
138 #[cfg(target_os = "windows")]
139 {
140 let output = std::process::Command::new("tasklist")
151 .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
152 .output();
153 match output {
154 Ok(o) => {
155 let stdout = String::from_utf8_lossy(&o.stdout);
156 stdout.contains(&format!("\"{pid}\""))
157 }
158 Err(_) => true,
159 }
160 }
161 #[cfg(not(any(unix, target_os = "windows")))]
162 {
163 let _ = pid;
164 true
165 }
166}
167
168#[derive(Debug)]
183pub struct SessionLock {
184 lock_path: PathBuf,
185}
186
187impl SessionLock {
188 pub fn acquire(session_dir: &Path) -> std::io::Result<Self> {
193 use std::fs::OpenOptions;
194 use std::io::Write;
195
196 let lock_path = session_dir.join(SESSION_LOCK_FILE);
197
198 if let Some(parent) = lock_path.parent() {
199 std::fs::create_dir_all(parent)?;
200 }
201
202 let info = SentinelInfo::for_self();
203 let sentinel = info.serialise();
204
205 match OpenOptions::new()
210 .write(true)
211 .create_new(true)
212 .open(&lock_path)
213 {
214 Ok(mut f) => {
215 f.write_all(sentinel.as_bytes())?;
216 return Ok(Self { lock_path });
217 }
218 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
219 }
221 Err(e) => return Err(e),
222 }
223
224 match std::fs::read_to_string(&lock_path) {
227 Ok(text) => {
228 if let Some(existing) = SentinelInfo::parse(&text) {
229 let our_host = current_hostname();
230 if existing.hostname != our_host || is_pid_alive(existing.pid) {
231 return Err(std::io::Error::other(SessionLockBusy {
232 pid: existing.pid,
233 hostname: existing.hostname,
234 started_at_unix: existing.started_at_unix,
235 session_dir: session_dir.to_path_buf(),
236 }));
237 }
238 eprintln!(
240 "warning: recovered stale session lock at {} \
241 (pid {} not running)",
242 lock_path.display(),
243 existing.pid,
244 );
245 }
246 }
248 Err(_) => {
249 }
251 }
252
253 std::fs::write(&lock_path, &sentinel)?;
257 Ok(Self { lock_path })
258 }
259
260 pub fn path(&self) -> &Path {
263 &self.lock_path
264 }
265}
266
267impl Drop for SessionLock {
268 fn drop(&mut self) {
269 let _ = std::fs::remove_file(&self.lock_path);
270 }
271}
272
273pub(crate) fn read_last_message_uuid(jsonl_path: &Path) -> Option<String> {
281 let file = std::fs::File::open(jsonl_path).ok()?;
282 let reader = std::io::BufReader::new(file);
283 let mut last = None;
284 for line in reader.lines().map_while(Result::ok) {
285 if line.trim().is_empty() {
286 continue;
287 }
288 if let Ok(entry) = serde_json::from_str::<super::serialize::TranscriptEntry>(&line) {
289 if !entry.uuid.is_empty() {
290 last = Some(entry.uuid);
291 }
292 }
293 }
294 last
295}
296
297pub fn truncate_transcript_to_turn(
312 session_dir: &Path,
313 cutoff_turn: usize,
314) -> std::io::Result<TruncateStats> {
315 let jsonl_path = session_dir.join("transcript.jsonl");
316 if !jsonl_path.exists() {
317 return Ok(TruncateStats {
318 kept: 0,
319 dropped: 0,
320 });
321 }
322
323 let file = std::fs::File::open(&jsonl_path)?;
325 let reader = std::io::BufReader::new(file);
326
327 let tmp_path = jsonl_path.with_extension("jsonl.rewind-tmp");
328 let tmp = std::fs::File::create(&tmp_path)?;
329 let mut writer = BufWriter::new(tmp);
330
331 let mut user_seen = 0usize;
332 let mut kept = 0u64;
333 let mut dropped = 0u64;
334 let mut stop = false;
335
336 for line in reader.lines() {
337 let line = line?;
338 if line.trim().is_empty() {
339 continue;
340 }
341
342 if stop {
343 dropped += 1;
344 continue;
345 }
346
347 let role = serde_json::from_str::<serde_json::Value>(&line)
349 .ok()
350 .and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
351
352 let is_turn_boundary = matches!(role.as_deref(), Some("user"));
353 if is_turn_boundary {
354 if user_seen >= cutoff_turn {
355 stop = true;
358 dropped += 1;
359 continue;
360 }
361 user_seen += 1;
362 }
363
364 writer.write_all(line.as_bytes())?;
365 writer.write_all(b"\n")?;
366 kept += 1;
367 }
368 writer.flush()?;
369 drop(writer);
370
371 std::fs::rename(&tmp_path, &jsonl_path)?;
372
373 let meta_path = session_dir.join(".meta.json");
375 if meta_path.exists() {
376 if let Ok(bytes) = std::fs::read(&meta_path) {
377 if let Ok(mut meta) = serde_json::from_slice::<super::SessionMeta>(&bytes) {
378 meta.message_count = kept;
379 meta.updated_at = super::chrono_lite_now();
380 if let Ok(json) = serde_json::to_string_pretty(&meta) {
381 let _ = crate::atomic::atomic_write(&meta_path, json.as_bytes());
382 }
383 }
384 }
385 }
386
387 Ok(TruncateStats { kept, dropped })
388}
389
390#[derive(Debug, Clone, Copy)]
392pub struct TruncateStats {
393 pub kept: u64,
394 pub dropped: u64,
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::message::Message;
401 use crate::session::{SessionReader, SessionStatus, SessionWriter};
402
403 #[test]
406 fn sentinel_parse_reads_three_lines() {
407 let text = "12345\nmyhostname\n1700000000\n";
409 let rec = SentinelInfo::parse(text).expect("must parse");
410 assert_eq!(rec.pid, 12345);
411 assert_eq!(rec.hostname, "myhostname");
412 assert_eq!(rec.started_at_unix, 1_700_000_000);
413 }
414
415 #[test]
416 fn sentinel_serialise_round_trips() {
417 let rec = SentinelInfo {
419 pid: 99,
420 hostname: "box01".to_string(),
421 started_at_unix: 42,
422 };
423 let text = rec.serialise();
424 let restored = SentinelInfo::parse(&text).expect("round-trip must succeed");
425 assert_eq!(restored.pid, 99);
426 assert_eq!(restored.hostname, "box01");
427 assert_eq!(restored.started_at_unix, 42);
428 }
429
430 #[test]
431 fn current_hostname_is_non_empty() {
432 let h = current_hostname();
434 assert!(!h.is_empty(), "hostname must be non-empty");
435 }
436
437 #[test]
440 fn lock_dead_pid_recovered() {
441 let tmp = crate::test_util::IsolatedWorkspace::new();
442 let session_dir = tmp.path().join("session-B");
443 std::fs::create_dir_all(&session_dir).unwrap();
444
445 let stale = SentinelInfo {
449 pid: u32::MAX,
450 hostname: current_hostname(),
451 started_at_unix: 0,
452 };
453 std::fs::write(session_dir.join(SESSION_LOCK_FILE), stale.serialise()).unwrap();
454
455 let lock = SessionLock::acquire(&session_dir).unwrap();
457 let raw = std::fs::read_to_string(lock.path()).unwrap();
458 let parsed = SentinelInfo::parse(&raw).unwrap();
459 assert_eq!(parsed.pid, std::process::id());
460 }
461
462 #[test]
463 fn lock_cross_host_aborts() {
464 let tmp = crate::test_util::IsolatedWorkspace::new();
465 let session_dir = tmp.path().join("session-C");
466 std::fs::create_dir_all(&session_dir).unwrap();
467
468 let cross = SentinelInfo {
471 pid: u32::MAX,
472 hostname: "definitely-not-our-host-123".to_string(),
473 started_at_unix: 0,
474 };
475 std::fs::write(session_dir.join(SESSION_LOCK_FILE), cross.serialise()).unwrap();
476
477 let err = SessionLock::acquire(&session_dir).expect_err("cross-host should fail");
478 assert!(
479 err.to_string().contains("definitely-not-our-host-123"),
480 "expected cross-host error to mention recorded host, got: {err}"
481 );
482 }
483
484 #[test]
485 fn lock_released_on_drop() {
486 let tmp = crate::test_util::IsolatedWorkspace::new();
487 let session_dir = tmp.path().join("session-D");
488 std::fs::create_dir_all(&session_dir).unwrap();
489
490 let lock = SessionLock::acquire(&session_dir).unwrap();
491 assert!(lock.path().exists());
492 drop(lock);
493 assert!(
495 !session_dir.join(SESSION_LOCK_FILE).exists(),
496 "sentinel must be removed on Drop"
497 );
498
499 let _lock2 = SessionLock::acquire(&session_dir).unwrap();
501 }
502
503 #[test]
504 fn lock_alive_pid_blocks_acquire() {
505 let tmp = crate::test_util::IsolatedWorkspace::new();
506 let session_dir = tmp.path().join("session-A");
507 std::fs::create_dir_all(&session_dir).unwrap();
508
509 let lock = SessionLock::acquire(&session_dir).unwrap();
511
512 let err = SessionLock::acquire(&session_dir).expect_err("second acquire should fail");
515 assert!(
517 err.to_string()
518 .contains(&format!("pid {}", std::process::id())),
519 "expected error to mention our pid {}, got: {}",
520 std::process::id(),
521 err
522 );
523
524 drop(lock);
525 }
526
527 #[test]
530 fn truncate_transcript_to_turn_drops_at_user_boundary() {
531 let dir = crate::test_util::IsolatedWorkspace::new();
532 let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
533 w.append(&Message::system("sys".to_string()), None, None)
536 .unwrap();
537 w.append(&Message::user("u0".to_string()), None, None)
538 .unwrap();
539 w.append(&Message::assistant("a0".to_string()), None, None)
540 .unwrap();
541 w.append(&Message::user("u1".to_string()), None, None)
542 .unwrap();
543 w.append(&Message::assistant("a1".to_string()), None, None)
544 .unwrap();
545 w.append(&Message::user("u2".to_string()), None, None)
546 .unwrap();
547 w.append(&Message::assistant("a2".to_string()), None, None)
548 .unwrap();
549 w.finish(SessionStatus::Completed).unwrap();
550
551 let session_dir = w.session_dir().to_path_buf();
552
553 let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
555 assert_eq!(stats.kept, 3);
556 assert_eq!(stats.dropped, 4);
557
558 let entries = SessionReader::load_transcript(&session_dir).unwrap();
559 assert_eq!(entries.len(), 3);
560 assert_eq!(entries[0].role, "system");
561 assert_eq!(entries[1].role, "user");
562 assert_eq!(entries[1].content, "u0");
563 assert_eq!(entries[2].role, "assistant");
564 assert_eq!(entries[2].content, "a0");
565
566 let meta = SessionReader::load_meta(&session_dir).unwrap();
568 assert_eq!(meta.message_count, 3);
569 }
570
571 #[test]
572 fn truncate_transcript_to_zero_drops_all_turns_keeps_system() {
573 let dir = crate::test_util::IsolatedWorkspace::new();
574 let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
575 w.append(&Message::system("sys".to_string()), None, None)
576 .unwrap();
577 w.append(&Message::user("u0".to_string()), None, None)
578 .unwrap();
579 w.append(&Message::assistant("a0".to_string()), None, None)
580 .unwrap();
581 w.finish(SessionStatus::Completed).unwrap();
582 let session_dir = w.session_dir().to_path_buf();
583
584 let stats = truncate_transcript_to_turn(&session_dir, 0).unwrap();
585 assert_eq!(stats.kept, 1, "system message should remain");
586 assert_eq!(stats.dropped, 2);
587
588 let entries = SessionReader::load_transcript(&session_dir).unwrap();
589 assert_eq!(entries.len(), 1);
590 assert_eq!(entries[0].role, "system");
591 }
592
593 #[test]
594 fn truncate_transcript_missing_file_is_noop() {
595 let dir = crate::test_util::IsolatedWorkspace::new();
596 let stats = truncate_transcript_to_turn(dir.path(), 5).unwrap();
598 assert_eq!(stats.kept, 0);
599 assert_eq!(stats.dropped, 0);
600 }
601
602 #[test]
603 fn truncate_transcript_cutoff_beyond_end_keeps_all() {
604 let dir = crate::test_util::IsolatedWorkspace::new();
606 let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
607 w.append(&Message::system("sys".to_string()), None, None)
608 .unwrap();
609 w.append(&Message::user("u0".to_string()), None, None)
610 .unwrap();
611 w.append(&Message::assistant("a0".to_string()), None, None)
612 .unwrap();
613 w.finish(SessionStatus::Completed).unwrap();
614 let session_dir = w.session_dir().to_path_buf();
615
616 let stats = truncate_transcript_to_turn(&session_dir, 99).unwrap();
618 assert_eq!(stats.dropped, 0, "cutoff beyond end must drop nothing");
619 assert_eq!(stats.kept, 3, "all 3 messages must be kept");
620 }
621
622 #[test]
623 fn truncate_transcript_exact_boundary_drops_last_turn() {
624 let dir = crate::test_util::IsolatedWorkspace::new();
626 let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
627 w.append(&Message::user("u0".to_string()), None, None)
629 .unwrap();
630 w.append(&Message::assistant("a0".to_string()), None, None)
631 .unwrap();
632 w.append(&Message::user("u1".to_string()), None, None)
633 .unwrap();
634 w.append(&Message::assistant("a1".to_string()), None, None)
635 .unwrap();
636 w.finish(SessionStatus::Completed).unwrap();
637 let session_dir = w.session_dir().to_path_buf();
638
639 let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
641 assert_eq!(stats.kept, 2, "must keep u0+a0");
642 assert_eq!(stats.dropped, 2, "must drop u1+a1");
643 }
644}