use std::collections::BTreeMap;
use std::fs;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use chrono::{
DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike, Utc, Weekday,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::task_manager::{NewTaskRequest, SharedTaskManager, TaskStatus};
use crate::utils::spawn_supervised;
const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 1;
const CURRENT_RUN_SCHEMA_VERSION: u32 = 1;
const CURRENT_TRIGGER_SCHEMA_VERSION: u32 = 1;
const DEFAULT_AUTOMATION_MODE: &str = "agent";
const DEFAULT_AUTOMATION_ALLOW_SHELL: bool = false;
const DEFAULT_AUTOMATION_TRUST_MODE: bool = false;
const DEFAULT_AUTOMATION_AUTO_APPROVE: bool = false;
const DEFAULT_AUTOMATION_DELIVERY_MODE: AutomationDeliveryMode = AutomationDeliveryMode::Task;
pub const AUTOMATION_WATCHER_NO_REPORT_SENTINEL: &str = "NOTHING_TO_REPORT";
const MAX_HOURLY_SEARCH_STEPS: usize = 24 * 21;
const MAX_CRON_SEARCH_MINUTES: usize = 60 * 24 * 366 * 5;
const fn default_automation_schema_version() -> u32 {
CURRENT_AUTOMATION_SCHEMA_VERSION
}
const fn default_run_schema_version() -> u32 {
CURRENT_RUN_SCHEMA_VERSION
}
const fn default_trigger_schema_version() -> u32 {
CURRENT_TRIGGER_SCHEMA_VERSION
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DelayedTriggerStatus {
Pending,
Fired,
Canceled,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelayedTriggerRecord {
#[serde(default = "default_trigger_schema_version")]
pub schema_version: u32,
pub trigger_id: String,
pub fire_at: DateTime<Utc>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<PathBuf>,
pub status: DelayedTriggerStatus,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fired_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_trigger_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CreateDelayedTriggerRequest {
pub fire_at: DateTime<Utc>,
pub message: String,
pub workspace: Option<PathBuf>,
pub parent_trigger_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutomationStatus {
Active,
Paused,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutomationRunStatus {
Queued,
Running,
Completed,
Failed,
Canceled,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AutomationDeliveryMode {
#[default]
Task,
Watcher,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationRecord {
#[serde(default = "default_automation_schema_version")]
pub schema_version: u32,
pub id: String,
pub name: String,
pub prompt: String,
pub rrule: String,
#[serde(default)]
pub cwds: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_shell: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trust_mode: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_mode: Option<AutomationDeliveryMode>,
pub status: AutomationStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_run_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_run_at: Option<DateTime<Utc>>,
}
impl AutomationRecord {
fn task_mode(&self) -> String {
self.mode
.as_deref()
.map(str::trim)
.filter(|mode| !mode.is_empty())
.unwrap_or(DEFAULT_AUTOMATION_MODE)
.to_string()
}
fn task_allow_shell(&self) -> bool {
self.allow_shell.unwrap_or(DEFAULT_AUTOMATION_ALLOW_SHELL)
}
fn task_trust_mode(&self) -> bool {
self.trust_mode.unwrap_or(DEFAULT_AUTOMATION_TRUST_MODE)
}
fn task_auto_approve(&self) -> bool {
self.auto_approve.unwrap_or(DEFAULT_AUTOMATION_AUTO_APPROVE)
}
fn delivery_mode(&self) -> AutomationDeliveryMode {
self.delivery_mode
.unwrap_or(DEFAULT_AUTOMATION_DELIVERY_MODE)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationRunRecord {
#[serde(default = "default_run_schema_version")]
pub schema_version: u32,
pub id: String,
pub automation_id: String,
pub scheduled_for: DateTime<Utc>,
pub status: AutomationRunStatus,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAutomationRequest {
pub name: String,
pub prompt: String,
pub rrule: String,
#[serde(default)]
pub cwds: Vec<PathBuf>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub allow_shell: Option<bool>,
#[serde(default)]
pub trust_mode: Option<bool>,
#[serde(default)]
pub auto_approve: Option<bool>,
#[serde(default)]
pub delivery_mode: Option<AutomationDeliveryMode>,
#[serde(default)]
pub status: Option<AutomationStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateAutomationRequest {
pub name: Option<String>,
pub prompt: Option<String>,
pub rrule: Option<String>,
pub cwds: Option<Vec<PathBuf>>,
pub mode: Option<String>,
pub allow_shell: Option<bool>,
pub trust_mode: Option<bool>,
pub auto_approve: Option<bool>,
pub delivery_mode: Option<AutomationDeliveryMode>,
pub status: Option<AutomationStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutomationFrequency {
Hourly,
Weekly,
}
#[derive(Debug, Clone)]
pub enum AutomationSchedule {
Once {
at: DateTime<Utc>,
},
Hourly {
interval_hours: u32,
byday: Option<Vec<Weekday>>,
anchor_hour: Option<u32>,
anchor_minute: Option<u32>,
},
Weekly {
byday: Vec<Weekday>,
byhour: u32,
byminute: u32,
},
Cron {
expr: String,
},
}
impl AutomationSchedule {
pub fn parse_rrule(rrule: &str) -> Result<Self> {
let mut parts: BTreeMap<String, String> = BTreeMap::new();
for raw in rrule.split(';') {
let item = raw.trim();
if item.is_empty() {
continue;
}
let Some((k, v)) = item.split_once('=') else {
bail!("Invalid RRULE segment '{item}'");
};
parts.insert(k.trim().to_ascii_uppercase(), v.trim().to_string());
}
let freq = match parts
.get("FREQ")
.map(|value| value.trim().to_ascii_uppercase())
.as_deref()
{
Some("ONCE") => return parse_once_schedule(&parts),
Some("HOURLY") => AutomationFrequency::Hourly,
Some("WEEKLY") => AutomationFrequency::Weekly,
Some("CRON") => return parse_cron_schedule(&parts),
Some(other) => {
bail!("Unsupported RRULE FREQ '{other}'. Supported: ONCE, HOURLY, WEEKLY, and CRON")
}
None => bail!("RRULE must include FREQ"),
};
match freq {
AutomationFrequency::Hourly => {
for key in parts.keys() {
if key != "FREQ"
&& key != "INTERVAL"
&& key != "BYDAY"
&& key != "BYHOUR"
&& key != "BYMINUTE"
{
bail!(
"Unsupported RRULE field '{key}' for HOURLY. Allowed: FREQ,INTERVAL,BYDAY,BYHOUR,BYMINUTE"
);
}
}
let interval_hours = parts
.get("INTERVAL")
.map(|v| v.parse::<u32>())
.transpose()
.context("Failed to parse INTERVAL")?
.unwrap_or(1);
if interval_hours == 0 {
bail!("INTERVAL must be >= 1 for HOURLY schedules");
}
let byday = parts
.get("BYDAY")
.map(|value| parse_byday(&value.to_ascii_uppercase()))
.transpose()?;
let anchor_hour = parts
.get("BYHOUR")
.map(|value| value.parse::<u32>())
.transpose()
.context("Failed to parse BYHOUR")?;
let anchor_minute = parts
.get("BYMINUTE")
.map(|value| value.parse::<u32>())
.transpose()
.context("Failed to parse BYMINUTE")?;
if anchor_hour.is_some_and(|hour| hour > 23) {
bail!("BYHOUR must be between 0 and 23");
}
if anchor_minute.is_some_and(|minute| minute > 59) {
bail!("BYMINUTE must be between 0 and 59");
}
Ok(Self::Hourly {
interval_hours,
byday,
anchor_hour,
anchor_minute,
})
}
AutomationFrequency::Weekly => {
for key in parts.keys() {
if key != "FREQ" && key != "BYDAY" && key != "BYHOUR" && key != "BYMINUTE" {
bail!(
"Unsupported RRULE field '{key}' for WEEKLY. Allowed: FREQ,BYDAY,BYHOUR,BYMINUTE"
);
}
}
let byday_raw = parts
.get("BYDAY")
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYDAY"))?;
let byday = parse_byday(&byday_raw.to_ascii_uppercase())?;
if byday.is_empty() {
bail!("BYDAY cannot be empty for WEEKLY schedules");
}
let byhour = parts
.get("BYHOUR")
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYHOUR"))?
.parse::<u32>()
.context("Failed to parse BYHOUR")?;
let byminute = parts
.get("BYMINUTE")
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYMINUTE"))?
.parse::<u32>()
.context("Failed to parse BYMINUTE")?;
if byhour > 23 {
bail!("BYHOUR must be between 0 and 23");
}
if byminute > 59 {
bail!("BYMINUTE must be between 0 and 59");
}
Ok(Self::Weekly {
byday,
byhour,
byminute,
})
}
}
}
fn next_after_with_anchor(
&self,
after: DateTime<Utc>,
anchor_reference: DateTime<Utc>,
) -> Result<DateTime<Utc>> {
self.next_after_in_timezone(after, anchor_reference, &Local)
}
fn next_after_in_timezone<Tz: TimeZone>(
&self,
after: DateTime<Utc>,
anchor_reference: DateTime<Utc>,
timezone: &Tz,
) -> Result<DateTime<Utc>> {
let local_after = after.with_timezone(timezone);
match self {
Self::Once { at } => {
if *at > after {
Ok(*at)
} else {
bail!(
"Once schedule has no future run after {}",
after.to_rfc3339()
)
}
}
Self::Hourly {
interval_hours,
byday,
anchor_hour,
anchor_minute,
} => {
if anchor_hour.is_some() || anchor_minute.is_some() {
let local_anchor_reference = anchor_reference.with_timezone(timezone);
let hour = anchor_hour.unwrap_or(local_anchor_reference.hour());
let minute = anchor_minute.unwrap_or(0);
let anchor_naive = local_anchor_reference
.date_naive()
.and_hms_opt(hour, minute, 0)
.ok_or_else(|| anyhow::anyhow!("Unable to construct HOURLY anchor"))?;
let interval_seconds = i64::from(*interval_hours) * 60 * 60;
let elapsed_seconds = local_after
.naive_local()
.signed_duration_since(anchor_naive)
.num_seconds();
let mut steps = if elapsed_seconds < 0 {
0
} else {
elapsed_seconds / interval_seconds + 1
};
for _ in 0..MAX_HOURLY_SEARCH_STEPS {
let hours = i64::from(*interval_hours)
.checked_mul(steps)
.ok_or_else(|| anyhow::anyhow!("HOURLY schedule exceeded its range"))?;
let delta = Duration::try_hours(hours)
.ok_or_else(|| anyhow::anyhow!("HOURLY schedule exceeded its range"))?;
let candidate_naive = anchor_naive
.checked_add_signed(delta)
.ok_or_else(|| anyhow::anyhow!("HOURLY schedule exceeded its range"))?;
if byday
.as_ref()
.is_none_or(|days| days.contains(&candidate_naive.weekday()))
&& let Some(candidate) =
resolve_local_datetime(timezone, candidate_naive)
{
let candidate = candidate.with_timezone(&Utc);
if candidate > after {
return Ok(candidate);
}
}
steps = steps
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("HOURLY schedule exceeded its range"))?;
}
bail!("Unable to compute next anchored HOURLY run");
}
let after_second = local_after.second();
let after_nanosecond = local_after.nanosecond();
let mut candidate = local_after + Duration::hours(i64::from(*interval_hours))
- Duration::seconds(i64::from(after_second))
- Duration::nanoseconds(i64::from(after_nanosecond));
if let Some(days) = byday {
for _ in 0..(24 * 21) {
if days.contains(&candidate.weekday()) {
return Ok(candidate.with_timezone(&Utc));
}
candidate += Duration::hours(i64::from(*interval_hours));
}
bail!("Unable to compute next HOURLY run for BYDAY filter");
}
Ok(candidate.with_timezone(&Utc))
}
Self::Weekly {
byday,
byhour,
byminute,
} => {
for day_offset in 0..15 {
let date = local_after.date_naive() + Duration::days(i64::from(day_offset));
if !byday.contains(&date.weekday()) {
continue;
}
let Some(candidate_naive) = date.and_hms_opt(*byhour, *byminute, 0) else {
continue;
};
if let Some(candidate) = resolve_local_datetime(timezone, candidate_naive)
&& candidate.with_timezone(&Utc) > after
{
return Ok(candidate.with_timezone(&Utc));
}
}
bail!("Unable to compute next WEEKLY run");
}
Self::Cron { expr } => {
let cron = ParsedCronExpr::parse(expr)?;
let mut candidate_naive = local_after
.naive_local()
.with_second(0)
.and_then(|dt| dt.with_nanosecond(0))
.ok_or_else(|| anyhow::anyhow!("Unable to round CRON search start"))?
.checked_add_signed(Duration::minutes(1))
.ok_or_else(|| anyhow::anyhow!("CRON schedule exceeded its range"))?;
for _ in 0..MAX_CRON_SEARCH_MINUTES {
if cron.matches(candidate_naive)
&& let Some(candidate) = resolve_local_datetime(timezone, candidate_naive)
{
let candidate = candidate.with_timezone(&Utc);
if candidate > after {
return Ok(candidate);
}
}
candidate_naive = candidate_naive
.checked_add_signed(Duration::minutes(1))
.ok_or_else(|| anyhow::anyhow!("CRON schedule exceeded its range"))?;
}
bail!("Unable to compute next CRON run within 5 years");
}
}
}
fn next_after_slot(
&self,
slot: DateTime<Utc>,
anchor_reference: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>> {
match self {
Self::Once { .. } => Ok(None),
_ => self
.next_after_with_anchor(slot, anchor_reference)
.map(Some),
}
}
}
fn resolve_local_datetime<Tz: TimeZone>(
timezone: &Tz,
naive: NaiveDateTime,
) -> Option<DateTime<Tz>> {
timezone.from_local_datetime(&naive).earliest()
}
fn parse_byday(value: &str) -> Result<Vec<Weekday>> {
let mut days = Vec::new();
for token in value.split(',') {
let day = match token.trim().to_ascii_uppercase().as_str() {
"MO" => Weekday::Mon,
"TU" => Weekday::Tue,
"WE" => Weekday::Wed,
"TH" => Weekday::Thu,
"FR" => Weekday::Fri,
"SA" => Weekday::Sat,
"SU" => Weekday::Sun,
other => bail!("Invalid BYDAY value '{other}'"),
};
if !days.contains(&day) {
days.push(day);
}
}
Ok(days)
}
fn parse_once_schedule(parts: &BTreeMap<String, String>) -> Result<AutomationSchedule> {
for key in parts.keys() {
if key != "FREQ" && key != "AT" {
bail!("Unsupported RRULE field '{key}' for ONCE. Allowed: FREQ,AT");
}
}
let raw_at = parts
.get("AT")
.ok_or_else(|| anyhow::anyhow!("ONCE schedules require AT"))?;
let at = parse_once_at(raw_at)?;
Ok(AutomationSchedule::Once { at })
}
fn parse_cron_schedule(parts: &BTreeMap<String, String>) -> Result<AutomationSchedule> {
for key in parts.keys() {
if key != "FREQ" && key != "EXPR" {
bail!("Unsupported RRULE field '{key}' for CRON. Allowed: FREQ,EXPR");
}
}
let expr = parts
.get("EXPR")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("CRON schedules require EXPR"))?;
ParsedCronExpr::parse(&expr)?;
Ok(AutomationSchedule::Cron { expr })
}
fn parse_once_at(raw: &str) -> Result<DateTime<Utc>> {
let trimmed = raw.trim();
if let Ok(at) = DateTime::parse_from_rfc3339(trimmed) {
return Ok(at.with_timezone(&Utc));
}
for format in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"] {
if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, format) {
return resolve_local_datetime(&Local, naive)
.map(|value| value.with_timezone(&Utc))
.ok_or_else(|| anyhow::anyhow!("ONCE local time does not exist: {trimmed}"));
}
}
bail!("Failed to parse ONCE AT '{trimmed}'. Use local YYYY-MM-DDTHH:MM[:SS] or RFC3339")
}
#[derive(Debug, Clone)]
struct ParsedCronExpr {
minute: CronField,
hour: CronField,
day_of_month: CronField,
month: CronField,
day_of_week: CronField,
}
impl ParsedCronExpr {
fn parse(expr: &str) -> Result<Self> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
bail!(
"CRON EXPR must have exactly 5 fields: minute hour day-of-month month day-of-week"
);
}
let parsed = Self {
minute: CronField::parse(fields[0], 0, 59, CronNameMap::none(), "minute")?,
hour: CronField::parse(fields[1], 0, 23, CronNameMap::none(), "hour")?,
day_of_month: CronField::parse(fields[2], 1, 31, CronNameMap::none(), "day-of-month")?,
month: CronField::parse(fields[3], 1, 12, CronNameMap::month(), "month")?,
day_of_week: CronField::parse(fields[4], 0, 7, CronNameMap::weekday(), "day-of-week")?
.normalized_day_of_week(),
};
parsed.validate_date_space()?;
Ok(parsed)
}
fn matches(&self, candidate: NaiveDateTime) -> bool {
if !self.minute.contains(candidate.minute())
|| !self.hour.contains(candidate.hour())
|| !self.month.contains(candidate.month())
{
return false;
}
let day_of_month = self.day_of_month.contains(candidate.day());
let weekday = self
.day_of_week
.contains(weekday_to_cron(candidate.weekday()));
if self.day_of_month.is_wildcard && self.day_of_week.is_wildcard {
true
} else if self.day_of_month.is_wildcard {
weekday
} else if self.day_of_week.is_wildcard {
day_of_month
} else {
day_of_month || weekday
}
}
fn validate_date_space(&self) -> Result<()> {
if self.day_of_month.is_wildcard {
return Ok(());
}
let months = self.month.values();
let days = self.day_of_month.values();
let valid = months.iter().copied().any(|month| {
let common = days_in_month(2025, month);
let leap = days_in_month(2024, month);
days.iter().copied().any(|day| day <= common || day <= leap)
});
if valid {
Ok(())
} else {
bail!("CRON EXPR day-of-month/month combination can never occur")
}
}
}
#[derive(Debug, Clone)]
struct CronField {
values: Vec<u32>,
is_wildcard: bool,
}
impl CronField {
fn parse(raw: &str, min: u32, max: u32, names: CronNameMap, field_name: &str) -> Result<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("CRON {field_name} field must not be empty");
}
let mut values = Vec::new();
let is_wildcard = trimmed == "*";
for part in trimmed.split(',') {
let part = part.trim();
if part.is_empty() {
bail!("CRON {field_name} field contains an empty list item");
}
let (base, step) = if let Some((base, step)) = part.split_once('/') {
let step = step
.trim()
.parse::<u32>()
.with_context(|| format!("Failed to parse CRON {field_name} step"))?;
if step == 0 {
bail!("CRON {field_name} step must be >= 1");
}
(base.trim(), step)
} else {
(part, 1)
};
let range = if base == "*" {
(min, max)
} else if let Some((start, end)) = base.split_once('-') {
let start = parse_cron_atom(start.trim(), min, max, names, field_name)?;
let end = parse_cron_atom(end.trim(), min, max, names, field_name)?;
if start > end {
bail!("CRON {field_name} range start must be <= end");
}
(start, end)
} else {
let start = parse_cron_atom(base, min, max, names, field_name)?;
if part.contains('/') {
(start, max)
} else {
(start, start)
}
};
let mut current = range.0;
while current <= range.1 {
if !values.contains(¤t) {
values.push(current);
}
let Some(next) = current.checked_add(step) else {
break;
};
if next <= current {
break;
}
current = next;
}
}
values.sort_unstable();
Ok(Self {
values,
is_wildcard,
})
}
fn normalized_day_of_week(mut self) -> Self {
for value in &mut self.values {
if *value == 7 {
*value = 0;
}
}
self.values.sort_unstable();
self.values.dedup();
self
}
fn contains(&self, value: u32) -> bool {
self.values.binary_search(&value).is_ok()
}
fn values(&self) -> &[u32] {
&self.values
}
}
#[derive(Debug, Clone, Copy)]
struct CronNameMap(&'static [(&'static str, u32)]);
impl CronNameMap {
const fn none() -> Self {
Self(&[])
}
const fn month() -> Self {
Self(&[
("JAN", 1),
("FEB", 2),
("MAR", 3),
("APR", 4),
("MAY", 5),
("JUN", 6),
("JUL", 7),
("AUG", 8),
("SEP", 9),
("OCT", 10),
("NOV", 11),
("DEC", 12),
])
}
const fn weekday() -> Self {
Self(&[
("SUN", 0),
("MON", 1),
("TUE", 2),
("WED", 3),
("THU", 4),
("FRI", 5),
("SAT", 6),
])
}
fn lookup(self, token: &str) -> Option<u32> {
let needle = token.trim().to_ascii_uppercase();
self.0
.iter()
.find_map(|(name, value)| (*name == needle).then_some(*value))
}
}
fn parse_cron_atom(
raw: &str,
min: u32,
max: u32,
names: CronNameMap,
field_name: &str,
) -> Result<u32> {
let value = names
.lookup(raw)
.or_else(|| raw.parse::<u32>().ok())
.ok_or_else(|| anyhow::anyhow!("Invalid CRON {field_name} value '{raw}'"))?;
if !(min..=max).contains(&value) {
bail!("CRON {field_name} value {value} is out of range {min}-{max}");
}
Ok(value)
}
fn weekday_to_cron(day: Weekday) -> u32 {
match day {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
}
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
if leap { 29 } else { 28 }
}
_ => 0,
}
}
#[derive(Debug, Clone)]
pub struct AutomationManager {
automations_dir: PathBuf,
runs_dir: PathBuf,
triggers_dir: PathBuf,
}
impl AutomationManager {
pub fn open(root: PathBuf) -> Result<Self> {
let automations_dir = root.join("automations");
let runs_dir = root.join("runs");
let triggers_dir = root.join("triggers");
fs::create_dir_all(&automations_dir)
.with_context(|| format!("Failed to create {}", automations_dir.display()))?;
fs::create_dir_all(&runs_dir)
.with_context(|| format!("Failed to create {}", runs_dir.display()))?;
fs::create_dir_all(&triggers_dir)
.with_context(|| format!("Failed to create {}", triggers_dir.display()))?;
Ok(Self {
automations_dir,
runs_dir,
triggers_dir,
})
}
pub fn default_location() -> Result<Self> {
Self::open(default_automations_dir())
}
fn automation_path(&self, id: &str) -> Result<PathBuf> {
ensure_safe_storage_id("automation id", id)?;
Ok(self.automations_dir.join(format!("{id}.json")))
}
fn runs_dir_for(&self, automation_id: &str) -> Result<PathBuf> {
ensure_safe_storage_id("automation id", automation_id)?;
Ok(self.runs_dir.join(automation_id))
}
fn trigger_path(&self, trigger_id: &str) -> Result<PathBuf> {
ensure_safe_storage_id("trigger id", trigger_id)?;
Ok(self.triggers_dir.join(format!("{trigger_id}.json")))
}
fn run_path(&self, run: &AutomationRunRecord) -> Result<PathBuf> {
ensure_safe_storage_id("run id", &run.id)?;
Ok(self.runs_dir_for(&run.automation_id)?.join(format!(
"{}-{}.json",
run_file_stamp(run.created_at),
run.id
)))
}
fn legacy_run_path(&self, automation_id: &str, run_id: &str) -> Result<PathBuf> {
ensure_safe_storage_id("run id", run_id)?;
Ok(self
.runs_dir_for(automation_id)?
.join(format!("{run_id}.json")))
}
pub fn create_automation(&self, req: CreateAutomationRequest) -> Result<AutomationRecord> {
validate_name_and_prompt(&req.name, &req.prompt)?;
let schedule = AutomationSchedule::parse_rrule(&req.rrule)?;
let now = Utc::now();
let status = req.status.unwrap_or(AutomationStatus::Active);
let next_run_at = if matches!(status, AutomationStatus::Active) {
Some(schedule.next_after_with_anchor(now, now)?)
} else {
None
};
let record = AutomationRecord {
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
name: req.name.trim().to_string(),
prompt: req.prompt.trim().to_string(),
rrule: req.rrule.trim().to_ascii_uppercase(),
cwds: req.cwds,
mode: normalize_optional_string(req.mode),
allow_shell: req.allow_shell,
trust_mode: req.trust_mode,
auto_approve: req.auto_approve,
delivery_mode: req.delivery_mode,
status,
created_at: now,
updated_at: now,
next_run_at,
last_run_at: None,
};
self.save_automation(&record)?;
Ok(record)
}
pub fn get_automation(&self, id: &str) -> Result<AutomationRecord> {
let path = self.automation_path(id)?;
let raw = fs::read_to_string(&path)
.with_context(|| format!("Failed to read automation {}", path.display()))?;
let record: AutomationRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse automation {}", path.display()))?;
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
bail!(
"Automation schema v{} is newer than supported v{}",
record.schema_version,
CURRENT_AUTOMATION_SCHEMA_VERSION
);
}
Ok(record)
}
pub fn save_automation(&self, record: &AutomationRecord) -> Result<()> {
write_json_atomic(&self.automation_path(&record.id)?, record)
}
pub fn list_automations(&self) -> Result<Vec<AutomationRecord>> {
let mut out = Vec::new();
for entry in fs::read_dir(&self.automations_dir)
.with_context(|| format!("Failed to read {}", self.automations_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let record: AutomationRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
bail!(
"Automation schema v{} is newer than supported v{}",
record.schema_version,
CURRENT_AUTOMATION_SCHEMA_VERSION
);
}
out.push(record);
}
out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
Ok(out)
}
pub fn update_automation(
&self,
id: &str,
req: UpdateAutomationRequest,
) -> Result<AutomationRecord> {
let mut existing = self.get_automation(id)?;
if let Some(name) = req.name {
if name.trim().is_empty() {
bail!("Automation name cannot be empty");
}
existing.name = name.trim().to_string();
}
if let Some(prompt) = req.prompt {
if prompt.trim().is_empty() {
bail!("Automation prompt cannot be empty");
}
existing.prompt = prompt.trim().to_string();
}
if let Some(rrule) = req.rrule {
let normalized = rrule.trim().to_ascii_uppercase();
AutomationSchedule::parse_rrule(&normalized)?;
existing.rrule = normalized;
if matches!(existing.status, AutomationStatus::Active) {
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
existing.next_run_at =
Some(schedule.next_after_with_anchor(Utc::now(), existing.created_at)?);
}
}
if let Some(cwds) = req.cwds {
existing.cwds = cwds;
}
if let Some(mode) = req.mode {
existing.mode = normalize_optional_string(Some(mode));
}
if let Some(allow_shell) = req.allow_shell {
existing.allow_shell = Some(allow_shell);
}
if let Some(trust_mode) = req.trust_mode {
existing.trust_mode = Some(trust_mode);
}
if let Some(auto_approve) = req.auto_approve {
existing.auto_approve = Some(auto_approve);
}
if let Some(delivery_mode) = req.delivery_mode {
existing.delivery_mode = Some(delivery_mode);
}
if let Some(status) = req.status {
existing.status = status;
if matches!(status, AutomationStatus::Paused) {
existing.next_run_at = None;
} else {
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
existing.next_run_at =
Some(schedule.next_after_with_anchor(Utc::now(), existing.created_at)?);
}
}
existing.updated_at = Utc::now();
self.save_automation(&existing)?;
Ok(existing)
}
pub fn pause_automation(&self, id: &str) -> Result<AutomationRecord> {
self.update_automation(
id,
UpdateAutomationRequest {
status: Some(AutomationStatus::Paused),
..UpdateAutomationRequest::default()
},
)
}
pub fn resume_automation(&self, id: &str) -> Result<AutomationRecord> {
self.update_automation(
id,
UpdateAutomationRequest {
status: Some(AutomationStatus::Active),
..UpdateAutomationRequest::default()
},
)
}
pub fn delete_automation(&self, id: &str) -> Result<AutomationRecord> {
let existing = self.get_automation(id)?;
let path = self.automation_path(id)?;
fs::remove_file(&path)
.with_context(|| format!("Failed to delete automation {}", path.display()))?;
let runs_dir = self.runs_dir_for(id)?;
if runs_dir.exists() {
fs::remove_dir_all(&runs_dir).with_context(|| {
format!("Failed to delete automation runs {}", runs_dir.display())
})?;
}
Ok(existing)
}
pub fn list_runs(
&self,
automation_id: &str,
limit: Option<usize>,
) -> Result<Vec<AutomationRunRecord>> {
let dir = self.runs_dir_for(automation_id)?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut sortable = Vec::new();
let mut legacy = Vec::new();
for entry in
fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
if path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(has_sortable_run_stem)
{
sortable.push(path);
} else {
legacy.push(path);
}
}
sortable.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
if let Some(limit) = limit {
sortable.truncate(limit);
}
let mut out = Vec::new();
for path in sortable.into_iter().chain(legacy) {
out.push(read_run_file(&path)?);
}
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
out.dedup_by(|a, b| a.id == b.id);
if let Some(limit) = limit {
out.truncate(limit);
}
Ok(out)
}
fn save_run(&self, run: &AutomationRunRecord) -> Result<()> {
let dir = self.runs_dir_for(&run.automation_id)?;
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
let path = self.run_path(run)?;
write_json_atomic(&path, run)?;
let legacy = self.legacy_run_path(&run.automation_id, &run.id)?;
if legacy != path && legacy.exists() {
fs::remove_file(&legacy)
.with_context(|| format!("Failed to remove legacy run {}", legacy.display()))?;
}
Ok(())
}
fn delete_run(&self, run: &AutomationRunRecord) -> Result<()> {
let sortable = self.run_path(run)?;
if sortable.exists() {
fs::remove_file(&sortable)
.with_context(|| format!("Failed to delete run {}", sortable.display()))?;
}
let legacy = self.legacy_run_path(&run.automation_id, &run.id)?;
if legacy.exists() {
fs::remove_file(&legacy)
.with_context(|| format!("Failed to delete run {}", legacy.display()))?;
}
Ok(())
}
fn collect_due_runs(
&self,
now: DateTime<Utc>,
) -> Result<Vec<(AutomationRecord, AutomationRunRecord)>> {
let mut due = Vec::new();
for mut automation in self.list_automations()? {
if !matches!(automation.status, AutomationStatus::Active) {
continue;
}
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
let Some(due_at) = automation.next_run_at else {
automation.next_run_at =
match schedule.next_after_with_anchor(now, automation.created_at) {
Ok(next) => Some(next),
Err(err)
if matches!(schedule, AutomationSchedule::Once { .. })
&& err.to_string().contains("Once schedule has no future run") =>
{
automation.status = AutomationStatus::Paused;
None
}
Err(err) => return Err(err),
};
automation.updated_at = now;
self.save_automation(&automation)?;
continue;
};
if due_at > now {
continue;
}
let existing_for_slot = self
.list_runs(&automation.id, Some(25))?
.into_iter()
.any(|run| run.scheduled_for == due_at);
if existing_for_slot {
self.advance_automation_after_slot(&mut automation, &schedule, due_at, now)?;
continue;
}
let run = new_run_record(&automation.id, due_at, now);
due.push((automation, run));
}
Ok(due)
}
fn finish_scheduled_run(&self, run: &AutomationRunRecord, now: DateTime<Utc>) -> Result<()> {
self.save_run(run)?;
let Ok(mut automation) = self.get_automation(&run.automation_id) else {
return Ok(());
};
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
self.advance_automation_after_slot(&mut automation, &schedule, run.scheduled_for, now)
}
fn advance_automation_after_slot(
&self,
automation: &mut AutomationRecord,
schedule: &AutomationSchedule,
slot: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<()> {
automation.updated_at = now;
automation.next_run_at = schedule.next_after_slot(slot, automation.created_at)?;
if automation.next_run_at.is_none() {
automation.status = AutomationStatus::Paused;
}
self.save_automation(automation)
}
fn collect_pending_runs(&self) -> Result<Vec<AutomationRunRecord>> {
let mut pending = Vec::new();
for automation in self.list_automations()? {
for run in self.list_runs(&automation.id, Some(100))? {
if matches!(
run.status,
AutomationRunStatus::Queued | AutomationRunStatus::Running
) && run.task_id.is_some()
{
pending.push(run);
}
}
}
Ok(pending)
}
pub fn create_trigger(&self, req: CreateDelayedTriggerRequest) -> Result<DelayedTriggerRecord> {
let now = Utc::now();
if req.fire_at <= now {
bail!(
"fire_at must be in the future (got {}, now is {})",
req.fire_at.to_rfc3339(),
now.to_rfc3339()
);
}
if req.message.trim().is_empty() {
bail!("Trigger message must not be empty");
}
let record = DelayedTriggerRecord {
schema_version: CURRENT_TRIGGER_SCHEMA_VERSION,
trigger_id: format!("trig_{}", Uuid::new_v4().simple()),
fire_at: req.fire_at,
message: req.message.trim().to_string(),
workspace: req.workspace,
status: DelayedTriggerStatus::Pending,
created_at: now,
fired_at: None,
task_id: None,
thread_id: None,
error: None,
parent_trigger_id: req.parent_trigger_id,
};
self.save_trigger(&record)?;
Ok(record)
}
pub fn get_trigger(&self, trigger_id: &str) -> Result<DelayedTriggerRecord> {
let path = self.trigger_path(trigger_id)?;
let raw = fs::read_to_string(&path)
.with_context(|| format!("Trigger '{trigger_id}' not found"))?;
let record: DelayedTriggerRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse trigger '{trigger_id}'"))?;
if record.schema_version > CURRENT_TRIGGER_SCHEMA_VERSION {
bail!(
"Trigger schema v{} is newer than supported v{}",
record.schema_version,
CURRENT_TRIGGER_SCHEMA_VERSION
);
}
Ok(record)
}
pub fn save_trigger(&self, record: &DelayedTriggerRecord) -> Result<()> {
let path = self.trigger_path(&record.trigger_id)?;
write_json_atomic(&path, record)
}
pub fn list_triggers(
&self,
status_filter: Option<DelayedTriggerStatus>,
limit: Option<usize>,
) -> Result<Vec<DelayedTriggerRecord>> {
let mut out = Vec::new();
if !self.triggers_dir.exists() {
return Ok(out);
}
for entry in fs::read_dir(&self.triggers_dir)
.with_context(|| format!("Failed to read {}", self.triggers_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
match fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str::<DelayedTriggerRecord>(&raw).ok())
{
Some(record) => {
if let Some(filter) = status_filter
&& record.status != filter
{
continue;
}
out.push(record);
}
None => {
tracing::warn!("Skipping unreadable trigger file {}", path.display());
}
}
}
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
if let Some(limit) = limit {
out.truncate(limit);
}
Ok(out)
}
pub fn cancel_trigger(&self, trigger_id: &str) -> Result<DelayedTriggerRecord> {
let mut record = self.get_trigger(trigger_id)?;
if !matches!(record.status, DelayedTriggerStatus::Pending) {
bail!(
"Trigger '{trigger_id}' cannot be canceled (status: {:?})",
record.status
);
}
record.status = DelayedTriggerStatus::Canceled;
self.save_trigger(&record)?;
Ok(record)
}
pub fn collect_due_triggers(&self, now: DateTime<Utc>) -> Result<Vec<DelayedTriggerRecord>> {
let pending = self.list_triggers(Some(DelayedTriggerStatus::Pending), None)?;
Ok(pending.into_iter().filter(|t| t.fire_at <= now).collect())
}
}
fn new_run_record(
automation_id: &str,
scheduled_for: DateTime<Utc>,
created_at: DateTime<Utc>,
) -> AutomationRunRecord {
AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
automation_id: automation_id.to_string(),
scheduled_for,
status: AutomationRunStatus::Queued,
created_at,
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
}
}
async fn enqueue_run_task(
automation: &AutomationRecord,
run: &mut AutomationRunRecord,
task_manager: &SharedTaskManager,
) {
let workspace = automation.cwds.first().cloned();
let new_task = NewTaskRequest {
prompt: automation.prompt.clone(),
model: None,
workspace,
mode: Some(automation.task_mode()),
allow_shell: Some(automation.task_allow_shell()),
trust_mode: Some(automation.task_trust_mode()),
auto_approve: Some(automation.task_auto_approve()),
owner_session_id: None,
};
match task_manager.add_task(new_task).await {
Ok(task) => {
run.status = AutomationRunStatus::Running;
run.started_at = Some(Utc::now());
run.task_id = Some(task.id.clone());
run.thread_id = task.thread_id.clone();
run.turn_id = task.turn_id.clone();
run.error = None;
}
Err(err) => {
run.status = AutomationRunStatus::Failed;
run.ended_at = Some(Utc::now());
run.error = Some(format!("Failed to enqueue task: {err}"));
}
}
}
pub async fn run_now_shared(
automations: &SharedAutomationManager,
automation_id: &str,
task_manager: &SharedTaskManager,
) -> Result<AutomationRunRecord> {
let task_manager = Arc::clone(task_manager);
run_now_with(
automations,
automation_id,
move |automation, mut run| async move {
enqueue_run_task(&automation, &mut run, &task_manager).await;
run
},
)
.await
}
async fn run_now_with<F, Fut>(
automations: &SharedAutomationManager,
automation_id: &str,
enqueue: F,
) -> Result<AutomationRunRecord>
where
F: FnOnce(AutomationRecord, AutomationRunRecord) -> Fut,
Fut: Future<Output = AutomationRunRecord>,
{
let automation = {
let manager = automations.lock().await;
manager.get_automation(automation_id)?
};
let now = Utc::now();
let run = new_run_record(&automation.id, now, now);
let run = enqueue(automation, run).await;
let manager = automations.lock().await;
manager.save_run(&run)?;
if let Ok(mut automation) = manager.get_automation(automation_id) {
automation.updated_at = Utc::now();
if matches!(
run.status,
AutomationRunStatus::Completed
| AutomationRunStatus::Failed
| AutomationRunStatus::Canceled
) {
automation.last_run_at = run.ended_at.or(Some(Utc::now()));
}
manager.save_automation(&automation)?;
}
Ok(run)
}
async fn scheduler_tick_shared(
automations: &SharedAutomationManager,
task_manager: &SharedTaskManager,
) -> Result<()> {
let now = Utc::now();
let due_runs = {
let manager = automations.lock().await;
manager.collect_due_runs(now)?
};
for (automation, mut run) in due_runs {
enqueue_run_task(&automation, &mut run, task_manager).await;
let manager = automations.lock().await;
manager.finish_scheduled_run(&run, now)?;
}
Ok(())
}
async fn fire_due_triggers_shared(
automations: &SharedAutomationManager,
task_manager: &SharedTaskManager,
) -> Result<()> {
let now = Utc::now();
let due_triggers = {
let manager = automations.lock().await;
manager.collect_due_triggers(now)?
};
for mut trigger in due_triggers {
let workspace = trigger.workspace.clone();
let new_task = NewTaskRequest {
prompt: trigger.message.clone(),
model: None,
workspace,
mode: Some("agent".to_string()),
allow_shell: Some(false),
trust_mode: Some(false),
auto_approve: Some(false),
owner_session_id: None,
};
match task_manager.add_task(new_task).await {
Ok(task) => {
trigger.status = DelayedTriggerStatus::Fired;
trigger.fired_at = Some(Utc::now());
trigger.task_id = Some(task.id.clone());
trigger.thread_id = task.thread_id.clone();
trigger.error = None;
}
Err(err) => {
trigger.status = DelayedTriggerStatus::Failed;
trigger.fired_at = Some(Utc::now());
trigger.error = Some(format!("Failed to enqueue task: {err}"));
}
}
let manager = automations.lock().await;
manager.save_trigger(&trigger)?;
}
Ok(())
}
fn apply_task_status(
run: &mut AutomationRunRecord,
task: &crate::task_manager::TaskRecord,
) -> bool {
run.thread_id = task.thread_id.clone();
run.turn_id = task.turn_id.clone();
let mut changed = false;
match task.status {
TaskStatus::Queued => {
if !matches!(run.status, AutomationRunStatus::Queued) {
run.status = AutomationRunStatus::Queued;
changed = true;
}
}
TaskStatus::Running => {
if !matches!(run.status, AutomationRunStatus::Running) {
run.status = AutomationRunStatus::Running;
changed = true;
}
if run.started_at.is_none() {
run.started_at = Some(task.started_at.unwrap_or_else(Utc::now));
changed = true;
}
}
TaskStatus::Completed => {
run.status = AutomationRunStatus::Completed;
run.started_at = run.started_at.or(task.started_at);
run.ended_at = task.ended_at.or(Some(Utc::now()));
run.error = None;
changed = true;
}
TaskStatus::Failed => {
run.status = AutomationRunStatus::Failed;
run.started_at = run.started_at.or(task.started_at);
run.ended_at = task.ended_at.or(Some(Utc::now()));
run.error = task.error.clone();
changed = true;
}
TaskStatus::Canceled => {
run.status = AutomationRunStatus::Canceled;
run.started_at = run.started_at.or(task.started_at);
run.ended_at = task.ended_at.or(Some(Utc::now()));
changed = true;
}
}
changed
}
async fn reconcile_run_statuses_shared(
automations: &SharedAutomationManager,
task_manager: &SharedTaskManager,
) -> Result<()> {
let pending = {
let manager = automations.lock().await;
manager.collect_pending_runs()?
};
for mut run in pending {
let Some(task_id) = run.task_id.clone() else {
continue;
};
let task = match task_manager.get_task(&task_id).await {
Ok(task) => task,
Err(_) => continue,
};
let watcher_noop = {
let manager = automations.lock().await;
manager
.get_automation(&run.automation_id)
.ok()
.is_some_and(|automation| {
automation.delivery_mode() == AutomationDeliveryMode::Watcher
&& task.status == TaskStatus::Completed
&& task.result_summary.as_deref().is_some_and(|summary| {
summary.trim() == AUTOMATION_WATCHER_NO_REPORT_SENTINEL
})
})
};
if watcher_noop {
let manager = automations.lock().await;
manager.delete_run(&run)?;
continue;
}
if !apply_task_status(&mut run, &task) {
continue;
}
let manager = automations.lock().await;
manager.save_run(&run)?;
if matches!(
run.status,
AutomationRunStatus::Completed
| AutomationRunStatus::Failed
| AutomationRunStatus::Canceled
) && let Ok(mut updated_automation) = manager.get_automation(&run.automation_id)
{
updated_automation.last_run_at = run.ended_at.or(Some(Utc::now()));
updated_automation.updated_at = Utc::now();
manager.save_automation(&updated_automation)?;
}
}
Ok(())
}
const RUN_STAMP_FORMAT: &str = "%Y%m%dT%H%M%S%3fZ";
const RUN_STAMP_LEN: usize = "20260705T142530123Z".len();
fn run_file_stamp(created_at: DateTime<Utc>) -> String {
created_at.format(RUN_STAMP_FORMAT).to_string()
}
fn has_sortable_run_stem(stem: &str) -> bool {
let Some((stamp, rest)) = stem.split_at_checked(RUN_STAMP_LEN) else {
return false;
};
if !rest.starts_with('-') || rest.len() < 2 {
return false;
}
stamp.char_indices().all(|(idx, ch)| match idx {
8 => ch == 'T',
18 => ch == 'Z',
_ => ch.is_ascii_digit(),
})
}
fn read_run_file(path: &Path) -> Result<AutomationRunRecord> {
let raw =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let run: AutomationRunRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if run.schema_version > CURRENT_RUN_SCHEMA_VERSION {
bail!(
"Automation run schema v{} is newer than supported v{}",
run.schema_version,
CURRENT_RUN_SCHEMA_VERSION
);
}
Ok(run)
}
fn ensure_safe_storage_id(kind: &str, value: &str) -> Result<()> {
let mut components = Path::new(value).components();
let Some(component) = components.next() else {
bail!("{kind} must not be empty");
};
if components.next().is_some() || !matches!(component, std::path::Component::Normal(_)) {
bail!("{kind} must be a single path component");
}
Ok(())
}
fn validate_name_and_prompt(name: &str, prompt: &str) -> Result<()> {
if name.trim().is_empty() {
bail!("Automation name is required");
}
if prompt.trim().is_empty() {
bail!("Automation prompt is required");
}
Ok(())
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let content = serde_json::to_string_pretty(value)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, content).with_context(|| format!("Failed to write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| {
format!(
"Failed to move temporary file {} to {}",
tmp.display(),
path.display()
)
})?;
Ok(())
}
pub fn default_automations_dir() -> PathBuf {
for var in ["CODEWHALE_AUTOMATIONS_DIR", "DEEPSEEK_AUTOMATIONS_DIR"] {
if let Ok(path) = std::env::var(var) {
let trimmed = path.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
}
if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
return home.join("automations");
}
codewhale_paths::user_home()
.map(|home| {
let primary = home.join(".codewhale").join("automations");
let legacy = home.join(".deepseek").join("automations");
if primary.exists() || !legacy.exists() {
return primary;
}
legacy
})
.unwrap_or_else(|| PathBuf::from(".codewhale").join("automations"))
}
pub type SharedAutomationManager = Arc<Mutex<AutomationManager>>;
#[derive(Debug, Clone)]
pub struct AutomationSchedulerConfig {
pub tick_interval_secs: u64,
}
impl Default for AutomationSchedulerConfig {
fn default() -> Self {
Self {
tick_interval_secs: 15,
}
}
}
pub fn spawn_scheduler(
automations: SharedAutomationManager,
task_manager: SharedTaskManager,
cancel: CancellationToken,
config: AutomationSchedulerConfig,
) -> tokio::task::JoinHandle<()> {
spawn_supervised(
"automation-scheduler",
std::panic::Location::caller(),
async move {
let interval = config.tick_interval_secs.max(5);
loop {
if cancel.is_cancelled() {
break;
}
if let Err(err) = scheduler_tick_shared(&automations, &task_manager).await {
tracing::warn!("automation scheduler tick failed: {err}");
}
if let Err(err) = reconcile_run_statuses_shared(&automations, &task_manager).await {
tracing::warn!("automation reconcile failed: {err}");
}
if let Err(err) = fire_due_triggers_shared(&automations, &task_manager).await {
tracing::warn!("delayed trigger tick failed: {err}");
}
tokio::select! {
_ = cancel.cancelled() => break,
_ = sleep(std::time::Duration::from_secs(interval)) => {}
}
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use chrono::{FixedOffset, LocalResult, NaiveDate};
use tokio::sync::mpsc;
use crate::task_manager::{
ExecutionTask, TaskExecutionEvent, TaskExecutionResult, TaskExecutor, TaskManager,
TaskManagerConfig,
};
struct AutomationNoopExecutor;
struct AutomationWatcherNoopExecutor;
#[derive(Debug, Clone, Copy)]
struct Eastern2026;
impl Eastern2026 {
fn standard_offset() -> FixedOffset {
FixedOffset::west_opt(5 * 60 * 60).expect("valid standard offset")
}
fn daylight_offset() -> FixedOffset {
FixedOffset::west_opt(4 * 60 * 60).expect("valid daylight offset")
}
fn time(month: u32, day: u32, hour: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(2026, month, day)
.expect("valid transition date")
.and_hms_opt(hour, 0, 0)
.expect("valid transition time")
}
}
impl TimeZone for Eastern2026 {
type Offset = FixedOffset;
fn from_offset(_offset: &Self::Offset) -> Self {
Self
}
fn offset_from_local_date(&self, local: &NaiveDate) -> LocalResult<Self::Offset> {
self.offset_from_local_datetime(
&local
.and_hms_opt(12, 0, 0)
.expect("valid local date midpoint"),
)
}
fn offset_from_local_datetime(&self, local: &NaiveDateTime) -> LocalResult<Self::Offset> {
let gap_start = Self::time(3, 8, 2);
let gap_end = Self::time(3, 8, 3);
let fold_start = Self::time(11, 1, 1);
let fold_end = Self::time(11, 1, 2);
if *local >= gap_start && *local < gap_end {
LocalResult::None
} else if *local >= fold_start && *local < fold_end {
LocalResult::Ambiguous(Self::daylight_offset(), Self::standard_offset())
} else if *local >= gap_end && *local < fold_start {
LocalResult::Single(Self::daylight_offset())
} else {
LocalResult::Single(Self::standard_offset())
}
}
fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
self.offset_from_utc_datetime(
&utc.and_hms_opt(12, 0, 0).expect("valid UTC date midpoint"),
)
}
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
let daylight_start = Self::time(3, 8, 7);
let daylight_end = Self::time(11, 1, 6);
if *utc >= daylight_start && *utc < daylight_end {
Self::daylight_offset()
} else {
Self::standard_offset()
}
}
}
#[async_trait]
impl TaskExecutor for AutomationNoopExecutor {
async fn execute(
&self,
_task: ExecutionTask,
_events: mpsc::UnboundedSender<TaskExecutionEvent>,
_cancel: CancellationToken,
) -> TaskExecutionResult {
TaskExecutionResult {
status: TaskStatus::Completed,
result_text: Some("done".to_string()),
error: None,
}
}
}
#[async_trait]
impl TaskExecutor for AutomationWatcherNoopExecutor {
async fn execute(
&self,
_task: ExecutionTask,
_events: mpsc::UnboundedSender<TaskExecutionEvent>,
_cancel: CancellationToken,
) -> TaskExecutionResult {
TaskExecutionResult {
status: TaskStatus::Completed,
result_text: Some(AUTOMATION_WATCHER_NO_REPORT_SENTINEL.to_string()),
error: None,
}
}
}
fn automation_task_config(root: PathBuf) -> TaskManagerConfig {
TaskManagerConfig {
data_dir: root,
worker_count: 1,
default_workspace: PathBuf::from("."),
default_model: "deepseek-v4-flash".to_string(),
default_mode: "plan".to_string(),
allow_shell: true,
trust_mode: true,
}
}
fn automation_record_with_settings(
mode: Option<&str>,
allow_shell: Option<bool>,
trust_mode: Option<bool>,
auto_approve: Option<bool>,
) -> AutomationRecord {
let now = Utc::now();
AutomationRecord {
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
name: "Test automation".to_string(),
prompt: "Run the automation".to_string(),
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
mode: mode.map(ToString::to_string),
allow_shell,
trust_mode,
auto_approve,
delivery_mode: None,
status: AutomationStatus::Active,
created_at: now,
updated_at: now,
next_run_at: None,
last_run_at: None,
}
}
fn queued_run_for(automation: &AutomationRecord) -> AutomationRunRecord {
let now = Utc::now();
AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
automation_id: automation.id.clone(),
scheduled_for: now,
status: AutomationRunStatus::Queued,
created_at: now,
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
}
}
fn eastern_datetime(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime<Utc> {
Eastern2026
.with_ymd_and_hms(year, month, day, hour, minute, 0)
.single()
.expect("unambiguous Eastern wall time")
.with_timezone(&Utc)
}
fn anchored_automation(
created_at: DateTime<Utc>,
status: AutomationStatus,
) -> AutomationRecord {
let mut record = automation_record_with_settings(None, None, None, None);
record.rrule = "FREQ=HOURLY;INTERVAL=7;BYMINUTE=17".to_string();
record.status = status;
record.created_at = created_at;
record.updated_at = created_at;
record.next_run_at = None;
record
}
fn local_naive_to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
Local
.from_local_datetime(&naive)
.earliest()
.expect("valid unambiguous local time")
.with_timezone(&Utc)
}
#[test]
fn parses_hourly_rrule() {
let parsed =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=2;BYDAY=MO,TU").expect("parse");
match parsed {
AutomationSchedule::Hourly {
interval_hours,
byday,
..
} => {
assert_eq!(interval_hours, 2);
assert_eq!(byday.expect("byday").len(), 2);
}
_ => panic!("expected hourly"),
}
}
#[test]
fn parses_once_rrule() {
let parsed =
AutomationSchedule::parse_rrule("FREQ=ONCE;AT=2026-08-03T14:30").expect("parse");
match parsed {
AutomationSchedule::Once { at } => {
assert_eq!(
at,
local_naive_to_utc(
NaiveDateTime::parse_from_str("2026-08-03T14:30", "%Y-%m-%dT%H:%M")
.expect("naive")
)
);
}
_ => panic!("expected once"),
}
}
#[test]
fn parses_hourly_clock_anchor() {
let parsed =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=24;BYHOUR=8;BYMINUTE=30")
.expect("parse anchored hourly schedule");
assert!(matches!(
parsed,
AutomationSchedule::Hourly {
anchor_hour: Some(8),
anchor_minute: Some(30),
..
}
));
let minute_only = AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=1;BYMINUTE=15")
.expect("parse minute-only anchor");
assert!(matches!(
minute_only,
AutomationSchedule::Hourly {
anchor_hour: None,
anchor_minute: Some(15),
..
}
));
}
#[test]
fn anchored_hourly_schedule_keeps_wall_time_across_spring_forward() {
let schedule =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=24;BYHOUR=8;BYMINUTE=30")
.expect("parse");
let created_at = eastern_datetime(2026, 3, 6, 7, 0);
let after = eastern_datetime(2026, 3, 7, 9, 0);
let next = schedule
.next_after_in_timezone(after, created_at, &Eastern2026)
.expect("next run");
assert_eq!(next, eastern_datetime(2026, 3, 8, 8, 30));
}
#[test]
fn anchored_hourly_schedule_keeps_wall_time_across_fall_back() {
let schedule =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=24;BYHOUR=8;BYMINUTE=30")
.expect("parse");
let created_at = eastern_datetime(2026, 10, 30, 7, 0);
let after = eastern_datetime(2026, 10, 31, 9, 0);
let next = schedule
.next_after_in_timezone(after, created_at, &Eastern2026)
.expect("next run");
assert_eq!(next, eastern_datetime(2026, 11, 1, 8, 30));
}
#[test]
fn anchored_hourly_schedule_skips_nonexistent_wall_time() {
let schedule =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=24;BYHOUR=2;BYMINUTE=30")
.expect("parse");
let created_at = eastern_datetime(2026, 3, 7, 1, 0);
let after = eastern_datetime(2026, 3, 7, 3, 0);
let next = schedule
.next_after_in_timezone(after, created_at, &Eastern2026)
.expect("next run after spring-forward gap");
assert_eq!(next, eastern_datetime(2026, 3, 9, 2, 30));
}
#[test]
fn anchored_hourly_schedule_uses_first_ambiguous_wall_time_once() {
let schedule =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=24;BYHOUR=1;BYMINUTE=30")
.expect("parse");
let created_at = eastern_datetime(2026, 10, 31, 0, 0);
let after = eastern_datetime(2026, 10, 31, 2, 0);
let first_fold_occurrence = Eastern2026
.with_ymd_and_hms(2026, 11, 1, 1, 30, 0)
.earliest()
.expect("first fold occurrence")
.with_timezone(&Utc);
let next = schedule
.next_after_in_timezone(after, created_at, &Eastern2026)
.expect("next run at fall-back fold");
assert_eq!(next, first_fold_occurrence);
let during_second_fold = Eastern2026
.with_ymd_and_hms(2026, 11, 1, 1, 15, 0)
.latest()
.expect("second fold occurrence")
.with_timezone(&Utc);
let after_fold = schedule
.next_after_in_timezone(during_second_fold, created_at, &Eastern2026)
.expect("next run after fold");
assert_eq!(after_fold, eastern_datetime(2026, 11, 2, 1, 30));
}
#[test]
fn anchored_hourly_schedule_reuses_persisted_anchor_after_restart_and_resume() {
let rrule = "FREQ=HOURLY;INTERVAL=24;BYHOUR=8;BYMINUTE=30";
let created_at = eastern_datetime(2026, 3, 6, 7, 0);
let schedule = AutomationSchedule::parse_rrule(rrule).expect("parse");
let before_restart = schedule
.next_after_in_timezone(
eastern_datetime(2026, 3, 7, 12, 0),
created_at,
&Eastern2026,
)
.expect("next before restart");
assert_eq!(before_restart, eastern_datetime(2026, 3, 8, 8, 30));
let restarted = AutomationSchedule::parse_rrule(rrule).expect("reparse after restart");
let after_restart = restarted
.next_after_in_timezone(
eastern_datetime(2026, 3, 8, 10, 0),
created_at,
&Eastern2026,
)
.expect("next after restart");
assert_eq!(after_restart, eastern_datetime(2026, 3, 9, 8, 30));
let after_resume = restarted
.next_after_in_timezone(
eastern_datetime(2026, 3, 10, 12, 0),
created_at,
&Eastern2026,
)
.expect("next after resume");
assert_eq!(after_resume, eastern_datetime(2026, 3, 11, 8, 30));
}
#[test]
fn scheduler_restart_uses_persisted_creation_anchor() {
let tempdir = tempfile::tempdir().expect("tempdir");
let now = Utc::now();
let created_at = now - Duration::hours(51);
let automation = anchored_automation(created_at, AutomationStatus::Active);
let schedule = AutomationSchedule::parse_rrule(&automation.rrule).expect("parse");
let expected = schedule
.next_after_with_anchor(now, created_at)
.expect("persisted-anchor schedule");
let reset_anchor = schedule
.next_after_with_anchor(now, now)
.expect("reset-anchor schedule");
assert_ne!(expected, reset_anchor, "fixture must detect anchor resets");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
manager.save_automation(&automation).expect("save");
drop(manager);
let restarted = AutomationManager::open(tempdir.path().to_path_buf()).expect("reopen");
assert!(
restarted
.collect_due_runs(now)
.expect("restart tick")
.is_empty(),
"an uninitialized future slot must not enqueue immediately"
);
let reloaded = restarted
.get_automation(&automation.id)
.expect("reloaded automation");
assert_eq!(reloaded.next_run_at, Some(expected));
}
#[test]
fn resume_uses_persisted_creation_anchor() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let before = Utc::now();
let created_at = before - Duration::hours(51);
let automation = anchored_automation(created_at, AutomationStatus::Paused);
let schedule = AutomationSchedule::parse_rrule(&automation.rrule).expect("parse");
manager.save_automation(&automation).expect("save");
let expected_before = schedule
.next_after_with_anchor(before, created_at)
.expect("next before resume");
let reset_anchor = schedule
.next_after_with_anchor(before, before)
.expect("reset-anchor schedule");
assert_ne!(
expected_before, reset_anchor,
"fixture must detect anchor resets"
);
let resumed = manager
.resume_automation(&automation.id)
.expect("resume automation");
let after = Utc::now();
let expected_after = schedule
.next_after_with_anchor(after, created_at)
.expect("next after resume");
let actual = resumed.next_run_at.expect("resumed next run");
assert!(
actual == expected_before || actual == expected_after,
"resume must keep the persisted creation anchor"
);
}
#[test]
fn anchored_hourly_schedule_applies_byday_on_calendar_slots() {
let schedule = AutomationSchedule::parse_rrule(
"FREQ=HOURLY;INTERVAL=24;BYDAY=MO,TU,WE,TH,FR;BYHOUR=8;BYMINUTE=30",
)
.expect("parse");
let created_at = eastern_datetime(2026, 3, 6, 7, 0);
let next = schedule
.next_after_in_timezone(eastern_datetime(2026, 3, 6, 9, 0), created_at, &Eastern2026)
.expect("next weekday run");
assert_eq!(next, eastern_datetime(2026, 3, 9, 8, 30));
}
#[test]
fn parses_weekly_rrule() {
let parsed =
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYDAY=MO,WE;BYHOUR=9;BYMINUTE=30")
.expect("parse");
match parsed {
AutomationSchedule::Weekly {
byday,
byhour,
byminute,
} => {
assert_eq!(byday.len(), 2);
assert_eq!(byhour, 9);
assert_eq!(byminute, 30);
}
_ => panic!("expected weekly"),
}
}
#[test]
fn parses_cron_rrule_and_computes_next_minute_slot() {
let schedule =
AutomationSchedule::parse_rrule("FREQ=CRON;EXPR=*/17 * * * *").expect("parse");
let after = Utc
.with_ymd_and_hms(2026, 8, 3, 9, 17, 1)
.single()
.expect("after");
let next = schedule
.next_after_in_timezone(after, after, &Utc)
.expect("next cron run");
assert_eq!(
next,
Utc.with_ymd_and_hms(2026, 8, 3, 9, 34, 0)
.single()
.expect("next")
);
}
#[test]
fn cron_weekday_schedule_uses_standard_five_field_local_time() {
let schedule =
AutomationSchedule::parse_rrule("FREQ=CRON;EXPR=3 9 * * MON-FRI").expect("parse");
let after = Utc
.with_ymd_and_hms(2026, 8, 7, 9, 4, 0)
.single()
.expect("after");
let next = schedule
.next_after_in_timezone(after, after, &Utc)
.expect("next weekday cron run");
assert_eq!(
next,
Utc.with_ymd_and_hms(2026, 8, 10, 9, 3, 0)
.single()
.expect("next")
);
}
#[test]
fn cron_rejects_impossible_date() {
let err = AutomationSchedule::parse_rrule("FREQ=CRON;EXPR=0 9 31 2 *")
.expect_err("impossible february date must fail");
assert!(err.to_string().contains("can never occur"));
}
#[test]
fn rejects_invalid_rrule_fields() {
let err =
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYSECOND=5").expect_err("should fail");
assert!(err.to_string().contains("Unsupported RRULE field"));
}
#[test]
fn deletes_automation_and_runs() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let created = manager
.create_automation(CreateAutomationRequest {
name: "Delete me".to_string(),
prompt: "prompt".to_string(),
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
mode: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
delivery_mode: None,
status: Some(AutomationStatus::Active),
})
.expect("create");
let run = AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
automation_id: created.id.clone(),
scheduled_for: Utc::now(),
status: AutomationRunStatus::Queued,
created_at: Utc::now(),
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
};
manager.save_run(&run).expect("save run");
assert!(
manager
.runs_dir_for(&created.id)
.expect("runs dir")
.exists()
);
manager
.delete_automation(&created.id)
.expect("delete automation");
assert!(manager.get_automation(&created.id).is_err());
assert!(
!manager
.runs_dir_for(&created.id)
.expect("runs dir")
.exists()
);
}
#[test]
fn automation_storage_rejects_traversal_ids() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().join("root")).expect("manager");
let escaped_file = tempdir.path().join("escape.json");
let escaped_runs = tempdir.path().join("escape-runs");
let err = manager
.get_automation("../escape")
.expect_err("traversal automation ids must be rejected");
assert!(err.to_string().contains("single path component"));
assert!(!escaped_file.exists());
let err = manager
.list_runs("../escape-runs", None)
.expect_err("traversal run dirs must be rejected");
assert!(err.to_string().contains("single path component"));
assert!(!escaped_runs.exists());
let run = AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: "../escape-run".to_string(),
automation_id: Uuid::new_v4().to_string(),
scheduled_for: Utc::now(),
status: AutomationRunStatus::Queued,
created_at: Utc::now(),
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
};
let err = manager
.save_run(&run)
.expect_err("traversal run ids must be rejected");
assert!(err.to_string().contains("single path component"));
assert!(!tempdir.path().join("escape-run.json").exists());
}
#[test]
fn automation_task_settings_default_for_legacy_records() {
let now = Utc::now().to_rfc3339();
let record: AutomationRecord = serde_json::from_value(serde_json::json!({
"schema_version": CURRENT_AUTOMATION_SCHEMA_VERSION,
"id": Uuid::new_v4().to_string(),
"name": "Legacy automation",
"prompt": "Run legacy automation",
"rrule": "FREQ=HOURLY;INTERVAL=1",
"cwds": [],
"status": "active",
"created_at": now,
"updated_at": now
}))
.expect("legacy automation record should deserialize");
assert_eq!(record.mode, None);
assert_eq!(record.task_mode(), "agent");
assert!(!record.task_allow_shell());
assert!(!record.task_trust_mode());
assert!(!record.task_auto_approve());
assert_eq!(record.delivery_mode(), AutomationDeliveryMode::Task);
}
#[tokio::test]
async fn automation_enqueue_uses_default_and_explicit_task_settings() -> Result<()> {
let tempdir = tempfile::tempdir().expect("tempdir");
let task_manager = TaskManager::start_with_executor(
automation_task_config(tempdir.path().join("tasks")),
std::sync::Arc::new(AutomationNoopExecutor),
)
.await?;
let default_automation = automation_record_with_settings(None, None, None, None);
let mut default_run = queued_run_for(&default_automation);
enqueue_run_task(&default_automation, &mut default_run, &task_manager).await;
let default_task = task_manager
.get_task(default_run.task_id.as_deref().expect("task id"))
.await?;
assert_eq!(default_task.mode, "agent");
assert!(!default_task.allow_shell);
assert!(!default_task.trust_mode);
assert!(!default_task.auto_approve);
let explicit_automation =
automation_record_with_settings(Some("plan"), Some(true), Some(true), Some(true));
let mut explicit_run = queued_run_for(&explicit_automation);
enqueue_run_task(&explicit_automation, &mut explicit_run, &task_manager).await;
let explicit_task = task_manager
.get_task(explicit_run.task_id.as_deref().expect("task id"))
.await?;
assert_eq!(explicit_task.mode, "plan");
assert!(explicit_task.allow_shell);
assert!(explicit_task.trust_mode);
assert!(explicit_task.auto_approve);
task_manager.shutdown();
Ok(())
}
fn write_legacy_run_file(manager: &AutomationManager, run: &AutomationRunRecord) {
let dir = manager.runs_dir_for(&run.automation_id).expect("runs dir");
fs::create_dir_all(&dir).expect("create runs dir");
fs::write(
dir.join(format!("{}.json", run.id)),
serde_json::to_string_pretty(run).expect("serialize run"),
)
.expect("write legacy run");
}
fn run_created_at(
automation: &AutomationRecord,
created_at: DateTime<Utc>,
) -> AutomationRunRecord {
let mut run = queued_run_for(automation);
run.created_at = created_at;
run.scheduled_for = created_at;
run
}
#[test]
fn save_run_uses_sortable_names_and_migrates_legacy_files() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let automation = automation_record_with_settings(None, None, None, None);
let run = queued_run_for(&automation);
write_legacy_run_file(&manager, &run);
manager.save_run(&run).expect("save run");
let dir = manager.runs_dir_for(&automation.id).expect("runs dir");
let names: Vec<String> = fs::read_dir(&dir)
.expect("read dir")
.map(|entry| {
entry
.expect("entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
let expected = format!("{}-{}.json", run_file_stamp(run.created_at), run.id);
assert_eq!(names, vec![expected.clone()]);
assert!(has_sortable_run_stem(expected.trim_end_matches(".json")));
assert!(!has_sortable_run_stem(&run.id));
}
#[test]
fn finish_scheduled_run_persists_run_when_automation_deleted_mid_enqueue() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let automation = automation_record_with_settings(None, None, None, None);
manager.save_automation(&automation).expect("save");
let run = queued_run_for(&automation);
manager.delete_automation(&automation.id).expect("delete");
manager
.finish_scheduled_run(&run, Utc::now())
.expect("finish");
let runs = manager.list_runs(&automation.id, None).expect("list runs");
assert_eq!(
runs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
vec![run.id.as_str()],
"run must be persisted even though its automation was deleted"
);
assert!(
manager.get_automation(&automation.id).is_err(),
"the deleted automation must not be resurrected"
);
}
#[test]
fn once_schedule_fires_once_and_auto_completes() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let due_at = Utc::now() - Duration::minutes(1);
let automation = AutomationRecord {
rrule: due_at
.format("FREQ=ONCE;AT=%Y-%m-%dT%H:%M:%S+00:00")
.to_string(),
next_run_at: Some(due_at),
created_at: due_at - Duration::minutes(5),
updated_at: due_at - Duration::minutes(5),
..automation_record_with_settings(None, None, None, None)
};
manager
.save_automation(&automation)
.expect("save automation");
let due = manager
.collect_due_runs(Utc::now())
.expect("collect due runs");
assert_eq!(due.len(), 1);
let (_automation, run) = &due[0];
assert_eq!(run.scheduled_for, due_at);
manager
.finish_scheduled_run(run, Utc::now())
.expect("finish one-shot run");
let updated = manager
.get_automation(&automation.id)
.expect("updated automation");
assert_eq!(updated.status, AutomationStatus::Paused);
assert_eq!(updated.next_run_at, None);
assert!(
manager
.collect_due_runs(Utc::now() + Duration::hours(1))
.expect("later tick")
.is_empty()
);
}
#[test]
fn list_runs_merges_legacy_and_sortable_files_newest_first() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let automation = automation_record_with_settings(None, None, None, None);
let base = Utc::now();
let legacy_oldest = run_created_at(&automation, base - Duration::minutes(30));
let legacy_newest = run_created_at(&automation, base + Duration::minutes(30));
write_legacy_run_file(&manager, &legacy_oldest);
write_legacy_run_file(&manager, &legacy_newest);
let sortable_old = run_created_at(&automation, base - Duration::minutes(20));
let sortable_new = run_created_at(&automation, base + Duration::minutes(20));
manager.save_run(&sortable_old).expect("save old");
manager.save_run(&sortable_new).expect("save new");
let all = manager.list_runs(&automation.id, None).expect("list all");
let ids: Vec<&str> = all.iter().map(|run| run.id.as_str()).collect();
assert_eq!(
ids,
vec![
legacy_newest.id.as_str(),
sortable_new.id.as_str(),
sortable_old.id.as_str(),
legacy_oldest.id.as_str(),
]
);
let top_two = manager.list_runs(&automation.id, Some(2)).expect("list 2");
let top_ids: Vec<&str> = top_two.iter().map(|run| run.id.as_str()).collect();
assert_eq!(
top_ids,
vec![legacy_newest.id.as_str(), sortable_new.id.as_str()]
);
}
#[test]
fn list_runs_with_limit_skips_older_sortable_files_entirely() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let automation = automation_record_with_settings(None, None, None, None);
let base = Utc::now();
let newest = run_created_at(&automation, base);
manager.save_run(&newest).expect("save newest");
let dir = manager.runs_dir_for(&automation.id).expect("runs dir");
let stale_stamp = run_file_stamp(base - Duration::minutes(5));
fs::write(
dir.join(format!("{stale_stamp}-{}.json", Uuid::new_v4())),
"{ not json",
)
.expect("write corrupt run");
let bounded = manager
.list_runs(&automation.id, Some(1))
.expect("bounded list must not read files beyond the limit");
assert_eq!(bounded.len(), 1);
assert_eq!(bounded[0].id, newest.id);
assert!(manager.list_runs(&automation.id, None).is_err());
}
#[tokio::test]
async fn list_automations_completes_during_slow_enqueue() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let created = manager
.create_automation(CreateAutomationRequest {
name: "Slow enqueue".to_string(),
prompt: "prompt".to_string(),
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
mode: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
delivery_mode: None,
status: Some(AutomationStatus::Active),
})
.expect("create");
let shared: SharedAutomationManager = Arc::new(Mutex::new(manager));
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel::<()>();
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
let run_task = tokio::spawn({
let shared = Arc::clone(&shared);
let automation_id = created.id.clone();
async move {
run_now_with(&shared, &automation_id, move |_, mut run| async move {
let _ = entered_tx.send(());
let _ = release_rx.await;
run.status = AutomationRunStatus::Failed;
run.ended_at = Some(Utc::now());
run.error = Some("stubbed enqueue".to_string());
run
})
.await
}
});
entered_rx.await.expect("enqueue phase entered");
let listed = tokio::time::timeout(std::time::Duration::from_secs(2), async {
shared.lock().await.list_automations()
})
.await
.expect("list_automations must not block behind a slow enqueue")
.expect("list automations");
assert_eq!(listed.len(), 1);
release_tx.send(()).expect("release stub");
let run = run_task.await.expect("join").expect("run now");
assert!(matches!(run.status, AutomationRunStatus::Failed));
let manager = shared.lock().await;
let runs = manager.list_runs(&created.id, None).expect("list runs");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].id, run.id);
assert!(matches!(runs[0].status, AutomationRunStatus::Failed));
let automation = manager.get_automation(&created.id).expect("automation");
assert!(automation.last_run_at.is_some());
}
#[tokio::test]
async fn watcher_noop_completion_removes_run_row() -> Result<()> {
let tempdir = tempfile::tempdir().expect("tempdir");
let task_manager = TaskManager::start_with_executor(
automation_task_config(tempdir.path().join("tasks")),
std::sync::Arc::new(AutomationWatcherNoopExecutor),
)
.await?;
let mut automation = automation_record_with_settings(None, None, None, None);
automation.delivery_mode = Some(AutomationDeliveryMode::Watcher);
automation.next_run_at = Some(Utc::now() - Duration::seconds(1));
let manager = AutomationManager::open(tempdir.path().join("automations")).expect("manager");
manager
.save_automation(&automation)
.expect("save automation");
let shared: SharedAutomationManager = Arc::new(Mutex::new(manager));
scheduler_tick_shared(&shared, &task_manager).await?;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
reconcile_run_statuses_shared(&shared, &task_manager).await?;
let manager = shared.lock().await;
assert!(
manager.list_runs(&automation.id, None)?.is_empty(),
"watcher no-op must not leave a phantom run row"
);
let updated = manager.get_automation(&automation.id)?;
assert!(
updated.next_run_at.is_some(),
"watcher should keep scheduling"
);
assert_eq!(
updated.last_run_at, None,
"no-op checks are not reportable runs"
);
drop(manager);
task_manager.shutdown();
Ok(())
}
#[test]
fn default_automations_dir_honors_codewhale_home_as_hard_override() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
unsafe {
std::env::remove_var("DEEPSEEK_AUTOMATIONS_DIR");
std::env::set_var("CODEWHALE_HOME", tmp.path());
}
assert_eq!(default_automations_dir(), tmp.path().join("automations"));
unsafe {
std::env::remove_var("CODEWHALE_HOME");
}
}
#[test]
fn default_automations_dir_prefers_deepseek_automations_dir_over_codewhale_home() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
unsafe {
std::env::set_var("DEEPSEEK_AUTOMATIONS_DIR", tmp.path());
std::env::set_var("CODEWHALE_HOME", "/should/not/be/used");
}
assert_eq!(default_automations_dir(), tmp.path());
unsafe {
std::env::remove_var("DEEPSEEK_AUTOMATIONS_DIR");
std::env::remove_var("CODEWHALE_HOME");
}
}
}