mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
parent
7d11a76e2a
commit
502bb318f7
6 changed files with 2306 additions and 21 deletions
347
run.json
347
run.json
File diff suppressed because one or more lines are too long
992
stages/005-implement@1/diff.patch
Normal file
992
stages/005-implement@1/diff.patch
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
diff --git a/Cargo.lock b/Cargo.lock
|
||||
index c50b6c665..bddde03c4 100644
|
||||
--- a/Cargo.lock
|
||||
+++ b/Cargo.lock
|
||||
@@ -1859,6 +1859,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.8.23",
|
||||
+ "toml_edit",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ulid",
|
||||
diff --git a/docs/public/changelog/2026-05-23.mdx b/docs/public/changelog/2026-05-23.mdx
|
||||
new file mode 100644
|
||||
index 000000000..a5c1843ad
|
||||
--- /dev/null
|
||||
+++ b/docs/public/changelog/2026-05-23.mdx
|
||||
@@ -0,0 +1,8 @@
|
||||
+---
|
||||
+title: "Legacy sandbox config migration"
|
||||
+date: "2026-05-23"
|
||||
+---
|
||||
+
|
||||
+## Legacy sandbox config auto-migration
|
||||
+
|
||||
+Fabro now temporarily rewrites confidently migratable pre-v1.0 `[run.sandbox]` config files to the named environment syntax. A backup is written next to the original file before rewriting. Ambiguous or unsupported legacy keys fail with a targeted migration message instead of the generic TOML unknown-field error.
|
||||
diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx
|
||||
index c75686e24..b45c4eb74 100644
|
||||
--- a/docs/public/execution/environments.mdx
|
||||
+++ b/docs/public/execution/environments.mdx
|
||||
@@ -8,6 +8,12 @@ Fabro separates **environments** from **sandboxes**:
|
||||
- An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, volumes, and environment variables.
|
||||
- A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment.
|
||||
|
||||
+<Warning>
|
||||
+Older pre-v1.0 config files that still use `[run.sandbox]` are temporarily auto-migrated when Fabro loads them from disk. Fabro writes a sibling `*.legacy-sandbox-migration.bak` file, rewrites the config to `[run.environment]` plus `[environments.default]`, and then continues startup.
|
||||
+
|
||||
+This compatibility rewrite only handles direct field mappings. Unsupported legacy fields fail with a migration message that lists the keys to edit manually. The rewrite path will be removed before v1.0.
|
||||
+</Warning>
|
||||
+
|
||||
Runs select environments by slug:
|
||||
|
||||
```toml title="workflow.toml"
|
||||
diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml
|
||||
index 7e8290f6a..feb448314 100644
|
||||
--- a/lib/crates/fabro-config/Cargo.toml
|
||||
+++ b/lib/crates/fabro-config/Cargo.toml
|
||||
@@ -34,6 +34,7 @@ serde_json.workspace = true
|
||||
strsim = "0.11"
|
||||
tempfile = "3"
|
||||
toml.workspace = true
|
||||
+toml_edit.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
thiserror.workspace = true
|
||||
diff --git a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs
|
||||
new file mode 100644
|
||||
index 000000000..a8d428929
|
||||
--- /dev/null
|
||||
+++ b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs
|
||||
@@ -0,0 +1,799 @@
|
||||
+#![expect(
|
||||
+ clippy::disallowed_methods,
|
||||
+ reason = "temporary startup config migration uses synchronous file I/O before config is loaded"
|
||||
+)]
|
||||
+
|
||||
+use std::fmt;
|
||||
+use std::path::{Path, PathBuf};
|
||||
+
|
||||
+use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value};
|
||||
+
|
||||
+use crate::{Error, Result, SettingsLayer};
|
||||
+
|
||||
+pub(crate) const REMOVAL_NOTE: &str =
|
||||
+ "This temporary compatibility migration will be removed before v1.0.";
|
||||
+
|
||||
+#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
+pub(crate) struct LegacySandboxMigrationReport {
|
||||
+ pub(crate) contents: String,
|
||||
+ pub(crate) backup_path: PathBuf,
|
||||
+ pub(crate) warning: String,
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
+struct MigrationFailure {
|
||||
+ unsupported_keys: Vec<String>,
|
||||
+}
|
||||
+
|
||||
+impl fmt::Display for MigrationFailure {
|
||||
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
+ writeln!(f, "Unsupported keys:")?;
|
||||
+ for key in &self.unsupported_keys {
|
||||
+ writeln!(f, " - {key}")?;
|
||||
+ }
|
||||
+ writeln!(f)?;
|
||||
+ write!(
|
||||
+ f,
|
||||
+ "Rename legacy sandbox configuration to [run.environment] and [environments.<slug>]. See docs/public/execution/environments.mdx."
|
||||
+ )
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+pub(crate) fn migrate_settings_path(
|
||||
+ path: &Path,
|
||||
+ original_contents: &str,
|
||||
+) -> Result<Option<LegacySandboxMigrationReport>> {
|
||||
+ let Some(next_contents) = migrate_contents(original_contents, path)? else {
|
||||
+ return Ok(None);
|
||||
+ };
|
||||
+
|
||||
+ next_contents
|
||||
+ .parse::<SettingsLayer>()
|
||||
+ .map_err(|err| Error::parse_file("Migrated settings file is invalid", path, err))?;
|
||||
+
|
||||
+ let backup_path = next_backup_path(path);
|
||||
+ std::fs::write(&backup_path, original_contents).map_err(|source| {
|
||||
+ Error::other(format!(
|
||||
+ "writing legacy sandbox migration backup {}: {source}",
|
||||
+ backup_path.display()
|
||||
+ ))
|
||||
+ })?;
|
||||
+ std::fs::write(path, &next_contents).map_err(|source| {
|
||||
+ Error::other(format!(
|
||||
+ "writing migrated settings file {}: {source}",
|
||||
+ path.display()
|
||||
+ ))
|
||||
+ })?;
|
||||
+
|
||||
+ let warning = format!(
|
||||
+ "Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.default]. Backup written to {}. {REMOVAL_NOTE}",
|
||||
+ path.display(),
|
||||
+ backup_path.display()
|
||||
+ );
|
||||
+
|
||||
+ Ok(Some(LegacySandboxMigrationReport {
|
||||
+ contents: next_contents,
|
||||
+ backup_path,
|
||||
+ warning,
|
||||
+ }))
|
||||
+}
|
||||
+
|
||||
+fn migrate_contents(original_contents: &str, path: &Path) -> Result<Option<String>> {
|
||||
+ let Ok(mut doc) = original_contents.parse::<DocumentMut>() else {
|
||||
+ return Ok(None);
|
||||
+ };
|
||||
+
|
||||
+ if !has_legacy_run_sandbox(&doc) {
|
||||
+ return Ok(None);
|
||||
+ }
|
||||
+ if has_new_environment_config(&doc) {
|
||||
+ return Err(Error::other(format!(
|
||||
+ "Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments.default]. Remove one config style and retry.",
|
||||
+ path.display()
|
||||
+ )));
|
||||
+ }
|
||||
+
|
||||
+ migrate_document(&mut doc).map_err(|failure| {
|
||||
+ Error::other(format!(
|
||||
+ "Legacy [run.sandbox] settings in {} could not be auto-migrated.\n\n{}",
|
||||
+ path.display(),
|
||||
+ failure
|
||||
+ ))
|
||||
+ })?;
|
||||
+
|
||||
+ Ok(Some(doc.to_string()))
|
||||
+}
|
||||
+
|
||||
+fn has_legacy_run_sandbox(doc: &DocumentMut) -> bool {
|
||||
+ doc.get("run")
|
||||
+ .and_then(Item::as_table)
|
||||
+ .and_then(|run| run.get("sandbox"))
|
||||
+ .is_some()
|
||||
+}
|
||||
+
|
||||
+fn has_new_environment_config(doc: &DocumentMut) -> bool {
|
||||
+ let has_run_environment = doc
|
||||
+ .get("run")
|
||||
+ .and_then(Item::as_table)
|
||||
+ .and_then(|run| run.get("environment"))
|
||||
+ .is_some();
|
||||
+ let has_default_environment = doc
|
||||
+ .get("environments")
|
||||
+ .and_then(Item::as_table)
|
||||
+ .and_then(|envs| envs.get("default"))
|
||||
+ .is_some();
|
||||
+ has_run_environment || has_default_environment
|
||||
+}
|
||||
+
|
||||
+fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {
|
||||
+ let Some(sandbox_item) = doc
|
||||
+ .get("run")
|
||||
+ .and_then(Item::as_table)
|
||||
+ .and_then(|run| run.get("sandbox"))
|
||||
+ else {
|
||||
+ return Ok(());
|
||||
+ };
|
||||
+ let Some(sandbox) = sandbox_item.as_table().cloned() else {
|
||||
+ return Err(MigrationFailure {
|
||||
+ unsupported_keys: vec!["run.sandbox".to_string()],
|
||||
+ });
|
||||
+ };
|
||||
+
|
||||
+ let mut unsupported = Vec::new();
|
||||
+ for (key, item) in &sandbox {
|
||||
+ if !matches!(key, "provider" | "preserve" | "env" | "daytona" | "docker") {
|
||||
+ item_path_keys(&format!("run.sandbox.{key}"), item, &mut unsupported);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let provider = sandbox.get("provider").and_then(Item::as_str);
|
||||
+ let provider_key = provider.unwrap_or_default().to_ascii_lowercase();
|
||||
+ if provider.is_none() {
|
||||
+ unsupported.push("run.sandbox.provider".to_string());
|
||||
+ }
|
||||
+
|
||||
+ match provider_key.as_str() {
|
||||
+ "daytona" => {
|
||||
+ migrate_skip_clone(doc, &sandbox, "daytona");
|
||||
+ reject_provider_table(&sandbox, "docker", &mut unsupported);
|
||||
+ }
|
||||
+ "docker" => {
|
||||
+ migrate_skip_clone(doc, &sandbox, "docker");
|
||||
+ reject_provider_table(&sandbox, "daytona", &mut unsupported);
|
||||
+ }
|
||||
+ _ => {
|
||||
+ reject_provider_table(&sandbox, "daytona", &mut unsupported);
|
||||
+ reject_provider_table(&sandbox, "docker", &mut unsupported);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ set_value(
|
||||
+ path_table(doc, &["run", "environment"]),
|
||||
+ "id",
|
||||
+ Value::from("default"),
|
||||
+ );
|
||||
+ if let Some(provider) = provider {
|
||||
+ set_value(
|
||||
+ path_table(doc, &["environments", "default"]),
|
||||
+ "provider",
|
||||
+ Value::from(provider),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ let env = path_table(doc, &["environments", "default"]);
|
||||
+ if let Some(preserve) = sandbox.get("preserve") {
|
||||
+ if preserve.as_bool().is_some() {
|
||||
+ set_item(
|
||||
+ path_table_in_table(env, &["lifecycle"]),
|
||||
+ "preserve",
|
||||
+ preserve.clone(),
|
||||
+ );
|
||||
+ } else {
|
||||
+ unsupported.push("run.sandbox.preserve".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ if let Some(env_item) = sandbox.get("env") {
|
||||
+ if is_table_like(env_item) {
|
||||
+ copy_table(env_item, path_table_in_table(env, &["env"]));
|
||||
+ } else {
|
||||
+ unsupported.push("run.sandbox.env".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ match provider_key.as_str() {
|
||||
+ "daytona" => migrate_daytona(&sandbox, env, &mut unsupported),
|
||||
+ "docker" => migrate_docker(&sandbox, env, &mut unsupported),
|
||||
+ _ => {}
|
||||
+ }
|
||||
+
|
||||
+ if !unsupported.is_empty() {
|
||||
+ return Err(MigrationFailure {
|
||||
+ unsupported_keys: unsupported,
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ remove_run_sandbox(doc);
|
||||
+ Ok(())
|
||||
+}
|
||||
+
|
||||
+fn migrate_skip_clone(doc: &mut DocumentMut, sandbox: &Table, provider: &str) {
|
||||
+ let Some(provider_table) = sandbox.get(provider).and_then(Item::as_table) else {
|
||||
+ return;
|
||||
+ };
|
||||
+ if provider_table
|
||||
+ .get("skip_clone")
|
||||
+ .and_then(Item::as_bool)
|
||||
+ .unwrap_or(false)
|
||||
+ {
|
||||
+ set_value(
|
||||
+ path_table(doc, &["run", "clone"]),
|
||||
+ "enabled",
|
||||
+ Value::from(false),
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn reject_provider_table(sandbox: &Table, provider: &str, unsupported: &mut Vec<String>) {
|
||||
+ if let Some(item) = sandbox.get(provider) {
|
||||
+ item_path_keys(&format!("run.sandbox.{provider}"), item, unsupported);
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String>) {
|
||||
+ let Some(daytona_item) = sandbox.get("daytona") else {
|
||||
+ return;
|
||||
+ };
|
||||
+ let Some(daytona) = daytona_item.as_table() else {
|
||||
+ unsupported.push("run.sandbox.daytona".to_string());
|
||||
+ return;
|
||||
+ };
|
||||
+
|
||||
+ for (key, item) in daytona {
|
||||
+ match key {
|
||||
+ "skip_clone" => {
|
||||
+ if item.as_bool() != Some(true) {
|
||||
+ unsupported.push("run.sandbox.daytona.skip_clone".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ "auto_stop_interval" => {
|
||||
+ if let Some(minutes) = item.as_integer().filter(|minutes| *minutes >= 0) {
|
||||
+ set_value(
|
||||
+ path_table_in_table(env, &["lifecycle"]),
|
||||
+ "auto_stop",
|
||||
+ Value::from(format!("{minutes}m")),
|
||||
+ );
|
||||
+ } else {
|
||||
+ unsupported.push("run.sandbox.daytona.auto_stop_interval".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ "labels" => {
|
||||
+ if is_table_like(item) {
|
||||
+ copy_table(item, path_table_in_table(env, &["labels"]));
|
||||
+ } else {
|
||||
+ unsupported.push("run.sandbox.daytona.labels".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ "snapshot" => migrate_daytona_snapshot(item, env, unsupported),
|
||||
+ "volumes" => copy_array_of_tables_with_volume_id(item, env, unsupported),
|
||||
+ _ => item_path_keys(&format!("run.sandbox.daytona.{key}"), item, unsupported),
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported: &mut Vec<String>) {
|
||||
+ let Some(snapshot) = snapshot_item.as_table() else {
|
||||
+ unsupported.push("run.sandbox.daytona.snapshot".to_string());
|
||||
+ return;
|
||||
+ };
|
||||
+
|
||||
+ for (key, item) in snapshot {
|
||||
+ match key {
|
||||
+ "name" => set_item(path_table_in_table(env, &["image"]), "ref", item.clone()),
|
||||
+ "cpu" => set_item(
|
||||
+ path_table_in_table(env, &["resources"]),
|
||||
+ "cpu",
|
||||
+ item.clone(),
|
||||
+ ),
|
||||
+ "memory" => set_item(
|
||||
+ path_table_in_table(env, &["resources"]),
|
||||
+ "memory",
|
||||
+ item.clone(),
|
||||
+ ),
|
||||
+ "disk" => set_item(
|
||||
+ path_table_in_table(env, &["resources"]),
|
||||
+ "disk",
|
||||
+ item.clone(),
|
||||
+ ),
|
||||
+ "dockerfile" => set_item(
|
||||
+ path_table_in_table(env, &["image"]),
|
||||
+ "dockerfile",
|
||||
+ item.clone(),
|
||||
+ ),
|
||||
+ _ => item_path_keys(
|
||||
+ &format!("run.sandbox.daytona.snapshot.{key}"),
|
||||
+ item,
|
||||
+ unsupported,
|
||||
+ ),
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String>) {
|
||||
+ let Some(docker_item) = sandbox.get("docker") else {
|
||||
+ return;
|
||||
+ };
|
||||
+ let Some(docker) = docker_item.as_table() else {
|
||||
+ unsupported.push("run.sandbox.docker".to_string());
|
||||
+ return;
|
||||
+ };
|
||||
+
|
||||
+ for (key, item) in docker {
|
||||
+ match key {
|
||||
+ "skip_clone" => {
|
||||
+ if item.as_bool() != Some(true) {
|
||||
+ unsupported.push("run.sandbox.docker.skip_clone".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ "image" => set_item(path_table_in_table(env, &["image"]), "ref", item.clone()),
|
||||
+ "memory_limit" => set_item(
|
||||
+ path_table_in_table(env, &["resources"]),
|
||||
+ "memory",
|
||||
+ item.clone(),
|
||||
+ ),
|
||||
+ "cpu_quota" => {
|
||||
+ if let Some(cpu_quota) = item.as_integer() {
|
||||
+ let cpu_count = cpu_quota / 100_000;
|
||||
+ if cpu_quota > 0 && cpu_quota % 100_000 == 0 && i32::try_from(cpu_count).is_ok()
|
||||
+ {
|
||||
+ set_value(
|
||||
+ path_table_in_table(env, &["resources"]),
|
||||
+ "cpu",
|
||||
+ Value::from(cpu_count),
|
||||
+ );
|
||||
+ } else {
|
||||
+ unsupported.push("run.sandbox.docker.cpu_quota".to_string());
|
||||
+ }
|
||||
+ } else {
|
||||
+ unsupported.push("run.sandbox.docker.cpu_quota".to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ _ => item_path_keys(&format!("run.sandbox.docker.{key}"), item, unsupported),
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn path_table<'a>(doc: &'a mut DocumentMut, path: &[&str]) -> &'a mut Table {
|
||||
+ let mut item = doc.as_item_mut();
|
||||
+ for segment in path {
|
||||
+ item = &mut item[segment];
|
||||
+ if !item.is_table() {
|
||||
+ *item = Item::Table(Table::new());
|
||||
+ }
|
||||
+ }
|
||||
+ item.as_table_mut().expect("path item should be a table")
|
||||
+}
|
||||
+
|
||||
+fn path_table_in_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut Table {
|
||||
+ let mut table = table;
|
||||
+ for segment in path {
|
||||
+ let item = &mut table[segment];
|
||||
+ if !item.is_table() {
|
||||
+ *item = Item::Table(Table::new());
|
||||
+ }
|
||||
+ table = item.as_table_mut().expect("path item should be a table");
|
||||
+ }
|
||||
+ table
|
||||
+}
|
||||
+
|
||||
+fn set_value(table: &mut Table, key: &str, value: Value) {
|
||||
+ table[key] = Item::Value(value);
|
||||
+}
|
||||
+
|
||||
+fn set_item(table: &mut Table, key: &str, item: Item) {
|
||||
+ table[key] = item;
|
||||
+}
|
||||
+
|
||||
+fn is_table_like(item: &Item) -> bool {
|
||||
+ item.is_table() || item.as_value().and_then(Value::as_inline_table).is_some()
|
||||
+}
|
||||
+
|
||||
+fn copy_table(source: &Item, target: &mut Table) {
|
||||
+ if let Some(table) = source.as_table() {
|
||||
+ for (key, item) in table {
|
||||
+ target[key] = item.clone();
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ if let Some(inline_table) = source.as_value().and_then(Value::as_inline_table) {
|
||||
+ for (key, value) in inline_table {
|
||||
+ target[key] = Item::Value(value.clone());
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn copy_array_of_tables_with_volume_id(
|
||||
+ source: &Item,
|
||||
+ target: &mut Table,
|
||||
+ unsupported: &mut Vec<String>,
|
||||
+) {
|
||||
+ let Some(volumes) = source.as_array_of_tables() else {
|
||||
+ unsupported.push("run.sandbox.daytona.volumes".to_string());
|
||||
+ return;
|
||||
+ };
|
||||
+
|
||||
+ let mut migrated = ArrayOfTables::new();
|
||||
+ for volume in volumes {
|
||||
+ let mut migrated_volume = Table::new();
|
||||
+ let mut has_id = false;
|
||||
+ let mut has_mount_path = false;
|
||||
+ for (key, item) in volume {
|
||||
+ match key {
|
||||
+ "volume_id" => {
|
||||
+ has_id = true;
|
||||
+ set_item(&mut migrated_volume, "id", item.clone());
|
||||
+ }
|
||||
+ "mount_path" => {
|
||||
+ has_mount_path = true;
|
||||
+ set_item(&mut migrated_volume, "mount_path", item.clone());
|
||||
+ }
|
||||
+ "subpath" => set_item(&mut migrated_volume, "subpath", item.clone()),
|
||||
+ _ => item_path_keys(
|
||||
+ &format!("run.sandbox.daytona.volumes.{key}"),
|
||||
+ item,
|
||||
+ unsupported,
|
||||
+ ),
|
||||
+ }
|
||||
+ }
|
||||
+ if !has_id {
|
||||
+ unsupported.push("run.sandbox.daytona.volumes.volume_id".to_string());
|
||||
+ }
|
||||
+ if !has_mount_path {
|
||||
+ unsupported.push("run.sandbox.daytona.volumes.mount_path".to_string());
|
||||
+ }
|
||||
+ migrated.push(migrated_volume);
|
||||
+ }
|
||||
+ target["volumes"] = Item::ArrayOfTables(migrated);
|
||||
+}
|
||||
+
|
||||
+fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec<String>) {
|
||||
+ if let Some(table) = item.as_table() {
|
||||
+ if table.is_empty() {
|
||||
+ out.push(prefix.to_string());
|
||||
+ }
|
||||
+ for (key, child) in table {
|
||||
+ item_path_keys(&format!("{prefix}.{key}"), child, out);
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ if let Some(array) = item.as_array_of_tables() {
|
||||
+ if array.is_empty() {
|
||||
+ out.push(prefix.to_string());
|
||||
+ }
|
||||
+ for table in array {
|
||||
+ if table.is_empty() {
|
||||
+ out.push(prefix.to_string());
|
||||
+ }
|
||||
+ for (key, child) in table {
|
||||
+ item_path_keys(&format!("{prefix}.{key}"), child, out);
|
||||
+ }
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ out.push(prefix.to_string());
|
||||
+}
|
||||
+
|
||||
+fn remove_run_sandbox(doc: &mut DocumentMut) {
|
||||
+ if let Some(run) = doc.get_mut("run").and_then(Item::as_table_mut) {
|
||||
+ run.remove("sandbox");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn next_backup_path(path: &Path) -> PathBuf {
|
||||
+ let base = path.with_file_name(format!(
|
||||
+ "{}.legacy-sandbox-migration.bak",
|
||||
+ path.file_name()
|
||||
+ .and_then(|name| name.to_str())
|
||||
+ .unwrap_or("settings.toml")
|
||||
+ ));
|
||||
+ if !base.exists() {
|
||||
+ return base;
|
||||
+ }
|
||||
+
|
||||
+ for index in 1.. {
|
||||
+ let candidate = path.with_file_name(format!(
|
||||
+ "{}.legacy-sandbox-migration.{index}.bak",
|
||||
+ path.file_name()
|
||||
+ .and_then(|name| name.to_str())
|
||||
+ .unwrap_or("settings.toml")
|
||||
+ ));
|
||||
+ if !candidate.exists() {
|
||||
+ return candidate;
|
||||
+ }
|
||||
+ }
|
||||
+ unreachable!("unbounded backup suffix search should return")
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use fabro_types::settings::InterpString;
|
||||
+ use fabro_types::settings::run::EnvironmentProvider;
|
||||
+
|
||||
+ use super::*;
|
||||
+
|
||||
+ fn migrate(source: &str) -> String {
|
||||
+ migrate_contents(source, Path::new("settings.toml"))
|
||||
+ .expect("migration should not error")
|
||||
+ .expect("legacy sandbox should migrate")
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn provider_only_daytona_config_migrates_to_default_environment() {
|
||||
+ let migrated = migrate(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "daytona"
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ let settings = migrated
|
||||
+ .parse::<SettingsLayer>()
|
||||
+ .expect("migrated TOML should parse");
|
||||
+ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
+ .expect("migrated settings should resolve")
|
||||
+ .run;
|
||||
+
|
||||
+ assert_eq!(resolved.environment.id, "default");
|
||||
+ assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);
|
||||
+ assert!(migrated.contains("[run.environment]"));
|
||||
+ assert!(migrated.contains("[environments.default]"));
|
||||
+ assert!(!migrated.contains("[run.sandbox]"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn non_legacy_config_is_not_migrated() {
|
||||
+ let migrated = migrate_contents("_version = 1\n", Path::new("settings.toml"))
|
||||
+ .expect("non-legacy TOML should not error");
|
||||
+
|
||||
+ assert!(migrated.is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {
|
||||
+ let migrated = migrate(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "daytona"
|
||||
+preserve = true
|
||||
+
|
||||
+[run.sandbox.env]
|
||||
+NODE_ENV = "development"
|
||||
+
|
||||
+[run.sandbox.daytona]
|
||||
+auto_stop_interval = 30
|
||||
+
|
||||
+[run.sandbox.daytona.labels]
|
||||
+repo = "fabro-sh/fabro"
|
||||
+
|
||||
+[run.sandbox.daytona.snapshot]
|
||||
+name = "fabro-v11"
|
||||
+cpu = 8
|
||||
+memory = "16GB"
|
||||
+disk = "20GB"
|
||||
+dockerfile = { path = "Dockerfile" }
|
||||
+
|
||||
+[[run.sandbox.daytona.volumes]]
|
||||
+volume_id = "vol_auth"
|
||||
+mount_path = "/home/daytona/.config"
|
||||
+subpath = "agents"
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ let settings = migrated
|
||||
+ .parse::<SettingsLayer>()
|
||||
+ .expect("migrated TOML should parse");
|
||||
+ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
+ .expect("migrated settings should resolve")
|
||||
+ .run
|
||||
+ .environment;
|
||||
+
|
||||
+ assert_eq!(resolved.image.reference.as_deref(), Some("fabro-v11"));
|
||||
+ assert_eq!(resolved.resources.cpu, Some(8));
|
||||
+ assert_eq!(
|
||||
+ resolved.resources.memory.map(|size| size.as_bytes()),
|
||||
+ Some(16_000_000_000)
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ resolved.resources.disk.map(|size| size.as_bytes()),
|
||||
+ Some(20_000_000_000)
|
||||
+ );
|
||||
+ assert!(resolved.lifecycle.preserve);
|
||||
+ assert_eq!(
|
||||
+ resolved
|
||||
+ .lifecycle
|
||||
+ .auto_stop
|
||||
+ .map(|duration| duration.as_std().as_secs()),
|
||||
+ Some(1800)
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ resolved.labels.get("repo").map(String::as_str),
|
||||
+ Some("fabro-sh/fabro")
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ resolved.env.get("NODE_ENV").map(InterpString::as_source),
|
||||
+ Some("development".to_string())
|
||||
+ );
|
||||
+ assert_eq!(resolved.volumes.len(), 1);
|
||||
+ assert_eq!(resolved.volumes[0].id, "vol_auth");
|
||||
+ assert_eq!(resolved.volumes[0].mount_path, "/home/daytona/.config");
|
||||
+ assert_eq!(resolved.volumes[0].subpath.as_deref(), Some("agents"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn docker_image_memory_and_cpu_quota_migrate() {
|
||||
+ let migrated = migrate(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "docker"
|
||||
+
|
||||
+[run.sandbox.docker]
|
||||
+image = "buildpack-deps:noble"
|
||||
+memory_limit = "4GB"
|
||||
+cpu_quota = 200000
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ let settings = migrated
|
||||
+ .parse::<SettingsLayer>()
|
||||
+ .expect("migrated TOML should parse");
|
||||
+ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
+ .expect("migrated settings should resolve")
|
||||
+ .run
|
||||
+ .environment;
|
||||
+
|
||||
+ assert_eq!(resolved.provider, EnvironmentProvider::Docker);
|
||||
+ assert_eq!(
|
||||
+ resolved.image.reference.as_deref(),
|
||||
+ Some("buildpack-deps:noble")
|
||||
+ );
|
||||
+ assert_eq!(resolved.resources.cpu, Some(2));
|
||||
+ assert_eq!(
|
||||
+ resolved.resources.memory.map(|size| size.as_bytes()),
|
||||
+ Some(4_000_000_000)
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn provider_skip_clone_true_migrates_to_run_clone_disabled() {
|
||||
+ let migrated = migrate(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "docker"
|
||||
+
|
||||
+[run.sandbox.docker]
|
||||
+skip_clone = true
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ let settings = migrated
|
||||
+ .parse::<SettingsLayer>()
|
||||
+ .expect("migrated TOML should parse");
|
||||
+ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
+ .expect("migrated settings should resolve")
|
||||
+ .run;
|
||||
+
|
||||
+ assert!(!resolved.clone.enabled);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn migrate_settings_path_writes_backup_and_rewrites_original() {
|
||||
+ let dir = tempfile::tempdir().expect("temp dir");
|
||||
+ let path = dir.path().join("settings.toml");
|
||||
+ let original = r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "daytona"
|
||||
+"#;
|
||||
+ std::fs::write(&path, original).expect("write fixture");
|
||||
+
|
||||
+ let report = migrate_settings_path(&path, original)
|
||||
+ .expect("migration should succeed")
|
||||
+ .expect("legacy config should migrate");
|
||||
+
|
||||
+ let rewritten = std::fs::read_to_string(&path).expect("read rewritten settings");
|
||||
+ let backup = std::fs::read_to_string(&report.backup_path).expect("read backup");
|
||||
+
|
||||
+ assert_eq!(backup, original);
|
||||
+ assert!(rewritten.contains("[run.environment]"));
|
||||
+ assert!(rewritten.contains("[environments.default]"));
|
||||
+ assert!(report.warning.contains("temporary compatibility migration"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn existing_backup_uses_numbered_suffix() {
|
||||
+ let dir = tempfile::tempdir().expect("temp dir");
|
||||
+ let path = dir.path().join("settings.toml");
|
||||
+ std::fs::write(
|
||||
+ path.with_file_name("settings.toml.legacy-sandbox-migration.bak"),
|
||||
+ "old",
|
||||
+ )
|
||||
+ .expect("write existing backup");
|
||||
+
|
||||
+ let next = next_backup_path(&path);
|
||||
+
|
||||
+ assert!(next.ends_with("settings.toml.legacy-sandbox-migration.1.bak"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn existing_new_environment_config_is_ambiguous() {
|
||||
+ let err = migrate_contents(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.environment]
|
||||
+id = "default"
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "daytona"
|
||||
+"#,
|
||||
+ Path::new("settings.toml"),
|
||||
+ )
|
||||
+ .expect_err("mixed old and new config should fail");
|
||||
+
|
||||
+ assert!(
|
||||
+ err.to_string()
|
||||
+ .contains("already contains [run.environment]")
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn unsupported_keys_are_reported_with_full_paths() {
|
||||
+ let err = migrate_contents(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "daytona"
|
||||
+
|
||||
+[run.sandbox.daytona]
|
||||
+unknown = true
|
||||
+"#,
|
||||
+ Path::new("settings.toml"),
|
||||
+ )
|
||||
+ .expect_err("unsupported keys should fail migration");
|
||||
+
|
||||
+ let rendered = err.to_string();
|
||||
+ assert!(rendered.contains("run.sandbox.daytona.unknown"));
|
||||
+ assert!(rendered.contains("docs/public/execution/environments.mdx"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn unsupported_docker_cpu_quota_is_reported() {
|
||||
+ let err = migrate_contents(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "docker"
|
||||
+
|
||||
+[run.sandbox.docker]
|
||||
+cpu_quota = 250000
|
||||
+"#,
|
||||
+ Path::new("settings.toml"),
|
||||
+ )
|
||||
+ .expect_err("non-divisible cpu quota should fail migration");
|
||||
+
|
||||
+ assert!(err.to_string().contains("run.sandbox.docker.cpu_quota"));
|
||||
+ }
|
||||
+}
|
||||
diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs
|
||||
index e2b6fb3b8..7ae698007 100644
|
||||
--- a/lib/crates/fabro-config/src/lib.rs
|
||||
+++ b/lib/crates/fabro-config/src/lib.rs
|
||||
@@ -16,6 +16,7 @@ pub mod envfile;
|
||||
pub mod error;
|
||||
pub mod home;
|
||||
pub mod input_overrides;
|
||||
+mod legacy_sandbox_migration;
|
||||
mod load;
|
||||
pub mod logging;
|
||||
pub mod parse;
|
||||
diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs
|
||||
index 5a2254603..94eba41fc 100644
|
||||
--- a/lib/crates/fabro-config/src/load.rs
|
||||
+++ b/lib/crates/fabro-config/src/load.rs
|
||||
@@ -7,13 +7,33 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_types::settings::InterpString;
|
||||
|
||||
-use crate::{Error, Result, RunGoalLayer, SettingsLayer};
|
||||
+use crate::{Error, Result, RunGoalLayer, SettingsLayer, legacy_sandbox_migration};
|
||||
|
||||
+#[expect(
|
||||
+ clippy::print_stderr,
|
||||
+ reason = "startup config auto-migration warning must be visible before caller logging is configured"
|
||||
+)]
|
||||
pub(crate) fn load_settings_path(path: &Path) -> Result<SettingsLayer> {
|
||||
let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?;
|
||||
- let mut layer = content
|
||||
- .parse::<SettingsLayer>()
|
||||
- .map_err(|err| Error::parse_file("Failed to parse settings file", path, err))?;
|
||||
+ let mut layer = match content.parse::<SettingsLayer>() {
|
||||
+ Ok(layer) => layer,
|
||||
+ Err(err) => match legacy_sandbox_migration::migrate_settings_path(path, &content)? {
|
||||
+ Some(report) => {
|
||||
+ tracing::warn!("{}", report.warning);
|
||||
+ eprintln!("{}", report.warning);
|
||||
+ report.contents.parse::<SettingsLayer>().map_err(|err| {
|
||||
+ Error::parse_file("Migrated settings file is invalid", path, err)
|
||||
+ })?
|
||||
+ }
|
||||
+ None => {
|
||||
+ return Err(Error::parse_file(
|
||||
+ "Failed to parse settings file",
|
||||
+ path,
|
||||
+ err,
|
||||
+ ));
|
||||
+ }
|
||||
+ },
|
||||
+ };
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
resolve_goal_file_paths(&mut layer, base_dir);
|
||||
Ok(layer)
|
||||
@@ -45,3 +65,38 @@ pub(crate) fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf
|
||||
base_dir.join(path)
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use fabro_types::settings::run::EnvironmentProvider;
|
||||
+
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn load_settings_path_auto_migrates_legacy_sandbox_file() {
|
||||
+ let dir = tempfile::tempdir().expect("temp dir");
|
||||
+ let path = dir.path().join("settings.toml");
|
||||
+ std::fs::write(
|
||||
+ &path,
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "daytona"
|
||||
+"#,
|
||||
+ )
|
||||
+ .expect("write legacy settings");
|
||||
+
|
||||
+ let layer = load_settings_path(&path).expect("legacy settings should auto-migrate");
|
||||
+ let resolved = crate::WorkflowSettingsBuilder::from_layer(&layer)
|
||||
+ .expect("migrated settings should resolve")
|
||||
+ .run;
|
||||
+
|
||||
+ assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);
|
||||
+ assert!(
|
||||
+ std::fs::read_to_string(&path)
|
||||
+ .expect("read rewritten settings")
|
||||
+ .contains("[run.environment]")
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs
|
||||
index 030a234b2..7554a3429 100644
|
||||
--- a/lib/crates/fabro-config/src/user.rs
|
||||
+++ b/lib/crates/fabro-config/src/user.rs
|
||||
@@ -72,6 +72,9 @@ fn load_v2_layer_from_path(path: &Path) -> Result<SettingsLayer> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
+ use fabro_static::EnvVars;
|
||||
+ use temp_env::with_var;
|
||||
+
|
||||
use super::{
|
||||
SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup, default_settings_path,
|
||||
default_socket_path, default_storage_dir,
|
||||
@@ -79,14 +82,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_paths_use_expected_filenames() {
|
||||
- let home = dirs::home_dir().unwrap();
|
||||
-
|
||||
- assert_eq!(
|
||||
- default_settings_path(),
|
||||
- home.join(".fabro").join(SETTINGS_CONFIG_FILENAME)
|
||||
- );
|
||||
- assert_eq!(default_storage_dir(), home.join(".fabro/storage"));
|
||||
- assert_eq!(default_socket_path(), home.join(".fabro/fabro.sock"));
|
||||
+ with_var(EnvVars::FABRO_HOME, None::<&str>, || {
|
||||
+ let home = dirs::home_dir().unwrap();
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ default_settings_path(),
|
||||
+ home.join(".fabro").join(SETTINGS_CONFIG_FILENAME)
|
||||
+ );
|
||||
+ assert_eq!(default_storage_dir(), home.join(".fabro/storage"));
|
||||
+ assert_eq!(default_socket_path(), home.join(".fabro/fabro.sock"));
|
||||
+ });
|
||||
}
|
||||
|
||||
#[test]
|
||||
6
stages/005-implement@1/status.json
Normal file
6
stages/005-implement@1/status.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"outcome": "succeeded",
|
||||
"notes": "Stage completed: implement",
|
||||
"failure_reason": null,
|
||||
"timestamp": "2026-05-23T20:13:15.232065Z"
|
||||
}
|
||||
957
stages/006-simplify_opus@1/prompt.md
Normal file
957
stages/006-simplify_opus@1/prompt.md
Normal file
|
|
@ -0,0 +1,957 @@
|
|||
Goal: # Legacy Sandbox Config Auto-Migration 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. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Automatically rewrite confidently migratable legacy `[run.sandbox]` config files to the named-environments syntax during startup.
|
||||
|
||||
**Architecture:** Keep legacy behavior isolated in a removable `fabro-config` module. The normal settings schema stays strict; only file-based loads get a temporary parse-failure recovery path that rewrites the file, writes a backup, warns, and then resumes normal parsing.
|
||||
|
||||
**Tech Stack:** Rust, `toml_edit`, existing `fabro-config` settings builders, `tracing`, `tempfile` tests, public docs under `docs/public`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`
|
||||
- Owns detection, TOML rewriting, backup naming/writing, unsupported-key diagnostics, and tests for legacy mappings.
|
||||
- Modify `lib/crates/fabro-config/src/lib.rs`
|
||||
- Register the module privately.
|
||||
- Modify `lib/crates/fabro-config/Cargo.toml`
|
||||
- Add the existing workspace `toml_edit` dependency; current main does not depend on it from `fabro-config`.
|
||||
- Modify `lib/crates/fabro-config/src/load.rs`
|
||||
- Add a small parse-failure hook that delegates to the migration module, then returns to normal parsing.
|
||||
- Modify docs:
|
||||
- `docs/public/execution/environments.mdx`
|
||||
- Create `docs/public/changelog/2026-05-23.mdx` because current main's latest public changelog is `2026-05-22.mdx`.
|
||||
|
||||
## Migration Contract
|
||||
|
||||
Only migrate when all of these are true:
|
||||
|
||||
- The file is valid TOML as a document.
|
||||
- `[run.sandbox]` exists.
|
||||
- `[run.environment]` does not exist.
|
||||
- `[environments.default]` does not exist.
|
||||
- Every legacy sandbox key is in the supported mapping below.
|
||||
- The migrated content parses successfully as `SettingsLayer`.
|
||||
|
||||
This is intentionally a file-load migration only. Current in-memory parsing behavior, including `legacy_run_sandbox_is_rejected` in `lib/crates/fabro-config/src/tests/resolve_run.rs`, should remain strict and unchanged.
|
||||
|
||||
Supported mappings:
|
||||
|
||||
| Legacy key | New key |
|
||||
|---|---|
|
||||
| `run.sandbox.provider` | `run.environment.id = "default"` and `environments.default.provider` |
|
||||
| `run.sandbox.preserve` | `environments.default.lifecycle.preserve` |
|
||||
| `run.sandbox.env` | `environments.default.env` |
|
||||
| `run.sandbox.daytona.skip_clone = true` | `run.clone.enabled = false` |
|
||||
| `run.sandbox.docker.skip_clone = true` | `run.clone.enabled = false` |
|
||||
| `run.sandbox.daytona.auto_stop_interval = N` | `environments.default.lifecycle.auto_stop = "{N}m"` |
|
||||
| `run.sandbox.daytona.labels` | `environments.default.labels` |
|
||||
| `run.sandbox.daytona.snapshot.name` | `environments.default.image.ref` |
|
||||
| `run.sandbox.daytona.snapshot.cpu` | `environments.default.resources.cpu` |
|
||||
| `run.sandbox.daytona.snapshot.memory` | `environments.default.resources.memory` |
|
||||
| `run.sandbox.daytona.snapshot.disk` | `environments.default.resources.disk` |
|
||||
| `run.sandbox.daytona.snapshot.dockerfile` | `environments.default.image.dockerfile` |
|
||||
| `run.sandbox.daytona.volumes[].volume_id` | `environments.default.volumes[].id` |
|
||||
| `run.sandbox.daytona.volumes[].mount_path` | `environments.default.volumes[].mount_path` |
|
||||
| `run.sandbox.daytona.volumes[].subpath` | `environments.default.volumes[].subpath` |
|
||||
| `run.sandbox.docker.image` | `environments.default.image.ref` |
|
||||
| `run.sandbox.docker.memory_limit` | `environments.default.resources.memory` |
|
||||
| `run.sandbox.docker.cpu_quota` | `environments.default.resources.cpu` when divisible by `100000` |
|
||||
|
||||
Unsupported or ambiguous cases fail with a message shaped like:
|
||||
|
||||
```text
|
||||
Legacy [run.sandbox] settings in <path> could not be auto-migrated.
|
||||
|
||||
Unsupported keys:
|
||||
- run.sandbox.daytona.foo
|
||||
- run.sandbox.docker.cpu_quota
|
||||
|
||||
Rename legacy sandbox configuration to [run.environment] and [environments.<slug>].
|
||||
See docs/public/execution/environments.mdx.
|
||||
```
|
||||
|
||||
Successful migration writes a backup next to the original file:
|
||||
|
||||
```text
|
||||
settings.toml.legacy-sandbox-migration.bak
|
||||
settings.toml.legacy-sandbox-migration.1.bak
|
||||
settings.toml.legacy-sandbox-migration.2.bak
|
||||
```
|
||||
|
||||
Successful migration emits:
|
||||
|
||||
```text
|
||||
Migrated legacy [run.sandbox] settings in <path> to [run.environment] and [environments.default]. Backup written to <backup>. This temporary compatibility migration will be removed before v1.0.
|
||||
```
|
||||
|
||||
## Task 1: Add the Migration Module Skeleton
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`
|
||||
- Modify: `lib/crates/fabro-config/src/lib.rs`
|
||||
- Modify: `lib/crates/fabro-config/Cargo.toml`
|
||||
|
||||
- [ ] **Step 1: Add the `toml_edit` dependency**
|
||||
|
||||
Add this to `[dependencies]` in `lib/crates/fabro-config/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
toml_edit.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the module privately**
|
||||
|
||||
Add this beside the other private modules in `lib/crates/fabro-config/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
mod legacy_sandbox_migration;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the module API**
|
||||
|
||||
Create `lib/crates/fabro-config/src/legacy_sandbox_migration.rs` with this starting shape:
|
||||
|
||||
```rust
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "temporary startup config migration uses synchronous file I/O before config is loaded"
|
||||
)]
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use toml_edit::{DocumentMut, Item, Table, Value};
|
||||
|
||||
use crate::{Error, Result, SettingsLayer};
|
||||
|
||||
pub(crate) const REMOVAL_NOTE: &str =
|
||||
"This temporary compatibility migration will be removed before v1.0.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LegacySandboxMigrationReport {
|
||||
pub(crate) contents: String,
|
||||
pub(crate) backup_path: PathBuf,
|
||||
pub(crate) warning: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MigrationFailure {
|
||||
unsupported_keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for MigrationFailure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "Legacy [run.sandbox] settings could not be auto-migrated.")?;
|
||||
writeln!(f)?;
|
||||
writeln!(f, "Unsupported keys:")?;
|
||||
for key in &self.unsupported_keys {
|
||||
writeln!(f, " - {key}")?;
|
||||
}
|
||||
writeln!(f)?;
|
||||
write!(
|
||||
f,
|
||||
"Rename legacy sandbox configuration to [run.environment] and [environments.<slug>]. See docs/public/execution/environments.mdx."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_settings_path(
|
||||
path: &Path,
|
||||
original_contents: &str,
|
||||
) -> Result<Option<LegacySandboxMigrationReport>> {
|
||||
let Some(next_contents) = migrate_contents(original_contents, path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
next_contents
|
||||
.parse::<SettingsLayer>()
|
||||
.map_err(|err| Error::parse_file("Migrated settings file is invalid", path, err))?;
|
||||
|
||||
let backup_path = next_backup_path(path);
|
||||
std::fs::write(&backup_path, original_contents).map_err(|source| {
|
||||
Error::other(format!(
|
||||
"writing legacy sandbox migration backup {}: {source}",
|
||||
backup_path.display()
|
||||
))
|
||||
})?;
|
||||
std::fs::write(path, &next_contents).map_err(|source| {
|
||||
Error::other(format!(
|
||||
"writing migrated settings file {}: {source}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let warning = format!(
|
||||
"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.default]. Backup written to {}. {REMOVAL_NOTE}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
Ok(Some(LegacySandboxMigrationReport {
|
||||
contents: next_contents,
|
||||
backup_path,
|
||||
warning,
|
||||
}))
|
||||
}
|
||||
|
||||
fn migrate_contents(original_contents: &str, path: &Path) -> Result<Option<String>> {
|
||||
let mut doc = match original_contents.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
if !has_legacy_run_sandbox(&doc) {
|
||||
return Ok(None);
|
||||
}
|
||||
if has_new_environment_config(&doc) {
|
||||
return Err(Error::other(format!(
|
||||
"Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments.default]. Remove one config style and retry.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
migrate_document(&mut doc).map_err(|failure| {
|
||||
Error::other(format!(
|
||||
"Legacy [run.sandbox] settings in {} could not be auto-migrated.\n\n{}",
|
||||
path.display(),
|
||||
failure
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Some(doc.to_string()))
|
||||
}
|
||||
|
||||
fn has_legacy_run_sandbox(doc: &DocumentMut) -> bool {
|
||||
doc.get("run")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|run| run.get("sandbox"))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn has_new_environment_config(doc: &DocumentMut) -> bool {
|
||||
let has_run_environment = doc
|
||||
.get("run")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|run| run.get("environment"))
|
||||
.is_some();
|
||||
let has_default_environment = doc
|
||||
.get("environments")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|envs| envs.get("default"))
|
||||
.is_some();
|
||||
has_run_environment || has_default_environment
|
||||
}
|
||||
|
||||
fn next_backup_path(path: &Path) -> PathBuf {
|
||||
let base = path.with_file_name(format!(
|
||||
"{}.legacy-sandbox-migration.bak",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("settings.toml")
|
||||
));
|
||||
if !base.exists() {
|
||||
return base;
|
||||
}
|
||||
|
||||
for index in 1.. {
|
||||
let candidate = path.with_file_name(format!(
|
||||
"{}.legacy-sandbox-migration.{index}.bak",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("settings.toml")
|
||||
));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!("unbounded backup suffix search should return")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add placeholder-free private stubs that compile**
|
||||
|
||||
Add private helpers with `unimplemented!()` only inside tests disabled by `#[cfg(test)]` is not allowed. Instead, make `migrate_document` return the one known unsupported failure until Task 2 fills it:
|
||||
|
||||
```rust
|
||||
fn migrate_document(_doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {
|
||||
Err(MigrationFailure {
|
||||
unsupported_keys: vec!["run.sandbox".to_string()],
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the focused compile check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_sandbox_migration --quiet
|
||||
```
|
||||
|
||||
Expected: compiles; there may be zero tests in this module at this point.
|
||||
|
||||
## Task 2: Implement Provider-Only Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`
|
||||
|
||||
- [ ] **Step 1: Add tests for provider-only migration**
|
||||
|
||||
Add these tests inside `legacy_sandbox_migration.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_types::settings::run::EnvironmentProvider;
|
||||
|
||||
fn migrate(source: &str) -> String {
|
||||
migrate_contents(source, Path::new("settings.toml"))
|
||||
.expect("migration should not error")
|
||||
.expect("legacy sandbox should migrate")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_only_daytona_config_migrates_to_default_environment() {
|
||||
let migrated = migrate(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
"#,
|
||||
);
|
||||
|
||||
let settings = migrated
|
||||
.parse::<SettingsLayer>()
|
||||
.expect("migrated TOML should parse");
|
||||
let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
.expect("migrated settings should resolve")
|
||||
.run;
|
||||
|
||||
assert_eq!(resolved.environment.id, "default");
|
||||
assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);
|
||||
assert!(migrated.contains("[run.environment]"));
|
||||
assert!(migrated.contains("[environments.default]"));
|
||||
assert!(!migrated.contains("[run.sandbox]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_legacy_config_is_not_migrated() {
|
||||
let migrated = migrate_contents("_version = 1\n", Path::new("settings.toml"))
|
||||
.expect("non-legacy TOML should not error");
|
||||
|
||||
assert!(migrated.is_none());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and confirm failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config provider_only_daytona_config_migrates_to_default_environment --quiet
|
||||
```
|
||||
|
||||
Expected: FAIL because `migrate_document` still returns unsupported `run.sandbox`.
|
||||
|
||||
- [ ] **Step 3: Replace `migrate_document` with provider migration**
|
||||
|
||||
Implement the initial migration:
|
||||
|
||||
```rust
|
||||
fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {
|
||||
let Some(sandbox_item) = doc
|
||||
.get("run")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|run| run.get("sandbox"))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(sandbox) = sandbox_item.as_table().cloned() else {
|
||||
return Err(MigrationFailure {
|
||||
unsupported_keys: vec!["run.sandbox".to_string()],
|
||||
});
|
||||
};
|
||||
|
||||
let mut unsupported = Vec::new();
|
||||
for (key, _) in sandbox.iter() {
|
||||
if key != "provider" {
|
||||
unsupported.push(format!("run.sandbox.{key}"));
|
||||
}
|
||||
}
|
||||
if !unsupported.is_empty() {
|
||||
return Err(MigrationFailure {
|
||||
unsupported_keys: unsupported,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(provider) = sandbox.get("provider").and_then(Item::as_str) else {
|
||||
return Err(MigrationFailure {
|
||||
unsupported_keys: vec!["run.sandbox.provider".to_string()],
|
||||
});
|
||||
};
|
||||
|
||||
set_value(path_table(doc, &["run", "environment"]), "id", Value::from("default"));
|
||||
set_value(
|
||||
path_table(doc, &["environments", "default"]),
|
||||
"provider",
|
||||
Value::from(provider),
|
||||
);
|
||||
|
||||
remove_run_sandbox(doc);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_table<'a>(doc: &'a mut DocumentMut, path: &[&str]) -> &'a mut Table {
|
||||
let mut item = doc.as_item_mut();
|
||||
for segment in path {
|
||||
item = &mut item[segment];
|
||||
if !item.is_table() {
|
||||
*item = Item::Table(Table::new());
|
||||
}
|
||||
}
|
||||
item.as_table_mut().expect("path item should be a table")
|
||||
}
|
||||
|
||||
fn set_value(table: &mut Table, key: &str, value: Value) {
|
||||
table[key] = Item::Value(value);
|
||||
}
|
||||
|
||||
fn remove_run_sandbox(doc: &mut DocumentMut) {
|
||||
if let Some(run) = doc.get_mut("run").and_then(Item::as_table_mut) {
|
||||
run.remove("sandbox");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_sandbox_migration --quiet
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Add Daytona and Docker Field Mappings
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`
|
||||
|
||||
- [ ] **Step 1: Add tests for direct legacy field mappings**
|
||||
|
||||
Add tests that assert resolved behavior, not only string contents:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {
|
||||
let migrated = migrate(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
preserve = true
|
||||
|
||||
[run.sandbox.env]
|
||||
NODE_ENV = "development"
|
||||
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 30
|
||||
|
||||
[run.sandbox.daytona.labels]
|
||||
repo = "fabro-sh/fabro"
|
||||
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "fabro-v11"
|
||||
cpu = 8
|
||||
memory = "16GB"
|
||||
disk = "20GB"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
|
||||
[[run.sandbox.daytona.volumes]]
|
||||
volume_id = "vol_auth"
|
||||
mount_path = "/home/daytona/.config"
|
||||
subpath = "agents"
|
||||
"#,
|
||||
);
|
||||
|
||||
let settings = migrated.parse::<SettingsLayer>().expect("migrated TOML should parse");
|
||||
let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
.expect("migrated settings should resolve")
|
||||
.run
|
||||
.environment;
|
||||
|
||||
assert_eq!(resolved.image.reference.as_deref(), Some("fabro-v11"));
|
||||
assert_eq!(resolved.resources.cpu, Some(8));
|
||||
assert_eq!(resolved.resources.memory.map(|size| size.as_bytes()), Some(16_000_000_000));
|
||||
assert_eq!(resolved.resources.disk.map(|size| size.as_bytes()), Some(20_000_000_000));
|
||||
assert!(resolved.lifecycle.preserve);
|
||||
assert_eq!(resolved.lifecycle.auto_stop.map(|duration| duration.as_std().as_secs()), Some(1800));
|
||||
assert_eq!(resolved.labels.get("repo").map(String::as_str), Some("fabro-sh/fabro"));
|
||||
assert_eq!(resolved.env.get("NODE_ENV").map(|value| value.as_source()).as_deref(), Some("development"));
|
||||
assert_eq!(resolved.volumes.len(), 1);
|
||||
assert_eq!(resolved.volumes[0].id, "vol_auth");
|
||||
assert_eq!(resolved.volumes[0].mount_path, "/home/daytona/.config");
|
||||
assert_eq!(resolved.volumes[0].subpath.as_deref(), Some("agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_image_memory_and_cpu_quota_migrate() {
|
||||
let migrated = migrate(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "docker"
|
||||
|
||||
[run.sandbox.docker]
|
||||
image = "buildpack-deps:noble"
|
||||
memory_limit = "4GB"
|
||||
cpu_quota = 200000
|
||||
"#,
|
||||
);
|
||||
|
||||
let settings = migrated.parse::<SettingsLayer>().expect("migrated TOML should parse");
|
||||
let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)
|
||||
.expect("migrated settings should resolve")
|
||||
.run
|
||||
.environment;
|
||||
|
||||
assert_eq!(resolved.provider, EnvironmentProvider::Docker);
|
||||
assert_eq!(resolved.image.reference.as_deref(), Some("buildpack-deps:noble"));
|
||||
assert_eq!(resolved.resources.cpu, Some(2));
|
||||
assert_eq!(resolved.resources.memory.map(|size| size.as_bytes()), Some(4_000_000_000));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and confirm failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_sandbox_migration --quiet
|
||||
```
|
||||
|
||||
Expected: FAIL because the two new nested-mapping tests are not implemented yet.
|
||||
|
||||
- [ ] **Step 3: Implement table copying and value transforms**
|
||||
|
||||
Extend `migrate_document` so it:
|
||||
|
||||
- Allows top-level legacy keys `provider`, `preserve`, `env`, `daytona`, and `docker`.
|
||||
- Copies `run.sandbox.env` into `environments.default.env`.
|
||||
- Sets `environments.default.lifecycle.preserve` from `run.sandbox.preserve`.
|
||||
- Handles provider-specific nested mappings only for the selected provider.
|
||||
- Removes `run.sandbox` after successful migration.
|
||||
|
||||
Use helper functions with these signatures:
|
||||
|
||||
```rust
|
||||
fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String>);
|
||||
fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String>);
|
||||
fn copy_table(source: &Item, target: &mut Table);
|
||||
fn copy_array_of_tables_with_volume_id(source: &Item, target: &mut Table, unsupported: &mut Vec<String>);
|
||||
fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec<String>);
|
||||
```
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- `auto_stop_interval` must be an integer. Store `format!("{minutes}m")`.
|
||||
- `docker.cpu_quota` must be an integer divisible by `100000`; otherwise add `run.sandbox.docker.cpu_quota` to unsupported keys.
|
||||
- For Daytona volumes, each array entry may contain only `volume_id`, `mount_path`, and `subpath`; rename `volume_id` to `id`.
|
||||
- `daytona.snapshot.dockerfile` must be copied as the existing TOML value, preserving inline string or `{ path = "..." }`.
|
||||
- When collecting unsupported nested keys, report full paths such as `run.sandbox.daytona.snapshot.foo`.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_sandbox_migration --quiet
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: Add File Rewrite and Loader Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-config/src/load.rs`
|
||||
- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`
|
||||
|
||||
- [ ] **Step 1: Add file rewrite tests**
|
||||
|
||||
Add tests:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn migrate_settings_path_writes_backup_and_rewrites_original() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let path = dir.path().join("settings.toml");
|
||||
let original = r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
"#;
|
||||
std::fs::write(&path, original).expect("write fixture");
|
||||
|
||||
let report = migrate_settings_path(&path, original)
|
||||
.expect("migration should succeed")
|
||||
.expect("legacy config should migrate");
|
||||
|
||||
let rewritten = std::fs::read_to_string(&path).expect("read rewritten settings");
|
||||
let backup = std::fs::read_to_string(&report.backup_path).expect("read backup");
|
||||
|
||||
assert_eq!(backup, original);
|
||||
assert!(rewritten.contains("[run.environment]"));
|
||||
assert!(rewritten.contains("[environments.default]"));
|
||||
assert!(report.warning.contains("temporary compatibility migration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_backup_uses_numbered_suffix() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let path = dir.path().join("settings.toml");
|
||||
std::fs::write(path.with_file_name("settings.toml.legacy-sandbox-migration.bak"), "old")
|
||||
.expect("write existing backup");
|
||||
|
||||
let next = next_backup_path(&path);
|
||||
|
||||
assert!(next.ends_with("settings.toml.legacy-sandbox-migration.1.bak"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and confirm current state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_sandbox_migration --quiet
|
||||
```
|
||||
|
||||
Expected: PASS if Task 1 file-writing code compiled; otherwise fix only the migration module.
|
||||
|
||||
- [ ] **Step 3: Hook migration into file loading**
|
||||
|
||||
Change `load_settings_path` in `lib/crates/fabro-config/src/load.rs` to this shape:
|
||||
|
||||
```rust
|
||||
pub(crate) fn load_settings_path(path: &Path) -> Result<SettingsLayer> {
|
||||
let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?;
|
||||
let mut layer = match content.parse::<SettingsLayer>() {
|
||||
Ok(layer) => layer,
|
||||
Err(err) => match crate::legacy_sandbox_migration::migrate_settings_path(path, &content)? {
|
||||
Some(report) => {
|
||||
tracing::warn!("{}", report.warning);
|
||||
eprintln!("{}", report.warning);
|
||||
report.contents.parse::<SettingsLayer>().map_err(|err| {
|
||||
Error::parse_file("Migrated settings file is invalid", path, err)
|
||||
})?
|
||||
}
|
||||
None => return Err(Error::parse_file("Failed to parse settings file", path, err)),
|
||||
},
|
||||
};
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
resolve_goal_file_paths(&mut layer, base_dir);
|
||||
Ok(layer)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run loader-level verification**
|
||||
|
||||
Add a test in `load.rs` under `#[cfg(test)]` if the file does not already have a test module:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_types::settings::run::EnvironmentProvider;
|
||||
|
||||
#[test]
|
||||
fn load_settings_path_auto_migrates_legacy_sandbox_file() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let path = dir.path().join("settings.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
"#,
|
||||
)
|
||||
.expect("write legacy settings");
|
||||
|
||||
let layer = load_settings_path(&path).expect("legacy settings should auto-migrate");
|
||||
let resolved = crate::WorkflowSettingsBuilder::from_layer(&layer)
|
||||
.expect("migrated settings should resolve")
|
||||
.run;
|
||||
|
||||
assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);
|
||||
assert!(std::fs::read_to_string(&path)
|
||||
.expect("read rewritten settings")
|
||||
.contains("[run.environment]"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config load_settings_path_auto_migrates_legacy_sandbox_file --quiet
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Verify in-memory TOML parsing remains strict**
|
||||
|
||||
Run the existing current-main rejection test:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet
|
||||
```
|
||||
|
||||
Expected: PASS. Do not weaken `SettingsLayer` deserialization to accept `run.sandbox`; only `load_settings_path` should rewrite files from disk.
|
||||
|
||||
## Task 5: Unsupported and Ambiguous Cases
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`
|
||||
|
||||
- [ ] **Step 1: Add failure tests**
|
||||
|
||||
Add tests:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn existing_new_environment_config_is_ambiguous() {
|
||||
let err = migrate_contents(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.environment]
|
||||
id = "default"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
"#,
|
||||
Path::new("settings.toml"),
|
||||
)
|
||||
.expect_err("mixed old and new config should fail");
|
||||
|
||||
assert!(err.to_string().contains("already contains [run.environment]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_keys_are_reported_with_full_paths() {
|
||||
let err = migrate_contents(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[run.sandbox.daytona]
|
||||
unknown = true
|
||||
"#,
|
||||
Path::new("settings.toml"),
|
||||
)
|
||||
.expect_err("unsupported keys should fail migration");
|
||||
|
||||
let rendered = err.to_string();
|
||||
assert!(rendered.contains("run.sandbox.daytona.unknown"));
|
||||
assert!(rendered.contains("docs/public/execution/environments.mdx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_docker_cpu_quota_is_reported() {
|
||||
let err = migrate_contents(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.sandbox]
|
||||
provider = "docker"
|
||||
|
||||
[run.sandbox.docker]
|
||||
cpu_quota = 250000
|
||||
"#,
|
||||
Path::new("settings.toml"),
|
||||
)
|
||||
.expect_err("non-divisible cpu quota should fail migration");
|
||||
|
||||
assert!(err.to_string().contains("run.sandbox.docker.cpu_quota"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run failure tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config legacy_sandbox_migration --quiet
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 6: Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/public/execution/environments.mdx`
|
||||
- Create: `docs/public/changelog/2026-05-23.mdx`
|
||||
|
||||
- [ ] **Step 1: Document temporary auto-migration**
|
||||
|
||||
Add this note near the top of `docs/public/execution/environments.mdx`, after the initial environment/sandbox distinction:
|
||||
|
||||
```mdx
|
||||
<Warning>
|
||||
Older pre-v1.0 config files that still use `[run.sandbox]` are temporarily auto-migrated when Fabro loads them from disk. Fabro writes a sibling `*.legacy-sandbox-migration.bak` file, rewrites the config to `[run.environment]` plus `[environments.default]`, and then continues startup.
|
||||
|
||||
This compatibility rewrite only handles direct field mappings. Unsupported legacy fields fail with a migration message that lists the keys to edit manually. The rewrite path will be removed before v1.0.
|
||||
</Warning>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a changelog note**
|
||||
|
||||
Create `docs/public/changelog/2026-05-23.mdx`:
|
||||
|
||||
```mdx
|
||||
---
|
||||
title: "Legacy sandbox config migration"
|
||||
date: "2026-05-23"
|
||||
---
|
||||
|
||||
## Legacy sandbox config auto-migration
|
||||
|
||||
Fabro now temporarily rewrites confidently migratable pre-v1.0 `[run.sandbox]` config files to the named environment syntax. A backup is written next to the original file before rewriting. Ambiguous or unsupported legacy keys fail with a targeted migration message instead of the generic TOML unknown-field error.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check docs references**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "\\[run\\.sandbox\\]|legacy-sandbox-migration|run\\.environment" docs/public/execution docs/public/changelog
|
||||
```
|
||||
|
||||
Expected: remaining `[run.sandbox]` references are either historical changelog entries or explicit migration warnings.
|
||||
|
||||
## Task 7: Full Verification
|
||||
|
||||
**Files:**
|
||||
- All files touched above.
|
||||
|
||||
- [ ] **Step 1: Run config crate tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test -p fabro-config --quiet
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run formatting check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo +nightly-2026-04-14 fmt --check --all
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Optional workspace lint if formatting and tests pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Expected: PASS. If this is slow, record that it was not run and include the reason in the handoff.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Boot-time config loading rewrites simple legacy `[run.sandbox]` files without user action.
|
||||
- The rewritten file uses `[run.environment] id = "default"` and `[environments.default]`.
|
||||
- The original file is preserved in a sibling backup before rewrite.
|
||||
- Unsupported legacy keys fail with a targeted migration message listing exact keys.
|
||||
- Normal strict schema behavior remains unchanged for in-memory `from_toml` calls.
|
||||
- All legacy migration code is isolated in `legacy_sandbox_migration.rs` and removable before v1.0.
|
||||
|
||||
|
||||
## 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**: succeeded
|
||||
- Model: gpt-5.5, 148.2k tokens in / 31.9k out
|
||||
|
||||
|
||||
# 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).
|
||||
5
stages/006-simplify_opus@1/provider_used.json
Normal file
5
stages/006-simplify_opus@1/provider_used.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"mode": "agent",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-7"
|
||||
}
|
||||
20
stages/006-simplify_opus@1/response.md
Normal file
20
stages/006-simplify_opus@1/response.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:
|
||||
|
||||
**Fixes applied:**
|
||||
1. **Correctness bug** (Agent 2 #1): `skip_clone = false` (the default) was wrongly rejected as unsupported. Now only non-bool values fail. Added test `explicit_skip_clone_false_is_accepted_as_default`.
|
||||
2. **Code reuse** (Agent 1 #1, #2): Removed custom `is_table_like` / `copy_table` branching; now uses toml_edit's built-in `Item::is_table_like()` and `Item::as_table_like()` + `TableLike` trait.
|
||||
3. **Duplicate helpers** (Agent 1 #3): Collapsed `path_table` and `path_table_in_table` into a single `ensure_table` that operates on `&mut Table`; callers use `doc.as_table_mut()`.
|
||||
4. **Stringly-typed** (Agent 2 #3): Replaced raw `"daytona"` / `"docker"` matching with `EnvironmentProvider::from_str` + `IntoStaticStr`; eliminated the awkward `migrate_skip_clone` + `reject_provider_table` mirror-dispatch.
|
||||
5. **Trivial wrappers** (Agent 1 #9, Agent 2 smaller): Dropped `set_value` and `set_item`; callers use `table[key] = …` directly.
|
||||
6. **Efficiency** (Agent 3 main finding): `migrate_settings_path` now returns the already-parsed `SettingsLayer` instead of returning a string for the caller to re-parse — eliminates a redundant serde pass on the migration path.
|
||||
7. **Double-report bug** (Agent 2 smaller): `item_path_keys` now stops after reporting an empty array-of-tables instead of re-pushing per element.
|
||||
8. **Dead-code cleanup** (Agent 1 #4): Trimmed `unwrap_or("settings.toml")` dead fallback and computed `file_name` once before the loop in `next_backup_path`.
|
||||
9. **Dead-code warning**: Made `backup_path` a `#[cfg(test)]` field since it's only used in tests, avoiding a `dead_code` warning in non-test builds without sprinkling `#[allow]`.
|
||||
|
||||
**Skipped (deliberate):**
|
||||
- Adding a dedicated `Error::WriteFile` variant and converting `MigrationFailure` into an Error variant (Agent 1 #5, #6) — out of scope for a soon-to-be-removed module.
|
||||
- Promoting `next_backup_path` to `fabro-util` (Agent 1 #4, #7) — this file is going away pre-v1.0.
|
||||
- `user.rs` test fix concerns (Agent 2 #5) — works correctly, leave alone.
|
||||
- Test fixture builder consolidation — legacy TOML shape is the point of the tests.
|
||||
|
||||
**Verification:** `cargo nextest run -p fabro-config` (216 pass), `cargo +nightly-2026-04-14 fmt --check --all` (clean), `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` (clean).
|
||||
Loading…
Add table
Reference in a new issue