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