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
#![deny(warnings)]
// Forbid `.unwrap()` in production code so a poisoned lock or other panic
// cannot take the daemon down. Tests use `.unwrap()` freely (panicking is the
// desired failure mode there), so the lint is scoped to non-test builds.
#![cfg_attr(not(test), deny(clippy::unwrap_used))]
//! Moadim server binary. Runs the Axum HTTP server with REST and MCP transports.
/// Compile-time build provenance (crate version + git commit/date).
mod build_info;
/// Command-line interface and background-process lifecycle.
mod cli;
/// Data-plane CLI subcommands (clap) that drive the running server over HTTP.
mod commands;
mod error;
/// Server filesystem location helpers.
mod filesystem;
/// Global lock sentinel that halts all routine scheduling and triggers without modifying routine
/// enabled states.
mod global_lock;
/// `log` backend initialization: human-readable by default, opt-in JSON via `MOADIM_LOG_FORMAT`.
mod logging;
/// Machine identity for multi-machine deployments (per-machine routine/job targeting).
mod machine;
/// Axum middleware stack.
mod middlewares;
mod openapi;
/// Filesystem path builders for the jobs directory.
mod paths;
/// Replace an already-running daemon with a fresh process on launch.
mod restart;
/// HTTP and MCP route definitions.
mod routes;
/// TOML-backed routine persistence.
mod routine_storage;
/// Routine (agent-driven job) data model, service layer, and handlers.
mod routines;
/// `moadim install` / `uninstall`: register the daemon as an OS service.
mod service;
/// Forward sync of managed routines into the OS crontab.
mod sync;
/// Shared utility functions.
mod utils;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
match cli::parse(std::env::args().skip(1)) {
cli::Command::Help => {
cli::print_help();
Ok(())
}
cli::Command::Version => {
cli::print_version();
Ok(())
}
cli::Command::Usage(arg) => {
cli::print_usage_error(&arg);
std::process::exit(cli::EXIT_USAGE);
}
cli::Command::Status { json, wait_secs } => {
std::process::exit(cli::status(json, wait_secs)?)
}
cli::Command::Cleanup { json } => std::process::exit(cli::cleanup(json)?),
cli::Command::Stop { json, quiet } => std::process::exit(cli::stop(json, quiet)?),
cli::Command::Trigger { id } => std::process::exit(cli::trigger(&id)?),
cli::Command::Logs { id } => std::process::exit(cli::logs(&id)?),
cli::Command::Background => cli::run_background(),
cli::Command::Restart {
json,
quiet,
interactive: false,
} => cli::restart(json, quiet),
cli::Command::Restart {
interactive: true, ..
} => {
cli::stop_existing_for_restart(false)?;
run_server().await
}
cli::Command::Install => service::install(),
cli::Command::Uninstall => uninstall(),
cli::Command::Completions(shell) => std::process::exit(cli::completions(shell.as_deref())),
cli::Command::Data(args) => std::process::exit(commands::run(args)),
cli::Command::Machine(args) => std::process::exit(machine::run(&args)),
cli::Command::Foreground => {
cli::ensure_not_running_for_foreground()?;
run_server().await
}
}
}
/// `moadim uninstall`: tear down everything install/usage added — the OS service
/// registration AND the managed crontab block the daemon wrote. Without the
/// crontab step, `cron` keeps firing routines against a removed daemon (#380).
///
/// Both steps are best-effort and independent: a failure (or unsupported-platform
/// error) in the service step is reported but does not skip the crontab cleanup,
/// and the command still succeeds so a partial install can always be torn down.
fn uninstall() -> anyhow::Result<()> {
if let Err(err) = service::uninstall() {
eprintln!("moadim: service uninstall step failed: {err}");
}
match sync::clear_managed_crontab_blocks() {
Ok(0) => println!("moadim: no managed crontab entries to remove"),
Ok(1) => println!("moadim: removed 1 managed crontab entry"),
Ok(n) => println!("moadim: removed {n} managed crontab entries"),
Err(err) => eprintln!("moadim: crontab cleanup failed: {err}"),
}
Ok(())
}
/// Run the HTTP/MCP/UI server in the foreground until a termination signal or the `/shutdown` route
/// stops it. Records this process's PID so `moadim stop`/`status` can find it, and clears it on exit.
async fn run_server() -> anyhow::Result<()> {
// Initialize the logging backend so the `log::*` call sites across the daemon actually emit;
// without an installed backend the `log` facade is a silent no-op and startup, crontab-sync,
// and HTTP-request diagnostics are dropped. A detached daemon redirects stderr to its log
// file, so these lines land there with timestamps and levels. See `logging` for format
// selection (`MOADIM_LOG_FORMAT`) and level filtering (`RUST_LOG`).
logging::init();
// tmux is a hard runtime dependency: every routine agent launches via `tmux new-session`. When
// it is missing the launch command silently no-ops (the statements are `;`-joined), so warn
// loudly at startup rather than letting scheduled runs vanish. Also surfaced in `GET /health`.
if !routines::tmux_available() {
log::warn!(
"tmux not found on PATH; scheduled routine runs will silently fail to launch their \
agent. Install tmux (e.g. `brew install tmux` or `apt install tmux`)."
);
}
// python3 is a hard dependency of the built-in `claude` agent's `setup` step (workspace-trust
// seeding). When it is missing, that step fails and the routine's agent never actually
// launches, yet nothing else surfaces the failure — the routine still shows a healthy status
// (issue #404). Warn at startup, same as the tmux check above; also surfaced in `GET /health`.
if !routines::agent_command_available("python3") {
log::warn!(
"python3 not found on PATH; the built-in `claude` agent's setup step requires it to \
pre-seed workspace-trust state, so routines using that agent will silently fail to \
launch. Install python3, or use a different agent."
);
}
routines::ensure_default_agents();
// Rename any prompt.txt sidecars to prompt.md before the crontab resync; otherwise the first
// cron trigger after upgrade would fail on the launch command's `cp prompt.compiled.local.md`
// step.
routine_storage::migrate_prompt_files();
// Move each routine's prompt file(s) into its prompts/ subfolder, and extract the raw prompt
// out of routine.toml into prompts/prompt.pure.md. Must run before migrate_routine_dirs and
// load_store, which both read the prompt from the new sidecar location.
routine_storage::migrate_prompts_to_subfolder();
// Rename the compiled-prompt sidecar from the legacy prompt.compiled.md to
// prompt.compiled.local.md so it matches the *.local.* gitignore pattern instead of relying on
// an explicit entry (issue #1046). Must run after migrate_prompts_to_subfolder (which is what
// lands the legacy filename in prompts/) and before load_store.
routine_storage::migrate_compiled_prompt_filename();
// Move legacy UUID-named routine dirs to the current slug-based layout before loading, so the
// store reflects the canonical dirs the crontab sync and the launch command's
// `cp prompt.compiled.local.md` both target.
routine_storage::migrate_routine_dirs();
// Migrate per-routine trigger timestamps from legacy TOML sidecars (scheduled.local.toml,
// last_manual_trigger_at in state.local.toml) into the new append-only log files
// (scheduled.log, manual.log). Must run before load_store so the first load already reads from
// the log files.
routine_storage::migrate_trigger_logs();
let routines = routine_storage::load_store();
// Seed any missing built-in default routines (e.g. the daily moadim cargo update check) so a
// fresh install ships with them, and a default deleted while stopped is restored. Existing
// routines are never overwritten. Must run before the crontab sync so the defaults schedule.
routines::ensure_default_routines(&routines);
// Re-persist so every routine has its routine.toml + schedule.cron + prompts/ sidecars in the
// slug dir (and any stale legacy run.sh is removed), healing dirs left without a prompt or
// cron file (otherwise the launch command's `cp prompt.compiled.local.md` fails and the agent
// launches with an empty prompt).
routine_storage::repersist_routines(&routines);
// Re-sync routines to the crontab on startup; otherwise a block that went stale (e.g. emptied
// by an earlier run before agent configs existed) would never be regenerated until the next
// create/update/delete, leaving scheduled routines silently un-fired.
if let Err(err) = sync::routines::sync_routines_to_crontab(&routines) {
log::warn!("startup crontab sync failed: {err}");
}
// The REST/MCP API has no authentication (issue #504): binding to a non-loopback address
// exposes unauthenticated routine CRUD to anyone who can reach it. That must never happen
// by accident (issue #253), so a non-loopback bind is refused unless the operator explicitly
// opts in with MOADIM_ALLOW_REMOTE=1 — and even then we warn loudly at startup, same as the
// tmux/python3 checks above, rather than letting this go unnoticed.
let bind_addr = cli::bind_addr();
match cli::classify_bind(&bind_addr, cli::remote_bind_allowed()) {
cli::BindDecision::Loopback => {}
cli::BindDecision::RemoteAllowed => {
log::warn!(
"moadim is binding to {bind_addr}, which is not loopback-only; the REST/MCP API \
has no authentication, so anyone who can reach this address can create, modify, \
or delete routines, trigger one to run an agent with your credentials, or shut \
the daemon down. Continuing because MOADIM_ALLOW_REMOTE=1 is set (see #253) — \
restrict network access to this port (firewall/VPN/reverse proxy) if you don't \
fully trust that network."
);
}
cli::BindDecision::RemoteRefused => {
anyhow::bail!(
"refusing to bind to {bind_addr}: it is not loopback-only, and the REST/MCP API \
has no authentication — anyone who can reach this address could create, modify, \
or delete routines, trigger one to run an agent with your credentials, or shut \
the daemon down. Set MOADIM_ALLOW_REMOTE=1 to start anyway if you understand and \
accept that risk (see the README's Bind address section and issue #253)."
);
}
}
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
cli::write_pid_file()?;
let result =
routes::http::run_with_listener_until(routines, listener, termination_signal()).await;
cli::clear_pid_file();
result
}
/// Resolves when the process receives a termination signal (SIGINT/Ctrl-C, or SIGTERM on Unix),
/// driving a graceful shutdown so the pid file is cleared even when stopped from the terminal.
async fn termination_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut term) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}