1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, RwLock};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18const DEFAULT_TIMEOUT_SECS: u64 = 60;
19const DEFAULT_MAX_OUTPUT: usize = 102_400; #[derive(Debug, Default)]
24pub struct SessionConfig {
25 pub root: PathBuf,
26 pub timeout_secs: Option<u64>,
27 pub max_output: Option<usize>,
28 pub alias: Option<String>,
34 pub global_recipe_dirs: Vec<PathBuf>,
41}
42
43#[derive(Debug, thiserror::Error)]
45pub enum SessionError {
46 #[error(
47 "session root path no longer exists, please call session_start again: {}",
48 _0.display()
49 )]
50 RootGone(PathBuf),
51}
52
53#[derive(Debug, thiserror::Error)]
55pub enum CoreError {
56 #[error("session root does not exist: {}", _0.display())]
57 RootNotFound(PathBuf),
58 #[error("no active session — call session_start first")]
59 NoSession,
60 #[error("session not found: {0}")]
61 SessionNotFound(String),
62 #[error("alias already in use: {0}")]
63 AliasConflict(String),
64}
65
66#[derive(Debug)]
75pub struct Session {
76 root: PathBuf,
77 session_id: String,
78 alias: RwLock<Option<String>>,
79 timeout: Duration,
80 max_output: usize,
81 global_recipe_dirs: Vec<PathBuf>,
82 created_at: u64,
83 last_used_at: RwLock<u64>,
84}
85
86impl Session {
87 pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
88 let root = config.root;
89 if !root.is_dir() {
90 tracing::warn!(root = %root.display(), "session root does not exist");
91 return Err(CoreError::RootNotFound(root));
92 }
93 let session_id = session_id_new();
94 let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
95 let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
96 let now = epoch_secs();
97 tracing::info!(
98 root = %root.display(),
99 session_id = %session_id,
100 alias = ?config.alias,
101 timeout_secs = timeout.as_secs(),
102 max_output,
103 "session started"
104 );
105 let global_recipe_dirs = config.global_recipe_dirs;
106 Ok(Self {
107 root,
108 session_id,
109 alias: RwLock::new(config.alias),
110 timeout,
111 max_output,
112 global_recipe_dirs,
113 created_at: now,
114 last_used_at: RwLock::new(now),
115 })
116 }
117
118 pub fn root(&self) -> &Path {
119 &self.root
120 }
121
122 pub fn id(&self) -> &str {
123 &self.session_id
124 }
125
126 pub fn alias(&self) -> Option<String> {
127 self.alias.read().expect("alias lock poisoned").clone()
128 }
129
130 pub fn created_at(&self) -> u64 {
131 self.created_at
132 }
133
134 pub fn last_used_at(&self) -> u64 {
135 *self.last_used_at.read().expect("last_used lock poisoned")
136 }
137
138 pub fn touch(&self) {
139 if let Ok(mut g) = self.last_used_at.write() {
140 *g = epoch_secs();
141 }
142 }
143
144 pub(crate) fn set_alias(&self, alias: Option<String>) {
145 if let Ok(mut g) = self.alias.write() {
146 *g = alias;
147 }
148 }
149
150 pub fn timeout(&self) -> Duration {
151 self.timeout
152 }
153
154 pub fn max_output(&self) -> usize {
155 self.max_output
156 }
157
158 pub fn global_recipe_dirs(&self) -> &[PathBuf] {
159 &self.global_recipe_dirs
160 }
161
162 pub fn ensure_alive(&self) -> Result<(), SessionError> {
168 if !self.root.is_dir() {
169 tracing::warn!(root = %self.root.display(), "session root no longer exists");
170 return Err(SessionError::RootGone(self.root.clone()));
171 }
172 Ok(())
173 }
174}
175
176#[derive(Debug, Clone)]
187pub struct LdsState {
188 sessions: HashMap<String, Arc<Session>>,
190 aliases: HashMap<String, String>,
192 default_id: Option<String>,
195}
196
197#[derive(Debug, Clone)]
199pub struct SessionEntry {
200 pub session_id: String,
201 pub alias: Option<String>,
202 pub root: PathBuf,
203 pub created_at: u64,
204 pub last_used_at: u64,
205 pub is_default: bool,
206}
207
208impl SessionEntry {
209 fn from_session(s: &Session, is_default: bool) -> Self {
210 Self {
211 session_id: s.id().to_string(),
212 alias: s.alias(),
213 root: s.root().to_path_buf(),
214 created_at: s.created_at(),
215 last_used_at: s.last_used_at(),
216 is_default,
217 }
218 }
219}
220
221impl LdsState {
222 pub fn new() -> Self {
223 Self {
224 sessions: HashMap::new(),
225 aliases: HashMap::new(),
226 default_id: None,
227 }
228 }
229
230 pub fn create_session(
238 &mut self,
239 config: SessionConfig,
240 make_default: bool,
241 ) -> Result<Arc<Session>, CoreError> {
242 if let Some(alias) = &config.alias
243 && self.aliases.contains_key(alias)
244 {
245 return Err(CoreError::AliasConflict(alias.clone()));
246 }
247 let alias_clone = config.alias.clone();
248 let session = Arc::new(Session::new(config)?);
249 let id = session.id().to_string();
250 self.sessions.insert(id.clone(), Arc::clone(&session));
251 if let Some(alias) = alias_clone {
252 self.aliases.insert(alias, id.clone());
253 }
254 if make_default || self.default_id.is_none() {
255 self.default_id = Some(id);
256 }
257 Ok(session)
258 }
259
260 pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
263 self.create_session(config, true)
264 }
265
266 pub fn resolve(&self, key: &str) -> Result<Arc<Session>, CoreError> {
271 if let Some(id) = self.aliases.get(key)
272 && let Some(s) = self.sessions.get(id)
273 {
274 return Ok(Arc::clone(s));
275 }
276 if let Some(s) = self.sessions.get(key) {
277 return Ok(Arc::clone(s));
278 }
279 Err(CoreError::SessionNotFound(key.to_string()))
280 }
281
282 pub fn session(&self) -> Result<Arc<Session>, CoreError> {
284 let id = self.default_id.as_ref().ok_or(CoreError::NoSession)?;
285 let s = self
286 .sessions
287 .get(id)
288 .ok_or_else(|| CoreError::SessionNotFound(id.clone()))?;
289 Ok(Arc::clone(s))
290 }
291
292 pub fn default_session_id(&self) -> Option<&str> {
293 self.default_id.as_deref()
294 }
295
296 pub fn list_sessions(&self) -> Vec<SessionEntry> {
298 let mut out: Vec<SessionEntry> = self
299 .sessions
300 .values()
301 .map(|s| {
302 let is_default = self.default_id.as_deref() == Some(s.id());
303 SessionEntry::from_session(s, is_default)
304 })
305 .collect();
306 out.sort_by_key(|e| e.created_at);
307 out
308 }
309
310 pub fn describe(&self, key: &str) -> Result<SessionEntry, CoreError> {
312 let s = self.resolve(key)?;
313 let is_default = self.default_id.as_deref() == Some(s.id());
314 Ok(SessionEntry::from_session(&s, is_default))
315 }
316
317 pub fn set_alias(&mut self, key: &str, alias: String) -> Result<(), CoreError> {
322 let target = self.resolve(key)?;
323 if let Some(owner_id) = self.aliases.get(&alias) {
324 if owner_id != target.id() {
325 return Err(CoreError::AliasConflict(alias));
326 }
327 return Ok(());
328 }
329 if let Some(prev) = target.alias() {
331 self.aliases.remove(&prev);
332 }
333 target.set_alias(Some(alias.clone()));
334 self.aliases.insert(alias, target.id().to_string());
335 Ok(())
336 }
337
338 pub fn unset_alias(&mut self, alias: &str) -> Result<(), CoreError> {
340 let id = self
341 .aliases
342 .remove(alias)
343 .ok_or_else(|| CoreError::SessionNotFound(alias.to_string()))?;
344 if let Some(s) = self.sessions.get(&id) {
345 s.set_alias(None);
346 }
347 Ok(())
348 }
349
350 pub fn close(&mut self, key: &str) -> Result<(), CoreError> {
356 let target = self.resolve(key)?;
357 let id = target.id().to_string();
358 if let Some(alias) = target.alias() {
359 self.aliases.remove(&alias);
360 }
361 self.sessions.remove(&id);
362 if self.default_id.as_deref() == Some(&id) {
363 self.default_id = None;
364 }
365 Ok(())
366 }
367}
368
369impl Default for LdsState {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
379pub enum CheckStatus {
380 Ok,
381 Warn,
382 Fail,
383}
384
385impl CheckStatus {
386 pub fn as_str(&self) -> &'static str {
387 match self {
388 CheckStatus::Ok => "ok",
389 CheckStatus::Warn => "warn",
390 CheckStatus::Fail => "fail",
391 }
392 }
393}
394
395#[derive(Debug, Clone)]
396pub struct DoctorCheck {
397 pub name: &'static str,
398 pub status: CheckStatus,
399 pub evidence: String,
400}
401
402#[derive(Debug, Clone)]
403pub struct DoctorReport {
404 pub session_id: String,
405 pub alias: Option<String>,
406 pub verdict: CheckStatus,
407 pub checks: Vec<DoctorCheck>,
408}
409
410const IDLE_WARN_SECS: u64 = 60 * 60 * 6; impl LdsState {
413 pub fn doctor(&self, key: &str) -> Result<DoctorReport, CoreError> {
415 let s = self.resolve(key)?;
416 let mut checks: Vec<DoctorCheck> = Vec::new();
417
418 let root = s.root();
420 if root.is_dir() {
421 checks.push(DoctorCheck {
422 name: "root-exists",
423 status: CheckStatus::Ok,
424 evidence: format!("root={}", root.display()),
425 });
426 } else {
427 checks.push(DoctorCheck {
428 name: "root-exists",
429 status: CheckStatus::Fail,
430 evidence: format!("root missing: {}", root.display()),
431 });
432 }
433
434 let git_path = root.join(".git");
436 if git_path.exists() {
437 checks.push(DoctorCheck {
438 name: "git-bound",
439 status: CheckStatus::Ok,
440 evidence: format!("{}/.git present", root.display()),
441 });
442 } else {
443 checks.push(DoctorCheck {
444 name: "git-bound",
445 status: CheckStatus::Warn,
446 evidence: "no .git in root; git_* tools will fail".into(),
447 });
448 }
449
450 let journal_dir = root.join("workspace");
452 let journal_status = if !journal_dir.exists() {
453 CheckStatus::Warn
454 } else {
455 match tempfile::NamedTempFile::new_in(&journal_dir) {
457 Ok(_) => CheckStatus::Ok,
458 Err(_) => CheckStatus::Fail,
459 }
460 };
461 checks.push(DoctorCheck {
462 name: "journal-db-writable",
463 status: journal_status,
464 evidence: format!("probe dir = {}", journal_dir.display()),
465 });
466
467 let mut stale = Vec::new();
469 for candidate in ["workspace/.journal.db.lock", ".journal.db.lock"] {
470 let p = root.join(candidate);
471 if let Ok(meta) = std::fs::metadata(&p)
472 && let Ok(modified) = meta.modified()
473 && let Ok(age) = SystemTime::now().duration_since(modified)
474 && age.as_secs() > 3600
475 {
476 stale.push(p.display().to_string());
477 }
478 }
479 checks.push(DoctorCheck {
480 name: "stale-lock",
481 status: if stale.is_empty() {
482 CheckStatus::Ok
483 } else {
484 CheckStatus::Warn
485 },
486 evidence: if stale.is_empty() {
487 "no stale lock found".into()
488 } else {
489 format!("stale: {}", stale.join(","))
490 },
491 });
492
493 let conflict_count = self
495 .sessions
496 .values()
497 .filter(|other| other.root() == root && other.id() != s.id())
498 .count();
499 checks.push(DoctorCheck {
500 name: "ownership-drift",
501 status: if conflict_count == 0 {
502 CheckStatus::Ok
503 } else {
504 CheckStatus::Warn
505 },
506 evidence: if conflict_count == 0 {
507 "exclusive owner".into()
508 } else {
509 format!("{conflict_count} other session(s) share root")
510 },
511 });
512
513 checks.push(DoctorCheck {
515 name: "root-conflict",
516 status: match conflict_count {
517 0 => CheckStatus::Ok,
518 1 => CheckStatus::Warn,
519 _ => CheckStatus::Fail,
520 },
521 evidence: format!("conflicts={conflict_count}"),
522 });
523
524 let idle = epoch_secs().saturating_sub(s.last_used_at());
526 checks.push(DoctorCheck {
527 name: "ledger-leak",
528 status: if idle > IDLE_WARN_SECS {
529 CheckStatus::Warn
530 } else {
531 CheckStatus::Ok
532 },
533 evidence: format!("idle_secs={idle}"),
534 });
535
536 let verdict = checks
537 .iter()
538 .map(|c| c.status.clone())
539 .fold(CheckStatus::Ok, |acc, s| match (&acc, &s) {
540 (CheckStatus::Fail, _) | (_, CheckStatus::Fail) => CheckStatus::Fail,
541 (CheckStatus::Warn, _) | (_, CheckStatus::Warn) => CheckStatus::Warn,
542 _ => CheckStatus::Ok,
543 });
544
545 Ok(DoctorReport {
546 session_id: s.id().to_string(),
547 alias: s.alias(),
548 verdict,
549 checks,
550 })
551 }
552}
553
554fn epoch_secs() -> u64 {
555 SystemTime::now()
556 .duration_since(UNIX_EPOCH)
557 .map(|d| d.as_secs())
558 .unwrap_or(0)
559}
560
561fn session_id_new() -> String {
568 use std::time::{SystemTime, UNIX_EPOCH};
569 let ts = SystemTime::now()
570 .duration_since(UNIX_EPOCH)
571 .unwrap_or_default()
572 .as_nanos();
573 let pid = std::process::id();
574 format!("{ts:x}-{pid:x}")
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
584 fn core_error_root_not_found_display_contains_prefix_and_path() {
585 let path = PathBuf::from("/some/missing/root");
586 let err = CoreError::RootNotFound(path.clone());
587 let msg = err.to_string();
588 assert!(
589 msg.contains("session root does not exist: "),
590 "I1: message must start with invariant prefix, got: {msg}"
591 );
592 assert!(
593 msg.contains("/some/missing/root"),
594 "I1: message must contain the path, got: {msg}"
595 );
596 }
597
598 #[test]
599 fn core_error_no_session_display_matches_invariant() {
600 let err = CoreError::NoSession;
601 let msg = err.to_string();
602 assert_eq!(
603 msg, "no active session \u{2014} call session_start first",
604 "I2: message must exactly match invariant string"
605 );
606 }
607
608 #[test]
611 fn session_error_root_gone_message_contains_invariant_substring() {
612 use std::path::PathBuf;
613 let path = PathBuf::from("/tmp/gone");
614 let err = SessionError::RootGone(path.clone());
615 let msg = err.to_string();
616 assert!(
617 msg.contains("session root path no longer exists, please call session_start again"),
618 "error message must contain the K-239 recovery substring, got: {msg}"
619 );
620 assert!(
621 msg.contains("/tmp/gone"),
622 "error message must include the path, got: {msg}"
623 );
624 }
625
626 #[test]
627 fn ensure_alive_ok_when_root_exists() {
628 let tmp = tempfile::tempdir().unwrap();
629 let session = Session::new(SessionConfig {
630 root: tmp.path().to_path_buf(),
631 ..Default::default()
632 })
633 .unwrap();
634 assert!(session.ensure_alive().is_ok());
635 }
636
637 fn mk_state_with_root(alias: Option<&str>) -> (LdsState, tempfile::TempDir, String) {
640 let tmp = tempfile::tempdir().unwrap();
641 let mut state = LdsState::new();
642 let session = state
643 .create_session(
644 SessionConfig {
645 root: tmp.path().to_path_buf(),
646 alias: alias.map(|s| s.to_string()),
647 ..Default::default()
648 },
649 true,
650 )
651 .unwrap();
652 let id = session.id().to_string();
653 (state, tmp, id)
654 }
655
656 #[test]
657 fn ledger_create_sets_default_when_first_session() {
658 let (state, _tmp, id) = mk_state_with_root(None);
659 assert_eq!(state.default_session_id(), Some(id.as_str()));
660 assert_eq!(state.list_sessions().len(), 1);
661 }
662
663 #[test]
664 fn ledger_second_session_preserves_default_unless_requested() {
665 let (mut state, _tmp1, id1) = mk_state_with_root(None);
666 let tmp2 = tempfile::tempdir().unwrap();
667 let _ = state
668 .create_session(
669 SessionConfig {
670 root: tmp2.path().to_path_buf(),
671 ..Default::default()
672 },
673 false,
674 )
675 .unwrap();
676 assert_eq!(state.default_session_id(), Some(id1.as_str()));
677 assert_eq!(state.list_sessions().len(), 2);
678 }
679
680 #[test]
681 fn ledger_resolve_by_id_or_alias() {
682 let (state, _tmp, id) = mk_state_with_root(Some("worker-1"));
683 let by_id = state.resolve(&id).unwrap();
684 let by_alias = state.resolve("worker-1").unwrap();
685 assert_eq!(by_id.id(), by_alias.id());
686 }
687
688 #[test]
689 fn ledger_resolve_unknown_returns_not_found() {
690 let (state, _tmp, _id) = mk_state_with_root(None);
691 let err = state.resolve("does-not-exist").unwrap_err();
692 assert!(matches!(err, CoreError::SessionNotFound(_)), "got {err:?}");
693 }
694
695 #[test]
696 fn ledger_alias_conflict_rejected_on_create() {
697 let (mut state, _tmp, _id) = mk_state_with_root(Some("dup"));
698 let tmp2 = tempfile::tempdir().unwrap();
699 let err = state
700 .create_session(
701 SessionConfig {
702 root: tmp2.path().to_path_buf(),
703 alias: Some("dup".to_string()),
704 ..Default::default()
705 },
706 false,
707 )
708 .unwrap_err();
709 assert!(matches!(err, CoreError::AliasConflict(_)), "got {err:?}");
710 }
711
712 #[test]
713 fn ledger_set_alias_assigns_and_replaces() {
714 let (mut state, _tmp, id) = mk_state_with_root(None);
715 state.set_alias(&id, "main".into()).unwrap();
716 assert_eq!(state.resolve("main").unwrap().id(), id);
717 state.set_alias(&id, "renamed".into()).unwrap();
719 assert!(state.resolve("main").is_err());
720 assert_eq!(state.resolve("renamed").unwrap().id(), id);
721 }
722
723 #[test]
724 fn ledger_unset_alias_removes_mapping_but_keeps_session() {
725 let (mut state, _tmp, id) = mk_state_with_root(Some("tmp-alias"));
726 state.unset_alias("tmp-alias").unwrap();
727 assert!(state.resolve("tmp-alias").is_err());
728 assert!(state.resolve(&id).is_ok());
729 }
730
731 #[test]
732 fn ledger_close_drops_session_and_clears_default() {
733 let (mut state, _tmp, id) = mk_state_with_root(Some("main"));
734 state.close(&id).unwrap();
735 assert!(state.resolve(&id).is_err());
736 assert_eq!(state.default_session_id(), None);
737 assert_eq!(state.list_sessions().len(), 0);
738 }
739
740 #[test]
741 fn ledger_list_sorted_by_created_at() {
742 let (mut state, _tmp1, id1) = mk_state_with_root(None);
743 std::thread::sleep(std::time::Duration::from_millis(1100));
745 let tmp2 = tempfile::tempdir().unwrap();
746 let s2 = state
747 .create_session(
748 SessionConfig {
749 root: tmp2.path().to_path_buf(),
750 ..Default::default()
751 },
752 false,
753 )
754 .unwrap();
755 let entries = state.list_sessions();
756 assert_eq!(entries.len(), 2);
757 assert_eq!(entries[0].session_id, id1);
758 assert_eq!(entries[1].session_id, s2.id());
759 }
760
761 #[test]
764 fn doctor_root_exists_passes_on_live_root() {
765 let (state, _tmp, id) = mk_state_with_root(None);
766 let report = state.doctor(&id).unwrap();
767 let root_check = report
768 .checks
769 .iter()
770 .find(|c| c.name == "root-exists")
771 .unwrap();
772 assert_eq!(root_check.status, CheckStatus::Ok);
773 }
774
775 #[test]
776 fn doctor_root_exists_fails_when_root_deleted() {
777 let tmp = tempfile::tempdir().unwrap();
778 let path = tmp.path().to_path_buf();
779 let mut state = LdsState::new();
780 let s = state
781 .create_session(
782 SessionConfig {
783 root: path.clone(),
784 ..Default::default()
785 },
786 true,
787 )
788 .unwrap();
789 std::fs::remove_dir_all(&path).unwrap();
790 let report = state.doctor(s.id()).unwrap();
791 let root_check = report
792 .checks
793 .iter()
794 .find(|c| c.name == "root-exists")
795 .unwrap();
796 assert_eq!(root_check.status, CheckStatus::Fail);
797 assert_eq!(report.verdict, CheckStatus::Fail);
798 }
799
800 #[test]
801 fn doctor_detects_root_conflict_between_sessions() {
802 let tmp = tempfile::tempdir().unwrap();
803 let mut state = LdsState::new();
804 let s1 = state
805 .create_session(
806 SessionConfig {
807 root: tmp.path().to_path_buf(),
808 ..Default::default()
809 },
810 true,
811 )
812 .unwrap();
813 let _s2 = state
814 .create_session(
815 SessionConfig {
816 root: tmp.path().to_path_buf(),
817 ..Default::default()
818 },
819 false,
820 )
821 .unwrap();
822 let report = state.doctor(s1.id()).unwrap();
823 let conflict = report
824 .checks
825 .iter()
826 .find(|c| c.name == "root-conflict")
827 .unwrap();
828 assert_eq!(conflict.status, CheckStatus::Warn);
829 }
830
831 #[test]
832 fn ensure_alive_err_when_root_deleted() {
833 let tmp = tempfile::tempdir().unwrap();
834 let path = tmp.path().to_path_buf();
835 let session = Session::new(SessionConfig {
836 root: path.clone(),
837 ..Default::default()
838 })
839 .unwrap();
840 std::fs::remove_dir_all(&path).unwrap();
841 let err = session.ensure_alive().unwrap_err();
842 let msg = err.to_string();
843 assert!(
844 msg.contains("session root path no longer exists, please call session_start again"),
845 "expected K-239 substring, got: {msg}"
846 );
847 }
848}