mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa289997e3 | ||
|
|
4aad0cfbaa | ||
|
|
112a4087e7 | ||
|
|
403f6f504f |
+1
-1
@@ -170,7 +170,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
||||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
||||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs (model/thinking fields in edit) |
|
| `cron` | ✅ | 🚧 | P2 | list/create/edit/enable/disable/delete/history; TODO: `cron run`, model/thinking fields |
|
||||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||||
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
||||||
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
||||||
//! - Querying workspace memory (`memory search`, `memory read`, `memory write`)
|
//! - Querying workspace memory (`memory search`, `memory read`, `memory write`)
|
||||||
|
//! - Managing routines (`routines list`, `routines create`, `routines edit`, ...)
|
||||||
//! - Managing OS service (`service install`, `service start`, `service stop`)
|
//! - Managing OS service (`service install`, `service start`, `service stop`)
|
||||||
//! - Listing configured channels (`channels list`)
|
//! - Listing configured channels (`channels list`)
|
||||||
//! - Active health diagnostics (`doctor`)
|
//! - Active health diagnostics (`doctor`)
|
||||||
@@ -23,6 +24,7 @@ pub mod memory;
|
|||||||
pub mod oauth_defaults;
|
pub mod oauth_defaults;
|
||||||
mod pairing;
|
mod pairing;
|
||||||
mod registry;
|
mod registry;
|
||||||
|
mod routines;
|
||||||
mod service;
|
mod service;
|
||||||
mod skills;
|
mod skills;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
@@ -39,6 +41,7 @@ pub use memory::MemoryCommand;
|
|||||||
pub use memory::run_memory_command_with_db;
|
pub use memory::run_memory_command_with_db;
|
||||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||||
pub use registry::{RegistryCommand, run_registry_command};
|
pub use registry::{RegistryCommand, run_registry_command};
|
||||||
|
pub use routines::{RoutinesCommand, run_routines_command};
|
||||||
pub use service::{ServiceCommand, run_service_command};
|
pub use service::{ServiceCommand, run_service_command};
|
||||||
pub use skills::{SkillsCommand, run_skills_command};
|
pub use skills::{SkillsCommand, run_skills_command};
|
||||||
pub use status::run_status_command;
|
pub use status::run_status_command;
|
||||||
@@ -147,6 +150,15 @@ pub enum Command {
|
|||||||
)]
|
)]
|
||||||
Channels(ChannelsCommand),
|
Channels(ChannelsCommand),
|
||||||
|
|
||||||
|
/// Manage routines (scheduled, event-driven, webhook, manual)
|
||||||
|
#[command(
|
||||||
|
subcommand,
|
||||||
|
alias = "cron",
|
||||||
|
about = "Manage routines",
|
||||||
|
long_about = "List, create, edit, enable/disable, delete, and view history of routines.\nExamples:\n ironclaw routines list\n ironclaw routines create --name daily-digest --schedule '0 0 9 * * *' --prompt 'Summarize today'"
|
||||||
|
)]
|
||||||
|
Routines(RoutinesCommand),
|
||||||
|
|
||||||
/// Manage MCP servers (hosted tool providers)
|
/// Manage MCP servers (hosted tool providers)
|
||||||
#[command(
|
#[command(
|
||||||
subcommand,
|
subcommand,
|
||||||
@@ -281,6 +293,23 @@ pub async fn init_secrets_store()
|
|||||||
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
|
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the Routines CLI subcommand.
|
||||||
|
pub async fn run_routines_cli(
|
||||||
|
routines_cmd: &RoutinesCommand,
|
||||||
|
config_path: Option<&std::path::Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let config = crate::config::Config::from_env_with_toml(config_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
|
||||||
|
|
||||||
|
let db: Arc<dyn crate::db::Database> = crate::db::connect_from_config(&config.database)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
|
||||||
|
|
||||||
|
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
|
||||||
|
run_routines_command(routines_cmd.clone(), db, &user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the Memory CLI subcommand.
|
/// Run the Memory CLI subcommand.
|
||||||
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
||||||
let config = crate::config::Config::from_env()
|
let config = crate::config::Config::from_env()
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
//! `ironclaw routines` — manage scheduled routines from the CLI.
|
||||||
|
//!
|
||||||
|
//! Provides subcommands for listing, creating, editing, enabling/disabling,
|
||||||
|
//! deleting, and viewing run history of routines without starting the full agent.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use clap::Subcommand;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::agent::routine::{
|
||||||
|
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
||||||
|
};
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
/// Routines subcommands.
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum RoutinesCommand {
|
||||||
|
/// List routines
|
||||||
|
List {
|
||||||
|
/// Filter by trigger type (e.g. "cron", "webhook", "event")
|
||||||
|
#[arg(long)]
|
||||||
|
trigger: Option<String>,
|
||||||
|
|
||||||
|
/// Include disabled routines
|
||||||
|
#[arg(long)]
|
||||||
|
disabled: bool,
|
||||||
|
|
||||||
|
/// Output as JSON (for scripting)
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Create a new cron routine
|
||||||
|
#[command(alias = "add")]
|
||||||
|
Create {
|
||||||
|
/// Routine name (must be unique per user)
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Cron schedule (6-field: "sec min hour day month weekday")
|
||||||
|
#[arg(long)]
|
||||||
|
schedule: String,
|
||||||
|
|
||||||
|
/// Prompt for the LLM
|
||||||
|
#[arg(long)]
|
||||||
|
prompt: String,
|
||||||
|
|
||||||
|
/// Optional description
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
description: String,
|
||||||
|
|
||||||
|
/// IANA timezone (e.g. "America/New_York")
|
||||||
|
#[arg(long)]
|
||||||
|
timezone: Option<String>,
|
||||||
|
|
||||||
|
/// Cooldown between fires in seconds
|
||||||
|
#[arg(long, default_value = "300")]
|
||||||
|
cooldown: u64,
|
||||||
|
|
||||||
|
/// Notification channel
|
||||||
|
#[arg(long)]
|
||||||
|
notify_channel: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Edit an existing routine
|
||||||
|
#[command(alias = "update")]
|
||||||
|
Edit {
|
||||||
|
/// Routine name
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// New schedule
|
||||||
|
#[arg(long)]
|
||||||
|
schedule: Option<String>,
|
||||||
|
|
||||||
|
/// New prompt
|
||||||
|
#[arg(long)]
|
||||||
|
prompt: Option<String>,
|
||||||
|
|
||||||
|
/// New description
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
|
||||||
|
/// New timezone
|
||||||
|
#[arg(long)]
|
||||||
|
timezone: Option<String>,
|
||||||
|
|
||||||
|
/// New cooldown in seconds
|
||||||
|
#[arg(long)]
|
||||||
|
cooldown: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Enable a routine
|
||||||
|
Enable {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Disable a routine
|
||||||
|
Disable {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Delete a routine
|
||||||
|
#[command(alias = "rm")]
|
||||||
|
Delete {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Skip confirmation prompt
|
||||||
|
#[arg(short, long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show run history for a routine
|
||||||
|
#[command(alias = "runs")]
|
||||||
|
History {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Maximum number of runs to show
|
||||||
|
#[arg(short, long, default_value = "10")]
|
||||||
|
limit: i64,
|
||||||
|
|
||||||
|
/// Output as JSON (for scripting)
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a routines CLI command against the database.
|
||||||
|
pub async fn run_routines_command(
|
||||||
|
cmd: RoutinesCommand,
|
||||||
|
db: Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match cmd {
|
||||||
|
RoutinesCommand::List {
|
||||||
|
trigger,
|
||||||
|
disabled,
|
||||||
|
json,
|
||||||
|
} => list(&db, user_id, trigger.as_deref(), disabled, json).await,
|
||||||
|
RoutinesCommand::Create {
|
||||||
|
name,
|
||||||
|
schedule,
|
||||||
|
prompt,
|
||||||
|
description,
|
||||||
|
timezone,
|
||||||
|
cooldown,
|
||||||
|
notify_channel,
|
||||||
|
} => {
|
||||||
|
create(
|
||||||
|
&db,
|
||||||
|
user_id,
|
||||||
|
&name,
|
||||||
|
&schedule,
|
||||||
|
&prompt,
|
||||||
|
&description,
|
||||||
|
timezone.as_deref(),
|
||||||
|
cooldown,
|
||||||
|
notify_channel,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RoutinesCommand::Edit {
|
||||||
|
name,
|
||||||
|
schedule,
|
||||||
|
prompt,
|
||||||
|
description,
|
||||||
|
timezone,
|
||||||
|
cooldown,
|
||||||
|
} => {
|
||||||
|
edit(
|
||||||
|
&db,
|
||||||
|
user_id,
|
||||||
|
&name,
|
||||||
|
schedule.as_deref(),
|
||||||
|
prompt.as_deref(),
|
||||||
|
description.as_deref(),
|
||||||
|
timezone.as_deref(),
|
||||||
|
cooldown,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RoutinesCommand::Enable { name } => set_enabled(&db, user_id, &name, true).await,
|
||||||
|
RoutinesCommand::Disable { name } => set_enabled(&db, user_id, &name, false).await,
|
||||||
|
RoutinesCommand::Delete { name, yes } => delete(&db, user_id, &name, yes).await,
|
||||||
|
RoutinesCommand::History { name, limit, json } => {
|
||||||
|
history(&db, user_id, &name, limit, json).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
trigger_filter: Option<&str>,
|
||||||
|
show_disabled: bool,
|
||||||
|
json: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let routines = db.list_routines(user_id).await?;
|
||||||
|
|
||||||
|
let filtered: Vec<&Routine> = routines
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
trigger_filter
|
||||||
|
.map(|t| r.trigger.type_tag() == t)
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.filter(|r| show_disabled || r.enabled)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let items: Vec<serde_json::Value> = filtered
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.id.to_string(),
|
||||||
|
"name": r.name,
|
||||||
|
"trigger": r.trigger.type_tag(),
|
||||||
|
"enabled": r.enabled,
|
||||||
|
"next_fire_at": r.next_fire_at,
|
||||||
|
"last_run_at": r.last_run_at,
|
||||||
|
"run_count": r.run_count,
|
||||||
|
"consecutive_failures": r.consecutive_failures,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!("{}", serde_json::to_string_pretty(&items)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
if let Some(t) = trigger_filter {
|
||||||
|
println!("No {t} routines found.");
|
||||||
|
} else {
|
||||||
|
println!("No routines found.");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<20} {:<8} {:<8} {:<22} {:<22} {:>5}",
|
||||||
|
"ID", "NAME", "TRIGGER", "STATUS", "NEXT FIRE", "LAST RUN", "RUNS"
|
||||||
|
);
|
||||||
|
println!("{}", "-".repeat(130));
|
||||||
|
|
||||||
|
for r in &filtered {
|
||||||
|
let status = if r.enabled {
|
||||||
|
if r.consecutive_failures > 0 {
|
||||||
|
format!("err({})", r.consecutive_failures)
|
||||||
|
} else {
|
||||||
|
"active".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"disabled".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let next_fire = r
|
||||||
|
.next_fire_at
|
||||||
|
.map(format_relative)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
let last_run = r
|
||||||
|
.last_run_at
|
||||||
|
.map(format_relative)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
let name = truncate(&r.name, 20);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<20} {:<8} {:<8} {:<22} {:<22} {:>5}",
|
||||||
|
r.id,
|
||||||
|
name,
|
||||||
|
r.trigger.type_tag(),
|
||||||
|
status,
|
||||||
|
next_fire,
|
||||||
|
last_run,
|
||||||
|
r.run_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{} routine(s)", filtered.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn create(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
schedule: &str,
|
||||||
|
prompt: &str,
|
||||||
|
description: &str,
|
||||||
|
timezone: Option<&str>,
|
||||||
|
cooldown_secs: u64,
|
||||||
|
notify_channel: Option<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
validate_timezone_arg(timezone)?;
|
||||||
|
|
||||||
|
// Validate the cron expression by computing next fire.
|
||||||
|
let next_fire = next_cron_fire(schedule, timezone)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
|
||||||
|
|
||||||
|
// Check for name conflict.
|
||||||
|
if db.get_routine_by_name(user_id, name).await?.is_some() {
|
||||||
|
anyhow::bail!("Routine '{}' already exists", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let routine = Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: Trigger::Cron {
|
||||||
|
schedule: schedule.to_string(),
|
||||||
|
timezone: timezone.map(String::from),
|
||||||
|
},
|
||||||
|
action: RoutineAction::Lightweight {
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 4096,
|
||||||
|
},
|
||||||
|
guardrails: RoutineGuardrails {
|
||||||
|
cooldown: std::time::Duration::from_secs(cooldown_secs),
|
||||||
|
max_concurrent: 1,
|
||||||
|
dedup_window: None,
|
||||||
|
},
|
||||||
|
notify: NotifyConfig {
|
||||||
|
channel: notify_channel,
|
||||||
|
user: user_id.to_string(),
|
||||||
|
on_attention: true,
|
||||||
|
on_failure: true,
|
||||||
|
on_success: false,
|
||||||
|
},
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: next_fire,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.create_routine(&routine).await?;
|
||||||
|
|
||||||
|
println!("Created routine '{}'", name);
|
||||||
|
println!(" ID: {}", routine.id);
|
||||||
|
println!(" Schedule: {}", schedule);
|
||||||
|
if let Some(tz) = timezone {
|
||||||
|
println!(" Timezone: {}", tz);
|
||||||
|
}
|
||||||
|
if let Some(nf) = next_fire {
|
||||||
|
println!(" Next fire: {}", format_relative(nf));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn edit(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
schedule: Option<&str>,
|
||||||
|
prompt: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
timezone: Option<&str>,
|
||||||
|
cooldown: Option<u64>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut routine = require_routine(db, user_id, name).await?;
|
||||||
|
validate_timezone_arg(timezone)?;
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Update schedule if provided (only valid for cron routines).
|
||||||
|
if let Some(new_schedule) = schedule {
|
||||||
|
let tz = timezone.or(match &routine.trigger {
|
||||||
|
Trigger::Cron { timezone, .. } => timezone.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let next_fire = next_cron_fire(new_schedule, tz)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
|
||||||
|
routine.trigger = Trigger::Cron {
|
||||||
|
schedule: new_schedule.to_string(),
|
||||||
|
timezone: tz.map(String::from),
|
||||||
|
};
|
||||||
|
routine.next_fire_at = next_fire;
|
||||||
|
changed = true;
|
||||||
|
} else if let Some(tz) = timezone {
|
||||||
|
// Update only timezone, recompute next fire with existing schedule.
|
||||||
|
if let Trigger::Cron { ref schedule, .. } = routine.trigger {
|
||||||
|
let next_fire = next_cron_fire(schedule, Some(tz))
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
|
||||||
|
routine.trigger = Trigger::Cron {
|
||||||
|
schedule: schedule.clone(),
|
||||||
|
timezone: Some(tz.to_string()),
|
||||||
|
};
|
||||||
|
routine.next_fire_at = next_fire;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Cannot set timezone on non-cron trigger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_prompt) = prompt {
|
||||||
|
match &mut routine.action {
|
||||||
|
RoutineAction::Lightweight { prompt: p, .. } => {
|
||||||
|
*p = new_prompt.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
RoutineAction::FullJob { description: d, .. } => {
|
||||||
|
*d = new_prompt.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_desc) = description {
|
||||||
|
routine.description = new_desc.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cd) = cooldown {
|
||||||
|
routine.guardrails.cooldown = std::time::Duration::from_secs(cd);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
println!("No changes specified.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.updated_at = Utc::now();
|
||||||
|
db.update_routine(&routine).await?;
|
||||||
|
println!("Updated routine '{}'", name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enable / Disable ────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn set_enabled(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut routine = require_routine(db, user_id, name).await?;
|
||||||
|
|
||||||
|
if routine.enabled == enabled {
|
||||||
|
println!(
|
||||||
|
"Routine '{}' is already {}",
|
||||||
|
name,
|
||||||
|
if enabled { "enabled" } else { "disabled" }
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.enabled = enabled;
|
||||||
|
|
||||||
|
// Recompute next fire when enabling a cron routine.
|
||||||
|
if enabled
|
||||||
|
&& let Trigger::Cron {
|
||||||
|
ref schedule,
|
||||||
|
ref timezone,
|
||||||
|
} = routine.trigger
|
||||||
|
{
|
||||||
|
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to compute next fire for stored schedule: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.updated_at = Utc::now();
|
||||||
|
db.update_routine(&routine).await?;
|
||||||
|
println!(
|
||||||
|
"{} routine '{}'",
|
||||||
|
if enabled { "Enabled" } else { "Disabled" },
|
||||||
|
name
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
skip_confirm: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let routine = require_routine(db, user_id, name).await?;
|
||||||
|
|
||||||
|
if !skip_confirm {
|
||||||
|
println!("Routine: {}", routine.name);
|
||||||
|
println!(" ID: {}", routine.id);
|
||||||
|
println!(" Trigger: {}", routine.trigger.type_tag());
|
||||||
|
if let Trigger::Cron { ref schedule, .. } = routine.trigger {
|
||||||
|
println!("Schedule: {}", schedule);
|
||||||
|
}
|
||||||
|
println!(" Runs: {}", routine.run_count);
|
||||||
|
print!("\nDelete this routine? [y/N] ");
|
||||||
|
std::io::Write::flush(&mut std::io::stdout())?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||||
|
println!("Cancelled.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = db.delete_routine(routine.id).await?;
|
||||||
|
if deleted {
|
||||||
|
println!("Deleted routine '{}'", name);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Failed to delete routine '{}'", name);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn history(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
limit: i64,
|
||||||
|
json: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let routine = require_routine(db, user_id, name).await?;
|
||||||
|
|
||||||
|
let limit = limit.clamp(1, 50);
|
||||||
|
let runs = db.list_routine_runs(routine.id, limit).await?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let items: Vec<serde_json::Value> = runs
|
||||||
|
.iter()
|
||||||
|
.map(|run| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": run.id.to_string(),
|
||||||
|
"status": run.status.to_string(),
|
||||||
|
"started_at": run.started_at,
|
||||||
|
"completed_at": run.completed_at,
|
||||||
|
"result_summary": run.result_summary,
|
||||||
|
"tokens_used": run.tokens_used,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!("{}", serde_json::to_string_pretty(&items)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if runs.is_empty() {
|
||||||
|
println!("No runs found for routine '{}'", name);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Run history for '{}' (last {}):\n", name, runs.len());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<8} {:<20} {:<12} SUMMARY",
|
||||||
|
"RUN ID", "STATUS", "STARTED", "DURATION"
|
||||||
|
);
|
||||||
|
println!("{}", "-".repeat(100));
|
||||||
|
|
||||||
|
for run in &runs {
|
||||||
|
let duration = run
|
||||||
|
.completed_at
|
||||||
|
.map(|end| {
|
||||||
|
let secs = (end - run.started_at).num_seconds();
|
||||||
|
if secs < 60 {
|
||||||
|
format!("{}s", secs)
|
||||||
|
} else {
|
||||||
|
format!("{}m{}s", secs / 60, secs % 60)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "running".to_string());
|
||||||
|
|
||||||
|
let summary = run
|
||||||
|
.result_summary
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| truncate(s, 40))
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<8} {:<20} {:<12} {}",
|
||||||
|
run.id,
|
||||||
|
run.status,
|
||||||
|
run.started_at.format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
duration,
|
||||||
|
summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{} run(s) shown", runs.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared lookup ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Look up a routine by name.
|
||||||
|
async fn require_routine(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Routine> {
|
||||||
|
db.get_routine_by_name(user_id, name)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Routine '{}' not found", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_timezone_arg(timezone: Option<&str>) -> anyhow::Result<()> {
|
||||||
|
if let Some(tz) = timezone
|
||||||
|
&& crate::timezone::parse_timezone(tz).is_none()
|
||||||
|
{
|
||||||
|
anyhow::bail!("Invalid timezone: '{tz}' is not a valid IANA timezone");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format a datetime relative to now (e.g. "in 2h", "3m ago").
|
||||||
|
fn format_relative(dt: DateTime<Utc>) -> String {
|
||||||
|
let now = Utc::now();
|
||||||
|
let diff = dt.signed_duration_since(now);
|
||||||
|
let secs = diff.num_seconds();
|
||||||
|
|
||||||
|
if secs.abs() < 60 {
|
||||||
|
if secs >= 0 {
|
||||||
|
"in <1m".to_string()
|
||||||
|
} else {
|
||||||
|
"<1m ago".to_string()
|
||||||
|
}
|
||||||
|
} else if secs.abs() < 3600 {
|
||||||
|
let mins = secs.abs() / 60;
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {}m", mins)
|
||||||
|
} else {
|
||||||
|
format!("{}m ago", mins)
|
||||||
|
}
|
||||||
|
} else if secs.abs() < 86400 {
|
||||||
|
let hours = secs.abs() / 3600;
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {}h", hours)
|
||||||
|
} else {
|
||||||
|
format!("{}h ago", hours)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let days = secs.abs() / 86400;
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {}d", days)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to a maximum character length.
|
||||||
|
fn truncate(s: &str, max_chars: usize) -> String {
|
||||||
|
if s.chars().count() <= max_chars {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let truncated: String = s.chars().take(max_chars.saturating_sub(2)).collect();
|
||||||
|
format!("{}..", truncated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_future() {
|
||||||
|
let future = Utc::now() + chrono::Duration::hours(2);
|
||||||
|
let result = format_relative(future);
|
||||||
|
assert!(
|
||||||
|
result.starts_with("in "),
|
||||||
|
"expected 'in ...' for future time, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_past() {
|
||||||
|
let past = Utc::now() - chrono::Duration::minutes(30);
|
||||||
|
let result = format_relative(past);
|
||||||
|
assert!(
|
||||||
|
result.ends_with(" ago"),
|
||||||
|
"expected '... ago' for past time, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_days() {
|
||||||
|
let far_future = Utc::now() + chrono::Duration::days(3);
|
||||||
|
let result = format_relative(far_future);
|
||||||
|
assert!(result.contains('d'), "expected days in: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_short_string() {
|
||||||
|
assert_eq!(truncate("hello", 10), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_long_string() {
|
||||||
|
let result = truncate("hello world", 7);
|
||||||
|
assert_eq!(result, "hello..");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_multibyte_safe() {
|
||||||
|
// Ensure no panic on multi-byte characters.
|
||||||
|
let cjk = "你好世界测试";
|
||||||
|
let result = truncate(cjk, 4);
|
||||||
|
assert!(result.ends_with(".."), "got: {result}");
|
||||||
|
// Must be valid UTF-8 (would have panicked otherwise).
|
||||||
|
assert!(result.is_char_boundary(result.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
source: src/cli/mod.rs
|
|
||||||
assertion_line: 302
|
|
||||||
expression: help
|
|
||||||
---
|
|
||||||
Secure personal AI assistant that protects your data and expands its capabilities
|
|
||||||
|
|
||||||
Usage: ironclaw [OPTIONS] [COMMAND]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
run Run the AI agent
|
|
||||||
onboard Run interactive setup wizard
|
|
||||||
config Manage app configs
|
|
||||||
tool Manage WASM tools
|
|
||||||
registry Browse/install extensions
|
|
||||||
mcp Manage MCP servers
|
|
||||||
memory Manage workspace memory
|
|
||||||
pairing Manage DM pairing
|
|
||||||
service Manage OS service
|
|
||||||
doctor Run diagnostics
|
|
||||||
status Show system status
|
|
||||||
completion Generate completions
|
|
||||||
import Import from other AI systems
|
|
||||||
help Print this message or the help of the given subcommand(s)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--cli-only Run in interactive CLI mode only (disable other channels)
|
|
||||||
--no-db Skip database connection (for testing)
|
|
||||||
-m, --message <MESSAGE> Single message mode - send one message and exit
|
|
||||||
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
|
|
||||||
--no-onboard Skip first-run onboarding check
|
|
||||||
-h, --help Print help (see more with '--help')
|
|
||||||
-V, --version Print version
|
|
||||||
@@ -13,6 +13,7 @@ Commands:
|
|||||||
tool Manage WASM tools
|
tool Manage WASM tools
|
||||||
registry Browse/install extensions
|
registry Browse/install extensions
|
||||||
channels Manage channels
|
channels Manage channels
|
||||||
|
routines Manage routines
|
||||||
mcp Manage MCP servers
|
mcp Manage MCP servers
|
||||||
memory Manage workspace memory
|
memory Manage workspace memory
|
||||||
pairing Manage DM pairing
|
pairing Manage DM pairing
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
source: src/cli/mod.rs
|
|
||||||
assertion_line: 318
|
|
||||||
expression: help
|
|
||||||
---
|
|
||||||
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
|
|
||||||
Examples:
|
|
||||||
ironclaw run # Start the agent
|
|
||||||
ironclaw config list # List configs
|
|
||||||
|
|
||||||
Usage: ironclaw [OPTIONS] [COMMAND]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
run Run the AI agent
|
|
||||||
onboard Run interactive setup wizard
|
|
||||||
config Manage app configs
|
|
||||||
tool Manage WASM tools
|
|
||||||
registry Browse/install extensions
|
|
||||||
mcp Manage MCP servers
|
|
||||||
memory Manage workspace memory
|
|
||||||
pairing Manage DM pairing
|
|
||||||
service Manage OS service
|
|
||||||
doctor Run diagnostics
|
|
||||||
status Show system status
|
|
||||||
completion Generate completions
|
|
||||||
import Import from other AI systems
|
|
||||||
help Print this message or the help of the given subcommand(s)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--cli-only
|
|
||||||
Run in interactive CLI mode only (disable other channels)
|
|
||||||
|
|
||||||
--no-db
|
|
||||||
Skip database connection (for testing)
|
|
||||||
|
|
||||||
-m, --message <MESSAGE>
|
|
||||||
Single message mode - send one message and exit
|
|
||||||
|
|
||||||
-c, --config <CONFIG>
|
|
||||||
Configuration file path (optional, uses env vars by default)
|
|
||||||
|
|
||||||
--no-onboard
|
|
||||||
Skip first-run onboarding check
|
|
||||||
|
|
||||||
-h, --help
|
|
||||||
Print help (see a summary with '-h')
|
|
||||||
|
|
||||||
-V, --version
|
|
||||||
Print version
|
|
||||||
@@ -16,6 +16,7 @@ Commands:
|
|||||||
tool Manage WASM tools
|
tool Manage WASM tools
|
||||||
registry Browse/install extensions
|
registry Browse/install extensions
|
||||||
channels Manage channels
|
channels Manage channels
|
||||||
|
routines Manage routines
|
||||||
mcp Manage MCP servers
|
mcp Manage MCP servers
|
||||||
memory Manage workspace memory
|
memory Manage workspace memory
|
||||||
pairing Manage DM pairing
|
pairing Manage DM pairing
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Some(Command::Routines(routines_cmd)) => {
|
||||||
|
init_cli_tracing();
|
||||||
|
return ironclaw::cli::run_routines_cli(routines_cmd, cli.config.as_deref()).await;
|
||||||
|
}
|
||||||
Some(Command::Mcp(mcp_cmd)) => {
|
Some(Command::Mcp(mcp_cmd)) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return run_mcp_command(*mcp_cmd.clone()).await;
|
return run_mcp_command(*mcp_cmd.clone()).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user