mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8731bb68ae | ||
|
|
c41e9c899f | ||
|
|
9ea89e5bc6 |
Generated
+29
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¶ms, "timestamp")?;
|
let timestamp = require_str(¶ms, "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(¶ms, "timestamp")?;
|
||||||
|
let to_tz_str = require_str(¶ms, "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(¶ms, "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(¶ms, "timestamp")?;
|
let ts1 = require_str(¶ms, "timestamp")?;
|
||||||
|
|
||||||
let ts2 = require_str(¶ms, "timestamp2")?;
|
let ts2 = require_str(¶ms, "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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user