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