mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
* feat(routines): add approval context for autonomous job execution Routines and background jobs were unable to use any tools that required approval (file ops, shell, message, http), making them effectively useless. This adds an ApprovalContext system that lets autonomous jobs pre-authorize tools at dispatch time. - Add ApprovalContext enum with Autonomous variant that auto-approves UnlessAutoApproved tools and optionally pre-authorizes Always tools - Add tool_permissions field to RoutineAction::FullJob for pre-authorizing Always-gated tools (e.g. destructive shell, cross-channel messaging) - Add Scheduler::dispatch_job_with_context() to thread approval context through to workers - Set message tool default channel/target from routine NotifyConfig so routines can send results without cross-channel approval - Fix Completed→Completed state transition error in worker (plan marks job completed, then direct loop or outer run() tries again) Co-Authored-By: Claude Opus 4.6 <[email protected]> * test(routines): add E2E trace for routine news digest workflow Add a 3-turn trace fixture and test that exercises: - Turn 1: routine_create with full_job mode and tool_permissions - Turn 2: Simulated digest workflow with echo + memory_write - Turn 3: Verification via memory_search [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): wire RoutineEngine into test rig for routine_create E2E - Add `with_routines()` to TestRigBuilder that passes a RoutineConfig to Agent::new, enabling routine tool registration during agent startup - Add Turn 2 (routine_list) to the trace to verify routine persistence in the database after routine_create - Fix formatting issues flagged by CI (cargo fmt) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(scheduler): deduplicate dispatch_job and dispatch_job_with_context Extract shared logic into private `dispatch_job_inner` to prevent divergence when dispatch behavior changes in the future. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(routines): add routine_fire tool and real E2E routine execution test - Add `routine_fire` tool that calls `RoutineEngine::fire_manual` to trigger a routine on demand. Registered alongside the other 5 routine tools (now 6 total). - Rewrite the routine_news_digest E2E trace to exercise the full execution stack end-to-end: 1. routine_create (manual trigger, full_job, tool_permissions: [message]) 2. routine_fire → RoutineEngine → Scheduler::dispatch_job_with_context → autonomous Worker consuming TraceLlm steps 3. Worker calls echo → memory_write → message (broadcast to test channel) 4. Test verifies the message broadcast arrived, proving ApprovalContext correctly allowed the Always-approval message tool - Register message tools in TestRig so routines can send messages to the test channel via channel_manager.broadcast(). [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(routines): wire HttpInterceptor through scheduler for routine worker http calls Propagate http_interceptor from AgentDeps → Scheduler → WorkerDeps → JobContext so that routine workers (and any scheduler-dispatched workers) can use the ReplayingHttpInterceptor for mock HTTP responses during tests. Changes: - Add http_interceptor field to Scheduler and WorkerDeps - Set job_ctx.http_interceptor in Worker before tool execution - Add with_http_exchanges() builder method to TestRigBuilder - Replace echo tool with http tool in routine_news_digest trace - Test now exercises real http tool with mock response → memory_write → message Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments from Copilot on PR #577 - Extract `ApprovalContext::is_blocked_or_default()` helper to deduplicate approval check logic in worker.rs and scheduler.rs - Extract `parse_tool_permissions()` helper to deduplicate JSON array parsing in routine.rs and builtin/routine.rs - Fix test name: `test_mark_completed_twice_does_not_error` → `test_mark_completed_twice_returns_error` (matches actual behavior) - Fix ApprovalContext doc comment to clarify it only models autonomous mode - Fix flaky index-based assertion in routine_news_digest test — now uses content-based search instead of fixed position - Fix stale comment: echo → http in routine test header - Add TODO for subtask approval context propagation (latent, not in active code paths) - Add TODO for global message tool context race in routine_engine Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in is_blocked_or_default test Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test_rig): destructure self in build() to avoid partial-move fragility Destructure TestRigBuilder at the top of build() instead of accessing self.* fields after moving self.http_exchanges. While the prior code compiled (remaining fields are Copy), it was fragile and would break if any non-Copy field were added. Co-Authored-By: Claude Opus 4.6 <[email protected]> * docs: clarify that routine_fire bypasses cooldown Manual fires are explicitly user-initiated and intentionally bypass cooldown checks (which only apply to automated cron/event triggers). Updated tool description and fire_manual docstring to make this clear. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(routines): fix message tool approval in routine context Two fixes for message tool failures in autonomous routine jobs: 1. MessageTool::requires_approval() now returns UnlessAutoApproved when the explicit channel param matches the default channel (was Always, causing "requires authentication" errors for routine workers). 2. routine_create tool now accepts notify_channel and notify_user params, wired into NotifyConfig. Without these, routines had channel: None, so set_message_tool_context was never called, causing "No channel specified" errors. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(message): remove approval requirement from message tool The message tool only sends to user-owned channels via ChannelManager::broadcast (TUI, Telegram, Slack, web gateway, etc.). It cannot reach arbitrary external services, so approval adds friction with no security benefit. This also eliminates the routine context errors entirely since approval is never checked. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments — routine_fire approval + test rename - routine_fire now returns UnlessAutoApproved since firing a routine can dispatch a full_job with pre-authorized Always-gated tools - Rename test_approval_context_never_always_passes to test_approval_context_never_is_not_blocked for clarity Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review nits — update stale docs and comments - Remove 'message' from tool_permissions example (no longer Always) - Reword message tool approval comment for accuracy - Clarify with_routines() docstring re: tool registration vs engine wiring Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
558 lines
18 KiB
Rust
558 lines
18 KiB
Rust
//! Core types for the routines system.
|
|
//!
|
|
//! A routine is a named, persistent, user-owned task with a trigger and an action.
|
|
//! Each routine fires independently when its trigger condition is met, with only
|
|
//! that routine's prompt and context sent to the LLM.
|
|
//!
|
|
//! ```text
|
|
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
|
|
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
|
|
//! │ cron/event│ │guardrail│ │lightweight│full_job│
|
|
//! │ webhook │ │ check │ └──────────────────┘
|
|
//! │ manual │ └─────────┘ │
|
|
//! └──────────┘ ▼
|
|
//! ┌──────────────┐
|
|
//! │ Notify user │
|
|
//! │ if needed │
|
|
//! └──────────────┘
|
|
//! ```
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::str::FromStr;
|
|
use std::time::Duration;
|
|
|
|
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 {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub user_id: String,
|
|
pub enabled: bool,
|
|
pub trigger: Trigger,
|
|
pub action: RoutineAction,
|
|
pub guardrails: RoutineGuardrails,
|
|
pub notify: NotifyConfig,
|
|
|
|
// Runtime state (DB-managed)
|
|
pub last_run_at: Option<DateTime<Utc>>,
|
|
pub next_fire_at: Option<DateTime<Utc>>,
|
|
pub run_count: u64,
|
|
pub consecutive_failures: u32,
|
|
pub state: serde_json::Value,
|
|
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// When a routine should fire.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum Trigger {
|
|
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
|
|
Cron { schedule: String },
|
|
/// Fire when a channel message matches a pattern.
|
|
Event {
|
|
/// Optional channel filter (e.g. "telegram", "slack").
|
|
channel: Option<String>,
|
|
/// Regex pattern to match against message content.
|
|
pattern: String,
|
|
},
|
|
/// Fire on incoming webhook POST to /hooks/routine/{id}.
|
|
Webhook {
|
|
/// Optional webhook path suffix (defaults to routine id).
|
|
path: Option<String>,
|
|
/// Optional shared secret for HMAC validation.
|
|
secret: Option<String>,
|
|
},
|
|
/// Only fires via tool call or CLI.
|
|
Manual,
|
|
}
|
|
|
|
impl Trigger {
|
|
/// The string tag stored in the DB trigger_type column.
|
|
pub fn type_tag(&self) -> &'static str {
|
|
match self {
|
|
Trigger::Cron { .. } => "cron",
|
|
Trigger::Event { .. } => "event",
|
|
Trigger::Webhook { .. } => "webhook",
|
|
Trigger::Manual => "manual",
|
|
}
|
|
}
|
|
|
|
/// Parse a trigger from its DB representation.
|
|
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_else(|| RoutineError::MissingField {
|
|
context: "cron trigger".into(),
|
|
field: "schedule".into(),
|
|
})?
|
|
.to_string();
|
|
Ok(Trigger::Cron { schedule })
|
|
}
|
|
"event" => {
|
|
let pattern = config
|
|
.get("pattern")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| RoutineError::MissingField {
|
|
context: "event trigger".into(),
|
|
field: "pattern".into(),
|
|
})?
|
|
.to_string();
|
|
let channel = config
|
|
.get("channel")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
Ok(Trigger::Event { channel, pattern })
|
|
}
|
|
"webhook" => {
|
|
let path = config
|
|
.get("path")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
let secret = config
|
|
.get("secret")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
Ok(Trigger::Webhook { path, secret })
|
|
}
|
|
"manual" => Ok(Trigger::Manual),
|
|
other => Err(RoutineError::UnknownTriggerType {
|
|
trigger_type: other.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Serialize trigger-specific config to JSON for DB storage.
|
|
pub fn to_config_json(&self) -> serde_json::Value {
|
|
match self {
|
|
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }),
|
|
Trigger::Event { channel, pattern } => serde_json::json!({
|
|
"pattern": pattern,
|
|
"channel": channel,
|
|
}),
|
|
Trigger::Webhook { path, secret } => serde_json::json!({
|
|
"path": path,
|
|
"secret": secret,
|
|
}),
|
|
Trigger::Manual => serde_json::json!({}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What happens when a routine fires.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum RoutineAction {
|
|
/// Single LLM call, no tools. Cheap and fast.
|
|
Lightweight {
|
|
/// The prompt sent to the LLM.
|
|
prompt: String,
|
|
/// Workspace paths to load as context (e.g. ["context/priorities.md"]).
|
|
#[serde(default)]
|
|
context_paths: Vec<String>,
|
|
/// Max output tokens (default: 4096).
|
|
#[serde(default = "default_max_tokens")]
|
|
max_tokens: u32,
|
|
},
|
|
/// Full multi-turn worker job with tool access.
|
|
FullJob {
|
|
/// Job title for the scheduler.
|
|
title: String,
|
|
/// Job description / initial prompt.
|
|
description: String,
|
|
/// Max reasoning iterations (default: 10).
|
|
#[serde(default = "default_max_iterations")]
|
|
max_iterations: u32,
|
|
/// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
|
|
/// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
|
|
/// automatically permitted in routine jobs without listing them here.
|
|
#[serde(default)]
|
|
tool_permissions: Vec<String>,
|
|
},
|
|
}
|
|
|
|
fn default_max_tokens() -> u32 {
|
|
4096
|
|
}
|
|
|
|
fn default_max_iterations() -> u32 {
|
|
10
|
|
}
|
|
|
|
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
|
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
|
value
|
|
.get("tool_permissions")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_str().map(String::from))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
impl RoutineAction {
|
|
/// The string tag stored in the DB action_type column.
|
|
pub fn type_tag(&self) -> &'static str {
|
|
match self {
|
|
RoutineAction::Lightweight { .. } => "lightweight",
|
|
RoutineAction::FullJob { .. } => "full_job",
|
|
}
|
|
}
|
|
|
|
/// Parse an action from its DB representation.
|
|
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_else(|| RoutineError::MissingField {
|
|
context: "lightweight action".into(),
|
|
field: "prompt".into(),
|
|
})?
|
|
.to_string();
|
|
let context_paths = config
|
|
.get("context_paths")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_str().map(String::from))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let max_tokens = config
|
|
.get("max_tokens")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(default_max_tokens() as u64) as u32;
|
|
Ok(RoutineAction::Lightweight {
|
|
prompt,
|
|
context_paths,
|
|
max_tokens,
|
|
})
|
|
}
|
|
"full_job" => {
|
|
let title = config
|
|
.get("title")
|
|
.and_then(|v| v.as_str())
|
|
.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_else(|| RoutineError::MissingField {
|
|
context: "full_job action".into(),
|
|
field: "description".into(),
|
|
})?
|
|
.to_string();
|
|
let max_iterations = config
|
|
.get("max_iterations")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(default_max_iterations() as u64)
|
|
as u32;
|
|
let tool_permissions = parse_tool_permissions(&config);
|
|
Ok(RoutineAction::FullJob {
|
|
title,
|
|
description,
|
|
max_iterations,
|
|
tool_permissions,
|
|
})
|
|
}
|
|
other => Err(RoutineError::UnknownActionType {
|
|
action_type: other.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Serialize action config to JSON for DB storage.
|
|
pub fn to_config_json(&self) -> serde_json::Value {
|
|
match self {
|
|
RoutineAction::Lightweight {
|
|
prompt,
|
|
context_paths,
|
|
max_tokens,
|
|
} => serde_json::json!({
|
|
"prompt": prompt,
|
|
"context_paths": context_paths,
|
|
"max_tokens": max_tokens,
|
|
}),
|
|
RoutineAction::FullJob {
|
|
title,
|
|
description,
|
|
max_iterations,
|
|
tool_permissions,
|
|
} => serde_json::json!({
|
|
"title": title,
|
|
"description": description,
|
|
"max_iterations": max_iterations,
|
|
"tool_permissions": tool_permissions,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Guardrails to prevent runaway execution.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RoutineGuardrails {
|
|
/// Minimum time between fires.
|
|
pub cooldown: Duration,
|
|
/// Max simultaneous runs of this routine.
|
|
pub max_concurrent: u32,
|
|
/// Window for content-hash dedup (event triggers). None = no dedup.
|
|
pub dedup_window: Option<Duration>,
|
|
}
|
|
|
|
impl Default for RoutineGuardrails {
|
|
fn default() -> Self {
|
|
Self {
|
|
cooldown: Duration::from_secs(300),
|
|
max_concurrent: 1,
|
|
dedup_window: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Notification preferences for a routine.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NotifyConfig {
|
|
/// Channel to notify on (None = default/broadcast all).
|
|
pub channel: Option<String>,
|
|
/// User to notify.
|
|
pub user: String,
|
|
/// Notify when routine produces actionable output.
|
|
pub on_attention: bool,
|
|
/// Notify when routine errors.
|
|
pub on_failure: bool,
|
|
/// Notify when routine runs with no findings.
|
|
pub on_success: bool,
|
|
}
|
|
|
|
impl Default for NotifyConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
channel: None,
|
|
user: "default".to_string(),
|
|
on_attention: true,
|
|
on_failure: true,
|
|
on_success: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Status of a routine run.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RunStatus {
|
|
Running,
|
|
Ok,
|
|
Attention,
|
|
Failed,
|
|
}
|
|
|
|
impl std::fmt::Display for RunStatus {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
RunStatus::Running => write!(f, "running"),
|
|
RunStatus::Ok => write!(f, "ok"),
|
|
RunStatus::Attention => write!(f, "attention"),
|
|
RunStatus::Failed => write!(f, "failed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for RunStatus {
|
|
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(RoutineError::UnknownRunStatus {
|
|
status: other.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A single execution of a routine.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RoutineRun {
|
|
pub id: Uuid,
|
|
pub routine_id: Uuid,
|
|
pub trigger_type: String,
|
|
pub trigger_detail: Option<String>,
|
|
pub started_at: DateTime<Utc>,
|
|
pub completed_at: Option<DateTime<Utc>>,
|
|
pub status: RunStatus,
|
|
pub result_summary: Option<String>,
|
|
pub tokens_used: Option<i32>,
|
|
pub job_id: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Compute a content hash for event dedup.
|
|
pub fn content_hash(content: &str) -> u64 {
|
|
let mut hasher = DefaultHasher::new();
|
|
content.hash(&mut hasher);
|
|
hasher.finish()
|
|
}
|
|
|
|
/// Parse a cron expression and compute the next fire time from now.
|
|
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
|
|
let cron_schedule =
|
|
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
|
reason: e.to_string(),
|
|
})?;
|
|
Ok(cron_schedule.upcoming(Utc).next())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::agent::routine::{
|
|
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
|
|
};
|
|
|
|
#[test]
|
|
fn test_trigger_roundtrip() {
|
|
let trigger = Trigger::Cron {
|
|
schedule: "0 9 * * MON-FRI".to_string(),
|
|
};
|
|
let json = trigger.to_config_json();
|
|
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
|
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_trigger_roundtrip() {
|
|
let trigger = Trigger::Event {
|
|
channel: Some("telegram".to_string()),
|
|
pattern: r"deploy\s+\w+".to_string(),
|
|
};
|
|
let json = trigger.to_config_json();
|
|
let parsed = Trigger::from_db("event", json).expect("parse event");
|
|
assert!(matches!(parsed, Trigger::Event { channel, pattern }
|
|
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_lightweight_roundtrip() {
|
|
let action = RoutineAction::Lightweight {
|
|
prompt: "Check PRs".to_string(),
|
|
context_paths: vec!["context/priorities.md".to_string()],
|
|
max_tokens: 2048,
|
|
};
|
|
let json = action.to_config_json();
|
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
|
assert!(
|
|
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
|
|
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_full_job_roundtrip() {
|
|
let action = RoutineAction::FullJob {
|
|
title: "Deploy review".to_string(),
|
|
description: "Review and deploy pending changes".to_string(),
|
|
max_iterations: 5,
|
|
tool_permissions: vec!["shell".to_string()],
|
|
};
|
|
let json = action.to_config_json();
|
|
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
|
assert!(
|
|
matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, .. }
|
|
if title == "Deploy review" && max_iterations == 5 && tool_permissions == vec!["shell".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_status_display_parse() {
|
|
for status in [
|
|
RunStatus::Running,
|
|
RunStatus::Ok,
|
|
RunStatus::Attention,
|
|
RunStatus::Failed,
|
|
] {
|
|
let s = status.to_string();
|
|
let parsed: RunStatus = s.parse().expect("parse status");
|
|
assert_eq!(parsed, status);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_hash_deterministic() {
|
|
let h1 = content_hash("deploy production");
|
|
let h2 = content_hash("deploy production");
|
|
assert_eq!(h1, h2);
|
|
|
|
let h3 = content_hash("deploy staging");
|
|
assert_ne!(h1, h3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_next_cron_fire_valid() {
|
|
// Every minute should always have a next fire
|
|
let next = next_cron_fire("* * * * * *").expect("valid cron");
|
|
assert!(next.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_next_cron_fire_invalid() {
|
|
let result = next_cron_fire("not a cron");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_guardrails_default() {
|
|
let g = RoutineGuardrails::default();
|
|
assert_eq!(g.cooldown.as_secs(), 300);
|
|
assert_eq!(g.max_concurrent, 1);
|
|
assert!(g.dedup_window.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_trigger_type_tag() {
|
|
assert_eq!(
|
|
Trigger::Cron {
|
|
schedule: String::new()
|
|
}
|
|
.type_tag(),
|
|
"cron"
|
|
);
|
|
assert_eq!(
|
|
Trigger::Event {
|
|
channel: None,
|
|
pattern: String::new()
|
|
}
|
|
.type_tag(),
|
|
"event"
|
|
);
|
|
assert_eq!(
|
|
Trigger::Webhook {
|
|
path: None,
|
|
secret: None
|
|
}
|
|
.type_tag(),
|
|
"webhook"
|
|
);
|
|
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
|
}
|
|
}
|