use std::io::{Read as _, Write as _};
use std::net::SocketAddr;
use std::time::{Duration, SystemTime};
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
pub(crate) const DAEMON_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
pub(crate) const DAEMON_LOG_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
pub(crate) const LOG_ROTATION_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
fn log_rotation_is_due(size: u64, age: Duration) -> bool {
size > DAEMON_LOG_MAX_BYTES || age > DAEMON_LOG_MAX_AGE
}
pub(crate) fn rotate_daemon_log_if_due(log_path: &std::path::Path) {
let Ok(metadata) = std::fs::metadata(log_path) else {
return;
};
let age = metadata
.created()
.ok()
.and_then(|created| SystemTime::now().duration_since(created).ok())
.unwrap_or_default();
if !log_rotation_is_due(metadata.len(), age) {
return;
}
let rotated_path = log_path.with_extension("log.1");
let _ = std::fs::rename(log_path, rotated_path);
}
pub fn write_pid_file() -> anyhow::Result<()> {
let path = crate::paths::pid_file();
let parent = crate::utils::fs_perms::parent_or_err(&path, "pid file")?;
crate::utils::fs_perms::create_private_dir_all(parent)?;
ensure_config_gitignore();
ensure_readme(&crate::paths::config_readme_path(), CONFIG_README);
ensure_readme(&crate::paths::routines_readme_path(), ROUTINES_README);
ensure_readme(&crate::paths::agents_readme_path(), AGENTS_README);
std::fs::write(&path, std::process::id().to_string())?;
Ok(())
}
const CONFIG_README: &str = "\
# moadim config
This is moadim's config directory (`$XDG_CONFIG_HOME/moadim`, default `~/.config/moadim`).
It is git-trackable — commit it (or the parts you want to keep) to version-control your
routines and agents across machines.
- `routines/` — one directory per routine (a scheduled agent); see its own `README.md`.
- `agents/` — the agent registry referenced by routines; see its own `README.md`.
- `machine.local.toml` — this machine's identity, used to match a routine's `machines`
targeting list. Gitignored: it's per-machine, not shared.
- `moadim.pid`, `daemon.log` — daemon-managed runtime files. Gitignored.
- `.gitignore` — seeded and kept up to date by the daemon; append your own patterns freely.
Full docs: https://github.com/moadim-io/daemon
";
const ROUTINES_README: &str = "\
# moadim routines
Each subdirectory here is one routine (a prompt + schedule + agent, run on a cron schedule).
- `<id>/routine.toml` — the schedule, agent, and repositories.
- `<id>/schedule.cron` — a tracked mirror of the cron entry; not functional yet, the daemon
reads `schedule` from `routine.toml`.
- `<id>/prompts/prompt.pure.md` — the prompt you wrote.
- `<id>/prompts/prompt.compiled.local.md` — the composed prompt (repositories preamble +
pure prompt) copied into each run's workbench. Gitignored (`.local.` matches the
`*.local.*` pattern): it's fully derived from `prompt.pure.md` + `routine.toml` and
rewritten on every save.
- `<id>/flags/` — open questions an agent raised mid-run: a gap, bug, edge case, or question
it couldn't resolve.
- `<id>/state.local.toml` — gitignored sidecar holding snooze/skip-runs state.
- `<id>/manual.log`, `<id>/scheduled.log` — gitignored append-only logs recording every
manual / scheduled trigger (one Unix timestamp per line).
Full docs: https://github.com/moadim-io/daemon
";
const AGENTS_README: &str = "\
# moadim agents
The agent registry referenced by routines. Each `<name>.toml` here (e.g. `claude.toml`)
describes one coding agent: the command to launch it and any agent-specific settings.
Routines reference an agent by name in their `routine.toml`.
Full docs: https://github.com/moadim-io/daemon
";
fn ensure_readme(path: &std::path::Path, content: &str) {
if path.exists() {
return;
}
let parent = crate::utils::fs_perms::parent_or_err(path, "readme");
let Some(parent) = parent.ok() else { return };
if crate::utils::fs_perms::create_private_dir_all(parent).is_err() {
return;
}
let _ = std::fs::write(path, content);
}
fn ensure_config_gitignore() {
const REQUIRED: &[&str] = &["*.pid", "*.log", "*.local.*", "*.compiled.*", "run.sh"];
let gitignore = crate::paths::config_gitignore_path();
let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
let lines: Vec<&str> = existing.lines().collect();
let missing: Vec<&str> = REQUIRED
.iter()
.copied()
.filter(|pat| !lines.iter().any(|line| line.trim() == *pat))
.collect();
if missing.is_empty() {
return;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
for pattern in &missing {
content.push_str(pattern);
content.push('\n');
}
let _ = std::fs::write(&gitignore, &content);
}
pub fn clear_pid_file() {
let _ = std::fs::remove_file(crate::paths::pid_file());
}
pub(crate) fn read_pid_file() -> Option<u32> {
let pid = std::fs::read_to_string(crate::paths::pid_file())
.ok()?
.trim()
.parse()
.ok()?;
if process_is_alive(pid) {
Some(pid)
} else {
clear_pid_file();
None
}
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
if pid > i32::MAX as u32 {
return false;
}
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.is_ok_and(|out| out.status.success())
}
#[cfg(not(unix))]
fn process_is_alive(_pid: u32) -> bool {
true
}
pub(super) fn paths_daemon_log() -> String {
crate::paths::daemon_log_file().display().to_string()
}
pub(crate) fn is_running() -> bool {
matches!(http_request("GET", "/api/v1/health"), Ok(200))
}
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(200);
pub(super) fn wait_until(mut check: impl FnMut() -> bool, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
loop {
if check() {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(WAIT_POLL_INTERVAL);
}
}
pub(crate) fn http_request(method: &str, path: &str) -> std::io::Result<u16> {
http_request_with_body(method, path).map(|(status, _)| status)
}
const DATA_OP_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) fn http_request_with_body(method: &str, path: &str) -> std::io::Result<(u16, String)> {
http_request_core(method, path, None, PROBE_TIMEOUT)
}
pub(crate) fn http_request_json(
method: &str,
path: &str,
body: Option<&str>,
) -> std::io::Result<(u16, String)> {
http_request_core(method, path, body, DATA_OP_TIMEOUT)
}
fn http_request_core(
method: &str,
path: &str,
body: Option<&str>,
timeout: Duration,
) -> std::io::Result<(u16, String)> {
let addr_str = super::bind_addr();
let addr: SocketAddr = addr_str.parse().map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid bind address {addr_str:?}: {err}"),
)
})?;
let mut stream = std::net::TcpStream::connect_timeout(&addr, timeout)?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let payload = body.unwrap_or_default();
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {addr_str}\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{payload}",
payload.len()
);
stream.write_all(req.as_bytes())?;
let mut resp = String::new();
let _ = stream.read_to_string(&mut resp);
let status = parse_status_code(&resp).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"no HTTP status line in response",
)
})?;
Ok((status, parse_body(&resp)))
}
pub(crate) fn parse_status_code(resp: &str) -> Option<u16> {
resp.lines().next()?.split_whitespace().nth(1)?.parse().ok()
}
pub(crate) fn parse_body(resp: &str) -> String {
resp.split_once("\r\n\r\n")
.map(|(_, body)| body.to_string())
.unwrap_or_default()
}
pub(super) fn parse_removed_count(body: &str) -> Option<usize> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value.get("removed")?.as_u64().map(|n| n as usize)
}
pub(super) fn parse_freed_bytes(body: &str) -> Option<u64> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value.get("freed_bytes")?.as_u64()
}
pub(super) fn spawn_detached() -> anyhow::Result<u32> {
spawn_detached_with(|cmd| {
cmd.arg("--interactive").env(super::DAEMONIZED_ENV, "1");
})
}
pub fn spawn_restart() -> anyhow::Result<u32> {
spawn_detached_with(|cmd| {
cmd.arg("--background");
})
}
fn spawn_detached_with(configure: impl FnOnce(&mut std::process::Command)) -> anyhow::Result<u32> {
use std::process::{Command as Proc, Stdio};
let exe = crate::utils::process::current_exe()
.map_err(|err| anyhow::anyhow!("resolve current executable path: {err}"))?;
let log_path = crate::paths::daemon_log_file();
let log_parent = crate::utils::fs_perms::parent_or_err(&log_path, "daemon log")?;
crate::utils::fs_perms::create_private_dir_all(log_parent)?;
rotate_daemon_log_if_due(&log_path);
let out = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let err = out.try_clone()?;
let mut cmd = Proc::new(exe);
cmd.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(err));
configure(&mut cmd);
detach(&mut cmd);
#[allow(
clippy::zombie_processes,
reason = "intentionally detached: the child outlives this process and is reaped by the OS/service manager, not waited on here"
)]
let child = cmd.spawn()?;
Ok(child.id())
}
#[cfg(unix)]
fn detach(cmd: &mut std::process::Command) {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
#[cfg(not(unix))]
fn detach(_cmd: &mut std::process::Command) {}
#[cfg(test)]
#[path = "system_tests.rs"]
mod cli_system_tests;