diff --git a/run.json b/run.json index 4904dd3c9..c3427b71a 100644 --- a/run.json +++ b/run.json @@ -492,7 +492,7 @@ "kind": "running" }, "status_updated_at": "2026-05-23T19:56:21.662602Z", - "last_event_at": "2026-05-23T20:23:53.000860Z", + "last_event_at": "2026-05-23T20:28:00.197558Z", "pending_control": null, "checkpoints": [ { @@ -859,9 +859,9 @@ } }, { - "seq": 0, + "seq": 850, "checkpoint": { - "timestamp": "2026-05-23T20:23:53.080105Z", + "timestamp": "2026-05-23T20:23:56.878333Z", "current_node": "simplify_opus", "completed_nodes": [ "start", @@ -873,15 +873,178 @@ ], "node_retries": {}, "context_values": { + "graph.goal": "# Legacy Sandbox Config Auto-Migration Implementation Plan\n\n> **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.\n\n**Goal:** Automatically rewrite confidently migratable legacy `[run.sandbox]` config files to the named-environments syntax during startup.\n\n**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.\n\n**Tech Stack:** Rust, `toml_edit`, existing `fabro-config` settings builders, `tracing`, `tempfile` tests, public docs under `docs/public`.\n\n---\n\n## File Structure\n\n- Create `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n - Owns detection, TOML rewriting, backup naming/writing, unsupported-key diagnostics, and tests for legacy mappings.\n- Modify `lib/crates/fabro-config/src/lib.rs`\n - Register the module privately.\n- Modify `lib/crates/fabro-config/Cargo.toml`\n - Add the existing workspace `toml_edit` dependency; current main does not depend on it from `fabro-config`.\n- Modify `lib/crates/fabro-config/src/load.rs`\n - Add a small parse-failure hook that delegates to the migration module, then returns to normal parsing.\n- Modify docs:\n - `docs/public/execution/environments.mdx`\n - Create `docs/public/changelog/2026-05-23.mdx` because current main's latest public changelog is `2026-05-22.mdx`.\n\n## Migration Contract\n\nOnly migrate when all of these are true:\n\n- The file is valid TOML as a document.\n- `[run.sandbox]` exists.\n- `[run.environment]` does not exist.\n- `[environments.default]` does not exist.\n- Every legacy sandbox key is in the supported mapping below.\n- The migrated content parses successfully as `SettingsLayer`.\n\nThis 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.\n\nSupported mappings:\n\n| Legacy key | New key |\n|---|---|\n| `run.sandbox.provider` | `run.environment.id = \"default\"` and `environments.default.provider` |\n| `run.sandbox.preserve` | `environments.default.lifecycle.preserve` |\n| `run.sandbox.env` | `environments.default.env` |\n| `run.sandbox.daytona.skip_clone = true` | `run.clone.enabled = false` |\n| `run.sandbox.docker.skip_clone = true` | `run.clone.enabled = false` |\n| `run.sandbox.daytona.auto_stop_interval = N` | `environments.default.lifecycle.auto_stop = \"{N}m\"` |\n| `run.sandbox.daytona.labels` | `environments.default.labels` |\n| `run.sandbox.daytona.snapshot.name` | `environments.default.image.ref` |\n| `run.sandbox.daytona.snapshot.cpu` | `environments.default.resources.cpu` |\n| `run.sandbox.daytona.snapshot.memory` | `environments.default.resources.memory` |\n| `run.sandbox.daytona.snapshot.disk` | `environments.default.resources.disk` |\n| `run.sandbox.daytona.snapshot.dockerfile` | `environments.default.image.dockerfile` |\n| `run.sandbox.daytona.volumes[].volume_id` | `environments.default.volumes[].id` |\n| `run.sandbox.daytona.volumes[].mount_path` | `environments.default.volumes[].mount_path` |\n| `run.sandbox.daytona.volumes[].subpath` | `environments.default.volumes[].subpath` |\n| `run.sandbox.docker.image` | `environments.default.image.ref` |\n| `run.sandbox.docker.memory_limit` | `environments.default.resources.memory` |\n| `run.sandbox.docker.cpu_quota` | `environments.default.resources.cpu` when divisible by `100000` |\n\nUnsupported or ambiguous cases fail with a message shaped like:\n\n```text\nLegacy [run.sandbox] settings in could not be auto-migrated.\n\nUnsupported keys:\n - run.sandbox.daytona.foo\n - run.sandbox.docker.cpu_quota\n\nRename legacy sandbox configuration to [run.environment] and [environments.].\nSee docs/public/execution/environments.mdx.\n```\n\nSuccessful migration writes a backup next to the original file:\n\n```text\nsettings.toml.legacy-sandbox-migration.bak\nsettings.toml.legacy-sandbox-migration.1.bak\nsettings.toml.legacy-sandbox-migration.2.bak\n```\n\nSuccessful migration emits:\n\n```text\nMigrated legacy [run.sandbox] settings in to [run.environment] and [environments.default]. Backup written to . This temporary compatibility migration will be removed before v1.0.\n```\n\n## Task 1: Add the Migration Module Skeleton\n\n**Files:**\n- Create: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n- Modify: `lib/crates/fabro-config/src/lib.rs`\n- Modify: `lib/crates/fabro-config/Cargo.toml`\n\n- [ ] **Step 1: Add the `toml_edit` dependency**\n\nAdd this to `[dependencies]` in `lib/crates/fabro-config/Cargo.toml`:\n\n```toml\ntoml_edit.workspace = true\n```\n\n- [ ] **Step 2: Register the module privately**\n\nAdd this beside the other private modules in `lib/crates/fabro-config/src/lib.rs`:\n\n```rust\nmod legacy_sandbox_migration;\n```\n\n- [ ] **Step 3: Create the module API**\n\nCreate `lib/crates/fabro-config/src/legacy_sandbox_migration.rs` with this starting shape:\n\n```rust\n#![expect(\n clippy::disallowed_methods,\n reason = \"temporary startup config migration uses synchronous file I/O before config is loaded\"\n)]\n\nuse std::fmt;\nuse std::path::{Path, PathBuf};\n\nuse toml_edit::{DocumentMut, Item, Table, Value};\n\nuse crate::{Error, Result, SettingsLayer};\n\npub(crate) const REMOVAL_NOTE: &str =\n \"This temporary compatibility migration will be removed before v1.0.\";\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) struct LegacySandboxMigrationReport {\n pub(crate) contents: String,\n pub(crate) backup_path: PathBuf,\n pub(crate) warning: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\nstruct MigrationFailure {\n unsupported_keys: Vec,\n}\n\nimpl fmt::Display for MigrationFailure {\n fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n writeln!(f, \"Legacy [run.sandbox] settings could not be auto-migrated.\")?;\n writeln!(f)?;\n writeln!(f, \"Unsupported keys:\")?;\n for key in &self.unsupported_keys {\n writeln!(f, \" - {key}\")?;\n }\n writeln!(f)?;\n write!(\n f,\n \"Rename legacy sandbox configuration to [run.environment] and [environments.]. See docs/public/execution/environments.mdx.\"\n )\n }\n}\n\npub(crate) fn migrate_settings_path(\n path: &Path,\n original_contents: &str,\n) -> Result> {\n let Some(next_contents) = migrate_contents(original_contents, path)? else {\n return Ok(None);\n };\n\n next_contents\n .parse::()\n .map_err(|err| Error::parse_file(\"Migrated settings file is invalid\", path, err))?;\n\n let backup_path = next_backup_path(path);\n std::fs::write(&backup_path, original_contents).map_err(|source| {\n Error::other(format!(\n \"writing legacy sandbox migration backup {}: {source}\",\n backup_path.display()\n ))\n })?;\n std::fs::write(path, &next_contents).map_err(|source| {\n Error::other(format!(\n \"writing migrated settings file {}: {source}\",\n path.display()\n ))\n })?;\n\n let warning = format!(\n \"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.default]. Backup written to {}. {REMOVAL_NOTE}\",\n path.display(),\n backup_path.display()\n );\n\n Ok(Some(LegacySandboxMigrationReport {\n contents: next_contents,\n backup_path,\n warning,\n }))\n}\n\nfn migrate_contents(original_contents: &str, path: &Path) -> Result> {\n let mut doc = match original_contents.parse::() {\n Ok(doc) => doc,\n Err(_) => return Ok(None),\n };\n\n if !has_legacy_run_sandbox(&doc) {\n return Ok(None);\n }\n if has_new_environment_config(&doc) {\n return Err(Error::other(format!(\n \"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.\",\n path.display()\n )));\n }\n\n migrate_document(&mut doc).map_err(|failure| {\n Error::other(format!(\n \"Legacy [run.sandbox] settings in {} could not be auto-migrated.\\n\\n{}\",\n path.display(),\n failure\n ))\n })?;\n\n Ok(Some(doc.to_string()))\n}\n\nfn has_legacy_run_sandbox(doc: &DocumentMut) -> bool {\n doc.get(\"run\")\n .and_then(Item::as_table)\n .and_then(|run| run.get(\"sandbox\"))\n .is_some()\n}\n\nfn has_new_environment_config(doc: &DocumentMut) -> bool {\n let has_run_environment = doc\n .get(\"run\")\n .and_then(Item::as_table)\n .and_then(|run| run.get(\"environment\"))\n .is_some();\n let has_default_environment = doc\n .get(\"environments\")\n .and_then(Item::as_table)\n .and_then(|envs| envs.get(\"default\"))\n .is_some();\n has_run_environment || has_default_environment\n}\n\nfn next_backup_path(path: &Path) -> PathBuf {\n let base = path.with_file_name(format!(\n \"{}.legacy-sandbox-migration.bak\",\n path.file_name()\n .and_then(|name| name.to_str())\n .unwrap_or(\"settings.toml\")\n ));\n if !base.exists() {\n return base;\n }\n\n for index in 1.. {\n let candidate = path.with_file_name(format!(\n \"{}.legacy-sandbox-migration.{index}.bak\",\n path.file_name()\n .and_then(|name| name.to_str())\n .unwrap_or(\"settings.toml\")\n ));\n if !candidate.exists() {\n return candidate;\n }\n }\n unreachable!(\"unbounded backup suffix search should return\")\n}\n```\n\n- [ ] **Step 4: Add placeholder-free private stubs that compile**\n\nAdd 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:\n\n```rust\nfn migrate_document(_doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {\n Err(MigrationFailure {\n unsupported_keys: vec![\"run.sandbox\".to_string()],\n })\n}\n```\n\n- [ ] **Step 5: Run the focused compile check**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: compiles; there may be zero tests in this module at this point.\n\n## Task 2: Implement Provider-Only Migration\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add tests for provider-only migration**\n\nAdd these tests inside `legacy_sandbox_migration.rs`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use fabro_types::settings::run::EnvironmentProvider;\n\n fn migrate(source: &str) -> String {\n migrate_contents(source, Path::new(\"settings.toml\"))\n .expect(\"migration should not error\")\n .expect(\"legacy sandbox should migrate\")\n }\n\n #[test]\n fn provider_only_daytona_config_migrates_to_default_environment() {\n let migrated = migrate(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#,\n );\n\n let settings = migrated\n .parse::()\n .expect(\"migrated TOML should parse\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n .expect(\"migrated settings should resolve\")\n .run;\n\n assert_eq!(resolved.environment.id, \"default\");\n assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);\n assert!(migrated.contains(\"[run.environment]\"));\n assert!(migrated.contains(\"[environments.default]\"));\n assert!(!migrated.contains(\"[run.sandbox]\"));\n }\n\n #[test]\n fn non_legacy_config_is_not_migrated() {\n let migrated = migrate_contents(\"_version = 1\\n\", Path::new(\"settings.toml\"))\n .expect(\"non-legacy TOML should not error\");\n\n assert!(migrated.is_none());\n }\n}\n```\n\n- [ ] **Step 2: Run tests and confirm failure**\n\nRun:\n\n```bash\ncargo test -p fabro-config provider_only_daytona_config_migrates_to_default_environment --quiet\n```\n\nExpected: FAIL because `migrate_document` still returns unsupported `run.sandbox`.\n\n- [ ] **Step 3: Replace `migrate_document` with provider migration**\n\nImplement the initial migration:\n\n```rust\nfn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {\n let Some(sandbox_item) = doc\n .get(\"run\")\n .and_then(Item::as_table)\n .and_then(|run| run.get(\"sandbox\"))\n else {\n return Ok(());\n };\n let Some(sandbox) = sandbox_item.as_table().cloned() else {\n return Err(MigrationFailure {\n unsupported_keys: vec![\"run.sandbox\".to_string()],\n });\n };\n\n let mut unsupported = Vec::new();\n for (key, _) in sandbox.iter() {\n if key != \"provider\" {\n unsupported.push(format!(\"run.sandbox.{key}\"));\n }\n }\n if !unsupported.is_empty() {\n return Err(MigrationFailure {\n unsupported_keys: unsupported,\n });\n }\n\n let Some(provider) = sandbox.get(\"provider\").and_then(Item::as_str) else {\n return Err(MigrationFailure {\n unsupported_keys: vec![\"run.sandbox.provider\".to_string()],\n });\n };\n\n set_value(path_table(doc, &[\"run\", \"environment\"]), \"id\", Value::from(\"default\"));\n set_value(\n path_table(doc, &[\"environments\", \"default\"]),\n \"provider\",\n Value::from(provider),\n );\n\n remove_run_sandbox(doc);\n Ok(())\n}\n\nfn path_table<'a>(doc: &'a mut DocumentMut, path: &[&str]) -> &'a mut Table {\n let mut item = doc.as_item_mut();\n for segment in path {\n item = &mut item[segment];\n if !item.is_table() {\n *item = Item::Table(Table::new());\n }\n }\n item.as_table_mut().expect(\"path item should be a table\")\n}\n\nfn set_value(table: &mut Table, key: &str, value: Value) {\n table[key] = Item::Value(value);\n}\n\nfn remove_run_sandbox(doc: &mut DocumentMut) {\n if let Some(run) = doc.get_mut(\"run\").and_then(Item::as_table_mut) {\n run.remove(\"sandbox\");\n }\n}\n```\n\n- [ ] **Step 4: Run focused tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS.\n\n## Task 3: Add Daytona and Docker Field Mappings\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add tests for direct legacy field mappings**\n\nAdd tests that assert resolved behavior, not only string contents:\n\n```rust\n#[test]\nfn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {\n let migrated = migrate(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\npreserve = true\n\n[run.sandbox.env]\nNODE_ENV = \"development\"\n\n[run.sandbox.daytona]\nauto_stop_interval = 30\n\n[run.sandbox.daytona.labels]\nrepo = \"fabro-sh/fabro\"\n\n[run.sandbox.daytona.snapshot]\nname = \"fabro-v11\"\ncpu = 8\nmemory = \"16GB\"\ndisk = \"20GB\"\ndockerfile = { path = \"Dockerfile\" }\n\n[[run.sandbox.daytona.volumes]]\nvolume_id = \"vol_auth\"\nmount_path = \"/home/daytona/.config\"\nsubpath = \"agents\"\n\"#,\n );\n\n let settings = migrated.parse::().expect(\"migrated TOML should parse\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n .expect(\"migrated settings should resolve\")\n .run\n .environment;\n\n assert_eq!(resolved.image.reference.as_deref(), Some(\"fabro-v11\"));\n assert_eq!(resolved.resources.cpu, Some(8));\n assert_eq!(resolved.resources.memory.map(|size| size.as_bytes()), Some(16_000_000_000));\n assert_eq!(resolved.resources.disk.map(|size| size.as_bytes()), Some(20_000_000_000));\n assert!(resolved.lifecycle.preserve);\n assert_eq!(resolved.lifecycle.auto_stop.map(|duration| duration.as_std().as_secs()), Some(1800));\n assert_eq!(resolved.labels.get(\"repo\").map(String::as_str), Some(\"fabro-sh/fabro\"));\n assert_eq!(resolved.env.get(\"NODE_ENV\").map(|value| value.as_source()).as_deref(), Some(\"development\"));\n assert_eq!(resolved.volumes.len(), 1);\n assert_eq!(resolved.volumes[0].id, \"vol_auth\");\n assert_eq!(resolved.volumes[0].mount_path, \"/home/daytona/.config\");\n assert_eq!(resolved.volumes[0].subpath.as_deref(), Some(\"agents\"));\n}\n\n#[test]\nfn docker_image_memory_and_cpu_quota_migrate() {\n let migrated = migrate(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"docker\"\n\n[run.sandbox.docker]\nimage = \"buildpack-deps:noble\"\nmemory_limit = \"4GB\"\ncpu_quota = 200000\n\"#,\n );\n\n let settings = migrated.parse::().expect(\"migrated TOML should parse\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n .expect(\"migrated settings should resolve\")\n .run\n .environment;\n\n assert_eq!(resolved.provider, EnvironmentProvider::Docker);\n assert_eq!(resolved.image.reference.as_deref(), Some(\"buildpack-deps:noble\"));\n assert_eq!(resolved.resources.cpu, Some(2));\n assert_eq!(resolved.resources.memory.map(|size| size.as_bytes()), Some(4_000_000_000));\n}\n```\n\n- [ ] **Step 2: Run tests and confirm failure**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: FAIL because the two new nested-mapping tests are not implemented yet.\n\n- [ ] **Step 3: Implement table copying and value transforms**\n\nExtend `migrate_document` so it:\n\n- Allows top-level legacy keys `provider`, `preserve`, `env`, `daytona`, and `docker`.\n- Copies `run.sandbox.env` into `environments.default.env`.\n- Sets `environments.default.lifecycle.preserve` from `run.sandbox.preserve`.\n- Handles provider-specific nested mappings only for the selected provider.\n- Removes `run.sandbox` after successful migration.\n\nUse helper functions with these signatures:\n\n```rust\nfn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec);\nfn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec);\nfn copy_table(source: &Item, target: &mut Table);\nfn copy_array_of_tables_with_volume_id(source: &Item, target: &mut Table, unsupported: &mut Vec);\nfn item_path_keys(prefix: &str, item: &Item, out: &mut Vec);\n```\n\nImplementation rules:\n\n- `auto_stop_interval` must be an integer. Store `format!(\"{minutes}m\")`.\n- `docker.cpu_quota` must be an integer divisible by `100000`; otherwise add `run.sandbox.docker.cpu_quota` to unsupported keys.\n- For Daytona volumes, each array entry may contain only `volume_id`, `mount_path`, and `subpath`; rename `volume_id` to `id`.\n- `daytona.snapshot.dockerfile` must be copied as the existing TOML value, preserving inline string or `{ path = \"...\" }`.\n- When collecting unsupported nested keys, report full paths such as `run.sandbox.daytona.snapshot.foo`.\n\n- [ ] **Step 4: Run focused tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS.\n\n## Task 4: Add File Rewrite and Loader Hook\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/load.rs`\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add file rewrite tests**\n\nAdd tests:\n\n```rust\n#[test]\nfn migrate_settings_path_writes_backup_and_rewrites_original() {\n let dir = tempfile::tempdir().expect(\"temp dir\");\n let path = dir.path().join(\"settings.toml\");\n let original = r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#;\n std::fs::write(&path, original).expect(\"write fixture\");\n\n let report = migrate_settings_path(&path, original)\n .expect(\"migration should succeed\")\n .expect(\"legacy config should migrate\");\n\n let rewritten = std::fs::read_to_string(&path).expect(\"read rewritten settings\");\n let backup = std::fs::read_to_string(&report.backup_path).expect(\"read backup\");\n\n assert_eq!(backup, original);\n assert!(rewritten.contains(\"[run.environment]\"));\n assert!(rewritten.contains(\"[environments.default]\"));\n assert!(report.warning.contains(\"temporary compatibility migration\"));\n}\n\n#[test]\nfn existing_backup_uses_numbered_suffix() {\n let dir = tempfile::tempdir().expect(\"temp dir\");\n let path = dir.path().join(\"settings.toml\");\n std::fs::write(path.with_file_name(\"settings.toml.legacy-sandbox-migration.bak\"), \"old\")\n .expect(\"write existing backup\");\n\n let next = next_backup_path(&path);\n\n assert!(next.ends_with(\"settings.toml.legacy-sandbox-migration.1.bak\"));\n}\n```\n\n- [ ] **Step 2: Run tests and confirm current state**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS if Task 1 file-writing code compiled; otherwise fix only the migration module.\n\n- [ ] **Step 3: Hook migration into file loading**\n\nChange `load_settings_path` in `lib/crates/fabro-config/src/load.rs` to this shape:\n\n```rust\npub(crate) fn load_settings_path(path: &Path) -> Result {\n let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?;\n let mut layer = match content.parse::() {\n Ok(layer) => layer,\n Err(err) => match crate::legacy_sandbox_migration::migrate_settings_path(path, &content)? {\n Some(report) => {\n tracing::warn!(\"{}\", report.warning);\n eprintln!(\"{}\", report.warning);\n report.contents.parse::().map_err(|err| {\n Error::parse_file(\"Migrated settings file is invalid\", path, err)\n })?\n }\n None => return Err(Error::parse_file(\"Failed to parse settings file\", path, err)),\n },\n };\n let base_dir = path.parent().unwrap_or_else(|| Path::new(\".\"));\n resolve_goal_file_paths(&mut layer, base_dir);\n Ok(layer)\n}\n```\n\n- [ ] **Step 4: Run loader-level verification**\n\nAdd a test in `load.rs` under `#[cfg(test)]` if the file does not already have a test module:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use fabro_types::settings::run::EnvironmentProvider;\n\n #[test]\n fn load_settings_path_auto_migrates_legacy_sandbox_file() {\n let dir = tempfile::tempdir().expect(\"temp dir\");\n let path = dir.path().join(\"settings.toml\");\n std::fs::write(\n &path,\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#,\n )\n .expect(\"write legacy settings\");\n\n let layer = load_settings_path(&path).expect(\"legacy settings should auto-migrate\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&layer)\n .expect(\"migrated settings should resolve\")\n .run;\n\n assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);\n assert!(std::fs::read_to_string(&path)\n .expect(\"read rewritten settings\")\n .contains(\"[run.environment]\"));\n }\n}\n```\n\nRun:\n\n```bash\ncargo test -p fabro-config load_settings_path_auto_migrates_legacy_sandbox_file --quiet\n```\n\nExpected: PASS.\n\n- [ ] **Step 5: Verify in-memory TOML parsing remains strict**\n\nRun the existing current-main rejection test:\n\n```bash\ncargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet\n```\n\nExpected: PASS. Do not weaken `SettingsLayer` deserialization to accept `run.sandbox`; only `load_settings_path` should rewrite files from disk.\n\n## Task 5: Unsupported and Ambiguous Cases\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add failure tests**\n\nAdd tests:\n\n```rust\n#[test]\nfn existing_new_environment_config_is_ambiguous() {\n let err = migrate_contents(\n r#\"\n_version = 1\n\n[run.environment]\nid = \"default\"\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#,\n Path::new(\"settings.toml\"),\n )\n .expect_err(\"mixed old and new config should fail\");\n\n assert!(err.to_string().contains(\"already contains [run.environment]\"));\n}\n\n#[test]\nfn unsupported_keys_are_reported_with_full_paths() {\n let err = migrate_contents(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\n[run.sandbox.daytona]\nunknown = true\n\"#,\n Path::new(\"settings.toml\"),\n )\n .expect_err(\"unsupported keys should fail migration\");\n\n let rendered = err.to_string();\n assert!(rendered.contains(\"run.sandbox.daytona.unknown\"));\n assert!(rendered.contains(\"docs/public/execution/environments.mdx\"));\n}\n\n#[test]\nfn unsupported_docker_cpu_quota_is_reported() {\n let err = migrate_contents(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"docker\"\n\n[run.sandbox.docker]\ncpu_quota = 250000\n\"#,\n Path::new(\"settings.toml\"),\n )\n .expect_err(\"non-divisible cpu quota should fail migration\");\n\n assert!(err.to_string().contains(\"run.sandbox.docker.cpu_quota\"));\n}\n```\n\n- [ ] **Step 2: Run failure tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS.\n\n## Task 6: Documentation\n\n**Files:**\n- Modify: `docs/public/execution/environments.mdx`\n- Create: `docs/public/changelog/2026-05-23.mdx`\n\n- [ ] **Step 1: Document temporary auto-migration**\n\nAdd this note near the top of `docs/public/execution/environments.mdx`, after the initial environment/sandbox distinction:\n\n```mdx\n\nOlder 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.\n\nThis 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.\n\n```\n\n- [ ] **Step 2: Add a changelog note**\n\nCreate `docs/public/changelog/2026-05-23.mdx`:\n\n```mdx\n---\ntitle: \"Legacy sandbox config migration\"\ndate: \"2026-05-23\"\n---\n\n## Legacy sandbox config auto-migration\n\nFabro 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.\n```\n\n- [ ] **Step 3: Check docs references**\n\nRun:\n\n```bash\nrg -n \"\\\\[run\\\\.sandbox\\\\]|legacy-sandbox-migration|run\\\\.environment\" docs/public/execution docs/public/changelog\n```\n\nExpected: remaining `[run.sandbox]` references are either historical changelog entries or explicit migration warnings.\n\n## Task 7: Full Verification\n\n**Files:**\n- All files touched above.\n\n- [ ] **Step 1: Run config crate tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config --quiet\n```\n\nExpected: PASS.\n\n- [ ] **Step 2: Run formatting check**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 fmt --check --all\n```\n\nExpected: PASS.\n\n- [ ] **Step 3: Optional workspace lint if formatting and tests pass**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\nExpected: PASS. If this is slow, record that it was not run and include the reason in the handoff.\n\n## Acceptance Criteria\n\n- Boot-time config loading rewrites simple legacy `[run.sandbox]` files without user action.\n- The rewritten file uses `[run.environment] id = \"default\"` and `[environments.default]`.\n- The original file is preserved in a sibling backup before rewrite.\n- Unsupported legacy keys fail with a targeted migration message listing exact keys.\n- Normal strict schema behavior remains unchanged for in-memory `from_toml` calls.\n- All legacy migration code is isolated in `legacy_sandbox_migration.rs` and removable before v1.0.\n", "outcome": "succeeded", + "response.simplify_opus": "Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:\n\n**Fixes applied:**\n1. **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`.\n2. **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.\n3. **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()`.\n4. **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.\n5. **Trivial wrappers** (Agent 1 #9, Agent 2 smaller): Dropped `set_value` and `set_item`; callers use `table[key] = …` directly.\n6. **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.\n7. **Double-report bug** (Agent 2 smaller): `item_path_keys` now stops after reporting an empty array-of-tables instead of re-pushing per element.\n8. **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`.\n9. **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]`.\n\n**Skipped (deliberate):**\n- 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.\n- Promoting `next_backup_path` to `fabro-util` (Agent 1 #4, #7) — this file is going away pre-v1.0.\n- `user.rs` test fix concerns (Agent 2 #5) — works correctly, leave alone.\n- Test fixture builder consolidation — legacy TOML shape is the point of the tests.\n\n**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).", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "internal.retry_count.start": 0, + "internal.thread_id": "implement", + "internal.retry_count.toolchain": 0, "internal.work_dir": "/home/daytona/workspace/fabro", "last_response": "Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:\n\n**Fixes applied:**\n1. **Correctness bug** (Agent 2 #1): `skip_clone = false` (the default) was wrongly", + "failure_signature": "", + "thread.implement.current_node": "simplify_opus", + "graph.rankdir": "LR", + "thread.preflight_compile.current_node": "preflight_lint", + "thread.preflight_lint.current_node": "implement", "thread.start.current_node": "toolchain", - "internal.thread_id": "implement", - "response.simplify_opus": "Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:\n\n**Fixes applied:**\n1. **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`.\n2. **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.\n3. **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()`.\n4. **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.\n5. **Trivial wrappers** (Agent 1 #9, Agent 2 smaller): Dropped `set_value` and `set_item`; callers use `table[key] = …` directly.\n6. **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.\n7. **Double-report bug** (Agent 2 smaller): `item_path_keys` now stops after reporting an empty array-of-tables instead of re-pushing per element.\n8. **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`.\n9. **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]`.\n\n**Skipped (deliberate):**\n- 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.\n- Promoting `next_backup_path` to `fabro-util` (Agent 1 #4, #7) — this file is going away pre-v1.0.\n- `user.rs` test fix concerns (Agent 2 #5) — works correctly, leave alone.\n- Test fixture builder consolidation — legacy TOML shape is the point of the tests.\n\n**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).", + "thread.toolchain.current_node": "preflight_compile", + "internal.run_id": "01KSB6HFNMJ802AXGBAV5JP7ZS", + "response.implement": "Implemented the legacy `[run.sandbox]` auto-migration.\n\nSummary:\n- Added isolated `fabro-config` migration module:\n - Detects legacy `[run.sandbox]`\n - Rewrites to `[run.environment] id = \"default\"` plus `[environments.default]`\n - Supports Daytona/Docker field mappings, `preserve`, `env`, `skip_clone = true`, labels, snapshots, volumes, Docker image/memory/cpu quota\n - Writes numbered sibling backups\n - Reports unsupported/ambiguous keys with full paths\n- Hooked migration only into file loading via `load_settings_path`; in-memory parsing remains strict.\n- Added migration tests, loader test, backup tests, failure diagnostics tests, and `skip_clone` coverage.\n- Added docs warning and new changelog entry.\n- Added a small existing test isolation fix in `fabro-config/src/user.rs` to prevent `FABRO_HOME` races during full crate tests.\n\nVerification run:\n- `cargo test -p fabro-config --quiet` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet` ✅\n- Loader migration focused test ✅\n\nDocs reference check:\n- Requested `rg ...` command could not run because `rg` is not installed in the environment.\n- Used an equivalent `grep -R -n -E ...` check instead; remaining `[run.sandbox]` references are migration/removal notes or historical changelog entries.", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "last_stage": "simplify_opus", + "current_node": "simplify_opus", + "internal.fidelity": "compact", + "internal.node_visit_count": 1, "internal.retry_count.preflight_compile": 0, "internal.retry_count.preflight_lint": 0, "failure_class": "", + "internal.retry_count.implement": 0, + "internal.retry_count.simplify_opus": 0 + }, + "node_outcomes": { + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null + }, + "start": { + "status": "succeeded", + "usage": null + }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "last_stage": "simplify_opus", + "last_response": "Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:\n\n**Fixes applied:**\n1. **Correctness bug** (Agent 2 #1): `skip_clone = false` (the default) was wrongly", + "response.simplify_opus": "Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:\n\n**Fixes applied:**\n1. **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`.\n2. **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.\n3. **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()`.\n4. **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.\n5. **Trivial wrappers** (Agent 1 #9, Agent 2 smaller): Dropped `set_value` and `set_item`; callers use `table[key] = …` directly.\n6. **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.\n7. **Double-report bug** (Agent 2 smaller): `item_path_keys` now stops after reporting an empty array-of-tables instead of re-pushing per element.\n8. **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`.\n9. **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]`.\n\n**Skipped (deliberate):**\n- 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.\n- Promoting `next_backup_path` to `fabro-util` (Agent 1 #4, #7) — this file is going away pre-v1.0.\n- `user.rs` test fix concerns (Agent 2 #5) — works correctly, leave alone.\n- Test fixture builder consolidation — legacy TOML shape is the point of the tests.\n\n**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)." + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 86218, + "output_tokens": 29230, + "reasoning_tokens": 0, + "cache_read_tokens": 3120558, + "cache_write_tokens": 305320 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 305320, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 4630369 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-config/src/legacy_sandbox_migration.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-config/src/load.rs" + ] + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: 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", + "usage": null + }, + "implement": { + "status": "succeeded", + "context_updates": { + "response.implement": "Implemented the legacy `[run.sandbox]` auto-migration.\n\nSummary:\n- Added isolated `fabro-config` migration module:\n - Detects legacy `[run.sandbox]`\n - Rewrites to `[run.environment] id = \"default\"` plus `[environments.default]`\n - Supports Daytona/Docker field mappings, `preserve`, `env`, `skip_clone = true`, labels, snapshots, volumes, Docker image/memory/cpu quota\n - Writes numbered sibling backups\n - Reports unsupported/ambiguous keys with full paths\n- Hooked migration only into file loading via `load_settings_path`; in-memory parsing remains strict.\n- Added migration tests, loader test, backup tests, failure diagnostics tests, and `skip_clone` coverage.\n- Added docs warning and new changelog entry.\n- Added a small existing test isolation fix in `fabro-config/src/user.rs` to prevent `FABRO_HOME` races during full crate tests.\n\nVerification run:\n- `cargo test -p fabro-config --quiet` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` ✅\n- `cargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet` ✅\n- Loader migration focused test ✅\n\nDocs reference check:\n- Requested `rg ...` command could not run because `rg` is not installed in the environment.\n- Used an equivalent `grep -R -n -E ...` check instead; remaining `[run.sandbox]` references are migration/removal notes or historical changelog entries.", + "last_stage": "implement", + "last_response": "Implemented the legacy `[run.sandbox]` auto-migration.\n\nSummary:\n- Added isolated `fabro-config` migration module:\n - Detects legacy `[run.sandbox]`\n - Rewrites to `[run.environment] id = \"default\"`" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 148246, + "output_tokens": 18262, + "reasoning_tokens": 13644, + "cache_read_tokens": 8010240, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 5703530 + } + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null + } + }, + "next_node_id": "simplify_gpt", + "git_commit_sha": "ef89c0d5101d6d66a4a8e61e82c83b8698a8542a", + "node_visits": { + "implement": 1, + "preflight_lint": 1, + "start": 1, + "toolchain": 1, + "simplify_opus": 1, + "preflight_compile": 1 + } + }, + "diff": { + "patch": "diff --git a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs\nindex a8d428929..4ed823128 100644\n--- a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs\n+++ b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs\n@@ -5,7 +5,9 @@\n \n use std::fmt;\n use std::path::{Path, PathBuf};\n+use std::str::FromStr;\n \n+use fabro_types::settings::run::EnvironmentProvider;\n use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value};\n \n use crate::{Error, Result, SettingsLayer};\n@@ -13,11 +15,12 @@ use crate::{Error, Result, SettingsLayer};\n pub(crate) const REMOVAL_NOTE: &str =\n \"This temporary compatibility migration will be removed before v1.0.\";\n \n-#[derive(Debug, Clone, PartialEq, Eq)]\n+#[derive(Debug)]\n pub(crate) struct LegacySandboxMigrationReport {\n- pub(crate) contents: String,\n- pub(crate) backup_path: PathBuf,\n- pub(crate) warning: String,\n+ pub(crate) layer: SettingsLayer,\n+ pub(crate) warning: String,\n+ #[cfg(test)]\n+ backup_path: PathBuf,\n }\n \n #[derive(Debug, Clone, PartialEq, Eq)]\n@@ -47,7 +50,7 @@ pub(crate) fn migrate_settings_path(\n return Ok(None);\n };\n \n- next_contents\n+ let layer = next_contents\n .parse::()\n .map_err(|err| Error::parse_file(\"Migrated settings file is invalid\", path, err))?;\n \n@@ -72,9 +75,10 @@ pub(crate) fn migrate_settings_path(\n );\n \n Ok(Some(LegacySandboxMigrationReport {\n- contents: next_contents,\n- backup_path,\n+ layer,\n warning,\n+ #[cfg(test)]\n+ backup_path,\n }))\n }\n \n@@ -146,63 +150,63 @@ fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationF\n }\n }\n \n- let provider = sandbox.get(\"provider\").and_then(Item::as_str);\n- let provider_key = provider.unwrap_or_default().to_ascii_lowercase();\n- if provider.is_none() {\n+ let provider_str = sandbox.get(\"provider\").and_then(Item::as_str);\n+ let active_provider = provider_str.and_then(|s| EnvironmentProvider::from_str(s).ok());\n+ if provider_str.is_none() {\n unsupported.push(\"run.sandbox.provider\".to_string());\n }\n \n- match provider_key.as_str() {\n- \"daytona\" => {\n- migrate_skip_clone(doc, &sandbox, \"daytona\");\n- reject_provider_table(&sandbox, \"docker\", &mut unsupported);\n- }\n- \"docker\" => {\n- migrate_skip_clone(doc, &sandbox, \"docker\");\n- reject_provider_table(&sandbox, \"daytona\", &mut unsupported);\n- }\n- _ => {\n- reject_provider_table(&sandbox, \"daytona\", &mut unsupported);\n- reject_provider_table(&sandbox, \"docker\", &mut unsupported);\n+ // Inspect each provider's table once: if it's the active provider, capture\n+ // skip_clone; otherwise, report it as unsupported.\n+ let mut disable_clone = false;\n+ for provider in [EnvironmentProvider::Daytona, EnvironmentProvider::Docker] {\n+ let key: &'static str = provider.into();\n+ let Some(item) = sandbox.get(key) else {\n+ continue;\n+ };\n+ if Some(provider) == active_provider {\n+ if item\n+ .as_table()\n+ .and_then(|table| table.get(\"skip_clone\"))\n+ .and_then(Item::as_bool)\n+ .unwrap_or(false)\n+ {\n+ disable_clone = true;\n+ }\n+ } else {\n+ item_path_keys(&format!(\"run.sandbox.{key}\"), item, &mut unsupported);\n }\n }\n \n- set_value(\n- path_table(doc, &[\"run\", \"environment\"]),\n- \"id\",\n- Value::from(\"default\"),\n- );\n- if let Some(provider) = provider {\n- set_value(\n- path_table(doc, &[\"environments\", \"default\"]),\n- \"provider\",\n- Value::from(provider),\n- );\n+ if disable_clone {\n+ ensure_table(doc.as_table_mut(), &[\"run\", \"clone\"])[\"enabled\"] =\n+ Item::Value(Value::from(false));\n }\n+ ensure_table(doc.as_table_mut(), &[\"run\", \"environment\"])[\"id\"] =\n+ Item::Value(Value::from(\"default\"));\n \n- let env = path_table(doc, &[\"environments\", \"default\"]);\n+ let env = ensure_table(doc.as_table_mut(), &[\"environments\", \"default\"]);\n+ if let Some(p) = provider_str {\n+ env[\"provider\"] = Item::Value(Value::from(p));\n+ }\n if let Some(preserve) = sandbox.get(\"preserve\") {\n if preserve.as_bool().is_some() {\n- set_item(\n- path_table_in_table(env, &[\"lifecycle\"]),\n- \"preserve\",\n- preserve.clone(),\n- );\n+ ensure_table(env, &[\"lifecycle\"])[\"preserve\"] = preserve.clone();\n } else {\n unsupported.push(\"run.sandbox.preserve\".to_string());\n }\n }\n if let Some(env_item) = sandbox.get(\"env\") {\n- if is_table_like(env_item) {\n- copy_table(env_item, path_table_in_table(env, &[\"env\"]));\n+ if env_item.is_table_like() {\n+ copy_table(env_item, ensure_table(env, &[\"env\"]));\n } else {\n unsupported.push(\"run.sandbox.env\".to_string());\n }\n }\n \n- match provider_key.as_str() {\n- \"daytona\" => migrate_daytona(&sandbox, env, &mut unsupported),\n- \"docker\" => migrate_docker(&sandbox, env, &mut unsupported),\n+ match active_provider {\n+ Some(EnvironmentProvider::Daytona) => migrate_daytona(&sandbox, env, &mut unsupported),\n+ Some(EnvironmentProvider::Docker) => migrate_docker(&sandbox, env, &mut unsupported),\n _ => {}\n }\n \n@@ -216,29 +220,6 @@ fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationF\n Ok(())\n }\n \n-fn migrate_skip_clone(doc: &mut DocumentMut, sandbox: &Table, provider: &str) {\n- let Some(provider_table) = sandbox.get(provider).and_then(Item::as_table) else {\n- return;\n- };\n- if provider_table\n- .get(\"skip_clone\")\n- .and_then(Item::as_bool)\n- .unwrap_or(false)\n- {\n- set_value(\n- path_table(doc, &[\"run\", \"clone\"]),\n- \"enabled\",\n- Value::from(false),\n- );\n- }\n-}\n-\n-fn reject_provider_table(sandbox: &Table, provider: &str, unsupported: &mut Vec) {\n- if let Some(item) = sandbox.get(provider) {\n- item_path_keys(&format!(\"run.sandbox.{provider}\"), item, unsupported);\n- }\n-}\n-\n fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec) {\n let Some(daytona_item) = sandbox.get(\"daytona\") else {\n return;\n@@ -251,24 +232,21 @@ fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec {\n- if item.as_bool() != Some(true) {\n+ if item.as_bool().is_none() {\n unsupported.push(\"run.sandbox.daytona.skip_clone\".to_string());\n }\n }\n \"auto_stop_interval\" => {\n if let Some(minutes) = item.as_integer().filter(|minutes| *minutes >= 0) {\n- set_value(\n- path_table_in_table(env, &[\"lifecycle\"]),\n- \"auto_stop\",\n- Value::from(format!(\"{minutes}m\")),\n- );\n+ ensure_table(env, &[\"lifecycle\"])[\"auto_stop\"] =\n+ Item::Value(Value::from(format!(\"{minutes}m\")));\n } else {\n unsupported.push(\"run.sandbox.daytona.auto_stop_interval\".to_string());\n }\n }\n \"labels\" => {\n- if is_table_like(item) {\n- copy_table(item, path_table_in_table(env, &[\"labels\"]));\n+ if item.is_table_like() {\n+ copy_table(item, ensure_table(env, &[\"labels\"]));\n } else {\n unsupported.push(\"run.sandbox.daytona.labels\".to_string());\n }\n@@ -288,27 +266,11 @@ fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported:\n \n for (key, item) in snapshot {\n match key {\n- \"name\" => set_item(path_table_in_table(env, &[\"image\"]), \"ref\", item.clone()),\n- \"cpu\" => set_item(\n- path_table_in_table(env, &[\"resources\"]),\n- \"cpu\",\n- item.clone(),\n- ),\n- \"memory\" => set_item(\n- path_table_in_table(env, &[\"resources\"]),\n- \"memory\",\n- item.clone(),\n- ),\n- \"disk\" => set_item(\n- path_table_in_table(env, &[\"resources\"]),\n- \"disk\",\n- item.clone(),\n- ),\n- \"dockerfile\" => set_item(\n- path_table_in_table(env, &[\"image\"]),\n- \"dockerfile\",\n- item.clone(),\n- ),\n+ \"name\" => ensure_table(env, &[\"image\"])[\"ref\"] = item.clone(),\n+ \"cpu\" => ensure_table(env, &[\"resources\"])[\"cpu\"] = item.clone(),\n+ \"memory\" => ensure_table(env, &[\"resources\"])[\"memory\"] = item.clone(),\n+ \"disk\" => ensure_table(env, &[\"resources\"])[\"disk\"] = item.clone(),\n+ \"dockerfile\" => ensure_table(env, &[\"image\"])[\"dockerfile\"] = item.clone(),\n _ => item_path_keys(\n &format!(\"run.sandbox.daytona.snapshot.{key}\"),\n item,\n@@ -330,26 +292,19 @@ fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec {\n- if item.as_bool() != Some(true) {\n+ if item.as_bool().is_none() {\n unsupported.push(\"run.sandbox.docker.skip_clone\".to_string());\n }\n }\n- \"image\" => set_item(path_table_in_table(env, &[\"image\"]), \"ref\", item.clone()),\n- \"memory_limit\" => set_item(\n- path_table_in_table(env, &[\"resources\"]),\n- \"memory\",\n- item.clone(),\n- ),\n+ \"image\" => ensure_table(env, &[\"image\"])[\"ref\"] = item.clone(),\n+ \"memory_limit\" => ensure_table(env, &[\"resources\"])[\"memory\"] = item.clone(),\n \"cpu_quota\" => {\n if let Some(cpu_quota) = item.as_integer() {\n let cpu_count = cpu_quota / 100_000;\n if cpu_quota > 0 && cpu_quota % 100_000 == 0 && i32::try_from(cpu_count).is_ok()\n {\n- set_value(\n- path_table_in_table(env, &[\"resources\"]),\n- \"cpu\",\n- Value::from(cpu_count),\n- );\n+ ensure_table(env, &[\"resources\"])[\"cpu\"] =\n+ Item::Value(Value::from(cpu_count));\n } else {\n unsupported.push(\"run.sandbox.docker.cpu_quota\".to_string());\n }\n@@ -362,18 +317,7 @@ fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec(doc: &'a mut DocumentMut, path: &[&str]) -> &'a mut Table {\n- let mut item = doc.as_item_mut();\n- for segment in path {\n- item = &mut item[segment];\n- if !item.is_table() {\n- *item = Item::Table(Table::new());\n- }\n- }\n- item.as_table_mut().expect(\"path item should be a table\")\n-}\n-\n-fn path_table_in_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut Table {\n+fn ensure_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut Table {\n let mut table = table;\n for segment in path {\n let item = &mut table[segment];\n@@ -385,30 +329,12 @@ fn path_table_in_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut Table\n table\n }\n \n-fn set_value(table: &mut Table, key: &str, value: Value) {\n- table[key] = Item::Value(value);\n-}\n-\n-fn set_item(table: &mut Table, key: &str, item: Item) {\n- table[key] = item;\n-}\n-\n-fn is_table_like(item: &Item) -> bool {\n- item.is_table() || item.as_value().and_then(Value::as_inline_table).is_some()\n-}\n-\n fn copy_table(source: &Item, target: &mut Table) {\n- if let Some(table) = source.as_table() {\n- for (key, item) in table {\n- target[key] = item.clone();\n- }\n+ let Some(src) = source.as_table_like() else {\n return;\n- }\n-\n- if let Some(inline_table) = source.as_value().and_then(Value::as_inline_table) {\n- for (key, value) in inline_table {\n- target[key] = Item::Value(value.clone());\n- }\n+ };\n+ for (key, item) in src.iter() {\n+ target[key] = item.clone();\n }\n }\n \n@@ -431,13 +357,13 @@ fn copy_array_of_tables_with_volume_id(\n match key {\n \"volume_id\" => {\n has_id = true;\n- set_item(&mut migrated_volume, \"id\", item.clone());\n+ migrated_volume[\"id\"] = item.clone();\n }\n \"mount_path\" => {\n has_mount_path = true;\n- set_item(&mut migrated_volume, \"mount_path\", item.clone());\n+ migrated_volume[\"mount_path\"] = item.clone();\n }\n- \"subpath\" => set_item(&mut migrated_volume, \"subpath\", item.clone()),\n+ \"subpath\" => migrated_volume[\"subpath\"] = item.clone(),\n _ => item_path_keys(\n &format!(\"run.sandbox.daytona.volumes.{key}\"),\n item,\n@@ -470,6 +396,7 @@ fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec) {\n if let Some(array) = item.as_array_of_tables() {\n if array.is_empty() {\n out.push(prefix.to_string());\n+ return;\n }\n for table in array {\n if table.is_empty() {\n@@ -492,23 +419,18 @@ fn remove_run_sandbox(doc: &mut DocumentMut) {\n }\n \n fn next_backup_path(path: &Path) -> PathBuf {\n- let base = path.with_file_name(format!(\n- \"{}.legacy-sandbox-migration.bak\",\n- path.file_name()\n- .and_then(|name| name.to_str())\n- .unwrap_or(\"settings.toml\")\n- ));\n+ let file_name = path\n+ .file_name()\n+ .and_then(|name| name.to_str())\n+ .expect(\"settings path always has a UTF-8 file name\");\n+ let base = path.with_file_name(format!(\"{file_name}.legacy-sandbox-migration.bak\"));\n if !base.exists() {\n return base;\n }\n \n- for index in 1.. {\n- let candidate = path.with_file_name(format!(\n- \"{}.legacy-sandbox-migration.{index}.bak\",\n- path.file_name()\n- .and_then(|name| name.to_str())\n- .unwrap_or(\"settings.toml\")\n- ));\n+ for index in 1u32.. {\n+ let candidate =\n+ path.with_file_name(format!(\"{file_name}.legacy-sandbox-migration.{index}.bak\"));\n if !candidate.exists() {\n return candidate;\n }\n@@ -519,7 +441,6 @@ fn next_backup_path(path: &Path) -> PathBuf {\n #[cfg(test)]\n mod tests {\n use fabro_types::settings::InterpString;\n- use fabro_types::settings::run::EnvironmentProvider;\n \n use super::*;\n \n@@ -796,4 +717,28 @@ cpu_quota = 250000\n \n assert!(err.to_string().contains(\"run.sandbox.docker.cpu_quota\"));\n }\n+\n+ #[test]\n+ fn explicit_skip_clone_false_is_accepted_as_default() {\n+ let migrated = migrate(\n+ r#\"\n+_version = 1\n+\n+[run.sandbox]\n+provider = \"docker\"\n+\n+[run.sandbox.docker]\n+skip_clone = false\n+\"#,\n+ );\n+\n+ let settings = migrated\n+ .parse::()\n+ .expect(\"migrated TOML should parse\");\n+ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n+ .expect(\"migrated settings should resolve\")\n+ .run;\n+\n+ assert!(resolved.clone.enabled);\n+ }\n }\ndiff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs\nindex 94eba41fc..37ec2f582 100644\n--- a/lib/crates/fabro-config/src/load.rs\n+++ b/lib/crates/fabro-config/src/load.rs\n@@ -21,9 +21,7 @@ pub(crate) fn load_settings_path(path: &Path) -> Result {\n Some(report) => {\n tracing::warn!(\"{}\", report.warning);\n eprintln!(\"{}\", report.warning);\n- report.contents.parse::().map_err(|err| {\n- Error::parse_file(\"Migrated settings file is invalid\", path, err)\n- })?\n+ report.layer\n }\n None => {\n return Err(Error::parse_file(\n", + "summary": { + "files_changed": 8, + "additions": 831, + "deletions": 12 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-23T20:28:00.249714Z", + "current_node": "simplify_gpt", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus", + "simplify_gpt" + ], + "node_retries": {}, + "context_values": { + "outcome": "succeeded", + "internal.work_dir": "/home/daytona/workspace/fabro", + "last_response": "Implemented cleanup after reviewing the migration changes.\n\nFixed:\n- Backup writing now uses `OpenOptions::create_new(true)` to avoid a TOCTOU overwrite race when choosing numbered backup names.\n- Bac", + "thread.start.current_node": "toolchain", + "internal.thread_id": "simplify_opus", + "response.simplify_opus": "Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review:\n\n**Fixes applied:**\n1. **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`.\n2. **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.\n3. **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()`.\n4. **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.\n5. **Trivial wrappers** (Agent 1 #9, Agent 2 smaller): Dropped `set_value` and `set_item`; callers use `table[key] = …` directly.\n6. **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.\n7. **Double-report bug** (Agent 2 smaller): `item_path_keys` now stops after reporting an empty array-of-tables instead of re-pushing per element.\n8. **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`.\n9. **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]`.\n\n**Skipped (deliberate):**\n- 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.\n- Promoting `next_backup_path` to `fabro-util` (Agent 1 #4, #7) — this file is going away pre-v1.0.\n- `user.rs` test fix concerns (Agent 2 #5) — works correctly, leave alone.\n- Test fixture builder consolidation — legacy TOML shape is the point of the tests.\n\n**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).", + "thread.simplify_opus.current_node": "simplify_gpt", + "internal.retry_count.preflight_compile": 0, + "internal.retry_count.preflight_lint": 0, + "internal.retry_count.simplify_gpt": 0, + "failure_class": "", "thread.toolchain.current_node": "preflight_compile", "failure_signature": "", "thread.implement.current_node": "simplify_opus", @@ -892,8 +1055,9 @@ "internal.retry_count.implement": 0, "graph.rankdir": "LR", "internal.run_id": "01KSB6HFNMJ802AXGBAV5JP7ZS", - "last_stage": "simplify_opus", - "current_node": "simplify_opus", + "last_stage": "simplify_gpt", + "current_node": "simplify_gpt", + "response.simplify_gpt": "Implemented cleanup after reviewing the migration changes.\n\nFixed:\n- Backup writing now uses `OpenOptions::create_new(true)` to avoid a TOCTOU overwrite race when choosing numbered backup names.\n- Backup path generation no longer panics on non-UTF-8 / missing file names; it falls back to `settings.toml`.\n- Invalid legacy provider values are now reported as `run.sandbox.provider` instead of producing a later “migrated settings invalid” error.\n- Unsupported key lists are sorted/deduplicated before rendering.\n- Added a regression test for unsupported provider values.\n- Addressed clippy findings from the cleanup.\n\nVerification passed:\n- `cargo test -p fabro-config legacy_sandbox_migration --quiet`\n- `cargo test -p fabro-config load_settings_path_auto_migrates_legacy_sandbox_file --quiet`\n- `cargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet`\n- `cargo test -p fabro-config --quiet`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -p fabro-config --all-targets -- -D warnings`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`", "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "graph.goal": "# Legacy Sandbox Config Auto-Migration Implementation Plan\n\n> **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.\n\n**Goal:** Automatically rewrite confidently migratable legacy `[run.sandbox]` config files to the named-environments syntax during startup.\n\n**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.\n\n**Tech Stack:** Rust, `toml_edit`, existing `fabro-config` settings builders, `tracing`, `tempfile` tests, public docs under `docs/public`.\n\n---\n\n## File Structure\n\n- Create `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n - Owns detection, TOML rewriting, backup naming/writing, unsupported-key diagnostics, and tests for legacy mappings.\n- Modify `lib/crates/fabro-config/src/lib.rs`\n - Register the module privately.\n- Modify `lib/crates/fabro-config/Cargo.toml`\n - Add the existing workspace `toml_edit` dependency; current main does not depend on it from `fabro-config`.\n- Modify `lib/crates/fabro-config/src/load.rs`\n - Add a small parse-failure hook that delegates to the migration module, then returns to normal parsing.\n- Modify docs:\n - `docs/public/execution/environments.mdx`\n - Create `docs/public/changelog/2026-05-23.mdx` because current main's latest public changelog is `2026-05-22.mdx`.\n\n## Migration Contract\n\nOnly migrate when all of these are true:\n\n- The file is valid TOML as a document.\n- `[run.sandbox]` exists.\n- `[run.environment]` does not exist.\n- `[environments.default]` does not exist.\n- Every legacy sandbox key is in the supported mapping below.\n- The migrated content parses successfully as `SettingsLayer`.\n\nThis 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.\n\nSupported mappings:\n\n| Legacy key | New key |\n|---|---|\n| `run.sandbox.provider` | `run.environment.id = \"default\"` and `environments.default.provider` |\n| `run.sandbox.preserve` | `environments.default.lifecycle.preserve` |\n| `run.sandbox.env` | `environments.default.env` |\n| `run.sandbox.daytona.skip_clone = true` | `run.clone.enabled = false` |\n| `run.sandbox.docker.skip_clone = true` | `run.clone.enabled = false` |\n| `run.sandbox.daytona.auto_stop_interval = N` | `environments.default.lifecycle.auto_stop = \"{N}m\"` |\n| `run.sandbox.daytona.labels` | `environments.default.labels` |\n| `run.sandbox.daytona.snapshot.name` | `environments.default.image.ref` |\n| `run.sandbox.daytona.snapshot.cpu` | `environments.default.resources.cpu` |\n| `run.sandbox.daytona.snapshot.memory` | `environments.default.resources.memory` |\n| `run.sandbox.daytona.snapshot.disk` | `environments.default.resources.disk` |\n| `run.sandbox.daytona.snapshot.dockerfile` | `environments.default.image.dockerfile` |\n| `run.sandbox.daytona.volumes[].volume_id` | `environments.default.volumes[].id` |\n| `run.sandbox.daytona.volumes[].mount_path` | `environments.default.volumes[].mount_path` |\n| `run.sandbox.daytona.volumes[].subpath` | `environments.default.volumes[].subpath` |\n| `run.sandbox.docker.image` | `environments.default.image.ref` |\n| `run.sandbox.docker.memory_limit` | `environments.default.resources.memory` |\n| `run.sandbox.docker.cpu_quota` | `environments.default.resources.cpu` when divisible by `100000` |\n\nUnsupported or ambiguous cases fail with a message shaped like:\n\n```text\nLegacy [run.sandbox] settings in could not be auto-migrated.\n\nUnsupported keys:\n - run.sandbox.daytona.foo\n - run.sandbox.docker.cpu_quota\n\nRename legacy sandbox configuration to [run.environment] and [environments.].\nSee docs/public/execution/environments.mdx.\n```\n\nSuccessful migration writes a backup next to the original file:\n\n```text\nsettings.toml.legacy-sandbox-migration.bak\nsettings.toml.legacy-sandbox-migration.1.bak\nsettings.toml.legacy-sandbox-migration.2.bak\n```\n\nSuccessful migration emits:\n\n```text\nMigrated legacy [run.sandbox] settings in to [run.environment] and [environments.default]. Backup written to . This temporary compatibility migration will be removed before v1.0.\n```\n\n## Task 1: Add the Migration Module Skeleton\n\n**Files:**\n- Create: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n- Modify: `lib/crates/fabro-config/src/lib.rs`\n- Modify: `lib/crates/fabro-config/Cargo.toml`\n\n- [ ] **Step 1: Add the `toml_edit` dependency**\n\nAdd this to `[dependencies]` in `lib/crates/fabro-config/Cargo.toml`:\n\n```toml\ntoml_edit.workspace = true\n```\n\n- [ ] **Step 2: Register the module privately**\n\nAdd this beside the other private modules in `lib/crates/fabro-config/src/lib.rs`:\n\n```rust\nmod legacy_sandbox_migration;\n```\n\n- [ ] **Step 3: Create the module API**\n\nCreate `lib/crates/fabro-config/src/legacy_sandbox_migration.rs` with this starting shape:\n\n```rust\n#![expect(\n clippy::disallowed_methods,\n reason = \"temporary startup config migration uses synchronous file I/O before config is loaded\"\n)]\n\nuse std::fmt;\nuse std::path::{Path, PathBuf};\n\nuse toml_edit::{DocumentMut, Item, Table, Value};\n\nuse crate::{Error, Result, SettingsLayer};\n\npub(crate) const REMOVAL_NOTE: &str =\n \"This temporary compatibility migration will be removed before v1.0.\";\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) struct LegacySandboxMigrationReport {\n pub(crate) contents: String,\n pub(crate) backup_path: PathBuf,\n pub(crate) warning: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\nstruct MigrationFailure {\n unsupported_keys: Vec,\n}\n\nimpl fmt::Display for MigrationFailure {\n fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n writeln!(f, \"Legacy [run.sandbox] settings could not be auto-migrated.\")?;\n writeln!(f)?;\n writeln!(f, \"Unsupported keys:\")?;\n for key in &self.unsupported_keys {\n writeln!(f, \" - {key}\")?;\n }\n writeln!(f)?;\n write!(\n f,\n \"Rename legacy sandbox configuration to [run.environment] and [environments.]. See docs/public/execution/environments.mdx.\"\n )\n }\n}\n\npub(crate) fn migrate_settings_path(\n path: &Path,\n original_contents: &str,\n) -> Result> {\n let Some(next_contents) = migrate_contents(original_contents, path)? else {\n return Ok(None);\n };\n\n next_contents\n .parse::()\n .map_err(|err| Error::parse_file(\"Migrated settings file is invalid\", path, err))?;\n\n let backup_path = next_backup_path(path);\n std::fs::write(&backup_path, original_contents).map_err(|source| {\n Error::other(format!(\n \"writing legacy sandbox migration backup {}: {source}\",\n backup_path.display()\n ))\n })?;\n std::fs::write(path, &next_contents).map_err(|source| {\n Error::other(format!(\n \"writing migrated settings file {}: {source}\",\n path.display()\n ))\n })?;\n\n let warning = format!(\n \"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.default]. Backup written to {}. {REMOVAL_NOTE}\",\n path.display(),\n backup_path.display()\n );\n\n Ok(Some(LegacySandboxMigrationReport {\n contents: next_contents,\n backup_path,\n warning,\n }))\n}\n\nfn migrate_contents(original_contents: &str, path: &Path) -> Result> {\n let mut doc = match original_contents.parse::() {\n Ok(doc) => doc,\n Err(_) => return Ok(None),\n };\n\n if !has_legacy_run_sandbox(&doc) {\n return Ok(None);\n }\n if has_new_environment_config(&doc) {\n return Err(Error::other(format!(\n \"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.\",\n path.display()\n )));\n }\n\n migrate_document(&mut doc).map_err(|failure| {\n Error::other(format!(\n \"Legacy [run.sandbox] settings in {} could not be auto-migrated.\\n\\n{}\",\n path.display(),\n failure\n ))\n })?;\n\n Ok(Some(doc.to_string()))\n}\n\nfn has_legacy_run_sandbox(doc: &DocumentMut) -> bool {\n doc.get(\"run\")\n .and_then(Item::as_table)\n .and_then(|run| run.get(\"sandbox\"))\n .is_some()\n}\n\nfn has_new_environment_config(doc: &DocumentMut) -> bool {\n let has_run_environment = doc\n .get(\"run\")\n .and_then(Item::as_table)\n .and_then(|run| run.get(\"environment\"))\n .is_some();\n let has_default_environment = doc\n .get(\"environments\")\n .and_then(Item::as_table)\n .and_then(|envs| envs.get(\"default\"))\n .is_some();\n has_run_environment || has_default_environment\n}\n\nfn next_backup_path(path: &Path) -> PathBuf {\n let base = path.with_file_name(format!(\n \"{}.legacy-sandbox-migration.bak\",\n path.file_name()\n .and_then(|name| name.to_str())\n .unwrap_or(\"settings.toml\")\n ));\n if !base.exists() {\n return base;\n }\n\n for index in 1.. {\n let candidate = path.with_file_name(format!(\n \"{}.legacy-sandbox-migration.{index}.bak\",\n path.file_name()\n .and_then(|name| name.to_str())\n .unwrap_or(\"settings.toml\")\n ));\n if !candidate.exists() {\n return candidate;\n }\n }\n unreachable!(\"unbounded backup suffix search should return\")\n}\n```\n\n- [ ] **Step 4: Add placeholder-free private stubs that compile**\n\nAdd 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:\n\n```rust\nfn migrate_document(_doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {\n Err(MigrationFailure {\n unsupported_keys: vec![\"run.sandbox\".to_string()],\n })\n}\n```\n\n- [ ] **Step 5: Run the focused compile check**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: compiles; there may be zero tests in this module at this point.\n\n## Task 2: Implement Provider-Only Migration\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add tests for provider-only migration**\n\nAdd these tests inside `legacy_sandbox_migration.rs`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use fabro_types::settings::run::EnvironmentProvider;\n\n fn migrate(source: &str) -> String {\n migrate_contents(source, Path::new(\"settings.toml\"))\n .expect(\"migration should not error\")\n .expect(\"legacy sandbox should migrate\")\n }\n\n #[test]\n fn provider_only_daytona_config_migrates_to_default_environment() {\n let migrated = migrate(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#,\n );\n\n let settings = migrated\n .parse::()\n .expect(\"migrated TOML should parse\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n .expect(\"migrated settings should resolve\")\n .run;\n\n assert_eq!(resolved.environment.id, \"default\");\n assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);\n assert!(migrated.contains(\"[run.environment]\"));\n assert!(migrated.contains(\"[environments.default]\"));\n assert!(!migrated.contains(\"[run.sandbox]\"));\n }\n\n #[test]\n fn non_legacy_config_is_not_migrated() {\n let migrated = migrate_contents(\"_version = 1\\n\", Path::new(\"settings.toml\"))\n .expect(\"non-legacy TOML should not error\");\n\n assert!(migrated.is_none());\n }\n}\n```\n\n- [ ] **Step 2: Run tests and confirm failure**\n\nRun:\n\n```bash\ncargo test -p fabro-config provider_only_daytona_config_migrates_to_default_environment --quiet\n```\n\nExpected: FAIL because `migrate_document` still returns unsupported `run.sandbox`.\n\n- [ ] **Step 3: Replace `migrate_document` with provider migration**\n\nImplement the initial migration:\n\n```rust\nfn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {\n let Some(sandbox_item) = doc\n .get(\"run\")\n .and_then(Item::as_table)\n .and_then(|run| run.get(\"sandbox\"))\n else {\n return Ok(());\n };\n let Some(sandbox) = sandbox_item.as_table().cloned() else {\n return Err(MigrationFailure {\n unsupported_keys: vec![\"run.sandbox\".to_string()],\n });\n };\n\n let mut unsupported = Vec::new();\n for (key, _) in sandbox.iter() {\n if key != \"provider\" {\n unsupported.push(format!(\"run.sandbox.{key}\"));\n }\n }\n if !unsupported.is_empty() {\n return Err(MigrationFailure {\n unsupported_keys: unsupported,\n });\n }\n\n let Some(provider) = sandbox.get(\"provider\").and_then(Item::as_str) else {\n return Err(MigrationFailure {\n unsupported_keys: vec![\"run.sandbox.provider\".to_string()],\n });\n };\n\n set_value(path_table(doc, &[\"run\", \"environment\"]), \"id\", Value::from(\"default\"));\n set_value(\n path_table(doc, &[\"environments\", \"default\"]),\n \"provider\",\n Value::from(provider),\n );\n\n remove_run_sandbox(doc);\n Ok(())\n}\n\nfn path_table<'a>(doc: &'a mut DocumentMut, path: &[&str]) -> &'a mut Table {\n let mut item = doc.as_item_mut();\n for segment in path {\n item = &mut item[segment];\n if !item.is_table() {\n *item = Item::Table(Table::new());\n }\n }\n item.as_table_mut().expect(\"path item should be a table\")\n}\n\nfn set_value(table: &mut Table, key: &str, value: Value) {\n table[key] = Item::Value(value);\n}\n\nfn remove_run_sandbox(doc: &mut DocumentMut) {\n if let Some(run) = doc.get_mut(\"run\").and_then(Item::as_table_mut) {\n run.remove(\"sandbox\");\n }\n}\n```\n\n- [ ] **Step 4: Run focused tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS.\n\n## Task 3: Add Daytona and Docker Field Mappings\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add tests for direct legacy field mappings**\n\nAdd tests that assert resolved behavior, not only string contents:\n\n```rust\n#[test]\nfn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {\n let migrated = migrate(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\npreserve = true\n\n[run.sandbox.env]\nNODE_ENV = \"development\"\n\n[run.sandbox.daytona]\nauto_stop_interval = 30\n\n[run.sandbox.daytona.labels]\nrepo = \"fabro-sh/fabro\"\n\n[run.sandbox.daytona.snapshot]\nname = \"fabro-v11\"\ncpu = 8\nmemory = \"16GB\"\ndisk = \"20GB\"\ndockerfile = { path = \"Dockerfile\" }\n\n[[run.sandbox.daytona.volumes]]\nvolume_id = \"vol_auth\"\nmount_path = \"/home/daytona/.config\"\nsubpath = \"agents\"\n\"#,\n );\n\n let settings = migrated.parse::().expect(\"migrated TOML should parse\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n .expect(\"migrated settings should resolve\")\n .run\n .environment;\n\n assert_eq!(resolved.image.reference.as_deref(), Some(\"fabro-v11\"));\n assert_eq!(resolved.resources.cpu, Some(8));\n assert_eq!(resolved.resources.memory.map(|size| size.as_bytes()), Some(16_000_000_000));\n assert_eq!(resolved.resources.disk.map(|size| size.as_bytes()), Some(20_000_000_000));\n assert!(resolved.lifecycle.preserve);\n assert_eq!(resolved.lifecycle.auto_stop.map(|duration| duration.as_std().as_secs()), Some(1800));\n assert_eq!(resolved.labels.get(\"repo\").map(String::as_str), Some(\"fabro-sh/fabro\"));\n assert_eq!(resolved.env.get(\"NODE_ENV\").map(|value| value.as_source()).as_deref(), Some(\"development\"));\n assert_eq!(resolved.volumes.len(), 1);\n assert_eq!(resolved.volumes[0].id, \"vol_auth\");\n assert_eq!(resolved.volumes[0].mount_path, \"/home/daytona/.config\");\n assert_eq!(resolved.volumes[0].subpath.as_deref(), Some(\"agents\"));\n}\n\n#[test]\nfn docker_image_memory_and_cpu_quota_migrate() {\n let migrated = migrate(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"docker\"\n\n[run.sandbox.docker]\nimage = \"buildpack-deps:noble\"\nmemory_limit = \"4GB\"\ncpu_quota = 200000\n\"#,\n );\n\n let settings = migrated.parse::().expect(\"migrated TOML should parse\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings)\n .expect(\"migrated settings should resolve\")\n .run\n .environment;\n\n assert_eq!(resolved.provider, EnvironmentProvider::Docker);\n assert_eq!(resolved.image.reference.as_deref(), Some(\"buildpack-deps:noble\"));\n assert_eq!(resolved.resources.cpu, Some(2));\n assert_eq!(resolved.resources.memory.map(|size| size.as_bytes()), Some(4_000_000_000));\n}\n```\n\n- [ ] **Step 2: Run tests and confirm failure**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: FAIL because the two new nested-mapping tests are not implemented yet.\n\n- [ ] **Step 3: Implement table copying and value transforms**\n\nExtend `migrate_document` so it:\n\n- Allows top-level legacy keys `provider`, `preserve`, `env`, `daytona`, and `docker`.\n- Copies `run.sandbox.env` into `environments.default.env`.\n- Sets `environments.default.lifecycle.preserve` from `run.sandbox.preserve`.\n- Handles provider-specific nested mappings only for the selected provider.\n- Removes `run.sandbox` after successful migration.\n\nUse helper functions with these signatures:\n\n```rust\nfn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec);\nfn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec);\nfn copy_table(source: &Item, target: &mut Table);\nfn copy_array_of_tables_with_volume_id(source: &Item, target: &mut Table, unsupported: &mut Vec);\nfn item_path_keys(prefix: &str, item: &Item, out: &mut Vec);\n```\n\nImplementation rules:\n\n- `auto_stop_interval` must be an integer. Store `format!(\"{minutes}m\")`.\n- `docker.cpu_quota` must be an integer divisible by `100000`; otherwise add `run.sandbox.docker.cpu_quota` to unsupported keys.\n- For Daytona volumes, each array entry may contain only `volume_id`, `mount_path`, and `subpath`; rename `volume_id` to `id`.\n- `daytona.snapshot.dockerfile` must be copied as the existing TOML value, preserving inline string or `{ path = \"...\" }`.\n- When collecting unsupported nested keys, report full paths such as `run.sandbox.daytona.snapshot.foo`.\n\n- [ ] **Step 4: Run focused tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS.\n\n## Task 4: Add File Rewrite and Loader Hook\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/load.rs`\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add file rewrite tests**\n\nAdd tests:\n\n```rust\n#[test]\nfn migrate_settings_path_writes_backup_and_rewrites_original() {\n let dir = tempfile::tempdir().expect(\"temp dir\");\n let path = dir.path().join(\"settings.toml\");\n let original = r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#;\n std::fs::write(&path, original).expect(\"write fixture\");\n\n let report = migrate_settings_path(&path, original)\n .expect(\"migration should succeed\")\n .expect(\"legacy config should migrate\");\n\n let rewritten = std::fs::read_to_string(&path).expect(\"read rewritten settings\");\n let backup = std::fs::read_to_string(&report.backup_path).expect(\"read backup\");\n\n assert_eq!(backup, original);\n assert!(rewritten.contains(\"[run.environment]\"));\n assert!(rewritten.contains(\"[environments.default]\"));\n assert!(report.warning.contains(\"temporary compatibility migration\"));\n}\n\n#[test]\nfn existing_backup_uses_numbered_suffix() {\n let dir = tempfile::tempdir().expect(\"temp dir\");\n let path = dir.path().join(\"settings.toml\");\n std::fs::write(path.with_file_name(\"settings.toml.legacy-sandbox-migration.bak\"), \"old\")\n .expect(\"write existing backup\");\n\n let next = next_backup_path(&path);\n\n assert!(next.ends_with(\"settings.toml.legacy-sandbox-migration.1.bak\"));\n}\n```\n\n- [ ] **Step 2: Run tests and confirm current state**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS if Task 1 file-writing code compiled; otherwise fix only the migration module.\n\n- [ ] **Step 3: Hook migration into file loading**\n\nChange `load_settings_path` in `lib/crates/fabro-config/src/load.rs` to this shape:\n\n```rust\npub(crate) fn load_settings_path(path: &Path) -> Result {\n let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?;\n let mut layer = match content.parse::() {\n Ok(layer) => layer,\n Err(err) => match crate::legacy_sandbox_migration::migrate_settings_path(path, &content)? {\n Some(report) => {\n tracing::warn!(\"{}\", report.warning);\n eprintln!(\"{}\", report.warning);\n report.contents.parse::().map_err(|err| {\n Error::parse_file(\"Migrated settings file is invalid\", path, err)\n })?\n }\n None => return Err(Error::parse_file(\"Failed to parse settings file\", path, err)),\n },\n };\n let base_dir = path.parent().unwrap_or_else(|| Path::new(\".\"));\n resolve_goal_file_paths(&mut layer, base_dir);\n Ok(layer)\n}\n```\n\n- [ ] **Step 4: Run loader-level verification**\n\nAdd a test in `load.rs` under `#[cfg(test)]` if the file does not already have a test module:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use fabro_types::settings::run::EnvironmentProvider;\n\n #[test]\n fn load_settings_path_auto_migrates_legacy_sandbox_file() {\n let dir = tempfile::tempdir().expect(\"temp dir\");\n let path = dir.path().join(\"settings.toml\");\n std::fs::write(\n &path,\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#,\n )\n .expect(\"write legacy settings\");\n\n let layer = load_settings_path(&path).expect(\"legacy settings should auto-migrate\");\n let resolved = crate::WorkflowSettingsBuilder::from_layer(&layer)\n .expect(\"migrated settings should resolve\")\n .run;\n\n assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);\n assert!(std::fs::read_to_string(&path)\n .expect(\"read rewritten settings\")\n .contains(\"[run.environment]\"));\n }\n}\n```\n\nRun:\n\n```bash\ncargo test -p fabro-config load_settings_path_auto_migrates_legacy_sandbox_file --quiet\n```\n\nExpected: PASS.\n\n- [ ] **Step 5: Verify in-memory TOML parsing remains strict**\n\nRun the existing current-main rejection test:\n\n```bash\ncargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet\n```\n\nExpected: PASS. Do not weaken `SettingsLayer` deserialization to accept `run.sandbox`; only `load_settings_path` should rewrite files from disk.\n\n## Task 5: Unsupported and Ambiguous Cases\n\n**Files:**\n- Modify: `lib/crates/fabro-config/src/legacy_sandbox_migration.rs`\n\n- [ ] **Step 1: Add failure tests**\n\nAdd tests:\n\n```rust\n#[test]\nfn existing_new_environment_config_is_ambiguous() {\n let err = migrate_contents(\n r#\"\n_version = 1\n\n[run.environment]\nid = \"default\"\n\n[run.sandbox]\nprovider = \"daytona\"\n\"#,\n Path::new(\"settings.toml\"),\n )\n .expect_err(\"mixed old and new config should fail\");\n\n assert!(err.to_string().contains(\"already contains [run.environment]\"));\n}\n\n#[test]\nfn unsupported_keys_are_reported_with_full_paths() {\n let err = migrate_contents(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"daytona\"\n\n[run.sandbox.daytona]\nunknown = true\n\"#,\n Path::new(\"settings.toml\"),\n )\n .expect_err(\"unsupported keys should fail migration\");\n\n let rendered = err.to_string();\n assert!(rendered.contains(\"run.sandbox.daytona.unknown\"));\n assert!(rendered.contains(\"docs/public/execution/environments.mdx\"));\n}\n\n#[test]\nfn unsupported_docker_cpu_quota_is_reported() {\n let err = migrate_contents(\n r#\"\n_version = 1\n\n[run.sandbox]\nprovider = \"docker\"\n\n[run.sandbox.docker]\ncpu_quota = 250000\n\"#,\n Path::new(\"settings.toml\"),\n )\n .expect_err(\"non-divisible cpu quota should fail migration\");\n\n assert!(err.to_string().contains(\"run.sandbox.docker.cpu_quota\"));\n}\n```\n\n- [ ] **Step 2: Run failure tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config legacy_sandbox_migration --quiet\n```\n\nExpected: PASS.\n\n## Task 6: Documentation\n\n**Files:**\n- Modify: `docs/public/execution/environments.mdx`\n- Create: `docs/public/changelog/2026-05-23.mdx`\n\n- [ ] **Step 1: Document temporary auto-migration**\n\nAdd this note near the top of `docs/public/execution/environments.mdx`, after the initial environment/sandbox distinction:\n\n```mdx\n\nOlder 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.\n\nThis 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.\n\n```\n\n- [ ] **Step 2: Add a changelog note**\n\nCreate `docs/public/changelog/2026-05-23.mdx`:\n\n```mdx\n---\ntitle: \"Legacy sandbox config migration\"\ndate: \"2026-05-23\"\n---\n\n## Legacy sandbox config auto-migration\n\nFabro 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.\n```\n\n- [ ] **Step 3: Check docs references**\n\nRun:\n\n```bash\nrg -n \"\\\\[run\\\\.sandbox\\\\]|legacy-sandbox-migration|run\\\\.environment\" docs/public/execution docs/public/changelog\n```\n\nExpected: remaining `[run.sandbox]` references are either historical changelog entries or explicit migration warnings.\n\n## Task 7: Full Verification\n\n**Files:**\n- All files touched above.\n\n- [ ] **Step 1: Run config crate tests**\n\nRun:\n\n```bash\ncargo test -p fabro-config --quiet\n```\n\nExpected: PASS.\n\n- [ ] **Step 2: Run formatting check**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 fmt --check --all\n```\n\nExpected: PASS.\n\n- [ ] **Step 3: Optional workspace lint if formatting and tests pass**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\nExpected: PASS. If this is slow, record that it was not run and include the reason in the handoff.\n\n## Acceptance Criteria\n\n- Boot-time config loading rewrites simple legacy `[run.sandbox]` files without user action.\n- The rewritten file uses `[run.environment] id = \"default\"` and `[environments.default]`.\n- The original file is preserved in a sibling backup before rewrite.\n- Unsupported legacy keys fail with a targeted migration message listing exact keys.\n- Normal strict schema behavior remains unchanged for in-memory `from_toml` calls.\n- All legacy migration code is isolated in `legacy_sandbox_migration.rs` and removable before v1.0.\n", "internal.node_visit_count": 1, @@ -977,6 +1141,44 @@ "total_usd_micros": 5703530 } }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: 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", + "usage": null + }, + "simplify_gpt": { + "status": "succeeded", + "context_updates": { + "response.simplify_gpt": "Implemented cleanup after reviewing the migration changes.\n\nFixed:\n- Backup writing now uses `OpenOptions::create_new(true)` to avoid a TOCTOU overwrite race when choosing numbered backup names.\n- Backup path generation no longer panics on non-UTF-8 / missing file names; it falls back to `settings.toml`.\n- Invalid legacy provider values are now reported as `run.sandbox.provider` instead of producing a later “migrated settings invalid” error.\n- Unsupported key lists are sorted/deduplicated before rendering.\n- Added a regression test for unsupported provider values.\n- Addressed clippy findings from the cleanup.\n\nVerification passed:\n- `cargo test -p fabro-config legacy_sandbox_migration --quiet`\n- `cargo test -p fabro-config load_settings_path_auto_migrates_legacy_sandbox_file --quiet`\n- `cargo test -p fabro-config legacy_run_sandbox_is_rejected --quiet`\n- `cargo test -p fabro-config --quiet`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -p fabro-config --all-targets -- -D warnings`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`", + "last_stage": "simplify_gpt", + "last_response": "Implemented cleanup after reviewing the migration changes.\n\nFixed:\n- Backup writing now uses `OpenOptions::create_new(true)` to avoid a TOCTOU overwrite race when choosing numbered backup names.\n- Bac" + }, + "notes": "Stage completed: simplify_gpt", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 103708, + "output_tokens": 5183, + "reasoning_tokens": 2855, + "cache_read_tokens": 1361408, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 1440384 + } + }, "preflight_compile": { "status": "succeeded", "context_updates": { @@ -988,20 +1190,13 @@ "start": { "status": "succeeded", "usage": null - }, - "toolchain": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" - }, - "notes": "Script completed: 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", - "usage": null } }, - "next_node_id": "simplify_gpt", + "next_node_id": "verify", "node_visits": { "preflight_lint": 1, "toolchain": 1, + "simplify_gpt": 1, "simplify_opus": 1, "preflight_compile": 1, "implement": 1, @@ -1083,7 +1278,12 @@ "first_event_seq": 510, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: simplify_opus", + "failure_reason": null, + "timestamp": "2026-05-23T20:23:53.079292Z" + }, "provider_used": { "mode": "agent", "provider": "anthropic", @@ -1096,6 +1296,12 @@ "output": null, "started_at": "2026-05-23T20:13:19.149546Z", "handler": "agent", + "timing": { + "wall_time_ms": 633925, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 86218, "output_tokens": 29230, @@ -1211,7 +1417,7 @@ } } ], - "state": "running" + "state": "succeeded" }, "toolchain@1": { "first_event_seq": 22, @@ -1261,6 +1467,100 @@ }, "state": "succeeded" }, + "simplify_gpt@1": { + "first_event_seq": 853, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "openai", + "model": "gpt-5.5" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-23T20:23:56.879979Z", + "handler": "agent", + "usage": { + "input_tokens": 103708, + "output_tokens": 5183, + "total_tokens": 1473154, + "reasoning_tokens": 2855, + "cache_read_tokens": 1361408, + "cache_write_tokens": 0, + "total_usd_micros": 1440384 + }, + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "todos": { + "kind": "openai_plan", + "list_id": "openai_plan:514ddb9b-0daa-4b54-a45e-7b509857b0b4", + "items": [ + { + "id": "630cb2553927a0cb", + "status": "completed", + "order": 0, + "subject": "Inspect current diff" + }, + { + "id": "ff5449b5b72aa194", + "status": "completed", + "order": 1, + "subject": "Run three parallel review agents" + }, + { + "id": "1f681696c2c8088f", + "status": "completed", + "order": 2, + "subject": "Aggregate findings and patch issues" + }, + { + "id": "d56bef18f3ddeacb", + "status": "completed", + "order": 3, + "subject": "Run focused verification" + } + ] + }, + "subagents": [ + { + "agent_id": "9016bb1a", + "depth": 1, + "task": "Code Reuse Review. Review the full diff saved at /tmp/fabro-migration.diff in /home/daytona/workspace/fabro. For each change, search for existing utilities/helpers that could replace newly written code. Flag duplicate functions or inline logic that could use existing utilities. Focus on simplicity. Return concise findings with file/line references and suggested fixes; include 'No issues' if clean.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 9 + } + }, + { + "agent_id": "98e9276f", + "depth": 1, + "task": "Code Quality Review. Review the full diff saved at /tmp/fabro-migration.diff in /home/daytona/workspace/fabro. Look for redundant state, parameter sprawl, copy-paste, leaky abstractions, stringly-typed code, brittle tests, and maintainability issues. Return concise actionable findings with file/line references; include 'No issues' if clean.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 9 + } + }, + { + "agent_id": "2b2e5aa0", + "depth": 1, + "task": "Efficiency Review. Review the full diff saved at /tmp/fabro-migration.diff in /home/daytona/workspace/fabro. Look for unnecessary work, missed concurrency, startup hot-path bloat, TOCTOU existence checks, memory issues, and overly broad operations. Return concise actionable findings with file/line references; include 'No issues' if clean.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 9 + } + } + ], + "state": "running" + }, "start@1": { "first_event_seq": 18, "prompt": null, diff --git a/stages/006-simplify_opus@1/diff.patch b/stages/006-simplify_opus@1/diff.patch new file mode 100644 index 000000000..7687a1b03 --- /dev/null +++ b/stages/006-simplify_opus@1/diff.patch @@ -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::() + .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) { +- 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) { + let Some(daytona_item) = sandbox.get("daytona") else { + return; +@@ -251,24 +232,21 @@ fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec { +- 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 { +- 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(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) { + 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::() ++ .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 { + Some(report) => { + tracing::warn!("{}", report.warning); + eprintln!("{}", report.warning); +- report.contents.parse::().map_err(|err| { +- Error::parse_file("Migrated settings file is invalid", path, err) +- })? ++ report.layer + } + None => { + return Err(Error::parse_file( diff --git a/stages/006-simplify_opus@1/status.json b/stages/006-simplify_opus@1/status.json new file mode 100644 index 000000000..b7f54a4a1 --- /dev/null +++ b/stages/006-simplify_opus@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: simplify_opus", + "failure_reason": null, + "timestamp": "2026-05-23T20:23:53.079292Z" +} \ No newline at end of file diff --git a/stages/007-simplify_gpt@1/prompt.md b/stages/007-simplify_gpt@1/prompt.md new file mode 100644 index 000000000..e97ce669b --- /dev/null +++ b/stages/007-simplify_gpt@1/prompt.md @@ -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 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.]. +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 to [run.environment] and [environments.default]. Backup written to . 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, +} + +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.]. See docs/public/execution/environments.mdx." + ) + } +} + +pub(crate) fn migrate_settings_path( + path: &Path, + original_contents: &str, +) -> Result> { + let Some(next_contents) = migrate_contents(original_contents, path)? else { + return Ok(None); + }; + + next_contents + .parse::() + .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> { + let mut doc = match original_contents.parse::() { + 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::() + .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::().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::().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); +fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec); +fn copy_table(source: &Item, target: &mut Table); +fn copy_array_of_tables_with_volume_id(source: &Item, target: &mut Table, unsupported: &mut Vec); +fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec); +``` + +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 { + let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?; + let mut layer = match content.parse::() { + 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::().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 + +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. + +``` + +- [ ] **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). \ No newline at end of file diff --git a/stages/007-simplify_gpt@1/provider_used.json b/stages/007-simplify_gpt@1/provider_used.json new file mode 100644 index 000000000..a04162cbf --- /dev/null +++ b/stages/007-simplify_gpt@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "openai", + "model": "gpt-5.5" +} \ No newline at end of file diff --git a/stages/007-simplify_gpt@1/response.md b/stages/007-simplify_gpt@1/response.md new file mode 100644 index 000000000..02ada985b --- /dev/null +++ b/stages/007-simplify_gpt@1/response.md @@ -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` \ No newline at end of file