mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
* feat: port NPA psychographic profiling system into IronClaw
Port the complete psychographic profiling system from NPA into IronClaw,
including enriched profile schema, conversational onboarding, profile
evolution, and three-tier prompt augmentation.
Personal onboarding moved from wizard Step 9 to first assistant
interaction per maintainer feedback — the First Contact system prompt
block now instructs the LLM to conduct a natural onboarding conversation
that builds the psychographic profile via memory_write.
Changes:
- Enrich profile.rs with 5 new structs, 9-dimension analysis framework,
custom deserializers for backward compatibility, and rendering methods
- Add conversational onboarding engine with one-step-removed questioning
technique, personality framework, and confidence-scored profile generation
- Add profile evolution with confidence gating, analysis metadata tracking,
and weekly update routine
- Replace thin interaction style injection with three-tier system gated on
confidence > 0.6 and profile recency
- Replace wizard Step 9 with First Contact system prompt block that drives
conversational onboarding during the user's first interaction
- Add autonomy progression to SOUL.md seed and personality framework to
AGENTS.md seed
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* feat: replace chat-based onboarding with bootstrap greeting and workspace seeds
Remove the interactive onboarding_chat.rs engine in favor of a simpler
bootstrap flow: fresh workspaces get a proactive LLM greeting that
naturally profiles the user. Identity files are now seeded from
src/workspace/seeds/ instead of being hardcoded. Also removes the
identity-file write protection (seeds are now managed), adds routine
advisor integration, and includes an e2e trace for bootstrap greeting.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* feat(safety): sanitize identity file writes via Sanitizer to prevent prompt injection
Identity files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md) are injected into
every system prompt. Rather than hard-blocking writes (which broke onboarding),
scan content through the existing Sanitizer and reject writes with High/Critical
severity injection patterns. Medium/Low warnings are logged but allowed.
Also clarifies AGENTS.md identity file roles (USER.md = user info, IDENTITY.md =
agent identity) and adds IDENTITY.md setup as an explicit bootstrap step.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* docs: update profile_onboarding_completed comment to reflect current wiring
The field is now actively used by the agent loop to suppress BOOTSTRAP.md
injection — remove the stale "not yet wired" TODO.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(setup): use env_or_override for NEARAI_API_KEY in model fetch config
When the user authenticates via NEAR AI Cloud API key (option 4),
api_key_login() stores the key via set_runtime_env(). But
build_nearai_model_fetch_config() was using std::env::var() which
doesn't check the runtime overlay — so model listing fell back to
session-token auth and re-triggered the interactive NEAR AI
authentication menu.
Switch to env_or_override() which checks both real env vars and the
runtime overlay.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(agent): correct channel/user_id in bootstrap greeting persist call
persist_assistant_response was called with channel="default",
user_id="system" but the assistant thread was created via
get_or_create_assistant_conversation("default", "gateway") which owns
the conversation as user_id="default", channel="gateway". The mismatch
caused ensure_writable_conversation to reject the write with:
WARN Rejected write for unavailable thread id user=system channel=default
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(web): remove all inline event handlers for CSP compliance
The Content-Security-Policy header (added in f48fe95) blocks inline JS
via script-src 'self'. All onclick/onchange attributes in index.html
are replaced with getElementById().addEventListener() calls. Dynamic
inline handlers in app.js (jobs, routines, memory breadcrumb, code
blocks, TEE report) are replaced with data-action attributes and a
single delegated click handler on document.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(agent): align bootstrap message user/channel and update fixture schema field
- Bootstrap IncomingMessage now uses ("default", "gateway") consistently
with persist and session registration calls
- Update bootstrap_greeting.json fixture: schema_version → version to
match current PROFILE_JSON_SCHEMA
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: cargo fmt
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(safety): address PR review — expand injection scanning and harden profile sync
- BOOTSTRAP.md: fix target "profile" → "context/profile.json" so the
write hits the correct path and triggers profile sync
- IDENTITY_FILES: add context/assistant-directives.md to the scanned
set since it is also injected into the system prompt
- sync_profile_documents(): scan derived USER.md and assistant-directives
content through Sanitizer before writing, rejecting High/Critical
injection patterns
- profile_evolution_prompt(): wrap recent_messages_summary in <user_data>
delimiters with untrusted-data instruction to mitigate indirect
prompt injection
- routine-advisor skill: update cron examples from 6-field to standard
5-field format for consistency with routine_create tool docs
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: cargo fmt
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(setup): detect env-provided LLM keys during quick-mode onboarding
Quick-mode wizard now checks LLM_BACKEND, NEARAI_API_KEY,
ANTHROPIC_API_KEY, and OPENAI_API_KEY env vars to pre-populate
the provider setting, so users aren't re-prompted for credentials
they already supplied. Also teaches setup_nearai() to recognize
NEARAI_API_KEY from env (previously only checked session tokens).
Includes web UI cleanup (remove duplicate event listeners) and
e2e test response count adjustment.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(test): update routine_create_list to expect 7-field normalized cron
The cron normalizer now always expands to 7-field format, so the
stored schedule is "0 0 9 * * * *" not "0 0 9 * * *".
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat(setup): skip LLM provider prompts when NEARAI_API_KEY is present
In quick mode, if NEARAI_API_KEY is set in the environment and the
backend was auto-detected as nearai, skip the interactive inference
provider and model selection steps. The API key is persisted to the
secrets store and a default model is set automatically.
Also simplify the static fallback model list for nearai to a single
default entry.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: unify default model, static bootstrap greeting, and web UI cleanup
- Add DEFAULT_MODEL const and default_models() fallback list in
llm/nearai_chat.rs; use from config, wizard, and .env.example so the
default model is defined in one place
- Restore multi-model fallback list in setup wizard (was reduced to 1)
- Move BOOTSTRAP_GREETING to module-level const (out of run() body)
- Replace LLM-based bootstrap with static greeting (persist to DB before
channels start, then broadcast — eliminates startup LLM call and race)
- Fix double env::var read for NEARAI_API_KEY in quick setup path
- Move thread sidebar buttons into threads-section-header (web UI)
- Remove orphaned .thread-sidebar-header CSS and fix double blank line
- Update bootstrap e2e test for static greeting (no LLM trace needed)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(safety): move prompt injection scanning into Workspace write/append
Addresses PR #927 review comments (#1, #3) — identity file write
protection and unsanitized profile fields in system prompt.
Instead of scanning at the tool layer (memory.rs) or the sync layer
(sync_profile_documents), injection scanning now lives in
Workspace::write() and Workspace::append() for all files that are
injected into the system prompt. This ensures every code path that
writes to these files is protected, including future ones.
- Add SYSTEM_PROMPT_FILES const and reject_if_injected() in workspace
- Add WorkspaceError::InjectionRejected variant
- Add map_write_err() in memory.rs to convert InjectionRejected to
ToolError::NotAuthorized
- Remove redundant IDENTITY_FILES/Sanitizer from memory.rs
- Remove redundant sanitizer calls from sync_profile_documents()
- Move sanitization tests to workspace::tests
- Existing integration test (test_memory_write_rejects_injection)
continues to pass through the new path
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review — merge marker order, orphan thread, stale fixture
- merge_profile_section: search for END marker after BEGIN position to
avoid matching a stray END earlier in the file
- Bootstrap phase 2: use get_or_create_session + Thread::with_id instead
of resolve_thread(None) to avoid creating an orphan thread
- setup_nearai: use env_or_override for NEARAI_API_KEY consistency with
runtime overlay
- Delete orphaned bootstrap_greeting.json fixture (no test references it)
- Add test_merge_end_marker_must_follow_begin regression test
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: fmt agent_loop.rs (CI stable rustfmt)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: lazy-init sanitizer, check profile non-empty before skipping bootstrap
Address Copilot review:
- Use LazyLock<Sanitizer> to avoid rebuilding Aho-Corasick + regexes
on every workspace write
- has_profile check now requires non-empty content, not just file
existence, to prevent empty profile.json from suppressing onboarding
- Add seed_tests integration tests (libsql-backed) verifying:
- Empty profile.json does not suppress BOOTSTRAP.md seeding
- Non-empty profile.json correctly suppresses bootstrap for upgrades
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: duplicate language handler, empty LLM_BACKEND, test_rig style
Address Copilot review on PR #927:
- Remove duplicate language-option click listeners (delegated
data-action handler already covers them)
- Guard LLM_BACKEND env prefill against empty string to prevent
suppressing API-key-based auto-detection
- Use destructured local `keep_bootstrap` instead of `self.keep_bootstrap`
in test_rig for consistency after destructure
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: update stale BOOTSTRAP.md write-protection comment [skip-regression-check]
BOOTSTRAP.md is now in SYSTEM_PROMPT_FILES and gets injection scanning
on write. The old comment incorrectly stated it was not write-protected.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: replace debug_assert panics with graceful error returns [skip-regression-check]
debug_assert! in execute_tool_with_safety and JobContext::transition_to
panicked in test builds before the graceful error path could run.
Existing tests (test_cancel_job_completed, test_execute_empty_tool_name_returns_not_found)
already cover these paths — they were the ones failing.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review — schema label, env var check, path normalization, profile validation
1. Label ANALYSIS_FRAMEWORK and PROFILE_JSON_SCHEMA sections separately
in bootstrap prompt so the LLM knows which blob is the target structure.
2. Wizard quick-mode backend auto-detection now rejects empty env vars
(std::env::var().is_ok_and(|v| !v.is_empty())) to avoid selecting the
wrong backend when e.g. NEARAI_API_KEY="" is set.
3. Normalize the target path before comparing with paths::PROFILE in
memory_write so non-canonical variants like "context//profile.json"
still trigger profile sync.
4. seed_if_empty now requires valid JSON parse of context/profile.json
before treating it as a populated profile. Corrupted content no longer
permanently suppresses bootstrap seeding.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
* fix: address Copilot review — append scan, profile validation, env_or_override
1. Workspace::append() now scans the combined content (existing + new)
for prompt injection, not just the appended chunk. Prevents split-
injection evasion across multiple appends.
2. seed_if_empty() now deserializes into PsychographicProfile instead of
serde_json::Value for profile validation. Stray/legacy JSON that
doesn't match the expected schema no longer suppresses bootstrap.
3. Wizard quick-mode backend auto-detection now uses env_or_override()
to honor runtime overlays and injected secrets. LLM_BACKEND value
is trimmed before storage.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: add bootstrap_onboarding_clears_bootstrap E2E trace test
Exercises the full onboarding flow end-to-end:
1. Bootstrap greeting fires automatically on fresh workspace
2. User converses for 3 turns (name, tools, work style)
3. Agent writes psychographic profile to context/profile.json
4. Profile sync generates USER.md and assistant-directives.md
5. Agent writes IDENTITY.md (chosen persona)
6. Agent clears BOOTSTRAP.md via memory_write(target: "bootstrap")
Verifies:
- BOOTSTRAP.md is non-empty before onboarding, empty after
- bootstrap_completed flag is set
- Profile contains expected user data (name, profession, interests)
- USER.md contains profile-derived content (name, tone, profession)
- Assistant-directives.md references user and communication style
- IDENTITY.md contains agent's chosen persona name
- All memory_write calls succeed
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review — slash collapse, env_or_override, cron trim [skip-regression-check]
1. memory.rs path normalization now uses the same char-by-char loop as
Workspace::normalize_path() to fully collapse consecutive slashes
(e.g. "context///profile.json" → "context/profile.json").
2. Quick-mode NEARAI_API_KEY check (line 239) now uses env_or_override()
consistently with the backend auto-detection block above it.
3. normalize_cron_expression() trims input before field counting so the
passthrough branch (7+ fields) also strips whitespace.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Jay Zalowitz <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
1303 lines
44 KiB
Rust
1303 lines
44 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│
|
|
//! │ system │ │ check │ └──────────────────┘
|
|
//! │ manual │ └─────────┘ │
|
|
//! └──────────┘ ▼
|
|
//! ┌──────────────┐
|
|
//! │ Notify user │
|
|
//! │ if needed │
|
|
//! └──────────────┘
|
|
//! ```
|
|
|
|
use std::collections::{HashSet, 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;
|
|
|
|
pub const FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY: &str = "routines.full_job_owner_allowed_tools";
|
|
pub const FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY: &str =
|
|
"routines.full_job_default_permission_mode";
|
|
|
|
/// Persisted per-routine permission mode for autonomous `full_job` routines.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FullJobPermissionMode {
|
|
/// Only use the routine's stored `tool_permissions`.
|
|
#[default]
|
|
Explicit,
|
|
/// Union the owner-scoped allowlist with the routine's `tool_permissions`.
|
|
InheritOwner,
|
|
}
|
|
|
|
impl FullJobPermissionMode {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Explicit => "explicit",
|
|
Self::InheritOwner => "inherit_owner",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for FullJobPermissionMode {
|
|
type Err = ();
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"explicit" => Ok(Self::Explicit),
|
|
"inherit_owner" => Ok(Self::InheritOwner),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Owner-scoped default behavior for newly-created `full_job` routines.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum FullJobPermissionDefaultMode {
|
|
Explicit,
|
|
#[default]
|
|
InheritOwner,
|
|
CopyOwner,
|
|
}
|
|
|
|
impl FullJobPermissionDefaultMode {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Explicit => "explicit",
|
|
Self::InheritOwner => "inherit_owner",
|
|
Self::CopyOwner => "copy_owner",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromStr for FullJobPermissionDefaultMode {
|
|
type Err = ();
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"explicit" => Ok(Self::Explicit),
|
|
"inherit_owner" => Ok(Self::InheritOwner),
|
|
"copy_owner" => Ok(Self::CopyOwner),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct FullJobPermissionSettings {
|
|
pub owner_allowed_tools: Vec<String>,
|
|
pub default_mode: FullJobPermissionDefaultMode,
|
|
}
|
|
|
|
pub fn normalize_tool_names<I>(tools: I) -> Vec<String>
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
let mut seen = HashSet::new();
|
|
let mut normalized = Vec::new();
|
|
for tool in tools {
|
|
let trimmed = tool.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let normalized_name = trimmed.to_string();
|
|
if seen.insert(normalized_name.clone()) {
|
|
normalized.push(normalized_name);
|
|
}
|
|
}
|
|
normalized
|
|
}
|
|
|
|
pub fn parse_full_job_permission_mode(value: &serde_json::Value) -> FullJobPermissionMode {
|
|
value
|
|
.get("permission_mode")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|mode| FullJobPermissionMode::from_str(mode).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn parse_owner_allowed_tools_setting(value: Option<serde_json::Value>) -> Vec<String> {
|
|
match value {
|
|
Some(serde_json::Value::Array(values)) => normalize_tool_names(
|
|
values
|
|
.into_iter()
|
|
.filter_map(|value| value.as_str().map(ToOwned::to_owned)),
|
|
),
|
|
Some(serde_json::Value::String(csv)) => normalize_tool_names(
|
|
csv.split([',', '\n'])
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned),
|
|
),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn parse_default_permission_mode_setting(
|
|
value: Option<serde_json::Value>,
|
|
) -> FullJobPermissionDefaultMode {
|
|
value
|
|
.and_then(|v| v.as_str().map(ToOwned::to_owned))
|
|
.and_then(|mode| FullJobPermissionDefaultMode::from_str(&mode).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub async fn load_full_job_permission_settings(
|
|
store: &(dyn crate::db::SettingsStore + Sync),
|
|
user_id: &str,
|
|
) -> Result<FullJobPermissionSettings, crate::error::DatabaseError> {
|
|
let owner_allowed_tools = parse_owner_allowed_tools_setting(
|
|
store
|
|
.get_setting(user_id, FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY)
|
|
.await?,
|
|
);
|
|
let default_mode = parse_default_permission_mode_setting(
|
|
store
|
|
.get_setting(user_id, FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY)
|
|
.await?,
|
|
);
|
|
Ok(FullJobPermissionSettings {
|
|
owner_allowed_tools,
|
|
default_mode,
|
|
})
|
|
}
|
|
|
|
pub fn effective_full_job_tool_permissions(
|
|
permission_mode: FullJobPermissionMode,
|
|
routine_tool_permissions: &[String],
|
|
owner_allowed_tools: &[String],
|
|
) -> Vec<String> {
|
|
match permission_mode {
|
|
FullJobPermissionMode::Explicit => {
|
|
normalize_tool_names(routine_tool_permissions.iter().cloned())
|
|
}
|
|
FullJobPermissionMode::InheritOwner => normalize_tool_names(
|
|
owner_allowed_tools
|
|
.iter()
|
|
.cloned()
|
|
.chain(routine_tool_permissions.iter().cloned()),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
#[serde(default)]
|
|
timezone: Option<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 when a structured system event is emitted.
|
|
SystemEvent {
|
|
/// Event source namespace (e.g. "github", "workflow", "tool").
|
|
source: String,
|
|
/// Event type within the source (e.g. "issue.opened").
|
|
event_type: String,
|
|
/// Optional exact-match filters against payload top-level fields.
|
|
#[serde(default)]
|
|
filters: std::collections::HashMap<String, 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::SystemEvent { .. } => "system_event",
|
|
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();
|
|
let timezone = config
|
|
.get("timezone")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|tz| {
|
|
if crate::timezone::parse_timezone(tz).is_some() {
|
|
Some(tz.to_string())
|
|
} else {
|
|
tracing::warn!(
|
|
"Ignoring invalid timezone '{}' from DB for cron trigger",
|
|
tz
|
|
);
|
|
None
|
|
}
|
|
});
|
|
Ok(Trigger::Cron { schedule, timezone })
|
|
}
|
|
"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 })
|
|
}
|
|
"system_event" => {
|
|
let source = config
|
|
.get("source")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| RoutineError::MissingField {
|
|
context: "system_event trigger".into(),
|
|
field: "source".into(),
|
|
})?
|
|
.to_string();
|
|
let event_type = config
|
|
.get("event_type")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| RoutineError::MissingField {
|
|
context: "system_event trigger".into(),
|
|
field: "event_type".into(),
|
|
})?
|
|
.to_string();
|
|
let filters = config
|
|
.get("filters")
|
|
.and_then(|v| v.as_object())
|
|
.map(|m| {
|
|
m.iter()
|
|
.filter_map(|(k, v)| {
|
|
json_value_as_filter_string(v).map(|s| (k.clone(), s))
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
Ok(Trigger::SystemEvent {
|
|
source,
|
|
event_type,
|
|
filters,
|
|
})
|
|
}
|
|
"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, timezone } => serde_json::json!({
|
|
"schedule": schedule,
|
|
"timezone": timezone,
|
|
}),
|
|
Trigger::Event { channel, pattern } => serde_json::json!({
|
|
"pattern": pattern,
|
|
"channel": channel,
|
|
}),
|
|
Trigger::SystemEvent {
|
|
source,
|
|
event_type,
|
|
filters,
|
|
} => serde_json::json!({
|
|
"source": source,
|
|
"event_type": event_type,
|
|
"filters": filters,
|
|
}),
|
|
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 (optionally with 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,
|
|
/// Enable tool access (default: false for backward compatibility).
|
|
/// When true, the LLM can call tools during execution.
|
|
/// Tools requiring approval are automatically filtered out.
|
|
#[serde(default)]
|
|
use_tools: bool,
|
|
/// Max tool call rounds (default: 3). Only used when use_tools is true.
|
|
#[serde(default = "default_max_tool_rounds")]
|
|
max_tool_rounds: 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>,
|
|
/// Whether this routine should inherit the owner's durable full-job
|
|
/// permission allowlist or use only its explicit `tool_permissions`.
|
|
#[serde(default)]
|
|
permission_mode: FullJobPermissionMode,
|
|
},
|
|
}
|
|
|
|
fn default_max_tokens() -> u32 {
|
|
4096
|
|
}
|
|
|
|
fn default_max_iterations() -> u32 {
|
|
10
|
|
}
|
|
|
|
fn default_max_tool_rounds() -> u32 {
|
|
3
|
|
}
|
|
|
|
/// Hard upper bound for max_tool_rounds to prevent runaway loops and cost explosion.
|
|
pub(crate) const MAX_TOOL_ROUNDS_LIMIT: u32 = 20;
|
|
|
|
/// Clamp max_tool_rounds to [1, MAX_TOOL_ROUNDS_LIMIT].
|
|
/// Accepts u64 to avoid truncation before clamping.
|
|
fn clamp_max_tool_rounds(value: u64) -> u32 {
|
|
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
|
|
}
|
|
|
|
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
|
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
|
normalize_tool_names(
|
|
value
|
|
.get("tool_permissions")
|
|
.and_then(|v| v.as_array())
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|v| v.as_str().map(String::from)),
|
|
)
|
|
}
|
|
|
|
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;
|
|
let use_tools = config
|
|
.get("use_tools")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
let max_tool_rounds = clamp_max_tool_rounds(
|
|
config
|
|
.get("max_tool_rounds")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(default_max_tool_rounds() as u64),
|
|
);
|
|
Ok(RoutineAction::Lightweight {
|
|
prompt,
|
|
context_paths,
|
|
max_tokens,
|
|
use_tools,
|
|
max_tool_rounds,
|
|
})
|
|
}
|
|
"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);
|
|
let permission_mode = parse_full_job_permission_mode(&config);
|
|
Ok(RoutineAction::FullJob {
|
|
title,
|
|
description,
|
|
max_iterations,
|
|
tool_permissions,
|
|
permission_mode,
|
|
})
|
|
}
|
|
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,
|
|
use_tools,
|
|
max_tool_rounds,
|
|
} => serde_json::json!({
|
|
"prompt": prompt,
|
|
"context_paths": context_paths,
|
|
"max_tokens": max_tokens,
|
|
"use_tools": use_tools,
|
|
"max_tool_rounds": max_tool_rounds,
|
|
}),
|
|
RoutineAction::FullJob {
|
|
title,
|
|
description,
|
|
max_iterations,
|
|
tool_permissions,
|
|
permission_mode,
|
|
} => serde_json::json!({
|
|
"title": title,
|
|
"description": description,
|
|
"max_iterations": max_iterations,
|
|
"tool_permissions": tool_permissions,
|
|
"permission_mode": permission_mode,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
/// Explicit target to notify. None means "resolve the owner's last-seen target".
|
|
pub user: Option<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: None,
|
|
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>,
|
|
}
|
|
|
|
/// Convert a JSON value to a string for filter storage.
|
|
///
|
|
/// Handles strings, numbers, and booleans — consistent with the matching
|
|
/// logic in `routine_engine::json_value_as_string`.
|
|
pub fn json_value_as_filter_string(v: &serde_json::Value) -> Option<String> {
|
|
match v {
|
|
serde_json::Value::String(s) => Some(s.clone()),
|
|
serde_json::Value::Number(n) => Some(n.to_string()),
|
|
serde_json::Value::Bool(b) => Some(b.to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
|
|
///
|
|
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
|
|
/// Standard cron uses 5 fields: `min hour day-of-month month day-of-week`.
|
|
/// This function auto-expands:
|
|
/// - 5-field → prepend `0` (seconds) and append `*` (year)
|
|
/// - 6-field → append `*` (year)
|
|
/// - 7-field → pass through unchanged
|
|
pub fn normalize_cron_expression(schedule: &str) -> String {
|
|
let trimmed = schedule.trim();
|
|
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
|
match fields.len() {
|
|
5 => format!("0 {} *", trimmed),
|
|
6 => format!("{} *", trimmed),
|
|
_ => trimmed.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Parse a cron expression and compute the next fire time from now.
|
|
///
|
|
/// Accepts standard 5-field, 6-field, or 7-field cron expressions (auto-normalized).
|
|
/// When `timezone` is provided and valid, the schedule is evaluated in that
|
|
/// timezone and the result is converted back to UTC. Otherwise UTC is used.
|
|
pub fn next_cron_fire(
|
|
schedule: &str,
|
|
timezone: Option<&str>,
|
|
) -> Result<Option<DateTime<Utc>>, RoutineError> {
|
|
let normalized = normalize_cron_expression(schedule);
|
|
let cron_schedule =
|
|
cron::Schedule::from_str(&normalized).map_err(|e| RoutineError::InvalidCron {
|
|
reason: e.to_string(),
|
|
})?;
|
|
if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) {
|
|
Ok(cron_schedule
|
|
.upcoming(tz)
|
|
.next()
|
|
.map(|dt| dt.with_timezone(&Utc)))
|
|
} else {
|
|
Ok(cron_schedule.upcoming(Utc).next())
|
|
}
|
|
}
|
|
|
|
/// Describe common routine cron patterns in plain English.
|
|
///
|
|
/// Falls back to `cron: <raw>` for malformed or complex expressions.
|
|
pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
|
|
fn fallback(raw: &str) -> String {
|
|
if raw.trim().is_empty() {
|
|
"cron: (empty)".to_string()
|
|
} else {
|
|
format!("cron: {}", raw.trim())
|
|
}
|
|
}
|
|
|
|
fn parse_u8_token(token: &str) -> Option<u8> {
|
|
token.parse::<u8>().ok()
|
|
}
|
|
|
|
fn parse_step(token: &str) -> Option<u8> {
|
|
token
|
|
.strip_prefix("*/")
|
|
.and_then(parse_u8_token)
|
|
.filter(|n| *n > 0)
|
|
}
|
|
|
|
fn weekday_name(dow: &str) -> Option<&'static str> {
|
|
let normalized = dow.trim().to_ascii_uppercase();
|
|
match normalized.as_str() {
|
|
"MON" | "1" => Some("Monday"),
|
|
"TUE" | "2" => Some("Tuesday"),
|
|
"WED" | "3" => Some("Wednesday"),
|
|
"THU" | "4" => Some("Thursday"),
|
|
"FRI" | "5" => Some("Friday"),
|
|
"SAT" | "6" => Some("Saturday"),
|
|
"SUN" | "0" | "7" => Some("Sunday"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn format_time(hour: u8, minute: u8) -> String {
|
|
if hour == 0 && minute == 0 {
|
|
return "midnight".to_string();
|
|
}
|
|
let (display_hour, am_pm) = match hour {
|
|
0 => (12, "AM"),
|
|
1..=11 => (hour, "AM"),
|
|
12 => (12, "PM"),
|
|
_ => (hour - 12, "PM"),
|
|
};
|
|
format!("{display_hour}:{minute:02} {am_pm}")
|
|
}
|
|
|
|
fn ordinal(n: u8) -> String {
|
|
let suffix = if (11..=13).contains(&(n % 100)) {
|
|
"th"
|
|
} else {
|
|
match n % 10 {
|
|
1 => "st",
|
|
2 => "nd",
|
|
3 => "rd",
|
|
_ => "th",
|
|
}
|
|
};
|
|
format!("{n}{suffix}")
|
|
}
|
|
|
|
fn describe_inner(raw: &str) -> Option<String> {
|
|
let fields: Vec<&str> = raw.split_whitespace().collect();
|
|
let (sec, min, hour, dom, month, dow, year) = match fields.len() {
|
|
5 => (
|
|
"0", fields[0], fields[1], fields[2], fields[3], fields[4], None,
|
|
),
|
|
6 => (
|
|
fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None,
|
|
),
|
|
7 => (
|
|
fields[0],
|
|
fields[1],
|
|
fields[2],
|
|
fields[3],
|
|
fields[4],
|
|
fields[5],
|
|
Some(fields[6]),
|
|
),
|
|
_ => return None,
|
|
};
|
|
|
|
if year.is_some_and(|v| v != "*") {
|
|
return None;
|
|
}
|
|
|
|
if sec == "0"
|
|
&& hour == "*"
|
|
&& dom == "*"
|
|
&& month == "*"
|
|
&& dow == "*"
|
|
&& let Some(step) = parse_step(min)
|
|
{
|
|
return Some(match step {
|
|
1 => "Every minute".to_string(),
|
|
n => format!("Every {n} minutes"),
|
|
});
|
|
}
|
|
|
|
if sec == "0"
|
|
&& min == "0"
|
|
&& dom == "*"
|
|
&& month == "*"
|
|
&& dow == "*"
|
|
&& let Some(step) = parse_step(hour)
|
|
{
|
|
return Some(match step {
|
|
1 => "Every hour".to_string(),
|
|
n => format!("Every {n} hours"),
|
|
});
|
|
}
|
|
|
|
let hour = parse_u8_token(hour).filter(|h| *h <= 23)?;
|
|
let minute = parse_u8_token(min).filter(|m| *m <= 59)?;
|
|
let time = format_time(hour, minute);
|
|
let time_phrase = if time == "midnight" {
|
|
"at midnight".to_string()
|
|
} else {
|
|
format!("at {time}")
|
|
};
|
|
|
|
if sec == "0" && dom == "*" && month == "*" && dow == "*" {
|
|
return Some(format!("Daily {time_phrase}"));
|
|
}
|
|
|
|
if sec == "0" && dom == "*" && month == "*" && dow.eq_ignore_ascii_case("MON-FRI") {
|
|
return Some(format!("Weekdays {time_phrase}"));
|
|
}
|
|
|
|
if sec == "0"
|
|
&& dom == "*"
|
|
&& month == "*"
|
|
&& let Some(day_name) = weekday_name(dow)
|
|
{
|
|
return Some(format!("Every {day_name} {time_phrase}"));
|
|
}
|
|
|
|
if sec == "0"
|
|
&& month == "*"
|
|
&& dow == "*"
|
|
&& let Some(day_of_month) = parse_u8_token(dom).filter(|d| (1..=31).contains(d))
|
|
{
|
|
return Some(format!(
|
|
"{} of every month {time_phrase}",
|
|
ordinal(day_of_month)
|
|
));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
let mut description = describe_inner(schedule).unwrap_or_else(|| fallback(schedule));
|
|
if let Some(tz) = timezone.map(str::trim).filter(|tz| !tz.is_empty()) {
|
|
description.push_str(" (");
|
|
description.push_str(tz);
|
|
description.push(')');
|
|
}
|
|
description
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::agent::routine::{
|
|
FullJobPermissionMode, MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus,
|
|
Trigger, content_hash, describe_cron, effective_full_job_tool_permissions, next_cron_fire,
|
|
normalize_cron_expression,
|
|
};
|
|
|
|
#[test]
|
|
fn test_trigger_roundtrip() {
|
|
let trigger = Trigger::Cron {
|
|
schedule: "0 9 * * MON-FRI".to_string(),
|
|
timezone: None,
|
|
};
|
|
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_system_event_trigger_roundtrip() {
|
|
let mut filters = std::collections::HashMap::new();
|
|
filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
|
|
filters.insert("action".to_string(), "opened".to_string());
|
|
let trigger = Trigger::SystemEvent {
|
|
source: "github".to_string(),
|
|
event_type: "issue".to_string(),
|
|
filters: filters.clone(),
|
|
};
|
|
let json = trigger.to_config_json();
|
|
let parsed = Trigger::from_db("system_event", json).expect("parse system_event");
|
|
assert!(
|
|
matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f }
|
|
if source == "github" && event_type == "issue" && f == filters)
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
use_tools: false,
|
|
max_tool_rounds: 3,
|
|
};
|
|
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()],
|
|
permission_mode: FullJobPermissionMode::InheritOwner,
|
|
};
|
|
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, permission_mode, .. }
|
|
if title == "Deploy review"
|
|
&& max_iterations == 5
|
|
&& tool_permissions == vec!["shell".to_string()]
|
|
&& permission_mode == FullJobPermissionMode::InheritOwner)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_full_job_missing_permission_mode_defaults_to_explicit() {
|
|
let parsed = RoutineAction::from_db(
|
|
"full_job",
|
|
serde_json::json!({
|
|
"title": "Deploy review",
|
|
"description": "Review and deploy pending changes",
|
|
"max_iterations": 5,
|
|
"tool_permissions": ["shell"]
|
|
}),
|
|
)
|
|
.expect("parse full_job");
|
|
assert!(matches!(
|
|
parsed,
|
|
RoutineAction::FullJob {
|
|
permission_mode: FullJobPermissionMode::Explicit,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_full_job_tool_permissions_inherit_owner_unions_lists() {
|
|
let resolved = effective_full_job_tool_permissions(
|
|
FullJobPermissionMode::InheritOwner,
|
|
&["shell".to_string(), "message".to_string()],
|
|
&["message".to_string(), "http".to_string()],
|
|
);
|
|
assert_eq!(
|
|
resolved,
|
|
vec![
|
|
"message".to_string(),
|
|
"http".to_string(),
|
|
"shell".to_string()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_full_job_tool_permissions_explicit_ignores_owner_defaults() {
|
|
let resolved = effective_full_job_tool_permissions(
|
|
FullJobPermissionMode::Explicit,
|
|
&["shell".to_string()],
|
|
&["message".to_string(), "http".to_string()],
|
|
);
|
|
assert_eq!(resolved, 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("* * * * * *", None).expect("valid cron");
|
|
assert!(next.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_next_cron_fire_invalid() {
|
|
let result = next_cron_fire("not a cron", None);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_trigger_cron_timezone_roundtrip() {
|
|
let trigger = Trigger::Cron {
|
|
schedule: "0 9 * * MON-FRI".to_string(),
|
|
timezone: Some("America/New_York".to_string()),
|
|
};
|
|
let json = trigger.to_config_json();
|
|
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
|
assert!(matches!(parsed, Trigger::Cron { schedule, timezone }
|
|
if schedule == "0 9 * * MON-FRI"
|
|
&& timezone.as_deref() == Some("America/New_York")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_trigger_cron_no_timezone_backward_compat() {
|
|
let json = serde_json::json!({"schedule": "0 9 * * *"});
|
|
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
|
assert!(matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_trigger_cron_invalid_timezone_coerced_to_none() {
|
|
let json = serde_json::json!({"schedule": "0 9 * * *", "timezone": "Fake/Zone"});
|
|
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
|
assert!(
|
|
matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()),
|
|
"invalid timezone should be coerced to None"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_next_cron_fire_with_timezone() {
|
|
let next_utc = next_cron_fire("0 0 9 * * * *", None)
|
|
.expect("valid cron")
|
|
.expect("has next");
|
|
let next_est = next_cron_fire("0 0 9 * * * *", Some("America/New_York"))
|
|
.expect("valid cron")
|
|
.expect("has next");
|
|
// EST is UTC-5 (or EDT UTC-4), so the UTC result should differ
|
|
assert_ne!(next_utc, next_est, "timezone should shift the fire time");
|
|
}
|
|
|
|
#[test]
|
|
fn test_describe_cron_common_patterns() {
|
|
let cases = vec![
|
|
("0 */30 * * * *", None, "Every 30 minutes"),
|
|
("0 0 9 * * *", None, "Daily at 9:00 AM"),
|
|
("0 0 9 * * MON-FRI", None, "Weekdays at 9:00 AM"),
|
|
("0 0 */2 * * *", None, "Every 2 hours"),
|
|
("0 0 0 * * *", None, "Daily at midnight"),
|
|
("0 0 9 * * 1", None, "Every Monday at 9:00 AM"),
|
|
("0 0 9 1 * *", None, "1st of every month at 9:00 AM"),
|
|
(
|
|
"0 0 9 * * MON-FRI",
|
|
Some("America/New_York"),
|
|
"Weekdays at 9:00 AM (America/New_York)",
|
|
),
|
|
("1 2 3 4 5 6", None, "cron: 1 2 3 4 5 6"),
|
|
];
|
|
|
|
for (schedule, timezone, expected) in cases {
|
|
let actual = describe_cron(schedule, timezone);
|
|
assert_eq!(actual, expected); // safety: test-only assertion in #[cfg(test)] module
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_describe_cron_edge_cases() {
|
|
assert_eq!(describe_cron("", None), "cron: (empty)"); // safety: test-only assertion in #[cfg(test)] module
|
|
assert_eq!(describe_cron("not a cron", None), "cron: not a cron"); // safety: test-only assertion in #[cfg(test)] module
|
|
let weekdays_5_field = describe_cron("0 9 * * MON-FRI", None);
|
|
assert_eq!(weekdays_5_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module
|
|
let weekdays_7_field = describe_cron("0 0 9 * * MON-FRI *", None);
|
|
assert_eq!(weekdays_7_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module
|
|
}
|
|
|
|
#[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(),
|
|
timezone: None,
|
|
}
|
|
.type_tag(),
|
|
"cron"
|
|
);
|
|
assert_eq!(
|
|
Trigger::Event {
|
|
channel: None,
|
|
pattern: String::new()
|
|
}
|
|
.type_tag(),
|
|
"event"
|
|
);
|
|
assert_eq!(
|
|
Trigger::SystemEvent {
|
|
source: String::new(),
|
|
event_type: String::new(),
|
|
filters: std::collections::HashMap::new(),
|
|
}
|
|
.type_tag(),
|
|
"system_event"
|
|
);
|
|
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_cron_5_field() {
|
|
// Standard cron: min hour dom month dow
|
|
assert_eq!(normalize_cron_expression("0 9 * * 1"), "0 0 9 * * 1 *");
|
|
assert_eq!(
|
|
normalize_cron_expression("0 9 * * MON-FRI"),
|
|
"0 0 9 * * MON-FRI *"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_cron_6_field() {
|
|
// 6-field: sec min hour dom month dow
|
|
assert_eq!(
|
|
normalize_cron_expression("0 0 9 * * MON-FRI"),
|
|
"0 0 9 * * MON-FRI *"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_cron_7_field_passthrough() {
|
|
// Already 7-field: no change
|
|
assert_eq!(
|
|
normalize_cron_expression("0 0 9 * * MON-FRI *"),
|
|
"0 0 9 * * MON-FRI *"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_next_cron_fire_5_field_accepted() {
|
|
// Standard 5-field cron should now work through normalization
|
|
let result = next_cron_fire("0 9 * * 1", None);
|
|
assert!(
|
|
result.is_ok(),
|
|
"5-field cron should be accepted: {result:?}"
|
|
);
|
|
assert!(result.unwrap().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_next_cron_fire_5_field_with_timezone() {
|
|
let result = next_cron_fire("0 9 * * MON-FRI", Some("America/New_York"));
|
|
assert!(
|
|
result.is_ok(),
|
|
"5-field cron with timezone should be accepted: {result:?}"
|
|
);
|
|
assert!(result.unwrap().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_lightweight_backward_compat_no_use_tools() {
|
|
// Simulate old DB record without use_tools field
|
|
let json = serde_json::json!({
|
|
"prompt": "old routine",
|
|
"context_paths": [],
|
|
"max_tokens": 4096
|
|
});
|
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
|
assert!(
|
|
matches!(parsed, RoutineAction::Lightweight { use_tools, max_tool_rounds, .. }
|
|
if !use_tools && max_tool_rounds == 3),
|
|
"missing use_tools should default to false, max_tool_rounds to 3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_tool_rounds_clamped_to_upper_bound() {
|
|
let json = serde_json::json!({
|
|
"prompt": "test",
|
|
"use_tools": true,
|
|
"max_tool_rounds": 9999
|
|
});
|
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
|
match parsed {
|
|
RoutineAction::Lightweight {
|
|
max_tool_rounds, ..
|
|
} => {
|
|
assert_eq!(
|
|
max_tool_rounds, MAX_TOOL_ROUNDS_LIMIT,
|
|
"should clamp to MAX_TOOL_ROUNDS_LIMIT"
|
|
);
|
|
}
|
|
_ => panic!("expected Lightweight"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_tool_rounds_clamped_to_lower_bound() {
|
|
let json = serde_json::json!({
|
|
"prompt": "test",
|
|
"use_tools": true,
|
|
"max_tool_rounds": 0
|
|
});
|
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
|
match parsed {
|
|
RoutineAction::Lightweight {
|
|
max_tool_rounds, ..
|
|
} => {
|
|
assert_eq!(max_tool_rounds, 1, "should clamp 0 to 1");
|
|
}
|
|
_ => panic!("expected Lightweight"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_tool_rounds_normal_value_passes_through() {
|
|
let json = serde_json::json!({
|
|
"prompt": "test",
|
|
"use_tools": true,
|
|
"max_tool_rounds": 10
|
|
});
|
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
|
match parsed {
|
|
RoutineAction::Lightweight {
|
|
max_tool_rounds, ..
|
|
} => {
|
|
assert_eq!(max_tool_rounds, 10, "normal value should pass through");
|
|
}
|
|
_ => panic!("expected Lightweight"),
|
|
}
|
|
}
|
|
}
|