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
#![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, in the host's local timezone (matching
/// crontab semantics), 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) -> 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 = cron.after(&Local::now()).next()?;
u64::try_from(next.timestamp()).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();
let timezone = 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();
let flag_count = list_flags(&slug).len();
let next_run_at = next_run_at(&schedules, routine.enabled);
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,
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
}