Implement time-unrestricted activities

This commit is contained in:
Albert Armea 2025-12-27 23:26:49 -05:00
parent d2bebd39a6
commit d449a7adff
16 changed files with 267 additions and 125 deletions

View file

@ -10,6 +10,7 @@ config_version = 1
# data_dir = "/var/lib/shepherdd" # data_dir = "/var/lib/shepherdd"
# Default max run duration if not specified per entry (1 hour) # 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_max_run_seconds = 3600
# Default warning thresholds # Default warning thresholds
@ -54,8 +55,9 @@ start = "10:00"
end = "20:00" end = "20:00"
[entries.limits] [entries.limits]
max_run_seconds = 3600 # 1 hour max # Set max_run_seconds or daily_quota_seconds to 0 for unlimited
daily_quota_seconds = 7200 # 2 hours per day 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 cooldown_seconds = 300 # 5 minute cooldown after each session
# Example: Minecraft (via snap mc-installer) # Example: Minecraft (via snap mc-installer)
@ -106,8 +108,26 @@ argv = ["tuxmath"]
[entries.availability] [entries.availability]
always = true 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] [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 # Example: Disabled entry
[[entries]] [[entries]]

View file

@ -144,7 +144,8 @@ pub enum ResponsePayload {
Entries(Vec<crate::EntryView>), Entries(Vec<crate::EntryView>),
LaunchApproved { LaunchApproved {
session_id: shepherd_util::SessionId, session_id: shepherd_util::SessionId,
deadline: DateTime<Local>, /// Deadline for the session. None means unlimited.
deadline: Option<DateTime<Local>>,
}, },
LaunchDenied { LaunchDenied {
reasons: Vec<crate::ReasonCode>, reasons: Vec<crate::ReasonCode>,
@ -157,7 +158,8 @@ pub enum ResponsePayload {
Unsubscribed, Unsubscribed,
Health(crate::HealthStatus), Health(crate::HealthStatus),
Extended { Extended {
new_deadline: DateTime<Local>, /// New deadline. None if session is unlimited (can't be extended).
new_deadline: Option<DateTime<Local>>,
}, },
Pong, Pong,
} }

View file

@ -37,7 +37,8 @@ pub enum EventPayload {
session_id: SessionId, session_id: SessionId,
entry_id: EntryId, entry_id: EntryId,
label: String, label: String,
deadline: DateTime<Local>, /// Deadline for session. None means unlimited.
deadline: Option<DateTime<Local>>,
}, },
/// Warning issued for current session /// Warning issued for current session
@ -93,7 +94,7 @@ mod tests {
session_id: SessionId::new(), session_id: SessionId::new(),
entry_id: EntryId::new("game-1"), entry_id: EntryId::new("game-1"),
label: "Test Game".into(), label: "Test Game".into(),
deadline: Local::now(), deadline: Some(Local::now()),
}); });
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
@ -102,4 +103,25 @@ mod tests {
assert_eq!(parsed.api_version, API_VERSION); assert_eq!(parsed.api_version, API_VERSION);
assert!(matches!(parsed.payload, EventPayload::SessionStarted { .. })); 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");
}
}
} }

View file

@ -79,7 +79,9 @@ pub struct EntryView {
pub kind_tag: EntryKindTag, pub kind_tag: EntryKindTag,
pub enabled: bool, pub enabled: bool,
pub reasons: Vec<ReasonCode>, pub reasons: Vec<ReasonCode>,
/// 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<Duration>, pub max_run_if_started_now: Option<Duration>,
} }
@ -104,7 +106,8 @@ pub enum ReasonCode {
/// Another session is active /// Another session is active
SessionActive { SessionActive {
entry_id: EntryId, entry_id: EntryId,
remaining: Duration, /// Time remaining in current session. None means unlimited.
remaining: Option<Duration>,
}, },
/// Host doesn't support this entry kind /// Host doesn't support this entry kind
UnsupportedKind { UnsupportedKind {
@ -173,8 +176,10 @@ pub struct SessionInfo {
pub label: String, pub label: String,
pub state: SessionState, pub state: SessionState,
pub started_at: DateTime<Local>, pub started_at: DateTime<Local>,
pub deadline: DateTime<Local>, /// Session deadline. None means unlimited (no time limit).
pub time_remaining: Duration, pub deadline: Option<DateTime<Local>>,
/// Time remaining. None means unlimited.
pub time_remaining: Option<Duration>,
pub warnings_issued: Vec<u64>, pub warnings_issued: Vec<u64>,
} }

View file

@ -20,8 +20,8 @@ pub struct Policy {
/// Default warning thresholds /// Default warning thresholds
pub default_warnings: Vec<WarningThreshold>, pub default_warnings: Vec<WarningThreshold>,
/// Default max run duration /// Default max run duration. None means unlimited.
pub default_max_run: Duration, pub default_max_run: Option<Duration>,
} }
impl Policy { impl Policy {
@ -34,11 +34,12 @@ impl Policy {
.map(|w| w.into_iter().map(convert_warning).collect()) .map(|w| w.into_iter().map(convert_warning).collect())
.unwrap_or_else(default_warning_thresholds); .unwrap_or_else(default_warning_thresholds);
// 0 means unlimited, None means use 1 hour default
let default_max_run = raw let default_max_run = raw
.daemon .daemon
.default_max_run_seconds .default_max_run_seconds
.map(Duration::from_secs) .map(seconds_to_duration_or_unlimited)
.unwrap_or(Duration::from_secs(3600)); // 1 hour default .unwrap_or(Some(Duration::from_secs(3600))); // 1 hour default
let entries = raw let entries = raw
.entries .entries
@ -112,7 +113,7 @@ impl Entry {
fn from_raw( fn from_raw(
raw: RawEntry, raw: RawEntry,
default_warnings: &[WarningThreshold], default_warnings: &[WarningThreshold],
default_max_run: Duration, default_max_run: Option<Duration>,
) -> Self { ) -> Self {
let kind = convert_entry_kind(raw.kind); let kind = convert_entry_kind(raw.kind);
let availability = raw let availability = raw
@ -124,7 +125,7 @@ impl Entry {
.map(|l| convert_limits(l, default_max_run)) .map(|l| convert_limits(l, default_max_run))
.unwrap_or_else(|| LimitsPolicy { .unwrap_or_else(|| LimitsPolicy {
max_run: default_max_run, max_run: default_max_run,
daily_quota: None, daily_quota: None, // None means unlimited
cooldown: None, cooldown: None,
}); });
let warnings = raw let warnings = raw
@ -182,7 +183,9 @@ impl AvailabilityPolicy {
/// Time limits for an entry /// Time limits for an entry
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LimitsPolicy { pub struct LimitsPolicy {
pub max_run: Duration, /// Maximum run duration. None means unlimited.
pub max_run: Option<Duration>,
/// Daily quota. None means unlimited.
pub daily_quota: Option<Duration>, pub daily_quota: Option<Duration>,
pub cooldown: Option<Duration>, pub cooldown: Option<Duration>,
} }
@ -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<Duration> {
if secs == 0 {
None // 0 means unlimited
} else {
Some(Duration::from_secs(secs))
}
}
fn convert_limits(raw: crate::schema::RawLimits, default_max_run: Option<Duration>) -> LimitsPolicy {
LimitsPolicy { LimitsPolicy {
max_run: raw max_run: raw
.max_run_seconds .max_run_seconds
.map(Duration::from_secs) .map(seconds_to_duration_or_unlimited)
.unwrap_or(default_max_run), .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), cooldown: raw.cooldown_seconds.map(Duration::from_secs),
} }
} }

View file

@ -105,13 +105,16 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
} }
// Validate warning thresholds vs max_run // 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 let max_run = entry
.limits .limits
.as_ref() .as_ref()
.and_then(|l| l.max_run_seconds) .and_then(|l| l.max_run_seconds)
.or(config.daemon.default_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) { if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run) {
if max_run > 0 {
for warning in warnings { for warning in warnings {
if warning.seconds_before >= max_run { if warning.seconds_before >= max_run {
errors.push(ValidationError::WarningExceedsMaxRun { errors.push(ValidationError::WarningExceedsMaxRun {
@ -122,6 +125,8 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
} }
} }
} }
// Note: warnings are ignored for unlimited entries (max_run = 0)
}
errors errors
} }

View file

@ -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 { let max_run_if_started_now = if enabled {
Some(self.compute_max_duration(entry, now)) self.compute_max_duration(entry, now)
} else { } else {
None None
}; };
@ -164,13 +164,17 @@ impl CoreEngine {
} }
} }
/// Compute maximum duration for an entry if started now /// Compute maximum duration for an entry if started now.
fn compute_max_duration(&self, entry: &Entry, now: DateTime<Local>) -> Duration { /// Returns None if the entry has no time limit (unlimited).
fn compute_max_duration(&self, entry: &Entry, now: DateTime<Local>) -> Option<Duration> {
let mut max = entry.limits.max_run; let mut max = entry.limits.max_run;
// Limit by time window remaining // Limit by time window remaining
if let Some(window_remaining) = entry.availability.remaining_in_window(&now) { 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 // Limit by daily quota remaining
@ -178,7 +182,10 @@ impl CoreEngine {
let today = now.date_naive(); let today = now.date_naive();
if let Ok(used) = self.store.get_usage(&entry.id, today) { if let Ok(used) = self.store.get_usage(&entry.id, today) {
let remaining = quota.saturating_sub(used); 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 // 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 { let plan = SessionPlan {
session_id: SessionId::new(), session_id: SessionId::new(),
entry_id: entry_id.clone(), entry_id: entry_id.clone(),
@ -228,11 +235,18 @@ impl CoreEngine {
warnings: entry.warnings.clone(), warnings: entry.warnings.clone(),
}; };
if let Some(max_dur) = max_duration {
debug!( debug!(
entry_id = %entry_id, entry_id = %entry_id,
max_duration_secs = max_duration.as_secs(), max_duration_secs = max_dur.as_secs(),
"Launch approved" "Launch approved"
); );
} else {
debug!(
entry_id = %entry_id,
"Launch approved (unlimited)"
);
}
LaunchDecision::Approved(plan) LaunchDecision::Approved(plan)
} }
@ -261,12 +275,20 @@ impl CoreEngine {
deadline: session.deadline, deadline: session.deadline,
})); }));
if let Some(deadline) = session.deadline {
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
entry_id = %session.plan.entry_id, entry_id = %session.plan.entry_id,
deadline = %session.deadline, deadline = %deadline,
"Session started" "Session started"
); );
} else {
info!(
session_id = %session.plan.session_id,
entry_id = %session.plan.entry_id,
"Session started (unlimited)"
);
}
self.current_session = Some(session); self.current_session = Some(session);
@ -484,6 +506,7 @@ impl CoreEngine {
} }
/// Extend current session (admin action) /// Extend current session (admin action)
/// Only works for sessions with a deadline (not unlimited sessions).
pub fn extend_current( pub fn extend_current(
&mut self, &mut self,
by: Duration, by: Duration,
@ -492,24 +515,31 @@ impl CoreEngine {
) -> Option<DateTime<Local>> { ) -> Option<DateTime<Local>> {
let session = self.current_session.as_mut()?; let session = self.current_session.as_mut()?;
session.deadline_mono = session.deadline_mono + by; // Can't extend unlimited sessions - they don't have a deadline
session.deadline = session.deadline + chrono::Duration::from_std(by).unwrap(); 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 // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionExtended { let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionExtended {
session_id: session.plan.session_id.clone(), session_id: session.plan.session_id.clone(),
extended_by: by, extended_by: by,
new_deadline: session.deadline, new_deadline,
})); }));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
extended_by_secs = by.as_secs(), extended_by_secs = by.as_secs(),
new_deadline = %session.deadline, new_deadline = %new_deadline,
"Session extended" "Session extended"
); );
Some(session.deadline) Some(new_deadline)
} }
} }
@ -538,7 +568,7 @@ mod tests {
always: true, always: true,
}, },
limits: LimitsPolicy { limits: LimitsPolicy {
max_run: Duration::from_secs(300), max_run: Some(Duration::from_secs(300)),
daily_quota: None, daily_quota: None,
cooldown: None, cooldown: None,
}, },
@ -547,7 +577,7 @@ mod tests {
disabled_reason: None, disabled_reason: None,
}], }],
default_warnings: vec![], 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, always: true,
}, },
limits: LimitsPolicy { limits: LimitsPolicy {
max_run: Duration::from_secs(120), // 2 minutes max_run: Some(Duration::from_secs(120)), // 2 minutes
daily_quota: None, daily_quota: None,
cooldown: None, cooldown: None,
}, },
@ -628,7 +658,7 @@ mod tests {
}], }],
daemon: Default::default(), daemon: Default::default(),
default_warnings: vec![], 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()); let store = Arc::new(SqliteStore::in_memory().unwrap());
@ -676,7 +706,7 @@ mod tests {
always: true, always: true,
}, },
limits: LimitsPolicy { limits: LimitsPolicy {
max_run: Duration::from_secs(60), max_run: Some(Duration::from_secs(60)),
daily_quota: None, daily_quota: None,
cooldown: None, cooldown: None,
}, },
@ -686,7 +716,7 @@ mod tests {
}], }],
daemon: Default::default(), daemon: Default::default(),
default_warnings: vec![], 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()); let store = Arc::new(SqliteStore::in_memory().unwrap());

View file

@ -13,7 +13,8 @@ pub enum CoreEvent {
session_id: SessionId, session_id: SessionId,
entry_id: EntryId, entry_id: EntryId,
label: String, label: String,
deadline: DateTime<Local>, /// Deadline for session. None means unlimited.
deadline: Option<DateTime<Local>>,
}, },
/// Warning threshold reached /// Warning threshold reached

View file

@ -12,19 +12,25 @@ pub struct SessionPlan {
pub session_id: SessionId, pub session_id: SessionId,
pub entry_id: EntryId, pub entry_id: EntryId,
pub label: String, pub label: String,
pub max_duration: Duration, /// Maximum duration for this session. None means unlimited.
pub max_duration: Option<Duration>,
pub warnings: Vec<WarningThreshold>, pub warnings: Vec<WarningThreshold>,
} }
impl SessionPlan { impl SessionPlan {
/// Compute warning times (as durations after start) /// Compute warning times (as durations after start)
/// Returns empty vec for unlimited sessions.
pub fn warning_times(&self) -> Vec<(u64, Duration)> { 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 self.warnings
.iter() .iter()
.filter(|w| Duration::from_secs(w.seconds_before) < self.max_duration) .filter(|w| Duration::from_secs(w.seconds_before) < max_duration)
.map(|w| { .map(|w| {
let trigger_after = 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) (w.seconds_before, trigger_after)
}) })
.collect() .collect()
@ -46,11 +52,11 @@ pub struct ActiveSession {
/// Monotonic start time (for enforcement) /// Monotonic start time (for enforcement)
pub started_at_mono: MonotonicInstant, pub started_at_mono: MonotonicInstant,
/// Wall-clock deadline (for display) /// Wall-clock deadline (for display). None means unlimited.
pub deadline: DateTime<Local>, pub deadline: Option<DateTime<Local>>,
/// Monotonic deadline (for enforcement) /// Monotonic deadline (for enforcement). None means unlimited.
pub deadline_mono: MonotonicInstant, pub deadline_mono: Option<MonotonicInstant>,
/// Warning thresholds already issued (seconds before expiry) /// Warning thresholds already issued (seconds before expiry)
pub warnings_issued: Vec<u64>, pub warnings_issued: Vec<u64>,
@ -66,8 +72,14 @@ impl ActiveSession {
now: DateTime<Local>, now: DateTime<Local>,
now_mono: MonotonicInstant, now_mono: MonotonicInstant,
) -> Self { ) -> Self {
let deadline = now + chrono::Duration::from_std(plan.max_duration).unwrap(); let (deadline, deadline_mono) = match plan.max_duration {
let deadline_mono = now_mono + 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 { Self {
plan, plan,
@ -87,20 +99,29 @@ impl ActiveSession {
self.state = SessionState::Running; self.state = SessionState::Running;
} }
/// Get time remaining using monotonic time /// Get time remaining using monotonic time. None means unlimited.
pub fn time_remaining(&self, now_mono: MonotonicInstant) -> Duration { pub fn time_remaining(&self, now_mono: MonotonicInstant) -> Option<Duration> {
self.deadline_mono.saturating_duration_until(now_mono) 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 { 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)> { 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 elapsed = now_mono.duration_since(self.started_at_mono);
let remaining = self.time_remaining(now_mono);
self.plan self.plan
.warning_times() .warning_times()
@ -173,7 +194,7 @@ mod tests {
session_id: SessionId::new(), session_id: SessionId::new(),
entry_id: EntryId::new("test"), entry_id: EntryId::new("test"),
label: "Test".into(), label: "Test".into(),
max_duration: Duration::from_secs(duration_secs), max_duration: Some(Duration::from_secs(duration_secs)),
warnings: vec![ warnings: vec![
WarningThreshold { WarningThreshold {
seconds_before: 60, seconds_before: 60,
@ -199,7 +220,7 @@ mod tests {
assert_eq!(session.state, SessionState::Launching); assert_eq!(session.state, SessionState::Launching);
assert!(session.warnings_issued.is_empty()); 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] #[test]
@ -225,7 +246,7 @@ mod tests {
session_id: SessionId::new(), session_id: SessionId::new(),
entry_id: EntryId::new("test"), entry_id: EntryId::new("test"),
label: "Test".into(), label: "Test".into(),
max_duration: Duration::from_secs(30), // 30 seconds max_duration: Some(Duration::from_secs(30)), // 30 seconds
warnings: vec![WarningThreshold { warnings: vec![WarningThreshold {
seconds_before: 60, // 60 second warning - longer than session! seconds_before: 60, // 60 second warning - longer than session!
severity: WarningSeverity::Warn, severity: WarningSeverity::Warn,

View file

@ -148,18 +148,21 @@ impl SharedState {
deadline, deadline,
} => { } => {
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if *deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(*deadline - now).num_seconds().max(0) as u64 let time_remaining = deadline.and_then(|d| {
if d > now {
Some((d - now).num_seconds().max(0) as u64)
} else { } else {
0 Some(0)
}; }
});
self.set_session_state(SessionState::Active { self.set_session_state(SessionState::Active {
session_id: session_id.clone(), session_id: session_id.clone(),
entry_id: entry_id.clone(), entry_id: entry_id.clone(),
entry_name: label.clone(), entry_name: label.clone(),
started_at: std::time::Instant::now(), started_at: std::time::Instant::now(),
time_limit_secs: Some(time_remaining), time_limit_secs: time_remaining,
time_remaining_secs: Some(time_remaining), time_remaining_secs: time_remaining,
paused: false, paused: false,
}); });
} }
@ -208,18 +211,21 @@ impl SharedState {
EventPayload::StateChanged(snapshot) => { EventPayload::StateChanged(snapshot) => {
if let Some(session) = &snapshot.current_session { if let Some(session) = &snapshot.current_session {
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if session.deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(session.deadline - now).num_seconds().max(0) as u64 let time_remaining = session.deadline.and_then(|d| {
if d > now {
Some((d - now).num_seconds().max(0) as u64)
} else { } else {
0 Some(0)
}; }
});
self.set_session_state(SessionState::Active { self.set_session_state(SessionState::Active {
session_id: session.session_id.clone(), session_id: session.session_id.clone(),
entry_id: session.entry_id.clone(), entry_id: session.entry_id.clone(),
entry_name: session.label.clone(), entry_name: session.label.clone(),
started_at: std::time::Instant::now(), started_at: std::time::Instant::now(),
time_limit_secs: Some(time_remaining), time_limit_secs: time_remaining,
time_remaining_secs: Some(time_remaining), time_remaining_secs: time_remaining,
paused: false, paused: false,
}); });
} else { } else {

View file

@ -191,11 +191,14 @@ impl LauncherApp {
shepherd_api::ResponsePayload::LaunchApproved { session_id, deadline } => { shepherd_api::ResponsePayload::LaunchApproved { session_id, deadline } => {
info!(session_id = %session_id, "Launch approved, setting SessionActive"); info!(session_id = %session_id, "Launch approved, setting SessionActive");
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(deadline - now).to_std().ok() let time_remaining = deadline.and_then(|d| {
if d > now {
(d - now).to_std().ok()
} else { } else {
Some(std::time::Duration::ZERO) Some(std::time::Duration::ZERO)
}; }
});
state.set(LauncherState::SessionActive { state.set(LauncherState::SessionActive {
session_id, session_id,
entry_label: entry_id.to_string(), entry_label: entry_id.to_string(),

View file

@ -140,11 +140,14 @@ impl DaemonClient {
ResponsePayload::State(snapshot) => { ResponsePayload::State(snapshot) => {
if let Some(session) = snapshot.current_session { if let Some(session) = snapshot.current_session {
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if session.deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(session.deadline - now).to_std().ok() let time_remaining = session.deadline.and_then(|d| {
if d > now {
(d - now).to_std().ok()
} else { } else {
Some(Duration::ZERO) Some(Duration::ZERO)
}; }
});
self.state.set(LauncherState::SessionActive { self.state.set(LauncherState::SessionActive {
session_id: session.session_id, session_id: session.session_id,
entry_label: session.label, entry_label: session.label,
@ -164,11 +167,14 @@ impl DaemonClient {
} }
ResponsePayload::LaunchApproved { session_id, deadline } => { ResponsePayload::LaunchApproved { session_id, deadline } => {
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(deadline - now).to_std().ok() let time_remaining = deadline.and_then(|d| {
if d > now {
(d - now).to_std().ok()
} else { } else {
Some(Duration::ZERO) Some(Duration::ZERO)
}; }
});
self.state.set(LauncherState::SessionActive { self.state.set(LauncherState::SessionActive {
session_id, session_id,
entry_label: "Starting...".into(), entry_label: "Starting...".into(),

View file

@ -74,11 +74,14 @@ impl SharedState {
} => { } => {
tracing::info!(session_id = %session_id, label = %label, "Session started event"); tracing::info!(session_id = %session_id, label = %label, "Session started event");
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(deadline - now).to_std().ok() let time_remaining = deadline.and_then(|d| {
if d > now {
(d - now).to_std().ok()
} else { } else {
Some(Duration::ZERO) Some(Duration::ZERO)
}; }
});
self.set(LauncherState::SessionActive { self.set(LauncherState::SessionActive {
session_id, session_id,
entry_label: label, entry_label: label,
@ -118,11 +121,14 @@ impl SharedState {
fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) { fn apply_snapshot(&self, snapshot: DaemonStateSnapshot) {
if let Some(session) = snapshot.current_session { if let Some(session) = snapshot.current_session {
let now = chrono::Local::now(); let now = chrono::Local::now();
let time_remaining = if session.deadline > now { // For unlimited sessions (deadline=None), time_remaining is None
(session.deadline - now).to_std().ok() let time_remaining = session.deadline.and_then(|d| {
if d > now {
(d - now).to_std().ok()
} else { } else {
Some(Duration::ZERO) Some(Duration::ZERO)
}; }
});
self.set(LauncherState::SessionActive { self.set(LauncherState::SessionActive {
session_id: session.session_id, session_id: session.session_id,
entry_label: session.label, entry_label: session.label,

View file

@ -24,7 +24,8 @@ pub enum AuditEventType {
session_id: SessionId, session_id: SessionId,
entry_id: EntryId, entry_id: EntryId,
label: String, label: String,
deadline: DateTime<Local>, /// Deadline for session. None means unlimited.
deadline: Option<DateTime<Local>>,
}, },
/// Warning issued /// Warning issued

View file

@ -667,11 +667,11 @@ impl Daemon {
let mut eng = engine.lock().await; let mut eng = engine.lock().await;
match eng.extend_current(by, now_mono, now) { match eng.extend_current(by, now_mono, now) {
Some(new_deadline) => { 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( None => Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::NoActiveSession, "No active session"), ErrorInfo::new(ErrorCode::NoActiveSession, "No active session or session is unlimited"),
), ),
} }
} }

View file

@ -31,7 +31,7 @@ fn make_test_policy() -> Policy {
always: true, always: true,
}, },
limits: LimitsPolicy { limits: LimitsPolicy {
max_run: Duration::from_secs(10), // Short for testing max_run: Some(Duration::from_secs(10)), // Short for testing
daily_quota: None, daily_quota: None,
cooldown: None, cooldown: None,
}, },
@ -52,7 +52,7 @@ fn make_test_policy() -> Policy {
}, },
], ],
default_warnings: vec![], 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 entry_id = EntryId::new("test-game");
let decision = engine.request_launch(&entry_id, Local::now()); 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] #[test]
@ -284,7 +284,7 @@ fn test_config_parsing() {
let policy = parse_config(config).unwrap(); let policy = parse_config(config).unwrap();
assert_eq!(policy.entries.len(), 1); assert_eq!(policy.entries.len(), 1);
assert_eq!(policy.entries[0].id.as_str(), "scummvm"); 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.daily_quota, Some(Duration::from_secs(7200)));
assert_eq!(policy.entries[0].limits.cooldown, Some(Duration::from_secs(300))); assert_eq!(policy.entries[0].limits.cooldown, Some(Duration::from_secs(300)));
assert_eq!(policy.entries[0].warnings.len(), 1); assert_eq!(policy.entries[0].warnings.len(), 1);
@ -308,8 +308,8 @@ fn test_session_extension() {
}; };
engine.start_session(plan, now, now_mono); engine.start_session(plan, now, now_mono);
// Get original deadline // Get original deadline (should be Some for this test)
let original_deadline = engine.current_session().unwrap().deadline; let original_deadline = engine.current_session().unwrap().deadline.expect("Expected deadline");
// Extend by 5 minutes // Extend by 5 minutes
let new_deadline = engine.extend_current(Duration::from_secs(300), now_mono, now); let new_deadline = engine.extend_current(Duration::from_secs(300), now_mono, now);