1use std::time::{Duration, Instant};
2
3use clap::Subcommand;
4use serde::Deserialize;
5use serde_json::{Value, json};
6use tokio::time::sleep;
7use uuid::Uuid;
8
9use crate::util::{api_request, client, exit_error, read_json_from_file};
10
11const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT: u64 = 90_000;
12const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN: u64 = 1_000;
13const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX: u64 = 300_000;
14const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT: u64 = 1_000;
15const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN: u64 = 100;
16const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX: u64 = 5_000;
17
18#[derive(Subcommand)]
19pub enum AnalysisCommands {
20 Run {
22 #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus"])]
24 request_file: Option<String>,
25 #[arg(long, short = 'o', conflicts_with = "objective_text")]
27 objective: Option<String>,
28 #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
30 objective_text: Option<String>,
31 #[arg(long = "horizon-days", alias = "days")]
33 horizon_days: Option<i32>,
34 #[arg(long)]
36 focus: Vec<String>,
37 #[arg(long)]
39 wait_timeout_ms: Option<u64>,
40 #[arg(long)]
42 overall_timeout_ms: Option<u64>,
43 #[arg(long)]
45 poll_interval_ms: Option<u64>,
46 },
47 Create {
49 #[arg(long)]
51 request_file: String,
52 },
53 Status {
55 #[arg(long)]
57 job_id: Uuid,
58 },
59}
60
61pub async fn run(api_url: &str, token: Option<&str>, command: AnalysisCommands) -> i32 {
62 match command {
63 AnalysisCommands::Run {
64 request_file,
65 objective,
66 objective_text,
67 horizon_days,
68 focus,
69 wait_timeout_ms,
70 overall_timeout_ms,
71 poll_interval_ms,
72 } => {
73 run_blocking(
74 api_url,
75 token,
76 request_file.as_deref(),
77 objective,
78 objective_text,
79 horizon_days,
80 focus,
81 wait_timeout_ms,
82 overall_timeout_ms,
83 poll_interval_ms,
84 )
85 .await
86 }
87 AnalysisCommands::Create { request_file } => create(api_url, token, &request_file).await,
88 AnalysisCommands::Status { job_id } => status(api_url, token, job_id).await,
89 }
90}
91
92async fn run_blocking(
93 api_url: &str,
94 token: Option<&str>,
95 request_file: Option<&str>,
96 objective_flag: Option<String>,
97 objective_positional: Option<String>,
98 horizon_days: Option<i32>,
99 focus: Vec<String>,
100 wait_timeout_ms: Option<u64>,
101 overall_timeout_ms: Option<u64>,
102 poll_interval_ms: Option<u64>,
103) -> i32 {
104 let base_body = build_run_request_body(
105 request_file,
106 objective_flag,
107 objective_positional,
108 horizon_days,
109 focus,
110 )
111 .unwrap_or_else(|e| {
112 exit_error(
113 &e,
114 Some(
115 "Use `kura analysis run --objective \"...\"` (or positional objective) for user-facing analyses, or `--request-file payload.json` for full JSON requests.",
116 ),
117 )
118 });
119 let body = apply_wait_timeout_override(base_body, wait_timeout_ms).unwrap_or_else(|e| {
120 exit_error(
121 &e,
122 Some("Analysis run request must be a JSON object with objective/horizon/focus fields."),
123 )
124 });
125
126 let overall_timeout_ms = clamp_cli_overall_timeout_ms(overall_timeout_ms);
127 let poll_interval_ms = clamp_cli_poll_interval_ms(poll_interval_ms);
128 let started = Instant::now();
129 let legacy_create_body = strip_run_only_fields(body.clone());
130
131 let (status, run_body) = request_json(
132 api_url,
133 reqwest::Method::POST,
134 "/v1/analysis/jobs/run",
135 token,
136 Some(body),
137 )
138 .await
139 .unwrap_or_else(|e| exit_error(&e, Some("Check API availability/auth and retry.")));
140
141 if !is_success_status(status) {
142 if is_run_endpoint_unsupported_status(status) {
143 return run_blocking_via_legacy_async_fallback(
144 api_url,
145 token,
146 legacy_create_body,
147 overall_timeout_ms,
148 poll_interval_ms,
149 started,
150 )
151 .await;
152 }
153 return print_json_response(status, &run_body);
154 }
155
156 let Some(run_response) = parse_cli_run_response(&run_body) else {
157 return print_json_response(status, &run_body);
158 };
159
160 if !run_response_needs_cli_poll_fallback(&run_response) {
161 return print_json_response(status, &run_body);
162 }
163
164 let mut latest_job = run_body.get("job").cloned().unwrap_or(Value::Null);
165 let mut polls = 0u32;
166 let mut last_retry_after_ms =
167 clamp_cli_poll_interval_ms(run_response.retry_after_ms.or(Some(poll_interval_ms)));
168 let job_id = run_response.job.job_id;
169 let path = format!("/v1/analysis/jobs/{job_id}");
170
171 loop {
172 let elapsed_total_ms = elapsed_ms(started);
173 if elapsed_total_ms >= overall_timeout_ms {
174 let timeout_output = build_cli_poll_fallback_output(
175 latest_job,
176 elapsed_total_ms,
177 false,
178 true,
179 Some(last_retry_after_ms),
180 polls,
181 overall_timeout_ms,
182 run_response.mode.as_deref(),
183 "server_run_timeout_poll",
184 );
185 return print_json_response(200, &timeout_output);
186 }
187
188 let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
189 let sleep_ms = min_u64(last_retry_after_ms, remaining_ms);
190 sleep(Duration::from_millis(sleep_ms)).await;
191
192 let (poll_status, poll_body) =
193 request_json(api_url, reqwest::Method::GET, &path, token, None)
194 .await
195 .unwrap_or_else(|e| {
196 exit_error(
197 &e,
198 Some(
199 "Blocking analysis fallback polling failed. Retry `kura analysis status --job-id <id>` in the same session if needed.",
200 ),
201 )
202 });
203
204 if !is_success_status(poll_status) {
205 return print_json_response(poll_status, &poll_body);
206 }
207
208 polls = polls.saturating_add(1);
209 latest_job = poll_body.clone();
210
211 if let Some(job_status) = parse_cli_job_status(&poll_body) {
212 if analysis_job_status_is_terminal(&job_status.status) {
213 let final_output = build_cli_poll_fallback_output(
214 poll_body,
215 elapsed_ms(started),
216 true,
217 false,
218 None,
219 polls,
220 overall_timeout_ms,
221 run_response.mode.as_deref(),
222 "server_run_timeout_poll",
223 );
224 return print_json_response(200, &final_output);
225 }
226 } else {
227 return print_json_response(200, &poll_body);
229 }
230
231 last_retry_after_ms = poll_interval_ms;
232 }
233}
234
235async fn run_blocking_via_legacy_async_fallback(
236 api_url: &str,
237 token: Option<&str>,
238 create_body: Value,
239 overall_timeout_ms: u64,
240 poll_interval_ms: u64,
241 started: Instant,
242) -> i32 {
243 let (create_status, create_resp_body) = request_json(
244 api_url,
245 reqwest::Method::POST,
246 "/v1/analysis/jobs",
247 token,
248 Some(create_body),
249 )
250 .await
251 .unwrap_or_else(|e| {
252 exit_error(
253 &e,
254 Some("Legacy analysis fallback failed to create a job. Check API availability/auth."),
255 )
256 });
257
258 if !is_success_status(create_status) {
259 return print_json_response(create_status, &create_resp_body);
260 }
261
262 let Some(create_resp) = parse_cli_create_job_response(&create_resp_body) else {
263 return print_json_response(create_status, &create_resp_body);
264 };
265
266 let mut latest_job = Value::Null;
267 let mut polls = 0u32;
268 let path = format!("/v1/analysis/jobs/{}", create_resp.job_id);
269
270 loop {
271 let elapsed_total_ms = elapsed_ms(started);
272 if elapsed_total_ms >= overall_timeout_ms {
273 let timeout_output = build_cli_poll_fallback_output(
274 if latest_job.is_null() {
275 create_resp_body.clone()
276 } else {
277 latest_job
278 },
279 elapsed_total_ms,
280 false,
281 true,
282 Some(poll_interval_ms),
283 polls,
284 overall_timeout_ms,
285 Some("legacy_async_create"),
286 "legacy_server_async_create",
287 );
288 return print_json_response(200, &timeout_output);
289 }
290
291 let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
292 sleep(Duration::from_millis(min_u64(
293 poll_interval_ms,
294 remaining_ms,
295 )))
296 .await;
297
298 let (poll_status, poll_body) =
299 request_json(api_url, reqwest::Method::GET, &path, token, None)
300 .await
301 .unwrap_or_else(|e| {
302 exit_error(
303 &e,
304 Some(
305 "Legacy analysis fallback polling failed. Retry `kura analysis status --job-id <id>` if needed.",
306 ),
307 )
308 });
309
310 if !is_success_status(poll_status) {
311 return print_json_response(poll_status, &poll_body);
312 }
313
314 polls = polls.saturating_add(1);
315 latest_job = poll_body.clone();
316
317 if let Some(job_status) = parse_cli_job_status(&poll_body) {
318 if analysis_job_status_is_terminal(&job_status.status) {
319 let final_output = build_cli_poll_fallback_output(
320 poll_body,
321 elapsed_ms(started),
322 true,
323 false,
324 None,
325 polls,
326 overall_timeout_ms,
327 Some("legacy_async_create"),
328 "legacy_server_async_create",
329 );
330 return print_json_response(200, &final_output);
331 }
332 } else {
333 return print_json_response(200, &poll_body);
334 }
335 }
336}
337
338async fn create(api_url: &str, token: Option<&str>, request_file: &str) -> i32 {
339 let body = match read_json_from_file(request_file) {
340 Ok(v) => v,
341 Err(e) => {
342 crate::util::exit_error(&e, Some("Provide a valid JSON analysis request payload."))
343 }
344 };
345
346 api_request(
347 api_url,
348 reqwest::Method::POST,
349 "/v1/analysis/jobs",
350 token,
351 Some(body),
352 &[],
353 &[],
354 false,
355 false,
356 )
357 .await
358}
359
360async fn status(api_url: &str, token: Option<&str>, job_id: Uuid) -> i32 {
361 let path = format!("/v1/analysis/jobs/{job_id}");
362 api_request(
363 api_url,
364 reqwest::Method::GET,
365 &path,
366 token,
367 None,
368 &[],
369 &[],
370 false,
371 false,
372 )
373 .await
374}
375
376fn build_run_request_body(
377 request_file: Option<&str>,
378 objective_flag: Option<String>,
379 objective_positional: Option<String>,
380 horizon_days: Option<i32>,
381 focus: Vec<String>,
382) -> Result<Value, String> {
383 if let Some(path) = request_file {
384 if objective_flag.is_some() || objective_positional.is_some() || horizon_days.is_some() {
385 return Err(
386 "`--request-file` cannot be combined with inline objective/horizon flags"
387 .to_string(),
388 );
389 }
390 return read_json_from_file(path);
391 }
392
393 let objective = choose_inline_objective(objective_flag, objective_positional)?;
394 let objective = objective
395 .ok_or_else(|| "Missing analysis objective. Provide `--objective \"...\"`, positional OBJECTIVE, or `--request-file`.".to_string())?;
396
397 let normalized_focus = normalize_focus_flags(focus);
398 let mut body = json!({ "objective": objective });
399 let obj = body
400 .as_object_mut()
401 .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
402 if let Some(days) = horizon_days {
403 obj.insert("horizon_days".to_string(), json!(days));
404 }
405 if !normalized_focus.is_empty() {
406 obj.insert("focus".to_string(), json!(normalized_focus));
407 }
408 Ok(body)
409}
410
411fn choose_inline_objective(
412 objective_flag: Option<String>,
413 objective_positional: Option<String>,
414) -> Result<Option<String>, String> {
415 if objective_flag.is_some() && objective_positional.is_some() {
416 return Err(
417 "Provide the objective either as positional text or via `--objective`, not both."
418 .to_string(),
419 );
420 }
421 Ok(objective_flag
422 .or(objective_positional)
423 .map(|s| s.trim().to_string())
424 .filter(|s| !s.is_empty()))
425}
426
427fn normalize_focus_flags(values: Vec<String>) -> Vec<String> {
428 let mut out = Vec::new();
429 for value in values {
430 let normalized = value.trim();
431 if normalized.is_empty() {
432 continue;
433 }
434 let normalized = normalized.to_string();
435 if !out.contains(&normalized) {
436 out.push(normalized);
437 }
438 }
439 out
440}
441
442fn apply_wait_timeout_override(
443 mut body: Value,
444 wait_timeout_ms: Option<u64>,
445) -> Result<Value, String> {
446 if let Some(timeout_ms) = wait_timeout_ms {
447 let obj = body
448 .as_object_mut()
449 .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
450 obj.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
451 }
452 Ok(body)
453}
454
455#[derive(Debug, Deserialize)]
456struct CliRunAnalysisResponse {
457 #[allow(dead_code)]
458 mode: Option<String>,
459 terminal: bool,
460 timed_out: bool,
461 #[serde(default)]
462 retry_after_ms: Option<u64>,
463 job: CliJobStatusRef,
464}
465
466#[derive(Debug, Deserialize)]
467struct CliJobStatusRef {
468 job_id: Uuid,
469 status: String,
470}
471
472#[derive(Debug, Deserialize)]
473struct CliCreateAnalysisJobResponse {
474 job_id: Uuid,
475}
476
477#[derive(Debug, Deserialize)]
478struct CliJobStatusEnvelope {
479 status: String,
480}
481
482fn parse_cli_run_response(value: &Value) -> Option<CliRunAnalysisResponse> {
483 serde_json::from_value(value.clone()).ok()
484}
485
486fn parse_cli_job_status(value: &Value) -> Option<CliJobStatusEnvelope> {
487 serde_json::from_value(value.clone()).ok()
488}
489
490fn parse_cli_create_job_response(value: &Value) -> Option<CliCreateAnalysisJobResponse> {
491 serde_json::from_value(value.clone()).ok()
492}
493
494fn run_response_needs_cli_poll_fallback(response: &CliRunAnalysisResponse) -> bool {
495 response.timed_out
496 && !response.terminal
497 && !analysis_job_status_is_terminal(&response.job.status)
498}
499
500fn analysis_job_status_is_terminal(status: &str) -> bool {
501 matches!(status, "completed" | "failed")
502}
503
504fn is_run_endpoint_unsupported_status(status: u16) -> bool {
505 matches!(status, 404 | 405)
506}
507
508fn strip_run_only_fields(mut body: Value) -> Value {
509 if let Some(obj) = body.as_object_mut() {
510 obj.remove("wait_timeout_ms");
511 }
512 body
513}
514
515fn clamp_cli_overall_timeout_ms(timeout_ms: Option<u64>) -> u64 {
516 timeout_ms
517 .unwrap_or(CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT)
518 .clamp(
519 CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN,
520 CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX,
521 )
522}
523
524fn clamp_cli_poll_interval_ms(timeout_ms: Option<u64>) -> u64 {
525 timeout_ms
526 .unwrap_or(CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT)
527 .clamp(
528 CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN,
529 CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX,
530 )
531}
532
533fn elapsed_ms(started: Instant) -> u64 {
534 let ms = started.elapsed().as_millis();
535 if ms > u128::from(u64::MAX) {
536 u64::MAX
537 } else {
538 ms as u64
539 }
540}
541
542fn min_u64(a: u64, b: u64) -> u64 {
543 if a < b { a } else { b }
544}
545
546fn build_cli_poll_fallback_output(
547 job: Value,
548 waited_ms: u64,
549 terminal: bool,
550 timed_out: bool,
551 retry_after_ms: Option<u64>,
552 polls: u32,
553 overall_timeout_ms: u64,
554 initial_mode: Option<&str>,
555 fallback_kind: &str,
556) -> Value {
557 let mut out = json!({
558 "mode": if terminal {
559 format!("blocking_cli_poll_fallback_completed:{fallback_kind}")
560 } else {
561 format!("blocking_cli_poll_fallback_timeout:{fallback_kind}")
562 },
563 "terminal": terminal,
564 "timed_out": timed_out,
565 "waited_ms": waited_ms,
566 "retry_after_ms": retry_after_ms,
567 "job": job,
568 "cli_fallback": {
569 "used": true,
570 "kind": fallback_kind,
571 "polls": polls,
572 "overall_timeout_ms": overall_timeout_ms,
573 "initial_mode": initial_mode,
574 }
575 });
576 if retry_after_ms.is_none() {
577 if let Some(obj) = out.as_object_mut() {
578 obj.insert("retry_after_ms".to_string(), Value::Null);
579 }
580 }
581 out
582}
583
584async fn request_json(
585 api_url: &str,
586 method: reqwest::Method,
587 path: &str,
588 token: Option<&str>,
589 body: Option<Value>,
590) -> Result<(u16, Value), String> {
591 let url = reqwest::Url::parse(&format!("{api_url}{path}"))
592 .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;
593
594 let mut req = client().request(method, url);
595 if let Some(t) = token {
596 req = req.header("Authorization", format!("Bearer {t}"));
597 }
598 if let Some(b) = body {
599 req = req.json(&b);
600 }
601
602 let resp = req.send().await.map_err(|e| format!("{e}"))?;
603 let status = resp.status().as_u16();
604 let body: Value = match resp.bytes().await {
605 Ok(bytes) => {
606 if bytes.is_empty() {
607 Value::Null
608 } else {
609 serde_json::from_slice(&bytes)
610 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))
611 }
612 }
613 Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
614 };
615 Ok((status, body))
616}
617
618fn is_success_status(status: u16) -> bool {
619 (200..=299).contains(&status)
620}
621
622fn http_status_exit_code(status: u16) -> i32 {
623 match status {
624 200..=299 => 0,
625 400..=499 => 1,
626 _ => 2,
627 }
628}
629
630fn print_json_response(status: u16, body: &Value) -> i32 {
631 let exit_code = http_status_exit_code(status);
632 let formatted = serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string());
633 if exit_code == 0 {
634 println!("{formatted}");
635 } else {
636 eprintln!("{formatted}");
637 }
638 exit_code
639}
640
641#[cfg(test)]
642mod tests {
643 use super::{
644 analysis_job_status_is_terminal, apply_wait_timeout_override, build_run_request_body,
645 clamp_cli_overall_timeout_ms, clamp_cli_poll_interval_ms,
646 is_run_endpoint_unsupported_status, parse_cli_job_status,
647 run_response_needs_cli_poll_fallback, strip_run_only_fields,
648 };
649 use serde_json::json;
650
651 #[test]
652 fn apply_wait_timeout_override_merges_field_into_request_object() {
653 let body = json!({
654 "objective": "trend of readiness",
655 "horizon_days": 90
656 });
657 let patched = apply_wait_timeout_override(body, Some(2500)).unwrap();
658 assert_eq!(patched["wait_timeout_ms"], json!(2500));
659 assert_eq!(patched["objective"], json!("trend of readiness"));
660 }
661
662 #[test]
663 fn apply_wait_timeout_override_rejects_non_object_when_override_present() {
664 let err = apply_wait_timeout_override(json!(["bad"]), Some(1000)).unwrap_err();
665 assert!(err.contains("JSON object"));
666 }
667
668 #[test]
669 fn build_run_request_body_accepts_inline_objective_and_flags() {
670 let body = build_run_request_body(
671 None,
672 Some("trend of plyometric quality".to_string()),
673 None,
674 Some(90),
675 vec![
676 "plyo".to_string(),
677 " lower_body ".to_string(),
678 "".to_string(),
679 ],
680 )
681 .unwrap();
682 assert_eq!(body["objective"], json!("trend of plyometric quality"));
683 assert_eq!(body["horizon_days"], json!(90));
684 assert_eq!(body["focus"], json!(["plyo", "lower_body"]));
685 }
686
687 #[test]
688 fn build_run_request_body_supports_positional_objective() {
689 let body = build_run_request_body(
690 None,
691 None,
692 Some("trend of sleep quality".to_string()),
693 None,
694 vec![],
695 )
696 .unwrap();
697 assert_eq!(body["objective"], json!("trend of sleep quality"));
698 assert!(body.get("horizon_days").is_none());
699 }
700
701 #[test]
702 fn build_run_request_body_rejects_missing_input() {
703 let err = build_run_request_body(None, None, None, None, vec![]).unwrap_err();
704 assert!(err.contains("Missing analysis objective"));
705 }
706
707 #[test]
708 fn build_run_request_body_rejects_duplicate_inline_objective_sources() {
709 let err = build_run_request_body(
710 None,
711 Some("a".to_string()),
712 Some("b".to_string()),
713 None,
714 vec![],
715 )
716 .unwrap_err();
717 assert!(err.contains("either as positional"));
718 }
719
720 #[test]
721 fn clamp_cli_timeouts_apply_bounds() {
722 assert_eq!(clamp_cli_overall_timeout_ms(None), 90_000);
723 assert_eq!(clamp_cli_overall_timeout_ms(Some(1)), 1_000);
724 assert_eq!(clamp_cli_overall_timeout_ms(Some(999_999)), 300_000);
725 assert_eq!(clamp_cli_poll_interval_ms(None), 1_000);
726 assert_eq!(clamp_cli_poll_interval_ms(Some(1)), 100);
727 assert_eq!(clamp_cli_poll_interval_ms(Some(50_000)), 5_000);
728 }
729
730 #[test]
731 fn analysis_terminal_status_matches_api_contract() {
732 assert!(analysis_job_status_is_terminal("completed"));
733 assert!(analysis_job_status_is_terminal("failed"));
734 assert!(!analysis_job_status_is_terminal("queued"));
735 assert!(!analysis_job_status_is_terminal("processing"));
736 }
737
738 #[test]
739 fn run_response_fallback_detection_requires_timeout_and_non_terminal_job() {
740 let timed_out = serde_json::from_value(json!({
741 "terminal": false,
742 "timed_out": true,
743 "retry_after_ms": 500,
744 "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "queued" }
745 }))
746 .unwrap();
747 assert!(run_response_needs_cli_poll_fallback(&timed_out));
748
749 let completed = serde_json::from_value(json!({
750 "terminal": true,
751 "timed_out": false,
752 "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "completed" }
753 }))
754 .unwrap();
755 assert!(!run_response_needs_cli_poll_fallback(&completed));
756 }
757
758 #[test]
759 fn parse_cli_job_status_reads_status_field() {
760 let parsed = parse_cli_job_status(&json!({
761 "job_id": "00000000-0000-0000-0000-000000000000",
762 "status": "queued"
763 }))
764 .unwrap();
765 assert_eq!(parsed.status, "queued");
766 }
767
768 #[test]
769 fn run_endpoint_unsupported_status_detection_matches_legacy_fallback_cases() {
770 assert!(is_run_endpoint_unsupported_status(404));
771 assert!(is_run_endpoint_unsupported_status(405));
772 assert!(!is_run_endpoint_unsupported_status(400));
773 assert!(!is_run_endpoint_unsupported_status(401));
774 assert!(!is_run_endpoint_unsupported_status(500));
775 }
776
777 #[test]
778 fn strip_run_only_fields_removes_wait_timeout_for_legacy_create_fallback() {
779 let body = json!({
780 "objective": "trend of readiness",
781 "horizon_days": 90,
782 "wait_timeout_ms": 2000
783 });
784 let stripped = strip_run_only_fields(body);
785 assert!(stripped.get("wait_timeout_ms").is_none());
786 assert_eq!(stripped["objective"], json!("trend of readiness"));
787 }
788}