1#![allow(clippy::redundant_pub_crate)]
3
4use std::{fs, path::Path, sync::mpsc, thread, time::Duration};
5
6use serde_json::{json, Value};
7use starweaver_agent::ResumableState;
8use starweaver_core::sdk_name;
9use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
10use starweaver_runtime::AgentStreamRecord;
11use starweaver_session::{ApprovalStatus, RunRecord, RunStatus};
12use starweaver_stream::DisplayMessage;
13
14use crate::{
15 args::{
16 ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
17 DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
18 OutputMode, ResumeCommand, RunCommand, SessionCommand, TuiCommand,
19 },
20 config::{read_current_session, write_current_session, CliConfig},
21 environment::{
22 resolve_environment_for_session, validate_environment_config, ResolvedEnvironment,
23 },
24 local_store::LocalStore,
25 profiles::{list_profiles, resolve_profile, ResolvedProfile},
26 prompt_input::PromptInput,
27 runner::{
28 execute_agent_session, execute_agent_session_with_channels, failed_display_message,
29 CliRunPolicy, CliSteeringMessage,
30 },
31 slash_commands::expand_slash_command,
32 CliError, CliResult,
33};
34
35mod auth;
36mod catalog;
37mod rendering;
38mod setup;
39mod tui;
40mod worktree;
41
42use auth::oauth_cli_error;
43use rendering::{
44 approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
45 render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
46 render_session_delete, render_session_show, render_sessions, render_trim_report, session_value,
47};
48use setup::remove_file_if_exists;
49#[cfg(test)]
50use tui::model_choices;
51use worktree::apply_starweaver_run_metadata;
52
53pub(super) struct PromptRunExecution {
54 pub(super) session_id: String,
55 pub(super) run_id: String,
56 pub(super) status: String,
57 pub(super) output_mode: OutputMode,
58 pub(super) messages: Vec<DisplayMessage>,
59}
60
61pub(super) struct PreparedPromptRun {
62 pub(super) session_id: String,
63 pub(super) run_id: String,
64 pub(super) output_mode: OutputMode,
65 pub(super) run: RunRecord,
66 run_input: PromptInput,
67 resolved_profile: ResolvedProfile,
68 environment: ResolvedEnvironment,
69 restore_state: Option<ResumableState>,
70 policy: CliRunPolicy,
71}
72
73pub(super) struct ExecutedPromptRun {
74 run: RunRecord,
75 output_mode: OutputMode,
76 execution: crate::runner::CliRunExecution,
77}
78
79const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
80const USER_RULES_TAG: &str = "user-rules";
81
82fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
83 if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
84 input.push_guidance_text_part(project_guidance);
85 }
86 if let Some(user_rules) = load_user_rules(&config.global_dir) {
87 input.push_guidance_text_part(user_rules);
88 }
89}
90
91fn load_project_guidance(workspace_root: &Path) -> Option<String> {
92 let path = workspace_root.join("AGENTS.md");
93 let content = read_non_empty_utf8_file(&path)?;
94 Some(format!(
95 "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
96 ))
97}
98
99fn load_user_rules(global_dir: &Path) -> Option<String> {
100 let path = global_dir.join("RULES.md");
101 let content = read_non_empty_utf8_file(&path)?;
102 Some(format!(
103 "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
104 path_absolute_posix(&path)
105 ))
106}
107
108fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
109 let content = fs::read_to_string(path).ok()?;
110 (!content.trim().is_empty()).then_some(content)
111}
112
113fn path_absolute_posix(path: &Path) -> String {
114 path.canonicalize()
115 .unwrap_or_else(|_| path.to_path_buf())
116 .display()
117 .to_string()
118 .replace('\\', "/")
119}
120
121#[allow(dead_code)]
122struct OAuthRefreshGuard {
123 stop_sender: mpsc::Sender<()>,
124 handle: Option<thread::JoinHandle<()>>,
125}
126
127impl Drop for OAuthRefreshGuard {
128 fn drop(&mut self) {
129 let _ = self.stop_sender.send(());
130 if let Some(handle) = self.handle.take() {
131 let _ = handle.join();
132 }
133 }
134}
135
136pub struct CliService {
138 config: CliConfig,
139 store: Option<LocalStore>,
140}
141
142impl CliService {
143 pub const fn open(config: CliConfig) -> CliResult<Self> {
145 Ok(Self {
146 config,
147 store: None,
148 })
149 }
150
151 fn store(&mut self) -> CliResult<&mut LocalStore> {
152 if self.store.is_none() {
153 self.store = Some(LocalStore::open(&self.config)?);
154 }
155 self.store
156 .as_mut()
157 .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
158 }
159
160 pub fn execute(mut self, cli: Cli) -> CliResult<String> {
162 if let Some(prompt) = cli.prompt.clone() {
163 let command = RunCommand {
164 prompt: Some(prompt),
165 prompt_parts: Vec::new(),
166 session: cli.session.clone(),
167 continue_session: cli.continue_session,
168 new_session: cli.new_session,
169 run: cli.run.clone(),
170 branch_from: cli.branch_from.clone(),
171 profile: cli.profile.clone(),
172 output: cli.output,
173 hitl: cli.hitl,
174 goal: None,
175 worker: cli.worker.clone(),
176 worker_label: cli.worker_label.clone(),
177 worktree: cli.worktree.clone(),
178 worktree_name: cli.worktree_name.clone(),
179 branch: cli.branch,
180 session_affinity_id: None,
181 };
182 return self.run_prompt(&command);
183 }
184 let default_command = CliCommand::Tui(TuiCommand {
185 session: cli.session.clone(),
186 run: cli.run.clone(),
187 after: None,
188 interactive: false,
189 snapshot: false,
190 output: OutputMode::Text,
191 });
192 match cli.command.unwrap_or(default_command) {
193 CliCommand::Version => Ok(format!("{}\n", sdk_name())),
194 CliCommand::Diagnostics => Ok(self.diagnostics()?),
195 CliCommand::ReplayCheck => {
196 Ok("run `make replay-check` from the repository root\n".to_string())
197 }
198 CliCommand::Update(command) => Self::update(&command),
199 CliCommand::Run(command) => self.run_prompt(&command),
200 CliCommand::Rpc(_) => Err(CliError::Usage(
201 "rpc owns stdin/stdout and must be run through run_from_env".to_string(),
202 )),
203 CliCommand::Session { command } => self.session(command),
204 CliCommand::Profile { command } => self.profile(command),
205 CliCommand::Setup(command) => self.setup(&command),
206 CliCommand::Auth { command } => Self::auth(command),
207 CliCommand::Skill { command } => self.skills(command),
208 CliCommand::Subagent { command } => self.subagents(command),
209 CliCommand::Mcp { command } => self.mcp(command),
210 CliCommand::Tools { command } => self.tools(&command),
211 CliCommand::Tui(command) => self.tui(&command),
212 CliCommand::Approval { command } => self.approval(command),
213 CliCommand::Deferred { command } => self.deferred(command),
214 CliCommand::Resume(command) => self.resume(&command),
215 CliCommand::Reset(command) => self.reset(&command),
216 CliCommand::Config { command } => self.config(command),
217 CliCommand::Completion { shell } => render_completion(shell),
218 }
219 }
220
221 pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
222 let execution = self.execute_prompt_run(command, None)?;
223 match execution.output_mode {
224 OutputMode::Text => Ok(render_display_text(&execution.messages)),
225 OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
226 OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
227 OutputMode::Json => render_prompt_run_json(&execution),
228 OutputMode::Silent => Ok(format!(
229 "session_id={}\nrun_id={}\nstatus={}\n",
230 execution.session_id, execution.run_id, execution.status
231 )),
232 }
233 }
234
235 fn execute_prompt_run(
236 &mut self,
237 command: &RunCommand,
238 stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
239 ) -> CliResult<PromptRunExecution> {
240 self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
241 }
242
243 #[allow(clippy::too_many_lines)]
244 fn execute_prompt_run_with_channels(
245 &mut self,
246 command: &RunCommand,
247 prompt_input: Option<PromptInput>,
248 stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
249 steering_receiver: Option<mpsc::Receiver<CliSteeringMessage>>,
250 cancel_receiver: Option<mpsc::Receiver<()>>,
251 ) -> CliResult<PromptRunExecution> {
252 let prepared = self.prepare_prompt_run(command, prompt_input)?;
253 let run_on_error = prepared.run.clone();
254 let executed = match Self::run_prepared_prompt(
255 prepared,
256 stream_sender,
257 steering_receiver,
258 cancel_receiver,
259 ) {
260 Ok(executed) => executed,
261 Err(error) => {
262 self.fail_prepared_prompt_run(run_on_error, &error)?;
263 return Err(error);
264 }
265 };
266 self.complete_prompt_run(executed)
267 }
268
269 pub(super) fn prepare_prompt_run(
270 &mut self,
271 command: &RunCommand,
272 prompt_input: Option<PromptInput>,
273 ) -> CliResult<PreparedPromptRun> {
274 let input =
275 prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
276 let raw_prompt = input.text.clone();
277 let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
278 let prompt = slash_expansion
279 .as_ref()
280 .map_or(raw_prompt, |expanded| expanded.prompt.clone());
281 let mut run_input = PromptInput {
282 text: prompt.clone(),
283 attachments: input.attachments,
284 extra_text_parts: input.extra_text_parts,
285 guidance_text_parts: input.guidance_text_parts,
286 };
287 let worktree = self.resolve_worktree(command)?;
288 let selected_profile = command
289 .profile
290 .as_deref()
291 .unwrap_or(&self.config.default_profile);
292 let resolved_profile = resolve_profile(&self.config, Some(selected_profile))?;
293 let mut run_config = self.config.clone();
294 if let Some(worktree) = worktree.as_ref() {
295 run_config.workspace_root.clone_from(&worktree.path);
296 }
297 validate_environment_config(&run_config)?;
298 append_guidance_files(&mut run_input, &run_config);
299 let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
300 let environment = resolve_environment_for_session(&run_config, &session_id)?;
301 let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
302 if restore_from.is_none() && !created {
303 restore_from = self
304 .store()?
305 .load_session(&session_id)
306 .ok()
307 .and_then(|session| {
308 session
309 .active_run_id
310 .or(session.head_run_id)
311 .or(session.head_success_run_id)
312 .map(|run| run.as_str().to_string())
313 });
314 }
315 let restore_state = self
316 .store()?
317 .load_restore_state(&session_id, restore_from.as_deref())?;
318 let mut run =
319 self.store()?
320 .append_run(&session_id, prompt, restore_from, &resolved_profile.name)?;
321 apply_starweaver_run_metadata(
322 &mut run,
323 command,
324 worktree.as_ref(),
325 slash_expansion.as_ref(),
326 );
327 if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
328 run.metadata.insert(
329 "starweaver.session_affinity_id".to_string(),
330 json!(session_affinity_id),
331 );
332 }
333 write_current_session(&self.config, &session_id)?;
334 let hitl = command.hitl.unwrap_or(self.config.default_hitl);
335 let goal = command
336 .goal
337 .as_ref()
338 .map(|goal| crate::runner::CliGoalRunPolicy {
339 objective: goal.objective.clone(),
340 max_iterations: goal.max_iterations.max(1),
341 });
342 let output_mode = command.output.unwrap_or(self.config.default_output);
343 Ok(PreparedPromptRun {
344 session_id,
345 run_id: run.run_id.as_str().to_string(),
346 output_mode,
347 run,
348 run_input,
349 resolved_profile,
350 environment,
351 restore_state,
352 policy: CliRunPolicy { hitl, goal },
353 })
354 }
355
356 pub(super) fn run_prepared_prompt(
357 prepared: PreparedPromptRun,
358 stream_sender: Option<mpsc::Sender<AgentStreamRecord>>,
359 steering_receiver: Option<mpsc::Receiver<CliSteeringMessage>>,
360 cancel_receiver: Option<mpsc::Receiver<()>>,
361 ) -> CliResult<ExecutedPromptRun> {
362 let PreparedPromptRun {
363 output_mode,
364 run,
365 run_input,
366 resolved_profile,
367 environment,
368 restore_state,
369 policy,
370 ..
371 } = prepared;
372 let result = if stream_sender.is_some()
373 || steering_receiver.is_some()
374 || cancel_receiver.is_some()
375 {
376 execute_agent_session_with_channels(
377 run_input,
378 &run,
379 &resolved_profile,
380 &environment.provider,
381 environment.process_provider.as_ref(),
382 restore_state,
383 &policy,
384 stream_sender,
385 steering_receiver,
386 cancel_receiver,
387 )
388 } else {
389 execute_agent_session(
390 run_input,
391 &run,
392 &resolved_profile,
393 &environment.provider,
394 environment.process_provider.as_ref(),
395 restore_state,
396 &policy,
397 )
398 };
399 result.map(|execution| ExecutedPromptRun {
400 run,
401 output_mode,
402 execution,
403 })
404 }
405
406 pub(super) fn fail_prepared_prompt_run(
407 &mut self,
408 mut run: RunRecord,
409 error: &CliError,
410 ) -> CliResult<()> {
411 let messages = failed_display_message(&run, &error.to_string());
412 self.store()?
413 .fail_run_with_messages(&mut run, error.to_string(), &messages)
414 }
415
416 pub(super) fn complete_prompt_run(
417 &mut self,
418 executed: ExecutedPromptRun,
419 ) -> CliResult<PromptRunExecution> {
420 let ExecutedPromptRun {
421 mut run,
422 output_mode,
423 execution,
424 } = executed;
425 let execution_failed = execution.artifacts.status == RunStatus::Failed;
426 let output = execution.output;
427 let messages = self
428 .store()?
429 .complete_run(&mut run, output.clone(), execution.artifacts)?;
430 if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
431 return Err(CliError::Run(output));
432 }
433 if self.config.auto_trim {
434 let keep_runs = self.config.current_session_keep_recent_runs;
435 let _report =
436 self.store()?
437 .trim(vec![run.session_id.as_str().to_string()], keep_runs, false)?;
438 }
439 Ok(PromptRunExecution {
440 session_id: run.session_id.as_str().to_string(),
441 run_id: run.run_id.as_str().to_string(),
442 status: run_status_name(run.status).to_string(),
443 output_mode,
444 messages,
445 })
446 }
447
448 fn resolve_session(
449 &mut self,
450 command: &RunCommand,
451 profile: &str,
452 ) -> CliResult<(String, bool)> {
453 if command.new_session {
454 let session = self
455 .store()?
456 .create_session(profile, Some("CLI session".to_string()))?;
457 return Ok((session.session_id.as_str().to_string(), true));
458 }
459 if let Some(session_id) = command.session.as_ref() {
460 self.store()?.load_session(session_id)?;
461 return Ok((session_id.clone(), false));
462 }
463 if command.continue_session {
464 if let Some(session_id) = read_current_session(&self.config)? {
465 if self.store()?.load_session(&session_id).is_ok() {
466 return Ok((session_id, false));
467 }
468 }
469 if let Some(session) = self.store()?.latest_session()? {
470 return Ok((session.session_id.as_str().to_string(), false));
471 }
472 }
473 let session = self
474 .store()?
475 .create_session(profile, Some("CLI session".to_string()))?;
476 Ok((session.session_id.as_str().to_string(), true))
477 }
478
479 fn session(&mut self, command: SessionCommand) -> CliResult<String> {
480 match command {
481 SessionCommand::List(command) => {
482 let sessions = self.store()?.list_sessions(command.limit)?;
483 render_sessions(&sessions, command.output)
484 }
485 SessionCommand::Show(command) => {
486 let session = self.store()?.load_session(&command.session_id)?;
487 let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
488 let value = session_value(&session);
489 render_session_show(&value, &runs, command.output)
490 }
491 SessionCommand::Replay(command) => {
492 let messages = self.store()?.replay_display(
493 &command.session_id,
494 command.run.as_deref(),
495 command.after,
496 )?;
497 match command.output {
498 OutputMode::Text => Ok(render_display_text(&messages)),
499 OutputMode::DisplayJsonl => render_display_jsonl(&messages),
500 OutputMode::AguiJsonl => render_agui_jsonl(&messages),
501 OutputMode::Json => Ok(format!(
502 "{}\n",
503 serde_json::to_string(&json!({
504 "sessionId": command.session_id,
505 "runId": command.run,
506 "messages": messages,
507 "status": "replayed"
508 }))?
509 )),
510 OutputMode::Silent => Ok(format!(
511 "session_id={}\nmessages={}\nstatus=replayed\n",
512 command.session_id,
513 messages.len()
514 )),
515 }
516 }
517 SessionCommand::Delete(command) => {
518 if !command.yes {
519 return Err(CliError::Usage(
520 "pass --yes to delete a local session".to_string(),
521 ));
522 }
523 let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
524 let deleted = self.store()?.delete_session(&session_id)?;
525 if read_current_session(&self.config)?.as_deref() == Some(session_id.as_str()) {
526 let _removed =
527 remove_file_if_exists(&self.config.project_dir.join("state.json"))?;
528 }
529 render_session_delete(&session_id, deleted, command.output)
530 }
531 SessionCommand::Trim(command) => {
532 let sessions = if command.all {
533 self.store()?.all_session_ids()?
534 } else if let Some(session_id) = command.session {
535 vec![session_id]
536 } else {
537 read_current_session(&self.config)?.into_iter().collect()
538 };
539 let older_than = command
540 .older_than
541 .as_deref()
542 .map(parse_duration)
543 .transpose()?;
544 let report = self.store()?.trim_with_age(
545 sessions,
546 command.keep_runs,
547 older_than,
548 command.dry_run,
549 )?;
550 render_trim_report(&report, command.output)
551 }
552 }
553 }
554
555 fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
556 match command {
557 ApprovalCommand::List(command) => self.approval_list(&command),
558 ApprovalCommand::Show { approval_id } => {
559 let approval = self.store()?.load_approval(&approval_id)?;
560 Ok(format!("{}\n", serde_json::to_string(&approval)?))
561 }
562 ApprovalCommand::Approve(command) => {
563 self.approval_decision(&command, ApprovalStatus::Approved)
564 }
565 ApprovalCommand::Reject(command) => {
566 self.approval_decision(&command, ApprovalStatus::Denied)
567 }
568 }
569 }
570
571 fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
572 let approvals = self
573 .store()?
574 .list_approvals(command.session.as_deref(), command.run.as_deref())?;
575 render_approvals(&approvals, command.output)
576 }
577
578 fn approval_decision(
579 &mut self,
580 command: &ApprovalDecisionCommand,
581 status: ApprovalStatus,
582 ) -> CliResult<String> {
583 let approval =
584 self.store()?
585 .decide_approval(&command.approval_id, status, command.reason.clone())?;
586 match command.output {
587 OutputMode::Text => Ok(format!(
588 "approval_id={}\nstatus={}\nrun_id={}\n",
589 approval.approval_id,
590 approval_status_name(approval.status),
591 approval.run_id.as_str()
592 )),
593 OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
594 Ok(format!("{}\n", serde_json::to_string(&approval)?))
595 }
596 OutputMode::Silent => Ok(format!(
597 "approval_id={}\nstatus={}\n",
598 approval.approval_id,
599 approval_status_name(approval.status)
600 )),
601 }
602 }
603
604 fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
605 match command {
606 DeferredCommand::List(command) => self.deferred_list(&command),
607 DeferredCommand::Show { deferred_id } => {
608 let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
609 Ok(format!("{}\n", serde_json::to_string(&deferred)?))
610 }
611 DeferredCommand::Complete(command) => self.deferred_complete(&command),
612 DeferredCommand::Fail(command) => self.deferred_fail(&command),
613 }
614 }
615
616 fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
617 let records = self
618 .store()?
619 .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
620 render_deferred(&records, command.output)
621 }
622
623 fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
624 let value = serde_json::from_str::<Value>(&command.result)
625 .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
626 let record = self
627 .store()?
628 .complete_deferred_tool(&command.deferred_id, value)?;
629 render_deferred_decision(&record, command.output)
630 }
631
632 fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
633 let record = self
634 .store()?
635 .fail_deferred_tool(&command.deferred_id, &command.error)?;
636 render_deferred_decision(&record, command.output)
637 }
638
639 fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
640 let session_id = self.resolve_session_id(command.session.as_deref())?;
641 let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
642 let run_command = RunCommand {
643 prompt: Some(format!(
644 "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
645 command.prompt,
646 source_run.run_id.as_str()
647 )),
648 prompt_parts: Vec::new(),
649 session: Some(session_id),
650 continue_session: false,
651 new_session: false,
652 run: Some(source_run.run_id.as_str().to_string()),
653 branch_from: None,
654 profile: source_run.profile.clone(),
655 output: command.output,
656 hitl: command.hitl,
657 goal: None,
658 worker: None,
659 worker_label: None,
660 worktree: None,
661 worktree_name: None,
662 branch: None,
663 session_affinity_id: None,
664 };
665 self.run_prompt(&run_command)
666 }
667
668 fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
669 if let Some(session_id) = requested {
670 self.store()?.load_session(session_id)?;
671 return Ok(session_id.to_string());
672 }
673 if let Some(session_id) = read_current_session(&self.config)? {
674 if self.store()?.load_session(&session_id).is_ok() {
675 return Ok(session_id);
676 }
677 }
678 self.store()?
679 .latest_session()?
680 .map(|session| session.session_id.as_str().to_string())
681 .ok_or_else(|| CliError::NotFound("session".to_string()))
682 }
683
684 fn resolve_resume_run(
685 &mut self,
686 session_id: &str,
687 requested: Option<&str>,
688 ) -> CliResult<starweaver_session::RunRecord> {
689 if let Some(run_id) = requested {
690 return self.store()?.load_run(session_id, run_id);
691 }
692 let session = self.store()?.load_session(session_id)?;
693 let run_id = session
694 .active_run_id
695 .as_ref()
696 .or(session.head_run_id.as_ref())
697 .ok_or_else(|| CliError::NotFound("run".to_string()))?;
698 self.store()?.load_run(session_id, run_id.as_str())
699 }
700}
701
702#[allow(dead_code)]
703fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
704 if !config.oauth_refresh.enabled {
705 return Ok(None);
706 }
707 let models = list_profiles(config)
708 .into_iter()
709 .map(|profile| profile.model_id)
710 .collect::<Vec<_>>();
711 if config.oauth_refresh.interval_seconds == 0 {
712 return Err(CliError::Usage(
713 "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
714 ));
715 }
716 if config.oauth_refresh.failure_retry_seconds == 0 {
717 return Err(CliError::Usage(
718 "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
719 ));
720 }
721 let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
722 models.iter().map(String::as_str),
723 Duration::from_secs(config.oauth_refresh.interval_seconds),
724 Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
725 config.oauth_refresh.refresh_on_startup,
726 )
727 .map_err(oauth_cli_error)?;
728 let Some(mut supervisor) = supervisor.take() else {
729 return Ok(None);
730 };
731 let (stop_sender, stop_receiver) = mpsc::channel::<()>();
732 let handle = thread::Builder::new()
733 .name("starweaver-oauth-refresh".to_string())
734 .spawn(move || {
735 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
736 .enable_all()
737 .build()
738 else {
739 return;
740 };
741 runtime.block_on(async move {
742 supervisor.start().await;
743 let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
744 supervisor.shutdown().await;
745 });
746 })
747 .map_err(|error| CliError::Run(error.to_string()))?;
748 Ok(Some(OAuthRefreshGuard {
749 stop_sender,
750 handle: Some(handle),
751 }))
752}
753
754fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
755 items
756 .iter()
757 .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
758 .collect::<Result<String, _>>()
759 .map_err(CliError::from)
760}
761
762const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
763 match status {
764 starweaver_session::RunStatus::Queued => "queued",
765 starweaver_session::RunStatus::Running => "running",
766 starweaver_session::RunStatus::Waiting => "waiting",
767 starweaver_session::RunStatus::Completed => "completed",
768 starweaver_session::RunStatus::Failed => "failed",
769 starweaver_session::RunStatus::Cancelled => "cancelled",
770 }
771}
772
773fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
774 let trimmed = value.trim();
775 if trimmed.is_empty() {
776 return Err(CliError::Usage("duration cannot be empty".to_string()));
777 }
778 let (number, unit) = trimmed.split_at(
779 trimmed
780 .find(|ch: char| !ch.is_ascii_digit())
781 .unwrap_or(trimmed.len()),
782 );
783 let amount = number
784 .parse::<i64>()
785 .map_err(|error| CliError::Usage(error.to_string()))?;
786 let duration = match unit {
787 "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
788 "m" | "min" | "mins" => chrono::Duration::minutes(amount),
789 "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
790 "d" | "day" | "days" => chrono::Duration::days(amount),
791 other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
792 };
793 Ok(duration)
794}
795
796#[cfg(test)]
797mod tests;