use std::time::Duration;
use chrono::Timelike;
use keel_events::{EventQueueHandle, KeelInput};
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use tracing::{debug, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatConfig {
pub interval: Duration,
pub prompt: String,
pub active_hours: Option<(u32, u32)>,
pub suppression_token: String,
pub project_id: Option<String>,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(30 * 60), prompt: "Check for anything that needs attention.".to_string(),
active_hours: None,
suppression_token: "heartbeat_ok".to_string(),
project_id: None,
}
}
}
pub struct HeartbeatRunner;
impl HeartbeatRunner {
pub fn spawn(
config: HeartbeatConfig,
queue_handle: EventQueueHandle,
shutdown: watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
Self::run(config, queue_handle, shutdown).await;
})
}
async fn run(
config: HeartbeatConfig,
queue_handle: EventQueueHandle,
mut shutdown: watch::Receiver<bool>,
) {
info!(
interval_secs = config.interval.as_secs(),
"Heartbeat runner started"
);
loop {
tokio::select! {
_ = tokio::time::sleep(config.interval) => {}
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Heartbeat runner shutting down");
return;
}
}
}
if let Some((start, end)) = config.active_hours {
let hour = chrono::Utc::now().hour();
let in_window = if start <= end {
hour >= start && hour < end
} else {
hour >= start || hour < end
};
if !in_window {
debug!(
current_hour = hour,
start,
end,
"Outside active hours, skipping heartbeat"
);
continue;
}
}
let mut input = KeelInput::heartbeat(&config.prompt);
if let Some(ref project_id) = config.project_id {
input = input.with_project(project_id.clone());
}
if let Err(e) = queue_handle.push(input) {
tracing::error!(error = %e, "Failed to push heartbeat input");
return;
}
debug!("Heartbeat fired");
}
}
}