//! Durable, project-scoped scheduled task definitions. use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{AgentId, ProjectId, ScheduledTaskId, TaskId}; /// Lowest interval accepted by the V1 scheduler. pub const MIN_SCHEDULE_INTERVAL_MINUTES: u32 = 5; /// Maximum command/name/description lengths keep the machine-local document bounded. pub const SCHEDULED_TASK_NAME_MAX_CHARS: usize = 160; /// Maximum size accepted for each persisted free-text or command value. pub const SCHEDULED_TASK_TEXT_MAX_BYTES: usize = 64 * 1024; /// Day names used by weekly schedules, independent from locale. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Weekday { /// Monday. Monday, /// Tuesday. Tuesday, /// Wednesday. Wednesday, /// Thursday. Thursday, /// Friday. Friday, /// Saturday. Saturday, /// Sunday. Sunday, } /// V1 bounded schedule. Free-form cron expressions are deliberately absent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde( rename_all = "camelCase", rename_all_fields = "camelCase", tag = "kind" )] pub enum ScheduledTaskSchedule { /// Recur after a fixed number of minutes. Interval { /// Interval length, subject to [`MIN_SCHEDULE_INTERVAL_MINUTES`]. every_minutes: u32, }, /// Recur once each local calendar day. Daily { /// Local wall-clock time formatted as `HH:mm`. time: String, }, /// Recur on selected local weekdays. Weekly { /// Non-empty set of distinct weekdays. days: Vec, /// Local wall-clock time formatted as `HH:mm`. time: String, }, } impl ScheduledTaskSchedule { /// Validates the bounded schedule syntax. pub fn validate(&self) -> Result<(), ScheduledProjectTaskError> { match self { Self::Interval { every_minutes } if *every_minutes < MIN_SCHEDULE_INTERVAL_MINUTES => { Err(ScheduledProjectTaskError::IntervalTooShort) } Self::Interval { .. } => Ok(()), Self::Daily { time } => validate_time(time), Self::Weekly { days, time } => { validate_time(time)?; if days.is_empty() { return Err(ScheduledProjectTaskError::EmptyWeekdays); } let mut unique = std::collections::HashSet::new(); if !days.iter().all(|day| unique.insert(*day)) { return Err(ScheduledProjectTaskError::DuplicateWeekday); } Ok(()) } } } } fn validate_time(value: &str) -> Result<(), ScheduledProjectTaskError> { let Some((hour, minute)) = value.split_once(':') else { return Err(ScheduledProjectTaskError::InvalidTime(value.to_owned())); }; if hour.len() != 2 || minute.len() != 2 || hour.parse::().map_or(true, |h| h > 23) || minute.parse::().map_or(true, |m| m > 59) { return Err(ScheduledProjectTaskError::InvalidTime(value.to_owned())); } Ok(()) } /// Exact invocation persisted for a scheduled task. Environment injection is /// intentionally absent in V1. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduledTaskCommand { /// Executable passed directly to the background runner. pub command: String, /// Literal argument vector; no shell interpolation is performed here. #[serde(default)] pub args: Vec, /// Project-relative working directory (`.` by default). #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, } /// Actor that created or last updated a schedule. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde( rename_all = "camelCase", rename_all_fields = "camelCase", tag = "kind" )] pub enum ScheduledTaskActor { /// A human used a trusted application surface. Human, /// An agent used its requester-scoped MCP surface. Agent { /// Requesting agent identity. agent_id: AgentId, }, /// An internal scheduler or reconciliation action. System, } /// Explicit approval state prevents agent-originated definitions from running silently. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ScheduledTaskApproval { /// A human approved this exact definition. Approved, /// An agent-created or modified definition awaits human review. Pending, /// A human explicitly rejected the proposal. Rejected, } /// Fixed V1 permission profile: project files may be modified, while filesystem /// access outside the project is denied except read/execute system toolchains. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum ScheduledTaskSandbox { /// Confined automatic project maintenance. #[default] ProjectReadWrite, } /// Durable recurring intent, distinct from every concrete [`crate::BackgroundTask`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduledProjectTask { /// Stable definition identity. pub id: ScheduledTaskId, /// Owning project. pub project_id: ProjectId, /// Human-readable task name. pub name: String, /// Optional explanation shown during review. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// Whether the next occurrence may be armed. pub enabled: bool, /// Human approval state for this exact revision. pub approval: ScheduledTaskApproval, /// Explicit, immutable V1 sandbox profile used by every run. #[serde(default)] pub sandbox: ScheduledTaskSandbox, /// Exact command definition, with no free-form environment. pub command: ScheduledTaskCommand, /// Bounded recurring schedule. pub schedule: ScheduledTaskSchedule, /// IANA timezone used for daily and weekly schedules. pub timezone: String, /// Persisted next future occurrence, in Unix milliseconds. pub next_run_at_ms: Option, /// Time the most recent concrete run was created. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_run_at_ms: Option, /// Most recently created background task, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_task_id: Option, /// Creator of the durable definition. pub created_by: ScheduledTaskActor, /// Actor responsible for its current revision. pub updated_by: ScheduledTaskActor, /// Creation time in Unix milliseconds. pub created_at_ms: u64, /// Last mutation time in Unix milliseconds. pub updated_at_ms: u64, /// Optimistic concurrency and audit sequence. pub audit_revision: u64, /// Hard execution limit for each concrete run. #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, } impl ScheduledProjectTask { /// Validates domain invariants independent of persistence and runtime I/O. pub fn validate(&self) -> Result<(), ScheduledProjectTaskError> { let name = self.name.trim(); if name.is_empty() || name.chars().count() > SCHEDULED_TASK_NAME_MAX_CHARS { return Err(ScheduledProjectTaskError::InvalidName); } if self.command.command.trim().is_empty() { return Err(ScheduledProjectTaskError::EmptyCommand); } if self.command.command.len() > SCHEDULED_TASK_TEXT_MAX_BYTES || self .command .args .iter() .any(|arg| arg.len() > SCHEDULED_TASK_TEXT_MAX_BYTES) || self .description .as_ref() .is_some_and(|v| v.len() > SCHEDULED_TASK_TEXT_MAX_BYTES) { return Err(ScheduledProjectTaskError::TextTooLong); } if self.timezone.trim().is_empty() { return Err(ScheduledProjectTaskError::InvalidTimezone); } if self.enabled && self.approval != ScheduledTaskApproval::Approved { return Err(ScheduledProjectTaskError::ApprovalRequired); } self.schedule.validate() } } /// One append-only audit record stored beside the project definitions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduledTaskAuditEntry { /// Monotonic definition revision associated with the action. pub revision: u64, /// Owning project. pub project_id: ProjectId, /// Definition affected by the action. pub scheduled_task_id: ScheduledTaskId, /// Actor responsible for the action. pub actor: ScheduledTaskActor, /// Stable action key. pub action: String, /// Human-readable outcome summary. pub summary: String, /// Action time in Unix milliseconds. pub at_ms: u64, } /// Invalid scheduled task definition. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ScheduledProjectTaskError { /// Name is empty or exceeds the length limit. #[error("scheduled task name must be non-empty and at most 160 characters")] InvalidName, /// Executable is empty. #[error("scheduled task command must not be empty")] EmptyCommand, /// One persisted text value exceeds its size limit. #[error("scheduled task text is too long")] TextTooLong, /// Interval is below the V1 minimum. #[error("scheduled task interval must be at least 5 minutes")] IntervalTooShort, /// Weekly schedule does not select any day. #[error("weekly schedule must contain at least one day")] EmptyWeekdays, /// Weekly schedule repeats a day. #[error("weekly schedule contains a duplicate day")] DuplicateWeekday, /// Daily or weekly wall-clock time is not valid `HH:mm`. #[error("invalid HH:mm schedule time `{0}`")] InvalidTime(String), /// Timezone is absent or invalid. #[error("timezone must be a valid non-empty IANA timezone")] InvalidTimezone, /// Enabled definition has not been approved by a human. #[error("scheduled task requires human approval before it can be enabled")] ApprovalRequired, }