use anyhow::{Result, bail};
use clap::{Args, Subcommand, ValueEnum};
#[derive(Args)]
pub struct WebhookArgs {
#[command(subcommand)]
pub command: WebhookCommand,
}
#[derive(Subcommand)]
pub enum WebhookCommand {
#[command(
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"
)]
Test(TestArgs),
#[command(
after_long_help = "Examples:\n ralph webhook status\n ralph webhook status --recent 10\n ralph webhook status --format json"
)]
Status(StatusArgs),
#[command(
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"
)]
Replay(ReplayArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum WebhookStatusFormat {
#[default]
Text,
Json,
}
#[derive(Args)]
pub struct StatusArgs {
#[arg(long, value_enum, default_value_t = WebhookStatusFormat::Text)]
pub format: WebhookStatusFormat,
#[arg(long, default_value_t = 20)]
pub recent: usize,
}
#[derive(Args)]
pub struct ReplayArgs {
#[arg(long = "id")]
pub ids: Vec<String>,
#[arg(long)]
pub event: Option<String>,
#[arg(long)]
pub task_id: Option<String>,
#[arg(long, default_value_t = 20)]
pub limit: usize,
#[arg(long, default_value_t = 3)]
pub max_replay_attempts: u32,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Args)]
pub struct TestArgs {
#[arg(short, long, default_value = "task_created")]
pub event: String,
#[arg(short, long)]
pub url: Option<String>,
#[arg(long, default_value = "TEST-0001")]
pub task_id: String,
#[arg(long, default_value = "Test webhook notification")]
pub task_title: String,
#[arg(long)]
pub print_json: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
pub pretty: bool,
}
pub fn handle_webhook(args: &WebhookArgs, resolved: &crate::config::Resolved) -> Result<()> {
match &args.command {
WebhookCommand::Test(test_args) => handle_test(test_args, resolved),
WebhookCommand::Status(status_args) => handle_status(status_args, resolved),
WebhookCommand::Replay(replay_args) => handle_replay(replay_args, resolved),
}
}
fn handle_test(args: &TestArgs, resolved: &crate::config::Resolved) -> Result<()> {
use crate::contracts::WebhookEventSubscription;
use crate::timeutil;
use crate::webhook::{WebhookContext, WebhookEventType, WebhookPayload, send_webhook_payload};
use std::str::FromStr;
let mut config = resolved.config.agent.webhook.clone();
if let Some(url) = &args.url {
config.url = Some(url.clone());
}
config.enabled = Some(true);
crate::contracts::validate_webhook_settings(&config)?;
if config.events.is_none() {
let event_sub: WebhookEventSubscription =
serde_json::from_str(&format!("\"{}\"", args.event))
.map_err(|e| anyhow::anyhow!("Invalid event type '{}': {}", args.event, e))?;
config.events = Some(vec![event_sub]);
}
let event_type = WebhookEventType::from_str(&args.event)?;
let now = timeutil::now_utc_rfc3339()?;
let note = Some("Test webhook from ralph webhook test command".to_string());
let (task_id, task_title, previous_status, current_status, context) = match event_type {
WebhookEventType::LoopStarted | WebhookEventType::LoopStopped => {
(
None,
None,
None,
None,
WebhookContext {
repo_root: Some(resolved.repo_root.display().to_string()),
branch: crate::git::current_branch(&resolved.repo_root).ok(),
commit: crate::session::get_git_head_commit(&resolved.repo_root),
..Default::default()
},
)
}
WebhookEventType::PhaseStarted | WebhookEventType::PhaseCompleted => {
(
Some(args.task_id.clone()),
Some(args.task_title.clone()),
None,
None,
WebhookContext {
runner: Some("claude".to_string()),
model: Some("sonnet".to_string()),
phase: Some(2),
phase_count: Some(3),
duration_ms: Some(15000),
repo_root: Some(resolved.repo_root.display().to_string()),
branch: crate::git::current_branch(&resolved.repo_root).ok(),
commit: crate::session::get_git_head_commit(&resolved.repo_root),
ci_gate: Some("passed".to_string()),
},
)
}
WebhookEventType::TaskStarted => (
Some(args.task_id.clone()),
Some(args.task_title.clone()),
Some("todo".to_string()),
Some("doing".to_string()),
WebhookContext::default(),
),
WebhookEventType::TaskCompleted => (
Some(args.task_id.clone()),
Some(args.task_title.clone()),
Some("doing".to_string()),
Some("done".to_string()),
WebhookContext::default(),
),
WebhookEventType::TaskFailed => (
Some(args.task_id.clone()),
Some(args.task_title.clone()),
Some("doing".to_string()),
Some("rejected".to_string()),
WebhookContext::default(),
),
WebhookEventType::TaskStatusChanged => (
Some(args.task_id.clone()),
Some(args.task_title.clone()),
Some("todo".to_string()),
Some("doing".to_string()),
WebhookContext::default(),
),
_ => {
(
Some(args.task_id.clone()),
Some(args.task_title.clone()),
None,
None,
WebhookContext::default(),
)
}
};
let payload = WebhookPayload {
event: event_type.as_str().to_string(),
timestamp: now.clone(),
task_id,
task_title,
previous_status,
current_status,
note,
context,
};
if args.print_json {
let json = if args.pretty {
serde_json::to_string_pretty(&payload)?
} else {
serde_json::to_string(&payload)?
};
println!("{}", json);
return Ok(());
}
if config.url.is_none() || config.url.as_ref().unwrap().is_empty() {
bail!("Webhook URL not configured. Set it in config or use --url.");
}
println!("Sending test webhook...");
println!(" URL: {}", config.url.as_ref().unwrap());
println!(" Event: {}", args.event);
if payload.task_id.is_some() {
println!(" Task ID: {}", args.task_id);
}
send_webhook_payload(payload, &config);
println!("Test webhook sent successfully.");
Ok(())
}
fn handle_status(args: &StatusArgs, resolved: &crate::config::Resolved) -> Result<()> {
let diagnostics = crate::webhook::diagnostics_snapshot(
&resolved.repo_root,
&resolved.config.agent.webhook,
args.recent,
)?;
match args.format {
WebhookStatusFormat::Json => {
println!("{}", serde_json::to_string_pretty(&diagnostics)?);
}
WebhookStatusFormat::Text => {
println!("Webhook delivery diagnostics");
println!(" queue depth: {}", diagnostics.queue_depth);
println!(" queue capacity: {}", diagnostics.queue_capacity);
println!(" queue policy: {:?}", diagnostics.queue_policy);
println!(" enqueued total: {}", diagnostics.enqueued_total);
println!(" delivered total: {}", diagnostics.delivered_total);
println!(" failed total: {}", diagnostics.failed_total);
println!(" dropped total: {}", diagnostics.dropped_total);
println!(
" retry attempts total: {}",
diagnostics.retry_attempts_total
);
println!(" failure store: {}", diagnostics.failure_store_path);
if diagnostics.recent_failures.is_empty() {
println!(" recent failures: none");
} else {
println!(" recent failures:");
for record in diagnostics.recent_failures {
let task = record.task_id.as_deref().unwrap_or("-");
println!(
" {} event={} task={} attempts={} replay_count={} at={} error={}",
record.id,
record.event,
task,
record.attempts,
record.replay_count,
record.failed_at,
record.error
);
}
}
}
}
Ok(())
}
fn handle_replay(args: &ReplayArgs, resolved: &crate::config::Resolved) -> Result<()> {
if args.ids.is_empty() && args.event.is_none() && args.task_id.is_none() {
bail!("Refusing broad replay. Provide --id, --event, or --task-id.");
}
let selector = crate::webhook::ReplaySelector {
ids: args.ids.clone(),
event: args.event.clone(),
task_id: args.task_id.clone(),
limit: args.limit,
max_replay_attempts: args.max_replay_attempts,
};
let report = crate::webhook::replay_failed_deliveries(
&resolved.repo_root,
&resolved.config.agent.webhook,
&selector,
args.dry_run,
)?;
if report.dry_run {
println!(
"Dry-run: matched {}, eligible {}, skipped over replay cap {}",
report.matched_count, report.eligible_count, report.skipped_max_replay_attempts
);
} else {
println!(
"Replay complete: matched {}, replayed {}, skipped over replay cap {}, skipped enqueue failures {}",
report.matched_count,
report.replayed_count,
report.skipped_max_replay_attempts,
report.skipped_enqueue_failures
);
}
if report.candidates.is_empty() {
println!("No matching failure records.");
} else {
println!("Candidates:");
for candidate in report.candidates {
let task = candidate.task_id.as_deref().unwrap_or("-");
println!(
" {} event={} task={} attempts={} replay_count={} eligible={} at={}",
candidate.id,
candidate.event,
task,
candidate.attempts,
candidate.replay_count,
candidate.eligible_for_replay,
candidate.failed_at
);
}
}
Ok(())
}