Skip to main content

ralph/cli/
daemon.rs

1//! `ralph daemon ...` command group for background service management.
2//!
3//! Responsibilities:
4//! - Define clap structures for daemon commands and flags.
5//! - Route daemon subcommands to the daemon command implementations.
6//!
7//! Not handled here:
8//! - Daemon process management (see `crate::commands::daemon`).
9//! - Queue execution logic (see `crate::commands::run`).
10//!
11//! Invariants/assumptions:
12//! - Daemon is Unix-only (Windows uses different service mechanisms).
13//! - Daemon uses a dedicated lock separate from the queue lock.
14
15use anyhow::Result;
16use clap::{Args, Subcommand, builder::PossibleValuesParser};
17
18use crate::{commands::daemon as daemon_cmd, config};
19
20pub fn handle_daemon(cmd: DaemonCommand) -> Result<()> {
21    let resolved = config::resolve_from_cwd()?;
22    match cmd {
23        DaemonCommand::Start(args) => daemon_cmd::start(&resolved, args),
24        DaemonCommand::Stop => daemon_cmd::stop(&resolved),
25        DaemonCommand::Status => daemon_cmd::status(&resolved),
26        DaemonCommand::Serve(args) => daemon_cmd::serve(&resolved, args),
27        DaemonCommand::Logs(args) => daemon_cmd::logs(&resolved, args),
28    }
29}
30
31#[derive(Args)]
32pub struct DaemonArgs {
33    #[command(subcommand)]
34    pub command: DaemonCommand,
35}
36
37#[derive(Subcommand)]
38pub enum DaemonCommand {
39    /// Start Ralph as a background daemon (continuous execution mode).
40    #[command(
41        about = "Start Ralph as a background daemon",
42        after_long_help = "Examples:
43 ralph daemon start
44 ralph daemon start --empty-poll-ms 5000
45 ralph daemon start --wait-poll-ms 500"
46    )]
47    Start(DaemonStartArgs),
48    /// Request the daemon to stop gracefully.
49    #[command(
50        about = "Request the daemon to stop",
51        after_long_help = "Examples:
52 ralph daemon stop"
53    )]
54    Stop,
55    /// Show daemon status (running, stopped, or stale).
56    #[command(
57        about = "Show daemon status",
58        after_long_help = "Examples:
59 ralph daemon status"
60    )]
61    Status,
62    /// Internal: Run the daemon serve loop (do not use directly).
63    #[command(hide = true)]
64    Serve(DaemonServeArgs),
65    /// Inspect daemon logs with filtering and follow mode.
66    #[command(
67        about = "Inspect daemon logs",
68        after_long_help = "Examples:
69 ralph daemon logs
70 ralph daemon logs --tail 50
71 ralph daemon logs --follow --tail 200
72 ralph daemon logs --since 'in 10 minutes'
73 ralph daemon logs --level error --contains \"webhook\"
74 ralph daemon logs --json --since 2026-02-01T00:00:00Z"
75    )]
76    Logs(DaemonLogsArgs),
77}
78
79#[derive(Args)]
80pub struct DaemonStartArgs {
81    /// Poll interval in milliseconds while waiting for new tasks when queue is empty
82    /// (default: 30000, min: 50).
83    #[arg(
84        long,
85        default_value_t = 30_000,
86        value_parser = clap::value_parser!(u64).range(50..)
87    )]
88    pub empty_poll_ms: u64,
89
90    /// Poll interval in milliseconds while waiting for blocked tasks (default: 1000, min: 50).
91    #[arg(
92        long,
93        default_value_t = 1_000,
94        value_parser = clap::value_parser!(u64).range(50..)
95    )]
96    pub wait_poll_ms: u64,
97
98    /// Notify when queue becomes unblocked (desktop + webhook).
99    #[arg(long)]
100    pub notify_when_unblocked: bool,
101}
102
103#[derive(Args)]
104pub struct DaemonServeArgs {
105    /// Poll interval in milliseconds while waiting for new tasks when queue is empty
106    /// (default: 30000, min: 50).
107    #[arg(
108        long,
109        default_value_t = 30_000,
110        value_parser = clap::value_parser!(u64).range(50..)
111    )]
112    pub empty_poll_ms: u64,
113
114    /// Poll interval in milliseconds while waiting for blocked tasks (default: 1000, min: 50).
115    #[arg(
116        long,
117        default_value_t = 1_000,
118        value_parser = clap::value_parser!(u64).range(50..)
119    )]
120    pub wait_poll_ms: u64,
121
122    /// Notify when queue becomes unblocked (desktop + webhook).
123    #[arg(long)]
124    pub notify_when_unblocked: bool,
125}
126
127#[derive(Args)]
128pub struct DaemonLogsArgs {
129    /// Show the last N lines from the daemon log.
130    #[arg(short = 'n', long = "tail", default_value_t = 100)]
131    pub tail: usize,
132
133    /// Follow daemon log output as lines are appended.
134    #[arg(short, long)]
135    pub follow: bool,
136
137    /// Only show lines at or after this timestamp (RFC3339) or relative expression.
138    #[arg(long, value_name = "DURATION_OR_TIMESTAMP", value_parser = parse_daemon_log_since)]
139    pub since: Option<time::OffsetDateTime>,
140
141    /// Filter by level (trace, debug, info, warn, error, fatal, critical).
142    #[arg(long = "level", value_name = "LEVEL", value_parser = PossibleValuesParser::new([
143        "trace",
144        "debug",
145        "info",
146        "warn",
147        "error",
148        "fatal",
149        "critical"
150    ]))]
151    pub level: Option<String>,
152
153    /// Show only lines containing this substring.
154    #[arg(long)]
155    pub contains: Option<String>,
156
157    /// Emit machine-readable JSON objects, one per output line.
158    #[arg(long)]
159    pub json: bool,
160}
161
162fn parse_daemon_log_since(raw: &str) -> anyhow::Result<time::OffsetDateTime> {
163    crate::timeutil::parse_relative_time(raw)
164        .and_then(|value| crate::timeutil::parse_rfc3339(&value))
165}
166
167#[cfg(test)]
168mod tests {
169    use clap::Parser;
170
171    use crate::cli::Cli;
172
173    #[test]
174    fn daemon_start_wait_poll_ms_rejects_below_minimum() {
175        let args = vec!["ralph", "daemon", "start", "--wait-poll-ms", "10"];
176        let result = Cli::try_parse_from(args);
177        assert!(
178            result.is_err(),
179            "daemon start --wait-poll-ms should reject values below 50"
180        );
181    }
182
183    #[test]
184    fn daemon_start_empty_poll_ms_rejects_below_minimum() {
185        let args = vec!["ralph", "daemon", "start", "--empty-poll-ms", "10"];
186        let result = Cli::try_parse_from(args);
187        assert!(
188            result.is_err(),
189            "daemon start --empty-poll-ms should reject values below 50"
190        );
191    }
192
193    #[test]
194    fn daemon_start_wait_poll_ms_accepts_minimum() {
195        let args = vec!["ralph", "daemon", "start", "--wait-poll-ms", "50"];
196        let result = Cli::try_parse_from(args);
197        assert!(
198            result.is_ok(),
199            "daemon start --wait-poll-ms should accept 50"
200        );
201    }
202
203    #[test]
204    fn daemon_start_empty_poll_ms_accepts_minimum() {
205        let args = vec!["ralph", "daemon", "start", "--empty-poll-ms", "50"];
206        let result = Cli::try_parse_from(args);
207        assert!(
208            result.is_ok(),
209            "daemon start --empty-poll-ms should accept 50"
210        );
211    }
212}