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":"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    /// Queue and wait (bounded) for deep analysis via /v1/analysis/jobs/run
267    Run {
268        /// Full JSON request payload (use '-' for stdin)
269        #[arg(long, conflicts_with = "kernel_json")]
270        request_file: Option<String>,
271        /// Analyze v2 kernel JSON object.
272        #[arg(long = "kernel-json", alias = "kernel")]
273        kernel_json: Option<String>,
274        /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
275        #[arg(long)]
276        wait_timeout_ms: Option<u64>,
277        /// Total CLI wait budget including timeout fallback polling
278        #[arg(long)]
279        overall_timeout_ms: Option<u64>,
280        /// Poll interval used after server-side timeout fallback kicks in
281        #[arg(long)]
282        poll_interval_ms: Option<u64>,
283    },
284    /// Queue a new async analysis job via /v1/analysis/jobs
285    #[command(hide = true)]
286    Create {
287        /// Full JSON request payload (use '-' for stdin)
288        #[arg(long)]
289        request_file: String,
290    },
291    /// Fetch analysis job status by id
292    #[command(hide = true)]
293    Status {
294        /// Analysis job UUID
295        #[arg(long)]
296        job_id: Uuid,
297        /// Optional result section: compact, kernel_execution, highlights, evidence_refs, or planning
298        #[arg(long)]
299        section: Option<String>,
300    },
301    /// Validate a draft answer against the authoritative analysis admissibility contract
302    #[command(hide = true)]
303    ValidateAnswer(ValidateAnswerArgs),
304}
305
306#[derive(Args, Clone)]
307#[command(after_help = ANALYZE_HELP)]
308pub struct AnalyzeArgs {
309    /// Full JSON request payload (use '-' for stdin)
310    #[arg(long, conflicts_with = "kernel_json")]
311    pub request_file: Option<String>,
312    /// Analyze v2 kernel JSON object.
313    #[arg(long = "kernel-json", alias = "kernel")]
314    pub kernel_json: Option<String>,
315    /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
316    #[arg(long)]
317    pub wait_timeout_ms: Option<u64>,
318    /// Total CLI wait budget including timeout fallback polling
319    #[arg(long)]
320    pub overall_timeout_ms: Option<u64>,
321    /// Poll interval used after server-side timeout fallback kicks in
322    #[arg(long)]
323    pub poll_interval_ms: Option<u64>,
324}
325
326#[derive(Args)]
327pub struct ValidateAnswerArgs {
328    /// Current user request, preferably passed verbatim
329    #[arg(long)]
330    task_intent: String,
331    /// Draft user-facing answer to validate
332    #[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            // If shape is unexpected, surface the payload instead of masking it.
518            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}