Skip to main content

ralph/cli/
webhook.rs

1//! Webhook CLI commands.
2//!
3//! Responsibilities:
4//! - Provide `ralph webhook test` command for testing webhook configuration.
5//! - Provide `ralph webhook status` for diagnostics snapshots.
6//! - Provide `ralph webhook replay` for explicit bounded failure replay.
7//!
8//! Does NOT handle:
9//! - Webhook configuration management (use config files).
10//! - Direct HTTP delivery internals (delegated to `crate::webhook`).
11
12use anyhow::{Result, bail};
13use clap::{Args, Subcommand, ValueEnum};
14
15#[derive(Args)]
16pub struct WebhookArgs {
17    #[command(subcommand)]
18    pub command: WebhookCommand,
19}
20
21#[derive(Subcommand)]
22pub enum WebhookCommand {
23    /// Test webhook configuration by sending a test event.
24    #[command(
25        after_long_help = "Examples:\n  ralph webhook test\n  ralph webhook test --event task_created\n  ralph webhook test --event phase_started --print-json\n  ralph webhook test --url https://example.com/webhook"
26    )]
27    Test(TestArgs),
28    /// Show webhook delivery diagnostics and recent failures.
29    #[command(
30        after_long_help = "Examples:\n  ralph webhook status\n  ralph webhook status --recent 10\n  ralph webhook status --format json"
31    )]
32    Status(StatusArgs),
33    /// Replay failed webhook deliveries with explicit targeting.
34    #[command(
35        after_long_help = "Examples:\n  ralph webhook replay --id wf-1700000000-1 --dry-run\n  ralph webhook replay --event task_completed --limit 5\n  ralph webhook replay --task-id RQ-0814 --max-replay-attempts 3"
36    )]
37    Replay(ReplayArgs),
38}
39
40#[derive(Debug, Clone, Copy, ValueEnum, Default)]
41pub enum WebhookStatusFormat {
42    #[default]
43    Text,
44    Json,
45}
46
47#[derive(Args)]
48pub struct StatusArgs {
49    /// Output format.
50    #[arg(long, value_enum, default_value_t = WebhookStatusFormat::Text)]
51    pub format: WebhookStatusFormat,
52
53    /// Number of recent failure records to include.
54    #[arg(long, default_value_t = 20)]
55    pub recent: usize,
56}
57
58#[derive(Args)]
59pub struct ReplayArgs {
60    /// Replay a specific failure record ID (repeatable).
61    #[arg(long = "id")]
62    pub ids: Vec<String>,
63
64    /// Replay failures matching an event name (e.g., task_completed).
65    #[arg(long)]
66    pub event: Option<String>,
67
68    /// Replay failures matching a task ID.
69    #[arg(long)]
70    pub task_id: Option<String>,
71
72    /// Maximum matched failures to consider for this invocation.
73    #[arg(long, default_value_t = 20)]
74    pub limit: usize,
75
76    /// Maximum allowed replay attempts per failure record.
77    #[arg(long, default_value_t = 3)]
78    pub max_replay_attempts: u32,
79
80    /// Preview replay candidates without enqueueing.
81    #[arg(long)]
82    pub dry_run: bool,
83}
84
85#[derive(Args)]
86pub struct TestArgs {
87    /// Event type to send (default: task_created).
88    /// Supported: task_created, task_started, task_completed, task_failed, task_status_changed,
89    ///            loop_started, loop_stopped, phase_started, phase_completed
90    #[arg(short, long, default_value = "task_created")]
91    pub event: String,
92
93    /// Override webhook URL (uses config if not specified).
94    #[arg(short, long)]
95    pub url: Option<String>,
96
97    /// Task ID to use in test payload (default: TEST-0001).
98    #[arg(long, default_value = "TEST-0001")]
99    pub task_id: String,
100
101    /// Task title to use in test payload.
102    #[arg(long, default_value = "Test webhook notification")]
103    pub task_title: String,
104
105    /// Print the JSON payload that would be sent (without sending).
106    #[arg(long)]
107    pub print_json: bool,
108
109    /// Pretty-print the JSON payload (only used with --print-json).
110    #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
111    pub pretty: bool,
112}
113
114pub fn handle_webhook(args: &WebhookArgs, resolved: &crate::config::Resolved) -> Result<()> {
115    match &args.command {
116        WebhookCommand::Test(test_args) => handle_test(test_args, resolved),
117        WebhookCommand::Status(status_args) => handle_status(status_args, resolved),
118        WebhookCommand::Replay(replay_args) => handle_replay(replay_args, resolved),
119    }
120}
121
122fn handle_test(args: &TestArgs, resolved: &crate::config::Resolved) -> Result<()> {
123    use crate::contracts::WebhookEventSubscription;
124    use crate::timeutil;
125    use crate::webhook::{WebhookContext, WebhookEventType, WebhookPayload, send_webhook_payload};
126    use std::str::FromStr;
127
128    let mut config = resolved.config.agent.webhook.clone();
129
130    // Override URL if provided
131    if let Some(url) = &args.url {
132        config.url = Some(url.clone());
133    }
134
135    // Ensure enabled for test
136    config.enabled = Some(true);
137
138    // For non-task events, temporarily enable them for this test
139    // This ensures new events can be tested without modifying config
140    if config.events.is_none() {
141        // Parse the event string into WebhookEventSubscription
142        let event_sub: WebhookEventSubscription =
143            serde_json::from_str(&format!("\"{}\"", args.event))
144                .map_err(|e| anyhow::anyhow!("Invalid event type '{}': {}", args.event, e))?;
145        config.events = Some(vec![event_sub]);
146    }
147
148    // Parse event type using FromStr
149    let event_type = WebhookEventType::from_str(&args.event)?;
150
151    // Build the payload
152    let now = timeutil::now_utc_rfc3339()?;
153
154    let note = Some("Test webhook from ralph webhook test command".to_string());
155
156    let (task_id, task_title, previous_status, current_status, context) = match event_type {
157        WebhookEventType::LoopStarted | WebhookEventType::LoopStopped => {
158            // Loop events don't have task association
159            (
160                None,
161                None,
162                None,
163                None,
164                WebhookContext {
165                    repo_root: Some(resolved.repo_root.display().to_string()),
166                    branch: crate::git::current_branch(&resolved.repo_root).ok(),
167                    commit: crate::session::get_git_head_commit(&resolved.repo_root),
168                    ..Default::default()
169                },
170            )
171        }
172        WebhookEventType::PhaseStarted | WebhookEventType::PhaseCompleted => {
173            // Phase events have task context plus phase metadata
174            (
175                Some(args.task_id.clone()),
176                Some(args.task_title.clone()),
177                None,
178                None,
179                WebhookContext {
180                    runner: Some("claude".to_string()),
181                    model: Some("sonnet".to_string()),
182                    phase: Some(2),
183                    phase_count: Some(3),
184                    duration_ms: Some(15000),
185                    repo_root: Some(resolved.repo_root.display().to_string()),
186                    branch: crate::git::current_branch(&resolved.repo_root).ok(),
187                    commit: crate::session::get_git_head_commit(&resolved.repo_root),
188                    ci_gate: Some("passed".to_string()),
189                },
190            )
191        }
192        WebhookEventType::TaskStarted => (
193            Some(args.task_id.clone()),
194            Some(args.task_title.clone()),
195            Some("todo".to_string()),
196            Some("doing".to_string()),
197            WebhookContext::default(),
198        ),
199        WebhookEventType::TaskCompleted => (
200            Some(args.task_id.clone()),
201            Some(args.task_title.clone()),
202            Some("doing".to_string()),
203            Some("done".to_string()),
204            WebhookContext::default(),
205        ),
206        WebhookEventType::TaskFailed => (
207            Some(args.task_id.clone()),
208            Some(args.task_title.clone()),
209            Some("doing".to_string()),
210            Some("rejected".to_string()),
211            WebhookContext::default(),
212        ),
213        WebhookEventType::TaskStatusChanged => (
214            Some(args.task_id.clone()),
215            Some(args.task_title.clone()),
216            Some("todo".to_string()),
217            Some("doing".to_string()),
218            WebhookContext::default(),
219        ),
220        _ => {
221            // Task events
222            (
223                Some(args.task_id.clone()),
224                Some(args.task_title.clone()),
225                None,
226                None,
227                WebhookContext::default(),
228            )
229        }
230    };
231
232    let payload = WebhookPayload {
233        event: event_type.as_str().to_string(),
234        timestamp: now.clone(),
235        task_id,
236        task_title,
237        previous_status,
238        current_status,
239        note,
240        context,
241    };
242
243    // Print JSON if requested
244    if args.print_json {
245        let json = if args.pretty {
246            serde_json::to_string_pretty(&payload)?
247        } else {
248            serde_json::to_string(&payload)?
249        };
250        println!("{}", json);
251        return Ok(());
252    }
253
254    // Validate URL exists before sending
255    if config.url.is_none() || config.url.as_ref().unwrap().is_empty() {
256        bail!("Webhook URL not configured. Set it in config or use --url.");
257    }
258
259    println!("Sending test webhook...");
260    println!("  URL: {}", config.url.as_ref().unwrap());
261    println!("  Event: {}", args.event);
262    if payload.task_id.is_some() {
263        println!("  Task ID: {}", args.task_id);
264    }
265
266    send_webhook_payload(payload, &config);
267
268    println!("Test webhook sent successfully.");
269    Ok(())
270}
271
272fn handle_status(args: &StatusArgs, resolved: &crate::config::Resolved) -> Result<()> {
273    let diagnostics = crate::webhook::diagnostics_snapshot(
274        &resolved.repo_root,
275        &resolved.config.agent.webhook,
276        args.recent,
277    )?;
278
279    match args.format {
280        WebhookStatusFormat::Json => {
281            println!("{}", serde_json::to_string_pretty(&diagnostics)?);
282        }
283        WebhookStatusFormat::Text => {
284            println!("Webhook delivery diagnostics");
285            println!("  queue depth: {}", diagnostics.queue_depth);
286            println!("  queue capacity: {}", diagnostics.queue_capacity);
287            println!("  queue policy: {:?}", diagnostics.queue_policy);
288            println!("  enqueued total: {}", diagnostics.enqueued_total);
289            println!("  delivered total: {}", diagnostics.delivered_total);
290            println!("  failed total: {}", diagnostics.failed_total);
291            println!("  dropped total: {}", diagnostics.dropped_total);
292            println!(
293                "  retry attempts total: {}",
294                diagnostics.retry_attempts_total
295            );
296            println!("  failure store: {}", diagnostics.failure_store_path);
297
298            if diagnostics.recent_failures.is_empty() {
299                println!("  recent failures: none");
300            } else {
301                println!("  recent failures:");
302                for record in diagnostics.recent_failures {
303                    let task = record.task_id.as_deref().unwrap_or("-");
304                    println!(
305                        "    {} event={} task={} attempts={} replay_count={} at={} error={}",
306                        record.id,
307                        record.event,
308                        task,
309                        record.attempts,
310                        record.replay_count,
311                        record.failed_at,
312                        record.error
313                    );
314                }
315            }
316        }
317    }
318
319    Ok(())
320}
321
322fn handle_replay(args: &ReplayArgs, resolved: &crate::config::Resolved) -> Result<()> {
323    if args.ids.is_empty() && args.event.is_none() && args.task_id.is_none() {
324        bail!("Refusing broad replay. Provide --id, --event, or --task-id.");
325    }
326
327    let selector = crate::webhook::ReplaySelector {
328        ids: args.ids.clone(),
329        event: args.event.clone(),
330        task_id: args.task_id.clone(),
331        limit: args.limit,
332        max_replay_attempts: args.max_replay_attempts,
333    };
334
335    let report = crate::webhook::replay_failed_deliveries(
336        &resolved.repo_root,
337        &resolved.config.agent.webhook,
338        &selector,
339        args.dry_run,
340    )?;
341
342    if report.dry_run {
343        println!(
344            "Dry-run: matched {}, eligible {}, skipped over replay cap {}",
345            report.matched_count, report.eligible_count, report.skipped_max_replay_attempts
346        );
347    } else {
348        println!(
349            "Replay complete: matched {}, replayed {}, skipped over replay cap {}, skipped enqueue failures {}",
350            report.matched_count,
351            report.replayed_count,
352            report.skipped_max_replay_attempts,
353            report.skipped_enqueue_failures
354        );
355    }
356
357    if report.candidates.is_empty() {
358        println!("No matching failure records.");
359    } else {
360        println!("Candidates:");
361        for candidate in report.candidates {
362            let task = candidate.task_id.as_deref().unwrap_or("-");
363            println!(
364                "  {} event={} task={} attempts={} replay_count={} eligible={} at={}",
365                candidate.id,
366                candidate.event,
367                task,
368                candidate.attempts,
369                candidate.replay_count,
370                candidate.eligible_for_replay,
371                candidate.failed_at
372            );
373        }
374    }
375
376    Ok(())
377}