diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index c316b6cb7..6117e3b2b 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -127,6 +127,10 @@ pub struct RunArgs { /// Skip retro generation after the run #[arg(long)] pub no_retro: bool, + + /// Create SSH access to the Daytona sandbox and print the connection command + #[arg(long)] + pub ssh: bool, } #[derive(Args)] @@ -562,6 +566,9 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String } => { format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={} files_skipped={files_skipped}", HumanBytes(*total_bytes)) } + WorkflowRunEvent::SshAccessReady { ssh_command } => { + format!("[SSH_ACCESS_READY] {ssh_command}") + } }; format!("{}", styles.dim.apply_to(body)) } diff --git a/crates/arc-workflows/src/cli/progress.rs b/crates/arc-workflows/src/cli/progress.rs index 7467262f4..67825060a 100644 --- a/crates/arc-workflows/src/cli/progress.rs +++ b/crates/arc-workflows/src/cli/progress.rs @@ -315,6 +315,9 @@ impl ProgressUI { WorkflowRunEvent::Agent { stage, event } => { self.on_agent_event(stage, event); } + WorkflowRunEvent::SshAccessReady { ssh_command } => { + self.on_ssh_access_ready(ssh_command); + } _ => {} } } @@ -378,6 +381,21 @@ impl ProgressUI { } } + // ── SSH access ────────────────────────────────────────────────────── + + fn on_ssh_access_ready(&mut self, ssh_command: &str) { + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = tty.multi.add(ProgressBar::new_spinner()); + bar.set_style(style_sandbox_detail()); + bar.finish_with_message(ssh_command.to_string()); + } + ProgressRenderer::Plain => { + eprintln!(" {ssh_command}"); + } + } + } + // ── Setup ─────────────────────────────────────────────────────────── fn on_setup_started(&mut self, command_count: usize) { diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 5219199fa..71c931577 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -412,6 +412,7 @@ pub async fn run_command( // Wrap emitter in Arc now so we can share it with exec env callbacks let emitter = Arc::new(emitter); + let mut daytona_sandbox_ref: Option> = None; let sandbox: Arc = match sandbox_provider { SandboxProvider::Docker => { let config = DockerSandboxConfig { @@ -436,7 +437,9 @@ pub async fn run_command( env.set_event_callback(Arc::new(move |event| { emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); })); - Arc::new(env) + let daytona_arc = Arc::new(env); + daytona_sandbox_ref = Some(Arc::clone(&daytona_arc)); + daytona_arc } SandboxProvider::Local => { let mut env = LocalSandbox::new(cwd); @@ -482,6 +485,30 @@ pub async fn run_command( (None, None, None) }; + // Create SSH access if requested + if args.ssh { + if let Some(ref daytona) = daytona_sandbox_ref { + match daytona.create_ssh_access(Some(60.0)).await { + Ok(ssh_info) => { + emitter.emit(&crate::event::WorkflowRunEvent::SshAccessReady { + ssh_command: ssh_info.ssh_command, + }); + } + Err(e) => { + eprintln!( + "{} Failed to create SSH access: {e}", + styles.yellow.apply_to("Warning:"), + ); + } + } + } else { + eprintln!( + "{} --ssh only works with --sandbox daytona, skipping.", + styles.yellow.apply_to("Warning:"), + ); + } + } + // Run setup commands inside the sandbox (once, not per-stage) if !setup_commands.is_empty() { emitter.emit(&crate::event::WorkflowRunEvent::SetupStarted { diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index 622553d09..e53a32378 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -59,6 +59,17 @@ impl DaytonaSandbox { self.event_callback = Some(cb); } + pub async fn create_ssh_access( + &self, + expires_in_minutes: Option, + ) -> Result { + let sandbox = self.sandbox()?; + sandbox + .create_ssh_access(expires_in_minutes) + .await + .map_err(|e| format!("Failed to create SSH access: {e}")) + } + fn emit(&self, event: SandboxEvent) { event.trace(); if let Some(ref cb) = self.event_callback { diff --git a/crates/arc-workflows/src/event.rs b/crates/arc-workflows/src/event.rs index 06b332d2b..7375a7508 100644 --- a/crates/arc-workflows/src/event.rs +++ b/crates/arc-workflows/src/event.rs @@ -187,6 +187,9 @@ pub enum WorkflowRunEvent { total_bytes: u64, files_skipped: usize, }, + SshAccessReady { + ssh_command: String, + }, } impl WorkflowRunEvent { @@ -444,6 +447,9 @@ impl WorkflowRunEvent { files_copied, total_bytes, files_skipped, "Assets captured" ); } + Self::SshAccessReady { ssh_command } => { + info!(ssh_command, "SSH access ready"); + } } } } diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index b45570c6b..b1f8d8311 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -1145,3 +1145,40 @@ async fn daytona_asset_collection() { env.cleanup().await.unwrap(); } + +#[tokio::test] +#[ignore] +async fn daytona_ssh_access() { + let env = create_env().await; + env.initialize().await.unwrap(); + + let ssh_info = env.create_ssh_access(Some(60.0)).await.unwrap(); + assert!( + !ssh_info.ssh_command.is_empty(), + "ssh_command should not be empty" + ); + assert!( + ssh_info.ssh_command.contains("ssh"), + "ssh_command should contain 'ssh': {}", + ssh_info.ssh_command + ); + assert!( + !ssh_info.token.is_empty(), + "token should not be empty" + ); + + env.cleanup().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn daytona_ssh_access_before_init_fails() { + let env = create_env().await; + + let result = env.create_ssh_access(Some(60.0)).await; + assert!(result.is_err(), "should fail before initialize()"); + assert!( + result.unwrap_err().contains("not initialized"), + "error should mention not initialized" + ); +}