diff --git a/.gitignore b/.gitignore index 9397a220..17bdb86d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ .env.* !.env.example -# Claude Code worktrees +# Claude Code worktrees and lock files .claude/worktrees/ +.claude/scheduled_tasks.lock # Sidecar tool data .sidecar/ diff --git a/Cargo.lock b/Cargo.lock index c6ad733a..31998df0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -864,6 +864,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2872,6 +2882,7 @@ dependencies = [ "bollard", "bytes", "chrono", + "chrono-tz", "clap", "clap_complete", "cron", @@ -2890,6 +2901,7 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-util", + "iana-time-zone", "insta", "libsql", "lru", @@ -3892,6 +3904,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -3966,6 +3987,15 @@ dependencies = [ "uncased", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index 75d42f63..6747bbd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,8 @@ toml = "0.8" # Core types uuid = { version = "1", features = ["v4", "v5", "serde"] } chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" +iana-time-zone = "0.1" rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] } rust_decimal_macros = "1" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index f76afc75..0e6f508c 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -356,6 +356,12 @@ impl Agent { if let Some(workspace) = self.workspace() { let mut config = AgentHeartbeatConfig::default() .with_interval(std::time::Duration::from_secs(hb_config.interval_secs)); + config.quiet_hours_start = hb_config.quiet_hours_start; + config.quiet_hours_end = hb_config.quiet_hours_end; + config.timezone = hb_config + .timezone + .clone() + .or_else(|| Some(self.config.default_timezone.clone())); if let (Some(user), Some(channel)) = (&hb_config.notify_user, &hb_config.notify_channel) { diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 2834777a..85c24763 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -50,8 +50,18 @@ impl Agent { // Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.) // In group chats, MEMORY.md is excluded to prevent leaking personal context. + // Resolve the user's timezone + let user_tz = crate::timezone::resolve_timezone( + message.timezone.as_deref(), + None, // user setting lookup can be added later + &self.config.default_timezone, + ); + let system_prompt = if let Some(ws) = self.workspace() { - match ws.system_prompt_for_context(is_group_chat).await { + match ws + .system_prompt_for_context_tz(is_group_chat, user_tz) + .await + { Ok(prompt) if !prompt.is_empty() => Some(prompt), Ok(_) => None, Err(e) => { @@ -130,6 +140,7 @@ impl Agent { let mut job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); job_ctx.http_interceptor = self.deps.http_interceptor.clone(); + job_ctx.user_timezone = user_tz.name().to_string(); // Build system prompts once for this turn. Two variants: with tools // (normal iterations) and without (force_text final iteration). @@ -785,6 +796,7 @@ impl Agent { tool_call_id: tc.id.clone(), context_messages: context_messages.clone(), deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(), + user_timezone: Some(user_tz.name().to_string()), }; return Ok(AgenticLoopResult::NeedApproval { pending }); @@ -1146,6 +1158,7 @@ mod tests { max_actions_per_hour: None, max_tool_iterations: 50, auto_approve_tools: false, + default_timezone: "UTC".to_string(), }, deps, Arc::new(ChannelManager::new()), @@ -1248,6 +1261,7 @@ mod tests { arguments: serde_json::json!({"message": "done"}), }, ], + user_timezone: None, }; let json = serde_json::to_string(&pending).expect("serialize"); @@ -1900,6 +1914,7 @@ mod tests { max_actions_per_hour: None, max_tool_iterations, auto_approve_tools: true, + default_timezone: "UTC".to_string(), }, deps, Arc::new(ChannelManager::new()), @@ -2015,6 +2030,7 @@ mod tests { max_actions_per_hour: None, max_tool_iterations: max_iter, auto_approve_tools: true, + default_timezone: "UTC".to_string(), }, deps, Arc::new(ChannelManager::new()), diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index a034a1a1..5c99d01e 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -48,6 +48,12 @@ pub struct HeartbeatConfig { pub notify_user_id: Option, /// Channel to notify on heartbeat findings. pub notify_channel: Option, + /// Hour (0-23) when quiet hours start. + pub quiet_hours_start: Option, + /// Hour (0-23) when quiet hours end. + pub quiet_hours_end: Option, + /// Timezone for quiet hours evaluation (IANA name). + pub timezone: Option, } impl Default for HeartbeatConfig { @@ -58,6 +64,9 @@ impl Default for HeartbeatConfig { max_failures: 3, notify_user_id: None, notify_channel: None, + quiet_hours_start: None, + quiet_hours_end: None, + timezone: None, } } } @@ -75,6 +84,26 @@ impl HeartbeatConfig { self } + /// Check whether the current time falls within configured quiet hours. + pub fn is_quiet_hours(&self) -> bool { + use chrono::Timelike; + let (Some(start), Some(end)) = (self.quiet_hours_start, self.quiet_hours_end) else { + return false; + }; + let tz = self + .timezone + .as_deref() + .and_then(crate::timezone::parse_timezone) + .unwrap_or(chrono_tz::UTC); + let now_hour = crate::timezone::now_in_tz(tz).hour(); + if start <= end { + now_hour >= start && now_hour < end + } else { + // Wraps midnight, e.g. 22..06 + now_hour >= start || now_hour < end + } + } + /// Set the notification target. pub fn with_notify(mut self, user_id: impl Into, channel: impl Into) -> Self { self.notify_user_id = Some(user_id.into()); @@ -162,6 +191,12 @@ impl HeartbeatRunner { loop { interval.tick().await; + // Skip during quiet hours + if self.config.is_quiet_hours() { + tracing::debug!("Heartbeat skipped: quiet hours"); + continue; + } + // Run memory hygiene in the background so it never delays the // heartbeat checklist. Failures are logged inside run_if_due. let hygiene_workspace = Arc::clone(&self.workspace); @@ -532,6 +567,83 @@ mod tests { assert!(!is_effectively_empty(content)); } + // ==================== quiet hours ==================== + + #[test] + fn test_quiet_hours_inside() { + use chrono::{Timelike, Utc}; + + let now_utc = Utc::now(); + let hour = now_utc.hour(); + let start = hour; + let end = (hour + 1) % 24; + + let config = HeartbeatConfig { + quiet_hours_start: Some(start), + quiet_hours_end: Some(end), + timezone: Some("UTC".to_string()), + ..HeartbeatConfig::default() + }; + // Current UTC hour is inside [start, end) by construction + assert!(config.is_quiet_hours()); + } + + #[test] + fn test_quiet_hours_outside() { + use chrono::{Timelike, Utc}; + + let now_utc = Utc::now(); + let hour = now_utc.hour(); + let start = (hour + 1) % 24; + let end = (hour + 2) % 24; + + let config = HeartbeatConfig { + quiet_hours_start: Some(start), + quiet_hours_end: Some(end), + timezone: Some("UTC".to_string()), + ..HeartbeatConfig::default() + }; + // Current UTC hour is outside [start, end) by construction + assert!(!config.is_quiet_hours()); + } + + #[test] + fn test_quiet_hours_wraparound_excludes_now() { + use chrono::{Timelike, Utc}; + + let now_utc = Utc::now(); + let hour = now_utc.hour(); + // Window covers all hours except the current one + let start = (hour + 1) % 24; + let end = hour; + + let config = HeartbeatConfig { + quiet_hours_start: Some(start), + quiet_hours_end: Some(end), + timezone: Some("UTC".to_string()), + ..HeartbeatConfig::default() + }; + assert!(!config.is_quiet_hours()); + } + + #[test] + fn test_quiet_hours_none_configured() { + let config = HeartbeatConfig::default(); + assert!(!config.is_quiet_hours()); + } + + #[test] + fn test_quiet_hours_same_start_end() { + let config = HeartbeatConfig { + quiet_hours_start: Some(10), + quiet_hours_end: Some(10), + timezone: Some("UTC".to_string()), + ..HeartbeatConfig::default() + }; + // start == end means zero-width window, should be false + assert!(!config.is_quiet_hours()); + } + #[test] fn test_spawn_heartbeat_accepts_store_param() { // Regression: spawn_heartbeat must accept an optional Database store diff --git a/src/agent/routine.rs b/src/agent/routine.rs index 4cf691be..fdd61012 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -57,7 +57,11 @@ pub struct Routine { #[serde(tag = "type", rename_all = "snake_case")] pub enum Trigger { /// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h"). - Cron { schedule: String }, + Cron { + schedule: String, + #[serde(default)] + timezone: Option, + }, /// Fire when a channel message matches a pattern. Event { /// Optional channel filter (e.g. "telegram", "slack"). @@ -99,7 +103,21 @@ impl Trigger { field: "schedule".into(), })? .to_string(); - Ok(Trigger::Cron { schedule }) + let timezone = config + .get("timezone") + .and_then(|v| v.as_str()) + .and_then(|tz| { + if crate::timezone::parse_timezone(tz).is_some() { + Some(tz.to_string()) + } else { + tracing::warn!( + "Ignoring invalid timezone '{}' from DB for cron trigger", + tz + ); + None + } + }); + Ok(Trigger::Cron { schedule, timezone }) } "event" => { let pattern = config @@ -137,7 +155,10 @@ impl Trigger { /// Serialize trigger-specific config to JSON for DB storage. pub fn to_config_json(&self) -> serde_json::Value { match self { - Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }), + Trigger::Cron { schedule, timezone } => serde_json::json!({ + "schedule": schedule, + "timezone": timezone, + }), Trigger::Event { channel, pattern } => serde_json::json!({ "pattern": pattern, "channel": channel, @@ -415,12 +436,25 @@ pub fn content_hash(content: &str) -> u64 { } /// Parse a cron expression and compute the next fire time from now. -pub fn next_cron_fire(schedule: &str) -> Result>, RoutineError> { +/// +/// When `timezone` is provided and valid, the schedule is evaluated in that +/// timezone and the result is converted back to UTC. Otherwise UTC is used. +pub fn next_cron_fire( + schedule: &str, + timezone: Option<&str>, +) -> Result>, RoutineError> { let cron_schedule = cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron { reason: e.to_string(), })?; - Ok(cron_schedule.upcoming(Utc).next()) + if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) { + Ok(cron_schedule + .upcoming(tz) + .next() + .map(|dt| dt.with_timezone(&Utc))) + } else { + Ok(cron_schedule.upcoming(Utc).next()) + } } #[cfg(test)] @@ -433,10 +467,11 @@ mod tests { fn test_trigger_roundtrip() { let trigger = Trigger::Cron { schedule: "0 9 * * MON-FRI".to_string(), + timezone: None, }; let json = trigger.to_config_json(); let parsed = Trigger::from_db("cron", json).expect("parse cron"); - assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI")); + assert!(matches!(parsed, Trigger::Cron { schedule, .. } if schedule == "0 9 * * MON-FRI")); } #[test] @@ -509,16 +544,58 @@ mod tests { #[test] fn test_next_cron_fire_valid() { // Every minute should always have a next fire - let next = next_cron_fire("* * * * * *").expect("valid cron"); + let next = next_cron_fire("* * * * * *", None).expect("valid cron"); assert!(next.is_some()); } #[test] fn test_next_cron_fire_invalid() { - let result = next_cron_fire("not a cron"); + let result = next_cron_fire("not a cron", None); assert!(result.is_err()); } + #[test] + fn test_trigger_cron_timezone_roundtrip() { + let trigger = Trigger::Cron { + schedule: "0 9 * * MON-FRI".to_string(), + timezone: Some("America/New_York".to_string()), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("cron", json).expect("parse cron"); + assert!(matches!(parsed, Trigger::Cron { schedule, timezone } + if schedule == "0 9 * * MON-FRI" + && timezone.as_deref() == Some("America/New_York"))); + } + + #[test] + fn test_trigger_cron_no_timezone_backward_compat() { + let json = serde_json::json!({"schedule": "0 9 * * *"}); + let parsed = Trigger::from_db("cron", json).expect("parse cron"); + assert!(matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none())); + } + + #[test] + fn test_trigger_cron_invalid_timezone_coerced_to_none() { + let json = serde_json::json!({"schedule": "0 9 * * *", "timezone": "Fake/Zone"}); + let parsed = Trigger::from_db("cron", json).expect("parse cron"); + assert!( + matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()), + "invalid timezone should be coerced to None" + ); + } + + #[test] + fn test_next_cron_fire_with_timezone() { + let next_utc = next_cron_fire("0 0 9 * * * *", None) + .expect("valid cron") + .expect("has next"); + let next_est = next_cron_fire("0 0 9 * * * *", Some("America/New_York")) + .expect("valid cron") + .expect("has next"); + // EST is UTC-5 (or EDT UTC-4), so the UTC result should differ + assert_ne!(next_utc, next_est, "timezone should shift the fire time"); + } + #[test] fn test_guardrails_default() { let g = RoutineGuardrails::default(); @@ -531,7 +608,8 @@ mod tests { fn test_trigger_type_tag() { assert_eq!( Trigger::Cron { - schedule: String::new() + schedule: String::new(), + timezone: None, } .type_tag(), "cron" diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index da22ffc1..fe2b95d5 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -170,7 +170,7 @@ impl RoutineEngine { continue; } - let detail = if let Trigger::Cron { ref schedule } = routine.trigger { + let detail = if let Trigger::Cron { ref schedule, .. } = routine.trigger { Some(schedule.clone()) } else { None @@ -380,8 +380,12 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) // Update routine runtime state let now = Utc::now(); - let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger { - next_cron_fire(schedule).unwrap_or(None) + let next_fire = if let Trigger::Cron { + ref schedule, + ref timezone, + } = routine.trigger + { + next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None) } else { None }; diff --git a/src/agent/session.rs b/src/agent/session.rs index 5dee8b47..a051ffea 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -164,6 +164,10 @@ pub struct PendingApproval { /// executed yet when approval was requested. #[serde(default)] pub deferred_tool_calls: Vec, + /// User timezone at the time the approval was requested, so it persists + /// through the approval flow even if the approval message lacks timezone. + #[serde(default)] + pub user_timezone: Option, } /// A conversation thread within a session. @@ -976,6 +980,7 @@ mod tests { tool_call_id: "call_123".to_string(), context_messages: vec![ChatMessage::user("do it")], deferred_tool_calls: vec![], + user_timezone: None, }; thread.await_approval(approval); @@ -1001,6 +1006,7 @@ mod tests { tool_call_id: "call_456".to_string(), context_messages: vec![], deferred_tool_calls: vec![], + user_timezone: None, }; thread.await_approval(approval); diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 954c0f02..a2001e4c 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -746,6 +746,16 @@ impl Agent { let mut job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); job_ctx.http_interceptor = self.deps.http_interceptor.clone(); + // Prefer a valid timezone from the approval message, fall back to the + // resolved timezone stored when the approval was originally requested. + let tz_candidate = message + .timezone + .as_deref() + .filter(|tz| crate::timezone::parse_timezone(tz).is_some()) + .or(pending.user_timezone.as_deref()); + if let Some(tz) = tz_candidate { + job_ctx.user_timezone = tz.to_string(); + } let _ = self .channels @@ -1111,6 +1121,8 @@ impl Agent { tool_call_id: tc.id.clone(), context_messages: context_messages.clone(), deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(), + // Carry forward the resolved timezone from the original pending approval + user_timezone: pending.user_timezone.clone(), }; let request_id = new_pending.request_id; diff --git a/src/channels/channel.rs b/src/channels/channel.rs index 1160c411..3ab5c1f6 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -79,6 +79,8 @@ pub struct IncomingMessage { pub received_at: DateTime, /// Channel-specific metadata. pub metadata: serde_json::Value, + /// IANA timezone string from the client (e.g. "America/New_York"). + pub timezone: Option, /// File or media attachments on this message. pub attachments: Vec, } @@ -99,6 +101,7 @@ impl IncomingMessage { thread_id: None, received_at: Utc::now(), metadata: serde_json::Value::Null, + timezone: None, attachments: Vec::new(), } } @@ -121,6 +124,12 @@ impl IncomingMessage { self } + /// Set the client timezone. + pub fn with_timezone(mut self, tz: impl Into) -> Self { + self.timezone = Some(tz.into()); + self + } + /// Set attachments. pub fn with_attachments(mut self, attachments: Vec) -> Self { self.attachments = attachments; @@ -454,4 +463,10 @@ mod tests { panic!("expected ToolCompleted variant"); } } + + #[test] + fn test_incoming_message_with_timezone() { + let msg = IncomingMessage::new("test", "user1", "hello").with_timezone("America/New_York"); + assert_eq!(msg.timezone.as_deref(), Some("America/New_York")); + } } diff --git a/src/channels/repl.rs b/src/channels/repl.rs index f4cc7d3f..9031aa4e 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -297,9 +297,11 @@ impl Channel for ReplChannel { let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false)); std::thread::spawn(move || { + let sys_tz = crate::timezone::detect_system_timezone().name().to_string(); + // Single message mode: send it and return if let Some(msg) = single_message { - let incoming = IncomingMessage::new("repl", "default", &msg); + let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz); let _ = tx.blocking_send(incoming); return; } @@ -361,7 +363,8 @@ impl Channel for ReplChannel { "/quit" | "/exit" => { // Forward shutdown command so the agent loop exits even // when other channels (e.g. web gateway) are still active. - let msg = IncomingMessage::new("repl", "default", "/quit"); + let msg = IncomingMessage::new("repl", "default", "/quit") + .with_timezone(&sys_tz); let _ = tx.blocking_send(msg); break; } @@ -382,7 +385,8 @@ impl Channel for ReplChannel { _ => {} } - let msg = IncomingMessage::new("repl", "default", line); + let msg = + IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz); if tx.blocking_send(msg).is_err() { break; } @@ -390,20 +394,23 @@ impl Channel for ReplChannel { Err(ReadlineError::Interrupted) => { if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) { // Esc: interrupt current operation and keep REPL open. - let msg = IncomingMessage::new("repl", "default", "/interrupt"); + let msg = IncomingMessage::new("repl", "default", "/interrupt") + .with_timezone(&sys_tz); if tx.blocking_send(msg).is_err() { break; } } else { // Ctrl+C (VINTR): request graceful shutdown. - let msg = IncomingMessage::new("repl", "default", "/quit"); + let msg = IncomingMessage::new("repl", "default", "/quit") + .with_timezone(&sys_tz); let _ = tx.blocking_send(msg); break; } } Err(ReadlineError::Eof) => { // Ctrl+D: send /quit so the agent loop runs graceful shutdown - let msg = IncomingMessage::new("repl", "default", "/quit"); + let msg = + IncomingMessage::new("repl", "default", "/quit").with_timezone(&sys_tz); let _ = tx.blocking_send(msg); break; } diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index 88abfc68..d7c4f764 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -264,7 +264,7 @@ pub async fn routines_runs_handler( /// Convert a Routine to the trimmed RoutineInfo for list display. fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { let (trigger_type, trigger_summary) = match &r.trigger { - crate::agent::routine::Trigger::Cron { schedule } => { + crate::agent::routine::Trigger::Cron { schedule, .. } => { ("cron".to_string(), format!("cron: {}", schedule)) } crate::agent::routine::Trigger::Event { diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 2f3b2a5b..1c6e7f85 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -610,6 +610,7 @@ async fn oauth_callback_handler( async fn chat_send_handler( State(state): State>, + headers: axum::http::HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { tracing::debug!( @@ -626,6 +627,14 @@ async fn chat_send_handler( } let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content); + // Prefer timezone from JSON body, fall back to X-Timezone header + let tz = req + .timezone + .as_deref() + .or_else(|| headers.get("X-Timezone").and_then(|v| v.to_str().ok())); + if let Some(tz) = tz { + msg = msg.with_timezone(tz); + } if let Some(ref thread_id) = req.thread_id { msg = msg.with_thread(thread_id); @@ -2115,7 +2124,7 @@ async fn routines_runs_handler( /// Convert a Routine to the trimmed RoutineInfo for list display. fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { let (trigger_type, trigger_summary) = match &r.trigger { - crate::agent::routine::Trigger::Cron { schedule } => { + crate::agent::routine::Trigger::Cron { schedule, .. } => { ("cron".to_string(), format!("cron: {}", schedule)) } crate::agent::routine::Trigger::Event { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 1513c418..68b803f9 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -181,6 +181,7 @@ function confirmRestart() { body: { content: '/restart', thread_id: currentThreadId, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, }) .then((response) => { @@ -454,7 +455,7 @@ function sendMessage() { apiFetch('/api/chat/send', { method: 'POST', - body: { content, thread_id: currentThreadId || undefined }, + body: { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }, }).catch((err) => { addMessage('system', 'Failed to send: ' + err.message); }); diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 20844fc5..7d65965d 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -9,6 +9,7 @@ use uuid::Uuid; pub struct SendMessageRequest { pub content: String, pub thread_id: Option, + pub timezone: Option, } #[derive(Debug, Serialize)] @@ -613,6 +614,7 @@ pub enum WsClientMessage { Message { content: String, thread_id: Option, + timezone: Option, }, /// Approve or deny a pending tool execution. #[serde(rename = "approval")] @@ -798,7 +800,9 @@ mod tests { let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#; let msg: WsClientMessage = serde_json::from_str(json).unwrap(); match msg { - WsClientMessage::Message { content, thread_id } => { + WsClientMessage::Message { + content, thread_id, .. + } => { assert_eq!(content, "hello"); assert_eq!(thread_id.as_deref(), Some("t1")); } @@ -811,7 +815,9 @@ mod tests { let json = r#"{"type":"message","content":"hi"}"#; let msg: WsClientMessage = serde_json::from_str(json).unwrap(); match msg { - WsClientMessage::Message { content, thread_id } => { + WsClientMessage::Message { + content, thread_id, .. + } => { assert_eq!(content, "hi"); assert!(thread_id.is_none()); } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 8a0caa54..e9e3c8e6 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -156,8 +156,15 @@ async fn handle_client_message( direct_tx: &mpsc::Sender, ) { match msg { - WsClientMessage::Message { content, thread_id } => { + WsClientMessage::Message { + content, + thread_id, + timezone, + } => { let mut incoming = IncomingMessage::new("gateway", user_id, &content); + if let Some(ref tz) = timezone { + incoming = incoming.with_timezone(tz); + } if let Some(ref tid) = thread_id { incoming = incoming.with_thread(tid); } @@ -349,6 +356,7 @@ mod tests { WsClientMessage::Message { content: "hello agent".to_string(), thread_id: Some("t1".to_string()), + timezone: None, }, &state, "user1", @@ -373,6 +381,7 @@ mod tests { WsClientMessage::Message { content: "hello".to_string(), thread_id: None, + timezone: None, }, &state, "user1", diff --git a/src/config/agent.rs b/src/config/agent.rs index b94e5d4b..096c141f 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -27,6 +27,8 @@ pub struct AgentConfig { pub max_tool_iterations: usize, /// When true, skip tool approval checks entirely. For benchmarks/CI. pub auto_approve_tools: bool, + /// Default timezone for new sessions (IANA name, e.g. "America/New_York"). + pub default_timezone: String, } impl AgentConfig { @@ -47,6 +49,7 @@ impl AgentConfig { max_actions_per_hour: None, max_tool_iterations: 10, auto_approve_tools: true, + default_timezone: "UTC".to_string(), } } @@ -89,6 +92,40 @@ impl AgentConfig { "AGENT_AUTO_APPROVE_TOOLS", settings.agent.auto_approve_tools, )?, + default_timezone: { + let tz: String = parse_optional_env( + "DEFAULT_TIMEZONE", + settings.agent.default_timezone.clone(), + )?; + if crate::timezone::parse_timezone(&tz).is_none() { + return Err(ConfigError::InvalidValue { + key: "DEFAULT_TIMEZONE".into(), + message: format!("invalid IANA timezone: '{tz}'"), + }); + } + tz + }, }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_timezone_rejects_invalid() { + let mut settings = Settings::default(); + settings.agent.default_timezone = "Fake/Zone".to_string(); + + let result = AgentConfig::resolve(&settings); + assert!(result.is_err(), "invalid IANA timezone should be rejected"); + } + + #[test] + fn test_default_timezone_accepts_valid() { + let settings = Settings::default(); // default is "UTC" + let config = AgentConfig::resolve(&settings).expect("resolve"); + assert_eq!(config.default_timezone, "UTC"); + } +} diff --git a/src/config/heartbeat.rs b/src/config/heartbeat.rs index f2f98071..3de1da66 100644 --- a/src/config/heartbeat.rs +++ b/src/config/heartbeat.rs @@ -1,4 +1,4 @@ -use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; +use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env}; use crate::error::ConfigError; use crate::settings::Settings; @@ -13,6 +13,12 @@ pub struct HeartbeatConfig { pub notify_channel: Option, /// User ID to notify on heartbeat findings. pub notify_user: Option, + /// Hour (0-23) when quiet hours start. + pub quiet_hours_start: Option, + /// Hour (0-23) when quiet hours end. + pub quiet_hours_end: Option, + /// Timezone for quiet hours evaluation (IANA name). + pub timezone: Option, } impl Default for HeartbeatConfig { @@ -22,6 +28,9 @@ impl Default for HeartbeatConfig { interval_secs: 1800, // 30 minutes notify_channel: None, notify_user: None, + quiet_hours_start: None, + quiet_hours_end: None, + timezone: None, } } } @@ -38,6 +47,98 @@ impl HeartbeatConfig { .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::("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::("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")); + } +} diff --git a/src/context/state.rs b/src/context/state.rs index 5b9c200b..a55cb8d1 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -164,6 +164,8 @@ pub struct JobContext { /// previous results by ID via `$tool_call_id` parameter syntax. #[serde(skip)] pub tool_output_stash: Arc>>, + /// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC". + pub user_timezone: String, } impl JobContext { @@ -203,9 +205,16 @@ impl JobContext { http_interceptor: None, metadata: serde_json::Value::Null, tool_output_stash: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + user_timezone: "UTC".to_string(), } } + /// Set the user timezone on this context. + pub fn with_timezone(mut self, tz: impl Into) -> Self { + self.user_timezone = tz.into(); + self + } + /// Transition to a new state. pub fn transition_to( &mut self, diff --git a/src/db/libsql/jobs.rs b/src/db/libsql/jobs.rs index 37506b51..d5172360 100644 --- a/src/db/libsql/jobs.rs +++ b/src/db/libsql/jobs.rs @@ -121,6 +121,9 @@ impl JobStore for LibSqlBackend { tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new( std::collections::HashMap::new(), )), + // TODO(#661): persist user_timezone in agent_jobs table so + // background/routine jobs retain the session's timezone context. + user_timezone: "UTC".to_string(), })) } None => Ok(None), diff --git a/src/history/store.rs b/src/history/store.rs index 2a46aaea..f0b0b144 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -241,6 +241,9 @@ impl Store { tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new( std::collections::HashMap::new(), )), + // TODO(#661): persist user_timezone in agent_jobs table so + // background/routine jobs retain the session's timezone context. + user_timezone: "UTC".to_string(), })) } None => Ok(None), diff --git a/src/lib.rs b/src/lib.rs index fff5c5fa..128d3edc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,7 @@ pub mod service; pub mod settings; pub mod setup; pub mod skills; +pub mod timezone; pub mod tools; pub mod tracing_fmt; pub mod transcription; diff --git a/src/settings.rs b/src/settings.rs index 8e62feaf..92fe207d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -291,6 +291,18 @@ pub struct HeartbeatSettings { /// User ID to notify on heartbeat findings. #[serde(default)] pub notify_user: Option, + + /// Hour (0-23) when quiet hours start (heartbeat skipped). + #[serde(default)] + pub quiet_hours_start: Option, + + /// Hour (0-23) when quiet hours end (heartbeat resumes). + #[serde(default)] + pub quiet_hours_end: Option, + + /// Timezone for quiet hours evaluation (IANA name, e.g. "America/New_York"). + #[serde(default)] + pub timezone: Option, } fn default_heartbeat_interval() -> u64 { @@ -304,6 +316,9 @@ impl Default for HeartbeatSettings { interval_secs: default_heartbeat_interval(), notify_channel: None, notify_user: None, + quiet_hours_start: None, + quiet_hours_end: None, + timezone: None, } } } @@ -351,6 +366,10 @@ pub struct AgentSettings { /// When true, skip tool approval checks entirely. For benchmarks/CI. #[serde(default)] pub auto_approve_tools: bool, + + /// Default timezone for new sessions (IANA name, e.g. "America/New_York"). + #[serde(default = "default_timezone")] + pub default_timezone: String, } fn default_agent_name() -> String { @@ -385,6 +404,10 @@ fn default_max_tool_iterations() -> usize { 50 } +fn default_timezone() -> String { + "UTC".to_string() +} + fn default_true() -> bool { true } @@ -402,6 +425,7 @@ impl Default for AgentSettings { session_idle_timeout_secs: default_session_idle_timeout(), max_tool_iterations: default_max_tool_iterations(), auto_approve_tools: false, + default_timezone: default_timezone(), } } } diff --git a/src/testing.rs b/src/testing.rs index 8660f82f..8f57cffc 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -1009,6 +1009,7 @@ mod tests { enabled: true, trigger: Trigger::Cron { schedule: "0 * * * *".to_string(), + timezone: None, }, action: RoutineAction::Lightweight { prompt: "Check status".to_string(), diff --git a/src/timezone.rs b/src/timezone.rs new file mode 100644 index 00000000..e0f93b76 --- /dev/null +++ b/src/timezone.rs @@ -0,0 +1,110 @@ +//! Timezone resolution and utilities. + +use chrono::{DateTime, NaiveDate, Utc}; +use chrono_tz::Tz; + +/// Resolve the effective timezone from a priority chain. +/// +/// Priority: client_tz > user_setting > config_default > UTC +pub fn resolve_timezone( + client_tz: Option<&str>, + user_setting: Option<&str>, + config_default: &str, +) -> Tz { + // Try each in priority order, skipping invalid values + for candidate in [client_tz, user_setting, Some(config_default)] { + if let Some(tz) = candidate.and_then(parse_timezone) { + return tz; + } + } + Tz::UTC +} + +/// Parse a timezone string (IANA name) into a `Tz`. +pub fn parse_timezone(s: &str) -> Option { + s.parse::().ok() +} + +/// Get today's date in the given timezone. +pub fn today_in_tz(tz: Tz) -> NaiveDate { + Utc::now().with_timezone(&tz).date_naive() +} + +/// Get the current time in the given timezone. +pub fn now_in_tz(tz: Tz) -> DateTime { + Utc::now().with_timezone(&tz) +} + +/// Detect the system's timezone, falling back to UTC. +pub fn detect_system_timezone() -> Tz { + iana_time_zone::get_timezone() + .ok() + .and_then(|s| parse_timezone(&s)) + .unwrap_or(Tz::UTC) +} + +#[cfg(test)] +mod tests { + use chrono::Datelike; + + use super::*; + + #[test] + fn test_resolve_client_wins() { + let tz = resolve_timezone(Some("America/New_York"), Some("Europe/London"), "UTC"); + assert_eq!(tz, chrono_tz::America::New_York); + } + + #[test] + fn test_resolve_user_setting_fallback() { + let tz = resolve_timezone(None, Some("Europe/London"), "UTC"); + assert_eq!(tz, chrono_tz::Europe::London); + } + + #[test] + fn test_resolve_config_fallback() { + let tz = resolve_timezone(None, None, "Asia/Tokyo"); + assert_eq!(tz, chrono_tz::Asia::Tokyo); + } + + #[test] + fn test_resolve_all_none_utc() { + let tz = resolve_timezone(None, None, "UTC"); + assert_eq!(tz, Tz::UTC); + } + + #[test] + fn test_resolve_invalid_client_skipped() { + let tz = resolve_timezone(Some("Fake/Zone"), Some("Europe/London"), "UTC"); + assert_eq!(tz, chrono_tz::Europe::London); + } + + #[test] + fn test_parse_valid() { + assert_eq!( + parse_timezone("America/Chicago"), + Some(chrono_tz::America::Chicago) + ); + } + + #[test] + fn test_parse_invalid() { + assert_eq!(parse_timezone("Fake/Zone"), None); + } + + #[test] + fn test_detect_system_tz() { + // Should always return a valid Tz (at minimum UTC) + let tz = detect_system_timezone(); + let _ = now_in_tz(tz); // Should not panic + } + + #[test] + fn test_today_in_tz_returns_valid_date() { + let date = today_in_tz(Tz::UTC); + // Verify it returns a valid date (year, month, day are all positive) + assert!(date.year() > 0); + assert!((1..=12).contains(&date.month())); + assert!((1..=31).contains(&date.day())); + } +} diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index dbb6b20b..71fe8a3b 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -172,7 +172,7 @@ impl Tool for MemoryWriteTool { async fn execute( &self, params: serde_json::Value, - _ctx: &JobContext, + ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); @@ -239,11 +239,12 @@ impl Tool for MemoryWriteTool { paths::MEMORY.to_string() } "daily_log" => { + let tz = crate::timezone::parse_timezone(&ctx.user_timezone) + .unwrap_or(chrono_tz::Tz::UTC); self.workspace - .append_daily_log(content) + .append_daily_log_tz(content, tz) .await - .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; - format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d")) + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))? } "heartbeat" => { if append { diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index 2fddec29..090d1ff9 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -107,6 +107,10 @@ impl Tool for RoutineCreateTool { "notify_user": { "type": "string", "description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'." + }, + "timezone": { + "type": "string", + "description": "IANA timezone for cron schedule evaluation (e.g. 'America/New_York'). Defaults to UTC." } }, "required": ["name", "trigger_type", "prompt"] @@ -143,12 +147,26 @@ impl Tool for RoutineCreateTool { "cron trigger requires 'schedule'".to_string(), ) })?; + let timezone = params + .get("timezone") + .and_then(|v| v.as_str()) + .map(|tz| { + crate::timezone::parse_timezone(tz) + .map(|_| tz.to_string()) + .ok_or_else(|| { + ToolError::InvalidParameters(format!( + "invalid IANA timezone: '{tz}'" + )) + }) + }) + .transpose()?; // Validate cron expression - next_cron_fire(schedule).map_err(|e| { + next_cron_fire(schedule, timezone.as_deref()).map_err(|e| { ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) })?; Trigger::Cron { schedule: schedule.to_string(), + timezone, } } "event" => { @@ -228,8 +246,12 @@ impl Tool for RoutineCreateTool { .unwrap_or(300); // Compute next fire time for cron - let next_fire = if let Trigger::Cron { ref schedule } = trigger { - next_cron_fire(schedule).unwrap_or(None) + let next_fire = if let Trigger::Cron { + ref schedule, + ref timezone, + } = trigger + { + next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None) } else { None }; @@ -412,6 +434,10 @@ impl Tool for RoutineUpdateTool { "type": "string", "description": "New cron schedule (for cron triggers)" }, + "timezone": { + "type": "string", + "description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers." + }, "description": { "type": "string", "description": "New description" @@ -453,15 +479,47 @@ impl Tool for RoutineUpdateTool { } } - if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) { - // Validate - next_cron_fire(schedule) - .map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?; + // Validate timezone param if provided + let new_timezone = params + .get("timezone") + .and_then(|v| v.as_str()) + .map(|tz| { + crate::timezone::parse_timezone(tz) + .map(|_| tz.to_string()) + .ok_or_else(|| { + ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'")) + }) + }) + .transpose()?; - routine.trigger = Trigger::Cron { - schedule: schedule.to_string(), + let new_schedule = params.get("schedule").and_then(|v| v.as_str()); + + if new_schedule.is_some() || new_timezone.is_some() { + // Extract existing cron fields (cloned to avoid borrow conflict) + let existing_cron = match &routine.trigger { + Trigger::Cron { schedule, timezone } => Some((schedule.clone(), timezone.clone())), + _ => None, }; - routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None); + + if let Some((old_schedule, old_tz)) = existing_cron { + let effective_schedule = new_schedule.unwrap_or(&old_schedule); + let effective_tz = new_timezone.or(old_tz); + // Validate + next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| { + ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) + })?; + + routine.trigger = Trigger::Cron { + schedule: effective_schedule.to_string(), + timezone: effective_tz.clone(), + }; + routine.next_fire_at = + next_cron_fire(effective_schedule, effective_tz.as_deref()).unwrap_or(None); + } else { + return Err(ToolError::InvalidParameters( + "Cannot update schedule or timezone on a non-cron routine.".to_string(), + )); + } } self.store diff --git a/src/tools/builtin/time.rs b/src/tools/builtin/time.rs index 9388f8c7..d93c09e4 100644 --- a/src/tools/builtin/time.rs +++ b/src/tools/builtin/time.rs @@ -48,7 +48,7 @@ impl Tool for TimeTool { async fn execute( &self, params: serde_json::Value, - _ctx: &JobContext, + ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); @@ -57,10 +57,15 @@ impl Tool for TimeTool { let result = match operation { "now" => { let now = Utc::now(); + let tz = + crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::UTC); + let local = now.with_timezone(&tz); serde_json::json!({ "iso": now.to_rfc3339(), "unix": now.timestamp(), - "unix_millis": now.timestamp_millis() + "unix_millis": now.timestamp_millis(), + "local_iso": local.to_rfc3339(), + "timezone": tz.name() }) } "parse" => { @@ -112,3 +117,42 @@ impl Tool for TimeTool { false // Internal tool, no external data } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_now_includes_local_time_when_timezone_set() { + let tool = TimeTool; + let mut ctx = JobContext::with_user("test", "chat", "test"); + ctx.user_timezone = "America/New_York".to_string(); + + let output = tool + .execute(serde_json::json!({"operation": "now"}), &ctx) + .await + .expect("execute"); + assert!( + output.result.get("local_iso").is_some(), + "should have local_iso" + ); + assert_eq!( + output.result["timezone"].as_str(), + Some("America/New_York"), + "should report timezone" + ); + } + + #[tokio::test] + async fn test_now_includes_utc_timezone_by_default() { + let tool = TimeTool; + let ctx = JobContext::with_user("test", "chat", "test"); + // Default user_timezone is "UTC" which is a valid IANA timezone + let output = tool + .execute(serde_json::json!({"operation": "now"}), &ctx) + .await + .expect("execute"); + assert!(output.result.get("iso").is_some(), "should have iso"); + assert_eq!(output.result["timezone"].as_str(), Some("UTC")); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 6196b3f1..16c7bc0e 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -565,11 +565,26 @@ impl Workspace { /// /// Daily logs are raw, append-only notes for the current day. pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> { - let today = Utc::now().date_naive(); + self.append_daily_log_tz(entry, chrono_tz::Tz::UTC) + .await + .map(|_| ()) + } + + /// Append an entry to today's daily log using the given timezone. + /// + /// Returns the path that was written to (e.g. `daily/2024-01-15.md`). + pub async fn append_daily_log_tz( + &self, + entry: &str, + tz: chrono_tz::Tz, + ) -> Result { + let now = crate::timezone::now_in_tz(tz); + let today = now.date_naive(); let path = format!("daily/{}.md", today.format("%Y-%m-%d")); - let timestamp = Utc::now().format("%H:%M:%S"); + let timestamp = now.format("%H:%M:%S"); let timestamped_entry = format!("[{}] {}", timestamp, entry); - self.append(&path, ×tamped_entry).await + self.append(&path, ×tamped_entry).await?; + Ok(path) } // ==================== System Prompt ==================== @@ -584,6 +599,18 @@ impl Workspace { self.system_prompt_for_context(false).await } + /// Build the system prompt with timezone-aware daily log dates. + /// + /// Uses the given timezone to determine "today" and "yesterday" for daily log injection. + pub async fn system_prompt_for_context_tz( + &self, + is_group_chat: bool, + tz: chrono_tz::Tz, + ) -> Result { + self.system_prompt_for_context_inner(is_group_chat, Some(tz)) + .await + } + /// Build the system prompt, optionally excluding personal memory. /// /// When `is_group_chat` is true, MEMORY.md is excluded to prevent @@ -591,6 +618,16 @@ impl Workspace { pub async fn system_prompt_for_context( &self, is_group_chat: bool, + ) -> Result { + self.system_prompt_for_context_inner(is_group_chat, None) + .await + } + + /// Inner implementation for system prompt building. + async fn system_prompt_for_context_inner( + &self, + is_group_chat: bool, + tz: Option, ) -> Result { let mut parts = Vec::new(); @@ -645,7 +682,10 @@ impl Workspace { } // Add today's memory context (last 2 days of daily logs) - let today = Utc::now().date_naive(); + let today = match tz { + Some(t) => crate::timezone::today_in_tz(t), + None => Utc::now().date_naive(), + }; let yesterday = today.pred_opt().unwrap_or(today); for date in [today, yesterday] { diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index 1e65fb3d..2e124816 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -118,6 +118,7 @@ mod tests { "cron-test", Trigger::Cron { schedule: "* * * * *".to_string(), + timezone: None, }, "Check system status.", ); @@ -203,6 +204,7 @@ mod tests { thread_id: None, received_at: Utc::now(), metadata: serde_json::json!({}), + timezone: None, attachments: Vec::new(), }; let fired = engine.check_event_triggers(&matching_msg).await; @@ -224,6 +226,7 @@ mod tests { thread_id: None, received_at: Utc::now(), metadata: serde_json::json!({}), + timezone: None, attachments: Vec::new(), }; let fired_neg = engine.check_event_triggers(&non_matching_msg).await; @@ -288,6 +291,7 @@ mod tests { thread_id: None, received_at: Utc::now(), metadata: serde_json::json!({}), + timezone: None, attachments: Vec::new(), }; let fired1 = engine.check_event_triggers(&msg).await;