//! Core policy engine use chrono::{DateTime, Local}; use shepherd_api::{ DaemonStateSnapshot, EntryKindTag, EntryView, ReasonCode, SessionEndReason, WarningSeverity, API_VERSION, }; use shepherd_config::{Entry, Policy}; use shepherd_host_api::{HostCapabilities, HostSessionHandle}; use shepherd_store::{AuditEvent, AuditEventType, Store}; use shepherd_util::{EntryId, MonotonicInstant, SessionId}; use std::sync::Arc; use std::time::Duration; use tracing::{debug, info, warn}; use crate::{ActiveSession, CoreEvent, SessionPlan, StopResult}; /// Launch decision from the core engine #[derive(Debug)] pub enum LaunchDecision { Approved(SessionPlan), Denied { reasons: Vec }, } /// Stop decision from the core engine #[derive(Debug)] pub enum StopDecision { Stopped(StopResult), NoActiveSession, } /// The core policy engine pub struct CoreEngine { policy: Policy, store: Arc, capabilities: HostCapabilities, current_session: Option, } impl CoreEngine { /// Create a new core engine pub fn new( policy: Policy, store: Arc, capabilities: HostCapabilities, ) -> Self { info!( entry_count = policy.entries.len(), "Core engine initialized" ); // Log policy load let _ = store.append_audit(AuditEvent::new(AuditEventType::PolicyLoaded { entry_count: policy.entries.len(), })); Self { policy, store, capabilities, current_session: None, } } /// Get current policy pub fn policy(&self) -> &Policy { &self.policy } /// Reload policy pub fn reload_policy(&mut self, policy: Policy) -> CoreEvent { let entry_count = policy.entries.len(); self.policy = policy; let _ = self.store.append_audit(AuditEvent::new(AuditEventType::PolicyLoaded { entry_count, })); info!(entry_count, "Policy reloaded"); CoreEvent::PolicyReloaded { entry_count } } /// List all entries with availability status pub fn list_entries(&self, now: DateTime) -> Vec { self.policy .entries .iter() .map(|entry| self.evaluate_entry(entry, now)) .collect() } /// Evaluate a single entry for availability fn evaluate_entry(&self, entry: &Entry, now: DateTime) -> EntryView { let mut reasons = Vec::new(); let mut enabled = true; // Check if explicitly disabled if entry.disabled { enabled = false; reasons.push(ReasonCode::Disabled { reason: entry.disabled_reason.clone(), }); } // Check host capabilities let kind_tag = entry.kind.tag(); if !self.capabilities.supports_kind(kind_tag) { enabled = false; reasons.push(ReasonCode::UnsupportedKind { kind: kind_tag }); } // Check availability window if !entry.availability.is_available(&now) { enabled = false; reasons.push(ReasonCode::OutsideTimeWindow { next_window_start: None, // TODO: compute next window }); } // Check if another session is active if let Some(session) = &self.current_session { enabled = false; reasons.push(ReasonCode::SessionActive { entry_id: session.plan.entry_id.clone(), remaining: session.time_remaining(MonotonicInstant::now()), }); } // Check cooldown if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id) { if until > now { enabled = false; reasons.push(ReasonCode::CooldownActive { available_at: until }); } } // Check daily quota if let Some(quota) = entry.limits.daily_quota { let today = now.date_naive(); if let Ok(used) = self.store.get_usage(&entry.id, today) { if used >= quota { enabled = false; reasons.push(ReasonCode::QuotaExhausted { used, quota }); } } } // Calculate max run if enabled (None when disabled, Some(None) flattened for unlimited) let max_run_if_started_now = if enabled { self.compute_max_duration(entry, now) } else { None }; EntryView { entry_id: entry.id.clone(), label: entry.label.clone(), icon_ref: entry.icon_ref.clone(), kind_tag, enabled, reasons, max_run_if_started_now, } } /// 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 = Some(match max { Some(m) => m.min(window_remaining), None => window_remaining, }); } // Limit by daily quota remaining if let Some(quota) = entry.limits.daily_quota { let today = now.date_naive(); if let Ok(used) = self.store.get_usage(&entry.id, today) { let remaining = quota.saturating_sub(used); max = Some(match max { Some(m) => m.min(remaining), None => remaining, }); } } max } /// Request to launch an entry pub fn request_launch( &self, entry_id: &EntryId, now: DateTime, ) -> LaunchDecision { // Find entry let entry = match self.policy.get_entry(entry_id) { Some(e) => e, None => { return LaunchDecision::Denied { reasons: vec![ReasonCode::Disabled { reason: Some("Entry not found".into()), }], }; } }; // Evaluate availability let view = self.evaluate_entry(entry, now); if !view.enabled { // Log denial let _ = self.store.append_audit(AuditEvent::new(AuditEventType::LaunchDenied { entry_id: entry_id.clone(), reasons: view.reasons.iter().map(|r| format!("{:?}", r)).collect(), })); return LaunchDecision::Denied { reasons: view.reasons, }; } // Compute session plan let max_duration = view.max_run_if_started_now; let plan = SessionPlan { session_id: SessionId::new(), entry_id: entry_id.clone(), label: entry.label.clone(), max_duration, warnings: entry.warnings.clone(), }; 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) } /// Start a session from an approved plan pub fn start_session( &mut self, plan: SessionPlan, now: DateTime, now_mono: MonotonicInstant, ) -> CoreEvent { let session = ActiveSession::new(plan.clone(), now, now_mono); let event = CoreEvent::SessionStarted { session_id: session.plan.session_id.clone(), entry_id: session.plan.entry_id.clone(), label: session.plan.label.clone(), deadline: session.deadline, }; // Log to audit let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionStarted { session_id: session.plan.session_id.clone(), entry_id: session.plan.entry_id.clone(), label: session.plan.label.clone(), deadline: session.deadline, })); 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); event } /// Attach host handle to current session pub fn attach_host_handle(&mut self, handle: HostSessionHandle) { if let Some(session) = &mut self.current_session { session.attach_handle(handle); } } /// Tick the engine - check for warnings and expiry pub fn tick(&mut self, now_mono: MonotonicInstant) -> Vec { let mut events = Vec::new(); let session = match &mut self.current_session { Some(s) => s, None => return events, }; // Check for pending warnings for (threshold, remaining) in session.pending_warnings(now_mono) { let severity = session .plan .warnings .iter() .find(|w| w.seconds_before == threshold) .map(|w| w.severity) .unwrap_or(WarningSeverity::Warn); let message = session .plan .warnings .iter() .find(|w| w.seconds_before == threshold) .and_then(|w| w.message_template.clone()); session.mark_warning_issued(threshold); // Log to audit let _ = self.store.append_audit(AuditEvent::new(AuditEventType::WarningIssued { session_id: session.plan.session_id.clone(), threshold_seconds: threshold, })); info!( session_id = %session.plan.session_id, threshold_seconds = threshold, remaining_secs = remaining.as_secs(), "Warning issued" ); events.push(CoreEvent::Warning { session_id: session.plan.session_id.clone(), threshold_seconds: threshold, time_remaining: remaining, severity, message, }); } // Check for expiry if session.is_expired(now_mono) && session.state != shepherd_api::SessionState::Expiring && session.state != shepherd_api::SessionState::Ended { session.mark_expiring(); info!( session_id = %session.plan.session_id, "Session expiring" ); events.push(CoreEvent::ExpireDue { session_id: session.plan.session_id.clone(), }); } events } /// Notify that a session has exited pub fn notify_session_exited( &mut self, exit_code: Option, now_mono: MonotonicInstant, now: DateTime, ) -> Option { let session = self.current_session.take()?; let duration = session.duration_so_far(now_mono); let reason = if session.state == shepherd_api::SessionState::Expiring { SessionEndReason::Expired } else { SessionEndReason::ProcessExited { exit_code } }; // Update usage accounting let today = now.date_naive(); let _ = self.store.add_usage(&session.plan.entry_id, today, duration); // Set cooldown if configured if let Some(entry) = self.policy.get_entry(&session.plan.entry_id) { if let Some(cooldown) = entry.limits.cooldown { let until = now + chrono::Duration::from_std(cooldown).unwrap(); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); } } // Log to audit let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { session_id: session.plan.session_id.clone(), entry_id: session.plan.entry_id.clone(), reason: reason.clone(), duration, })); info!( session_id = %session.plan.session_id, entry_id = %session.plan.entry_id, duration_secs = duration.as_secs(), reason = ?reason, "Session ended" ); Some(CoreEvent::SessionEnded { session_id: session.plan.session_id, entry_id: session.plan.entry_id, reason, duration, }) } /// Stop the current session pub fn stop_current( &mut self, reason: SessionEndReason, now_mono: MonotonicInstant, now: DateTime, ) -> StopDecision { let session = match self.current_session.take() { Some(s) => s, None => return StopDecision::NoActiveSession, }; let duration = session.duration_so_far(now_mono); // Update usage accounting let today = now.date_naive(); let _ = self.store.add_usage(&session.plan.entry_id, today, duration); // Set cooldown if configured if let Some(entry) = self.policy.get_entry(&session.plan.entry_id) { if let Some(cooldown) = entry.limits.cooldown { let until = now + chrono::Duration::from_std(cooldown).unwrap(); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); } } // Log to audit let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { session_id: session.plan.session_id.clone(), entry_id: session.plan.entry_id.clone(), reason: reason.clone(), duration, })); info!( session_id = %session.plan.session_id, reason = ?reason, "Session stopped" ); StopDecision::Stopped(StopResult { session_id: session.plan.session_id, entry_id: session.plan.entry_id, reason, duration, }) } /// Get current daemon state snapshot pub fn get_state(&self) -> DaemonStateSnapshot { let current_session = self.current_session.as_ref().map(|s| { s.to_session_info(MonotonicInstant::now()) }); // Build entry views for the snapshot let entries = self.list_entries(Local::now()); DaemonStateSnapshot { api_version: API_VERSION, policy_loaded: true, current_session, entry_count: self.policy.entries.len(), entries, } } /// Get current session reference pub fn current_session(&self) -> Option<&ActiveSession> { self.current_session.as_ref() } /// Get mutable current session reference pub fn current_session_mut(&mut self) -> Option<&mut ActiveSession> { self.current_session.as_mut() } /// Check if a session is active pub fn has_active_session(&self) -> bool { self.current_session.is_some() } /// Extend current session (admin action) /// Only works for sessions with a deadline (not unlimited sessions). pub fn extend_current( &mut self, by: Duration, now_mono: MonotonicInstant, now: DateTime, ) -> Option> { let session = self.current_session.as_mut()?; // 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, })); info!( session_id = %session.plan.session_id, extended_by_secs = by.as_secs(), new_deadline = %new_deadline, "Session extended" ); Some(new_deadline) } } #[cfg(test)] mod tests { use super::*; use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy}; use shepherd_api::EntryKind; use shepherd_store::SqliteStore; use std::collections::HashMap; fn make_test_policy() -> Policy { Policy { daemon: Default::default(), entries: vec![Entry { id: EntryId::new("test-game"), label: "Test Game".into(), icon_ref: None, kind: EntryKind::Process { argv: vec!["game".into()], env: HashMap::new(), cwd: None, }, availability: AvailabilityPolicy { windows: vec![], always: true, }, limits: LimitsPolicy { max_run: Some(Duration::from_secs(300)), daily_quota: None, cooldown: None, }, warnings: vec![], disabled: false, disabled_reason: None, }], default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), } } #[test] fn test_list_entries() { let policy = make_test_policy(); let store = Arc::new(SqliteStore::in_memory().unwrap()); let caps = HostCapabilities::minimal(); let engine = CoreEngine::new(policy, store, caps); let entries = engine.list_entries(Local::now()); assert_eq!(entries.len(), 1); assert!(entries[0].enabled); } #[test] fn test_launch_approval() { let policy = make_test_policy(); let store = Arc::new(SqliteStore::in_memory().unwrap()); let caps = HostCapabilities::minimal(); let engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test-game"); let decision = engine.request_launch(&entry_id, Local::now()); assert!(matches!(decision, LaunchDecision::Approved(_))); } #[test] fn test_session_blocks_new_launch() { let policy = make_test_policy(); let store = Arc::new(SqliteStore::in_memory().unwrap()); let caps = HostCapabilities::minimal(); let mut engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test-game"); let now = Local::now(); let now_mono = MonotonicInstant::now(); // Launch first session if let LaunchDecision::Approved(plan) = engine.request_launch(&entry_id, now) { engine.start_session(plan, now, now_mono); } // Try to launch again - should be denied let decision = engine.request_launch(&entry_id, now); assert!(matches!(decision, LaunchDecision::Denied { .. })); } #[test] fn test_tick_warnings() { let policy = Policy { entries: vec![Entry { id: EntryId::new("test"), label: "Test".into(), icon_ref: None, kind: EntryKind::Process { argv: vec!["test".into()], env: HashMap::new(), cwd: None, }, availability: AvailabilityPolicy { windows: vec![], always: true, }, limits: LimitsPolicy { max_run: Some(Duration::from_secs(120)), // 2 minutes daily_quota: None, cooldown: None, }, warnings: vec![shepherd_api::WarningThreshold { seconds_before: 60, severity: WarningSeverity::Warn, message_template: Some("1 minute left".into()), }], disabled: false, disabled_reason: None, }], daemon: Default::default(), default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); let caps = HostCapabilities::minimal(); let mut engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test"); let now = Local::now(); let now_mono = MonotonicInstant::now(); // Start session if let LaunchDecision::Approved(plan) = engine.request_launch(&entry_id, now) { engine.start_session(plan, now, now_mono); } // No warnings initially let events = engine.tick(now_mono); assert!(events.is_empty()); // At 70 seconds (10 seconds past warning threshold), warning should fire let later = now_mono + Duration::from_secs(70); let events = engine.tick(later); assert_eq!(events.len(), 1); assert!(matches!(events[0], CoreEvent::Warning { threshold_seconds: 60, .. })); // Warning shouldn't fire twice let events = engine.tick(later); assert!(events.is_empty()); } #[test] fn test_session_expiry() { let policy = Policy { entries: vec![Entry { id: EntryId::new("test"), label: "Test".into(), icon_ref: None, kind: EntryKind::Process { argv: vec!["test".into()], env: HashMap::new(), cwd: None, }, availability: AvailabilityPolicy { windows: vec![], always: true, }, limits: LimitsPolicy { max_run: Some(Duration::from_secs(60)), daily_quota: None, cooldown: None, }, warnings: vec![], disabled: false, disabled_reason: None, }], daemon: Default::default(), default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); let caps = HostCapabilities::minimal(); let mut engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test"); let now = Local::now(); let now_mono = MonotonicInstant::now(); // Start session if let LaunchDecision::Approved(plan) = engine.request_launch(&entry_id, now) { engine.start_session(plan, now, now_mono); } // At 61 seconds, should be expired let later = now_mono + Duration::from_secs(61); let events = engine.tick(later); assert_eq!(events.len(), 1); assert!(matches!(events[0], CoreEvent::ExpireDue { .. })); } }