mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(timezone): add timezone-aware session context (#671)
* 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]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a20e19ab16
commit
df3635d6be
+2
-1
@@ -4,8 +4,9 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Claude Code worktrees
|
# Claude Code worktrees and lock files
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
# Sidecar tool data
|
# Sidecar tool data
|
||||||
.sidecar/
|
.sidecar/
|
||||||
|
|||||||
Generated
+30
@@ -864,6 +864,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -2872,6 +2882,7 @@ dependencies = [
|
|||||||
"bollard",
|
"bollard",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"cron",
|
"cron",
|
||||||
@@ -2890,6 +2901,7 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.8.1",
|
"hyper 1.8.1",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"iana-time-zone",
|
||||||
"insta",
|
"insta",
|
||||||
"libsql",
|
"libsql",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -3892,6 +3904,15 @@ dependencies = [
|
|||||||
"phf_shared 0.11.3",
|
"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]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -3966,6 +3987,15 @@ dependencies = [
|
|||||||
"uncased",
|
"uncased",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ toml = "0.8"
|
|||||||
# Core types
|
# Core types
|
||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["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 = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
||||||
rust_decimal_macros = "1"
|
rust_decimal_macros = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,12 @@ impl Agent {
|
|||||||
if let Some(workspace) = self.workspace() {
|
if let Some(workspace) = self.workspace() {
|
||||||
let mut config = AgentHeartbeatConfig::default()
|
let mut config = AgentHeartbeatConfig::default()
|
||||||
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
|
.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)) =
|
if let (Some(user), Some(channel)) =
|
||||||
(&hb_config.notify_user, &hb_config.notify_channel)
|
(&hb_config.notify_user, &hb_config.notify_channel)
|
||||||
{
|
{
|
||||||
|
|||||||
+17
-1
@@ -50,8 +50,18 @@ impl Agent {
|
|||||||
|
|
||||||
// Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
|
// Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
|
||||||
// In group chats, MEMORY.md is excluded to prevent leaking personal context.
|
// 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() {
|
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(prompt) if !prompt.is_empty() => Some(prompt),
|
||||||
Ok(_) => None,
|
Ok(_) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -130,6 +140,7 @@ impl Agent {
|
|||||||
let mut job_ctx =
|
let mut job_ctx =
|
||||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
||||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
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
|
// Build system prompts once for this turn. Two variants: with tools
|
||||||
// (normal iterations) and without (force_text final iteration).
|
// (normal iterations) and without (force_text final iteration).
|
||||||
@@ -785,6 +796,7 @@ impl Agent {
|
|||||||
tool_call_id: tc.id.clone(),
|
tool_call_id: tc.id.clone(),
|
||||||
context_messages: context_messages.clone(),
|
context_messages: context_messages.clone(),
|
||||||
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
|
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
|
||||||
|
user_timezone: Some(user_tz.name().to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(AgenticLoopResult::NeedApproval { pending });
|
return Ok(AgenticLoopResult::NeedApproval { pending });
|
||||||
@@ -1146,6 +1158,7 @@ mod tests {
|
|||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_tool_iterations: 50,
|
max_tool_iterations: 50,
|
||||||
auto_approve_tools: false,
|
auto_approve_tools: false,
|
||||||
|
default_timezone: "UTC".to_string(),
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -1248,6 +1261,7 @@ mod tests {
|
|||||||
arguments: serde_json::json!({"message": "done"}),
|
arguments: serde_json::json!({"message": "done"}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
user_timezone: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&pending).expect("serialize");
|
let json = serde_json::to_string(&pending).expect("serialize");
|
||||||
@@ -1900,6 +1914,7 @@ mod tests {
|
|||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_tool_iterations,
|
max_tool_iterations,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
|
default_timezone: "UTC".to_string(),
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -2015,6 +2030,7 @@ mod tests {
|
|||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_tool_iterations: max_iter,
|
max_tool_iterations: max_iter,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
|
default_timezone: "UTC".to_string(),
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ pub struct HeartbeatConfig {
|
|||||||
pub notify_user_id: Option<String>,
|
pub notify_user_id: Option<String>,
|
||||||
/// Channel to notify on heartbeat findings.
|
/// Channel to notify on heartbeat findings.
|
||||||
pub notify_channel: Option<String>,
|
pub notify_channel: 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 {
|
impl Default for HeartbeatConfig {
|
||||||
@@ -58,6 +64,9 @@ impl Default for HeartbeatConfig {
|
|||||||
max_failures: 3,
|
max_failures: 3,
|
||||||
notify_user_id: None,
|
notify_user_id: None,
|
||||||
notify_channel: None,
|
notify_channel: None,
|
||||||
|
quiet_hours_start: None,
|
||||||
|
quiet_hours_end: None,
|
||||||
|
timezone: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +84,26 @@ impl HeartbeatConfig {
|
|||||||
self
|
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.
|
/// Set the notification target.
|
||||||
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
|
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
|
||||||
self.notify_user_id = Some(user_id.into());
|
self.notify_user_id = Some(user_id.into());
|
||||||
@@ -162,6 +191,12 @@ impl HeartbeatRunner {
|
|||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
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
|
// Run memory hygiene in the background so it never delays the
|
||||||
// heartbeat checklist. Failures are logged inside run_if_due.
|
// heartbeat checklist. Failures are logged inside run_if_due.
|
||||||
let hygiene_workspace = Arc::clone(&self.workspace);
|
let hygiene_workspace = Arc::clone(&self.workspace);
|
||||||
@@ -532,6 +567,83 @@ mod tests {
|
|||||||
assert!(!is_effectively_empty(content));
|
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]
|
#[test]
|
||||||
fn test_spawn_heartbeat_accepts_store_param() {
|
fn test_spawn_heartbeat_accepts_store_param() {
|
||||||
// Regression: spawn_heartbeat must accept an optional Database store
|
// Regression: spawn_heartbeat must accept an optional Database store
|
||||||
|
|||||||
+87
-9
@@ -57,7 +57,11 @@ pub struct Routine {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum Trigger {
|
pub enum Trigger {
|
||||||
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
|
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
|
||||||
Cron { schedule: String },
|
Cron {
|
||||||
|
schedule: String,
|
||||||
|
#[serde(default)]
|
||||||
|
timezone: Option<String>,
|
||||||
|
},
|
||||||
/// Fire when a channel message matches a pattern.
|
/// Fire when a channel message matches a pattern.
|
||||||
Event {
|
Event {
|
||||||
/// Optional channel filter (e.g. "telegram", "slack").
|
/// Optional channel filter (e.g. "telegram", "slack").
|
||||||
@@ -99,7 +103,21 @@ impl Trigger {
|
|||||||
field: "schedule".into(),
|
field: "schedule".into(),
|
||||||
})?
|
})?
|
||||||
.to_string();
|
.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" => {
|
"event" => {
|
||||||
let pattern = config
|
let pattern = config
|
||||||
@@ -137,7 +155,10 @@ impl Trigger {
|
|||||||
/// Serialize trigger-specific config to JSON for DB storage.
|
/// Serialize trigger-specific config to JSON for DB storage.
|
||||||
pub fn to_config_json(&self) -> serde_json::Value {
|
pub fn to_config_json(&self) -> serde_json::Value {
|
||||||
match self {
|
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!({
|
Trigger::Event { channel, pattern } => serde_json::json!({
|
||||||
"pattern": pattern,
|
"pattern": pattern,
|
||||||
"channel": channel,
|
"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.
|
/// Parse a cron expression and compute the next fire time from now.
|
||||||
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, 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<Option<DateTime<Utc>>, RoutineError> {
|
||||||
let cron_schedule =
|
let cron_schedule =
|
||||||
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
||||||
reason: e.to_string(),
|
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)]
|
#[cfg(test)]
|
||||||
@@ -433,10 +467,11 @@ mod tests {
|
|||||||
fn test_trigger_roundtrip() {
|
fn test_trigger_roundtrip() {
|
||||||
let trigger = Trigger::Cron {
|
let trigger = Trigger::Cron {
|
||||||
schedule: "0 9 * * MON-FRI".to_string(),
|
schedule: "0 9 * * MON-FRI".to_string(),
|
||||||
|
timezone: None,
|
||||||
};
|
};
|
||||||
let json = trigger.to_config_json();
|
let json = trigger.to_config_json();
|
||||||
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
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]
|
#[test]
|
||||||
@@ -509,16 +544,58 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_next_cron_fire_valid() {
|
fn test_next_cron_fire_valid() {
|
||||||
// Every minute should always have a next fire
|
// 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());
|
assert!(next.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_next_cron_fire_invalid() {
|
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());
|
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]
|
#[test]
|
||||||
fn test_guardrails_default() {
|
fn test_guardrails_default() {
|
||||||
let g = RoutineGuardrails::default();
|
let g = RoutineGuardrails::default();
|
||||||
@@ -531,7 +608,8 @@ mod tests {
|
|||||||
fn test_trigger_type_tag() {
|
fn test_trigger_type_tag() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Trigger::Cron {
|
Trigger::Cron {
|
||||||
schedule: String::new()
|
schedule: String::new(),
|
||||||
|
timezone: None,
|
||||||
}
|
}
|
||||||
.type_tag(),
|
.type_tag(),
|
||||||
"cron"
|
"cron"
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ impl RoutineEngine {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = if let Trigger::Cron { ref schedule } = routine.trigger {
|
let detail = if let Trigger::Cron { ref schedule, .. } = routine.trigger {
|
||||||
Some(schedule.clone())
|
Some(schedule.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -380,8 +380,12 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
|||||||
|
|
||||||
// Update routine runtime state
|
// Update routine runtime state
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger {
|
let next_fire = if let Trigger::Cron {
|
||||||
next_cron_fire(schedule).unwrap_or(None)
|
ref schedule,
|
||||||
|
ref timezone,
|
||||||
|
} = routine.trigger
|
||||||
|
{
|
||||||
|
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ pub struct PendingApproval {
|
|||||||
/// executed yet when approval was requested.
|
/// executed yet when approval was requested.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub deferred_tool_calls: Vec<ToolCall>,
|
pub deferred_tool_calls: Vec<ToolCall>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A conversation thread within a session.
|
/// A conversation thread within a session.
|
||||||
@@ -976,6 +980,7 @@ mod tests {
|
|||||||
tool_call_id: "call_123".to_string(),
|
tool_call_id: "call_123".to_string(),
|
||||||
context_messages: vec![ChatMessage::user("do it")],
|
context_messages: vec![ChatMessage::user("do it")],
|
||||||
deferred_tool_calls: vec![],
|
deferred_tool_calls: vec![],
|
||||||
|
user_timezone: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
thread.await_approval(approval);
|
thread.await_approval(approval);
|
||||||
@@ -1001,6 +1006,7 @@ mod tests {
|
|||||||
tool_call_id: "call_456".to_string(),
|
tool_call_id: "call_456".to_string(),
|
||||||
context_messages: vec![],
|
context_messages: vec![],
|
||||||
deferred_tool_calls: vec![],
|
deferred_tool_calls: vec![],
|
||||||
|
user_timezone: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
thread.await_approval(approval);
|
thread.await_approval(approval);
|
||||||
|
|||||||
@@ -746,6 +746,16 @@ impl Agent {
|
|||||||
let mut job_ctx =
|
let mut job_ctx =
|
||||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
||||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
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
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
@@ -1111,6 +1121,8 @@ impl Agent {
|
|||||||
tool_call_id: tc.id.clone(),
|
tool_call_id: tc.id.clone(),
|
||||||
context_messages: context_messages.clone(),
|
context_messages: context_messages.clone(),
|
||||||
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
|
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;
|
let request_id = new_pending.request_id;
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ pub struct IncomingMessage {
|
|||||||
pub received_at: DateTime<Utc>,
|
pub received_at: DateTime<Utc>,
|
||||||
/// Channel-specific metadata.
|
/// Channel-specific metadata.
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
/// IANA timezone string from the client (e.g. "America/New_York").
|
||||||
|
pub timezone: Option<String>,
|
||||||
/// File or media attachments on this message.
|
/// File or media attachments on this message.
|
||||||
pub attachments: Vec<IncomingAttachment>,
|
pub attachments: Vec<IncomingAttachment>,
|
||||||
}
|
}
|
||||||
@@ -99,6 +101,7 @@ impl IncomingMessage {
|
|||||||
thread_id: None,
|
thread_id: None,
|
||||||
received_at: Utc::now(),
|
received_at: Utc::now(),
|
||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
|
timezone: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +124,12 @@ impl IncomingMessage {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the client timezone.
|
||||||
|
pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
|
||||||
|
self.timezone = Some(tz.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set attachments.
|
/// Set attachments.
|
||||||
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
|
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
|
||||||
self.attachments = attachments;
|
self.attachments = attachments;
|
||||||
@@ -454,4 +463,10 @@ mod tests {
|
|||||||
panic!("expected ToolCompleted variant");
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -297,9 +297,11 @@ impl Channel for ReplChannel {
|
|||||||
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
let sys_tz = crate::timezone::detect_system_timezone().name().to_string();
|
||||||
|
|
||||||
// Single message mode: send it and return
|
// Single message mode: send it and return
|
||||||
if let Some(msg) = single_message {
|
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);
|
let _ = tx.blocking_send(incoming);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -361,7 +363,8 @@ impl Channel for ReplChannel {
|
|||||||
"/quit" | "/exit" => {
|
"/quit" | "/exit" => {
|
||||||
// Forward shutdown command so the agent loop exits even
|
// Forward shutdown command so the agent loop exits even
|
||||||
// when other channels (e.g. web gateway) are still active.
|
// 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);
|
let _ = tx.blocking_send(msg);
|
||||||
break;
|
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() {
|
if tx.blocking_send(msg).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -390,20 +394,23 @@ impl Channel for ReplChannel {
|
|||||||
Err(ReadlineError::Interrupted) => {
|
Err(ReadlineError::Interrupted) => {
|
||||||
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
|
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
|
||||||
// Esc: interrupt current operation and keep REPL open.
|
// 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() {
|
if tx.blocking_send(msg).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Ctrl+C (VINTR): request graceful shutdown.
|
// 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);
|
let _ = tx.blocking_send(msg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(ReadlineError::Eof) => {
|
Err(ReadlineError::Eof) => {
|
||||||
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
|
// 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);
|
let _ = tx.blocking_send(msg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ pub async fn routines_runs_handler(
|
|||||||
/// Convert a Routine to the trimmed RoutineInfo for list display.
|
/// Convert a Routine to the trimmed RoutineInfo for list display.
|
||||||
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
|
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
|
||||||
let (trigger_type, trigger_summary) = match &r.trigger {
|
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))
|
("cron".to_string(), format!("cron: {}", schedule))
|
||||||
}
|
}
|
||||||
crate::agent::routine::Trigger::Event {
|
crate::agent::routine::Trigger::Event {
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ async fn oauth_callback_handler(
|
|||||||
|
|
||||||
async fn chat_send_handler(
|
async fn chat_send_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Json(req): Json<SendMessageRequest>,
|
Json(req): Json<SendMessageRequest>,
|
||||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -626,6 +627,14 @@ async fn chat_send_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
|
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 {
|
if let Some(ref thread_id) = req.thread_id {
|
||||||
msg = msg.with_thread(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.
|
/// Convert a Routine to the trimmed RoutineInfo for list display.
|
||||||
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
|
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
|
||||||
let (trigger_type, trigger_summary) = match &r.trigger {
|
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))
|
("cron".to_string(), format!("cron: {}", schedule))
|
||||||
}
|
}
|
||||||
crate::agent::routine::Trigger::Event {
|
crate::agent::routine::Trigger::Event {
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ function confirmRestart() {
|
|||||||
body: {
|
body: {
|
||||||
content: '/restart',
|
content: '/restart',
|
||||||
thread_id: currentThreadId,
|
thread_id: currentThreadId,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -454,7 +455,7 @@ function sendMessage() {
|
|||||||
|
|
||||||
apiFetch('/api/chat/send', {
|
apiFetch('/api/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { content, thread_id: currentThreadId || undefined },
|
body: { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
addMessage('system', 'Failed to send: ' + err.message);
|
addMessage('system', 'Failed to send: ' + err.message);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
|||||||
pub struct SendMessageRequest {
|
pub struct SendMessageRequest {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub thread_id: Option<String>,
|
pub thread_id: Option<String>,
|
||||||
|
pub timezone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -613,6 +614,7 @@ pub enum WsClientMessage {
|
|||||||
Message {
|
Message {
|
||||||
content: String,
|
content: String,
|
||||||
thread_id: Option<String>,
|
thread_id: Option<String>,
|
||||||
|
timezone: Option<String>,
|
||||||
},
|
},
|
||||||
/// Approve or deny a pending tool execution.
|
/// Approve or deny a pending tool execution.
|
||||||
#[serde(rename = "approval")]
|
#[serde(rename = "approval")]
|
||||||
@@ -798,7 +800,9 @@ mod tests {
|
|||||||
let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#;
|
let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#;
|
||||||
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
WsClientMessage::Message { content, thread_id } => {
|
WsClientMessage::Message {
|
||||||
|
content, thread_id, ..
|
||||||
|
} => {
|
||||||
assert_eq!(content, "hello");
|
assert_eq!(content, "hello");
|
||||||
assert_eq!(thread_id.as_deref(), Some("t1"));
|
assert_eq!(thread_id.as_deref(), Some("t1"));
|
||||||
}
|
}
|
||||||
@@ -811,7 +815,9 @@ mod tests {
|
|||||||
let json = r#"{"type":"message","content":"hi"}"#;
|
let json = r#"{"type":"message","content":"hi"}"#;
|
||||||
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
WsClientMessage::Message { content, thread_id } => {
|
WsClientMessage::Message {
|
||||||
|
content, thread_id, ..
|
||||||
|
} => {
|
||||||
assert_eq!(content, "hi");
|
assert_eq!(content, "hi");
|
||||||
assert!(thread_id.is_none());
|
assert!(thread_id.is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -156,8 +156,15 @@ async fn handle_client_message(
|
|||||||
direct_tx: &mpsc::Sender<WsServerMessage>,
|
direct_tx: &mpsc::Sender<WsServerMessage>,
|
||||||
) {
|
) {
|
||||||
match msg {
|
match msg {
|
||||||
WsClientMessage::Message { content, thread_id } => {
|
WsClientMessage::Message {
|
||||||
|
content,
|
||||||
|
thread_id,
|
||||||
|
timezone,
|
||||||
|
} => {
|
||||||
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
|
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 {
|
if let Some(ref tid) = thread_id {
|
||||||
incoming = incoming.with_thread(tid);
|
incoming = incoming.with_thread(tid);
|
||||||
}
|
}
|
||||||
@@ -349,6 +356,7 @@ mod tests {
|
|||||||
WsClientMessage::Message {
|
WsClientMessage::Message {
|
||||||
content: "hello agent".to_string(),
|
content: "hello agent".to_string(),
|
||||||
thread_id: Some("t1".to_string()),
|
thread_id: Some("t1".to_string()),
|
||||||
|
timezone: None,
|
||||||
},
|
},
|
||||||
&state,
|
&state,
|
||||||
"user1",
|
"user1",
|
||||||
@@ -373,6 +381,7 @@ mod tests {
|
|||||||
WsClientMessage::Message {
|
WsClientMessage::Message {
|
||||||
content: "hello".to_string(),
|
content: "hello".to_string(),
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
|
timezone: None,
|
||||||
},
|
},
|
||||||
&state,
|
&state,
|
||||||
"user1",
|
"user1",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub struct AgentConfig {
|
|||||||
pub max_tool_iterations: usize,
|
pub max_tool_iterations: usize,
|
||||||
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
||||||
pub auto_approve_tools: bool,
|
pub auto_approve_tools: bool,
|
||||||
|
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||||
|
pub default_timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
@@ -47,6 +49,7 @@ impl AgentConfig {
|
|||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_tool_iterations: 10,
|
max_tool_iterations: 10,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
|
default_timezone: "UTC".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +92,40 @@ impl AgentConfig {
|
|||||||
"AGENT_AUTO_APPROVE_TOOLS",
|
"AGENT_AUTO_APPROVE_TOOLS",
|
||||||
settings.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+102
-1
@@ -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::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
@@ -13,6 +13,12 @@ pub struct HeartbeatConfig {
|
|||||||
pub notify_channel: Option<String>,
|
pub notify_channel: Option<String>,
|
||||||
/// User ID to notify on heartbeat findings.
|
/// User ID to notify on heartbeat findings.
|
||||||
pub notify_user: Option<String>,
|
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 {
|
impl Default for HeartbeatConfig {
|
||||||
@@ -22,6 +28,9 @@ impl Default for HeartbeatConfig {
|
|||||||
interval_secs: 1800, // 30 minutes
|
interval_secs: 1800, // 30 minutes
|
||||||
notify_channel: None,
|
notify_channel: None,
|
||||||
notify_user: 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()),
|
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
||||||
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
||||||
.or_else(|| settings.heartbeat.notify_user.clone()),
|
.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ pub struct JobContext {
|
|||||||
/// previous results by ID via `$tool_call_id` parameter syntax.
|
/// previous results by ID via `$tool_call_id` parameter syntax.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
|
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
|
||||||
|
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
|
||||||
|
pub user_timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobContext {
|
impl JobContext {
|
||||||
@@ -203,9 +205,16 @@ impl JobContext {
|
|||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
tool_output_stash: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
|
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<String>) -> Self {
|
||||||
|
self.user_timezone = tz.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Transition to a new state.
|
/// Transition to a new state.
|
||||||
pub fn transition_to(
|
pub fn transition_to(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ impl JobStore for LibSqlBackend {
|
|||||||
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
|
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||||||
std::collections::HashMap::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),
|
None => Ok(None),
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ impl Store {
|
|||||||
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
|
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||||||
std::collections::HashMap::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),
|
None => Ok(None),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub mod service;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
|
pub mod timezone;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod tracing_fmt;
|
pub mod tracing_fmt;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
|
|||||||
@@ -291,6 +291,18 @@ pub struct HeartbeatSettings {
|
|||||||
/// User ID to notify on heartbeat findings.
|
/// User ID to notify on heartbeat findings.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notify_user: Option<String>,
|
pub notify_user: Option<String>,
|
||||||
|
|
||||||
|
/// Hour (0-23) when quiet hours start (heartbeat skipped).
|
||||||
|
#[serde(default)]
|
||||||
|
pub quiet_hours_start: Option<u32>,
|
||||||
|
|
||||||
|
/// Hour (0-23) when quiet hours end (heartbeat resumes).
|
||||||
|
#[serde(default)]
|
||||||
|
pub quiet_hours_end: Option<u32>,
|
||||||
|
|
||||||
|
/// Timezone for quiet hours evaluation (IANA name, e.g. "America/New_York").
|
||||||
|
#[serde(default)]
|
||||||
|
pub timezone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_heartbeat_interval() -> u64 {
|
fn default_heartbeat_interval() -> u64 {
|
||||||
@@ -304,6 +316,9 @@ impl Default for HeartbeatSettings {
|
|||||||
interval_secs: default_heartbeat_interval(),
|
interval_secs: default_heartbeat_interval(),
|
||||||
notify_channel: None,
|
notify_channel: None,
|
||||||
notify_user: 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.
|
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub auto_approve_tools: bool,
|
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 {
|
fn default_agent_name() -> String {
|
||||||
@@ -385,6 +404,10 @@ fn default_max_tool_iterations() -> usize {
|
|||||||
50
|
50
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_timezone() -> String {
|
||||||
|
"UTC".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -402,6 +425,7 @@ impl Default for AgentSettings {
|
|||||||
session_idle_timeout_secs: default_session_idle_timeout(),
|
session_idle_timeout_secs: default_session_idle_timeout(),
|
||||||
max_tool_iterations: default_max_tool_iterations(),
|
max_tool_iterations: default_max_tool_iterations(),
|
||||||
auto_approve_tools: false,
|
auto_approve_tools: false,
|
||||||
|
default_timezone: default_timezone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1009,6 +1009,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
trigger: Trigger::Cron {
|
trigger: Trigger::Cron {
|
||||||
schedule: "0 * * * *".to_string(),
|
schedule: "0 * * * *".to_string(),
|
||||||
|
timezone: None,
|
||||||
},
|
},
|
||||||
action: RoutineAction::Lightweight {
|
action: RoutineAction::Lightweight {
|
||||||
prompt: "Check status".to_string(),
|
prompt: "Check status".to_string(),
|
||||||
|
|||||||
+110
@@ -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<Tz> {
|
||||||
|
s.parse::<Tz>().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<Tz> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,7 +172,7 @@ impl Tool for MemoryWriteTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -239,11 +239,12 @@ impl Tool for MemoryWriteTool {
|
|||||||
paths::MEMORY.to_string()
|
paths::MEMORY.to_string()
|
||||||
}
|
}
|
||||||
"daily_log" => {
|
"daily_log" => {
|
||||||
|
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
|
||||||
|
.unwrap_or(chrono_tz::Tz::UTC);
|
||||||
self.workspace
|
self.workspace
|
||||||
.append_daily_log(content)
|
.append_daily_log_tz(content, tz)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
|
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?
|
||||||
format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d"))
|
|
||||||
}
|
}
|
||||||
"heartbeat" => {
|
"heartbeat" => {
|
||||||
if append {
|
if append {
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ impl Tool for RoutineCreateTool {
|
|||||||
"notify_user": {
|
"notify_user": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'."
|
"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"]
|
"required": ["name", "trigger_type", "prompt"]
|
||||||
@@ -143,12 +147,26 @@ impl Tool for RoutineCreateTool {
|
|||||||
"cron trigger requires 'schedule'".to_string(),
|
"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
|
// 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}"))
|
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
|
||||||
})?;
|
})?;
|
||||||
Trigger::Cron {
|
Trigger::Cron {
|
||||||
schedule: schedule.to_string(),
|
schedule: schedule.to_string(),
|
||||||
|
timezone,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"event" => {
|
"event" => {
|
||||||
@@ -228,8 +246,12 @@ impl Tool for RoutineCreateTool {
|
|||||||
.unwrap_or(300);
|
.unwrap_or(300);
|
||||||
|
|
||||||
// Compute next fire time for cron
|
// Compute next fire time for cron
|
||||||
let next_fire = if let Trigger::Cron { ref schedule } = trigger {
|
let next_fire = if let Trigger::Cron {
|
||||||
next_cron_fire(schedule).unwrap_or(None)
|
ref schedule,
|
||||||
|
ref timezone,
|
||||||
|
} = trigger
|
||||||
|
{
|
||||||
|
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -412,6 +434,10 @@ impl Tool for RoutineUpdateTool {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New cron schedule (for cron triggers)"
|
"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": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New description"
|
"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 timezone param if provided
|
||||||
// Validate
|
let new_timezone = params
|
||||||
next_cron_fire(schedule)
|
.get("timezone")
|
||||||
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;
|
.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 {
|
let new_schedule = params.get("schedule").and_then(|v| v.as_str());
|
||||||
schedule: schedule.to_string(),
|
|
||||||
|
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
|
self.store
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ impl Tool for TimeTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -57,10 +57,15 @@ impl Tool for TimeTool {
|
|||||||
let result = match operation {
|
let result = match operation {
|
||||||
"now" => {
|
"now" => {
|
||||||
let now = Utc::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!({
|
serde_json::json!({
|
||||||
"iso": now.to_rfc3339(),
|
"iso": now.to_rfc3339(),
|
||||||
"unix": now.timestamp(),
|
"unix": now.timestamp(),
|
||||||
"unix_millis": now.timestamp_millis()
|
"unix_millis": now.timestamp_millis(),
|
||||||
|
"local_iso": local.to_rfc3339(),
|
||||||
|
"timezone": tz.name()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"parse" => {
|
"parse" => {
|
||||||
@@ -112,3 +117,42 @@ impl Tool for TimeTool {
|
|||||||
false // Internal tool, no external data
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+44
-4
@@ -565,11 +565,26 @@ impl Workspace {
|
|||||||
///
|
///
|
||||||
/// Daily logs are raw, append-only notes for the current day.
|
/// Daily logs are raw, append-only notes for the current day.
|
||||||
pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> {
|
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<String, WorkspaceError> {
|
||||||
|
let now = crate::timezone::now_in_tz(tz);
|
||||||
|
let today = now.date_naive();
|
||||||
let path = format!("daily/{}.md", today.format("%Y-%m-%d"));
|
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);
|
let timestamped_entry = format!("[{}] {}", timestamp, entry);
|
||||||
self.append(&path, ×tamped_entry).await
|
self.append(&path, ×tamped_entry).await?;
|
||||||
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== System Prompt ====================
|
// ==================== System Prompt ====================
|
||||||
@@ -584,6 +599,18 @@ impl Workspace {
|
|||||||
self.system_prompt_for_context(false).await
|
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<String, WorkspaceError> {
|
||||||
|
self.system_prompt_for_context_inner(is_group_chat, Some(tz))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the system prompt, optionally excluding personal memory.
|
/// Build the system prompt, optionally excluding personal memory.
|
||||||
///
|
///
|
||||||
/// When `is_group_chat` is true, MEMORY.md is excluded to prevent
|
/// 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(
|
pub async fn system_prompt_for_context(
|
||||||
&self,
|
&self,
|
||||||
is_group_chat: bool,
|
is_group_chat: bool,
|
||||||
|
) -> Result<String, WorkspaceError> {
|
||||||
|
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<chrono_tz::Tz>,
|
||||||
) -> Result<String, WorkspaceError> {
|
) -> Result<String, WorkspaceError> {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
@@ -645,7 +682,10 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add today's memory context (last 2 days of daily logs)
|
// 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);
|
let yesterday = today.pred_opt().unwrap_or(today);
|
||||||
|
|
||||||
for date in [today, yesterday] {
|
for date in [today, yesterday] {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ mod tests {
|
|||||||
"cron-test",
|
"cron-test",
|
||||||
Trigger::Cron {
|
Trigger::Cron {
|
||||||
schedule: "* * * * *".to_string(),
|
schedule: "* * * * *".to_string(),
|
||||||
|
timezone: None,
|
||||||
},
|
},
|
||||||
"Check system status.",
|
"Check system status.",
|
||||||
);
|
);
|
||||||
@@ -203,6 +204,7 @@ mod tests {
|
|||||||
thread_id: None,
|
thread_id: None,
|
||||||
received_at: Utc::now(),
|
received_at: Utc::now(),
|
||||||
metadata: serde_json::json!({}),
|
metadata: serde_json::json!({}),
|
||||||
|
timezone: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
};
|
};
|
||||||
let fired = engine.check_event_triggers(&matching_msg).await;
|
let fired = engine.check_event_triggers(&matching_msg).await;
|
||||||
@@ -224,6 +226,7 @@ mod tests {
|
|||||||
thread_id: None,
|
thread_id: None,
|
||||||
received_at: Utc::now(),
|
received_at: Utc::now(),
|
||||||
metadata: serde_json::json!({}),
|
metadata: serde_json::json!({}),
|
||||||
|
timezone: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
};
|
};
|
||||||
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
|
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
|
||||||
@@ -288,6 +291,7 @@ mod tests {
|
|||||||
thread_id: None,
|
thread_id: None,
|
||||||
received_at: Utc::now(),
|
received_at: Utc::now(),
|
||||||
metadata: serde_json::json!({}),
|
metadata: serde_json::json!({}),
|
||||||
|
timezone: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
};
|
};
|
||||||
let fired1 = engine.check_event_triggers(&msg).await;
|
let fired1 = engine.check_event_triggers(&msg).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user