mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +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
+44
-4
@@ -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<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 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<String, WorkspaceError> {
|
||||
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<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> {
|
||||
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] {
|
||||
|
||||
Reference in New Issue
Block a user