From d449a7adfff7f57e768f9695dfa9a4da61072ca7 Mon Sep 17 00:00:00 2001 From: Albert Armea Date: Sat, 27 Dec 2025 23:26:49 -0500 Subject: [PATCH] Implement time-unrestricted activities --- config.example.toml | 26 ++++++- crates/shepherd-api/src/commands.rs | 6 +- crates/shepherd-api/src/events.rs | 26 ++++++- crates/shepherd-api/src/types.rs | 13 ++-- crates/shepherd-config/src/policy.rs | 34 ++++++--- crates/shepherd-config/src/validation.rs | 19 +++-- crates/shepherd-core/src/engine.rs | 88 +++++++++++++++-------- crates/shepherd-core/src/events.rs | 3 +- crates/shepherd-core/src/session.rs | 59 ++++++++++----- crates/shepherd-hud/src/state.rs | 34 +++++---- crates/shepherd-launcher-ui/src/app.rs | 13 ++-- crates/shepherd-launcher-ui/src/client.rs | 26 ++++--- crates/shepherd-launcher-ui/src/state.rs | 26 ++++--- crates/shepherd-store/src/audit.rs | 3 +- crates/shepherdd/src/main.rs | 4 +- crates/shepherdd/tests/integration.rs | 12 ++-- 16 files changed, 267 insertions(+), 125 deletions(-) diff --git a/config.example.toml b/config.example.toml index 3381ad3..8d0cac6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,7 @@ config_version = 1 # data_dir = "/var/lib/shepherdd" # Default max run duration if not specified per entry (1 hour) +# Set to 0 for unlimited (no time limit) default_max_run_seconds = 3600 # Default warning thresholds @@ -54,8 +55,9 @@ start = "10:00" end = "20:00" [entries.limits] -max_run_seconds = 3600 # 1 hour max -daily_quota_seconds = 7200 # 2 hours per day +# Set max_run_seconds or daily_quota_seconds to 0 for unlimited +max_run_seconds = 3600 # 1 hour max (0 = unlimited) +daily_quota_seconds = 7200 # 2 hours per day (0 = unlimited) cooldown_seconds = 300 # 5 minute cooldown after each session # Example: Minecraft (via snap mc-installer) @@ -106,8 +108,26 @@ argv = ["tuxmath"] [entries.availability] always = true +# No [entries.limits] section - uses daemon defaults +# Omitting limits entirely uses default_max_run_seconds + +# Example: YouTube video via mpv +[[entries]] +id = "lofi-beats" +label = "Lofi Beats" +icon = "mpv" + +[entries.kind] +type = "process" +argv = ["mpv", "https://www.youtube.com/watch?v=jfKfPfyJRdk"] + +[entries.availability] +always = true + [entries.limits] -max_run_seconds = 7200 # 2 hours +max_run_seconds = 0 # Unlimited: sleep/study aid +daily_quota_seconds = 0 # Unlimited +cooldown_seconds = 0 # No cooldown # Example: Disabled entry [[entries]] diff --git a/crates/shepherd-api/src/commands.rs b/crates/shepherd-api/src/commands.rs index da3c1ab..1c3d49f 100644 --- a/crates/shepherd-api/src/commands.rs +++ b/crates/shepherd-api/src/commands.rs @@ -144,7 +144,8 @@ pub enum ResponsePayload { Entries(Vec), LaunchApproved { session_id: shepherd_util::SessionId, - deadline: DateTime, + /// Deadline for the session. None means unlimited. + deadline: Option>, }, LaunchDenied { reasons: Vec, @@ -157,7 +158,8 @@ pub enum ResponsePayload { Unsubscribed, Health(crate::HealthStatus), Extended { - new_deadline: DateTime, + /// New deadline. None if session is unlimited (can't be extended). + new_deadline: Option>, }, Pong, } diff --git a/crates/shepherd-api/src/events.rs b/crates/shepherd-api/src/events.rs index 3abf059..fa58dd8 100644 --- a/crates/shepherd-api/src/events.rs +++ b/crates/shepherd-api/src/events.rs @@ -37,7 +37,8 @@ pub enum EventPayload { session_id: SessionId, entry_id: EntryId, label: String, - deadline: DateTime, + /// Deadline for session. None means unlimited. + deadline: Option>, }, /// Warning issued for current session @@ -93,7 +94,7 @@ mod tests { session_id: SessionId::new(), entry_id: EntryId::new("game-1"), label: "Test Game".into(), - deadline: Local::now(), + deadline: Some(Local::now()), }); let json = serde_json::to_string(&event).unwrap(); @@ -102,4 +103,25 @@ mod tests { assert_eq!(parsed.api_version, API_VERSION); assert!(matches!(parsed.payload, EventPayload::SessionStarted { .. })); } + + #[test] + fn event_serialization_unlimited() { + // Test with unlimited session (deadline=None) + let event = Event::new(EventPayload::SessionStarted { + session_id: SessionId::new(), + entry_id: EntryId::new("game-1"), + label: "Unlimited Game".into(), + deadline: None, + }); + + let json = serde_json::to_string(&event).unwrap(); + let parsed: Event = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.api_version, API_VERSION); + if let EventPayload::SessionStarted { deadline, .. } = parsed.payload { + assert!(deadline.is_none()); + } else { + panic!("Expected SessionStarted"); + } + } } diff --git a/crates/shepherd-api/src/types.rs b/crates/shepherd-api/src/types.rs index 1993b49..838bd0e 100644 --- a/crates/shepherd-api/src/types.rs +++ b/crates/shepherd-api/src/types.rs @@ -79,7 +79,9 @@ pub struct EntryView { pub kind_tag: EntryKindTag, pub enabled: bool, pub reasons: Vec, - /// If enabled, maximum run duration if started now + /// Maximum run duration if started now. None means: + /// - If enabled=false: entry is not available + /// - If enabled=true: entry has no time limit (unlimited) pub max_run_if_started_now: Option, } @@ -104,7 +106,8 @@ pub enum ReasonCode { /// Another session is active SessionActive { entry_id: EntryId, - remaining: Duration, + /// Time remaining in current session. None means unlimited. + remaining: Option, }, /// Host doesn't support this entry kind UnsupportedKind { @@ -173,8 +176,10 @@ pub struct SessionInfo { pub label: String, pub state: SessionState, pub started_at: DateTime, - pub deadline: DateTime, - pub time_remaining: Duration, + /// Session deadline. None means unlimited (no time limit). + pub deadline: Option>, + /// Time remaining. None means unlimited. + pub time_remaining: Option, pub warnings_issued: Vec, } diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index c36a71f..c6f8f41 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -20,8 +20,8 @@ pub struct Policy { /// Default warning thresholds pub default_warnings: Vec, - /// Default max run duration - pub default_max_run: Duration, + /// Default max run duration. None means unlimited. + pub default_max_run: Option, } impl Policy { @@ -34,11 +34,12 @@ impl Policy { .map(|w| w.into_iter().map(convert_warning).collect()) .unwrap_or_else(default_warning_thresholds); + // 0 means unlimited, None means use 1 hour default let default_max_run = raw .daemon .default_max_run_seconds - .map(Duration::from_secs) - .unwrap_or(Duration::from_secs(3600)); // 1 hour default + .map(seconds_to_duration_or_unlimited) + .unwrap_or(Some(Duration::from_secs(3600))); // 1 hour default let entries = raw .entries @@ -112,7 +113,7 @@ impl Entry { fn from_raw( raw: RawEntry, default_warnings: &[WarningThreshold], - default_max_run: Duration, + default_max_run: Option, ) -> Self { let kind = convert_entry_kind(raw.kind); let availability = raw @@ -124,7 +125,7 @@ impl Entry { .map(|l| convert_limits(l, default_max_run)) .unwrap_or_else(|| LimitsPolicy { max_run: default_max_run, - daily_quota: None, + daily_quota: None, // None means unlimited cooldown: None, }); let warnings = raw @@ -182,7 +183,9 @@ impl AvailabilityPolicy { /// Time limits for an entry #[derive(Debug, Clone)] pub struct LimitsPolicy { - pub max_run: Duration, + /// Maximum run duration. None means unlimited. + pub max_run: Option, + /// Daily quota. None means unlimited. pub daily_quota: Option, pub cooldown: Option, } @@ -222,13 +225,24 @@ fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow { } } -fn convert_limits(raw: crate::schema::RawLimits, default_max_run: Duration) -> LimitsPolicy { +/// Convert seconds to Duration, treating 0 as "unlimited" (None) +fn seconds_to_duration_or_unlimited(secs: u64) -> Option { + if secs == 0 { + None // 0 means unlimited + } else { + Some(Duration::from_secs(secs)) + } +} + +fn convert_limits(raw: crate::schema::RawLimits, default_max_run: Option) -> LimitsPolicy { LimitsPolicy { max_run: raw .max_run_seconds - .map(Duration::from_secs) + .map(seconds_to_duration_or_unlimited) .unwrap_or(default_max_run), - daily_quota: raw.daily_quota_seconds.map(Duration::from_secs), + daily_quota: raw + .daily_quota_seconds + .and_then(seconds_to_duration_or_unlimited), cooldown: raw.cooldown_seconds.map(Duration::from_secs), } } diff --git a/crates/shepherd-config/src/validation.rs b/crates/shepherd-config/src/validation.rs index 66493d8..fd347d4 100644 --- a/crates/shepherd-config/src/validation.rs +++ b/crates/shepherd-config/src/validation.rs @@ -105,22 +105,27 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec } // Validate warning thresholds vs max_run + // Skip validation if max_run is 0 (unlimited) since there's no expiry to warn about let max_run = entry .limits .as_ref() .and_then(|l| l.max_run_seconds) .or(config.daemon.default_max_run_seconds); + // Only validate warnings if max_run is Some and not 0 (unlimited) if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run) { - for warning in warnings { - if warning.seconds_before >= max_run { - errors.push(ValidationError::WarningExceedsMaxRun { - entry_id: entry.id.clone(), - seconds: warning.seconds_before, - max_run, - }); + if max_run > 0 { + for warning in warnings { + if warning.seconds_before >= max_run { + errors.push(ValidationError::WarningExceedsMaxRun { + entry_id: entry.id.clone(), + seconds: warning.seconds_before, + max_run, + }); + } } } + // Note: warnings are ignored for unlimited entries (max_run = 0) } errors diff --git a/crates/shepherd-core/src/engine.rs b/crates/shepherd-core/src/engine.rs index bd4f5a9..b99e2a2 100644 --- a/crates/shepherd-core/src/engine.rs +++ b/crates/shepherd-core/src/engine.rs @@ -146,9 +146,9 @@ impl CoreEngine { } } - // Calculate max run if enabled + // Calculate max run if enabled (None when disabled, Some(None) flattened for unlimited) let max_run_if_started_now = if enabled { - Some(self.compute_max_duration(entry, now)) + self.compute_max_duration(entry, now) } else { None }; @@ -164,13 +164,17 @@ impl CoreEngine { } } - /// Compute maximum duration for an entry if started now - fn compute_max_duration(&self, entry: &Entry, now: DateTime) -> Duration { + /// Compute maximum duration for an entry if started now. + /// Returns None if the entry has no time limit (unlimited). + fn compute_max_duration(&self, entry: &Entry, now: DateTime) -> Option { let mut max = entry.limits.max_run; // Limit by time window remaining if let Some(window_remaining) = entry.availability.remaining_in_window(&now) { - max = max.min(window_remaining); + max = Some(match max { + Some(m) => m.min(window_remaining), + None => window_remaining, + }); } // Limit by daily quota remaining @@ -178,7 +182,10 @@ impl CoreEngine { let today = now.date_naive(); if let Ok(used) = self.store.get_usage(&entry.id, today) { let remaining = quota.saturating_sub(used); - max = max.min(remaining); + max = Some(match max { + Some(m) => m.min(remaining), + None => remaining, + }); } } @@ -219,7 +226,7 @@ impl CoreEngine { } // Compute session plan - let max_duration = view.max_run_if_started_now.unwrap(); + let max_duration = view.max_run_if_started_now; let plan = SessionPlan { session_id: SessionId::new(), entry_id: entry_id.clone(), @@ -228,11 +235,18 @@ impl CoreEngine { warnings: entry.warnings.clone(), }; - debug!( - entry_id = %entry_id, - max_duration_secs = max_duration.as_secs(), - "Launch approved" - ); + if let Some(max_dur) = max_duration { + debug!( + entry_id = %entry_id, + max_duration_secs = max_dur.as_secs(), + "Launch approved" + ); + } else { + debug!( + entry_id = %entry_id, + "Launch approved (unlimited)" + ); + } LaunchDecision::Approved(plan) } @@ -261,12 +275,20 @@ impl CoreEngine { deadline: session.deadline, })); - info!( - session_id = %session.plan.session_id, - entry_id = %session.plan.entry_id, - deadline = %session.deadline, - "Session started" - ); + if let Some(deadline) = session.deadline { + info!( + session_id = %session.plan.session_id, + entry_id = %session.plan.entry_id, + deadline = %deadline, + "Session started" + ); + } else { + info!( + session_id = %session.plan.session_id, + entry_id = %session.plan.entry_id, + "Session started (unlimited)" + ); + } self.current_session = Some(session); @@ -484,6 +506,7 @@ impl CoreEngine { } /// Extend current session (admin action) + /// Only works for sessions with a deadline (not unlimited sessions). pub fn extend_current( &mut self, by: Duration, @@ -492,24 +515,31 @@ impl CoreEngine { ) -> Option> { let session = self.current_session.as_mut()?; - session.deadline_mono = session.deadline_mono + by; - session.deadline = session.deadline + chrono::Duration::from_std(by).unwrap(); + // Can't extend unlimited sessions - they don't have a deadline + let deadline_mono = session.deadline_mono?; + let deadline = session.deadline?; + + let new_deadline_mono = deadline_mono + by; + let new_deadline = deadline + chrono::Duration::from_std(by).unwrap(); + + session.deadline_mono = Some(new_deadline_mono); + session.deadline = Some(new_deadline); // Log to audit let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionExtended { session_id: session.plan.session_id.clone(), extended_by: by, - new_deadline: session.deadline, + new_deadline, })); info!( session_id = %session.plan.session_id, extended_by_secs = by.as_secs(), - new_deadline = %session.deadline, + new_deadline = %new_deadline, "Session extended" ); - Some(session.deadline) + Some(new_deadline) } } @@ -538,7 +568,7 @@ mod tests { always: true, }, limits: LimitsPolicy { - max_run: Duration::from_secs(300), + max_run: Some(Duration::from_secs(300)), daily_quota: None, cooldown: None, }, @@ -547,7 +577,7 @@ mod tests { disabled_reason: None, }], default_warnings: vec![], - default_max_run: Duration::from_secs(3600), + default_max_run: Some(Duration::from_secs(3600)), } } @@ -614,7 +644,7 @@ mod tests { always: true, }, limits: LimitsPolicy { - max_run: Duration::from_secs(120), // 2 minutes + max_run: Some(Duration::from_secs(120)), // 2 minutes daily_quota: None, cooldown: None, }, @@ -628,7 +658,7 @@ mod tests { }], daemon: Default::default(), default_warnings: vec![], - default_max_run: Duration::from_secs(3600), + default_max_run: Some(Duration::from_secs(3600)), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); @@ -676,7 +706,7 @@ mod tests { always: true, }, limits: LimitsPolicy { - max_run: Duration::from_secs(60), + max_run: Some(Duration::from_secs(60)), daily_quota: None, cooldown: None, }, @@ -686,7 +716,7 @@ mod tests { }], daemon: Default::default(), default_warnings: vec![], - default_max_run: Duration::from_secs(3600), + default_max_run: Some(Duration::from_secs(3600)), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); diff --git a/crates/shepherd-core/src/events.rs b/crates/shepherd-core/src/events.rs index e27d53f..ff2a371 100644 --- a/crates/shepherd-core/src/events.rs +++ b/crates/shepherd-core/src/events.rs @@ -13,7 +13,8 @@ pub enum CoreEvent { session_id: SessionId, entry_id: EntryId, label: String, - deadline: DateTime, + /// Deadline for session. None means unlimited. + deadline: Option>, }, /// Warning threshold reached diff --git a/crates/shepherd-core/src/session.rs b/crates/shepherd-core/src/session.rs index 4612ff4..281ffcc 100644 --- a/crates/shepherd-core/src/session.rs +++ b/crates/shepherd-core/src/session.rs @@ -12,19 +12,25 @@ pub struct SessionPlan { pub session_id: SessionId, pub entry_id: EntryId, pub label: String, - pub max_duration: Duration, + /// Maximum duration for this session. None means unlimited. + pub max_duration: Option, pub warnings: Vec, } impl SessionPlan { /// Compute warning times (as durations after start) + /// Returns empty vec for unlimited sessions. pub fn warning_times(&self) -> Vec<(u64, Duration)> { + let max_duration = match self.max_duration { + Some(d) => d, + None => return Vec::new(), // No warnings for unlimited sessions + }; self.warnings .iter() - .filter(|w| Duration::from_secs(w.seconds_before) < self.max_duration) + .filter(|w| Duration::from_secs(w.seconds_before) < max_duration) .map(|w| { let trigger_after = - self.max_duration - Duration::from_secs(w.seconds_before); + max_duration - Duration::from_secs(w.seconds_before); (w.seconds_before, trigger_after) }) .collect() @@ -46,11 +52,11 @@ pub struct ActiveSession { /// Monotonic start time (for enforcement) pub started_at_mono: MonotonicInstant, - /// Wall-clock deadline (for display) - pub deadline: DateTime, + /// Wall-clock deadline (for display). None means unlimited. + pub deadline: Option>, - /// Monotonic deadline (for enforcement) - pub deadline_mono: MonotonicInstant, + /// Monotonic deadline (for enforcement). None means unlimited. + pub deadline_mono: Option, /// Warning thresholds already issued (seconds before expiry) pub warnings_issued: Vec, @@ -66,8 +72,14 @@ impl ActiveSession { now: DateTime, now_mono: MonotonicInstant, ) -> Self { - let deadline = now + chrono::Duration::from_std(plan.max_duration).unwrap(); - let deadline_mono = now_mono + plan.max_duration; + let (deadline, deadline_mono) = match plan.max_duration { + Some(max_dur) => { + let deadline = now + chrono::Duration::from_std(max_dur).unwrap(); + let deadline_mono = now_mono + max_dur; + (Some(deadline), Some(deadline_mono)) + } + None => (None, None), // Unlimited session + }; Self { plan, @@ -87,20 +99,29 @@ impl ActiveSession { self.state = SessionState::Running; } - /// Get time remaining using monotonic time - pub fn time_remaining(&self, now_mono: MonotonicInstant) -> Duration { - self.deadline_mono.saturating_duration_until(now_mono) + /// Get time remaining using monotonic time. None means unlimited. + pub fn time_remaining(&self, now_mono: MonotonicInstant) -> Option { + self.deadline_mono.map(|deadline| deadline.saturating_duration_until(now_mono)) } - /// Check if session is expired + /// Check if session is expired (never true for unlimited sessions) pub fn is_expired(&self, now_mono: MonotonicInstant) -> bool { - now_mono >= self.deadline_mono + match self.deadline_mono { + Some(deadline) => now_mono >= deadline, + None => false, // Unlimited sessions never expire + } } - /// Get pending warnings (not yet issued) that should fire now + /// Get pending warnings (not yet issued) that should fire now. + /// Returns empty vec for unlimited sessions (no warnings to issue). pub fn pending_warnings(&self, now_mono: MonotonicInstant) -> Vec<(u64, Duration)> { + // Unlimited sessions don't have warnings + let remaining = match self.time_remaining(now_mono) { + Some(r) => r, + None => return Vec::new(), + }; + let elapsed = now_mono.duration_since(self.started_at_mono); - let remaining = self.time_remaining(now_mono); self.plan .warning_times() @@ -173,7 +194,7 @@ mod tests { session_id: SessionId::new(), entry_id: EntryId::new("test"), label: "Test".into(), - max_duration: Duration::from_secs(duration_secs), + max_duration: Some(Duration::from_secs(duration_secs)), warnings: vec![ WarningThreshold { seconds_before: 60, @@ -199,7 +220,7 @@ mod tests { assert_eq!(session.state, SessionState::Launching); assert!(session.warnings_issued.is_empty()); - assert_eq!(session.time_remaining(now_mono), Duration::from_secs(300)); + assert_eq!(session.time_remaining(now_mono), Some(Duration::from_secs(300))); } #[test] @@ -225,7 +246,7 @@ mod tests { session_id: SessionId::new(), entry_id: EntryId::new("test"), label: "Test".into(), - max_duration: Duration::from_secs(30), // 30 seconds + max_duration: Some(Duration::from_secs(30)), // 30 seconds warnings: vec![WarningThreshold { seconds_before: 60, // 60 second warning - longer than session! severity: WarningSeverity::Warn, diff --git a/crates/shepherd-hud/src/state.rs b/crates/shepherd-hud/src/state.rs index 5b47951..a35ef54 100644 --- a/crates/shepherd-hud/src/state.rs +++ b/crates/shepherd-hud/src/state.rs @@ -148,18 +148,21 @@ impl SharedState { deadline, } => { let now = chrono::Local::now(); - let time_remaining = if *deadline > now { - (*deadline - now).num_seconds().max(0) as u64 - } else { - 0 - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = deadline.and_then(|d| { + if d > now { + Some((d - now).num_seconds().max(0) as u64) + } else { + Some(0) + } + }); self.set_session_state(SessionState::Active { session_id: session_id.clone(), entry_id: entry_id.clone(), entry_name: label.clone(), started_at: std::time::Instant::now(), - time_limit_secs: Some(time_remaining), - time_remaining_secs: Some(time_remaining), + time_limit_secs: time_remaining, + time_remaining_secs: time_remaining, paused: false, }); } @@ -208,18 +211,21 @@ impl SharedState { EventPayload::StateChanged(snapshot) => { if let Some(session) = &snapshot.current_session { let now = chrono::Local::now(); - let time_remaining = if session.deadline > now { - (session.deadline - now).num_seconds().max(0) as u64 - } else { - 0 - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = session.deadline.and_then(|d| { + if d > now { + Some((d - now).num_seconds().max(0) as u64) + } else { + Some(0) + } + }); self.set_session_state(SessionState::Active { session_id: session.session_id.clone(), entry_id: session.entry_id.clone(), entry_name: session.label.clone(), started_at: std::time::Instant::now(), - time_limit_secs: Some(time_remaining), - time_remaining_secs: Some(time_remaining), + time_limit_secs: time_remaining, + time_remaining_secs: time_remaining, paused: false, }); } else { diff --git a/crates/shepherd-launcher-ui/src/app.rs b/crates/shepherd-launcher-ui/src/app.rs index 45161ad..02259d5 100644 --- a/crates/shepherd-launcher-ui/src/app.rs +++ b/crates/shepherd-launcher-ui/src/app.rs @@ -191,11 +191,14 @@ impl LauncherApp { shepherd_api::ResponsePayload::LaunchApproved { session_id, deadline } => { info!(session_id = %session_id, "Launch approved, setting SessionActive"); let now = chrono::Local::now(); - let time_remaining = if deadline > now { - (deadline - now).to_std().ok() - } else { - Some(std::time::Duration::ZERO) - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = deadline.and_then(|d| { + if d > now { + (d - now).to_std().ok() + } else { + Some(std::time::Duration::ZERO) + } + }); state.set(LauncherState::SessionActive { session_id, entry_label: entry_id.to_string(), diff --git a/crates/shepherd-launcher-ui/src/client.rs b/crates/shepherd-launcher-ui/src/client.rs index 6225688..b04bd62 100644 --- a/crates/shepherd-launcher-ui/src/client.rs +++ b/crates/shepherd-launcher-ui/src/client.rs @@ -140,11 +140,14 @@ impl DaemonClient { ResponsePayload::State(snapshot) => { if let Some(session) = snapshot.current_session { let now = chrono::Local::now(); - let time_remaining = if session.deadline > now { - (session.deadline - now).to_std().ok() - } else { - Some(Duration::ZERO) - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = session.deadline.and_then(|d| { + if d > now { + (d - now).to_std().ok() + } else { + Some(Duration::ZERO) + } + }); self.state.set(LauncherState::SessionActive { session_id: session.session_id, entry_label: session.label, @@ -164,11 +167,14 @@ impl DaemonClient { } ResponsePayload::LaunchApproved { session_id, deadline } => { let now = chrono::Local::now(); - let time_remaining = if deadline > now { - (deadline - now).to_std().ok() - } else { - Some(Duration::ZERO) - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = deadline.and_then(|d| { + if d > now { + (d - now).to_std().ok() + } else { + Some(Duration::ZERO) + } + }); self.state.set(LauncherState::SessionActive { session_id, entry_label: "Starting...".into(), diff --git a/crates/shepherd-launcher-ui/src/state.rs b/crates/shepherd-launcher-ui/src/state.rs index 3d6c205..e267b74 100644 --- a/crates/shepherd-launcher-ui/src/state.rs +++ b/crates/shepherd-launcher-ui/src/state.rs @@ -74,11 +74,14 @@ impl SharedState { } => { tracing::info!(session_id = %session_id, label = %label, "Session started event"); let now = chrono::Local::now(); - let time_remaining = if deadline > now { - (deadline - now).to_std().ok() - } else { - Some(Duration::ZERO) - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = deadline.and_then(|d| { + if d > now { + (d - now).to_std().ok() + } else { + Some(Duration::ZERO) + } + }); self.set(LauncherState::SessionActive { session_id, entry_label: label, @@ -118,11 +121,14 @@ impl SharedState { fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) { if let Some(session) = snapshot.current_session { let now = chrono::Local::now(); - let time_remaining = if session.deadline > now { - (session.deadline - now).to_std().ok() - } else { - Some(Duration::ZERO) - }; + // For unlimited sessions (deadline=None), time_remaining is None + let time_remaining = session.deadline.and_then(|d| { + if d > now { + (d - now).to_std().ok() + } else { + Some(Duration::ZERO) + } + }); self.set(LauncherState::SessionActive { session_id: session.session_id, entry_label: session.label, diff --git a/crates/shepherd-store/src/audit.rs b/crates/shepherd-store/src/audit.rs index 697696d..bcc810e 100644 --- a/crates/shepherd-store/src/audit.rs +++ b/crates/shepherd-store/src/audit.rs @@ -24,7 +24,8 @@ pub enum AuditEventType { session_id: SessionId, entry_id: EntryId, label: String, - deadline: DateTime, + /// Deadline for session. None means unlimited. + deadline: Option>, }, /// Warning issued diff --git a/crates/shepherdd/src/main.rs b/crates/shepherdd/src/main.rs index 16391d1..639af88 100644 --- a/crates/shepherdd/src/main.rs +++ b/crates/shepherdd/src/main.rs @@ -667,11 +667,11 @@ impl Daemon { let mut eng = engine.lock().await; match eng.extend_current(by, now_mono, now) { Some(new_deadline) => { - Response::success(request_id, ResponsePayload::Extended { new_deadline }) + Response::success(request_id, ResponsePayload::Extended { new_deadline: Some(new_deadline) }) } None => Response::error( request_id, - ErrorInfo::new(ErrorCode::NoActiveSession, "No active session"), + ErrorInfo::new(ErrorCode::NoActiveSession, "No active session or session is unlimited"), ), } } diff --git a/crates/shepherdd/tests/integration.rs b/crates/shepherdd/tests/integration.rs index fa5b28e..6b97584 100644 --- a/crates/shepherdd/tests/integration.rs +++ b/crates/shepherdd/tests/integration.rs @@ -31,7 +31,7 @@ fn make_test_policy() -> Policy { always: true, }, limits: LimitsPolicy { - max_run: Duration::from_secs(10), // Short for testing + max_run: Some(Duration::from_secs(10)), // Short for testing daily_quota: None, cooldown: None, }, @@ -52,7 +52,7 @@ fn make_test_policy() -> Policy { }, ], default_warnings: vec![], - default_max_run: Duration::from_secs(3600), + default_max_run: Some(Duration::from_secs(3600)), } } @@ -88,7 +88,7 @@ fn test_launch_approval() { let entry_id = EntryId::new("test-game"); let decision = engine.request_launch(&entry_id, Local::now()); - assert!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Duration::from_secs(10))); + assert!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10)))); } #[test] @@ -284,7 +284,7 @@ fn test_config_parsing() { let policy = parse_config(config).unwrap(); assert_eq!(policy.entries.len(), 1); assert_eq!(policy.entries[0].id.as_str(), "scummvm"); - assert_eq!(policy.entries[0].limits.max_run, Duration::from_secs(3600)); + assert_eq!(policy.entries[0].limits.max_run, Some(Duration::from_secs(3600))); assert_eq!(policy.entries[0].limits.daily_quota, Some(Duration::from_secs(7200))); assert_eq!(policy.entries[0].limits.cooldown, Some(Duration::from_secs(300))); assert_eq!(policy.entries[0].warnings.len(), 1); @@ -308,8 +308,8 @@ fn test_session_extension() { }; engine.start_session(plan, now, now_mono); - // Get original deadline - let original_deadline = engine.current_session().unwrap().deadline; + // Get original deadline (should be Some for this test) + let original_deadline = engine.current_session().unwrap().deadline.expect("Expected deadline"); // Extend by 5 minutes let new_deadline = engine.extend_current(Duration::from_secs(300), now_mono, now);