Compare commits

..
Author SHA1 Message Date
ZakiandClaude Opus 4.6 8731bb68ae style: fix rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-07 18:02:45 -08:00
ZakiandClaude Opus 4.6 c41e9c899f feat: add POST /api/webhooks/{path} endpoint for routine webhook triggers (#651)
Public endpoint (no auth token) protected by per-routine webhook secrets
with constant-time comparison via subtle::ConstantTimeEq.

Matches path against routines with Trigger::Webhook, validates
X-Webhook-Secret header, and fires the routine through the message
pipeline. Returns 404/401/200 as appropriate.

2 unit tests for secret comparison and path matching.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-07 18:02:32 -08:00
ZakiandClaude Opus 4.6 9ea89e5bc6 feat: add timezone support to time tool with convert/format operations (#662)
- now: accepts optional timezone param, returns utc_iso + local_iso
- convert: new operation for timezone-to-timezone conversion
- format: new operation with strftime formatting + timezone
- parse: accepts naive datetimes with timezone context
- diff: uses flexible timestamp parsing
- Adds chrono-tz dependency for IANA timezone support
- 11 unit tests covering all operations

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-07 17:58:13 -08:00
8 changed files with 644 additions and 181 deletions
Generated
+29
View File
@@ -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",
@@ -3892,6 +3903,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 +3986,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"
+1
View File
@@ -73,6 +73,7 @@ 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"
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"
+1
View File
@@ -26,3 +26,4 @@ pub mod routines;
pub mod settings; pub mod settings;
#[allow(dead_code)] #[allow(dead_code)]
pub mod static_files; pub mod static_files;
pub mod webhooks;
+210
View File
@@ -0,0 +1,210 @@
//! Public webhook trigger endpoint for routine webhook triggers.
//!
//! `POST /api/webhooks/{path}` — matches the path against routines with
//! `Trigger::Webhook { path, secret }`, validates the secret via constant-time
//! comparison, and fires the matching routine through the message pipeline.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::{HeaderMap, StatusCode},
};
use subtle::ConstantTimeEq;
use crate::agent::routine::{RoutineAction, Trigger};
use crate::channels::IncomingMessage;
use crate::channels::web::server::GatewayState;
/// Handle incoming webhook POST to `/api/webhooks/{path}`.
///
/// This endpoint is **public** (no gateway auth token required) but protected
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
pub async fn webhook_trigger_handler(
State(state): State<Arc<GatewayState>>,
Path(path): Path<String>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Load all routines and find one whose Trigger::Webhook path matches.
let routines = store
.list_all_routines()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let matched = routines.into_iter().find(|r| {
if !r.enabled {
return false;
}
match &r.trigger {
Trigger::Webhook { path: Some(wp), .. } => *wp == path,
Trigger::Webhook { path: None, .. } => path == r.id.to_string(),
_ => false,
}
});
let routine = matched.ok_or((
StatusCode::NOT_FOUND,
"No routine matches this webhook path".to_string(),
))?;
// Validate the webhook secret if one is configured on the routine.
if let Trigger::Webhook {
secret: Some(expected_secret),
..
} = &routine.trigger
{
let provided_secret = headers
.get("x-webhook-secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !bool::from(provided_secret.as_bytes().ct_eq(expected_secret.as_bytes())) {
return Err((
StatusCode::UNAUTHORIZED,
"Invalid webhook secret".to_string(),
));
}
}
// Build the prompt from the routine action.
let prompt = match &routine.action {
RoutineAction::Lightweight { prompt, .. } => prompt.clone(),
RoutineAction::FullJob {
title, description, ..
} => format!("{}: {}", title, description),
};
let content = format!("[routine:{}] {}", routine.name, prompt);
let thread_id = format!(
"routine-{}-{}",
routine.id,
chrono::Utc::now().timestamp_millis()
);
let msg = IncomingMessage::new("gateway", &routine.user_id, content).with_thread(thread_id);
let tx_guard = state.msg_tx.read().await;
let tx = tx_guard.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Channel not started".to_string(),
))?;
tx.send(msg).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Channel closed".to_string(),
)
})?;
Ok(Json(serde_json::json!({
"status": "triggered",
"routine_id": routine.id,
"routine_name": routine.name,
})))
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify constant-time comparison logic for webhook secrets.
#[test]
fn test_webhook_secret_constant_time_comparison() {
let expected = "my-secret-token";
// Matching secret
let provided = "my-secret-token";
assert!(bool::from(provided.as_bytes().ct_eq(expected.as_bytes())));
// Wrong secret
let wrong = "wrong-secret";
assert!(!bool::from(wrong.as_bytes().ct_eq(expected.as_bytes())));
// Empty secret
let empty = "";
assert!(!bool::from(empty.as_bytes().ct_eq(expected.as_bytes())));
}
/// Verify that webhook path matching logic works for both explicit paths
/// and fallback to routine ID.
#[test]
fn test_webhook_path_matching() {
use chrono::Utc;
use uuid::Uuid;
let routine_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let routine = crate::agent::routine::Routine {
id: routine_id,
name: "test-routine".to_string(),
description: "A test routine".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Webhook {
path: Some("my-hook".to_string()),
secret: None,
},
action: RoutineAction::Lightweight {
prompt: "do stuff".to_string(),
context_paths: vec![],
max_tokens: 4096,
},
guardrails: crate::agent::routine::RoutineGuardrails::default(),
notify: crate::agent::routine::NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::Value::Null,
created_at: Utc::now(),
updated_at: Utc::now(),
};
// Explicit path match
let matches_explicit = match &routine.trigger {
Trigger::Webhook { path: Some(wp), .. } => *wp == "my-hook",
_ => false,
};
assert!(matches_explicit);
// Should NOT match wrong path
let matches_wrong = match &routine.trigger {
Trigger::Webhook { path: Some(wp), .. } => *wp == "other-hook",
_ => false,
};
assert!(!matches_wrong);
// Routine with no explicit path falls back to ID
let routine_no_path = crate::agent::routine::Routine {
trigger: Trigger::Webhook {
path: None,
secret: None,
},
..routine
};
let matches_id = match &routine_no_path.trigger {
Trigger::Webhook { path: None, .. } => {
routine_no_path.id.to_string() == "550e8400-e29b-41d4-a716-446655440000"
}
_ => false,
};
assert!(matches_id);
// Disabled routine should not match
let disabled_routine = crate::agent::routine::Routine {
enabled: false,
trigger: Trigger::Webhook {
path: Some("my-hook".to_string()),
secret: None,
},
..routine_no_path
};
let should_skip = !disabled_routine.enabled;
assert!(should_skip);
}
}
+3 -1
View File
@@ -37,6 +37,7 @@ use crate::channels::web::handlers::jobs::{
use crate::channels::web::handlers::skills::{ use crate::channels::web::handlers::skills::{
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler, skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
}; };
use crate::channels::web::handlers::webhooks::webhook_trigger_handler;
use crate::channels::web::log_layer::LogBroadcaster; use crate::channels::web::log_layer::LogBroadcaster;
use crate::channels::web::sse::SseManager; use crate::channels::web::sse::SseManager;
use crate::channels::web::types::*; use crate::channels::web::types::*;
@@ -200,7 +201,8 @@ pub async fn start_server(
// Public routes (no auth) // Public routes (no auth)
let public = Router::new() let public = Router::new()
.route("/api/health", get(health_handler)) .route("/api/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler)); .route("/oauth/callback", get(oauth_callback_handler))
.route("/api/webhooks/{path}", post(webhook_trigger_handler));
// Protected routes (require auth) // Protected routes (require auth)
let auth_state = AuthState { token: auth_token }; let auth_state = AuthState { token: auth_token };
-102
View File
@@ -169,18 +169,10 @@ pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
} }
// Naive with fractional seconds (legacy or SQLite datetime() output) // Naive with fractional seconds (legacy or SQLite datetime() output)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
tracing::warn!(
timestamp = s,
"parsing naive timestamp without timezone; assuming UTC — consider re-running migrations"
);
return Ok(ndt.and_utc()); return Ok(ndt.and_utc());
} }
// Naive without fractional seconds (legacy format) // Naive without fractional seconds (legacy format)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
tracing::warn!(
timestamp = s,
"parsing naive timestamp without timezone; assuming UTC — consider re-running migrations"
);
return Ok(ndt.and_utc()); return Ok(ndt.and_utc());
} }
Err(format!("unparseable timestamp: {:?}", s)) Err(format!("unparseable timestamp: {:?}", s))
@@ -518,98 +510,4 @@ mod tests {
); );
} }
} }
#[test]
fn test_parse_timestamp_rfc3339() {
use super::parse_timestamp;
// Standard RFC 3339 with Z suffix
let dt = parse_timestamp("2024-01-15T10:30:00.123Z").unwrap();
assert_eq!(
dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"2024-01-15T10:30:00.123Z"
);
// RFC 3339 with +00:00 offset
let dt = parse_timestamp("2024-01-15T10:30:00.000+00:00").unwrap();
assert_eq!(
dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"2024-01-15T10:30:00.000Z"
);
}
#[test]
fn test_parse_timestamp_naive_fallback() {
use super::parse_timestamp;
// Naive with fractional seconds (legacy datetime('now') output)
let dt = parse_timestamp("2024-01-15 10:30:00.123").unwrap();
assert_eq!(
dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"2024-01-15T10:30:00.123Z"
);
// Naive without fractional seconds
let dt = parse_timestamp("2024-01-15 10:30:00").unwrap();
assert_eq!(
dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"2024-01-15T10:30:00.000Z"
);
}
#[test]
fn test_parse_timestamp_invalid() {
use super::parse_timestamp;
assert!(parse_timestamp("not-a-timestamp").is_err());
assert!(parse_timestamp("").is_err());
}
#[tokio::test]
async fn test_default_timestamps_are_rfc3339() {
// Verify that DEFAULT column values produce RFC 3339 timestamps
// after the migration change from datetime('now') to strftime.
// Use file-based DB because in-memory doesn't share schema across connections.
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_ts.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let conn = backend.connect().await.unwrap();
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO conversations (id, channel, user_id) VALUES (?1, ?2, ?3)",
libsql::params![id.clone(), "test", "user1"],
)
.await
.unwrap();
let mut rows = conn
.query(
"SELECT started_at, last_activity FROM conversations WHERE id = ?1",
libsql::params![id],
)
.await
.unwrap();
let row = rows.next().await.unwrap().unwrap();
let started_at: String = row.get(0).unwrap();
let last_activity: String = row.get(1).unwrap();
// Must end with 'Z' (RFC 3339 UTC) and contain 'T' separator
assert!(
started_at.ends_with('Z') && started_at.contains('T'),
"started_at should be RFC 3339, got: {started_at}"
);
assert!(
last_activity.ends_with('Z') && last_activity.contains('T'),
"last_activity should be RFC 3339, got: {last_activity}"
);
// Must be parseable by the RFC 3339 parser directly (not just naive fallback)
use chrono::DateTime;
assert!(
DateTime::parse_from_rfc3339(&started_at).is_ok(),
"started_at not valid RFC 3339: {started_at}"
);
}
} }
+55 -55
View File
@@ -26,7 +26,7 @@ pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS _migrations ( CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) applied_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- ==================== Conversations ==================== -- ==================== Conversations ====================
@@ -36,8 +36,8 @@ CREATE TABLE IF NOT EXISTS conversations (
channel TEXT NOT NULL, channel TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
thread_id TEXT, thread_id TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), started_at TEXT NOT NULL DEFAULT (datetime('now')),
last_activity TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), last_activity TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT NOT NULL DEFAULT '{}' metadata TEXT NOT NULL DEFAULT '{}'
); );
@@ -59,7 +59,7 @@ CREATE TABLE IF NOT EXISTS conversation_messages (
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation
@@ -91,7 +91,7 @@ CREATE TABLE IF NOT EXISTS agent_jobs (
failure_reason TEXT, failure_reason TEXT,
stuck_since TEXT, stuck_since TEXT,
repair_attempts INTEGER NOT NULL DEFAULT 0, repair_attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT, started_at TEXT,
completed_at TEXT completed_at TEXT
); );
@@ -116,7 +116,7 @@ CREATE TABLE IF NOT EXISTS job_actions (
duration_ms INTEGER, duration_ms INTEGER,
success INTEGER NOT NULL, success INTEGER NOT NULL,
error_message TEXT, error_message TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(job_id, sequence_num) UNIQUE(job_id, sequence_num)
); );
@@ -137,8 +137,8 @@ CREATE TABLE IF NOT EXISTS dynamic_tools (
failure_count INTEGER NOT NULL DEFAULT 0, failure_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT, last_error TEXT,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_dynamic_tools_status ON dynamic_tools(status); CREATE INDEX IF NOT EXISTS idx_dynamic_tools_status ON dynamic_tools(status);
@@ -156,7 +156,7 @@ CREATE TABLE IF NOT EXISTS llm_calls (
output_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL,
cost TEXT NOT NULL, cost TEXT NOT NULL,
purpose TEXT, purpose TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_llm_calls_job ON llm_calls(job_id); CREATE INDEX IF NOT EXISTS idx_llm_calls_job ON llm_calls(job_id);
@@ -176,7 +176,7 @@ CREATE TABLE IF NOT EXISTS estimation_snapshots (
actual_time_secs INTEGER, actual_time_secs INTEGER,
estimated_value TEXT NOT NULL, estimated_value TEXT NOT NULL,
actual_value TEXT, actual_value TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_estimation_category ON estimation_snapshots(category); CREATE INDEX IF NOT EXISTS idx_estimation_category ON estimation_snapshots(category);
@@ -192,7 +192,7 @@ CREATE TABLE IF NOT EXISTS repair_attempts (
action_taken TEXT NOT NULL, action_taken TEXT NOT NULL,
success INTEGER NOT NULL, success INTEGER NOT NULL,
error_message TEXT, error_message TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_repair_attempts_target ON repair_attempts(target_type, target_id); CREATE INDEX IF NOT EXISTS idx_repair_attempts_target ON repair_attempts(target_type, target_id);
@@ -206,8 +206,8 @@ CREATE TABLE IF NOT EXISTS memory_documents (
agent_id TEXT, agent_id TEXT,
path TEXT NOT NULL, path TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT NOT NULL DEFAULT '{}', metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE (user_id, agent_id, path) UNIQUE (user_id, agent_id, path)
); );
@@ -222,7 +222,7 @@ CREATE TRIGGER IF NOT EXISTS update_memory_documents_updated_at
FOR EACH ROW FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at WHEN NEW.updated_at = OLD.updated_at
BEGIN BEGIN
UPDATE memory_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = NEW.id; UPDATE memory_documents SET updated_at = datetime('now') WHERE id = NEW.id;
END; END;
-- ==================== Workspace: Memory Chunks ==================== -- ==================== Workspace: Memory Chunks ====================
@@ -234,7 +234,7 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
chunk_index INTEGER NOT NULL, chunk_index INTEGER NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
embedding BLOB, embedding BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (document_id, chunk_index) UNIQUE (document_id, chunk_index)
); );
@@ -296,8 +296,8 @@ CREATE TABLE IF NOT EXISTS secrets (
expires_at TEXT, expires_at TEXT,
last_used_at TEXT, last_used_at TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
@@ -318,8 +318,8 @@ CREATE TABLE IF NOT EXISTS wasm_tools (
source_url TEXT, source_url TEXT,
trust_level TEXT NOT NULL DEFAULT 'user', trust_level TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name, version) UNIQUE (user_id, name, version)
); );
@@ -340,8 +340,8 @@ CREATE TABLE IF NOT EXISTS wasm_channels (
binary_hash BLOB NOT NULL, binary_hash BLOB NOT NULL,
capabilities_json TEXT NOT NULL DEFAULT '{}', capabilities_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
@@ -359,8 +359,8 @@ CREATE TABLE IF NOT EXISTS tool_capabilities (
max_response_body_bytes INTEGER NOT NULL DEFAULT 10485760, max_response_body_bytes INTEGER NOT NULL DEFAULT 10485760,
workspace_read_prefixes TEXT NOT NULL DEFAULT '[]', workspace_read_prefixes TEXT NOT NULL DEFAULT '[]',
http_timeout_secs INTEGER NOT NULL DEFAULT 30, http_timeout_secs INTEGER NOT NULL DEFAULT 30,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (wasm_tool_id) UNIQUE (wasm_tool_id)
); );
@@ -373,7 +373,7 @@ CREATE TABLE IF NOT EXISTS leak_detection_patterns (
severity TEXT NOT NULL DEFAULT 'high', severity TEXT NOT NULL DEFAULT 'high',
action TEXT NOT NULL DEFAULT 'block', action TEXT NOT NULL DEFAULT 'block',
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- ==================== Rate Limit State ==================== -- ==================== Rate Limit State ====================
@@ -382,9 +382,9 @@ CREATE TABLE IF NOT EXISTS tool_rate_limit_state (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE, wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
minute_window_start TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), minute_window_start TEXT NOT NULL DEFAULT (datetime('now')),
minute_count INTEGER NOT NULL DEFAULT 0, minute_count INTEGER NOT NULL DEFAULT 0,
hour_window_start TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), hour_window_start TEXT NOT NULL DEFAULT (datetime('now')),
hour_count INTEGER NOT NULL DEFAULT 0, hour_count INTEGER NOT NULL DEFAULT 0,
UNIQUE (wasm_tool_id, user_id) UNIQUE (wasm_tool_id, user_id)
); );
@@ -400,7 +400,7 @@ CREATE TABLE IF NOT EXISTS secret_usage_log (
target_path TEXT, target_path TEXT,
success INTEGER NOT NULL, success INTEGER NOT NULL,
error_message TEXT, error_message TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_secret_usage_user ON secret_usage_log(user_id); CREATE INDEX IF NOT EXISTS idx_secret_usage_user ON secret_usage_log(user_id);
@@ -415,7 +415,7 @@ CREATE TABLE IF NOT EXISTS leak_detection_events (
source TEXT NOT NULL, source TEXT NOT NULL,
action_taken TEXT NOT NULL, action_taken TEXT NOT NULL,
context_preview TEXT, context_preview TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- ==================== Tool Failures ==================== -- ==================== Tool Failures ====================
@@ -425,8 +425,8 @@ CREATE TABLE IF NOT EXISTS tool_failures (
tool_name TEXT NOT NULL UNIQUE, tool_name TEXT NOT NULL UNIQUE,
error_message TEXT, error_message TEXT,
error_count INTEGER DEFAULT 1, error_count INTEGER DEFAULT 1,
first_failure TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), first_failure TEXT DEFAULT (datetime('now')),
last_failure TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), last_failure TEXT DEFAULT (datetime('now')),
last_build_result TEXT, last_build_result TEXT,
repaired_at TEXT, repaired_at TEXT,
repair_attempts INTEGER DEFAULT 0 repair_attempts INTEGER DEFAULT 0
@@ -441,7 +441,7 @@ CREATE TABLE IF NOT EXISTS job_events (
job_id TEXT NOT NULL REFERENCES agent_jobs(id), job_id TEXT NOT NULL REFERENCES agent_jobs(id),
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
data TEXT NOT NULL, data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id, id); CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id, id);
@@ -471,8 +471,8 @@ CREATE TABLE IF NOT EXISTS routines (
next_fire_at TEXT, next_fire_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0, run_count INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0, consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
@@ -485,13 +485,13 @@ CREATE TABLE IF NOT EXISTS routine_runs (
routine_id TEXT NOT NULL REFERENCES routines(id) ON DELETE CASCADE, routine_id TEXT NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL, trigger_type TEXT NOT NULL,
trigger_detail TEXT, trigger_detail TEXT,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT, completed_at TEXT,
status TEXT NOT NULL DEFAULT 'running', status TEXT NOT NULL DEFAULT 'running',
result_summary TEXT, result_summary TEXT,
tokens_used INTEGER, tokens_used INTEGER,
job_id TEXT REFERENCES agent_jobs(id), job_id TEXT REFERENCES agent_jobs(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id); CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id);
@@ -502,7 +502,7 @@ CREATE TABLE IF NOT EXISTS settings (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key) PRIMARY KEY (user_id, key)
); );
@@ -558,24 +558,24 @@ CREATE INDEX IF NOT EXISTS idx_heartbeat_next_run ON heartbeat_state(next_run);
-- Pre-populate leak detection patterns (matches PostgreSQL V2 migration). -- Pre-populate leak detection patterns (matches PostgreSQL V2 migration).
INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES
('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])', 'high', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440005', 'github_token', 'gh[pousr]_[A-Za-z0-9_]{36,}', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440005', 'github_token', 'gh[pousr]_[A-Za-z0-9_]{36,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440006', 'github_fine_grained_pat', 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440006', 'github_fine_grained_pat', 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440007', 'stripe_api_key', 'sk_(?:live|test)_[a-zA-Z0-9]{24,}', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440007', 'stripe_api_key', 'sk_(?:live|test)_[a-zA-Z0-9]{24,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440008', 'nearai_session', 'sess_[a-zA-Z0-9]{32,}', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440008', 'nearai_session', 'sess_[a-zA-Z0-9]{32,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440009', 'bearer_token', 'Bearer\s+[a-zA-Z0-9_-]{20,}', 'high', 'redact', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440009', 'bearer_token', 'Bearer\s+[a-zA-Z0-9_-]{20,}', 'high', 'redact', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000a', 'pem_private_key', '-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-44665544000a', 'pem_private_key', '-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000b', 'ssh_private_key', '-----BEGIN\s+(?:OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----', 'critical', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-44665544000b', 'ssh_private_key', '-----BEGIN\s+(?:OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000c', 'google_api_key', 'AIza[0-9A-Za-z_-]{35}', 'high', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-44665544000c', 'google_api_key', 'AIza[0-9A-Za-z_-]{35}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000d', 'slack_token', 'xox[baprs]-[0-9a-zA-Z-]{10,}', 'high', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-44665544000d', 'slack_token', 'xox[baprs]-[0-9a-zA-Z-]{10,}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000e', 'discord_token', '[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}', 'high', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-44665544000e', 'discord_token', '[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000f', 'twilio_api_key', 'SK[a-fA-F0-9]{32}', 'high', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-44665544000f', 'twilio_api_key', 'SK[a-fA-F0-9]{32}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440010', 'sendgrid_api_key', 'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}', 'high', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440010', 'sendgrid_api_key', 'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); ('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, datetime('now'));
"#; "#;
@@ -613,7 +613,7 @@ CREATE TABLE IF NOT EXISTS memory_chunks_new (
chunk_index INTEGER NOT NULL, chunk_index INTEGER NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
embedding BLOB, embedding BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (document_id, chunk_index) UNIQUE (document_id, chunk_index)
); );
+345 -23
View File
@@ -1,11 +1,53 @@
//! Time utility tool. //! Time utility tool.
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, FixedOffset, Utc};
use chrono_tz::Tz;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
/// Parse a timezone string into a `chrono_tz::Tz`, returning a clear error.
fn parse_timezone(tz_str: &str) -> Result<Tz, ToolError> {
tz_str.parse::<Tz>().map_err(|_| {
ToolError::InvalidParameters(format!(
"Unknown timezone '{}'. Use IANA names like 'America/New_York' or 'Europe/London'.",
tz_str
))
})
}
/// Parse an input timestamp string. Accepts RFC 3339 with offset, or naive
/// datetime in `YYYY-MM-DDTHH:MM:SS` / `YYYY-MM-DD HH:MM:SS` format
/// (interpreted as UTC unless `default_tz` is provided).
fn parse_input_timestamp(
input: &str,
default_tz: Option<Tz>,
) -> Result<DateTime<FixedOffset>, ToolError> {
// Try RFC 3339 first (has offset info)
if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
return Ok(dt);
}
// Try common formats without offset — interpret in default_tz or UTC
for fmt in &["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(input, fmt) {
let tz = default_tz.unwrap_or(Tz::UTC);
let local = naive.and_local_timezone(tz).single().ok_or_else(|| {
ToolError::InvalidParameters(format!(
"Ambiguous or invalid datetime '{}' in timezone '{}'",
input, tz
))
})?;
return Ok(local.fixed_offset());
}
}
Err(ToolError::InvalidParameters(format!(
"Invalid timestamp '{}'. Use RFC 3339 (e.g. '2026-03-07T12:00:00Z') \
or 'YYYY-MM-DD HH:MM:SS' format.",
input
)))
}
/// Tool for getting current time and date operations. /// Tool for getting current time and date operations.
pub struct TimeTool; pub struct TimeTool;
@@ -16,7 +58,7 @@ impl Tool for TimeTool {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Get current time, convert timezones, or calculate time differences." "Get current time, convert timezones, format timestamps, or calculate time differences."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -25,20 +67,28 @@ impl Tool for TimeTool {
"properties": { "properties": {
"operation": { "operation": {
"type": "string", "type": "string",
"enum": ["now", "parse", "format", "diff"], "enum": ["now", "parse", "convert", "format", "diff"],
"description": "The time operation to perform" "description": "The time operation to perform"
}, },
"timestamp": { "timestamp": {
"type": "string", "type": "string",
"description": "ISO 8601 timestamp (for parse/format/diff operations)" "description": "ISO 8601 timestamp (for parse/convert/format/diff operations)"
},
"format": {
"type": "string",
"description": "Output format string (for format operation)"
}, },
"timestamp2": { "timestamp2": {
"type": "string", "type": "string",
"description": "Second timestamp (for diff operation)" "description": "Second timestamp (for diff operation)"
},
"timezone": {
"type": "string",
"description": "IANA timezone name, e.g. 'America/New_York' (for now/convert/format/parse)"
},
"to_timezone": {
"type": "string",
"description": "Target IANA timezone for convert operation"
},
"format_string": {
"type": "string",
"description": "strftime format string (for format operation), default: '%Y-%m-%d %H:%M:%S %Z'"
} }
}, },
"required": ["operation"] "required": ["operation"]
@@ -57,36 +107,91 @@ impl Tool for TimeTool {
let result = match operation { let result = match operation {
"now" => { "now" => {
let now = Utc::now(); let now = Utc::now();
serde_json::json!({ let mut result = serde_json::json!({
"utc_iso": now.to_rfc3339(),
"iso": now.to_rfc3339(), "iso": now.to_rfc3339(),
"unix": now.timestamp(), "unix": now.timestamp(),
"unix_millis": now.timestamp_millis() "unix_millis": now.timestamp_millis()
}) });
if let Some(tz_str) = params.get("timezone").and_then(|v| v.as_str()) {
let tz = parse_timezone(tz_str)?;
let local = now.with_timezone(&tz);
result["local_iso"] = serde_json::json!(local.to_rfc3339());
result["timezone"] = serde_json::json!(tz_str);
}
result
} }
"parse" => { "parse" => {
let timestamp = require_str(&params, "timestamp")?; let timestamp = require_str(&params, "timestamp")?;
let tz = params
.get("timezone")
.and_then(|v| v.as_str())
.map(parse_timezone)
.transpose()?;
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| { let dt = parse_input_timestamp(timestamp, tz)?;
ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) let utc = dt.with_timezone(&Utc);
})?;
let mut result = serde_json::json!({
"iso": utc.to_rfc3339(),
"unix": utc.timestamp(),
"unix_millis": utc.timestamp_millis()
});
if let Some(tz) = tz {
let local = dt.with_timezone(&tz);
result["local_iso"] = serde_json::json!(local.to_rfc3339());
result["timezone"] = serde_json::json!(tz.to_string());
}
result
}
"convert" => {
let timestamp = require_str(&params, "timestamp")?;
let to_tz_str = require_str(&params, "to_timezone")?;
let to_tz = parse_timezone(to_tz_str)?;
let from_tz = params
.get("timezone")
.and_then(|v| v.as_str())
.map(parse_timezone)
.transpose()?;
let dt = parse_input_timestamp(timestamp, from_tz)?;
let converted = dt.with_timezone(&to_tz);
serde_json::json!({ serde_json::json!({
"iso": dt.to_rfc3339(), "input": timestamp,
"unix": dt.timestamp(), "output": converted.to_rfc3339(),
"unix_millis": dt.timestamp_millis() "timezone": to_tz.to_string()
}) })
} }
"format" => {
let timestamp = require_str(&params, "timestamp")?;
let fmt = params
.get("format_string")
.and_then(|v| v.as_str())
.unwrap_or("%Y-%m-%d %H:%M:%S %Z");
let tz = params
.get("timezone")
.and_then(|v| v.as_str())
.map(parse_timezone)
.transpose()?;
let dt = parse_input_timestamp(timestamp, None)?;
let formatted = if let Some(tz) = tz {
dt.with_timezone(&tz).format(fmt).to_string()
} else {
dt.format(fmt).to_string()
};
serde_json::json!({ "formatted": formatted })
}
"diff" => { "diff" => {
let ts1 = require_str(&params, "timestamp")?; let ts1 = require_str(&params, "timestamp")?;
let ts2 = require_str(&params, "timestamp2")?; let ts2 = require_str(&params, "timestamp2")?;
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| { let dt1 = parse_input_timestamp(ts1, None)?;
ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) let dt2 = parse_input_timestamp(ts2, None)?;
})?;
let dt2: DateTime<Utc> = ts2.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp2: {}", e))
})?;
let diff = dt2.signed_duration_since(dt1); let diff = dt2.signed_duration_since(dt1);
@@ -112,3 +217,220 @@ impl Tool for TimeTool {
false // Internal tool, no external data false // Internal tool, no external data
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::context::JobContext;
use serde_json::json;
fn test_ctx() -> JobContext {
JobContext::new("test-job", "test time tool")
}
#[tokio::test]
async fn test_now_utc() {
let tool = TimeTool;
let result = tool
.execute(json!({"operation": "now"}), &test_ctx())
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
assert!(v["utc_iso"].as_str().is_some());
assert!(v["iso"].as_str().is_some());
assert!(v["unix"].as_i64().is_some());
// No timezone requested — no local_iso
assert!(v.get("local_iso").is_none());
}
#[tokio::test]
async fn test_now_with_timezone() {
let tool = TimeTool;
let result = tool
.execute(
json!({"operation": "now", "timezone": "America/New_York"}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
assert!(v["local_iso"].as_str().is_some());
assert_eq!(v["timezone"].as_str().unwrap(), "America/New_York");
// local_iso should contain a non-UTC offset
let local = v["local_iso"].as_str().unwrap();
assert!(!local.ends_with('Z') || local.contains("-04:00") || local.contains("-05:00"));
}
#[tokio::test]
async fn test_now_invalid_timezone() {
let tool = TimeTool;
let result = tool
.execute(
json!({"operation": "now", "timezone": "Not/A/Zone"}),
&test_ctx(),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Unknown timezone"));
assert!(err.to_string().contains("Not/A/Zone"));
}
#[tokio::test]
async fn test_convert_timezone() {
let tool = TimeTool;
let result = tool
.execute(
json!({
"operation": "convert",
"timestamp": "2026-03-07T12:00:00Z",
"to_timezone": "Asia/Tokyo"
}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
// UTC 12:00 -> JST 21:00 (UTC+9)
let output = v["output"].as_str().unwrap();
assert!(output.contains("21:00:00"));
assert_eq!(v["timezone"].as_str().unwrap(), "Asia/Tokyo");
}
#[tokio::test]
async fn test_convert_dst_boundary() {
let tool = TimeTool;
// US spring forward: 2026-03-08 2:00 AM EST -> 3:00 AM EDT
// Before DST: EST = UTC-5, After: EDT = UTC-4
let result = tool
.execute(
json!({
"operation": "convert",
"timestamp": "2026-03-08T06:30:00Z",
"to_timezone": "America/New_York"
}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
// UTC 06:30 on Mar 8 -> after spring forward, EDT (UTC-4) = 02:30
// But DST springs forward at 2 AM -> 3 AM, so 06:30 UTC = 01:30 EST or 02:30 EDT
let output = v["output"].as_str().unwrap();
assert!(output.contains("2026-03-08"));
}
#[tokio::test]
async fn test_format_with_timezone() {
let tool = TimeTool;
let result = tool
.execute(
json!({
"operation": "format",
"timestamp": "2026-03-07T12:00:00Z",
"timezone": "Europe/London",
"format_string": "%Y-%m-%d %H:%M %Z"
}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
let formatted = v["formatted"].as_str().unwrap();
assert!(formatted.contains("2026-03-07"));
assert!(formatted.contains("12:00")); // London = UTC in March (before DST)
assert!(formatted.contains("GMT"));
}
#[tokio::test]
async fn test_format_default_format_string() {
let tool = TimeTool;
let result = tool
.execute(
json!({
"operation": "format",
"timestamp": "2026-06-15T18:30:00Z",
"timezone": "America/Los_Angeles"
}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
let formatted = v["formatted"].as_str().unwrap();
// UTC 18:30 -> PDT (UTC-7) = 11:30
assert!(formatted.contains("11:30:00"));
assert!(formatted.contains("PDT"));
}
#[tokio::test]
async fn test_parse_naive_with_timezone() {
let tool = TimeTool;
let result = tool
.execute(
json!({
"operation": "parse",
"timestamp": "2026-03-07 09:00:00",
"timezone": "America/New_York"
}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
// 09:00 EST = 14:00 UTC (EST = UTC-5 in March before DST)
let iso = v["iso"].as_str().unwrap();
assert!(iso.contains("14:00:00"));
assert_eq!(v["timezone"].as_str().unwrap(), "America/New_York");
}
#[tokio::test]
async fn test_diff() {
let tool = TimeTool;
let result = tool
.execute(
json!({
"operation": "diff",
"timestamp": "2026-03-07T00:00:00Z",
"timestamp2": "2026-03-07T02:30:00Z"
}),
&test_ctx(),
)
.await
.unwrap();
let v: serde_json::Value = result.result.clone();
assert_eq!(v["hours"].as_i64().unwrap(), 2);
assert_eq!(v["minutes"].as_i64().unwrap(), 150);
assert_eq!(v["seconds"].as_i64().unwrap(), 9000);
}
#[tokio::test]
async fn test_convert_missing_to_timezone() {
let tool = TimeTool;
let result = tool
.execute(
json!({
"operation": "convert",
"timestamp": "2026-03-07T12:00:00Z"
}),
&test_ctx(),
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_unknown_operation() {
let tool = TimeTool;
let result = tool
.execute(json!({"operation": "explode"}), &test_ctx())
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unknown operation")
);
}
}