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}