mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
fix: persist turns after approval and add agent-level tests (#250)
* fix: persist turns after approval and add agent-level tests Port relevant changes from PR #112 that were not carried over to #237: - Add persist_turn calls in process_approval for the response, error, and auth-required paths. Previously, turns completed after tool approval were never persisted to DB — if the process crashed after approval the entire turn (user message + assistant response) was lost. - Add agent-level unit tests: StaticLlmProvider mock, make_test_agent helper, tests for auto-approval logic, destructive shell command detection, and PendingApproval backward-compatible deserialization (without deferred_tool_calls field). - Remove unused _thread_state binding in process_approval. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address 14 audit findings in src/agent/ Audit of the agent module found 2 High, 7 Medium, 3 Low, and 2 Nit severity issues. This commit fixes all of them: High: - Remove 4 `.expect()` calls in session.rs (entry API, match, direct indexing, if-let) to eliminate panic paths in production - Add typed RoutineError enum replacing Result<_, String> across routine.rs, routine_engine.rs, and callers in history/store.rs and db/libsql/mod.rs Medium: - Sanitize routine names in path construction to prevent directory traversal (routine_engine.rs) - Log warnings for 5 silently-swallowed errors in scheduler.rs, compaction.rs, and worker.rs - Extract shared handle_auth_intercept helper to deduplicate auth interception in thread_ops.rs - Add session count warning threshold in session_manager.rs - Make FullJob stub degradation visible via warn-level log and prepended warning in output Low: - Restrict dead code visibility with #[cfg(test)] on 19 unused items in submission.rs, task.rs, and undo.rs - Narrow pub to pub(crate) on self_repair.rs builder methods - Remove TaskStatus from mod.rs re-exports (test-only type) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review comments - Reorder persist_turn before persist_response_chain so the conversation row exists before the metadata UPDATE runs - Add persist_response_chain call to handle_auth_intercept so auth-required paths preserve the response chain - Harden sanitize_routine_name to use allowlist (alphanumeric, dash, underscore) instead of denylist replacements - Fix stale active_thread ID in get_or_create_thread: fall back to create_thread() when the stored ID is missing from the map - Persist turn on approval rejection so user messages survive crashes after a tool is rejected Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
97a7637f30
commit
3f135bdde9
+38
-13
@@ -26,6 +26,8 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::RoutineError;
|
||||
|
||||
/// A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Routine {
|
||||
@@ -86,13 +88,16 @@ impl Trigger {
|
||||
}
|
||||
|
||||
/// Parse a trigger from its DB representation.
|
||||
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, String> {
|
||||
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
|
||||
match trigger_type {
|
||||
"cron" => {
|
||||
let schedule = config
|
||||
.get("schedule")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("cron trigger missing 'schedule'")?
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "cron trigger".into(),
|
||||
field: "schedule".into(),
|
||||
})?
|
||||
.to_string();
|
||||
Ok(Trigger::Cron { schedule })
|
||||
}
|
||||
@@ -100,7 +105,10 @@ impl Trigger {
|
||||
let pattern = config
|
||||
.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("event trigger missing 'pattern'")?
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "event trigger".into(),
|
||||
field: "pattern".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let channel = config
|
||||
.get("channel")
|
||||
@@ -120,7 +128,9 @@ impl Trigger {
|
||||
Ok(Trigger::Webhook { path, secret })
|
||||
}
|
||||
"manual" => Ok(Trigger::Manual),
|
||||
other => Err(format!("unknown trigger type: {other}")),
|
||||
other => Err(RoutineError::UnknownTriggerType {
|
||||
trigger_type: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,13 +196,16 @@ impl RoutineAction {
|
||||
}
|
||||
|
||||
/// Parse an action from its DB representation.
|
||||
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, String> {
|
||||
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
|
||||
match action_type {
|
||||
"lightweight" => {
|
||||
let prompt = config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("lightweight action missing 'prompt'")?
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "lightweight action".into(),
|
||||
field: "prompt".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let context_paths = config
|
||||
.get("context_paths")
|
||||
@@ -217,12 +230,18 @@ impl RoutineAction {
|
||||
let title = config
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("full_job action missing 'title'")?
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "full_job action".into(),
|
||||
field: "title".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let description = config
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("full_job action missing 'description'")?
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "full_job action".into(),
|
||||
field: "description".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let max_iterations = config
|
||||
.get("max_iterations")
|
||||
@@ -235,7 +254,9 @@ impl RoutineAction {
|
||||
max_iterations,
|
||||
})
|
||||
}
|
||||
other => Err(format!("unknown action type: {other}")),
|
||||
other => Err(RoutineError::UnknownActionType {
|
||||
action_type: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,14 +355,16 @@ impl std::fmt::Display for RunStatus {
|
||||
}
|
||||
|
||||
impl FromStr for RunStatus {
|
||||
type Err = String;
|
||||
type Err = RoutineError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"running" => Ok(RunStatus::Running),
|
||||
"ok" => Ok(RunStatus::Ok),
|
||||
"attention" => Ok(RunStatus::Attention),
|
||||
"failed" => Ok(RunStatus::Failed),
|
||||
other => Err(format!("unknown run status: {other}")),
|
||||
other => Err(RoutineError::UnknownRunStatus {
|
||||
status: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,9 +393,11 @@ pub fn content_hash(content: &str) -> u64 {
|
||||
}
|
||||
|
||||
/// Parse a cron expression and compute the next fire time from now.
|
||||
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, String> {
|
||||
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
|
||||
let cron_schedule =
|
||||
cron::Schedule::from_str(schedule).map_err(|e| format!("invalid cron: {e}"))?;
|
||||
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
Ok(cron_schedule.upcoming(Utc).next())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user