1use std::time::{Duration, Instant};
2
3use clap::{Args, Subcommand};
4use serde::Deserialize;
5use serde_json::{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;
20
21#[derive(Subcommand)]
22pub enum AnalysisCommands {
23 Run {
25 #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus"])]
27 request_file: Option<String>,
28 #[arg(long, short = 'o', conflicts_with = "objective_text")]
30 objective: Option<String>,
31 #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
33 objective_text: Option<String>,
34 #[arg(long = "horizon-days", alias = "days")]
36 horizon_days: Option<i32>,
37 #[arg(long)]
39 focus: Vec<String>,
40 #[arg(long)]
42 wait_timeout_ms: Option<u64>,
43 #[arg(long)]
45 overall_timeout_ms: Option<u64>,
46 #[arg(long)]
48 poll_interval_ms: Option<u64>,
49 },
50 #[command(hide = true)]
52 Create {
53 #[arg(long)]
55 request_file: String,
56 },
57 #[command(hide = true)]
59 Status {
60 #[arg(long)]
62 job_id: Uuid,
63 },
64 #[command(hide = true)]
66 ValidateAnswer(ValidateAnswerArgs),
67}
68
69#[derive(Args)]
70pub struct ValidateAnswerArgs {
71 #[arg(long)]
73 task_intent: String,
74 #[arg(long)]
76 draft_answer: String,
77}
78
79pub async fn run(api_url: &str, token: Option<&str>, command: AnalysisCommands) -> i32 {
80 match command {
81 AnalysisCommands::Run {
82 request_file,
83 objective,
84 objective_text,
85 horizon_days,
86 focus,
87 wait_timeout_ms,
88 overall_timeout_ms,
89 poll_interval_ms,
90 } => {
91 run_blocking(
92 api_url,
93 token,
94 request_file.as_deref(),
95 objective,
96 objective_text,
97 horizon_days,
98 focus,
99 wait_timeout_ms,
100 overall_timeout_ms,
101 poll_interval_ms,
102 )
103 .await
104 }
105 AnalysisCommands::Create { request_file } => create(api_url, token, &request_file).await,
106 AnalysisCommands::Status { job_id } => status(api_url, token, job_id).await,
107 AnalysisCommands::ValidateAnswer(args) => {
108 validate_answer(api_url, token, args.task_intent, args.draft_answer).await
109 }
110 }
111}
112
113async fn run_blocking(
114 api_url: &str,
115 token: Option<&str>,
116 request_file: Option<&str>,
117 objective_flag: Option<String>,
118 objective_positional: Option<String>,
119 horizon_days: Option<i32>,
120 focus: Vec<String>,
121 wait_timeout_ms: Option<u64>,
122 overall_timeout_ms: Option<u64>,
123 poll_interval_ms: Option<u64>,
124) -> i32 {
125 let base_body = build_run_request_body(
126 request_file,
127 objective_flag,
128 objective_positional,
129 horizon_days,
130 focus,
131 )
132 .unwrap_or_else(|e| {
133 exit_error(
134 &e,
135 Some(
136 "Use `kura analysis run --objective \"...\"` (or positional objective) for user-facing analyses, or `--request-file payload.json` for full JSON requests.",
137 ),
138 )
139 });
140 let body = apply_wait_timeout_override(base_body, wait_timeout_ms).unwrap_or_else(|e| {
141 exit_error(
142 &e,
143 Some("Analysis run request must be a JSON object with objective/horizon/focus fields."),
144 )
145 });
146
147 let overall_timeout_ms = clamp_cli_overall_timeout_ms(overall_timeout_ms);
148 let poll_interval_ms = clamp_cli_poll_interval_ms(poll_interval_ms);
149
150 if dry_run_enabled() {
151 return emit_dry_run_request(
152 &reqwest::Method::POST,
153 api_url,
154 "/v1/analysis/jobs/run",
155 token.is_some(),
156 Some(&body),
157 &[],
158 &[],
159 false,
160 Some(
161 "Dry-run skips server execution and timeout polling. Use `kura analysis status --job-id <id>` on a real run.",
162 ),
163 );
164 }
165
166 let started = Instant::now();
167
168 let (status, run_body) = request_json(
169 api_url,
170 reqwest::Method::POST,
171 "/v1/analysis/jobs/run",
172 token,
173 Some(body),
174 )
175 .await
176 .unwrap_or_else(|e| exit_error(&e, Some("Check API availability/auth and retry.")));
177
178 if !is_success_status(status) {
179 if is_run_endpoint_unsupported_status(status) {
180 let blocked = build_run_endpoint_contract_block(status, run_body);
181 return print_json_response(status, &blocked);
182 }
183 return print_json_response(status, &run_body);
184 }
185
186 let Some(run_response) = parse_cli_run_response(&run_body) else {
187 return print_json_response(status, &run_body);
188 };
189
190 if !run_response_needs_cli_poll_fallback(&run_response) {
191 return print_json_response(status, &run_body);
192 }
193
194 let mut latest_job = run_body.get("job").cloned().unwrap_or(Value::Null);
195 let mut polls = 0u32;
196 let mut last_retry_after_ms =
197 clamp_cli_poll_interval_ms(run_response.retry_after_ms.or(Some(poll_interval_ms)));
198 let job_id = run_response.job.job_id;
199 let path = format!("/v1/analysis/jobs/{job_id}");
200
201 loop {
202 let elapsed_total_ms = elapsed_ms(started);
203 if elapsed_total_ms >= overall_timeout_ms {
204 let timeout_output = build_cli_poll_fallback_output(
205 latest_job,
206 elapsed_total_ms,
207 false,
208 true,
209 Some(last_retry_after_ms),
210 polls,
211 overall_timeout_ms,
212 run_response.mode.as_deref(),
213 "server_run_timeout_poll",
214 );
215 return print_json_response(200, &timeout_output);
216 }
217
218 let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
219 let sleep_ms = min_u64(last_retry_after_ms, remaining_ms);
220 sleep(Duration::from_millis(sleep_ms)).await;
221
222 let (poll_status, poll_body) =
223 request_json(api_url, reqwest::Method::GET, &path, token, None)
224 .await
225 .unwrap_or_else(|e| {
226 exit_error(
227 &e,
228 Some(
229 "Blocking analysis fallback polling failed. Retry `kura analysis status --job-id <id>` in the same session if needed.",
230 ),
231 )
232 });
233
234 if !is_success_status(poll_status) {
235 return print_json_response(poll_status, &poll_body);
236 }
237
238 polls = polls.saturating_add(1);
239 latest_job = poll_body.clone();
240
241 if let Some(job_status) = parse_cli_job_status(&poll_body) {
242 if analysis_job_status_is_terminal(&job_status.status) {
243 let final_output = build_cli_poll_fallback_output(
244 poll_body,
245 elapsed_ms(started),
246 true,
247 false,
248 None,
249 polls,
250 overall_timeout_ms,
251 run_response.mode.as_deref(),
252 "server_run_timeout_poll",
253 );
254 return print_json_response(200, &final_output);
255 }
256 } else {
257 return print_json_response(200, &poll_body);
259 }
260
261 last_retry_after_ms = poll_interval_ms;
262 }
263}
264
265async fn create(api_url: &str, token: Option<&str>, request_file: &str) -> i32 {
266 let body = match read_json_from_file(request_file) {
267 Ok(v) => v,
268 Err(e) => {
269 crate::util::exit_error(&e, Some("Provide a valid JSON analysis request payload."))
270 }
271 };
272
273 if dry_run_enabled() {
274 return emit_dry_run_request(
275 &reqwest::Method::POST,
276 api_url,
277 "/v1/analysis/jobs",
278 token.is_some(),
279 Some(&body),
280 &[],
281 &[],
282 false,
283 None,
284 );
285 }
286
287 api_request(
288 api_url,
289 reqwest::Method::POST,
290 "/v1/analysis/jobs",
291 token,
292 Some(body),
293 &[],
294 &[],
295 false,
296 false,
297 )
298 .await
299}
300
301async fn status(api_url: &str, token: Option<&str>, job_id: Uuid) -> i32 {
302 let path = format!("/v1/analysis/jobs/{job_id}");
303 api_request(
304 api_url,
305 reqwest::Method::GET,
306 &path,
307 token,
308 None,
309 &[],
310 &[],
311 false,
312 false,
313 )
314 .await
315}
316
317async fn validate_answer(
318 api_url: &str,
319 token: Option<&str>,
320 task_intent: String,
321 draft_answer: String,
322) -> i32 {
323 let body = build_validate_answer_body(task_intent, draft_answer);
324 api_request(
325 api_url,
326 reqwest::Method::POST,
327 "/v1/agent/answer-admissibility",
328 token,
329 Some(body),
330 &[],
331 &[],
332 false,
333 false,
334 )
335 .await
336}
337
338fn build_run_request_body(
339 request_file: Option<&str>,
340 objective_flag: Option<String>,
341 objective_positional: Option<String>,
342 horizon_days: Option<i32>,
343 focus: Vec<String>,
344) -> Result<Value, String> {
345 if let Some(path) = request_file {
346 if objective_flag.is_some() || objective_positional.is_some() || horizon_days.is_some() {
347 return Err(
348 "`--request-file` cannot be combined with inline objective/horizon flags"
349 .to_string(),
350 );
351 }
352 return read_json_from_file(path);
353 }
354
355 let objective = choose_inline_objective(objective_flag, objective_positional)?;
356 let objective = objective
357 .ok_or_else(|| "Missing analysis objective. Provide `--objective \"...\"`, positional OBJECTIVE, or `--request-file`.".to_string())?;
358
359 let normalized_focus = normalize_focus_flags(focus);
360 let mut body = json!({ "objective": objective });
361 let obj = body
362 .as_object_mut()
363 .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
364 if let Some(days) = horizon_days {
365 obj.insert("horizon_days".to_string(), json!(days));
366 }
367 if !normalized_focus.is_empty() {
368 obj.insert("focus".to_string(), json!(normalized_focus));
369 }
370 Ok(body)
371}
372
373fn build_validate_answer_body(task_intent: String, draft_answer: String) -> Value {
374 json!({
375 "task_intent": task_intent,
376 "draft_answer": draft_answer,
377 })
378}
379
380fn choose_inline_objective(
381 objective_flag: Option<String>,
382 objective_positional: Option<String>,
383) -> Result<Option<String>, String> {
384 if objective_flag.is_some() && objective_positional.is_some() {
385 return Err(
386 "Provide the objective either as positional text or via `--objective`, not both."
387 .to_string(),
388 );
389 }
390 Ok(objective_flag
391 .or(objective_positional)
392 .map(|s| s.trim().to_string())
393 .filter(|s| !s.is_empty()))
394}
395
396fn normalize_focus_flags(values: Vec<String>) -> Vec<String> {
397 let mut out = Vec::new();
398 for value in values {
399 let normalized = value.trim();
400 if normalized.is_empty() {
401 continue;
402 }
403 let normalized = normalized.to_string();
404 if !out.contains(&normalized) {
405 out.push(normalized);
406 }
407 }
408 out
409}
410
411fn apply_wait_timeout_override(
412 mut body: Value,
413 wait_timeout_ms: Option<u64>,
414) -> Result<Value, String> {
415 if let Some(timeout_ms) = wait_timeout_ms {
416 let obj = body
417 .as_object_mut()
418 .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
419 obj.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
420 }
421 Ok(body)
422}
423
424#[derive(Debug, Deserialize)]
425struct CliRunAnalysisResponse {
426 #[allow(dead_code)]
427 mode: Option<String>,
428 terminal: bool,
429 timed_out: bool,
430 #[serde(default)]
431 retry_after_ms: Option<u64>,
432 job: CliJobStatusRef,
433}
434
435#[derive(Debug, Deserialize)]
436struct CliJobStatusRef {
437 job_id: Uuid,
438 status: String,
439}
440
441#[derive(Debug, Deserialize)]
442struct CliJobStatusEnvelope {
443 status: String,
444}
445
446fn parse_cli_run_response(value: &Value) -> Option<CliRunAnalysisResponse> {
447 serde_json::from_value(value.clone()).ok()
448}
449
450fn parse_cli_job_status(value: &Value) -> Option<CliJobStatusEnvelope> {
451 serde_json::from_value(value.clone()).ok()
452}
453
454fn run_response_needs_cli_poll_fallback(response: &CliRunAnalysisResponse) -> bool {
455 response.timed_out
456 && !response.terminal
457 && !analysis_job_status_is_terminal(&response.job.status)
458}
459
460fn analysis_job_status_is_terminal(status: &str) -> bool {
461 matches!(status, "completed" | "failed")
462}
463
464fn is_run_endpoint_unsupported_status(status: u16) -> bool {
465 matches!(status, 404 | 405)
466}
467
468fn clamp_cli_overall_timeout_ms(timeout_ms: Option<u64>) -> u64 {
469 timeout_ms
470 .unwrap_or(CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT)
471 .clamp(
472 CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN,
473 CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX,
474 )
475}
476
477fn clamp_cli_poll_interval_ms(timeout_ms: Option<u64>) -> u64 {
478 timeout_ms
479 .unwrap_or(CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT)
480 .clamp(
481 CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN,
482 CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX,
483 )
484}
485
486fn elapsed_ms(started: Instant) -> u64 {
487 let ms = started.elapsed().as_millis();
488 if ms > u128::from(u64::MAX) {
489 u64::MAX
490 } else {
491 ms as u64
492 }
493}
494
495fn min_u64(a: u64, b: u64) -> u64 {
496 if a < b { a } else { b }
497}
498
499fn build_cli_poll_fallback_output(
500 job: Value,
501 waited_ms: u64,
502 terminal: bool,
503 timed_out: bool,
504 retry_after_ms: Option<u64>,
505 polls: u32,
506 overall_timeout_ms: u64,
507 initial_mode: Option<&str>,
508 fallback_kind: &str,
509) -> Value {
510 let mut out = json!({
511 "mode": if terminal {
512 format!("blocking_cli_poll_fallback_completed:{fallback_kind}")
513 } else {
514 format!("blocking_cli_poll_fallback_timeout:{fallback_kind}")
515 },
516 "terminal": terminal,
517 "timed_out": timed_out,
518 "waited_ms": waited_ms,
519 "retry_after_ms": retry_after_ms,
520 "job": job,
521 "cli_fallback": {
522 "used": true,
523 "kind": fallback_kind,
524 "polls": polls,
525 "overall_timeout_ms": overall_timeout_ms,
526 "initial_mode": initial_mode,
527 }
528 });
529 if retry_after_ms.is_none() {
530 if let Some(obj) = out.as_object_mut() {
531 obj.insert("retry_after_ms".to_string(), Value::Null);
532 }
533 }
534 out
535}
536
537fn build_run_endpoint_contract_block(status: u16, details: Value) -> Value {
538 json!({
539 "error": "agent_mode_blocked",
540 "reason_code": "analysis_run_contract_missing",
541 "message": "Public `kura analysis run` is blocked because the server did not honor the `/v1/analysis/jobs/run` contract.",
542 "blocked_command": "kura analysis run",
543 "required_endpoint": "/v1/analysis/jobs/run",
544 "legacy_fallback_disabled": true,
545 "next_action": "Update the CLI/server pair until `/v1/analysis/jobs/run` is supported, then retry `kura analysis run`.",
546 "status": status,
547 "details": details,
548 })
549}
550
551async fn request_json(
552 api_url: &str,
553 method: reqwest::Method,
554 path: &str,
555 token: Option<&str>,
556 body: Option<Value>,
557) -> Result<(u16, Value), String> {
558 let url = reqwest::Url::parse(&format!("{api_url}{path}"))
559 .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;
560
561 let mut req = client().request(method, url);
562 if let Some(t) = token {
563 req = req.header("Authorization", format!("Bearer {t}"));
564 }
565 if let Some(b) = body {
566 req = req.json(&b);
567 }
568
569 let resp = req.send().await.map_err(|e| format!("{e}"))?;
570 let status = resp.status().as_u16();
571 let body: Value = match resp.bytes().await {
572 Ok(bytes) => {
573 if bytes.is_empty() {
574 Value::Null
575 } else {
576 serde_json::from_slice(&bytes)
577 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))
578 }
579 }
580 Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
581 };
582 Ok((status, body))
583}
584
585fn is_success_status(status: u16) -> bool {
586 (200..=299).contains(&status)
587}
588
589fn http_status_exit_code(status: u16) -> i32 {
590 match status {
591 200..=299 => 0,
592 400..=499 => 1,
593 _ => 2,
594 }
595}
596
597fn print_json_response(status: u16, body: &Value) -> i32 {
598 let exit_code = http_status_exit_code(status);
599 if exit_code == 0 {
600 print_json_stdout(body);
601 } else {
602 print_json_stderr(body);
603 }
604 exit_code
605}
606
607#[cfg(test)]
608mod tests {
609 use super::{
610 analysis_job_status_is_terminal, apply_wait_timeout_override,
611 build_run_endpoint_contract_block, build_run_request_body, build_validate_answer_body,
612 clamp_cli_overall_timeout_ms, clamp_cli_poll_interval_ms,
613 is_run_endpoint_unsupported_status, parse_cli_job_status,
614 run_response_needs_cli_poll_fallback,
615 };
616 use serde_json::json;
617
618 #[test]
619 fn apply_wait_timeout_override_merges_field_into_request_object() {
620 let body = json!({
621 "objective": "trend of readiness",
622 "horizon_days": 90
623 });
624 let patched = apply_wait_timeout_override(body, Some(2500)).unwrap();
625 assert_eq!(patched["wait_timeout_ms"], json!(2500));
626 assert_eq!(patched["objective"], json!("trend of readiness"));
627 }
628
629 #[test]
630 fn apply_wait_timeout_override_rejects_non_object_when_override_present() {
631 let err = apply_wait_timeout_override(json!(["bad"]), Some(1000)).unwrap_err();
632 assert!(err.contains("JSON object"));
633 }
634
635 #[test]
636 fn build_run_request_body_accepts_inline_objective_and_flags() {
637 let body = build_run_request_body(
638 None,
639 Some("trend of plyometric quality".to_string()),
640 None,
641 Some(90),
642 vec![
643 "plyo".to_string(),
644 " lower_body ".to_string(),
645 "".to_string(),
646 ],
647 )
648 .unwrap();
649 assert_eq!(body["objective"], json!("trend of plyometric quality"));
650 assert_eq!(body["horizon_days"], json!(90));
651 assert_eq!(body["focus"], json!(["plyo", "lower_body"]));
652 }
653
654 #[test]
655 fn build_run_request_body_supports_positional_objective() {
656 let body = build_run_request_body(
657 None,
658 None,
659 Some("trend of sleep quality".to_string()),
660 None,
661 vec![],
662 )
663 .unwrap();
664 assert_eq!(body["objective"], json!("trend of sleep quality"));
665 assert!(body.get("horizon_days").is_none());
666 }
667
668 #[test]
669 fn build_run_request_body_rejects_missing_input() {
670 let err = build_run_request_body(None, None, None, None, vec![]).unwrap_err();
671 assert!(err.contains("Missing analysis objective"));
672 }
673
674 #[test]
675 fn build_run_request_body_rejects_duplicate_inline_objective_sources() {
676 let err = build_run_request_body(
677 None,
678 Some("a".to_string()),
679 Some("b".to_string()),
680 None,
681 vec![],
682 )
683 .unwrap_err();
684 assert!(err.contains("either as positional"));
685 }
686
687 #[test]
688 fn build_validate_answer_body_serializes_expected_shape() {
689 let body = build_validate_answer_body(
690 "How has my squat progressed?".to_string(),
691 "Your squat is clearly up 15%.".to_string(),
692 );
693 assert_eq!(body["task_intent"], json!("How has my squat progressed?"));
694 assert_eq!(body["draft_answer"], json!("Your squat is clearly up 15%."));
695 }
696
697 #[test]
698 fn clamp_cli_timeouts_apply_bounds() {
699 assert_eq!(clamp_cli_overall_timeout_ms(None), 90_000);
700 assert_eq!(clamp_cli_overall_timeout_ms(Some(1)), 1_000);
701 assert_eq!(clamp_cli_overall_timeout_ms(Some(999_999)), 300_000);
702 assert_eq!(clamp_cli_poll_interval_ms(None), 1_000);
703 assert_eq!(clamp_cli_poll_interval_ms(Some(1)), 100);
704 assert_eq!(clamp_cli_poll_interval_ms(Some(50_000)), 5_000);
705 }
706
707 #[test]
708 fn analysis_terminal_status_matches_api_contract() {
709 assert!(analysis_job_status_is_terminal("completed"));
710 assert!(analysis_job_status_is_terminal("failed"));
711 assert!(!analysis_job_status_is_terminal("queued"));
712 assert!(!analysis_job_status_is_terminal("processing"));
713 }
714
715 #[test]
716 fn run_response_fallback_detection_requires_timeout_and_non_terminal_job() {
717 let timed_out = serde_json::from_value(json!({
718 "terminal": false,
719 "timed_out": true,
720 "retry_after_ms": 500,
721 "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "queued" }
722 }))
723 .unwrap();
724 assert!(run_response_needs_cli_poll_fallback(&timed_out));
725
726 let completed = serde_json::from_value(json!({
727 "terminal": true,
728 "timed_out": false,
729 "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "completed" }
730 }))
731 .unwrap();
732 assert!(!run_response_needs_cli_poll_fallback(&completed));
733 }
734
735 #[test]
736 fn parse_cli_job_status_reads_status_field() {
737 let parsed = parse_cli_job_status(&json!({
738 "job_id": "00000000-0000-0000-0000-000000000000",
739 "status": "queued"
740 }))
741 .unwrap();
742 assert_eq!(parsed.status, "queued");
743 }
744
745 #[test]
746 fn run_endpoint_unsupported_status_detection_matches_hard_block_cases() {
747 assert!(is_run_endpoint_unsupported_status(404));
748 assert!(is_run_endpoint_unsupported_status(405));
749 assert!(!is_run_endpoint_unsupported_status(400));
750 assert!(!is_run_endpoint_unsupported_status(401));
751 assert!(!is_run_endpoint_unsupported_status(500));
752 }
753
754 #[test]
755 fn run_endpoint_contract_block_disables_legacy_async_escape() {
756 let output = build_run_endpoint_contract_block(
757 404,
758 json!({
759 "error": "not_found",
760 }),
761 );
762
763 assert_eq!(output["error"], json!("agent_mode_blocked"));
764 assert_eq!(
765 output["reason_code"],
766 json!("analysis_run_contract_missing")
767 );
768 assert_eq!(output["required_endpoint"], json!("/v1/analysis/jobs/run"));
769 assert_eq!(output["legacy_fallback_disabled"], json!(true));
770 assert_eq!(output["status"], json!(404));
771 }
772}