mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add arc cp command for copying files to/from run sandboxes
Introduces sandbox reconnection via persisted SandboxRecord, adds upload_file_from_local to the Sandbox trait, and migrates arc-sprites shell quoting to shlex::try_quote. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc90f5988b
commit
aae1efab98
24 changed files with 1010 additions and 56 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -340,6 +340,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
|
|
|||
|
|
@ -114,6 +114,22 @@ impl DockerSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
/// Maps a container-space path to the corresponding host-space path
|
||||
/// using the bind-mount configuration.
|
||||
fn container_to_host_path(&self, remote_path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let container_path = self.resolve_container_path(remote_path);
|
||||
if container_path.starts_with(&self.config.container_mount_point) {
|
||||
let relative = &container_path[self.config.container_mount_point.len()..];
|
||||
let relative = relative.strip_prefix('/').unwrap_or(relative);
|
||||
Ok(std::path::PathBuf::from(&self.config.host_working_directory).join(relative))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Path {container_path} is outside the bind-mounted directory {}",
|
||||
self.config.container_mount_point
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a command inside the container, returning `(stdout, stderr, exit_code)`.
|
||||
async fn docker_exec(
|
||||
&self,
|
||||
|
|
@ -275,19 +291,7 @@ impl Sandbox for DockerSandbox {
|
|||
remote_path: &str,
|
||||
local_path: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
// Docker bind-mounts host_working_directory -> container_mount_point.
|
||||
// Resolve the container path to the corresponding host path.
|
||||
let container_path = self.resolve_container_path(remote_path);
|
||||
let host_path = if container_path.starts_with(&self.config.container_mount_point) {
|
||||
let relative = &container_path[self.config.container_mount_point.len()..];
|
||||
let relative = relative.strip_prefix('/').unwrap_or(relative);
|
||||
std::path::PathBuf::from(&self.config.host_working_directory).join(relative)
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Path {container_path} is outside the bind-mounted directory {}",
|
||||
self.config.container_mount_point
|
||||
));
|
||||
};
|
||||
let host_path = self.container_to_host_path(remote_path)?;
|
||||
|
||||
if let Some(parent) = local_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
|
|
@ -304,6 +308,28 @@ impl Sandbox for DockerSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &std::path::Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let host_path = self.container_to_host_path(remote_path)?;
|
||||
|
||||
if let Some(parent) = host_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
|
||||
}
|
||||
tokio::fs::copy(local_path, &host_path).await.map_err(|e| {
|
||||
format!(
|
||||
"Failed to copy {} to {}: {e}",
|
||||
local_path.display(),
|
||||
host_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.emit(SandboxEvent::Initializing {
|
||||
provider: "docker".into(),
|
||||
|
|
@ -930,4 +956,4 @@ mod tests {
|
|||
|
||||
std::fs::remove_dir_all(&host_dir).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -382,6 +382,27 @@ impl Sandbox for LocalSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let full_path = self.resolve_path(remote_path);
|
||||
if let Some(parent) = full_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
|
||||
}
|
||||
tokio::fs::copy(local_path, &full_path).await.map_err(|e| {
|
||||
format!(
|
||||
"Failed to copy {} to {}: {e}",
|
||||
local_path.display(),
|
||||
full_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.emit(SandboxEvent::Initializing {
|
||||
provider: "local".into(),
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ macro_rules! delegate_sandbox {
|
|||
self.$field.download_file_to_local(remote_path, local_path).await
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &std::path::Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
self.$field.upload_file_from_local(local_path, remote_path).await
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.$field.initialize().await
|
||||
}
|
||||
|
|
@ -345,6 +353,13 @@ pub trait Sandbox: Send + Sync {
|
|||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<(), String>;
|
||||
/// Copy a file from the local filesystem into the sandbox.
|
||||
/// Handles binary files correctly across all sandbox types.
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String>;
|
||||
async fn initialize(&self) -> Result<(), String>;
|
||||
async fn cleanup(&self) -> Result<(), String>;
|
||||
fn working_directory(&self) -> &str;
|
||||
|
|
|
|||
|
|
@ -189,6 +189,17 @@ impl Sandbox for MockSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &std::path::Path,
|
||||
_remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
if !local_path.exists() {
|
||||
return Err(format!("File not found: {}", local_path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.emit(crate::sandbox::SandboxEvent::Initializing {
|
||||
provider: "mock".into(),
|
||||
|
|
@ -351,6 +362,21 @@ impl Sandbox for MutableMockSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &std::path::Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let content = tokio::fs::read_to_string(local_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
|
||||
self.files
|
||||
.lock()
|
||||
.expect("files lock poisoned")
|
||||
.insert(remote_path.to_string(), content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ enum Command {
|
|||
Validate(arc_workflows::cli::ValidateArgs),
|
||||
/// Parse a DOT file and print its AST
|
||||
Parse(arc_workflows::cli::ParseArgs),
|
||||
/// Copy files to/from a run's sandbox
|
||||
Cp(arc_workflows::cli::cp::CpArgs),
|
||||
/// List and test LLM models
|
||||
Model {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -133,6 +135,7 @@ async fn main() -> Result<()> {
|
|||
Command::Run(_) => "run",
|
||||
Command::Validate(_) => "validate",
|
||||
Command::Parse(_) => "parse",
|
||||
Command::Cp(_) => "cp",
|
||||
Command::Model { .. } => "model",
|
||||
Command::Serve(_) => "serve",
|
||||
Command::Doctor { .. } => "doctor",
|
||||
|
|
@ -292,6 +295,9 @@ async fn main() -> Result<()> {
|
|||
Command::Parse(args) => {
|
||||
arc_workflows::cli::parse::parse_command(&args)?;
|
||||
}
|
||||
Command::Cp(args) => {
|
||||
arc_workflows::cli::cp::cp_command(args).await?;
|
||||
}
|
||||
Command::Model { command } => {
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let resolved =
|
||||
|
|
|
|||
|
|
@ -18,6 +18,30 @@ pub use openssh_runner::OpensshRunner;
|
|||
const WORKING_DIRECTORY: &str = "/home/exedev";
|
||||
const PROVIDER: &str = "exe";
|
||||
|
||||
/// No-op SSH runner used as a placeholder for the management plane
|
||||
/// when reconnecting to an existing VM via `from_existing`.
|
||||
struct NoopSshRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl SshRunner for NoopSshRunner {
|
||||
async fn run_command(&self, _command: &str) -> Result<SshOutput, String> {
|
||||
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
|
||||
}
|
||||
async fn run_command_with_timeout(
|
||||
&self,
|
||||
_command: &str,
|
||||
_timeout: std::time::Duration,
|
||||
) -> Result<SshOutput, String> {
|
||||
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
|
||||
}
|
||||
async fn upload_file(&self, _path: &str, _content: &[u8]) -> Result<(), String> {
|
||||
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
|
||||
}
|
||||
async fn download_file(&self, _path: &str) -> Result<Vec<u8>, String> {
|
||||
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shell_quote(s: &str) -> String {
|
||||
shlex::try_quote(s).map_or_else(
|
||||
|_| format!("'{}'", s.replace('\'', "'\\''")),
|
||||
|
|
@ -127,11 +151,43 @@ impl ExeSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an `ExeSandbox` from a pre-connected data-plane SSH runner.
|
||||
/// Used for reconnection (e.g. `arc cp`) when the VM already exists.
|
||||
pub fn from_existing(data_ssh: Box<dyn SshRunner>) -> Self {
|
||||
let data_cell = tokio::sync::OnceCell::new();
|
||||
let _ = data_cell.set(data_ssh);
|
||||
Self {
|
||||
mgmt_ssh: Box::new(NoopSshRunner),
|
||||
data_ssh: data_cell,
|
||||
vm_name: tokio::sync::OnceCell::new(),
|
||||
data_host: tokio::sync::OnceCell::new(),
|
||||
rg_available: tokio::sync::OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
data_ssh_factory: Box::new(|_: &str| {
|
||||
Box::pin(async { Err("from_existing sandbox cannot create new SSH connections".to_string()) })
|
||||
}),
|
||||
config: ExeConfig::default(),
|
||||
clone_params: None,
|
||||
run_id: None,
|
||||
origin_url: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display URL of the cloned origin remote, if a clone was performed.
|
||||
pub fn origin_url(&self) -> Option<&str> {
|
||||
self.origin_url.get().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The VM name, available after initialization.
|
||||
pub fn vm_name(&self) -> Option<&str> {
|
||||
self.vm_name.get().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The data-plane SSH host, available after initialization.
|
||||
pub fn data_host(&self) -> Option<&str> {
|
||||
self.data_host.get().map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn set_event_callback(&mut self, cb: SandboxEventCallback) {
|
||||
self.event_callback = Some(cb);
|
||||
}
|
||||
|
|
@ -710,6 +766,29 @@ impl Sandbox for ExeSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let ssh = self.data_ssh()?;
|
||||
let resolved = if Path::new(remote_path).is_absolute() {
|
||||
remote_path.to_string()
|
||||
} else {
|
||||
format!("{WORKING_DIRECTORY}/{remote_path}")
|
||||
};
|
||||
|
||||
let bytes = tokio::fs::read(local_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
|
||||
|
||||
ssh.upload_file(&resolved, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file {resolved}: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
WORKING_DIRECTORY
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ chrono.workspace = true
|
|||
rand.workspace = true
|
||||
tracing.workspace = true
|
||||
base64.workspace = true
|
||||
shlex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ pub use cli_runner::CliSpriteRunner;
|
|||
const WORKING_DIRECTORY: &str = "/home/sprite";
|
||||
const PROVIDER: &str = "sprites";
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
shlex::try_quote(s).map_or_else(
|
||||
|_| format!("'{}'", s.replace('\'', "'\\''")),
|
||||
|q| q.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Output from a sprite CLI command execution.
|
||||
pub struct SpriteOutput {
|
||||
pub stdout: String,
|
||||
|
|
@ -52,12 +59,10 @@ pub struct SpritesSandbox {
|
|||
preview_url: tokio::sync::OnceCell<String>,
|
||||
rg_available: tokio::sync::OnceCell<bool>,
|
||||
event_callback: Option<SandboxEventCallback>,
|
||||
preserve_sprite: bool,
|
||||
}
|
||||
|
||||
impl SpritesSandbox {
|
||||
pub fn new(runner: Box<dyn SpriteRunner>, config: SpritesConfig) -> Self {
|
||||
let preserve_sprite = config.sprite_name.is_some();
|
||||
Self {
|
||||
config,
|
||||
runner,
|
||||
|
|
@ -65,7 +70,6 @@ impl SpritesSandbox {
|
|||
preview_url: tokio::sync::OnceCell::new(),
|
||||
rg_available: tokio::sync::OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
preserve_sprite,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +235,7 @@ impl Sandbox for SpritesSandbox {
|
|||
});
|
||||
let start = Instant::now();
|
||||
|
||||
if !self.preserve_sprite {
|
||||
if self.config.sprite_name.is_none() {
|
||||
if let Some(name) = self.sprite_name.get() {
|
||||
let args = self.build_sprite_args(&["destroy", "-s", name, "-force"]);
|
||||
let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
|
|
@ -269,19 +273,19 @@ impl Sandbox for SpritesSandbox {
|
|||
if let Some(vars) = env_vars {
|
||||
for (key, value) in vars {
|
||||
parts.push(format!(
|
||||
"export {}='{}';",
|
||||
key,
|
||||
value.replace('\'', "'\\''")
|
||||
"export {}={};",
|
||||
shell_quote(key),
|
||||
shell_quote(value)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dir) = working_dir {
|
||||
let resolved = self.resolve_path(dir);
|
||||
parts.push(format!("cd '{}'", resolved.replace('\'', "'\\''")));
|
||||
parts.push(format!("cd {}", shell_quote(&resolved)));
|
||||
parts.push("&&".to_string());
|
||||
} else {
|
||||
parts.push(format!("cd '{WORKING_DIRECTORY}'"));
|
||||
parts.push(format!("cd {}", shell_quote(WORKING_DIRECTORY)));
|
||||
parts.push("&&".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +325,7 @@ impl Sandbox for SpritesSandbox {
|
|||
limit: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let resolved = self.resolve_path(path);
|
||||
let cmd = format!("cat '{}'", resolved.replace('\'', "'\\''"));
|
||||
let cmd = format!("cat {}", shell_quote(&resolved));
|
||||
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
|
||||
|
|
@ -339,7 +343,7 @@ impl Sandbox for SpritesSandbox {
|
|||
if let Some(parent) = Path::new(&resolved).parent() {
|
||||
let parent_str = parent.to_string_lossy();
|
||||
if parent_str != "/" {
|
||||
let mkdir_cmd = format!("mkdir -p '{}'", parent_str.replace('\'', "'\\''"));
|
||||
let mkdir_cmd = format!("mkdir -p {}", shell_quote(&parent_str));
|
||||
self.exec_command(&mkdir_cmd, 30_000, None, None, None)
|
||||
.await?;
|
||||
}
|
||||
|
|
@ -348,9 +352,9 @@ impl Sandbox for SpritesSandbox {
|
|||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(content.as_bytes());
|
||||
let cmd = format!(
|
||||
"echo '{}' | base64 -d > '{}'",
|
||||
"echo '{}' | base64 -d > {}",
|
||||
encoded,
|
||||
resolved.replace('\'', "'\\''"),
|
||||
shell_quote(&resolved),
|
||||
);
|
||||
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
|
|
@ -363,7 +367,7 @@ impl Sandbox for SpritesSandbox {
|
|||
|
||||
async fn delete_file(&self, path: &str) -> Result<(), String> {
|
||||
let resolved = self.resolve_path(path);
|
||||
let cmd = format!("rm -f '{}'", resolved.replace('\'', "'\\''"));
|
||||
let cmd = format!("rm -f {}", shell_quote(&resolved));
|
||||
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
if result.exit_code != 0 {
|
||||
|
|
@ -374,7 +378,7 @@ impl Sandbox for SpritesSandbox {
|
|||
|
||||
async fn file_exists(&self, path: &str) -> Result<bool, String> {
|
||||
let resolved = self.resolve_path(path);
|
||||
let cmd = format!("test -e '{}'", resolved.replace('\'', "'\\''"));
|
||||
let cmd = format!("test -e {}", shell_quote(&resolved));
|
||||
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
Ok(result.exit_code == 0)
|
||||
|
|
@ -389,8 +393,8 @@ impl Sandbox for SpritesSandbox {
|
|||
let max_depth = depth.unwrap_or(1);
|
||||
|
||||
let cmd = format!(
|
||||
"find '{}' -mindepth 1 -maxdepth {} -printf '%y\\t%s\\t%P\\n'",
|
||||
resolved.replace('\'', "'\\''"),
|
||||
"find {} -mindepth 1 -maxdepth {} -printf '%y\\t%s\\t%P\\n'",
|
||||
shell_quote(&resolved),
|
||||
max_depth,
|
||||
);
|
||||
|
||||
|
|
@ -452,15 +456,15 @@ impl Sandbox for SpritesSandbox {
|
|||
cmd.push_str(" -i");
|
||||
}
|
||||
if let Some(ref glob_filter) = options.glob_filter {
|
||||
cmd.push_str(&format!(" --glob '{glob_filter}'"));
|
||||
cmd.push_str(&format!(" --glob {}", shell_quote(glob_filter)));
|
||||
}
|
||||
if let Some(max) = options.max_results {
|
||||
cmd.push_str(&format!(" --max-count {max}"));
|
||||
}
|
||||
cmd.push_str(&format!(
|
||||
" -- '{}' '{}'",
|
||||
pattern.replace('\'', "'\\''"),
|
||||
resolved
|
||||
" -- {} {}",
|
||||
shell_quote(pattern),
|
||||
shell_quote(&resolved)
|
||||
));
|
||||
cmd
|
||||
} else {
|
||||
|
|
@ -469,15 +473,15 @@ impl Sandbox for SpritesSandbox {
|
|||
cmd.push_str(" -i");
|
||||
}
|
||||
if let Some(ref glob_filter) = options.glob_filter {
|
||||
cmd.push_str(&format!(" --include '{glob_filter}'"));
|
||||
cmd.push_str(&format!(" --include {}", shell_quote(glob_filter)));
|
||||
}
|
||||
if let Some(max) = options.max_results {
|
||||
cmd.push_str(&format!(" -m {max}"));
|
||||
}
|
||||
cmd.push_str(&format!(
|
||||
" -- '{}' '{}'",
|
||||
pattern.replace('\'', "'\\''"),
|
||||
resolved
|
||||
" -- {} {}",
|
||||
shell_quote(pattern),
|
||||
shell_quote(&resolved)
|
||||
));
|
||||
cmd
|
||||
};
|
||||
|
|
@ -503,9 +507,9 @@ impl Sandbox for SpritesSandbox {
|
|||
.unwrap_or_else(|| WORKING_DIRECTORY.to_string());
|
||||
|
||||
let cmd = format!(
|
||||
"find '{}' -name '{}' -type f | sort",
|
||||
base.replace('\'', "'\\''"),
|
||||
pattern.replace('\'', "'\\''"),
|
||||
"find {} -name {} -type f | sort",
|
||||
shell_quote(&base),
|
||||
shell_quote(pattern),
|
||||
);
|
||||
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
|
|
@ -531,7 +535,7 @@ impl Sandbox for SpritesSandbox {
|
|||
local_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let resolved = self.resolve_path(remote_path);
|
||||
let cmd = format!("base64 '{}'", resolved.replace('\'', "'\\''"));
|
||||
let cmd = format!("base64 {}", shell_quote(&resolved));
|
||||
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
if result.exit_code != 0 {
|
||||
|
|
@ -555,6 +559,40 @@ impl Sandbox for SpritesSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let resolved = self.resolve_path(remote_path);
|
||||
|
||||
let bytes = tokio::fs::read(local_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&resolved).parent() {
|
||||
let mkdir_cmd = format!("mkdir -p {}", shell_quote(&parent.to_string_lossy()));
|
||||
self.exec_command(&mkdir_cmd, 10_000, None, None, None)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let cmd = format!(
|
||||
"echo {} | base64 -d > {}",
|
||||
shell_quote(&encoded),
|
||||
shell_quote(&resolved)
|
||||
);
|
||||
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
|
||||
if result.exit_code != 0 {
|
||||
return Err(format!("Failed to write {resolved}: {}", result.stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
WORKING_DIRECTORY
|
||||
}
|
||||
|
|
@ -735,7 +773,7 @@ mod tests {
|
|||
let recorded = commands.lock().unwrap();
|
||||
let cmd = recorded[0].args.last().unwrap();
|
||||
assert!(
|
||||
cmd.contains("cd '/tmp/work'"),
|
||||
cmd.contains("cd /tmp/work"),
|
||||
"expected cd to working dir, got: {cmd}",
|
||||
);
|
||||
}
|
||||
|
|
@ -758,7 +796,7 @@ mod tests {
|
|||
let recorded = commands.lock().unwrap();
|
||||
let cmd = recorded[0].args.last().unwrap();
|
||||
assert!(
|
||||
cmd.contains("export FOO='bar';"),
|
||||
cmd.contains("export FOO=bar;"),
|
||||
"expected env var export, got: {cmd}",
|
||||
);
|
||||
}
|
||||
|
|
@ -820,7 +858,7 @@ mod tests {
|
|||
let recorded = commands.lock().unwrap();
|
||||
let cmd = recorded[0].args.last().unwrap();
|
||||
assert!(
|
||||
cmd.contains("cat '/etc/hosts'"),
|
||||
cmd.contains("cat /etc/hosts"),
|
||||
"expected absolute path in cat, got: {cmd}",
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -586,6 +586,14 @@ mod tests {
|
|||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
_local_path: &std::path::Path,
|
||||
_remote_path: &str,
|
||||
) -> std::result::Result<(), String> {
|
||||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -401,6 +401,13 @@ mod tests {
|
|||
.map_err(|e| format!("Failed to write: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
_local_path: &std::path::Path,
|
||||
_remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -878,6 +878,9 @@ mod tests {
|
|||
async fn download_file_to_local(&self, _remote: &str, _local: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn upload_file_from_local(&self, _local: &Path, _remote: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
451
crates/arc-workflows/src/cli/cp.rs
Normal file
451
crates/arc-workflows/src/cli/cp.rs
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::cli::runs::{default_logs_base, scan_runs};
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CpArgs {
|
||||
/// Source: <run-id>:<path> or local path
|
||||
pub src: String,
|
||||
/// Destination: <run-id>:<path> or local path
|
||||
pub dst: String,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
/// Parsed copy direction.
|
||||
enum CopyDirection {
|
||||
/// Download from sandbox to local
|
||||
Download {
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
local_path: PathBuf,
|
||||
},
|
||||
/// Upload from local to sandbox
|
||||
Upload {
|
||||
local_path: PathBuf,
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse src/dst to determine direction.
|
||||
///
|
||||
/// The convention is: `<run-id>:<path>` refers to a sandbox path,
|
||||
/// and a plain path (no colon) is local. We split on the first colon.
|
||||
fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
|
||||
let src_parts = split_run_path(src);
|
||||
let dst_parts = split_run_path(dst);
|
||||
|
||||
match (src_parts, dst_parts) {
|
||||
(Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download {
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
local_path: PathBuf::from(dst),
|
||||
}),
|
||||
(None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload {
|
||||
local_path: PathBuf::from(src),
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
}),
|
||||
(Some(_), Some(_)) => bail!("Cannot copy between two sandboxes; one argument must be a local path"),
|
||||
(None, None) => bail!("One argument must contain a run-id prefix (e.g. <run-id>:<path>)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `"run-id:path"` on the first colon.
|
||||
/// Returns `None` if the string doesn't look like a run-id:path reference.
|
||||
///
|
||||
/// We distinguish local paths from run references by checking:
|
||||
/// - Paths starting with `/`, `./`, or `../` are always local
|
||||
/// - Otherwise, split on the first colon
|
||||
fn split_run_path(s: &str) -> Option<(&str, &str)> {
|
||||
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
s.split_once(':')
|
||||
}
|
||||
|
||||
/// Find a run directory by prefix match against run IDs.
|
||||
fn find_run_by_prefix(base: &Path, prefix: &str) -> Result<PathBuf> {
|
||||
let runs = scan_runs(base).context("Failed to scan runs")?;
|
||||
let matches: Vec<_> = runs
|
||||
.iter()
|
||||
.filter(|r| r.run_id.starts_with(prefix))
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => {
|
||||
warn!(run_id = %prefix, "No matching run found");
|
||||
bail!("No run found matching prefix '{prefix}'")
|
||||
}
|
||||
1 => {
|
||||
let run = &matches[0];
|
||||
debug!(run_id = %prefix, matched = %run.run_id, "Resolved run by prefix");
|
||||
Ok(run.path.clone())
|
||||
}
|
||||
n => {
|
||||
let ids: Vec<&str> = matches.iter().map(|r| r.run_id.as_str()).collect();
|
||||
bail!(
|
||||
"Ambiguous prefix '{prefix}': {n} runs match: {}",
|
||||
ids.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconnect to a sandbox from a saved record.
|
||||
///
|
||||
/// Returns a sandbox that can perform file operations.
|
||||
/// Note: for Docker and Local sandboxes, the container/directory may still
|
||||
/// need to be alive. For Daytona and Exe, we reconnect via their APIs.
|
||||
async fn reconnect(
|
||||
record: &SandboxRecord,
|
||||
) -> Result<Box<dyn arc_agent::sandbox::Sandbox>> {
|
||||
debug!(
|
||||
provider = %record.provider,
|
||||
identifier = record.identifier.as_deref().unwrap_or(""),
|
||||
"Reconnecting to sandbox"
|
||||
);
|
||||
|
||||
match record.provider.as_str() {
|
||||
"local" => {
|
||||
let sandbox =
|
||||
arc_agent::local_sandbox::LocalSandbox::new(PathBuf::from(&record.working_directory));
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"docker" => {
|
||||
let host_dir = record
|
||||
.host_working_directory
|
||||
.as_deref()
|
||||
.context("Docker sandbox record missing host_working_directory")?;
|
||||
let mount_point = record
|
||||
.container_mount_point
|
||||
.as_deref()
|
||||
.unwrap_or("/workspace");
|
||||
|
||||
// Docker uses bind mounts — file operations can go directly through
|
||||
// the host filesystem without needing the container running.
|
||||
// We create a DockerSandboxConfig with the bind-mount info and use
|
||||
// a LocalSandbox pointed at the host directory (since we just need
|
||||
// file copy operations, not container exec).
|
||||
let config = arc_agent::docker_sandbox::DockerSandboxConfig {
|
||||
host_working_directory: host_dir.to_string(),
|
||||
container_mount_point: mount_point.to_string(),
|
||||
..arc_agent::docker_sandbox::DockerSandboxConfig::default()
|
||||
};
|
||||
let sandbox = arc_agent::docker_sandbox::DockerSandbox::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Docker sandbox: {e}"))?;
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"daytona" => {
|
||||
let name = record
|
||||
.identifier
|
||||
.as_deref()
|
||||
.context("Daytona sandbox record missing identifier (sandbox name)")?;
|
||||
|
||||
let client = daytona_sdk::Client::new()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Daytona client: {e}"))?;
|
||||
|
||||
let sdk_sandbox = client
|
||||
.get(name)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to reconnect to Daytona sandbox '{name}': {e}"))?;
|
||||
|
||||
let sandbox = crate::daytona_sandbox::DaytonaSandbox::from_existing(
|
||||
client,
|
||||
sdk_sandbox,
|
||||
);
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
"exe" => {
|
||||
let data_host = record
|
||||
.data_host
|
||||
.as_deref()
|
||||
.context("Exe sandbox record missing data_host")?;
|
||||
|
||||
let data_ssh = arc_exe::OpensshRunner::connect(data_host)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}"))?;
|
||||
|
||||
let sandbox = arc_exe::ExeSandbox::from_existing(Box::new(data_ssh));
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
other => bail!("Unknown sandbox provider: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cp_command(args: CpArgs) -> Result<()> {
|
||||
let direction = parse_direction(&args.src, &args.dst)?;
|
||||
let base = default_logs_base();
|
||||
|
||||
match direction {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
let run_dir = find_run_by_prefix(&base, &run_prefix)?;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
debug!(path = %sandbox_json.display(), "Loading sandbox record");
|
||||
let record = SandboxRecord::load(&sandbox_json)
|
||||
.context("Failed to load sandbox.json — was this run started with a recent version of arc?")?;
|
||||
|
||||
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
|
||||
let sandbox = reconnect(&record).await?;
|
||||
|
||||
if args.recursive {
|
||||
download_recursive(&*sandbox, &remote_path, &local_path).await?;
|
||||
} else {
|
||||
debug!(path = %remote_path, "Downloading file from sandbox");
|
||||
sandbox
|
||||
.download_file_to_local(&remote_path, &local_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
info!(direction = "download", path = %remote_path, "Copy complete");
|
||||
}
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
let run_dir = find_run_by_prefix(&base, &run_prefix)?;
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
debug!(path = %sandbox_json.display(), "Loading sandbox record");
|
||||
let record = SandboxRecord::load(&sandbox_json)
|
||||
.context("Failed to load sandbox.json — was this run started with a recent version of arc?")?;
|
||||
|
||||
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
|
||||
let sandbox = reconnect(&record).await?;
|
||||
|
||||
if args.recursive {
|
||||
upload_recursive(&*sandbox, &local_path, &remote_path).await?;
|
||||
} else {
|
||||
debug!(path = %remote_path, "Uploading file to sandbox");
|
||||
sandbox
|
||||
.upload_file_from_local(&local_path, &remote_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
info!(direction = "upload", path = %remote_path, "Copy complete");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively download a directory from the sandbox.
|
||||
async fn download_recursive(
|
||||
sandbox: &dyn arc_agent::sandbox::Sandbox,
|
||||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
let entries = sandbox
|
||||
.list_directory(remote_path, Some(100))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to list directory {remote_path}: {e}"))?;
|
||||
|
||||
let mut file_count = 0usize;
|
||||
for entry in &entries {
|
||||
let remote_file = format!("{remote_path}/{}", entry.name);
|
||||
let local_file = local_path.join(&entry.name);
|
||||
if entry.is_dir {
|
||||
// Directories are listed with their contents via depth traversal
|
||||
continue;
|
||||
}
|
||||
debug!(path = %remote_file, "Downloading file from sandbox");
|
||||
sandbox
|
||||
.download_file_to_local(&remote_file, &local_file)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
file_count += 1;
|
||||
}
|
||||
debug!(count = file_count, "Recursive download complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively upload a directory to the sandbox.
|
||||
async fn upload_recursive(
|
||||
sandbox: &dyn arc_agent::sandbox::Sandbox,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<()> {
|
||||
let mut file_count = 0usize;
|
||||
let mut stack: Vec<(PathBuf, String)> = vec![(local_path.to_path_buf(), remote_path.to_string())];
|
||||
|
||||
while let Some((dir_path, dir_remote)) = stack.pop() {
|
||||
let mut entries = tokio::fs::read_dir(&dir_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read directory {}", dir_path.display()))?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let entry_path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
let remote_file = format!("{dir_remote}/{file_name}");
|
||||
|
||||
if entry_path.is_dir() {
|
||||
stack.push((entry_path, remote_file));
|
||||
} else {
|
||||
debug!(path = %remote_file, "Uploading file to sandbox");
|
||||
sandbox
|
||||
.upload_file_from_local(&entry_path, &remote_file)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(count = file_count, "Recursive upload complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_direction_download() {
|
||||
let dir = parse_direction("abc123:/some/file.txt", "./local.txt").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "/some/file.txt");
|
||||
assert_eq!(local_path, PathBuf::from("./local.txt"));
|
||||
}
|
||||
_ => panic!("Expected Download"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_upload() {
|
||||
let dir = parse_direction("./local.txt", "abc123:/some/file.txt").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
assert_eq!(local_path, PathBuf::from("./local.txt"));
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "/some/file.txt");
|
||||
}
|
||||
_ => panic!("Expected Upload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_absolute_local_path() {
|
||||
let dir = parse_direction("abc123:src/main.rs", "/tmp/main.rs").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Download {
|
||||
run_prefix,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "src/main.rs");
|
||||
assert_eq!(local_path, PathBuf::from("/tmp/main.rs"));
|
||||
}
|
||||
_ => panic!("Expected Download"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_both_sandbox_errors() {
|
||||
let result = parse_direction("abc:path", "def:path");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_neither_sandbox_errors() {
|
||||
let result = parse_direction("./file.txt", "/tmp/file.txt");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_direction_relative_upload() {
|
||||
let dir = parse_direction("../local.txt", "abc123:remote.txt").unwrap();
|
||||
match dir {
|
||||
CopyDirection::Upload {
|
||||
local_path,
|
||||
run_prefix,
|
||||
remote_path,
|
||||
} => {
|
||||
assert_eq!(local_path, PathBuf::from("../local.txt"));
|
||||
assert_eq!(run_prefix, "abc123");
|
||||
assert_eq!(remote_path, "remote.txt");
|
||||
}
|
||||
_ => panic!("Expected Upload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_run_by_prefix_no_match() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = find_run_by_prefix(dir.path(), "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_run_by_prefix_single_match() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let run_dir = dir.path().join("20260101-ABC123");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": "abc123-full-id",
|
||||
"workflow_name": "test",
|
||||
"goal": "",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = find_run_by_prefix(dir.path(), "abc123").unwrap();
|
||||
assert_eq!(result, run_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_run_by_prefix_ambiguous() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (subdir, run_id) in [("d1", "abc-111"), ("d2", "abc-222")] {
|
||||
let run_dir = dir.path().join(subdir);
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
std::fs::write(
|
||||
run_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"run_id": run_id,
|
||||
"workflow_name": "test",
|
||||
"goal": "",
|
||||
"start_time": "2026-01-01T12:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = find_run_by_prefix(dir.path(), "abc");
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("Ambiguous"),
|
||||
"Should mention ambiguity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod backend;
|
||||
pub mod cli_backend;
|
||||
pub mod cp;
|
||||
pub mod parse;
|
||||
pub mod progress;
|
||||
pub mod run;
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ pub async fn run_command(
|
|||
exe_arc
|
||||
}
|
||||
SandboxProvider::Local => {
|
||||
let mut env = LocalSandbox::new(cwd);
|
||||
let mut env = LocalSandbox::new(cwd.clone());
|
||||
let emitter_cb = Arc::clone(&emitter);
|
||||
env.set_event_callback(Arc::new(move |event| {
|
||||
emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event });
|
||||
|
|
@ -613,6 +613,55 @@ pub async fn run_command(
|
|||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize sandbox: {e}"))?;
|
||||
|
||||
// Persist sandbox connection info for `arc cp`
|
||||
{
|
||||
let sandbox_info_opt = {
|
||||
let info = sandbox.sandbox_info();
|
||||
if info.is_empty() { None } else { Some(info) }
|
||||
};
|
||||
let record = match sandbox_provider {
|
||||
SandboxProvider::Local => crate::sandbox_record::SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: sandbox.working_directory().to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
},
|
||||
SandboxProvider::Docker => crate::sandbox_record::SandboxRecord {
|
||||
provider: "docker".to_string(),
|
||||
working_directory: sandbox.working_directory().to_string(),
|
||||
identifier: sandbox_info_opt,
|
||||
host_working_directory: Some(cwd.to_string_lossy().to_string()),
|
||||
container_mount_point: Some(sandbox.working_directory().to_string()),
|
||||
data_host: None,
|
||||
},
|
||||
SandboxProvider::Daytona => crate::sandbox_record::SandboxRecord {
|
||||
provider: "daytona".to_string(),
|
||||
working_directory: sandbox.working_directory().to_string(),
|
||||
identifier: sandbox_info_opt,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
},
|
||||
SandboxProvider::Exe => crate::sandbox_record::SandboxRecord {
|
||||
provider: "exe".to_string(),
|
||||
working_directory: sandbox.working_directory().to_string(),
|
||||
identifier: exe_sandbox_ref
|
||||
.as_ref()
|
||||
.and_then(|e| e.vm_name().map(String::from)),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: exe_sandbox_ref
|
||||
.as_ref()
|
||||
.and_then(|e| e.data_host().map(String::from)),
|
||||
},
|
||||
};
|
||||
if let Err(e) = record.save(&logs_dir.join("sandbox.json")) {
|
||||
tracing::warn!(error = %e, "Failed to save sandbox record");
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap exe.dev sandbox with GitCredentialSandbox for push credential refresh
|
||||
let sandbox: Arc<dyn Sandbox> = if sandbox_provider == SandboxProvider::Exe {
|
||||
let origin_url = exe_sandbox_ref
|
||||
|
|
@ -2209,4 +2258,4 @@ mod tests {
|
|||
let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap();
|
||||
assert_eq!(parsed["run_id"], "def-456");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +183,7 @@ fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn default_logs_base() -> PathBuf {
|
||||
pub(crate) fn default_logs_base() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("could not determine home directory")
|
||||
.join(".arc")
|
||||
|
|
@ -660,4 +660,4 @@ mod tests {
|
|||
let result = parse_label_filters(&args);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,6 +182,24 @@ impl DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a `DaytonaSandbox` from an already-existing Daytona SDK sandbox.
|
||||
/// Used for reconnection (e.g. `arc cp`).
|
||||
#[must_use]
|
||||
pub fn from_existing(client: daytona_sdk::Client, sdk_sandbox: daytona_sdk::Sandbox) -> Self {
|
||||
let sandbox_cell = tokio::sync::OnceCell::new();
|
||||
let _ = sandbox_cell.set(sdk_sandbox);
|
||||
Self {
|
||||
config: DaytonaConfig::default(),
|
||||
client,
|
||||
github_app: None,
|
||||
sandbox: sandbox_cell,
|
||||
rg_available: tokio::sync::OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
origin_url: tokio::sync::OnceCell::new(),
|
||||
run_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_event_callback(&mut self, cb: SandboxEventCallback) {
|
||||
self.event_callback = Some(cb);
|
||||
}
|
||||
|
|
@ -410,6 +428,31 @@ impl Sandbox for DaytonaSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let sandbox = self.sandbox()?;
|
||||
let resolved = self.resolve_path(remote_path);
|
||||
|
||||
let bytes = tokio::fs::read(local_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
|
||||
|
||||
let fs_svc = sandbox
|
||||
.fs()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get fs service: {e}"))?;
|
||||
|
||||
fs_svc
|
||||
.upload_file_bytes(&resolved, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file {resolved}: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.emit(SandboxEvent::Initializing {
|
||||
provider: "daytona".into(),
|
||||
|
|
|
|||
|
|
@ -107,6 +107,16 @@ impl Sandbox for GitCredentialSandbox {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &std::path::Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
self.inner
|
||||
.upload_file_from_local(local_path, remote_path)
|
||||
.await
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
self.inner.working_directory()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -647,6 +647,9 @@ mod tests {
|
|||
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,15 @@ impl Sandbox for WorktreeSandbox {
|
|||
.download_file_to_local(remote_path, local_path)
|
||||
.await
|
||||
}
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
local_path: &std::path::Path,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
self.inner
|
||||
.upload_file_from_local(local_path, remote_path)
|
||||
.await
|
||||
}
|
||||
async fn initialize(&self) -> Result<(), String> {
|
||||
self.inner.initialize().await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ pub mod preamble;
|
|||
pub mod pull_request;
|
||||
pub mod retro;
|
||||
pub mod retro_agent;
|
||||
pub mod sandbox_record;
|
||||
pub mod stylesheet;
|
||||
pub mod transform;
|
||||
pub mod validation;
|
||||
|
|
|
|||
140
crates/arc-workflows/src/sandbox_record.rs
Normal file
140
crates/arc-workflows/src/sandbox_record.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxRecord {
|
||||
/// Provider type: "local", "docker", "daytona", "exe"
|
||||
pub provider: String,
|
||||
/// Working directory inside the sandbox
|
||||
pub working_directory: String,
|
||||
/// Provider-specific identifier (container_id / sandbox_name / vm_name)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier: Option<String>,
|
||||
/// Docker: host path that is bind-mounted into the container
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_working_directory: Option<String>,
|
||||
/// Docker: mount point inside the container
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub container_mount_point: Option<String>,
|
||||
/// Exe: SSH destination for the data plane
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_host: Option<String>,
|
||||
}
|
||||
|
||||
impl SandboxRecord {
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
crate::save_json(self, path, "sandbox_record")
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
crate::load_json(path, "sandbox_record")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip_local() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("sandbox.json");
|
||||
|
||||
let record = SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/work".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
record.save(&path).unwrap();
|
||||
let loaded = SandboxRecord::load(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.provider, "local");
|
||||
assert_eq!(loaded.working_directory, "/tmp/work");
|
||||
assert!(loaded.identifier.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip_docker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("sandbox.json");
|
||||
|
||||
let record = SandboxRecord {
|
||||
provider: "docker".to_string(),
|
||||
working_directory: "/workspace".to_string(),
|
||||
identifier: Some("abc123container".to_string()),
|
||||
host_working_directory: Some("/home/user/project".to_string()),
|
||||
container_mount_point: Some("/workspace".to_string()),
|
||||
data_host: None,
|
||||
};
|
||||
record.save(&path).unwrap();
|
||||
let loaded = SandboxRecord::load(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.provider, "docker");
|
||||
assert_eq!(loaded.identifier.as_deref(), Some("abc123container"));
|
||||
assert_eq!(
|
||||
loaded.host_working_directory.as_deref(),
|
||||
Some("/home/user/project")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.container_mount_point.as_deref(),
|
||||
Some("/workspace")
|
||||
);
|
||||
assert!(loaded.data_host.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip_exe() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("sandbox.json");
|
||||
|
||||
let record = SandboxRecord {
|
||||
provider: "exe".to_string(),
|
||||
working_directory: "/home/exedev".to_string(),
|
||||
identifier: Some("my-vm".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: Some("my-vm.exe.xyz".to_string()),
|
||||
};
|
||||
record.save(&path).unwrap();
|
||||
let loaded = SandboxRecord::load(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.provider, "exe");
|
||||
assert_eq!(loaded.identifier.as_deref(), Some("my-vm"));
|
||||
assert_eq!(loaded.data_host.as_deref(), Some("my-vm.exe.xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_omitted_when_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("sandbox.json");
|
||||
|
||||
let record = SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/work".to_string(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
};
|
||||
record.save(&path).unwrap();
|
||||
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(raw.get("identifier").is_none());
|
||||
assert!(raw.get("host_working_directory").is_none());
|
||||
assert!(raw.get("container_mount_point").is_none());
|
||||
assert!(raw.get("data_host").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_nonexistent_file() {
|
||||
let result = SandboxRecord::load(Path::new("/nonexistent/sandbox.json"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -8926,6 +8926,10 @@ impl arc_agent::Sandbox for RemoteMockEnv {
|
|||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> std::result::Result<(), String> {
|
||||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
&self.working_dir
|
||||
}
|
||||
|
|
@ -9361,6 +9365,10 @@ impl arc_agent::Sandbox for CliTestEnv {
|
|||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
|
||||
Err("not implemented".to_string())
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
"/tmp/test"
|
||||
}
|
||||
|
|
@ -9638,6 +9646,9 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
|
|||
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
|
||||
Err("not implemented".to_string())
|
||||
}
|
||||
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
|
||||
Err("not implemented".to_string())
|
||||
}
|
||||
fn working_directory(&self) -> &str {
|
||||
"/tmp"
|
||||
}
|
||||
|
|
@ -13245,4 +13256,4 @@ async fn wait_timer_e2e() {
|
|||
};
|
||||
let outcome = engine.run(&graph, &config).await.expect("run");
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,15 @@ version = 1
|
|||
graph = "implement-and-simplify.dot"
|
||||
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
provider = "local"
|
||||
# provider = "daytona"
|
||||
|
||||
[sandbox.env]
|
||||
CARGO_INCREMENTAL = "0"
|
||||
# [sandbox.env]
|
||||
# CARGO_INCREMENTAL = "0"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "daytona-large"
|
||||
# [sandbox.daytona.snapshot]
|
||||
# name = "daytona-large"
|
||||
|
||||
|
||||
[sandbox.local]
|
||||
worktree = true
|
||||
Loading…
Add table
Reference in a new issue