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 if config.events.is_none() {
141 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 let event_type = WebhookEventType::from_str(&args.event)?;
150
151 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 (
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 (
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 (
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 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 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}