Skip to main content

starweaver_cli/
service.rs

1//! CLI service layer over local storage and SDK execution.
2#![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
136/// CLI service.
137pub struct CliService {
138    config: CliConfig,
139    store: Option<LocalStore>,
140}
141
142impl CliService {
143    /// Open service from resolved config.
144    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    /// Execute a parsed CLI command.
161    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;