1use 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 #[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 #[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 #[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 #[arg(long, value_enum, default_value_t = WebhookStatusFormat::Text)]
51 pub format: WebhookStatusFormat,
52
53 #[arg(long, default_value_t = 20)]
55 pub recent: usize,
56}
57
58#[derive(Args)]
59pub struct ReplayArgs {
60 #[arg(long = "id")]
62 pub ids: Vec<String>,
63
64 #[arg(long)]
66 pub event: Option<String>,
67
68 #[arg(long)]
70 pub task_id: Option<String>,
71
72 #[arg(long, default_value_t = 20)]
74 pub limit: usize,
75
76 #[arg(long, default_value_t = 3)]
78 pub max_replay_attempts: u32,
79
80 #[arg(long)]
82 pub dry_run: bool,
83}
84
85#[derive(Args)]
86pub struct TestArgs {
87 #[arg(short, long, default_value = "task_created")]
91 pub event: String,
92
93 #[arg(short, long)]
95 pub url: Option<String>,
96
97 #[arg(long, default_value = "TEST-0001")]
99 pub task_id: String,
100
101 #[arg(long, default_value = "Test webhook notification")]
103 pub task_title: String,
104
105 #[arg(long)]
107 pub print_json: bool,
108
109 #[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 if let Some(url) = &args.url {
132 config.url = Some(url.clone());
133 }
134
135 config.enabled = Some(true);
137
138 crate::contracts::validate_webhook_settings(&config)?;
139
140 if config.events.is_none() {
143 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 let event_type = WebhookEventType::from_str(&args.event)?;
152
153 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 (
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 (
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 (
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 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 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}