mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
* feat(timezone): add timezone-aware session context (#661) All timestamps were UTC-only, causing daily logs to split at UTC midnight, cron schedules to fire in UTC, and no quiet hours for heartbeat. This adds timezone as a per-session property flowing from the client. Key changes: - New `src/timezone.rs` module with resolution chain, parsing, and detection - `IncomingMessage` carries optional timezone from client - `JobContext.user_timezone` flows timezone to tools - `next_cron_fire()` accepts timezone for schedule evaluation - `Trigger::Cron` stores optional timezone (backward-compatible) - Workspace gains `_tz` variants for daily logs and system prompt - Heartbeat supports quiet hours (`HEARTBEAT_QUIET_START/END`) - Web frontend sends `Intl.DateTimeFormat().resolvedOptions().timeZone` - REPL auto-detects system timezone - `DEFAULT_TIMEZONE` env var / settings for server-wide default Storage stays UTC. Conversion happens at display boundaries. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): address review feedback on timezone-aware sessions - Validate quiet hours values (0-23) in HeartbeatConfig::resolve() - Fall back to settings values when env vars are unset for quiet hours - Validate IANA timezone strings in routine_create/update with parse_timezone - Add timezone field to routine_create tool schema - Allow standalone timezone update on cron routines without changing schedule - Return path from append_daily_log_tz to avoid TOCTOU race at midnight - Delegate append_daily_log to append_daily_log_tz(entry, UTC) to avoid drift - Preserve timezone through approval flow via PendingApproval.user_timezone - Improve test_today_in_tz to not depend on hardcoded year - Add 3 regression tests for quiet hours config validation Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in routine.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): address second round of review feedback - Remove .claude/scheduled_tasks.lock from repo and add to .gitignore - Store resolved timezone (not raw message.timezone) in PendingApproval - Carry forward user_timezone through chained approvals in thread_ops - Wire quiet_hours_start/end from config to HeartbeatRunner - Support X-Timezone header as fallback in chat_send_handler [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): include user's local time in time tool response The time tool's "now" operation now returns local_iso and timezone fields based on ctx.user_timezone, so the LLM can report time in the user's timezone instead of always UTC. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in time.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): address Copilot review round 3 — validation, deterministic tests, schema fixes - Validate DEFAULT_TIMEZONE and HEARTBEAT_TIMEZONE at config load time - Add timezone field to HeartbeatSettings and config::HeartbeatConfig - Wire heartbeat timezone from config through agent_loop to HeartbeatRunner - Add timezone to routine_update tool schema (was accepted but not advertised) - Error on schedule/timezone update for non-cron routines - Validate timezone in Trigger::from_db (coerce invalid to None with warning) - Validate timezone in approval path (thread_ops.rs) before overwriting - Time tool always includes timezone/local_iso fields (fallback to UTC) - Make quiet hours tests deterministic using current UTC hour - Add regression tests for config validation [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
145 lines
5.1 KiB
Rust
145 lines
5.1 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.
|
|
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>,
|
|
/// 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 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,
|
|
quiet_hours_start: None,
|
|
quiet_hours_end: None,
|
|
timezone: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HeartbeatConfig {
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
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()),
|
|
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"));
|
|
}
|
|
}
|