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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#![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::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// A persisted routine: a scheduled AI-agent task.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)]
pub struct Routine {
/// Unique identifier (UUID v4).
pub id: String,
/// Primary cron expression defining when the routine runs, evaluated in the host's local
/// system timezone (the OS crontab timezone), not UTC. Kept for backward-compatible clients.
pub schedule: String,
/// All cron expressions defining when the routine runs. The first entry mirrors [`Routine::schedule`].
#[serde(default)]
pub schedules: Vec<String>,
/// Human name; slugified to name the workbench and tmux session.
pub title: String,
/// Agent registry key (e.g. `"claude"`) resolved from `~/.config/moadim/agents/`.
pub agent: String,
/// Model ID to run the agent with (e.g. `"claude-sonnet-4-6"`), passed as `--model` on the
/// agent invocation. `None` uses the agent's own default.
#[serde(default)]
pub model: Option<String>,
/// The task prompt handed to the agent.
///
/// Omitted from serialized output when empty. A persisted routine always has a
/// non-blank prompt (enforced by `validate_prompt`), so this never affects
/// `routine.toml` persistence; it lets list responses drop the prompt by blanking
/// it in-memory (see [`RoutineListQuery::include_prompts`] / `svc_list`).
#[serde(skip_serializing_if = "String::is_empty")]
pub prompt: String,
/// A very short (at most 5 lines) statement of the routine's goal — the "why" behind the
/// prompt. Rendered into the agent's `prompt.md` as a `## Goal` preamble. `None` when unset.
#[serde(default)]
pub goal: Option<String>,
/// Repositories listed in the prompt as context.
#[serde(default)]
pub repositories: Vec<Repository>,
/// Machines this routine runs on. Each daemon schedules a routine only when this list names its
/// own machine identity ([`crate::machine::current_machine`]); an **empty list runs nowhere**, so
/// a routine is dormant until explicitly assigned. Lets one shared config repo drive different
/// routines on different machines.
#[serde(default)]
pub machines: Vec<String>,
/// Whether the routine is active.
pub enabled: bool,
/// Optional user-provided reason captured when the routine was manually disabled.
///
/// Persisted in the tracked `disabled.json` marker, not `routine.toml`. `None` for enabled
/// routines, disabled routines whose marker predates reason metadata, and malformed markers
/// whose presence still disables the routine.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
/// `"managed"` for routines owned by this server.
pub source: String,
/// Unix timestamp (seconds) when the routine was created.
pub created_at: u64,
/// Unix timestamp (seconds) when the routine was last updated.
pub updated_at: u64,
/// Unix timestamp (seconds) when the routine was last manually triggered, if ever.
///
/// Only manual triggers (`trigger_routine`) update this; scheduled cron firings run the built
/// command directly and do not. Accepts the legacy `last_triggered_at` key on deserialize.
#[serde(alias = "last_triggered_at")]
pub last_manual_trigger_at: Option<u64>,
/// Unix timestamp (seconds) when the routine was last fired by its cron schedule, if ever.
///
/// The mirror of [`Routine::last_manual_trigger_at`] for scheduled runs: a manual trigger
/// updates only the manual field, a scheduled firing updates only this one. The host OS crontab
/// line runs `moadim schedule trigger <id>`, and the launch command the daemon spawns appends
/// the Unix timestamp to the gitignored `scheduled.log` at fire time; the daemon reads the last
/// line back on load. The daemon never writes this field directly (it is absent from
/// `routine.toml` and the daemon-owned `state.local.toml`), so re-persisting a routine can't
/// clobber the log.
#[serde(default)]
pub last_scheduled_trigger_at: Option<u64>,
/// Unix timestamp (seconds) until which scheduled (cron) fires are skipped, or `None`.
///
/// Cleared automatically the first time a scheduled fire observes `now >= snoozed_until`, which
/// also runs that fire. Manual triggers ([`crate::routines::svc_trigger`]) ignore this entirely.
/// Set via the `snooze_routine` MCP tool; mutually exclusive with `skip_runs`.
#[serde(default)]
pub snoozed_until: Option<u64>,
/// Number of upcoming scheduled fires still to skip, or `None`.
///
/// Decremented (and cleared once it reaches zero) on each skipped scheduled fire; manual
/// triggers do not consume it. Mutually exclusive with `snoozed_until`.
#[serde(default)]
pub skip_runs: Option<u32>,
/// Whether scheduled and manual firing is paused to conserve resources, independent of
/// [`Routine::enabled`].
///
/// `enabled` is user-owned intent ("I want this routine on/off"); `power_saving` is a
/// system/policy throttle layered on top — both must hold for a firing to launch an agent
/// (`enabled && !power_saving`). Never mutated by `svc_create`/`svc_update` (set via
/// [`crate::routines::svc_set_power_saving`] instead), so it survives a config edit the same
/// way `snoozed_until` and `skip_runs` do. Daemon-owned runtime state: persisted in the
/// gitignored `state.local.toml` sidecar, not the version-controlled `routine.toml`.
#[serde(default)]
pub power_saving: bool,
/// Whether this routine is allowed to run while the host is in system power saving.
///
/// This is user-owned routine metadata, persisted in `routine.toml`: critical maintenance or
/// alerting routines can opt out of the host-level battery/Low Power Mode throttle while normal
/// routines keep the conservative default and skip launches until the host leaves power saving.
#[serde(default)]
pub power_saving_exempt: bool,
/// Number of scheduled/manual runs that finished failed-or-unknown in a row, most recently
/// first — reset to `0` the instant any run succeeds. Daemon-owned runtime state: persisted in
/// the gitignored `state.local.toml` sidecar, not `routine.toml`, mirroring
/// [`Routine::power_saving`]. Counted by `crate::routines::cleanup::circuit_breaker` as each
/// run's outcome becomes durable; see [`Routine::failure_threshold`] and issue #521.
#[serde(default)]
pub consecutive_failures: u32,
/// Human-readable reason this routine was auto-disabled by the failure circuit-breaker, or
/// `None` if it has never tripped one (or a user has since manually re-enabled it, which clears
/// this — see `svc_update`). Distinguishes an auto-disable from a user-initiated one: both flip
/// [`Routine::enabled`] to `false`, but only the former sets a reason. Daemon-owned runtime
/// state, persisted in `state.local.toml` alongside [`Routine::consecutive_failures`].
#[serde(default)]
pub auto_disabled_reason: Option<String>,
/// How long (seconds) a finished run's workbench is retained before auto-cleanup removes it.
/// Caps the cron-derived retention (`min(MAX_TTL_SECS, cron interval)`) lower; it can only
/// shorten, never extend it. `None` uses the cron-derived value. Sessions still running are
/// never reaped. The cap and [`Routine::effective_ttl_secs`] live in the cleanup module. Must
/// be greater than zero when set; `0` is rejected by `svc_create`/`svc_update` (#233).
#[serde(default)]
pub ttl_secs: Option<u64>,
/// Maximum wall-clock seconds a single run may execute before the cleanup watchdog force-kills
/// its (hung) tmux session, after which the workbench is reaped under the normal TTL rules.
/// `None` uses `min(MAX_RUNTIME_SECS, cron interval)`; an explicit value can only lower that. A
/// session still within this bound is never touched. The cap and
/// [`Routine::effective_max_runtime_secs`] live in the cleanup module. Must be greater than
/// zero when set; `0` is rejected by `svc_create`/`svc_update` (#233).
#[serde(default)]
pub max_runtime_secs: Option<u64>,
/// Consecutive failed-or-unknown-outcome runs after which this routine auto-disables — the
/// opt-in failure circuit-breaker (issue #521). `None` or `0` opts out, preserving today's
/// behavior of retrying forever no matter how many times in a row a routine has failed; this is
/// the default so existing routines are unaffected. A "failed" run here is any [`RunStatus`]
/// other than `Success`, including `Unknown` (session gone with no exit code — e.g. force-killed
/// by the max-runtime watchdog): a routine that only ever hangs and gets killed is exactly the
/// resource-wasting loop this breaker exists to stop. Tracked config, written to `routine.toml`
/// like [`Routine::ttl_secs`]/[`Routine::max_runtime_secs`]; unlike those two, `0` is a valid,
/// meaningful value here (opt-out) rather than a rejected one. See
/// `crate::routines::cleanup::circuit_breaker` for where it's enforced.
#[serde(default)]
pub failure_threshold: Option<u32>,
/// Optional per-routine failure notification hooks. Empty means use the global hooks, if any.
#[serde(default)]
pub notifications: FailureNotificationConfig,
/// Free-form labels for grouping and filtering routines (e.g. `"triage"`, `"nightly"`).
/// Defaults to empty; each entry is trimmed and must be non-blank.
#[serde(default)]
pub tags: Vec<String>,
/// Non-secret environment variables injected into the agent's shell session at launch,
/// tracked in `routine.toml`'s `[env]` table (see [`crate::routines::command::build_routine_command`]).
///
/// **Never serialized to JSON** (`skip_serializing`): this field can hold values a routine
/// author committed to the tracked `routine.toml`, and a gitignored `routine.local.toml`
/// sidecar can layer secret overrides on top at launch time (never held here at all — see
/// [`crate::routine_storage::read_local_env`]). Neither belongs in an API response, the UI, or
/// a log line: [`RoutineResponse::env_keys`] surfaces the *names* only, so a client can show
/// what's set without ever seeing a value. Keys must match `[A-Za-z_][A-Za-z0-9_]*` and
/// values must not contain newlines (enforced at create/update time — see
/// `service_validate::validate_env`).
#[serde(default, skip_serializing)]
pub env: std::collections::HashMap<String, String>,
}
/// The IANA name of the host's local timezone (e.g. `"Asia/Jerusalem"`).
///
/// Managed schedules run via the local `crontab`, which interprets cron
/// expressions in this timezone — not UTC. Returns `None` if it can't be
/// determined.
pub fn local_timezone() -> Option<String> {
iana_time_zone::get_timezone().ok()
}
/// Render a human-readable schedule description for `schedule`, appending the
/// timezone in parentheses when known. Returns `None` when the cron expression
/// cannot be parsed.
pub(crate) fn describe_schedule(schedule: &str, timezone: Option<&str>) -> Option<String> {
schedule.parse::<Cron>().ok().map(|cron| {
let desc = cron.describe();
match timezone {
Some(tz) => format!("{desc} ({tz})"),
None => desc,
}
})
}