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 =
537 checks
538 .iter()
539 .map(|c| c.status.clone())
540 .fold(CheckStatus::Ok, |acc, s| match (&acc, &s) {
541 (CheckStatus::Fail, _) | (_, CheckStatus::Fail) => CheckStatus::Fail,
542 (CheckStatus::Warn, _) | (_, CheckStatus::Warn) => CheckStatus::Warn,
543 _ => CheckStatus::Ok,
544 });
545
546 Ok(DoctorReport {
547 session_id: s.id().to_string(),
548 alias: s.alias(),
549 verdict,
550 checks,
551 })
552 }
553}
554
555fn epoch_secs() -> u64 {
556 SystemTime::now()
557 .duration_since(UNIX_EPOCH)
558 .map(|d| d.as_secs())
559 .unwrap_or(0)
560}
561
562fn session_id_new() -> String {
569 use std::time::{SystemTime, UNIX_EPOCH};
570 let ts = SystemTime::now()
571 .duration_since(UNIX_EPOCH)
572 .unwrap_or_default()
573 .as_nanos();
574 let pid = std::process::id();
575 format!("{ts:x}-{pid:x}")
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
585 fn core_error_root_not_found_display_contains_prefix_and_path() {
586 let path = PathBuf::from("/some/missing/root");
587 let err = CoreError::RootNotFound(path.clone());
588 let msg = err.to_string();
589 assert!(
590 msg.contains("session root does not exist: "),
591 "I1: message must start with invariant prefix, got: {msg}"
592 );
593 assert!(
594 msg.contains("/some/missing/root"),
595 "I1: message must contain the path, got: {msg}"
596 );
597 }
598
599 #[test]
600 fn core_error_no_session_display_matches_invariant() {
601 let err = CoreError::NoSession;
602 let msg = err.to_string();
603 assert_eq!(
604 msg, "no active session \u{2014} call session_start first",
605 "I2: message must exactly match invariant string"
606 );
607 }
608
609 #[test]
612 fn session_error_root_gone_message_contains_invariant_substring() {
613 use std::path::PathBuf;
614 let path = PathBuf::from("/tmp/gone");
615 let err = SessionError::RootGone(path.clone());
616 let msg = err.to_string();
617 assert!(
618 msg.contains("session root path no longer exists, please call session_start again"),
619 "error message must contain the K-239 recovery substring, got: {msg}"
620 );
621 assert!(
622 msg.contains("/tmp/gone"),
623 "error message must include the path, got: {msg}"
624 );
625 }
626
627 #[test]
628 fn ensure_alive_ok_when_root_exists() {
629 let tmp = tempfile::tempdir().unwrap();
630 let session = Session::new(SessionConfig {
631 root: tmp.path().to_path_buf(),
632 ..Default::default()
633 })
634 .unwrap();
635 assert!(session.ensure_alive().is_ok());
636 }
637
638 fn mk_state_with_root(alias: Option<&str>) -> (LdsState, tempfile::TempDir, String) {
641 let tmp = tempfile::tempdir().unwrap();
642 let mut state = LdsState::new();
643 let session = state
644 .create_session(
645 SessionConfig {
646 root: tmp.path().to_path_buf(),
647 alias: alias.map(|s| s.to_string()),
648 ..Default::default()
649 },
650 true,
651 )
652 .unwrap();
653 let id = session.id().to_string();
654 (state, tmp, id)
655 }
656
657 #[test]
658 fn ledger_create_sets_default_when_first_session() {
659 let (state, _tmp, id) = mk_state_with_root(None);
660 assert_eq!(state.default_session_id(), Some(id.as_str()));
661 assert_eq!(state.list_sessions().len(), 1);
662 }
663
664 #[test]
665 fn ledger_second_session_preserves_default_unless_requested() {
666 let (mut state, _tmp1, id1) = mk_state_with_root(None);
667 let tmp2 = tempfile::tempdir().unwrap();
668 let _ = state
669 .create_session(
670 SessionConfig {
671 root: tmp2.path().to_path_buf(),
672 ..Default::default()
673 },
674 false,
675 )
676 .unwrap();
677 assert_eq!(state.default_session_id(), Some(id1.as_str()));
678 assert_eq!(state.list_sessions().len(), 2);
679 }
680
681 #[test]
682 fn ledger_resolve_by_id_or_alias() {
683 let (state, _tmp, id) = mk_state_with_root(Some("worker-1"));
684 let by_id = state.resolve(&id).unwrap();
685 let by_alias = state.resolve("worker-1").unwrap();
686 assert_eq!(by_id.id(), by_alias.id());
687 }
688
689 #[test]
690 fn ledger_resolve_unknown_returns_not_found() {
691 let (state, _tmp, _id) = mk_state_with_root(None);
692 let err = state.resolve("does-not-exist").unwrap_err();
693 assert!(matches!(err, CoreError::SessionNotFound(_)), "got {err:?}");
694 }
695
696 #[test]
697 fn ledger_alias_conflict_rejected_on_create() {
698 let (mut state, _tmp, _id) = mk_state_with_root(Some("dup"));
699 let tmp2 = tempfile::tempdir().unwrap();
700 let err = state
701 .create_session(
702 SessionConfig {
703 root: tmp2.path().to_path_buf(),
704 alias: Some("dup".to_string()),
705 ..Default::default()
706 },
707 false,
708 )
709 .unwrap_err();
710 assert!(matches!(err, CoreError::AliasConflict(_)), "got {err:?}");
711 }
712
713 #[test]
714 fn ledger_set_alias_assigns_and_replaces() {
715 let (mut state, _tmp, id) = mk_state_with_root(None);
716 state.set_alias(&id, "main".into()).unwrap();
717 assert_eq!(state.resolve("main").unwrap().id(), id);
718 state.set_alias(&id, "renamed".into()).unwrap();
720 assert!(state.resolve("main").is_err());
721 assert_eq!(state.resolve("renamed").unwrap().id(), id);
722 }
723
724 #[test]
725 fn ledger_unset_alias_removes_mapping_but_keeps_session() {
726 let (mut state, _tmp, id) = mk_state_with_root(Some("tmp-alias"));
727 state.unset_alias("tmp-alias").unwrap();
728 assert!(state.resolve("tmp-alias").is_err());
729 assert!(state.resolve(&id).is_ok());
730 }
731
732 #[test]
733 fn ledger_close_drops_session_and_clears_default() {
734 let (mut state, _tmp, id) = mk_state_with_root(Some("main"));
735 state.close(&id).unwrap();
736 assert!(state.resolve(&id).is_err());
737 assert_eq!(state.default_session_id(), None);
738 assert_eq!(state.list_sessions().len(), 0);
739 }
740
741 #[test]
742 fn ledger_list_sorted_by_created_at() {
743 let (mut state, _tmp1, id1) = mk_state_with_root(None);
744 std::thread::sleep(std::time::Duration::from_millis(1100));
746 let tmp2 = tempfile::tempdir().unwrap();
747 let s2 = state
748 .create_session(
749 SessionConfig {
750 root: tmp2.path().to_path_buf(),
751 ..Default::default()
752 },
753 false,
754 )
755 .unwrap();
756 let entries = state.list_sessions();
757 assert_eq!(entries.len(), 2);
758 assert_eq!(entries[0].session_id, id1);
759 assert_eq!(entries[1].session_id, s2.id());
760 }
761
762 #[test]
765 fn doctor_root_exists_passes_on_live_root() {
766 let (state, _tmp, id) = mk_state_with_root(None);
767 let report = state.doctor(&id).unwrap();
768 let root_check = report
769 .checks
770 .iter()
771 .find(|c| c.name == "root-exists")
772 .unwrap();
773 assert_eq!(root_check.status, CheckStatus::Ok);
774 }
775
776 #[test]
777 fn doctor_root_exists_fails_when_root_deleted() {
778 let tmp = tempfile::tempdir().unwrap();
779 let path = tmp.path().to_path_buf();
780 let mut state = LdsState::new();
781 let s = state
782 .create_session(
783 SessionConfig {
784 root: path.clone(),
785 ..Default::default()
786 },
787 true,
788 )
789 .unwrap();
790 std::fs::remove_dir_all(&path).unwrap();
791 let report = state.doctor(s.id()).unwrap();
792 let root_check = report
793 .checks
794 .iter()
795 .find(|c| c.name == "root-exists")
796 .unwrap();
797 assert_eq!(root_check.status, CheckStatus::Fail);
798 assert_eq!(report.verdict, CheckStatus::Fail);
799 }
800
801 #[test]
802 fn doctor_detects_root_conflict_between_sessions() {
803 let tmp = tempfile::tempdir().unwrap();
804 let mut state = LdsState::new();
805 let s1 = state
806 .create_session(
807 SessionConfig {
808 root: tmp.path().to_path_buf(),
809 ..Default::default()
810 },
811 true,
812 )
813 .unwrap();
814 let _s2 = state
815 .create_session(
816 SessionConfig {
817 root: tmp.path().to_path_buf(),
818 ..Default::default()
819 },
820 false,
821 )
822 .unwrap();
823 let report = state.doctor(s1.id()).unwrap();
824 let conflict = report
825 .checks
826 .iter()
827 .find(|c| c.name == "root-conflict")
828 .unwrap();
829 assert_eq!(conflict.status, CheckStatus::Warn);
830 }
831
832 #[test]
833 fn ensure_alive_err_when_root_deleted() {
834 let tmp = tempfile::tempdir().unwrap();
835 let path = tmp.path().to_path_buf();
836 let session = Session::new(SessionConfig {
837 root: path.clone(),
838 ..Default::default()
839 })
840 .unwrap();
841 std::fs::remove_dir_all(&path).unwrap();
842 let err = session.ensure_alive().unwrap_err();
843 let msg = err.to_string();
844 assert!(
845 msg.contains("session root path no longer exists, please call session_start again"),
846 "expected K-239 substring, got: {msg}"
847 );
848 }
849}