Implement time-unrestricted activities
This commit is contained in:
parent
d2bebd39a6
commit
d449a7adff
16 changed files with 267 additions and 125 deletions
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue