checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-05-22 20:10:01 -04:00
parent 1a988bbfe3
commit 35fd18c160
6 changed files with 1238 additions and 19 deletions

220
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,780 @@
diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs
index 6f919e179..7f6306ffa 100644
--- a/lib/crates/fabro-config/src/layers/combine.rs
+++ b/lib/crates/fabro-config/src/layers/combine.rs
@@ -49,6 +49,12 @@ impl Combine for Option<Vec<DaytonaVolumeLayer>> {
}
}
+impl Combine for Option<Vec<crate::EnvironmentVolumeLayer>> {
+ fn combine(self, other: Self) -> Self {
+ self.or(other)
+ }
+}
+
macro_rules! impl_combine_or_option {
($($ty:ty),+ $(,)?) => {
$(
@@ -378,4 +384,4 @@ mod tests {
})
);
}
-}
+}
\ No newline at end of file
diff --git a/lib/crates/fabro-config/src/layers/environment.rs b/lib/crates/fabro-config/src/layers/environment.rs
new file mode 100644
index 000000000..1131cdc1a
--- /dev/null
+++ b/lib/crates/fabro-config/src/layers/environment.rs
@@ -0,0 +1,262 @@
+//! Sparse `[environments.<slug>]` and `[run.environment]` layer
+//! definitions.
+//!
+//! An `EnvironmentLayer` describes a single reusable environment profile
+//! and may appear at any config layer. The top-level `[environments]`
+//! catalog merges across layers by slug.
+//!
+//! `RunEnvironmentLayer` is the `[run.environment]` selection: it picks an
+//! environment slug via `id` and may sparsely override fields of the
+//! selected environment.
+
+use std::collections::HashMap;
+
+use fabro_types::settings::run::DockerfileSource;
+use fabro_types::settings::{Duration, EnvironmentNetworkMode, EnvironmentProvider, InterpString,
+ Size};
+use serde::{Deserialize, Serialize};
+
+use super::combine::Combine;
+use super::maps::StickyMap;
+
+/// A single `[environments.<slug>]` profile.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct EnvironmentLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub provider: Option<EnvironmentProvider>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub image: Option<EnvironmentImageLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub resources: Option<EnvironmentResourcesLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub network: Option<EnvironmentNetworkLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lifecycle: Option<EnvironmentLifecycleLayer>,
+ /// Sticky merge-by-key (provider-native labels).
+ #[serde(default, skip_serializing_if = "StickyMap::is_empty")]
+ pub labels: StickyMap<String>,
+ /// Existing volumes to mount when creating the sandbox. Replaces
+ /// wholesale across layers.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub volumes: Option<Vec<EnvironmentVolumeLayer>>,
+ /// Process environment variables to set in the sandbox. Sticky
+ /// merge-by-key across layers.
+ #[serde(default, skip_serializing_if = "StickyMap::is_empty")]
+ pub env: StickyMap<InterpString>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct EnvironmentImageLayer {
+ /// Provider-native image reference (Docker image tag or Daytona
+ /// snapshot name).
+ #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
+ pub image_ref: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub dockerfile: Option<DockerfileSource>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct EnvironmentResourcesLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cpu: Option<i32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub memory: Option<Size>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub disk: Option<Size>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct EnvironmentNetworkLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub mode: Option<EnvironmentNetworkMode>,
+ /// CIDR allow-list entries. Replaces wholesale across layers.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub allow: Vec<String>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct EnvironmentLifecycleLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub preserve: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub stop_on_terminal: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub auto_stop: Option<Duration>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct EnvironmentVolumeLayer {
+ pub id: String,
+ pub mount_path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub subpath: Option<String>,
+}
+
+/// `[run.environment]` — selection plus sparse overlays on the selected
+/// environment.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct RunEnvironmentLayer {
+ /// Slug of the environment in the top-level `[environments]` catalog.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub provider: Option<EnvironmentProvider>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub image: Option<EnvironmentImageLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub resources: Option<EnvironmentResourcesLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub network: Option<EnvironmentNetworkLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lifecycle: Option<EnvironmentLifecycleLayer>,
+ #[serde(default, skip_serializing_if = "StickyMap::is_empty")]
+ pub labels: StickyMap<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub volumes: Option<Vec<EnvironmentVolumeLayer>>,
+ #[serde(default, skip_serializing_if = "StickyMap::is_empty")]
+ pub env: StickyMap<InterpString>,
+}
+
+impl RunEnvironmentLayer {
+ /// Convert this overlay into an `EnvironmentLayer` (dropping `id`).
+ #[must_use]
+ pub fn to_environment_overlay(&self) -> EnvironmentLayer {
+ EnvironmentLayer {
+ provider: self.provider,
+ image: self.image.clone(),
+ resources: self.resources.clone(),
+ network: self.network.clone(),
+ lifecycle: self.lifecycle.clone(),
+ labels: self.labels.clone(),
+ volumes: self.volumes.clone(),
+ env: self.env.clone(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn environment_layer_parses_full_shape() {
+ let toml = r#"
+provider = "daytona"
+
+[image]
+ref = "fabro-v11"
+
+[image.dockerfile]
+type = "path"
+path = "Dockerfile"
+
+[resources]
+cpu = 8
+memory = "16GB"
+disk = "20GB"
+
+[network]
+mode = "block"
+allow = ["10.0.0.0/8"]
+
+[lifecycle]
+preserve = false
+stop_on_terminal = true
+auto_stop = "30m"
+
+[labels]
+repo = "fabro-sh/fabro"
+
+[[volumes]]
+id = "vol-agent-state"
+mount_path = "/home/daytona/agent-state"
+subpath = "auth"
+
+[env]
+NODE_ENV = "development"
+"#;
+ let layer: EnvironmentLayer = toml::from_str(toml).expect("env layer should parse");
+ assert_eq!(layer.provider, Some(EnvironmentProvider::Daytona));
+ assert_eq!(
+ layer.image.as_ref().and_then(|i| i.image_ref.as_deref()),
+ Some("fabro-v11")
+ );
+ assert_eq!(layer.resources.as_ref().and_then(|r| r.cpu), Some(8));
+ assert_eq!(
+ layer.network.as_ref().and_then(|n| n.mode),
+ Some(EnvironmentNetworkMode::Block)
+ );
+ assert_eq!(layer.network.as_ref().unwrap().allow, vec![
+ "10.0.0.0/8".to_string()
+ ]);
+ assert_eq!(
+ layer.lifecycle.as_ref().and_then(|l| l.preserve),
+ Some(false)
+ );
+ assert_eq!(layer.labels.get("repo").map(String::as_str), Some("fabro-sh/fabro"));
+ let volumes = layer.volumes.as_ref().expect("volumes set");
+ assert_eq!(volumes.len(), 1);
+ assert_eq!(volumes[0].id, "vol-agent-state");
+ assert!(layer.env.contains_key("NODE_ENV"));
+ }
+
+ #[test]
+ fn run_environment_layer_parses_id_with_overlays() {
+ let toml = r#"
+id = "fabro-dev"
+
+[resources]
+memory = "32GB"
+
+[lifecycle]
+preserve = true
+"#;
+ let layer: RunEnvironmentLayer = toml::from_str(toml).expect("run env layer should parse");
+ assert_eq!(layer.id.as_deref(), Some("fabro-dev"));
+ assert_eq!(
+ layer.lifecycle.as_ref().and_then(|l| l.preserve),
+ Some(true)
+ );
+ }
+
+ #[test]
+ fn environment_layer_combine_merges_labels_and_env_by_key() {
+ let upper = EnvironmentLayer {
+ labels: StickyMap::from(HashMap::from([("repo".into(), "upper".into())])),
+ env: StickyMap::from(HashMap::from([(
+ "NODE_ENV".into(),
+ InterpString::parse("upper"),
+ )])),
+ ..EnvironmentLayer::default()
+ };
+ let lower = EnvironmentLayer {
+ labels: StickyMap::from(HashMap::from([
+ ("repo".into(), "lower".into()),
+ ("team".into(), "lower".into()),
+ ])),
+ env: StickyMap::from(HashMap::from([(
+ "RUST_LOG".into(),
+ InterpString::parse("info"),
+ )])),
+ ..EnvironmentLayer::default()
+ };
+ let combined = upper.combine(lower);
+ assert_eq!(
+ combined.labels.get("repo").map(String::as_str),
+ Some("upper")
+ );
+ assert_eq!(
+ combined.labels.get("team").map(String::as_str),
+ Some("lower")
+ );
+ assert!(combined.env.contains_key("NODE_ENV"));
+ assert!(combined.env.contains_key("RUST_LOG"));
+ }
+}
diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs
index fc67327b2..837a4d159 100644
--- a/lib/crates/fabro-config/src/layers/mod.rs
+++ b/lib/crates/fabro-config/src/layers/mod.rs
@@ -1,5 +1,6 @@
mod cli;
mod combine;
+mod environment;
mod llm;
mod log_filter;
mod maps;
@@ -15,6 +16,10 @@ pub use cli::{
CliOutputLayer, CliTargetLayer, CliUpdatesLayer,
};
pub(crate) use combine::Combine;
+pub use environment::{
+ EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, EnvironmentNetworkLayer,
+ EnvironmentResourcesLayer, EnvironmentVolumeLayer, RunEnvironmentLayer,
+};
pub use llm::{
CostRates, CredentialRef, CredentialRefParseError, HeaderValueRef, LlmLayer, ModelControls,
ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits,
@@ -41,4 +46,4 @@ pub use server::{
ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer,
};
pub(crate) use settings::SettingsLayer;
-pub use workflow::WorkflowLayer;
+pub use workflow::WorkflowLayer;
\ No newline at end of file
diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs
index 63a12130b..26b9ff02a 100644
--- a/lib/crates/fabro-config/src/layers/run.rs
+++ b/lib/crates/fabro-config/src/layers/run.rs
@@ -9,6 +9,7 @@ use fabro_types::settings::{Duration, InterpString, ModelRef, Size};
use serde::{Deserialize, Serialize};
use super::combine::Combine;
+use super::environment::RunEnvironmentLayer;
use super::maps::{MergeMap, ReplaceMap, StickyMap};
use super::splice_array::SPLICE_MARKER;
@@ -43,6 +44,10 @@ pub struct RunLayer {
pub meta_branch: Option<RunMetaBranchLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<RunSandboxLayer>,
+ /// `[run.environment]` — selection of a named environment and sparse
+ /// overlays for the selected environment.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub environment: Option<RunEnvironmentLayer>,
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
pub notifications: MergeMap<NotificationRouteLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -672,4 +677,4 @@ pub struct RunPullRequestLayer {
pub struct RunArtifactsLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
-}
+}
\ No newline at end of file
diff --git a/lib/crates/fabro-config/src/layers/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs
index 653b4f0cd..2dc0e7c15 100644
--- a/lib/crates/fabro-config/src/layers/settings.rs
+++ b/lib/crates/fabro-config/src/layers/settings.rs
@@ -10,7 +10,9 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::cli::CliLayer;
+use super::environment::EnvironmentLayer;
use super::llm::LlmLayer;
+use super::maps::MergeMap;
use super::project::ProjectLayer;
use super::run::RunLayer;
use super::server::ServerLayer;
@@ -34,6 +36,10 @@ pub(crate) struct SettingsLayer {
pub server: Option<ServerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmLayer>,
+ /// Top-level `[environments.<slug>]` catalog. Merges by slug across
+ /// settings layers; each value recursively combines.
+ #[serde(default, skip_serializing_if = "MergeMap::is_empty")]
+ pub environments: MergeMap<EnvironmentLayer>,
}
impl FromStr for SettingsLayer {
@@ -131,4 +137,4 @@ impl SettingsLayer {
let auth = server.auth.get_or_insert_with(ServerAuthLayer::default);
auth.methods = Some(vec![ServerAuthMethod::DevToken]);
}
-}
+}
\ No newline at end of file
diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs
index e3d3df83c..02cd342fd 100644
--- a/lib/crates/fabro-config/src/lib.rs
+++ b/lib/crates/fabro-config/src/lib.rs
@@ -42,16 +42,19 @@ pub use layers::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef,
CredentialRefParseError, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer,
- DaytonaVolumeLayer, DockerSandboxLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
+ DaytonaVolumeLayer, DockerSandboxLayer, EnvironmentImageLayer, EnvironmentLayer,
+ EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer,
+ EnvironmentVolumeLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer,
InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer,
MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
- RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,
- RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
- RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
- RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
+ RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer,
+ RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
+ RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer,
+ RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer,
+ ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
@@ -90,4 +93,4 @@ where
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(Error::read_file(&default_path, e)),
}
-}
+}
\ No newline at end of file
diff --git a/lib/crates/fabro-types/src/dense.rs b/lib/crates/fabro-types/src/dense.rs
index 6ffacc92a..24a8c941c 100644
--- a/lib/crates/fabro-types/src/dense.rs
+++ b/lib/crates/fabro-types/src/dense.rs
@@ -4,8 +4,8 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::settings::{
- CliNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, RunNamespace,
- ServerNamespace, WorkflowNamespace,
+ CliNamespace, EnvironmentSettings, InterpString, ObjectStoreSettings, ProjectNamespace,
+ RunNamespace, ServerNamespace, WorkflowNamespace,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -47,9 +47,13 @@ pub struct UserSettings {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct WorkflowSettings {
- pub project: ProjectNamespace,
- pub workflow: WorkflowNamespace,
- pub run: RunNamespace,
+ pub project: ProjectNamespace,
+ pub workflow: WorkflowNamespace,
+ pub run: RunNamespace,
+ /// Resolved environment catalog merged across config layers, keyed by
+ /// slug. The selected environment also appears as `run.environment`.
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub environments: HashMap<String, EnvironmentSettings>,
}
impl WorkflowSettings {
@@ -60,4 +64,4 @@ impl WorkflowSettings {
labels.extend(self.run.metadata.clone());
labels
}
-}
+}
\ No newline at end of file
diff --git a/lib/crates/fabro-types/src/settings/environment.rs b/lib/crates/fabro-types/src/settings/environment.rs
new file mode 100644
index 000000000..864651bf4
--- /dev/null
+++ b/lib/crates/fabro-types/src/settings/environment.rs
@@ -0,0 +1,241 @@
+//! Environment domain.
+//!
+//! A named environment is reusable desired configuration that describes how
+//! a run should be executed: which provider, what image, what resources,
+//! what network policy, lifecycle, labels, volumes, and environment
+//! variables. Environments live in the top-level `[environments.<slug>]`
+//! catalog and a run selects one via `[run.environment].id`.
+//!
+//! These dense types are the resolved view consumed by the workflow engine
+//! and server preflight. Sparse, layer-mergeable counterparts live in
+//! `fabro_config::layers::environment`.
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::duration::Duration;
+use super::interp::InterpString;
+use super::run::DockerfileSource;
+use super::size::Size;
+
+/// A resolved, named environment.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct EnvironmentSettings {
+ pub provider: EnvironmentProvider,
+ #[serde(default, skip_serializing_if = "EnvironmentImageSettings::is_empty")]
+ pub image: EnvironmentImageSettings,
+ #[serde(default, skip_serializing_if = "EnvironmentResourcesSettings::is_empty")]
+ pub resources: EnvironmentResourcesSettings,
+ #[serde(default, skip_serializing_if = "EnvironmentNetworkSettings::is_default")]
+ pub network: EnvironmentNetworkSettings,
+ #[serde(default, skip_serializing_if = "EnvironmentLifecycleSettings::is_default")]
+ pub lifecycle: EnvironmentLifecycleSettings,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub labels: HashMap<String, String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub volumes: Vec<EnvironmentVolumeSettings>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub env: HashMap<String, InterpString>,
+}
+
+impl Default for EnvironmentSettings {
+ fn default() -> Self {
+ Self {
+ provider: EnvironmentProvider::Local,
+ image: EnvironmentImageSettings::default(),
+ resources: EnvironmentResourcesSettings::default(),
+ network: EnvironmentNetworkSettings::default(),
+ lifecycle: EnvironmentLifecycleSettings::default(),
+ labels: HashMap::new(),
+ volumes: Vec::new(),
+ env: HashMap::new(),
+ }
+ }
+}
+
+/// The runtime provider responsible for materializing an environment.
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ PartialEq,
+ Eq,
+ Serialize,
+ Deserialize,
+ strum::Display,
+ strum::EnumString,
+ strum::IntoStaticStr,
+)]
+#[serde(rename_all = "snake_case")]
+#[strum(serialize_all = "snake_case")]
+pub enum EnvironmentProvider {
+ Local,
+ Docker,
+ Daytona,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct EnvironmentImageSettings {
+ /// Provider-native image reference (Docker image tag or Daytona
+ /// snapshot name).
+ #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
+ pub image_ref: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub dockerfile: Option<DockerfileSource>,
+}
+
+impl EnvironmentImageSettings {
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.image_ref.is_none() && self.dockerfile.is_none()
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct EnvironmentResourcesSettings {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cpu: Option<i32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub memory: Option<Size>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub disk: Option<Size>,
+}
+
+impl EnvironmentResourcesSettings {
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.cpu.is_none() && self.memory.is_none() && self.disk.is_none()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct EnvironmentNetworkSettings {
+ pub mode: EnvironmentNetworkMode,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub allow: Vec<String>,
+}
+
+impl Default for EnvironmentNetworkSettings {
+ fn default() -> Self {
+ Self {
+ mode: EnvironmentNetworkMode::AllowAll,
+ allow: Vec::new(),
+ }
+ }
+}
+
+impl EnvironmentNetworkSettings {
+ #[must_use]
+ pub fn is_default(&self) -> bool {
+ matches!(self.mode, EnvironmentNetworkMode::AllowAll) && self.allow.is_empty()
+ }
+}
+
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ PartialEq,
+ Eq,
+ Serialize,
+ Deserialize,
+ strum::Display,
+ strum::EnumString,
+ strum::IntoStaticStr,
+)]
+#[serde(rename_all = "snake_case")]
+#[strum(serialize_all = "snake_case")]
+pub enum EnvironmentNetworkMode {
+ AllowAll,
+ Block,
+ CidrAllowList,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct EnvironmentLifecycleSettings {
+ pub preserve: bool,
+ pub stop_on_terminal: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub auto_stop: Option<Duration>,
+}
+
+impl Default for EnvironmentLifecycleSettings {
+ fn default() -> Self {
+ Self {
+ preserve: false,
+ stop_on_terminal: true,
+ auto_stop: None,
+ }
+ }
+}
+
+impl EnvironmentLifecycleSettings {
+ #[must_use]
+ pub fn is_default(&self) -> bool {
+ !self.preserve && self.stop_on_terminal && self.auto_stop.is_none()
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct EnvironmentVolumeSettings {
+ pub id: String,
+ pub mount_path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub subpath: Option<String>,
+}
+
+/// Resolved `[run.environment]` selection plus selected environment.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct RunEnvironmentSettings {
+ /// Slug of the selected environment in the workflow's environment
+ /// catalog. Always populated for resolved runs.
+ pub id: String,
+ /// The fully resolved, overlay-applied environment for the run.
+ #[serde(flatten)]
+ pub environment: EnvironmentSettings,
+}
+
+impl Default for RunEnvironmentSettings {
+ fn default() -> Self {
+ Self {
+ id: "default".to_string(),
+ environment: EnvironmentSettings::default(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn environment_provider_round_trips_via_strum() {
+ assert_eq!(EnvironmentProvider::Docker.to_string(), "docker");
+ assert_eq!(
+ "daytona".parse::<EnvironmentProvider>().unwrap(),
+ EnvironmentProvider::Daytona
+ );
+ }
+
+ #[test]
+ fn network_mode_serializes_snake_case() {
+ let mode = EnvironmentNetworkMode::CidrAllowList;
+ let json = serde_json::to_string(&mode).unwrap();
+ assert_eq!(json, "\"cidr_allow_list\"");
+ }
+
+ #[test]
+ fn lifecycle_defaults_match_plan() {
+ let lifecycle = EnvironmentLifecycleSettings::default();
+ assert!(!lifecycle.preserve);
+ assert!(lifecycle.stop_on_terminal);
+ assert!(lifecycle.auto_stop.is_none());
+ }
+
+ #[test]
+ fn environment_settings_default_uses_local_provider() {
+ let env = EnvironmentSettings::default();
+ assert_eq!(env.provider, EnvironmentProvider::Local);
+ }
+}
diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs
index 9b9d680cd..08e86b106 100644
--- a/lib/crates/fabro-types/src/settings/mod.rs
+++ b/lib/crates/fabro-types/src/settings/mod.rs
@@ -11,6 +11,7 @@
pub mod cli;
pub mod duration;
+pub mod environment;
pub mod interp;
pub mod model_ref;
pub mod project;
@@ -25,6 +26,11 @@ pub use cli::{
CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings,
};
pub use duration::{Duration, ParseDurationError};
+pub use environment::{
+ EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkMode,
+ EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
+ EnvironmentSettings, EnvironmentVolumeSettings, RunEnvironmentSettings,
+};
pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved};
pub use model_ref::{
AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef,
@@ -51,4 +57,4 @@ pub use server::{
ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
};
pub use size::{ParseSizeError, Size};
-pub use workflow::WorkflowNamespace;
+pub use workflow::WorkflowNamespace;
\ No newline at end of file
diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs
index 80c890f9e..8a4f1c3d4 100644
--- a/lib/crates/fabro-types/src/settings/run.rs
+++ b/lib/crates/fabro-types/src/settings/run.rs
@@ -13,6 +13,7 @@ use std::time::Duration as StdDuration;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
+use super::environment::EnvironmentSettings;
use super::interp::InterpString;
use super::model_ref::ModelRef;
@@ -31,6 +32,15 @@ pub struct RunNamespace {
pub clone: RunCloneSettings,
pub run_branch: RunBranchSettings,
pub meta_branch: RunMetaBranchSettings,
+ /// Slug of the selected environment from the top-level
+ /// `[environments]` catalog. Resolved together with `environment`.
+ pub environment_id: String,
+ /// Fully resolved environment for the run (provider + image + resources +
+ /// network + lifecycle + labels + volumes + env).
+ pub environment: EnvironmentSettings,
+ /// Deprecated provider-tagged sandbox view. Populated during the
+ /// transition while consumers migrate to `environment`. New callers
+ /// should read `environment` instead.
pub sandbox: RunSandboxSettings,
pub notifications: HashMap<String, NotificationRouteSettings>,
pub interviews: RunInterviewsSettings,
@@ -61,6 +71,8 @@ impl Default for RunNamespace {
clone: RunCloneSettings::default(),
run_branch: RunBranchSettings::default(),
meta_branch: RunMetaBranchSettings::default(),
+ environment_id: "default".to_string(),
+ environment: EnvironmentSettings::default(),
sandbox: RunSandboxSettings::default(),
notifications: HashMap::new(),
interviews: RunInterviewsSettings::default(),
@@ -763,4 +775,4 @@ pub enum MergeStrategy {
Merge,
Squash,
Rebase,
-}
+}
\ No newline at end of file

View file

@ -0,0 +1,6 @@
{
"outcome": "failed",
"notes": null,
"failure_reason": "LLM error: Stream error: error decoding response body",
"timestamp": "2026-05-23T00:01:43.105547Z"
}

View file

@ -0,0 +1,231 @@
Goal: # Named Environments Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace run-scoped sandbox configuration with named, provider-explicit environments that runs can select by slug.
**Architecture:** Add a shared top-level environment catalog, resolve a selected environment into the run's dense settings, validate provider capabilities, and convert the resolved environment into the existing sandbox runtime specs. Keep "environment" as reusable desired configuration and "sandbox" as the concrete runtime instance created for a run.
**Tech Stack:** Rust config/types crates, TOML settings layers, Fabro workflow sandbox providers, OpenAPI-generated clients, public docs.
---
## Summary
Replace run-scoped sandbox configuration with named, provider-explicit environments. A run selects an environment by slug via `[run.environment] id = "..."`; Fabro resolves the environment catalog through normal config precedence, applies run-level environment overrides, validates provider capabilities, freezes the resolved environment into the run settings, and creates a concrete sandbox instance from it.
This is a greenfield break: no `[run.sandbox]` compatibility layer, no server policy layer, and no required/optional volume semantics.
## Key Interface Changes
- Add top-level `[environments.<slug>]` to the shared settings schema. It is valid in `settings.toml`, `.fabro/project.toml`, and `workflow.toml`.
- Replace sandbox selection with:
```toml
[run.environment]
id = "fabro-dev"
```
- Allow sparse run-level overrides under the same table:
```toml
[run.environment.resources]
memory = "32GB"
[run.environment.lifecycle]
preserve = true
```
- Environment shape:
```toml
[environments.fabro-dev]
provider = "daytona" # local | docker | daytona
[environments.fabro-dev.image]
ref = "fabro-v11" # Docker image or Daytona snapshot name
dockerfile = { path = "Dockerfile" }
[environments.fabro-dev.resources]
cpu = 8
memory = "16GB"
disk = "20GB"
[environments.fabro-dev.network]
mode = "block" # allow_all | block | cidr_allow_list
allow = ["10.0.0.0/8"]
[environments.fabro-dev.lifecycle]
preserve = false
stop_on_terminal = true
auto_stop = "30m"
[environments.fabro-dev.labels]
repo = "fabro-sh/fabro"
[[environments.fabro-dev.volumes]]
id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "auth"
[environments.fabro-dev.env]
NODE_ENV = "development"
```
- Built-in default becomes:
```toml
[run.environment]
id = "default"
[environments.default]
provider = "docker"
[environments.default.image]
ref = "buildpack-deps:noble"
[environments.default.resources]
cpu = 2
memory = "4GB"
[environments.default.lifecycle]
preserve = false
stop_on_terminal = true
```
## Implementation Changes
- Add environment sparse and dense types:
- Sparse layer in `fabro-config` for `EnvironmentLayer`, `RunEnvironmentLayer`, image/resources/network/lifecycle/volume sublayers, and `[environments]` as a `MergeMap`.
- Dense types in `fabro-types` for `EnvironmentSettings`, `RunEnvironmentSettings`, `EnvironmentProvider`, `EnvironmentNetworkMode`, and related subsettings.
- Add `environments` to the top-level `SettingsLayer` and resolved `WorkflowSettings`; add selected `environment` to `RunNamespace`.
- Resolve environments before run consumers use sandbox data:
- Merge environment definitions by slug.
- Resolve `[run.environment].id`; error if the slug is missing.
- Overlay sparse `[run.environment.*]` fields onto the selected environment.
- Validate provider is `local`, `docker`, or `daytona`.
- Validate CIDRs with existing `ipnet`.
- Store the selected resolved environment in `RunNamespace.environment`.
- Replace sandbox runtime mapping:
- Convert `RunNamespace.environment` to `SandboxSpec` in workflow start and server preflight paths.
- Daytona: `image.ref` maps to snapshot name, `dockerfile` to snapshot Dockerfile, resources to snapshot sizing, network to Daytona policy, labels/volumes/env/lifecycle to existing provider fields.
- Docker: `image.ref` maps to Docker image, `cpu` maps to `cpu_quota = cpu * 100000`, memory maps to memory limit, `network.mode = block` maps to `network_mode = none`, `allow_all` maps to default/bridge.
- Local: use resolved working directory; env overlays process env as today.
- Capability diagnostics:
- Hard error for explicit security/isolation properties a provider cannot enforce:
- local with `network.mode = block` or `cidr_allow_list`
- docker with `network.mode = cidr_allow_list`
- Warnings only for unsupported resource limits, volumes, labels, `auto_stop`, and Docker `image.dockerfile`.
- If Daytona has `image.dockerfile` without `image.ref`, error because snapshot creation needs a name.
- Remove old sandbox config surface:
- Delete `[run.sandbox]` parsing/resolution/types from user-facing config.
- Replace CLI/API/tool manifest args named `sandbox` with `environment` where they select execution profile.
- Keep runtime/public "sandbox" terminology only for concrete instances, e.g. `fabro sandbox ssh`, `RunSandbox`, sandbox details.
- Update docs and generated clients:
- Update run configuration, environments, Daytona, server configuration, CLI reference, and OpenAPI spec.
- Regenerate Rust API types/client and TypeScript API client after OpenAPI changes.
## Test Plan
- Config tests:
- default resolves to `run.environment.id = "default"` and Docker environment settings.
- project/workflow/run layers merge environment catalog by slug.
- `[run.environment]` overrides selected environment fields.
- `env` and `labels` merge by key; `volumes` replace wholesale.
- missing environment slug errors.
- old `[run.sandbox]` is rejected as an unknown field.
- Provider mapping tests:
- Daytona environment maps to snapshot/resources/network/labels/volumes/env.
- Docker environment maps image, CPU, memory, network block, and env.
- Local environment ignores non-security unsupported fields with warnings.
- Validation tests:
- docker plus CIDR allow-list errors.
- local plus blocked network errors.
- resource limits unsupported by provider produce warnings, not errors.
- volumes unsupported by provider produce warnings, not errors.
- Daytona dockerfile without image ref errors.
- Integration/API tests:
- run manifest with `[environments.<slug>]` and `[run.environment]` starts with the selected provider.
- Dockerfile path bundling works from environment image config.
- preflight reports capability warnings and security errors.
- CLI/API `environment` override wins over config selection.
## Assumptions
- No compatibility behavior is required for `[run.sandbox]` or `--sandbox`.
- No server-side environment policy or quota enforcement is in scope.
- Volumes are simple provider hints; unsupported volume config warns and continues.
- Resource limits are best-effort hints; unsupported resource fields warn and continue.
- Provider names remain explicit for now: `local`, `docker`, and `daytona`.
## Completed stages
- **toolchain**: succeeded
- Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1`
- Output:
```
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
```
- **preflight_compile**: succeeded
- Script: `cargo check -q --workspace 2>&1`
- Output: (empty)
- **preflight_lint**: succeeded
- Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1`
- Output: (empty)
- **implement**: failed
## Context
- failure_class: transient_infra
- failure_signature: implement|transient_infra|api_transient|unknown|stream
# Simplify: Code Review and Cleanup
Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found.
## Phase 1: Identify Changes
Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.
## Phase 2: Launch Three Review Agents in Parallel
Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
### Agent 1: Code Reuse Review
For each change:
1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.
2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.
3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.
Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.
### Agent 2: Code Quality Review
Review the same changes for hacky patterns:
1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls
2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones
3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction
4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
Note: This is a greenfield app, so be aggressive in optimizing quality.
### Agent 3: Efficiency Review
Review the same changes for efficiency:
1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns
2. Missed concurrency: independent operations run sequentially when they could run in parallel
3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths
4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error
5. Memory: unbounded data structures, missing cleanup, event listener leaks
6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one
## Phase 3: Fix Issues
Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.
When done, briefly summarize what was fixed (or confirm the code was already clean).

View file

@ -0,0 +1,5 @@
{
"mode": "agent",
"provider": "anthropic",
"model": "claude-opus-4-7"
}

View file

@ -0,0 +1,15 @@
Tree is clean, builds, and lints. Done.
## Summary
The `implement` stage failed mid-way and committed a small partial state (only added type definitions). I launched three parallel review agents (reuse / quality / efficiency) on the diff. Findings were consistent and serious:
| Axis | Top findings |
|---|---|
| **Reuse** | `EnvironmentProvider` duplicates `SandboxProvider`; `EnvironmentVolumeLayer` duplicates `DaytonaVolumeLayer`; `EnvironmentVolumeLayer` and `EnvironmentVolumeSettings` are identical; `EnvironmentNetworkSettings` regresses on `DaytonaNetworkLayer`'s tagged union; new `Combine for Option<Vec<…>>` impl bypasses the existing `impl_combine_or_option!` macro. |
| **Quality** | Code doesn't compile (`fabro_macros::Combine` derived on structs whose field types lack `Combine`/`SpliceMarker`); `RunEnvironmentSettings` dead-code; `RunNamespace` carries triple state (`environment_id`, `environment`, legacy `sandbox`) violating the plan's "no compat layer" rule; `EnvironmentLayer` and `RunEnvironmentLayer` are 95% copy-paste; unused imports; trailing newlines stripped from 8 files. |
| **Efficiency** | Three parallel sandbox representations on every `WorkflowSettings::default()`; `EnvironmentSettings` cloned twice (in catalog and inline); `to_environment_overlay()` clones unnecessarily; new `String::from("default")` heap-allocates on every default. |
Given the partial work was structurally flawed across all three axes and didn't compile, the simplest correct cleanup was to revert to the last known-good state (`preflight_compile` and `preflight_lint` passing). The revert commit message captures the findings so the next `implement` attempt can use them as design guidance.
**Result:** `cargo check --workspace` and `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` both pass clean.