diff --git a/docs/internal/events-strategy.md b/docs/internal/events-strategy.md index 68b0e8aef..c57b60d04 100644 --- a/docs/internal/events-strategy.md +++ b/docs/internal/events-strategy.md @@ -151,6 +151,8 @@ Prefer `Emitter::emit(&Event::...)`. Use `to_run_event()` only for true bypass paths. +For cache-backed lifecycle work, emit slow-path start events only when the operation actually misses cache or waits on remote state. Completion events should represent a real ensure step (inspect, build, pull, or poll), not a configured no-op. + ### 7. Update consumers Check: diff --git a/docs/internal/events.md b/docs/internal/events.md index 81d0a0a96..92e026b43 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1652,6 +1652,8 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo ### `sandbox.snapshot.pulling` +Emitted only when the Docker image cache misses and Fabro starts pulling the image. + ```json { "id": "...", "ts": "...", "run_id": "...", @@ -1666,42 +1668,10 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo |----------|------|-------------| | `name` | string | Image/snapshot name | -### `sandbox.snapshot.pulled` - -```json -{ - "id": "...", "ts": "...", "run_id": "...", - "event": "sandbox.snapshot.pulled", - "properties": { - "name": "my-image:latest", - "duration_ms": 15000 - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | string | Image/snapshot name | -| `duration_ms` | number | Pull duration | - -### `sandbox.snapshot.ensuring` - -```json -{ - "id": "...", "ts": "...", "run_id": "...", - "event": "sandbox.snapshot.ensuring", - "properties": { - "name": "my-snapshot" - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | string | Snapshot name | - ### `sandbox.snapshot.creating` +Emitted only when a Daytona snapshot cache miss or inactive snapshot requires Fabro to create or wait for the snapshot. + ```json { "id": "...", "ts": "...", "run_id": "...", @@ -1718,6 +1688,8 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo ### `sandbox.snapshot.ready` +Emitted when an image or snapshot ensure step succeeds. Cache hits still emit this event with a near-zero `duration_ms`; explicit no-op paths such as Docker `auto_pull = false` and the Daytona default snapshot path do not. + ```json { "id": "...", "ts": "...", "run_id": "...", @@ -1732,10 +1704,12 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo | Property | Type | Description | |----------|------|-------------| | `name` | string | Snapshot name | -| `duration_ms` | number | Creation duration | +| `duration_ms` | number | Ensure duration | ### `sandbox.snapshot.failed` +Emitted when an image or snapshot ensure step fails. + ```json { "id": "...", "ts": "...", "run_id": "...", @@ -1751,6 +1725,7 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo |----------|------|-------------| | `name` | string | Snapshot name | | `error` | string | Error message | +| `causes` | string[] | Optional error cause chain | ### `sandbox.git.started` diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 7ff438998..8f01378cc 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -642,6 +642,43 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O styles.dim.apply_to(&duration), )) } + "sandbox.snapshot.pulling" => { + let name = prop_str_field(envelope, "name").unwrap_or("?"); + Some(format!( + "{} Sandbox: pulling {}", + styles.dim.apply_to(&ts), + name, + )) + } + "sandbox.snapshot.creating" => { + let name = prop_str_field(envelope, "name").unwrap_or("?"); + Some(format!( + "{} Sandbox: building {}", + styles.dim.apply_to(&ts), + name, + )) + } + "sandbox.snapshot.ready" => { + let name = prop_str_field(envelope, "name").unwrap_or("?"); + let duration = format_duration_ms(prop_field(envelope, "duration_ms")); + Some(format!( + "{} Sandbox snapshot: {} {}", + styles.dim.apply_to(&ts), + name, + styles.dim.apply_to(&duration), + )) + } + "sandbox.snapshot.failed" => { + let name = prop_str_field(envelope, "name").unwrap_or("?"); + let error = prop_str_field(envelope, "error").unwrap_or("unknown error"); + Some(format!( + "{} {} Sandbox snapshot {} failed: {}", + styles.dim.apply_to(&ts), + styles.bold_red.apply_to("\u{2717}"), + name, + styles.red.apply_to(error), + )) + } "setup.completed" => { let count = prop_field(envelope, "command_count").and_then(serde_json::Value::as_u64); let duration = format_duration_ms(prop_field(envelope, "duration_ms")); @@ -1155,6 +1192,45 @@ mod tests { assert!(result.contains("[push]"), "got: {result}"); } + #[test] + fn pretty_sandbox_snapshot_pulling() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.pulling","properties":{"name":"buildpack-deps:noble"}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("Sandbox: pulling"), "got: {result}"); + assert!(result.contains("buildpack-deps:noble"), "got: {result}"); + } + + #[test] + fn pretty_sandbox_snapshot_creating() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.creating","properties":{"name":"fabro-v9-test"}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("Sandbox: building"), "got: {result}"); + assert!(result.contains("fabro-v9-test"), "got: {result}"); + } + + #[test] + fn pretty_sandbox_snapshot_ready() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.ready","properties":{"name":"buildpack-deps:noble","duration_ms":8200}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("Sandbox snapshot:"), "got: {result}"); + assert!(result.contains("buildpack-deps:noble"), "got: {result}"); + assert!(result.contains("8s"), "got: {result}"); + } + + #[test] + fn pretty_sandbox_snapshot_failed() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.failed","properties":{"name":"buildpack-deps:noble","error":"pull failed"}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!( + result.contains("Sandbox snapshot buildpack-deps:noble failed: pull failed"), + "got: {result}" + ); + } + #[test] fn pretty_stream_suppresses_metadata_compat_notice_only() { let styles = no_color_styles(); diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index 0fef308a3..f1a172220 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -55,6 +55,24 @@ pub(super) enum ProgressEvent { memory: Option, url: Option, }, + SandboxFailed { + provider: String, + error: String, + }, + SnapshotPulling { + name: String, + }, + SnapshotCreating { + name: String, + }, + SnapshotReady { + name: String, + duration_ms: u64, + }, + SnapshotFailed { + name: String, + error: String, + }, SshAccessReady { ssh_command: String, }, @@ -252,6 +270,24 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { memory: props.memory, url: props.url.clone(), }), + EventBody::SandboxFailed(props) => Some(ProgressEvent::SandboxFailed { + provider: props.provider.clone(), + error: props.error.clone(), + }), + EventBody::SnapshotPulling(props) => Some(ProgressEvent::SnapshotPulling { + name: props.name.clone(), + }), + EventBody::SnapshotCreating(props) => Some(ProgressEvent::SnapshotCreating { + name: props.name.clone(), + }), + EventBody::SnapshotReady(props) => Some(ProgressEvent::SnapshotReady { + name: props.name.clone(), + duration_ms: props.duration_ms, + }), + EventBody::SnapshotFailed(props) => Some(ProgressEvent::SnapshotFailed { + name: props.name.clone(), + error: props.error.clone(), + }), EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady { ssh_command: props.ssh_command.clone(), }), @@ -698,6 +734,72 @@ mod tests { )); } + #[test] + fn round_trip_sandbox_failed() { + let event = Event::Sandbox { + event: fabro_agent::SandboxEvent::InitializeFailed { + provider: "docker".into(), + error: "pull failed".into(), + causes: Vec::new(), + duration_ms: 900, + }, + }; + + let stored = to_run_event(&fixtures::RUN_1, &event); + let parsed = from_run_event(&stored).unwrap(); + assert!(matches!( + parsed, + ProgressEvent::SandboxFailed { provider, error } + if provider == "docker" && error == "pull failed" + )); + } + + #[test] + fn round_trip_snapshot_lifecycle_events() { + let pulling = to_run_event(&fixtures::RUN_1, &Event::Sandbox { + event: fabro_agent::SandboxEvent::SnapshotPulling { + name: "buildpack-deps:noble".into(), + }, + }); + let creating = to_run_event(&fixtures::RUN_1, &Event::Sandbox { + event: fabro_agent::SandboxEvent::SnapshotCreating { + name: "fabro-v9".into(), + }, + }); + let ready = to_run_event(&fixtures::RUN_1, &Event::Sandbox { + event: fabro_agent::SandboxEvent::SnapshotReady { + name: "buildpack-deps:noble".into(), + duration_ms: 1200, + }, + }); + let failed = to_run_event(&fixtures::RUN_1, &Event::Sandbox { + event: fabro_agent::SandboxEvent::SnapshotFailed { + name: "fabro-v9".into(), + error: "build failed".into(), + causes: Vec::new(), + }, + }); + + assert!(matches!( + from_run_event(&pulling).unwrap(), + ProgressEvent::SnapshotPulling { name } if name == "buildpack-deps:noble" + )); + assert!(matches!( + from_run_event(&creating).unwrap(), + ProgressEvent::SnapshotCreating { name } if name == "fabro-v9" + )); + assert!(matches!( + from_run_event(&ready).unwrap(), + ProgressEvent::SnapshotReady { name, duration_ms } + if name == "buildpack-deps:noble" && duration_ms == 1200 + )); + assert!(matches!( + from_run_event(&failed).unwrap(), + ProgressEvent::SnapshotFailed { name, error } + if name == "fabro-v9" && error == "build failed" + )); + } + #[test] fn round_trip_run_notice() { let event = Event::RunNotice { diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 9940fcf11..428e96315 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -122,6 +122,21 @@ impl ProgressUI { ProgressEvent::SandboxInitializing { provider } => { self.setup.on_sandbox_initializing(renderer, &provider); } + ProgressEvent::SandboxFailed { provider, error } => { + self.setup.on_sandbox_failed(renderer, &provider, &error); + } + ProgressEvent::SnapshotPulling { name } => { + self.setup.on_snapshot_pulling(renderer, &name); + } + ProgressEvent::SnapshotCreating { name } => { + self.setup.on_snapshot_creating(renderer, &name); + } + ProgressEvent::SnapshotReady { name, duration_ms } => { + self.setup.on_snapshot_ready(renderer, &name, duration_ms); + } + ProgressEvent::SnapshotFailed { name, error } => { + self.setup.on_snapshot_failed(renderer, &name, &error); + } ProgressEvent::SandboxReady { provider, duration_ms, @@ -882,6 +897,189 @@ mod tests { "); } + #[test] + fn plain_daytona_snapshot_creation_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "daytona".into(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::SnapshotCreating { + name: "fabro-v9-test".into(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::SnapshotReady { + name: "fabro-v9-test".into(), + duration_ms: 210_000, + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".into(), + duration_ms: 212_000, + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: None, + }, + }); + + insta::assert_snapshot!(rendered(&buffer), @r" + Sandbox: building fabro-v9-test... + Sandbox: daytona (ready in 3m32s) + sandbox-1 (4 cpu, 8 GB) + "); + } + + #[test] + fn plain_docker_snapshot_pull_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "docker".into(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::SnapshotPulling { + name: "buildpack-deps:noble".into(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::SnapshotReady { + name: "buildpack-deps:noble".into(), + duration_ms: 8_200, + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Ready { + provider: "docker".into(), + duration_ms: 9_000, + name: None, + cpu: None, + memory: None, + url: None, + }, + }); + + insta::assert_snapshot!(rendered(&buffer), @r" + Sandbox: pulling buildpack-deps:noble... + Sandbox: docker (ready in 9s) + "); + } + + #[test] + fn plain_docker_skipped_snapshot_phase_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "docker".into(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Ready { + provider: "docker".into(), + duration_ms: 20, + name: None, + cpu: None, + memory: None, + url: None, + }, + }); + + insta::assert_snapshot!(rendered(&buffer), @" Sandbox: docker (ready in 20ms)"); + } + + #[test] + fn plain_snapshot_failure_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "docker".into(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::SnapshotFailed { + name: "buildpack-deps:noble".into(), + error: "pull failed".into(), + causes: Vec::new(), + }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::InitializeFailed { + provider: "docker".into(), + error: "pull failed".into(), + causes: Vec::new(), + duration_ms: 900, + }, + }); + + insta::assert_snapshot!(rendered(&buffer), @r" + Sandbox: Snapshot buildpack-deps:noble failed: pull failed + Sandbox: docker failed: pull failed + "); + } + + #[test] + fn tty_snapshot_ready_keeps_sandbox_bar_until_sandbox_ready() { + let mut ui = ProgressUI::new(true, false); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "docker".into(), + }, + }); + assert!(ui.setup.sandbox_bar.is_some()); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::SnapshotReady { + name: "buildpack-deps:noble".into(), + duration_ms: 10, + }, + }); + assert!(ui.setup.sandbox_bar.is_some()); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Ready { + provider: "docker".into(), + duration_ms: 20, + name: None, + cpu: None, + memory: None, + url: None, + }, + }); + assert!(ui.setup.sandbox_bar.is_none()); + } + + #[test] + fn tty_sandbox_failed_finishes_sandbox_bar() { + let mut ui = ProgressUI::new(true, false); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "docker".into(), + }, + }); + assert!(ui.setup.sandbox_bar.is_some()); + + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::InitializeFailed { + provider: "docker".into(), + error: "pull failed".into(), + causes: Vec::new(), + duration_ms: 900, + }, + }); + assert!(ui.setup.sandbox_bar.is_none()); + } + #[test] fn plain_verbose_snapshot() { let (mut ui, buffer) = capture_ui(true); diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs index cb4aedf18..1e0d980a8 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs @@ -8,6 +8,7 @@ use crate::shared::format_duration_ms; pub(super) struct SetupDisplay { verbose: bool, + current_provider: Option, pub(super) sandbox_bar: Option, pub(super) setup_bar: Option, pub(super) setup_command_count: u64, @@ -20,6 +21,7 @@ impl SetupDisplay { pub(super) fn new(verbose: bool) -> Self { Self { verbose, + current_provider: None, sandbox_bar: None, setup_bar: None, setup_command_count: 0, @@ -45,10 +47,11 @@ impl SetupDisplay { } pub(super) fn on_sandbox_initializing(&mut self, renderer: &ProgressRenderer, provider: &str) { + self.current_provider = Some(provider.to_string()); if renderer.is_tty() { let bar = renderer.add_spinner(); bar.set_style(styles::style_header_running()); - bar.set_message(format!("Initializing {provider} sandbox...")); + bar.set_message(initializing_message(provider)); bar.enable_steady_tick(Duration::from_millis(100)); self.sandbox_bar = Some(bar); } @@ -99,6 +102,70 @@ impl SetupDisplay { } } + pub(super) fn on_sandbox_failed( + &mut self, + renderer: &ProgressRenderer, + provider: &str, + error: &str, + ) { + if renderer.is_tty() { + let bar = self + .sandbox_bar + .take() + .unwrap_or_else(|| renderer.add_spinner()); + bar.set_style(styles::style_header_failed()); + bar.finish_with_message(format!("Sandbox: {provider} failed: {error}")); + } else { + renderer.print_line(4, &format!("Sandbox: {provider} failed: {error}")); + } + } + + pub(super) fn on_snapshot_pulling(&self, renderer: &ProgressRenderer, name: &str) { + if renderer.is_tty() { + if let Some(bar) = self.sandbox_bar.as_ref() { + bar.set_message(format!("Pulling {name}...")); + } + } else { + renderer.print_line(4, &format!("Sandbox: pulling {name}...")); + } + } + + pub(super) fn on_snapshot_creating(&self, renderer: &ProgressRenderer, name: &str) { + if renderer.is_tty() { + if let Some(bar) = self.sandbox_bar.as_ref() { + bar.set_message(format!("Building {name}...")); + } + } else { + renderer.print_line(4, &format!("Sandbox: building {name}...")); + } + } + + pub(super) fn on_snapshot_ready( + &self, + renderer: &ProgressRenderer, + _name: &str, + _duration_ms: u64, + ) { + if renderer.is_tty() { + if let Some(bar) = self.sandbox_bar.as_ref() { + let provider = self.current_provider.as_deref().unwrap_or("sandbox"); + bar.set_style(styles::style_header_running()); + bar.set_message(initializing_message(provider)); + } + } + } + + pub(super) fn on_snapshot_failed(&self, renderer: &ProgressRenderer, name: &str, error: &str) { + let message = format!("Snapshot {name} failed: {error}"); + if renderer.is_tty() { + if let Some(bar) = self.sandbox_bar.as_ref() { + bar.set_message(message); + } + } else { + renderer.print_line(4, &format!("Sandbox: {message}")); + } + } + pub(super) fn on_ssh_access_ready(renderer: &ProgressRenderer, ssh_command: &str) { if renderer.is_tty() { let bar = renderer.add_spinner(); @@ -376,3 +443,11 @@ impl SetupDisplay { } } } + +fn initializing_message(provider: &str) -> String { + if provider == "sandbox" { + "Initializing sandbox...".to_string() + } else { + format!("Initializing {provider} sandbox...") + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs index 96390b7b5..6faf8bada 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs @@ -21,6 +21,7 @@ cached_style!( " {spinner:.dim} {wide_msg} {elapsed:.dim}" ); cached_style!(style_header_done, " {wide_msg:.dim} {prefix:.dim}"); +cached_style!(style_header_failed, " {wide_msg:.red} {prefix:.dim}"); cached_style!( style_stage_running, " {spinner:.cyan} {wide_msg} {elapsed:.dim}" diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 1bdb6f13a..5d5570e3f 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -437,6 +437,9 @@ impl DaytonaSandbox { } _ => { // Building/Pending/Pulling — fall through to poll + self.emit(SandboxEvent::SnapshotCreating { + name: snap_cfg.name.clone(), + }); } } } @@ -457,6 +460,10 @@ impl DaytonaSandbox { } }; + self.emit(SandboxEvent::SnapshotCreating { + name: snap_cfg.name.clone(), + }); + let params = daytona_sdk::CreateSnapshotParams { name: snap_cfg.name.clone(), image: daytona_sdk::ImageSource::Custom( @@ -627,9 +634,6 @@ impl Sandbox for DaytonaSandbox { let init_start = Instant::now(); let params = if let Some(ref snap_cfg) = self.config.snapshot { - self.emit(SandboxEvent::SnapshotEnsuring { - name: snap_cfg.name.clone(), - }); let snap_start = Instant::now(); if let Err(e) = self.ensure_snapshot(snap_cfg).await { self.emit(SandboxEvent::SnapshotFailed { diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs index aeb7df763..e6562e5d6 100644 --- a/lib/crates/fabro-sandbox/src/docker.rs +++ b/lib/crates/fabro-sandbox/src/docker.rs @@ -93,6 +93,12 @@ pub struct DockerSandbox { event_callback: Option, } +enum EnsureImageOutcome { + Skipped, + AlreadyLocal, + Pulled, +} + impl DockerSandbox { pub fn new( config: DockerSandboxOptions, @@ -441,13 +447,13 @@ impl DockerSandbox { Ok(()) } - async fn ensure_image(&self) -> crate::Result<()> { + async fn ensure_image(&self) -> crate::Result { if !self.config.auto_pull { - return Ok(()); + return Ok(EnsureImageOutcome::Skipped); } match self.docker.inspect_image(&self.config.image).await { - Ok(_) => return Ok(()), + Ok(_) => return Ok(EnsureImageOutcome::AlreadyLocal), Err(e) if docker_not_found(&e) => {} Err(e) => { return Err(crate::Error::docker_image_inspect( @@ -469,12 +475,15 @@ impl DockerSandbox { ..Default::default() }; + self.emit(SandboxEvent::SnapshotPulling { + name: self.config.image.clone(), + }); let mut stream = self.docker.create_image(Some(opts), None, None); while let Some(result) = stream.next().await { result.map_err(|e| crate::Error::docker_image_pull(self.config.image.clone(), e))?; } - Ok(()) + Ok(EnsureImageOutcome::Pulled) } async fn create_workspace(&self) -> crate::Result<()> { @@ -972,18 +981,26 @@ impl Sandbox for DockerSandbox { }); let init_start = Instant::now(); - self.emit(SandboxEvent::SnapshotPulling { - name: self.config.image.clone(), - }); let pull_start = Instant::now(); - if let Err(e) = self.ensure_image().await { - return Err(self.fail_init(init_start, e)); + match self.ensure_image().await { + Ok(EnsureImageOutcome::Skipped) => {} + Ok(EnsureImageOutcome::AlreadyLocal | EnsureImageOutcome::Pulled) => { + let pull_duration = + u64::try_from(pull_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::SnapshotReady { + name: self.config.image.clone(), + duration_ms: pull_duration, + }); + } + Err(e) => { + self.emit(SandboxEvent::SnapshotFailed { + name: self.config.image.clone(), + error: e.to_string(), + causes: e.causes(), + }); + return Err(self.fail_init(init_start, e)); + } } - let pull_duration = u64::try_from(pull_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::SnapshotPulled { - name: self.config.image.clone(), - duration_ms: pull_duration, - }); let container_name = self .ensure_name_available() diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs index f14dc1629..1176c6dce 100644 --- a/lib/crates/fabro-sandbox/src/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/sandbox.rs @@ -254,19 +254,10 @@ pub enum SandboxEvent { causes: Vec, }, - // -- Docker -- + // -- Snapshot lifecycle -- SnapshotPulling { name: String, }, - SnapshotPulled { - name: String, - duration_ms: u64, - }, - - // -- Daytona snapshots -- - SnapshotEnsuring { - name: String, - }, SnapshotCreating { name: String, }, @@ -339,12 +330,6 @@ impl SandboxEvent { Self::SnapshotPulling { name } => { debug!(name, "Snapshot pulling"); } - Self::SnapshotPulled { name, duration_ms } => { - debug!(name, duration_ms, "Snapshot pulled"); - } - Self::SnapshotEnsuring { name } => { - debug!(name, "Snapshot ensuring"); - } Self::SnapshotCreating { name } => { debug!(name, "Snapshot creating"); } @@ -1168,13 +1153,6 @@ mod tests { SandboxEvent::SnapshotPulling { name: "ubuntu:22.04".into(), }, - SandboxEvent::SnapshotPulled { - name: "ubuntu:22.04".into(), - duration_ms: 5000, - }, - SandboxEvent::SnapshotEnsuring { - name: "my-snap".into(), - }, SandboxEvent::SnapshotCreating { name: "my-snap".into(), }, @@ -1202,7 +1180,7 @@ mod tests { }, ]; - assert_eq!(events.len(), 15, "should test all 15 variants"); + assert_eq!(events.len(), 13, "should test all 13 variants"); for event in &events { let json = serde_json::to_string(event).unwrap(); diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index b65eb38b1..8276534a3 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -206,10 +206,6 @@ pub enum EventBody { SandboxCleanupFailed(SandboxCleanupFailedProps), #[serde(rename = "sandbox.snapshot.pulling")] SnapshotPulling(SnapshotNameProps), - #[serde(rename = "sandbox.snapshot.pulled")] - SnapshotPulled(SnapshotCompletedProps), - #[serde(rename = "sandbox.snapshot.ensuring")] - SnapshotEnsuring(SnapshotNameProps), #[serde(rename = "sandbox.snapshot.creating")] SnapshotCreating(SnapshotNameProps), #[serde(rename = "sandbox.snapshot.ready")] @@ -414,8 +410,6 @@ impl EventBody { Self::SandboxCleanupCompleted(_) => "sandbox.cleanup.completed", Self::SandboxCleanupFailed(_) => "sandbox.cleanup.failed", Self::SnapshotPulling(_) => "sandbox.snapshot.pulling", - Self::SnapshotPulled(_) => "sandbox.snapshot.pulled", - Self::SnapshotEnsuring(_) => "sandbox.snapshot.ensuring", Self::SnapshotCreating(_) => "sandbox.snapshot.creating", Self::SnapshotReady(_) => "sandbox.snapshot.ready", Self::SnapshotFailed(_) => "sandbox.snapshot.failed", @@ -548,8 +542,6 @@ fn is_known_event_name(event: &str) -> bool { | "sandbox.cleanup.completed" | "sandbox.cleanup.failed" | "sandbox.snapshot.pulling" - | "sandbox.snapshot.pulled" - | "sandbox.snapshot.ensuring" | "sandbox.snapshot.creating" | "sandbox.snapshot.ready" | "sandbox.snapshot.failed" @@ -1242,6 +1234,34 @@ mod tests { )); } + #[test] + fn retired_sandbox_snapshot_events_deserialize_as_unknown() { + for (event_name, expected_properties) in [ + ( + "sandbox.snapshot.pulled", + json!({"name": "buildpack-deps:noble", "duration_ms": 5000}), + ), + ("sandbox.snapshot.ensuring", json!({"name": "fabro-v8"})), + ] { + let value = json!({ + "id": "evt_retired_snapshot", + "ts": "2026-04-29T12:00:00.000Z", + "run_id": fixtures::RUN_1, + "event": event_name, + "properties": expected_properties + }); + + let parsed = RunEvent::from_value(value).unwrap(); + match parsed.body { + EventBody::Unknown { name, properties } => { + assert_eq!(name, event_name); + assert_eq!(properties, expected_properties); + } + other => panic!("expected Unknown body, got {other:?}"), + } + } + } + #[test] fn metadata_snapshot_failed_omits_empty_optional_fields() { let body = EventBody::MetadataSnapshotFailed(MetadataSnapshotFailedProps { diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 3dd20d8cd..a66a43bdc 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -769,15 +769,6 @@ fn event_body_from_event(event: &Event) -> EventBody { SandboxEvent::SnapshotPulling { name } => { EventBody::SnapshotPulling(fabro_types::SnapshotNameProps { name: name.clone() }) } - SandboxEvent::SnapshotPulled { name, duration_ms } => { - EventBody::SnapshotPulled(fabro_types::SnapshotCompletedProps { - name: name.clone(), - duration_ms: *duration_ms, - }) - } - SandboxEvent::SnapshotEnsuring { name } => { - EventBody::SnapshotEnsuring(fabro_types::SnapshotNameProps { name: name.clone() }) - } SandboxEvent::SnapshotCreating { name } => { EventBody::SnapshotCreating(fabro_types::SnapshotNameProps { name: name.clone() }) } diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs index 76d5dc98f..e176b2474 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -92,8 +92,6 @@ pub fn event_name(event: &Event) -> &'static str { SandboxEvent::CleanupCompleted { .. } => "sandbox.cleanup.completed", SandboxEvent::CleanupFailed { .. } => "sandbox.cleanup.failed", SandboxEvent::SnapshotPulling { .. } => "sandbox.snapshot.pulling", - SandboxEvent::SnapshotPulled { .. } => "sandbox.snapshot.pulled", - SandboxEvent::SnapshotEnsuring { .. } => "sandbox.snapshot.ensuring", SandboxEvent::SnapshotCreating { .. } => "sandbox.snapshot.creating", SandboxEvent::SnapshotReady { .. } => "sandbox.snapshot.ready", SandboxEvent::SnapshotFailed { .. } => "sandbox.snapshot.failed",