From 5ef0da452b43358beb1f61f2aaf12622fcef61e1 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Fri, 26 Jun 2026 17:02:06 -0400 Subject: [PATCH 1/5] feat: add monitors CLI command (API parity) Adds the monitors resource to the CLI with full CRUD support: - monitors list - monitors get - monitors create (with --conditions, --name, --scope, etc.) - monitors update (PATCH semantics) - monitors delete - monitors test-evaluate --run-id - monitors events [list] Verified working against API with create, list, get, update, delete, events, and test-evaluate operations. --- src/cli.rs | 12 ++ src/client/mod.rs | 70 ++++++++ src/client/models/mod.rs | 2 + src/client/models/monitor.rs | 189 ++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/monitors.rs | 323 +++++++++++++++++++++++++++++++++++ 6 files changed, 597 insertions(+) create mode 100644 src/client/models/monitor.rs create mode 100644 src/commands/monitors.rs diff --git a/src/cli.rs b/src/cli.rs index 62aefb8..5d9550f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -110,6 +110,10 @@ pub enum Commands { #[command(subcommand)] command: commands::reports::ReportCommands, }, + Monitors { + #[command(subcommand)] + command: commands::monitors::MonitorCommands, + }, } impl Commands { @@ -140,6 +144,7 @@ impl Commands { Self::ReviewAnnotations { .. } => "review-annotations", Self::ReviewProjects { .. } => "review-projects", Self::Reports { .. } => "reports", + Self::Monitors { .. } => "monitors", } } @@ -165,6 +170,7 @@ impl Commands { Self::ReviewAnnotations { command } => command.operation(), Self::ReviewProjects { command } => command.operation(), Self::Reports { command } => command.operation(), + Self::Monitors { command } => command.operation(), } } } @@ -254,6 +260,9 @@ pub async fn run(cli: Cli, ctx: &OutputContext) -> anyhow::Result<()> { Commands::Reports { command: commands::reports::ReportCommands::Context, } => commands::agent::resource_context("reports", ctx), + Commands::Monitors { + command: commands::monitors::MonitorCommands::Context, + } => commands::agent::resource_context("monitors", ctx), _ => { let api_key = api_key.ok_or_else(|| { anyhow::anyhow!( @@ -309,6 +318,9 @@ pub async fn run(cli: Cli, ctx: &OutputContext) -> anyhow::Result<()> { Commands::Reports { command } => { commands::reports::execute(command, &client, ctx).await } + Commands::Monitors { command } => { + commands::monitors::execute(command, &client, ctx).await + } _ => unreachable!(), } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 898b9fc..d7348b3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -211,6 +211,10 @@ impl CovalClient { dashboard_id: dashboard_id.to_string(), } } + + pub fn monitors(&self) -> MonitorsClient<'_> { + MonitorsClient(self) + } } pub struct AgentsClient<'a>(&'a CovalClient); @@ -237,6 +241,8 @@ pub struct WidgetsClient<'a> { dashboard_id: String, } +pub struct MonitorsClient<'a>(&'a CovalClient); + impl AgentsClient<'_> { pub async fn list( &self, @@ -1046,3 +1052,67 @@ impl WidgetsClient<'_> { self.client.delete(url).await } } + +impl MonitorsClient<'_> { + pub async fn list( + &self, + params: models::ListParams, + ) -> Result { + let mut url = self.0.url("/v1/monitors"); + params.apply_to(&mut url); + self.0.get(url).await + } + + pub async fn get(&self, id: &str) -> Result { + let url = self.0.url(&format!("/v1/monitors/{id}")); + self.0.get(url).await + } + + pub async fn create( + &self, + req: models::CreateMonitorRequest, + ) -> Result { + let url = self.0.url("/v1/monitors"); + self.0.post(url, &req).await + } + + pub async fn update( + &self, + id: &str, + req: models::UpdateMonitorRequest, + ) -> Result { + let url = self.0.url(&format!("/v1/monitors/{id}")); + self.0.patch(url, &req).await + } + + pub async fn delete(&self, id: &str) -> Result<(), ApiError> { + let url = self.0.url(&format!("/v1/monitors/{id}")); + self.0.delete(url).await + } + + pub async fn test_evaluate( + &self, + monitor_id: &str, + run_id: &str, + ) -> Result { + let url = self + .0 + .url(&format!("/v1/monitors/{monitor_id}/test-evaluate")); + let req = models::TestEvaluateRequest { + run_id: run_id.to_string(), + }; + self.0.post(url, &req).await + } + + pub async fn list_events( + &self, + monitor_id: &str, + params: models::ListParams, + ) -> Result { + let mut url = self + .0 + .url(&format!("/v1/monitors/{monitor_id}/events")); + params.apply_to(&mut url); + self.0.get(url).await + } +} diff --git a/src/client/models/mod.rs b/src/client/models/mod.rs index 9e30726..3ee0413 100644 --- a/src/client/models/mod.rs +++ b/src/client/models/mod.rs @@ -6,6 +6,7 @@ mod common; mod conversation; mod dashboard; mod metric; +mod monitor; mod mutation; mod persona; mod report; @@ -25,6 +26,7 @@ pub use common::*; pub use conversation::*; pub use dashboard::*; pub use metric::*; +pub use monitor::*; pub use mutation::*; pub use persona::*; pub use report::*; diff --git a/src/client/models/monitor.rs b/src/client/models/monitor.rs new file mode 100644 index 0000000..1c572f0 --- /dev/null +++ b/src/client/models/monitor.rs @@ -0,0 +1,189 @@ +use serde::{Deserialize, Serialize}; + +use crate::output::Tabular; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Monitor { + #[serde(alias = "ulid")] + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, + pub status: String, + pub evaluation_type: String, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub match_mode: Option, + #[serde(default)] + pub cooldown_seconds: Option, + #[serde(default)] + pub custom_message_template: Option, + #[serde(default)] + pub agent_ids: Option>, + #[serde(default)] + pub required_tags: Option>, + #[serde(default)] + pub scheduled_run_ids: Option>, + #[serde(default)] + pub trigger_count: Option, + #[serde(default)] + pub last_triggered_at: Option, + #[serde(default)] + pub conditions: Option>, + #[serde(default)] + pub channels: Option>, + pub create_time: String, + pub update_time: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl Tabular for Monitor { + fn headers() -> Vec<&'static str> { + vec!["ID", "Name", "Status", "Scope", "Evaluation", "Triggers"] + } + + fn row(&self) -> Vec { + vec![ + self.id.clone(), + self.name.clone(), + self.status.clone(), + self.scope.as_deref().unwrap_or("ALL").to_string(), + self.evaluation_type.clone(), + self.trigger_count.unwrap_or(0).to_string(), + ] + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListMonitorsResponse { + pub monitors: Vec, + #[serde(default)] + pub next_page_token: Option, + #[serde(default)] + pub total_count: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetMonitorResponse { + pub monitor: Monitor, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateMonitorRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub evaluation_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub match_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cooldown_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_message_template: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required_tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_run_ids: Option>, + pub conditions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub channels: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateMonitorRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evaluation_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub match_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cooldown_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_message_template: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required_tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_run_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub conditions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub channels: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TestEvaluateRequest { + pub run_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TestEvaluateResponse { + pub monitor_id: String, + pub run_id: String, + pub triggered: bool, + #[serde(default)] + pub suppressed: bool, + #[serde(default)] + pub condition_results: Option>, + #[serde(default)] + pub dispatch_results: Option>, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorEvent { + #[serde(alias = "ulid")] + pub id: String, + #[serde(alias = "monitor_ulid")] + pub monitor_id: String, + #[serde(default)] + pub run_id: Option, + pub outcome: String, + #[serde(default)] + pub condition_results: Option>, + #[serde(default)] + pub dispatched_channels: Option>, + #[serde(default)] + pub message_sent: Option, + #[serde(default)] + pub created_at: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl Tabular for MonitorEvent { + fn headers() -> Vec<&'static str> { + vec!["Event ID", "Monitor ID", "Run ID", "Outcome"] + } + + fn row(&self) -> Vec { + vec![ + self.id.clone(), + self.monitor_id.clone(), + self.run_id.as_deref().unwrap_or("").to_string(), + self.outcome.clone(), + ] + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListMonitorEventsResponse { + pub events: Vec, + #[serde(default)] + pub next_page_token: Option, + #[serde(default)] + pub total_count: Option, +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 81013f9..fab0f91 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod config; pub mod conversations; pub mod dashboards; pub mod metrics; +pub mod monitors; pub mod mutations; pub mod personas; pub mod reports; diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs new file mode 100644 index 0000000..2d911b1 --- /dev/null +++ b/src/commands/monitors.rs @@ -0,0 +1,323 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::client::models::ListParams; +use crate::client::CovalClient; +use crate::input_json::{self, InputJsonArg}; +use crate::next_actions; +use crate::output::{ + emit_list_with_actions, emit_one_with_actions, emit_success_with_actions, OutputContext, +}; + +#[derive(Subcommand)] +pub enum MonitorCommands { + Context, + List(ListArgs), + Get(GetArgs), + Create(CreateArgs), + Update(UpdateArgs), + Delete(DeleteArgs), + #[command(name = "test-evaluate")] + TestEvaluate(TestEvaluateArgs), + Events { + monitor_id: String, + #[command(subcommand)] + command: Option, + }, +} + +#[derive(Subcommand)] +pub enum EventCommands { + List(ListEventArgs), +} + +impl MonitorCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::Context => "context", + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + Self::TestEvaluate(_) => "test-evaluate", + Self::Events { command, .. } => match command { + Some(EventCommands::List(_)) => "list-events", + None => "list-events", + }, + } + } +} + +#[derive(Args)] +pub struct ListArgs { + #[arg(long)] + scope: Option, + #[arg(long, default_value = "50")] + page_size: u32, + #[arg(long)] + order_by: Option, +} + +#[derive(Args)] +pub struct GetArgs { + monitor_id: String, +} + +#[derive(Args)] +pub struct CreateArgs { + #[command(flatten)] + input_json: InputJsonArg, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + #[arg(long, default_value = "ON_RUN_COMPLETE")] + evaluation_type: String, + #[arg(long)] + scope: Option, + #[arg(long)] + match_mode: Option, + #[arg(long)] + cooldown_seconds: Option, + #[arg(long)] + custom_message_template: Option, + #[arg(long)] + agent_ids: Option, + #[arg(long)] + required_tags: Option, + #[arg(long)] + scheduled_run_ids: Option, + #[arg(long)] + conditions: Option, + #[arg(long)] + channels: Option, +} + +#[derive(Args)] +pub struct UpdateArgs { + monitor_id: String, + #[command(flatten)] + input_json: InputJsonArg, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + #[arg(long)] + scope: Option, + #[arg(long)] + match_mode: Option, + #[arg(long)] + cooldown_seconds: Option, + #[arg(long)] + custom_message_template: Option, + #[arg(long)] + agent_ids: Option, + #[arg(long)] + required_tags: Option, + #[arg(long)] + scheduled_run_ids: Option, + #[arg(long)] + conditions: Option, + #[arg(long)] + channels: Option, +} + +#[derive(Args)] +pub struct DeleteArgs { + monitor_id: String, +} + +#[derive(Args)] +pub struct TestEvaluateArgs { + monitor_id: String, + #[arg(long)] + run_id: String, +} + +#[derive(Args)] +pub struct ListEventArgs { + #[arg(long)] + outcome: Option, + #[arg(long, default_value = "50")] + page_size: u32, +} + +pub async fn execute( + cmd: MonitorCommands, + client: &CovalClient, + ctx: &OutputContext, +) -> Result<()> { + let operation = cmd.operation(); + match cmd { + MonitorCommands::Context => { + return crate::commands::agent::resource_context("monitors", ctx); + } + MonitorCommands::List(args) => { + let params = ListParams { + filter: args.scope.map(|s| format!("scope:{s}")), + page_size: Some(args.page_size), + order_by: args.order_by, + ..Default::default() + }; + let response = client.monitors().list(params).await?; + emit_list_with_actions( + ctx, + "monitors", + operation, + &response.monitors, + next_actions::list_result( + "monitors", + response.monitors.first().map(|m| m.id.as_str()), + ), + ); + } + MonitorCommands::Get(args) => { + let monitor = client.monitors().get(&args.monitor_id).await?; + emit_one_with_actions( + ctx, + "monitors", + operation, + &monitor, + next_actions::item_result("monitors", &monitor.id), + ); + } + MonitorCommands::Create(args) => { + let mut input = args.input_json.object()?; + input_json::insert(&mut input, "name", args.name)?; + input_json::insert(&mut input, "description", args.description)?; + input_json::insert(&mut input, "evaluation_type", Some(args.evaluation_type))?; + input_json::insert(&mut input, "scope", args.scope)?; + input_json::insert(&mut input, "match_mode", args.match_mode)?; + input_json::insert(&mut input, "cooldown_seconds", args.cooldown_seconds)?; + input_json::insert( + &mut input, + "custom_message_template", + args.custom_message_template, + )?; + if let Some(ref ids) = args.agent_ids { + let v: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + input_json::insert(&mut input, "agent_ids", Some(v))?; + } + if let Some(ref ids) = args.required_tags { + let v: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + input_json::insert(&mut input, "required_tags", Some(v))?; + } + if let Some(ref ids) = args.scheduled_run_ids { + let v: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + input_json::insert(&mut input, "scheduled_run_ids", Some(v))?; + } + if let Some(ref c) = args.conditions { + let v: serde_json::Value = serde_json::from_str(c)?; + input_json::insert(&mut input, "conditions", Some(v))?; + } + if let Some(ref c) = args.channels { + let v: serde_json::Value = serde_json::from_str(c)?; + input_json::insert(&mut input, "channels", Some(v))?; + } + let req = input_json::finish(input)?; + let monitor = client.monitors().create(req).await?; + emit_one_with_actions( + ctx, + "monitors", + operation, + &monitor, + next_actions::item_result("monitors", &monitor.id), + ); + } + MonitorCommands::Update(args) => { + let mut input = args.input_json.object()?; + input_json::insert(&mut input, "name", args.name)?; + input_json::insert(&mut input, "description", args.description)?; + input_json::insert(&mut input, "scope", args.scope)?; + input_json::insert(&mut input, "match_mode", args.match_mode)?; + input_json::insert(&mut input, "cooldown_seconds", args.cooldown_seconds)?; + input_json::insert( + &mut input, + "custom_message_template", + args.custom_message_template, + )?; + if let Some(ref ids) = args.agent_ids { + let v: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + input_json::insert(&mut input, "agent_ids", Some(v))?; + } + if let Some(ref ids) = args.required_tags { + let v: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + input_json::insert(&mut input, "required_tags", Some(v))?; + } + if let Some(ref ids) = args.scheduled_run_ids { + let v: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + input_json::insert(&mut input, "scheduled_run_ids", Some(v))?; + } + if let Some(ref c) = args.conditions { + let v: serde_json::Value = serde_json::from_str(c)?; + input_json::insert(&mut input, "conditions", Some(v))?; + } + if let Some(ref c) = args.channels { + let v: serde_json::Value = serde_json::from_str(c)?; + input_json::insert(&mut input, "channels", Some(v))?; + } + let req = input_json::finish(input)?; + let monitor = client.monitors().update(&args.monitor_id, req).await?; + emit_one_with_actions( + ctx, + "monitors", + operation, + &monitor, + next_actions::item_result("monitors", &monitor.id), + ); + } + MonitorCommands::Delete(args) => { + client.monitors().delete(&args.monitor_id).await?; + emit_success_with_actions( + ctx, + "monitors", + operation, + "Monitor deleted.", + next_actions::delete_result("monitors"), + ); + } + MonitorCommands::TestEvaluate(args) => { + let result = client + .monitors() + .test_evaluate(&args.monitor_id, &args.run_id) + .await?; + emit_one_with_actions( + ctx, + "monitors", + operation, + &result, + next_actions::item_result("monitors", &args.monitor_id), + ); + } + MonitorCommands::Events { + monitor_id, + command, + } => { + let list_args = match command { + Some(EventCommands::List(args)) => args, + None => ListEventArgs { + outcome: None, + page_size: 50, + }, + }; + let params = ListParams { + filter: list_args.outcome.map(|o| format!("outcome:{o}")), + page_size: Some(list_args.page_size), + ..Default::default() + }; + let response = client.monitors().list_events(&monitor_id, params).await?; + emit_list_with_actions( + ctx, + "monitor-events", + "list-events", + &response.events, + next_actions::list_result( + "monitor-events", + response.events.first().map(|e| e.id.as_str()), + ), + ); + } + } + Ok(()) +} \ No newline at end of file From 3167514c05dd0dd524aad531b13f65139d01ac15 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Fri, 26 Jun 2026 17:23:54 -0400 Subject: [PATCH 2/5] chore: cargo fmt --- .worktrees/cli-parity | 1 + src/client/mod.rs | 4 +--- src/client/models/monitor.rs | 2 +- src/commands/monitors.rs | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) create mode 160000 .worktrees/cli-parity diff --git a/.worktrees/cli-parity b/.worktrees/cli-parity new file mode 160000 index 0000000..4510550 --- /dev/null +++ b/.worktrees/cli-parity @@ -0,0 +1 @@ +Subproject commit 451055036de18553cc30742aa94c37a161ecc436 diff --git a/src/client/mod.rs b/src/client/mod.rs index d7348b3..d4067f9 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1109,9 +1109,7 @@ impl MonitorsClient<'_> { monitor_id: &str, params: models::ListParams, ) -> Result { - let mut url = self - .0 - .url(&format!("/v1/monitors/{monitor_id}/events")); + let mut url = self.0.url(&format!("/v1/monitors/{monitor_id}/events")); params.apply_to(&mut url); self.0.get(url).await } diff --git a/src/client/models/monitor.rs b/src/client/models/monitor.rs index 1c572f0..2f92c20 100644 --- a/src/client/models/monitor.rs +++ b/src/client/models/monitor.rs @@ -186,4 +186,4 @@ pub struct ListMonitorEventsResponse { pub next_page_token: Option, #[serde(default)] pub total_count: Option, -} \ No newline at end of file +} diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs index 2d911b1..c6bcc67 100644 --- a/src/commands/monitors.rs +++ b/src/commands/monitors.rs @@ -320,4 +320,4 @@ pub async fn execute( } } Ok(()) -} \ No newline at end of file +} From 540b9ab4a48512642a59c2bcd762295b23075219 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Thu, 2 Jul 2026 10:54:55 -0700 Subject: [PATCH 3/5] fix: address review comments --- .gitignore | 2 ++ .worktrees/cli-parity | 1 - src/client/mod.rs | 3 ++- src/commands/monitors.rs | 14 ++++++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) delete mode 160000 .worktrees/cli-parity diff --git a/.gitignore b/.gitignore index 1803c29..96d5a86 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .DS_Store *.log .claude/ +.worktrees/ + diff --git a/.worktrees/cli-parity b/.worktrees/cli-parity deleted file mode 160000 index 4510550..0000000 --- a/.worktrees/cli-parity +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 451055036de18553cc30742aa94c37a161ecc436 diff --git a/src/client/mod.rs b/src/client/mod.rs index d4067f9..64803c0 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1065,7 +1065,8 @@ impl MonitorsClient<'_> { pub async fn get(&self, id: &str) -> Result { let url = self.0.url(&format!("/v1/monitors/{id}")); - self.0.get(url).await + let resp: models::GetMonitorResponse = self.0.get(url).await?; + Ok(resp.monitor) } pub async fn create( diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs index c6bcc67..f6d25b6 100644 --- a/src/commands/monitors.rs +++ b/src/commands/monitors.rs @@ -215,6 +215,20 @@ pub async fn execute( let v: serde_json::Value = serde_json::from_str(c)?; input_json::insert(&mut input, "channels", Some(v))?; } + if input + .get("name") + .and_then(serde_json::Value::as_str) + .is_none() + { + anyhow::bail!("--name is required (or provide it via --input-json)"); + } + let has_conditions = input + .get("conditions") + .and_then(serde_json::Value::as_array) + .is_some_and(|c| !c.is_empty()); + if !has_conditions { + anyhow::bail!("--conditions is required (or provide it via --input-json)"); + } let req = input_json::finish(input)?; let monitor = client.monitors().create(req).await?; emit_one_with_actions( From eafe11437155f3459aab447ebdae8e2288e9a826 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Thu, 2 Jul 2026 11:22:06 -0700 Subject: [PATCH 4/5] fix: redact monitor channel auth tokens --- .gitignore | 1 - src/client/models/monitor.rs | 96 ++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 96d5a86..13ac30d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ *.log .claude/ .worktrees/ - diff --git a/src/client/models/monitor.rs b/src/client/models/monitor.rs index 2f92c20..3beaae7 100644 --- a/src/client/models/monitor.rs +++ b/src/client/models/monitor.rs @@ -1,4 +1,5 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; use crate::output::Tabular; @@ -31,8 +32,8 @@ pub struct Monitor { pub last_triggered_at: Option, #[serde(default)] pub conditions: Option>, - #[serde(default)] - pub channels: Option>, + #[serde(default, deserialize_with = "deserialize_redacted_json_values")] + pub channels: Option>, pub create_time: String, pub update_time: String, #[serde(flatten)] @@ -154,8 +155,8 @@ pub struct MonitorEvent { pub outcome: String, #[serde(default)] pub condition_results: Option>, - #[serde(default)] - pub dispatched_channels: Option>, + #[serde(default, deserialize_with = "deserialize_redacted_json_values")] + pub dispatched_channels: Option>, #[serde(default)] pub message_sent: Option, #[serde(default)] @@ -187,3 +188,88 @@ pub struct ListMonitorEventsResponse { #[serde(default)] pub total_count: Option, } + +fn deserialize_redacted_json_values<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let mut values = Option::>::deserialize(deserializer)?; + if let Some(items) = values.as_mut() { + for item in items { + redact_auth_tokens(item); + } + } + Ok(values) +} + +fn redact_auth_tokens(value: &mut Value) { + match value { + Value::Object(map) => { + if map.contains_key("auth_token") { + map.insert( + "auth_token".to_string(), + Value::String("".to_string()), + ); + } + for child in map.values_mut() { + redact_auth_tokens(child); + } + } + Value::Array(items) => { + for item in items { + redact_auth_tokens(item); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{Monitor, MonitorEvent}; + + #[test] + fn monitor_channels_redact_auth_tokens() { + let monitor: Monitor = serde_json::from_value(json!({ + "ulid": "mon_123", + "name": "Webhook monitor", + "status": "ACTIVE", + "evaluation_type": "RUN", + "channels": [{ + "type": "WEBHOOK", + "config": { + "url": "https://example.com/webhook", + "auth_token": "secret-token" + } + }], + "create_time": "2026-01-01T00:00:00Z", + "update_time": "2026-01-01T00:00:00Z" + })) + .unwrap(); + + let rendered = serde_json::to_string(&monitor).unwrap(); + assert!(!rendered.contains("secret-token")); + assert!(rendered.contains("")); + } + + #[test] + fn monitor_events_redact_dispatched_channel_auth_tokens() { + let event: MonitorEvent = serde_json::from_value(json!({ + "ulid": "evt_123", + "monitor_ulid": "mon_123", + "outcome": "TRIGGERED", + "dispatched_channels": [{ + "config": { + "auth_token": "event-secret" + } + }] + })) + .unwrap(); + + let rendered = serde_json::to_string(&event).unwrap(); + assert!(!rendered.contains("event-secret")); + assert!(rendered.contains("")); + } +} From ef25300ba903a8d804f359375558544cd966d523 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Thu, 2 Jul 2026 12:57:08 -0700 Subject: [PATCH 5/5] fix: preserve monitor evaluation type input --- src/agent_discovery.rs | 28 +++++++++++++ src/commands/monitors.rs | 15 +++++-- tests/cli_tests.rs | 85 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/agent_discovery.rs b/src/agent_discovery.rs index abc974e..3392a0f 100644 --- a/src/agent_discovery.rs +++ b/src/agent_discovery.rs @@ -578,4 +578,32 @@ const RESOURCE_SPECS: &[ResourceSpec] = &[ "PUBLIC reports also mark their runs public.", ], }, + ResourceSpec { + name: "monitors", + commands: &[ + "context", + "list", + "get", + "create", + "update", + "delete", + "test-evaluate", + "events.list", + ], + description: "Monitors evaluate runs and dispatch alerts when configured conditions match.", + id_name: "monitor_id", + id_format: "Coval monitor ID", + requires: &["runs"], + optional: &["agents", "tags", "scheduled-runs"], + produces: &["monitor events", "alerts"], + related: &["runs", "metrics", "agents", "scheduled-runs"], + workflows: &[WorkflowSpec { + name: "Inspect monitors", + argv: &["monitors", "list"], + }], + pitfalls: &[ + "Create requests require at least one condition.", + "Channel auth tokens are redacted in CLI output.", + ], + }, ]; diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs index f6d25b6..443d50f 100644 --- a/src/commands/monitors.rs +++ b/src/commands/monitors.rs @@ -9,6 +9,8 @@ use crate::output::{ emit_list_with_actions, emit_one_with_actions, emit_success_with_actions, OutputContext, }; +const DEFAULT_EVALUATION_TYPE: &str = "ON_RUN_COMPLETE"; + #[derive(Subcommand)] pub enum MonitorCommands { Context, @@ -72,8 +74,8 @@ pub struct CreateArgs { name: Option, #[arg(long)] description: Option, - #[arg(long, default_value = "ON_RUN_COMPLETE")] - evaluation_type: String, + #[arg(long)] + evaluation_type: Option, #[arg(long)] scope: Option, #[arg(long)] @@ -186,7 +188,7 @@ pub async fn execute( let mut input = args.input_json.object()?; input_json::insert(&mut input, "name", args.name)?; input_json::insert(&mut input, "description", args.description)?; - input_json::insert(&mut input, "evaluation_type", Some(args.evaluation_type))?; + input_json::insert(&mut input, "evaluation_type", args.evaluation_type)?; input_json::insert(&mut input, "scope", args.scope)?; input_json::insert(&mut input, "match_mode", args.match_mode)?; input_json::insert(&mut input, "cooldown_seconds", args.cooldown_seconds)?; @@ -215,6 +217,13 @@ pub async fn execute( let v: serde_json::Value = serde_json::from_str(c)?; input_json::insert(&mut input, "channels", Some(v))?; } + if !input.contains_key("evaluation_type") { + input_json::insert( + &mut input, + "evaluation_type", + Some(DEFAULT_EVALUATION_TYPE.to_string()), + )?; + } if input .get("name") .and_then(serde_json::Value::as_str) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index c03ce2f..5611320 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -44,6 +44,7 @@ const AGENT_RESOURCES: &[&str] = &[ "review-annotations", "review-projects", "reports", + "monitors", ]; const INPUT_JSON_HELP_COMMANDS: &[&[&str]] = &[ @@ -79,6 +80,8 @@ const INPUT_JSON_HELP_COMMANDS: &[&[&str]] = &[ &["review-projects", "update", "--help"], &["reports", "create", "--help"], &["reports", "update", "--help"], + &["monitors", "create", "--help"], + &["monitors", "update", "--help"], ]; #[test] @@ -2071,6 +2074,88 @@ async fn test_input_json_stdin() { .stdout(predicate::str::contains("dash123")); } +#[tokio::test] +async fn test_monitors_create_preserves_input_json_evaluation_type() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/monitors")) + .and(header("X-API-Key", "test_key")) + .and(body_partial_json(json!({ + "name": "Scheduled monitor", + "evaluation_type": "SCHEDULED", + "conditions": [{"type": "metric"}] + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "id": "mon123", + "name": "Scheduled monitor", + "status": "ACTIVE", + "evaluation_type": "SCHEDULED", + "conditions": [{"type": "metric"}], + "create_time": "2026-01-01T00:00:00Z", + "update_time": "2026-01-01T00:00:00Z" + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("monitors") + .arg("create") + .arg("--input-json") + .arg( + r#"{"name":"Scheduled monitor","evaluation_type":"SCHEDULED","conditions":[{"type":"metric"}]}"#, + ) + .assert() + .success() + .stdout(predicate::str::contains("mon123")); +} + +#[tokio::test] +async fn test_monitors_create_evaluation_type_flag_overrides_input_json() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/monitors")) + .and(header("X-API-Key", "test_key")) + .and(body_partial_json(json!({ + "name": "Run monitor", + "evaluation_type": "ON_RUN_COMPLETE", + "conditions": [{"type": "metric"}] + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "id": "mon456", + "name": "Run monitor", + "status": "ACTIVE", + "evaluation_type": "ON_RUN_COMPLETE", + "conditions": [{"type": "metric"}], + "create_time": "2026-01-01T00:00:00Z", + "update_time": "2026-01-01T00:00:00Z" + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("monitors") + .arg("create") + .arg("--input-json") + .arg( + r#"{"name":"Run monitor","evaluation_type":"SCHEDULED","conditions":[{"type":"metric"}]}"#, + ) + .arg("--evaluation-type") + .arg("ON_RUN_COMPLETE") + .assert() + .success() + .stdout(predicate::str::contains("mon456")); +} + #[tokio::test] async fn test_dashboard_create_with_full_fields() { let mock_server = MockServer::start().await;