1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Duration as ChronoDuration, Local, Timelike, Utc};
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9use super::cron::ParsedCron;
10
11const SESSION_JITTER_CAP_SECS: u64 = 15 * 60;
12const ONE_SHOT_TOP_OF_HOUR_JITTER_SECS: u64 = 90;
13
14static NEXT_TASK_COUNTER: AtomicU64 = AtomicU64::new(1);
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(tag = "kind", rename_all = "snake_case")]
18pub enum ScheduledTaskAction {
19 Prompt { prompt: String },
20 Reminder { message: String },
21}
22
23impl ScheduledTaskAction {
24 #[must_use]
25 pub fn summary(&self) -> &str {
26 match self {
27 Self::Prompt { prompt } => prompt,
28 Self::Reminder { message } => message,
29 }
30 }
31
32 #[must_use]
33 pub fn kind_label(&self) -> &'static str {
34 match self {
35 Self::Prompt { .. } => "prompt",
36 Self::Reminder { .. } => "reminder",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum ScheduleSpec {
44 Cron5(Cron5),
45 FixedInterval(FixedInterval),
46 OneShot(OneShot),
47}
48
49impl ScheduleSpec {
50 pub fn cron5(expression: impl Into<String>) -> Result<Self> {
51 Ok(Self::Cron5(Cron5::parse(expression)?))
52 }
53
54 pub fn fixed_interval(duration: Duration) -> Result<Self> {
55 Ok(Self::FixedInterval(FixedInterval::from_duration(duration)?))
56 }
57
58 pub fn one_shot(at: DateTime<Utc>) -> Self {
59 Self::OneShot(OneShot { at })
60 }
61
62 #[must_use]
63 pub fn is_recurring(&self) -> bool {
64 !matches!(self, Self::OneShot(_))
65 }
66
67 #[must_use]
68 pub fn human_description(&self) -> String {
69 match self {
70 Self::Cron5(spec) => format!("cron {}", spec.expression),
71 Self::FixedInterval(spec) => spec.human_description(),
72 Self::OneShot(spec) => {
73 format!("once at {}", spec.at.with_timezone(&Local).format("%Y-%m-%d %H:%M"))
74 }
75 }
76 }
77
78 pub fn first_base_fire_at(&self, created_at: DateTime<Utc>) -> Result<Option<DateTime<Utc>>> {
79 let created_local = created_at.with_timezone(&Local);
80 let base_local = match self {
81 Self::Cron5(spec) => spec.next_after(created_local)?,
82 Self::FixedInterval(spec) => Some(created_local + spec.chrono_duration()?),
83 Self::OneShot(spec) => Some(spec.at.with_timezone(&Local)),
84 };
85 Ok(base_local.map(|value| value.with_timezone(&Utc)))
86 }
87
88 pub fn next_base_fire_after(&self, last_base_fire_at: DateTime<Utc>) -> Result<Option<DateTime<Utc>>> {
89 let last_base_local = last_base_fire_at.with_timezone(&Local);
90 let next_local = match self {
91 Self::Cron5(spec) => spec.next_after(last_base_local)?,
92 Self::FixedInterval(spec) => Some(last_base_local + spec.chrono_duration()?),
93 Self::OneShot(_) => None,
94 };
95 Ok(next_local.map(|value| value.with_timezone(&Utc)))
96 }
97
98 pub(crate) fn jittered_fire_at(&self, id: &str, base_fire_at: DateTime<Utc>) -> Result<DateTime<Utc>> {
99 let base_local = base_fire_at.with_timezone(&Local);
100 let hash = stable_hash_u64(id.as_bytes());
101 match self {
102 Self::Cron5(_) | Self::FixedInterval(_) => {
103 let Some(period) = self.nominal_period()? else {
104 return Ok(base_fire_at);
105 };
106 #[allow(
107 clippy::cast_sign_loss,
108 reason = "Intentional compatibility, platform, or test-only suppression."
109 )]
110 let period_secs = period.num_seconds().max(0) as u64;
111 #[allow(
112 clippy::cast_sign_loss,
113 reason = "Intentional compatibility, platform, or test-only suppression."
114 )]
115 let max_delay = (((period_secs as f64) * 0.10).floor()).max(0.0) as u64;
116 let max_delay = max_delay.min(SESSION_JITTER_CAP_SECS);
117 let delay_secs = if max_delay == 0 { 0 } else { hash % (max_delay + 1) };
118 Ok(base_fire_at + ChronoDuration::seconds(delay_secs as i64))
119 }
120 Self::OneShot(_) if matches!(base_local.minute(), 0 | 30) => {
121 let lead_secs = hash % (ONE_SHOT_TOP_OF_HOUR_JITTER_SECS + 1);
122 Ok(base_fire_at - ChronoDuration::seconds(lead_secs as i64))
123 }
124 Self::OneShot(_) => Ok(base_fire_at),
125 }
126 }
127
128 fn nominal_period(&self) -> Result<Option<ChronoDuration>> {
129 match self {
130 Self::FixedInterval(spec) => Ok(Some(spec.chrono_duration()?)),
131 Self::Cron5(spec) => spec.approx_period(),
132 Self::OneShot(_) => Ok(None),
133 }
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct Cron5 {
139 pub expression: String,
140}
141
142impl Cron5 {
143 pub fn parse(expression: impl Into<String>) -> Result<Self> {
144 let expression = expression.into();
145 ParsedCron::parse(&expression)?;
146 Ok(Self { expression })
147 }
148
149 pub(crate) fn parsed(&self) -> Result<ParsedCron> {
150 ParsedCron::parse(&self.expression)
151 }
152
153 pub(crate) fn next_after(&self, after: DateTime<Local>) -> Result<Option<DateTime<Local>>> {
154 self.parsed()?.next_after(after)
155 }
156
157 pub(crate) fn approx_period(&self) -> Result<Option<ChronoDuration>> {
158 let now = Local::now();
159 let Some(first) = self.next_after(now)? else {
160 return Ok(None);
161 };
162 let Some(second) = self.next_after(first)? else {
163 return Ok(None);
164 };
165 Ok(Some(second - first))
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170pub struct FixedInterval {
171 pub seconds: u64,
172}
173
174impl FixedInterval {
175 pub fn from_duration(duration: Duration) -> Result<Self> {
176 if duration.as_secs() < 60 {
177 bail!("Fixed intervals must be at least 1 minute");
178 }
179 Ok(Self { seconds: duration.as_secs() })
180 }
181
182 pub fn chrono_duration(&self) -> Result<ChronoDuration> {
183 let seconds = i64::try_from(self.seconds).context("interval is too large")?;
184 Ok(ChronoDuration::seconds(seconds))
185 }
186
187 #[must_use]
188 pub fn human_description(&self) -> String {
189 humanize_interval(self.seconds)
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194pub struct OneShot {
195 pub at: DateTime<Utc>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199pub struct ScheduledTaskDefinition {
200 pub id: String,
201 pub name: String,
202 pub schedule: ScheduleSpec,
203 pub action: ScheduledTaskAction,
204 pub workspace: Option<PathBuf>,
205 pub created_at: DateTime<Utc>,
206 pub expires_at: Option<DateTime<Utc>>,
207}
208
209impl ScheduledTaskDefinition {
210 pub fn new(
211 name: Option<String>,
212 schedule: ScheduleSpec,
213 action: ScheduledTaskAction,
214 workspace: Option<PathBuf>,
215 created_at: DateTime<Utc>,
216 expires_at: Option<DateTime<Utc>>,
217 ) -> Result<Self> {
218 let rendered_name = name
219 .map(|value| value.trim().to_string())
220 .filter(|value| !value.is_empty())
221 .unwrap_or_else(|| summarize_task_name(action.summary()));
222 let id = generate_task_id(&rendered_name, action.summary(), created_at);
223 Ok(Self {
224 id,
225 name: rendered_name,
226 schedule,
227 action,
228 workspace,
229 created_at,
230 expires_at,
231 })
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236#[serde(tag = "state", rename_all = "snake_case")]
237pub enum TaskRunStatus {
238 Triggered,
239 ReminderSent,
240 Success,
241 Failed { message: String },
242}
243
244impl fmt::Display for TaskRunStatus {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 match self {
247 Self::Triggered => write!(f, "triggered"),
248 Self::ReminderSent => write!(f, "reminder_sent"),
249 Self::Success => write!(f, "success"),
250 Self::Failed { message } => write!(f, "failed: {message}"),
251 }
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
256pub struct ScheduledTaskRuntimeState {
257 pub last_run_at: Option<DateTime<Utc>>,
258 pub next_base_run_at: Option<DateTime<Utc>>,
259 pub next_run_at: Option<DateTime<Utc>>,
260 pub last_status: Option<TaskRunStatus>,
261 pub last_artifact_dir: Option<PathBuf>,
262 pub last_events_file: Option<PathBuf>,
263 pub last_message_file: Option<PathBuf>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ScheduledTaskRecord {
268 pub definition: ScheduledTaskDefinition,
269 pub runtime: ScheduledTaskRuntimeState,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273pub struct ScheduledTaskSummary {
274 pub id: String,
275 pub name: String,
276 pub action_kind: String,
277 pub schedule: String,
278 pub workspace: Option<PathBuf>,
279 pub recurring: bool,
280 pub next_run_at: Option<DateTime<Utc>>,
281 pub last_run_at: Option<DateTime<Utc>>,
282 pub last_status: Option<String>,
283}
284
285impl ScheduledTaskRecord {
286 pub(crate) fn summary(&self) -> ScheduledTaskSummary {
287 ScheduledTaskSummary {
288 id: self.definition.id.clone(),
289 name: self.definition.name.clone(),
290 action_kind: self.definition.action.kind_label().to_string(),
291 schedule: self.definition.schedule.human_description(),
292 workspace: self.definition.workspace.clone(),
293 recurring: self.definition.schedule.is_recurring(),
294 next_run_at: self.runtime.next_run_at,
295 last_run_at: self.runtime.last_run_at,
296 last_status: self.runtime.last_status.as_ref().map(ToString::to_string),
297 }
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct DueSessionPrompt {
303 pub id: String,
304 pub name: String,
305 pub prompt: String,
306}
307
308fn summarize_task_name(summary: &str) -> String {
309 let trimmed = summary.trim();
310 if trimmed.is_empty() {
311 return "Scheduled task".to_string();
312 }
313 let compact = vtcode_commons::formatting::collapse_whitespace(trimmed);
314 let mut output = String::new();
315 for ch in compact.chars().take(32) {
316 output.push(ch);
317 }
318 output
319}
320
321fn generate_task_id(name: &str, summary: &str, created_at: DateTime<Utc>) -> String {
322 let counter = NEXT_TASK_COUNTER.fetch_add(1, Ordering::Relaxed);
323 let seed = format!(
324 "{name}|{summary}|{}|{}|{}",
325 created_at.timestamp_nanos_opt().unwrap_or_default(),
326 std::process::id(),
327 counter
328 );
329 format!("{:08x}", stable_hash_u64(seed.as_bytes()) as u32)
330}
331
332fn stable_hash_u64(bytes: &[u8]) -> u64 {
333 let mut hash = 0xcbf29ce484222325u64;
334 for byte in bytes {
335 hash ^= u64::from(*byte);
336 hash = hash.wrapping_mul(0x100000001b3);
337 }
338 hash
339}
340
341fn humanize_interval(seconds: u64) -> String {
342 match seconds {
343 value if value % 86_400 == 0 => {
344 let days = value / 86_400;
345 if days == 1 {
346 "every 1 day".to_string()
347 } else {
348 format!("every {days} days")
349 }
350 }
351 value if value % 3_600 == 0 => {
352 let hours = value / 3_600;
353 if hours == 1 {
354 "every 1 hour".to_string()
355 } else {
356 format!("every {hours} hours")
357 }
358 }
359 value => {
360 let minutes = value / 60;
361 if minutes == 1 {
362 "every 1 minute".to_string()
363 } else {
364 format!("every {minutes} minutes")
365 }
366 }
367 }
368}