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