Skip to main content

kura_cli/commands/
analysis.rs

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    /// Queue and wait (bounded) for deep analysis via /v1/analysis/jobs/run
332    Run {
333        /// Full JSON request payload (use '-' for stdin)
334        #[arg(long, conflicts_with = "kernel_json")]
335        request_file: Option<String>,
336        /// Analyze v2 kernel JSON object.
337        #[arg(long = "kernel-json", alias = "kernel")]
338        kernel_json: Option<String>,
339        /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
340        #[arg(long)]
341        wait_timeout_ms: Option<u64>,
342        /// Total CLI wait budget including timeout fallback polling
343        #[arg(long)]
344        overall_timeout_ms: Option<u64>,
345        /// Poll interval used after server-side timeout fallback kicks in
346        #[arg(long)]
347        poll_interval_ms: Option<u64>,
348    },
349    /// Queue a new async analysis job via /v1/analysis/jobs
350    #[command(hide = true)]
351    Create {
352        /// Full JSON request payload (use '-' for stdin)
353        #[arg(long)]
354        request_file: String,
355    },
356    /// Fetch analysis job status by id
357    #[command(hide = true)]
358    Status {
359        /// Analysis job UUID
360        #[arg(long)]
361        job_id: Uuid,
362        /// Optional result section: compact, kernel_execution, highlights, evidence_refs, or planning
363        #[arg(long)]
364        section: Option<String>,
365    },
366    /// Validate a draft answer against the authoritative analysis admissibility contract
367    #[command(hide = true)]
368    ValidateAnswer(ValidateAnswerArgs),
369}
370
371#[derive(Args, Clone)]
372#[command(after_help = ANALYZE_HELP)]
373pub struct AnalyzeArgs {
374    /// Full JSON request payload (use '-' for stdin)
375    #[arg(long, conflicts_with = "kernel_json")]
376    pub request_file: Option<String>,
377    /// Analyze v2 kernel JSON object.
378    #[arg(long = "kernel-json", alias = "kernel")]
379    pub kernel_json: Option<String>,
380    /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
381    #[arg(long)]
382    pub wait_timeout_ms: Option<u64>,
383    /// Total CLI wait budget including timeout fallback polling
384    #[arg(long)]
385    pub overall_timeout_ms: Option<u64>,
386    /// Poll interval used after server-side timeout fallback kicks in
387    #[arg(long)]
388    pub poll_interval_ms: Option<u64>,
389}
390
391#[derive(Args)]
392pub struct ValidateAnswerArgs {
393    /// Current user request, preferably passed verbatim
394    #[arg(long)]
395    task_intent: String,
396    /// Draft user-facing answer to validate
397    #[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            // If shape is unexpected, surface the payload instead of masking it.
583            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}