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    crate::contracts::validate_webhook_settings(&config)?;
139
140    // For non-task events, temporarily enable them for this test
141    // This ensures new events can be tested without modifying config
142    if config.events.is_none() {
143        // Parse the event string into WebhookEventSubscription
144        let event_sub: WebhookEventSubscription =
145            serde_json::from_str(&format!("\"{}\"", args.event))
146                .map_err(|e| anyhow::anyhow!("Invalid event type '{}': {}", args.event, e))?;
147        config.events = Some(vec![event_sub]);
148    }
149
150    // Parse event type using FromStr
151    let event_type = WebhookEventType::from_str(&args.event)?;
152
153    // Build the payload
154    let now = timeutil::now_utc_rfc3339()?;
155
156    let note = Some("Test webhook from ralph webhook test command".to_string());
157
158    let (task_id, task_title, previous_status, current_status, context) = match event_type {
159        WebhookEventType::LoopStarted | WebhookEventType::LoopStopped => {
160            // Loop events don't have task association
161            (
162                None,
163                None,
164                None,
165                None,
166                WebhookContext {
167                    repo_root: Some(resolved.repo_root.display().to_string()),
168                    branch: crate::git::current_branch(&resolved.repo_root).ok(),
169                    commit: crate::session::get_git_head_commit(&resolved.repo_root),
170                    ..Default::default()
171                },
172            )
173        }
174        WebhookEventType::PhaseStarted | WebhookEventType::PhaseCompleted => {
175            // Phase events have task context plus phase metadata
176            (
177                Some(args.task_id.clone()),
178                Some(args.task_title.clone()),
179                None,
180                None,
181                WebhookContext {
182                    runner: Some("claude".to_string()),
183                    model: Some("sonnet".to_string()),
184                    phase: Some(2),
185                    phase_count: Some(3),
186                    duration_ms: Some(15000),
187                    repo_root: Some(resolved.repo_root.display().to_string()),
188                    branch: crate::git::current_branch(&resolved.repo_root).ok(),
189                    commit: crate::session::get_git_head_commit(&resolved.repo_root),
190                    ci_gate: Some("passed".to_string()),
191                },
192            )
193        }
194        WebhookEventType::TaskStarted => (
195            Some(args.task_id.clone()),
196            Some(args.task_title.clone()),
197            Some("todo".to_string()),
198            Some("doing".to_string()),
199            WebhookContext::default(),
200        ),
201        WebhookEventType::TaskCompleted => (
202            Some(args.task_id.clone()),
203            Some(args.task_title.clone()),
204            Some("doing".to_string()),
205            Some("done".to_string()),
206            WebhookContext::default(),
207        ),
208        WebhookEventType::TaskFailed => (
209            Some(args.task_id.clone()),
210            Some(args.task_title.clone()),
211            Some("doing".to_string()),
212            Some("rejected".to_string()),
213            WebhookContext::default(),
214        ),
215        WebhookEventType::TaskStatusChanged => (
216            Some(args.task_id.clone()),
217            Some(args.task_title.clone()),
218            Some("todo".to_string()),
219            Some("doing".to_string()),
220            WebhookContext::default(),
221        ),
222        _ => {
223            // Task events
224            (
225                Some(args.task_id.clone()),
226                Some(args.task_title.clone()),
227                None,
228                None,
229                WebhookContext::default(),
230            )
231        }
232    };
233
234    let payload = WebhookPayload {
235        event: event_type.as_str().to_string(),
236        timestamp: now.clone(),
237        task_id,
238        task_title,
239        previous_status,
240        current_status,
241        note,
242        context,
243    };
244
245    // Print JSON if requested
246    if args.print_json {
247        let json = if args.pretty {
248            serde_json::to_string_pretty(&payload)?
249        } else {
250            serde_json::to_string(&payload)?
251        };
252        println!("{}", json);
253        return Ok(());
254    }
255
256    // Validate URL exists before sending
257    if config.url.is_none() || config.url.as_ref().unwrap().is_empty() {
258        bail!("Webhook URL not configured. Set it in config or use --url.");
259    }
260
261    println!("Sending test webhook...");
262    println!("  URL: {}", config.url.as_ref().unwrap());
263    println!("  Event: {}", args.event);
264    if payload.task_id.is_some() {
265        println!("  Task ID: {}", args.task_id);
266    }
267
268    send_webhook_payload(payload, &config);
269
270    println!("Test webhook sent successfully.");
271    Ok(())
272}
273
274fn handle_status(args: &StatusArgs, resolved: &crate::config::Resolved) -> Result<()> {
275    let diagnostics = crate::webhook::diagnostics_snapshot(
276        &resolved.repo_root,
277        &resolved.config.agent.webhook,
278        args.recent,
279    )?;
280
281    match args.format {
282        WebhookStatusFormat::Json => {
283            println!("{}", serde_json::to_string_pretty(&diagnostics)?);
284        }
285        WebhookStatusFormat::Text => {
286            println!("Webhook delivery diagnostics");
287            println!("  queue depth: {}", diagnostics.queue_depth);
288            println!("  queue capacity: {}", diagnostics.queue_capacity);
289            println!("  queue policy: {:?}", diagnostics.queue_policy);
290            println!("  enqueued total: {}", diagnostics.enqueued_total);
291            println!("  delivered total: {}", diagnostics.delivered_total);
292            println!("  failed total: {}", diagnostics.failed_total);
293            println!("  dropped total: {}", diagnostics.dropped_total);
294            println!(
295                "  retry attempts total: {}",
296                diagnostics.retry_attempts_total
297            );
298            println!("  failure store: {}", diagnostics.failure_store_path);
299
300            if diagnostics.recent_failures.is_empty() {
301                println!("  recent failures: none");
302            } else {
303                println!("  recent failures:");
304                for record in diagnostics.recent_failures {
305                    let task = record.task_id.as_deref().unwrap_or("-");
306                    println!(
307                        "    {} event={} task={} attempts={} replay_count={} at={} error={}",
308                        record.id,
309                        record.event,
310                        task,
311                        record.attempts,
312                        record.replay_count,
313                        record.failed_at,
314                        record.error
315                    );
316                }
317            }
318        }
319    }
320
321    Ok(())
322}
323
324fn handle_replay(args: &ReplayArgs, resolved: &crate::config::Resolved) -> Result<()> {
325    if args.ids.is_empty() && args.event.is_none() && args.task_id.is_none() {
326        bail!("Refusing broad replay. Provide --id, --event, or --task-id.");
327    }
328
329    let selector = crate::webhook::ReplaySelector {
330        ids: args.ids.clone(),
331        event: args.event.clone(),
332        task_id: args.task_id.clone(),
333        limit: args.limit,
334        max_replay_attempts: args.max_replay_attempts,
335    };
336
337    let report = crate::webhook::replay_failed_deliveries(
338        &resolved.repo_root,
339        &resolved.config.agent.webhook,
340        &selector,
341        args.dry_run,
342    )?;
343
344    if report.dry_run {
345        println!(
346            "Dry-run: matched {}, eligible {}, skipped over replay cap {}",
347            report.matched_count, report.eligible_count, report.skipped_max_replay_attempts
348        );
349    } else {
350        println!(
351            "Replay complete: matched {}, replayed {}, skipped over replay cap {}, skipped enqueue failures {}",
352            report.matched_count,
353            report.replayed_count,
354            report.skipped_max_replay_attempts,
355            report.skipped_enqueue_failures
356        );
357    }
358
359    if report.candidates.is_empty() {
360        println!("No matching failure records.");
361    } else {
362        println!("Candidates:");
363        for candidate in report.candidates {
364            let task = candidate.task_id.as_deref().unwrap_or("-");
365            println!(
366                "  {} event={} task={} attempts={} replay_count={} eligible={} at={}",
367                candidate.id,
368                candidate.event,
369                task,
370                candidate.attempts,
371                candidate.replay_count,
372                candidate.eligible_for_replay,
373                candidate.failed_at
374            );
375        }
376    }
377
378    Ok(())
379}