1#![allow(clippy::redundant_pub_crate)]
3
4use std::{
5 collections::BTreeSet,
6 fs,
7 path::Path,
8 sync::{
9 Arc, Mutex,
10 atomic::{AtomicBool, Ordering},
11 mpsc,
12 },
13 thread,
14 time::Duration,
15};
16
17use chrono::Utc;
18use ring::digest::{SHA256, digest};
19use serde_json::{Value, json};
20use starweaver_agent::materialization::STARWEAVER_AGENT_POLICY_VERSION;
21use starweaver_agent::{
22 ContinuationMaterialization, ResolvedAgentMaterialization, ResumableState,
23 environment_binding_class,
24};
25use starweaver_core::{RunId, SessionId, sdk_name};
26use starweaver_oauth_provider::create_oauth_refresh_supervisor_for_models_with_options;
27use starweaver_runtime::AgentStreamRecord;
28use starweaver_session::{
29 ApprovalStatus, ExecutionStatus, HitlResumeAbortOutcome, HitlResumeClaim, PreparedContinuation,
30 RunAdmissionLease, RunRecord, RunStatus, SessionSearchFilter, SessionSearchGranularity,
31 SessionSearchQuery, SessionSearchSource, SessionStatus, SessionStore,
32};
33use starweaver_stream::DisplayMessage;
34
35use crate::{
36 CliError, CliResult,
37 args::{
38 ApprovalCommand, ApprovalDecisionCommand, ApprovalListCommand, Cli, CliCommand,
39 DeferredCommand, DeferredCompleteCommand, DeferredFailCommand, DeferredListCommand,
40 OutputMode, ResumeCommand, RunCommand, SessionCommand, SessionSearchCommand,
41 SessionSearchGranularityArg, SessionSearchSourceArg, SessionSearchStatusArg,
42 StorageCommand, StorageImportLegacyCommand, TuiCommand,
43 },
44 config::{
45 CliConfig, read_current_session, read_last_retention_maintenance,
46 remove_project_state_if_current_session, write_current_session,
47 write_last_retention_maintenance,
48 },
49 environment::{
50 EnvironmentAttachmentAccessMode, ResolvedEnvironment,
51 resolve_environment_for_session_with_attachments, validate_environment_config,
52 },
53 local_store::{
54 HITL_RESUME_CLAIM_ID_METADATA_KEY, HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY,
55 HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY, LocalSessionStore, LocalStore,
56 },
57 profiles::{ResolvedProfile, list_profiles, resolve_profile},
58 prompt_input::PromptInput,
59 runner::{
60 CliAgentExecutionHost, CliRunPolicy, CliSteeringChannel, execute_agent_session_with_host,
61 failed_display_message, validate_prepared_hitl_continuation,
62 },
63 slash_commands::{ExpandedExplicitSkills, expand_explicit_skills, expand_slash_command},
64};
65
66mod auth;
67mod catalog;
68mod rendering;
69mod setup;
70mod tui;
71mod worktree;
72
73use auth::oauth_cli_error;
74use rendering::{
75 approval_status_name, render_agui_jsonl, render_approvals, render_completion, render_deferred,
76 render_deferred_decision, render_display_jsonl, render_display_text, render_prompt_run_json,
77 render_session_delete, render_session_search, render_session_show, render_sessions,
78 render_trim_report, session_value,
79};
80#[cfg(test)]
81use tui::model_choices;
82use worktree::apply_starweaver_run_metadata;
83
84pub(super) struct PromptRunExecution {
85 pub(super) session_id: String,
86 pub(super) run_id: String,
87 pub(super) status: String,
88 pub(super) output_mode: OutputMode,
89 pub(super) messages: Vec<DisplayMessage>,
90 pub(super) continuation: Option<ContinuationMaterialization>,
91}
92
93pub(super) struct PreparedPromptRun {
94 pub(super) session_id: String,
95 pub(super) run_id: String,
96 pub(super) output_mode: OutputMode,
97 pub(super) run: RunRecord,
98 pub(super) admission: CliRunAdmission,
99 admission_cancel_receiver: Option<mpsc::Receiver<()>>,
100 run_input: PromptInput,
101 resolved_profile: ResolvedProfile,
102 pub(super) environment: ResolvedEnvironment,
103 restore_state: Option<ResumableState>,
104 prepared_continuation: Option<PreparedContinuation>,
105 policy: CliRunPolicy,
106 execution_host: CliAgentExecutionHost,
107 hitl_resume_claim: Option<HitlResumeClaim>,
108}
109
110impl PreparedPromptRun {
111 pub(super) fn set_execution_host(&mut self, execution_host: CliAgentExecutionHost) {
112 self.execution_host = execution_host;
113 }
114}
115
116pub(super) struct ExecutedPromptRun {
117 run: RunRecord,
118 output_mode: OutputMode,
119 execution: crate::runner::CliRunExecution,
120 admission: CliRunAdmission,
121}
122
123enum HitlResumeClaimOperation {
124 Claim(HitlResumeClaim),
125 StartEffect {
126 lease: RunAdmissionLease,
127 source_run_id: RunId,
128 claim_id: String,
129 },
130 Release {
131 session_id: SessionId,
132 run_id: RunId,
133 claim_id: String,
134 },
135}
136
137const PROJECT_GUIDANCE_TAG: &str = "project-guidance";
138const USER_RULES_TAG: &str = "user-rules";
139
140fn restore_requires_hitl_claim(status: RunStatus, hitl_resume: bool, branch_from: bool) -> bool {
141 status == RunStatus::Waiting && !hitl_resume && !branch_from
142}
143
144fn deterministic_hitl_resume_claim_id(session_id: &str, run_id: &RunId) -> String {
145 const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
146 let identity = format!(
147 "starweaver.cli.hitl_resume_claim.v1\0{session_id}\0{}",
148 run_id.as_str()
149 );
150 let digest = digest(&SHA256, identity.as_bytes());
151 let mut fingerprint = String::with_capacity(digest.as_ref().len() * 2);
152 for byte in digest.as_ref() {
153 fingerprint.push(char::from(HEX_DIGITS[usize::from(byte >> 4)]));
154 fingerprint.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)]));
155 }
156 format!("cli-hitl-resume-{fingerprint}")
157}
158
159fn search_query(command: SessionSearchCommand) -> SessionSearchQuery {
160 let session_statuses = command
161 .status
162 .map(|status| match status {
163 SessionSearchStatusArg::Active => SessionStatus::Active,
164 SessionSearchStatusArg::Archived => SessionStatus::Archived,
165 SessionSearchStatusArg::Failed => SessionStatus::Failed,
166 })
167 .into_iter()
168 .collect();
169 let sources = command
170 .sources
171 .into_iter()
172 .map(|source| match source {
173 SessionSearchSourceArg::SessionMetadata => SessionSearchSource::SessionMetadata,
174 SessionSearchSourceArg::RunInput => SessionSearchSource::RunInput,
175 SessionSearchSourceArg::RunOutputPreview => SessionSearchSource::RunOutputPreview,
176 SessionSearchSourceArg::DisplayMessage => SessionSearchSource::DisplayMessage,
177 })
178 .collect::<BTreeSet<_>>();
179 let granularity = match command.granularity {
180 SessionSearchGranularityArg::Session => SessionSearchGranularity::Session,
181 SessionSearchGranularityArg::Run => SessionSearchGranularity::Run,
182 SessionSearchGranularityArg::Occurrence => SessionSearchGranularity::Occurrence,
183 };
184 SessionSearchQuery {
185 text: command.text,
186 filter: SessionSearchFilter {
187 session_statuses,
188 profile: command.profile,
189 workspace: command.workspace,
190 ..SessionSearchFilter::default()
191 },
192 sources,
193 granularity,
194 limit: command.limit,
195 cursor: command.cursor,
196 ..SessionSearchQuery::default()
197 }
198}
199
200fn append_guidance_files(input: &mut PromptInput, config: &CliConfig) {
201 if let Some(project_guidance) = load_project_guidance(&config.workspace_root) {
202 input.push_guidance_text_part(project_guidance);
203 }
204 if let Some(user_rules) = load_user_rules(&config.global_dir) {
205 input.push_guidance_text_part(user_rules);
206 }
207}
208
209fn append_explicit_skill_guidance(
210 input: &mut PromptInput,
211 explicit_skills: Option<&ExpandedExplicitSkills>,
212) {
213 let Some(explicit_skills) = explicit_skills else {
214 return;
215 };
216 let names = explicit_skills
217 .skills
218 .iter()
219 .map(|skill| skill.package.name.as_str())
220 .collect::<Vec<_>>()
221 .join(", ");
222 input.push_guidance_text_part(format!(
223 "<explicit-skill-composition>\nThe user explicitly activated these skills in priority order: {names}. Apply all compatible instructions. Treat the first skill as the primary workflow and later skills as supporting workflows. The current user request overrides skill defaults. If selected skills conflict irreconcilably, ask the user for clarification.\n</explicit-skill-composition>"
224 ));
225 for skill in &explicit_skills.skills {
226 let package = &skill.package;
227 let Some(body) = package.body.as_deref() else {
228 continue;
229 };
230 input.push_guidance_text_part(format!(
231 "<explicitly-activated-skill>\n<name>{}</name>\n<path>{}</path>\n<instructions>\n{}\n</instructions>\n</explicitly-activated-skill>",
232 escape_xml_text(&package.name),
233 escape_xml_text(&package.path),
234 body
235 ));
236 }
237}
238
239fn escape_xml_text(value: &str) -> String {
240 value
241 .replace('&', "&")
242 .replace('<', "<")
243 .replace('>', ">")
244}
245
246fn load_project_guidance(workspace_root: &Path) -> Option<String> {
247 let path = workspace_root.join("AGENTS.md");
248 let content = read_non_empty_utf8_file(&path)?;
249 Some(format!(
250 "<{PROJECT_GUIDANCE_TAG} name=AGENTS.md>\n{content}\n</{PROJECT_GUIDANCE_TAG}>"
251 ))
252}
253
254fn load_user_rules(global_dir: &Path) -> Option<String> {
255 let path = global_dir.join("RULES.md");
256 let content = read_non_empty_utf8_file(&path)?;
257 Some(format!(
258 "<{USER_RULES_TAG} location={}>\n{content}\n</{USER_RULES_TAG}>",
259 path_absolute_posix(&path)
260 ))
261}
262
263fn read_non_empty_utf8_file(path: &Path) -> Option<String> {
264 let content = fs::read_to_string(path).ok()?;
265 (!content.trim().is_empty()).then_some(content)
266}
267
268fn path_absolute_posix(path: &Path) -> String {
269 path.canonicalize()
270 .unwrap_or_else(|_| path.to_path_buf())
271 .display()
272 .to_string()
273 .replace('\\', "/")
274}
275
276#[allow(dead_code)]
277struct OAuthRefreshGuard {
278 stop_sender: mpsc::Sender<()>,
279 handle: Option<thread::JoinHandle<()>>,
280}
281
282impl Drop for OAuthRefreshGuard {
283 fn drop(&mut self) {
284 let _ = self.stop_sender.send(());
285 if let Some(handle) = self.handle.take() {
286 let _ = handle.join();
287 }
288 }
289}
290
291#[derive(Clone)]
292pub(super) struct CliRunAdmission {
293 config: CliConfig,
294 lease: Arc<Mutex<RunAdmissionLease>>,
295 stop_sender: mpsc::Sender<()>,
296 handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
297 released: Arc<AtomicBool>,
298 lost: Arc<AtomicBool>,
299}
300
301impl CliRunAdmission {
302 fn start(config: CliConfig, lease: RunAdmissionLease) -> (Self, mpsc::Receiver<()>) {
303 const RETRY_INTERVAL: Duration = Duration::from_secs(1);
304 const SAFETY_MARGIN: chrono::Duration = chrono::Duration::seconds(5);
305
306 let lease = Arc::new(Mutex::new(lease));
307 let (stop_sender, stop_receiver) = mpsc::channel();
308 let (cancel_sender, cancel_receiver) = mpsc::channel();
309 let lost = Arc::new(AtomicBool::new(false));
310 let heartbeat_lease = Arc::clone(&lease);
311 let heartbeat_lost = Arc::clone(&lost);
312 let heartbeat_config = config.clone();
313 let handle = thread::spawn(move || {
314 let mut wait = Duration::from_secs(10);
315 loop {
316 match stop_receiver.recv_timeout(wait) {
317 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
318 Err(mpsc::RecvTimeoutError::Timeout) => {}
319 }
320 let current = if let Ok(lease) = heartbeat_lease.lock() {
321 lease.clone()
322 } else {
323 heartbeat_lost.store(true, Ordering::Release);
324 let _ = cancel_sender.send(());
325 break;
326 };
327 if let Ok(refreshed) =
328 LocalStore::heartbeat_run_admission(&heartbeat_config, ¤t)
329 {
330 if let Ok(mut lease) = heartbeat_lease.lock() {
331 *lease = refreshed;
332 } else {
333 heartbeat_lost.store(true, Ordering::Release);
334 let _ = cancel_sender.send(());
335 break;
336 }
337 wait = Duration::from_secs(10);
338 } else {
339 if Utc::now() + SAFETY_MARGIN >= current.lease_expires_at {
340 heartbeat_lost.store(true, Ordering::Release);
341 let _ = cancel_sender.send(());
342 break;
343 }
344 wait = RETRY_INTERVAL;
345 }
346 }
347 });
348 (
349 Self {
350 config,
351 lease,
352 stop_sender,
353 handle: Arc::new(Mutex::new(Some(handle))),
354 released: Arc::new(AtomicBool::new(false)),
355 lost,
356 },
357 cancel_receiver,
358 )
359 }
360
361 fn refresh(&self) -> CliResult<()> {
362 if self.lost.load(Ordering::Acquire) {
363 return Err(CliError::Run(
364 "run admission lease was lost before completion".to_string(),
365 ));
366 }
367 if self.released.load(Ordering::Acquire) {
368 return Err(CliError::Run(
369 "run admission lease was already released".to_string(),
370 ));
371 }
372 let current = self
373 .lease
374 .lock()
375 .map_err(|error| CliError::Storage(error.to_string()))?
376 .clone();
377 let refreshed = LocalStore::heartbeat_run_admission(&self.config, ¤t)?;
378 *self
379 .lease
380 .lock()
381 .map_err(|error| CliError::Storage(error.to_string()))? = refreshed;
382 Ok(())
383 }
384
385 fn current_lease(&self) -> CliResult<RunAdmissionLease> {
386 if self.lost.load(Ordering::Acquire) {
387 return Err(CliError::Run(
388 "run admission lease was lost before evidence commit".to_string(),
389 ));
390 }
391 self.lease
392 .lock()
393 .map_err(|error| CliError::Storage(error.to_string()))
394 .map(|lease| lease.clone())
395 }
396
397 fn release(&self, store: &LocalStore) -> CliResult<()> {
398 if self.released.swap(true, Ordering::AcqRel) {
399 return Ok(());
400 }
401 let _ = self.stop_sender.send(());
402 if let Ok(mut handle) = self.handle.lock()
403 && let Some(handle) = handle.take()
404 {
405 let _ = handle.join();
406 }
407 let lease = self
408 .lease
409 .lock()
410 .map_err(|error| CliError::Storage(error.to_string()))?
411 .clone();
412 store.release_run_admission(&lease)
413 }
414}
415
416pub struct CliService {
418 config: CliConfig,
419 store: Option<LocalStore>,
420 host_instance_id: String,
421}
422
423impl CliService {
424 pub fn open(config: CliConfig) -> CliResult<Self> {
426 Ok(Self {
427 config,
428 store: None,
429 host_instance_id: format!("cli-host-{}", uuid::Uuid::new_v4()),
430 })
431 }
432
433 fn store(&mut self) -> CliResult<&mut LocalStore> {
434 if self.store.is_none() {
435 self.store = Some(LocalStore::open(&self.config)?);
436 }
437 self.store
438 .as_mut()
439 .ok_or_else(|| CliError::Storage("store initialization failed".to_string()))
440 }
441
442 pub fn execute(mut self, cli: Cli) -> CliResult<String> {
444 if let Some(prompt) = cli.prompt.clone() {
445 let command = RunCommand {
446 prompt: Some(prompt),
447 prompt_parts: Vec::new(),
448 session: cli.session.clone(),
449 continue_session: cli.continue_session,
450 new_session: cli.new_session,
451 run: cli.run.clone(),
452 branch_from: cli.branch_from.clone(),
453 profile: cli.profile.clone(),
454 continuation_mode: cli.continuation_mode,
455 output: cli.output,
456 hitl: cli.hitl,
457 goal: None,
458 worker: cli.worker.clone(),
459 worker_label: cli.worker_label.clone(),
460 worktree: cli.worktree.clone(),
461 worktree_name: cli.worktree_name.clone(),
462 branch: cli.branch,
463 session_affinity_id: None,
464 environment_attachments: Vec::new(),
465 hitl_resume: false,
466 };
467 return self.run_prompt(&command);
468 }
469 let default_command = CliCommand::Tui(TuiCommand {
470 session: cli.session.clone(),
471 run: cli.run.clone(),
472 after: None,
473 interactive: false,
474 snapshot: false,
475 output: OutputMode::Text,
476 render_mode: None,
477 });
478 match cli.command.unwrap_or(default_command) {
479 CliCommand::Version => Ok(format!("{}\n", sdk_name())),
480 CliCommand::Diagnostics => Ok(self.diagnostics()?),
481 CliCommand::ReplayCheck => {
482 Ok("run `make replay-check` from the repository root\n".to_string())
483 }
484 CliCommand::Update(command) => Self::update(&command),
485 CliCommand::Run(command) => self.run_prompt(&command),
486 CliCommand::Session { command } => self.session(command),
487 CliCommand::Storage { command } => self.storage(command),
488 CliCommand::Profile { command } => self.profile(command),
489 CliCommand::Setup(command) => self.setup(&command),
490 CliCommand::Auth { command } => Self::auth(command),
491 CliCommand::Skill { command } => self.skills(command),
492 CliCommand::Subagent { command } => self.subagents(command),
493 CliCommand::Mcp { command } => self.mcp(command),
494 CliCommand::Tools { command } => self.tools(&command),
495 CliCommand::Tui(command) => self.tui(&command),
496 CliCommand::Approval { command } => self.approval(command),
497 CliCommand::Deferred { command } => self.deferred(command),
498 CliCommand::Resume(command) => self.resume(&command),
499 CliCommand::Reset(command) => self.reset(&command),
500 CliCommand::Config { command } => self.config(command),
501 CliCommand::Completion { shell } => render_completion(shell),
502 }
503 }
504
505 pub(crate) fn run_prompt(&mut self, command: &RunCommand) -> CliResult<String> {
506 let execution = self.execute_prompt_run(command, None)?;
507 match execution.output_mode {
508 OutputMode::Text => {
509 let output = render_display_text(&execution.messages);
510 Ok(continuation_drift_prefix(execution.continuation.as_ref()) + &output)
511 }
512 OutputMode::DisplayJsonl => render_display_jsonl(&execution.messages),
513 OutputMode::AguiJsonl => render_agui_jsonl(&execution.messages),
514 OutputMode::Json => render_prompt_run_json(&execution),
515 OutputMode::Silent => Ok(format!(
516 "{}session_id={}\nrun_id={}\nstatus={}\n",
517 continuation_drift_prefix(execution.continuation.as_ref()),
518 execution.session_id,
519 execution.run_id,
520 execution.status
521 )),
522 }
523 }
524
525 fn execute_prompt_run(
526 &mut self,
527 command: &RunCommand,
528 stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
529 ) -> CliResult<PromptRunExecution> {
530 self.execute_prompt_run_with_channels(command, None, stream_sender, None, None)
531 }
532
533 #[allow(clippy::too_many_lines)]
534 fn execute_prompt_run_with_channels(
535 &mut self,
536 command: &RunCommand,
537 prompt_input: Option<PromptInput>,
538 stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
539 steering_channel: Option<CliSteeringChannel>,
540 cancel_receiver: Option<mpsc::Receiver<()>>,
541 ) -> CliResult<PromptRunExecution> {
542 let mut prepared = self.prepare_prompt_run(command, prompt_input)?;
543 let admission_on_error = prepared.admission.clone();
544 if let Err(error) = self.start_prepared_hitl_resume(&mut prepared) {
545 self.fail_prepared_prompt_run(prepared.run.clone(), &error, &admission_on_error)?;
548 return Err(error);
549 }
550 let run_on_error = prepared.run.clone();
551 let admission_on_error = prepared.admission.clone();
552 let executed = match Self::run_prepared_prompt(
553 prepared,
554 stream_sender,
555 steering_channel,
556 cancel_receiver,
557 ) {
558 Ok(executed) => executed,
559 Err(error) => {
560 self.fail_prepared_prompt_run(run_on_error, &error, &admission_on_error)?;
561 return Err(error);
562 }
563 };
564 self.complete_prompt_run(executed)
565 }
566
567 fn reject_ordinary_admission_during_waiting_continuation(
568 &mut self,
569 session_id: &str,
570 hitl_resume: bool,
571 ) -> CliResult<()> {
572 if hitl_resume {
573 return Ok(());
574 }
575 let session = self.store()?.load_session(session_id)?;
576 let runs = self.store()?.list_run_records(session_id)?;
577 let active_run_id = session.active_run_id.as_ref();
578 for run in runs.iter().filter(|run| {
579 active_run_id == Some(&run.run_id)
580 || (run.status.is_active() && run.status != RunStatus::Waiting)
581 }) {
582 let waiting_source_run_id = if run.status == RunStatus::Waiting {
583 Some(&run.run_id)
584 } else {
585 run.restore_from_run_id.as_ref().filter(|source_run_id| {
586 runs.iter().any(|source| {
587 source.run_id.as_str() == source_run_id.as_str()
588 && source.status == RunStatus::Waiting
589 })
590 })
591 };
592 if let Some(waiting_source_run_id) = waiting_source_run_id {
593 return Err(CliError::Run(format!(
594 "run {} is continuing waiting run {}; wait for it to finish before starting another prompt",
595 run.run_id.as_str(),
596 waiting_source_run_id.as_str()
597 )));
598 }
599 }
600 Ok(())
601 }
602
603 #[allow(clippy::too_many_lines)]
604 pub(super) fn prepare_prompt_run(
605 &mut self,
606 command: &RunCommand,
607 prompt_input: Option<PromptInput>,
608 ) -> CliResult<PreparedPromptRun> {
609 let input =
610 prompt_input.map_or_else(|| command.prompt_text().map(PromptInput::text), Ok)?;
611 let raw_prompt = input.text.clone();
612 let worktree = self.resolve_worktree(command)?;
613 let selected_profile = command
614 .profile
615 .as_deref()
616 .unwrap_or(&self.config.default_profile);
617 let resolved_profile = resolve_profile(&self.config, Some(selected_profile))?;
618 let slash_expansion = expand_slash_command(&self.config.slash_commands, &raw_prompt);
619 let explicit_skills = slash_expansion
620 .is_none()
621 .then(|| expand_explicit_skills(&resolved_profile.skills, &raw_prompt))
622 .flatten();
623 let prompt = slash_expansion.as_ref().map_or_else(
624 || {
625 explicit_skills
626 .as_ref()
627 .map_or_else(|| raw_prompt.clone(), |expanded| expanded.prompt.clone())
628 },
629 |expanded| expanded.prompt.clone(),
630 );
631 let mut run_input = PromptInput {
632 text: prompt.clone(),
633 attachments: input.attachments,
634 extra_text_parts: input.extra_text_parts,
635 guidance_text_parts: input.guidance_text_parts,
636 };
637 let mut run_config = self.config.clone();
638 if let Some(worktree) = worktree.as_ref() {
639 run_config.workspace_root.clone_from(&worktree.path);
640 }
641 validate_environment_config(&run_config)?;
642 append_guidance_files(&mut run_input, &run_config);
643 append_explicit_skill_guidance(&mut run_input, explicit_skills.as_ref());
644 let (session_id, created) = self.resolve_session(command, &resolved_profile.name)?;
645 if command.run.is_some() || command.branch_from.is_some() {
646 self.reject_ordinary_admission_during_waiting_continuation(
647 &session_id,
648 command.hitl_resume,
649 )?;
650 }
651 let environment = resolve_environment_for_session_with_attachments(
652 &run_config,
653 &session_id,
654 &command.environment_attachments,
655 )?;
656 let hitl_resume_source_run_id = command
657 .hitl_resume
658 .then(|| {
659 command
660 .run
661 .as_deref()
662 .ok_or_else(|| {
663 CliError::Usage("HITL continuation requires a source run id".to_string())
664 })
665 .map(RunId::from_string)
666 })
667 .transpose()?;
668 let mut restore_from = command.run.clone().or_else(|| command.branch_from.clone());
669 if restore_from.is_none() && !created {
670 restore_from = self
671 .store()?
672 .load_session(&session_id)
673 .ok()
674 .and_then(|session| {
675 session
676 .active_run_id
677 .or(session.head_run_id)
678 .or(session.head_success_run_id)
679 .map(|run| run.as_str().to_string())
680 });
681 }
682 let mut waiting_restore_without_claim = None;
683 if let Some(source_run_id) = restore_from.as_deref() {
684 let source = self.store()?.load_run(&session_id, source_run_id)?;
685 if restore_requires_hitl_claim(
686 source.status,
687 command.hitl_resume,
688 command.branch_from.is_some(),
689 ) {
690 waiting_restore_without_claim = Some(source_run_id.to_string());
691 }
692 if source.status.is_active()
693 && source.status != RunStatus::Waiting
694 && command.branch_from.is_none()
695 && let Some(waiting_source_run_id) = source.restore_from_run_id.as_ref()
696 && self
697 .store()?
698 .load_run(&session_id, waiting_source_run_id.as_str())?
699 .status
700 == RunStatus::Waiting
701 {
702 return Err(CliError::Run(format!(
703 "run {source_run_id} is continuing waiting run {}; wait for it to finish before starting another prompt",
704 waiting_source_run_id.as_str()
705 )));
706 }
707 }
708 let prepared_continuation = match hitl_resume_source_run_id.as_ref() {
709 Some(source_run_id) => Some(
710 self.store()?
711 .prepare_waiting_continuation(&session_id, source_run_id.as_str())?,
712 ),
713 None => None,
714 };
715 let restore_state = match prepared_continuation.as_ref() {
716 Some(prepared) => Some(prepared.snapshot.state.clone()),
717 None => self
718 .store()?
719 .load_restore_state(&session_id, restore_from.as_deref())?,
720 };
721 if let Some(source_run_id) = waiting_restore_without_claim {
722 let mut pending = self
723 .store()?
724 .list_approvals(Some(&session_id), Some(&source_run_id))?
725 .into_iter()
726 .filter(|approval| approval.status == ApprovalStatus::Pending)
727 .map(|approval| approval.approval_id)
728 .collect::<Vec<_>>();
729 pending.extend(
730 self.store()?
731 .list_deferred_tools(Some(&session_id), Some(&source_run_id))?
732 .into_iter()
733 .filter(|deferred| {
734 matches!(
735 deferred.status,
736 ExecutionStatus::Pending
737 | ExecutionStatus::Running
738 | ExecutionStatus::Waiting
739 )
740 })
741 .map(|deferred| deferred.deferred_id),
742 );
743 if !pending.is_empty() {
744 return Err(CliError::Run(format!(
745 "cannot resume run {source_run_id} while HITL items are pending: {}",
746 pending.join(", ")
747 )));
748 }
749 return Err(CliError::Run(format!(
750 "run {source_run_id} is waiting and requires an explicit HITL resume"
751 )));
752 }
753 let binding_class = environment_binding_class(environment.attachments.iter().map(|item| {
754 (
755 item.kind.clone(),
756 match item.resolved_mode() {
757 EnvironmentAttachmentAccessMode::ReadOnly => "read_only",
758 EnvironmentAttachmentAccessMode::ReadWrite => "read_write",
759 }
760 .to_string(),
761 )
762 }));
763 let materialization = resolved_profile
764 .spec
765 .resolved_materialization(
766 &resolved_profile.registry,
767 STARWEAVER_AGENT_POLICY_VERSION,
768 binding_class,
769 )
770 .map_err(|error| CliError::Config(error.to_string()))?;
771 let continuation = restore_from
772 .as_deref()
773 .map(|source_run_id| {
774 let source = self.store()?.load_run(&session_id, source_run_id)?;
775 let source_materialization =
776 ResolvedAgentMaterialization::from_metadata(&source.metadata)
777 .map_err(|error| CliError::Run(error.to_string()))?;
778 let assessment = ContinuationMaterialization::assess(
779 source_materialization.as_ref(),
780 &materialization,
781 command.continuation_mode.into(),
782 );
783 if !assessment.allowed {
784 return Err(CliError::Run(format!(
785 "continuation materialization mode {} rejected drift: {}; retry with --continuation-mode compatible or switch after review",
786 assessment.mode.as_str(),
787 assessment.drift_summary()
788 )));
789 }
790 Ok(assessment)
791 })
792 .transpose()?;
793 if let Some(prepared) = prepared_continuation.as_ref() {
794 validate_prepared_hitl_continuation(
795 &resolved_profile,
796 &environment.provider,
797 environment.process_provider.as_ref(),
798 prepared,
799 )?;
800 }
801 let mut admission_metadata = serde_json::Map::new();
802 materialization
803 .insert_into(&mut admission_metadata)
804 .map_err(CliError::from)?;
805 if let Some(continuation) = continuation.as_ref() {
806 continuation
807 .insert_into(&mut admission_metadata)
808 .map_err(CliError::from)?;
809 }
810 write_current_session(&self.config, &session_id)?;
811 self.reject_ordinary_admission_during_waiting_continuation(
812 &session_id,
813 command.hitl_resume,
814 )?;
815 let hitl_resume_claim = hitl_resume_source_run_id.clone().map(|source_run_id| {
816 HitlResumeClaim::new(
817 deterministic_hitl_resume_claim_id(&session_id, &source_run_id),
818 SessionId::from_string(&session_id),
819 source_run_id,
820 Utc::now(),
821 )
822 });
823 if let Some(claim) = hitl_resume_claim.clone() {
824 run_hitl_resume_claim_operation(
825 self.config.clone(),
826 HitlResumeClaimOperation::Claim(claim),
827 )?;
828 }
829 let host_instance_id = self.host_instance_id.clone();
830 let (mut run, admission_lease) = match self.store()?.admit_run(
831 &session_id,
832 prompt,
833 restore_from,
834 &resolved_profile.name,
835 admission_metadata,
836 &host_instance_id,
837 hitl_resume_source_run_id,
838 hitl_resume_claim
839 .as_ref()
840 .map(|claim| claim.claim_id.clone()),
841 ) {
842 Ok(admitted) => admitted,
843 Err(error) => {
844 if let Some(claim) = hitl_resume_claim.as_ref() {
845 let release = run_hitl_resume_claim_operation(
846 self.config.clone(),
847 HitlResumeClaimOperation::Release {
848 session_id: claim.session_id.clone(),
849 run_id: claim.run_id.clone(),
850 claim_id: claim.claim_id.clone(),
851 },
852 );
853 if let Err(release_error) = release {
854 return Err(CliError::Storage(format!(
855 "{error}; failed to release preflight HITL claim: {release_error}"
856 )));
857 }
858 }
859 return Err(error);
860 }
861 };
862 let (admission, admission_cancel_receiver) =
863 CliRunAdmission::start(self.config.clone(), admission_lease);
864 if let Some(claim) = hitl_resume_claim.as_ref() {
865 run.metadata.insert(
866 HITL_RESUME_PREFLIGHT_SOURCE_RUN_ID_METADATA_KEY.to_string(),
867 json!(claim.run_id.as_str()),
868 );
869 }
870 apply_starweaver_run_metadata(
871 &mut run,
872 command,
873 worktree.as_ref(),
874 slash_expansion.as_ref(),
875 );
876 if let Some(explicit_skills) = explicit_skills.as_ref() {
877 run.metadata.insert(
878 "cli.skills.activated".to_string(),
879 json!(
880 explicit_skills
881 .skills
882 .iter()
883 .map(|skill| json!({
884 "name": skill.package.name,
885 "invoked": skill.invoked_name,
886 "description": skill.package.description,
887 "path": skill.package.path,
888 "metadata": skill.package.metadata,
889 }))
890 .collect::<Vec<_>>()
891 ),
892 );
893 }
894 if let Some(session_affinity_id) = command.session_affinity_id.as_deref() {
895 run.metadata.insert(
896 "starweaver.session_affinity_id".to_string(),
897 json!(session_affinity_id),
898 );
899 }
900 if !command.environment_attachments.is_empty() {
901 run.metadata.insert(
902 "starweaver.environment_attachments".to_string(),
903 json!(command.environment_attachments),
904 );
905 run.metadata.insert(
906 "starweaver.environment_attachment_ids".to_string(),
907 json!(
908 command
909 .environment_attachments
910 .iter()
911 .map(|attachment| attachment.id.as_str())
912 .collect::<Vec<_>>()
913 ),
914 );
915 }
916 let hitl = command.hitl.unwrap_or(self.config.default_hitl);
917 let goal = command
918 .goal
919 .as_ref()
920 .map(|goal| crate::runner::CliGoalRunPolicy {
921 objective: goal.objective.clone(),
922 max_iterations: goal.max_iterations.max(1),
923 });
924 let output_mode = command.output.unwrap_or(self.config.default_output);
925 Ok(PreparedPromptRun {
926 session_id,
927 run_id: run.run_id.as_str().to_string(),
928 output_mode,
929 run,
930 admission,
931 admission_cancel_receiver: Some(admission_cancel_receiver),
932 run_input,
933 resolved_profile,
934 environment,
935 restore_state,
936 prepared_continuation,
937 policy: CliRunPolicy { hitl, goal },
938 execution_host: if command.worker.is_some() || command.worker_label.is_some() {
939 CliAgentExecutionHost::disabled()
940 } else {
941 CliAgentExecutionHost::blocking()
942 },
943 hitl_resume_claim,
944 })
945 }
946
947 pub(super) fn start_prepared_hitl_resume(
953 &self,
954 prepared: &mut PreparedPromptRun,
955 ) -> CliResult<()> {
956 let Some(claim) = prepared.hitl_resume_claim.take() else {
957 return Ok(());
958 };
959 let source_run_id = claim.run_id;
960 let claim_id = claim.claim_id;
961 prepared.run.metadata.insert(
965 HITL_RESUME_CLAIM_ID_METADATA_KEY.to_string(),
966 json!(claim_id),
967 );
968 prepared.run.metadata.insert(
969 HITL_RESUME_SOURCE_RUN_ID_METADATA_KEY.to_string(),
970 json!(source_run_id.as_str()),
971 );
972 let lease_before_refresh = prepared.admission.current_lease()?;
973 if let Err(refresh_error) = prepared.admission.refresh() {
974 return Err(phase_aware_hitl_start_error(
975 self.config.clone(),
976 lease_before_refresh,
977 source_run_id,
978 claim_id,
979 refresh_error,
980 ));
981 }
982 let lease = prepared.admission.current_lease()?;
983 if let Err(start_error) = run_hitl_resume_claim_operation(
984 self.config.clone(),
985 HitlResumeClaimOperation::StartEffect {
986 lease: lease.clone(),
987 source_run_id: source_run_id.clone(),
988 claim_id: claim_id.clone(),
989 },
990 ) {
991 return Err(phase_aware_hitl_start_error(
992 self.config.clone(),
993 lease,
994 source_run_id,
995 claim_id,
996 start_error,
997 ));
998 }
999 Ok(())
1000 }
1001
1002 pub(super) fn run_prepared_prompt(
1003 prepared: PreparedPromptRun,
1004 stream_sender: Option<mpsc::SyncSender<AgentStreamRecord>>,
1005 steering_channel: Option<CliSteeringChannel>,
1006 cancel_receiver: Option<mpsc::Receiver<()>>,
1007 ) -> CliResult<ExecutedPromptRun> {
1008 let PreparedPromptRun {
1009 output_mode,
1010 run,
1011 admission,
1012 admission_cancel_receiver,
1013 run_input,
1014 resolved_profile,
1015 environment,
1016 restore_state,
1017 prepared_continuation,
1018 policy,
1019 execution_host,
1020 ..
1021 } = prepared;
1022 let result = execute_agent_session_with_host(
1023 run_input,
1024 &run,
1025 &resolved_profile,
1026 &environment.provider,
1027 environment.process_provider.as_ref(),
1028 restore_state,
1029 prepared_continuation.as_ref(),
1030 &policy,
1031 stream_sender,
1032 steering_channel,
1033 cancel_receiver,
1034 admission_cancel_receiver,
1035 execution_host,
1036 );
1037 result.map(|execution| ExecutedPromptRun {
1038 run,
1039 output_mode,
1040 execution,
1041 admission,
1042 })
1043 }
1044
1045 pub(super) fn fail_prepared_prompt_run(
1046 &mut self,
1047 mut run: RunRecord,
1048 _error: &CliError,
1049 admission: &CliRunAdmission,
1050 ) -> CliResult<()> {
1051 if self
1056 .store()?
1057 .load_run(run.session_id.as_str(), run.run_id.as_str())?
1058 .status
1059 .is_terminal()
1060 {
1061 return admission.release(self.store()?);
1062 }
1063 admission.refresh()?;
1064 let lease = admission.current_lease()?;
1065 let message = "CLI run failed".to_string();
1066 let messages = failed_display_message(&run, &message);
1067 self.store()?
1068 .fail_run_with_messages_fenced(&mut run, message, &messages, &lease)?;
1069 admission.release(self.store()?)
1070 }
1071
1072 pub(super) fn complete_prompt_run(
1073 &mut self,
1074 executed: ExecutedPromptRun,
1075 ) -> CliResult<PromptRunExecution> {
1076 let ExecutedPromptRun {
1077 mut run,
1078 output_mode,
1079 execution,
1080 admission,
1081 } = executed;
1082 admission.refresh()?;
1083 let execution_failed = execution.artifacts.status == RunStatus::Failed;
1084 let output = execution.output;
1085 let continuation = run
1086 .metadata
1087 .get(starweaver_agent::AGENT_CONTINUATION_METADATA_KEY)
1088 .cloned()
1089 .map(serde_json::from_value)
1090 .transpose()?;
1091 let lease = admission.current_lease()?;
1092 let messages = self.store()?.complete_run_fenced(
1093 &mut run,
1094 output.clone(),
1095 execution.artifacts,
1096 &lease,
1097 )?;
1098 admission.release(self.store()?)?;
1099 if execution_failed && matches!(output_mode, OutputMode::Text | OutputMode::Silent) {
1100 return Err(CliError::Run(output));
1101 }
1102 self.run_retention_maintenance(run.session_id.as_str())?;
1103 Ok(PromptRunExecution {
1104 session_id: run.session_id.as_str().to_string(),
1105 run_id: run.run_id.as_str().to_string(),
1106 status: run_status_name(run.status).to_string(),
1107 output_mode,
1108 messages,
1109 continuation,
1110 })
1111 }
1112
1113 fn run_retention_maintenance(&mut self, current_session_id: &str) -> CliResult<()> {
1114 if !self.config.auto_trim {
1115 return Ok(());
1116 }
1117 let current_keep_runs = self.config.current_session_keep_recent_runs;
1118 self.store()?.trim(
1119 vec![current_session_id.to_string()],
1120 current_keep_runs,
1121 false,
1122 )?;
1123 if !self.should_run_all_sessions_retention()? {
1124 return Ok(());
1125 }
1126 let sessions = self.store()?.all_session_ids()?;
1127 let all_sessions_keep_runs = self.config.all_sessions_keep_recent_runs;
1128 let older_than = chrono::Duration::days(
1129 i64::try_from(self.config.all_sessions_keep_days)
1130 .unwrap_or(i64::MAX)
1131 .max(0),
1132 );
1133 self.store()?
1134 .trim_with_age(sessions, all_sessions_keep_runs, Some(older_than), false)?;
1135 write_last_retention_maintenance(&self.config, chrono::Utc::now())?;
1136 Ok(())
1137 }
1138
1139 fn should_run_all_sessions_retention(&self) -> CliResult<bool> {
1140 if self.config.all_sessions_keep_days == 0 || self.config.all_sessions_interval_hours == 0 {
1141 return Ok(false);
1142 }
1143 let Some(last_run) = read_last_retention_maintenance(&self.config)? else {
1144 return Ok(true);
1145 };
1146 let elapsed = chrono::Utc::now().signed_duration_since(last_run);
1147 Ok(elapsed
1148 >= chrono::Duration::hours(
1149 i64::try_from(self.config.all_sessions_interval_hours)
1150 .unwrap_or(i64::MAX)
1151 .max(0),
1152 ))
1153 }
1154
1155 fn resolve_session(
1156 &mut self,
1157 command: &RunCommand,
1158 profile: &str,
1159 ) -> CliResult<(String, bool)> {
1160 if command.new_session {
1161 let session = self
1162 .store()?
1163 .create_session(profile, Some("CLI session".to_string()))?;
1164 return Ok((session.session_id.as_str().to_string(), true));
1165 }
1166 if let Some(session_id) = command.session.as_ref() {
1167 self.store()?.load_session(session_id)?;
1168 return Ok((session_id.clone(), false));
1169 }
1170 if command.continue_session {
1171 if let Some(session_id) = read_current_session(&self.config)?
1172 && self.store()?.load_workspace_session(&session_id).is_ok()
1173 {
1174 return Ok((session_id, false));
1175 }
1176 if let Some(session) = self.store()?.latest_session()? {
1177 return Ok((session.session_id.as_str().to_string(), false));
1178 }
1179 }
1180 let session = self
1181 .store()?
1182 .create_session(profile, Some("CLI session".to_string()))?;
1183 Ok((session.session_id.as_str().to_string(), true))
1184 }
1185
1186 fn storage(&mut self, command: StorageCommand) -> CliResult<String> {
1187 match command {
1188 StorageCommand::ImportLegacy(command) => self.import_legacy_database(command),
1189 }
1190 }
1191
1192 fn import_legacy_database(&mut self, command: StorageImportLegacyCommand) -> CliResult<String> {
1193 let source = command
1194 .source
1195 .unwrap_or_else(|| self.config.project_dir.join("starweaver.sqlite"));
1196 let workspace = command
1197 .workspace
1198 .unwrap_or_else(|| self.config.workspace_root.clone());
1199 let report = self.store()?.import_legacy_database(&source, &workspace)?;
1200 match command.output {
1201 OutputMode::Text => Ok(format!(
1202 "source={}\nworkspace={}\nsessions_imported={}\nrows_imported={}\nstatus={}\n",
1203 report.source_path.display(),
1204 report.workspace,
1205 report.sessions_imported,
1206 report.rows_imported,
1207 if report.imported {
1208 "imported"
1209 } else {
1210 "unchanged"
1211 }
1212 )),
1213 OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => Ok(format!(
1214 "{}\n",
1215 serde_json::to_string(&json!({
1216 "sourcePath": report.source_path,
1217 "workspace": report.workspace,
1218 "sessionsImported": report.sessions_imported,
1219 "rowsImported": report.rows_imported,
1220 "imported": report.imported,
1221 }))?
1222 )),
1223 OutputMode::Silent => Ok(format!(
1224 "sessions_imported={}\nrows_imported={}\nstatus={}\n",
1225 report.sessions_imported,
1226 report.rows_imported,
1227 if report.imported {
1228 "imported"
1229 } else {
1230 "unchanged"
1231 }
1232 )),
1233 }
1234 }
1235
1236 fn session(&mut self, command: SessionCommand) -> CliResult<String> {
1237 match command {
1238 SessionCommand::List(command) => {
1239 let sessions = self.store()?.list_sessions(command.limit)?;
1240 render_sessions(&sessions, command.output)
1241 }
1242 SessionCommand::Search(command) => {
1243 let output = command.output;
1244 let page = self.store()?.search_sessions(search_query(command))?;
1245 render_session_search(&page, output)
1246 }
1247 SessionCommand::Show(command) => {
1248 let session = self.store()?.load_session(&command.session_id)?;
1249 let runs = self.store()?.list_runs(&command.session_id, command.runs)?;
1250 let value = session_value(&session);
1251 render_session_show(&value, &runs, command.output)
1252 }
1253 SessionCommand::Replay(command) => {
1254 let messages = self.store()?.replay_display(
1255 &command.session_id,
1256 command.run.as_deref(),
1257 command.after,
1258 )?;
1259 match command.output {
1260 OutputMode::Text => Ok(render_display_text(&messages)),
1261 OutputMode::DisplayJsonl => render_display_jsonl(&messages),
1262 OutputMode::AguiJsonl => render_agui_jsonl(&messages),
1263 OutputMode::Json => Ok(format!(
1264 "{}\n",
1265 serde_json::to_string(&json!({
1266 "sessionId": command.session_id,
1267 "runId": command.run,
1268 "messages": messages,
1269 "status": "replayed"
1270 }))?
1271 )),
1272 OutputMode::Silent => Ok(format!(
1273 "session_id={}\nmessages={}\nstatus=replayed\n",
1274 command.session_id,
1275 messages.len()
1276 )),
1277 }
1278 }
1279 SessionCommand::Delete(command) => {
1280 if !command.yes {
1281 return Err(CliError::Usage(
1282 "pass --yes to delete a local session".to_string(),
1283 ));
1284 }
1285 let session_id = self.store()?.resolve_session_prefix(&command.session_id)?;
1286 let deleted = self.store()?.delete_session(&session_id)?;
1287 let _removed = remove_project_state_if_current_session(&self.config, &session_id)?;
1288 render_session_delete(&session_id, deleted, command.output)
1289 }
1290 SessionCommand::Trim(command) => {
1291 let sessions = if command.all {
1292 self.store()?.all_session_ids()?
1293 } else if let Some(session_id) = command.session {
1294 vec![session_id]
1295 } else {
1296 read_current_session(&self.config)?
1297 .filter(|session_id| {
1298 self.store()
1299 .is_ok_and(|store| store.load_workspace_session(session_id).is_ok())
1300 })
1301 .into_iter()
1302 .collect()
1303 };
1304 let older_than = command
1305 .older_than
1306 .as_deref()
1307 .map(parse_duration)
1308 .transpose()?;
1309 let report = self.store()?.trim_with_age(
1310 sessions,
1311 command.keep_runs,
1312 older_than,
1313 command.dry_run,
1314 )?;
1315 render_trim_report(&report, command.output)
1316 }
1317 }
1318 }
1319
1320 fn approval(&mut self, command: ApprovalCommand) -> CliResult<String> {
1321 match command {
1322 ApprovalCommand::List(command) => self.approval_list(&command),
1323 ApprovalCommand::Show { approval_id } => {
1324 let approval = self.store()?.load_approval(&approval_id)?;
1325 Ok(format!("{}\n", serde_json::to_string(&approval)?))
1326 }
1327 ApprovalCommand::Approve(command) => {
1328 self.approval_decision(&command, ApprovalStatus::Approved)
1329 }
1330 ApprovalCommand::Reject(command) => {
1331 self.approval_decision(&command, ApprovalStatus::Denied)
1332 }
1333 }
1334 }
1335
1336 fn approval_list(&mut self, command: &ApprovalListCommand) -> CliResult<String> {
1337 let approvals = self
1338 .store()?
1339 .list_approvals(command.session.as_deref(), command.run.as_deref())?;
1340 render_approvals(&approvals, command.output)
1341 }
1342
1343 fn approval_decision(
1344 &mut self,
1345 command: &ApprovalDecisionCommand,
1346 status: ApprovalStatus,
1347 ) -> CliResult<String> {
1348 let approval =
1349 self.store()?
1350 .decide_approval(&command.approval_id, status, command.reason.clone())?;
1351 match command.output {
1352 OutputMode::Text => Ok(format!(
1353 "approval_id={}\nstatus={}\nrun_id={}\n",
1354 approval.approval_id,
1355 approval_status_name(approval.status),
1356 approval.run_id.as_str()
1357 )),
1358 OutputMode::DisplayJsonl | OutputMode::AguiJsonl | OutputMode::Json => {
1359 Ok(format!("{}\n", serde_json::to_string(&approval)?))
1360 }
1361 OutputMode::Silent => Ok(format!(
1362 "approval_id={}\nstatus={}\n",
1363 approval.approval_id,
1364 approval_status_name(approval.status)
1365 )),
1366 }
1367 }
1368
1369 fn deferred(&mut self, command: DeferredCommand) -> CliResult<String> {
1370 match command {
1371 DeferredCommand::List(command) => self.deferred_list(&command),
1372 DeferredCommand::Show { deferred_id } => {
1373 let deferred = self.store()?.load_deferred_tool(&deferred_id)?;
1374 Ok(format!("{}\n", serde_json::to_string(&deferred)?))
1375 }
1376 DeferredCommand::Complete(command) => self.deferred_complete(&command),
1377 DeferredCommand::Fail(command) => self.deferred_fail(&command),
1378 }
1379 }
1380
1381 fn deferred_list(&mut self, command: &DeferredListCommand) -> CliResult<String> {
1382 let records = self
1383 .store()?
1384 .list_deferred_tools(command.session.as_deref(), command.run.as_deref())?;
1385 render_deferred(&records, command.output)
1386 }
1387
1388 fn deferred_complete(&mut self, command: &DeferredCompleteCommand) -> CliResult<String> {
1389 let value = serde_json::from_str::<Value>(&command.result)
1390 .map_err(|error| CliError::Usage(format!("invalid deferred result JSON: {error}")))?;
1391 let record = self
1392 .store()?
1393 .complete_deferred_tool(&command.deferred_id, value)?;
1394 render_deferred_decision(&record, command.output)
1395 }
1396
1397 fn deferred_fail(&mut self, command: &DeferredFailCommand) -> CliResult<String> {
1398 let record = self
1399 .store()?
1400 .fail_deferred_tool(&command.deferred_id, &command.error)?;
1401 render_deferred_decision(&record, command.output)
1402 }
1403
1404 fn resume(&mut self, command: &ResumeCommand) -> CliResult<String> {
1405 let session_id = self.resolve_session_id(command.session.as_deref())?;
1406 let source_run = self.resolve_resume_run(&session_id, command.run.as_deref())?;
1407 let hitl_resume = source_run.status == RunStatus::Waiting;
1408 let run_command = RunCommand {
1409 prompt: Some(format!(
1410 "{}\n\nResuming from run {} with any persisted approval and deferred-tool decisions.",
1411 command.prompt,
1412 source_run.run_id.as_str()
1413 )),
1414 prompt_parts: Vec::new(),
1415 session: Some(session_id),
1416 continue_session: false,
1417 new_session: false,
1418 run: Some(source_run.run_id.as_str().to_string()),
1419 branch_from: None,
1420 profile: source_run.profile.clone(),
1421 continuation_mode: command.continuation_mode,
1422 output: command.output,
1423 hitl: command.hitl,
1424 goal: None,
1425 worker: None,
1426 worker_label: None,
1427 worktree: None,
1428 worktree_name: None,
1429 branch: None,
1430 session_affinity_id: None,
1431 environment_attachments: Vec::new(),
1432 hitl_resume,
1433 };
1434 self.run_prompt(&run_command)
1435 }
1436
1437 fn resolve_session_id(&mut self, requested: Option<&str>) -> CliResult<String> {
1438 if let Some(session_id) = requested {
1439 self.store()?.load_session(session_id)?;
1440 return Ok(session_id.to_string());
1441 }
1442 if let Some(session_id) = read_current_session(&self.config)?
1443 && self.store()?.load_workspace_session(&session_id).is_ok()
1444 {
1445 return Ok(session_id);
1446 }
1447 self.store()?
1448 .latest_session()?
1449 .map(|session| session.session_id.as_str().to_string())
1450 .ok_or_else(|| CliError::NotFound("session".to_string()))
1451 }
1452
1453 fn resolve_resume_run(
1454 &mut self,
1455 session_id: &str,
1456 requested: Option<&str>,
1457 ) -> CliResult<starweaver_session::RunRecord> {
1458 if let Some(run_id) = requested {
1459 return self.store()?.load_run(session_id, run_id);
1460 }
1461 let session = self.store()?.load_session(session_id)?;
1462 let run_id = session
1463 .active_run_id
1464 .as_ref()
1465 .or(session.head_run_id.as_ref())
1466 .ok_or_else(|| CliError::NotFound("run".to_string()))?;
1467 self.store()?.load_run(session_id, run_id.as_str())
1468 }
1469}
1470
1471fn continuation_drift_prefix(continuation: Option<&ContinuationMaterialization>) -> String {
1472 continuation
1473 .filter(|item| !item.drift.is_empty())
1474 .map_or_else(String::new, |item| {
1475 format!(
1476 "materialization_drift mode={} fields={}\n",
1477 item.mode.as_str(),
1478 item.drift_summary()
1479 )
1480 })
1481}
1482
1483fn phase_aware_hitl_start_error(
1484 config: CliConfig,
1485 lease: RunAdmissionLease,
1486 source_run_id: RunId,
1487 claim_id: String,
1488 start_error: CliError,
1489) -> CliError {
1490 match abort_admitted_hitl_resume_operation(
1494 config,
1495 lease,
1496 source_run_id,
1497 claim_id,
1498 "HITL continuation failed before effect start",
1499 ) {
1500 Ok(HitlResumeAbortOutcome::AbortedBeforeEffect | HitlResumeAbortOutcome::EffectStarted) => {
1501 start_error
1502 }
1503 Err(abort_error) => CliError::Storage(format!(
1504 "{start_error}; failed to determine HITL effect phase: {abort_error}"
1505 )),
1506 }
1507}
1508
1509fn abort_admitted_hitl_resume_operation(
1510 config: CliConfig,
1511 lease: RunAdmissionLease,
1512 source_run_id: RunId,
1513 claim_id: String,
1514 output_preview: &'static str,
1515) -> CliResult<HitlResumeAbortOutcome> {
1516 thread::spawn(move || {
1517 let store =
1518 LocalSessionStore::new(config).map_err(|error| CliError::Storage(error.to_string()))?;
1519 let runtime = tokio::runtime::Builder::new_current_thread()
1520 .enable_all()
1521 .build()
1522 .map_err(|error| CliError::Run(error.to_string()))?;
1523 runtime
1524 .block_on(store.abort_admitted_hitl_resume(
1525 &lease,
1526 &source_run_id,
1527 &claim_id,
1528 output_preview,
1529 ))
1530 .map_err(|error| CliError::Storage(error.to_string()))
1531 })
1532 .join()
1533 .map_err(|_| CliError::Run("HITL resume abort worker panicked".to_string()))?
1534}
1535
1536fn run_hitl_resume_claim_operation(
1537 config: CliConfig,
1538 operation: HitlResumeClaimOperation,
1539) -> CliResult<()> {
1540 thread::spawn(move || {
1541 let store =
1542 LocalSessionStore::new(config).map_err(|error| CliError::Storage(error.to_string()))?;
1543 let runtime = tokio::runtime::Builder::new_current_thread()
1544 .enable_all()
1545 .build()
1546 .map_err(|error| CliError::Run(error.to_string()))?;
1547 runtime
1548 .block_on(async {
1549 match operation {
1550 HitlResumeClaimOperation::Claim(claim) => {
1551 store
1552 .reconcile_expired_run_admissions(
1553 starweaver_session::LOCAL_SESSION_NAMESPACE,
1554 Utc::now(),
1555 )
1556 .await?;
1557 store.claim_hitl_resume(claim).await
1558 }
1559 HitlResumeClaimOperation::StartEffect {
1560 lease,
1561 source_run_id,
1562 claim_id,
1563 } => {
1564 store
1565 .start_hitl_resume_effect(&lease, &source_run_id, &claim_id)
1566 .await
1567 }
1568 HitlResumeClaimOperation::Release {
1569 session_id,
1570 run_id,
1571 claim_id,
1572 } => {
1573 store
1574 .release_hitl_resume_claim(&session_id, &run_id, &claim_id)
1575 .await
1576 }
1577 }
1578 })
1579 .map_err(|error| CliError::Storage(error.to_string()))
1580 })
1581 .join()
1582 .map_err(|_| CliError::Run("HITL resume claim worker panicked".to_string()))?
1583}
1584
1585#[allow(dead_code)]
1586fn start_oauth_refresh_guard(config: &CliConfig) -> CliResult<Option<OAuthRefreshGuard>> {
1587 if !config.oauth_refresh.enabled {
1588 return Ok(None);
1589 }
1590 let models = list_profiles(config)
1591 .into_iter()
1592 .map(|profile| profile.model_id)
1593 .collect::<Vec<_>>();
1594 if config.oauth_refresh.interval_seconds == 0 {
1595 return Err(CliError::Usage(
1596 "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1597 ));
1598 }
1599 if config.oauth_refresh.failure_retry_seconds == 0 {
1600 return Err(CliError::Usage(
1601 "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1602 ));
1603 }
1604 let mut supervisor = create_oauth_refresh_supervisor_for_models_with_options(
1605 models.iter().map(String::as_str),
1606 Duration::from_secs(config.oauth_refresh.interval_seconds),
1607 Duration::from_secs(config.oauth_refresh.failure_retry_seconds),
1608 config.oauth_refresh.refresh_on_startup,
1609 )
1610 .map_err(oauth_cli_error)?;
1611 let Some(mut supervisor) = supervisor.take() else {
1612 return Ok(None);
1613 };
1614 let (stop_sender, stop_receiver) = mpsc::channel::<()>();
1615 let handle = thread::Builder::new()
1616 .name("starweaver-oauth-refresh".to_string())
1617 .spawn(move || {
1618 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
1619 .enable_all()
1620 .build()
1621 else {
1622 return;
1623 };
1624 runtime.block_on(async move {
1625 supervisor.start().await;
1626 let _ = tokio::task::spawn_blocking(move || stop_receiver.recv()).await;
1627 supervisor.shutdown().await;
1628 });
1629 })
1630 .map_err(|error| CliError::Run(error.to_string()))?;
1631 Ok(Some(OAuthRefreshGuard {
1632 stop_sender,
1633 handle: Some(handle),
1634 }))
1635}
1636
1637fn render_json_lines<T: serde::Serialize>(items: &[T]) -> CliResult<String> {
1638 items
1639 .iter()
1640 .map(|item| serde_json::to_string(item).map(|line| format!("{line}\n")))
1641 .collect::<Result<String, _>>()
1642 .map_err(CliError::from)
1643}
1644
1645const fn run_status_name(status: starweaver_session::RunStatus) -> &'static str {
1646 status.as_str()
1647}
1648
1649fn parse_duration(value: &str) -> CliResult<chrono::Duration> {
1650 let trimmed = value.trim();
1651 if trimmed.is_empty() {
1652 return Err(CliError::Usage("duration cannot be empty".to_string()));
1653 }
1654 let (number, unit) = trimmed.split_at(
1655 trimmed
1656 .find(|ch: char| !ch.is_ascii_digit())
1657 .unwrap_or(trimmed.len()),
1658 );
1659 let amount = number
1660 .parse::<i64>()
1661 .map_err(|error| CliError::Usage(error.to_string()))?;
1662 let duration = match unit {
1663 "" | "s" | "sec" | "secs" => chrono::Duration::seconds(amount),
1664 "m" | "min" | "mins" => chrono::Duration::minutes(amount),
1665 "h" | "hr" | "hrs" => chrono::Duration::hours(amount),
1666 "d" | "day" | "days" => chrono::Duration::days(amount),
1667 other => return Err(CliError::Usage(format!("unknown duration unit: {other}"))),
1668 };
1669 Ok(duration)
1670}
1671
1672#[cfg(test)]
1673mod tests;