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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Persisted routine types, derived API response, and request bodies.
use chrono::Local;
use croner::Cron;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use super::agents::load_agent_command;
use super::cleanup::tmux_session_prefix_alive;
use super::command::{agent_command_available, setup_step_available, slugify, tmux_session_prefix};
use super::flags::list_flags;
use crate::paths::routine_toml_path;
/// A git repository made available to a routine's agent as prompt context (not cloned by moadim).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)]
pub struct Repository {
/// Git remote URL.
pub repository: String,
/// Branch to use, or `None` for the remote default branch.
#[serde(default)]
pub branch: Option<String>,
}
/// Field to sort a routine listing by.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RoutineSort {
/// Creation time (default).
#[default]
Created,
/// Last update time.
Updated,
/// Title, alphabetically (case-insensitive).
Title,
/// Primary (first) repository URL, alphabetically; routines with no
/// repository sort last.
Repository,
}
/// Sort direction for a routine listing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SortOrder {
/// Ascending (default): oldest / A→Z first.
#[default]
Asc,
/// Descending: newest / Z→A first.
Desc,
}
/// Query parameters for `GET /routines`: filter and sort a routine listing,
/// notably by the repositories a routine references.
#[derive(Debug, Clone, Default, Deserialize, JsonSchema, utoipa::IntoParams)]
#[serde(default)]
#[into_params(parameter_in = Query)]
pub struct RoutineListQuery {
/// Keep only routines with at least one repository whose URL contains this
/// substring (case-insensitive). Empty or absent keeps every routine.
pub repository: Option<String>,
/// Field to sort by (default: creation time).
pub sort: RoutineSort,
/// Sort direction (default: ascending).
pub order: SortOrder,
/// When `true`, only return routines whose `machines` list includes the current machine.
/// Defaults to `false` (return all routines, preserving backwards compatibility).
pub local_only: Option<bool>,
/// When `true`, include each routine's `prompt` in the response. Defaults to `false`:
/// the prompt (often the largest field) is omitted so listings stay compact. Fetch a
/// single routine with `svc_get` / `GET /routines/{id}` to always see its prompt.
pub include_prompts: Option<bool>,
}
/// Query parameters for `GET /routines.ics`: optionally scope the feed to one routine.
#[derive(Debug, Clone, Default, Deserialize, JsonSchema, utoipa::IntoParams)]
#[serde(default)]
#[into_params(parameter_in = Query)]
pub struct IcalFeedQuery {
/// Render only the fire times of the routine with this UUID. Absent (the default)
/// renders every enabled routine. An unknown or disabled id yields a well-formed
/// empty calendar.
pub routine: Option<String>,
}
/// 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,
/// Cron expression defining when the routine runs, evaluated in the host's local
/// system timezone (the OS crontab timezone), not UTC.
pub schedule: 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,
/// `"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,
/// 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>,
/// 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>,
}
/// A [`Routine`] enriched with derived, non-persisted fields for API responses.
#[derive(Debug, Clone, Serialize, JsonSchema, utoipa::ToSchema)]
pub struct RoutineResponse {
/// The underlying routine.
#[serde(flatten)]
pub routine: Routine,
/// `true` if an agent config exists at `~/.config/moadim/agents/<agent>.toml` *and* parses
/// successfully. A present-but-malformed config is silently dropped at crontab-sync time, so
/// it reports `false` here too — file existence alone is not "registered".
pub agent_registered: bool,
/// `true` if the agent config's `command` (e.g. `claude`, `codex`) resolves to an executable
/// on the daemon's `PATH`. Distinct from [`Self::agent_registered`]: a routine can have a
/// present, well-formed agent config yet reference a binary that isn't installed, in which
/// case the cron firing launches a tmux session that dies immediately with "command not
/// found" — a silent no-op indistinguishable from a healthy routine by `agent_registered`
/// alone. `false` whenever the agent config is missing, unreadable, or malformed, since no
/// `command` can be resolved in that case either.
pub agent_command_available: bool,
/// `true` if the agent config has no `setup` step, or that step's first whitespace-delimited
/// token (the interpreter/binary it shells out to, e.g. `python3` for the built-in `claude`
/// agent's workspace-trust seeding) resolves on the daemon's `PATH`. Distinct from
/// [`Self::agent_command_available`]: the agent's own `command` can be installed while its
/// `setup` step still shells out to something that isn't — in which case the launch command's
/// fail-fast guard (`build_routine_command`) aborts the run before the agent ever starts, a
/// failure otherwise invisible to anything checking only `agent_command_available`. `false`
/// here means the run is expected to abort in `setup` rather than actually launch the agent.
/// `false` whenever the agent config is missing, unreadable, or malformed too (mirrors
/// `agent_command_available`'s pessimistic default) — `agent_registered` is the field that
/// distinguishes that case. See issue #404.
pub agent_setup_available: bool,
/// Absolute path to the routine's `routine.toml` file on disk.
pub file_path: String,
/// Human-readable description of the schedule, including the timezone the
/// cron expression is interpreted in, or `null` if it cannot be parsed.
pub schedule_description: Option<String>,
/// IANA name of the local timezone the schedule is interpreted in (e.g.
/// `"Asia/Jerusalem"`), or `null` if it cannot be determined. Cron
/// expressions are evaluated in this timezone, **not** UTC.
pub timezone: Option<String>,
/// Number of open flags raised against this routine (see [`super::flags`]). Surfaced here so
/// listings can badge it without a separate `list_flags` round-trip per routine.
pub flag_count: usize,
/// Unix epoch seconds of this routine's next scheduled fire, in the host's local timezone
/// (matching crontab semantics) — the future counterpart to `last_scheduled_trigger_at`.
/// `None` when disabled, globally locked, or `schedule` is unparseable or has no upcoming
/// fire (e.g. `@reboot`). See issue #369.
pub next_run_at: Option<u64>,
/// `true` if any fire of this routine currently has a live tmux session — i.e. an agent is
/// running right now. Derived by probing for a session under the routine's
/// `moadim-{slug}-` prefix (the same overlap-guard check `svc_trigger` uses, #514), not
/// persisted. `false` whenever no `tmux` binary is available, mirroring the probe's existing
/// best-effort "no tmux, nothing running" stance. See issue #438.
pub is_running: bool,
/// Names (never values) of every environment variable set for this routine, merging the
/// tracked `routine.toml` `[env]` table with the untracked `routine.local.toml` sidecar
/// (secrets), deduplicated and sorted. Lets a client show *what* is configured without ever
/// exposing a secret value over the API (issue #408).
pub env_keys: Vec<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.
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,
}
})
}
/// 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.
fn next_run_at(schedule: &str, enabled: bool) -> Option<u64> {
if !enabled || crate::global_lock::is_globally_locked() {
return None;
}
let union = crate::utils::cron::compiled_union(schedule)?;
let cron = union.iter().next()?.schedule();
let next = cron.after(&Local::now()).next()?;
u64::try_from(next.timestamp()).ok()
}
impl RoutineResponse {
/// Build a response from `routine`, deriving registration status and schedule description.
pub fn from_routine(routine: Routine) -> Self {
let slug = slugify(&routine.title);
// 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 = routine_toml_path(&slug).to_string_lossy().into_owned();
let timezone = local_timezone();
let schedule_description = describe_schedule(&routine.schedule, timezone.as_deref());
let flag_count = list_flags(&slug).len();
let next_run_at = next_run_at(&routine.schedule, 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,
schedule_description,
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,
}
/// Outcome of a single past run, derived from its workbench on disk.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
/// The tmux session is still alive.
Running,
/// The agent process exited `0`.
Success,
/// The agent process exited non-zero.
Failed,
/// The session is gone but no exit code was recorded (killed, crashed before
/// writing it, or from a build predating exit-code capture).
Unknown,
}
/// One past (or in-progress) run of a routine, listed newest-first.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema, utoipa::ToSchema)]
pub struct RunSummary {
/// Workbench directory name (`{slug}-{unix_secs}`); pass to `GET /routines/{id}/runs/{workbench}/log`.
pub workbench: String,
/// Unix seconds the run was triggered.
pub started_at: u64,
/// `started_at` as a human-readable local (daemon machine's timezone) timestamp.
pub started_at_local: String,
/// Unix seconds the run finished (`exit_code` file's mtime), `None` while running or unknown.
pub finished_at: Option<u64>,
/// `finished_at` as a human-readable local timestamp, when finished.
pub finished_at_local: Option<String>,
/// Success/failure/running/unknown, derived from the exit-code file and tmux session liveness.
pub status: RunStatus,
/// Process exit code, when recorded.
pub exit_code: Option<i32>,
/// Unix seconds this run's workbench is due to be reaped (`finished_at` +
/// [`Routine::effective_ttl_secs`]). `None` while the run hasn't finished, or once its
/// workbench is already gone (a run restored from `runs.log` after TTL reaping).
pub retention_expires_at: Option<u64>,
}
/// One past (or in-progress) run, across every routine, listed newest-first — the fleet-wide
/// counterpart to [`RunSummary`] backing an overview "recent runs" view.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema, utoipa::ToSchema)]
pub struct FleetRunSummary {
/// The routine this run belongs to.
pub routine_id: String,
/// The routine's title, at the time of this call (not snapshotted per-run).
pub routine_title: String,
/// Workbench directory name (`{slug}-{unix_secs}`).
pub workbench: String,
/// Unix seconds the run was triggered.
pub started_at: u64,
/// `started_at` as a human-readable local (daemon machine's timezone) timestamp.
pub started_at_local: String,
/// Unix seconds the run finished (`exit_code` file's mtime), `None` while running or unknown.
pub finished_at: Option<u64>,
/// `finished_at` as a human-readable local timestamp, when finished.
pub finished_at_local: Option<String>,
/// Success/failure/running/unknown, derived from the exit-code file and tmux session liveness.
pub status: RunStatus,
/// Process exit code, when recorded.
pub exit_code: Option<i32>,
}
/// Thread-safe shared store of routines keyed by ID.
pub type RoutineStore = Arc<Mutex<HashMap<String, Routine>>>;
/// Create an empty [`RoutineStore`].
#[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
}
// `CreateRoutineRequest` / `UpdateRoutineRequest` live in `model_requests.rs` (split out to keep
// this file under the line cap).
#[path = "model_requests.rs"]
mod model_requests;
pub use model_requests::{CreateRoutineRequest, UpdateRoutineRequest};
#[cfg(test)]
#[path = "model_tests.rs"]
mod model_tests;