mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
parent
502bb318f7
commit
dff39eb1f2
6 changed files with 1750 additions and 18 deletions
336
run.json
336
run.json
File diff suppressed because one or more lines are too long
443
stages/006-simplify_opus@1/diff.patch
Normal file
443
stages/006-simplify_opus@1/diff.patch
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
diff --git a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs
|
||||
index a8d428929..4ed823128 100644
|
||||
--- a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs
|
||||
+++ b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
+use std::str::FromStr;
|
||||
|
||||
+use fabro_types::settings::run::EnvironmentProvider;
|
||||
use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value};
|
||||
|
||||
use crate::{Error, Result, SettingsLayer};
|
||||
@@ -13,11 +15,12 @@ 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)]
|
||||
+#[derive(Debug)]
|
||||
pub(crate) struct LegacySandboxMigrationReport {
|
||||
- pub(crate) contents: String,
|
||||
- pub(crate) backup_path: PathBuf,
|
||||
- pub(crate) warning: String,
|
||||
+ pub(crate) layer: SettingsLayer,
|
||||
+ pub(crate) warning: String,
|
||||
+ #[cfg(test)]
|
||||
+ backup_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -47,7 +50,7 @@ pub(crate) fn migrate_settings_path(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
- next_contents
|
||||
+ let layer = next_contents
|
||||
.parse::<SettingsLayer>()
|
||||
.map_err(|err| Error::parse_file("Migrated settings file is invalid", path, err))?;
|
||||
|
||||
@@ -72,9 +75,10 @@ pub(crate) fn migrate_settings_path(
|
||||
);
|
||||
|
||||
Ok(Some(LegacySandboxMigrationReport {
|
||||
- contents: next_contents,
|
||||
- backup_path,
|
||||
+ layer,
|
||||
warning,
|
||||
+ #[cfg(test)]
|
||||
+ backup_path,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -146,63 +150,63 @@ fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationF
|
||||
}
|
||||
}
|
||||
|
||||
- let provider = sandbox.get("provider").and_then(Item::as_str);
|
||||
- let provider_key = provider.unwrap_or_default().to_ascii_lowercase();
|
||||
- if provider.is_none() {
|
||||
+ let provider_str = sandbox.get("provider").and_then(Item::as_str);
|
||||
+ let active_provider = provider_str.and_then(|s| EnvironmentProvider::from_str(s).ok());
|
||||
+ if provider_str.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);
|
||||
+ // Inspect each provider's table once: if it's the active provider, capture
|
||||
+ // skip_clone; otherwise, report it as unsupported.
|
||||
+ let mut disable_clone = false;
|
||||
+ for provider in [EnvironmentProvider::Daytona, EnvironmentProvider::Docker] {
|
||||
+ let key: &'static str = provider.into();
|
||||
+ let Some(item) = sandbox.get(key) else {
|
||||
+ continue;
|
||||
+ };
|
||||
+ if Some(provider) == active_provider {
|
||||
+ if item
|
||||
+ .as_table()
|
||||
+ .and_then(|table| table.get("skip_clone"))
|
||||
+ .and_then(Item::as_bool)
|
||||
+ .unwrap_or(false)
|
||||
+ {
|
||||
+ disable_clone = true;
|
||||
+ }
|
||||
+ } else {
|
||||
+ item_path_keys(&format!("run.sandbox.{key}"), item, &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),
|
||||
- );
|
||||
+ if disable_clone {
|
||||
+ ensure_table(doc.as_table_mut(), &["run", "clone"])["enabled"] =
|
||||
+ Item::Value(Value::from(false));
|
||||
}
|
||||
+ ensure_table(doc.as_table_mut(), &["run", "environment"])["id"] =
|
||||
+ Item::Value(Value::from("default"));
|
||||
|
||||
- let env = path_table(doc, &["environments", "default"]);
|
||||
+ let env = ensure_table(doc.as_table_mut(), &["environments", "default"]);
|
||||
+ if let Some(p) = provider_str {
|
||||
+ env["provider"] = Item::Value(Value::from(p));
|
||||
+ }
|
||||
if let Some(preserve) = sandbox.get("preserve") {
|
||||
if preserve.as_bool().is_some() {
|
||||
- set_item(
|
||||
- path_table_in_table(env, &["lifecycle"]),
|
||||
- "preserve",
|
||||
- preserve.clone(),
|
||||
- );
|
||||
+ ensure_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"]));
|
||||
+ if env_item.is_table_like() {
|
||||
+ copy_table(env_item, ensure_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),
|
||||
+ match active_provider {
|
||||
+ Some(EnvironmentProvider::Daytona) => migrate_daytona(&sandbox, env, &mut unsupported),
|
||||
+ Some(EnvironmentProvider::Docker) => migrate_docker(&sandbox, env, &mut unsupported),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -216,29 +220,6 @@ fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationF
|
||||
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;
|
||||
@@ -251,24 +232,21 @@ fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<Strin
|
||||
for (key, item) in daytona {
|
||||
match key {
|
||||
"skip_clone" => {
|
||||
- if item.as_bool() != Some(true) {
|
||||
+ if item.as_bool().is_none() {
|
||||
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")),
|
||||
- );
|
||||
+ ensure_table(env, &["lifecycle"])["auto_stop"] =
|
||||
+ Item::Value(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"]));
|
||||
+ if item.is_table_like() {
|
||||
+ copy_table(item, ensure_table(env, &["labels"]));
|
||||
} else {
|
||||
unsupported.push("run.sandbox.daytona.labels".to_string());
|
||||
}
|
||||
@@ -288,27 +266,11 @@ fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported:
|
||||
|
||||
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(),
|
||||
- ),
|
||||
+ "name" => ensure_table(env, &["image"])["ref"] = item.clone(),
|
||||
+ "cpu" => ensure_table(env, &["resources"])["cpu"] = item.clone(),
|
||||
+ "memory" => ensure_table(env, &["resources"])["memory"] = item.clone(),
|
||||
+ "disk" => ensure_table(env, &["resources"])["disk"] = item.clone(),
|
||||
+ "dockerfile" => ensure_table(env, &["image"])["dockerfile"] = item.clone(),
|
||||
_ => item_path_keys(
|
||||
&format!("run.sandbox.daytona.snapshot.{key}"),
|
||||
item,
|
||||
@@ -330,26 +292,19 @@ fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String
|
||||
for (key, item) in docker {
|
||||
match key {
|
||||
"skip_clone" => {
|
||||
- if item.as_bool() != Some(true) {
|
||||
+ if item.as_bool().is_none() {
|
||||
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(),
|
||||
- ),
|
||||
+ "image" => ensure_table(env, &["image"])["ref"] = item.clone(),
|
||||
+ "memory_limit" => ensure_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),
|
||||
- );
|
||||
+ ensure_table(env, &["resources"])["cpu"] =
|
||||
+ Item::Value(Value::from(cpu_count));
|
||||
} else {
|
||||
unsupported.push("run.sandbox.docker.cpu_quota".to_string());
|
||||
}
|
||||
@@ -362,18 +317,7 @@ fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String
|
||||
}
|
||||
}
|
||||
|
||||
-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 {
|
||||
+fn ensure_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut Table {
|
||||
let mut table = table;
|
||||
for segment in path {
|
||||
let item = &mut table[segment];
|
||||
@@ -385,30 +329,12 @@ fn path_table_in_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut 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();
|
||||
- }
|
||||
+ let Some(src) = source.as_table_like() else {
|
||||
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());
|
||||
- }
|
||||
+ };
|
||||
+ for (key, item) in src.iter() {
|
||||
+ target[key] = item.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,13 +357,13 @@ fn copy_array_of_tables_with_volume_id(
|
||||
match key {
|
||||
"volume_id" => {
|
||||
has_id = true;
|
||||
- set_item(&mut migrated_volume, "id", item.clone());
|
||||
+ migrated_volume["id"] = item.clone();
|
||||
}
|
||||
"mount_path" => {
|
||||
has_mount_path = true;
|
||||
- set_item(&mut migrated_volume, "mount_path", item.clone());
|
||||
+ migrated_volume["mount_path"] = item.clone();
|
||||
}
|
||||
- "subpath" => set_item(&mut migrated_volume, "subpath", item.clone()),
|
||||
+ "subpath" => migrated_volume["subpath"] = item.clone(),
|
||||
_ => item_path_keys(
|
||||
&format!("run.sandbox.daytona.volumes.{key}"),
|
||||
item,
|
||||
@@ -470,6 +396,7 @@ fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec<String>) {
|
||||
if let Some(array) = item.as_array_of_tables() {
|
||||
if array.is_empty() {
|
||||
out.push(prefix.to_string());
|
||||
+ return;
|
||||
}
|
||||
for table in array {
|
||||
if table.is_empty() {
|
||||
@@ -492,23 +419,18 @@ fn remove_run_sandbox(doc: &mut DocumentMut) {
|
||||
}
|
||||
|
||||
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")
|
||||
- ));
|
||||
+ let file_name = path
|
||||
+ .file_name()
|
||||
+ .and_then(|name| name.to_str())
|
||||
+ .expect("settings path always has a UTF-8 file name");
|
||||
+ let base = path.with_file_name(format!("{file_name}.legacy-sandbox-migration.bak"));
|
||||
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")
|
||||
- ));
|
||||
+ for index in 1u32.. {
|
||||
+ let candidate =
|
||||
+ path.with_file_name(format!("{file_name}.legacy-sandbox-migration.{index}.bak"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
@@ -519,7 +441,6 @@ fn next_backup_path(path: &Path) -> PathBuf {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::settings::InterpString;
|
||||
- use fabro_types::settings::run::EnvironmentProvider;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -796,4 +717,28 @@ cpu_quota = 250000
|
||||
|
||||
assert!(err.to_string().contains("run.sandbox.docker.cpu_quota"));
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn explicit_skip_clone_false_is_accepted_as_default() {
|
||||
+ let migrated = migrate(
|
||||
+ r#"
|
||||
+_version = 1
|
||||
+
|
||||
+[run.sandbox]
|
||||
+provider = "docker"
|
||||
+
|
||||
+[run.sandbox.docker]
|
||||
+skip_clone = false
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ 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);
|
||||
+ }
|
||||
}
|
||||
diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs
|
||||
index 94eba41fc..37ec2f582 100644
|
||||
--- a/lib/crates/fabro-config/src/load.rs
|
||||
+++ b/lib/crates/fabro-config/src/load.rs
|
||||
@@ -21,9 +21,7 @@ pub(crate) fn load_settings_path(path: &Path) -> Result<SettingsLayer> {
|
||||
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)
|
||||
- })?
|
||||
+ report.layer
|
||||
}
|
||||
None => {
|
||||
return Err(Error::parse_file(
|
||||
6
stages/006-simplify_opus@1/status.json
Normal file
6
stages/006-simplify_opus@1/status.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"outcome": "succeeded",
|
||||
"notes": "Stage completed: simplify_opus",
|
||||
"failure_reason": null,
|
||||
"timestamp": "2026-05-23T20:23:53.079292Z"
|
||||
}
|
||||
960
stages/007-simplify_gpt@1/prompt.md
Normal file
960
stages/007-simplify_gpt@1/prompt.md
Normal file
|
|
@ -0,0 +1,960 @@
|
|||
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_opus**: succeeded
|
||||
- Model: claude-opus-4-7, 86.2k tokens in / 29.2k out
|
||||
- Files: /home/daytona/workspace/fabro/lib/crates/fabro-config/src/legacy_sandbox_migration.rs, /home/daytona/workspace/fabro/lib/crates/fabro-config/src/load.rs
|
||||
|
||||
|
||||
# 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/007-simplify_gpt@1/provider_used.json
Normal file
5
stages/007-simplify_gpt@1/provider_used.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"mode": "agent",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.5"
|
||||
}
|
||||
18
stages/007-simplify_gpt@1/response.md
Normal file
18
stages/007-simplify_gpt@1/response.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
Implemented cleanup after reviewing the migration changes.
|
||||
|
||||
Fixed:
|
||||
- Backup writing now uses `OpenOptions::create_new(true)` to avoid a TOCTOU overwrite race when choosing numbered backup names.
|
||||
- Backup path generation no longer panics on non-UTF-8 / missing file names; it falls back to `settings.toml`.
|
||||
- Invalid legacy provider values are now reported as `run.sandbox.provider` instead of producing a later “migrated settings invalid” error.
|
||||
- Unsupported key lists are sorted/deduplicated before rendering.
|
||||
- Added a regression test for unsupported provider values.
|
||||
- Addressed clippy findings from the cleanup.
|
||||
|
||||
Verification passed:
|
||||
- `cargo test -p fabro-config legacy_sandbox_migration --quiet`
|
||||
- `cargo test -p fabro-config load_settings_path_auto_migrates_legacy_sandbox_file --quiet`
|
||||
- `cargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet`
|
||||
- `cargo test -p fabro-config --quiet`
|
||||
- `cargo +nightly-2026-04-14 fmt --check --all`
|
||||
- `cargo +nightly-2026-04-14 clippy -p fabro-config --all-targets -- -D warnings`
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
|
||||
Loading…
Add table
Reference in a new issue