1#![allow(clippy::redundant_pub_crate)]
3
4use std::{fs, path::Path, sync::mpsc, thread, time::Duration};
5
6use serde_json::{json, Value};
7use starweaver_agent::ResumableState;
8use starweaver_core::sdk_name;
9use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
10use starweaver_runtime::AgentStreamRecord;
11use starweaver_session::{ApprovalStatus, RunRecord, RunStatus};
12use starweaver_stream::DisplayMessage;
13
14use crate::{
15 args::{
16 ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
17 DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
18 OutputMode, ResumeCommand, RunCommand, SessionCommand, TuiCommand,
19 },
20 config::{read_current_session, write_current_session, CliConfig},
21 environment::{
22 resolve_environment_for_session_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
137pub struct CliService {
139 config: CliConfig,
140 store: Option<LocalStore>,
141}
142
143impl CliService {
144 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 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;