Skip to content
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ coval simulations list --run-id <run_id>
| `coval dashboards` | Manage dashboards and widgets |
| `coval review-annotations` | Manage human-review annotations |
| `coval review-projects` | Manage human-review projects |
| `coval reports` | Save multi-run comparison reports |
| `coval reports` | Save, merge, and read multi-run comparison reports |
| `coval monitors` | Manage production monitors and events |
| `coval tags` | Manage resource tags |
| `coval traces` | Search and inspect OpenTelemetry traces |
Expand Down Expand Up @@ -145,6 +145,11 @@ coval reports create \
--run-ids run1,run2 \
--compare-by test_case

# Merge existing reports into one report with a group per source report
coval reports merge \
--name "Q3 Scorecard" \
--report-ids 01HAAAAAAAAAAAAAAAAAAAAAAA,01HBBBBBBBBBBBBBBBBBBBBBBB

# Upload a custom background sound
coval personas background-sounds upload ./lobby-noise.mp3 \
--display-name "Lobby Noise"
Expand Down
7 changes: 3 additions & 4 deletions api-coverage-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ opens or updates a PR only when coverage actually changes.
| --- | ---: |
| Reconciliation status | PASS |
| Published operations | 174 |
| First-class CLI operations | 117 |
| Reviewed gaps | 57 |
| Client operations | 125 |
| First-class CLI operations | 118 |
| Reviewed gaps | 56 |
| Client operations | 126 |

Catalog: https://api.coval.dev/v1/openapi

Expand Down Expand Up @@ -72,7 +72,6 @@ Catalog: https://api.coval.dev/v1/openapi
- `GET /organization/monitoring-metrics`
- `GET /personas/tags`
- `GET /personas/{persona_id}/versions`
- `GET /reports/{report_id}/rows`
- `GET /review-annotations/metric-health-stats`
- `GET /review-projects/{project_id}/insights`
- `GET /review-projects/{project_id}/metric-agreement`
Expand Down
6 changes: 1 addition & 5 deletions api-coverage.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
catalog_url = "https://api.coval.dev/v1/openapi"
reviewed_at = "2026-08-04"
published_operations = 174
cli_supported_operations = 117
cli_supported_operations = 118

[[allowed_extra]]
operation = "POST /test-cases/{test_case_id}/media:upload-url"
Expand Down Expand Up @@ -184,10 +184,6 @@ reason = "Persona version rollback remains to be modeled under COVAL-2079."
operation = "POST /personas/{persona_id}/duplicate"
reason = "Persona duplication remains to be modeled under COVAL-2079."

[[known_gap]]
operation = "GET /reports/{report_id}/rows"
reason = "Report row expansion remains to be modeled under COVAL-2079."

[[known_gap]]
operation = "POST /review-annotations:withMetricOutputs"
reason = "Advanced review reads remain to be modeled under COVAL-2079."
Expand Down
54 changes: 40 additions & 14 deletions src/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,29 +559,55 @@ const RESOURCE_SPECS: &[ResourceSpec] = &[
},
ResourceSpec {
name: "reports",
commands: &["context", "list", "get", "create", "update", "delete"],
commands: &[
"context",
"list",
"get",
"rows",
"create",
"merge",
"update",
"delete",
],
description: "Reports save a comparison view across multiple runs, grouped by a chosen dimension.",
id_name: "report_id",
id_format: "26-character ULID",
requires: &["runs"],
optional: &[],
produces: &["shareable report views"],
related: &["runs", "agents", "personas", "mutations"],
workflows: &[WorkflowSpec {
name: "Compare runs by test case",
argv: &[
"reports",
"create",
"--name",
"<name>",
"--run-ids",
"<run_id>",
"--compare-by",
"test_case",
],
}],
workflows: &[
WorkflowSpec {
name: "Compare runs by test case",
argv: &[
"reports",
"create",
"--name",
"<name>",
"--run-ids",
"<run_id>",
"--compare-by",
"test_case",
],
},
WorkflowSpec {
name: "Merge reports into one grouped comparison",
argv: &[
"reports",
"merge",
"--name",
"<name>",
"--report-ids",
"<report_id>,<report_id>",
],
},
],
pitfalls: &[
"metadata_key is required when compare-by is metadata and rejected otherwise.",
"merge needs at least two distinct report IDs and reads every source report's rows.",
"merge takes at most 500 source reports, 2,000 runs in total, and 10,000 simulations per source.",
"A simulation in several merged reports lands in the first one's group only.",
"custom_dimensions is required when compare-by is custom and rejected otherwise; custom_dimension_id must name one of them.",
"PUBLIC reports also mark their runs public.",
],
},
Expand Down
27 changes: 27 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,33 @@ impl ReportsClient<'_> {
let url = self.0.url(&format!("/v1/reports/{id}"));
self.0.delete(url).await
}

pub async fn rows(
&self,
id: &str,
cursor: Option<&str>,
limit: Option<u32>,
metric_ids: Option<&str>,
simulation_output_ids: Option<&str>,
) -> Result<models::ListReportRowsResponse, ApiError> {
let mut url = self.0.url(&format!("/v1/reports/{id}/rows"));
{
let mut pairs = url.query_pairs_mut();
if let Some(cursor) = cursor {
pairs.append_pair("cursor", cursor);
}
if let Some(limit) = limit {
pairs.append_pair("limit", &limit.to_string());
}
if let Some(metric_ids) = metric_ids {
pairs.append_pair("metric_ids", metric_ids);
}
if let Some(simulation_output_ids) = simulation_output_ids {
pairs.append_pair("simulation_output_ids", simulation_output_ids);
}
}
self.0.get(url).await
}
}

impl WidgetsClient<'_> {
Expand Down
77 changes: 77 additions & 0 deletions src/client/models/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,39 @@ pub enum CompareBy {
#[serde(rename = "metadata")]
#[value(name = "metadata")]
Metadata,
#[serde(rename = "custom")]
#[value(name = "custom")]
Custom,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)]
pub enum ReportViewMode {
#[serde(rename = "rows")]
#[value(name = "rows")]
Rows,
#[serde(rename = "grouped")]
#[value(name = "grouped")]
Grouped,
}

/// One named bucket of simulations inside a report's custom dimension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportCustomDimensionGroup {
pub id: String,
pub name: String,
#[serde(default)]
pub simulation_ids: Vec<String>,
}

/// A caller-defined grouping of a report's simulations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportCustomDimension {
pub id: String,
pub name: String,
pub groups: Vec<ReportCustomDimensionGroup>,
/// The API defaults this to false, so an input payload may omit it.
#[serde(default)]
pub hide_unassigned: bool,
Comment thread
callumreid marked this conversation as resolved.
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)]
Expand All @@ -61,6 +94,12 @@ pub struct CreateReportRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_dimensions: Option<Vec<ReportCustomDimension>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_dimension_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub view_mode: Option<ReportViewMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permissions: Option<ReportPermission>,
}

Expand Down Expand Up @@ -99,6 +138,44 @@ pub struct UpdateReportResponse {
pub report: Report,
}

/// One simulation in a report, with its metric outputs kept in `extra` for JSON output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportRow {
pub simulation_id: String,
pub run_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub persona_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ListReportRowsResponse {
#[serde(default)]
pub rows: Vec<ReportRow>,
pub next_page_token: Option<String>,
}

impl Tabular for ReportRow {
fn headers() -> Vec<&'static str> {
vec!["SIMULATION ID", "RUN ID", "AGENT", "PERSONA", "STATUS"]
}

fn row(&self) -> Vec<String> {
vec![
self.simulation_id.clone(),
self.run_id.clone(),
self.agent_id.clone().unwrap_or_default(),
self.persona_id.clone().unwrap_or_default(),
self.status.clone().unwrap_or_default(),
]
}
}

impl Tabular for Report {
fn headers() -> Vec<&'static str> {
vec!["ID", "NAME", "COMPARE BY", "RUNS", "PERMISSIONS"]
Expand Down
Loading
Loading