mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 08:59:31 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5677e5e955 | ||
|
|
91e0c2ee62 |
@@ -1150,11 +1150,9 @@ pub fn spawn_cron_ticker(
|
|||||||
interval: Duration,
|
interval: Duration,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Run one check immediately so routines due at startup don't wait
|
|
||||||
// an extra full polling interval.
|
|
||||||
engine.check_cron_triggers().await;
|
|
||||||
|
|
||||||
let mut ticker = tokio::time::interval(interval);
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
// Skip immediate first tick
|
||||||
|
ticker.tick().await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
@@ -1360,11 +1358,4 @@ mod tests {
|
|||||||
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
|
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
|
||||||
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
|
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncate_adds_ellipsis_when_over_limit() {
|
|
||||||
let input = "abcdefghijk";
|
|
||||||
let out = super::truncate(input, 5);
|
|
||||||
assert_eq!(out, "abcde...");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
use crate::error::RoutineError;
|
use crate::error::RoutineError;
|
||||||
@@ -183,21 +182,12 @@ pub async fn routines_toggle_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||||
|
|
||||||
let was_enabled = routine.enabled;
|
|
||||||
// If a specific value was provided, use it; otherwise toggle.
|
// If a specific value was provided, use it; otherwise toggle.
|
||||||
routine.enabled = match body {
|
routine.enabled = match body {
|
||||||
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
|
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
|
||||||
None => !routine.enabled,
|
None => !routine.enabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
if routine.enabled
|
|
||||||
&& !was_enabled
|
|
||||||
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
|
|
||||||
{
|
|
||||||
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
store
|
store
|
||||||
.update_routine(&routine)
|
.update_routine(&routine)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ use tower_http::set_header::SetResponseHeaderLayer;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agent::SessionManager;
|
use crate::agent::SessionManager;
|
||||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::channels::IncomingMessage;
|
use crate::channels::IncomingMessage;
|
||||||
use crate::channels::relay::DEFAULT_RELAY_NAME;
|
use crate::channels::relay::DEFAULT_RELAY_NAME;
|
||||||
@@ -2417,21 +2416,12 @@ async fn routines_toggle_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||||
|
|
||||||
let was_enabled = routine.enabled;
|
|
||||||
// If a specific value was provided, use it; otherwise toggle.
|
// If a specific value was provided, use it; otherwise toggle.
|
||||||
routine.enabled = match body {
|
routine.enabled = match body {
|
||||||
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
|
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
|
||||||
None => !routine.enabled,
|
None => !routine.enabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
if routine.enabled
|
|
||||||
&& !was_enabled
|
|
||||||
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
|
|
||||||
{
|
|
||||||
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
store
|
store
|
||||||
.update_routine(&routine)
|
.update_routine(&routine)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -342,19 +342,8 @@ function connectSSE() {
|
|||||||
|
|
||||||
eventSource.addEventListener('approval_needed', (e) => {
|
eventSource.addEventListener('approval_needed', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
const hasThread = !!data.thread_id;
|
if (!isCurrentThread(data.thread_id)) return;
|
||||||
const forCurrentThread = !hasThread || isCurrentThread(data.thread_id);
|
showApproval(data);
|
||||||
|
|
||||||
if (forCurrentThread) {
|
|
||||||
showApproval(data);
|
|
||||||
} else {
|
|
||||||
// Keep thread list fresh when approval is requested in a background thread.
|
|
||||||
unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1);
|
|
||||||
debouncedLoadThreads();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extension setup flows can surface approvals while user is on Extensions tab.
|
|
||||||
if (currentTab === 'extensions') loadExtensions();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('auth_required', (e) => {
|
eventSource.addEventListener('auth_required', (e) => {
|
||||||
@@ -1002,10 +991,6 @@ function finalizeActivityGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showApproval(data) {
|
function showApproval(data) {
|
||||||
// Avoid duplicate cards on reconnect/history refresh.
|
|
||||||
const existing = document.querySelector('.approval-card[data-request-id="' + CSS.escape(data.request_id) + '"]');
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
const container = document.getElementById('chat-messages');
|
const container = document.getElementById('chat-messages');
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'approval-card';
|
card.className = 'approval-card';
|
||||||
|
|||||||
@@ -326,6 +326,24 @@ impl Database for LibSqlBackend {
|
|||||||
libsql_migrations::run_incremental(&conn).await?;
|
libsql_migrations::run_incremental(&conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown(&self) -> Result<(), DatabaseError> {
|
||||||
|
match self.db.flush_replicator().await {
|
||||||
|
Ok(Some(frame_no)) => {
|
||||||
|
tracing::debug!("libSQL replicator flushed at frame {}", frame_no);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::debug!("No libSQL replicator to flush, skipping shutdown sync");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(libsql::Error::SyncNotSupported(_)) => {
|
||||||
|
tracing::debug!("libSQL sync not supported, skipping flush on shutdown");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => Err(DatabaseError::from(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Row conversion helpers ====================
|
// ==================== Row conversion helpers ====================
|
||||||
|
|||||||
@@ -523,6 +523,13 @@ pub trait Database:
|
|||||||
{
|
{
|
||||||
/// Run schema migrations for this backend.
|
/// Run schema migrations for this backend.
|
||||||
async fn run_migrations(&self) -> Result<(), DatabaseError>;
|
async fn run_migrations(&self) -> Result<(), DatabaseError>;
|
||||||
|
|
||||||
|
/// Shutdown hook for backend-specific drain/flush behavior.
|
||||||
|
///
|
||||||
|
/// Default implementation is a no-op so existing backends remain compatible.
|
||||||
|
async fn shutdown(&self) -> Result<(), DatabaseError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ impl Database for PgBackend {
|
|||||||
async fn run_migrations(&self) -> Result<(), DatabaseError> {
|
async fn run_migrations(&self) -> Result<(), DatabaseError> {
|
||||||
self.store.run_migrations().await
|
self.store.run_migrations().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown(&self) -> Result<(), DatabaseError> {
|
||||||
|
self.store.pool().close();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ConversationStore ====================
|
// ==================== ConversationStore ====================
|
||||||
|
|||||||
@@ -672,6 +672,8 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
|
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
|
||||||
|
|
||||||
|
let db_for_shutdown = components.db.clone();
|
||||||
|
|
||||||
let deps = AgentDeps {
|
let deps = AgentDeps {
|
||||||
store: components.db,
|
store: components.db,
|
||||||
llm: components.llm,
|
llm: components.llm,
|
||||||
@@ -930,6 +932,12 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(db) = db_for_shutdown {
|
||||||
|
if let Err(e) = db.shutdown().await {
|
||||||
|
tracing::warn!("Failed to shutdown database cleanly: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::debug!("Agent shutdown complete");
|
tracing::debug!("Agent shutdown complete");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -397,6 +397,10 @@ impl Tool for ListDirTool {
|
|||||||
false // Directory listings are safe
|
false // Directory listings are safe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
}
|
||||||
|
|
||||||
fn domain(&self) -> ToolDomain {
|
fn domain(&self) -> ToolDomain {
|
||||||
ToolDomain::Container
|
ToolDomain::Container
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-34
@@ -398,7 +398,7 @@ impl Tool for HttpTool {
|
|||||||
"method": {
|
"method": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||||
"description": "HTTP method (default: GET)"
|
"description": "HTTP method"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -429,7 +429,7 @@ impl Tool for HttpTool {
|
|||||||
"description": "Save response body as raw bytes to this file path instead of returning it. Use for binary downloads (images, PDFs, etc.). The path must be under /tmp/."
|
"description": "Save response body as raw bytes to this file path instead of returning it. Use for binary downloads (images, PDFs, etc.). The path must be under /tmp/."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["url"]
|
"required": ["method", "url"]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ impl Tool for HttpTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let method = params["method"].as_str().unwrap_or("GET");
|
let method = require_str(¶ms, "method")?;
|
||||||
let method_upper = method.to_uppercase();
|
let method_upper = method.to_uppercase();
|
||||||
|
|
||||||
let url = require_str(¶ms, "url")?;
|
let url = require_str(¶ms, "url")?;
|
||||||
@@ -829,22 +829,18 @@ impl Tool for HttpTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
let has_credentials = crate::safety::params_contain_manual_credentials(params)
|
// 1. Manual auth headers/query params in LLM params
|
||||||
|| (self.credential_registry.as_ref().is_some_and(|registry| {
|
if crate::safety::params_contain_manual_credentials(params) {
|
||||||
extract_host_from_params(params)
|
|
||||||
.is_some_and(|host| registry.has_credentials_for_host(&host))
|
|
||||||
}));
|
|
||||||
|
|
||||||
if has_credentials {
|
|
||||||
return ApprovalRequirement::Always;
|
return ApprovalRequirement::Always;
|
||||||
}
|
}
|
||||||
|
// 2. Target host has credential mappings (will be auto-injected)
|
||||||
// GET requests (or missing method, since GET is the default) are low-risk
|
if let Some(ref registry) = self.credential_registry
|
||||||
let method = params["method"].as_str().unwrap_or("GET");
|
&& let Some(host) = extract_host_from_params(params)
|
||||||
if method.eq_ignore_ascii_case("GET") {
|
&& registry.has_credentials_for_host(&host)
|
||||||
return ApprovalRequirement::Never;
|
{
|
||||||
|
return ApprovalRequirement::Always;
|
||||||
}
|
}
|
||||||
|
// Default: outbound HTTP still needs approval unless auto-approved
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,22 +1063,12 @@ mod tests {
|
|||||||
// ── Approval requirement tests ──────────────────────────────────────
|
// ── Approval requirement tests ──────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_no_auth_headers_returns_never() {
|
fn test_no_auth_headers_returns_unless_auto_approved() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data"
|
"url": "https://api.example.com/data"
|
||||||
});
|
});
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_post_no_auth_headers_returns_unless_auto_approved() {
|
|
||||||
let tool = HttpTool::new();
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"method": "POST",
|
|
||||||
"url": "https://api.example.com/data"
|
|
||||||
});
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(¶ms),
|
tool.requires_approval(¶ms),
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
@@ -1166,18 +1152,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_non_auth_headers_return_never() {
|
fn test_non_auth_headers_return_unless_auto_approved() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": {"Content-Type": "application/json", "Accept": "text/html"}
|
"headers": {"Content-Type": "application/json", "Accept": "text/html"}
|
||||||
});
|
});
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
assert_eq!(
|
||||||
|
tool.requires_approval(¶ms),
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_empty_headers_return_never() {
|
fn test_empty_headers_return_unless_auto_approved() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
|
|
||||||
// Empty object
|
// Empty object
|
||||||
@@ -1186,7 +1175,10 @@ mod tests {
|
|||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": {}
|
"headers": {}
|
||||||
});
|
});
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
assert_eq!(
|
||||||
|
tool.requires_approval(¶ms),
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
);
|
||||||
|
|
||||||
// Empty array
|
// Empty array
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
@@ -1194,7 +1186,10 @@ mod tests {
|
|||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": []
|
"headers": []
|
||||||
});
|
});
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
assert_eq!(
|
||||||
|
tool.requires_approval(¶ms),
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credential registry approval tests ─────────────────────────────
|
// ── Credential registry approval tests ─────────────────────────────
|
||||||
@@ -1224,7 +1219,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_host_without_credential_mapping_returns_never() {
|
fn test_host_without_credential_mapping_returns_unless_auto_approved() {
|
||||||
use crate::tools::wasm::SharedCredentialRegistry;
|
use crate::tools::wasm::SharedCredentialRegistry;
|
||||||
|
|
||||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||||
@@ -1236,7 +1231,10 @@ mod tests {
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data"
|
"url": "https://api.example.com/data"
|
||||||
});
|
});
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
assert_eq!(
|
||||||
|
tool.requires_approval(¶ms),
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::builtin::path_utils::validate_path;
|
use crate::tools::builtin::path_utils::validate_path;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
/// Tool for analyzing images using a vision-capable model.
|
/// Tool for analyzing images using a vision-capable model.
|
||||||
pub struct ImageAnalyzeTool {
|
pub struct ImageAnalyzeTool {
|
||||||
@@ -86,6 +86,10 @@ impl Tool for ImageAnalyzeTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
}
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
fn requires_sanitization(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -181,7 +185,6 @@ impl Tool for ImageAnalyzeTool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::media_type_from_path;
|
use super::super::media_type_from_path;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -196,7 +199,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_requires_approval_returns_never() {
|
fn test_requires_approval_returns_unless_auto_approved() {
|
||||||
let tool = ImageAnalyzeTool::new(
|
let tool = ImageAnalyzeTool::new(
|
||||||
"https://api.example.com".to_string(),
|
"https://api.example.com".to_string(),
|
||||||
"test-key".to_string(),
|
"test-key".to_string(),
|
||||||
@@ -205,7 +208,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(&serde_json::json!({})),
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
ApprovalRequirement::Never
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::builtin::path_utils::validate_path;
|
use crate::tools::builtin::path_utils::validate_path;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
/// Tool for editing images using an AI image editing API.
|
/// Tool for editing images using an AI image editing API.
|
||||||
pub struct ImageEditTool {
|
pub struct ImageEditTool {
|
||||||
@@ -85,6 +85,10 @@ impl Tool for ImageEditTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
}
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
fn requires_sanitization(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -262,7 +266,6 @@ impl ImageEditTool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -277,7 +280,7 @@ mod tests {
|
|||||||
assert!(!tool.requires_sanitization());
|
assert!(!tool.requires_sanitization());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(&serde_json::json!({})),
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
ApprovalRequirement::Never
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
|
use crate::tools::tool::ApprovalRequirement;
|
||||||
use crate::tools::{Tool, ToolError, ToolOutput};
|
use crate::tools::{Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
/// Tool for generating images using FLUX or compatible image generation APIs.
|
/// Tool for generating images using FLUX or compatible image generation APIs.
|
||||||
@@ -86,6 +87,10 @@ impl Tool for ImageGenerateTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
}
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
fn requires_sanitization(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -181,7 +186,6 @@ impl Tool for ImageGenerateTool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tool_metadata() {
|
fn test_tool_metadata() {
|
||||||
@@ -193,7 +197,7 @@ mod tests {
|
|||||||
assert_eq!(tool.name(), "image_generate");
|
assert_eq!(tool.name(), "image_generate");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(&serde_json::json!({})),
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
ApprovalRequirement::Never
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
);
|
);
|
||||||
|
|
||||||
let schema = tool.parameters_schema();
|
let schema = tool.parameters_schema();
|
||||||
|
|||||||
+20
-20
@@ -539,6 +539,26 @@ impl Tool for MemoryTreeTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod path_routing_tests {
|
||||||
|
use super::looks_like_filesystem_path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_filesystem_paths() {
|
||||||
|
assert!(looks_like_filesystem_path("/Users/nige/file.md"));
|
||||||
|
assert!(looks_like_filesystem_path("C:\\Users\\nige\\file.md"));
|
||||||
|
assert!(looks_like_filesystem_path("D:/work/file.md"));
|
||||||
|
assert!(looks_like_filesystem_path("~/notes.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_workspace_memory_paths() {
|
||||||
|
assert!(!looks_like_filesystem_path("MEMORY.md"));
|
||||||
|
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
|
||||||
|
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "postgres"))]
|
#[cfg(all(test, feature = "postgres"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -616,23 +636,3 @@ mod tests {
|
|||||||
assert_eq!(schema["properties"]["depth"]["default"], 1);
|
assert_eq!(schema["properties"]["depth"]["default"], 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod path_routing_tests {
|
|
||||||
use super::looks_like_filesystem_path;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_filesystem_paths() {
|
|
||||||
assert!(looks_like_filesystem_path("/Users/nige/file.md"));
|
|
||||||
assert!(looks_like_filesystem_path("C:\\Users\\nige\\file.md"));
|
|
||||||
assert!(looks_like_filesystem_path("D:/work/file.md"));
|
|
||||||
assert!(looks_like_filesystem_path("~/notes.md"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allows_workspace_memory_paths() {
|
|
||||||
assert!(!looks_like_filesystem_path("MEMORY.md"));
|
|
||||||
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
|
|
||||||
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ mod support;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
|
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
|
||||||
use crate::support::mock_openai_server::{
|
use crate::support::mock_openai_server::{
|
||||||
MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall,
|
MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall,
|
||||||
@@ -149,115 +147,4 @@ mod tests {
|
|||||||
harness.shutdown().await;
|
harness.shutdown().await;
|
||||||
mock.shutdown().await;
|
mock.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn routines_toggle_reenable_cron_recomputes_next_fire_at() {
|
|
||||||
let mock = MockOpenAiServerBuilder::new()
|
|
||||||
.with_rule(MockOpenAiRule::on_user_contains(
|
|
||||||
"create cron routine",
|
|
||||||
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
|
|
||||||
"call_create_cron_1",
|
|
||||||
"routine_create",
|
|
||||||
serde_json::json!({
|
|
||||||
"name": "wf-cron-toggle-reenable",
|
|
||||||
"description": "Cron toggle regression test",
|
|
||||||
"trigger_type": "cron",
|
|
||||||
"schedule": "0 */5 * * * *",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"action_type": "lightweight",
|
|
||||||
"prompt": "noop"
|
|
||||||
}),
|
|
||||||
)]),
|
|
||||||
))
|
|
||||||
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
|
|
||||||
.start()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let harness =
|
|
||||||
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let thread_id = harness.create_thread().await;
|
|
||||||
harness.send_chat(&thread_id, "create cron routine").await;
|
|
||||||
harness
|
|
||||||
.wait_for_turns(&thread_id, 1, Duration::from_secs(10))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let routine = harness
|
|
||||||
.routine_by_name("wf-cron-toggle-reenable")
|
|
||||||
.await
|
|
||||||
.expect("routine should exist");
|
|
||||||
let routine_id = routine
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.expect("routine id missing");
|
|
||||||
|
|
||||||
let routine_uuid = Uuid::parse_str(routine_id).expect("valid routine uuid");
|
|
||||||
|
|
||||||
// Disable through the web toggle endpoint.
|
|
||||||
harness
|
|
||||||
.client
|
|
||||||
.post(format!(
|
|
||||||
"{}/api/routines/{routine_id}/toggle",
|
|
||||||
harness.base_url()
|
|
||||||
))
|
|
||||||
.bearer_auth(&harness.auth_token)
|
|
||||||
.json(&serde_json::json!({ "enabled": false }))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("disable toggle request failed")
|
|
||||||
.error_for_status()
|
|
||||||
.expect("disable toggle non-2xx");
|
|
||||||
|
|
||||||
// Simulate an unscheduled disabled cron routine (next_fire_at missing).
|
|
||||||
let mut stored = harness
|
|
||||||
.db
|
|
||||||
.get_routine(routine_uuid)
|
|
||||||
.await
|
|
||||||
.expect("db get_routine")
|
|
||||||
.expect("routine should still exist");
|
|
||||||
stored.next_fire_at = None;
|
|
||||||
harness
|
|
||||||
.db
|
|
||||||
.update_routine(&stored)
|
|
||||||
.await
|
|
||||||
.expect("db update_routine");
|
|
||||||
|
|
||||||
// Re-enable through the web toggle endpoint.
|
|
||||||
harness
|
|
||||||
.client
|
|
||||||
.post(format!(
|
|
||||||
"{}/api/routines/{routine_id}/toggle",
|
|
||||||
harness.base_url()
|
|
||||||
))
|
|
||||||
.bearer_auth(&harness.auth_token)
|
|
||||||
.json(&serde_json::json!({ "enabled": true }))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("enable toggle request failed")
|
|
||||||
.error_for_status()
|
|
||||||
.expect("enable toggle non-2xx");
|
|
||||||
|
|
||||||
let detail = harness
|
|
||||||
.client
|
|
||||||
.get(format!("{}/api/routines/{routine_id}", harness.base_url()))
|
|
||||||
.bearer_auth(&harness.auth_token)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("detail request failed")
|
|
||||||
.error_for_status()
|
|
||||||
.expect("detail non-2xx")
|
|
||||||
.json::<serde_json::Value>()
|
|
||||||
.await
|
|
||||||
.expect("invalid detail response");
|
|
||||||
|
|
||||||
assert_eq!(detail["enabled"].as_bool(), Some(true));
|
|
||||||
assert!(
|
|
||||||
detail["next_fire_at"].as_str().is_some(),
|
|
||||||
"expected next_fire_at to be recomputed when re-enabling cron routine, got {detail}"
|
|
||||||
);
|
|
||||||
|
|
||||||
harness.shutdown().await;
|
|
||||||
mock.shutdown().await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user