fix(sandbox): preserve error chains

Introduce typed sandbox errors and carry source causes through workflow events, persisted failure summaries, and API/CLI string boundaries so Docker client failures keep the actionable underlying cause.
This commit is contained in:
Bryan Helmkamp 2026-04-27 10:41:39 -07:00
parent 43197729d1
commit 50847ebc6f
No known key found for this signature in database
38 changed files with 1308 additions and 634 deletions

3
Cargo.lock generated
View file

@ -2067,6 +2067,7 @@ dependencies = [
"fabro-redact",
"fabro-static",
"fabro-types",
"fabro-util",
"futures",
"git2",
"glob",
@ -2077,6 +2078,7 @@ dependencies = [
"strum",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"toml 0.8.23",
@ -2204,6 +2206,7 @@ dependencies = [
"chrono",
"dashmap",
"fabro-types",
"fabro-util",
"futures",
"hex",
"insta",

View file

@ -299,7 +299,7 @@ impl Session {
let launch_result = sandbox
.exec_command(&launch_script, 30_000, None, env_ref, None)
.await
.map_err(|e| format!("Failed to launch MCP server: {e}"))?;
.map_err(|e| format!("Failed to launch MCP server: {}", e.display_with_causes()))?;
let pid = launch_result.stdout.trim();
info!(pid, port, "MCP server process launched in sandbox");
@ -311,7 +311,12 @@ impl Session {
let poll_result = sandbox
.exec_command(&poll_cmd, 60_000, None, None, None)
.await
.map_err(|e| format!("Failed to poll MCP server readiness: {e}"))?;
.map_err(|e| {
format!(
"Failed to poll MCP server readiness: {}",
e.display_with_causes()
)
})?;
if poll_result.stdout.trim() != "ready" {
// Grab stderr for debugging
@ -333,7 +338,11 @@ impl Session {
// Get the preview URL for the port, or fall back to localhost for local
// sandboxes
if let Some(url_and_headers) = sandbox.get_preview_url(port).await? {
if let Some(url_and_headers) = sandbox
.get_preview_url(port)
.await
.map_err(|e| e.display_with_causes())?
{
Ok(url_and_headers)
} else {
info!(port, "No preview URL available, using localhost");

View file

@ -13,6 +13,10 @@ use crate::tool_registry::{RegisteredTool, ToolRegistry};
const MAX_WEB_FETCH_BYTES: usize = 100 * 1024;
fn sandbox_error(error: fabro_sandbox::Error) -> String {
error.display_with_causes()
}
/// Configuration for the optional LLM-based summarizer used by `web_fetch`.
#[derive(Clone)]
pub struct WebFetchSummarizer {
@ -103,7 +107,8 @@ pub fn make_read_file_tool() -> RegisteredTool {
let content = ctx
.env
.read_file(file_path, offset_usize, limit_usize)
.await?;
.await
.map_err(sandbox_error)?;
ctx.env.mark_agent_read(file_path);
Ok(content)
})
@ -131,7 +136,10 @@ pub fn make_write_file_tool() -> RegisteredTool {
let file_path = required_str(&args, "file_path")?;
let content = required_str(&args, "content")?;
ctx.env.write_file(file_path, content).await?;
ctx.env
.write_file(file_path, content)
.await
.map_err(sandbox_error)?;
Ok(format!("Successfully wrote to {file_path}"))
})
}),
@ -165,7 +173,11 @@ pub fn make_edit_file_tool() -> RegisteredTool {
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let numbered_content = ctx.env.read_file(file_path, None, None).await?;
let numbered_content = ctx
.env
.read_file(file_path, None, None)
.await
.map_err(sandbox_error)?;
// Strip line numbers: each line looks like " 1 | content" or " 10 | content"
let raw_lines: Vec<&str> = numbered_content
@ -190,7 +202,10 @@ pub fn make_edit_file_tool() -> RegisteredTool {
raw_content.replacen(old_string, new_string, 1)
};
ctx.env.write_file(file_path, &new_content).await?;
ctx.env
.write_file(file_path, &new_content)
.await
.map_err(sandbox_error)?;
Ok(format!("Successfully edited {file_path}"))
})
}),
@ -245,7 +260,8 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
ctx.tool_env.as_ref(),
Some(ctx.cancel),
)
.await?;
.await
.map_err(sandbox_error)?;
let mut output = String::new();
if result.timed_out {
@ -307,7 +323,11 @@ pub fn make_grep_tool() -> RegisteredTool {
max_results,
};
let results = ctx.env.grep(pattern, path, &options).await?;
let results = ctx
.env
.grep(pattern, path, &options)
.await
.map_err(sandbox_error)?;
let mut seen_files = std::collections::HashSet::new();
for line in &results {
if let Some(file_path) = line.split(':').next() {
@ -342,7 +362,7 @@ pub fn make_glob_tool() -> RegisteredTool {
let pattern = required_str(&args, "pattern")?;
let path = args.get("path").and_then(serde_json::Value::as_str);
let results = ctx.env.glob(pattern, path).await?;
let results = ctx.env.glob(pattern, path).await.map_err(sandbox_error)?;
Ok(results.join("\n"))
})
}),
@ -414,7 +434,11 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool {
let path = required_str(&args, "path")?;
let depth = optional_usize_arg(&args, "depth")?;
let entries = ctx.env.list_directory(path, depth).await?;
let entries = ctx
.env
.list_directory(path, depth)
.await
.map_err(sandbox_error)?;
let lines: Vec<String> = entries
.iter()
.map(|e| {
@ -579,9 +603,17 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option<WebFetchSummarizer>) -> Reg
"curl -sL --max-time {timeout_secs} -H 'User-Agent: fabro-agent/0.1' {escaped_url}"
);
let result = ctx.env
.exec_command(&command, timeout_ms, None, ctx.tool_env.as_ref(), Some(ctx.cancel))
.await?;
let result = ctx
.env
.exec_command(
&command,
timeout_ms,
None,
ctx.tool_env.as_ref(),
Some(ctx.cancel),
)
.await
.map_err(sandbox_error)?;
if result.exit_code != 0 {
return Err(format!(

View file

@ -6,6 +6,10 @@ use crate::sandbox::{Sandbox, format_lines_numbered};
use crate::tool_registry::RegisteredTool;
use crate::truncation::{TruncationMode, truncate_output};
fn sandbox_error(error: fabro_sandbox::Error) -> String {
error.display_with_causes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
Remove(String),
@ -205,11 +209,11 @@ pub async fn apply_patch_operations(
for op in ops {
match op {
PatchOperation::Add { path, content } => {
env.write_file(path, content).await?;
env.write_file(path, content).await.map_err(sandbox_error)?;
results.push(format!("Added file: {path}"));
}
PatchOperation::Delete { path } => {
env.delete_file(path).await?;
env.delete_file(path).await.map_err(sandbox_error)?;
results.push(format!("Deleted file: {path}"));
}
PatchOperation::Update {
@ -217,13 +221,18 @@ pub async fn apply_patch_operations(
new_path,
hunks,
} => {
let original = env.read_file(path, None, None).await?;
let original = env
.read_file(path, None, None)
.await
.map_err(sandbox_error)?;
let updated = apply_hunks(&original, hunks)
.map_err(|err| format_patch_error(&err, path, &original))?;
let dest = new_path.as_deref().unwrap_or(path);
env.write_file(dest, &updated).await?;
env.write_file(dest, &updated)
.await
.map_err(sandbox_error)?;
if new_path.is_some() {
env.delete_file(path).await?;
env.delete_file(path).await.map_err(sandbox_error)?;
results.push(format!("Moved file: {path} → {dest}"));
} else {
results.push(format!("Updated file: {path}"));

View file

@ -690,6 +690,7 @@ mod tests {
assert_eq!(
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
error: "cancelled".to_string(),
causes: Vec::new(),
duration_ms: 10,
reason: FailureReason::Cancelled,
git_commit_sha: None,
@ -700,6 +701,7 @@ mod tests {
assert_eq!(
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
error: "boom".to_string(),
causes: Vec::new(),
duration_ms: 10,
reason: FailureReason::Terminated,
git_commit_sha: None,

View file

@ -22,6 +22,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
serde.workspace = true
@ -31,6 +32,7 @@ tracing.workspace = true
base64.workspace = true
fabro-proc = { path = "../fabro-proc" }
fabro-static.workspace = true
fabro-util = { path = "../fabro-util" }
fabro-redact.workspace = true
shlex = "1"

View file

@ -30,7 +30,7 @@ pub(crate) fn decide_clone(
skip_clone: bool,
clone_origin_url: Option<&str>,
clone_branch: Option<&str>,
) -> Result<CloneDecision, String> {
) -> crate::Result<CloneDecision> {
if skip_clone {
return Ok(CloneDecision::EmptyWorkspace {
reason: EmptyWorkspaceReason::SkipClone,
@ -45,9 +45,9 @@ pub(crate) fn decide_clone(
let origin_url = fabro_github::normalize_repo_origin_url(origin_url);
if let Err(err) = fabro_github::parse_github_owner_repo(&origin_url) {
return Err(format!(
return Err(crate::Error::message(format!(
"Clone-based sandboxes currently support GitHub repository origins only: {err}"
));
)));
}
Ok(CloneDecision::GitHub {

View file

@ -75,10 +75,10 @@ impl DaytonaSandbox {
clone_origin_url: Option<String>,
clone_branch: Option<String>,
api_key: Option<String>,
) -> Result<Self, String> {
) -> crate::Result<Self> {
let client = build_daytona_client(api_key)
.await
.map_err(|e| format!("Failed to create Daytona client: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
Ok(Self {
config,
client,
@ -104,14 +104,16 @@ impl DaytonaSandbox {
repo_cloned: bool,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
) -> Result<Self, String> {
) -> crate::Result<Self> {
let client = build_daytona_client(api_key)
.await
.map_err(|e| format!("Failed to create Daytona client: {e}"))?;
let sdk_sandbox = client
.get(sandbox_name)
.await
.map_err(|e| format!("Failed to reconnect to Daytona sandbox '{sandbox_name}': {e}"))?;
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
let sdk_sandbox = client.get(sandbox_name).await.map_err(|e| {
crate::Error::context(
format!("Failed to reconnect to Daytona sandbox '{sandbox_name}'"),
e,
)
})?;
let sandbox_cell = OnceCell::new();
let _ = sandbox_cell.set(sdk_sandbox);
let origin_url = OnceCell::new();
@ -144,31 +146,30 @@ impl DaytonaSandbox {
/// Get the `ComputerUseService` for this sandbox.
///
/// Requires the sandbox to be initialized first.
pub async fn computer_use(&self) -> Result<daytona_sdk::ComputerUseService, String> {
pub async fn computer_use(&self) -> crate::Result<daytona_sdk::ComputerUseService> {
let sandbox = self.sandbox()?;
sandbox
.computer_use()
.await
.map_err(|e| format!("Failed to get computer use service: {e}"))
.map_err(|e| crate::Error::context("Failed to get computer use service", e))
}
/// Create SSH access and return the connection command string.
pub async fn create_ssh_access(&self, ttl_minutes: Option<f64>) -> Result<String, String> {
pub async fn create_ssh_access(&self, ttl_minutes: Option<f64>) -> crate::Result<String> {
let sandbox = self.sandbox()?;
let dto = sandbox
.create_ssh_access(ttl_minutes)
.await
.map_err(|e| format!("Failed to create SSH access: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create SSH access", e))?;
Ok(dto.ssh_command)
}
/// Get a preview link (URL + token) for a port on this sandbox.
pub async fn get_preview_link(&self, port: u16) -> Result<daytona_sdk::PreviewLink, String> {
pub async fn get_preview_link(&self, port: u16) -> crate::Result<daytona_sdk::PreviewLink> {
let sandbox = self.sandbox()?;
sandbox
.get_preview_link(port)
.await
.map_err(|e| format!("Failed to get preview link for port {port}: {e}"))
sandbox.get_preview_link(port).await.map_err(|e| {
crate::Error::context(format!("Failed to get preview link for port {port}"), e)
})
}
/// Get a signed preview URL for a port on this sandbox.
@ -176,12 +177,17 @@ impl DaytonaSandbox {
&self,
port: u16,
expires_in_seconds: Option<i32>,
) -> Result<SignedPortPreviewUrl, String> {
) -> crate::Result<SignedPortPreviewUrl> {
let sandbox = self.sandbox()?;
sandbox
.get_signed_preview_url(i32::from(port), expires_in_seconds)
.await
.map_err(|e| format!("Failed to get signed preview URL for port {port}: {e}"))
.map_err(|e| {
crate::Error::context(
format!("Failed to get signed preview URL for port {port}"),
e,
)
})
}
fn emit(&self, event: SandboxEvent) {
@ -200,10 +206,10 @@ impl DaytonaSandbox {
}
/// Get the sandbox, returning an error if not yet initialized.
fn sandbox(&self) -> Result<&daytona_sdk::Sandbox, String> {
self.sandbox
.get()
.ok_or_else(|| "Daytona sandbox not initialized — call initialize() first".to_string())
fn sandbox(&self) -> crate::Result<&daytona_sdk::Sandbox> {
self.sandbox.get().ok_or_else(|| {
crate::Error::message("Daytona sandbox not initialized — call initialize() first")
})
}
fn repo_cloned(&self) -> bool {
@ -243,19 +249,19 @@ impl DaytonaSandbox {
/// If the snapshot doesn't exist and a dockerfile is provided, creates it
/// and polls until it reaches `Active` state. Returns an error if the
/// snapshot is in a terminal failure state.
async fn ensure_snapshot(&self, snap_cfg: &DaytonaSnapshotConfig) -> Result<(), String> {
async fn ensure_snapshot(&self, snap_cfg: &DaytonaSnapshotConfig) -> crate::Result<()> {
match self.client.snapshot.get(&snap_cfg.name).await {
Ok(dto) => {
use daytona_api_client::models::SnapshotState;
match dto.state {
SnapshotState::Active => return Ok(()),
SnapshotState::Error | SnapshotState::BuildFailed => {
return Err(format!(
return Err(crate::Error::message(format!(
"Snapshot '{}' is in state '{}': {}",
snap_cfg.name,
dto.state,
dto.error_reason.unwrap_or_default()
));
)));
}
_ => {
// Building/Pending/Pulling — fall through to poll
@ -266,16 +272,16 @@ impl DaytonaSandbox {
let dockerfile = match &snap_cfg.dockerfile {
Some(DockerfileSource::Inline(s)) => s.as_str(),
Some(DockerfileSource::Path { .. }) => {
return Err(format!(
return Err(crate::Error::message(format!(
"Snapshot '{}': dockerfile path should have been resolved to inline content before sandbox creation",
snap_cfg.name
));
)));
}
None => {
return Err(format!(
return Err(crate::Error::message(format!(
"Snapshot '{}' does not exist and no dockerfile provided to create it",
snap_cfg.name
));
)));
}
};
@ -292,14 +298,18 @@ impl DaytonaSandbox {
}),
entrypoint: None,
};
self.client
.snapshot
.create(&params)
.await
.map_err(|e| format!("Failed to create snapshot '{}': {e}", snap_cfg.name))?;
self.client.snapshot.create(&params).await.map_err(|e| {
crate::Error::context(
format!("Failed to create snapshot '{}'", snap_cfg.name),
e,
)
})?;
}
Err(e) => {
return Err(format!("Failed to get snapshot '{}': {e}", snap_cfg.name));
return Err(crate::Error::context(
format!("Failed to get snapshot '{}'", snap_cfg.name),
e,
));
}
}
@ -309,7 +319,7 @@ impl DaytonaSandbox {
/// Poll a snapshot until it reaches `Active` state, with exponential
/// back-off.
async fn poll_snapshot_active(&self, name: &str) -> Result<(), String> {
async fn poll_snapshot_active(&self, name: &str) -> crate::Result<()> {
use daytona_api_client::models::SnapshotState;
let mut delay = std::time::Duration::from_secs(2);
let max_delay = std::time::Duration::from_secs(30);
@ -317,21 +327,18 @@ impl DaytonaSandbox {
while Instant::now() < deadline {
time::sleep(delay).await;
let dto = self
.client
.snapshot
.get(name)
.await
.map_err(|e| format!("Failed to poll snapshot '{name}': {e}"))?;
let dto = self.client.snapshot.get(name).await.map_err(|e| {
crate::Error::context(format!("Failed to poll snapshot '{name}'"), e)
})?;
match dto.state {
SnapshotState::Active => return Ok(()),
SnapshotState::Error | SnapshotState::BuildFailed => {
return Err(format!(
return Err(crate::Error::message(format!(
"Snapshot '{name}' failed ({}): {}",
dto.state,
dto.error_reason.unwrap_or_default()
));
)));
}
_ => {
delay = (delay * 2).min(max_delay);
@ -339,9 +346,9 @@ impl DaytonaSandbox {
}
}
Err(format!(
Err(crate::Error::message(format!(
"Timed out waiting for snapshot '{name}' to become active"
))
)))
}
}
@ -349,15 +356,19 @@ impl DaytonaSandbox {
///
/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL
/// and the HEAD branch name.
pub fn detect_repo_info(path: &Path) -> Result<(String, Option<String>), String> {
let repo = git2::Repository::discover(path)
.map_err(|e| format!("Failed to discover git repo at {}: {e}", path.display()))?;
pub fn detect_repo_info(path: &Path) -> crate::Result<(String, Option<String>)> {
let repo = git2::Repository::discover(path).map_err(|e| {
crate::Error::context(
format!("Failed to discover git repo at {}", path.display()),
e,
)
})?;
let url = repo
.find_remote("origin")
.map_err(|e| format!("Failed to find 'origin' remote: {e}"))?
.map_err(|e| crate::Error::context("Failed to find 'origin' remote", e))?
.url()
.ok_or_else(|| "origin remote URL is not valid UTF-8".to_string())?
.ok_or_else(|| crate::Error::message("origin remote URL is not valid UTF-8"))?
.to_string();
let branch = repo
@ -374,28 +385,28 @@ impl Sandbox for DaytonaSandbox {
&self,
remote_path: &str,
local_path: &Path,
) -> Result<(), String> {
) -> crate::Result<()> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(remote_path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
let bytes = fs_svc
.download_file(&resolved)
.await
.map_err(|e| format!("Failed to download file {resolved}: {e}"))?;
.map_err(|e| crate::Error::context(format!("Failed to download file {resolved}"), e))?;
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::write(local_path, &bytes)
.await
.map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?;
fs::write(local_path, &bytes).await.map_err(|e| {
crate::Error::context(format!("Failed to write {}", local_path.display()), e)
})?;
Ok(())
}
@ -404,7 +415,7 @@ impl Sandbox for DaytonaSandbox {
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
) -> crate::Result<()> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(remote_path);
@ -415,29 +426,29 @@ impl Sandbox for DaytonaSandbox {
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
let _ = fs_svc.create_folder(&parent_str, None).await;
}
}
let bytes = fs::read(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
let bytes = fs::read(local_path).await.map_err(|e| {
crate::Error::context(format!("Failed to read {}", local_path.display()), e)
})?;
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
fs_svc
.upload_file_bytes(&resolved, &bytes)
.await
.map_err(|e| format!("Failed to upload file {resolved}: {e}"))?;
.map_err(|e| crate::Error::context(format!("Failed to upload file {resolved}"), e))?;
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> crate::Result<()> {
self.emit(SandboxEvent::Initializing {
provider: "daytona".into(),
});
@ -450,14 +461,16 @@ impl Sandbox for DaytonaSandbox {
let snap_start = Instant::now();
if let Err(e) = self.ensure_snapshot(snap_cfg).await {
self.emit(SandboxEvent::SnapshotFailed {
name: snap_cfg.name.clone(),
error: e.clone(),
name: snap_cfg.name.clone(),
error: e.to_string(),
causes: e.causes(),
});
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -485,12 +498,13 @@ impl Sandbox for DaytonaSandbox {
.create(params, daytona_sdk::CreateSandboxOptions::default())
.await
.map_err(|e| {
let err = format!("Failed to create Daytona sandbox: {e}");
let err = crate::Error::context("Failed to create Daytona sandbox", e);
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
err
@ -507,7 +521,8 @@ impl Sandbox for DaytonaSandbox {
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -526,11 +541,11 @@ impl Sandbox for DaytonaSandbox {
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get Daytona fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get Daytona fs service", e))?;
fs_svc
.create_folder(WORKING_DIRECTORY, None)
.await
.map_err(|e| format!("Failed to create working directory: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create working directory", e))?;
let _ = self.repo_cloned.set(false);
}
CloneDecision::GitHub { origin_url, branch } => {
@ -544,10 +559,13 @@ impl Sandbox for DaytonaSandbox {
Some(creds) => {
let (owner, repo) = fabro_github::parse_github_owner_repo(&origin_url)
.map_err(|e| {
let err = format!("Failed to parse GitHub URL for clone: {e}");
let err = crate::Error::message(format!(
"Failed to parse GitHub URL for clone: {e}"
));
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url.clone(),
error: err.clone(),
url: origin_url.clone(),
error: err.to_string(),
causes: err.causes(),
});
err
})?;
@ -561,17 +579,20 @@ impl Sandbox for DaytonaSandbox {
)
.await
.map_err(|e| {
let err =
format!("Failed to get GitHub App credentials for clone: {e}");
let err = crate::Error::message(format!(
"Failed to get GitHub App credentials for clone: {e}"
));
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url.clone(),
error: err.clone(),
url: origin_url.clone(),
error: err.to_string(),
causes: err.causes(),
});
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
err
@ -583,19 +604,21 @@ impl Sandbox for DaytonaSandbox {
let git_svc = sandbox
.git()
.await
.map_err(|e| format!("Failed to get Daytona git service: {e}"));
.map_err(|e| crate::Error::context("Failed to get Daytona git service", e));
let git_svc = match git_svc {
Ok(g) => g,
Err(e) => {
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url.clone(),
error: e.clone(),
url: origin_url.clone(),
error: e.to_string(),
causes: e.causes(),
});
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -661,35 +684,41 @@ impl Sandbox for DaytonaSandbox {
}
}
Err(e) if self.github_app.is_none() => {
let err = format!(
"Git clone failed: {e}. If this is a private repository, \
let err = crate::Error::context(
"Git clone failed. If this is a private repository, \
configure a GitHub App with `fabro install` and install it \
for your organization."
for your organization.",
e,
);
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url,
error: err.clone(),
url: origin_url,
error: err.to_string(),
causes: err.causes(),
});
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
return Err(err);
}
Err(e) => {
let err = format!("Failed to clone repo into Daytona sandbox: {e}");
let err =
crate::Error::context("Failed to clone repo into Daytona sandbox", e);
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url,
error: err.clone(),
url: origin_url,
error: err.to_string(),
causes: err.causes(),
});
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "daytona".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
return Err(err);
@ -703,7 +732,7 @@ impl Sandbox for DaytonaSandbox {
let sandbox_memory = sandbox.memory;
self.sandbox
.set(sandbox)
.map_err(|_| "Daytona sandbox already initialized".to_string())?;
.map_err(|_| crate::Error::message("Daytona sandbox already initialized"))?;
tracing::info!("Daytona sandbox ready");
let init_duration = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
@ -719,7 +748,7 @@ impl Sandbox for DaytonaSandbox {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupStarted {
provider: "daytona".into(),
});
@ -727,10 +756,11 @@ impl Sandbox for DaytonaSandbox {
if let Some(sandbox) = self.sandbox.get() {
tracing::info!("Destroying Daytona sandbox");
if let Err(e) = sandbox.delete().await {
let err = format!("Failed to delete Daytona sandbox: {e}");
let err = crate::Error::context("Failed to delete Daytona sandbox", e);
self.emit(SandboxEvent::CleanupFailed {
provider: "daytona".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
});
return Err(err);
}
@ -762,7 +792,7 @@ impl Sandbox for DaytonaSandbox {
.unwrap_or_default()
}
async fn setup_git_for_run(&self, run_id: &str) -> Result<Option<crate::GitRunInfo>, String> {
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
if !self.repo_cloned() {
return Ok(None);
}
@ -803,7 +833,7 @@ impl Sandbox for DaytonaSandbox {
)
}
async fn ssh_access_command(&self) -> Result<Option<String>, String> {
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
self.create_ssh_access(Some(60.0)).await.map(Some)
}
@ -817,12 +847,11 @@ impl Sandbox for DaytonaSandbox {
async fn get_preview_url(
&self,
port: u16,
) -> Result<Option<(String, HashMap<String, String>)>, String> {
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
let sandbox = self.sandbox()?;
let preview = sandbox
.get_preview_link(port)
.await
.map_err(|e| format!("Failed to get preview link for port {port}: {e}"))?;
let preview = sandbox.get_preview_link(port).await.map_err(|e| {
crate::Error::context(format!("Failed to get preview link for port {port}"), e)
})?;
let mut headers = HashMap::new();
if !preview.token.is_empty() {
headers.insert("x-daytona-preview-token".to_string(), preview.token);
@ -834,7 +863,7 @@ impl Sandbox for DaytonaSandbox {
Ok(Some((preview.url, headers)))
}
async fn refresh_push_credentials(&self) -> Result<(), String> {
async fn refresh_push_credentials(&self) -> crate::Result<()> {
if !self.repo_cloned() {
return Ok(());
}
@ -850,7 +879,7 @@ impl Sandbox for DaytonaSandbox {
origin_url,
)
.await
.map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to refresh GitHub App token: {e}")))?;
let cmd = format!(
"git -c maintenance.auto=0 remote set-url origin {}",
@ -859,31 +888,30 @@ impl Sandbox for DaytonaSandbox {
let result = self
.exec_command(&cmd, 10_000, None, None, None)
.await
.map_err(|e| format!("Failed to set refreshed push credentials: {e}"))?;
.map_err(|e| crate::Error::context("Failed to set refreshed push credentials", e))?;
if result.exit_code != 0 {
let stderr = result
.stderr
.replace(&auth_url.raw_string(), &auth_url.redacted_string());
return Err(format!(
return Err(crate::Error::message(format!(
"Failed to set refreshed push credentials (exit {}): {}",
result.exit_code, stderr
));
)));
}
Ok(())
}
async fn set_autostop_interval(&self, minutes: i32) -> Result<(), String> {
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
let sandbox_id = self.sandbox()?.id.clone();
let mut sandbox = self
.client
.get(&sandbox_id)
.await
.map_err(|e| format!("Failed to get sandbox for autostop update: {e}"))?;
let mut sandbox =
self.client.get(&sandbox_id).await.map_err(|e| {
crate::Error::context("Failed to get sandbox for autostop update", e)
})?;
sandbox
.set_autostop_interval(minutes)
.await
.map_err(|e| format!("Failed to set autostop interval: {e}"))
.map_err(|e| crate::Error::context("Failed to set autostop interval", e))
}
async fn read_file(
@ -891,27 +919,27 @@ impl Sandbox for DaytonaSandbox {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
) -> crate::Result<String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
let bytes = fs_svc
.download_file(&resolved)
.await
.map_err(|e| format!("Failed to read file {resolved}: {e}"))?;
.map_err(|e| crate::Error::context(format!("Failed to read file {resolved}"), e))?;
let content =
String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {e}"))?;
let content = String::from_utf8(bytes)
.map_err(|e| crate::Error::context("File is not valid UTF-8", e))?;
Ok(format_lines_numbered(&content, offset, limit))
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
@ -922,7 +950,7 @@ impl Sandbox for DaytonaSandbox {
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
let _ = fs_svc.create_folder(&parent_str, None).await;
}
}
@ -930,46 +958,49 @@ impl Sandbox for DaytonaSandbox {
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
fs_svc
.upload_file_bytes(&resolved, content.as_bytes())
.await
.map_err(|e| format!("Failed to write file {resolved}: {e}"))?;
.map_err(|e| crate::Error::context(format!("Failed to write file {resolved}"), e))?;
Ok(())
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
async fn delete_file(&self, path: &str) -> crate::Result<()> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
fs_svc
.delete_file(&resolved, false)
.await
.map_err(|e| format!("Failed to delete file {resolved}: {e}"))?;
.map_err(|e| crate::Error::context(format!("Failed to delete file {resolved}"), e))?;
Ok(())
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
match fs_svc.get_file_info(&resolved).await {
Ok(_) => Ok(true),
Err(daytona_sdk::DaytonaError::NotFound { .. }) => Ok(false),
Err(e) => Err(format!("Failed to check file existence {resolved}: {e}")),
Err(e) => Err(crate::Error::context(
format!("Failed to check file existence {resolved}"),
e,
)),
}
}
@ -977,19 +1008,18 @@ impl Sandbox for DaytonaSandbox {
&self,
path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> crate::Result<Vec<DirEntry>> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
let files = fs_svc
.list_files(&resolved)
.await
.map_err(|e| format!("Failed to list directory {resolved}: {e}"))?;
let files = fs_svc.list_files(&resolved).await.map_err(|e| {
crate::Error::context(format!("Failed to list directory {resolved}"), e)
})?;
Ok(files
.into_iter()
@ -1012,7 +1042,7 @@ impl Sandbox for DaytonaSandbox {
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
tracing::info!(command, timeout_ms, "exec_command: entered");
let sandbox = self.sandbox()?;
@ -1024,7 +1054,7 @@ impl Sandbox for DaytonaSandbox {
let process_svc = sandbox
.process()
.await
.map_err(|e| format!("Failed to get process service: {e}"))?;
.map_err(|e| crate::Error::context("Failed to get process service", e))?;
tracing::info!(
elapsed_ms = elapsed_ms(&start),
@ -1070,7 +1100,7 @@ impl Sandbox for DaytonaSandbox {
ok = res.is_ok(),
"exec_command: HTTP response received"
);
res.map_err(|e| format!("Failed to execute command: {e}"))?
res.map_err(|e| crate::Error::context("Failed to execute command", e))?
}
() = time::sleep(timeout_duration) => {
tracing::info!(
@ -1119,7 +1149,7 @@ impl Sandbox for DaytonaSandbox {
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> crate::Result<Vec<String>> {
let resolved = self.resolve_path(path);
// Detect ripgrep availability (cached)
@ -1178,16 +1208,16 @@ impl Sandbox for DaytonaSandbox {
return Ok(Vec::new());
}
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"grep failed (exit {}): {}",
result.exit_code, result.stderr
));
)));
}
Ok(result.stdout.lines().map(String::from).collect())
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>> {
let base = path.map_or_else(|| WORKING_DIRECTORY.to_string(), |p| self.resolve_path(p));
let cmd = format!(
@ -1199,10 +1229,10 @@ impl Sandbox for DaytonaSandbox {
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"glob failed (exit {}): {}",
result.exit_code, result.stderr
));
)));
}
Ok(result

View file

@ -88,9 +88,8 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
) -> Result<Self, String> {
let docker = Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker daemon: {e}"))?;
) -> crate::Result<Self> {
let docker = Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect)?;
Ok(Self {
docker,
config,
@ -113,7 +112,7 @@ impl DockerSandbox {
repo_cloned: bool,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
) -> Result<Self, String> {
) -> crate::Result<Self> {
let sandbox = Self::new(
DockerSandboxOptions::default(),
None,
@ -149,11 +148,10 @@ impl DockerSandbox {
}
}
fn container_id(&self) -> Result<&str, String> {
self.container_id
.get()
.map(String::as_str)
.ok_or_else(|| "Container not initialized — call initialize() first".to_string())
fn container_id(&self) -> crate::Result<&str> {
self.container_id.get().map(String::as_str).ok_or_else(|| {
crate::Error::message("Container not initialized — call initialize() first")
})
}
fn resolve_container_path(path: &str) -> String {
@ -169,7 +167,7 @@ impl DockerSandbox {
cmd: Vec<String>,
working_dir: Option<&str>,
env: Option<Vec<String>>,
) -> Result<(String, String, i32), String> {
) -> crate::Result<(String, String, i32)> {
let container_id = self.container_id()?;
let exec_opts = CreateExecOptions {
@ -185,13 +183,13 @@ impl DockerSandbox {
.docker
.create_exec(container_id, exec_opts)
.await
.map_err(|e| format!("Failed to create exec: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to create exec: {e}")))?;
let start_result = self
.docker
.start_exec(&exec_instance.id, None)
.await
.map_err(|e| format!("Failed to start exec: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to start exec: {e}")))?;
let mut stdout = String::new();
let mut stderr = String::new();
@ -206,7 +204,11 @@ impl DockerSandbox {
stderr.push_str(&String::from_utf8_lossy(&message));
}
Ok(_) => {}
Err(e) => return Err(format!("Error reading exec output: {e}")),
Err(e) => {
return Err(crate::Error::message(format!(
"Error reading exec output: {e}"
)));
}
}
}
}
@ -215,7 +217,7 @@ impl DockerSandbox {
.docker
.inspect_exec(&exec_instance.id)
.await
.map_err(|e| format!("Failed to inspect exec: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to inspect exec: {e}")))?;
let exit_code = inspect
.exit_code
@ -231,7 +233,7 @@ impl DockerSandbox {
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
let start = Instant::now();
let effective_dir = working_dir.unwrap_or(WORKING_DIRECTORY).to_string();
let env: Option<Vec<String>> =
@ -280,13 +282,20 @@ impl DockerSandbox {
}
}
async fn ensure_image(&self) -> Result<(), String> {
async fn ensure_image(&self) -> crate::Result<()> {
if !self.config.auto_pull {
return Ok(());
}
if self.docker.inspect_image(&self.config.image).await.is_ok() {
return Ok(());
match self.docker.inspect_image(&self.config.image).await {
Ok(_) => return Ok(()),
Err(e) if docker_not_found(&e) => {}
Err(e) => {
return Err(crate::Error::docker_image_inspect(
self.config.image.clone(),
e,
));
}
}
let (repo, tag) = if let Some((r, t)) = self.config.image.rsplit_once(':') {
@ -303,13 +312,13 @@ impl DockerSandbox {
let mut stream = self.docker.create_image(Some(opts), None, None);
while let Some(result) = stream.next().await {
result.map_err(|e| format!("Failed to pull image {}: {e}", self.config.image))?;
result.map_err(|e| crate::Error::docker_image_pull(self.config.image.clone(), e))?;
}
Ok(())
}
async fn create_workspace(&self) -> Result<(), String> {
async fn create_workspace(&self) -> crate::Result<()> {
let result = self
.docker_exec_shell(
&format!("mkdir -p {}", shell_quote(WORKING_DIRECTORY)),
@ -320,23 +329,23 @@ impl DockerSandbox {
)
.await?;
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"Failed to create Docker workspace (exit {}): {}",
result.exit_code, result.stderr
));
)));
}
Ok(())
}
async fn verify_git_available(&self) -> Result<(), String> {
async fn verify_git_available(&self) -> crate::Result<()> {
let result = self
.docker_exec_shell("git --version", 10_000, Some("/"), None, None)
.await?;
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"Docker image '{}' must include git for repository clone and git lifecycle operations. Use an image with bash and git, such as buildpack-deps:noble.",
self.config.image
));
)));
}
Ok(())
}
@ -345,7 +354,7 @@ impl DockerSandbox {
&self,
origin_url: String,
branch: Option<String>,
) -> Result<(), String> {
) -> crate::Result<()> {
self.verify_git_available().await?;
self.emit(SandboxEvent::GitCloneStarted {
@ -361,7 +370,11 @@ impl DockerSandbox {
&origin_url,
)
.await
.map_err(|e| format!("Failed to get GitHub App credentials for clone: {e}"))?,
.map_err(|e| {
crate::Error::message(format!(
"Failed to get GitHub App credentials for clone: {e}"
))
})?,
),
None => None,
};
@ -385,16 +398,17 @@ impl DockerSandbox {
.await?;
if result.exit_code != 0 {
let stderr = redact_auth_url(&result.stderr, auth_url.as_ref());
let err = if self.github_app.is_none() {
let err = crate::Error::message(if self.github_app.is_none() {
format!(
"Git clone failed: {stderr}. If this is a private repository, configure a GitHub App with `fabro install` and install it for your organization."
)
} else {
format!("Failed to clone repo into Docker sandbox: {stderr}")
};
});
self.emit(SandboxEvent::GitCloneFailed {
url: origin_url,
error: err.clone(),
url: origin_url,
error: err.to_string(),
causes: err.causes(),
});
return Err(err);
}
@ -426,21 +440,23 @@ impl DockerSandbox {
Ok(())
}
async fn validate_managed_container(&self, container_id: &str) -> Result<(), String> {
async fn validate_managed_container(&self, container_id: &str) -> crate::Result<()> {
let labels = self.inspect_labels(container_id).await?;
verify_managed_labels(container_id, &labels, self.run_id.as_ref())
}
async fn inspect_labels(&self, container_id: &str) -> Result<HashMap<String, String>, String> {
async fn inspect_labels(&self, container_id: &str) -> crate::Result<HashMap<String, String>> {
let inspect = self
.docker
.inspect_container(container_id, None::<InspectContainerOptions>)
.await
.map_err(|e| {
if docker_not_found(&e) {
format!("Docker container '{container_id}' is gone")
crate::Error::message(format!("Docker container '{container_id}' is gone"))
} else {
format!("Failed to inspect Docker container '{container_id}': {e}")
crate::Error::message(format!(
"Failed to inspect Docker container '{container_id}': {e}"
))
}
})?;
Ok(inspect
@ -449,7 +465,7 @@ impl DockerSandbox {
.unwrap_or_default())
}
async fn ensure_name_available(&self) -> Result<Option<String>, String> {
async fn ensure_name_available(&self) -> crate::Result<Option<String>> {
let Some(run_id) = self.run_id.as_ref() else {
return Ok(None);
};
@ -459,17 +475,17 @@ impl DockerSandbox {
.inspect_container(&name, None::<InspectContainerOptions>)
.await
{
Ok(_) => Err(format!(
Ok(_) => Err(crate::Error::message(format!(
"Docker container name '{name}' already exists for run {run_id}. Remove the stale container manually before retrying."
)),
))),
Err(e) if docker_not_found(&e) => Ok(Some(name)),
Err(e) => Err(format!(
Err(e) => Err(crate::Error::message(format!(
"Failed to check Docker container name '{name}' before creation: {e}"
)),
))),
}
}
async fn upload_bytes_to_container(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
async fn upload_bytes_to_container(&self, path: &str, bytes: &[u8]) -> crate::Result<()> {
let container_path = Self::resolve_container_path(path);
let container_id = self.container_id()?;
let parent_dir = std::path::Path::new(&container_path)
@ -477,7 +493,7 @@ impl DockerSandbox {
.map_or_else(|| "/".to_string(), |p| p.to_string_lossy().to_string());
let file_name = std::path::Path::new(&container_path)
.file_name()
.ok_or_else(|| format!("Invalid path: {container_path}"))?
.ok_or_else(|| crate::Error::message(format!("Invalid path: {container_path}")))?
.to_string_lossy()
.to_string();
@ -491,10 +507,10 @@ impl DockerSandbox {
)
.await?;
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"Failed to create parent dirs for {container_path}: {}",
result.stderr
));
)));
}
let tar_bytes = build_single_file_tar(&file_name, bytes)?;
@ -506,13 +522,14 @@ impl DockerSandbox {
self.docker
.upload_to_container(container_id, Some(upload_opts), tar_bytes.into())
.await
.map_err(|e| format!("Failed to upload file to container: {e}"))
.map_err(|e| crate::Error::context("Failed to upload file to container", e))
}
fn cleanup_error(&self, error: String) -> Result<(), String> {
fn cleanup_error(&self, error: crate::Error) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupFailed {
provider: "docker".into(),
error: error.clone(),
error: error.to_string(),
causes: error.causes(),
});
Err(error)
}
@ -567,19 +584,19 @@ fn verify_managed_labels(
container_id: &str,
labels: &HashMap<String, String>,
run_id: Option<&RunId>,
) -> Result<(), String> {
) -> crate::Result<()> {
if labels.get(MANAGED_LABEL).map(String::as_str) != Some("true") {
return Err(format!(
return Err(crate::Error::message(format!(
"Refusing to operate on Docker container '{container_id}' because it is missing label {MANAGED_LABEL}=true"
));
)));
}
if let Some(run_id) = run_id {
let actual = labels.get(RUN_ID_LABEL).map(String::as_str);
let expected = run_id.to_string();
if actual != Some(expected.as_str()) {
return Err(format!(
return Err(crate::Error::message(format!(
"Refusing to operate on Docker container '{container_id}' because label {RUN_ID_LABEL}={actual:?} does not match run {run_id}"
));
)));
}
}
Ok(())
@ -612,23 +629,24 @@ fn redact_auth_url(text: &str, auth_url: Option<&fabro_redact::DisplaySafeUrl>)
text.replace(&auth_url.raw_string(), &auth_url.redacted_string())
}
fn build_single_file_tar(file_name: &str, bytes: &[u8]) -> Result<Vec<u8>, String> {
fn build_single_file_tar(file_name: &str, bytes: &[u8]) -> crate::Result<Vec<u8>> {
let mut tar_builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header
.set_path(file_name)
.map_err(|e| format!("Failed to set tar path: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to set tar path: {e}")))?;
header.set_size(
u64::try_from(bytes.len()).map_err(|_| "file is too large for tar header".to_string())?,
u64::try_from(bytes.len())
.map_err(|_| crate::Error::message("file is too large for tar header"))?,
);
header.set_mode(0o644);
header.set_cksum();
tar_builder
.append(&header, bytes)
.map_err(|e| format!("Failed to build tar archive: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to build tar archive: {e}")))?;
tar_builder
.into_inner()
.map_err(|e| format!("Failed to finalize tar archive: {e}"))
.map_err(|e| crate::Error::message(format!("Failed to finalize tar archive: {e}")))
}
#[async_trait]
@ -637,7 +655,7 @@ impl Sandbox for DockerSandbox {
&self,
remote_path: &str,
local_path: &std::path::Path,
) -> Result<(), String> {
) -> crate::Result<()> {
let container_id = self.container_id()?;
let container_path = Self::resolve_container_path(remote_path);
let opts = DownloadFromContainerOptions {
@ -648,8 +666,12 @@ impl Sandbox for DockerSandbox {
.download_from_container(container_id, Some(opts));
let mut archive_bytes = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk
.map_err(|e| format!("Failed to download {container_path} from container: {e}"))?;
let chunk = chunk.map_err(|e| {
crate::Error::context(
format!("Failed to download {container_path} from container"),
e,
)
})?;
archive_bytes.extend_from_slice(&chunk);
}
@ -661,51 +683,62 @@ impl Sandbox for DockerSandbox {
use std::io::Read as _;
let mut archive = tar::Archive::new(Cursor::new(archive_bytes));
let entries = archive
.entries()
.map_err(|e| format!("Failed to read Docker archive for {container_path}: {e}"))?;
let entries = archive.entries().map_err(|e| {
crate::Error::context(
format!("Failed to read Docker archive for {container_path}"),
e,
)
})?;
let mut file_bytes = None;
for entry in entries {
let mut entry = entry.map_err(|e| {
format!("Failed to read Docker archive entry for {container_path}: {e}")
crate::Error::context(
format!("Failed to read Docker archive entry for {container_path}"),
e,
)
})?;
if !entry.header().entry_type().is_file() {
continue;
}
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| {
format!("Failed to read Docker archive file for {container_path}: {e}")
crate::Error::context(
format!("Failed to read Docker archive file for {container_path}"),
e,
)
})?;
file_bytes = Some(bytes);
break;
}
file_bytes.ok_or_else(|| {
format!("Docker archive for {container_path} did not contain a file")
crate::Error::message(format!(
"Docker archive for {container_path} did not contain a file"
))
})?
};
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::write(local_path, bytes)
.await
.map_err(|e| format!("Failed to write {}: {e}", local_path.display()))
fs::write(local_path, bytes).await.map_err(|e| {
crate::Error::context(format!("Failed to write {}", local_path.display()), e)
})
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
let bytes = fs::read(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
) -> crate::Result<()> {
let bytes = fs::read(local_path).await.map_err(|e| {
crate::Error::context(format!("Failed to read {}", local_path.display()), e)
})?;
self.upload_bytes_to_container(remote_path, &bytes).await
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> crate::Result<()> {
self.emit(SandboxEvent::Initializing {
provider: "docker".into(),
});
@ -719,7 +752,8 @@ impl Sandbox for DockerSandbox {
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -737,7 +771,8 @@ impl Sandbox for DockerSandbox {
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -752,24 +787,24 @@ impl Sandbox for DockerSandbox {
.create_container(create_options, container_config(&self.config, self.run_id.as_ref()))
.await
.map_err(|e| {
let err = if matches!(
let message = if matches!(
e,
DockerError::DockerResponseServerError {
status_code: 409,
..
}
) {
format!(
"Docker container for run already exists. Remove the stale fabro-run container manually before retrying: {e}"
)
"Docker container for run already exists. Remove the stale fabro-run container manually before retrying.".to_string()
} else {
format!("Failed to create container: {e}")
"Failed to create Docker container".to_string()
};
let err = crate::Error::context(message, e);
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
err
@ -778,18 +813,19 @@ impl Sandbox for DockerSandbox {
let id = container.id.clone();
self.container_id
.set(id.clone())
.map_err(|_| "Container already initialized".to_string())?;
.map_err(|_| crate::Error::message("Container already initialized"))?;
self.docker
.start_container(&id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| {
let err = bash_remediation(&e, &self.config.image);
let err = crate::Error::context(bash_remediation(&e, &self.config.image), e);
let duration_ms =
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
err
@ -807,13 +843,14 @@ impl Sandbox for DockerSandbox {
)
.await?;
if exit_code != 0 || !stdout.contains("ready") {
let err = format!(
let err = crate::Error::message(format!(
"Docker container health check failed. Docker sandboxes require /bin/bash; use an image with bash and git, such as buildpack-deps:noble. {stderr}"
);
));
let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: err.clone(),
error: err.to_string(),
causes: err.causes(),
duration_ms,
});
return Err(err);
@ -838,7 +875,8 @@ impl Sandbox for DockerSandbox {
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -859,7 +897,8 @@ impl Sandbox for DockerSandbox {
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -872,7 +911,8 @@ impl Sandbox for DockerSandbox {
u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
});
return Err(e);
@ -893,7 +933,7 @@ impl Sandbox for DockerSandbox {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupStarted {
provider: "docker".into(),
});
@ -923,8 +963,11 @@ impl Sandbox for DockerSandbox {
.await
{
if !docker_not_found(&e) && !docker_already_stopped(&e) {
return self.cleanup_error(format!(
"Failed to stop Docker container '{container_id}' with labels {labels:?}: {e}"
return self.cleanup_error(crate::Error::context(
format!(
"Failed to stop Docker container '{container_id}' with labels {labels:?}"
),
e,
));
}
}
@ -939,8 +982,11 @@ impl Sandbox for DockerSandbox {
.await
{
if !docker_not_found(&e) {
return self.cleanup_error(format!(
"Failed to remove Docker container '{container_id}' with labels {labels:?}: {e}"
return self.cleanup_error(crate::Error::context(
format!(
"Failed to remove Docker container '{container_id}' with labels {labels:?}"
),
e,
));
}
}
@ -961,7 +1007,7 @@ impl Sandbox for DockerSandbox {
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
let dir = working_dir.map(Self::resolve_container_path);
self.docker_exec_shell(command, timeout_ms, dir.as_deref(), env_vars, cancel_token)
.await
@ -972,25 +1018,27 @@ impl Sandbox for DockerSandbox {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
) -> crate::Result<String> {
let container_path = Self::resolve_container_path(path);
let (stdout, stderr, exit_code) = self
.docker_exec(vec!["cat".to_string(), container_path.clone()], None, None)
.await?;
if exit_code != 0 {
return Err(format!("Failed to read {container_path}: {stderr}"));
return Err(crate::Error::message(format!(
"Failed to read {container_path}: {stderr}"
)));
}
Ok(format_lines_numbered(&stdout, offset, limit))
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
self.upload_bytes_to_container(path, content.as_bytes())
.await
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
async fn delete_file(&self, path: &str) -> crate::Result<()> {
let container_path = Self::resolve_container_path(path);
let (_, stderr, exit_code) = self
.docker_exec(
@ -1001,12 +1049,14 @@ impl Sandbox for DockerSandbox {
.await?;
if exit_code != 0 {
return Err(format!("Failed to delete {container_path}: {stderr}"));
return Err(crate::Error::message(format!(
"Failed to delete {container_path}: {stderr}"
)));
}
Ok(())
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
let container_path = Self::resolve_container_path(path);
let (_, _, exit_code) = self
.docker_exec(
@ -1023,7 +1073,7 @@ impl Sandbox for DockerSandbox {
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> crate::Result<Vec<DirEntry>> {
let container_path = Self::resolve_container_path(path);
let max_depth = depth.unwrap_or(1);
let (stdout, stderr, exit_code) = self
@ -1044,9 +1094,9 @@ impl Sandbox for DockerSandbox {
.await?;
if exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"Failed to list directory {container_path}: {stderr}"
));
)));
}
let mut entries: Vec<DirEntry> = stdout
@ -1078,7 +1128,7 @@ impl Sandbox for DockerSandbox {
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> crate::Result<Vec<String>> {
let container_path = Self::resolve_container_path(path);
let use_rg = *self
.rg_available
@ -1133,10 +1183,10 @@ impl Sandbox for DockerSandbox {
return Ok(Vec::new());
}
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"grep failed (exit {}): {}",
result.exit_code, result.stderr
));
)));
}
Ok(result
@ -1147,7 +1197,7 @@ impl Sandbox for DockerSandbox {
.collect())
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>> {
let base_dir = path.map_or_else(
|| WORKING_DIRECTORY.to_string(),
Self::resolve_container_path,
@ -1161,10 +1211,10 @@ impl Sandbox for DockerSandbox {
.docker_exec_shell(&command, 30_000, None, None, None)
.await?;
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"glob failed (exit {}): {}",
result.exit_code, result.stderr
));
)));
}
Ok(result
@ -1194,7 +1244,7 @@ impl Sandbox for DockerSandbox {
self.container_id.get().cloned().unwrap_or_default()
}
async fn setup_git_for_run(&self, run_id: &str) -> Result<Option<crate::GitRunInfo>, String> {
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
if !self.repo_cloned() {
return Ok(None);
}
@ -1242,7 +1292,7 @@ impl Sandbox for DockerSandbox {
self.origin_url.get().map(String::as_str)
}
async fn refresh_push_credentials(&self) -> Result<(), String> {
async fn refresh_push_credentials(&self) -> crate::Result<()> {
if !self.repo_cloned() {
return Ok(());
}
@ -1258,7 +1308,7 @@ impl Sandbox for DockerSandbox {
origin_url,
)
.await
.map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?;
.map_err(|e| crate::Error::message(format!("Failed to refresh GitHub App token: {e}")))?;
let command = format!(
"git -c maintenance.auto=0 remote set-url origin {}",
@ -1269,10 +1319,10 @@ impl Sandbox for DockerSandbox {
.await?;
if result.exit_code != 0 {
let stderr = redact_auth_url(&result.stderr, Some(&auth_url));
return Err(format!(
return Err(crate::Error::message(format!(
"Failed to set refreshed push credentials (exit {}): {}",
result.exit_code, stderr
));
)));
}
Ok(())

View file

@ -0,0 +1,94 @@
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Message(String),
#[error("{message}")]
Context {
message: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[cfg(feature = "docker")]
#[error("Failed to connect to Docker daemon")]
DockerConnect {
#[source]
source: bollard::errors::Error,
},
#[cfg(feature = "docker")]
#[error("Failed to inspect Docker image {image}")]
DockerImageInspect {
image: String,
#[source]
source: bollard::errors::Error,
},
#[cfg(feature = "docker")]
#[error("Failed to pull Docker image {image}")]
DockerImagePull {
image: String,
#[source]
source: bollard::errors::Error,
},
}
impl Error {
pub fn message(message: impl Into<String>) -> Self {
Self::Message(message.into())
}
pub fn context(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Context {
message: message.into(),
source: Box::new(source),
}
}
#[cfg(feature = "docker")]
pub fn docker_connect(source: bollard::errors::Error) -> Self {
Self::DockerConnect { source }
}
#[cfg(feature = "docker")]
pub fn docker_image_inspect(image: impl Into<String>, source: bollard::errors::Error) -> Self {
Self::DockerImageInspect {
image: image.into(),
source,
}
}
#[cfg(feature = "docker")]
pub fn docker_image_pull(image: impl Into<String>, source: bollard::errors::Error) -> Self {
Self::DockerImagePull {
image: image.into(),
source,
}
}
pub fn causes(&self) -> Vec<String> {
fabro_util::error::collect_causes(self)
}
pub fn display_with_causes(&self) -> String {
fabro_util::error::render_with_causes(&self.to_string(), &self.causes())
}
}
impl From<String> for Error {
fn from(value: String) -> Self {
Self::Message(value)
}
}
impl From<&str> for Error {
fn from(value: &str) -> Self {
Self::Message(value.to_string())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -1,4 +1,5 @@
pub mod config;
pub mod error;
pub mod sandbox;
pub mod sandbox_spec;
@ -28,6 +29,7 @@ pub mod test_support;
#[cfg(feature = "docker")]
pub use docker::{DockerSandbox, DockerSandboxOptions};
pub use error::{Error, Result};
pub use local::LocalSandbox;
pub use read_guard::ReadBeforeWriteSandbox;
pub use sandbox::{

View file

@ -132,35 +132,35 @@ impl Sandbox for LocalSandbox {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
) -> crate::Result<String> {
let full_path = self.resolve_path(path);
let content = fs::read_to_string(&full_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?;
let content = fs::read_to_string(&full_path).await.map_err(|e| {
crate::Error::context(format!("Failed to read {}", full_path.display()), e)
})?;
Ok(format_lines_numbered(&content, offset, limit))
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
let full_path = self.resolve_path(path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::write(&full_path, content)
.await
.map_err(|e| format!("Failed to write {}: {e}", full_path.display()))
fs::write(&full_path, content).await.map_err(|e| {
crate::Error::context(format!("Failed to write {}", full_path.display()), e)
})
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
async fn delete_file(&self, path: &str) -> crate::Result<()> {
let full_path = self.resolve_path(path);
fs::remove_file(&full_path)
.await
.map_err(|e| format!("Failed to delete {}: {e}", full_path.display()))
fs::remove_file(&full_path).await.map_err(|e| {
crate::Error::context(format!("Failed to delete {}", full_path.display()), e)
})
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
let full_path = self.resolve_path(path);
Ok(full_path.exists())
}
@ -169,7 +169,7 @@ impl Sandbox for LocalSandbox {
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> crate::Result<Vec<DirEntry>> {
#[expect(
clippy::disallowed_methods,
reason = "sync recursive read_dir; caller wraps invocation in tokio::task::spawn_blocking"
@ -180,9 +180,11 @@ impl Sandbox for LocalSandbox {
current_depth: usize,
max_depth: usize,
entries: &mut Vec<DirEntry>,
) -> Result<(), String> {
) -> crate::Result<()> {
let mut dir_entries: Vec<std::fs::DirEntry> = std::fs::read_dir(base)
.map_err(|e| format!("Failed to read directory {}: {e}", base.display()))?
.map_err(|e| {
crate::Error::context(format!("Failed to read directory {}", base.display()), e)
})?
.filter_map(std::result::Result::ok)
.collect();
dir_entries.sort_by_key(std::fs::DirEntry::file_name);
@ -190,7 +192,7 @@ impl Sandbox for LocalSandbox {
for entry in dir_entries {
let metadata = entry
.metadata()
.map_err(|e| format!("Failed to read metadata: {e}"))?;
.map_err(|e| crate::Error::context("Failed to read metadata", e))?;
let name = if prefix.is_empty() {
entry.file_name().to_string_lossy().into_owned()
} else {
@ -221,7 +223,7 @@ impl Sandbox for LocalSandbox {
Ok(entries)
})
.await
.map_err(|e| format!("list_directory task failed: {e}"))?
.map_err(|e| crate::Error::context("list_directory task failed", e))?
}
async fn exec_command(
@ -231,7 +233,7 @@ impl Sandbox for LocalSandbox {
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
let start = Instant::now();
let mut filtered_env: Vec<(String, String)> = process_env_vars()
@ -264,7 +266,7 @@ impl Sandbox for LocalSandbox {
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn command: {e}"))?;
.map_err(|e| crate::Error::context("Failed to spawn command", e))?;
let timeout_duration = std::time::Duration::from_millis(timeout_ms);
let token = cancel_token.unwrap_or_default();
@ -293,7 +295,8 @@ impl Sandbox for LocalSandbox {
let (timed_out, exit_code) = tokio::select! {
status_result = child.wait() => {
let status = status_result.map_err(|e| format!("Failed to wait for process: {e}"))?;
let status = status_result
.map_err(|e| crate::Error::context("Failed to wait for process", e))?;
(false, status.code().unwrap_or(-1))
}
() = time::sleep(timeout_duration) => {
@ -325,7 +328,7 @@ impl Sandbox for LocalSandbox {
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> crate::Result<Vec<String>> {
let full_path = self.resolve_path(path);
// Try rg (ripgrep) first, fall back to grep
@ -351,7 +354,7 @@ impl Sandbox for LocalSandbox {
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to run rg: {e}"))?
.map_err(|e| crate::Error::context("Failed to run rg", e))?
} else {
let mut args = vec!["-rn".to_string()];
if options.case_insensitive {
@ -372,7 +375,7 @@ impl Sandbox for LocalSandbox {
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to run grep: {e}"))?
.map_err(|e| crate::Error::context("Failed to run grep", e))?
};
let stdout = String::from_utf8_lossy(&output.stdout);
@ -384,7 +387,7 @@ impl Sandbox for LocalSandbox {
Ok(results)
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>> {
let base_dir =
path.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from);
@ -395,7 +398,7 @@ impl Sandbox for LocalSandbox {
};
let mut results: Vec<String> = glob::glob(&full_pattern)
.map_err(|e| format!("Invalid glob pattern: {e}"))?
.map_err(|e| crate::Error::context("Invalid glob pattern", e))?
.filter_map(Result::ok)
.map(|p| p.to_string_lossy().into_owned())
.collect();
@ -416,18 +419,21 @@ impl Sandbox for LocalSandbox {
&self,
remote_path: &str,
local_path: &Path,
) -> Result<(), String> {
) -> crate::Result<()> {
let full_path = self.resolve_path(remote_path);
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::copy(&full_path, local_path).await.map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
full_path.display(),
local_path.display()
crate::Error::context(
format!(
"Failed to copy {} to {}",
full_path.display(),
local_path.display()
),
e,
)
})?;
Ok(())
@ -437,31 +443,34 @@ impl Sandbox for LocalSandbox {
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
) -> crate::Result<()> {
let full_path = self.resolve_path(remote_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::copy(local_path, &full_path).await.map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
local_path.display(),
full_path.display()
crate::Error::context(
format!(
"Failed to copy {} to {}",
local_path.display(),
full_path.display()
),
e,
)
})?;
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> crate::Result<()> {
self.emit(SandboxEvent::Initializing {
provider: "local".into(),
});
let start = Instant::now();
let result = fs::create_dir_all(&self.working_directory)
.await
.map_err(|e| format!("Failed to create working directory: {e}"));
.map_err(|e| crate::Error::context("Failed to create working directory", e));
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
match &result {
Ok(()) => self.emit(SandboxEvent::Ready {
@ -474,14 +483,15 @@ impl Sandbox for LocalSandbox {
}),
Err(e) => self.emit(SandboxEvent::InitializeFailed {
provider: "local".into(),
error: e.clone(),
error: e.to_string(),
causes: e.causes(),
duration_ms,
}),
}
result
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupStarted {
provider: "local".into(),
});

View file

@ -62,7 +62,7 @@ impl ReadBeforeWriteSandbox {
.contains(&normalized)
}
async fn guard_write(&self, path: &str) -> Result<(), String> {
async fn guard_write(&self, path: &str) -> crate::Result<()> {
let normalized = self.normalize_path(path);
if normalized.starts_with("/tmp/") {
return Ok(());
@ -70,10 +70,10 @@ impl ReadBeforeWriteSandbox {
let exists = self.inner.file_exists(path).await?;
if exists && !self.has_read(path) {
warn!(path = %path, "Write blocked: file not read by agent");
Err(format!(
Err(crate::Error::message(format!(
"Cannot write to '{path}': file exists but has not been read. \
Use read_file to read the file before writing to it."
))
)))
} else {
Ok(())
}
@ -82,12 +82,12 @@ impl ReadBeforeWriteSandbox {
crate::delegate_sandbox! {
ReadBeforeWriteSandbox => inner {
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
self.guard_write(path).await?;
self.inner.write_file(path, content).await
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
async fn delete_file(&self, path: &str) -> crate::Result<()> {
self.guard_write(path).await?;
self.inner.delete_file(path).await
}

View file

@ -27,7 +27,7 @@ pub struct GitRunInfo {
/// delegate_sandbox! {
/// MyDecorator => inner {
/// // Only provide methods with custom logic — the rest delegate automatically.
/// async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String, String> {
/// async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> $crate::Result<String> {
/// // custom logic...
/// }
/// }
@ -44,7 +44,7 @@ macro_rules! delegate_sandbox {
impl $crate::Sandbox for $type {
$($custom)*
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> $crate::Result<bool> {
self.$field.file_exists(path).await
}
@ -52,7 +52,7 @@ macro_rules! delegate_sandbox {
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<$crate::DirEntry>, String> {
) -> $crate::Result<Vec<$crate::DirEntry>> {
self.$field.list_directory(path, depth).await
}
@ -63,13 +63,13 @@ macro_rules! delegate_sandbox {
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<$crate::ExecResult, String> {
) -> $crate::Result<$crate::ExecResult> {
self.$field
.exec_command(command, timeout_ms, working_dir, env_vars, cancel_token)
.await
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, pattern: &str, path: Option<&str>) -> $crate::Result<Vec<String>> {
self.$field.glob(pattern, path).await
}
@ -77,7 +77,7 @@ macro_rules! delegate_sandbox {
&self,
remote_path: &str,
local_path: &std::path::Path,
) -> Result<(), String> {
) -> $crate::Result<()> {
self.$field.download_file_to_local(remote_path, local_path).await
}
@ -85,15 +85,15 @@ macro_rules! delegate_sandbox {
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
) -> $crate::Result<()> {
self.$field.upload_file_from_local(local_path, remote_path).await
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> $crate::Result<()> {
self.$field.initialize().await
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> $crate::Result<()> {
self.$field.cleanup().await
}
@ -113,15 +113,15 @@ macro_rules! delegate_sandbox {
self.$field.sandbox_info()
}
async fn refresh_push_credentials(&self) -> Result<(), String> {
async fn refresh_push_credentials(&self) -> $crate::Result<()> {
self.$field.refresh_push_credentials().await
}
async fn set_autostop_interval(&self, minutes: i32) -> Result<(), String> {
async fn set_autostop_interval(&self, minutes: i32) -> $crate::Result<()> {
self.$field.set_autostop_interval(minutes).await
}
async fn setup_git_for_run(&self, run_id: &str) -> Result<Option<$crate::GitRunInfo>, String> {
async fn setup_git_for_run(&self, run_id: &str) -> $crate::Result<Option<$crate::GitRunInfo>> {
self.$field.setup_git_for_run(run_id).await
}
@ -147,7 +147,7 @@ macro_rules! delegate_sandbox {
self.$field.parallel_worktree_path(run_dir, run_id, node_id, key)
}
async fn ssh_access_command(&self) -> Result<Option<String>, String> {
async fn ssh_access_command(&self) -> $crate::Result<Option<String>> {
self.$field.ssh_access_command().await
}
@ -155,7 +155,7 @@ macro_rules! delegate_sandbox {
self.$field.origin_url()
}
async fn get_preview_url(&self, port: u16) -> Result<Option<(String, std::collections::HashMap<String, String>)>, String> {
async fn get_preview_url(&self, port: u16) -> $crate::Result<Option<(String, std::collections::HashMap<String, String>)>> {
self.$field.get_preview_url(port).await
}
@ -164,7 +164,7 @@ macro_rules! delegate_sandbox {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
) -> $crate::Result<String> {
self.$field.read_file(path, offset, limit).await
}
@ -173,7 +173,7 @@ macro_rules! delegate_sandbox {
pattern: &str,
path: &str,
options: &$crate::GrepOptions,
) -> Result<Vec<String>, String> {
) -> $crate::Result<Vec<String>> {
self.$field.grep(pattern, path, options).await
}
}
@ -198,6 +198,8 @@ pub enum SandboxEvent {
InitializeFailed {
provider: String,
error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec<String>,
duration_ms: u64,
},
CleanupStarted {
@ -210,6 +212,8 @@ pub enum SandboxEvent {
CleanupFailed {
provider: String,
error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec<String>,
},
// -- Docker --
@ -233,8 +237,10 @@ pub enum SandboxEvent {
duration_ms: u64,
},
SnapshotFailed {
name: String,
error: String,
name: String,
error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec<String>,
},
// -- Daytona git --
@ -247,8 +253,10 @@ pub enum SandboxEvent {
duration_ms: u64,
},
GitCloneFailed {
url: String,
error: String,
url: String,
error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec<String>,
},
}
@ -269,9 +277,10 @@ impl SandboxEvent {
Self::InitializeFailed {
provider,
error,
causes,
duration_ms,
} => {
error!(provider, error, duration_ms, "Sandbox init failed");
error!(provider, error, causes = ?causes, duration_ms, "Sandbox init failed");
}
Self::CleanupStarted { provider } => {
debug!(provider, "Sandbox cleanup started");
@ -282,8 +291,12 @@ impl SandboxEvent {
} => {
debug!(provider, duration_ms, "Sandbox cleanup completed");
}
Self::CleanupFailed { provider, error } => {
warn!(provider, error, "Sandbox cleanup failed");
Self::CleanupFailed {
provider,
error,
causes,
} => {
warn!(provider, error, causes = ?causes, "Sandbox cleanup failed");
}
Self::SnapshotPulling { name } => {
debug!(name, "Snapshot pulling");
@ -300,8 +313,12 @@ impl SandboxEvent {
Self::SnapshotReady { name, duration_ms } => {
info!(name, duration_ms, "Snapshot ready");
}
Self::SnapshotFailed { name, error } => {
error!(name, error, "Snapshot failed");
Self::SnapshotFailed {
name,
error,
causes,
} => {
error!(name, error, causes = ?causes, "Snapshot failed");
}
Self::GitCloneStarted { url, branch } => {
debug!(
@ -313,8 +330,8 @@ impl SandboxEvent {
Self::GitCloneCompleted { url, duration_ms } => {
debug!(url, duration_ms, "Git clone completed");
}
Self::GitCloneFailed { url, error } => {
error!(url, error, "Git clone failed");
Self::GitCloneFailed { url, error, causes } => {
error!(url, error, causes = ?causes, "Git clone failed");
}
}
}
@ -372,15 +389,15 @@ pub trait Sandbox: Send + Sync {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String>;
async fn write_file(&self, path: &str, content: &str) -> Result<(), String>;
async fn delete_file(&self, path: &str) -> Result<(), String>;
async fn file_exists(&self, path: &str) -> Result<bool, String>;
) -> crate::Result<String>;
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()>;
async fn delete_file(&self, path: &str) -> crate::Result<()>;
async fn file_exists(&self, path: &str) -> crate::Result<bool>;
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, String>;
) -> crate::Result<Vec<DirEntry>>;
async fn exec_command(
&self,
command: &str,
@ -388,30 +405,30 @@ pub trait Sandbox: Send + Sync {
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String>;
) -> crate::Result<ExecResult>;
async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String>;
) -> crate::Result<Vec<String>>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>>;
/// Copy a file from the sandbox to a local filesystem path.
/// Handles binary files correctly across all sandbox types.
async fn download_file_to_local(
&self,
remote_path: &str,
local_path: &Path,
) -> Result<(), String>;
) -> crate::Result<()>;
/// 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>;
) -> crate::Result<()>;
async fn initialize(&self) -> crate::Result<()>;
async fn cleanup(&self) -> crate::Result<()>;
fn working_directory(&self) -> &str;
fn platform(&self) -> &str;
fn os_version(&self) -> String;
@ -425,13 +442,13 @@ pub trait Sandbox: Send + Sync {
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
/// Default is a no-op; Daytona overrides to update the remote URL with a
/// fresh token.
async fn refresh_push_credentials(&self) -> Result<(), String> {
async fn refresh_push_credentials(&self) -> crate::Result<()> {
Ok(())
}
/// Set the auto-stop interval in minutes (0 to disable).
/// Default is a no-op; Daytona overrides to call the Daytona API.
async fn set_autostop_interval(&self, _minutes: i32) -> Result<(), String> {
async fn set_autostop_interval(&self, _minutes: i32) -> crate::Result<()> {
Ok(())
}
@ -439,7 +456,7 @@ pub trait Sandbox: Send + Sync {
/// Sandboxes that manage their own git clone (e.g., remote VMs) should
/// create a run branch and return the git info. Local sandboxes return
/// `None`.
async fn setup_git_for_run(&self, _run_id: &str) -> Result<Option<GitRunInfo>, String> {
async fn setup_git_for_run(&self, _run_id: &str) -> crate::Result<Option<GitRunInfo>> {
Ok(None)
}
@ -481,7 +498,7 @@ pub trait Sandbox: Send + Sync {
/// Return an SSH command string for connecting to this sandbox, if
/// supported.
async fn ssh_access_command(&self) -> Result<Option<String>, String> {
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
Ok(None)
}
@ -497,7 +514,7 @@ pub trait Sandbox: Send + Sync {
async fn get_preview_url(
&self,
_port: u16,
) -> Result<Option<(String, HashMap<String, String>)>, String> {
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
Ok(None)
}
@ -530,12 +547,14 @@ pub fn shell_quote(s: &str) -> String {
/// Helper for sandbox implementations that manage git internally.
/// Executes git commands inside the sandbox to create a run branch.
pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> Result<GitRunInfo, String> {
pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::Result<GitRunInfo> {
// Get current branch name
let branch_result = sandbox
.exec_command("git rev-parse --abbrev-ref HEAD", 10_000, None, None, None)
.await
.map_err(|e| format!("git rev-parse --abbrev-ref HEAD failed: {e}"))?;
.map_err(|e| {
crate::Error::message(format!("git rev-parse --abbrev-ref HEAD failed: {e}"))
})?;
let base_branch = if branch_result.exit_code == 0 {
let name = branch_result.stdout.trim().to_string();
if name.is_empty() || name == "HEAD" {
@ -551,12 +570,12 @@ pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> Result<G
let sha_result = sandbox
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
.await
.map_err(|e| format!("git rev-parse HEAD failed: {e}"))?;
.map_err(|e| crate::Error::message(format!("git rev-parse HEAD failed: {e}")))?;
if sha_result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"git rev-parse HEAD failed (exit {}): {}",
sha_result.exit_code, sha_result.stderr
));
)));
}
let base_sha = sha_result.stdout.trim().to_string();
@ -567,12 +586,12 @@ pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> Result<G
let checkout_result = sandbox
.exec_command(&checkout_cmd, 10_000, None, None, None)
.await
.map_err(|e| format!("git checkout failed: {e}"))?;
.map_err(|e| crate::Error::message(format!("git checkout failed: {e}")))?;
if checkout_result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"git checkout -b failed (exit {}): {}",
checkout_result.exit_code, checkout_result.stderr
));
)));
}
Ok(GitRunInfo {
@ -660,6 +679,7 @@ mod tests {
SandboxEvent::InitializeFailed {
provider: "docker".into(),
error: "no daemon".into(),
causes: vec!["connection refused".into()],
duration_ms: 100,
},
SandboxEvent::CleanupStarted {
@ -672,6 +692,7 @@ mod tests {
SandboxEvent::CleanupFailed {
provider: "docker".into(),
error: "container gone".into(),
causes: Vec::new(),
},
SandboxEvent::SnapshotPulling {
name: "ubuntu:22.04".into(),
@ -691,8 +712,9 @@ mod tests {
duration_ms: 30000,
},
SandboxEvent::SnapshotFailed {
name: "my-snap".into(),
error: "build failed".into(),
name: "my-snap".into(),
error: "build failed".into(),
causes: Vec::new(),
},
SandboxEvent::GitCloneStarted {
url: "https://github.com/org/repo.git".into(),
@ -703,8 +725,9 @@ mod tests {
duration_ms: 8000,
},
SandboxEvent::GitCloneFailed {
url: "https://github.com/org/repo.git".into(),
error: "auth failed".into(),
url: "https://github.com/org/repo.git".into(),
error: "auth failed".into(),
causes: Vec::new(),
},
];

View file

@ -89,12 +89,12 @@ impl Sandbox for MockSandbox {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
) -> crate::Result<String> {
let content = self
.files
.get(path)
.cloned()
.ok_or_else(|| format!("File not found: {path}"))?;
.ok_or_else(|| crate::Error::message(format!("File not found: {path}")))?;
if self.apply_read_offset_limit {
let lines: Vec<&str> = content.lines().collect();
@ -107,7 +107,7 @@ impl Sandbox for MockSandbox {
}
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
self.written_files
.lock()
.expect("written_files lock poisoned")
@ -115,11 +115,11 @@ impl Sandbox for MockSandbox {
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
async fn delete_file(&self, _path: &str) -> crate::Result<()> {
Ok(())
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
Ok(self.files.contains_key(path))
}
@ -127,7 +127,7 @@ impl Sandbox for MockSandbox {
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> crate::Result<Vec<DirEntry>> {
Ok(vec![])
}
@ -138,7 +138,7 @@ impl Sandbox for MockSandbox {
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
*self
.captured_timeout
.lock()
@ -167,11 +167,11 @@ impl Sandbox for MockSandbox {
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> crate::Result<Vec<String>> {
Ok(self.grep_results.clone())
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> crate::Result<Vec<String>> {
Ok(self.glob_results.clone())
}
@ -179,19 +179,21 @@ impl Sandbox for MockSandbox {
&self,
remote_path: &str,
local_path: &std::path::Path,
) -> Result<(), String> {
) -> crate::Result<()> {
let content = self
.files
.get(remote_path)
.ok_or_else(|| format!("File not found: {remote_path}"))?;
.ok_or_else(|| crate::Error::message(format!("File not found: {remote_path}")))?;
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::write(local_path, content.as_bytes())
.await
.map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?;
.map_err(|e| {
crate::Error::context(format!("Failed to write {}", local_path.display()), e)
})?;
Ok(())
}
@ -199,14 +201,17 @@ impl Sandbox for MockSandbox {
&self,
local_path: &std::path::Path,
_remote_path: &str,
) -> Result<(), String> {
) -> crate::Result<()> {
if !local_path.exists() {
return Err(format!("File not found: {}", local_path.display()));
return Err(crate::Error::message(format!(
"File not found: {}",
local_path.display()
)));
}
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> crate::Result<()> {
self.emit(SandboxEvent::Initializing {
provider: "mock".into(),
});
@ -221,7 +226,7 @@ impl Sandbox for MockSandbox {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupStarted {
provider: "mock".into(),
});
@ -269,16 +274,16 @@ impl Sandbox for MutableMockSandbox {
path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
) -> crate::Result<String> {
self.files
.lock()
.expect("files lock poisoned")
.get(path)
.cloned()
.ok_or_else(|| format!("File not found: {path}"))
.ok_or_else(|| crate::Error::message(format!("File not found: {path}")))
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
self.files
.lock()
.expect("files lock poisoned")
@ -286,12 +291,12 @@ impl Sandbox for MutableMockSandbox {
Ok(())
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
async fn delete_file(&self, path: &str) -> crate::Result<()> {
self.files.lock().expect("files lock poisoned").remove(path);
Ok(())
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
Ok(self
.files
.lock()
@ -303,7 +308,7 @@ impl Sandbox for MutableMockSandbox {
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> crate::Result<Vec<DirEntry>> {
Ok(vec![])
}
@ -314,7 +319,7 @@ impl Sandbox for MutableMockSandbox {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
Ok(ExecResult {
stdout: String::new(),
stderr: String::new(),
@ -329,7 +334,7 @@ impl Sandbox for MutableMockSandbox {
pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> crate::Result<Vec<String>> {
let files = self.files.lock().expect("files lock poisoned");
let mut results = Vec::new();
for (path, content) in files.iter() {
@ -342,7 +347,7 @@ impl Sandbox for MutableMockSandbox {
Ok(results)
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> crate::Result<Vec<String>> {
Ok(vec![])
}
@ -350,22 +355,24 @@ impl Sandbox for MutableMockSandbox {
&self,
remote_path: &str,
local_path: &std::path::Path,
) -> Result<(), String> {
) -> crate::Result<()> {
let content = self
.files
.lock()
.expect("files lock poisoned")
.get(remote_path)
.cloned()
.ok_or_else(|| format!("File not found: {remote_path}"))?;
.ok_or_else(|| crate::Error::message(format!("File not found: {remote_path}")))?;
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
.map_err(|e| crate::Error::context("Failed to create parent dirs", e))?;
}
fs::write(local_path, content.as_bytes())
.await
.map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?;
.map_err(|e| {
crate::Error::context(format!("Failed to write {}", local_path.display()), e)
})?;
Ok(())
}
@ -373,10 +380,10 @@ impl Sandbox for MutableMockSandbox {
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
let content = fs::read_to_string(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
) -> crate::Result<()> {
let content = fs::read_to_string(local_path).await.map_err(|e| {
crate::Error::context(format!("Failed to read {}", local_path.display()), e)
})?;
self.files
.lock()
.expect("files lock poisoned")
@ -384,11 +391,11 @@ impl Sandbox for MutableMockSandbox {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> crate::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> crate::Result<()> {
Ok(())
}

View file

@ -110,7 +110,7 @@ impl Sandbox for WorktreeSandbox {
/// 3. Add the worktree, emit `WorktreeAdded`.
///
/// Does NOT call `inner.initialize()`.
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> crate::Result<()> {
if self
.initialized
.swap(true, std::sync::atomic::Ordering::Relaxed)
@ -145,11 +145,11 @@ impl Sandbox for WorktreeSandbox {
.exec_command(&cmd, 30_000, None, None, None)
.await?;
if result.exit_code != 0 {
return Err(format!(
return Err(crate::Error::message(format!(
"git branch --force failed (exit {}): {}",
result.exit_code,
result.stderr.trim()
));
)));
}
self.emit(WorktreeEvent::BranchCreated {
branch: self.config.branch_name.clone(),
@ -171,11 +171,11 @@ impl Sandbox for WorktreeSandbox {
.exec_command(&rollback_cmd, 30_000, None, None, None)
.await;
}
return Err(format!(
return Err(crate::Error::message(format!(
"git worktree add failed (exit {}): {}",
result.exit_code,
result.stderr.trim()
));
)));
}
self.emit(WorktreeEvent::WorktreeAdded {
path: self.config.worktree_path.clone(),
@ -187,7 +187,7 @@ impl Sandbox for WorktreeSandbox {
/// No-op — the worktree must survive cleanup for `fabro cp` access.
/// Worktrees are pruned separately by `system prune`.
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> crate::Result<()> {
Ok(())
}
@ -204,7 +204,7 @@ impl Sandbox for WorktreeSandbox {
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> crate::Result<ExecResult> {
let wd = working_dir.unwrap_or(&self.config.worktree_path);
self.inner
.exec_command(command, timeout_ms, Some(wd), env_vars, cancel_token)
@ -218,22 +218,22 @@ impl Sandbox for WorktreeSandbox {
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
) -> crate::Result<String> {
let resolved = self.resolve_path(path);
self.inner.read_file(&resolved, offset, limit).await
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
let resolved = self.resolve_path(path);
self.inner.write_file(&resolved, content).await
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
async fn delete_file(&self, path: &str) -> crate::Result<()> {
let resolved = self.resolve_path(path);
self.inner.delete_file(&resolved).await
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
let resolved = self.resolve_path(path);
self.inner.file_exists(&resolved).await
}
@ -242,7 +242,7 @@ impl Sandbox for WorktreeSandbox {
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> crate::Result<Vec<DirEntry>> {
let resolved = self.resolve_path(path);
self.inner.list_directory(&resolved, depth).await
}
@ -252,12 +252,12 @@ impl Sandbox for WorktreeSandbox {
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> crate::Result<Vec<String>> {
let resolved = self.resolve_path(path);
self.inner.grep(pattern, &resolved, options).await
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>> {
let resolved = path.map(|p| self.resolve_path(p));
let glob_path = resolved.as_deref().unwrap_or(&self.config.worktree_path);
self.inner.glob(pattern, Some(glob_path)).await
@ -267,7 +267,7 @@ impl Sandbox for WorktreeSandbox {
&self,
remote_path: &str,
local_path: &Path,
) -> Result<(), String> {
) -> crate::Result<()> {
let resolved = self.resolve_path(remote_path);
self.inner
.download_file_to_local(&resolved, local_path)
@ -278,7 +278,7 @@ impl Sandbox for WorktreeSandbox {
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
) -> crate::Result<()> {
let resolved = self.resolve_path(remote_path);
self.inner
.upload_file_from_local(local_path, &resolved)
@ -297,11 +297,11 @@ impl Sandbox for WorktreeSandbox {
self.inner.sandbox_info()
}
async fn refresh_push_credentials(&self) -> Result<(), String> {
async fn refresh_push_credentials(&self) -> crate::Result<()> {
self.inner.refresh_push_credentials().await
}
async fn set_autostop_interval(&self, minutes: i32) -> Result<(), String> {
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
self.inner.set_autostop_interval(minutes).await
}
@ -309,7 +309,7 @@ impl Sandbox for WorktreeSandbox {
Some(&self.config.worktree_path)
}
async fn setup_git_for_run(&self, run_id: &str) -> Result<Option<crate::GitRunInfo>, String> {
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
self.inner.setup_git_for_run(run_id).await
}
@ -332,7 +332,7 @@ impl Sandbox for WorktreeSandbox {
.parallel_worktree_path(run_dir, run_id, node_id, key)
}
async fn ssh_access_command(&self) -> Result<Option<String>, String> {
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
self.inner.ssh_access_command().await
}
@ -343,7 +343,7 @@ impl Sandbox for WorktreeSandbox {
async fn get_preview_url(
&self,
port: u16,
) -> Result<Option<(String, HashMap<String, String>)>, String> {
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
self.inner.get_preview_url(port).await
}

View file

@ -0,0 +1,32 @@
#[test]
fn context_error_preserves_source_cause() {
let source = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let error = fabro_sandbox::Error::context("Failed to read file", source);
assert_eq!(error.to_string(), "Failed to read file");
assert_eq!(error.causes(), vec!["permission denied"]);
assert_eq!(
error.display_with_causes(),
"Failed to read file\n caused by: permission denied"
);
}
#[cfg(feature = "docker")]
#[test]
fn docker_image_inspect_error_preserves_source_cause() {
let source = bollard::errors::Error::DockerResponseServerError {
status_code: 500,
message: "daemon unavailable".to_string(),
};
let error = fabro_sandbox::Error::docker_image_inspect("buildpack-deps:noble", source);
assert_eq!(
error.to_string(),
"Failed to inspect Docker image buildpack-deps:noble"
);
assert_eq!(error.causes(), vec![
"Docker responded with status code 500: daemon unavailable"
]);
}

View file

@ -670,7 +670,7 @@ async fn resolve_head_sha_and_time(
None,
)
.await
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err))?;
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?;
if res.exit_code != 0 {
return Err(ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,

View file

@ -6483,7 +6483,8 @@ async fn generate_preview_url(
url: preview.url,
},
Err(err) => {
return ApiError::new(StatusCode::CONFLICT, err).into_response();
return ApiError::new(StatusCode::CONFLICT, err.display_with_causes())
.into_response();
}
}
} else {
@ -6493,7 +6494,8 @@ async fn generate_preview_url(
url: preview.url,
},
Err(err) => {
return ApiError::new(StatusCode::CONFLICT, err).into_response();
return ApiError::new(StatusCode::CONFLICT, err.display_with_causes())
.into_response();
}
}
};
@ -6517,7 +6519,7 @@ async fn create_ssh_access(
};
match sandbox.create_ssh_access(Some(request.ttl_minutes)).await {
Ok(command) => (StatusCode::CREATED, Json(SshAccessResponse { command })).into_response(),
Err(err) => ApiError::new(StatusCode::CONFLICT, err).into_response(),
Err(err) => ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(),
}
}
@ -6547,7 +6549,7 @@ async fn list_sandbox_files(
.collect(),
})
.into_response(),
Err(err) => ApiError::new(StatusCode::NOT_FOUND, err).into_response(),
Err(err) => ApiError::new(StatusCode::NOT_FOUND, err.display_with_causes()).into_response(),
}
}
@ -6576,7 +6578,7 @@ async fn get_sandbox_file(
.download_file_to_local(&params.path, temp.path())
.await
{
return ApiError::new(StatusCode::NOT_FOUND, err).into_response();
return ApiError::new(StatusCode::NOT_FOUND, err.display_with_causes()).into_response();
}
match fs::read(temp.path()).await {
Ok(bytes) => octet_stream_response(bytes.into()),
@ -6619,7 +6621,8 @@ async fn put_sandbox_file(
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.display_with_causes())
.into_response(),
}
}
@ -6629,9 +6632,13 @@ async fn reconnect_run_sandbox(
) -> Result<Box<dyn Sandbox>, Response> {
let record = load_run_sandbox_record(state, run_id).await?;
let daytona_api_key = state.vault_or_env(EnvVars::DAYTONA_API_KEY);
reconnect(&record, daytona_api_key)
.await
.map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err}")).into_response())
reconnect(&record, daytona_api_key).await.map_err(|err| {
let detail = fabro_util::error::render_with_causes(
&err.to_string(),
&fabro_util::error::collect_causes(err.as_ref()),
);
ApiError::new(StatusCode::CONFLICT, detail).into_response()
})
}
async fn reconnect_daytona_sandbox(
@ -6669,7 +6676,7 @@ async fn reconnect_daytona_sandbox(
record.clone_branch.clone(),
)
.await
.map_err(|err| ApiError::new(StatusCode::CONFLICT, err.clone()).into_response())
.map_err(|err| ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response())
}
async fn load_run_sandbox_record(

View file

@ -13,6 +13,7 @@ workspace = true
[dependencies]
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
hex.workspace = true
slatedb.workspace = true
object_store.workspace = true

View file

@ -461,7 +461,10 @@ fn conclusion_from_failed(props: &RunFailedProps, timestamp: DateTime<Utc>) -> C
timestamp,
status: StageStatus::Fail,
duration_ms: props.duration_ms,
failure_reason: Some(props.error.clone()),
failure_reason: Some(fabro_util::error::render_with_causes(
&props.error,
&props.causes,
)),
final_git_commit_sha: props.git_commit_sha.clone(),
stages: Vec::new(),
billing: None,
@ -1066,6 +1069,7 @@ mod tests {
1,
EventBody::RunFailed(RunFailedProps {
error: "boom".to_string(),
causes: Vec::new(),
duration_ms: 42,
reason: FailureReason::WorkflowError,
git_commit_sha: Some("abc123".to_string()),
@ -1078,6 +1082,35 @@ mod tests {
assert_eq!(state.final_patch.as_deref(), Some(patch));
}
#[test]
fn run_failed_projection_renders_causes() {
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunFailed(RunFailedProps {
error: "Engine error: Failed to initialize sandbox".to_string(),
causes: vec![
"Failed to pull Docker image buildpack-deps:noble".to_string(),
"connection refused".to_string(),
],
duration_ms: 42,
reason: FailureReason::WorkflowError,
git_commit_sha: None,
final_patch: None,
}),
None,
))
.unwrap();
assert_eq!(
state.conclusion.unwrap().failure_reason.as_deref(),
Some(
"Engine error: Failed to initialize sandbox\n caused by: Failed to pull Docker image buildpack-deps:noble\n caused by: connection refused"
)
);
}
#[test]
fn run_archived_captures_prior_status_and_preserves_reason() {
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps};

View file

@ -23,6 +23,8 @@ pub struct SandboxReadyProps {
pub struct SandboxFailedProps {
pub provider: String,
pub error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub causes: Vec<String>,
pub duration_ms: u64,
}
@ -41,6 +43,8 @@ pub struct SandboxCleanupCompletedProps {
pub struct SandboxCleanupFailedProps {
pub provider: String,
pub error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub causes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -56,8 +60,10 @@ pub struct SnapshotCompletedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SnapshotFailedProps {
pub name: String,
pub error: String,
pub name: String,
pub error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub causes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -75,8 +81,10 @@ pub struct GitCloneCompletedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitCloneFailedProps {
pub url: String,
pub error: String,
pub url: String,
pub error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub causes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -125,6 +125,8 @@ pub struct RunCompletedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunFailedProps {
pub error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub causes: Vec<String>,
pub duration_ms: u64,
pub reason: FailureReason,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -0,0 +1,28 @@
pub fn collect_causes(error: &(dyn std::error::Error + 'static)) -> Vec<String> {
let mut causes = Vec::new();
let mut source = error.source();
while let Some(cause) = source {
causes.push(cause.to_string());
source = cause.source();
}
causes
}
pub fn collect_chain(error: &(dyn std::error::Error + 'static)) -> Vec<String> {
let mut chain = vec![error.to_string()];
chain.extend(collect_causes(error));
chain
}
pub fn render_with_causes(message: &str, causes: &[String]) -> String {
if causes.is_empty() {
return message.to_string();
}
let mut rendered = String::from(message);
for cause in causes {
rendered.push_str("\n caused by: ");
rendered.push_str(cause);
}
rendered
}

View file

@ -3,6 +3,7 @@ pub mod browser;
pub mod check_report;
pub mod dev_token;
pub mod env;
pub mod error;
pub mod exit;
pub mod home;
pub mod json;

View file

@ -0,0 +1,53 @@
#[derive(Debug)]
struct Cause(&'static str);
impl std::fmt::Display for Cause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Cause {}
#[derive(Debug)]
struct Outer {
message: &'static str,
source: Cause,
}
impl std::fmt::Display for Outer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message)
}
}
impl std::error::Error for Outer {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[test]
fn collect_causes_walks_error_source_chain() {
let error = Outer {
message: "outer failure",
source: Cause("inner failure"),
};
assert_eq!(fabro_util::error::collect_causes(&error), vec![
"inner failure"
]);
}
#[test]
fn render_with_causes_adds_indented_caused_by_lines() {
let rendered = fabro_util::error::render_with_causes("operation failed", &[
"first cause".to_string(),
"second cause".to_string(),
]);
assert_eq!(
rendered,
"operation failed\n caused by: first cause\n caused by: second cause"
);
}

View file

@ -566,11 +566,11 @@ mod tests {
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> std::result::Result<String, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<String> {
Err("not implemented".into())
}
async fn write_file(&self, path: &str, content: &str) -> std::result::Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.written
.lock()
.unwrap()
@ -578,11 +578,11 @@ mod tests {
Ok(())
}
async fn delete_file(&self, _path: &str) -> std::result::Result<(), String> {
Err("not implemented".to_string())
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn file_exists(&self, _path: &str) -> std::result::Result<bool, String> {
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(self.accessible)
}
@ -590,8 +590,8 @@ mod tests {
&self,
_path: &str,
_depth: Option<usize>,
) -> std::result::Result<Vec<fabro_agent::DirEntry>, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Err("not implemented".into())
}
async fn exec_command(
@ -601,8 +601,8 @@ mod tests {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> std::result::Result<fabro_agent::ExecResult, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
Err("not implemented".into())
}
async fn grep(
@ -610,39 +610,39 @@ mod tests {
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn initialize(&self) -> std::result::Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> std::result::Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}

View file

@ -285,7 +285,8 @@ pub async fn collect_artifacts(
debug!(cmd = cmd.as_str(), "Collecting artifacts");
let result = sandbox
.exec_command(&cmd, FIND_TIMEOUT_MS, None, None, None)
.await?;
.await
.map_err(|e| e.display_with_causes())?;
let discovered = parse_find_output(&result.stdout, platform);
let discovered = normalize_paths(discovered, root);
@ -323,9 +324,10 @@ pub async fn collect_artifacts(
}
},
Err(e) => {
let error = e.display_with_causes();
warn!(
path = file.relative_path.as_str(),
error = e.as_str(),
error = error.as_str(),
"Asset download failed"
);
download_errors += 1;
@ -384,23 +386,23 @@ mod tests {
_: &str,
_: Option<usize>,
_: Option<usize>,
) -> Result<String, String> {
) -> fabro_sandbox::Result<String> {
Err("not implemented".into())
}
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> {
async fn write_file(&self, _: &str, _: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn delete_file(&self, _: &str) -> Result<(), String> {
async fn delete_file(&self, _: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _: &str) -> Result<bool, String> {
async fn file_exists(&self, _: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<fabro_agent::sandbox::DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
Ok(vec![])
}
async fn exec_command(
@ -410,7 +412,7 @@ mod tests {
_: Option<&str>,
_: Option<&std::collections::HashMap<String, String>>,
_: Option<tokio_util::sync::CancellationToken>,
) -> Result<ExecResult, String> {
) -> fabro_sandbox::Result<ExecResult> {
Ok(self.exec_result.clone())
}
async fn grep(
@ -418,42 +420,41 @@ mod tests {
_: &str,
_: &str,
_: &fabro_agent::sandbox::GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn download_file_to_local(
&self,
remote_path: &str,
local_path: &std::path::Path,
) -> Result<(), String> {
let content = self
.files
.get(remote_path)
.ok_or_else(|| format!("File not found: {remote_path}"))?;
) -> fabro_sandbox::Result<()> {
let content = self.files.get(remote_path).ok_or_else(|| {
fabro_sandbox::Error::message(format!("File not found: {remote_path}"))
})?;
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create dirs: {e}"))?;
.map_err(|e| fabro_sandbox::Error::context("Failed to create dirs", e))?;
}
fs::write(local_path, content.as_bytes())
.await
.map_err(|e| format!("Failed to write: {e}"))?;
.map_err(|e| fabro_sandbox::Error::context("Failed to write", e))?;
Ok(())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> Result<(), String> {
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {

View file

@ -278,23 +278,23 @@ mod tests {
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
) -> fabro_sandbox::Result<String> {
Ok(String::new())
}
async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> {
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<fabro_agent::sandbox::DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
Ok(vec![])
}
async fn exec_command(
@ -304,14 +304,15 @@ mod tests {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> fabro_sandbox::Result<ExecResult> {
self.commands.lock().unwrap().push(command.to_string());
self.cancel_tokens
.lock()
.unwrap()
.push(cancel_token.is_some());
if self.wait_for_cancel {
let token = cancel_token.ok_or_else(|| "missing cancel token".to_string())?;
let token = cancel_token
.ok_or_else(|| fabro_sandbox::Error::message("missing cancel token"))?;
token.cancelled().await;
return Ok(ExecResult {
stdout: String::new(),
@ -338,30 +339,34 @@ mod tests {
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> Result<(), String> {
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> Result<(), String> {
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {

View file

@ -209,12 +209,16 @@ pub enum Error {
Engine {
message: String,
failure_class: FailureCategory,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec<String>,
},
#[error("Handler error: {message}")]
Handler {
message: String,
failure_class: FailureCategory,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec<String>,
},
#[error("LLM error: {0}")]
@ -251,6 +255,22 @@ impl Error {
Self::Handler {
message,
failure_class,
causes: Vec::new(),
}
}
pub fn handler_with_source(
message: impl Into<String>,
source: &(dyn std::error::Error + 'static),
) -> Self {
let message = message.into();
let causes = fabro_util::error::collect_chain(source);
let rendered = fabro_util::error::render_with_causes(&message, &causes);
let failure_class = classify_failure_reason(&rendered);
Self::Handler {
message,
failure_class,
causes,
}
}
@ -262,9 +282,39 @@ impl Error {
Self::Engine {
message,
failure_class,
causes: Vec::new(),
}
}
pub fn engine_with_source(
message: impl Into<String>,
source: &(dyn std::error::Error + 'static),
) -> Self {
let message = message.into();
let causes = fabro_util::error::collect_chain(source);
let rendered = fabro_util::error::render_with_causes(&message, &causes);
let failure_class = classify_failure_reason(&rendered);
Self::Engine {
message,
failure_class,
causes,
}
}
#[must_use]
pub fn causes(&self) -> Vec<String> {
match self {
Self::Engine { causes, .. } | Self::Handler { causes, .. } => causes.clone(),
Self::Llm(err) => fabro_util::error::collect_causes(err),
_ => Vec::new(),
}
}
#[must_use]
pub fn display_with_causes(&self) -> String {
fabro_util::error::render_with_causes(&self.to_string(), &self.causes())
}
/// Whether this error category is retryable (transient) or terminal.
///
/// Retryable: Handler (transient handler failures), Engine (could be
@ -322,7 +372,7 @@ impl Error {
/// Build a fail `Outcome` with structured `FailureDetail`.
pub fn to_fail_outcome(&self) -> Outcome {
let failure = FailureDetail {
message: self.to_string(),
message: self.display_with_causes(),
category: self.failure_category(),
signature: self.failure_signature_hint(),
};
@ -390,6 +440,35 @@ mod tests {
use super::*;
use crate::outcome::OutcomeExt;
#[derive(Debug)]
struct TestCause(&'static str);
impl std::fmt::Display for TestCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for TestCause {}
#[derive(Debug)]
struct TestOuterError {
message: &'static str,
source: TestCause,
}
impl std::fmt::Display for TestOuterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message)
}
}
impl std::error::Error for TestOuterError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[test]
fn parse_error_display() {
let err = Error::Parse("unexpected token".to_string());
@ -423,6 +502,29 @@ mod tests {
assert_eq!(err.to_string(), "Engine error: no outgoing edge");
}
#[test]
fn engine_error_with_source_preserves_cause_chain() {
let source = TestOuterError {
message: "Failed to pull Docker image buildpack-deps:noble",
source: TestCause("connection refused"),
};
let err = Error::engine_with_source("Failed to initialize sandbox", &source);
assert_eq!(
err.to_string(),
"Engine error: Failed to initialize sandbox"
);
assert_eq!(err.causes(), vec![
"Failed to pull Docker image buildpack-deps:noble".to_string(),
"connection refused".to_string(),
]);
assert_eq!(
err.display_with_causes(),
"Engine error: Failed to initialize sandbox\n caused by: Failed to pull Docker image buildpack-deps:noble\n caused by: connection refused"
);
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
}
#[test]
fn handler_error_display() {
let err = Error::handler("LLM call failed");

View file

@ -660,7 +660,12 @@ impl Event {
Self::WorkflowRunFailed {
error, duration_ms, ..
} => {
error!(error = %error, duration_ms, "Workflow run failed");
error!(
error = %error,
causes = ?error.causes(),
duration_ms,
"Workflow run failed"
);
}
Self::RunNotice {
level,
@ -1643,6 +1648,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
final_patch,
} => EventBody::RunFailed(fabro_types::RunFailedProps {
error: error.to_string(),
causes: error.causes(),
duration_ms: *duration_ms,
reason: *reason,
git_commit_sha: git_commit_sha.clone(),
@ -2160,10 +2166,12 @@ fn event_body_from_event(event: &Event) -> EventBody {
SandboxEvent::InitializeFailed {
provider,
error,
causes,
duration_ms,
} => EventBody::SandboxFailed(fabro_types::SandboxFailedProps {
provider: provider.clone(),
error: error.clone(),
causes: causes.clone(),
duration_ms: *duration_ms,
}),
SandboxEvent::CleanupStarted { provider } => {
@ -2178,12 +2186,15 @@ fn event_body_from_event(event: &Event) -> EventBody {
provider: provider.clone(),
duration_ms: *duration_ms,
}),
SandboxEvent::CleanupFailed { provider, error } => {
EventBody::SandboxCleanupFailed(fabro_types::SandboxCleanupFailedProps {
provider: provider.clone(),
error: error.clone(),
})
}
SandboxEvent::CleanupFailed {
provider,
error,
causes,
} => EventBody::SandboxCleanupFailed(fabro_types::SandboxCleanupFailedProps {
provider: provider.clone(),
error: error.clone(),
causes: causes.clone(),
}),
SandboxEvent::SnapshotPulling { name } => {
EventBody::SnapshotPulling(fabro_types::SnapshotNameProps { name: name.clone() })
}
@ -2205,12 +2216,15 @@ fn event_body_from_event(event: &Event) -> EventBody {
duration_ms: *duration_ms,
})
}
SandboxEvent::SnapshotFailed { name, error } => {
EventBody::SnapshotFailed(fabro_types::SnapshotFailedProps {
name: name.clone(),
error: error.clone(),
})
}
SandboxEvent::SnapshotFailed {
name,
error,
causes,
} => EventBody::SnapshotFailed(fabro_types::SnapshotFailedProps {
name: name.clone(),
error: error.clone(),
causes: causes.clone(),
}),
SandboxEvent::GitCloneStarted { url, branch } => {
EventBody::GitCloneStarted(fabro_types::GitCloneStartedProps {
url: url.clone(),
@ -2223,10 +2237,11 @@ fn event_body_from_event(event: &Event) -> EventBody {
duration_ms: *duration_ms,
})
}
SandboxEvent::GitCloneFailed { url, error } => {
SandboxEvent::GitCloneFailed { url, error, causes } => {
EventBody::GitCloneFailed(fabro_types::GitCloneFailedProps {
url: url.clone(),
error: error.clone(),
url: url.clone(),
error: error.clone(),
causes: causes.clone(),
})
}
},
@ -3163,6 +3178,30 @@ mod tests {
assert_eq!(properties["duration_ms"], 2500);
}
#[test]
fn run_event_sandbox_failure_serializes_causes() {
let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox {
event: SandboxEvent::InitializeFailed {
provider: "docker".to_string(),
error: "Failed to pull Docker image buildpack-deps:noble".to_string(),
causes: vec!["connection refused".to_string()],
duration_ms: 42,
},
});
assert_eq!(stored.event_name(), "sandbox.failed");
let properties = stored.properties().unwrap();
assert_eq!(properties["provider"], "docker");
assert_eq!(
properties["error"],
"Failed to pull Docker image buildpack-deps:noble"
);
assert_eq!(
properties["causes"],
serde_json::json!(["connection refused"])
);
}
#[test]
fn run_event_workflow_failure_uses_display_error() {
let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed {
@ -3179,6 +3218,39 @@ mod tests {
assert_eq!(properties["duration_ms"], 900);
}
#[derive(Debug)]
struct EventTestCause;
impl std::fmt::Display for EventTestCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("connection refused")
}
}
impl std::error::Error for EventTestCause {}
#[test]
fn run_event_workflow_failure_serializes_causes() {
let source = EventTestCause;
let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed {
error: Error::engine_with_source("Failed to initialize sandbox", &source),
duration_ms: 900,
reason: FailureReason::WorkflowError,
git_commit_sha: None,
final_patch: None,
});
let properties = stored.properties().unwrap();
assert_eq!(
properties["error"],
"Engine error: Failed to initialize sandbox"
);
assert_eq!(
properties["causes"],
serde_json::json!(["connection refused"])
);
}
#[tokio::test]
async fn append_event_writes_store_event_shape() {
let store = fabro_store::Database::new(

View file

@ -739,23 +739,23 @@ mod tests {
_: &str,
_: Option<usize>,
_: Option<usize>,
) -> Result<String, String> {
) -> fabro_sandbox::Result<String> {
unimplemented!()
}
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> {
async fn write_file(&self, _: &str, _: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn delete_file(&self, _: &str) -> Result<(), String> {
async fn delete_file(&self, _: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn file_exists(&self, _: &str) -> Result<bool, String> {
async fn file_exists(&self, _: &str) -> fabro_sandbox::Result<bool> {
unimplemented!()
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<fabro_agent::sandbox::DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
unimplemented!()
}
async fn exec_command(
@ -765,7 +765,7 @@ mod tests {
_working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<fabro_agent::sandbox::ExecResult, String> {
) -> fabro_sandbox::Result<fabro_agent::sandbox::ExecResult> {
*self.captured_command.lock().unwrap() = Some(command.to_string());
*self.captured_env_vars.lock().unwrap() = env_vars.cloned();
*self.captured_cancel_token.lock().unwrap() = Some(cancel_token.is_some());
@ -776,22 +776,30 @@ mod tests {
_: &str,
_: &str,
_: &fabro_agent::sandbox::GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {

View file

@ -890,23 +890,23 @@ mod tests {
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
) -> fabro_sandbox::Result<String> {
Ok(String::new())
}
async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> {
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<DirEntry>> {
Ok(vec![])
}
async fn exec_command(
@ -916,35 +916,47 @@ mod tests {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<ExecResult, String> {
) -> fabro_sandbox::Result<ExecResult> {
self.commands.lock().unwrap().push(command.to_string());
self.results
.lock()
.unwrap()
.pop_front()
.ok_or_else(|| "no more mock results".to_string())
.ok_or_else(|| fabro_sandbox::Error::message("no more mock results"))
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn download_file_to_local(&self, _remote: &str, _local: &Path) -> Result<(), String> {
async fn download_file_to_local(
&self,
_remote: &str,
_local: &Path,
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn upload_file_from_local(&self, _local: &Path, _remote: &str) -> Result<(), String> {
async fn upload_file_from_local(
&self,
_local: &Path,
_remote: &str,
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {
@ -956,7 +968,7 @@ mod tests {
fn os_version(&self) -> String {
"Ubuntu 22.04".to_string()
}
async fn set_autostop_interval(&self, _minutes: i32) -> Result<(), String> {
async fn set_autostop_interval(&self, _minutes: i32) -> fabro_sandbox::Result<()> {
Ok(())
}
}

View file

@ -43,7 +43,7 @@ pub fn classify_engine_result(
),
Err(err) => (
StageStatus::Fail,
Some(err.to_string()),
Some(err.display_with_causes()),
RunStatus::Failed {
reason: FailureReason::WorkflowError,
},
@ -284,7 +284,11 @@ async fn cleanup_sandbox(
);
let _ = services.run_hooks(&hook_ctx).await;
if !preserve {
services.sandbox.cleanup().await?;
services
.sandbox
.cleanup()
.await
.map_err(|e| e.display_with_causes())?;
}
Ok(())
}

View file

@ -536,7 +536,7 @@ pub async fn initialize(
sandbox
.initialize()
.await
.map_err(|e| Error::engine(format!("Failed to initialize sandbox: {e}")))?;
.map_err(|e| Error::engine_with_source("Failed to initialize sandbox", &e))?;
let hook_ctx = HookContext::new(
HookEvent::SandboxReady,

View file

@ -218,7 +218,7 @@ pub(crate) async fn git_diff_with_timeout(
{
Ok(r) if r.exit_code == 0 => Ok(r.stdout),
Ok(r) => Err(exec_err("git diff", &r)),
Err(e) => Err(e.clone()),
Err(e) => Err(e.display_with_causes()),
}
}
@ -401,7 +401,9 @@ pub async fn list_changed_files_raw(
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient { message: e })?;
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.timed_out {
return Err(DiffError::Transient {
@ -585,7 +587,9 @@ pub async fn list_diff_numstat(
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient { message: e })?;
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.timed_out {
return Err(DiffError::Transient {
@ -674,7 +678,9 @@ pub async fn stream_blob_metadata(
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient { message: e })?;
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.timed_out {
return Err(DiffError::Transient {
@ -739,7 +745,9 @@ pub async fn stream_blobs(
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient { message: e })?;
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.timed_out {
return Err(DiffError::Transient {
@ -853,19 +861,19 @@ mod tests {
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
Err("read_file not implemented for ScriptedSandbox".to_string())
) -> fabro_sandbox::Result<String> {
Err("read_file not implemented for ScriptedSandbox".into())
}
async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> {
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
@ -873,7 +881,7 @@ mod tests {
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<DirEntry>> {
Ok(Vec::new())
}
@ -884,12 +892,12 @@ mod tests {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
) -> fabro_sandbox::Result<ExecResult> {
self.exec_results
.lock()
.expect("exec_results lock poisoned")
.pop_front()
.ok_or_else(|| "unexpected exec_command call".to_string())
.ok_or_else(|| fabro_sandbox::Error::message("unexpected exec_command call"))
}
async fn grep(
@ -897,11 +905,15 @@ mod tests {
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
Ok(Vec::new())
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(Vec::new())
}
@ -909,7 +921,7 @@ mod tests {
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> Result<(), String> {
) -> fabro_sandbox::Result<()> {
Ok(())
}
@ -917,15 +929,15 @@ mod tests {
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> Result<(), String> {
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}

View file

@ -1419,7 +1419,7 @@ async fn daytona_ssh_access_before_init_fails() {
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"),
result.unwrap_err().to_string().contains("not initialized"),
"error should mention not initialized"
);
}

View file

@ -8823,11 +8823,11 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> std::result::Result<String, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<String> {
Err("not implemented".into())
}
async fn write_file(&self, path: &str, content: &str) -> std::result::Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.written
.lock()
.unwrap()
@ -8836,11 +8836,11 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
Ok(())
}
async fn delete_file(&self, _path: &str) -> std::result::Result<(), String> {
Err("not implemented".to_string())
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn file_exists(&self, path: &str) -> std::result::Result<bool, String> {
async fn file_exists(&self, path: &str) -> fabro_sandbox::Result<bool> {
Ok(self.existing_paths.lock().unwrap().contains(path))
}
@ -8848,8 +8848,8 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
&self,
_path: &str,
_depth: Option<usize>,
) -> std::result::Result<Vec<fabro_agent::DirEntry>, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Err("not implemented".into())
}
async fn exec_command(
@ -8859,8 +8859,8 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> std::result::Result<fabro_agent::ExecResult, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
Err("not implemented".into())
}
async fn grep(
@ -8868,23 +8868,23 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn initialize(&self) -> std::result::Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> std::result::Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
@ -8892,16 +8892,16 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
&self,
_: &str,
_: &std::path::Path,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
fn working_directory(&self) -> &str {
@ -9338,11 +9338,11 @@ impl fabro_agent::Sandbox for CliTestEnv {
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
) -> fabro_sandbox::Result<String> {
Ok(String::new())
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.written_files
.lock()
.unwrap()
@ -9350,11 +9350,11 @@ impl fabro_agent::Sandbox for CliTestEnv {
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
@ -9362,7 +9362,7 @@ impl fabro_agent::Sandbox for CliTestEnv {
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<fabro_agent::DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Ok(vec![])
}
@ -9373,7 +9373,7 @@ impl fabro_agent::Sandbox for CliTestEnv {
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<fabro_agent::ExecResult, String> {
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
self.commands.lock().unwrap().push(command.to_string());
// git diff calls: first pair returns empty (before), second pair returns
@ -9467,28 +9467,40 @@ impl fabro_agent::Sandbox for CliTestEnv {
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
Err("not implemented".to_string())
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
Err("not implemented".to_string())
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
fn working_directory(&self) -> &str {
@ -9664,23 +9676,23 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
_: &str,
_: Option<usize>,
_: Option<usize>,
) -> Result<String, String> {
) -> fabro_sandbox::Result<String> {
Ok(String::new())
}
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> {
async fn write_file(&self, _: &str, _: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn delete_file(&self, _: &str) -> Result<(), String> {
async fn delete_file(&self, _: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _: &str) -> Result<bool, String> {
async fn file_exists(&self, _: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<fabro_agent::DirEntry>, String> {
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Ok(vec![])
}
async fn exec_command(
@ -9690,7 +9702,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
_: Option<&str>,
_: Option<&std::collections::HashMap<String, String>>,
_: Option<tokio_util::sync::CancellationToken>,
) -> Result<fabro_agent::ExecResult, String> {
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
if command.starts_with("git") {
return Ok(fabro_agent::ExecResult {
stdout: String::new(),
@ -9743,23 +9755,31 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
_: &str,
_: &str,
_: &fabro_agent::GrepOptions,
) -> Result<Vec<String>, String> {
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> {
async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn initialize(&self) -> Result<(), String> {
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
Err("not implemented".to_string())
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
Err("not implemented".to_string())
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
fn working_directory(&self) -> &str {
"/tmp"