use std::collections::BTreeMap;
pub const DEFAULT_PORT: u16 = 7274;
pub const EPHEMERAL_IDLE_SECS: u64 = 300;
pub const EPHEMERAL_COUNTDOWN_AFTER_SECS: u64 = 5;
pub const SESSION_STALE_SECS: u64 = 10;
pub const MAX_STORED_SESSIONS: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DaemonMode {
Persistent,
Ephemeral,
}
impl DaemonMode {
pub fn as_str(self) -> &'static str {
match self {
DaemonMode::Persistent => "persistent",
DaemonMode::Ephemeral => "ephemeral",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"persistent" => Some(DaemonMode::Persistent),
"ephemeral" => Some(DaemonMode::Ephemeral),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionLifecycle {
Running,
Completed,
Failed,
Cancelled,
Terminated,
}
impl SessionLifecycle {
pub fn label(self) -> &'static str {
match self {
SessionLifecycle::Running => "running",
SessionLifecycle::Completed => "completed",
SessionLifecycle::Failed => "failed",
SessionLifecycle::Cancelled => "cancelled",
SessionLifecycle::Terminated => "terminated abruptly",
}
}
pub fn css_class(self) -> &'static str {
match self {
SessionLifecycle::Running => "running",
SessionLifecycle::Completed => "completed",
SessionLifecycle::Failed => "failed",
SessionLifecycle::Cancelled => "cancelled",
SessionLifecycle::Terminated => "terminated",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcStatus {
Waiting,
Running,
Ok,
Fail,
}
impl ProcStatus {
pub fn as_str(self) -> &'static str {
match self {
ProcStatus::Waiting => "waiting",
ProcStatus::Running => "running",
ProcStatus::Ok => "ok",
ProcStatus::Fail => "fail",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"waiting" => Some(ProcStatus::Waiting),
"running" => Some(ProcStatus::Running),
"ok" => Some(ProcStatus::Ok),
"fail" => Some(ProcStatus::Fail),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcKind {
Build,
Skill,
}
impl ProcKind {
pub fn as_str(self) -> &'static str {
match self {
ProcKind::Build => "build",
ProcKind::Skill => "skill",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"build" => Some(ProcKind::Build),
"skill" => Some(ProcKind::Skill),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutputLine {
pub at: f64,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProcRecord {
pub index: usize,
pub label: String,
pub kind: ProcKind,
pub status: ProcStatus,
pub skill_name: Option<String>,
pub harness: Option<String>,
pub model: Option<String>,
pub started_at: Option<u64>,
pub note: Option<String>,
pub detail: Option<String>,
pub fail_reason: Option<String>,
pub elapsed: Option<f64>,
pub lines: Vec<OutputLine>,
pub container_name: Option<String>,
pub cast_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkillMeta {
pub name: String,
pub harness: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Session {
pub id: String,
pub started_at: u64,
pub ended_at: Option<u64>,
pub profile: Option<String>,
pub repo: String,
pub branch: String,
pub skills: Vec<SkillMeta>,
pub procs: Vec<ProcRecord>,
pub last_seen_at: u64,
pub client_connected: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Store {
pub mode: DaemonMode,
pub port: u16,
pub started_at: u64,
pub active_clients: u32,
pub last_activity: u64,
pub no_alive_since: Option<u64>,
pub sessions: BTreeMap<String, Session>,
}
impl Store {
pub fn new(mode: DaemonMode, port: u16, now: u64) -> Store {
Store {
mode,
port,
started_at: now,
active_clients: 0,
last_activity: now,
no_alive_since: Some(now),
sessions: BTreeMap::new(),
}
}
pub fn touch(&mut self, now: u64) {
self.last_activity = now;
}
pub fn alive_clients(&self, now: u64) -> u32 {
self
.sessions
.values()
.filter(|s| s.client_connected && s.lifecycle_status(now) == SessionLifecycle::Running)
.count() as u32
}
pub fn reconcile(&mut self, now: u64) {
for session in self.sessions.values_mut() {
if session.client_connected && session.lifecycle_status(now) != SessionLifecycle::Running {
session.client_connected = false;
}
}
self.active_clients = self.sessions.values().filter(|s| s.client_connected).count() as u32;
if self.alive_clients(now) > 0 {
self.no_alive_since = None;
} else if self.no_alive_since.is_none() {
self.no_alive_since = Some(now);
}
}
pub fn ephemeral_shutdown_in_secs(&self, now: u64) -> Option<u64> {
if self.mode != DaemonMode::Ephemeral {
return None;
}
let since = self.no_alive_since?;
let idle = now.saturating_sub(since);
if idle < EPHEMERAL_COUNTDOWN_AFTER_SECS {
return None;
}
Some(EPHEMERAL_IDLE_SECS.saturating_sub(idle))
}
pub fn should_shutdown_ephemeral(&self, now: u64) -> bool {
self.mode == DaemonMode::Ephemeral
&& self.alive_clients(now) == 0
&& self.no_alive_since.is_some_and(|since| now.saturating_sub(since) >= EPHEMERAL_IDLE_SECS)
}
pub fn session_mut(&mut self, id: &str) -> Option<&mut Session> {
self.sessions.get_mut(id)
}
pub fn proc_mut(&mut self, session_id: &str, proc_index: usize) -> Option<&mut ProcRecord> {
self.session_mut(session_id).and_then(|s| s.procs.iter_mut().find(|p| p.index == proc_index))
}
pub fn insert_session(&mut self, id: String, session: Session) {
self.sessions.insert(id, session);
trim_sessions_to_cap(&mut self.sessions);
}
}
pub fn trim_sessions_to_cap(sessions: &mut std::collections::BTreeMap<String, Session>) {
while sessions.len() > MAX_STORED_SESSIONS {
let Some(old_id) = sessions.iter().min_by_key(|(_, s)| s.started_at).map(|(id, _)| id.clone()) else {
break;
};
sessions.remove(&old_id);
}
}
pub fn sessions_for_index<'a>(sessions: &'a BTreeMap<String, Session>, now: u64) -> Vec<&'a Session> {
let mut list: Vec<&Session> = sessions.values().collect();
list.sort_by(|a, b| {
let a_live = a.lifecycle_status(now) == SessionLifecycle::Running;
let b_live = b.lifecycle_status(now) == SessionLifecycle::Running;
match (a_live, b_live) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.started_at.cmp(&a.started_at),
}
});
list
}
impl Session {
pub fn has_incomplete_procs(&self) -> bool {
self.procs.iter().any(|p| p.status == ProcStatus::Running || p.status == ProcStatus::Waiting)
}
pub fn lifecycle_status(&self, now: u64) -> SessionLifecycle {
if self.ended_at.is_some() {
if self.has_incomplete_procs() {
return SessionLifecycle::Cancelled;
}
if self.procs.iter().any(|p| p.status == ProcStatus::Fail) {
return SessionLifecycle::Failed;
}
return SessionLifecycle::Completed;
}
if now.saturating_sub(self.last_seen_at) > SESSION_STALE_SECS {
return SessionLifecycle::Terminated;
}
SessionLifecycle::Running
}
pub fn duration_secs(&self, now: u64) -> Option<u64> {
if let Some(end) = self.ended_at {
return Some(end.saturating_sub(self.started_at));
}
let lifecycle = self.lifecycle_status(now);
if lifecycle == SessionLifecycle::Running {
return Some(now.saturating_sub(self.started_at));
}
if lifecycle == SessionLifecycle::Terminated {
return Some(self.last_seen_at.saturating_sub(self.started_at));
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifecycle_completed_when_ended_cleanly() {
let session = Session {
id: "done".into(),
started_at: 100,
ended_at: Some(200),
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: vec![ProcRecord {
index: 0,
label: "skill".into(),
kind: ProcKind::Skill,
status: ProcStatus::Ok,
skill_name: None,
harness: None,
model: None,
started_at: Some(100),
note: None,
detail: None,
fail_reason: None,
elapsed: Some(5.0),
lines: Vec::new(),
container_name: None,
cast_path: None,
}],
last_seen_at: 200,
client_connected: false,
};
assert_eq!(session.lifecycle_status(200), SessionLifecycle::Completed);
assert_eq!(session.duration_secs(200), Some(100));
}
#[test]
fn lifecycle_terminated_when_stale_without_ended_at() {
let session = Session {
id: "stale".into(),
started_at: 100,
ended_at: None,
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: Vec::new(),
last_seen_at: 100,
client_connected: true,
};
assert_eq!(session.lifecycle_status(110), SessionLifecycle::Running);
assert_eq!(session.lifecycle_status(111), SessionLifecycle::Terminated);
}
#[test]
fn lifecycle_cancelled_when_ended_with_incomplete_procs() {
let session = Session {
id: "cancel".into(),
started_at: 1,
ended_at: Some(50),
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: vec![ProcRecord {
index: 0,
label: "skill".into(),
kind: ProcKind::Skill,
status: ProcStatus::Running,
skill_name: None,
harness: None,
model: None,
started_at: Some(1),
note: None,
detail: None,
fail_reason: None,
elapsed: None,
lines: Vec::new(),
container_name: None,
cast_path: None,
}],
last_seen_at: 50,
client_connected: false,
};
assert_eq!(session.lifecycle_status(50), SessionLifecycle::Cancelled);
}
#[test]
fn lifecycle_running_while_incomplete_procs_and_recent() {
let session = Session {
id: "test".into(),
started_at: 1,
ended_at: None,
profile: None,
repo: "/repo".into(),
branch: "main".into(),
skills: Vec::new(),
procs: vec![
ProcRecord {
index: 0,
label: "done".into(),
kind: ProcKind::Skill,
status: ProcStatus::Ok,
skill_name: None,
harness: None,
model: None,
started_at: None,
note: None,
detail: None,
fail_reason: None,
elapsed: None,
lines: Vec::new(),
container_name: None,
cast_path: None,
},
ProcRecord {
index: 1,
label: "still going".into(),
kind: ProcKind::Skill,
status: ProcStatus::Waiting,
skill_name: None,
harness: None,
model: None,
started_at: None,
note: None,
detail: None,
fail_reason: None,
elapsed: None,
lines: Vec::new(),
container_name: None,
cast_path: None,
},
],
last_seen_at: 1,
client_connected: true,
};
assert!(session.has_incomplete_procs());
assert_eq!(session.lifecycle_status(2), SessionLifecycle::Running);
}
#[test]
fn sessions_for_index_puts_running_first_then_recent() {
let running = Session {
id: "run".into(),
started_at: 10,
ended_at: None,
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: Vec::new(),
last_seen_at: 100,
client_connected: true,
};
let done = Session {
id: "done".into(),
started_at: 200,
ended_at: Some(250),
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: Vec::new(),
last_seen_at: 250,
client_connected: false,
};
let mut sessions = BTreeMap::new();
sessions.insert(done.id.clone(), done);
sessions.insert(running.id.clone(), running);
let ordered = sessions_for_index(&sessions, 100);
assert_eq!(ordered.len(), 2);
assert_eq!(ordered[0].id, "run");
assert_eq!(ordered[1].id, "done");
}
#[test]
fn insert_session_evicts_oldest_when_over_cap() {
let mut store = Store::new(DaemonMode::Persistent, DEFAULT_PORT, 0);
for i in 0..=MAX_STORED_SESSIONS {
store.insert_session(
format!("{i:06}"),
Session {
id: format!("{i:06}"),
started_at: i as u64,
ended_at: None,
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: Vec::new(),
last_seen_at: i as u64,
client_connected: false,
},
);
}
assert_eq!(store.sessions.len(), MAX_STORED_SESSIONS);
assert!(!store.sessions.contains_key("000000"));
assert!(store.sessions.contains_key(&format!("{MAX_STORED_SESSIONS:06}")));
}
#[test]
fn session_id_is_six_lowercase_letters() {
let id = crate::runtime::random_nonce_6();
assert_eq!(id.len(), 6);
assert!(id.chars().all(|c| c.is_ascii_lowercase()));
}
#[test]
fn ephemeral_shutdown_after_idle() {
let now = 1_000_000;
let mut store = Store::new(DaemonMode::Ephemeral, DEFAULT_PORT, now);
store.reconcile(now + 100);
assert!(!store.should_shutdown_ephemeral(now + 100));
assert!(!store.should_shutdown_ephemeral(now + EPHEMERAL_IDLE_SECS - 1));
assert!(store.should_shutdown_ephemeral(now + EPHEMERAL_IDLE_SECS));
}
#[test]
fn terminated_client_not_counted_alive() {
let now = 100;
let mut store = Store::new(DaemonMode::Ephemeral, DEFAULT_PORT, now);
store.insert_session(
"stale".into(),
Session {
id: "stale".into(),
started_at: now,
ended_at: None,
profile: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: Vec::new(),
last_seen_at: now,
client_connected: true,
},
);
assert_eq!(store.alive_clients(now + SESSION_STALE_SECS), 1);
assert_eq!(store.alive_clients(now + SESSION_STALE_SECS + 1), 0);
store.reconcile(now + SESSION_STALE_SECS + 1);
assert_eq!(store.active_clients, 0);
assert!(store.no_alive_since.is_some());
}
#[test]
fn ephemeral_countdown_after_no_alive_grace() {
let now = 0;
let store = Store::new(DaemonMode::Ephemeral, DEFAULT_PORT, now);
assert!(store.ephemeral_shutdown_in_secs(now + EPHEMERAL_COUNTDOWN_AFTER_SECS - 1).is_none());
assert_eq!(
store.ephemeral_shutdown_in_secs(now + EPHEMERAL_COUNTDOWN_AFTER_SECS),
Some(EPHEMERAL_IDLE_SECS - EPHEMERAL_COUNTDOWN_AFTER_SECS)
);
assert_eq!(store.ephemeral_shutdown_in_secs(now + EPHEMERAL_IDLE_SECS), Some(0));
}
#[test]
fn persistent_never_auto_shutdown() {
let store = Store::new(DaemonMode::Persistent, DEFAULT_PORT, 0);
assert!(!store.should_shutdown_ephemeral(u64::MAX));
}
}