mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(heartbeat): fire_at time-of-day scheduling with IANA timezone support - HEARTBEAT_FIRE_AT=HH:MM — fire heartbeat at a specific time of day instead of on a rolling interval; format is 24h HH:MM (e.g. "14:00") - HEARTBEAT_TIMEZONE=Region/City — IANA timezone name for fire_at (e.g. "Pacific/Auckland", "America/New_York"). Defaults to UTC. - When fire_at is set, interval_secs is ignored - Config also readable from settings.toml [heartbeat] section Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(heartbeat): wire fire_at + timezone into HeartbeatConfig runner Missed file from heartbeat scheduling commit. HeartbeatConfig struct in agent/heartbeat.rs now carries fire_at: Option<NaiveTime> and timezone: Tz so the runner can schedule against a fixed time of day. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: add chrono-tz dependency for heartbeat fire_at timezone support The chrono-tz crate was used in the heartbeat fire_at commits but its Cargo.toml entry was lost during rebase conflict resolution. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: rustfmt fix for chained method call Co-Authored-By: Claude Opus 4.6 <[email protected]> * test(heartbeat): add fire_at scheduling and DST safety tests - test_default_config_has_no_fire_at: interval-based default unchanged - test_with_fire_at_builder: builder sets time and timezone - test_duration_until_next_fire_is_bounded: result always 1s–24h - test_duration_until_next_fire_dst_timezone_no_panic: US Eastern DST - test_resolved_tz_defaults_to_utc: missing timezone falls back to UTC - test_resolved_tz_parses_iana: IANA string resolves correctly Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(heartbeat): restore drift-free interval, add settings.json fallback for fire_at - Interval path: restore tokio::time::interval (drift-free) instead of tokio::time::sleep which drifts by loop body execution time - fire_at config: fall back to settings.heartbeat.fire_at when HEARTBEAT_FIRE_AT env var is not set, consistent with other settings Addresses Gemini Code Assist review feedback. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: IronClaw <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
162 lines
5.9 KiB
Rust
162 lines
5.9 KiB
Rust
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
|
use crate::error::ConfigError;
|
|
use crate::settings::Settings;
|
|
|
|
/// Heartbeat configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HeartbeatConfig {
|
|
/// Whether heartbeat is enabled.
|
|
pub enabled: bool,
|
|
/// Interval between heartbeat checks in seconds (used when fire_at is not set).
|
|
pub interval_secs: u64,
|
|
/// Channel to notify on heartbeat findings.
|
|
pub notify_channel: Option<String>,
|
|
/// User ID to notify on heartbeat findings.
|
|
pub notify_user: Option<String>,
|
|
/// Fixed time-of-day to fire (HH:MM, 24h). When set, interval_secs is ignored.
|
|
pub fire_at: Option<chrono::NaiveTime>,
|
|
/// Hour (0-23) when quiet hours start.
|
|
pub quiet_hours_start: Option<u32>,
|
|
/// Hour (0-23) when quiet hours end.
|
|
pub quiet_hours_end: Option<u32>,
|
|
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
|
pub timezone: Option<String>,
|
|
}
|
|
|
|
impl Default for HeartbeatConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
interval_secs: 1800, // 30 minutes
|
|
notify_channel: None,
|
|
notify_user: None,
|
|
fire_at: None,
|
|
quiet_hours_start: None,
|
|
quiet_hours_end: None,
|
|
timezone: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HeartbeatConfig {
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
let fire_at_str =
|
|
optional_env("HEARTBEAT_FIRE_AT")?.or_else(|| settings.heartbeat.fire_at.clone());
|
|
let fire_at = fire_at_str
|
|
.map(|s| {
|
|
chrono::NaiveTime::parse_from_str(&s, "%H:%M").map_err(|e| {
|
|
ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_FIRE_AT".to_string(),
|
|
message: format!("must be HH:MM (24h), e.g. '14:00': {e}"),
|
|
}
|
|
})
|
|
})
|
|
.transpose()?;
|
|
|
|
Ok(Self {
|
|
enabled: parse_bool_env("HEARTBEAT_ENABLED", settings.heartbeat.enabled)?,
|
|
interval_secs: parse_optional_env(
|
|
"HEARTBEAT_INTERVAL_SECS",
|
|
settings.heartbeat.interval_secs,
|
|
)?,
|
|
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
|
|
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
|
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
|
.or_else(|| settings.heartbeat.notify_user.clone()),
|
|
fire_at,
|
|
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
|
|
.or(settings.heartbeat.quiet_hours_start)
|
|
.map(|h| {
|
|
if h > 23 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_QUIET_START".into(),
|
|
message: "must be 0-23".into(),
|
|
});
|
|
}
|
|
Ok(h)
|
|
})
|
|
.transpose()?,
|
|
quiet_hours_end: parse_option_env::<u32>("HEARTBEAT_QUIET_END")?
|
|
.or(settings.heartbeat.quiet_hours_end)
|
|
.map(|h| {
|
|
if h > 23 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_QUIET_END".into(),
|
|
message: "must be 0-23".into(),
|
|
});
|
|
}
|
|
Ok(h)
|
|
})
|
|
.transpose()?,
|
|
timezone: {
|
|
let tz = optional_env("HEARTBEAT_TIMEZONE")?
|
|
.or_else(|| settings.heartbeat.timezone.clone());
|
|
if let Some(ref tz_str) = tz
|
|
&& crate::timezone::parse_timezone(tz_str).is_none()
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_TIMEZONE".into(),
|
|
message: format!("invalid IANA timezone: '{tz_str}'"),
|
|
});
|
|
}
|
|
tz
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_quiet_hours_settings_fallback() {
|
|
// When env vars are not set, settings values should be used
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.quiet_hours_start = Some(22);
|
|
settings.heartbeat.quiet_hours_end = Some(6);
|
|
|
|
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.quiet_hours_start, Some(22));
|
|
assert_eq!(config.quiet_hours_end, Some(6));
|
|
}
|
|
|
|
#[test]
|
|
fn test_quiet_hours_rejects_invalid_hour() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.quiet_hours_start = Some(24);
|
|
|
|
let result = HeartbeatConfig::resolve(&settings);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_quiet_hours_accepts_boundary_values() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.quiet_hours_start = Some(0);
|
|
settings.heartbeat.quiet_hours_end = Some(23);
|
|
|
|
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.quiet_hours_start, Some(0));
|
|
assert_eq!(config.quiet_hours_end, Some(23));
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_timezone_rejects_invalid() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.timezone = Some("Fake/Zone".to_string());
|
|
|
|
let result = HeartbeatConfig::resolve(&settings);
|
|
assert!(result.is_err(), "invalid IANA timezone should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_timezone_accepts_valid() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.timezone = Some("America/New_York".to_string());
|
|
|
|
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.timezone.as_deref(), Some("America/New_York"));
|
|
}
|
|
}
|