1use std::time::{Duration, Instant};
2
3use clap::{Args, Subcommand};
4use serde::Deserialize;
5use serde_json::{Map, Value, json};
6use tokio::time::sleep;
7use uuid::Uuid;
8
9use crate::util::{
10 api_request, client, dry_run_enabled, emit_dry_run_request, exit_error, print_json_stderr,
11 print_json_stdout, read_json_from_file,
12};
13
14const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT: u64 = 90_000;
15const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN: u64 = 1_000;
16const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX: u64 = 300_000;
17const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT: u64 = 1_000;
18const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN: u64 = 100;
19const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX: u64 = 5_000;
20const ANALYZE_HELP: &str = r#"Examples:
21 kura analyze "Why has my CMJ changed over the last 60 days, and what recovery or training context lines up with the best and worst results?" --horizon-days 60 --focus performance_tests --focus recovery --focus activity --wait-timeout-ms 15000
22 kura analyze "Am I stagnating in jump power, and what should I inspect next?" --horizon-days 90 --focus jump --focus performance_tests --focus recovery --focus health
23 kura analyze "Compare Hang Power Clean below knee progress with CMJ trend without mixing variants" --horizon-days 120 --focus activity --focus performance_tests
24 kura analyze "Why is my sprint speed stalling?" --method recommended_by_server --program-json '{"sources":["performance_tests","activity_session_history","recovery"],"target_signal":"sprint_speed","method":"recommended_by_server"}'
25 kura analyze --request-file analysis_payload.json
26
27Rules:
28 Use analyze first for trend, stagnation, driver, influence, relationship, cross-domain explanation, and training-decision questions.
29 Do not prefetch every source with read_query before analyze; analyze loads the relevant evidence pack itself.
30 Do not invent ad-hoc formulas when backend analysis methods can answer the question; ask for method-guided analysis instead.
31 Use read_query first only for exact records, lists, timelines, or when analyze asks for a supporting read.
32"#;
33
34pub fn public_analyze_payload_contract() -> Value {
35 json!({
36 "schema_version": "public_analyze_payload_contract.v1",
37 "entrypoint": "kura analyze",
38 "truth_namespace": "bounded_deep_analysis",
39 "principles": [
40 "Use analyze as the first tool for trend, stagnation, driver, influence, relationship, cross-domain explanation, and training-decision questions.",
41 "Do not prefetch every source with read_query before analyze; analyze loads the relevant evidence pack itself.",
42 "Phrase objective as the exact user-facing analysis goal.",
43 "Keep focus hints short and domain-specific."
44 ],
45 "examples": [
46 {
47 "label": "Cross-family performance explanation",
48 "payload": {
49 "objective": "Explain how my CMJ changed over the last 60 days and what recovery or health context lined up with the best and worst results.",
50 "horizon_days": 60,
51 "focus": ["performance_tests", "recovery", "health", "activity"],
52 "method": "recommended_by_server",
53 "wait_timeout_ms": 15000
54 }
55 },
56 {
57 "label": "Stagnation review",
58 "payload": {
59 "objective": "Am I stagnating in jump power, and what should I inspect next?",
60 "horizon_days": 90,
61 "focus": ["jump", "performance_tests", "recovery", "health", "activity"],
62 "method": "recommended_by_server",
63 "wait_timeout_ms": 15000
64 }
65 },
66 {
67 "label": "Variant-aware relationship analysis",
68 "payload": {
69 "objective": "Compare Hang Power Clean below knee progress with CMJ trend without mixing variants.",
70 "horizon_days": 120,
71 "focus": ["activity", "performance_tests"],
72 "method": "recommended_by_server",
73 "analysis_program": {
74 "method": "recommended_by_server",
75 "sources": ["activity_session_history", "performance_tests"],
76 "target_signal": "cmj",
77 "comparison_streams": ["hang_power_clean::below_knee"],
78 "group_by": ["comparison_stream"]
79 },
80 "wait_timeout_ms": 15000
81 }
82 }
83 ]
84 })
85}
86
87#[derive(Subcommand)]
88pub enum AnalysisCommands {
89 Run {
91 #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus", "filters_json", "method", "analysis_program_json"])]
93 request_file: Option<String>,
94 #[arg(long, short = 'o', conflicts_with = "objective_text")]
96 objective: Option<String>,
97 #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
99 objective_text: Option<String>,
100 #[arg(long = "horizon-days", alias = "days")]
102 horizon_days: Option<i32>,
103 #[arg(long)]
105 focus: Vec<String>,
106 #[arg(long = "filters-json", alias = "filters")]
108 filters_json: Option<String>,
109 #[arg(long)]
111 method: Option<String>,
112 #[arg(long = "program-json", alias = "analysis-program")]
114 analysis_program_json: Option<String>,
115 #[arg(long)]
117 wait_timeout_ms: Option<u64>,
118 #[arg(long)]
120 overall_timeout_ms: Option<u64>,
121 #[arg(long)]
123 poll_interval_ms: Option<u64>,
124 },
125 #[command(hide = true)]
127 Create {
128 #[arg(long)]
130 request_file: String,
131 },
132 #[command(hide = true)]
134 Status {
135 #[arg(long)]
137 job_id: Uuid,
138 },
139 #[command(hide = true)]
141 ValidateAnswer(ValidateAnswerArgs),
142}
143
144#[derive(Args, Clone)]
145#[command(after_help = ANALYZE_HELP)]
146pub struct AnalyzeArgs {
147 #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus", "filters_json", "method", "analysis_program_json"])]
149 pub request_file: Option<String>,
150 #[arg(long, short = 'o', conflicts_with = "objective_text")]
152 pub objective: Option<String>,
153 #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
155 pub objective_text: Option<String>,
156 #[arg(long = "horizon-days", alias = "days")]
158 pub horizon_days: Option<i32>,
159 #[arg(long)]
161 pub focus: Vec<String>,
162 #[arg(long = "filters-json", alias = "filters")]
164 pub filters_json: Option<String>,
165 #[arg(long)]
167 pub method: Option<String>,
168 #[arg(long = "program-json", alias = "analysis-program")]
170 pub analysis_program_json: Option<String>,
171 #[arg(long)]
173 pub wait_timeout_ms: Option<u64>,
174 #[arg(long)]
176 pub overall_timeout_ms: Option<u64>,
177 #[arg(long)]
179 pub poll_interval_ms: Option<u64>,
180}
181
182#[derive(Args)]
183pub struct ValidateAnswerArgs {
184 #[arg(long)]
186 task_intent: String,
187 #[arg(long)]
189 draft_answer: String,
190}
191
192pub async fn run(api_url: &str, token: Option<&str>, command: AnalysisCommands) -> i32 {
193 match command {
194 AnalysisCommands::Run {
195 request_file,
196 objective,
197 objective_text,
198 horizon_days,
199 focus,
200 filters_json,
201 method,
202 analysis_program_json,
203 wait_timeout_ms,
204 overall_timeout_ms,
205 poll_interval_ms,
206 } => {
207 run_blocking(
208 api_url,
209 token,
210 request_file.as_deref(),
211 objective,
212 objective_text,
213 horizon_days,
214 focus,
215 filters_json,
216 method,
217 analysis_program_json,
218 wait_timeout_ms,
219 overall_timeout_ms,
220 poll_interval_ms,
221 )
222 .await
223 }
224 AnalysisCommands::Create { request_file } => create(api_url, token, &request_file).await,
225 AnalysisCommands::Status { job_id } => status(api_url, token, job_id).await,
226 AnalysisCommands::ValidateAnswer(args) => {
227 validate_answer(api_url, token, args.task_intent, args.draft_answer).await
228 }
229 }
230}
231
232pub async fn analyze(api_url: &str, token: Option<&str>, args: AnalyzeArgs) -> i32 {
233 run_blocking(
234 api_url,
235 token,
236 args.request_file.as_deref(),
237 args.objective,
238 args.objective_text,
239 args.horizon_days,
240 args.focus,
241 args.filters_json,
242 args.method,
243 args.analysis_program_json,
244 args.wait_timeout_ms,
245 args.overall_timeout_ms,
246 args.poll_interval_ms,
247 )
248 .await
249}
250
251async fn run_blocking(
252 api_url: &str,
253 token: Option<&str>,
254 request_file: Option<&str>,
255 objective_flag: Option<String>,
256 objective_positional: Option<String>,
257 horizon_days: Option<i32>,
258 focus: Vec<String>,
259 filters_json: Option<String>,
260 method: Option<String>,
261 analysis_program_json: Option<String>,
262 wait_timeout_ms: Option<u64>,
263 overall_timeout_ms: Option<u64>,
264 poll_interval_ms: Option<u64>,
265) -> i32 {
266 let base_body = build_run_request_body(
267 request_file,
268 objective_flag,
269 objective_positional,
270 horizon_days,
271 focus,
272 filters_json,
273 method,
274 analysis_program_json,
275 )
276 .unwrap_or_else(|e| {
277 exit_error(
278 &e,
279 Some(
280 "Use `kura analyze --objective \"...\"` (or positional objective) for user-facing analyses, or `--request-file payload.json` for full JSON requests.",
281 ),
282 )
283 });
284 let body = apply_wait_timeout_override(base_body, wait_timeout_ms).unwrap_or_else(|e| {
285 exit_error(
286 &e,
287 Some("Analysis run request must be a JSON object with objective/horizon/focus fields."),
288 )
289 });
290
291 let overall_timeout_ms = clamp_cli_overall_timeout_ms(overall_timeout_ms);
292 let poll_interval_ms = clamp_cli_poll_interval_ms(poll_interval_ms);
293
294 if dry_run_enabled() {
295 return emit_dry_run_request(
296 &reqwest::Method::POST,
297 api_url,
298 "/v1/analysis/jobs/run",
299 token.is_some(),
300 Some(&body),
301 &[],
302 &[],
303 false,
304 Some(
305 "Dry-run skips server execution and timeout polling. Use `kura analysis status --job-id <id>` on a real run.",
306 ),
307 );
308 }
309
310 let started = Instant::now();
311
312 let (status, run_body) = request_json(
313 api_url,
314 reqwest::Method::POST,
315 "/v1/analysis/jobs/run",
316 token,
317 Some(body),
318 )
319 .await
320 .unwrap_or_else(|e| exit_error(&e, Some("Check API availability/auth and retry.")));
321
322 if !is_success_status(status) {
323 if is_run_endpoint_unsupported_status(status) {
324 let blocked = build_run_endpoint_contract_block(status, run_body);
325 return print_json_response(status, &blocked);
326 }
327 return print_json_response(status, &run_body);
328 }
329
330 let Some(run_response) = parse_cli_run_response(&run_body) else {
331 return print_json_response(status, &run_body);
332 };
333
334 if !run_response_needs_cli_poll_fallback(&run_response) {
335 return print_json_response(status, &run_body);
336 }
337
338 let mut latest_job = run_body.get("job").cloned().unwrap_or(Value::Null);
339 let mut polls = 0u32;
340 let mut last_retry_after_ms =
341 clamp_cli_poll_interval_ms(run_response.retry_after_ms.or(Some(poll_interval_ms)));
342 let job_id = run_response.job.job_id;
343 let path = format!("/v1/analysis/jobs/{job_id}");
344
345 loop {
346 let elapsed_total_ms = elapsed_ms(started);
347 if elapsed_total_ms >= overall_timeout_ms {
348 let timeout_output = build_cli_poll_fallback_output(
349 latest_job,
350 elapsed_total_ms,
351 false,
352 true,
353 Some(last_retry_after_ms),
354 polls,
355 overall_timeout_ms,
356 run_response.mode.as_deref(),
357 "server_run_timeout_poll",
358 );
359 return print_json_response(200, &timeout_output);
360 }
361
362 let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
363 let sleep_ms = min_u64(last_retry_after_ms, remaining_ms);
364 sleep(Duration::from_millis(sleep_ms)).await;
365
366 let (poll_status, poll_body) =
367 request_json(api_url, reqwest::Method::GET, &path, token, None)
368 .await
369 .unwrap_or_else(|e| {
370 exit_error(
371 &e,
372 Some(
373 "Blocking analysis fallback polling failed. Retry `kura analysis status --job-id <id>` in the same session if needed.",
374 ),
375 )
376 });
377
378 if !is_success_status(poll_status) {
379 return print_json_response(poll_status, &poll_body);
380 }
381
382 polls = polls.saturating_add(1);
383 latest_job = poll_body.clone();
384
385 if let Some(job_status) = parse_cli_job_status(&poll_body) {
386 if analysis_job_status_is_terminal(&job_status.status) {
387 let final_output = build_cli_poll_fallback_output(
388 poll_body,
389 elapsed_ms(started),
390 true,
391 false,
392 None,
393 polls,
394 overall_timeout_ms,
395 run_response.mode.as_deref(),
396 "server_run_timeout_poll",
397 );
398 return print_json_response(200, &final_output);
399 }
400 } else {
401 return print_json_response(200, &poll_body);
403 }
404
405 last_retry_after_ms = poll_interval_ms;
406 }
407}
408
409async fn create(api_url: &str, token: Option<&str>, request_file: &str) -> i32 {
410 let body = match read_json_from_file(request_file) {
411 Ok(v) => v,
412 Err(e) => {
413 crate::util::exit_error(&e, Some("Provide a valid JSON analysis request payload."))
414 }
415 };
416
417 if dry_run_enabled() {
418 return emit_dry_run_request(
419 &reqwest::Method::POST,
420 api_url,
421 "/v1/analysis/jobs",
422 token.is_some(),
423 Some(&body),
424 &[],
425 &[],
426 false,
427 None,
428 );
429 }
430
431 api_request(
432 api_url,
433 reqwest::Method::POST,
434 "/v1/analysis/jobs",
435 token,
436 Some(body),
437 &[],
438 &[],
439 false,
440 false,
441 )
442 .await
443}
444
445async fn status(api_url: &str, token: Option<&str>, job_id: Uuid) -> i32 {
446 let path = format!("/v1/analysis/jobs/{job_id}");
447 api_request(
448 api_url,
449 reqwest::Method::GET,
450 &path,
451 token,
452 None,
453 &[],
454 &[],
455 false,
456 false,
457 )
458 .await
459}
460
461async fn validate_answer(
462 api_url: &str,
463 token: Option<&str>,
464 task_intent: String,
465 draft_answer: String,
466) -> i32 {
467 let body = build_validate_answer_body(task_intent, draft_answer);
468 api_request(
469 api_url,
470 reqwest::Method::POST,
471 "/v1/agent/answer-admissibility",
472 token,
473 Some(body),
474 &[],
475 &[],
476 false,
477 false,
478 )
479 .await
480}
481
482fn build_run_request_body(
483 request_file: Option<&str>,
484 objective_flag: Option<String>,
485 objective_positional: Option<String>,
486 horizon_days: Option<i32>,
487 focus: Vec<String>,
488 filters_json: Option<String>,
489 method: Option<String>,
490 analysis_program_json: Option<String>,
491) -> Result<Value, String> {
492 if let Some(path) = request_file {
493 if objective_flag.is_some()
494 || objective_positional.is_some()
495 || horizon_days.is_some()
496 || filters_json.is_some()
497 || method.is_some()
498 || analysis_program_json.is_some()
499 {
500 return Err(
501 "`--request-file` cannot be combined with inline objective/horizon/filter/method/program flags"
502 .to_string(),
503 );
504 }
505 return normalize_analysis_request_file_payload(read_json_from_file(path)?);
506 }
507
508 let objective = choose_inline_objective(objective_flag, objective_positional)?;
509 let objective = objective
510 .ok_or_else(|| "Missing analysis objective. Provide `--objective \"...\"`, positional OBJECTIVE, or `--request-file`.".to_string())?;
511
512 let normalized_focus = normalize_focus_flags(focus);
513 let mut body = json!({ "objective": objective });
514 let obj = body
515 .as_object_mut()
516 .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
517 if let Some(days) = horizon_days {
518 obj.insert("horizon_days".to_string(), json!(days));
519 }
520 if !normalized_focus.is_empty() {
521 obj.insert("focus".to_string(), json!(normalized_focus));
522 }
523 if let Some(filters) =
524 parse_optional_json_object_arg(filters_json.as_deref(), "--filters-json")?
525 {
526 obj.insert("filters".to_string(), Value::Object(filters));
527 }
528 if let Some(method) = method
529 .map(|value| value.trim().to_string())
530 .filter(|value| !value.is_empty())
531 {
532 obj.insert("method".to_string(), json!(method));
533 }
534 if let Some(program) =
535 parse_optional_json_object_arg(analysis_program_json.as_deref(), "--program-json")?
536 {
537 obj.insert("analysis_program".to_string(), Value::Object(program));
538 }
539 Ok(body)
540}
541
542fn normalize_analysis_request_file_payload(value: Value) -> Result<Value, String> {
543 if value.get("objective").is_some() {
544 return Ok(value);
545 }
546 for pointer in [
547 "/analyze_payload",
548 "/analysis_handoff/analyze_payload",
549 "/analysis_handoff/next_request/payload",
550 "/next_request/payload",
551 ] {
552 if let Some(candidate) = value.pointer(pointer) {
553 if candidate.get("objective").is_some() {
554 return Ok(candidate.clone());
555 }
556 }
557 }
558 Err("Analysis request file must be an analyze payload with objective or a read_query analysis_handoff containing analyze_payload/next_request.payload.".to_string())
559}
560
561fn build_validate_answer_body(task_intent: String, draft_answer: String) -> Value {
562 json!({
563 "task_intent": task_intent,
564 "draft_answer": draft_answer,
565 })
566}
567
568fn choose_inline_objective(
569 objective_flag: Option<String>,
570 objective_positional: Option<String>,
571) -> Result<Option<String>, String> {
572 if objective_flag.is_some() && objective_positional.is_some() {
573 return Err(
574 "Provide the objective either as positional text or via `--objective`, not both."
575 .to_string(),
576 );
577 }
578 Ok(objective_flag
579 .or(objective_positional)
580 .map(|s| s.trim().to_string())
581 .filter(|s| !s.is_empty()))
582}
583
584fn normalize_focus_flags(values: Vec<String>) -> Vec<String> {
585 let mut out = Vec::new();
586 for value in values {
587 let normalized = value.trim();
588 if normalized.is_empty() {
589 continue;
590 }
591 let normalized = normalized.to_string();
592 if !out.contains(&normalized) {
593 out.push(normalized);
594 }
595 }
596 out
597}
598
599fn parse_optional_json_object_arg(
600 raw: Option<&str>,
601 flag_name: &str,
602) -> Result<Option<Map<String, Value>>, String> {
603 let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
604 return Ok(None);
605 };
606 let parsed: Value = serde_json::from_str(raw)
607 .map_err(|error| format!("Could not parse {flag_name} as JSON: {error}"))?;
608 let object = parsed
609 .as_object()
610 .cloned()
611 .ok_or_else(|| format!("{flag_name} must be a JSON object"))?;
612 Ok(Some(object))
613}
614
615fn apply_wait_timeout_override(
616 mut body: Value,
617 wait_timeout_ms: Option<u64>,
618) -> Result<Value, String> {
619 if let Some(timeout_ms) = wait_timeout_ms {
620 let obj = body
621 .as_object_mut()
622 .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
623 obj.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
624 }
625 Ok(body)
626}
627
628#[derive(Debug, Deserialize)]
629struct CliRunAnalysisResponse {
630 #[allow(dead_code)]
631 mode: Option<String>,
632 terminal: bool,
633 timed_out: bool,
634 #[serde(default)]
635 retry_after_ms: Option<u64>,
636 job: CliJobStatusRef,
637}
638
639#[derive(Debug, Deserialize)]
640struct CliJobStatusRef {
641 job_id: Uuid,
642 status: String,
643}
644
645#[derive(Debug, Deserialize)]
646struct CliJobStatusEnvelope {
647 status: String,
648}
649
650fn parse_cli_run_response(value: &Value) -> Option<CliRunAnalysisResponse> {
651 serde_json::from_value(value.clone()).ok()
652}
653
654fn parse_cli_job_status(value: &Value) -> Option<CliJobStatusEnvelope> {
655 serde_json::from_value(value.clone()).ok()
656}
657
658fn run_response_needs_cli_poll_fallback(response: &CliRunAnalysisResponse) -> bool {
659 response.timed_out
660 && !response.terminal
661 && !analysis_job_status_is_terminal(&response.job.status)
662}
663
664fn analysis_job_status_is_terminal(status: &str) -> bool {
665 matches!(status, "completed" | "failed")
666}
667
668fn is_run_endpoint_unsupported_status(status: u16) -> bool {
669 matches!(status, 404 | 405)
670}
671
672fn clamp_cli_overall_timeout_ms(timeout_ms: Option<u64>) -> u64 {
673 timeout_ms
674 .unwrap_or(CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT)
675 .clamp(
676 CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN,
677 CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX,
678 )
679}
680
681fn clamp_cli_poll_interval_ms(timeout_ms: Option<u64>) -> u64 {
682 timeout_ms
683 .unwrap_or(CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT)
684 .clamp(
685 CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN,
686 CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX,
687 )
688}
689
690fn elapsed_ms(started: Instant) -> u64 {
691 let ms = started.elapsed().as_millis();
692 if ms > u128::from(u64::MAX) {
693 u64::MAX
694 } else {
695 ms as u64
696 }
697}
698
699fn min_u64(a: u64, b: u64) -> u64 {
700 if a < b { a } else { b }
701}
702
703fn build_cli_poll_fallback_output(
704 job: Value,
705 waited_ms: u64,
706 terminal: bool,
707 timed_out: bool,
708 retry_after_ms: Option<u64>,
709 polls: u32,
710 overall_timeout_ms: u64,
711 initial_mode: Option<&str>,
712 fallback_kind: &str,
713) -> Value {
714 let mut out = json!({
715 "mode": if terminal {
716 format!("blocking_cli_poll_fallback_completed:{fallback_kind}")
717 } else {
718 format!("blocking_cli_poll_fallback_timeout:{fallback_kind}")
719 },
720 "terminal": terminal,
721 "timed_out": timed_out,
722 "waited_ms": waited_ms,
723 "retry_after_ms": retry_after_ms,
724 "job": job,
725 "cli_fallback": {
726 "used": true,
727 "kind": fallback_kind,
728 "polls": polls,
729 "overall_timeout_ms": overall_timeout_ms,
730 "initial_mode": initial_mode,
731 }
732 });
733 if retry_after_ms.is_none() {
734 if let Some(obj) = out.as_object_mut() {
735 obj.insert("retry_after_ms".to_string(), Value::Null);
736 }
737 }
738 out
739}
740
741fn build_run_endpoint_contract_block(status: u16, details: Value) -> Value {
742 json!({
743 "error": "agent_mode_blocked",
744 "reason_code": "analysis_run_contract_missing",
745 "message": "Public `kura analyze` is blocked because the server did not honor the `/v1/analysis/jobs/run` contract.",
746 "blocked_command": "kura analyze",
747 "required_endpoint": "/v1/analysis/jobs/run",
748 "legacy_fallback_disabled": true,
749 "next_action": "Update the CLI/server pair until `/v1/analysis/jobs/run` is supported, then retry `kura analyze`.",
750 "status": status,
751 "details": details,
752 })
753}
754
755async fn request_json(
756 api_url: &str,
757 method: reqwest::Method,
758 path: &str,
759 token: Option<&str>,
760 body: Option<Value>,
761) -> Result<(u16, Value), String> {
762 let url = reqwest::Url::parse(&format!("{api_url}{path}"))
763 .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;
764
765 let mut req = client().request(method, url);
766 if let Some(t) = token {
767 req = req.header("Authorization", format!("Bearer {t}"));
768 }
769 if let Some(b) = body {
770 req = req.json(&b);
771 }
772
773 let resp = req.send().await.map_err(|e| format!("{e}"))?;
774 let status = resp.status().as_u16();
775 let body: Value = match resp.bytes().await {
776 Ok(bytes) => {
777 if bytes.is_empty() {
778 Value::Null
779 } else {
780 serde_json::from_slice(&bytes)
781 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))
782 }
783 }
784 Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
785 };
786 Ok((status, body))
787}
788
789fn is_success_status(status: u16) -> bool {
790 (200..=299).contains(&status)
791}
792
793fn http_status_exit_code(status: u16) -> i32 {
794 match status {
795 200..=299 => 0,
796 400..=499 => 1,
797 _ => 2,
798 }
799}
800
801fn print_json_response(status: u16, body: &Value) -> i32 {
802 let exit_code = http_status_exit_code(status);
803 if exit_code == 0 {
804 print_json_stdout(body);
805 } else {
806 print_json_stderr(body);
807 }
808 exit_code
809}
810
811#[cfg(test)]
812mod tests {
813 use super::{
814 analysis_job_status_is_terminal, apply_wait_timeout_override,
815 build_run_endpoint_contract_block, build_run_request_body, build_validate_answer_body,
816 clamp_cli_overall_timeout_ms, clamp_cli_poll_interval_ms,
817 is_run_endpoint_unsupported_status, parse_cli_job_status,
818 run_response_needs_cli_poll_fallback,
819 };
820 use serde_json::json;
821
822 #[test]
823 fn apply_wait_timeout_override_merges_field_into_request_object() {
824 let body = json!({
825 "objective": "trend of readiness",
826 "horizon_days": 90
827 });
828 let patched = apply_wait_timeout_override(body, Some(2500)).unwrap();
829 assert_eq!(patched["wait_timeout_ms"], json!(2500));
830 assert_eq!(patched["objective"], json!("trend of readiness"));
831 }
832
833 #[test]
834 fn apply_wait_timeout_override_rejects_non_object_when_override_present() {
835 let err = apply_wait_timeout_override(json!(["bad"]), Some(1000)).unwrap_err();
836 assert!(err.contains("JSON object"));
837 }
838
839 #[test]
840 fn build_run_request_body_accepts_inline_objective_and_flags() {
841 let body = build_run_request_body(
842 None,
843 Some("trend of plyometric quality".to_string()),
844 None,
845 Some(90),
846 vec![
847 "plyo".to_string(),
848 " lower_body ".to_string(),
849 "".to_string(),
850 ],
851 Some(r#"{"performance_tests":{"test_types":["cmj"]}}"#.to_string()),
852 Some("recommended_by_server".to_string()),
853 Some(
854 r#"{"sources":["performance_tests"],"method":"recommended_by_server"}"#.to_string(),
855 ),
856 )
857 .unwrap();
858 assert_eq!(body["objective"], json!("trend of plyometric quality"));
859 assert_eq!(body["horizon_days"], json!(90));
860 assert_eq!(body["focus"], json!(["plyo", "lower_body"]));
861 assert_eq!(
862 body["filters"]["performance_tests"]["test_types"],
863 json!(["cmj"])
864 );
865 assert_eq!(body["method"], json!("recommended_by_server"));
866 assert_eq!(
867 body["analysis_program"]["sources"],
868 json!(["performance_tests"])
869 );
870 }
871
872 #[test]
873 fn build_run_request_body_supports_positional_objective() {
874 let body = build_run_request_body(
875 None,
876 None,
877 Some("trend of sleep quality".to_string()),
878 None,
879 vec![],
880 None,
881 None,
882 None,
883 )
884 .unwrap();
885 assert_eq!(body["objective"], json!("trend of sleep quality"));
886 assert!(body.get("horizon_days").is_none());
887 }
888
889 #[test]
890 fn build_run_request_body_rejects_missing_input() {
891 let err =
892 build_run_request_body(None, None, None, None, vec![], None, None, None).unwrap_err();
893 assert!(err.contains("Missing analysis objective"));
894 }
895
896 #[test]
897 fn build_run_request_body_rejects_duplicate_inline_objective_sources() {
898 let err = build_run_request_body(
899 None,
900 Some("a".to_string()),
901 Some("b".to_string()),
902 None,
903 vec![],
904 None,
905 None,
906 None,
907 )
908 .unwrap_err();
909 assert!(err.contains("either as positional"));
910 }
911
912 #[test]
913 fn request_file_payload_can_be_read_query_analysis_handoff() {
914 let normalized = super::normalize_analysis_request_file_payload(json!({
915 "analysis_handoff": {
916 "next_request": {
917 "payload": {
918 "objective": "Analyze handoff",
919 "method": "recommended_by_server",
920 "focus": ["performance_tests"]
921 }
922 }
923 }
924 }))
925 .unwrap();
926
927 assert_eq!(normalized["objective"], json!("Analyze handoff"));
928 assert_eq!(normalized["method"], json!("recommended_by_server"));
929 }
930
931 #[test]
932 fn build_validate_answer_body_serializes_expected_shape() {
933 let body = build_validate_answer_body(
934 "How has my squat progressed?".to_string(),
935 "Your squat is clearly up 15%.".to_string(),
936 );
937 assert_eq!(body["task_intent"], json!("How has my squat progressed?"));
938 assert_eq!(body["draft_answer"], json!("Your squat is clearly up 15%."));
939 }
940
941 #[test]
942 fn clamp_cli_timeouts_apply_bounds() {
943 assert_eq!(clamp_cli_overall_timeout_ms(None), 90_000);
944 assert_eq!(clamp_cli_overall_timeout_ms(Some(1)), 1_000);
945 assert_eq!(clamp_cli_overall_timeout_ms(Some(999_999)), 300_000);
946 assert_eq!(clamp_cli_poll_interval_ms(None), 1_000);
947 assert_eq!(clamp_cli_poll_interval_ms(Some(1)), 100);
948 assert_eq!(clamp_cli_poll_interval_ms(Some(50_000)), 5_000);
949 }
950
951 #[test]
952 fn analysis_terminal_status_matches_api_contract() {
953 assert!(analysis_job_status_is_terminal("completed"));
954 assert!(analysis_job_status_is_terminal("failed"));
955 assert!(!analysis_job_status_is_terminal("queued"));
956 assert!(!analysis_job_status_is_terminal("processing"));
957 }
958
959 #[test]
960 fn run_response_fallback_detection_requires_timeout_and_non_terminal_job() {
961 let timed_out = serde_json::from_value(json!({
962 "terminal": false,
963 "timed_out": true,
964 "retry_after_ms": 500,
965 "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "queued" }
966 }))
967 .unwrap();
968 assert!(run_response_needs_cli_poll_fallback(&timed_out));
969
970 let completed = serde_json::from_value(json!({
971 "terminal": true,
972 "timed_out": false,
973 "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "completed" }
974 }))
975 .unwrap();
976 assert!(!run_response_needs_cli_poll_fallback(&completed));
977 }
978
979 #[test]
980 fn parse_cli_job_status_reads_status_field() {
981 let parsed = parse_cli_job_status(&json!({
982 "job_id": "00000000-0000-0000-0000-000000000000",
983 "status": "queued"
984 }))
985 .unwrap();
986 assert_eq!(parsed.status, "queued");
987 }
988
989 #[test]
990 fn run_endpoint_unsupported_status_detection_matches_hard_block_cases() {
991 assert!(is_run_endpoint_unsupported_status(404));
992 assert!(is_run_endpoint_unsupported_status(405));
993 assert!(!is_run_endpoint_unsupported_status(400));
994 assert!(!is_run_endpoint_unsupported_status(401));
995 assert!(!is_run_endpoint_unsupported_status(500));
996 }
997
998 #[test]
999 fn run_endpoint_contract_block_disables_legacy_async_escape() {
1000 let output = build_run_endpoint_contract_block(
1001 404,
1002 json!({
1003 "error": "not_found",
1004 }),
1005 );
1006
1007 assert_eq!(output["error"], json!("agent_mode_blocked"));
1008 assert_eq!(
1009 output["reason_code"],
1010 json!("analysis_run_contract_missing")
1011 );
1012 assert_eq!(output["required_endpoint"], json!("/v1/analysis/jobs/run"));
1013 assert_eq!(output["legacy_fallback_disabled"], json!(true));
1014 assert_eq!(output["status"], json!(404));
1015 }
1016}