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