1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#![allow(
clippy::wildcard_imports,
clippy::too_many_arguments,
clippy::needless_pass_by_value,
dead_code,
reason = "split-out module preserves existing code while keeping files under the linecheck limit"
)]
use super::*;
/// Unix epoch seconds of `schedule`'s next fire after now, evaluated in `timezone` (a routine's
/// [`Routine::timezone`], the zone `crate::routine_scheduler` fires it in) or the host's local
/// timezone when unset, reusing the same compiled schedule path as the TTL sweep
/// (`cleanup::ttl::cron_interval_secs`).
///
/// `None` when `enabled` is `false`, the daemon is globally locked (see [`crate::global_lock`]),
/// `schedule` cannot be parsed (e.g. `@reboot`), or it has no upcoming fire.
pub(crate) fn next_run_at(
schedules: &[String],
enabled: bool,
timezone: Option<&str>,
) -> Option<u64> {
if !enabled || crate::global_lock::is_globally_locked() {
return None;
}
let union = crate::utils::cron::compiled_union_many(schedules)?;
let cron = union.iter().next()?.schedule();
let next = match timezone.and_then(|tz| tz.parse::<chrono_tz::Tz>().ok()) {
Some(tz) => cron
.after(&chrono::Utc::now().with_timezone(&tz))
.next()?
.timestamp(),
None => cron.after(&Local::now()).next()?.timestamp(),
};
u64::try_from(next).ok()
}
impl Routine {
/// Return all configured schedules, falling back to the legacy primary schedule when needed.
pub fn effective_schedules(&self) -> Vec<String> {
if self.schedules.is_empty() {
vec![self.schedule.clone()]
} else {
self.schedules.clone()
}
}
}
impl RoutineResponse {
/// Build a response from `routine`, deriving registration status and schedule description.
pub fn from_routine(routine: Routine) -> Self {
let rel_path = crate::routine_storage::routine_rel_dir(&routine);
let slug = crate::routine_storage::slug_from_rel_dir(&rel_path);
let folder = crate::routine_storage::folder_from_rel_dir(&rel_path);
// An agent counts as registered only if its config both exists *and* parses: a
// present-but-malformed config is silently dropped at crontab-sync time, so reporting it as
// registered would paint a never-firing routine as healthy. See issue #301.
let agent_command = load_agent_command(&routine.agent);
let agent_registered = agent_command.is_ok();
let agent_command_available = agent_command
.as_ref()
.is_ok_and(|agent| agent_command_available(&agent.command));
// Distinct from `agent_command_available` above: the agent binary itself can resolve while
// its `setup` step still shells out to something missing (issue #404's `python3` case).
let agent_setup_available = agent_command
.as_ref()
.is_ok_and(|agent| setup_step_available(agent.setup.as_deref()));
let file_path = routines_dir()
.join(&rel_path)
.join("routine.toml")
.to_string_lossy()
.into_owned();
// The routine's own override, when set, is the *effective* zone its schedule is
// interpreted in (see `Routine::timezone`); only fall back to the host's zone when unset,
// so this reflects reality instead of always reporting the host's zone (issue #405).
let timezone = routine.timezone.clone().or_else(local_timezone);
let schedules = routine.effective_schedules();
let schedule_description = schedules
.first()
.and_then(|schedule| describe_schedule(schedule, timezone.as_deref()));
let schedule_descriptions = schedules
.iter()
.filter_map(|schedule| describe_schedule(schedule, timezone.as_deref()))
.collect();
// Keyed by `rel_path` (the routine's actual on-disk directory), not the bare `slug`: for a
// routine inside a folder those differ, and the flags/ dir lives under the full rel_path
// (see #1514).
let flag_count = list_flags(&rel_path).len();
let next_run_at = next_run_at(&schedules, routine.enabled, routine.timezone.as_deref());
let missed_scheduled_run_at = missed_scheduled_run_at(&routine, &schedules);
let is_running = tmux_session_prefix_alive(&tmux_session_prefix(&slug));
// Key names only — never values. `local_env_keys` reads `routine.local.toml` (if any) and
// drops the values immediately, so a secret override never survives past this call. See
// `Routine::env`'s doc comment for why the values themselves are never serialized.
let mut env_keys: Vec<String> = routine.env.keys().cloned().collect();
env_keys.extend(crate::routine_storage::local_env_keys(&slug));
env_keys.sort();
env_keys.dedup();
Self {
routine,
agent_registered,
agent_command_available,
agent_setup_available,
file_path,
folder,
slug,
rel_path,
schedule_description,
schedule_descriptions,
timezone,
flag_count,
next_run_at,
missed_scheduled_run_at,
is_running,
env_keys,
}
}
}
/// Result of an on-demand workbench cleanup sweep.
#[derive(Debug, Clone, Serialize, JsonSchema, utoipa::ToSchema)]
pub struct CleanupResponse {
/// Number of finished, expired run workbenches removed by this sweep.
pub removed: usize,
/// Total disk space reclaimed, in bytes, summed across the removed workbench trees. Additive
/// field: existing `{"removed": N}` consumers are unaffected.
pub freed_bytes: u64,
}
/// Routines keyed by ID.
pub type RoutineStore = Arc<Mutex<HashMap<String, Routine>>>;
#[cfg(test)]
pub fn new_store() -> RoutineStore {
Arc::new(Mutex::new(HashMap::new()))
}
/// Serde default for boolean fields that should default to `true`.
pub(crate) const fn bool_true() -> bool {
true
}