1use std::path::PathBuf;
7use std::time::Duration;
8
9use agent_infra_sdk::{
10 EvaluateClient, InfraClient, WaitOptions,
11 evaluate_contract::{
12 ClaimReviewRequest, CompareReportsRequest, CompleteReviewRequest, CreateEvalRunRequest,
13 GatePolicy, GetOperationRequest, GetReportRequest, ListCaseRunsRequest,
14 ListMetricCandidatesRequest, MetricHealthRequest, RegisterEnvironmentProfileRequest,
15 RegisterEvalCaseRequest, RegisterEvaluatorVersionRequest, RegisterSuiteRequest,
16 ReportComparison, SampleReviewsRequest,
17 },
18};
19use anyhow::{Context, Result, bail};
20use clap::Subcommand;
21
22use crate::{CommandOutput, JsonInput, display_source, evaluate_bundle, read_input};
23
24#[derive(Debug, Subcommand)]
25pub enum EvaluateCommand {
26 RegisterCase(JsonInput),
27 RegisterEnvironment(JsonInput),
28 RegisterEvaluator(JsonInput),
29 RegisterSuite(JsonInput),
30 Push {
35 #[command(flatten)]
36 input: JsonInput,
37 #[arg(long)]
39 dry_run: bool,
40 },
41 #[command(alias = "run")]
43 TaskCreate(JsonInput),
44 RunWait {
46 #[command(flatten)]
47 input: JsonInput,
48 #[arg(long, default_value_t = 1800, value_parser = clap::value_parser!(u64).range(1..=86_400))]
49 wait_timeout: u64,
50 },
51 Operation {
52 operation_id: String,
53 },
54 Cases(JsonInput),
55 Report {
56 report_id: String,
57 },
58 Diff {
60 #[arg(long)]
61 report_id: String,
62 #[arg(long)]
63 baseline_report_id: String,
64 #[arg(long)]
66 policy: Option<PathBuf>,
67 #[arg(long)]
69 no_fail: bool,
70 },
71 Review {
73 #[command(subcommand)]
74 command: EvaluateReviewCommand,
75 },
76 Metrics {
78 #[command(subcommand)]
79 command: EvaluateMetricsCommand,
80 },
81}
82
83#[derive(Debug, Subcommand)]
84pub enum EvaluateReviewCommand {
85 Sample(JsonInput),
87 Claim {
89 #[arg(long, default_value_t = 300_000, value_parser = clap::value_parser!(i64).range(10_000..=3_600_000))]
90 lease_ms: i64,
91 },
92 Complete(JsonInput),
94}
95
96#[derive(Debug, Subcommand)]
97pub enum EvaluateMetricsCommand {
98 Health {
100 #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..=1_000))]
101 window_runs: u32,
102 #[arg(long)]
103 evaluator_id: Option<String>,
104 },
105 Candidates {
107 #[arg(long)]
108 run_id: Option<String>,
109 #[arg(long, default_value_t = 3)]
110 minimum_occurrences: u64,
111 },
112}
113
114pub(crate) async fn execute_evaluate(
115 client: &InfraClient,
116 command: EvaluateCommand,
117) -> Result<CommandOutput> {
118 let evaluate = client.evaluate();
119 match command {
120 EvaluateCommand::RegisterCase(input) => {
121 let request: RegisterEvalCaseRequest = input.read()?;
122 CommandOutput::json(evaluate.register_eval_case(&request).await?)
123 }
124 EvaluateCommand::RegisterEnvironment(input) => {
125 let request: RegisterEnvironmentProfileRequest = input.read()?;
126 CommandOutput::json(evaluate.register_environment_profile(&request).await?)
127 }
128 EvaluateCommand::RegisterEvaluator(input) => {
129 let request: RegisterEvaluatorVersionRequest = input.read()?;
130 CommandOutput::json(evaluate.register_evaluator_version(&request).await?)
131 }
132 EvaluateCommand::RegisterSuite(input) => {
133 let request: RegisterSuiteRequest = input.read()?;
134 CommandOutput::json(evaluate.register_suite(&request).await?)
135 }
136 EvaluateCommand::Push { input, dry_run } => {
137 let bundle: evaluate_bundle::EvalBundle = input.read()?;
138 let resolved = evaluate_bundle::resolve(&bundle)?;
139 let summary = serde_json::json!({
140 "suiteId": resolved.suite.suite_id,
141 "suiteVersion": resolved.suite.version,
142 "manifestDigest": resolved.suite.manifest_digest,
143 "environmentProfiles": resolved
144 .environment_profiles
145 .iter()
146 .map(|profile| serde_json::json!({
147 "profileId": profile.profile_id,
148 "version": profile.version,
149 }))
150 .collect::<Vec<_>>(),
151 "evaluators": resolved
152 .evaluators
153 .iter()
154 .map(|evaluator| serde_json::json!({
155 "evaluatorId": evaluator.evaluator_id,
156 "version": evaluator.version,
157 "class": evaluator.metric.class,
158 }))
159 .collect::<Vec<_>>(),
160 "cases": resolved
161 .cases
162 .iter()
163 .map(|case| serde_json::json!({
164 "caseId": case.case_id,
165 "version": case.version,
166 }))
167 .collect::<Vec<_>>(),
168 "applied": !dry_run,
169 });
170 if dry_run {
171 return CommandOutput::json(summary);
172 }
173 for profile in &resolved.environment_profiles {
177 evaluate.register_environment_profile(profile).await?;
178 }
179 for evaluator in &resolved.evaluators {
180 evaluate.register_evaluator_version(evaluator).await?;
181 }
182 for case in &resolved.cases {
183 evaluate.register_eval_case(case).await?;
184 }
185 evaluate.register_suite(&resolved.suite).await?;
186 CommandOutput::json(summary)
187 }
188 EvaluateCommand::TaskCreate(input) => {
189 let request: CreateEvalRunRequest = input.read()?;
190 let handle = evaluate.create_task(&request).await?;
191 let operation = handle.refresh().await?;
192 CommandOutput::json(serde_json::json!({
193 "evaluateTaskId": operation.id,
194 "operation": operation,
195 }))
196 }
197 EvaluateCommand::RunWait {
198 input,
199 wait_timeout,
200 } => {
201 let request: CreateEvalRunRequest = input.read()?;
202 let report = evaluate
203 .run_and_wait(
204 &request,
205 WaitOptions::deadline(Duration::from_secs(wait_timeout)),
206 )
207 .await?;
208 CommandOutput::json(report)
209 }
210 EvaluateCommand::Operation { operation_id } => {
211 let request = GetOperationRequest { operation_id };
212 let handle = evaluate.operation(request.operation_id).await?;
213 CommandOutput::json(handle.refresh().await?)
214 }
215 EvaluateCommand::Cases(input) => {
216 let request: ListCaseRunsRequest = input.read()?;
217 CommandOutput::json(evaluate.list_case_runs(&request).await?)
218 }
219 EvaluateCommand::Report { report_id } => {
220 let request = GetReportRequest { report_id };
221 CommandOutput::json(evaluate.report(&request.report_id).await?)
222 }
223 EvaluateCommand::Diff {
224 report_id,
225 baseline_report_id,
226 policy,
227 no_fail,
228 } => {
229 let policy = policy
230 .map(|path| -> Result<GatePolicy> {
231 let bytes = read_input(&path)?;
232 serde_json::from_slice(&bytes)
233 .with_context(|| format!("failed to parse {}", display_source(&path)))
234 })
235 .transpose()?;
236 let comparison = evaluate
237 .compare_reports(&CompareReportsRequest {
238 report_id,
239 baseline_report_id,
240 minimum_pass_rate: 0.0,
241 maximum_infra_failure_rate: 1.0,
242 significance_level: 0.05,
243 minimum_effect: 0.0,
244 policy,
245 })
246 .await?;
247 if !comparison.gate_passed && !no_fail {
248 bail!("{}", describe_gate_failure(&comparison));
251 }
252 CommandOutput::json(comparison)
253 }
254 EvaluateCommand::Review { command } => execute_evaluate_review(evaluate, command).await,
255 EvaluateCommand::Metrics { command } => execute_evaluate_metrics(evaluate, command).await,
256 }
257}
258
259fn describe_gate_failure(comparison: &ReportComparison) -> String {
260 let blocking = comparison
261 .gate
262 .blocking_metrics
263 .iter()
264 .map(|violation| {
265 format!(
266 "{} [{}] {}",
267 violation.evaluator_id,
268 violation.class.as_str(),
269 violation.detail
270 )
271 })
272 .collect::<Vec<_>>()
273 .join("; ");
274 if blocking.is_empty() {
275 "evaluation gate failed".to_string()
276 } else {
277 format!("evaluation gate blocked by {blocking}")
278 }
279}
280
281async fn execute_evaluate_review(
282 evaluate: &EvaluateClient,
283 command: EvaluateReviewCommand,
284) -> Result<CommandOutput> {
285 match command {
286 EvaluateReviewCommand::Sample(input) => {
287 let request: SampleReviewsRequest = input.read()?;
288 CommandOutput::json(evaluate.sample_reviews(&request).await?)
289 }
290 EvaluateReviewCommand::Claim { lease_ms } => {
291 match evaluate
292 .claim_review(&ClaimReviewRequest { lease_ms })
293 .await?
294 {
295 Some(review) => CommandOutput::json(review),
296 None => CommandOutput::json(serde_json::json!({ "queued": false })),
297 }
298 }
299 EvaluateReviewCommand::Complete(input) => {
300 let request: CompleteReviewRequest = input.read()?;
301 CommandOutput::json(evaluate.complete_review(&request).await?)
302 }
303 }
304}
305
306async fn execute_evaluate_metrics(
307 evaluate: &EvaluateClient,
308 command: EvaluateMetricsCommand,
309) -> Result<CommandOutput> {
310 match command {
311 EvaluateMetricsCommand::Health {
312 window_runs,
313 evaluator_id,
314 } => CommandOutput::json(
315 evaluate
316 .metric_health(&MetricHealthRequest {
317 window_runs,
318 evaluator_id,
319 })
320 .await?,
321 ),
322 EvaluateMetricsCommand::Candidates {
323 run_id,
324 minimum_occurrences,
325 } => CommandOutput::json(
326 evaluate
327 .metric_candidates(&ListMetricCandidatesRequest {
328 run_id,
329 minimum_occurrences,
330 })
331 .await?,
332 ),
333 }
334}