Skip to main content

zeph_scheduler/
task.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::borrow::Cow;
5use std::fmt;
6use std::future::Future;
7use std::pin::Pin;
8use std::str::FromStr;
9
10use chrono::{DateTime, Utc};
11use cron::Schedule as CronSchedule;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14use crate::error::SchedulerError;
15
16/// Normalise a cron expression to the 6-field format required by the `cron` crate.
17///
18/// Standard 5-field expressions (`min hour day month weekday`) are prepended with `"0 "` to
19/// default seconds to zero. 6-field expressions are passed through unchanged. Any other field
20/// count is also passed through unchanged and will produce an error from the `cron` crate at
21/// parse time.
22///
23/// # Examples
24///
25/// ```
26/// use zeph_scheduler::normalize_cron_expr;
27///
28/// // 5-field: seconds are defaulted to 0.
29/// assert_eq!(normalize_cron_expr("*/5 * * * *").as_ref(), "0 */5 * * * *");
30///
31/// // 6-field: passed through unchanged.
32/// assert_eq!(normalize_cron_expr("0 */5 * * * *").as_ref(), "0 */5 * * * *");
33/// ```
34#[must_use]
35pub fn normalize_cron_expr(expr: &str) -> Cow<'_, str> {
36    if expr.split_whitespace().count() == 5 {
37        Cow::Owned(format!("0 {expr}"))
38    } else {
39        Cow::Borrowed(expr)
40    }
41}
42
43/// A validated cron expression that can only be constructed from a well-formed cron string.
44///
45/// `CronExpr` guarantees that the wrapped string is accepted by both [`normalize_cron_expr`]
46/// and `cron::Schedule::from_str`. Constructing one via [`TryFrom`] validates the expression
47/// eagerly, so any code that holds a `CronExpr` can assume the schedule is syntactically valid.
48///
49/// The raw `String` stored in the database column is not changed — `CronExpr` is a type-level
50/// wrapper that enforces validity at the boundary where cron strings enter the system.
51///
52/// # Examples
53///
54/// ```
55/// use zeph_scheduler::CronExpr;
56///
57/// // Valid 6-field expression.
58/// let expr: CronExpr = "0 0 3 * * *".try_into().expect("valid cron");
59/// assert_eq!(expr.as_ref(), "0 0 3 * * *");
60///
61/// // Valid 5-field expression (auto-normalised to 6-field).
62/// let expr: CronExpr = "0 3 * * *".try_into().expect("valid 5-field cron");
63/// assert_eq!(expr.as_ref(), "0 0 3 * * *");
64///
65/// // Invalid expression returns an error.
66/// assert!(CronExpr::try_from("not a cron").is_err());
67/// ```
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub struct CronExpr(String);
70
71impl CronExpr {
72    /// Return the validated cron expression string.
73    ///
74    /// The returned string is always in the normalised 6-field form accepted by the `cron` crate.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79}
80
81impl AsRef<str> for CronExpr {
82    fn as_ref(&self) -> &str {
83        &self.0
84    }
85}
86
87impl fmt::Display for CronExpr {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(&self.0)
90    }
91}
92
93impl TryFrom<String> for CronExpr {
94    type Error = SchedulerError;
95
96    /// Validate and normalise a cron expression string.
97    ///
98    /// Accepts both 5-field and 6-field expressions. Returns [`SchedulerError::InvalidCron`]
99    /// if the expression is not accepted by the `cron` parser after normalisation.
100    fn try_from(s: String) -> Result<Self, Self::Error> {
101        let normalized = normalize_cron_expr(&s);
102        CronSchedule::from_str(&normalized)
103            .map_err(|e| SchedulerError::InvalidCron(format!("{s}: {e}")))?;
104        // Store the normalised form so the string round-trips consistently.
105        Ok(Self(normalized.into_owned()))
106    }
107}
108
109impl TryFrom<&str> for CronExpr {
110    type Error = SchedulerError;
111
112    fn try_from(s: &str) -> Result<Self, Self::Error> {
113        Self::try_from(s.to_owned())
114    }
115}
116
117impl Serialize for CronExpr {
118    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
119        serializer.serialize_str(&self.0)
120    }
121}
122
123impl<'de> Deserialize<'de> for CronExpr {
124    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
125        let s = String::deserialize(deserializer)?;
126        Self::try_from(s).map_err(serde::de::Error::custom)
127    }
128}
129
130/// Trust level assigned to a scheduled task, used by the RTW-A re-entry defense.
131///
132/// Provenance determines how strictly the tick-fence and injection-detection
133/// mechanisms are applied when a task is dispatched.
134///
135/// # Invariants
136///
137/// - `Static` tasks have their config set at binary startup and are never
138///   overwritten by DB writes between ticks.
139/// - `External` tasks originate from out-of-process writes (CLI, direct SQL)
140///   and are subject to the full quarantine and injection-detection pipeline.
141/// - `UserAdded` tasks are user-initiated via the control channel and
142///   receive a single-tick quarantine before their prompt enters the LLM.
143///
144/// # Examples
145///
146/// ```
147/// use zeph_scheduler::TaskProvenance;
148///
149/// assert_eq!(TaskProvenance::Static.as_str(), "static");
150/// assert_eq!(TaskProvenance::from_provenance_str("external"), TaskProvenance::External);
151/// assert_eq!(TaskProvenance::from_provenance_str("unknown_value"), TaskProvenance::External);
152/// ```
153#[derive(Debug, Clone, PartialEq, Eq)]
154#[non_exhaustive]
155pub enum TaskProvenance {
156    /// Registered at binary startup via [`crate::Scheduler::add_task`] — config is immutable.
157    Static,
158    /// Added via the runtime control channel (e.g. CLI `zeph schedule add`) — user-originated.
159    UserAdded,
160    /// Loaded from the DB on hydration or written by an external process — untrusted.
161    External,
162}
163
164impl TaskProvenance {
165    /// Return the stable persistence string for this provenance level.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use zeph_scheduler::TaskProvenance;
171    ///
172    /// assert_eq!(TaskProvenance::Static.as_str(), "static");
173    /// assert_eq!(TaskProvenance::UserAdded.as_str(), "user_added");
174    /// assert_eq!(TaskProvenance::External.as_str(), "external");
175    /// ```
176    #[must_use]
177    pub fn as_str(&self) -> &'static str {
178        match self {
179            Self::Static => "static",
180            Self::UserAdded => "user_added",
181            Self::External => "external",
182        }
183    }
184
185    /// Parse a provenance string from the database.
186    ///
187    /// Unknown strings default to [`TaskProvenance::External`] — the most restrictive
188    /// level — so future schema additions degrade safely.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use zeph_scheduler::TaskProvenance;
194    ///
195    /// assert_eq!(TaskProvenance::from_provenance_str("static"), TaskProvenance::Static);
196    /// assert_eq!(TaskProvenance::from_provenance_str("user_added"), TaskProvenance::UserAdded);
197    /// assert_eq!(TaskProvenance::from_provenance_str("external"), TaskProvenance::External);
198    /// // Unknown values fall back to External (most restrictive).
199    /// assert_eq!(TaskProvenance::from_provenance_str("hydrated"), TaskProvenance::External);
200    /// ```
201    #[must_use]
202    pub fn from_provenance_str(s: &str) -> Self {
203        match s {
204            "static" => Self::Static,
205            "user_added" => Self::UserAdded,
206            _ => Self::External,
207        }
208    }
209
210    /// Returns `true` if this task originated from a potentially untrusted external source.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use zeph_scheduler::TaskProvenance;
216    ///
217    /// assert!(TaskProvenance::External.is_external());
218    /// assert!(!TaskProvenance::Static.is_external());
219    /// assert!(!TaskProvenance::UserAdded.is_external());
220    /// ```
221    #[must_use]
222    pub fn is_external(&self) -> bool {
223        matches!(self, Self::External)
224    }
225}
226
227/// Identifies what type of work a scheduled task performs.
228///
229/// Built-in variants map to well-known agent subsystems. [`TaskKind::Custom`]
230/// carries an arbitrary string so callers can define their own task kinds without
231/// modifying this enum.
232///
233/// # Persistence
234///
235/// Each variant serialises to a stable `snake_case` string via [`TaskKind::as_str`]
236/// and deserialises via [`TaskKind::from_str_kind`]. These strings are stored in
237/// the `kind` column of the `scheduled_jobs` table.
238///
239/// # Examples
240///
241/// ```
242/// use zeph_scheduler::TaskKind;
243///
244/// assert_eq!(TaskKind::HealthCheck.as_str(), "health_check");
245/// assert_eq!(TaskKind::from_str_kind("memory_cleanup"), TaskKind::MemoryCleanup);
246/// assert_eq!(TaskKind::from_str_kind("my_custom"), TaskKind::Custom("my_custom".into()));
247/// ```
248#[non_exhaustive]
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum TaskKind {
251    /// Triggers the memory subsystem's cleanup / compaction routine.
252    MemoryCleanup,
253    /// Reloads skills from the skill registry.
254    SkillRefresh,
255    /// Runs a liveness or readiness probe for the agent.
256    HealthCheck,
257    /// Checks the GitHub releases API for a newer Zeph version.
258    UpdateCheck,
259    /// Runs an experiment task (used by `zeph-experiments`).
260    Experiment,
261    /// An application-defined task kind. The string is the persistence key.
262    Custom(String),
263}
264
265impl TaskKind {
266    /// Parse a task kind from its persistence string.
267    ///
268    /// Unknown strings are wrapped in [`TaskKind::Custom`] rather than returning
269    /// an error, so new built-in variants added in future versions do not break
270    /// existing stored jobs loaded with an older build.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use zeph_scheduler::TaskKind;
276    ///
277    /// assert_eq!(TaskKind::from_str_kind("health_check"), TaskKind::HealthCheck);
278    /// assert_eq!(TaskKind::from_str_kind("unknown"), TaskKind::Custom("unknown".into()));
279    /// ```
280    #[must_use]
281    pub fn from_str_kind(s: &str) -> Self {
282        match s {
283            "memory_cleanup" => Self::MemoryCleanup,
284            "skill_refresh" => Self::SkillRefresh,
285            "health_check" => Self::HealthCheck,
286            "update_check" => Self::UpdateCheck,
287            "experiment" => Self::Experiment,
288            other => Self::Custom(other.to_owned()),
289        }
290    }
291
292    /// Return the stable string key used for database persistence.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use zeph_scheduler::TaskKind;
298    ///
299    /// assert_eq!(TaskKind::SkillRefresh.as_str(), "skill_refresh");
300    /// assert_eq!(TaskKind::Custom("my_job".into()).as_str(), "my_job");
301    /// ```
302    #[must_use]
303    pub fn as_str(&self) -> &str {
304        match self {
305            Self::MemoryCleanup => "memory_cleanup",
306            Self::SkillRefresh => "skill_refresh",
307            Self::HealthCheck => "health_check",
308            Self::UpdateCheck => "update_check",
309            Self::Experiment => "experiment",
310            Self::Custom(s) => s,
311        }
312    }
313}
314
315/// Execution mode for a scheduled task.
316///
317/// Determines how the scheduler decides when to run a task and what to do after it
318/// completes:
319///
320/// - [`TaskMode::Periodic`] re-computes `next_run` from the cron schedule after
321///   each successful execution and never removes the task from memory.
322/// - [`TaskMode::OneShot`] fires once when `now >= run_at` and then removes the
323///   task from the in-memory task list and marks it `done` in the store.
324#[non_exhaustive]
325pub enum TaskMode {
326    /// Run on a repeating cron schedule.
327    Periodic {
328        /// Parsed cron schedule that drives `next_run` computation.
329        schedule: Box<CronSchedule>,
330    },
331    /// Run once at the specified UTC timestamp.
332    OneShot {
333        /// The earliest UTC time at which the task should execute.
334        run_at: DateTime<Utc>,
335    },
336}
337
338/// Descriptor sent over the control channel to register tasks at runtime.
339///
340/// Send a `SchedulerMessage::Add` wrapping a boxed `TaskDescriptor` to add a
341/// new task (or replace an existing one with the same name) without stopping the
342/// scheduler loop.
343pub struct TaskDescriptor {
344    /// Unique name for the task. Replaces any existing task with the same name.
345    pub name: String,
346    /// Execution mode (periodic or one-shot).
347    pub mode: TaskMode,
348    /// The category of work this task performs.
349    pub kind: TaskKind,
350    /// Arbitrary JSON configuration forwarded to the [`TaskHandler`] at execution time.
351    pub config: serde_json::Value,
352    /// Trust level for RTW-A re-entry defense.
353    ///
354    /// Tasks sent via the runtime channel default to [`TaskProvenance::UserAdded`].
355    pub provenance: TaskProvenance,
356}
357
358/// A task held in memory by the [`crate::Scheduler`].
359///
360/// Use [`ScheduledTask::new`] / [`ScheduledTask::periodic`] for cron-based tasks
361/// and [`ScheduledTask::oneshot`] for tasks that run at a fixed point in time.
362///
363/// # Examples
364///
365/// ```
366/// use zeph_scheduler::{ScheduledTask, TaskKind};
367///
368/// let task = ScheduledTask::new(
369///     "daily-cleanup",
370///     "0 3 * * *",           // every day at 03:00 UTC (5-field cron)
371///     TaskKind::MemoryCleanup,
372///     serde_json::Value::Null,
373/// )
374/// .expect("valid cron expression");
375///
376/// assert_eq!(task.task_mode_str(), "periodic");
377/// assert!(task.cron_schedule().is_some());
378/// ```
379pub struct ScheduledTask {
380    /// Unique task name used as the primary key in the job store.
381    pub name: String,
382    /// Execution mode (periodic or one-shot).
383    pub mode: TaskMode,
384    /// The category of work this task performs.
385    pub kind: TaskKind,
386    /// Arbitrary JSON configuration forwarded to the [`TaskHandler`] at execution time.
387    pub config: serde_json::Value,
388    /// Trust level for RTW-A re-entry defense.
389    pub provenance: TaskProvenance,
390}
391
392impl ScheduledTask {
393    /// Create a new periodic task from a cron expression string.
394    ///
395    /// The resulting task has [`TaskProvenance::Static`] provenance.
396    ///
397    /// # Errors
398    ///
399    /// Returns `SchedulerError::InvalidCron` if the expression is not valid.
400    pub fn new(
401        name: impl Into<String>,
402        cron_expr: &str,
403        kind: TaskKind,
404        config: serde_json::Value,
405    ) -> Result<Self, SchedulerError> {
406        Self::periodic(name, cron_expr, kind, config)
407    }
408
409    /// Create a periodic task from a cron expression.
410    ///
411    /// The resulting task has [`TaskProvenance::Static`] provenance. To create a task with
412    /// different provenance, use [`ScheduledTask::periodic_with_provenance`].
413    ///
414    /// # Errors
415    ///
416    /// Returns `SchedulerError::InvalidCron` if the expression is not valid.
417    pub fn periodic(
418        name: impl Into<String>,
419        cron_expr: &str,
420        kind: TaskKind,
421        config: serde_json::Value,
422    ) -> Result<Self, SchedulerError> {
423        Self::periodic_with_provenance(name, cron_expr, kind, config, TaskProvenance::Static)
424    }
425
426    /// Create a periodic task from a cron expression with explicit provenance.
427    ///
428    /// # Errors
429    ///
430    /// Returns `SchedulerError::InvalidCron` if the expression is not valid.
431    pub fn periodic_with_provenance(
432        name: impl Into<String>,
433        cron_expr: &str,
434        kind: TaskKind,
435        config: serde_json::Value,
436        provenance: TaskProvenance,
437    ) -> Result<Self, SchedulerError> {
438        let normalized = normalize_cron_expr(cron_expr);
439        let schedule = CronSchedule::from_str(&normalized)
440            .map_err(|e| SchedulerError::InvalidCron(format!("{cron_expr}: {e}")))?;
441        Ok(Self {
442            name: name.into(),
443            mode: TaskMode::Periodic {
444                schedule: Box::new(schedule),
445            },
446            kind,
447            config,
448            provenance,
449        })
450    }
451
452    /// Create a one-shot task that runs at a specific point in time.
453    ///
454    /// The resulting task has [`TaskProvenance::Static`] provenance. To create a task with
455    /// different provenance, use [`ScheduledTask::oneshot_with_provenance`].
456    #[must_use]
457    pub fn oneshot(
458        name: impl Into<String>,
459        run_at: DateTime<Utc>,
460        kind: TaskKind,
461        config: serde_json::Value,
462    ) -> Self {
463        Self::oneshot_with_provenance(name, run_at, kind, config, TaskProvenance::Static)
464    }
465
466    /// Create a one-shot task that runs at a specific point in time, with explicit provenance.
467    ///
468    /// Parallel to [`ScheduledTask::periodic_with_provenance`]; used by
469    /// [`crate::scheduler::Scheduler::init`] to hydrate CLI-added one-shot rows with
470    /// [`TaskProvenance::External`] (#6361).
471    #[must_use]
472    pub fn oneshot_with_provenance(
473        name: impl Into<String>,
474        run_at: DateTime<Utc>,
475        kind: TaskKind,
476        config: serde_json::Value,
477        provenance: TaskProvenance,
478    ) -> Self {
479        Self {
480            name: name.into(),
481            mode: TaskMode::OneShot { run_at },
482            kind,
483            config,
484            provenance,
485        }
486    }
487
488    /// Returns the cron schedule if this is a periodic task.
489    #[must_use]
490    pub fn cron_schedule(&self) -> Option<&CronSchedule> {
491        if let TaskMode::Periodic { schedule } = &self.mode {
492            Some(schedule.as_ref())
493        } else {
494            None
495        }
496    }
497
498    /// Returns the canonical 6-field cron expression string for DB persistence.
499    ///
500    /// Returns an empty string for one-shot tasks, which do not have a cron schedule.
501    #[must_use]
502    pub fn cron_expr_string(&self) -> String {
503        match &self.mode {
504            TaskMode::Periodic { schedule } => schedule.to_string(),
505            TaskMode::OneShot { .. } => String::new(),
506        }
507    }
508
509    /// Returns the `task_mode` string used for DB persistence.
510    ///
511    /// Returns `"periodic"` or `"oneshot"`.
512    #[must_use]
513    pub fn task_mode_str(&self) -> &'static str {
514        match &self.mode {
515            TaskMode::Periodic { .. } => "periodic",
516            TaskMode::OneShot { .. } => "oneshot",
517        }
518    }
519}
520
521/// Trait for types that can execute a scheduled task.
522///
523/// Implementations receive the per-task JSON configuration stored in
524/// [`ScheduledTask::config`] and return `Ok(())` on success or a
525/// [`SchedulerError`] on failure. Failures are logged as warnings; the scheduler
526/// continues running and will retry on the next due tick.
527///
528/// Because async trait methods in Edition 2024 require returning a pinned boxed
529/// future for object safety, implementations must wrap their async work in
530/// `Box::pin(async move { … })`.
531///
532/// # Example
533///
534/// ```rust
535/// use std::future::Future;
536/// use std::pin::Pin;
537/// use zeph_scheduler::{SchedulerError, TaskHandler};
538///
539/// struct NoopHandler;
540///
541/// impl TaskHandler for NoopHandler {
542///     fn execute(
543///         &self,
544///         _config: &serde_json::Value,
545///     ) -> Pin<Box<dyn Future<Output = Result<(), SchedulerError>> + Send + '_>> {
546///         Box::pin(async move { Ok(()) })
547///     }
548/// }
549/// ```
550pub trait TaskHandler: Send + Sync {
551    /// Execute the task with the provided configuration.
552    ///
553    /// # Errors
554    ///
555    /// Return [`SchedulerError::TaskFailed`] (or any other variant) to indicate
556    /// that the task could not complete successfully. The error is logged but does
557    /// not stop the scheduler.
558    fn execute(
559        &self,
560        config: &serde_json::Value,
561    ) -> Pin<Box<dyn Future<Output = Result<(), SchedulerError>> + Send + '_>>;
562
563    /// Returns `true` when [`execute`](TaskHandler::execute) reads content that originates
564    /// outside the scheduler's own trusted config or in-process state — e.g. a network/HTTP
565    /// response, an MCP tool result, a file loaded from a plugin-marketplace skill directory,
566    /// or stored memory/graph facts that may themselves have been populated from such an
567    /// external source.
568    ///
569    /// RTW-A Mechanism 4 (capability attenuation, see
570    /// [`crate::Scheduler::with_reentry_defense`]) reads this flag to decide whether the
571    /// *current tick* counts as an external-read tick and should therefore suppress any
572    /// custom-prompt injection dispatched later in the same tick.
573    ///
574    /// Defaults to `false` so a handler must explicitly opt in — treating "unknown" as
575    /// "safe" would silently exempt any handler that reads untrusted content from
576    /// attenuation, defeating the purpose of the mechanism. Override this to `true` for any
577    /// handler whose `execute` performs network I/O, reads MCP/tool output, reloads skills
578    /// from disk, or surfaces previously stored content that may have originated from a
579    /// less-trusted source.
580    ///
581    /// # Examples
582    ///
583    /// ```rust
584    /// use std::future::Future;
585    /// use std::pin::Pin;
586    /// use zeph_scheduler::{SchedulerError, TaskHandler};
587    ///
588    /// struct FetchesRemoteData;
589    ///
590    /// impl TaskHandler for FetchesRemoteData {
591    ///     fn execute(
592    ///         &self,
593    ///         _config: &serde_json::Value,
594    ///     ) -> Pin<Box<dyn Future<Output = Result<(), SchedulerError>> + Send + '_>> {
595    ///         Box::pin(async move { Ok(()) })
596    ///     }
597    ///
598    ///     fn reads_external_content(&self) -> bool {
599    ///         true
600    ///     }
601    /// }
602    ///
603    /// assert!(FetchesRemoteData.reads_external_content());
604    /// ```
605    fn reads_external_content(&self) -> bool {
606        false
607    }
608
609    /// Returns `true` when [`execute`](TaskHandler::execute) injects an operator- or
610    /// externally-supplied prompt into the agent loop (e.g. by sending it on a channel the
611    /// agent reads as a new user message), as opposed to doing purely internal work (memory
612    /// consolidation, a health probe, a network poll with no agent-facing output, etc.).
613    ///
614    /// RTW-A Mechanism 4 (capability attenuation, see
615    /// [`crate::Scheduler::with_reentry_defense`]) reads this flag on the scheduler's normal
616    /// handler-dispatch path (`register_handler` + `execute_handler`) to decide whether to
617    /// suppress this handler's `execute` for the remainder of a tick that already contained an
618    /// external-read task (see [`reads_external_content`](TaskHandler::reads_external_content)).
619    /// Without this declaration, a handler registered for `TaskKind::Custom` that injects
620    /// prompts (e.g. the built-in `CustomTaskHandler`) would bypass Mechanism 4 entirely, since
621    /// suppression only guarded the no-handler-registered fallback path.
622    ///
623    /// Defaults to `false` for the same reason [`reads_external_content`] defaults to `false`:
624    /// a handler must explicitly declare that it performs agent-facing prompt injection, so
625    /// unrelated handlers (health checks, memory daemons, experiment runs) are never
626    /// unnecessarily suppressed.
627    ///
628    /// [`reads_external_content`]: TaskHandler::reads_external_content
629    ///
630    /// # Examples
631    ///
632    /// ```rust
633    /// use std::future::Future;
634    /// use std::pin::Pin;
635    /// use zeph_scheduler::{SchedulerError, TaskHandler};
636    ///
637    /// struct InjectsAgentPrompt;
638    ///
639    /// impl TaskHandler for InjectsAgentPrompt {
640    ///     fn execute(
641    ///         &self,
642    ///         _config: &serde_json::Value,
643    ///     ) -> Pin<Box<dyn Future<Output = Result<(), SchedulerError>> + Send + '_>> {
644    ///         Box::pin(async move { Ok(()) })
645    ///     }
646    ///
647    ///     fn injects_agent_prompt(&self) -> bool {
648    ///         true
649    ///     }
650    /// }
651    ///
652    /// assert!(InjectsAgentPrompt.injects_agent_prompt());
653    /// ```
654    fn injects_agent_prompt(&self) -> bool {
655        false
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn task_kind_roundtrip() {
665        assert_eq!(
666            TaskKind::from_str_kind("memory_cleanup"),
667            TaskKind::MemoryCleanup
668        );
669        assert_eq!(TaskKind::MemoryCleanup.as_str(), "memory_cleanup");
670        assert_eq!(
671            TaskKind::from_str_kind("skill_refresh"),
672            TaskKind::SkillRefresh
673        );
674        assert_eq!(TaskKind::SkillRefresh.as_str(), "skill_refresh");
675        assert_eq!(
676            TaskKind::from_str_kind("health_check"),
677            TaskKind::HealthCheck
678        );
679        assert_eq!(
680            TaskKind::from_str_kind("update_check"),
681            TaskKind::UpdateCheck
682        );
683        assert_eq!(TaskKind::UpdateCheck.as_str(), "update_check");
684        assert_eq!(
685            TaskKind::from_str_kind("custom_job"),
686            TaskKind::Custom("custom_job".into())
687        );
688        assert_eq!(TaskKind::Custom("x".into()).as_str(), "x");
689    }
690
691    #[test]
692    fn task_kind_experiment_roundtrip() {
693        assert_eq!(
694            TaskKind::from_str_kind("experiment"),
695            TaskKind::Experiment,
696            "from_str_kind must map 'experiment' to Experiment variant, not Custom"
697        );
698        assert_eq!(TaskKind::Experiment.as_str(), "experiment");
699    }
700
701    #[test]
702    fn normalize_five_field_prepends_zero() {
703        assert_eq!(normalize_cron_expr("*/5 * * * *"), "0 */5 * * * *");
704        assert_eq!(normalize_cron_expr("0 3 * * *"), "0 0 3 * * *");
705    }
706
707    #[test]
708    fn normalize_six_field_passthrough() {
709        assert_eq!(normalize_cron_expr("0 0 3 * * *"), "0 0 3 * * *");
710        assert_eq!(normalize_cron_expr("* * * * * *"), "* * * * * *");
711    }
712
713    #[test]
714    fn normalize_other_field_count_passthrough() {
715        assert_eq!(normalize_cron_expr("not_cron"), "not_cron");
716        assert_eq!(normalize_cron_expr("0 0 0 0"), "0 0 0 0");
717    }
718
719    #[test]
720    fn normalize_empty_string_passthrough() {
721        assert_eq!(normalize_cron_expr(""), "");
722    }
723
724    #[test]
725    fn normalize_whitespace_only_passthrough() {
726        assert_eq!(normalize_cron_expr("   "), "   ");
727    }
728
729    #[test]
730    fn valid_cron_creates_task() {
731        let task = ScheduledTask::new(
732            "test",
733            "0 0 * * * *",
734            TaskKind::HealthCheck,
735            serde_json::Value::Null,
736        );
737        assert!(task.is_ok());
738    }
739
740    #[test]
741    fn five_field_cron_creates_task() {
742        let task = ScheduledTask::new(
743            "five-field",
744            "*/5 * * * *",
745            TaskKind::HealthCheck,
746            serde_json::Value::Null,
747        );
748        assert!(task.is_ok(), "5-field cron must be accepted");
749    }
750
751    #[test]
752    fn invalid_cron_returns_error() {
753        let task = ScheduledTask::new(
754            "test",
755            "not_cron",
756            TaskKind::HealthCheck,
757            serde_json::Value::Null,
758        );
759        assert!(task.is_err());
760    }
761
762    #[test]
763    fn oneshot_task_creates_correctly() {
764        let run_at = Utc::now() + chrono::Duration::hours(1);
765        let task =
766            ScheduledTask::oneshot("t", run_at, TaskKind::HealthCheck, serde_json::Value::Null);
767        assert_eq!(task.task_mode_str(), "oneshot");
768        assert!(task.cron_schedule().is_none());
769    }
770
771    #[test]
772    fn periodic_task_mode_str() {
773        let task = ScheduledTask::periodic(
774            "p",
775            "0 * * * * *",
776            TaskKind::HealthCheck,
777            serde_json::Value::Null,
778        )
779        .unwrap();
780        assert_eq!(task.task_mode_str(), "periodic");
781        assert!(task.cron_schedule().is_some());
782    }
783
784    #[test]
785    fn task_provenance_roundtrip() {
786        assert_eq!(
787            TaskProvenance::from_provenance_str("static"),
788            TaskProvenance::Static
789        );
790        assert_eq!(
791            TaskProvenance::from_provenance_str("user_added"),
792            TaskProvenance::UserAdded
793        );
794        assert_eq!(
795            TaskProvenance::from_provenance_str("external"),
796            TaskProvenance::External
797        );
798        // Unknown values fall back to External (most restrictive fail-safe).
799        assert_eq!(
800            TaskProvenance::from_provenance_str("hydrated"),
801            TaskProvenance::External
802        );
803        assert_eq!(TaskProvenance::Static.as_str(), "static");
804        assert_eq!(TaskProvenance::UserAdded.as_str(), "user_added");
805        assert_eq!(TaskProvenance::External.as_str(), "external");
806    }
807
808    #[test]
809    fn task_provenance_is_external() {
810        assert!(TaskProvenance::External.is_external());
811        assert!(!TaskProvenance::Static.is_external());
812        assert!(!TaskProvenance::UserAdded.is_external());
813    }
814
815    #[test]
816    fn cron_expr_valid_six_field() {
817        let expr = CronExpr::try_from("0 0 3 * * *").expect("valid 6-field");
818        assert_eq!(expr.as_ref(), "0 0 3 * * *");
819    }
820
821    #[test]
822    fn cron_expr_valid_five_field_normalised() {
823        let expr = CronExpr::try_from("0 3 * * *").expect("valid 5-field");
824        // 5-field is normalised by prepending "0 "
825        assert_eq!(expr.as_ref(), "0 0 3 * * *");
826    }
827
828    #[test]
829    fn cron_expr_invalid_returns_error() {
830        assert!(CronExpr::try_from("not a cron").is_err());
831        assert!(CronExpr::try_from("").is_err());
832    }
833
834    #[test]
835    fn cron_expr_display_and_as_str_consistent() {
836        let expr = CronExpr::try_from("* * * * * *").expect("wildcard cron");
837        assert_eq!(expr.as_str(), expr.to_string());
838    }
839
840    #[test]
841    fn cron_expr_clone_and_eq() {
842        let a = CronExpr::try_from("0 0 * * * *").expect("valid");
843        let b = a.clone();
844        assert_eq!(a, b);
845    }
846
847    #[test]
848    fn cron_expr_serialize_deserialize_roundtrip() {
849        let expr = CronExpr::try_from("0 0 3 * * *").expect("valid");
850        let json = serde_json::to_string(&expr).expect("serialize");
851        assert_eq!(json, r#""0 0 3 * * *""#);
852        let decoded: CronExpr = serde_json::from_str(&json).expect("deserialize");
853        assert_eq!(expr, decoded);
854    }
855
856    #[test]
857    fn cron_expr_deserialize_invalid_returns_error() {
858        let result: Result<CronExpr, _> = serde_json::from_str(r#""not-a-cron""#);
859        assert!(result.is_err());
860    }
861
862    #[test]
863    fn new_task_has_static_provenance() {
864        let task = ScheduledTask::new(
865            "test",
866            "0 * * * * *",
867            TaskKind::HealthCheck,
868            serde_json::Value::Null,
869        )
870        .unwrap();
871        assert_eq!(task.provenance, TaskProvenance::Static);
872    }
873
874    #[test]
875    fn periodic_with_provenance_sets_provenance() {
876        let task = ScheduledTask::periodic_with_provenance(
877            "ext",
878            "0 * * * * *",
879            TaskKind::HealthCheck,
880            serde_json::Value::Null,
881            TaskProvenance::External,
882        )
883        .unwrap();
884        assert_eq!(task.provenance, TaskProvenance::External);
885    }
886}